
Cf Tunnel
- 11 installs
- 17 repo stars
- Updated July 25, 2026
- dwsy/agent
Exposes local services publicly via Cloudflare Tunnel with a unified CLI, port allocation, and status monitoring.
About
A skill providing a unified Bun CLI for managing Cloudflare Tunnel exposure, control panels, and port allocation, with automatic port-conflict fallback and aggregated status across local services and public URLs. A solo builder reaches for it to quickly and cleanly expose a local dev server to the internet for demos or webhooks.
- Unified Bun CLI for Cloudflare Tunnel share and panels
- Automatic port conflict resolution
- Aggregated tunnel and service status monitoring
Cf Tunnel by the numbers
- 11 all-time installs (skills.sh)
- Ranked #987 of 1,438 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dwsy/agent --skill cf-tunnelAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 17 |
| Last updated | July 25, 2026 |
| Repository | dwsy/agent ↗ |
What it does
Exposes local services publicly via Cloudflare Tunnel with a unified CLI, port allocation, and status monitoring.
Who is it for?
Builders exposing local dev servers for demos or webhooks
Skip if: Production-grade ingress or load balancing
Files
Cloudflare Tunnel 管理技能(统一版)
目标:把“临时暴露 + 管理面板 + 端口管理”统一到一个命令入口,避免端口混乱。
✅ 推荐入口(统一 Bun CLI)
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts命令总览
# 启动临时暴露(等同 share start)
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts start --dir ./demos/html
# 暴露已有端口
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts start --port 8766
# 暴露单文件
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts start --file ./demos/html/index.html --route /index.html
# 启动/停止/查看面板
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts panel start --port 8788 --host 127.0.0.1
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts panel status
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts panel stop
# 综合状态(share + panel)
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts status
# 停止(默认全停)
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts stop
# 只停 share
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts stop --share
# 只停 panel
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts stop --panel---
功能特性
- 统一入口:
cf.ts同时管理 share / panel - 端口自动避让:面板端口占用时自动切换到下一个可用端口
- 会话清晰:固定 tmux session 名
cf-share-webcf-share-tunnelcf-share-panel- 状态聚合:一个命令看完整状态(本地服务 / tunnel / panel / 公网 URL)
- 向后兼容:
share.ts、panel.ts仍可直接使用
---
底层命令(兼容保留)
# share 底层
bun ~/.pi/agent/skills/cf-tunnel/scripts/share.ts start --dir ./demos/html
bun ~/.pi/agent/skills/cf-tunnel/scripts/share.ts status
bun ~/.pi/agent/skills/cf-tunnel/scripts/share.ts stop
# panel 底层
bun ~/.pi/agent/skills/cf-tunnel/scripts/panel.ts --port 8788 --host 127.0.0.1建议优先使用 cf.ts,底层命令主要用于调试。---
API 与面板
启动面板后默认地址:
http://127.0.0.1:8788(若占用会自动避让)
API:
GET /api/status当前状态POST /api/start启动(body 支持port/dir/file/route)POST /api/stop停止GET /api/logs日志 tailGET /api/history历史记录POST /api/history/clear清空历史
---
依赖
# Cloudflared(如未安装)
wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared-linux-amd64.deb本地静态服务优先级:python3 > bunx > npx(无可用工具时直接失败)
---
故障排查
1. cf.ts status 先看三类会话是否在线 2. 如果 tunnel 无 URL:检查 ~/.cf-tunnel/share-tunnel.log 3. 如果 panel 打不开:cf.ts panel status 看实际端口(可能已自动避让) 4. 彻底重置:cf.ts stop --all 后重新 start
cf-tunnel README(当前复用逻辑)
本文档说明 cf-tunnel 技能当前版本的“复用”行为,避免误解为“永远复用已有进程”。
1. 统一入口与职责
- 统一入口:
scripts/cf.ts - 底层执行:
scripts/share.ts - 面板服务:
scripts/panel.ts
cf.ts 负责命令编排(start/stop/status + panel 子命令),真正启动/停止隧道与本地静态服务由 share.ts 完成。
2. 当前复用逻辑(重点)
2.1 Share 服务(web + tunnel)
固定 tmux session:
cf-share-webcf-share-tunnel
当执行 start 时: 1. share.ts 会先检查 session 是否存在; 2. 若存在,先 kill 再重启(不是保留原进程); 3. 新配置写入 ~/.cf-tunnel/share.json; 4. 隧道日志写入 ~/.cf-tunnel/share-tunnel.log。
结论:share 是“同名会话复用(覆盖式)”,不是“无中断复用”。
2.2 Panel 服务
固定 tmux session:
cf-share-panel
当执行 panel start 时: 1. 若 cf-share-panel 已运行,则直接返回“已运行”; 2. 若未运行,则启动新 panel; 3. 若目标端口占用,自动避让到下一个可用端口; 4. 运行信息保存到 ~/.cf-tunnel/panel.json。
结论:panel 是“运行态复用 + 端口自动避让”。
3. 状态与数据文件
目录:~/.cf-tunnel/
主要文件:
share.json:当前 share 配置(模式、端口、目录/文件)share-tunnel.log:cloudflared 输出(用于提取 trycloudflare URL)panel.json:panel 当前 host/port/sessionshare-history.json:面板记录的历史暴露记录ports.json:lib/port-manager.ts的多端口注册状态
4. 常用命令
# 启动 share(目录模式)
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts start --dir ./demos/html
# 查看综合状态
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts status
# 启动 panel
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts panel start --port 8788 --host 127.0.0.1
# 停止 share / panel / 全部
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts stop --share
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts stop --panel
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts stop --all5. 维护建议
如果后续要实现“真正复用已有 share(不重启)”,建议增加:
- 配置一致性判断(参数未变则直接返回)
--force-restart显式重启开关- 热切换/无中断更新策略
---
更新时间:2026-02-18
#!/usr/bin/env bun
// 统一入口:Cloudflare 临时暴露 + 管理面板
import { execSync } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import {
findAvailablePort,
isPortInUse,
tmuxSessionExists,
killTmuxSession,
execSilent,
printStatus,
} from "./lib/utils.ts";
const SCRIPT_DIR = import.meta.dir;
const SHARE_SCRIPT = path.join(SCRIPT_DIR, "share.ts");
const PANEL_SCRIPT = path.join(SCRIPT_DIR, "panel.ts");
const SHARE_DIR = path.join(os.homedir(), ".cf-tunnel");
const SHARE_FILE = path.join(SHARE_DIR, "share.json");
const SHARE_LOG = path.join(SHARE_DIR, "share-tunnel.log");
const PANEL_STATE = path.join(SHARE_DIR, "panel.json");
const PANEL_SESSION = "cf-share-panel";
const DEFAULT_PANEL_PORT = 8788;
const DEFAULT_PANEL_HOST = "127.0.0.1";
type PanelState = {
host: string;
port: number;
startedAt: string;
session: string;
};
type Parsed = {
cmd: string;
sub?: string;
flags: Record<string, string | boolean>;
rest: string[];
};
function usage() {
console.log(`
CF Tunnel 统一命令(Bun CLI)
用法:
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts <command> [options]
主命令:
start [share options] 启动临时暴露(等同 share start)
stop [--all|--share|--panel]
停止服务(默认 --all)
status 查看 share + panel 综合状态
子命令:
share start [--port N|--dir PATH|--file PATH] [--route /path]
share status
share stop
panel start [--port N|--host 127.0.0.1]
panel status
panel stop
示例:
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts start --dir ./demos/html
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts panel start --port 8788
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts status
`);
}
function ensureShareDir() {
if (!fs.existsSync(SHARE_DIR)) fs.mkdirSync(SHARE_DIR, { recursive: true });
}
function parseArgs(argv: string[]): Parsed {
const [cmd = "status", maybeSub, ...restRaw] = argv;
const hasSub = cmd === "share" || cmd === "panel";
const sub = hasSub ? maybeSub || "status" : undefined;
const rest = hasSub ? restRaw : [maybeSub, ...restRaw].filter(Boolean) as string[];
const flags: Record<string, string | boolean> = {};
const positional: string[] = [];
for (let i = 0; i < rest.length; i++) {
const arg = rest[i];
if (arg.startsWith("--")) {
const key = arg.slice(2);
const next = rest[i + 1];
if (!next || next.startsWith("--")) {
flags[key] = true;
} else {
flags[key] = next;
i++;
}
} else {
positional.push(arg);
}
}
return { cmd, sub, flags, rest: positional };
}
function loadPanelState(): PanelState | null {
try {
if (!fs.existsSync(PANEL_STATE)) return null;
return JSON.parse(fs.readFileSync(PANEL_STATE, "utf-8"));
} catch {
return null;
}
}
function savePanelState(state: PanelState) {
ensureShareDir();
fs.writeFileSync(PANEL_STATE, JSON.stringify(state, null, 2), "utf-8");
}
function removePanelState() {
try {
if (fs.existsSync(PANEL_STATE)) fs.unlinkSync(PANEL_STATE);
} catch {
// ignore
}
}
function getTryUrlFromLog(): string | null {
try {
if (!fs.existsSync(SHARE_LOG)) return null;
const content = fs.readFileSync(SHARE_LOG, "utf-8");
const m = content.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
return m?.[0] ?? null;
} catch {
return null;
}
}
function loadShareConfig(): any | null {
try {
if (!fs.existsSync(SHARE_FILE)) return null;
return JSON.parse(fs.readFileSync(SHARE_FILE, "utf-8"));
} catch {
return null;
}
}
function runShare(args: string[]): number {
try {
execSync(`bun "${SHARE_SCRIPT}" ${args.map((a) => `"${a.replaceAll('"', '\\"')}"`).join(" ")}`, {
stdio: "inherit",
});
return 0;
} catch {
return 1;
}
}
function startPanel(flags: Record<string, string | boolean>) {
if (tmuxSessionExists(PANEL_SESSION)) {
const current = loadPanelState();
printStatus("running", `Panel 已运行: http://${current?.host ?? DEFAULT_PANEL_HOST}:${current?.port ?? DEFAULT_PANEL_PORT}`);
return;
}
const host = String(flags.host || DEFAULT_PANEL_HOST);
const requestedPort = Number(flags.port || DEFAULT_PANEL_PORT);
let port = Number.isFinite(requestedPort) ? requestedPort : DEFAULT_PANEL_PORT;
if (isPortInUse(port)) {
const nextPort = findAvailablePort(port + 1, 50);
if (!nextPort) {
console.error(`❌ 面板端口 ${port} 被占用,且未找到可用端口`);
process.exit(1);
}
console.log(`⚠️ 端口 ${port} 被占用,自动改用 ${nextPort}`);
port = nextPort;
}
const cmd = `bun "${PANEL_SCRIPT}" --port ${port} --host ${host}`;
execSync(`tmux new-session -d -s ${PANEL_SESSION} "${cmd}"`, { stdio: "inherit" });
savePanelState({ host, port, startedAt: new Date().toISOString(), session: PANEL_SESSION });
printStatus("running", `Panel 已启动: http://${host}:${port}`);
}
function stopPanel() {
if (tmuxSessionExists(PANEL_SESSION)) {
killTmuxSession(PANEL_SESSION);
printStatus("stopped", "Panel 已停止");
} else {
printStatus("stopped", "Panel 未运行");
}
removePanelState();
}
function statusPanel() {
const running = tmuxSessionExists(PANEL_SESSION);
const state = loadPanelState();
printStatus(running ? "running" : "stopped", "Panel (cf-share-panel)");
if (running) {
const host = state?.host ?? DEFAULT_PANEL_HOST;
const port = state?.port ?? DEFAULT_PANEL_PORT;
console.log(` URL: http://${host}:${port}`);
}
}
function statusAll() {
console.log("\n📊 CF Tunnel 综合状态\n");
const shareCfg = loadShareConfig();
const shareUrl = getTryUrlFromLog();
const webRunning = tmuxSessionExists("cf-share-web");
const tunnelRunning = tmuxSessionExists("cf-share-tunnel");
printStatus(webRunning ? "running" : "stopped", "Share Web (cf-share-web)");
printStatus(tunnelRunning ? "running" : "stopped", "Share Tunnel (cf-share-tunnel)");
if (shareCfg) {
console.log(` 模式: ${shareCfg.mode}`);
console.log(` 端口: ${shareCfg.localPort}`);
}
console.log(` 公网: ${shareUrl ?? "(等待中或未启动)"}`);
if (shareCfg?.fileRoute && shareUrl) {
const route = String(shareCfg.fileRoute).startsWith("/")
? shareCfg.fileRoute
: `/${shareCfg.fileRoute}`;
console.log(` 文件: ${shareUrl}${route}`);
}
console.log("");
statusPanel();
console.log("");
}
function stopAll(flags: Record<string, string | boolean>) {
const stopShareOnly = Boolean(flags.share);
const stopPanelOnly = Boolean(flags.panel);
const stopAll = Boolean(flags.all) || (!stopShareOnly && !stopPanelOnly);
if (stopAll || stopShareOnly) {
runShare(["stop"]);
}
if (stopAll || stopPanelOnly) {
stopPanel();
}
}
(function main() {
const parsed = parseArgs(process.argv.slice(2));
if (["help", "--help", "-h"].includes(parsed.cmd)) {
usage();
return;
}
if (parsed.cmd === "start") {
process.exit(runShare(["start", ...process.argv.slice(3)]));
}
if (parsed.cmd === "stop") {
stopAll(parsed.flags);
return;
}
if (parsed.cmd === "status") {
statusAll();
return;
}
if (parsed.cmd === "share") {
const sub = parsed.sub || "status";
if (!["start", "stop", "status"].includes(sub)) {
usage();
process.exit(1);
}
process.exit(runShare([sub, ...process.argv.slice(4)]));
}
if (parsed.cmd === "panel") {
const sub = parsed.sub || "status";
if (sub === "start") {
startPanel(parsed.flags);
return;
}
if (sub === "stop") {
stopPanel();
return;
}
if (sub === "status") {
statusPanel();
return;
}
usage();
process.exit(1);
}
usage();
process.exit(1);
})();
#!/usr/bin/env bun
// 初始化 Cloudflare Tunnel 配置
import {
CONFIG_DIR,
CONFIG_FILE,
DEFAULT_CONFIG,
saveConfig,
exec,
execSilent,
ensureDir,
prompt,
} from "./lib/utils.ts";
console.log("🚀 Cloudflare Tunnel 初始化\n");
// 检查 cloudflared
console.log("📋 检查 cloudflared...");
const version = execSilent("cloudflared --version");
if (!version) {
console.error("❌ cloudflared 未安装");
console.log("💡 安装命令:");
console.log(" wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb");
console.log(" sudo dpkg -i cloudflared-linux-amd64.deb");
process.exit(1);
}
console.log(`✅ cloudflared ${version}\n`);
// 检查 tmux
console.log("📋 检查 tmux...");
const tmuxVersion = execSilent("tmux -V");
if (!tmuxVersion) {
console.error("❌ tmux 未安装");
console.log("💡 安装: sudo apt install tmux");
process.exit(1);
}
console.log(`✅ ${tmuxVersion}\n`);
// 登录 Cloudflare
console.log("🔐 登录 Cloudflare...");
console.log(" 将打开浏览器进行授权\n");
const loginResult = execSilent("cloudflared tunnel login");
if (!loginResult && !execSilent("ls ~/.cloudflared/*.json 2>/dev/null")) {
console.error("❌ 登录失败");
process.exit(1);
}
console.log("✅ 登录成功\n");
// 配置参数
const tunnelName = await prompt(`隧道名称 [${DEFAULT_CONFIG.tunnelName}]: `) || DEFAULT_CONFIG.tunnelName;
const hostname = await prompt("域名 (如 mysite.example.com): ");
if (!hostname) {
console.error("❌ 域名不能为空");
process.exit(1);
}
const portStr = await prompt("本地端口 (留空自动分配): ");
const localPort = parseInt(portStr, 10) || 0; // 0 表示自动分配
const webDir = await prompt(`网站目录 [${DEFAULT_CONFIG.webDir}]: `) || DEFAULT_CONFIG.webDir;
// 创建目录
ensureDir(webDir);
ensureDir(CONFIG_DIR);
// 创建隧道
console.log(`\n🔧 创建隧道 "${tunnelName}"...`);
const createOutput = exec(`cloudflared tunnel create ${tunnelName}`, true);
// 提取 tunnel ID
let tunnelId = "";
const match = createOutput.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
if (match) {
tunnelId = match[1];
} else {
// 尝试从 list 获取
const listOutput = execSilent("cloudflared tunnel list");
const lines = listOutput.split("\n");
for (const line of lines) {
if (line.includes(tunnelName)) {
const parts = line.trim().split(/\s+/);
if (parts[0] && parts[0].includes("-")) {
tunnelId = parts[0];
break;
}
}
}
}
if (!tunnelId) {
console.error("❌ 无法获取隧道 ID");
process.exit(1);
}
console.log(`✅ 隧道 ID: ${tunnelId}\n`);
// 创建配置文件(端口设为0表示自动分配)
const config = {
...DEFAULT_CONFIG,
tunnelId,
tunnelName,
hostname,
localPort: localPort || 0,
webDir,
};
// 创建 cloudflared config.yml
const cloudflaredDir = `${process.env.HOME}/.cloudflared`;
ensureDir(cloudflaredDir);
const configYml = `tunnel: ${tunnelId}
credentials-file: ${cloudflaredDir}/${tunnelId}.json
ingress:
- hostname: ${hostname}
service: http://localhost:${localPort}
- service: http_status:404
`;
import * as fs from "fs";
fs.writeFileSync(`${cloudflaredDir}/config-${tunnelName}.yml`, configYml);
// 添加 DNS 记录
console.log(`🌐 添加 DNS 记录: ${hostname}...`);
exec(`cloudflared tunnel route dns ${tunnelName} ${hostname}`);
console.log("✅ DNS 记录已添加\n");
// 保存配置
saveConfig(config);
console.log("✅ 初始化完成!\n");
console.log("配置信息:");
console.log(` 隧道: ${tunnelName} (${tunnelId})`);
console.log(` 域名: https://${hostname}`);
console.log(` 本地端口: ${localPort || "自动分配 (10000-65000)"}`);
console.log(` 网站目录: ${webDir}\n`);
console.log("启动命令:");
console.log(` bun ~/.pi/agent/skills/cf-tunnel/scripts/start.ts\n`);
/**
* 端口暴露管理器 - 内存 + 临时文件存储
* 管理通过 CF Tunnel 暴露的本地端口
*/
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { isPortInUse, tmuxSessionExists, killTmuxSession, execSilent, findAvailablePort } from "./utils.ts";
const SHARE_DIR = path.join(os.homedir(), ".cf-tunnel");
const PORT_STATE_FILE = path.join(SHARE_DIR, "ports.json");
const TUNNEL_LOG_DIR = path.join(SHARE_DIR, "logs");
export type PortEntry = {
id: string; // 唯一标识
name: string; // 自定义名称
localPort: number; // 本地端口
publicUrl?: string; // 公网 URL
pid?: number; // tunnel 进程 ID
sessionName: string; // tmux session 名
createdAt: string;
status: "running" | "stopped" | "error";
logFile: string;
metadata?: Record<string, any>;
};
// 内存存储
let _portRegistry = new Map<string, PortEntry>();
let _initialized = false;
function ensureDirs(): void {
if (!fs.existsSync(SHARE_DIR)) fs.mkdirSync(SHARE_DIR, { recursive: true });
if (!fs.existsSync(TUNNEL_LOG_DIR)) fs.mkdirSync(TUNNEL_LOG_DIR, { recursive: true });
}
function generateId(): string {
return `port-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
}
function generateSessionName(port: number): string {
return `cf-port-${port}`;
}
/**
* 从临时文件加载状态
*/
export function loadPortState(): void {
if (_initialized) return;
ensureDirs();
try {
if (fs.existsSync(PORT_STATE_FILE)) {
const data = JSON.parse(fs.readFileSync(PORT_STATE_FILE, "utf-8"));
if (Array.isArray(data)) {
for (const entry of data) {
// 验证 session 是否还在运行
const isRunning = tmuxSessionExists(entry.sessionName);
entry.status = isRunning ? "running" : "stopped";
// 如果运行中,尝试获取 URL
if (isRunning && fs.existsSync(entry.logFile)) {
entry.publicUrl = extractUrlFromLog(entry.logFile);
}
_portRegistry.set(entry.id, entry);
}
}
}
} catch (e) {
console.error("加载端口状态失败:", e);
}
_initialized = true;
}
/**
* 保存到临时文件
*/
export function savePortState(): void {
ensureDirs();
const data = Array.from(_portRegistry.values());
fs.writeFileSync(PORT_STATE_FILE, JSON.stringify(data, null, 2), "utf-8");
}
/**
* 从日志提取 URL
*/
function extractUrlFromLog(logFile: string): string | undefined {
try {
if (!fs.existsSync(logFile)) return undefined;
const content = fs.readFileSync(logFile, "utf-8");
const m = content.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
return m?.[0];
} catch {
return undefined;
}
}
/**
* 列出所有端口暴露
*/
export function listPorts(): PortEntry[] {
loadPortState();
// 刷新状态
for (const entry of _portRegistry.values()) {
entry.status = tmuxSessionExists(entry.sessionName) ? "running" : "stopped";
if (entry.status === "running" && !entry.publicUrl) {
entry.publicUrl = extractUrlFromLog(entry.logFile);
}
}
return Array.from(_portRegistry.values()).sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
/**
* 获取单个端口信息
*/
export function getPort(id: string): PortEntry | undefined {
loadPortState();
const entry = _portRegistry.get(id);
if (entry) {
entry.status = tmuxSessionExists(entry.sessionName) ? "running" : "stopped";
}
return entry;
}
/**
* 通过本地端口查找
*/
export function findByLocalPort(port: number): PortEntry | undefined {
loadPortState();
for (const entry of _portRegistry.values()) {
if (entry.localPort === port) {
entry.status = tmuxSessionExists(entry.sessionName) ? "running" : "stopped";
return entry;
}
}
return undefined;
}
/**
* 添加并暴露端口
*/
export async function addPort(
port: number,
name?: string,
metadata?: Record<string, any>
): Promise<PortEntry> {
loadPortState();
// 检查 cloudflared
if (!execSilent("which cloudflared")) {
throw new Error("未找到 cloudflared,请先安装");
}
// 检查端口是否监听
if (!isPortInUse(port)) {
throw new Error(`端口 ${port} 未监听,请先启动本地服务`);
}
// 检查是否已存在
const existing = findByLocalPort(port);
if (existing && existing.status === "running") {
throw new Error(`端口 ${port} 已在暴露中 (ID: ${existing.id})`);
}
const id = generateId();
const sessionName = generateSessionName(port);
const logFile = path.join(TUNNEL_LOG_DIR, `${sessionName}.log`);
// 清理旧 session
if (tmuxSessionExists(sessionName)) {
killTmuxSession(sessionName);
}
// 清理旧日志
if (fs.existsSync(logFile)) {
fs.unlinkSync(logFile);
}
// 启动 tunnel
const cmd = `cloudflared tunnel --no-autoupdate --url http://localhost:${port} > "${logFile}" 2>&1`;
execSync(`tmux new-session -d -s ${sessionName} "${cmd}"`, { stdio: "pipe" });
const entry: PortEntry = {
id,
name: name || `Port ${port}`,
localPort: port,
sessionName,
createdAt: new Date().toISOString(),
status: "running",
logFile,
metadata,
};
_portRegistry.set(id, entry);
savePortState();
// 等待 URL
await waitForUrl(entry, 15000);
return entry;
}
/**
* 等待 URL 生成
*/
async function waitForUrl(entry: PortEntry, timeoutMs: number): Promise<void> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const url = extractUrlFromLog(entry.logFile);
if (url) {
entry.publicUrl = url;
savePortState();
return;
}
await new Promise((r) => setTimeout(r, 500));
}
}
/**
* 停止端口暴露
*/
export function removePort(id: string): boolean {
loadPortState();
const entry = _portRegistry.get(id);
if (!entry) return false;
if (tmuxSessionExists(entry.sessionName)) {
killTmuxSession(entry.sessionName);
}
entry.status = "stopped";
_portRegistry.delete(id);
savePortState();
return true;
}
/**
* 停止指定本地端口的暴露
*/
export function removeByLocalPort(port: number): boolean {
const entry = findByLocalPort(port);
if (entry) {
return removePort(entry.id);
}
return false;
}
/**
* 停止所有端口暴露
*/
export function removeAllPorts(): number {
loadPortState();
let count = 0;
for (const entry of _portRegistry.values()) {
if (tmuxSessionExists(entry.sessionName)) {
killTmuxSession(entry.sessionName);
count++;
}
}
_portRegistry.clear();
savePortState();
return count;
}
/**
* 刷新所有端口状态
*/
export function refreshPorts(): PortEntry[] {
loadPortState();
const entries = Array.from(_portRegistry.values());
for (const entry of entries) {
const wasRunning = entry.status === "running";
const isRunning = tmuxSessionExists(entry.sessionName);
if (wasRunning && !isRunning) {
// Tunnel 意外停止
entry.status = "stopped";
} else if (isRunning) {
entry.status = "running";
entry.publicUrl = extractUrlFromLog(entry.logFile);
}
}
savePortState();
return entries;
}
/**
* 获取状态摘要
*/
export function getStatus(): { total: number; running: number; stopped: number } {
const entries = listPorts();
return {
total: entries.length,
running: entries.filter((e) => e.status === "running").length,
stopped: entries.filter((e) => e.status === "stopped").length,
};
}
// 初始化
loadPortState();
// 通用工具函数
import { execSync, spawn } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
export const CONFIG_DIR = path.join(os.homedir(), ".cf-tunnel");
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
export interface Config {
tunnelId: string;
tunnelName: string;
hostname: string;
localPort: number;
webDir: string;
tmux: {
webSession: string;
tunnelSession: string;
};
}
export const DEFAULT_CONFIG: Config = {
tunnelId: "",
tunnelName: "my-website",
hostname: "",
localPort: 8080,
webDir: path.join(os.homedir(), "my-website"),
tmux: {
webSession: "cf-web",
tunnelSession: "cf-tunnel",
},
};
export function loadConfig(): Config | null {
try {
if (!fs.existsSync(CONFIG_FILE)) return null;
const content = fs.readFileSync(CONFIG_FILE, "utf-8");
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
} catch {
return null;
}
}
export function saveConfig(config: Config): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
export function exec(cmd: string, silent = false): string {
try {
return execSync(cmd, { encoding: "utf-8", stdio: silent ? "pipe" : "inherit" });
} catch (e: any) {
if (silent) return "";
throw e;
}
}
export function execSilent(cmd: string): string {
return exec(cmd, true).trim();
}
export function tmuxSessionExists(name: string): boolean {
try {
execSync(`tmux has-session -t ${name}`, { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export function killTmuxSession(name: string): void {
try {
execSync(`tmux kill-session -t ${name} 2>/dev/null`, { stdio: "ignore" });
} catch {
// ignore
}
}
export function getTmuxSessionPid(name: string): number | null {
try {
const pid = execSync(`tmux list-panes -t ${name} -F "#{pane_pid}" 2>/dev/null`, {
encoding: "utf-8",
}).trim();
return pid ? parseInt(pid, 10) : null;
} catch {
return null;
}
}
export function isPortInUse(port: number): boolean {
try {
execSync(`lsof -Pi :${port} -sTCP:LISTEN -t >/dev/null 2>&1`);
return true;
} catch {
return false;
}
}
export function getPortPid(port: number): number | null {
try {
const pid = execSync(`lsof -Pi :${port} -sTCP:LISTEN -t 2>/dev/null`, {
encoding: "utf-8",
}).trim().split("\n")[0];
return pid ? parseInt(pid, 10) : null;
} catch {
return null;
}
}
export function getProcessInfo(pid: number): { command: string; user: string } | null {
try {
const cmd = execSync(`ps -p ${pid} -o comm= 2>/dev/null`, { encoding: "utf-8" }).trim();
const user = execSync(`ps -p ${pid} -o user= 2>/dev/null`, { encoding: "utf-8" }).trim();
return { command: cmd, user };
} catch {
return null;
}
}
export function killProcess(pid: number, signal: "SIGTERM" | "SIGKILL" = "SIGTERM"): boolean {
try {
process.kill(pid, signal);
return true;
} catch {
return false;
}
}
// 获取一个随机高端口(10000-65000),避免常见端口冲突
export function getRandomPort(): number {
return Math.floor(Math.random() * (65000 - 10000) + 10000);
}
// 查找可用端口,优先使用随机高端口
export function findAvailablePort(startPort?: number, maxTry = 20): number | null {
// 如果没有指定起始端口,使用随机高端口
let port = startPort ?? getRandomPort();
for (let i = 0; i < maxTry; i++) {
if (!isPortInUse(port)) return port;
// 端口被占用,尝试下一个随机端口
port = getRandomPort();
}
return null;
}
// 获取并自动分配一个可用端口
export function assignAvailablePort(): number {
const port = findAvailablePort();
if (!port) {
throw new Error("无法找到可用端口");
}
return port;
}
export function printStatus(status: "running" | "stopped" | "error", message: string): void {
const icons = { running: "🟢", stopped: "🔴", error: "🟡" };
console.log(`${icons[status]} ${message}`);
}
export function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
export function createSampleHtml(dir: string): void {
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Cloudflare Tunnel Site</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
line-height: 1.6;
}
h1 { color: #f48120; }
.meta { color: #666; font-size: 0.9em; }
</style>
</head>
<body>
<h1>🚀 网站已上线</h1>
<p>通过 Cloudflare Tunnel 成功暴露到公网!</p>
<p class="meta">启动时间: <span id="time"></span></p>
<script>document.getElementById('time').textContent = new Date().toLocaleString()</script>
</body>
</html>`;
fs.writeFileSync(path.join(dir, "index.html"), html);
}
export async function prompt(question: string): Promise<string> {
process.stdout.write(question);
return new Promise((resolve) => {
const stdin = process.stdin;
stdin.resume();
stdin.setEncoding("utf-8");
stdin.once("data", (data) => {
stdin.pause();
resolve(data.toString().trim());
});
});
}
export async function confirm(question: string): Promise<boolean> {
const answer = await prompt(`${question} [y/N]: `);
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
}
#!/usr/bin/env bun
// 查看日志
import { loadConfig, tmuxSessionExists } from "./lib/utils.ts";
const args = process.argv.slice(2);
const showWeb = args.includes("--web");
const showTunnel = args.includes("--tunnel");
const showAll = !showWeb && !showTunnel;
const config = loadConfig();
if (!config) {
console.log("⚠️ 未找到配置\n");
process.exit(1);
}
console.log("📜 日志查看\n");
console.log("提示: 按 Ctrl+B 然后 D 退出日志视图\n");
if ((showAll || showWeb) && tmuxSessionExists(config.tmux.webSession)) {
console.log("正在打开 Web 服务器日志...\n");
try {
const { execSync } = await import("child_process");
execSync(`tmux attach -t ${config.tmux.webSession}`, { stdio: "inherit" });
} catch {
// 用户退出
}
}
if ((showAll || showTunnel) && tmuxSessionExists(config.tmux.tunnelSession)) {
console.log("正在打开 Tunnel 日志...\n");
try {
const { execSync } = await import("child_process");
execSync(`tmux attach -t ${config.tmux.tunnelSession}`, { stdio: "inherit" });
} catch {
// 用户退出
}
}
#!/usr/bin/env bun
// Cloudflare 通用临时暴露管理面板(本地)
import { spawnSync } from "child_process";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { tmuxSessionExists, isPortInUse, findAvailablePort } from "./lib/utils.ts";
const SHARE_DIR = join(homedir(), ".cf-tunnel");
const SHARE_FILE = join(SHARE_DIR, "share.json");
const SHARE_LOG = join(SHARE_DIR, "share-tunnel.log");
const HISTORY_FILE = join(SHARE_DIR, "share-history.json");
const WEB_SESSION = "cf-share-web";
const TUNNEL_SESSION = "cf-share-tunnel";
const SHARE_SCRIPT = join(import.meta.dir, "share.ts");
const PANEL_HTML = join(import.meta.dir, "../web/panel.html");
function ensureShareDir() {
if (!existsSync(SHARE_DIR)) mkdirSync(SHARE_DIR, { recursive: true });
}
function parsePanelArgs() {
const args = process.argv.slice(2);
// 兼容旧用法:panel.ts 8790 0.0.0.0
let legacyPort: string | undefined;
let legacyHost: string | undefined;
if (args[0] && !args[0].startsWith("--")) legacyPort = args[0];
if (args[1] && !args[1].startsWith("--")) legacyHost = args[1];
const flags: Record<string, string> = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
const b = args[i + 1];
if (!a.startsWith("--")) continue;
if (b && !b.startsWith("--")) {
flags[a.slice(2)] = b;
i++;
} else {
flags[a.slice(2)] = "true";
}
}
const host = flags.host || legacyHost || "127.0.0.1";
const askedPort = Number(flags.port || legacyPort || 8788);
let port = Number.isFinite(askedPort) ? askedPort : 8788;
if (isPortInUse(port)) {
const fallback = findAvailablePort(port + 1, 50);
if (!fallback) {
console.error(`❌ 面板端口 ${port} 被占用,且未找到可用端口`);
process.exit(1);
}
console.log(`⚠️ 面板端口 ${port} 被占用,自动改用 ${fallback}`);
port = fallback;
}
return { host, port };
}
function getTryUrlFromLog(): string | null {
try {
if (!existsSync(SHARE_LOG)) return null;
const content = readFileSync(SHARE_LOG, "utf-8");
const m = content.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
return m?.[0] ?? null;
} catch {
return null;
}
}
function getLastLogTail(lines = 40): string {
try {
if (!existsSync(SHARE_LOG)) return "";
const content = readFileSync(SHARE_LOG, "utf-8").split("\n");
return content.slice(-lines).join("\n").trim();
} catch {
return "";
}
}
function loadShareConfig(): any | null {
try {
if (!existsSync(SHARE_FILE)) return null;
return JSON.parse(readFileSync(SHARE_FILE, "utf-8"));
} catch {
return null;
}
}
type HistoryItem = {
mode: string;
localPort: number;
webDir?: string;
filePath?: string;
fileRoute?: string;
tunnelUrl: string;
fileUrl?: string;
startedAt?: string;
stoppedAt?: string;
};
function loadHistory(): HistoryItem[] {
try {
if (!existsSync(HISTORY_FILE)) return [];
return JSON.parse(readFileSync(HISTORY_FILE, "utf-8"));
} catch {
return [];
}
}
function saveHistory(list: HistoryItem[]) {
ensureShareDir();
writeFileSync(HISTORY_FILE, JSON.stringify(list.slice(0, 50), null, 2), "utf-8");
}
function pushHistory(item: HistoryItem) {
const list = loadHistory();
list.unshift(item);
saveHistory(list);
}
function statusPayload() {
const cfg = loadShareConfig();
const tunnelUrl = getTryUrlFromLog();
return {
ok: true,
webRunning: tmuxSessionExists(WEB_SESSION),
tunnelRunning: tmuxSessionExists(TUNNEL_SESSION),
config: cfg,
tunnelUrl,
fileUrl:
cfg?.fileRoute && tunnelUrl
? `${tunnelUrl}${String(cfg.fileRoute).startsWith("/") ? cfg.fileRoute : `/${cfg.fileRoute}`}`
: null,
logTail: getLastLogTail(60),
now: new Date().toISOString(),
};
}
function runShare(args: string[]): { ok: boolean; output: string } {
const result = spawnSync("bun", [SHARE_SCRIPT, ...args], {
encoding: "utf-8",
});
const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
return { ok: result.status === 0, output };
}
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data, null, 2), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
function recordStop() {
const cfg = loadShareConfig();
if (!cfg) return;
const url = getTryUrlFromLog();
if (url) {
pushHistory({
mode: cfg.mode,
localPort: cfg.localPort,
webDir: cfg.webDir,
filePath: cfg.filePath,
fileRoute: cfg.fileRoute,
tunnelUrl: url,
fileUrl: cfg.fileRoute
? `${url}${String(cfg.fileRoute).startsWith("/") ? cfg.fileRoute : `/${cfg.fileRoute}`}`
: undefined,
startedAt: cfg.startedAt,
stoppedAt: new Date().toISOString(),
});
}
}
const { host, port } = parsePanelArgs();
const server = Bun.serve({
port,
hostname: host,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/panel")) {
const html = readFileSync(PANEL_HTML, "utf-8");
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
if (req.method === "GET" && url.pathname === "/api/status") {
return json(statusPayload());
}
if (req.method === "POST" && url.pathname === "/api/start") {
const body = (await req.json().catch(() => ({} as any))) as any;
const args: string[] = ["start"];
if (body?.port) args.push("--port", String(body.port));
if (body?.dir) args.push("--dir", String(body.dir));
if (body?.file) args.push("--file", String(body.file));
if (body?.route) args.push("--route", String(body.route));
const result = runShare(args);
return json({ ...result, status: statusPayload() }, result.ok ? 200 : 500);
}
if (req.method === "POST" && url.pathname === "/api/stop") {
recordStop();
const result = runShare(["stop"]);
return json({ ...result, status: statusPayload() }, result.ok ? 200 : 500);
}
if (req.method === "GET" && url.pathname === "/api/logs") {
return json({ ok: true, logTail: getLastLogTail(200) });
}
if (req.method === "GET" && url.pathname === "/api/history") {
return json({ ok: true, history: loadHistory() });
}
if (req.method === "POST" && url.pathname === "/api/history/clear") {
saveHistory([]);
return json({ ok: true });
}
return new Response("Not Found", { status: 404 });
},
});
console.log(`\n✨ CF Share Panel 已启动`);
console.log(` http://${host}:${server.port}`);
console.log(` 管理接口: /api/status /api/start /api/stop /api/logs\n`);
#!/usr/bin/env bun
// 端口检测工具
import {
isPortInUse,
getPortPid,
getProcessInfo,
findAvailablePort,
execSilent,
confirm,
} from "./lib/utils.ts";
const port = parseInt(process.argv[2], 10) || 8080;
console.log(`🔍 端口检测: ${port}\n`);
if (!isPortInUse(port)) {
console.log(`✅ 端口 ${port} 可用\n`);
process.exit(0);
}
const pid = getPortPid(port);
console.log(`❌ 端口 ${port} 已被占用\n`);
if (pid) {
const info = getProcessInfo(pid);
console.log("占用进程信息:");
console.log(` PID: ${pid}`);
if (info) {
console.log(` 命令: ${info.command}`);
console.log(` 用户: ${info.user}`);
}
// 尝试获取更多信息
const cmdline = execSilent(`cat /proc/${pid}/cmdline 2>/dev/null | tr '\\0' ' '`);
if (cmdline) {
console.log(` 完整命令: ${cmdline.substring(0, 100)}${cmdline.length > 100 ? "..." : ""}`);
}
}
// 检查是否为 tmux 会话
const tmuxList = execSilent("tmux list-sessions -F '#{session_name} #{session_id}' 2>/dev/null");
if (tmuxList) {
console.log("\n当前 tmux 会话:");
console.log(tmuxList.split("\n").map(l => " " + l).join("\n"));
}
console.log("\n选项:");
const available = findAvailablePort(port + 1);
if (available) {
console.log(` 💡 推荐可用端口: ${available}`);
}
const shouldKill = await confirm("\n是否终止占用进程?");
if (shouldKill && pid) {
console.log(`\n🛑 发送 SIGTERM 到进程 ${pid}...`);
try {
process.kill(pid, "SIGTERM");
await new Promise(r => setTimeout(r, 1500));
if (isPortInUse(port)) {
console.log("进程未响应,发送 SIGKILL...");
process.kill(pid, "SIGKILL");
await new Promise(r => setTimeout(r, 500));
}
if (isPortInUse(port)) {
console.log("❌ 无法终止进程(可能需要 sudo)\n");
} else {
console.log("✅ 端口已释放\n");
}
} catch (e) {
console.error(`❌ 错误: ${e}\n`);
}
}
#!/usr/bin/env bun
// 重启 Cloudflare Tunnel
import { execSync } from "child_process";
import * as path from "path";
const SCRIPT_DIR = path.dirname(import.meta.url.replace("file://", ""));
console.log("🔄 重启 Cloudflare Tunnel\n");
// 先停止
try {
execSync(`bun "${path.join(SCRIPT_DIR, "stop.ts")}"`, { stdio: "inherit" });
} catch {
// ignore
}
console.log("\n---\n");
// 再启动
try {
execSync(`bun "${path.join(SCRIPT_DIR, "start.ts")}" ${process.argv.slice(2).join(" ")}`, { stdio: "inherit" });
} catch {
process.exit(1);
}
#!/usr/bin/env bun
// 通用临时暴露:任意本地端口 / 目录 / 单文件 -> trycloudflare 公网地址
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import {
findAvailablePort,
isPortInUse,
tmuxSessionExists,
killTmuxSession,
execSilent,
printStatus,
} from "./lib/utils.ts";
const SHARE_DIR = path.join(os.homedir(), ".cf-tunnel");
const SHARE_FILE = path.join(SHARE_DIR, "share.json");
const SHARE_LOG = path.join(SHARE_DIR, "share-tunnel.log");
const WEB_SESSION = "cf-share-web";
const TUNNEL_SESSION = "cf-share-tunnel";
type ShareConfig = {
mode: "port" | "dir" | "file";
localPort: number;
webDir?: string;
filePath?: string;
fileRoute?: string;
startedAt: string;
};
function ensureShareDir(): void {
if (!fs.existsSync(SHARE_DIR)) fs.mkdirSync(SHARE_DIR, { recursive: true });
}
function saveShareConfig(cfg: ShareConfig): void {
ensureShareDir();
fs.writeFileSync(SHARE_FILE, JSON.stringify(cfg, null, 2), "utf-8");
}
function loadShareConfig(): ShareConfig | null {
try {
if (!fs.existsSync(SHARE_FILE)) return null;
return JSON.parse(fs.readFileSync(SHARE_FILE, "utf-8"));
} catch {
return null;
}
}
function usage() {
console.log(`
Cloudflare 临时暴露(底层命令)
推荐统一入口:
bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts start --dir ./demos/html
底层用法:
bun ~/.pi/agent/skills/cf-tunnel/scripts/share.ts start [--port 8766] [--dir ./demos/html] [--file ./demos/html/index.html] [--route /index.html]
bun ~/.pi/agent/skills/cf-tunnel/scripts/share.ts status
bun ~/.pi/agent/skills/cf-tunnel/scripts/share.ts stop
说明:
- start + --port: 仅暴露已有本地端口
- start + --dir : 自动起本地静态服务并暴露
- start + --file: 以文件所在目录起服务,并提示文件访问路径
`);
}
function parseArgs() {
const args = process.argv.slice(2);
const cmd = args[0] ?? "status";
let port: number | undefined;
let dir: string | undefined;
let file: string | undefined;
let route: string | undefined;
for (let i = 1; i < args.length; i++) {
const a = args[i];
const b = args[i + 1];
if (a === "--port" && b) {
port = parseInt(b, 10);
i++;
} else if (a === "--dir" && b) {
dir = b;
i++;
} else if (a === "--file" && b) {
file = b;
i++;
} else if (a === "--route" && b) {
route = b;
i++;
}
}
return { cmd, port, dir, file, route };
}
function mustHaveCloudflared() {
if (!execSilent("which cloudflared")) {
console.error("❌ 未找到 cloudflared,请先安装");
process.exit(1);
}
}
function getTryUrlFromLog(): string | null {
try {
if (!fs.existsSync(SHARE_LOG)) return null;
const content = fs.readFileSync(SHARE_LOG, "utf-8");
const m = content.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
return m?.[0] ?? null;
} catch {
return null;
}
}
function startLocalWeb(dir: string, port: number): void {
if (tmuxSessionExists(WEB_SESSION)) killTmuxSession(WEB_SESSION);
let serverCmd = "";
if (execSilent("which python3")) {
serverCmd = `python3 -m http.server ${port} --bind 0.0.0.0`;
} else if (execSilent("which bunx")) {
serverCmd = `bunx serve -p ${port}`;
} else if (execSilent("which npx")) {
serverCmd = `npx serve -p ${port}`;
} else {
console.error("❌ 未找到可用静态服务工具 (python3/bunx/npx)");
process.exit(1);
}
execSync(`tmux new-session -d -s ${WEB_SESSION} -c \"${dir}\" \"${serverCmd}\"`, { stdio: "inherit" });
}
function startTunnel(port: number): void {
if (tmuxSessionExists(TUNNEL_SESSION)) killTmuxSession(TUNNEL_SESSION);
ensureShareDir();
if (fs.existsSync(SHARE_LOG)) fs.unlinkSync(SHARE_LOG);
const cmd = `cloudflared tunnel --no-autoupdate --url http://localhost:${port} > \"${SHARE_LOG}\" 2>&1`;
execSync(`tmux new-session -d -s ${TUNNEL_SESSION} \"${cmd}\"`, { stdio: "inherit" });
}
async function waitTryUrl(timeoutMs = 12000): Promise<string | null> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const url = getTryUrlFromLog();
if (url) return url;
await new Promise((r) => setTimeout(r, 500));
}
return null;
}
function status() {
const cfg = loadShareConfig();
const webOn = tmuxSessionExists(WEB_SESSION);
const tunnelOn = tmuxSessionExists(TUNNEL_SESSION);
const url = getTryUrlFromLog();
console.log("\n📊 临时暴露状态\n");
printStatus(webOn ? "running" : "stopped", `本地服务 (${WEB_SESSION})`);
printStatus(tunnelOn ? "running" : "stopped", `Cloudflare Tunnel (${TUNNEL_SESSION})`);
if (cfg) {
console.log(`\n模式: ${cfg.mode}`);
console.log(`端口: ${cfg.localPort}`);
if (cfg.webDir) console.log(`目录: ${cfg.webDir}`);
if (cfg.filePath) console.log(`文件: ${cfg.filePath}`);
}
if (url) {
console.log(`\n🌐 公网地址: ${url}`);
if (cfg?.fileRoute) {
const suffix = cfg.fileRoute.startsWith("/") ? cfg.fileRoute : `/${cfg.fileRoute}`;
console.log(`📄 文件直达: ${url}${suffix}`);
}
} else {
console.log("\n🌐 公网地址: (等待中或未启动)");
}
console.log("");
}
function stop() {
if (tmuxSessionExists(WEB_SESSION)) killTmuxSession(WEB_SESSION);
if (tmuxSessionExists(TUNNEL_SESSION)) killTmuxSession(TUNNEL_SESSION);
printStatus("stopped", "已停止临时暴露会话");
}
async function start() {
mustHaveCloudflared();
const { port, dir, file, route } = parseArgs();
const modes = [port ? 1 : 0, dir ? 1 : 0, file ? 1 : 0].reduce((a, b) => a + b, 0);
if (modes > 1) {
console.error("❌ --port / --dir / --file 只能选一个");
process.exit(1);
}
let mode: ShareConfig["mode"] = "port";
let localPort = port ?? 0;
let webDir: string | undefined;
let filePath: string | undefined;
let fileRoute: string | undefined;
if (file) {
const abs = path.resolve(file);
if (!fs.existsSync(abs)) {
console.error(`❌ 文件不存在: ${abs}`);
process.exit(1);
}
mode = "file";
filePath = abs;
webDir = path.dirname(abs);
fileRoute = route || `/${path.basename(abs)}`;
localPort = localPort || findAvailablePort() || 8766;
startLocalWeb(webDir, localPort);
} else if (dir) {
const absDir = path.resolve(dir);
if (!fs.existsSync(absDir) || !fs.statSync(absDir).isDirectory()) {
console.error(`❌ 目录不存在: ${absDir}`);
process.exit(1);
}
mode = "dir";
webDir = absDir;
localPort = localPort || findAvailablePort() || 8766;
startLocalWeb(webDir, localPort);
} else {
mode = "port";
localPort = localPort || 8766;
if (!isPortInUse(localPort)) {
console.error(`❌ 端口 ${localPort} 未监听,请先启动本地服务,或改用 --dir/--file`);
process.exit(1);
}
}
startTunnel(localPort);
const cfg: ShareConfig = {
mode,
localPort,
webDir,
filePath,
fileRoute,
startedAt: new Date().toISOString(),
};
saveShareConfig(cfg);
const url = await waitTryUrl();
console.log("\n✅ 临时暴露已启动\n");
console.log(`本地: http://localhost:${localPort}`);
if (url) {
console.log(`公网: ${url}`);
if (fileRoute) {
const suffix = fileRoute.startsWith("/") ? fileRoute : `/${fileRoute}`;
console.log(`文件: ${url}${suffix}`);
}
} else {
console.log("公网: 还在建立中,稍后执行 status 查看");
}
console.log("\n管理命令:");
console.log(` bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts status`);
console.log(` bun ~/.pi/agent/skills/cf-tunnel/scripts/cf.ts stop --share`);
console.log(` bun ~/.pi/agent/skills/cf-tunnel/scripts/share.ts status # 底层命令`);
}
(async () => {
const { cmd } = parseArgs();
if (cmd === "start") {
await start();
} else if (cmd === "status") {
status();
} else if (cmd === "stop") {
stop();
} else if (cmd === "help" || cmd === "--help" || cmd === "-h") {
usage();
} else {
usage();
process.exit(1);
}
})();
#!/usr/bin/env bun
// 启动 Cloudflare Tunnel 和本地服务器
import {
loadConfig,
saveConfig,
tmuxSessionExists,
killTmuxSession,
isPortInUse,
getPortPid,
getProcessInfo,
findAvailablePort,
printStatus,
ensureDir,
createSampleHtml,
exec,
execSilent,
confirm,
} from "./lib/utils.ts";
import * as path from "path";
import * as fs from "fs";
console.log("🚀 启动 Cloudflare Tunnel\n");
// 解析参数
const args = process.argv.slice(2);
let customPort: number | null = null;
let customDir: string | null = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--port" && args[i + 1]) {
customPort = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === "--dir" && args[i + 1]) {
customDir = args[i + 1];
i++;
}
}
// 加载配置
let config = loadConfig();
if (!config) {
console.log("⚠️ 未找到配置,请先运行初始化\n");
console.log(" bun ~/.pi/agent/skills/cf-tunnel/scripts/init.ts\n");
process.exit(1);
}
// 应用命令行参数
if (customPort) config.localPort = customPort;
if (customDir) config.webDir = customDir;
saveConfig(config);
// 确保网站目录存在
ensureDir(config.webDir);
// 如果没有 index.html,创建示例
const indexPath = path.join(config.webDir, "index.html");
if (!fs.existsSync(indexPath)) {
console.log("📝 创建示例 index.html...\n");
createSampleHtml(config.webDir);
}
// 自动检测并分配可用端口(除非用户明确指定)
let assignedPort: number;
if (customPort) {
// 用户明确指定了端口,检查是否可用
console.log(`🔍 检查指定端口 ${customPort}...`);
if (isPortInUse(customPort)) {
const pid = getPortPid(customPort);
const info = pid ? getProcessInfo(pid) : null;
console.log(`⚠️ 端口 ${customPort} 已被占用`);
if (info) {
console.log(` 进程: ${info.command} (PID: ${pid}, 用户: ${info.user})`);
}
console.log("\n选项:");
console.log(" 1. 终止占用进程");
console.log(" 2. 自动寻找其他端口");
console.log(" 3. 取消启动\n");
const choice = await confirm("终止占用进程并继续?") ? "1" :
await confirm("自动寻找其他端口?") ? "2" : "3";
if (choice === "1" && pid) {
console.log(`\n🛑 终止进程 ${pid}...`);
try {
process.kill(pid, "SIGTERM");
await new Promise(r => setTimeout(r, 1000));
if (isPortInUse(customPort)) {
process.kill(pid, "SIGKILL");
}
console.log("✅ 进程已终止\n");
assignedPort = customPort;
} catch (e) {
console.error("❌ 无法终止进程\n");
process.exit(1);
}
} else if (choice === "2") {
const newPort = findAvailablePort();
if (!newPort) {
console.error("❌ 未找到可用端口\n");
process.exit(1);
}
console.log(`✅ 自动分配端口 ${newPort}\n`);
assignedPort = newPort;
} else {
console.log("❌ 已取消\n");
process.exit(0);
}
} else {
console.log(`✅ 端口 ${customPort} 可用\n`);
assignedPort = customPort;
}
} else {
// 自动寻找可用端口
console.log("🔍 自动寻找可用端口...");
const autoPort = findAvailablePort();
if (!autoPort) {
console.error("❌ 未找到可用端口\n");
process.exit(1);
}
assignedPort = autoPort;
console.log(`✅ 自动分配端口 ${assignedPort}\n`);
}
// 更新配置中的端口
config.localPort = assignedPort;
saveConfig(config);
// 检查现有会话
if (tmuxSessionExists(config.tmux.webSession)) {
console.log("⚠️ Web 服务器已在运行");
const restart = await confirm("是否重启?");
if (restart) {
killTmuxSession(config.tmux.webSession);
} else {
console.log("保持现有会话\n");
}
}
if (tmuxSessionExists(config.tmux.tunnelSession)) {
console.log("⚠️ Tunnel 已在运行");
const restart = await confirm("是否重启?");
if (restart) {
killTmuxSession(config.tmux.tunnelSession);
} else {
console.log("保持现有会话\n");
}
}
// 启动 Web 服务器
if (!tmuxSessionExists(config.tmux.webSession)) {
console.log("🌐 启动 Web 服务器...");
// 检测可用的 HTTP 服务器(优先使用系统级工具,减少依赖)
let serverCmd = "";
if (execSilent("which python3")) {
serverCmd = `python3 -m http.server ${config.localPort}`;
} else if (execSilent("which bunx")) {
serverCmd = `bunx serve -p ${config.localPort}`;
} else if (execSilent("which npx")) {
serverCmd = `npx serve -p ${config.localPort}`;
} else {
console.error("❌ 未找到可用的 HTTP 服务器 (python3/bunx/npx)");
process.exit(1);
}
exec(`tmux new-session -d -s ${config.tmux.webSession} -c "${config.webDir}" "${serverCmd}"`);
// 等待服务器启动
let retries = 10;
while (retries-- > 0 && !isPortInUse(config.localPort)) {
await new Promise(r => setTimeout(r, 500));
}
if (isPortInUse(config.localPort)) {
printStatus("running", `Web 服务器运行在端口 ${config.localPort}`);
} else {
printStatus("error", "Web 服务器启动失败");
process.exit(1);
}
}
// 启动 Tunnel
if (!tmuxSessionExists(config.tmux.tunnelSession)) {
console.log("\n🔒 启动 Cloudflare Tunnel...");
exec(`tmux new-session -d -s ${config.tmux.tunnelSession} "cloudflared tunnel run ${config.tunnelName}"`);
// 等待 tunnel 启动
await new Promise(r => setTimeout(r, 3000));
if (tmuxSessionExists(config.tmux.tunnelSession)) {
printStatus("running", "Cloudflare Tunnel 运行中");
} else {
printStatus("error", "Tunnel 启动失败");
process.exit(1);
}
}
console.log("\n✅ 全部启动成功!\n");
console.log("访问地址:");
console.log(` 🌐 https://${config.hostname}`);
console.log(` 🏠 http://localhost:${config.localPort}\n`);
console.log("管理命令:");
console.log(` 查看状态: bun ~/.pi/agent/skills/cf-tunnel/scripts/status.ts`);
console.log(` 停止服务: bun ~/.pi/agent/skills/cf-tunnel/scripts/stop.ts`);
console.log(` 查看日志: tmux attach -t ${config.tmux.webSession}`);
console.log(` tmux attach -t ${config.tmux.tunnelSession}\n`);
#!/usr/bin/env bun
// 查看 Cloudflare Tunnel 状态
import {
loadConfig,
tmuxSessionExists,
getTmuxSessionPid,
isPortInUse,
printStatus,
execSilent,
} from "./lib/utils.ts";
console.log("📊 Cloudflare Tunnel 状态\n");
const config = loadConfig();
if (!config) {
console.log("⚠️ 未找到配置\n");
process.exit(1);
}
console.log("配置信息:");
console.log(` 隧道: ${config.tunnelName}`);
console.log(` 域名: ${config.hostname}`);
console.log(` 端口: ${config.localPort || "自动分配"}`);
console.log(` 目录: ${config.webDir}\n`);
console.log("服务状态:");
// Web 服务器状态
const webRunning = tmuxSessionExists(config.tmux.webSession);
if (webRunning) {
const portOpen = isPortInUse(config.localPort);
const pid = getTmuxSessionPid(config.tmux.webSession);
printStatus("running", `Web 服务器 (tmux: ${config.tmux.webSession}, PID: ${pid}, 端口: ${portOpen ? "开放" : "关闭"})`);
} else {
printStatus("stopped", "Web 服务器");
}
// Tunnel 状态
const tunnelRunning = tmuxSessionExists(config.tmux.tunnelSession);
if (tunnelRunning) {
const pid = getTmuxSessionPid(config.tmux.tunnelSession);
printStatus("running", `Cloudflare Tunnel (tmux: ${config.tmux.tunnelSession}, PID: ${pid})`);
} else {
printStatus("stopped", "Cloudflare Tunnel");
}
console.log("\n访问地址:");
if (webRunning && tunnelRunning) {
console.log(` 🌐 https://${config.hostname}`);
console.log(` 🏠 http://localhost:${config.localPort}`);
} else if (webRunning) {
console.log(` 🏠 http://localhost:${config.localPort} (仅本地)`);
} else {
console.log(" ❌ 服务未运行");
}
// 检查 tunnel 健康状态
if (tunnelRunning) {
console.log("\n健康检查:");
try {
const info = execSilent(`cloudflared tunnel info ${config.tunnelName} 2>/dev/null`);
if (info) {
console.log(" ✅ Tunnel 连接正常");
} else {
console.log(" 🟡 无法获取 Tunnel 信息");
}
} catch {
console.log(" 🟡 Tunnel 信息获取失败");
}
}
console.log("\n");
#!/usr/bin/env bun
// 停止 Cloudflare Tunnel 和本地服务器
import {
loadConfig,
tmuxSessionExists,
killTmuxSession,
printStatus,
} from "./lib/utils.ts";
console.log("🛑 停止 Cloudflare Tunnel\n");
const config = loadConfig();
if (!config) {
console.log("⚠️ 未找到配置\n");
process.exit(1);
}
let stopped = false;
// 停止 Web 服务器
if (tmuxSessionExists(config.tmux.webSession)) {
console.log(`停止 Web 服务器 (${config.tmux.webSession})...`);
killTmuxSession(config.tmux.webSession);
printStatus("stopped", "Web 服务器已停止");
stopped = true;
} else {
printStatus("stopped", "Web 服务器未运行");
}
// 停止 Tunnel
if (tmuxSessionExists(config.tmux.tunnelSession)) {
console.log(`\n停止 Cloudflare Tunnel (${config.tmux.tunnelSession})...`);
killTmuxSession(config.tmux.tunnelSession);
printStatus("stopped", "Cloudflare Tunnel 已停止");
stopped = true;
} else {
printStatus("stopped", "Cloudflare Tunnel 未运行");
}
console.log(stopped ? "\n✅ 已停止" : "\n所有服务已处于停止状态");
console.log("\n启动命令:");
console.log(` bun ~/.pi/agent/skills/cf-tunnel/scripts/start.ts\n`);
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CF Share 控制台</title>
<style>
:root {
--bg: #0b1020;
--bg-grad-1: #3b82f622;
--bg-grad-2: #22d3ee1f;
--hero-bg: linear-gradient(140deg, rgba(27, 38, 70, 0.88), rgba(14, 21, 42, 0.82));
--card: rgba(18, 27, 52, .68);
--line: rgba(132, 164, 255, .24);
--line-soft: rgba(132, 164, 255, .16);
--text: #eef3ff;
--muted: #adc0eb;
--input-bg: rgba(9, 16, 34, .56);
--input-readonly-bg: rgba(7, 14, 30, .62);
--btn-ghost-bg: rgba(104, 135, 223, .16);
--ok: #30d39d;
--warn: #ffb347;
--danger: #ff637d;
--accent: #6ea8ff;
--accent-strong: #4388ff;
--shadow: 0 20px 44px rgba(0, 0, 0, .28);
--blur: saturate(145%) blur(14px);
}
[data-theme="light"] {
--bg: #f2f6ff;
--bg-grad-1: #8db8ff33;
--bg-grad-2: #8ce6ff33;
--hero-bg: linear-gradient(145deg, rgba(255, 255, 255, .95), rgba(242, 247, 255, .92));
--card: rgba(255, 255, 255, .78);
--line: rgba(126, 154, 224, .26);
--line-soft: rgba(126, 154, 224, .17);
--text: #1a2747;
--muted: #5d72a3;
--input-bg: rgba(255, 255, 255, .9);
--input-readonly-bg: rgba(248, 251, 255, .95);
--btn-ghost-bg: rgba(83, 118, 206, .12);
--ok: #188d62;
--warn: #b98000;
--danger: #be3650;
--accent: #2f6fff;
--accent-strong: #1f60f4;
--shadow: 0 14px 30px rgba(61, 94, 170, .14);
--blur: saturate(130%) blur(12px);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", Roboto, sans-serif;
color: var(--text);
background:
radial-gradient(1200px 800px at 84% -12%, var(--bg-grad-1), transparent 70%),
radial-gradient(920px 720px at -14% 22%, var(--bg-grad-2), transparent 62%),
var(--bg);
min-height: 100vh;
padding: 18px;
letter-spacing: .1px;
}
.wrap { max-width: 1220px; margin: 0 auto; display: grid; gap: 14px; }
.hero {
border: 1px solid var(--line);
background: var(--hero-bg);
border-radius: 22px;
padding: 16px 18px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
box-shadow: var(--shadow);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: .2px;
}
.meta { color: var(--muted); font-size: 12px; }
.grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(12, 1fr);
}
.card {
grid-column: span 12;
border: 1px solid var(--line);
background: var(--card);
border-radius: 20px;
padding: 16px;
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
box-shadow: var(--shadow);
}
@media (min-width: 980px) {
.status { grid-column: span 4; }
.control { grid-column: span 5; }
.qrcode { grid-column: span 3; }
.history { grid-column: span 6; }
.log { grid-column: span 6; }
}
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
label {
font-size: 12px;
color: var(--muted);
display: grid;
gap: 5px;
min-width: 190px;
font-weight: 500;
}
input, select, button, textarea {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--input-bg);
color: var(--text);
padding: 10px 12px;
font-size: 14px;
outline: none;
transition: .2s ease;
}
input:focus, select:focus, textarea:focus {
border-color: color-mix(in oklab, var(--accent) 65%, white 10%);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent);
}
button {
cursor: pointer;
font-weight: 600;
letter-spacing: .2px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.14);
}
button:hover { transform: translateY(-1px); }
button:active { transform: translateY(0); }
.btn-primary {
background: linear-gradient(180deg, color-mix(in oklab, var(--accent-strong) 92%, white), var(--accent-strong));
border-color: transparent;
color: #fff;
}
.btn-danger {
background: linear-gradient(180deg, color-mix(in oklab, var(--danger) 90%, white), var(--danger));
border-color: transparent;
color: #fff;
}
.btn-ghost { background: var(--btn-ghost-bg); }
.btn-sm { padding: 6px 10px; font-size: 12px; border-radius: 11px; }
.pill {
border-radius: 999px;
padding: 4px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: .3px;
border: 1px solid transparent;
}
.ok {
background: color-mix(in oklab, var(--ok) 16%, transparent);
color: color-mix(in oklab, var(--ok) 78%, white 10%);
border-color: color-mix(in oklab, var(--ok) 40%, transparent);
}
.off {
background: color-mix(in oklab, var(--danger) 14%, transparent);
color: color-mix(in oklab, var(--danger) 74%, white 10%);
border-color: color-mix(in oklab, var(--danger) 32%, transparent);
}
.mono { font-family: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace; }
.kvs { display: grid; gap: 8px; margin-top: 10px; }
.kv { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }
textarea {
width: 100%;
min-height: 240px;
resize: vertical;
line-height: 1.45;
}
a { color: color-mix(in oklab, var(--accent) 86%, white 8%); }
.copy-row { display: flex; gap: 8px; align-items: center; }
.copy-row input { flex: 1; background: var(--input-readonly-bg); }
.toast {
position: fixed;
top: 16px;
right: 16px;
padding: 11px 14px;
border-radius: 13px;
background: color-mix(in oklab, var(--ok) 24%, black 36%);
color: #d8ffef;
border: 1px solid color-mix(in oklab, var(--ok) 44%, transparent);
opacity: 0;
transform: translateY(-10px);
transition: opacity .25s ease, transform .25s ease;
z-index: 100;
box-shadow: 0 10px 22px rgba(0,0,0,.22);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.toast.show { opacity: 1; transform: translateY(0); }
.history-list { max-height: 250px; overflow: auto; }
.history-item {
padding: 10px 12px;
border-bottom: 1px solid var(--line-soft);
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.history-item:last-child { border-bottom: none; }
.history-meta { font-size: 12px; color: var(--muted); }
.empty { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
#qrcode { text-align: center; padding: 10px; }
#qrcode img {
max-width: 186px;
border-radius: 16px;
background: white;
padding: 10px;
border: 1px solid var(--line-soft);
box-shadow: inset 0 1px 0 rgba(255,255,255,.8);
}
@media (max-width: 680px) {
body { padding: 10px; }
.hero, .card { border-radius: 16px; }
label { min-width: 100%; }
}
</style>
</head>
<body>
<div class="toast" id="toast">已复制到剪贴板</div>
<div class="wrap">
<section class="hero">
<div>
<h1>Cloudflare 临时暴露控制台</h1>
<div class="meta">统一管理端口 / 目录 / 单文件 暴露(trycloudflare)</div>
</div>
<div class="row">
<button class="btn-ghost" id="themeBtn" onclick="toggleTheme()">主题:自动</button>
<button class="btn-ghost" onclick="refreshStatus()">刷新</button>
<button class="btn-ghost" onclick="loadHistory()">历史</button>
</div>
</section>
<section class="grid">
<article class="card status">
<div class="row" style="justify-content:space-between">
<strong>运行状态</strong>
<span id="lastUpdate" class="meta">-</span>
</div>
<div class="kvs">
<div class="kv"><span>本地服务</span><span id="webState" class="pill off">STOPPED</span></div>
<div class="kv"><span>Tunnel</span><span id="tunnelState" class="pill off">STOPPED</span></div>
<div class="kv"><span>模式</span><span id="mode" class="mono">-</span></div>
<div class="kv"><span>端口</span><span id="port" class="mono">-</span></div>
</div>
<hr style="border-color:var(--line-soft); opacity:.45" />
<div class="meta">公网 URL</div>
<div class="copy-row" style="margin-top:6px">
<input id="publicUrlInput" readonly placeholder="-" />
<button class="btn-sm btn-primary" onclick="copy('publicUrlInput')">复制</button>
<button class="btn-sm btn-ghost" onclick="openPublic()">打开</button>
</div>
<div class="meta" style="margin-top:10px">文件直达</div>
<div class="copy-row" style="margin-top:6px">
<input id="fileUrlInput" readonly placeholder="-" />
<button class="btn-sm btn-primary" onclick="copy('fileUrlInput')">复制</button>
</div>
</article>
<article class="card control">
<strong>启动参数</strong>
<div class="row" style="margin-top:10px">
<label>模式
<select id="modeSelect" onchange="syncMode()">
<option value="port">port(已有端口)</option>
<option value="dir">dir(目录)</option>
<option value="file">file(单文件)</option>
</select>
</label>
<label>端口(可选)
<input id="portInput" type="number" placeholder="如 8766" />
</label>
<label id="dirWrap">目录(dir 模式)
<input id="dirInput" placeholder="如 /root/.pi/gateway/workspaces/default/demos/html" />
</label>
<label id="fileWrap" style="display:none">文件(file 模式)
<input id="fileInput" placeholder="如 /root/.pi/gateway/workspaces/default/demos/html/index.html" />
</label>
<label id="routeWrap" style="display:none">文件路由(可选)
<input id="routeInput" placeholder="如 /index.html" />
</label>
</div>
<div class="row" style="margin-top:12px">
<button class="btn-primary" onclick="startShare()">启动暴露</button>
<button class="btn-danger" onclick="stopShare()">停止暴露</button>
<button class="btn-ghost" onclick="autoFillLast()">恢复上次</button>
</div>
<div id="errorBox" style="margin-top:12px; color:var(--danger); font-size:13px; display:none"></div>
<pre id="output" class="mono" style="margin-top:10px; white-space:pre-wrap; color:var(--text); font-size:12px; opacity:.92"></pre>
</article>
<article class="card qrcode">
<strong>手机扫码访问</strong>
<div id="qrcode">
<div class="empty">暂无公网链接</div>
</div>
<div class="meta" style="text-align:center; margin-top:6px">用微信/浏览器扫描</div>
</article>
<article class="card history">
<div class="row" style="justify-content:space-between">
<strong>最近暴露记录</strong>
<button class="btn-ghost btn-sm" onclick="clearHistory()">清空</button>
</div>
<div id="historyList" class="history-list">
<div class="empty">暂无记录</div>
</div>
</article>
<article class="card log">
<div class="row" style="justify-content:space-between">
<strong>隧道日志(实时)</strong>
<div class="row">
<label style="min-width:auto; display:flex; align-items:center; gap:6px; cursor:pointer">
<input type="checkbox" id="autoScroll" checked /> 自动滚动
</label>
<button class="btn-ghost btn-sm" onclick="loadLogs()">刷新</button>
</div>
</div>
<textarea id="logs" class="mono" readonly></textarea>
</article>
</section>
</div>
<script>
const els = {
webState: document.getElementById('webState'),
tunnelState: document.getElementById('tunnelState'),
mode: document.getElementById('mode'),
port: document.getElementById('port'),
publicUrlInput: document.getElementById('publicUrlInput'),
fileUrlInput: document.getElementById('fileUrlInput'),
logs: document.getElementById('logs'),
output: document.getElementById('output'),
errorBox: document.getElementById('errorBox'),
lastUpdate: document.getElementById('lastUpdate'),
modeSelect: document.getElementById('modeSelect'),
portInput: document.getElementById('portInput'),
dirInput: document.getElementById('dirInput'),
fileInput: document.getElementById('fileInput'),
routeInput: document.getElementById('routeInput'),
dirWrap: document.getElementById('dirWrap'),
fileWrap: document.getElementById('fileWrap'),
routeWrap: document.getElementById('routeWrap'),
qrcode: document.getElementById('qrcode'),
historyList: document.getElementById('historyList'),
autoScroll: document.getElementById('autoScroll'),
toast: document.getElementById('toast'),
themeBtn: document.getElementById('themeBtn'),
};
let pollTimer = null;
const THEME_KEY = 'cfShareThemeMode';
function resolveAutoTheme() {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function applyTheme(mode) {
const realMode = mode === 'auto' ? resolveAutoTheme() : mode;
document.documentElement.setAttribute('data-theme', realMode);
const labels = { auto: '自动', dark: '深色', light: '浅色' };
if (els.themeBtn) els.themeBtn.textContent = `主题:${labels[mode] || '自动'}`;
}
function getThemeMode() {
return localStorage.getItem(THEME_KEY) || 'auto';
}
function setThemeMode(mode) {
localStorage.setItem(THEME_KEY, mode);
applyTheme(mode);
}
function toggleTheme() {
const order = ['auto', 'dark', 'light'];
const current = getThemeMode();
const next = order[(order.indexOf(current) + 1) % order.length];
setThemeMode(next);
showToast(`主题已切换:${next === 'auto' ? '自动' : next === 'dark' ? '深色' : '浅色'}`);
}
window.toggleTheme = toggleTheme;
function setStatePill(el, on) {
el.className = `pill ${on ? 'ok' : 'off'}`;
el.textContent = on ? 'RUNNING' : 'STOPPED';
}
function syncMode() {
const mode = els.modeSelect.value;
els.dirWrap.style.display = mode === 'dir' ? '' : 'none';
els.fileWrap.style.display = mode === 'file' ? '' : 'none';
els.routeWrap.style.display = mode === 'file' ? '' : 'none';
}
function showToast(msg) {
els.toast.textContent = msg;
els.toast.classList.add('show');
setTimeout(() => els.toast.classList.remove('show'), 2000);
}
function copy(inputId) {
const el = document.getElementById(inputId);
if (!el || !el.value) return;
navigator.clipboard.writeText(el.value).then(() => showToast('已复制'));
}
function openPublic() {
const url = els.publicUrlInput.value;
if (url) window.open(url, '_blank');
}
function updateQRCode(url) {
if (!url) {
els.qrcode.innerHTML = '<div class="empty">暂无公网链接</div>';
return;
}
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(url)}`;
els.qrcode.innerHTML = `<img src="${qrUrl}" alt="QR Code" />`;
}
function renderHistory(list) {
if (!list || list.length === 0) {
els.historyList.innerHTML = '<div class="empty">暂无记录</div>';
return;
}
els.historyList.innerHTML = list.map((h, idx) => `
<div class="history-item">
<div>
<div class="mono" style="color:var(--text)">${h.tunnelUrl || '-'}</div>
<div class="history-meta">${h.mode} • ${h.localPort} • ${new Date(h.startedAt).toLocaleString()}</div>
</div>
<div class="row">
<button class="btn-sm btn-primary" onclick="copyText('${h.tunnelUrl || ''}')">复制</button>
<button class="btn-sm btn-ghost" onclick="applyHistory(${idx})">恢复</button>
</div>
</div>
`).join('');
}
function copyText(text) {
if (!text) return;
navigator.clipboard.writeText(text).then(() => showToast('已复制'));
}
async function refreshStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
setStatePill(els.webState, data.webRunning);
setStatePill(els.tunnelState, data.tunnelRunning);
els.mode.textContent = data.config?.mode ?? '-';
els.port.textContent = data.config?.localPort ?? '-';
els.publicUrlInput.value = data.tunnelUrl || '';
els.fileUrlInput.value = data.fileUrl || '';
updateQRCode(data.tunnelUrl);
els.logs.value = data.logTail || '';
if (els.autoScroll.checked) els.logs.scrollTop = els.logs.scrollHeight;
els.lastUpdate.textContent = new Date().toLocaleTimeString();
els.errorBox.style.display = 'none';
} catch (e) {
els.errorBox.textContent = '连接失败: ' + String(e);
els.errorBox.style.display = 'block';
}
}
async function loadLogs() {
try {
const res = await fetch('/api/logs');
const data = await res.json();
els.logs.value = data.logTail || '';
if (els.autoScroll.checked) els.logs.scrollTop = els.logs.scrollHeight;
} catch {}
}
async function loadHistory() {
try {
const res = await fetch('/api/history');
const data = await res.json();
renderHistory(data.history || []);
} catch {}
}
async function clearHistory() {
try {
await fetch('/api/history/clear', { method: 'POST' });
loadHistory();
} catch {}
}
function autoFillLast() {
const saved = localStorage.getItem('cfShareLastConfig');
if (!saved) { showToast('没有上次记录'); return; }
const cfg = JSON.parse(saved);
els.modeSelect.value = cfg.mode || 'port';
els.portInput.value = cfg.localPort || '';
els.dirInput.value = cfg.webDir || '';
els.fileInput.value = cfg.filePath || '';
els.routeInput.value = cfg.fileRoute || '';
syncMode();
showToast('已恢复');
}
function applyHistory(idx) {
const saved = localStorage.getItem('cfShareHistory');
if (!saved) return;
const list = JSON.parse(saved);
const h = list[idx];
if (!h) return;
els.modeSelect.value = h.mode || 'port';
els.portInput.value = h.localPort || '';
els.dirInput.value = h.webDir || '';
els.fileInput.value = h.filePath || '';
els.routeInput.value = h.fileRoute || '';
syncMode();
showToast('已填充');
}
async function startShare() {
els.errorBox.style.display = 'none';
const mode = els.modeSelect.value;
const body = {};
if (els.portInput.value) body.port = Number(els.portInput.value);
if (mode === 'dir' && els.dirInput.value.trim()) body.dir = els.dirInput.value.trim();
if (mode === 'file' && els.fileInput.value.trim()) body.file = els.fileInput.value.trim();
if (mode === 'file' && els.routeInput.value.trim()) body.route = els.routeInput.value.trim();
localStorage.setItem('cfShareLastConfig', JSON.stringify({
mode, localPort: body.port, webDir: body.dir, filePath: body.file, fileRoute: body.route,
}));
try {
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
els.output.textContent = data.output || '(无输出)';
if (!data.ok) {
els.errorBox.textContent = '启动失败: ' + (data.output || '');
els.errorBox.style.display = 'block';
}
await refreshStatus();
await loadHistory();
} catch (e) {
els.errorBox.textContent = '请求失败: ' + String(e);
els.errorBox.style.display = 'block';
}
}
async function stopShare() {
els.errorBox.style.display = 'none';
try {
const res = await fetch('/api/stop', { method: 'POST' });
const data = await res.json();
els.output.textContent = data.output || '(无输出)';
await refreshStatus();
await loadHistory();
} catch (e) {
els.errorBox.textContent = '请求失败: ' + String(e);
els.errorBox.style.display = 'block';
}
}
function saveHistoryToLocal(data) {
if (!data.config || !data.tunnelUrl) return;
const item = {
...data.config,
tunnelUrl: data.tunnelUrl,
fileUrl: data.fileUrl,
stoppedAt: new Date().toISOString(),
};
const saved = localStorage.getItem('cfShareHistory');
let list = saved ? JSON.parse(saved) : [];
list.unshift(item);
list = list.slice(0, 20);
localStorage.setItem('cfShareHistory', JSON.stringify(list));
renderHistory(list);
}
setThemeMode(getThemeMode());
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
if (getThemeMode() === 'auto') applyTheme('auto');
});
syncMode();
refreshStatus();
loadHistory();
pollTimer = setInterval(() => { refreshStatus(); loadHistory(); }, 4000);
</script>
</body>
</html>