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:
晴天
2026-02-23 01:20:18 +08:00
parent 48c80f64b8
commit a7e4cf9dff
12 changed files with 278 additions and 43 deletions
+4 -1
View File
@@ -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
+12 -2
View File
@@ -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 SkillsAI 助手可直接管理隧道
- **跨平台** — 支持 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
```
**WindowsPowerShell):**
```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
View File
@@ -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 <端口> 开始使用"
+51 -3
View File
@@ -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)
}
+2 -7
View File
@@ -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
+23
View File
@@ -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)
}
+23
View File
@@ -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()
}
+2 -3
View File
@@ -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 {
+13
View File
@@ -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)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build windows
package daemon
import "os/exec"
// stopChildProcess 终止子进程(Windows: Kill
func stopChildProcess(cmd *exec.Cmd) {
cmd.Process.Kill()
}
+75 -27
View File
@@ -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")
}
+38
View File
@@ -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{}
}