返回文章索引
TRANSMISSION / CYAN35 分钟阅读

StunDeck 实战:飞牛 fnOS FPK、Docker 与 Cloudflare 302

在飞牛 fnOS 手动安装 StunDeck FPK,或使用 Docker 部署;再配置 Cloudflare 最小权限 API Token、代理 DNS 与 Single Redirect,让固定域名跟随动态 STUN 公网地址。

#StunDeck#fnOS#FPK#Docker#STUN#UPnP#Cloudflare#302#Homelab

视频讲解 / 16:9

StunDeck 完整教程:Docker、Cloudflare 302/307 与 STUN 诊断

6 分钟完整操作演示,从部署、首次初始化和最小权限 Token,一路讲到映射服务、UPnP/NAT-PMP、六层诊断与独立外网验证。

6:15中文讲解10 章节

点击播放即可观看完整讲解;支持进度拖动、画中画与全屏播放。

这篇教程的重点不是单独把 STUN 跑通,而是让用户始终访问一个固定域名:StunDeck 发现公网 IP 或动态端口变化后,自动更新 Cloudflare Single Redirect;Cloudflare 对入口域名返回 302(或 307),客户端再访问新的公网映射。

部署仍从 Docker 开始,但配置顺序必须是:部署 StunDeck → 创建 Cloudflare Token → 保存 Cloudflare 连接 → 创建 Redirect 服务 → 启动 STUN/UPnP → 验证 302 与第二跳。Cloudflare 不能等服务建完才补,否则创建服务时没有可选连接,也无法验证自动同步。

最终实测结果:

  • Debian 13.6,amd64;
  • Docker Engine 29.7.1,Docker Compose 5.3.1;
  • StunDeck 镜像对应公开仓库提交 bcb5a1a
  • Docker 服务环境没有代理,StunDeck 容器内的代理变量全部为空;
  • NATMap 成功取得映射,UPnP 已把第一跳监听端口放行;
  • 独立外网主机直连动态公网地址返回 HTTP 200;固定入口复查返回 HTTP 302 和 Location
  • 没有把真实用户名、密码、Token、内网地址、公网 IP 或动态端口写入本文与配图。

STUN 不是中继,也不能把对称型 NAT 或受限 CGNAT 变成公网入口。本文记录的是在当前测试网络中验证成功的部署流程,不代表所有宽带环境都能复现相同结果。

飞牛 fnOS:直接安装正式 FPK

上架状态(2026-08-26):StunDeck 已提交飞牛应用中心上架审核。审核通过并公开上架前,可以先手动安装这里提供的正式 FPK。这里的飞牛安装包格式是 FPK,不是 Android APK。

当前正式版本为 StunDeck v0.1.1,支持 linux/amd64linux/arm64。博客保留一份经过校验的正式发布包,同时提供 GitHub Release 作为备用来源:

安装步骤:

  1. 下载 FPK,并在本机核对 SHA-256;
  2. 在飞牛 fnOS 打开「应用中心 → 设置 → 手动安装应用」;
  3. 选择 stundeck-fpk-0.1.1.fpk,按向导设置时区;只有通过 HTTPS 反向代理访问时才启用安全 Cookie;
  4. 首次安装会从 20000–59999 选择一个空闲端口并持久化保存,升级和重启不会重新随机;
  5. 从飞牛桌面打开 StunDeck,创建管理员,再添加 Cloudflare Token、映射服务与 Webhook。

FPK 固定拉取不可变镜像 ghcr.io/nciae-zyh/stundeck:v0.1.1,因此 NAS 首次安装时需要能够访问 GHCR。安装包和向导不会预置或收集 Cloudflare Token、Webhook Secret 等秘密。

fnOS 会管理应用配置与持久化目录,真实数据位于包的 ${TRIM_PKGVAR}/data。升级、迁移或重装前,必须把 stundeck.dbmaster.key 成对备份;缺少 master.key 时,数据库中加密保存的 Cloudflare Token、Webhook Secret 与 TOTP Secret 无法恢复。卸载向导默认保留数据,只有明确选择“永久删除数据”时才会删除它。

先理解重点链路

用户始终访问固定域名
  └─ Cloudflare 代理 DNS(橙色云)
       └─ Single Redirect 返回 302 / 307 + Location
            └─ 当前公网 IP:动态端口
                 └─ 主路由 UPnP / NAT-PMP / 手动端口映射
                      └─ StunDeck + NATMap
                           └─ 局域网目标服务

固定的是入口域名,不是公网端口。Cloudflare 只处理第一跳:它返回重定向响应,不会为第二跳提供 Tunnel、中继、WAF 或 Access。浏览器看到 Location 后会直接连接公网 IP 与动态端口。

这里有两个独立成功条件:

  1. 固定域名返回正确的 302/307,证明 DNS 与 Single Redirect 已同步;
  2. 跟随 Location 后业务返回 200,证明 STUN、网关放行、防火墙与目标服务均可用。

只看到第一个结果,不能把服务标记为“公网可用”。

1. 部署前检查

准备一台 Linux 主机,建议满足:

  • Debian 13/12 或 Docker 官方支持的其他发行版;
  • amd64 或 arm64;
  • 固定或可识别的局域网地址;
  • 能访问局域网目标服务;
  • 主路由允许按需启用 UPnP,或者你能配置手动端口转发;
  • 准备一个无敏感数据、可以随时关闭的测试服务。

Lucky 的 STUN 文档建议在 NAT1 环境使用,并说明局域网主机需要 UPnP、端口转发或 DMZ 等额外条件。不同设备对 NAT 类型的命名并不统一,所以最终应以 STUN 日志与真正的外网回连为准。

不要把 NAS 管理后台、路由器后台或 StunDeck 控制面当作第一条公网测试规则。先使用静态测试页或临时 HTTP 服务。

2. 安装 Docker Engine 与 Compose

下面按 Docker 官方 Debian 安装文档配置 apt 仓库。Docker Compose 使用官方 docker-compose-plugin,不要再安装旧版独立 docker-compose

sudo apt update
sudo apt install -y ca-certificates curl

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<'EOF'
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: trixie
Components: stable
Architectures: amd64
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
sudo apt install -y \
  docker-ce \
  docker-ce-cli \
  containerd.io \
  docker-buildx-plugin \
  docker-compose-plugin

如果不是 Debian 13 amd64,把 SuitesArchitectures 改成实际值。可以用下面两条命令确认:

. /etc/os-release && echo "$VERSION_CODENAME"
dpkg --print-architecture

下载需要代理时,只让安装阶段使用

测试中,Docker 官方 GPG 地址有一次直连被重置。临时代理只用于下载,不应写进 /etc/environment,也不应长期留在 Docker systemd 服务中。

一个进程级示例:

PROXY_URL=http://192.168.50.9:7890

sudo curl --proxy "$PROXY_URL" -fsSL \
  https://download.docker.com/linux/debian/gpg \
  -o /etc/apt/keyrings/docker.asc

sudo env http_proxy="$PROXY_URL" https_proxy="$PROXY_URL" \
  apt update

unset PROXY_URL

如果仓库持续不稳定,可以按 Docker 官方文档的“Install from a package”下载与系统匹配的 .deb,先校验文件,再在主机上安装。不要从网盘或不明镜像站获取 Docker 安装包。

安装后验证:

sudo docker version
sudo docker compose version
sudo systemctl is-enabled docker
sudo systemctl is-active docker
sudo systemctl show docker -p Environment

最后一条在本次环境中输出:

Environment=

这能证明 Docker daemon 没有继承常驻代理。它还不能证明容器没有代理,后面会再检查一次。

根据本次真实命令输出整理;主机名、登录身份、局域网地址和临时下载代理均已移除。

3. 编写 Compose 配置

创建目录:

sudo install -d -m 0755 /opt/stundeck
cd /opt/stundeck

保存为 /opt/stundeck/compose.yaml

services:
  stundeck:
    image: ghcr.io/nciae-zyh/stundeck:latest
    network_mode: host
    restart: unless-stopped
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp:size=16m,mode=1777
    volumes:
      - stundeck-data:/var/lib/stundeck
    environment:
      STUNDECK_LISTEN: 0.0.0.0:8080
      STUNDECK_DATA_DIR: /var/lib/stundeck
      STUNDECK_SECURE_COOKIES: "false"
      TZ: Asia/Shanghai
      HTTP_PROXY: ""
      HTTPS_PROXY: ""
      ALL_PROXY: ""
      http_proxy: ""
      https_proxy: ""
      all_proxy: ""

volumes:
  stundeck-data:

几个关键点:

  • network_mode: host:NATMap 和 UPnP 需要看到 Linux 主机的真实网络栈;
  • read_onlycap_drop: ALLno-new-privileges:减少容器权限;
  • 只持久化 /var/lib/stundeck
  • 显式清空大小写代理变量;
  • 局域网 HTTP 首次部署使用 STUNDECK_SECURE_COOKIES: "false";如果以后由 HTTPS 反向代理访问,应改为 "true"

不要使用 Docker Desktop 作为正式 STUN 网关,也不要为了省事加入 privileged: true

4. 拉取并启动

cd /opt/stundeck
sudo docker compose config --quiet
sudo docker compose pull
sudo docker compose up -d --no-build
sudo docker compose ps

本次环境的健康状态在约 10 秒后变为 healthy。继续验证:

curl -fsS http://127.0.0.1:8080/api/v1/health

sudo docker inspect stundeck-stundeck-1 \
  --format '{{range .Config.Env}}{{println .}}{{end}}' \
  | grep -i proxy

健康接口应返回 status: ok。代理检查会看到 6 个变量,但等号右侧都必须为空。镜像拉取时即使临时使用过代理,也要在启动 StunDeck 前撤销 daemon 代理并重启 Docker。

本次容器进入 healthy,健康接口返回 ok,容器内的大小写代理变量全部为空。画面未保留主机地址或容器标识。

5. 首次初始化与安全边界

从局域网打开:

http://服务器局域网地址:8080

首次进入需要自行创建管理员:

  1. 使用唯一用户名和至少 12 位的强密码;
  2. 访问模式选择“局域网”;
  3. 如果已经确定反向代理域名,可以填入允许访问的 Host;
  4. 登录后进入“安全设置”,再配置允许 Host 与验证器 2FA;
  5. 不要直接把 8080 控制面映射到公网。

一次性空数据环境中的真实首次初始化页面;用户名、密码与确认密码都没有填写。

域名 stun-lab-20260802.sparkles-editor.com 经本人确认可以公开,仅用于演示 Host 白名单;截图环境未保存该值。画面没有 DNS 记录、Zone/Rule ID、Token、TOTP 密钥或管理员身份。

如果必须远程管理控制面,优先使用 Cloudflare Tunnel、VPN 或其他带身份认证的反向代理。STUN 动态映射更适合发布可控业务端口,不适合暴露管理后台。

6. 先在 Cloudflare 创建最小权限 API Token

Single Redirect 只会处理经过 Cloudflare 代理的请求,因此入口域名必须存在于目标 Zone,并使用代理 DNS(橙色云)。本文不展示 DNS 记录值、Zone ID、Ruleset ID 或其他域名。

进入 Cloudflare 右上角用户菜单,打开“个人资料 → API 令牌”,点击“创建令牌”。

选择“创建自定义令牌”,不要使用 Global API Key。

建议使用可识别的名称,例如 StunDeck Redirect · sparkles-editor.com,然后添加三条 Zone 权限:

资源权限级别用途
区域区域读取查找并校验目标 Zone
区域DNS编辑自动创建或维护代理 DNS 记录
区域单一重定向编辑创建和更新 302/307 Single Redirect

在“区域资源”中选择“包括 → 特定区域 → sparkles-editor.com”。不要授权所有 Zone。若你关闭 StunDeck 的“自动管理 DNS”,可以不授予 DNS 编辑,但必须自行保证入口记录存在且处于代理状态。

客户端 IP 筛选只适合管理端出口固定的环境。家庭宽带出口会变化时,不要误把当前公网 IP 写死,否则 StunDeck 之后可能无法同步。可以按运维周期设置 Token 到期日。

点击“继续以显示摘要”,确认目标 Zone 只有三项权限:

最后点击“创建令牌”。Token 明文只显示一次:立即放入密码管理器并粘贴到 StunDeck,不要截图、不要写入 Compose、Markdown、Git、日志或聊天记录。本文为了避免创建不必要的长期凭据,截图停在复核页,没有展示或保存任何 Token 值。

7. 在 StunDeck 保存并检测 Cloudflare 连接

先进入 StunDeck 的“Cloudflare”页面,再创建映射服务:

  1. 连接名称填写易识别的名称;
  2. 粘贴刚创建的 API Token;
  3. 点击“检测权限”;
  4. 只在 Zone、DNS 和 Single Redirect 检测都通过后保存。

来自重建后的真实管理页面。输入框为空,截图不含 Token、账户名、DNS 值或任何资源 ID。

凭据会由 StunDeck 使用本地主密钥加密保存。备份时必须同时保留 SQLite 数据库与 master.key;缺少后者,连接凭据无法恢复。

8. 创建以 302 为核心的 Redirect 服务

现在进入“映射服务”,使用一项可以随时关闭的 HTTP 测试服务:

服务名称:家庭 NAS(示例)
局域网目标:192.168.1.20(示例)
目标端口:80(示例)
协议:TCP
监听端口:0(自动分配)
路由器端口放行:UPnP(推荐)
发布方式:Cloudflare Redirect
Cloudflare 连接:选择上一步保存的连接
入口域名:stun-lab-20260802.sparkles-editor.com
目标协议:HTTP
跳转状态:302 Temporary
自动管理 DNS:开启
保留路径:开启
保留查询参数:开启

真实页面中的文档表单;局域网地址和端口均为占位示例,Cloudflare 连接未保存,未提交这条服务。画面上方的实际服务内容已裁掉。

302 还是 307

状态客户端行为推荐场景
302 Temporary临时跳转;部分客户端会把 POST 改成 GET普通网页、下载页、以 GET 为主的服务,本文默认
307 Temporary Redirect临时跳转并保留 HTTP 方法与请求体API、Webhook、上传或不能改变 POST 语义的服务

两者都不会被当作永久跳转缓存。不要使用 301/308 表示会持续变化的 STUN 端点。

“保留路径”和“保留查询参数”开启后,访问 /files?a=1 会把路径与查询串拼到当前动态目标。使用 HTTPS 目标时还必须填写“HTTPS 目标域名”,并保证目标证书覆盖该域名;直接跳到公网 IP 通常无法通过证书主机名校验。

自动 DNS 只维护 StunDeck 自己创建并带有管理标记的记录。若同名记录由其他系统管理,StunDeck 会停止同步而不是强行覆盖;应先处理冲突,不能删除整组 DNS 后重建。

9. 启动服务并完成 STUN / UPnP 检测

点击“创建服务”,再点击“启动”。正常状态会依次经过:

已停止 → 探测中 → 路由已放行 → Cloudflare 已同步

StunDeck 的 UPnP 逻辑会把穿透监听端口映射到同一个本地端口,而不是把最终动态公网端口误当成本地端口。随后它把 NATMap 发现的公网 IP 与端口写入 Single Redirect 的目标。

点击服务卡片上的“STUN 检测”,逐项检查:

  • 代理环境:Docker daemon 与容器都必须没有常驻代理;
  • NATMap 程序:二进制可执行;
  • 目标端口:StunDeck 主机能连接局域网服务;
  • UDP/TCP STUN:至少与你所选协议对应的 Binding 成功;
  • 路由器能力:发现正确的 UPnP IGD 或 NAT-PMP 网关;
  • 路由器端口映射:映射已下发;
  • 公网映射:已取得地址且更新时间合理;
  • Cloudflare 同步:DNS 与 Redirect Rule 均已完成或明确报告冲突。

真实诊断结果的安全裁切;公网 IP、动态端口、内网地址与网关地址均不在画面中。

真实环境概览;没有展示服务地址、映射地址、凭据或 Cloudflare 配置。

10. 验证固定域名的 302 与第二跳

先禁止 curl 自动跟随跳转,只检查 Cloudflare 第一跳:

curl -sS -o /dev/null -D - --max-redirs 0 \
  https://stun-lab-20260802.sparkles-editor.com/

应看到 HTTP/2 302(选择 307 时则为 307)以及 Location。Cloudflare 规则列表也应显示同一入口域名、302 状态与活动状态:

真实 Cloudflare 规则行;仅保留经允许公开的入口域名和 302 状态,动态公网目标、账户信息、其他规则与资源 ID 均未进入画面。

再从手机蜂窝网络或独立外网主机验证第二跳:

curl -sS -L --max-time 15 -o /dev/null \
  -w 'status=%{http_code} redirects=%{num_redirects}\n' \
  https://stun-lab-20260802.sparkles-editor.com/

目标是 status=200 redirects=1。本次重建中,独立外网主机直连新的动态映射得到 HTTP 200;固定入口也实测返回 302 和 Location。不过截图所示的旧隔离服务随后已下线,所以它现在只能证明第一跳规则仍生效,不能作为第二跳在线承诺。教程因此明确要求每次映射变化后同时检查这两层。

不要把真实 Location、公网 IP 与端口贴在文章、截图或工单里。它会变化,也会增加不必要的扫描风险。

11. 常见故障定位

已取得公网映射,但浏览器是 ERR_EMPTY_RESPONSE

按顺序检查:

  1. 局域网目标是否从 StunDeck 主机可达;
  2. 目标服务是否监听正确地址,而不是只监听 127.0.0.1
  3. 容器是否使用 host network;
  4. StunDeck 服务是否选择了正确的网关模式;
  5. UPnP/NAT-PMP 是否在真实出网网关下发映射;
  6. 多层 NAT 时,上一级光猫或路由器是否仍阻断入站;
  7. 是否用真正的外网测试,而不是在不支持 NAT Loopback 的局域网里访问公网地址。

STUN 时好时坏

  • 确认 Docker daemon 与容器都没有代理;
  • 关闭会改变出口的透明代理、策略路由或多 WAN 规则后对比;
  • 保持监听端口唯一;
  • 不要把动态端口当成固定端口写入客户端;
  • 观察映射更新时间与 NATMap 进程是否持续运行;
  • 对称型 NAT、运营商 CGNAT 或入站过滤严格时,改用 Tunnel、VPN 或中继方案。

固定域名没有返回 302/307

按顺序检查:

  1. 入口 DNS 是否为代理状态(橙色云);
  2. Token 是否限定到正确 Zone;
  3. 是否具备 Zone 读取与单一重定向编辑权限;
  4. 开启自动 DNS 时,是否额外具备 DNS 编辑权限;
  5. 同名 DNS 或 Redirect Rule 是否由其他系统管理而发生冲突;
  6. Cloudflare Rules 中是否存在 http_request_dynamic_redirect 阶段的活动规则。

302 能打开入口,但第二跳失败

302 只更新入口。直接请求动态公网地址;如果仍失败,问题在 STUN、路由器映射、防火墙或目标服务,而不是 Cloudflare Redirect Rule。

12. 更新、备份与卸载

更新前把 SQLite 数据库和 master.key 一起备份,二者缺一不可。Compose 命名卷通常为 stundeck_stundeck-data,先确认实际名称:

sudo docker volume ls
sudo docker volume inspect stundeck_stundeck-data

更新:

cd /opt/stundeck
sudo docker compose pull
sudo docker compose up -d --no-build
sudo docker compose ps

停止但保留数据:

sudo docker compose down

只有确定不要恢复时才删除卷:

sudo docker compose down -v

down -v 会删除管理员、服务、事件记录、Cloudflare 加密凭据以及本地主密钥,无法通过重新拉取镜像恢复。

参考资料

这次重建最重要的结论是把验证拆成五层:目标端口可达、STUN Binding 成功、网关映射已下发、Cloudflare 固定入口返回 302/307、独立外网跟随跳转后业务成功。固定域名解决的是“入口不变”,不是“第二跳自动获得隧道保护”。

文章结束
READER CHANNEL

留言

00
还没有留言。成为第一个发出回应的人。