踩坑笔记 # 内网 # 网络通信已发布

从公网暴露到按需访问:Caddy + Tailscale 改造自建服务的完整实践

一次真实的个人服务器改造复盘:将代码仓库、身份认证、持续集成、镜像仓库和后台管理从公网迁入内网,同时保留 API、网站和文件服务的公网访问...

2026.11.0110 分钟阅读
从公网暴露到按需访问:Caddy + Tailscale 改造自建服务的完整实践
从公网暴露到按需访问:Caddy + Tailscale 改造自建服务的完整实践 · Cover

一次真实的个人服务器改造复盘:将代码仓库、身份认证、持续集成、镜像仓库和后台管理从公网迁入内网,同时保留 API、网站和文件服务的公网访问。

为什么要做这次改造

最初,我把多个自建服务都部署在同一台云服务器上,用 Caddy 按域名转发。开发使用起来很方便,但 Gitea、Casdoor、Drone、Harbor 和后台管理界面并不需要所有互联网用户访问。公开这些服务意味着更多扫描面、更多需要维护的认证入口,以及容易被忽视的 Docker 端口暴露。

这次改造的目标不是把所有服务都关进内网,而是重新划分边界:公开网站和 API 继续服务互联网;开发工具、管理后台、CI/CD 和镜像仓库只对家庭局域网、授权的 Tailscale 设备与必要的服务器内部组件开放。

改造后的流量路径大致如下:

flowchart TD
    Internet[公网用户] --> Caddy[Caddy HTTPS 入口]
    Home[家庭网络与 Tailnet 设备] --> Caddy
    Caddy --> Public[公开 API / 博客 / 文件代理]
    Caddy --> Gate{内网来源检查}
    Gate -->|允许| Private[管理后台 / Gitea / Casdoor / Drone / Harbor]
    Gate -->|拒绝| Deny[403]
    Internal[授权的内部服务] --> Gate

这里有一个很重要的认识:DNS、Tailscale 路由、Caddy 白名单、Docker 网络和应用登录认证分别解决不同的问题,任何一层都不能代替其他层。

Caddy 配置拆分:站点与公共逻辑解耦

以前所有域名都写在一个 Caddyfile 中,重复出现证书配置、日志输出和反向代理。服务数量增长后,改一处配置容易误伤其他站点。

现在按用途拆分:

/etc/caddy/
├── Caddyfile
├── snippets/
│   ├── access-log.caddy
│   ├── tls.caddy
│   ├── internal.caddy
│   └── public.caddy
├── sites-internal/
│   ├── git.caddy
│   ├── auth.caddy
│   ├── ci.caddy
│   └── registry.caddy
└── sites-public/
    ├── api.caddy
    └── files.caddy

主文件只负责导入:

import snippets/*.caddy
import sites-internal/*.caddy
import sites-public/*.caddy

可复用的公网反向代理:

(public_proxy) {
    reverse_proxy {args[0]}
}

可复用的内网反向代理(IP 和网段通过环境变量或导入参数提供):

(internal_proxy) {
    @trusted remote_ip {$HOME_TAILSCALE_IP} {args[1:]}

    handle @trusted {
        reverse_proxy {args[0]}
    }

    handle {
        respond "Forbidden" 403
    }
}

环境变量 HOME_TAILSCALE_IP 是自定义示例名称,必须在 Caddy 的 systemd 环境中实际设置;它不是 Caddy 的内置变量。{args[0]} 是代理地址,{args[1:]} 是可选的额外可信来源。允许哪些 Docker 来源,应先依据真实日志核实,而不是盲目填写整个私网。

下面使用示例端口环境变量表示后端地址:

git.internal.example.com {
    import access_log
    import alidns_tls
    import internal_proxy localhost:{$GIT_WEB_PORT} {$CI_SOURCE_CIDR}
}

api.example.net {
    import access_log
    import alidns_tls
    import public_proxy localhost:{$API_WEB_PORT}
}

日志和 DNS-01 HTTPS 证书也各自写成 snippet。DNS-01 的优势是内网域名不必开放公网 HTTP 验证入口就能申请证书;所用云 DNS API 凭据应放在受权限保护的服务环境中,避免直接写入公开仓库。

修改 Caddy 时坚持两步:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

踩坑: 曾经因为 Caddy 日志文件目录权限不正确,导致 reload 失败并停留在异常状态。要查 journalctl -u caddy 和 systemctl status caddy,验证配置并不等于运行用户拥有日志文件写权限。不能把 reload 卡住一概当成 Caddyfile 语法错误。

内网 DNS 解析成功,不代表所有设备都能访问

我用一个泛域名把 *.internal.example.com 解析到 Tailnet 地址。家庭设备经由子网路由器可以连通,但未加入 Tailnet 的公网设备通常没有到目标地址的有效路由。这种设计能减少内部 DNS 记录的维护成本。

但它不是访问控制:公开 DNS 即使返回私有网络地址,也不能替代 Caddy 白名单和 Tailscale ACL/Grants。相反,若原有公网域名和 Caddy 旧站点仍然保留,也可能出现“新地址只能内网访问,旧地址依然可访问”的情况。

另外,本地开启 Clash Fake-IP 后,DNS 返回的测试网段虚拟地址不等于真实服务器地址。排查 Git SSH 或访问路径时,应同时检查代理规则、实际连接目标、DNS 记录和服务器端监听端口。

Gitea + Casdoor:OAuth 回调域名必须一致

迁移 Gitea 域名后,我先在 Casdoor 中修改了 Redirect URI,却仍然看到旧域名出现在报错中:

Redirect URI ... is not in the allowed Redirect URI list

根因不是 Casdoor 没保存,而是 Gitea 仍通过旧 ROOT_URL 生成 OAuth 回调。

Gitea 官方 Docker 镜像可通过环境变量覆盖配置:

environment:
  - GITEA__server__ROOT_URL=https://git.internal.example.com/
  - GITEA__server__DOMAIN=git.internal.example.com

与此同时,Casdoor 应用的允许回调地址需要与 Gitea 实际发送的 URI 完全一致。认证源名称、大小写和路径都不可随意改变。修改环境变量后应重新创建 Gitea 容器,并检查生成的 app.ini 是否确实更新。

第二个坑是 Gitea 在服务器端拉取 Casdoor 的 OIDC Discovery 文档时,Docker 内置 DNS 无法解析内网域名:

lookup auth.internal.example.com on Docker embedded DNS: no such host

解决思路有两部分:给容器提供可靠的内部 DNS 或 extra_hosts 映射;同时确认它到 Caddy 的 HTTPS 访问未被白名单拒绝。DNS 修好以后仍可能遇到 403,这是两个不同层面的问题。

Drone:登录 Cookie、Token 交换和 Webhook 是三件事

把 Drone Web 页面改成内网域名后,最初点击 Continue 会跳回旧公网地址,并报:

http: named cookie not present

这是因为 Drone 的对外地址仍然保留旧值。OAuth 登录前写入新域名的状态 Cookie,不会自动发送给旧域名。需要同时检查:

environment:
  - DRONE_SERVER_HOST=ci.internal.example.com
  - DRONE_SERVER_PROTO=https
  - DRONE_GITEA_SERVER=https://git.internal.example.com

还要在 Gitea 中修改为 Drone 创建的 OAuth 应用回调地址,例如:

https://ci.internal.example.com/login

之后出现 oauth: cannot exchange code 和前端 /api/user 的 Unauthorized。排查发现:Drone 容器能连到 Caddy,但 Caddy 返回了 403。浏览器登录正常,不代表后台 Token 交换正常——后者是 Drone 容器主动向 Gitea 发出的请求,来源地址不同。

排查时分别验证:

  1. Drone 容器能否解析 Gitea 的内网域名;
  2. 从 Drone 网络环境访问 Gitea HTTPS 是否返回预期状态;
  3. Caddy 日志记录的 remote_ip 是多少;
  4. Gitea OAuth 应用中的回调 URL 是否与 Drone 地址一致;
  5. Git Push 触发的 Webhook 是否仍指向旧域名。

这里没有采用“放行所有 Docker 私网”的捷径。按站点只开放实际需要的内部调用来源更安全;如果存在 Docker NAT,不同容器可能共享 Caddy 看到的来源 IP,不能把来源 IP 当成可靠的容器身份。

Git SSH 不会因为 HTTPS 域名迁移而自动关闭

迁移完 Gitea Web 后,旧 SSH Remote 竟然还能正常 push。原因很简单:git@host:group/repo.git 使用 SSH,并不走 Caddy 的普通 HTTP reverse_proxy。

我们用以下命令区分 Git SSH 与服务器管理 SSH:

docker port gitea
sudo ss -lntp

最终为 Gitea SSH 单独绑定到 Tailnet 接口,而管理 SSH 保持独立。示例 Compose:

ports:
  - "127.0.0.1:${GIT_WEB_HOST_PORT}:${GIT_WEB_CONTAINER_PORT}"
  - "${ECS_TAILSCALE_IP}:${GIT_SSH_HOST_PORT}:${GIT_SSH_CONTAINER_PORT}"

示例中宿主机与容器端口均通过变量配置,不包含真实端口。Tailnet 接口需要先于端口发布就绪,否则开机重建可能失败。

已有本地 Git 仓库无需重新 Clone;确认新 SSH 端口和认证正常后修改 Remote:

git remote set-url origin ssh://git@git.internal.example.com:<GIT_SSH_PORT>/group/repo.git
git ls-remote origin HEAD

再验证 fetch、pull、push。关闭旧域名之前,还要排查其他设备、子模块、CI 脚本和部署密钥是否引用旧地址。

Harbor:最复杂的不是 UI,而是外部依赖与镜像引用

Harbor 的 Web 页面能打开,不代表 Registry 可用。迁移时重点检查 /v2/ API:未认证时返回 401 Unauthorized 通常是预期行为,还要查看 WWW-Authenticate 头中的 Token realm 是否使用新域名。

官方安装包使用 harbor.yml,反向代理场景应统一:

hostname: registry.internal.example.com
external_url: https://registry.internal.example.com

prepare 会生成服务配置,重新创建容器时可能覆盖手工修改的网络连接。我的 Harbor 使用外部 Redis 和 PostgreSQL,最初只修改域名,看似无关,却在容器重建后连续出现:

lookup redis-service: no such host
lookup database-service: no such host

原来外部数据库在业务 Docker 网络,Harbor Core 在自己的 Compose 网络。之前手动执行的 docker network connect 没有写入 Compose,重建以后丢失了。

补齐网络后又遇到 PostgreSQL:

FATAL: no pg_hba.conf entry for host "<HARBOR_CORE_IP>"

这说明 DNS 和 TCP 已经通了,下一关是 PostgreSQL 来源地址认证。最终为 Harbor 使用的数据库账号、数据库及必要来源网段增加 scram-sha-256 规则,并重新加载配置。绝不能用 trust 或无限制开放来“修复”连接。

这一连串问题让我更确定:数据库、缓存等外部依赖的跨 Compose 网络关系必须写成声明式配置,不能只依赖临时手工连接。

迁移 Registry 后,也需要修改 Docker Compose、Dockerfile、CI 和部署脚本中包含的镜像域名;旧镜像引用不会自动变成新地址。先在真正的部署机器上验证 docker login、pull、push,再逐个重建业务容器,旧入口最后关闭。

另一个坑是 ECS 自己不一定能解析 registry.internal.example.com,尽管家里的 Windows 可以。对与 Caddy 同机的 Docker Daemon,可以在验证本机 HTTPS 和 Caddy 白名单后,按需配置宿主机名称解析;但容器不会自动继承宿主机 /etc/hosts,Runner 的联网能力也需要单独检查。

最后把公网端口真正收紧

把站点域名改为内网,并不会自动关闭原来的容器宿主机端口。例如:

ports:
  - "${WEB_PUBLIC_PORT}:${WEB_CONTAINER_PORT}"  # 可能绑定所有接口

如果本机 Caddy 是唯一的访问入口,更合适的是:

ports:
  - "127.0.0.1:${WEB_HOST_PORT}:${WEB_CONTAINER_PORT}"

然后用 docker port、ss -lntp、云安全组和外部网络实测确认。不能把“DNS 已删除”理解成“服务已下线”:旧 Caddy 站点、直接监听端口、缓存的 DNS 和 SSH 入口都可能仍可访问。

收尾顺序固定为:

  1. 新域名的 Web、OAuth、Git SSH、Registry Push/Pull 逐项验证;
  2. 更新所有应用回调、Webhook、Git Remote 和镜像引用;
  3. 把 Redis、PostgreSQL 等外部依赖网络写入持久配置;
  4. 将非必要的 Web 端口绑定本机,并检查云安全组;
  5. 移除 Caddy 旧站点;
  6. 确认没有依赖后删除旧 DNS 记录;
  7. 最后用非 Tailnet 的网络验证内网站点确实无法访问。

对于对象存储 CDN 文件,若选择经 Caddy 转发以使用自动证书续签,需要接受文件流量经过 ECS 的带宽成本;若改为 CDN CNAME 直连,则 HTTPS 证书必须在 CDN 平台配置。私有文件的访问控制必须依赖存储空间权限与签名下载,不能只依赖域名名称。

结语

这次改造真正花时间的不是写 Caddyfile,而是拆清几个常被混淆的概念:浏览器可访问 ≠ 容器可解析;DNS 可解析 ≠ Caddy 放行;页面可登录 ≠ OAuth 后台换令牌成功;Web 已内网化 ≠ Git SSH 已内网化;更改域名 ≠ 镜像引用和 Webhook 自动更新。

最后形成了一套更清楚的边界:公网服务按需开放,管理与开发系统通过 Tailnet 访问,Caddy 统一处理证书、日志和反向代理,Docker 发布端口尽可能收敛,外部数据库依赖通过持久配置管理。监控侧继续将 Caddy 访问日志送往 Loki/Grafana,后续还可以针对认证失败、异常来源和 Registry 访问建立专用面板与告警。

比“全部能访问”更重要的,是清楚地知道每一条请求为什么能访问,以及为什么其他请求不能访问。

#内网 #网络通信
ZZC

ZZC

写代码,也写一些生活。