私人播客搭建指南

私人播客搭建指南(本地录音 → 不对外发布的 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 的代理网络,不在这次要规避的封锁范围内。

  1. Cloudflare Dashboard → 域名 → DNS,新建一条 A 记录,主机名随便起一个不容易联想到内容的子域名(比如 f7a3c9e1.pabloye.es),先随便填个 IP 占位
  2. 确认这条记录的云朵图标是灰色(DNS only),不是橙色(Proxied)——这一步是安全的核心,点一下图标切换状态
  3. Cloudflare Dashboard → 右上角头像 → My Profile → API Tokens → Create Token,用"Edit zone DNS"模板,权限范围限定到这一个域名,生成一个 API Token(不要用 Global API Key,那个权限过大,一旦泄露影响全部域名)
  4. 查一下 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.tool
    
    输出里的 id 字段就是 Record ID
  5. 在 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。

  1. 先给 Pi 在路由器里设一个固定的局域网 IP(DHCP 保留,绑定 Pi 的 MAC 地址),避免路由器重启后 Pi 的局域网 IP 变了,端口转发失效
  2. 登录路由器管理后台(一般是 192.168.1.1 或 192.168.0.1,具体看你的路由器品牌),找"端口转发 / 虚拟服务器 / NAT"这类设置
  3. 添加两条规则:外部端口 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 管理)