私人播客搭建指南(本地录音 → 不对外发布的 RSS)
场景:Tascam DR-05X 录的音频(讲道之外的个人用途,比如私下的谈话记录、不方便公开的内容),只想自己或指定的少数人用播客 App 收听(可调速、断点续播、离线下载),不提交 Apple Podcasts / Spotify 审核,不出现在任何公开目录。
架构选择说明:这一版指南采用"存储与出口分离、Pi 自己对外服务"的方案——原因是西班牙 LaLiga 转播期间会协调运营商封锁 Cloudflare、Backblaze 等多个 CDN/托管商代理网络的 IP 段打击盗播,而这些 IP 背后往往同时挂着几千个互不相关的正常网站,一并被误封。只要走的是"多租户共享 IP 代理转发流量",换哪家云厂商都治标不治本;真正解决办法是让实际提供服务的 IP 只属于你自己。被封的是 Cloudflare 的代理网络(橙色云朵),不是它的 DNS 解析服务,所以 DNS 这一层继续用 Cloudflare 完全没问题——只要关掉代理(灰色云朵,DNS-only),流量直接打到你家 IP,根本不经过 Cloudflare 网络。
1. 先明确一个关键前提:RSS 播客的"私有"本质上是"不可枚举",不是"加密"
播客协议本身没有账号登录的概念,播客 App 只认一件事:一个 RSS feed 的 URL。所谓"私有播客",行业里(Patreon、Supercast 这类会员播客平台)用的都是同一个原理:
- URL 本身包含一段无法被猜到的随机字符串,不做目录列表、不被搜索引擎收录、不提交到任何播客目录
- 只要知道这个 URL 就能订阅,所以"私有"约等于"不公开的地址",不是真正意义上的账号鉴权
自己一个人用,这个强度完全够。因为这一版是 Pi 自己对外提供服务(不是第三方托管),如果以后想分享给一两个人、要真正的访问控制,也可以在自己的 Web 服务器上直接加 HTTP Basic Auth(见第 6 节),比依赖第三方的"访问控制产品"更彻底——权限完全在自己手上,不用再考虑对方是不是又被列进了某个封锁名单。
2. 架构总览:Pi 400 直接对外服务,云存储只做离线备份
Tascam 录音 → inbox/ → 本地处理(复用现有 enhance_audio/whisper 函数)
│
▼
本地磁盘:~/private-podcast/media/
(这就是最终对外提供下载的文件,不上传到任何公开可访问的第三方)
│
┌─────────────┴─────────────┐
│ │
▼ ▼
Caddy(本机 :80/:443) 可选:同步一份到 B2 私有 bucket
直接对外提供 RSS + 音频 (纯备份,从不公开这个 bucket 的域名,
文件,走家庭宽带出口 只有你自己用 B2 后台/密钥能访问)
│
▼
DDNS(Cloudflare DNS-only)把域名指向
家里当前的公网 IP,自动更新
│
▼
播客 App 通过这个域名订阅
核心逻辑:把"存数据"和"对外服务"彻底拆开。B2(或任何对象存储)只用来防止 Pi 的 SD 卡/硬盘损坏导致录音丢失,这个 bucket 永远不公开,播客 App 和听众根本不知道它的存在,自然也不会因为 B2 的 IP 被列进某个封锁名单而受影响。真正对外提供服务的,是只属于你自己、没有任何第三方共享的家庭宽带 IP。
3. 域名与 DDNS
家庭宽带大多是动态 IP(每隔一段时间会变),需要一个 DDNS(动态域名解析)服务,自动把域名指向你当前的公网 IP。
用你现成的 Cloudflare 账号做 DNS 就行,不用另外注册 DuckDNS——关键是这条记录要设成 DNS-only(灰色云朵,不代理),这样只是"查询这个域名对应哪个 IP"这一步走 Cloudflare,实际的 HTTP 请求会直连你家的公网 IP,完全不经过 Cloudflare 的代理网络,不在这次要规避的封锁范围内。
- Cloudflare Dashboard → 域名 → DNS,新建一条 A 记录,主机名随便起一个不容易联想到内容的子域名(比如
f7a3c9e1.pabloye.es),先随便填个 IP 占位 - 确认这条记录的云朵图标是灰色(DNS only),不是橙色(Proxied)——这一步是安全的核心,点一下图标切换状态
- Cloudflare Dashboard → 右上角头像 → My Profile → API Tokens → Create Token,用"Edit zone DNS"模板,权限范围限定到这一个域名,生成一个 API Token(不要用 Global API Key,那个权限过大,一旦泄露影响全部域名)
- 查一下 Zone ID(域名概览页右下角)和这条记录的 Record ID(可以用下面这条命令查,把 Token 和域名换成自己的):输出里的
curl -s -X GET "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/dns_records?name=f7a3c9e1.pabloye.es" \ -H "Authorization: Bearer <API_TOKEN>" | python3 -m json.toolid字段就是 Record ID - 在 Pi 上写一个定时更新脚本:
mkdir -p ~/cf-ddns
cat > ~/cf-ddns/update.sh << 'EOF'
#!/bin/bash
CF_API_TOKEN="你的API Token"
CF_ZONE_ID="你的Zone ID"
CF_RECORD_ID="你的DNS记录ID"
DOMAIN="f7a3c9e1.pabloye.es"
CURRENT_IP=$(curl -s -4 https://api.ipify.org)
curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$CF_RECORD_ID" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"type":"A","name":"'"$DOMAIN"'","content":"'"$CURRENT_IP"'","ttl":120,"proxied":false}'
EOF
chmod +x ~/cf-ddns/update.sh
# 每 5 分钟检查一次 IP 有没有变
crontab -e
# 加入:
*/5 * * * * ~/cf-ddns/update.sh >/dev/null 2>&1
注意请求体里显式带了 "proxied":false——即使这条记录当前已经是灰色云朵,每次 PATCH 更新时也顺手把这个字段带上,防止哪天手滑在 Dashboard 上点开了代理都不自知。
如果不想用 Cloudflare(比如担心账号层面哪天被牵连、或者单纯想要更彻底的隔离),DuckDNS 是完全独立于 Cloudflare 的免费替代方案:登录 duckdns.org,创建子域名拿 token,更新逻辑一样是定时 curl 一下它的 update 接口。两种方案本质相同,选哪个纯粹看你想不想多开一个账号。
4. 路由器:端口转发
只需要转发 80(HTTP,用于 Let’s Encrypt 证书验证)和 443(HTTPS) 这两个端口到 Pi,千万不要把 SSH(22)端口转发到公网——这是最容易被扫描器盯上的端口,需要远程管理 Pi 用第 6 节提到的 Tailscale,不要直接暴露 SSH。
- 先给 Pi 在路由器里设一个固定的局域网 IP(DHCP 保留,绑定 Pi 的 MAC 地址),避免路由器重启后 Pi 的局域网 IP 变了,端口转发失效
- 登录路由器管理后台(一般是
192.168.1.1或192.168.0.1,具体看你的路由器品牌),找"端口转发 / 虚拟服务器 / NAT"这类设置 - 添加两条规则:外部端口 80 → Pi 内网 IP:80;外部端口 443 → Pi 内网 IP:443
如果你的宽带运营商用的是 CGNAT(多个家庭共用一个公网 IP,常见于部分光纤/移动宽带套餐),端口转发在路由器层面根本转发不出去——这种情况下,家里其实没有真正独立的公网 IP,需要额外加一层内网穿透(比如 Tailscale Funnel、frp 自建,或者向运营商申请独立公网 IP,部分运营商肯免费或加钱开通)。建议先确认一下:
# 在 Pi 上看到的公网 IP
curl -4 ifconfig.me
# 对比路由器管理页面里显示的"WAN IP"
如果两者一致,说明是独立公网 IP,直接端口转发就行;如果路由器 WAN 口显示的是 100.64.x.x 或者和上面查到的不一致,大概率是 CGNAT,需要换方案。
5. Caddy:自动 HTTPS,一份配置搞定
比 nginx + certbot 省心很多——Caddy 会自动向 Let’s Encrypt 申请证书并自动续期,不需要额外配置。
# 安装(Debian/Raspberry Pi OS)
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
配置文件 /etc/caddy/Caddyfile:
你的子域名.duckdns.org {
root * /home/juan/private-podcast/public
file_server
# 可选:真正的访问控制,比单纯 URL 混淆更彻底
# basic_auth {
# 听众用户名 加密后的密码哈希
# }
# 安全响应头
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
}
}
root 指向的目录里放 feed.xml 和音频文件(可以是软链接,指向 ~/private-podcast/media/ 实际存放的地方,避免重复占用空间):
sudo systemctl restart caddy
sudo systemctl enable caddy # 开机自启
# 查看日志排查问题
sudo journalctl -u caddy -f
如果需要 Basic Auth(上面配置里注释掉的那段),生成密码哈希:
caddy hash-password --plaintext '你的密码'
# 把输出的哈希值填进 Caddyfile 的 basic_auth 块里
6. 目录结构与本地文件接入
~/private-podcast/
├── inbox/ # 手动把 Tascam 导出的 wav 拖进来
├── processed/ # 处理完的原始 wav 移到这里存档
├── media/ # 处理后的最终 mp3,Caddy 实际提供下载的文件
├── public/ # Caddy 的 root 目录,feed.xml + media 的软链接放这里
│ ├── feed.xml
│ └── media -> ../media
├── metadata/ # 每集元数据 json
├── private_podcast.py # 独立脚本
└── .env # B2 备份用的密钥(如果启用离线备份)
private_podcast.py 核心逻辑(跳过下载步骤,直接从 inbox/ 读取,处理完直接落地到 media/,不上传到任何公开可访问的地方):
import os, shutil, uuid
INBOX_DIR = os.path.expanduser("~/private-podcast/inbox")
PROCESSED_DIR = os.path.expanduser("~/private-podcast/processed")
MEDIA_DIR = os.path.expanduser("~/private-podcast/media")
def scan_inbox():
return [f for f in os.listdir(INBOX_DIR) if f.lower().endswith(".wav")]
def process_local_file(filename: str):
raw_path = os.path.join(INBOX_DIR, filename)
base_name = os.path.splitext(filename)[0]
enhanced_path = f"/dev/shm/private_temp/{base_name}_enhanced.mp3"
# 复用现有的 enhance_audio()(从主脚本复制过来)
success = enhance_audio(raw_path, enhanced_path)
if not success:
return False
# 可选:whisper 转录,只是自己听的话这一步可以跳过
# transcript = transcribe(enhanced_path)
# 文件名本身也用随机字符串,而不是保留原始文件名(可能带日期/信息)
random_name = f"{uuid.uuid4().hex}.mp3"
final_path = os.path.join(MEDIA_DIR, random_name)
shutil.move(enhanced_path, final_path)
write_metadata_and_rebuild_feed(base_name, random_name)
# 可选:顺手同步一份到 B2 做离线备份(这个 bucket 从不公开)
# backup_to_private_b2(final_path, random_name)
shutil.move(raw_path, os.path.join(PROCESSED_DIR, filename))
return True
触发方式:偶尔录一次就手动跑 python3 private_podcast.py;如果比较频繁,加个 systemd timer,逻辑和公开播客那条完全一样,照抄即可。
7. 离线备份(可选):B2 私有 bucket,永不公开
即使有本地磁盘,Pi 的 SD 卡/硬盘也可能损坏,建议额外备份一份原始录音到云端对象存储(B2、S3 都行)。关键原则:这个 bucket 从头到尾不做任何"公开访问"设置,不绑自定义域名,不生成公开链接,纯粹当成异地备份使用,访问方式只有你自己用 B2 后台或 API 密钥去主动拉取,播客 App 和任何听众永远不会接触到这个地址:
import boto3
B2_ENDPOINT = "https://s3.<region>.backblazeb2.com"
b2 = boto3.client("s3",
endpoint_url=B2_ENDPOINT,
aws_access_key_id=os.getenv("B2_KEY_ID"),
aws_secret_access_key=os.getenv("B2_APP_KEY"))
def backup_to_private_b2(local_path: str, key: str):
b2.upload_file(local_path, "private-podcast-backup", key)
8. 安全加固清单
- 防火墙只开 80/443:
sudo ufw allow 80,443/tcp && sudo ufw enable,其他端口一律拒绝外部访问 - SSH 不对公网开放:如果需要在外面远程管理 Pi,装 Tailscale(免费额度够个人用),它建立的是加密的点对点隧道,不需要在路由器上做任何端口转发,也不会把 SSH 暴露给整个互联网扫描
- fail2ban:即使只开了 80/443,也建议装上防止针对 Caddy 的暴力扫描/撞库
sudo apt install -y fail2ban - 定期看 Caddy 访问日志,异常大量请求要留意(虽然 URL 混淆+可选 Basic Auth 已经把被发现的概率降到很低)
- 家庭宽带 ISP 条款:极少数家庭宽带套餐的服务条款技术上禁止"运行服务器",个人体量的播客流量几乎不会被注意到,但如果在意可以先翻一下自己套餐的条款
9. 一个诚实的取舍:可用性 vs 独立性
这套方案用独立性换来"不会被多租户误封",代价是可用性不如云服务:
| 场景 | 影响 |
|---|---|
| 家里断网/停电 | 播客暂时无法访问,恢复后自动正常,讲道/私人内容不是直播,晚几分钟到几小时没人会察觉 |
| 公网 IP 变化但 DDNS 还没来得及更新 | 短暂几分钟的不可用窗口(脚本默认 5 分钟检查一次,可以调更短) |
| 运营商是 CGNAT | 需要额外的内网穿透方案,见第 4 节 |
| 想要更高可用性 | 可以退一步用一台几十块钱/月的独立小 VPS(Hetzner、OVH 欧洲机房)做同样的事,IP 依然是你独占的,只是换成付费换稳定性 |
个人向、几十个听众量级的私人播客,用 Pi 直接对外服务是合理的取舍——你现在整条流水线本来就跑在家里,不用再多维护一台机器。
10. 检查清单
- Cloudflare DNS 记录已确认是灰色云朵(DNS-only),API Token 权限限定为该域名的 DNS 编辑,定时更新脚本已加入 crontab
- 路由器已确认不是 CGNAT,80/443 端口转发正确指向 Pi 的固定内网 IP
- Caddy 装好,能正常签发 HTTPS 证书,
https://你的域名/feed.xml能正常访问 - SSH 没有暴露给公网,远程管理走 Tailscale
-
ufw、fail2ban已启用 - RSS 标题/描述/封面/集数标题、媒体文件名都不含真实姓名、地点、日期(用随机字符串)
- 如果启用了 B2 离线备份,确认这个 bucket 没有任何公开访问/自定义域名配置
-
inbox/、processed/、.env已经加进.gitignore(如果这部分代码也用 git 管理)