diff --git a/cmd/add.go b/cmd/add.go
index 453cacc..a3a1341 100644
--- a/cmd/add.go
+++ b/cmd/add.go
@@ -2,19 +2,23 @@ package cmd
import (
"context"
+ "encoding/hex"
"fmt"
"strings"
+ "github.com/qingchencloud/cftunnel/internal/authproxy"
"github.com/qingchencloud/cftunnel/internal/cfapi"
"github.com/qingchencloud/cftunnel/internal/config"
"github.com/spf13/cobra"
)
var addDomain string
+var addAuth string
func init() {
addCmd.Flags().StringVar(&addDomain, "domain", "", "完整域名 (如 webhook.example.com)")
addCmd.MarkFlagRequired("domain")
+ addCmd.Flags().StringVar(&addAuth, "auth", "", "启用密码保护 (格式: 用户名:密码)")
rootCmd.AddCommand(addCmd)
}
@@ -77,14 +81,31 @@ var addCmd = &cobra.Command{
return err
}
- // 保存路由
- cfg.Routes = append(cfg.Routes, config.RouteConfig{
+ // 构建路由配置
+ route := config.RouteConfig{
Name: name,
Hostname: addDomain,
Service: service,
ZoneID: zone.ID,
DNSRecordID: recordID,
- })
+ }
+
+ // 如果指定了 --auth,填充鉴权配置
+ if addAuth != "" {
+ user, pass, err := parseAuth(addAuth)
+ if err != nil {
+ return err
+ }
+ route.Auth = &config.AuthProxy{
+ Username: user,
+ Password: pass,
+ SigningKey: hex.EncodeToString(authproxy.RandomKey()),
+ }
+ fmt.Printf("已启用密码保护: %s\n", addDomain)
+ }
+
+ // 保存路由
+ cfg.Routes = append(cfg.Routes, route)
if err := cfg.Save(); err != nil {
return err
}
diff --git a/cmd/quick.go b/cmd/quick.go
index cae03f7..34539c8 100644
--- a/cmd/quick.go
+++ b/cmd/quick.go
@@ -1,11 +1,17 @@
package cmd
import (
+ "fmt"
+ "strings"
+
"github.com/qingchencloud/cftunnel/internal/daemon"
"github.com/spf13/cobra"
)
+var quickAuth string
+
func init() {
+ quickCmd.Flags().StringVar(&quickAuth, "auth", "", "启用密码保护 (格式: 用户名:密码)")
rootCmd.AddCommand(quickCmd)
}
@@ -15,6 +21,27 @@ var quickCmd = &cobra.Command{
Long: "无需 Cloudflare 账户、API Token 或域名,一条命令生成临时公网地址。\n适合临时分享、快速调试,Ctrl+C 退出后域名自动失效。",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
+ if quickAuth != "" {
+ user, pass, err := parseAuth(quickAuth)
+ if err != nil {
+ return err
+ }
+ return daemon.StartQuickWithAuth(args[0], user, pass)
+ }
return daemon.StartQuick(args[0])
},
}
+
+// parseAuth 解析 "用户名:密码" 格式,密码部分允许包含冒号
+func parseAuth(s string) (string, string, error) {
+ idx := strings.Index(s, ":")
+ if idx < 0 {
+ return "", "", fmt.Errorf("--auth 格式错误,应为 用户名:密码")
+ }
+ user := s[:idx]
+ pass := s[idx+1:]
+ if user == "" || pass == "" {
+ return "", "", fmt.Errorf("--auth 用户名和密码不能为空")
+ }
+ return user, pass, nil
+}
diff --git a/cmd/up.go b/cmd/up.go
index 366923c..89da5c7 100644
--- a/cmd/up.go
+++ b/cmd/up.go
@@ -2,8 +2,13 @@ package cmd
import (
"context"
+ "encoding/hex"
"fmt"
+ "strconv"
+ "strings"
+ "time"
+ "github.com/qingchencloud/cftunnel/internal/authproxy"
"github.com/qingchencloud/cftunnel/internal/cfapi"
"github.com/qingchencloud/cftunnel/internal/config"
"github.com/qingchencloud/cftunnel/internal/daemon"
@@ -26,6 +31,48 @@ var upCmd = &cobra.Command{
if cfg.Tunnel.Token == "" {
return fmt.Errorf("请先运行 cftunnel init && cftunnel create <名称>")
}
+
+ // 为有鉴权配置的路由启动代理
+ var proxies []*authproxy.Proxy
+ for i, r := range cfg.Routes {
+ if r.Auth == nil {
+ continue
+ }
+ sigKey, err := hex.DecodeString(r.Auth.SigningKey)
+ if err != nil {
+ return fmt.Errorf("路由 %s 的 signing_key 无效: %w", r.Name, err)
+ }
+ // 从 service URL 提取端口
+ port := extractPort(r.Service)
+ if port == "" {
+ return fmt.Errorf("路由 %s 的 service 格式无效: %s", r.Name, r.Service)
+ }
+ proxy, err := authproxy.New(authproxy.Config{
+ Username: r.Auth.Username,
+ Password: r.Auth.Password,
+ TargetPort: port,
+ SigningKey: sigKey,
+ CookieTTL: time.Duration(r.Auth.CookieTTLOrDefault()) * time.Second,
+ })
+ if err != nil {
+ return fmt.Errorf("路由 %s 启动鉴权代理失败: %w", r.Name, err)
+ }
+ if err := proxy.Start(); err != nil {
+ return fmt.Errorf("路由 %s 启动鉴权代理失败: %w", r.Name, err)
+ }
+ proxies = append(proxies, proxy)
+ proxyPort := strconv.Itoa(proxy.ListenPort())
+ fmt.Printf("鉴权代理已启动: %s → 127.0.0.1:%s → 127.0.0.1:%s\n", r.Hostname, proxyPort, port)
+ // 临时修改 service 指向代理端口(仅内存,不持久化)
+ cfg.Routes[i].Service = "http://localhost:" + proxyPort
+ }
+ // 确保退出时关闭所有代理
+ defer func() {
+ for _, p := range proxies {
+ p.Stop()
+ }
+ }()
+
// 启动前同步 ingress 配置到远端,确保本地与远端一致
if len(cfg.Routes) > 0 {
client := cfapi.New(cfg.Auth.APIToken, cfg.Auth.AccountID)
@@ -47,3 +94,12 @@ var upCmd = &cobra.Command{
return daemon.Start(cfg.Tunnel.Token)
},
}
+
+// extractPort 从 "http://localhost:3000" 格式中提取端口号
+func extractPort(service string) string {
+ idx := strings.LastIndex(service, ":")
+ if idx < 0 {
+ return ""
+ }
+ return service[idx+1:]
+}
diff --git a/docs/index.html b/docs/index.html
index aa27055..ed5509b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -317,6 +317,11 @@ footer{border-top:1px solid var(--border);padding:48px 0;position:relative;z-ind
安全免费
基于 Cloudflare 全球网络加密传输,无需公网 IP,完全免费使用
+
+
+
访问保护
+
--auth user:pass 一键启用密码保护,内置鉴权代理中间件,无需外部依赖,支持 WebSocket 透传
+
系统服务
@@ -410,6 +415,7 @@ footer{border-top:1px solid var(--border);padding:48px 0;position:relative;z-ind
| 清理资源 | 手动删隧道 + 删 DNS + 删配置 | cftunnel destroy |
| 自更新 | 无 | cftunnel update |
| 便携模式 | 无 | portable 文件启用 |
+
| 密码保护 | 需配合 Cloudflare Access | --auth user:pass 内置鉴权 |
| 下载加速 | 仅 GitHub 原始地址 | 多镜像源自动轮询 |
| Windows 检测 | 无 | 自动检测 Win10+ 版本 |
@@ -428,9 +434,11 @@ footer{border-top:1px solid var(--border);padding:48px 0;position:relative;z-ind
quick <端口>
免域名模式,一键穿透,Ctrl+C 退出自动清理
+
quick <端口> --auth user:pass
免域名模式 + 密码保护,访问需登录验证
init
初始化配置,输入 API Token 和 Account ID
create <名称>
创建 Cloudflare Tunnel
add <名> <端口>
添加路由,自动创建 DNS CNAME 记录
+
add <名> <端口> --auth user:pass
添加路由 + 密码保护
remove <名称>
删除路由,同步清理 DNS 记录
up / down
启动 / 停止 cloudflared 守护进程
diff --git a/internal/authproxy/login.html b/internal/authproxy/login.html
new file mode 100644
index 0000000..856da1e
--- /dev/null
+++ b/internal/authproxy/login.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+
cftunnel - 访问验证
+
+
+
+
+
cftunnel
+
此服务需要身份验证
+
用户名或密码错误
+
+
+
+
+
+
diff --git a/internal/authproxy/port.go b/internal/authproxy/port.go
new file mode 100644
index 0000000..25f6c57
--- /dev/null
+++ b/internal/authproxy/port.go
@@ -0,0 +1,19 @@
+package authproxy
+
+import (
+ "fmt"
+ "net"
+ "strconv"
+)
+
+// FindAvailableListener 从 startPort 开始探测,返回第一个可用的 listener
+// 直接返回 listener 而非端口号,避免 TOCTOU 竞态
+func FindAvailableListener(startPort int) (net.Listener, error) {
+ for p := startPort; p < startPort+100; p++ {
+ ln, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
+ if err == nil {
+ return ln, nil
+ }
+ }
+ return nil, fmt.Errorf("在 %d-%d 范围内未找到可用端口", startPort, startPort+99)
+}
diff --git a/internal/authproxy/proxy.go b/internal/authproxy/proxy.go
new file mode 100644
index 0000000..7c7a172
--- /dev/null
+++ b/internal/authproxy/proxy.go
@@ -0,0 +1,189 @@
+package authproxy
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ _ "embed"
+ "encoding/hex"
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+//go:embed login.html
+var loginHTML []byte
+
+const cookieName = "__cftunnel_auth"
+const loginPath = "/___auth/login"
+
+// RandomKey 生成 32 字节随机签名密钥
+func RandomKey() []byte {
+ key := make([]byte, 32)
+ rand.Read(key)
+ return key
+}
+
+// Config 鉴权代理配置
+type Config struct {
+ Username string
+ Password string
+ TargetPort string
+ SigningKey []byte
+ CookieTTL time.Duration
+}
+
+// Proxy 鉴权反向代理
+type Proxy struct {
+ cfg Config
+ listener net.Listener
+ server *http.Server
+ reverse *httputil.ReverseProxy
+}
+
+// New 创建鉴权代理实例,自动探测可用端口
+func New(cfg Config) (*Proxy, error) {
+ port, _ := strconv.Atoi(cfg.TargetPort)
+ ln, err := FindAvailableListener(port + 1)
+ if err != nil {
+ return nil, err
+ }
+
+ target, _ := url.Parse("http://127.0.0.1:" + cfg.TargetPort)
+ rp := httputil.NewSingleHostReverseProxy(target)
+
+ if cfg.CookieTTL == 0 {
+ cfg.CookieTTL = 24 * time.Hour
+ }
+
+ p := &Proxy{
+ cfg: cfg,
+ listener: ln,
+ reverse: rp,
+ }
+ p.server = &http.Server{Handler: p}
+ return p, nil
+}
+
+// ListenPort 返回代理实际监听的端口
+func (p *Proxy) ListenPort() int {
+ return p.listener.Addr().(*net.TCPAddr).Port
+}
+
+// Start 非阻塞启动代理
+func (p *Proxy) Start() error {
+ go p.server.Serve(p.listener)
+ return nil
+}
+
+// Stop 优雅关闭代理
+func (p *Proxy) Stop() error {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ return p.server.Shutdown(ctx)
+}
+
+// ServeHTTP 核心路由逻辑
+func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // WebSocket 升级请求直接透传
+ if isWebSocket(r) {
+ p.reverse.ServeHTTP(w, r)
+ return
+ }
+
+ // 登录表单提交
+ if r.Method == http.MethodPost && r.URL.Path == loginPath {
+ p.handleLogin(w, r)
+ return
+ }
+
+ // 检查 Cookie 鉴权
+ if p.checkAuth(r) {
+ p.reverse.ServeHTTP(w, r)
+ return
+ }
+
+ // 未认证,返回登录页
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ w.Write(loginHTML)
+}
+
+// handleLogin 处理登录表单提交
+func (p *Proxy) handleLogin(w http.ResponseWriter, r *http.Request) {
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ if username != p.cfg.Username || password != p.cfg.Password {
+ http.Redirect(w, r, "/?error=1", http.StatusSeeOther)
+ return
+ }
+
+ // 签发 Cookie
+ expiry := time.Now().Add(p.cfg.CookieTTL).Unix()
+ payload := fmt.Sprintf("%s:%x", username, expiry)
+ sig := signPayload(p.cfg.SigningKey, payload)
+ value := payload + "." + sig
+
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: value,
+ Path: "/",
+ MaxAge: int(p.cfg.CookieTTL.Seconds()),
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// checkAuth 校验请求中的鉴权 Cookie
+func (p *Proxy) checkAuth(r *http.Request) bool {
+ cookie, err := r.Cookie(cookieName)
+ if err != nil {
+ return false
+ }
+
+ // 格式:username:expiry_hex.hmac_hex
+ dotIdx := strings.LastIndex(cookie.Value, ".")
+ if dotIdx < 0 {
+ return false
+ }
+ payload := cookie.Value[:dotIdx]
+ sig := cookie.Value[dotIdx+1:]
+
+ // 验证签名
+ if signPayload(p.cfg.SigningKey, payload) != sig {
+ return false
+ }
+
+ // 验证过期时间
+ colonIdx := strings.LastIndex(payload, ":")
+ if colonIdx < 0 {
+ return false
+ }
+ expiryHex := payload[colonIdx+1:]
+ expiry, err := strconv.ParseInt(expiryHex, 16, 64)
+ if err != nil {
+ return false
+ }
+ return time.Now().Unix() < expiry
+}
+
+// signPayload 使用 HMAC-SHA256 签名
+func signPayload(key []byte, payload string) string {
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(payload))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+// isWebSocket 检测是否为 WebSocket 升级请求
+func isWebSocket(r *http.Request) bool {
+ return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index a5eeb2f..929bcb4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -29,11 +29,28 @@ type TunnelConfig struct {
}
type RouteConfig struct {
- Name string `yaml:"name"`
- Hostname string `yaml:"hostname"`
- Service string `yaml:"service"`
- ZoneID string `yaml:"zone_id"`
- DNSRecordID string `yaml:"dns_record_id"`
+ Name string `yaml:"name"`
+ Hostname string `yaml:"hostname"`
+ Service string `yaml:"service"`
+ ZoneID string `yaml:"zone_id"`
+ DNSRecordID string `yaml:"dns_record_id"`
+ Auth *AuthProxy `yaml:"auth,omitempty"`
+}
+
+// AuthProxy 鉴权代理配置
+type AuthProxy struct {
+ Username string `yaml:"username"`
+ Password string `yaml:"password"`
+ SigningKey string `yaml:"signing_key,omitempty"`
+ CookieTTL int `yaml:"cookie_ttl,omitempty"` // 秒,默认 86400
+}
+
+// CookieTTLOrDefault 返回 Cookie 有效期(秒),默认 86400
+func (a *AuthProxy) CookieTTLOrDefault() int {
+ if a.CookieTTL > 0 {
+ return a.CookieTTL
+ }
+ return 86400
}
type CloudflaredConfig struct {
diff --git a/internal/daemon/quick.go b/internal/daemon/quick.go
index 20b99eb..4f9783b 100644
--- a/internal/daemon/quick.go
+++ b/internal/daemon/quick.go
@@ -8,6 +8,9 @@ import (
"os/exec"
"os/signal"
"strings"
+ "time"
+
+ "github.com/qingchencloud/cftunnel/internal/authproxy"
)
// StartQuick 启动免域名模式(前台运行,Ctrl+C 退出)
@@ -78,3 +81,65 @@ func extractURL(line string) string {
}
return ""
}
+
+// StartQuickWithAuth 启动带鉴权代理的免域名模式
+func StartQuickWithAuth(port, username, password string) error {
+ binPath, err := EnsureCloudflared()
+ if err != nil {
+ return err
+ }
+ if Running() {
+ return fmt.Errorf("cloudflared 已在运行,请先执行 cftunnel down")
+ }
+
+ // 启动鉴权代理
+ proxy, err := authproxy.New(authproxy.Config{
+ Username: username,
+ Password: password,
+ TargetPort: port,
+ SigningKey: authproxy.RandomKey(),
+ CookieTTL: 24 * time.Hour,
+ })
+ if err != nil {
+ return fmt.Errorf("启动鉴权代理失败: %w", err)
+ }
+ if err := proxy.Start(); err != nil {
+ return fmt.Errorf("启动鉴权代理失败: %w", err)
+ }
+ defer proxy.Stop()
+
+ proxyPort := fmt.Sprintf("%d", proxy.ListenPort())
+ fmt.Printf("鉴权代理已启动 127.0.0.1:%s → 127.0.0.1:%s\n", proxyPort, port)
+
+ // cloudflared 指向代理端口
+ cmd := exec.Command(binPath, "tunnel", "--url", "http://localhost:"+proxyPort)
+
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return err
+ }
+ cmd.Stdout = os.Stdout
+
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("启动 cloudflared 失败: %w", err)
+ }
+
+ go scanForURL(stderr)
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt)
+
+ done := make(chan error, 1)
+ go func() { done <- cmd.Wait() }()
+
+ select {
+ case <-sig:
+ stopChildProcess(cmd)
+ <-done
+ case err := <-done:
+ if err != nil {
+ return fmt.Errorf("cloudflared 异常退出: %w", err)
+ }
+ }
+ return nil
+}