mirror of
https://github.com/qingchencloud/cftunnel.git
synced 2026-08-28 10:03:59 +08:00
feat: 全面支持 Windows 平台 + 修复 macOS tgz 解压 bug
- goreleaser 添加 windows 构建(zip 格式) - cloudflared 下载支持 Windows + macOS tgz 解压 - 进程管理跨平台(process_unix/process_windows) - 信号处理跨平台(signal_unix/signal_windows) - Windows Service 实现(sc.exe) - selfupdate 支持 Windows zip 格式 - PowerShell 安装脚本 - README 添加 Windows 安装说明
This commit is contained in:
+4
-1
@@ -2,7 +2,7 @@ project_name: cftunnel
|
||||
|
||||
builds:
|
||||
- env: [CGO_ENABLED=0]
|
||||
goos: [darwin, linux]
|
||||
goos: [darwin, linux, windows]
|
||||
goarch: [amd64, arm64]
|
||||
ldflags:
|
||||
- -s -w -X github.com/qingchencloud/cftunnel/cmd.Version={{.Version}}
|
||||
@@ -10,6 +10,9 @@ builds:
|
||||
archives:
|
||||
- format: tar.gz
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
@@ -44,10 +44,10 @@ cftunnel 把 Cloudflare Tunnel 的繁琐流程封装成极简 CLI,**免费、
|
||||
- **免域名模式** — `cftunnel quick <端口>`,零配置生成 `*.trycloudflare.com` 临时公网地址
|
||||
- **极简操作** — `init` → `create` → `add` → `up`,4 步搞定自有域名穿透
|
||||
- **自动 DNS** — 添加路由时自动创建 CNAME 记录,删除时自动清理
|
||||
- **进程托管** — 自动下载 cloudflared,支持 macOS launchd / Linux systemd 开机自启
|
||||
- **进程托管** — 自动下载 cloudflared,支持 macOS launchd / Linux systemd / Windows Service 开机自启
|
||||
- **自动更新** — 内置版本检查和一键自更新
|
||||
- **AI 友好** — 内置 Claude Code / OpenClaw Skills,AI 助手可直接管理隧道
|
||||
- **跨平台** — 支持 macOS (Intel/Apple Silicon) + Linux (amd64/arm64)
|
||||
- **跨平台** — 支持 macOS (Intel/Apple Silicon) + Linux (amd64/arm64) + Windows (amd64/arm64)
|
||||
|
||||
<p align="right"><a href="#cftunnel">⬆ 回到顶部</a></p>
|
||||
|
||||
@@ -55,10 +55,18 @@ cftunnel 把 Cloudflare Tunnel 的繁琐流程封装成极简 CLI,**免费、
|
||||
|
||||
### 一键安装(推荐)
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/qingchencloud/cftunnel/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows(PowerShell):**
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/qingchencloud/cftunnel/main/install.ps1 | iex
|
||||
```
|
||||
|
||||
### 手动下载
|
||||
|
||||
从 [Releases](https://github.com/qingchencloud/cftunnel/releases) 下载对应平台的二进制文件:
|
||||
@@ -77,6 +85,8 @@ curl -fsSL https://github.com/qingchencloud/cftunnel/releases/latest/download/cf
|
||||
curl -fsSL https://github.com/qingchencloud/cftunnel/releases/latest/download/cftunnel_linux_arm64.tar.gz | tar xz -C /usr/local/bin/
|
||||
```
|
||||
|
||||
**Windows:** 从 [Releases](https://github.com/qingchencloud/cftunnel/releases) 下载 `cftunnel_windows_amd64.zip`,解压后将 `cftunnel.exe` 放到 PATH 目录中。
|
||||
|
||||
### 从源码构建
|
||||
|
||||
```bash
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# cftunnel Windows 安装脚本
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repo = "qingchencloud/cftunnel"
|
||||
$installDir = "$env:LOCALAPPDATA\cftunnel"
|
||||
|
||||
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "amd64" }
|
||||
$url = "https://github.com/$repo/releases/latest/download/cftunnel_windows_$arch.zip"
|
||||
|
||||
Write-Host "正在下载 cftunnel (windows/$arch)..."
|
||||
$tmp = New-TemporaryFile | Rename-Item -NewName { $_.Name + ".zip" } -PassThru
|
||||
Invoke-WebRequest -Uri $url -OutFile $tmp.FullName
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
|
||||
Expand-Archive -Path $tmp.FullName -DestinationPath $installDir -Force
|
||||
Remove-Item $tmp.FullName
|
||||
|
||||
# 添加到用户 PATH
|
||||
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
if ($userPath -notlike "*$installDir*") {
|
||||
[Environment]::SetEnvironmentVariable("Path", "$userPath;$installDir", "User")
|
||||
Write-Host "已添加 $installDir 到 PATH(重启终端生效)"
|
||||
}
|
||||
|
||||
Write-Host "cftunnel 已安装到 $installDir\cftunnel.exe"
|
||||
Write-Host "运行 cftunnel quick <端口> 开始使用"
|
||||
@@ -1,6 +1,8 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -8,13 +10,18 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/qingchencloud/cftunnel/internal/config"
|
||||
)
|
||||
|
||||
// CloudflaredPath 返回 cloudflared 二进制路径
|
||||
func CloudflaredPath() string {
|
||||
return filepath.Join(config.Dir(), "bin", "cloudflared")
|
||||
name := "cloudflared"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "cloudflared.exe"
|
||||
}
|
||||
return filepath.Join(config.Dir(), "bin", name)
|
||||
}
|
||||
|
||||
// EnsureCloudflared 确保 cloudflared 已安装,未安装则自动下载
|
||||
@@ -49,6 +56,12 @@ func download(dest string) error {
|
||||
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// macOS 的 cloudflared 是 tgz 格式,需要解压
|
||||
if strings.HasSuffix(url, ".tgz") {
|
||||
return extractTgz(resp.Body, dest)
|
||||
}
|
||||
|
||||
// Linux/Windows 是裸二进制,直接写入
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -57,13 +70,44 @@ func download(dest string) error {
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dest, 0755); err != nil {
|
||||
return err
|
||||
if runtime.GOOS != "windows" {
|
||||
os.Chmod(dest, 0755)
|
||||
}
|
||||
fmt.Printf("cloudflared 已下载到 %s\n", dest)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTgz(r io.Reader, dest string) error {
|
||||
gr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
defer gr.Close()
|
||||
tr := tar.NewReader(gr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return fmt.Errorf("tgz 中未找到 cloudflared")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
if filepath.Base(hdr.Name) == "cloudflared" {
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Chmod(dest, 0755)
|
||||
fmt.Printf("cloudflared 已下载到 %s\n", dest)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadURL() (string, error) {
|
||||
const base = "https://github.com/cloudflare/cloudflared/releases/latest/download/"
|
||||
switch runtime.GOOS + "/" + runtime.GOARCH {
|
||||
@@ -75,6 +119,10 @@ func downloadURL() (string, error) {
|
||||
return base + "cloudflared-linux-amd64", nil
|
||||
case "linux/arm64":
|
||||
return base + "cloudflared-linux-arm64", nil
|
||||
case "windows/amd64":
|
||||
return base + "cloudflared-windows-amd64.exe", nil
|
||||
case "windows/arm64":
|
||||
return base + "cloudflared-windows-amd64.exe", nil
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
@@ -41,11 +41,7 @@ func Stop() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("未找到运行中的 cloudflared")
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := proc.Signal(os.Interrupt); err != nil {
|
||||
if err := processKill(pid); err != nil {
|
||||
return fmt.Errorf("停止 cloudflared 失败: %w", err)
|
||||
}
|
||||
os.Remove(pidFile)
|
||||
@@ -59,8 +55,7 @@ func Running() bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// 用 kill -0 检测进程是否存在
|
||||
return exec.Command("kill", "-0", strconv.Itoa(pid)).Run() == nil
|
||||
return processRunning(pid)
|
||||
}
|
||||
|
||||
// PID 返回当前运行的 PID
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// processRunning 检查进程是否存活(Unix: kill -0)
|
||||
func processRunning(pid int) bool {
|
||||
return exec.Command("kill", "-0", strconv.Itoa(pid)).Run() == nil
|
||||
}
|
||||
|
||||
// processKill 终止进程(Unix: SIGINT)
|
||||
func processKill(pid int) error {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return proc.Signal(os.Interrupt)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// processRunning 检查进程是否存活(Windows: tasklist)
|
||||
func processRunning(pid int) bool {
|
||||
out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/NH").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !strings.Contains(string(out), "No tasks")
|
||||
}
|
||||
|
||||
// processKill 终止进程(Windows: taskkill)
|
||||
func processKill(pid int) error {
|
||||
return exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F").Run()
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// StartQuick 启动免域名模式(前台运行,Ctrl+C 退出)
|
||||
@@ -39,14 +38,14 @@ func StartQuick(port string) error {
|
||||
|
||||
// 捕获 Ctrl+C 优雅退出
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case <-sig:
|
||||
cmd.Process.Signal(syscall.SIGINT)
|
||||
stopChildProcess(cmd)
|
||||
<-done
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// stopChildProcess 优雅终止子进程(Unix: SIGINT)
|
||||
func stopChildProcess(cmd *exec.Cmd) {
|
||||
cmd.Process.Signal(syscall.SIGINT)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// stopChildProcess 终止子进程(Windows: Kill)
|
||||
func stopChildProcess(cmd *exec.Cmd) {
|
||||
cmd.Process.Kill()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package selfupdate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -36,8 +37,12 @@ func LatestVersion() (string, error) {
|
||||
|
||||
// Update 下载最新版本替换自身
|
||||
func Update(version string) error {
|
||||
url := fmt.Sprintf("https://github.com/%s/releases/download/%s/cftunnel_%s_%s.tar.gz",
|
||||
repo, version, runtime.GOOS, runtime.GOARCH)
|
||||
ext := "tar.gz"
|
||||
if runtime.GOOS == "windows" {
|
||||
ext = "zip"
|
||||
}
|
||||
url := fmt.Sprintf("https://github.com/%s/releases/download/%s/cftunnel_%s_%s.%s",
|
||||
repo, version, runtime.GOOS, runtime.GOARCH, ext)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
@@ -48,45 +53,88 @@ func Update(version string) error {
|
||||
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解压 tar.gz 提取 cftunnel 二进制
|
||||
gr, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
tr := tar.NewReader(gr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return fmt.Errorf("tar.gz 中未找到 cftunnel 二进制")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
if hdr.Name == "cftunnel" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var binData io.Reader
|
||||
if runtime.GOOS == "windows" {
|
||||
binData, err = extractZip(resp.Body)
|
||||
} else {
|
||||
binData, err = extractTarGz(resp.Body)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Windows 不允许覆盖运行中的 exe,先 rename 旧文件
|
||||
tmp := exe + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
if _, err := io.Copy(f, binData); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
if err := os.Chmod(tmp, 0755); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
if runtime.GOOS != "windows" {
|
||||
os.Chmod(tmp, 0755)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
old := exe + ".old"
|
||||
os.Remove(old)
|
||||
os.Rename(exe, old)
|
||||
}
|
||||
return os.Rename(tmp, exe)
|
||||
}
|
||||
|
||||
func extractTarGz(r io.Reader) (io.Reader, error) {
|
||||
gr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
tr := tar.NewReader(gr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil, fmt.Errorf("tar.gz 中未找到 cftunnel")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
if hdr.Name == "cftunnel" {
|
||||
return tr, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractZip(r io.Reader) (io.Reader, error) {
|
||||
// zip 需要 ReaderAt,先写到临时文件
|
||||
tmp, err := os.CreateTemp("", "cftunnel-update-*.zip")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
defer tmp.Close()
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, _ := tmp.Stat()
|
||||
zr, err := zip.NewReader(tmp, info.Size())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解压 zip 失败: %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "cftunnel.exe" {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("zip 中未找到 cftunnel.exe")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Windows struct{}
|
||||
|
||||
const svcName = "cftunnel"
|
||||
|
||||
func (w *Windows) Install(binPath, token string) error {
|
||||
binArg := fmt.Sprintf(`%s tunnel run --token %s`, binPath, token)
|
||||
if err := exec.Command("sc", "create", svcName, "binPath=", binArg, "start=", "auto").Run(); err != nil {
|
||||
return fmt.Errorf("创建服务失败: %w", err)
|
||||
}
|
||||
return exec.Command("sc", "start", svcName).Run()
|
||||
}
|
||||
|
||||
func (w *Windows) Uninstall() error {
|
||||
exec.Command("sc", "stop", svcName).Run()
|
||||
return exec.Command("sc", "delete", svcName).Run()
|
||||
}
|
||||
|
||||
func (w *Windows) Running() bool {
|
||||
out, err := exec.Command("sc", "query", svcName).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), "RUNNING")
|
||||
}
|
||||
|
||||
func New() Service {
|
||||
return &Windows{}
|
||||
}
|
||||
Reference in New Issue
Block a user