feat: 内置鉴权代理 --auth 密码保护

在 cloudflared 和用户服务之间插入鉴权反向代理中间件,
用户通过 --auth user:pass 即可启用密码保护。

- 新增 internal/authproxy 包(代理核心/端口探测/登录页)
- quick 和 add 命令支持 --auth flag
- up 命令启动前自动为有 Auth 的路由启动代理
- 登录页深色主题,底部链接官网
- 官网新增访问保护特性卡片/对比表/命令速查
This commit is contained in:
晴天
2026-02-25 03:31:23 +08:00
parent 13fd73617c
commit 266b978371
9 changed files with 484 additions and 8 deletions
+24 -3
View File
@@ -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
}
+27
View File
@@ -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
}
+56
View File
@@ -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:]
}
+8
View File
@@ -317,6 +317,11 @@ footer{border-top:1px solid var(--border);padding:48px 0;position:relative;z-ind
<h3>安全免费</h3>
<p>基于 Cloudflare 全球网络加密传输,无需公网 IP,完全免费使用</p>
</div>
<div class="feature-card reveal">
<div class="feature-icon purple"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>
<h3>访问保护</h3>
<p>--auth user:pass 一键启用密码保护,内置鉴权代理中间件,无需外部依赖,支持 WebSocket 透传</p>
</div>
<div class="feature-card reveal">
<div class="feature-icon blue"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div>
<h3>系统服务</h3>
@@ -410,6 +415,7 @@ footer{border-top:1px solid var(--border);padding:48px 0;position:relative;z-ind
<tr><td>清理资源</td><td>手动删隧道 + 删 DNS + 删配置</td><td>cftunnel destroy</td></tr>
<tr><td>自更新</td><td></td><td>cftunnel update</td></tr>
<tr><td>便携模式</td><td></td><td>portable 文件启用</td></tr>
<tr><td>密码保护</td><td>需配合 Cloudflare Access</td><td>--auth user:pass 内置鉴权</td></tr>
<tr><td>下载加速</td><td>仅 GitHub 原始地址</td><td>多镜像源自动轮询</td></tr>
<tr><td>Windows 检测</td><td></td><td>自动检测 Win10+ 版本</td></tr>
</tbody>
@@ -428,9 +434,11 @@ footer{border-top:1px solid var(--border);padding:48px 0;position:relative;z-ind
</div>
<div class="cmd-grid">
<div class="cmd-item reveal"><div class="cmd-code">quick &lt;端口&gt;</div><div class="cmd-desc">免域名模式,一键穿透,Ctrl+C 退出自动清理</div></div>
<div class="cmd-item reveal"><div class="cmd-code">quick &lt;端口&gt; --auth user:pass</div><div class="cmd-desc">免域名模式 + 密码保护,访问需登录验证</div></div>
<div class="cmd-item reveal"><div class="cmd-code">init</div><div class="cmd-desc">初始化配置,输入 API Token 和 Account ID</div></div>
<div class="cmd-item reveal"><div class="cmd-code">create &lt;名称&gt;</div><div class="cmd-desc">创建 Cloudflare Tunnel</div></div>
<div class="cmd-item reveal"><div class="cmd-code">add &lt;&gt; &lt;端口&gt;</div><div class="cmd-desc">添加路由,自动创建 DNS CNAME 记录</div></div>
<div class="cmd-item reveal"><div class="cmd-code">add &lt;&gt; &lt;端口&gt; --auth user:pass</div><div class="cmd-desc">添加路由 + 密码保护</div></div>
<div class="cmd-item reveal"><div class="cmd-code">remove &lt;名称&gt;</div><div class="cmd-desc">删除路由,同步清理 DNS 记录</div></div>
<div class="cmd-item reveal"><div class="cmd-code">up / down</div><div class="cmd-desc">启动 / 停止 cloudflared 守护进程</div></div>
<div class="cmd-item reveal"><div class="cmd-code">install</div><div class="cmd-desc">注册系统服务,开机自启</div></div>
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cftunnel - 访问验证</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{
min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#06060b;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
color:#e0e0e0;
}
.card{
background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);
border-radius:16px;padding:40px;width:380px;
backdrop-filter:blur(20px);box-shadow:0 8px 32px rgba(0,0,0,.4);
position:relative;overflow:hidden;
}
.card::before{
content:'';position:absolute;top:0;left:0;right:0;height:1px;
background:linear-gradient(90deg,transparent,rgba(255,255,255,.1),transparent);
}
.logo{text-align:center;margin-bottom:8px;font-size:22px;font-weight:800}
.logo span{background:linear-gradient(135deg,#60a5fa,#22c55e);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.subtitle{text-align:center;color:#7a7a95;font-size:14px;margin-bottom:32px}
.field{margin-bottom:16px}
.field label{display:block;font-size:13px;color:#7a7a95;margin-bottom:6px;font-weight:500}
.field input{
width:100%;padding:11px 14px;
background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);
border-radius:10px;color:#fff;font-size:15px;
outline:none;transition:border-color .2s;
}
.field input:focus{border-color:#3b82f6}
.btn{
width:100%;padding:12px;margin-top:8px;
background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;border:none;
border-radius:10px;font-size:15px;font-weight:600;
cursor:pointer;transition:all .2s;
}
.btn:hover{box-shadow:0 4px 20px rgba(59,130,246,.3);transform:translateY(-1px)}
.error{
background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);
color:#f87171;padding:10px 14px;border-radius:8px;font-size:13px;
margin-bottom:16px;display:none;text-align:center;
}
.footer{text-align:center;margin-top:24px;font-size:12px;color:#50506a}
</style>
</head>
<body>
<div class="card">
<div class="logo">cf<span>tunnel</span></div>
<div class="subtitle">此服务需要身份验证</div>
<div class="error" id="err">用户名或密码错误</div>
<form method="POST" action="/___auth/login">
<div class="field">
<label for="u">用户名</label>
<input type="text" id="u" name="username" autocomplete="username" required autofocus>
</div>
<div class="field">
<label for="p">密码</label>
<input type="password" id="p" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn">登 录</button>
</form>
<div class="footer">Powered by <a href="https://cftunnel.qt.cool" target="_blank" style="color:#7a7a95;text-decoration:underline;text-underline-offset:2px">cftunnel</a></div>
</div>
<script>
if(new URLSearchParams(location.search).has('error'))document.getElementById('err').style.display='block';
</script>
</body>
</html>
+19
View File
@@ -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)
}
+189
View File
@@ -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")
}
+17
View File
@@ -34,6 +34,23 @@ type RouteConfig struct {
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 {
+65
View File
@@ -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
}