Skip to content

Commit 266b978

Browse files
committed
feat: 内置鉴权代理 --auth 密码保护
在 cloudflared 和用户服务之间插入鉴权反向代理中间件, 用户通过 --auth user:pass 即可启用密码保护。 - 新增 internal/authproxy 包(代理核心/端口探测/登录页) - quick 和 add 命令支持 --auth flag - up 命令启动前自动为有 Auth 的路由启动代理 - 登录页深色主题,底部链接官网 - 官网新增访问保护特性卡片/对比表/命令速查
1 parent 13fd736 commit 266b978

File tree

9 files changed

+484
-8
lines changed

9 files changed

+484
-8
lines changed

cmd/add.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,23 @@ package cmd
22

33
import (
44
"context"
5+
"encoding/hex"
56
"fmt"
67
"strings"
78

9+
"github.com/qingchencloud/cftunnel/internal/authproxy"
810
"github.com/qingchencloud/cftunnel/internal/cfapi"
911
"github.com/qingchencloud/cftunnel/internal/config"
1012
"github.com/spf13/cobra"
1113
)
1214

1315
var addDomain string
16+
var addAuth string
1417

1518
func init() {
1619
addCmd.Flags().StringVar(&addDomain, "domain", "", "完整域名 (如 webhook.example.com)")
1720
addCmd.MarkFlagRequired("domain")
21+
addCmd.Flags().StringVar(&addAuth, "auth", "", "启用密码保护 (格式: 用户名:密码)")
1822
rootCmd.AddCommand(addCmd)
1923
}
2024

@@ -77,14 +81,31 @@ var addCmd = &cobra.Command{
7781
return err
7882
}
7983

80-
// 保存路由
81-
cfg.Routes = append(cfg.Routes, config.RouteConfig{
84+
// 构建路由配置
85+
route := config.RouteConfig{
8286
Name: name,
8387
Hostname: addDomain,
8488
Service: service,
8589
ZoneID: zone.ID,
8690
DNSRecordID: recordID,
87-
})
91+
}
92+
93+
// 如果指定了 --auth,填充鉴权配置
94+
if addAuth != "" {
95+
user, pass, err := parseAuth(addAuth)
96+
if err != nil {
97+
return err
98+
}
99+
route.Auth = &config.AuthProxy{
100+
Username: user,
101+
Password: pass,
102+
SigningKey: hex.EncodeToString(authproxy.RandomKey()),
103+
}
104+
fmt.Printf("已启用密码保护: %s\n", addDomain)
105+
}
106+
107+
// 保存路由
108+
cfg.Routes = append(cfg.Routes, route)
88109
if err := cfg.Save(); err != nil {
89110
return err
90111
}

cmd/quick.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
package cmd
22

33
import (
4+
"fmt"
5+
"strings"
6+
47
"github.com/qingchencloud/cftunnel/internal/daemon"
58
"github.com/spf13/cobra"
69
)
710

11+
var quickAuth string
12+
813
func init() {
14+
quickCmd.Flags().StringVar(&quickAuth, "auth", "", "启用密码保护 (格式: 用户名:密码)")
915
rootCmd.AddCommand(quickCmd)
1016
}
1117

@@ -15,6 +21,27 @@ var quickCmd = &cobra.Command{
1521
Long: "无需 Cloudflare 账户、API Token 或域名,一条命令生成临时公网地址。\n适合临时分享、快速调试,Ctrl+C 退出后域名自动失效。",
1622
Args: cobra.ExactArgs(1),
1723
RunE: func(cmd *cobra.Command, args []string) error {
24+
if quickAuth != "" {
25+
user, pass, err := parseAuth(quickAuth)
26+
if err != nil {
27+
return err
28+
}
29+
return daemon.StartQuickWithAuth(args[0], user, pass)
30+
}
1831
return daemon.StartQuick(args[0])
1932
},
2033
}
34+
35+
// parseAuth 解析 "用户名:密码" 格式,密码部分允许包含冒号
36+
func parseAuth(s string) (string, string, error) {
37+
idx := strings.Index(s, ":")
38+
if idx < 0 {
39+
return "", "", fmt.Errorf("--auth 格式错误,应为 用户名:密码")
40+
}
41+
user := s[:idx]
42+
pass := s[idx+1:]
43+
if user == "" || pass == "" {
44+
return "", "", fmt.Errorf("--auth 用户名和密码不能为空")
45+
}
46+
return user, pass, nil
47+
}

cmd/up.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package cmd
22

33
import (
44
"context"
5+
"encoding/hex"
56
"fmt"
7+
"strconv"
8+
"strings"
9+
"time"
610

11+
"github.com/qingchencloud/cftunnel/internal/authproxy"
712
"github.com/qingchencloud/cftunnel/internal/cfapi"
813
"github.com/qingchencloud/cftunnel/internal/config"
914
"github.com/qingchencloud/cftunnel/internal/daemon"
@@ -26,6 +31,48 @@ var upCmd = &cobra.Command{
2631
if cfg.Tunnel.Token == "" {
2732
return fmt.Errorf("请先运行 cftunnel init && cftunnel create <名称>")
2833
}
34+
35+
// 为有鉴权配置的路由启动代理
36+
var proxies []*authproxy.Proxy
37+
for i, r := range cfg.Routes {
38+
if r.Auth == nil {
39+
continue
40+
}
41+
sigKey, err := hex.DecodeString(r.Auth.SigningKey)
42+
if err != nil {
43+
return fmt.Errorf("路由 %s 的 signing_key 无效: %w", r.Name, err)
44+
}
45+
// 从 service URL 提取端口
46+
port := extractPort(r.Service)
47+
if port == "" {
48+
return fmt.Errorf("路由 %s 的 service 格式无效: %s", r.Name, r.Service)
49+
}
50+
proxy, err := authproxy.New(authproxy.Config{
51+
Username: r.Auth.Username,
52+
Password: r.Auth.Password,
53+
TargetPort: port,
54+
SigningKey: sigKey,
55+
CookieTTL: time.Duration(r.Auth.CookieTTLOrDefault()) * time.Second,
56+
})
57+
if err != nil {
58+
return fmt.Errorf("路由 %s 启动鉴权代理失败: %w", r.Name, err)
59+
}
60+
if err := proxy.Start(); err != nil {
61+
return fmt.Errorf("路由 %s 启动鉴权代理失败: %w", r.Name, err)
62+
}
63+
proxies = append(proxies, proxy)
64+
proxyPort := strconv.Itoa(proxy.ListenPort())
65+
fmt.Printf("鉴权代理已启动: %s → 127.0.0.1:%s → 127.0.0.1:%s\n", r.Hostname, proxyPort, port)
66+
// 临时修改 service 指向代理端口(仅内存,不持久化)
67+
cfg.Routes[i].Service = "http://localhost:" + proxyPort
68+
}
69+
// 确保退出时关闭所有代理
70+
defer func() {
71+
for _, p := range proxies {
72+
p.Stop()
73+
}
74+
}()
75+
2976
// 启动前同步 ingress 配置到远端,确保本地与远端一致
3077
if len(cfg.Routes) > 0 {
3178
client := cfapi.New(cfg.Auth.APIToken, cfg.Auth.AccountID)
@@ -47,3 +94,12 @@ var upCmd = &cobra.Command{
4794
return daemon.Start(cfg.Tunnel.Token)
4895
},
4996
}
97+
98+
// extractPort 从 "http://localhost:3000" 格式中提取端口号
99+
func extractPort(service string) string {
100+
idx := strings.LastIndex(service, ":")
101+
if idx < 0 {
102+
return ""
103+
}
104+
return service[idx+1:]
105+
}

docs/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,11 @@ <h3>全平台覆盖</h3>
317317
<h3>安全免费</h3>
318318
<p>基于 Cloudflare 全球网络加密传输,无需公网 IP,完全免费使用</p>
319319
</div>
320+
<div class="feature-card reveal">
321+
<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>
322+
<h3>访问保护</h3>
323+
<p>--auth user:pass 一键启用密码保护,内置鉴权代理中间件,无需外部依赖,支持 WebSocket 透传</p>
324+
</div>
320325
<div class="feature-card reveal">
321326
<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>
322327
<h3>系统服务</h3>
@@ -410,6 +415,7 @@ <h2>cftunnel vs 原生 cloudflared</h2>
410415
<tr><td>清理资源</td><td>手动删隧道 + 删 DNS + 删配置</td><td>cftunnel destroy</td></tr>
411416
<tr><td>自更新</td><td></td><td>cftunnel update</td></tr>
412417
<tr><td>便携模式</td><td></td><td>portable 文件启用</td></tr>
418+
<tr><td>密码保护</td><td>需配合 Cloudflare Access</td><td>--auth user:pass 内置鉴权</td></tr>
413419
<tr><td>下载加速</td><td>仅 GitHub 原始地址</td><td>多镜像源自动轮询</td></tr>
414420
<tr><td>Windows 检测</td><td></td><td>自动检测 Win10+ 版本</td></tr>
415421
</tbody>
@@ -428,9 +434,11 @@ <h2>命令速查</h2>
428434
</div>
429435
<div class="cmd-grid">
430436
<div class="cmd-item reveal"><div class="cmd-code">quick &lt;端口&gt;</div><div class="cmd-desc">免域名模式,一键穿透,Ctrl+C 退出自动清理</div></div>
437+
<div class="cmd-item reveal"><div class="cmd-code">quick &lt;端口&gt; --auth user:pass</div><div class="cmd-desc">免域名模式 + 密码保护,访问需登录验证</div></div>
431438
<div class="cmd-item reveal"><div class="cmd-code">init</div><div class="cmd-desc">初始化配置,输入 API Token 和 Account ID</div></div>
432439
<div class="cmd-item reveal"><div class="cmd-code">create &lt;名称&gt;</div><div class="cmd-desc">创建 Cloudflare Tunnel</div></div>
433440
<div class="cmd-item reveal"><div class="cmd-code">add &lt;名&gt; &lt;端口&gt;</div><div class="cmd-desc">添加路由,自动创建 DNS CNAME 记录</div></div>
441+
<div class="cmd-item reveal"><div class="cmd-code">add &lt;名&gt; &lt;端口&gt; --auth user:pass</div><div class="cmd-desc">添加路由 + 密码保护</div></div>
434442
<div class="cmd-item reveal"><div class="cmd-code">remove &lt;名称&gt;</div><div class="cmd-desc">删除路由,同步清理 DNS 记录</div></div>
435443
<div class="cmd-item reveal"><div class="cmd-code">up / down</div><div class="cmd-desc">启动 / 停止 cloudflared 守护进程</div></div>
436444
<div class="cmd-item reveal"><div class="cmd-code">install</div><div class="cmd-desc">注册系统服务,开机自启</div></div>

internal/authproxy/login.html

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<!DOCTYPE html>
2+
<html lang="zh-CN">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>cftunnel - 访问验证</title>
7+
<style>
8+
*{margin:0;padding:0;box-sizing:border-box}
9+
body{
10+
min-height:100vh;display:flex;align-items:center;justify-content:center;
11+
background:#06060b;
12+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
13+
color:#e0e0e0;
14+
}
15+
.card{
16+
background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);
17+
border-radius:16px;padding:40px;width:380px;
18+
backdrop-filter:blur(20px);box-shadow:0 8px 32px rgba(0,0,0,.4);
19+
position:relative;overflow:hidden;
20+
}
21+
.card::before{
22+
content:'';position:absolute;top:0;left:0;right:0;height:1px;
23+
background:linear-gradient(90deg,transparent,rgba(255,255,255,.1),transparent);
24+
}
25+
.logo{text-align:center;margin-bottom:8px;font-size:22px;font-weight:800}
26+
.logo span{background:linear-gradient(135deg,#60a5fa,#22c55e);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
27+
.subtitle{text-align:center;color:#7a7a95;font-size:14px;margin-bottom:32px}
28+
.field{margin-bottom:16px}
29+
.field label{display:block;font-size:13px;color:#7a7a95;margin-bottom:6px;font-weight:500}
30+
.field input{
31+
width:100%;padding:11px 14px;
32+
background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);
33+
border-radius:10px;color:#fff;font-size:15px;
34+
outline:none;transition:border-color .2s;
35+
}
36+
.field input:focus{border-color:#3b82f6}
37+
.btn{
38+
width:100%;padding:12px;margin-top:8px;
39+
background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;border:none;
40+
border-radius:10px;font-size:15px;font-weight:600;
41+
cursor:pointer;transition:all .2s;
42+
}
43+
.btn:hover{box-shadow:0 4px 20px rgba(59,130,246,.3);transform:translateY(-1px)}
44+
.error{
45+
background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);
46+
color:#f87171;padding:10px 14px;border-radius:8px;font-size:13px;
47+
margin-bottom:16px;display:none;text-align:center;
48+
}
49+
.footer{text-align:center;margin-top:24px;font-size:12px;color:#50506a}
50+
</style>
51+
</head>
52+
<body>
53+
<div class="card">
54+
<div class="logo">cf<span>tunnel</span></div>
55+
<div class="subtitle">此服务需要身份验证</div>
56+
<div class="error" id="err">用户名或密码错误</div>
57+
<form method="POST" action="/___auth/login">
58+
<div class="field">
59+
<label for="u">用户名</label>
60+
<input type="text" id="u" name="username" autocomplete="username" required autofocus>
61+
</div>
62+
<div class="field">
63+
<label for="p">密码</label>
64+
<input type="password" id="p" name="password" autocomplete="current-password" required>
65+
</div>
66+
<button type="submit" class="btn">登 录</button>
67+
</form>
68+
<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>
69+
</div>
70+
<script>
71+
if(new URLSearchParams(location.search).has('error'))document.getElementById('err').style.display='block';
72+
</script>
73+
</body>
74+
</html>

internal/authproxy/port.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package authproxy
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"strconv"
7+
)
8+
9+
// FindAvailableListener 从 startPort 开始探测,返回第一个可用的 listener
10+
// 直接返回 listener 而非端口号,避免 TOCTOU 竞态
11+
func FindAvailableListener(startPort int) (net.Listener, error) {
12+
for p := startPort; p < startPort+100; p++ {
13+
ln, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
14+
if err == nil {
15+
return ln, nil
16+
}
17+
}
18+
return nil, fmt.Errorf("在 %d-%d 范围内未找到可用端口", startPort, startPort+99)
19+
}

0 commit comments

Comments
 (0)