From 7834fd0ed7fe08f4501ac1347e9e6915285c48c9 Mon Sep 17 00:00:00 2001 From: Christophe Kyvrakidis Date: Sat, 20 Jun 2026 22:10:55 +0200 Subject: [PATCH 1/4] feat(provider): new deployment provider: f5 big-ip --- internal/certmgmt/deployers/sp_f5_bigip.go | 30 +++ internal/domain/access.go | 7 + internal/domain/provider.go | 2 + pkg/core/deployer/providers/f5bigip/consts.go | 6 + .../deployer/providers/f5bigip/f5bigip.go | 140 +++++++++++ .../providers/f5bigip/f5bigip_test.go | 53 +++++ pkg/sdk3rd/f5bigip/client.go | 221 ++++++++++++++++++ ui/public/imgs/providers/f5bigip.svg | 4 + .../forms/AccessConfigFieldsProvider.tsx | 2 + .../AccessConfigFieldsProviderF5BigIP.tsx | 80 +++++++ .../BizDeployNodeConfigFieldsProvider.tsx | 2 + ...zDeployNodeConfigFieldsProviderF5BigIP.tsx | 113 +++++++++ ui/src/domain/provider.ts | 4 + ui/src/i18n/resources/en/nls.access.json | 14 ++ ui/src/i18n/resources/en/nls.provider.json | 1 + .../i18n/resources/en/nls.workflow.nodes.json | 23 ++ ui/src/i18n/resources/zh/nls.access.json | 14 ++ ui/src/i18n/resources/zh/nls.provider.json | 1 + .../i18n/resources/zh/nls.workflow.nodes.json | 23 ++ 19 files changed, 740 insertions(+) create mode 100644 internal/certmgmt/deployers/sp_f5_bigip.go create mode 100644 pkg/core/deployer/providers/f5bigip/consts.go create mode 100644 pkg/core/deployer/providers/f5bigip/f5bigip.go create mode 100644 pkg/core/deployer/providers/f5bigip/f5bigip_test.go create mode 100644 pkg/sdk3rd/f5bigip/client.go create mode 100644 ui/public/imgs/providers/f5bigip.svg create mode 100644 ui/src/components/access/forms/AccessConfigFieldsProviderF5BigIP.tsx create mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderF5BigIP.tsx diff --git a/internal/certmgmt/deployers/sp_f5_bigip.go b/internal/certmgmt/deployers/sp_f5_bigip.go new file mode 100644 index 00000000..765fbdd8 --- /dev/null +++ b/internal/certmgmt/deployers/sp_f5_bigip.go @@ -0,0 +1,30 @@ +package deployers + +import ( + "fmt" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/pkg/core" + dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/f5bigip" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +func init() { + Registries.MustRegister(domain.DeploymentProviderTypeF5BigIP, func(options *ProviderFactoryOptions) (core.Deployer, error) { + credentials := domain.AccessConfigForF5BigIP{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{ + ServerUrl: credentials.ServerUrl, + Username: credentials.Username, + Password: credentials.Password, + AllowInsecureConnections: credentials.AllowInsecureConnections, + DeployTarget: xmaps.GetString(options.ProviderExtendedConfig, "deployTarget"), + Partition: xmaps.GetString(options.ProviderExtendedConfig, "partition"), + ClientSSLProfileName: xmaps.GetString(options.ProviderExtendedConfig, "clientSSLProfileName"), + }) + return provider, err + }) +} diff --git a/internal/domain/access.go b/internal/domain/access.go index d21c820a..ffd88213 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -265,6 +265,13 @@ type AccessConfigForEmail struct { AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` } +type AccessConfigForF5BigIP struct { + ServerUrl string `json:"serverUrl"` + Username string `json:"username"` + Password string `json:"password"` + AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` +} + type AccessConfigForFlexCDN struct { ServerUrl string `json:"serverUrl"` ApiRole string `json:"apiRole"` diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 8d5f57b7..855346fd 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -60,6 +60,7 @@ const ( AccessProviderTypeDynu = AccessProviderType("dynu") AccessProviderTypeDynv6 = AccessProviderType("dynv6") AccessProviderTypeEmail = AccessProviderType("email") + AccessProviderTypeF5BigIP = AccessProviderType("f5bigip") AccessProviderTypeFastly = AccessProviderType("fastly") // Fastly(预留) AccessProviderTypeFlexCDN = AccessProviderType("flexcdn") AccessProviderTypeFlyIO = AccessProviderType("flyio") @@ -368,6 +369,7 @@ const ( DeploymentProviderTypeDigitalOceanCertificate = DeploymentProviderType(AccessProviderTypeDigitalOcean + "-certificate") DeploymentProviderTypeDogeCloudCDN = DeploymentProviderType(AccessProviderTypeDogeCloud + "-cdn") DeploymentProviderTypeDokploy = DeploymentProviderType(AccessProviderTypeDokploy) + DeploymentProviderTypeF5BigIP = DeploymentProviderType(AccessProviderTypeF5BigIP) DeploymentProviderTypeFlexCDN = DeploymentProviderType(AccessProviderTypeFlexCDN) DeploymentProviderTypeFlyIO = DeploymentProviderType(AccessProviderTypeFlyIO) DeploymentProviderTypeFTP = DeploymentProviderType(AccessProviderTypeFTP) diff --git a/pkg/core/deployer/providers/f5bigip/consts.go b/pkg/core/deployer/providers/f5bigip/consts.go new file mode 100644 index 00000000..c4639f16 --- /dev/null +++ b/pkg/core/deployer/providers/f5bigip/consts.go @@ -0,0 +1,6 @@ +package f5bigip + +const ( + DEPLOY_TARGET_CERTIFICATE = "certificate" + DEPLOY_TARGET_CLIENTSSL = "clientssl" +) diff --git a/pkg/core/deployer/providers/f5bigip/f5bigip.go b/pkg/core/deployer/providers/f5bigip/f5bigip.go new file mode 100644 index 00000000..c37e1080 --- /dev/null +++ b/pkg/core/deployer/providers/f5bigip/f5bigip.go @@ -0,0 +1,140 @@ +package f5bigip + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "log/slog" + "strings" + + "github.com/certimate-go/certimate/pkg/core" + f5sdk "github.com/certimate-go/certimate/pkg/sdk3rd/f5bigip" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" +) + +type ( + Provider = core.Deployer + DeployResult = core.DeployerDeployResult +) + +type DeployerConfig struct { + ServerUrl string `json:"serverUrl"` + Username string `json:"username"` + Password string `json:"password"` + AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` + DeployTarget string `json:"deployTarget"` + Partition string `json:"partition,omitempty"` + ClientSSLProfileName string `json:"clientSSLProfileName,omitempty"` +} + +type Deployer struct { + config *DeployerConfig + logger *slog.Logger + sdkClient *f5sdk.Client +} + +var _ Provider = (*Deployer)(nil) + +func NewDeployer(config *DeployerConfig) (*Deployer, error) { + if config == nil { + return nil, fmt.Errorf("the configuration of the deployer provider is nil") + } + + client, err := createSDKClient(config.ServerUrl, config.AllowInsecureConnections) + if err != nil { + return nil, fmt.Errorf("could not create client: %w", err) + } + + return &Deployer{ + config: config, + logger: slog.Default(), + sdkClient: client, + }, nil +} + +func (d *Deployer) SetLogger(logger *slog.Logger) { + if logger == nil { + d.logger = slog.New(slog.DiscardHandler) + } else { + d.logger = logger + } +} + +func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) { + partition := d.config.Partition + if partition == "" { + partition = "Common" + } + + if err := d.sdkClient.Login(ctx, d.config.Username, d.config.Password); err != nil { + return nil, fmt.Errorf("failed to login to F5 Big-IP: %w", err) + } + + certX509, err := xcert.ParseCertificateFromPEM(certPEM) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + certName := buildCertName(certX509) + d.logger.Info("deploying certificate to F5 Big-IP", slog.String("certName", certName), slog.String("subject", certX509.Subject.CommonName)) + + if err := d.sdkClient.UploadCertificate(ctx, certName, partition, certPEM); err != nil { + return nil, fmt.Errorf("failed to upload certificate: %w", err) + } + d.logger.Info("certificate uploaded", slog.String("certName", certName)) + + if err := d.sdkClient.UploadKey(ctx, certName, partition, privkeyPEM); err != nil { + return nil, fmt.Errorf("failed to upload private key: %w", err) + } + d.logger.Info("private key uploaded", slog.String("keyName", certName)) + + switch d.config.DeployTarget { + case "", DEPLOY_TARGET_CERTIFICATE: + d.logger.Info("deployment completed (certificate only)") + + case DEPLOY_TARGET_CLIENTSSL: + if d.config.ClientSSLProfileName == "" { + return nil, fmt.Errorf("config `clientSSLProfileName` is required when deploy target is 'clientssl'") + } + + certPath := fmt.Sprintf("/%s/%s", partition, certName) + keyPath := fmt.Sprintf("/%s/%s", partition, certName) + if err := d.sdkClient.UpdateClientSSLProfile(ctx, d.config.ClientSSLProfileName, partition, certPath, keyPath); err != nil { + return nil, fmt.Errorf("failed to update client-ssl profile: %w", err) + } + d.logger.Info("client-ssl profile updated", slog.String("profile", d.config.ClientSSLProfileName)) + + default: + return nil, fmt.Errorf("unsupported deploy target '%s'", d.config.DeployTarget) + } + + return &DeployResult{}, nil +} + +func buildCertName(cert *x509.Certificate) string { + san := "" + if len(cert.DNSNames) > 0 { + san = cert.DNSNames[0] + } else if cert.Subject.CommonName != "" { + san = cert.Subject.CommonName + } else { + return "" + } + + san = strings.TrimPrefix(san, "*.") + return "certimate_" + san +} + +func createSDKClient(serverUrl string, skipTlsVerify bool) (*f5sdk.Client, error) { + client, err := f5sdk.NewClient(serverUrl) + if err != nil { + return nil, err + } + + if skipTlsVerify { + client.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) + } + + return client, nil +} diff --git a/pkg/core/deployer/providers/f5bigip/f5bigip_test.go b/pkg/core/deployer/providers/f5bigip/f5bigip_test.go new file mode 100644 index 00000000..cb32ad84 --- /dev/null +++ b/pkg/core/deployer/providers/f5bigip/f5bigip_test.go @@ -0,0 +1,53 @@ +package f5bigip_test + +import ( + "testing" + + "github.com/certimate-go/certimate/pkg/core/deployer/internal/tester" + impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/f5bigip" +) + +var ( + fp = tester.Args("F5BIGIP_") + fTestCertPath string + fTestKeyPath string + fServerUrl string + fUsername string + fPassword string + fPartition string + fClientSSLName string + fDeployTarget string +) + +func init() { + fp.DefineString(&fTestCertPath, "TESTCERTPATH") + fp.DefineString(&fTestKeyPath, "TESTKEYPATH") + fp.DefineString(&fServerUrl, "SERVERURL") + fp.DefineString(&fUsername, "USERNAME") + fp.DefineString(&fPassword, "PASSWORD") + fp.DefineString(&fPartition, "PARTITION") + fp.DefineString(&fClientSSLName, "CLIENTSSLNAME") + fp.DefineString(&fDeployTarget, "DEPLOYTARGET") +} + +func TestProvider(t *testing.T) { + fp.Parse() + + t.Run("Deploy", func(t *testing.T) { + provider, err := impl.NewDeployer(&impl.DeployerConfig{ + ServerUrl: fServerUrl, + Username: fUsername, + Password: fPassword, + AllowInsecureConnections: true, + DeployTarget: fDeployTarget, + Partition: fPartition, + ClientSSLProfileName: fClientSSLName, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + tester.TestDeploy(t, provider, tester.TestDeployArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) + }) +} diff --git a/pkg/sdk3rd/f5bigip/client.go b/pkg/sdk3rd/f5bigip/client.go new file mode 100644 index 00000000..fafa9102 --- /dev/null +++ b/pkg/sdk3rd/f5bigip/client.go @@ -0,0 +1,221 @@ +package f5bigip + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-resty/resty/v2" + + "github.com/certimate-go/certimate/internal/app" +) + +type Client struct { + baseUrl string + token string + rc *resty.Client +} + +func NewClient(serverUrl string) (*Client, error) { + if serverUrl == "" { + return nil, fmt.Errorf("sdkerr: unset serverUrl") + } + if _, err := url.Parse(serverUrl); err != nil { + return nil, fmt.Errorf("sdkerr: invalid serverUrl: %w", err) + } + + c := &Client{ + baseUrl: strings.TrimSuffix(serverUrl, "/"), + } + + c.rc = resty.New(). + SetBaseURL(c.baseUrl). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json"). + SetHeader("User-Agent", app.AppUserAgent). + SetPreRequestHook(func(_ *resty.Client, req *http.Request) error { + if c.token != "" { + req.Header.Set("X-F5-Auth-Token", c.token) + } + return nil + }) + + return c, nil +} + +func (c *Client) SetTimeout(timeout time.Duration) *Client { + c.rc.SetTimeout(timeout) + return c +} + +func (c *Client) SetTLSConfig(config *tls.Config) *Client { + c.rc.SetTLSClientConfig(config) + return c +} + +func (c *Client) Login(ctx context.Context, username, password string) error { + loginReq := map[string]string{ + "username": username, + "password": password, + "loginProviderName": "tmos", + } + + httpreq, err := c.newRequest(http.MethodPost, "/mgmt/shared/authn/login") + if err != nil { + return err + } + httpreq.SetBody(loginReq) + httpreq.SetContext(ctx) + + type loginResponse struct { + Token struct { + Token string `json:"token"` + } `json:"token"` + } + + result := &loginResponse{} + if _, err := c.doRequestWithResult(httpreq, result); err != nil { + return err + } + + c.token = result.Token.Token + return nil +} + +func (c *Client) UploadCertificate(ctx context.Context, name, partition, content string) error { + path := fmt.Sprintf("/mgmt/tm/sys/file/ssl-cert") + body := map[string]string{ + "name": name, + "partition": partition, + "content": content, + } + + httpreq, err := c.newRequest(http.MethodPost, path) + if err != nil { + return err + } + httpreq.SetBody(body) + httpreq.SetContext(ctx) + + if _, err := c.doRequest(httpreq); err != nil { + return fmt.Errorf("failed to upload certificate: %w", err) + } + + return nil +} + +func (c *Client) UploadKey(ctx context.Context, name, partition, content string) error { + path := fmt.Sprintf("/mgmt/tm/sys/file/ssl-key") + body := map[string]string{ + "name": name, + "partition": partition, + "content": content, + } + + httpreq, err := c.newRequest(http.MethodPost, path) + if err != nil { + return err + } + httpreq.SetBody(body) + httpreq.SetContext(ctx) + + if _, err := c.doRequest(httpreq); err != nil { + return fmt.Errorf("failed to upload key: %w", err) + } + + return nil +} + +func (c *Client) GetClientSSLProfile(ctx context.Context, name, partition string) (map[string]any, error) { + path := fmt.Sprintf("/mgmt/tm/ltm/profile/client-ssl/~%s~%s", url.PathEscape(partition), url.PathEscape(name)) + + httpreq, err := c.newRequest(http.MethodGet, path) + if err != nil { + return nil, err + } + httpreq.SetContext(ctx) + + result := make(map[string]any) + if _, err := c.doRequestWithResult(httpreq, &result); err != nil { + return nil, fmt.Errorf("failed to get client-ssl profile: %w", err) + } + + return result, nil +} + +func (c *Client) UpdateClientSSLProfile(ctx context.Context, name, partition, certPath, keyPath string) error { + path := fmt.Sprintf("/mgmt/tm/ltm/profile/client-ssl/~%s~%s", url.PathEscape(partition), url.PathEscape(name)) + body := map[string]string{ + "cert": certPath, + "key": keyPath, + } + + httpreq, err := c.newRequest(http.MethodPatch, path) + if err != nil { + return err + } + httpreq.SetBody(body) + httpreq.SetContext(ctx) + + if _, err := c.doRequest(httpreq); err != nil { + return fmt.Errorf("failed to update client-ssl profile: %w", err) + } + + return nil +} + +func (c *Client) newRequest(method string, path string) (*resty.Request, error) { + if method == "" { + return nil, fmt.Errorf("sdkerr: unset method") + } + if path == "" { + return nil, fmt.Errorf("sdkerr: unset path") + } + + req := c.rc.R() + req.Method = method + req.URL = path + return req, nil +} + +func (c *Client) doRequest(req *resty.Request) (*resty.Response, error) { + if req == nil { + return nil, fmt.Errorf("sdkerr: nil request") + } + + resp, err := req.Send() + if err != nil { + return resp, fmt.Errorf("sdkerr: failed to send request: %w", err) + } else if resp.IsError() { + return resp, fmt.Errorf("sdkerr: unexpected status code: %d (resp: %s)", resp.StatusCode(), resp.String()) + } + + return resp, nil +} + +func (c *Client) doRequestWithResult(req *resty.Request, res interface{}) (*resty.Response, error) { + if req == nil { + return nil, fmt.Errorf("sdkerr: nil request") + } + + resp, err := c.doRequest(req) + if err != nil { + if resp != nil { + json.Unmarshal(resp.Body(), &res) + } + return resp, err + } + + if len(resp.Body()) != 0 { + if err := json.Unmarshal(resp.Body(), &res); err != nil { + return resp, fmt.Errorf("sdkerr: failed to unmarshal response: %w (resp: %s)", err, resp.String()) + } + } + + return resp, nil +} diff --git a/ui/public/imgs/providers/f5bigip.svg b/ui/public/imgs/providers/f5bigip.svg new file mode 100644 index 00000000..11cbc8a7 --- /dev/null +++ b/ui/public/imgs/providers/f5bigip.svg @@ -0,0 +1,4 @@ + + + F5 + diff --git a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx index a6cc62e9..87906dfa 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx @@ -48,6 +48,7 @@ import AccessConfigFieldsProviderDynadot from "./AccessConfigFieldsProviderDynad import AccessConfigFieldsProviderDynu from "./AccessConfigFieldsProviderDynu"; import AccessConfigFieldsProviderDynv6 from "./AccessConfigFieldsProviderDynv6"; import AccessConfigFieldsProviderEmail from "./AccessConfigFieldsProviderEmail"; +import AccessConfigFieldsProviderF5BigIP from "./AccessConfigFieldsProviderF5BigIP"; import AccessConfigFieldsProviderFlexCDN from "./AccessConfigFieldsProviderFlexCDN"; import AccessConfigFieldsProviderFlyIO from "./AccessConfigFieldsProviderFlyIO"; import AccessConfigFieldsProviderFTP from "./AccessConfigFieldsProviderFTP"; @@ -172,6 +173,7 @@ const providerComponentMap: Partial { + const { i18n, t } = useTranslation(); + + const { parentNamePath } = useFormNestedFieldsContext(); + const formSchema = z.object({ + [parentNamePath]: getSchema({ i18n }), + }); + const formRule = createSchemaFieldRule(formSchema); + const initialValues = getInitialValues(); + + return ( + <> + + + + + } + > + + + + } + > + + + + + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + serverUrl: "https://", + username: "", + password: "", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { + const { t: _ } = i18n; + + return z.object({ + serverUrl: z.url({ protocol: z.core.regexes.httpProtocol }), + username: z.string().nonempty(), + password: z.string().nonempty(), + allowInsecureConnections: z.boolean().nullish(), + }); +}; + +const _default = Object.assign(AccessConfigFormFieldsProviderF5BigIP, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx index 8e1f3f80..891ae40e 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx @@ -59,6 +59,7 @@ import BizDeployNodeConfigFieldsProviderCTCCCloudFaaS from "./BizDeployNodeConfi import BizDeployNodeConfigFieldsProviderCTCCCloudICDN from "./BizDeployNodeConfigFieldsProviderCTCCCloudICDN"; import BizDeployNodeConfigFieldsProviderCTCCCloudLVDN from "./BizDeployNodeConfigFieldsProviderCTCCCloudLVDN"; import BizDeployNodeConfigFieldsProviderDogeCloudCDN from "./BizDeployNodeConfigFieldsProviderDogeCloudCDN"; +import BizDeployNodeConfigFieldsProviderF5BigIP from "./BizDeployNodeConfigFieldsProviderF5BigIP"; import BizDeployNodeConfigFieldsProviderFlexCDN from "./BizDeployNodeConfigFieldsProviderFlexCDN"; import BizDeployNodeConfigFieldsProviderFlyIO from "./BizDeployNodeConfigFieldsProviderFlyIO"; import BizDeployNodeConfigFieldsProviderFTP from "./BizDeployNodeConfigFieldsProviderFTP"; @@ -203,6 +204,7 @@ const providerComponentMap: Partial { + const { i18n, t } = useTranslation(); + + const { parentNamePath } = useFormNestedFieldsContext(); + const formSchema = z.object({ + [parentNamePath]: getSchema({ i18n }), + }); + const formRule = createSchemaFieldRule(formSchema); + const formInst = Form.useFormInstance(); + const initialValues = getInitialValues(); + + const fieldResourceType = Form.useWatch([parentNamePath, "deployTarget"], formInst); + + return ( + <> + + } /> + + + + + + + + } + > + + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + deployTarget: DEPLOY_TARGET_CERTIFICATE, + partition: "Common", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { + const { t: _ } = i18n; + + return z + .object({ + deployTarget: z.enum([DEPLOY_TARGET_CERTIFICATE, DEPLOY_TARGET_CLIENTSSL]), + partition: z.string().nonempty(), + clientSSLProfileName: z.string().nullish(), + }) + .superRefine((values, ctx) => { + switch (values.deployTarget) { + case DEPLOY_TARGET_CLIENTSSL: + { + const scClientSSLProfileName = z.string().nonempty(); + const spClientSSLProfileName = scClientSSLProfileName.safeParse(values.clientSSLProfileName); + if (!spClientSSLProfileName.success) { + ctx.addIssue({ + code: "custom", + message: z.treeifyError(spClientSSLProfileName.error).errors.join(), + path: ["clientSSLProfileName"], + }); + } + } + break; + } + }); +}; + +const _default = Object.assign(BizDeployNodeConfigFieldsProviderF5BigIP, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 2cf23ad7..fe4384e8 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -61,6 +61,7 @@ export const ACCESS_PROVIDERS = Object.freeze({ DYNU: "dynu", DYNV6: "dynv6", EMAIL: "email", + F5BIGIP: "f5bigip", FLEXCDN: "flexcdn", FLYIO: "flyio", FTP: "ftp", @@ -208,6 +209,7 @@ export const accessProvidersMap: Map Date: Sat, 20 Jun 2026 22:25:18 +0200 Subject: [PATCH 2/4] chore: replace f5bigip placeholder icon with official F5 logo --- ui/public/imgs/providers/f5bigip.svg | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/public/imgs/providers/f5bigip.svg b/ui/public/imgs/providers/f5bigip.svg index 11cbc8a7..7ef537fb 100644 --- a/ui/public/imgs/providers/f5bigip.svg +++ b/ui/public/imgs/providers/f5bigip.svg @@ -1,4 +1,6 @@ - - - F5 - + + + + + + \ No newline at end of file From d099d5cc592edd233672c3d7c039348e1a538193 Mon Sep 17 00:00:00 2001 From: Christophe Kyvrakidis Date: Tue, 23 Jun 2026 18:10:21 +0200 Subject: [PATCH 3/4] feat(f5-bigip): create client-ssl profile if not found and upload certificate chain - Add CreateClientSSLProfile SDK method for profile auto-creation - Add chain certificate upload and reference in client-ssl profile - Add ErrNotFound sentinel error for 404 detection - Use ExtractCertificatesFromPEM to separate leaf and chain certs --- .../deployer/providers/f5bigip/f5bigip.go | 38 +++++++++++++++-- pkg/sdk3rd/f5bigip/client.go | 41 ++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/pkg/core/deployer/providers/f5bigip/f5bigip.go b/pkg/core/deployer/providers/f5bigip/f5bigip.go index c37e1080..db2bc6a7 100644 --- a/pkg/core/deployer/providers/f5bigip/f5bigip.go +++ b/pkg/core/deployer/providers/f5bigip/f5bigip.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "errors" "fmt" "log/slog" "strings" @@ -76,10 +77,15 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep return nil, fmt.Errorf("failed to parse certificate: %w", err) } + leafCertPEM, chainCertPEM, err := xcert.ExtractCertificatesFromPEM(certPEM) + if err != nil { + return nil, fmt.Errorf("failed to extract certificates from PEM: %w", err) + } + certName := buildCertName(certX509) d.logger.Info("deploying certificate to F5 Big-IP", slog.String("certName", certName), slog.String("subject", certX509.Subject.CommonName)) - if err := d.sdkClient.UploadCertificate(ctx, certName, partition, certPEM); err != nil { + if err := d.sdkClient.UploadCertificate(ctx, certName, partition, leafCertPEM); err != nil { return nil, fmt.Errorf("failed to upload certificate: %w", err) } d.logger.Info("certificate uploaded", slog.String("certName", certName)) @@ -89,6 +95,16 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep } d.logger.Info("private key uploaded", slog.String("keyName", certName)) + var chainPath string + if chainCertPEM != "" { + chainName := certName + "-ca" + if err := d.sdkClient.UploadCertificate(ctx, chainName, partition, chainCertPEM); err != nil { + return nil, fmt.Errorf("failed to upload chain certificate: %w", err) + } + d.logger.Info("chain certificate uploaded", slog.String("chainName", chainName)) + chainPath = fmt.Sprintf("/%s/%s", partition, chainName) + } + switch d.config.DeployTarget { case "", DEPLOY_TARGET_CERTIFICATE: d.logger.Info("deployment completed (certificate only)") @@ -100,10 +116,24 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep certPath := fmt.Sprintf("/%s/%s", partition, certName) keyPath := fmt.Sprintf("/%s/%s", partition, certName) - if err := d.sdkClient.UpdateClientSSLProfile(ctx, d.config.ClientSSLProfileName, partition, certPath, keyPath); err != nil { - return nil, fmt.Errorf("failed to update client-ssl profile: %w", err) + + _, err := d.sdkClient.GetClientSSLProfile(ctx, d.config.ClientSSLProfileName, partition) + if err != nil { + if errors.Is(err, f5sdk.ErrNotFound) { + d.logger.Info("client-ssl profile not found, creating it", slog.String("profile", d.config.ClientSSLProfileName)) + if err := d.sdkClient.CreateClientSSLProfile(ctx, d.config.ClientSSLProfileName, partition, certPath, keyPath, chainPath); err != nil { + return nil, fmt.Errorf("failed to create client-ssl profile: %w", err) + } + d.logger.Info("client-ssl profile created", slog.String("profile", d.config.ClientSSLProfileName)) + } else { + return nil, fmt.Errorf("failed to get client-ssl profile: %w", err) + } + } else { + if err := d.sdkClient.UpdateClientSSLProfile(ctx, d.config.ClientSSLProfileName, partition, certPath, keyPath, chainPath); err != nil { + return nil, fmt.Errorf("failed to update client-ssl profile: %w", err) + } + d.logger.Info("client-ssl profile updated", slog.String("profile", d.config.ClientSSLProfileName)) } - d.logger.Info("client-ssl profile updated", slog.String("profile", d.config.ClientSSLProfileName)) default: return nil, fmt.Errorf("unsupported deploy target '%s'", d.config.DeployTarget) diff --git a/pkg/sdk3rd/f5bigip/client.go b/pkg/sdk3rd/f5bigip/client.go index fafa9102..7906892c 100644 --- a/pkg/sdk3rd/f5bigip/client.go +++ b/pkg/sdk3rd/f5bigip/client.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -15,6 +16,8 @@ import ( "github.com/certimate-go/certimate/internal/app" ) +var ErrNotFound = errors.New("not found") + type Client struct { baseUrl string token string @@ -141,19 +144,53 @@ func (c *Client) GetClientSSLProfile(ctx context.Context, name, partition string httpreq.SetContext(ctx) result := make(map[string]any) - if _, err := c.doRequestWithResult(httpreq, &result); err != nil { + resp, err := c.doRequestWithResult(httpreq, &result) + if err != nil { + if resp != nil && resp.StatusCode() == http.StatusNotFound { + return nil, fmt.Errorf("failed to get client-ssl profile: %w", ErrNotFound) + } return nil, fmt.Errorf("failed to get client-ssl profile: %w", err) } return result, nil } -func (c *Client) UpdateClientSSLProfile(ctx context.Context, name, partition, certPath, keyPath string) error { +func (c *Client) CreateClientSSLProfile(ctx context.Context, name, partition, certPath, keyPath, chainPath string) error { + path := fmt.Sprintf("/mgmt/tm/ltm/profile/client-ssl") + body := map[string]string{ + "name": name, + "partition": partition, + "defaultsFrom": "clientssl", + "cert": certPath, + "key": keyPath, + } + if chainPath != "" { + body["chain"] = chainPath + } + + httpreq, err := c.newRequest(http.MethodPost, path) + if err != nil { + return err + } + httpreq.SetBody(body) + httpreq.SetContext(ctx) + + if _, err := c.doRequest(httpreq); err != nil { + return fmt.Errorf("failed to create client-ssl profile: %w", err) + } + + return nil +} + +func (c *Client) UpdateClientSSLProfile(ctx context.Context, name, partition, certPath, keyPath, chainPath string) error { path := fmt.Sprintf("/mgmt/tm/ltm/profile/client-ssl/~%s~%s", url.PathEscape(partition), url.PathEscape(name)) body := map[string]string{ "cert": certPath, "key": keyPath, } + if chainPath != "" { + body["chain"] = chainPath + } httpreq, err := c.newRequest(http.MethodPatch, path) if err != nil { From db6a4dfcbe7d81b61cedbe303d9d2f6ef8732a18 Mon Sep 17 00:00:00 2001 From: Christophe Kyvrakidis Date: Tue, 23 Jun 2026 18:22:01 +0200 Subject: [PATCH 4/4] feat(f5-bigip): make ssl-cert object name unique across CAs and wildcards Use wildcard substitution, issuer-derived prefix and short SHA1 hash to prevent naming collisions in ssl-cert/ssl-key uploads: certimate___ - Replace * with 'wildcard' instead of stripping it - Extract issuer slug from X.509 Organization[0] or CommonName - Append short SHA1 hash of the raw certificate - Sanitize issuer name against non-alphanumeric characters --- .../deployer/providers/f5bigip/f5bigip.go | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/pkg/core/deployer/providers/f5bigip/f5bigip.go b/pkg/core/deployer/providers/f5bigip/f5bigip.go index db2bc6a7..b2d0fe9d 100644 --- a/pkg/core/deployer/providers/f5bigip/f5bigip.go +++ b/pkg/core/deployer/providers/f5bigip/f5bigip.go @@ -2,11 +2,14 @@ package f5bigip import ( "context" + "crypto/sha1" "crypto/tls" "crypto/x509" + "encoding/hex" "errors" "fmt" "log/slog" + "regexp" "strings" "github.com/certimate-go/certimate/pkg/core" @@ -14,6 +17,8 @@ import ( xcert "github.com/certimate-go/certimate/pkg/utils/cert" ) +var nonAlphaNumericRegexp = regexp.MustCompile(`[^a-zA-Z0-9_-]`) + type ( Provider = core.Deployer DeployResult = core.DeployerDeployResult @@ -142,6 +147,16 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep return &DeployResult{}, nil } +// buildCertName generates a unique object name for F5 BIG-IP ssl-cert and ssl-key uploads. +// +// Naming pattern: certimate___ +// +// Examples: +// +// certimate_example.com_letsencrypt_a1b2c3d4 (SAN: example.com, CA: Let's Encrypt) +// certimate_wildcard.example.com_letsencrypt_e5f6g7h8 (SAN: *.example.com, CA: Let's Encrypt) +// certimate_example.com_zerossl_i9j0k1l2 (SAN: example.com, CA: ZeroSSL) +// certimate_example.com_googletrust_m3n4o5p6 (SAN: example.com, CA: Google Trust Services) func buildCertName(cert *x509.Certificate) string { san := "" if len(cert.DNSNames) > 0 { @@ -152,8 +167,23 @@ func buildCertName(cert *x509.Certificate) string { return "" } - san = strings.TrimPrefix(san, "*.") - return "certimate_" + san + san = strings.Replace(san, "*", "wildcard", 1) + + issuerName := "" + if len(cert.Issuer.Organization) > 0 { + issuerName = cert.Issuer.Organization[0] + } else { + issuerName = cert.Issuer.CommonName + } + issuerName = nonAlphaNumericRegexp.ReplaceAllString(issuerName, "") + if issuerName == "" { + issuerName = "unknown" + } + + fingerprint := sha1.Sum(cert.Raw) + shortHash := hex.EncodeToString(fingerprint[:4]) + + return fmt.Sprintf("certimate_%s_%s_%s", san, strings.ToLower(issuerName), shortHash) } func createSDKClient(serverUrl string, skipTlsVerify bool) (*f5sdk.Client, error) {