系列上一篇:PVE LXC 部署 Tailscale 与公网 Peer Relay

配套开源代码:Hanserwei/tailscale-home-services(MIT) · 安装说明 · 下载完整脚本 ZIP。脚本采用脱敏示例配置,使用前请按文中顺序修改。

上一篇,我在 PVE 中部署了一个 Tailscale LXC 子网路由器,并利用已有的公网服务器配置 Peer Relay,解决了手机访问家庭服务时绕海外中继、加载缓慢的问题。

网络打通后,又遇到了一个很常见的麻烦:家里的网段是 192.168.1.0/24,外面的 Wi-Fi 也可能使用同一个网段。

打开 192.168.1.10 时,客户端应该去找眼前这张网络里的设备,还是通过 Tailscale 去找家里的 PVE?具体走向取决于客户端路由、前缀长度、接口和路由策略。单靠记住一个 IP,已经不够可靠。

这次我把访问方式改成了:

  • 在家:域名解析到家庭网关的局域网地址,直接访问,不需要开启 Tailscale。
  • 在外:域名解析到家庭网关的 Tailscale 地址,通过直连或 Peer Relay 访问。
  • 应用端:由家里的 Nginx 根据域名转发到不同服务。
  • 证书端:公网服务器负责申请与续期,家庭网关自动拉取证书并加载。

最后,无论在家还是在外,都可以使用同一组 HTTPS 域名。

脱敏与脚本说明:本文所有域名均替换为 example.com,公网服务器地址如需表示,使用文档保留地址 203.0.113.10;Tailscale 地址、应用 IP 和容器名也做了替换。没有真实公网 IP、登录账号、密码、DNS 密钥或证书私钥。示例值不能直接部署,请先按自己的环境修改。

文中脚本是本次运行脚本的脱敏整理版。为了方便复用,DNS 凭据改为从独立文件读取,证书拉取脚本加强了证书链验证和失败回滚。实际环境的部署与续期演练已通过;仓库中的示例地址未再次用于真实部署。

一、为什么“给 IP 加个域名”还不够?

假设增加下面这条 DNS 记录:

pve.example.com → 192.168.1.10

浏览器解析完域名以后,仍然需要连接 192.168.1.10。如果外部网络与家庭网络重名,原来的路由歧义依然存在。

因此,这次改造的关键是让外部客户端连接一个不依赖家庭子网路由的入口地址

pve.example.com → 家庭网关的 Tailscale IP → Nginx → 家中 PVE

客户端访问的是 Tailscale 节点地址。到真正的家庭应用 IP 的最后一段连接,由位于家里的 Nginx 发起。

这里有一个边界:这套方法是让接入反向代理的网页服务绕开重叠网段,并没有从网络层消除所有冲突。

访问方式 本次是否处理
浏览器访问 PVE、音乐库、代码仓库、监控页面 已处理
通过 HTTP/HTTPS 访问 Elasticsearch API 已处理
直接连接旧的 192.168.1.x 仍可能受冲突影响
PostgreSQL、Redis、SSH、RocketMQ 等其他协议 需要单独规划

对于非网页服务,可以考虑在目标机器上直接安装 Tailscale,或设计专用的 TCP 入口。如果迁移完成后已经不需要旧的家庭子网路由,再评估停止发布该路由;本次没有直接撤掉它,以免影响原有使用方式。

二、最终结构:同一域名,两条私有访问路径

flowchart LR
    LAN["家中电脑<br/>无需 Tailscale"]
    RouterDNS["家庭路由器 DNS<br/>返回 192.168.1.250"]
    Remote["外部电脑 / 手机<br/>连接 Tailscale"]
    TailDNS["Tailscale Split DNS<br/>返回 100.100.10.10"]
    Relay["公网 Peer Relay<br/>直连失败时参与转发"]

    subgraph Home["家庭网络"]
      Nginx["LXC 113 · Nginx<br/>LAN + Tailscale 双地址监听"]
      PVE["PVE · HTTPS 8006"]
      Apps["Homepage / Forgejo / Navidrome<br/>Grafana / Nacos / RustFS / PowerJob / ES"]
      Nginx --> PVE
      Nginx --> Apps
    end

    LAN -.->|"解析同一服务域名"| RouterDNS
    LAN -->|"局域网 HTTPS"| Nginx
    Remote -.->|"解析同一服务域名"| TailDNS
    Remote -->|"优先直连"| Nginx
    Remote -.-> Relay
    Relay -.-> Nginx

公网服务器有两个职责,但不能混为一谈:

  1. 作为 Peer Relay,必要时转发 Tailscale 节点之间的加密流量。
  2. 作为证书申请和续期的执行位置,保存 DNS 授权并向家庭网关提供证书。

本次没有把这批家庭服务反向代理到公网服务器的公开 443 端口,也没有把它们加入 CDN。公网 DNS 中能查到名字,不代表公网用户就能连接这些服务。

三、哪些机器做了哪些事情?

位置 本次工作
PVE 宿主机 通过 pct 管理 LXC;提供 PVE 根 CA;持久化网关 DNS 配置
家庭 LXC 网关 安装 Nginx;监听 LAN 和 Tailscale 地址;配置 9 个 HTTPS 站点;定时拉取证书
家庭应用容器 / 虚拟机 调整 Homepage 链接、Forgejo 和 Grafana 的外部 URL;保留内部服务地址
家庭 QWRT 路由器 固定网关 DHCP 地址;添加 9 个服务域名的局域网 DNS 映射
公网服务器 使用 DNSPod API;运行 Certbot DNS-01;定时续期;提供受限的证书导出命令
腾讯云 DNSPod 新增服务 A 记录,指向网关 Tailscale IP;配合 ACME 临时 TXT 验证
Tailscale 控制台 为服务域名后缀配置 Split DNS,避开本地 DNS 过滤问题
客户端 家中使用路由器 DNS;外部连接 Tailscale 并开启“使用 Tailscale DNS”

下面是全文使用的示例地址,所有脚本与配置按这张表对应。

角色 示例值
家庭网段 192.168.1.0/24
家庭路由器 192.168.1.1
家庭网关 LAN IP 192.168.1.250
家庭网关 Tailscale IP 100.100.10.10
公网服务器 Tailscale IP 100.100.10.20
家庭网关 LXC ID 113
示例域名 example.com

四、先整理服务清单,避免覆盖已有域名

我先从 Homepage 的服务配置中提取入口,再核对域名平台已有的记录。不能只看某个名称“很适合”,就直接覆盖它。

这次就发现,原本想给 Navidrome 使用的 music 已经被公开音乐卡片占用。因此,音乐库改用 navidrome,现有音乐卡片继续使用原地址。

服务 新域名示例 家庭上游示例
PVE pve.example.com https://192.168.1.10:8006
Homepage nav.example.com http://192.168.1.20:3000
Forgejo git.example.com http://192.168.1.21:3000
Navidrome navidrome.example.com http://192.168.1.22:4533
Grafana grafana.example.com http://192.168.1.23:3000
Nacos nacos.example.com http://192.168.1.24:8080
RustFS 控制台 rustfs.example.com http://192.168.1.25:9001
PowerJob powerjob.example.com http://192.168.1.26:7700
Elasticsearch es.example.com http://192.168.1.26:9200

RustFS 这里接入的是控制台,不能把它当成已经配置好了所有 S3 客户端的 API 地址。Forgejo 这里接入的是网页与 HTTPS Git,SSH 克隆地址需要另外处理。

五、证书为什么放在公网服务器上申请?

如果服务域名解析到 Tailscale IP,普通公网验证服务器无法直接访问它。此时,直接套用“域名解析到公网 IP,然后在 80 端口验证”的教程,通常走不通。

本次使用 DNS-01:申请证书时向 DNS 添加临时 TXT 记录,CA 通过 DNS 验证域名控制权,不要求家庭网关对公网开放端口。Certbot 使用说明

DNS-01 本身并不要求一定在公网服务器执行。在这里选择公网服务器,是为了集中保存已有的 DNS 授权,让家庭网关只拿到运行 HTTPS 所需的证书和私钥。

sequenceDiagram
    participant Timer as 公网服务器定时任务
    participant Certbot as Certbot
    participant DNS as 腾讯云 DNSPod
    participant CA as 证书颁发机构
    participant LXC as 家庭 LXC
    participant Web as Nginx

    Timer->>Certbot: 检查是否需要续期
    Certbot->>DNS: 添加临时 TXT
    CA->>DNS: 验证域名控制权
    CA-->>Certbot: 签发证书
    Certbot->>DNS: 清理本次验证 TXT
    LXC->>Certbot: 经受限 SSH 定时拉取证书
    LXC->>LXC: 校验域名、有效期、证书链、私钥
    LXC->>Web: 检查配置并 reload

这里的“只拿到证书”包含证书私钥,因为 Nginx 必须用它完成 TLS 握手。DNS API 密钥没有下发到家庭网关。

六、公网服务器:DNS 记录与自动验证脚本

6.1 安装工具,准备 DNS 授权

以下命令在公网服务器执行:

install -d -m 700 /opt/tailscale-home-dns
python3 -m venv /opt/tailscale-home-dns/venv
/opt/tailscale-home-dns/venv/bin/pip install \
  certbot tencentcloud-sdk-python-dnspod dnspython

install -d -m 700 /etc/tailscale-home-dns

实际部署使用的是公网服务器 1Panel 中已经配置的腾讯云 DNS 账号。我没有把它输出到聊天,也没有把密钥复制到家庭网关。

直接读取 1Panel 内部数据库会依赖具体版本的表结构。为了让这篇文章更容易复用,下面的脚本改为读取独立文件:

/etc/tailscale-home-dns/tencentcloud.json

文件内容格式如下,填写自己的凭据,并设为 600 权限。应使用具备目标域名所需权限的 DNS 授权,不把真实文件放进代码仓库或博客附件。

{
  "secretID": "REPLACE_WITH_YOUR_SECRET_ID",
  "secretKey": "REPLACE_WITH_YOUR_SECRET_KEY"
}
chmod 600 /etc/tailscale-home-dns/tencentcloud.json

6.2 DNSPod 脚本

脚本负责四项操作:

子命令 作用
check 查看目标域名是否已有解析
apply 为目标域名创建 A 记录,遇到冲突停止,不覆盖原记录
auth Certbot 调用,创建 TXT 并等待解析生效
cleanup Certbot 调用,只删除本次验证创建且内容仍匹配的 TXT

保存为 /opt/tailscale-home-dns/dnspod_home.py

#!/opt/tailscale-home-dns/venv/bin/python
"""Redacted article edition. Credentials are read from a root-only JSON file."""
import json
import os
import sys
import time
from pathlib import Path

import dns.resolver
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.dnspod.v20210323 import dnspod_client, models

ZONE = 'example.com'
HOSTS = ('pve', 'nav', 'git', 'navidrome', 'grafana', 'nacos', 'rustfs', 'powerjob', 'es')
ADDRESS = '100.100.10.10'
ROOT = Path('/opt/tailscale-home-dns')


def client():
    authfile = Path(os.environ.get('TENCENT_DNS_CREDENTIALS', '/etc/tailscale-home-dns/tencentcloud.json'))
    if authfile.stat().st_mode & 0o077:
        raise RuntimeError('DNS credential file must only be accessible to its owner')
    auth = json.loads(authfile.read_text())
    profile = ClientProfile()
    profile.httpProfile = HttpProfile(endpoint='dnspod.tencentcloudapi.com')
    return dnspod_client.DnspodClient(credential.Credential(auth['secretID'], auth['secretKey']), '', profile)


def call(action, payload):
    req = getattr(models, action + 'Request')()
    req.from_json_string(json.dumps(payload))
    return json.loads(getattr(client(), action)(req).to_json_string())


def records():
    result = []
    offset = 0
    while True:
        data = call('DescribeRecordList', {'Domain': ZONE, 'Offset': offset, 'Limit': 100})
        batch = data.get('RecordList', [])
        result.extend(batch)
        offset += len(batch)
        if not batch or offset >= data['RecordCountInfo']['TotalCount']:
            return result


def check():
    rows = records()
    print(json.dumps({'record_count': len(rows), 'target_records': [r for r in rows if r['Name'] in HOSTS]}, ensure_ascii=False))


def apply():
    rows = records()
    # Check the whole batch before the first write; never overwrite existing names.
    for host in HOSTS:
        existing = [r for r in rows if r['Name'] == host]
        if existing and not (len(existing) == 1 and existing[0]['Type'] == 'A' and existing[0]['Value'] == ADDRESS and existing[0]['Status'] == 'ENABLE'):
            raise RuntimeError('Conflicting existing record for ' + host)
    backup = ROOT / ('dns-before-' + time.strftime('%Y%m%d-%H%M%S') + '.json')
    backup.write_text(json.dumps(rows, ensure_ascii=False, indent=2))
    backup.chmod(0o600)
    for host in HOSTS:
        if any(r['Name'] == host for r in rows):
            print(host + ': already correct')
            continue
        result = call('CreateRecord', {'Domain': ZONE, 'SubDomain': host, 'RecordType': 'A', 'RecordLine': '默认', 'Value': ADDRESS, 'TTL': 600, 'Remark': 'Tailscale private home service'})
        with (ROOT / 'created-records.jsonl').open('a') as f:
            f.write(json.dumps({'name': host, 'id': result['RecordId'], 'type': 'A', 'value': ADDRESS}) + '\n')
        print(host + ': created')


def challenge_name():
    domain = os.environ['CERTBOT_DOMAIN']
    if domain not in [host + '.' + ZONE for host in HOSTS]:
        raise RuntimeError('Unexpected certificate domain')
    return '_acme-challenge.' + domain[:-(len(ZONE) + 1)]


def auth():
    name = challenge_name()
    token = os.environ['CERTBOT_VALIDATION']
    result = call('CreateRecord', {'Domain': ZONE, 'SubDomain': name, 'RecordType': 'TXT', 'RecordLine': '默认', 'Value': token, 'TTL': 600, 'Remark': 'Temporary ACME validation for private home proxy'})
    state = {'name': name, 'id': result['RecordId'], 'value': token}
    statefile = ROOT / ('challenge-' + os.environ['CERTBOT_DOMAIN'] + '.json')
    statefile.write_text(json.dumps(state))
    statefile.chmod(0o600)
    # Verify against authoritative DNS; avoid waiting on recursive negative caches.
    detail = call('DescribeDomain', {'Domain': ZONE})
    nsnames = detail['DomainInfo']['DnspodNsList']
    resolver = dns.resolver.Resolver()
    resolver.timeout = 4
    resolver.lifetime = 8
    nsips = [str(resolver.resolve(ns.rstrip('.'), 'A')[0]) for ns in nsnames]
    deadline = time.monotonic() + 180
    while time.monotonic() < deadline:
        propagated = True
        for ns in nsips:
            direct = dns.resolver.Resolver(configure=False)
            direct.nameservers = [ns]
            direct.timeout = 3
            direct.lifetime = 5
            try:
                values = [''.join(v.decode() for v in rr.strings) for rr in direct.resolve(name + '.' + ZONE, 'TXT')]
                if token not in values:
                    propagated = False
            except Exception:
                propagated = False
        if propagated:
            print(json.dumps(state))
            return
        time.sleep(5)
    raise RuntimeError('TXT propagation timed out for ' + name)


def cleanup():
    name = challenge_name()
    statefile = ROOT / ('challenge-' + os.environ['CERTBOT_DOMAIN'] + '.json')
    if not statefile.exists():
        return
    state = json.loads(statefile.read_text())
    if state['name'] != name or state['value'] != os.environ['CERTBOT_VALIDATION']:
        raise RuntimeError('Challenge cleanup mismatch')
    current = call('DescribeRecord', {'Domain': ZONE, 'RecordId': state['id']})['RecordInfo']
    if current['Value'] != state['value'] or current['RecordType'] != 'TXT':
        raise RuntimeError('Refusing to delete a changed record')
    call('DeleteRecord', {'Domain': ZONE, 'RecordId': state['id']})
    statefile.unlink()


if __name__ == '__main__':
    os.umask(0o077)
    ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
    actions = {'check': check, 'apply': apply, 'auth': auth, 'cleanup': cleanup}
    actions[sys.argv[1]]()

这里使用腾讯云官方 SDK 的 DNSPod 接口。判断冲突时会先检查整个目标集合,再开始写入;网络或 API 故障仍可能造成部分记录已经创建,因此保留变更记录,并支持对正确记录重复执行。DNSPod 记录查询接口

赋予执行权限,先检查:

chmod 700 /opt/tailscale-home-dns/dnspod_home.py
/opt/tailscale-home-dns/dnspod_home.py check

等后面的网关和证书准备完成,再执行 apply 发布 A 记录。也可以在 DNSPod 控制台手动创建相同记录:9 个名称都指向 100.100.10.10,不指向公网服务器地址。

6.3 首次签发多域名证书

/opt/tailscale-home-dns/venv/bin/certbot certonly \
  --manual \
  --preferred-challenges dns \
  --manual-auth-hook '/opt/tailscale-home-dns/dnspod_home.py auth' \
  --manual-cleanup-hook '/opt/tailscale-home-dns/dnspod_home.py cleanup' \
  --cert-name tailscale-home \
  --non-interactive --agree-tos --register-unsafely-without-email \
  -d pve.example.com \
  -d nav.example.com \
  -d git.example.com \
  -d navidrome.example.com \
  -d grafana.example.com \
  -d nacos.example.com \
  -d rustfs.example.com \
  -d powerjob.example.com \
  -d es.example.com

运行前应阅读并接受对应 CA 的服务条款。示例采用无邮箱注册,也可以替换为自己的联系邮箱参数。

虽然参数里有 --manual,但验证动作已经交给 auth/cleanup hook,后续不需要人工粘贴 TXT。没有这些 hook 的纯手动 DNS 验证,才无法直接完成无人值守续期。

成功后,证书位于:

/etc/letsencrypt/live/tailscale-home/fullchain.pem
/etc/letsencrypt/live/tailscale-home/privkey.pem

6.4 配置续期任务

/etc/systemd/system/tailscale-home-cert-renew.service

[Unit]
Description=Renew private home service certificate with DNS validation
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/tailscale-home-dns/venv/bin/certbot renew --cert-name tailscale-home --quiet --no-random-sleep-on-renew
TimeoutStartSec=1800

/etc/systemd/system/tailscale-home-cert-renew.timer

[Unit]
Description=Check private home certificate renewal twice daily

[Timer]
OnCalendar=*-*-* 03,15:00:00
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now tailscale-home-cert-renew.timer
systemctl list-timers tailscale-home-cert-renew.timer

任务每天检查两次,不是每天强制申请新证书。OnCalendar 使用服务器时区,本次服务器设为 Asia/Shanghai。定时器已经添加随机延迟,脚本因此关闭了 Certbot 额外的续期随机等待,方便定位执行时间。

七、家庭网关:让 Nginx 同时服务 LAN 与 Tailscale

7.1 安装并暂时停止默认站点

家庭 LXC 内执行:

apt-get update
apt-get install -y nginx openssh-client ca-certificates openssl
systemctl stop nginx

install -d -m 700 /etc/nginx/home-tls
install -d /etc/nginx/disabled-sites
if [ -L /etc/nginx/sites-enabled/default ]; then
  mv /etc/nginx/sites-enabled/default /etc/nginx/disabled-sites/default
fi

先准备配置和证书,再启动正式入口。无需修改每个应用原本监听的地址。

7.2 双地址监听,按域名转发

关键配置以 Homepage 为例:

# 放在 http 上下文,例如 /etc/nginx/conf.d/home-services.conf。
# 完整生成文件已包含这个 map,不要重复定义。
map $http_upgrade $home_connection_upgrade {
    default upgrade;
    '' close;
}

server {
    listen 192.168.1.250:80;
    listen 100.100.10.10:80;
    server_name nav.example.com;
    return 308 https://$host$request_uri;
}

server {
    listen 192.168.1.250:443 ssl;
    listen 100.100.10.10:443 ssl;
    server_name nav.example.com;

    ssl_certificate /etc/nginx/home-tls/current/fullchain.pem;
    ssl_certificate_key /etc/nginx/home-tls/current/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://192.168.1.20:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port 443;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $home_connection_upgrade;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_buffering off;
        proxy_request_buffering off;
    }
}

外部访问进入 Tailscale 地址,家庭访问进入局域网地址,但使用相同证书和站点规则。

完整的 9 站点配置由仓库中的 generate_proxy.py 生成,还包含未知主机名拒绝规则和 RustFS 控制台路径重定向。修改映射后,在保存脚本的目录执行:

python3 generate_proxy.py
install -m 644 home-services.conf /etc/nginx/conf.d/home-services.conf

首次证书尚未导入时,nginx -t 会因为证书文件不存在而失败;先完成下一节的证书导入,再启动 Nginx。

7.3 PVE:保留上游 TLS 验证与 WebSocket

PVE 后端本身使用 HTTPS。我没有用 proxy_ssl_verify off 跳过校验,而是把 PVE 根 CA 放到网关,供 Nginx 验证上游。

PVE 宿主机执行:

pct push 113 /etc/pve/pve-root-ca.pem /etc/nginx/pve-root-ca.pem

PVE 站点的 location 在通用代理设置基础上增加:

proxy_pass https://192.168.1.10:8006;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/nginx/pve-root-ca.pem;
proxy_ssl_server_name on;
proxy_ssl_name pve;

proxy_ssl_name 必须与自己 PVE 后端证书中的名称匹配,不能机械照抄。仓库生成器默认使用 pve

PVE 的交互控制台还需要正确转发 WebSocket,所以上一段通用配置里的 UpgradeConnection 和超时设置需要保留。

八、证书怎样自动送到家里?

我没有把腾讯云 DNS 密钥复制到家庭容器,也没有给它一把能随意操作公网服务器的 SSH 密钥。

家庭网关使用独立密钥,只能在公网服务器执行一个固定命令:导出这套证书。

8.1 公网服务器上的固定导出命令

保存为 /usr/local/sbin/export-home-certificate

#!/bin/sh
set -eu
exec /usr/bin/tar -chf - \
  -C /etc/letsencrypt/live/tailscale-home fullchain.pem privkey.pem
chmod 700 /usr/local/sbin/export-home-certificate

这里的 tar -h 会跟随 Certbot live 目录中的符号链接,导出实际文件内容,而不是只发送符号链接。

8.2 家庭网关生成专用 SSH 密钥

家庭 LXC 内执行:

ssh-keygen -t ed25519 -N '' \
  -C home-cert-readonly \
  -f /etc/nginx/home-cert-pull-key

cat /etc/nginx/home-cert-pull-key.pub

把输出的公钥加入公网服务器 /root/.ssh/authorized_keys,前面加上限制选项。下面的 REPLACE_WITH_GATEWAY_PUBLIC_KEY 代表完整的 ssh-ed25519 ... 公钥内容:

restrict,from="100.100.10.10",command="/usr/local/sbin/export-home-certificate" REPLACE_WITH_GATEWAY_PUBLIC_KEY

这个密钥只允许从家庭网关的 Tailscale 地址使用,并强制执行固定命令,不能拿来获得交互式 root shell 或做端口转发。本次沿用服务器的 root SSH 入口,加上上述限制;也可以另外设计专用的证书导出用户。

还要固定 SSH 服务器的主机公钥。在已经可信登录的公网服务器会话中读取:

cat /etc/ssh/ssh_host_ed25519_key.pub

在家庭网关创建 /etc/nginx/home-cert-known-hosts,内容为服务器 Tailscale IP 加上经过核对的主机公钥:

100.100.10.20 ssh-ed25519 REPLACE_WITH_SERVER_HOST_PUBLIC_KEY

不要把未经核验的扫描结果直接当成可信身份,也不要关闭 StrictHostKeyChecking。同时确认 Tailscale 访问规则允许网关连接公网服务器的 SSH 端口。

8.3 家庭网关证书拉取脚本

保存为 /usr/local/sbin/pull-home-certificate。这份文章版脚本在本次实现基础上增加了完整证书链检查,以及 reload 失败时恢复旧证书链接的处理:

#!/bin/bash
# Article edition: adds chain validation and rollback on nginx reload failure.
set -euo pipefail
umask 077
exec 9>/run/home-cert-pull.lock
flock -n 9 || exit 0

base=/etc/nginx/home-tls
install -d -m 700 "$base"
stage=$(mktemp -d "$base/.stage-XXXXXX")
next="$base/.current-next-$$"
trap 'rm -rf "$stage"; rm -f "$next"' EXIT

ssh -n -T -o BatchMode=yes -o ConnectTimeout=15 \
  -o StrictHostKeyChecking=yes \
  -o UserKnownHostsFile=/etc/nginx/home-cert-known-hosts \
  -i /etc/nginx/home-cert-pull-key \
  root@100.100.10.20 > "$stage/bundle.tar"

tar --no-same-owner --no-same-permissions -xf "$stage/bundle.tar" \
  -C "$stage" -- fullchain.pem privkey.pem
for file in fullchain.pem privkey.pem; do
  [[ -f "$stage/$file" && ! -L "$stage/$file" ]]
done
chmod 600 "$stage/privkey.pem"
chmod 644 "$stage/fullchain.pem"
openssl x509 -in "$stage/fullchain.pem" -noout -checkend 86400
for host in pve nav git navidrome grafana nacos rustfs powerjob es; do
  openssl verify -purpose sslserver -verify_hostname "$host.example.com" \
    -untrusted "$stage/fullchain.pem" "$stage/fullchain.pem" >/dev/null
done
certpub=$(openssl x509 -in "$stage/fullchain.pem" -pubkey -noout | sha256sum)
keypub=$(openssl pkey -in "$stage/privkey.pem" -pubout | sha256sum)
[[ "$certpub" = "$keypub" ]]

if [[ -f "$base/current/fullchain.pem" ]] && \
   cmp -s "$stage/fullchain.pem" "$base/current/fullchain.pem" && \
   cmp -s "$stage/privkey.pem" "$base/current/privkey.pem"; then
  echo 'Certificate unchanged'
  exit 0
fi

rm "$stage/bundle.tar"
version="$base/cert-$(date -u +%Y%m%dT%H%M%SZ)-$$"
mv "$stage" "$version"
old=$(readlink "$base/current" || true)
ln -s "$version" "$next"
mv -Tf "$next" "$base/current"

rollback() {
  if [[ -n "$old" ]]; then
    ln -s "$old" "$next"
    mv -Tf "$next" "$base/current"
  else
    rm -f "$base/current"
  fi
}

if ! nginx -t; then
  rollback
  exit 1
fi
if systemctl is-active --quiet nginx; then
  if ! systemctl reload nginx; then
    rollback
    nginx -t && systemctl reload nginx
    exit 1
  fi
fi
echo 'Certificate installed successfully'

其中 ssh -n 很容易被忽略:它防止 SSH 消耗调用脚本的标准输入。通过 SSH + heredoc 批量执行部署命令时,如果遗漏,后面的命令可能被内部 SSH 吃掉。

证书存放在版本目录中,current 是指向当前版本的符号链接。新证书通过验证后才切换链接,并执行 nginx -t。保留旧版本目录,方便回退;后续可以按保留策略清理旧版本,但不要删除当前或回滚所需的证书。

chmod 700 /usr/local/sbin/pull-home-certificate
chmod 600 /etc/nginx/home-cert-pull-key /etc/nginx/home-cert-known-hosts

/usr/local/sbin/pull-home-certificate
nginx -t

8.4 拉取任务与启动顺序

/etc/systemd/system/home-certificate-pull.service

[Unit]
Description=Fetch home HTTPS certificate through a restricted SSH key
Wants=network-online.target tailscaled.service
After=network-online.target tailscaled.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/pull-home-certificate
TimeoutStartSec=120

/etc/systemd/system/home-certificate-pull.timer

[Unit]
Description=Refresh home HTTPS certificate twice daily

[Timer]
OnBootSec=3min
OnUnitActiveSec=12h
RandomizedDelaySec=10min

[Install]
WantedBy=timers.target

网关的 Tailscale 地址并不一定在 Nginx 启动那一刻就已经存在。仓库中的 wait-home-addresses.sh 会等待 LAN 与 Tailscale 地址都出现,再启动 Nginx。

把它安装为 /usr/local/sbin/wait-home-addresses,并把 nginx-tailnet.conf 放到 /etc/systemd/system/nginx.service.d/tailscale.conf

chmod 755 /usr/local/sbin/wait-home-addresses
systemctl daemon-reload
systemctl enable --now nginx
systemctl enable --now home-certificate-pull.timer

这套方案中,公网服务器负责续期,家庭网关每 12 小时检查一次新证书,所以续期完成到网关加载之间存在同步间隔。排查或首次部署时,手动运行拉取任务即可立即同步。

九、DNS:外面指向 Tailscale,家里指向局域网

9.1 发布公网 DNS 记录

证书和反向代理准备好以后,在公网服务器执行:

/opt/tailscale-home-dns/dnspod_home.py apply

最终 DNSPod 中是这样的记录:

pve.example.com        A  100.100.10.10
nav.example.com        A  100.100.10.10
git.example.com        A  100.100.10.10
navidrome.example.com  A  100.100.10.10
grafana.example.com    A  100.100.10.10
nacos.example.com      A  100.100.10.10
rustfs.example.com     A  100.100.10.10
powerjob.example.com   A  100.100.10.10
es.example.com         A  100.100.10.10

9.2 处理 DNS 重绑定过滤

本次遇到过一个现象:公共 DNS 能返回正确的 Tailscale 地址,但家中路由器查询同一个域名时,返回 NOERROR,却没有 A 记录。

检查路由器后发现启用了 DNS 重绑定保护。这类保护会过滤部分指向私有地址的上游解析结果,因此不能把“状态是 NOERROR”直接当成“已经解析成功”。

我在 Tailscale 的 DNS 页面添加了只针对服务域名后缀的 Split DNS:

Domain: example.com
Nameservers:
  223.5.5.5
  223.6.6.6

这两个公共解析器是本次实测可用的选择,并非必须使用的固定配置。实际部署应选择客户端网络能够访问、能正确返回记录的解析器。

这个设置只负责对应域名后缀,没有开启全局 DNS 覆盖。外部客户端保持“使用 Tailscale DNS”开启。Tailscale DNS 配置说明

9.3 补齐家中无需 Tailscale 的访问路径

最初只配置了公网 A 记录和 Tailscale 入口,结果手机连接 Tailscale 时可用,但没装 Tailscale 的家中电脑,从 Homepage 点击新链接就打不开。

因此,家庭 DNS 和 LAN 监听必须一起补上

在 QWRT/OpenWrt 中:

  1. 为网关的 MAC 保留固定 DHCP 地址,例如 192.168.1.250
  2. 在“网络 → DNS → 常规 → 地址”增加下面的规则。
  3. 保存并应用 DNS 配置。
/pve.example.com/nav.example.com/git.example.com/navidrome.example.com/grafana.example.com/nacos.example.com/rustfs.example.com/powerjob.example.com/es.example.com/192.168.1.250

这是 dnsmasq 的地址映射语法,匹配列出的域名及其下级名称,不是让你修改整个根域名的解析。现有公开网站、音乐卡片和 Steam 卡片保持原路径。

保留路由器的重绑定保护,用受控的本地解析解决这几个名字,不必关闭整个保护功能。

如果某台家中设备开启了绕过路由器的加密 DNS,它可能仍取得公网记录里的 Tailscale 地址。这时应让它使用家庭 DNS,或在该设备上连接 Tailscale。

另外,本次路由器还开启了 DNS 重定向。因此,在家里执行 dig @某个公共DNS 不一定真的查询到了那台服务器。验证公网解析时,我也从公网服务器侧进行查询,避免把被路由器接管的结果当成公网结果。

十、应用配置也需要跟上

代理能返回首页,不等于应用所有链接都已经正确。还需要检查登录跳转、静态资源、WebSocket 和应用生成的绝对 URL。

应用 本次调整
Homepage 增加 nav.example.com 的允许 Host,更新网页入口,刷新静态页面
Forgejo 更新网页 DOMAINROOT_URL,保留独立的 SSH 地址配置
Grafana 更新 domainroot_url
PVE 验证后端证书,转发 WebSocket
RustFS 域名根路径跳转到 /rustfs/console/
其余控制台 检查页面与同源 JS/CSS 能否正常加载

Homepage 示例:

HOMEPAGE_ALLOWED_HOSTS=localhost:3000,192.168.1.20:3000,nav.example.com

修改时是在原有允许列表中追加,不是覆盖掉其他仍在使用的入口。Homepage Host 配置

Forgejo 的网页配置示例:

[server]
DOMAIN = git.example.com
ROOT_URL = https://git.example.com/

Grafana 示例:

[server]
domain = grafana.example.com
root_url = https://grafana.example.com/

按对应应用要求重新加载配置。Homepage 修改环境变量需要重启进程;服务链接变更后,我还使用它提供的刷新接口重新生成静态页面:

curl -fsS https://nav.example.com/api/revalidate

如果 Homepage 开启了自身认证,刷新操作也需要相应登录状态。

Homepage 的网页 href 应切到新域名,但服务器端监控 widget 仍可使用内部 IP。不应对整个配置文件做无差别字符串替换,把监控、数据库或认证配置一起改掉。

十一、怎样验证确实绕开了冲突?

11.1 家中电脑:不开 Tailscale

getent ahostsv4 nav.example.com

curl --noproxy '*' -sS -L \
  -o /dev/null \
  -w 'HTTP=%{http_code} remote=%{remote_ip} total=%{time_total}\n' \
  https://nav.example.com/

预期解析和连接目标都是 192.168.1.250

本次在没有安装 Tailscale 的家中电脑上,验证了全部 9 个 HTTPS 入口,均返回 200,实际连接到网关 LAN 地址,一次请求约 30–40 ms。也在浏览器里从原来的 Homepage 局域网地址点击 PVE,成功到达 HTTPS 登录页。

11.2 外部设备:开启 Tailscale

同样访问这些域名,连接目标应是网关 Tailscale IP:

HTTP=200 remote=100.100.10.10

本次还用没有接受家庭子网路由的公网节点访问这些 HTTPS 域名,验证全部入口正常。这一步很关键:它证明网页访问走的是 Tailscale 网关加反向代理,而不是碰巧还在使用原来的家庭子网路由。

直连和中继状态依然可以通过下面的命令检查:

tailscale status
tailscale ping home-router

域名不会强制流量一定走公网中继;直连可用时仍优先直连。Peer Relay 的角色与上一篇相同。

11.3 自动续期与同步分别验证

公网服务器演练续期:

/opt/tailscale-home-dns/venv/bin/certbot renew \
  --cert-name tailscale-home \
  --dry-run --no-random-sleep-on-renew

家庭网关检查证书同步:

systemctl start home-certificate-pull.service
journalctl -u home-certificate-pull.service -n 30 --no-pager
nginx -t

本次真实部署的 9 个域名模拟续期全部成功,临时 TXT 与验证状态文件清理完成,证书拉取也成功。

Type=oneshot 服务执行结束后显示 inactive (dead) 可以是正常状态,应该结合退出码、Result=success 和日志判断。

这次验证覆盖了首页、部分同源静态资源、域名跳转和证书链路。没有据此声称每个应用的全部登录、写操作及交互功能都已完成测试。

十二、顺手处理的另外两类证书

这次后续还整理了已有公网卡片和 Halo 的证书。它们与私有服务使用不同的续期方式,不应该混成一个“每天全部强制续签”的脚本。

场景 谁申请/续期 谁部署
家庭私有 HTTPS 域名 公网服务器 Certbot + DNS-01 家庭网关定时拉取并 reload Nginx
公开 Steam / 音乐卡片 公网服务器 Certbot + HTTP-01 deploy hook 更新站点文件并 reload OpenResty
Halo 源站 原有 1Panel 自动续期 1Panel 部署,另有每日一致性检查
Halo 的 EdgeOne 边缘证书 EdgeOne 免费证书服务 EdgeOne 自动部署、更新

12.1 公开卡片:webroot 验证加部署 hook

公开卡片的 80 端口已经能提供 ACME challenge 文件,因此使用 HTTP-01 即可,不需要再增加 DNS API 依赖。

本次 OpenResty 容器中的 /usr/share/nginx/html 映射到宿主机:

/opt/1panel/apps/openresty/openresty/root

首次签发示例:

/opt/tailscale-home-dns/venv/bin/certbot certonly \
  --webroot -w /opt/1panel/apps/openresty/openresty/root \
  --cert-name public-cards \
  --non-interactive --agree-tos --register-unsafely-without-email \
  -d steam.example.com -d music.example.com

配套脚本 deploy-public-card-certificates.sh 是本次部署 hook 的脱敏版本。它只处理 public-cards 这一套证书,检查域名与私钥后备份并更新两个站点的文件,再执行 OpenResty 配置检查和 reload。使用前把脚本中的 openresty 改成实际容器名。

安装位置:

/etc/letsencrypt/renewal-hooks/deploy/30-public-cards

该目录里的脚本应有执行权限。仓库还提供 public-cards-cert-renew.service.timer,每天检查两次。

演练时需要同时验证部署 hook:

/opt/tailscale-home-dns/venv/bin/certbot renew \
  --cert-name public-cards \
  --dry-run --run-deploy-hooks --no-random-sleep-on-renew

本次这一流程已通过,线上两个卡片站点也确认提供了更新后的证书。

12.2 Halo:分清源站证书和 CDN 边缘证书

检查 Halo 时发现,源站的证书其实已经开启 1Panel 自动续期,网站绑定也正确。真正还在手动维护的是 EdgeOne 边缘节点使用的 SSL 托管证书。

最终处理分成两部分:

  • 在 EdgeOne 把主域名和 www 切换为免费自动证书,并确认签发、部署完成。控制台显示到期前 15 天自动更新。
  • 在公网服务器增加每日 06:40 的源站检查,核对 1Panel 管理证书、站点文件和 OpenResty 实际提供的证书是否一致。不同步时自动同步和 reload,但不创建第二套证书签发任务。

配套脚本 halo-cert-maintenance.py 保留了这部分逻辑:证书绑定查询、私钥匹配、域名与有效期检查、文件备份和 TLS 指纹核对。它依赖本次 1Panel v2 数据库结构,使用前需要核对自己的版本;它只读管理数据库,不向数据库写入证书。

对应的 .service / .timer 也在仓库中。这个检查任务的失败会记录在 systemd 日志,本次没有额外配置邮件或聊天通知。

两层证书分别自动维护,就不用每次续签源站后,再手工下载、上传到 CDN。1Panel 自动续期EdgeOne 免费证书各自负责对应的一层。

十三、多人使用时,共用 443 端口意味着什么?

反向代理让多个服务共用网关的 443 端口,使用起来方便,但权限管理也要跟着调整思路。

如果原来想用 Tailscale 的 IP/端口规则区分“谁能访问音乐库、谁能访问 PVE”,现在它们在网络层看起来都指向同一个 100.100.10.10:443

因此,按服务区分权限需要应用自身认证、反向代理认证,或者独立的入口地址/端口等设计。不能认为域名不同,Tailscale 的 IP/端口访问规则就会自动把它们隔开。

另外,公开 DNS 和公开可信证书通常会暴露域名存在这一事实;真正的访问边界仍来自网络路径、Tailscale 访问控制和应用认证。博客与仓库中不放真实密钥,实际服务也不应依赖“别人不知道 URL”作为唯一保护。

十四、开源脚本与复用顺序

本篇配套文件已按 MIT 许可证开源在 GitHub 仓库,目录为 scripts/。凭据模板位于 config/tencentcloud.example.json,安装步骤和离线验证命令见 README

scripts/
├── public/
│   ├── dnspod_home.py
│   ├── export-home-certificate.sh
│   ├── tailscale-home-cert-renew.service
│   └── tailscale-home-cert-renew.timer
├── gateway/
│   ├── generate_proxy.py
│   ├── home-services.conf
│   ├── pull-home-certificate.sh
│   ├── wait-home-addresses.sh
│   ├── nginx-tailnet.conf
│   ├── home-certificate-pull.service
│   └── home-certificate-pull.timer
└── optional/
    ├── deploy-public-card-certificates.sh
    ├── public-cards-cert-renew.service
    ├── public-cards-cert-renew.timer
    ├── halo-cert-maintenance.py
    ├── halo-cert-maintenance.service
    └── halo-cert-maintenance.timer

建议按下面的顺序复用:

  1. 修改脚本中的域名、地址和容器名,确认与自己的服务清单一致。
  2. 在公网服务器配置 DNS 授权,完成证书首次签发。
  3. 在家庭网关准备 Nginx 配置、PVE CA 和受限 SSH 同步。
  4. 拉取证书,启动 Nginx,先用指定解析测试入口。
  5. 发布公网 A 记录,并补齐家庭 DNS 与固定 DHCP 地址。
  6. 调整应用外部 URL 和 Homepage 链接。
  7. 分别验证家中、外部与自动续期,最后启用各自的定时任务。

可以通过 git clone https://github.com/Hanserwei/tailscale-home-services.git 获取脚本,也可下载 ZIP。仓库根目录执行 python3 tools/verify.py 可以做离线语法与示例配置一致性检查;它不会修改 DNS、签发证书或启动服务。具体范围见验证记录

这次改造最终解决的是一条完整的访问路径:域名把客户端带到正确的入口,网关在家里找到应用,证书自动续期并真正加载到服务中。家庭 DNS 补齐以后,同一套链接才能同时服务家里和外面的设备。