Skip to content

Commit a7e4cf9

Browse files
committed
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 安装说明
1 parent 48c80f6 commit a7e4cf9

12 files changed

Lines changed: 275 additions & 40 deletions

File tree

.goreleaser.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ project_name: cftunnel
22

33
builds:
44
- env: [CGO_ENABLED=0]
5-
goos: [darwin, linux]
5+
goos: [darwin, linux, windows]
66
goarch: [amd64, arm64]
77
ldflags:
88
- -s -w -X github.com/qingchencloud/cftunnel/cmd.Version={{.Version}}
99

1010
archives:
1111
- format: tar.gz
1212
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
13+
format_overrides:
14+
- goos: windows
15+
format: zip
1316

1417
checksum:
1518
name_template: checksums.txt

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,29 @@ cftunnel 把 Cloudflare Tunnel 的繁琐流程封装成极简 CLI,**免费、
4444
- **免域名模式**`cftunnel quick <端口>`,零配置生成 `*.trycloudflare.com` 临时公网地址
4545
- **极简操作**`init``create``add``up`,4 步搞定自有域名穿透
4646
- **自动 DNS** — 添加路由时自动创建 CNAME 记录,删除时自动清理
47-
- **进程托管** — 自动下载 cloudflared,支持 macOS launchd / Linux systemd 开机自启
47+
- **进程托管** — 自动下载 cloudflared,支持 macOS launchd / Linux systemd / Windows Service 开机自启
4848
- **自动更新** — 内置版本检查和一键自更新
4949
- **AI 友好** — 内置 Claude Code / OpenClaw Skills,AI 助手可直接管理隧道
50-
- **跨平台** — 支持 macOS (Intel/Apple Silicon) + Linux (amd64/arm64)
50+
- **跨平台** — 支持 macOS (Intel/Apple Silicon) + Linux (amd64/arm64) + Windows (amd64/arm64)
5151

5252
<p align="right"><a href="#cftunnel">⬆ 回到顶部</a></p>
5353

5454
<h2 id="install">安装</h2>
5555

5656
### 一键安装(推荐)
5757

58+
**macOS / Linux:**
59+
5860
```bash
5961
curl -fsSL https://raw.githubusercontent.com/qingchencloud/cftunnel/main/install.sh | bash
6062
```
6163

64+
**Windows(PowerShell):**
65+
66+
```powershell
67+
irm https://raw.githubusercontent.com/qingchencloud/cftunnel/main/install.ps1 | iex
68+
```
69+
6270
### 手动下载
6371

6472
[Releases](https://github.com/qingchencloud/cftunnel/releases) 下载对应平台的二进制文件:
@@ -77,6 +85,8 @@ curl -fsSL https://github.com/qingchencloud/cftunnel/releases/latest/download/cf
7785
curl -fsSL https://github.com/qingchencloud/cftunnel/releases/latest/download/cftunnel_linux_arm64.tar.gz | tar xz -C /usr/local/bin/
7886
```
7987

88+
**Windows:**[Releases](https://github.com/qingchencloud/cftunnel/releases) 下载 `cftunnel_windows_amd64.zip`,解压后将 `cftunnel.exe` 放到 PATH 目录中。
89+
8090
### 从源码构建
8191

8292
```bash

install.ps1

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# cftunnel Windows 安装脚本
2+
$ErrorActionPreference = "Stop"
3+
$repo = "qingchencloud/cftunnel"
4+
$installDir = "$env:LOCALAPPDATA\cftunnel"
5+
6+
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "amd64" }
7+
$url = "https://github.com/$repo/releases/latest/download/cftunnel_windows_$arch.zip"
8+
9+
Write-Host "正在下载 cftunnel (windows/$arch)..."
10+
$tmp = New-TemporaryFile | Rename-Item -NewName { $_.Name + ".zip" } -PassThru
11+
Invoke-WebRequest -Uri $url -OutFile $tmp.FullName
12+
13+
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
14+
Expand-Archive -Path $tmp.FullName -DestinationPath $installDir -Force
15+
Remove-Item $tmp.FullName
16+
17+
# 添加到用户 PATH
18+
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
19+
if ($userPath -notlike "*$installDir*") {
20+
[Environment]::SetEnvironmentVariable("Path", "$userPath;$installDir", "User")
21+
Write-Host "已添加 $installDir 到 PATH(重启终端生效)"
22+
}
23+
24+
Write-Host "cftunnel 已安装到 $installDir\cftunnel.exe"
25+
Write-Host "运行 cftunnel quick <端口> 开始使用"

internal/daemon/download.go

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
package daemon
22

33
import (
4+
"archive/tar"
5+
"compress/gzip"
46
"fmt"
57
"io"
68
"net/http"
79
"os"
810
"os/exec"
911
"path/filepath"
1012
"runtime"
13+
"strings"
1114

1215
"github.com/qingchencloud/cftunnel/internal/config"
1316
)
1417

1518
// CloudflaredPath 返回 cloudflared 二进制路径
1619
func CloudflaredPath() string {
17-
return filepath.Join(config.Dir(), "bin", "cloudflared")
20+
name := "cloudflared"
21+
if runtime.GOOS == "windows" {
22+
name = "cloudflared.exe"
23+
}
24+
return filepath.Join(config.Dir(), "bin", name)
1825
}
1926

2027
// EnsureCloudflared 确保 cloudflared 已安装,未安装则自动下载
@@ -49,6 +56,12 @@ func download(dest string) error {
4956
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
5057
}
5158

59+
// macOS 的 cloudflared 是 tgz 格式,需要解压
60+
if strings.HasSuffix(url, ".tgz") {
61+
return extractTgz(resp.Body, dest)
62+
}
63+
64+
// Linux/Windows 是裸二进制,直接写入
5265
f, err := os.Create(dest)
5366
if err != nil {
5467
return err
@@ -57,13 +70,44 @@ func download(dest string) error {
5770
if _, err := io.Copy(f, resp.Body); err != nil {
5871
return err
5972
}
60-
if err := os.Chmod(dest, 0755); err != nil {
61-
return err
73+
if runtime.GOOS != "windows" {
74+
os.Chmod(dest, 0755)
6275
}
6376
fmt.Printf("cloudflared 已下载到 %s\n", dest)
6477
return nil
6578
}
6679

80+
func extractTgz(r io.Reader, dest string) error {
81+
gr, err := gzip.NewReader(r)
82+
if err != nil {
83+
return fmt.Errorf("解压失败: %w", err)
84+
}
85+
defer gr.Close()
86+
tr := tar.NewReader(gr)
87+
for {
88+
hdr, err := tr.Next()
89+
if err == io.EOF {
90+
return fmt.Errorf("tgz 中未找到 cloudflared")
91+
}
92+
if err != nil {
93+
return fmt.Errorf("解压失败: %w", err)
94+
}
95+
if filepath.Base(hdr.Name) == "cloudflared" {
96+
f, err := os.Create(dest)
97+
if err != nil {
98+
return err
99+
}
100+
defer f.Close()
101+
if _, err := io.Copy(f, tr); err != nil {
102+
return err
103+
}
104+
os.Chmod(dest, 0755)
105+
fmt.Printf("cloudflared 已下载到 %s\n", dest)
106+
return nil
107+
}
108+
}
109+
}
110+
67111
func downloadURL() (string, error) {
68112
const base = "https://github.com/cloudflare/cloudflared/releases/latest/download/"
69113
switch runtime.GOOS + "/" + runtime.GOARCH {
@@ -75,6 +119,10 @@ func downloadURL() (string, error) {
75119
return base + "cloudflared-linux-amd64", nil
76120
case "linux/arm64":
77121
return base + "cloudflared-linux-arm64", nil
122+
case "windows/amd64":
123+
return base + "cloudflared-windows-amd64.exe", nil
124+
case "windows/arm64":
125+
return base + "cloudflared-windows-amd64.exe", nil
78126
default:
79127
return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH)
80128
}

internal/daemon/manager.go

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,7 @@ func Stop() error {
4141
if err != nil {
4242
return fmt.Errorf("未找到运行中的 cloudflared")
4343
}
44-
proc, err := os.FindProcess(pid)
45-
if err != nil {
46-
return err
47-
}
48-
if err := proc.Signal(os.Interrupt); err != nil {
44+
if err := processKill(pid); err != nil {
4945
return fmt.Errorf("停止 cloudflared 失败: %w", err)
5046
}
5147
os.Remove(pidFile)
@@ -59,8 +55,7 @@ func Running() bool {
5955
if err != nil {
6056
return false
6157
}
62-
// 用 kill -0 检测进程是否存在
63-
return exec.Command("kill", "-0", strconv.Itoa(pid)).Run() == nil
58+
return processRunning(pid)
6459
}
6560

6661
// PID 返回当前运行的 PID

internal/daemon/process_unix.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//go:build !windows
2+
3+
package daemon
4+
5+
import (
6+
"os"
7+
"os/exec"
8+
"strconv"
9+
)
10+
11+
// processRunning 检查进程是否存活(Unix: kill -0)
12+
func processRunning(pid int) bool {
13+
return exec.Command("kill", "-0", strconv.Itoa(pid)).Run() == nil
14+
}
15+
16+
// processKill 终止进程(Unix: SIGINT)
17+
func processKill(pid int) error {
18+
proc, err := os.FindProcess(pid)
19+
if err != nil {
20+
return err
21+
}
22+
return proc.Signal(os.Interrupt)
23+
}

internal/daemon/process_windows.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//go:build windows
2+
3+
package daemon
4+
5+
import (
6+
"os/exec"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
// processRunning 检查进程是否存活(Windows: tasklist)
12+
func processRunning(pid int) bool {
13+
out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/NH").Output()
14+
if err != nil {
15+
return false
16+
}
17+
return !strings.Contains(string(out), "No tasks")
18+
}
19+
20+
// processKill 终止进程(Windows: taskkill)
21+
func processKill(pid int) error {
22+
return exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F").Run()
23+
}

internal/daemon/quick.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"os/exec"
99
"os/signal"
1010
"strings"
11-
"syscall"
1211
)
1312

1413
// StartQuick 启动免域名模式(前台运行,Ctrl+C 退出)
@@ -39,14 +38,14 @@ func StartQuick(port string) error {
3938

4039
// 捕获 Ctrl+C 优雅退出
4140
sig := make(chan os.Signal, 1)
42-
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
41+
signal.Notify(sig, os.Interrupt)
4342

4443
done := make(chan error, 1)
4544
go func() { done <- cmd.Wait() }()
4645

4746
select {
4847
case <-sig:
49-
cmd.Process.Signal(syscall.SIGINT)
48+
stopChildProcess(cmd)
5049
<-done
5150
case err := <-done:
5251
if err != nil {

internal/daemon/signal_unix.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//go:build !windows
2+
3+
package daemon
4+
5+
import (
6+
"os/exec"
7+
"syscall"
8+
)
9+
10+
// stopChildProcess 优雅终止子进程(Unix: SIGINT)
11+
func stopChildProcess(cmd *exec.Cmd) {
12+
cmd.Process.Signal(syscall.SIGINT)
13+
}

internal/daemon/signal_windows.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build windows
2+
3+
package daemon
4+
5+
import "os/exec"
6+
7+
// stopChildProcess 终止子进程(Windows: Kill)
8+
func stopChildProcess(cmd *exec.Cmd) {
9+
cmd.Process.Kill()
10+
}

0 commit comments

Comments
 (0)