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 守护进程
install
注册系统服务,开机自启
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 - 访问验证 + + + +
+ +
此服务需要身份验证
+
用户名或密码错误
+
+
+ + +
+
+ + +
+ +
+ +
+ + + 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 +}