diff --git a/.gitignore b/.gitignore index c6480f59..bd3e6b67 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ client.key # AI CLAUDE.md + +# TLS +.autotls-cache \ No newline at end of file diff --git a/client/proxy/proxy.go b/client/proxy/proxy.go index 8faff38d..3bafbcc6 100644 --- a/client/proxy/proxy.go +++ b/client/proxy/proxy.go @@ -19,7 +19,9 @@ import ( "io" "net" "reflect" + "slices" "strconv" + "strings" "sync" "time" @@ -69,6 +71,7 @@ func NewProxy( baseProxy := BaseProxy{ baseCfg: pxyConf.GetBaseConfig(), + configurer: pxyConf, clientCfg: clientCfg, encryptionKey: encryptionKey, limiter: limiter, @@ -87,6 +90,7 @@ func NewProxy( type BaseProxy struct { baseCfg *v1.ProxyBaseConfig + configurer v1.ProxyConfigurer clientCfg *v1.ClientCommonConfig encryptionKey []byte msgTransporter transport.MessageTransporter @@ -106,6 +110,7 @@ func (pxy *BaseProxy) Run() error { if pxy.baseCfg.Plugin.Type != "" { p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{ Name: pxy.baseCfg.Name, + HostAllowList: pxy.getPluginHostAllowList(), VnetController: pxy.vnetController, }, pxy.baseCfg.Plugin.ClientPluginOptions) if err != nil { @@ -116,6 +121,39 @@ func (pxy *BaseProxy) Run() error { return nil } +func (pxy *BaseProxy) getPluginHostAllowList() []string { + dedupHosts := make([]string, 0) + addHost := func(host string) { + host = strings.TrimSpace(strings.ToLower(host)) + if host == "" { + return + } + // autocert.HostWhitelist only supports exact host names. + if strings.Contains(host, "*") { + return + } + if !slices.Contains(dedupHosts, host) { + dedupHosts = append(dedupHosts, host) + } + } + + switch cfg := pxy.configurer.(type) { + case *v1.HTTPProxyConfig: + for _, host := range cfg.CustomDomains { + addHost(host) + } + case *v1.HTTPSProxyConfig: + for _, host := range cfg.CustomDomains { + addHost(host) + } + case *v1.TCPMuxProxyConfig: + for _, host := range cfg.CustomDomains { + addHost(host) + } + } + return dedupHosts +} + func (pxy *BaseProxy) Close() { if pxy.proxyPlugin != nil { pxy.proxyPlugin.Close() diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index 5a414137..6dbcf6d5 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -329,6 +329,14 @@ type = "https2http" localAddr = "127.0.0.1:80" crtPath = "./server.crt" keyPath = "./server.key" +# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates. +# [proxies.plugin.autoTLS] +# enable = true +# email = "admin@example.com" +# cacheDir = "./.autotls-cache" +# hostAllowList is optional. If omitted, frpc will use customDomains automatically. +# hostAllowList = ["test.yourdomain.com"] +# caDirURL = "https://acme-v02.api.letsencrypt.org/directory" hostHeaderRewrite = "127.0.0.1" requestHeaders.set.x-from-where = "frp" @@ -341,6 +349,14 @@ type = "https2https" localAddr = "127.0.0.1:443" crtPath = "./server.crt" keyPath = "./server.key" +# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates. +# [proxies.plugin.autoTLS] +# enable = true +# email = "admin@example.com" +# cacheDir = "./.autotls-cache" +# hostAllowList is optional. If omitted, frpc will use customDomains automatically. +# hostAllowList = ["test.yourdomain.com"] +# caDirURL = "https://acme-v02.api.letsencrypt.org/directory" hostHeaderRewrite = "127.0.0.1" requestHeaders.set.x-from-where = "frp" @@ -373,6 +389,14 @@ type = "tls2raw" localAddr = "127.0.0.1:80" crtPath = "./server.crt" keyPath = "./server.key" +# autoTLS can replace crtPath/keyPath and automatically apply/renew certificates. +# [proxies.plugin.autoTLS] +# enable = true +# email = "admin@example.com" +# cacheDir = "./.autotls-cache" +# hostAllowList is optional. If omitted, frpc will use customDomains automatically. +# hostAllowList = ["test.yourdomain.com"] +# caDirURL = "https://acme-v02.api.letsencrypt.org/directory" [[proxies]] name = "secret_tcp" diff --git a/pkg/config/v1/proxy_plugin.go b/pkg/config/v1/proxy_plugin.go index 128ccae6..07825140 100644 --- a/pkg/config/v1/proxy_plugin.go +++ b/pkg/config/v1/proxy_plugin.go @@ -117,6 +117,18 @@ type HTTPProxyPluginOptions struct { func (o *HTTPProxyPluginOptions) Complete() {} +type AutoTLSOptions struct { + Enable bool `json:"enable,omitempty"` + // Contact email for certificate expiration and important notices. + Email string `json:"email,omitempty"` + // Directory used to cache ACME account and certificates. + CacheDir string `json:"cacheDir,omitempty"` + // ACME directory URL, e.g. Let's Encrypt staging/prod endpoint. + CADirURL string `json:"caDirURL,omitempty"` + // Restrict certificate issuance to the listed domains. + HostAllowList []string `json:"hostAllowList,omitempty"` +} + type HTTPS2HTTPPluginOptions struct { Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` @@ -125,6 +137,7 @@ type HTTPS2HTTPPluginOptions struct { EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` CrtPath string `json:"crtPath,omitempty"` KeyPath string `json:"keyPath,omitempty"` + AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"` } func (o *HTTPS2HTTPPluginOptions) Complete() { @@ -139,6 +152,7 @@ type HTTPS2HTTPSPluginOptions struct { EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` CrtPath string `json:"crtPath,omitempty"` KeyPath string `json:"keyPath,omitempty"` + AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"` } func (o *HTTPS2HTTPSPluginOptions) Complete() { @@ -180,10 +194,11 @@ type UnixDomainSocketPluginOptions struct { func (o *UnixDomainSocketPluginOptions) Complete() {} type TLS2RawPluginOptions struct { - Type string `json:"type,omitempty"` - LocalAddr string `json:"localAddr,omitempty"` - CrtPath string `json:"crtPath,omitempty"` - KeyPath string `json:"keyPath,omitempty"` + Type string `json:"type,omitempty"` + LocalAddr string `json:"localAddr,omitempty"` + CrtPath string `json:"crtPath,omitempty"` + KeyPath string `json:"keyPath,omitempty"` + AutoTLS *AutoTLSOptions `json:"autoTLS,omitempty"` } func (o *TLS2RawPluginOptions) Complete() {} diff --git a/pkg/config/v1/validation/plugin.go b/pkg/config/v1/validation/plugin.go index 30a66d83..fb66be36 100644 --- a/pkg/config/v1/validation/plugin.go +++ b/pkg/config/v1/validation/plugin.go @@ -16,6 +16,8 @@ package validation import ( "errors" + "fmt" + "strings" v1 "github.com/fatedier/frp/pkg/config/v1" ) @@ -49,6 +51,9 @@ func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error { if c.LocalAddr == "" { return errors.New("localAddr is required") } + if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil { + return fmt.Errorf("invalid autoTLS options: %w", err) + } return nil } @@ -56,6 +61,9 @@ func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error { if c.LocalAddr == "" { return errors.New("localAddr is required") } + if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil { + return fmt.Errorf("invalid autoTLS options: %w", err) + } return nil } @@ -77,5 +85,29 @@ func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error { if c.LocalAddr == "" { return errors.New("localAddr is required") } + if err := validateAutoTLSOptions(c.AutoTLS, c.CrtPath, c.KeyPath); err != nil { + return fmt.Errorf("invalid autoTLS options: %w", err) + } + return nil +} + +func validateAutoTLSOptions(c *v1.AutoTLSOptions, crtPath, keyPath string) error { + if c == nil || !c.Enable { + return nil + } + + if crtPath != "" || keyPath != "" { + return errors.New("crtPath and keyPath must be empty when autoTLS.enable is true") + } + if strings.TrimSpace(c.CacheDir) == "" { + return errors.New("autoTLS.cacheDir is required when autoTLS.enable is true") + } + if len(c.HostAllowList) > 0 { + for _, host := range c.HostAllowList { + if strings.TrimSpace(host) == "" { + return errors.New("autoTLS.hostAllowList cannot contain empty domain") + } + } + } return nil } diff --git a/pkg/plugin/client/autotls.go b/pkg/plugin/client/autotls.go new file mode 100644 index 00000000..a4038e64 --- /dev/null +++ b/pkg/plugin/client/autotls.go @@ -0,0 +1,211 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !frps + +package client + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "strings" + "sync" + "time" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/util/log" + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" +) + +func buildAutoTLSServerConfigWithHosts(pluginName string, auto *v1.AutoTLSOptions, fallbackHosts []string) (*tls.Config, error) { + if auto == nil || !auto.Enable { + return nil, fmt.Errorf("插件 %s 未启用 autoTLS", pluginName) + } + + if err := os.MkdirAll(auto.CacheDir, 0o700); err != nil { + return nil, fmt.Errorf("插件 %s 创建 autoTLS 缓存目录失败: %w", pluginName, err) + } + + hostSet := make(map[string]struct{}) + hosts := make([]string, 0, len(auto.HostAllowList)) + addHost := func(host string) { + host = strings.TrimSpace(strings.ToLower(host)) + if host == "" { + return + } + if strings.Contains(host, "*") { + log.Warnf("[autoTLS][%s] 域名 [%s] 含通配符,自动申请不支持,已忽略", pluginName, host) + return + } + if _, ok := hostSet[host]; ok { + return + } + hostSet[host] = struct{}{} + hosts = append(hosts, host) + } + + for _, host := range auto.HostAllowList { + addHost(host) + } + if len(hosts) == 0 { + for _, host := range fallbackHosts { + addHost(host) + } + } + if len(hosts) == 0 { + return nil, fmt.Errorf("插件 %s 的 hostAllowList 为空;请设置 autoTLS.hostAllowList 或 customDomains", pluginName) + } + + manager := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + Email: strings.TrimSpace(auto.Email), + HostPolicy: autocert.HostWhitelist(hosts...), + } + caDirURL := strings.TrimSpace(auto.CADirURL) + if caDirURL != "" { + manager.Client = &acme.Client{DirectoryURL: caDirURL} + } else { + caDirURL = autocert.DefaultACMEDirectory + } + managedHosts := make(map[string]struct{}, len(hosts)) + for _, host := range hosts { + managedHosts[host] = struct{}{} + } + var warmupInProgress sync.Map + var warmupMissLogged sync.Map + manager.Cache = &autoTLSCache{ + inner: autocert.DirCache(auto.CacheDir), + managedHosts: managedHosts, + pluginName: pluginName, + caDirURL: caDirURL, + warmupInProgress: &warmupInProgress, + warmupMissLogged: &warmupMissLogged, + } + + cfg := manager.TLSConfig() + log.Infof("[autoTLS][%s] 已启用 autoTLS,管理域名=%v,缓存目录=%s", pluginName, hosts, auto.CacheDir) + + var readySeen sync.Map + + handleCertReady := func(host string, cert *tls.Certificate) { + var ( + notAfter time.Time + hasExpiry bool + ) + if t, ok := getCertificateNotAfter(cert); ok { + notAfter = t + hasExpiry = true + } + + _, readyLogged := readySeen.LoadOrStore(host, struct{}{}) + if hasExpiry { + if !readyLogged { + log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪,过期时间 %s", pluginName, host, notAfter.Format(time.RFC3339)) + } + } else if !readyLogged { + log.Infof("[autoTLS][%s] 域名 [%s] 证书已就绪", pluginName, host) + } + } + + cfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + host := strings.TrimSpace(strings.ToLower(hello.ServerName)) + if host == "" { + host = "<空SNI>" + } + + cert, err := manager.GetCertificate(hello) + if err != nil { + log.Warnf("[autoTLS][%s] 获取域名 [%s] 证书失败: %v", pluginName, host, err) + return nil, err + } + handleCertReady(host, cert) + return cert, nil + } + + // Warm up certificates in background after startup. + for _, host := range hosts { + h := host + go func() { + // Leave time for listener setup and route registration. + time.Sleep(1 * time.Second) + warmupMissLogged.Delete(h) + warmupInProgress.Store(h, struct{}{}) + cert, err := manager.GetCertificate(&tls.ClientHelloInfo{ServerName: h}) + warmupInProgress.Delete(h) + if err != nil { + log.Warnf("[autoTLS][%s] 域名 [%s] 预申请失败: %v", pluginName, h, err) + return + } + handleCertReady(h, cert) + }() + } + return cfg, nil +} + +func getCertificateNotAfter(cert *tls.Certificate) (time.Time, bool) { + if cert == nil { + return time.Time{}, false + } + if cert.Leaf != nil { + return cert.Leaf.NotAfter, true + } + if len(cert.Certificate) == 0 { + return time.Time{}, false + } + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return time.Time{}, false + } + return leaf.NotAfter, true +} + +type autoTLSCache struct { + inner autocert.Cache + managedHosts map[string]struct{} + pluginName string + caDirURL string + warmupInProgress *sync.Map + warmupMissLogged *sync.Map +} + +func (c *autoTLSCache) Get(ctx context.Context, key string) ([]byte, error) { + data, err := c.inner.Get(ctx, key) + if err != autocert.ErrCacheMiss { + return data, err + } + + host := strings.TrimSuffix(key, "+rsa") + if _, ok := c.managedHosts[host]; !ok { + return data, err + } + if _, warming := c.warmupInProgress.Load(host); !warming { + return data, err + } + if _, loaded := c.warmupMissLogged.LoadOrStore(host, struct{}{}); !loaded { + log.Infof("[autoTLS][%s] 开始预申请域名 [%s] 证书,申请方式=TLS-ALPN-01,caDirURL=%s", c.pluginName, host, c.caDirURL) + } + return data, err +} + +func (c *autoTLSCache) Put(ctx context.Context, key string, data []byte) error { + return c.inner.Put(ctx, key, data) +} + +func (c *autoTLSCache) Delete(ctx context.Context, key string) error { + return c.inner.Delete(ctx, key) +} diff --git a/pkg/plugin/client/https2http.go b/pkg/plugin/client/https2http.go index 963b9d2e..05b8c4d6 100644 --- a/pkg/plugin/client/https2http.go +++ b/pkg/plugin/client/https2http.go @@ -46,7 +46,7 @@ type HTTPS2HTTPPlugin struct { s *http.Server } -func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { +func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTPS2HTTPPluginOptions) listener := NewProxyListener() @@ -84,9 +84,18 @@ func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugi rp.ServeHTTP(w, r) }) - tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") - if err != nil { - return nil, fmt.Errorf("gen TLS config error: %v", err) + var tlsConfig *tls.Config + var err error + if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable { + tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList) + if err != nil { + return nil, fmt.Errorf("build autoTLS config error: %v", err) + } + } else { + tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") + if err != nil { + return nil, fmt.Errorf("gen TLS config error: %v", err) + } } p.s = &http.Server{ diff --git a/pkg/plugin/client/https2https.go b/pkg/plugin/client/https2https.go index 5c669d36..3b752b57 100644 --- a/pkg/plugin/client/https2https.go +++ b/pkg/plugin/client/https2https.go @@ -46,7 +46,7 @@ type HTTPS2HTTPSPlugin struct { s *http.Server } -func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { +func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTPS2HTTPSPluginOptions) listener := NewProxyListener() @@ -90,9 +90,18 @@ func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plug rp.ServeHTTP(w, r) }) - tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") - if err != nil { - return nil, fmt.Errorf("gen TLS config error: %v", err) + var tlsConfig *tls.Config + var err error + if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable { + tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList) + if err != nil { + return nil, fmt.Errorf("build autoTLS config error: %v", err) + } + } else { + tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") + if err != nil { + return nil, fmt.Errorf("gen TLS config error: %v", err) + } } p.s = &http.Server{ diff --git a/pkg/plugin/client/plugin.go b/pkg/plugin/client/plugin.go index 7bcd0489..fdf8c850 100644 --- a/pkg/plugin/client/plugin.go +++ b/pkg/plugin/client/plugin.go @@ -30,6 +30,7 @@ import ( type PluginContext struct { Name string + HostAllowList []string VnetController *vnet.Controller } diff --git a/pkg/plugin/client/tls2raw.go b/pkg/plugin/client/tls2raw.go index 445b6c91..b59830d2 100644 --- a/pkg/plugin/client/tls2raw.go +++ b/pkg/plugin/client/tls2raw.go @@ -39,16 +39,25 @@ type TLS2RawPlugin struct { tlsConfig *tls.Config } -func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { +func NewTLS2RawPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.TLS2RawPluginOptions) p := &TLS2RawPlugin{ opts: opts, } - tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") - if err != nil { - return nil, err + var tlsConfig *tls.Config + var err error + if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable { + tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList) + if err != nil { + return nil, err + } + } else { + tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") + if err != nil { + return nil, err + } } p.tlsConfig = tlsConfig return p, nil