diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2c112e2b2..34bcb5c92 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,3 +10,5 @@ services: - /etc/timezone:/etc/timezone:ro - ./data:/app/pb_data restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/internal/certapply/applicators/sp_ssh.go b/internal/certapply/applicators/sp_ssh.go index d4d6c7fc3..e21a68e29 100644 --- a/internal/certapply/applicators/sp_ssh.go +++ b/internal/certapply/applicators/sp_ssh.go @@ -11,41 +11,46 @@ import ( ) func init() { - if err := ACMEHttp01Registries.Register(domain.ACMEHttp01ProviderTypeSSH, func(options *ProviderFactoryOptions) (challenge.Provider, error) { - credentials := domain.AccessConfigForSSH{} - if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { - return nil, fmt.Errorf("failed to populate provider access config: %w", err) - } + register := func(providerType domain.ACMEHttp01ProviderType) { + if err := ACMEHttp01Registries.Register(providerType, func(options *ProviderFactoryOptions) (challenge.Provider, error) { + credentials := domain.AccessConfigForSSH{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } - jumpServers := make([]ssh.ServerConfig, len(credentials.JumpServers)) - for i, jumpServer := range credentials.JumpServers { - jumpServers[i] = ssh.ServerConfig{ - SshHost: jumpServer.Host, - SshPort: jumpServer.Port, - SshAuthMethod: jumpServer.AuthMethod, - SshUsername: jumpServer.Username, - SshPassword: jumpServer.Password, - SshKey: jumpServer.Key, - SshKeyPassphrase: jumpServer.KeyPassphrase, + jumpServers := make([]ssh.ServerConfig, len(credentials.JumpServers)) + for i, jumpServer := range credentials.JumpServers { + jumpServers[i] = ssh.ServerConfig{ + SshHost: jumpServer.Host, + SshPort: jumpServer.Port, + SshAuthMethod: jumpServer.AuthMethod, + SshUsername: jumpServer.Username, + SshPassword: jumpServer.Password, + SshKey: jumpServer.Key, + SshKeyPassphrase: jumpServer.KeyPassphrase, + } } - } - provider, err := ssh.NewChallengeProvider(&ssh.ChallengeProviderConfig{ - ServerConfig: ssh.ServerConfig{ - SshHost: credentials.Host, - SshPort: credentials.Port, - SshAuthMethod: credentials.AuthMethod, - SshUsername: credentials.Username, - SshPassword: credentials.Password, - SshKey: credentials.Key, - SshKeyPassphrase: credentials.KeyPassphrase, - }, - JumpServers: jumpServers, - UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), - WebRootPath: xmaps.GetString(options.ProviderExtendedConfig, "webRootPath"), - }) - return provider, err - }); err != nil { - panic(err) + provider, err := ssh.NewChallengeProvider(&ssh.ChallengeProviderConfig{ + ServerConfig: ssh.ServerConfig{ + SshHost: credentials.Host, + SshPort: credentials.Port, + SshAuthMethod: credentials.AuthMethod, + SshUsername: credentials.Username, + SshPassword: credentials.Password, + SshKey: credentials.Key, + SshKeyPassphrase: credentials.KeyPassphrase, + }, + JumpServers: jumpServers, + UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), + WebRootPath: xmaps.GetString(options.ProviderExtendedConfig, "webRootPath"), + }) + return provider, err + }); err != nil { + panic(err) + } } + + register(domain.ACMEHttp01ProviderTypeSSH) + register(domain.ACMEHttp01ProviderTypeDockerHost) } diff --git a/internal/certdeploy/deployers/sp_ssh.go b/internal/certdeploy/deployers/sp_ssh.go index d2591905f..d5ac94bcd 100644 --- a/internal/certdeploy/deployers/sp_ssh.go +++ b/internal/certdeploy/deployers/sp_ssh.go @@ -10,51 +10,56 @@ import ( ) func init() { - if err := Registries.Register(domain.DeploymentProviderTypeSSH, func(options *ProviderFactoryOptions) (core.SSLDeployer, error) { - credentials := domain.AccessConfigForSSH{} - if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { - return nil, fmt.Errorf("failed to populate provider access config: %w", err) - } + register := func(providerType domain.DeploymentProviderType) { + if err := Registries.Register(providerType, func(options *ProviderFactoryOptions) (core.SSLDeployer, error) { + credentials := domain.AccessConfigForSSH{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } - jumpServers := make([]ssh.ServerConfig, len(credentials.JumpServers)) - for i, jumpServer := range credentials.JumpServers { - jumpServers[i] = ssh.ServerConfig{ - SshHost: jumpServer.Host, - SshPort: jumpServer.Port, - SshAuthMethod: jumpServer.AuthMethod, - SshUsername: jumpServer.Username, - SshPassword: jumpServer.Password, - SshKey: jumpServer.Key, - SshKeyPassphrase: jumpServer.KeyPassphrase, + jumpServers := make([]ssh.ServerConfig, len(credentials.JumpServers)) + for i, jumpServer := range credentials.JumpServers { + jumpServers[i] = ssh.ServerConfig{ + SshHost: jumpServer.Host, + SshPort: jumpServer.Port, + SshAuthMethod: jumpServer.AuthMethod, + SshUsername: jumpServer.Username, + SshPassword: jumpServer.Password, + SshKey: jumpServer.Key, + SshKeyPassphrase: jumpServer.KeyPassphrase, + } } - } - provider, err := ssh.NewSSLDeployerProvider(&ssh.SSLDeployerProviderConfig{ - ServerConfig: ssh.ServerConfig{ - SshHost: credentials.Host, - SshPort: credentials.Port, - SshAuthMethod: credentials.AuthMethod, - SshUsername: credentials.Username, - SshPassword: credentials.Password, - SshKey: credentials.Key, - SshKeyPassphrase: credentials.KeyPassphrase, - }, - JumpServers: jumpServers, - UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), - PreCommand: xmaps.GetString(options.ProviderExtendedConfig, "preCommand"), - PostCommand: xmaps.GetString(options.ProviderExtendedConfig, "postCommand"), - OutputFormat: ssh.OutputFormatType(xmaps.GetOrDefaultString(options.ProviderExtendedConfig, "format", string(ssh.OUTPUT_FORMAT_PEM))), - OutputKeyPath: xmaps.GetString(options.ProviderExtendedConfig, "keyPath"), - OutputCertPath: xmaps.GetString(options.ProviderExtendedConfig, "certPath"), - OutputServerCertPath: xmaps.GetString(options.ProviderExtendedConfig, "certPathForServerOnly"), - OutputIntermediaCertPath: xmaps.GetString(options.ProviderExtendedConfig, "certPathForIntermediaOnly"), - PfxPassword: xmaps.GetString(options.ProviderExtendedConfig, "pfxPassword"), - JksAlias: xmaps.GetString(options.ProviderExtendedConfig, "jksAlias"), - JksKeypass: xmaps.GetString(options.ProviderExtendedConfig, "jksKeypass"), - JksStorepass: xmaps.GetString(options.ProviderExtendedConfig, "jksStorepass"), - }) - return provider, err - }); err != nil { - panic(err) + provider, err := ssh.NewSSLDeployerProvider(&ssh.SSLDeployerProviderConfig{ + ServerConfig: ssh.ServerConfig{ + SshHost: credentials.Host, + SshPort: credentials.Port, + SshAuthMethod: credentials.AuthMethod, + SshUsername: credentials.Username, + SshPassword: credentials.Password, + SshKey: credentials.Key, + SshKeyPassphrase: credentials.KeyPassphrase, + }, + JumpServers: jumpServers, + UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), + PreCommand: xmaps.GetString(options.ProviderExtendedConfig, "preCommand"), + PostCommand: xmaps.GetString(options.ProviderExtendedConfig, "postCommand"), + OutputFormat: ssh.OutputFormatType(xmaps.GetOrDefaultString(options.ProviderExtendedConfig, "format", string(ssh.OUTPUT_FORMAT_PEM))), + OutputKeyPath: xmaps.GetString(options.ProviderExtendedConfig, "keyPath"), + OutputCertPath: xmaps.GetString(options.ProviderExtendedConfig, "certPath"), + OutputServerCertPath: xmaps.GetString(options.ProviderExtendedConfig, "certPathForServerOnly"), + OutputIntermediaCertPath: xmaps.GetString(options.ProviderExtendedConfig, "certPathForIntermediaOnly"), + PfxPassword: xmaps.GetString(options.ProviderExtendedConfig, "pfxPassword"), + JksAlias: xmaps.GetString(options.ProviderExtendedConfig, "jksAlias"), + JksKeypass: xmaps.GetString(options.ProviderExtendedConfig, "jksKeypass"), + JksStorepass: xmaps.GetString(options.ProviderExtendedConfig, "jksStorepass"), + }) + return provider, err + }); err != nil { + panic(err) + } } + + register(domain.DeploymentProviderTypeSSH) + register(domain.DeploymentProviderTypeDockerHost) } diff --git a/internal/domain/provider.go b/internal/domain/provider.go index d2f2c5f10..084a38aff 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -39,6 +39,7 @@ const ( AccessProviderTypeDigitalOcean = AccessProviderType("digitalocean") AccessProviderTypeDingTalkBot = AccessProviderType("dingtalkbot") AccessProviderTypeDiscordBot = AccessProviderType("discordbot") + AccessProviderTypeDockerHost = AccessProviderType("dockerhost") AccessProviderTypeDNSLA = AccessProviderType("dnsla") AccessProviderTypeDogeCloud = AccessProviderType("dogecloud") AccessProviderTypeDuckDNS = AccessProviderType("duckdns") @@ -205,8 +206,9 @@ ACME HTTP-01 提供商常量值。 NOTICE: If you add new constant, please keep ASCII order. */ const ( - ACMEHttp01ProviderTypeLocal = ACMEHttp01ProviderType(AccessProviderTypeLocal) - ACMEHttp01ProviderTypeSSH = ACMEHttp01ProviderType(AccessProviderTypeSSH) + ACMEHttp01ProviderTypeLocal = ACMEHttp01ProviderType(AccessProviderTypeLocal) + ACMEHttp01ProviderTypeSSH = ACMEHttp01ProviderType(AccessProviderTypeSSH) + ACMEHttp01ProviderTypeDockerHost = ACMEHttp01ProviderType(AccessProviderTypeDockerHost) ) type DeploymentProviderType string @@ -290,6 +292,7 @@ const ( DeploymentProviderTypeRatPanelSite = DeploymentProviderType(AccessProviderTypeRatPanel + "-site") DeploymentProviderTypeSafeLine = DeploymentProviderType(AccessProviderTypeSafeLine) DeploymentProviderTypeSSH = DeploymentProviderType(AccessProviderTypeSSH) + DeploymentProviderTypeDockerHost = DeploymentProviderType(AccessProviderTypeDockerHost) DeploymentProviderTypeTencentCloudCDN = DeploymentProviderType(AccessProviderTypeTencentCloud + "-cdn") DeploymentProviderTypeTencentCloudCLB = DeploymentProviderType(AccessProviderTypeTencentCloud + "-clb") DeploymentProviderTypeTencentCloudCOS = DeploymentProviderType(AccessProviderTypeTencentCloud + "-cos") diff --git a/internal/rest/handlers/system.go b/internal/rest/handlers/system.go new file mode 100644 index 000000000..88b781923 --- /dev/null +++ b/internal/rest/handlers/system.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "context" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/router" + + "github.com/certimate-go/certimate/internal/rest/resp" + "github.com/certimate-go/certimate/internal/system" +) + +type environmentService interface { + GetEnvironment(context.Context) (*system.Environment, error) +} + +type SystemHandler struct { + service environmentService +} + +func NewSystemHandler(router *router.RouterGroup[*core.RequestEvent], service environmentService) { + handler := &SystemHandler{service: service} + + group := router.Group("/system") + group.GET("/environment", handler.getEnvironment) +} + +func (handler *SystemHandler) getEnvironment(e *core.RequestEvent) error { + env, err := handler.service.GetEnvironment(e.Request.Context()) + if err != nil { + return resp.Err(e, err) + } + + return resp.Ok(e, env) +} diff --git a/internal/rest/routes/routes.go b/internal/rest/routes/routes.go index 7d5f5c694..5ede75d94 100644 --- a/internal/rest/routes/routes.go +++ b/internal/rest/routes/routes.go @@ -12,6 +12,7 @@ import ( "github.com/certimate-go/certimate/internal/repository" "github.com/certimate-go/certimate/internal/rest/handlers" "github.com/certimate-go/certimate/internal/statistics" + "github.com/certimate-go/certimate/internal/system" "github.com/certimate-go/certimate/internal/workflow" ) @@ -20,6 +21,7 @@ var ( workflowSvc *workflow.WorkflowService statisticsSvc *statistics.StatisticsService notifySvc *notify.NotifyService + systemSvc *system.EnvironmentService ) func Register(router *router.Router[*core.RequestEvent]) { @@ -34,12 +36,14 @@ func Register(router *router.Router[*core.RequestEvent]) { workflowSvc = workflow.NewWorkflowService(workflowRepo, workflowRunRepo, settingsRepo) statisticsSvc = statistics.NewStatisticsService(statisticsRepo) notifySvc = notify.NewNotifyService(accessRepo) + systemSvc = system.NewEnvironmentService(nil) group := router.Group("/api") group.Bind(apis.RequireSuperuserAuth()) handlers.NewCertificateHandler(group, certificateSvc) handlers.NewWorkflowHandler(group, workflowSvc) handlers.NewStatisticsHandler(group, statisticsSvc) + handlers.NewSystemHandler(group, systemSvc) handlers.NewNotifyHandler(group, notifySvc) } diff --git a/internal/system/environment.go b/internal/system/environment.go new file mode 100644 index 000000000..c04bb4bcd --- /dev/null +++ b/internal/system/environment.go @@ -0,0 +1,35 @@ +package system + +import ( + "context" + + "github.com/certimate-go/certimate/pkg/utils/netutil" +) + +type DockerHostInfo struct { + Reachable bool `json:"reachable"` + Address string `json:"address,omitempty"` +} + +type Environment struct { + DockerHost DockerHostInfo `json:"dockerHost"` +} + +type EnvironmentService struct { + resolver netutil.IPResolver +} + +func NewEnvironmentService(resolver netutil.IPResolver) *EnvironmentService { + return &EnvironmentService{resolver: resolver} +} + +func (s *EnvironmentService) GetEnvironment(ctx context.Context) (*Environment, error) { + addr, ok := netutil.LookupDockerHost(ctx, s.resolver) + + return &Environment{ + DockerHost: DockerHostInfo{ + Reachable: ok, + Address: addr, + }, + }, nil +} diff --git a/internal/system/environment_test.go b/internal/system/environment_test.go new file mode 100644 index 000000000..b902d71a5 --- /dev/null +++ b/internal/system/environment_test.go @@ -0,0 +1,50 @@ +package system + +import ( + "context" + "net" + "testing" +) + +type stubResolver struct { + addrs []net.IPAddr + err error +} + +func (s stubResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) { + return s.addrs, s.err +} + +func TestEnvironmentService_GetEnvironment(t *testing.T) { + resolver := stubResolver{addrs: []net.IPAddr{{IP: net.ParseIP("172.17.0.1")}}} + svc := NewEnvironmentService(resolver) + + env, err := svc.GetEnvironment(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if env.DockerHost.Address != "172.17.0.1" { + t.Fatalf("expected docker host address '172.17.0.1', got %q", env.DockerHost.Address) + } + if !env.DockerHost.Reachable { + t.Fatalf("expected docker host to be reachable") + } +} + +func TestEnvironmentService_GetEnvironment_Unreachable(t *testing.T) { + resolver := stubResolver{} + svc := NewEnvironmentService(resolver) + + env, err := svc.GetEnvironment(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if env.DockerHost.Address != "" { + t.Fatalf("expected empty address, got %q", env.DockerHost.Address) + } + if env.DockerHost.Reachable { + t.Fatalf("expected docker host to be unreachable") + } +} diff --git a/pkg/utils/netutil/dockerhost.go b/pkg/utils/netutil/dockerhost.go new file mode 100644 index 000000000..fc6742310 --- /dev/null +++ b/pkg/utils/netutil/dockerhost.go @@ -0,0 +1,54 @@ +package netutil + +import ( + "context" + "net" + "time" +) + +const ( + dockerHostName = "host.docker.internal" + defaultLookupTimeout = 500 * time.Millisecond +) + +// IPResolver defines the interface required to resolve hostnames to IP addresses. +type IPResolver interface { + LookupIPAddr(context.Context, string) ([]net.IPAddr, error) +} + +// LookupDockerHost resolves the docker host name to an IP address. +// +// It returns the resolved IP address and a flag indicating whether the lookup succeeded. +// When no address can be resolved, an empty string and false will be returned. +func LookupDockerHost(ctx context.Context, resolver IPResolver) (string, bool) { + if resolver == nil { + resolver = net.DefaultResolver + } + + if ctx == nil { + ctx = context.Background() + } + + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, defaultLookupTimeout) + defer cancel() + + addrs, err := resolver.LookupIPAddr(ctx, dockerHostName) + if err != nil || len(addrs) == 0 { + return "", false + } + + for _, addr := range addrs { + if ip4 := addr.IP.To4(); ip4 != nil { + return ip4.String(), true + } + } + + for _, addr := range addrs { + if addr.IP != nil { + return addr.IP.String(), true + } + } + + return "", false +} diff --git a/pkg/utils/netutil/dockerhost_test.go b/pkg/utils/netutil/dockerhost_test.go new file mode 100644 index 000000000..2aa5c0063 --- /dev/null +++ b/pkg/utils/netutil/dockerhost_test.go @@ -0,0 +1,65 @@ +package netutil + +import ( + "context" + "errors" + "net" + "testing" + "time" +) + +type mockResolver struct { + addrs []net.IPAddr + err error +} + +func (m mockResolver) LookupIPAddr(context.Context, string) ([]net.IPAddr, error) { + if m.err != nil { + return nil, m.err + } + return m.addrs, nil +} + +func TestLookupDockerHost_ReturnsIPv4WhenAvailable(t *testing.T) { + resolver := mockResolver{addrs: []net.IPAddr{{IP: net.ParseIP("172.17.0.1")}}} + + addr, ok := LookupDockerHost(context.Background(), resolver) + + if !ok { + t.Fatalf("expected lookup to succeed") + } + if addr != "172.17.0.1" { + t.Fatalf("expected address '172.17.0.1', got %q", addr) + } +} + +func TestLookupDockerHost_FallsBackToIPv6(t *testing.T) { + resolver := mockResolver{addrs: []net.IPAddr{{IP: net.ParseIP("::1")}}} + + addr, ok := LookupDockerHost(context.Background(), resolver) + + if !ok { + t.Fatalf("expected lookup to succeed") + } + if addr != "::1" { + t.Fatalf("expected address '::1', got %q", addr) + } +} + +func TestLookupDockerHost_ReturnsFalseOnError(t *testing.T) { + resolver := mockResolver{err: errors.New("lookup failed")} + + if addr, ok := LookupDockerHost(context.Background(), resolver); ok || addr != "" { + t.Fatalf("expected lookup to fail, got ok=%v addr=%q", ok, addr) + } +} + +func TestLookupDockerHost_TimeoutContext(t *testing.T) { + resolver := mockResolver{} + ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) + cancel() + + if addr, ok := LookupDockerHost(ctx, resolver); ok || addr != "" { + t.Fatalf("expected lookup to fail due to timeout, got ok=%v addr=%q", ok, addr) + } +} diff --git a/ui/public/imgs/providers/docker.svg b/ui/public/imgs/providers/docker.svg new file mode 100644 index 000000000..adb0b9661 --- /dev/null +++ b/ui/public/imgs/providers/docker.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/ui/src/api/system.ts b/ui/src/api/system.ts new file mode 100644 index 000000000..35968287b --- /dev/null +++ b/ui/src/api/system.ts @@ -0,0 +1,19 @@ +import { ClientResponseError } from "pocketbase"; + +import { type SystemEnvironment } from "@/domain/system"; +import { getPocketBase } from "@/repository/_pocketbase"; + +export const getEnvironment = async () => { + const pb = getPocketBase(); + + const resp = await pb.send>("/api/system/environment", { + method: "GET", + }); + + if (resp.code != 0) { + throw new ClientResponseError({ status: resp.code, response: resp, data: {} }); + } + + return resp; +}; + diff --git a/ui/src/components/access/AccessForm.tsx b/ui/src/components/access/AccessForm.tsx index a10a82187..39bd3fd9e 100644 --- a/ui/src/components/access/AccessForm.tsx +++ b/ui/src/components/access/AccessForm.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Form, type FormInstance, Input } from "antd"; import { createSchemaFieldRule } from "antd-zod"; @@ -7,7 +7,8 @@ import { z } from "zod"; import AccessProviderSelect from "@/components/provider/AccessProviderSelect"; import { type AccessModel } from "@/domain/access"; import { ACCESS_PROVIDERS, ACCESS_USAGES } from "@/domain/provider"; -import { useAntdForm } from "@/hooks"; +import { useSystemEnvironmentStore } from "@/stores/system"; +import { useAntdForm, useZustandShallowSelector } from "@/hooks"; import { FormNestedFieldsContextProvider } from "./forms/_context"; import { useProviderFilterByUsage } from "./forms/_hooks"; @@ -43,6 +44,7 @@ import AccessConfigFieldsProviderDNSLA from "./forms/AccessConfigFieldsProviderD import AccessConfigFieldsProviderDogeCloud from "./forms/AccessConfigFieldsProviderDogeCloud"; import AccessConfigFieldsProviderDuckDNS from "./forms/AccessConfigFieldsProviderDuckDNS"; import AccessConfigFieldsProviderDynv6 from "./forms/AccessConfigFieldsProviderDynv6"; +import AccessConfigFieldsProviderDockerHost from "./forms/AccessConfigFieldsProviderDockerHost"; import AccessConfigFieldsProviderEmail from "./forms/AccessConfigFieldsProviderEmail"; import AccessConfigFieldsProviderFlexCDN from "./forms/AccessConfigFieldsProviderFlexCDN"; import AccessConfigFieldsProviderGandinet from "./forms/AccessConfigFieldsProviderGandinet"; @@ -130,6 +132,12 @@ const AccessForm = ({ className, style, disabled, initialValues, mode, usage, on initialValues: initialValues, }); + const { fetchEnvironment } = useSystemEnvironmentStore(useZustandShallowSelector(["fetchEnvironment"])); + + useEffect(() => { + fetchEnvironment(false); + }, [fetchEnvironment]); + const providerFilter = useProviderFilterByUsage(usage); const fieldProvider = Form.useWatch("provider", { form: formInst, preserve: true }); @@ -353,6 +361,9 @@ const AccessForm = ({ className, style, disabled, initialValues, mode, usage, on case ACCESS_PROVIDERS.SSH: { return ; } + case ACCESS_PROVIDERS.DOCKERHOST: { + return ; + } case ACCESS_PROVIDERS.TECHNITIUMDNS: { return ; } diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderDockerHost.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderDockerHost.tsx new file mode 100644 index 000000000..5270f845b --- /dev/null +++ b/ui/src/components/access/forms/AccessConfigFieldsProviderDockerHost.tsx @@ -0,0 +1,48 @@ +import { useEffect, useMemo } from "react"; +import { Form } from "antd"; + +import { useZustandShallowSelector } from "@/hooks"; +import { useSystemEnvironmentStore } from "@/stores/system"; + +import AccessConfigFieldsProviderSSH, { + getAccessConfigFieldsProviderSSHInitialValues, +} from "./AccessConfigFieldsProviderSSH"; +import { useFormNestedFieldsContext } from "./_context"; + +const FALLBACK_HOST = "host.docker.internal"; + +const AccessConfigFieldsProviderDockerHost = ({ disabled }: { disabled?: boolean }) => { + const formInst = Form.useFormInstance(); + const { parentNamePath } = useFormNestedFieldsContext(); + + const { environment, fetchEnvironment, loadedEnvironment } = useSystemEnvironmentStore( + useZustandShallowSelector(["environment", "fetchEnvironment", "loadedEnvironment"]) + ); + + useEffect(() => { + if (!loadedEnvironment) { + fetchEnvironment(false); + } + }, [fetchEnvironment, loadedEnvironment]); + + const resolvedHost = useMemo(() => { + if (environment?.dockerHost.reachable && environment.dockerHost.address) { + return environment.dockerHost.address; + } + return FALLBACK_HOST; + }, [environment]); + + useEffect(() => { + const defaultHost = getAccessConfigFieldsProviderSSHInitialValues()?.host; + const currentHost = formInst.getFieldValue([parentNamePath, "host"]); + + if (!currentHost || currentHost === defaultHost || currentHost === FALLBACK_HOST) { + formInst.setFieldValue([parentNamePath, "host"], resolvedHost); + } + }, [formInst, parentNamePath, resolvedHost]); + + return ; +}; + +export default AccessConfigFieldsProviderDockerHost; + diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx index 278faa83e..799591f60 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx @@ -15,7 +15,15 @@ const AUTH_METHOD_NONE = "none" as const; const AUTH_METHOD_PASSWORD = "password" as const; const AUTH_METHOD_KEY = "key" as const; -const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) => { +const AccessConfigFormFieldsProviderSSH = ({ + disabled, + hostDisabled, + hideJumpServers, +}: { + disabled?: boolean; + hostDisabled?: boolean; + hideJumpServers?: boolean; +}) => { const { i18n, t } = useTranslation(); const { parentNamePath } = useFormNestedFieldsContext(); @@ -24,7 +32,7 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) }); const formRule = createSchemaFieldRule(formSchema); const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); + const initialValues = getInitialValuesInternal(); const fieldAuthMethod = Form.useWatch([parentNamePath, "authMethod"], formInst); const fieldJumpServers = Form.useWatch([parentNamePath, "jumpServers"], formInst); @@ -34,7 +42,7 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean })
- +
@@ -83,7 +91,8 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) - + {!hideJumpServers && ( + {(fields, { add, remove, move }) => (
@@ -203,7 +212,7 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) onClick={() => { add(); setTimeout(() => { - const jumpServer = getInitialValues(); + const jumpServer = getInitialValuesInternal(); delete jumpServer.jumpServers; formInst.setFieldValue([parentNamePath, "jumpServers", (fieldJumpServers?.length ?? 0) + 1 - 1], jumpServer); }, 0); @@ -215,12 +224,13 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) )} - + + )} ); }; -const getInitialValues = (): Nullish>> => { +const getInitialValuesInternal = (): Nullish>> => { return { host: "127.0.0.1", port: 22, @@ -229,6 +239,8 @@ const getInitialValues = (): Nullish>> => { }; }; +export const getAccessConfigFieldsProviderSSHInitialValues = getInitialValuesInternal; + const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { const { t } = i18n; @@ -300,7 +312,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) = }; const _default = Object.assign(AccessConfigFormFieldsProviderSSH, { - getInitialValues, + getInitialValues: getInitialValuesInternal, getSchema, }); diff --git a/ui/src/components/provider/AccessProviderPicker.tsx b/ui/src/components/provider/AccessProviderPicker.tsx index cd4404347..c9b797000 100644 --- a/ui/src/components/provider/AccessProviderPicker.tsx +++ b/ui/src/components/provider/AccessProviderPicker.tsx @@ -4,7 +4,13 @@ import { useMount } from "ahooks"; import { Avatar, Card, Empty, Input, type InputRef, Tag, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; -import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; +import { + ACCESS_PROVIDERS, + ACCESS_USAGES, + type AccessProvider, + type AccessUsageType, + accessProvidersMap, +} from "@/domain/provider"; import { mergeCls } from "@/utils/css"; import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; @@ -62,14 +68,20 @@ const AccessProviderPicker = ({ }); const renderOption = (provider: AccessProvider) => { + const shouldShowBadgeRow = showOptionTagAnyhow || provider.disabled; + return (
{ - if (provider.builtin) { + if (provider.builtin || provider.disabled) { return; } @@ -81,13 +93,18 @@ const AccessProviderPicker = ({ } shape="square" size={32} />
-
+
- {t(provider.name) || "\u00A0"} + + {t(provider.name) || "\u00A0"} +
- +
+ + {t("provider.badge.unsupported")} + {t("access.props.provider.builtin")} diff --git a/ui/src/components/provider/AccessProviderSelect.tsx b/ui/src/components/provider/AccessProviderSelect.tsx index 934015ab9..b84bb02a1 100644 --- a/ui/src/components/provider/AccessProviderSelect.tsx +++ b/ui/src/components/provider/AccessProviderSelect.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Avatar, Select, Tag, Typography, theme } from "antd"; import Show from "@/components/Show"; -import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; +import { ACCESS_PROVIDERS, ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; import { type SharedSelectProps } from "./_shared"; @@ -45,22 +45,30 @@ const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProv key: provider.type, value: provider.type, label: t(provider.name), - disabled: provider.builtin, + disabled: provider.builtin || provider.disabled, data: provider, })); }, [onFilter]); const renderOption = (key: string) => { const provider = accessProvidersMap.get(key) ?? ({ type: "", name: "", icon: "", usages: [] } as unknown as AccessProvider); + const showUnsupportedBadge = provider.type === ACCESS_PROVIDERS.DOCKERHOST && provider.disabled; return (
- + {t(provider.name)}
+ + {t("provider.badge.unsupported")} + {t("access.props.provider.builtin")} diff --git a/ui/src/components/provider/_shared.ts b/ui/src/components/provider/_shared.ts index ec72f09c4..876aceb76 100644 --- a/ui/src/components/provider/_shared.ts +++ b/ui/src/components/provider/_shared.ts @@ -46,6 +46,7 @@ export const useSelectDataSource = ({ return accesses.some((access) => { if ("builtin" in provider && provider.builtin) return true; if ("provider" in provider) return access.provider === provider.provider; + if ("disabled" in provider && provider.disabled) return false; return access.provider === provider.type; }); }); @@ -133,6 +134,7 @@ export const usePickerDataSource = ({ return accesses.some((access) => { if ("builtin" in provider && provider.builtin) return true; if ("provider" in provider) return access.provider === provider.provider; + if ("disabled" in provider && provider.disabled) return false; return access.provider === provider.type; }); }); diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index 9c99cf64c..3cff225ae 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx @@ -128,6 +128,9 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) case CHALLENGE_TYPE_HTTP01: switch (fieldProvider) { + case ACME_HTTP01_PROVIDERS.DOCKERHOST: { + return BizApplyNodeConfigFieldsProviderSSH; + } case ACME_HTTP01_PROVIDERS.LOCAL: { return BizApplyNodeConfigFieldsProviderLocal; } diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 50f716634..bbd1a389e 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -43,6 +43,7 @@ export const ACCESS_PROVIDERS = Object.freeze({ DIGITALOCEAN: "digitalocean", DINGTALKBOT: "dingtalkbot", DISCORDBOT: "discordbot", + DOCKERHOST: "dockerhost", DNSLA: "dnsla", DOGECLOUD: "dogecloud", DUCKDNS: "duckdns", @@ -118,6 +119,7 @@ export type AccessUsageType = (typeof ACCESS_USAGES)[keyof typeof ACCESS_USAGES] export interface AccessProvider extends BaseProvider { usages: AccessUsageType[]; + disabled?: boolean; } export const accessProvidersMap: Map = new Map( @@ -129,6 +131,7 @@ export const accessProvidersMap: Map diff --git a/ui/src/domain/system.ts b/ui/src/domain/system.ts new file mode 100644 index 000000000..4486b47fc --- /dev/null +++ b/ui/src/domain/system.ts @@ -0,0 +1,7 @@ +export interface SystemEnvironment { + dockerHost: { + reachable: boolean; + address?: string; + }; +} + diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json index 7b3f7d0d9..ea1aa4ac8 100644 --- a/ui/src/i18n/locales/en/nls.provider.json +++ b/ui/src/i18n/locales/en/nls.provider.json @@ -144,6 +144,7 @@ "provider.slackbot": "Slack Bot", "provider.spaceship": "Spaceship", "provider.ssh": "Remote host (SSH)", + "provider.dockerhost": "Docker host", "provider.sslcom": "SSL.com", "provider.technitiumdns": "Technitium DNS", "provider.telegrambot": "Telegram Bot", @@ -212,5 +213,6 @@ "provider.text.default_group": "Default", "provider.text.available_group": "Available (with added credentials)", "provider.text.unavailable_group": "Unavailable (without added credentials)", - "provider.text.unavailable_divider": "The following providers are not available (without added credentials)" + "provider.text.unavailable_divider": "The following providers are not available (without added credentials)", + "provider.badge.unsupported": "Unsupported" } diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index bf23ceca9..9a2a23287 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -144,6 +144,7 @@ "provider.slackbot": "Slack 机器人", "provider.spaceship": "Spaceship", "provider.ssh": "远程主机(SSH)", + "provider.dockerhost": "Docker 宿主机", "provider.sslcom": "SSL.com", "provider.technitiumdns": "Technitium DNS", "provider.telegrambot": "Telegram 机器人", @@ -211,5 +212,6 @@ "provider.text.default_group": "默认", "provider.text.available_group": "可用(已添加授权凭据)", "provider.text.unavailable_group": "不可用(未添加授权凭据)", - "provider.text.unavailable_divider": "以下提供商不可用(即未添加过授权凭据)" + "provider.text.unavailable_divider": "以下提供商不可用(即未添加过授权凭据)", + "provider.badge.unsupported": "环境不支持" } diff --git a/ui/src/stores/system/index.ts b/ui/src/stores/system/index.ts new file mode 100644 index 000000000..d3cda5427 --- /dev/null +++ b/ui/src/stores/system/index.ts @@ -0,0 +1,40 @@ +import { create } from "zustand"; + +import { getEnvironment as requestEnvironment } from "@/api/system"; +import { ACCESS_PROVIDERS, accessProvidersMap } from "@/domain/provider"; + +import { type SystemEnvironmentStore } from "./types"; + +export const useSystemEnvironmentStore = create((set, get) => ({ + environment: null, + loadingEnvironment: false, + loadedEnvironment: false, + + fetchEnvironment: async (refresh = true) => { + if (!refresh && get().loadedEnvironment) { + return get().environment; + } + if (get().loadingEnvironment) { + return get().environment; + } + + set({ loadingEnvironment: true }); + + try { + const resp = await requestEnvironment(); + const environment = resp.data ?? null; + + accessProvidersMap.get(ACCESS_PROVIDERS.DOCKERHOST)!.disabled = !(environment?.dockerHost.reachable ?? false); + + set({ environment, loadedEnvironment: true }); + return environment; + } catch (err) { + accessProvidersMap.get(ACCESS_PROVIDERS.DOCKERHOST)!.disabled = true; + set({ environment: null, loadedEnvironment: false }); + return null; + } finally { + set({ loadingEnvironment: false }); + } + }, +})); + diff --git a/ui/src/stores/system/types.ts b/ui/src/stores/system/types.ts new file mode 100644 index 000000000..0583f4917 --- /dev/null +++ b/ui/src/stores/system/types.ts @@ -0,0 +1,14 @@ +import { type SystemEnvironment } from "@/domain/system"; + +export interface SystemEnvironmentState { + environment: SystemEnvironment | null; + loadingEnvironment: boolean; + loadedEnvironment: boolean; +} + +export interface SystemEnvironmentActions { + fetchEnvironment: (refresh?: boolean) => Promise; +} + +export interface SystemEnvironmentStore extends SystemEnvironmentState, SystemEnvironmentActions {} +