零信任 = 绝对安全:CF 边缘白名单,无人能碰你的飞牛 NAS
关键词:Cloudflare、Zero Trust、Access Policy、IP 白名单、内网穿透、安全
写在前面
自用NAS上的服务暴露到公网,最怕什么?不是带宽不够,不是配置复杂——是别人也能访问。
哪怕用了 Cloudflare Tunnel,域名还是暴露的,谁都可以试试能不能打进去。
这套方案的核心是零信任:默认谁也不信,只信你的 IP。不在白名单里的请求,CF 边缘直接拒绝,连 NAS 的门都摸不到。
不止省资源(NAS 0 负载),更重要的是绝对安全——攻击流量在云端就被截停了。
搭建方式
本文提供两种方式:
- 方式 A(AI 快速部署):适合不想手打命令行的用户,把信息喂给 AI 让它操作
- 方式 B(手动部署):适合想自己一步一步跟着走的用户
两种方式最终效果完全一样,选你觉得舒服的就行。
一、GitHub 项目
所有代码放这里了:
https://github.com/mydelren/cloudflare-worker-ip-whitelist
里面有 Worker 代码、wrangler 部署模板、中英文 README、手机自动触发脚本。
如果能给个 Star,万分感谢。
二、思路
你的手机/电脑 → Cloudflare 边缘 → IP 在白名单?→ 放行 → 到达飞牛 NAS
→ IP 不在? → 直接拒绝(连握手都没有)
这套方案的好处:
- NAS 上 0 额外开销:不用跑 WAF、不用跑反代,省下的内存给真正的服务
- ARM 低配 NAS 最友好:雷池(SafeLine)要 300MB+ 内存,这方案 0MB
- 全都是免费额度:CF Tunnel 免费、CF Access 免费、CF Workers 免费(10 万次/天)
前提:你的服务是自用的,不是给陌生人公开的。
三、日常怎么用
收藏夹存一个 URL,平时正常访问。
IP 变了被拦截了?点一下收藏夹,5 秒恢复。
不需要装 App,不需要学配置,不需要在 NAS 上额外跑容器。
四、原理
1. 手机打开 sync URL(Worker 返回 HTML 页面)
2. Worker 服务端记录 CF-Connecting-IP
3. 页面 JS 同时探测 IPv4(api.ipify.org)+ IPv6(api64.ipify.org)
4. 每个 IP 调用 add 接口 → 写入 KV 存储(每设备最多 8 个 IP)
5. Worker 从 KV 读取所有设备的 IP 列表
6. 合并固定 IP(如有)+ 动态 IP → 重建 include 数组
7. PUT 更新 CF Access Policy → 边缘 1-2 秒生效
五、方式 A:AI 快速部署
如果你不想手打命令行,把下面这些信息喂给 AI 工具,让它帮你操作。
适合的 AI 工具:需要支持运行终端命令的,比如 opencode、Claude Code、Cursor、TRAE、OpenClaw、Hermes Agent 等。网页版 AI(豆包、Kimi 网页版等)无法操作你的电脑,只能给建议,不适合这种方式。
5.1 准备信息
| 信息 |
在哪找 |
| 域名 |
你自己的 |
| CF Account ID |
CF Dashboard 右上角 → 点邮箱 |
| CF Access Policy ID |
Zero Trust → Access → Policies → 点你的策略 → 地址栏 UUID |
| CF API Token |
右上角 → My Profile → API Tokens → 创建新 Token。权限选:Workers Scripts Edit、KV Storage Edit、Access: Apps and Policies Edit |
5.2 喂给 AI
打开你的 AI 工具(比如 opencode、Claude Code、OpenClaw 等),把下面这段话发出去,替换成你自己的信息:
我有一台飞牛 NAS,没有公网 IP,用 Cloudflare Tunnel 做内网穿透。
请参考这个 GitHub 项目帮我部署:
https://github.com/mydelren/cloudflare-worker-ip-whitelist
我的信息:
- 域名:nas.你的域名.com
- CF Account ID:d20a63605308...
- CF Access Policy ID:5800b1ea-f35d-...
- CF API Token:cfut_...
请帮我一步步来。
5.3 你只需要配合几步
AI 会按照项目 README 指引你操作,你需要做的只有:
- 浏览器里创建 CF Access Policy(AI 会告诉你填什么)
- 给 Worker 配自定义域名(告诉你在哪点)
- 手机上收藏 sync URL
六、方式 B:手动部署(保姆级)
全部在 Cloudflare Dashboard 网页上操作,不需要装任何软件。
6.1 前置条件
- 一个域名,托管到 Cloudflare
- Cloudflare Tunnel 已经跑起来了
6.2 创建 Worker
- 打开 CF Dashboard → Workers & Pages
- 点 Create application → Create Worker
- 给你的 Worker 起个名字,比如
cf-ip-whitelist
- 点 Deploy
6.3 粘贴代码
- 在 Worker 编辑页面,全选
worker.js 默认代码,删掉
- 粘贴下面代码(来自 GitHub 项目,可以直接复制):
// Cloudflare Worker: Access Policy IP Whitelist Auto-Updater
// https://github.com/mydelren/cloudflare-worker-ip-whitelist
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders() });
}
const key = url.searchParams.get("key");
const action = url.searchParams.get("action") || "sync";
if (!key) {
return htmlPage("Error", "<p>Missing key parameter</p>", 403);
}
const device = validateKey(key, env);
if (!device) {
return htmlPage("Error", "<p>Invalid key</p>", 403);
}
try {
if (action === "sync") {
return await handleSync(request, device, key, env);
} else if (action === "add") {
const ip = url.searchParams.get("ip");
if (!ip) return jsonResponse({ success: false, error: "Missing ip parameter" }, 400);
return await handleAdd(device, ip, env);
} else if (action === "list") {
return await handleList(device, env);
} else if (action === "remove") {
const ip = url.searchParams.get("ip");
if (!ip) return jsonResponse({ success: false, error: "Missing ip parameter" }, 400);
return await handleRemove(device, ip, env);
} else {
return htmlPage("Error", "<p>Unknown action</p>", 400);
}
} catch (e) {
return htmlPage("Error", `<p>${e.message}</p>`, 500);
}
},
};
const MAX_IPS_PER_DEVICE = 8;
const DEVICE_KEYS_MAP_KEY = "meta:device_keys";
function validateKey(key, env) {
const devices = [
{ key: env.KEY_DEVICE_1, name: "device1" },
{ key: env.KEY_DEVICE_2, name: "device2" },
];
const match = devices.find((d) => d.key && d.key === key);
return match ? match.name : null;
}
async function handleSync(request, device, key, env) {
const cfIp = request.headers.get("CF-Connecting-IP");
let cfResult = null;
if (cfIp) {
cfResult = await addIpToDevice(device, cfIp, env);
}
const baseUrl = new URL(request.url).origin;
const body = `
<div id="status">
<h2>Updating whitelist...</h2>
<div id="cf-ip" class="item">
<span class="label">Connection IP:</span>
<span class="value">${cfIp || "unknown"}</span>
<span class="badge ${cfResult?.changed ? "added" : "ok"}">${cfResult?.changed ? "Added" : "Exists"}</span>
</div>
<div id="ipv4" class="item">
<span class="label">IPv4:</span>
<span class="value" id="v4">Detecting...</span>
<span class="badge" id="v4badge"></span>
</div>
<div id="ipv6" class="item">
<span class="label">IPv6:</span>
<span class="value" id="v6">Detecting...</span>
<span class="badge" id="v6badge"></span>
</div>
</div>
<div id="summary"></div>
<div id="entries"></div>
<script>
const BASE = "${baseUrl}";
const KEY = "${key}";
const CF_IP = "${cfIp || ""}";
async function probe(url, timeout) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeout);
try {
const r = await fetch(url, { signal: ctrl.signal });
clearTimeout(t);
return (await r.text()).trim();
} catch(e) {
clearTimeout(t);
return null;
}
}
async function addIp(ip) {
if (!ip || ip === CF_IP) return { changed: false, skip: true };
try {
const r = await fetch(BASE + "/?key=" + KEY + "&action=add&ip=" + encodeURIComponent(ip));
return await r.json();
} catch(e) {
return { error: e.message };
}
}
function setBadge(id, result) {
const el = document.getElementById(id);
if (!el) return;
if (result.skip) { el.textContent = "Same as connection IP"; el.className = "badge ok"; }
else if (result.error) { el.textContent = "Failed"; el.className = "badge err"; }
else if (result.changed) { el.textContent = "Added"; el.className = "badge added"; }
else { el.textContent = "Exists"; el.className = "badge ok"; }
}
async function run() {
const [v4, v6] = await Promise.all([
probe("https://api.ipify.org", 5000),
probe("https://api64.ipify.org", 5000)
]);
document.getElementById("v4").textContent = v4 || "Unavailable";
document.getElementById("v6").textContent = v6 || "Unavailable";
let r4 = { skip: true }, r6 = { skip: true };
if (v4 && v4 !== CF_IP) {
r4 = await addIp(v4);
}
if (v6 && v6.includes(":") && v6 !== CF_IP) {
r6 = await addIp(v6);
}
setBadge("v4badge", v4 ? (v4 === CF_IP ? { skip: true } : r4) : { error: "N/A" });
setBadge("v6badge", v6 && v6.includes(":") ? (v6 === CF_IP ? { skip: true } : r6) : { error: "N/A" });
const added = [cfIpAdded(), r4.changed, r6.changed].filter(Boolean).length;
document.getElementById("summary").innerHTML =
'<p class="done">Done! ' + (added > 0 ? added + ' IP(s) added' : 'No new IPs') + '</p>';
try {
const lr = await fetch(BASE + "/?key=" + KEY + "&action=list");
const ld = await lr.json();
if (ld.success) {
let html = '<h3>Current whitelist (' + ld.count + '/' + ld.max + ')</h3><ul>';
for (const e of ld.entries) {
html += '<li>' + e.ip + ' <small>' + e.ago + '</small></li>';
}
html += '</ul>';
document.getElementById("entries").innerHTML = html;
}
} catch(e) {}
}
function cfIpAdded() { return ${cfResult?.changed ? "true" : "false"}; }
run();
</script>`;
return htmlPage("IP Whitelist - " + device, body);
}
async function handleAdd(device, ip, env) {
const result = await addIpToDevice(device, ip, env);
return jsonResponse(result);
}
async function addIpToDevice(device, ip, env) {
const kvKey = "device:" + device;
const raw = await env.DEVICE_IPS.get(kvKey, "json");
let entries = Array.isArray(raw) ? raw : [];
const existing = entries.find((e) => e.ip === ip);
if (existing) {
existing.ts = Math.floor(Date.now() / 1000);
await env.DEVICE_IPS.put(kvKey, JSON.stringify(entries));
await syncPolicy(env);
return { success: true, changed: false, ip, device, entries: entries.length };
}
entries.push({ ip, ts: Math.floor(Date.now() / 1000) });
if (entries.length > MAX_IPS_PER_DEVICE) {
entries.sort((a, b) => b.ts - a.ts);
entries = entries.slice(0, MAX_IPS_PER_DEVICE);
}
await env.DEVICE_IPS.put(kvKey, JSON.stringify(entries));
await registerDevice(device, env);
await syncPolicy(env);
return { success: true, changed: true, ip, device, entries: entries.length, max: MAX_IPS_PER_DEVICE };
}
async function handleList(device, env) {
const kvKey = "device:" + device;
const raw = await env.DEVICE_IPS.get(kvKey, "json");
const entries = Array.isArray(raw) ? raw : [];
return jsonResponse({
success: true,
device,
entries: entries.sort((a, b) => b.ts - a.ts).map((e) => ({
ip: e.ip,
lastSeen: new Date(e.ts * 1000).toISOString(),
ago: timeAgo(e.ts),
})),
count: entries.length,
max: MAX_IPS_PER_DEVICE,
});
}
async function handleRemove(device, ip, env) {
const kvKey = "device:" + device;
const raw = await env.DEVICE_IPS.get(kvKey, "json");
let entries = Array.isArray(raw) ? raw : [];
const before = entries.length;
entries = entries.filter((e) => e.ip !== ip);
if (entries.length === before) {
return jsonResponse({ success: false, error: "IP not found for " + device }, 404);
}
await env.DEVICE_IPS.put(kvKey, JSON.stringify(entries));
await syncPolicy(env);
return jsonResponse({ success: true, action: "removed", ip, device, remaining: entries.length });
}
function getFixedEntries(env) {
const fixedIps = env.FIXED_IPS || "";
if (!fixedIps.trim()) return [];
return fixedIps
.split(",")
.map((ip) => ip.trim())
.filter(Boolean)
.map((ip) => ({ ip: { ip } }));
}
async function syncPolicy(env) {
const include = [...getFixedEntries(env)];
const list = await env.DEVICE_IPS.list({ prefix: "device:" });
for (const kvKey of list.keys) {
const raw = await env.DEVICE_IPS.get(kvKey.name, "json");
if (!Array.isArray(raw)) continue;
for (const entry of raw) {
const isV6 = entry.ip.includes(":");
include.push({
ip: { ip: isV6 ? entry.ip + "/128" : entry.ip + "/32" },
});
}
}
const policy = await cfFetch(env, "GET", `/access/policies/${env.POLICY_ID}`);
const result = policy.result;
await cfFetch(env, "PUT", `/access/policies/${env.POLICY_ID}`, {
name: result.name,
decision: result.decision,
session_duration: result.session_duration || "24h",
include,
exclude: result.exclude || [],
require: result.require || [],
});
}
async function registerDevice(device, env) {
const raw = await env.DEVICE_IPS.get(DEVICE_KEYS_MAP_KEY, "json");
const keys = Array.isArray(raw) ? raw : [];
if (!keys.includes(device)) {
keys.push(device);
await env.DEVICE_IPS.put(DEVICE_KEYS_MAP_KEY, JSON.stringify(keys));
}
}
async function cfFetch(env, method, path, body) {
const opts = {
method,
headers: {
Authorization: "Bearer " + env.CF_API_TOKEN,
"Content-Type": "application/json",
},
};
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.ACCOUNT_ID}${path}`,
opts
);
const data = await resp.json();
if (!data.success) {
throw new Error("CF API: " + JSON.stringify(data.errors));
}
return data;
}
function timeAgo(ts) {
const diff = Math.floor(Date.now() / 1000) - ts;
if (diff < 60) return diff + "s ago";
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
return Math.floor(diff / 86400) + "d ago";
}
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data, null, 2), {
status,
headers: { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" },
});
}
function htmlPage(title, body, status = 200) {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#eee;padding:20px;min-height:100vh}
h2{color:#0ff;margin-bottom:16px;font-size:18px}
h3{color:#aaa;margin:20px 0 10px;font-size:14px}
.item{display:flex;align-items:center;gap:8px;padding:10px;margin:6px 0;background:#16213e;border-radius:8px;font-size:14px}
.label{color:#888;min-width:60px}
.value{flex:1;word-break:break-all;font-family:monospace;font-size:13px}
.badge{padding:2px 8px;border-radius:4px;font-size:12px;white-space:nowrap}
.badge.added{background:#0a3;color:#fff}
.badge.ok{background:#555;color:#ccc}
.badge.err{background:#a00;color:#fff}
.done{margin-top:16px;padding:12px;background:#0a3;border-radius:8px;text-align:center;font-weight:bold}
ul{list-style:none;padding:0}
li{padding:8px 10px;margin:4px 0;background:#16213e;border-radius:6px;font-family:monospace;font-size:13px}
li small{color:#888;margin-left:8px}
</style>
</head>
<body>
${body}
</body>
</html>`;
return new Response(html, {
status,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
}
- 点 Save and Deploy
6.4 创建 KV 命名空间并绑定
- CF Dashboard → Workers & Pages → KV
- 点 Create namespace,名称填
DEVICE_IPS
- 创建后回到 Worker 页面 → Settings → Variables
- 在 KV Namespace Bindings 点 Add binding
- Variable name:
DEVICE_IPS
- KV namespace: 选择刚创建的
DEVICE_IPS
- 点 Save
6.5 设置环境变量
同一个 Settings → Variables 页面,在 Environment Variables 部分添加以下变量(敏感的打勾 Encrypt):
| Variable name |
值 |
Encrypt |
CF_API_TOKEN |
你的 CF API Token(见下方说明) |
✅ |
ACCOUNT_ID |
你的 CF 账号 ID(右上角→邮箱→Account ID) |
|
POLICY_ID |
Access Policy ID(下一步创建完回来填) |
|
KEY_DEVICE_1 |
设备1的密钥(如 a1b2c3d4e5f6g7h8) |
✅ |
KEY_DEVICE_2 |
设备2的密钥 |
✅ |
FIXED_IPS |
固定 IP 段(可选,多个逗号隔开) |
|
点 Save and Deploy 让变量生效。
API Token 怎么创建:CF Dashboard 右上角 → My Profile → API Tokens → Create Token。权限选:Workers Scripts Edit、KV Storage Edit、Access: Apps and Policies Edit。
设备密钥怎么生成:浏览器打开 https://www.uuidgenerator.net/ 或任何随机字符串生成器,生成两个 32 位十六进制字符串。
6.6 创建 CF Access Policy
- CF Dashboard → Zero Trust → Access → Policies
- 点 Create a policy
- Policy Name 随便填,比如
手机白名单
- Action 选 Bypass(白名单里的直接放行)
- Include 规则添加一条,选择 IP ranges,先随便填
1.1.1.1 占位
- 创建完后,浏览器地址栏有一串 UUID,那就是 Policy ID。填回上一步的
POLICY_ID 环境变量
6.7 创建 Access Application(和 Policy 关联)
- CF Dashboard → Zero Trust → Access → Applications
- 点 Add an application → Self-hosted
- Application Name 随便填(比如
飞牛NAS)
- Subdomain 填你 Tunnel 用的域名,比如
nas.你的域名.com
- 在 Policies 页关联上一步创建的
手机白名单 策略
- Save
6.8 配自定义域名(重要!)
*.workers.dev 在国内被墙,必须配自己的域名。
- Worker 页面 → Settings → Domains & Routes
- 点 Add Custom Domain
- 输入
wl.你的域名.com
- CF 自动配 DNS 和 SSL
6.9 放行 Worker 域名
如果你有 *.你的域名.com 的通配符 Access Application,wl.你的域名.com 会被匹配拦截。
- Zero Trust → Access → Applications
- 创建新 Application:
- Name:
Worker放行
- Domain:
wl.你的域名.com
- Policy: Bypass → Everyone
- 把这个 Application 拖到最上面
6.10 生成设备密钥并收藏 URL
把下面两个 URL 加到手机/电脑浏览器收藏夹:
https://wl.你的域名.com/?key=设备1的密钥
https://wl.你的域名.com/?key=设备2的密钥
切网时打开对应收藏夹,5 秒更新白名单。
七、手机端使用
7.1 手动:打开收藏夹
把 URL 加到浏览器收藏夹:
https://wl.你的域名.com/?key=你的设备密钥
连到新 Wi-Fi 或者切了蜂窝后,打开这个收藏夹。页面会自动:
- 显示你的 IPv4 和 IPv6 地址
- 写入白名单
- 更新 CF Access Policy
7.2 进阶:自动触发(切网时自动更新)
iPhone(Shortcuts + Scriptable)
- App Store 装 Scriptable
- 新建脚本,内容:
const url = "https://wl.你的域名.com/?key=你的密钥";
Safari.open(url);
- Shortcuts → Automation → 创建两个自动化:
- Wi-Fi Is Connected → 运行 Scriptable 脚本
- Wi-Fi Is Disconnected → 运行 Scriptable 脚本
- 两个自动化都 关掉"Ask Before Running"
这样连 Wi-Fi / 断 Wi-Fi(切蜂窝)时自动更新白名单。
Android(HTTP Shortcuts)
- 酷安或 Google Play 装 HTTP Shortcuts
- 新建 Shortcut → Method: GET → URL 填同步地址
- 添加到桌面 Widget,切网时点一下
小米手机记得给 App 开自启动权限。
八、踩坑记录
1. workers.dev 无法访问
*.workers.dev 无法访问。需要配自定义域名。
2. Access 通配符拦截 Worker
如果你有 *.域名.com 的通配符 Application,Worker 子域名会被拦。解决方法:为 Worker 域名单独创建 Bypass 规则(见 6.9)。
3. IPv4 和 IPv6 都要记录
手机 Wi-Fi 下可能只有 IPv4,切蜂窝可能只有 IPv6。sync 页面同时探测两个地址并自动写入。
4. description 字段导致 CF API 报错
CF Access Policy 的 include 条目里不能带 description 字段。Node 代码已经排除了这个字段,直接用没问题。
5. CF API Token 权限要够
必须包含三个权限:Workers Scripts Edit、KV Storage Edit、Access: Apps and Policies Edit。缺少任何一个都会报错。
九、适用场景
- 飞牛 NAS 远程访问
- Home Assistant、Alist、Bitwarden 等自用服务
- 任何没有公网 IP 的内网服务
- ARM 低配 NAS(NAS 零负载)
不适合:需要公开访问的服务(博客、论坛)。
十、为什么不装本地 WAF
| 对比 |
本地 WAF(雷池/SafeLine) |
CF Access IP 白名单 |
| 拦截位置 |
NAS 本地(流量已到达) |
CF 边缘(到不了你) |
| 内存占用 |
300-500MB |
0MB |
| 配置难度 |
规则多、易误报 |
只管 IP |
| 攻击面 |
NAS 端**露 |
完全隐藏 |
对于 ARM 低配 NAS,这套方案是最省资源也最安全的选择。
十一、局限
- 每台新设备要生成一个密钥(
openssl rand -hex 16)
- 切网后需要触发一次更新(手动收藏夹或自动 Shortcuts)
- Workers 免费 10 万次/天(手动触发完全够用)
- CF Access Policy include 数组上限 1000 条(每设备 8 条,远超日常需求)
项目地址
https://github.com/mydelren/cloudflare-worker-ip-whitelist
给个 Star 支持一下,有问题提 Issue。欢迎回帖交流。