mirror of
https://github.com/qingchencloud/cftunnel.git
synced 2026-08-28 10:03:59 +08:00
feat: 内置鉴权代理 --auth 密码保护
在 cloudflared 和用户服务之间插入鉴权反向代理中间件, 用户通过 --auth user:pass 即可启用密码保护。 - 新增 internal/authproxy 包(代理核心/端口探测/登录页) - quick 和 add 命令支持 --auth flag - up 命令启动前自动为有 Auth 的路由启动代理 - 登录页深色主题,底部链接官网 - 官网新增访问保护特性卡片/对比表/命令速查
This commit is contained in:
+24
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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 <端口></div><div class="cmd-desc">免域名模式,一键穿透,Ctrl+C 退出自动清理</div></div>
|
||||
<div class="cmd-item reveal"><div class="cmd-code">quick <端口> --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 <名称></div><div class="cmd-desc">创建 Cloudflare Tunnel</div></div>
|
||||
<div class="cmd-item reveal"><div class="cmd-code">add <名> <端口></div><div class="cmd-desc">添加路由,自动创建 DNS CNAME 记录</div></div>
|
||||
<div class="cmd-item reveal"><div class="cmd-code">add <名> <端口> --auth user:pass</div><div class="cmd-desc">添加路由 + 密码保护</div></div>
|
||||
<div class="cmd-item reveal"><div class="cmd-code">remove <名称></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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user