简介
适用场景:Scaleway等仅提供原生IPv6、通过WARP获得IPv4出口的Debian 13云服务器。
本文只覆盖系统初始化、WARP、Tailscale、ZeroTier、Caddy和安全检查,不包含任何AI Agent的安装配置。
本文示例使用占位符,不包含真实主机名、地址、网络ID、账号或密钥。发布前仍应执行文末的隐私扫描,不要直接发布完整终端日志。
需要让Agent帮你部署操作的,直接将这篇文章丢给你的Agent(Codex/Claude/Hermes/OpenClaw等),让Agent学习,按照你的要求进行部署即可。
1. 目标架构
Debian 13宿主机
├── 原生IPv6网卡,例如 `ensX`
├── WARP:提供IPv4/IPv6出站能力
├── Tailscale:远程管理、可选Exit Node
├── ZeroTier:私有虚拟局域网
├── Caddy:反向代理Tailscale/ZeroTier内网服务
└── Swap:512 MiB
建议保持以下边界:
- WARP负责服务器公网出站;
- Tailscale负责远程管理和可选Exit Node;
- ZeroTier负责另一套私有组网;
- 不让Tailscale、ZeroTier或Caddy随意接管默认路由和DNS;
- Caddy只代理明确指定的内网服务,不默认公开管理后台;
- AI Agent单独使用普通用户运行,不加入
sudo或docker组。

2. 登录后的基础检查
sudo -i
cat /etc/os-release
uname -a
hostnamectl
ip -br address
ip -6 route show default
df -h /
free -h
确认:
- 系统为Debian 13;
- 主网卡有全球IPv6地址;
- IPv6默认路由仍然存在;
- 根分区有足够空间;
- 当前没有正在运行的高负载任务。
# 安装基础软件
apt install bash bash-completion sudo wget curl nano htop -y
# 系统更新
sudo apt update -y && sudo apt list --upgradable
sudo apt upgrade -y && sudo apt full-upgrade -y
sudo apt autoremove -y
3. 设置主机名
使用自己的主机名替换示例变量。不要在博客中写入真实主机名。
SERVER_HOSTNAME="edge-v6-node-01"
hostnamectl set-hostname "$SERVER_HOSTNAME"
if ! grep -qE "^[[:space:]]*127\\.0\\.1\\.1[[:space:]]+$SERVER_HOSTNAME([[:space:]]|$)" /etc/hosts; then
printf '127.0.1.1\t%s\n' "$SERVER_HOSTNAME" >> /etc/hosts
fi
hostnamectl
hostname
cat /etc/hostname
如果使用大写主机名,建议在DNS记录中仍使用小写形式,避免不同客户端的名称规范化差异。
4. 创建512 MiB Swap
这里使用512 MiB,而不是模糊的“0.5GB”。在Linux服务器上,512 MiB更容易复现和验证。
if swapon --show --noheadings --raw | awk '{print $1}' | grep -qx '/swapfile'; then
echo 'Swapfile already active; skip creation.'
elif [ -e /swapfile ]; then
echo '/swapfile exists but is not active. Inspect it before overwriting.' >&2
exit 1
else
dd if=/dev/zero of=/swapfile bs=1M count=512 status=progress conv=fsync
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
fi
grep -qE '^[[:space:]]*/swapfile[[:space:]]' /etc/fstab || \
printf '/swapfile none swap sw 0 0\n' >> /etc/fstab
swapon --show
free -h
初期建议保持Linux默认:
sysctl vm.swappiness
# 预期通常为60
不要因为当前Swap为0就立即扩大Swap。先部署服务,再根据实际内存压力、vmstat和OOM日志判断。
5. 安装基础工具
apt update
apt install -y \
ca-certificates \
curl \
wget \
gnupg \
debian-keyring \
debian-archive-keyring \
apt-transport-https \
ethtool
检查TUN设备:
modprobe tun
test -c /dev/net/tun && echo 'TUN OK' || echo 'TUN unavailable'
printf 'tun\n' > /etc/modules-load.d/99-cxt-tun.conf
6. WARP安装与注意事项
本次实际使用的是第三方脚本。第三方脚本应先审阅内容,并且不建议未经审核地放进通用初始化脚本自动执行。
手动安装示例:
cd /tmp
wget -N https://gitlab.com/fscarmen/warp/-/raw/main/menu.sh && bash menu.sh d
其中d是脚本菜单中的双栈相关模式,具体含义以脚本当前版本菜单为准。安装完成后检查:
warp v
ip -br address
ip rule
ip route show table 51820 || true
ip -6 rule
ip -6 route show table 51820 || true
wg show warp
不同版本的WARP脚本可能只为IPv4建立51820策略路由表;因此ip -6 route show table 51820没有输出不一定是故障。最终应以实际IPv4/IPv6出口测试、WireGuard握手和流量统计为准。
验证IPv4/IPv6出口:
curl -s -4 'https://www.cloudflare.com/cdn-cgi/trace' \
| grep -E '^(ip|colo|loc|warp)='
curl -s -6 'https://www.cloudflare.com/cdn-cgi/trace' \
| grep -E '^(ip|colo|loc|warp)='
重点检查:
warp=on
loc=目标出口国家
WARP通常只负责出站流量,不应将WARP虚拟IPv6地址当作服务器公网入站地址。公网服务的AAAA记录应指向云厂商原生IPv6。
7. Tailscale安装
官方安装脚本:
curl -fsSL https://tailscale.com/install.sh | sh
systemctl enable --now tailscaled
systemctl is-active tailscaled
7.1 仅作为远程管理节点
tailscale up \
--accept-dns=false \
--accept-routes=false
这会显示登录URL。登录完成后检查:
tailscale status
tailscale ip
tailscale netcheck
7.2 作为Exit Node
如果确实要让其他Tailnet设备通过本机上网,需要先开启转发。
注意:Scaleway原生IPv6默认路由来自Router Advertisement。开启IPv6转发时必须保留accept_ra=2,否则默认路由可能在RA过期后消失。
PRIMARY_IFACE="$(ip -6 route show default | awk '/^default/{print $5; exit}')"
[ -n "$PRIMARY_IFACE" ] || { echo "未找到IPv6默认路由接口" >&2; exit 1; }
echo "Primary interface: $PRIMARY_IFACE"
sysctl -w "net.ipv6.conf.${PRIMARY_IFACE}.accept_ra=2"
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1
cat > /etc/sysctl.d/99-cxt-tailscale-forwarding.conf <<EOF
# Keep Scaleway IPv6 Router Advertisements while forwarding is enabled.
net.ipv6.conf.${PRIMARY_IFACE}.accept_ra = 2
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sysctl -p /etc/sysctl.d/99-cxt-tailscale-forwarding.conf
ip -6 route show default
然后配置Tailscale:
tailscale up \
--advertise-exit-node \
--accept-dns=false \
--accept-routes=false
还需要在Tailscale管理后台批准该机器为Exit Node,并在客户端主动选择它。
7.3 UDP GRO转发优化
这项优化不会改变功能,只改善UDP转发吞吐。
ethtool -K "$PRIMARY_IFACE" \
rx-udp-gro-forwarding on \
rx-gro-list off
ethtool -k "$PRIMARY_IFACE" \
| grep -E 'rx-udp-gro-forwarding|rx-gro-list'
为了重启后自动应用,创建systemd服务:
cat > /etc/systemd/system/tailscale-ethtool.service <<EOF
[Unit]
Description=Tailscale UDP GRO optimization for ${PRIMARY_IFACE}
Wants=network-online.target
After=network-online.target
Before=tailscaled.service
ConditionPathExists=/sys/class/net/${PRIMARY_IFACE}
[Service]
Type=oneshot
ExecStart=/usr/sbin/ethtool -K ${PRIMARY_IFACE} rx-udp-gro-forwarding on rx-gro-list off
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now tailscale-ethtool.service
systemctl --no-pager --full status tailscale-ethtool.service
8. ZeroTier安装
官方安装:
curl -fsSL https://install.zerotier.com | bash
systemctl enable --now zerotier-one
zerotier-cli info
加入网络时替换为自己的Network ID。Network ID不是登录密码,但仍不应把真实ID、节点ID和完整终端输出直接发布到博客。
ZT_NETWORK_ID="0000000000000000"
zerotier-cli join "$ZT_NETWORK_ID"
确认在ZeroTier Central批准成员后,再设置拆分路由,避免覆盖WARP、Tailscale和系统DNS:
zerotier-cli set "$ZT_NETWORK_ID" allowManaged=1
zerotier-cli set "$ZT_NETWORK_ID" allowGlobal=0
zerotier-cli set "$ZT_NETWORK_ID" allowDefault=0
zerotier-cli set "$ZT_NETWORK_ID" allowDNS=0
注意:ZeroTier 1.16.x的CLI需要把设置和值写成一个参数,例如:
allowDefault=0
不要写成两个参数:
allowDefault 0
检查:
zerotier-cli listnetworks
zerotier-cli get "$ZT_NETWORK_ID" allowManaged
zerotier-cli get "$ZT_NETWORK_ID" allowGlobal
zerotier-cli get "$ZT_NETWORK_ID" allowDefault
zerotier-cli get "$ZT_NETWORK_ID" allowDNS
ip -br address | grep -E '^zt'
期望值:
allowManaged: 1
allowGlobal: 0
allowDefault: 0
allowDNS: 0
确认ZeroTier没有安装默认路由:
ip route show dev ztXXXXXXX
ip -6 route show dev ztXXXXXXX
ip -6 route show default
ip route show table 51820
ip -6 route show table 51820
9. Caddy安装
官方软件源安装:
curl -1sLf \
'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor --yes \
-o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf \
'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
> /etc/apt/sources.list.d/caddy-stable.list
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
chmod o+r /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install -y caddy
全新安装且尚未写入自己的Caddyfile时,先不要直接公开默认页面:
systemctl disable --now caddy
9.1 Caddy代理Tailscale/ZeroTier内网服务
示例:
service.example.invalid {
encode zstd gzip
# 后端可以是Tailscale或ZeroTier地址
reverse_proxy 100.64.0.10:8080
}
Caddy服务器可以通过Tailscale/ZeroTier访问后端;后端不需要公网IP。
配置前应确认:
- DNS的AAAA记录指向云厂商原生IPv6;
- Scaleway Security Group放行TCP 80和443;
- 后端服务有独立认证;
- 不要把没有认证的管理面板直接暴露公网。
加载配置:
caddy fmt --overwrite /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
systemctl enable --now caddy
systemctl --no-pager --full status caddy
9.2 Caddy是否需要代理AI Agent
不一定需要。
Caddy代理Agent WebUI的作用是让外部浏览器访问Agent Gateway。它与消息渠道的连接方式不是一回事:Telegram轮询和个人微信iLink主动连接通常不要求公网反向代理;Telegram Webhook、外部Webhook或浏览器访问WebUI才可能需要Caddy。
建议默认保持Agent只监听:
127.0.0.1:<AGENT_PORT>
只有在确实需要浏览器访问、外部Webhook或第三方服务调用时,才为Agent单独配置Caddy,并增加HTTPS、认证和访问控制。
10. 组合网络检查
echo '===== Interfaces ====='
ip -br address
echo '===== Native IPv6 default ====='
ip -6 route show default
echo '===== WARP routes ====='
ip rule
ip route show table 51820
ip -6 route show table 51820
echo '===== Tailscale ====='
tailscale status
tailscale netcheck
echo '===== ZeroTier ====='
zerotier-cli info
zerotier-cli listnetworks
echo '===== Services ====='
systemctl is-active tailscaled
systemctl is-active zerotier-one
systemctl is-active caddy
echo '===== Resources ====='
free -h
df -h /
swapon --show
11. 排错重点
IPv6默认路由消失
现象:
SSH断开
curl -6失败
ip -6 route show default没有输出
检查:
PRIMARY_IFACE="$(ip -6 route show default | awk '/^default/{print $5; exit}')"
[ -n "$PRIMARY_IFACE" ] || { echo "未找到IPv6默认路由接口" >&2; exit 1; }
sysctl net.ipv6.conf.all.forwarding
sysctl "net.ipv6.conf.${PRIMARY_IFACE}.accept_ra"
ip -6 route show default
恢复接收RA:
sysctl -w "net.ipv6.conf.${PRIMARY_IFACE}.accept_ra=2"
不要盲目假设网卡一定叫ens2;先从默认路由的dev字段确定实际接口名。
WARP正常但Tailscale出口节点不可用
检查:
sysctl net.ipv4.ip_forward
sysctl net.ipv6.conf.all.forwarding
ip route show table 51820
ip -6 route show table 51820
tailscale status
tailscale netcheck
确认:
- Tailscale管理后台已批准Exit Node;
- 客户端已选择该Exit Node;
- Tailscale没有接受其他节点发布的默认路由;
- WARP的策略路由表仍存在。
ZeroTier设置命令显示帮助
错误写法:
zerotier-cli set NETWORK_ID allowDefault 0
正确写法:
zerotier-cli set NETWORK_ID allowDefault=0
Caddy启动失败
caddy validate --config /etc/caddy/Caddyfile
journalctl -u caddy -n 100 --no-pager
ss -lntp | grep -E ':(80|443)'
常见原因:
- 80/443被其他服务占用;
- DNS没有指向原生IPv6;
- Security Group未放行;
- Caddy配置的后端Tailscale/ZeroTier地址不可达;
- Caddy只绑定了尚未启动的Tailscale地址。
空间不足
df -h /
du -xhd1 /var /home 2>/dev/null | sort -h
apt clean
journalctl --vacuum-size=50M
不要在5GB磁盘上长期保留大量Docker镜像、编译缓存或日志。journalctl --vacuum-size=50M会删除较旧的系统日志,只在确认不再需要这些日志时执行。
12. Agent说明
本文不安装、不配置任何AI Agent。Agent通常涉及账号、API Key、Webhook、消息渠道和权限策略,应在基础网络环境稳定后单独选择、核对官方文档并进行最小权限配置。
不要把Agent的API Key、Bot Token、Cookie、完整配置文件或完整终端日志放入博客、代码仓库或公共工单。
13. 一键脚本的边界
init-debian13-ipv6-server.sh适合在全新或接近全新的Debian 13 IPv6服务器上执行。它会安装基础工具、创建512 MiB Swap、开启转发、安装Tailscale、安装ZeroTier、应用可用的UDP GRO优化并安装Caddy。
脚本不会自动执行以下高风险或需要人工授权的操作:
- 不自动安装WARP第三方脚本;
- 不执行
tailscale up,也不会替你完成账号登录; - 不替你批准ZeroTier成员;
- 不写入Caddy反向代理站点;
- 不安装或配置AI Agent;
- 不自动公开任何Web服务。
首次运行前请先阅读脚本。可以通过环境变量传入实际参数,或在交互提示中输入参数;但不要把真实参数写进博客脚本正文。
14. 隐私发布检查清单
发布博客前搜索并删除或替换:
真实主机名
真实IPv6地址
真实IPv6网关或链路本地地址
真实IPv4地址
Tailscale 100.x地址
ZeroTier 10.x或fdxx地址
ZeroTier Network ID
ZeroTier Node ID
Tailscale账号邮箱
Tailscale机器ID
产品UUID
WARP公钥
API Key
Bot Token
Webhook Key
域名
SSH命令、Shell历史和配置文件中的私密参数
完整的Caddyfile、ZeroTier配置和Tailscale诊断输出
SSH公钥中不希望公开的邮箱注释
建议统一替换为:
SERVER_HOSTNAME
PRIMARY_IPV6
TAILSCALE_IP
ZEROTIER_NETWORK_ID
ZEROTIER_NODE_ID
example.invalid
<REDACTED>
本文中的通用命令应先在测试服务器验证,再用于生产环境。网络路由、WARP、Tailscale和ZeroTier同时运行时,务必保留云厂商控制台或带外控制台作为恢复入口。
一键脚本如下(init-debian13-ipv6-server.sh):
安全声明:在用户使用/Agent调用之前,请再次进行安全审查和校验一键脚本。
因版本更迭、发布时间差异、项目重构、软件变化等因素、原始脚本仅供参考。
#!/usr/bin/env bash
set -Eeuo pipefail
# Debian 13 IPv6-only server bootstrap.
#
# This script intentionally does NOT install WARP automatically: the WARP
# installer used in this workflow is a third-party interactive script and
# should be reviewed and run manually.
#
# It also does NOT authenticate Tailscale, approve ZeroTier, configure Caddy,
# or install/configure an AI Agent. Those steps require operator decisions.
log() { printf '\n[%s] %s\n' "$(date -Is)" "$*"; }
die() { printf '\nERROR: %s\n' "$*" >&2; exit 1; }
[[ "$EUID" -eq 0 ]] || die 'Run this script as root.'
command -v systemctl >/dev/null 2>&1 || die 'systemd is required.'
[[ -r /etc/os-release ]] || die '/etc/os-release is missing.'
# Keep this script tied to the documented target platform.
. /etc/os-release
[[ "${ID:-}" == 'debian' && "${VERSION_ID:-}" == '13' ]] || \
die "This script targets Debian 13; detected ${PRETTY_NAME:-unknown}."
SERVER_HOSTNAME="${SERVER_HOSTNAME:-}"
ZT_NETWORK_ID="${ZT_NETWORK_ID:-}"
PRIMARY_IFACE="${PRIMARY_IFACE:-}"
if [[ -z "$SERVER_HOSTNAME" ]]; then
read -r -p 'Hostname (example: edge-v6-node-01): ' SERVER_HOSTNAME
fi
[[ "$SERVER_HOSTNAME" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || \
die 'Invalid hostname: use letters, digits, dots and hyphens only.'
[[ "$SERVER_HOSTNAME" != *..* ]] || die 'Invalid hostname: consecutive dots are not allowed.'
(( ${#SERVER_HOSTNAME} <= 63 )) || die 'Hostname is longer than 63 characters.'
if [[ -z "$PRIMARY_IFACE" ]]; then
PRIMARY_IFACE="$(ip -6 route show default 2>/dev/null | awk '/^default/{print $5; exit}' || true)"
fi
PRIMARY_IFACE="${PRIMARY_IFACE:-ens2}"
[[ "$PRIMARY_IFACE" =~ ^[A-Za-z0-9_.-]{1,15}$ ]] || \
die 'Invalid network interface name.'
[[ -d "/sys/class/net/$PRIMARY_IFACE" ]] || \
die "Network interface does not exist: $PRIMARY_IFACE"
ip -6 route show default | grep -q '^default' || \
die 'No IPv6 default route found; restore native IPv6 connectivity before continuing.'
if [[ -n "$ZT_NETWORK_ID" ]]; then
[[ "$ZT_NETWORK_ID" =~ ^[0-9a-fA-F]{16}$ ]] || \
die 'ZT_NETWORK_ID must be a 16-hex-digit Network ID.'
fi
log "Using hostname: $SERVER_HOSTNAME"
log "Using primary IPv6 interface: $PRIMARY_IFACE"
log 'Installing base packages'
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y \
ca-certificates curl wget gnupg \
debian-keyring debian-archive-keyring \
apt-transport-https ethtool
log 'Setting hostname'
hostnamectl set-hostname "$SERVER_HOSTNAME"
if ! awk -v h="$SERVER_HOSTNAME" \
'$1 == "127.0.1.1" && $2 == h { found=1 } END { exit !found }' \
/etc/hosts; then
printf '127.0.1.1\t%s\n' "$SERVER_HOSTNAME" >> /etc/hosts
fi
log 'Creating 512 MiB swap if not already active'
if swapon --show --noheadings --raw | awk '{print $1}' | grep -qx '/swapfile'; then
log 'Swapfile is already active; skipping.'
elif [[ -e /swapfile ]]; then
die '/swapfile exists but is not active; inspect it manually before changing it.'
else
dd if=/dev/zero of=/swapfile bs=1M count=512 status=progress conv=fsync
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
fi
grep -qE '^[[:space:]]*/swapfile[[:space:]]' /etc/fstab || \
printf '/swapfile none swap sw 0 0\n' >> /etc/fstab
log 'Checking TUN support'
modprobe tun || die 'Could not load the tun kernel module.'
test -c /dev/net/tun || die '/dev/net/tun is unavailable; Tailscale Exit Node setup cannot continue.'
TUN_MODULE_FILE='/etc/modules-load.d/99-cxt-tun.conf'
grep -qxF tun "$TUN_MODULE_FILE" 2>/dev/null || printf 'tun\n' >> "$TUN_MODULE_FILE"
log 'Enabling IPv4/IPv6 forwarding while preserving IPv6 Router Advertisements'
sysctl -w "net.ipv6.conf.${PRIMARY_IFACE}.accept_ra=2"
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1
SYSCTL_FILE='/etc/sysctl.d/99-cxt-tailscale-forwarding.conf'
cat > "$SYSCTL_FILE" <<EOF
# Keep the native IPv6 Router Advertisement route while forwarding is enabled.
net.ipv6.conf.${PRIMARY_IFACE}.accept_ra = 2
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
chmod 0644 "$SYSCTL_FILE"
sysctl -p "$SYSCTL_FILE"
log 'Installing Tailscale if necessary'
if ! command -v tailscale >/dev/null 2>&1; then
curl -fsSL https://tailscale.com/install.sh | sh
fi
systemctl enable --now tailscaled
log 'Applying UDP GRO forwarding optimization when supported'
ETHTOOL_BIN="$(command -v ethtool)"
GRO_SUPPORTED=0
if "$ETHTOOL_BIN" -K "$PRIMARY_IFACE" \
rx-udp-gro-forwarding on rx-gro-list off; then
GRO_SUPPORTED=1
else
log 'GRO optimization is unsupported by this network interface; continuing without the helper service.'
fi
if (( GRO_SUPPORTED == 1 )); then
cat > /etc/systemd/system/tailscale-ethtool.service <<EOF
[Unit]
Description=Tailscale UDP GRO optimization for ${PRIMARY_IFACE}
Wants=network-online.target
After=network-online.target
Before=tailscaled.service
ConditionPathExists=/sys/class/net/${PRIMARY_IFACE}
[Service]
Type=oneshot
ExecStart=${ETHTOOL_BIN} -K ${PRIMARY_IFACE} rx-udp-gro-forwarding on rx-gro-list off
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
chmod 0644 /etc/systemd/system/tailscale-ethtool.service
systemctl daemon-reload
systemctl enable --now tailscale-ethtool.service
else
log 'Skipping persistent tailscale-ethtool.service because GRO is unavailable.'
fi
log 'Installing ZeroTier if necessary'
if ! command -v zerotier-cli >/dev/null 2>&1; then
curl -fsSL https://install.zerotier.com | bash
fi
systemctl enable --now zerotier-one
if [[ -n "$ZT_NETWORK_ID" ]]; then
if ! zerotier-cli listnetworks | awk -v id="$ZT_NETWORK_ID" \
'$1 == id { found=1 } END { exit !found }'; then
log 'Joining the supplied ZeroTier network.'
zerotier-cli join "$ZT_NETWORK_ID"
else
log 'ZeroTier network is already joined; skipping join.'
fi
zerotier-cli set "$ZT_NETWORK_ID" allowManaged=1
zerotier-cli set "$ZT_NETWORK_ID" allowGlobal=0
zerotier-cli set "$ZT_NETWORK_ID" allowDefault=0
zerotier-cli set "$ZT_NETWORK_ID" allowDNS=0
else
log 'ZT_NETWORK_ID is empty; ZeroTier is installed but no network was joined.'
fi
log 'Installing Caddy if necessary'
CADDY_PREEXISTING=0
CADDY_WAS_ACTIVE=0
if command -v caddy >/dev/null 2>&1; then
CADDY_PREEXISTING=1
if systemctl is-active --quiet caddy; then
CADDY_WAS_ACTIVE=1
fi
fi
if (( CADDY_PREEXISTING == 0 )); then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor --yes \
-o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
> /etc/apt/sources.list.d/caddy-stable.list
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
chmod o+r /etc/apt/sources.list.d/caddy-stable.list
apt-get update
apt-get install -y caddy
# A fresh package may start its default site. Stop it until the operator
# writes and validates a real Caddyfile.
systemctl disable --now caddy || true
log 'Caddy installed but left stopped until /etc/caddy/Caddyfile is reviewed.'
elif (( CADDY_WAS_ACTIVE == 1 )); then
log 'Caddy was already active; leaving its existing configuration untouched.'
else
log 'Caddy was already installed and stopped; preserving its current state.'
fi
apt-get clean
log 'Bootstrap complete'
printf '\nManual next steps:\n'
printf ' 1. Review and run the WARP installer manually if IPv4 egress is required.\n'
printf ' 2. Authenticate Tailscale, then choose one:\n'
printf ' tailscale up --accept-dns=false --accept-routes=false\n'
printf ' tailscale up --advertise-exit-node --accept-dns=false --accept-routes=false\n'
printf ' 3. Approve the ZeroTier member in ZeroTier Central.\n'
printf ' 4. Write and validate /etc/caddy/Caddyfile before starting Caddy.\n'
printf ' 5. Check: free -h; df -h /; ip -6 route show default; tailscale status; zerotier-cli listnetworks\n'