Files
NexusGate/scripts/agent-cert.sh
Muse 084afeb8f4 3x-ui 式共享节点改造:nodes 状态机 + nodeClients 各自凭据
- 移除 VMess 协议
- VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级)
- sing-box v2ray_api stats 支持
- 内存优化:面板 128M,agent 64M,sing-box 64M
2026-10-19 22:23:40 +08:00

274 lines
13 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -Eeuo pipefail
[[ "${EUID}" -eq 0 ]] || { printf '请使用 root 运行\n' >&2; exit 1; }
action="${1:-setup}" domain="${2:-}"
if [[ "$action" == setup ]]; then
exec 3</dev/tty || { printf '交互式申请需要 SSH 终端;请使用 ng-agent cert issue 域名 邮箱\n' >&2; exit 1; }
printf '节点证书一键申请(已在 Cloudflare 配置的入口域名)\n'
read -r -p '节点域名(如 node.example.com):' domain <&3
read -r -p '证书通知邮箱(可留空,建议填写):' setup_email <&3
[[ -z "$setup_email" || "$setup_email" == *@* ]] || { printf '请输入有效邮箱\n' >&2; exit 1; }
fi
[[ "$domain" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$ ]] || { printf '请输入有效节点域名\n' >&2; exit 1; }
domain="${domain,,}"
target="/etc/nexusgate/tls/$domain"
sync_panel() {
[[ -r /etc/nexusgate/agent.env ]] || return 0
if ! ( set -a; source /etc/nexusgate/agent.env; set +a
NG_CERT_DOMAIN="$domain" node <<'NODE'
const controller = String(process.env.NG_CONTROLLER || '').replace(/\/$/, '');
const key = process.env.NG_AGENT_KEY;
const domain = process.env.NG_CERT_DOMAIN;
if (!controller || !key) process.exit(1);
fetch(`${controller}/api/agent/tls-domain`, { method:'POST',
headers:{ authorization:`Bearer ${key}`, 'content-type':'application/json' },
body:JSON.stringify({ domain }), signal:AbortSignal.timeout(10000) })
.then((response) => { if (!response.ok) throw Error(`HTTP ${response.status}`); console.log('面板设备 TLS 域名已同步'); })
.catch((error) => { console.error('面板同步失败:', error.message); process.exitCode = 1; });
NODE
); then
printf '证书已安装,但面板未同步;请在“服务器 → 编辑”手动填写 %s 后再部署。\n' "$domain" >&2
fi
}
validate() {
local cert="$1" key="$2" a b
openssl x509 -in "$cert" -noout -checkend 86400 >/dev/null || { printf '证书不存在、格式错误或将在 24 小时内过期\n' >&2; exit 1; }
openssl x509 -in "$cert" -noout -checkhost "$domain" | grep -q 'does match' || { printf '证书与节点域名不匹配\n' >&2; exit 1; }
openssl verify -verify_hostname "$domain" -untrusted "$cert" "$cert" >/dev/null || { printf '证书链不受系统信任,请使用受信任 CA 签发的证书\n' >&2; exit 1; }
a="$(openssl x509 -in "$cert" -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256)"
b="$(openssl pkey -in "$key" -pubout -outform DER | openssl dgst -sha256)"
[[ "$a" == "$b" ]] || { printf '证书和私钥不匹配\n' >&2; exit 1; }
}
activate() {
flush_usage
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
systemctl try-restart nexusgate-sing-box.service || printf 'sing-box 重启失败,请运行 ng-agent doctor\n' >&2
elif command -v rc-service >/dev/null; then
rc-service nexusgate-sing-box status >/dev/null 2>&1 && rc-service nexusgate-sing-box restart || true
fi
printf '证书已安装:%s;有效期:' "$target"
openssl x509 -in "$target/fullchain.pem" -enddate -noout
sync_panel
}
flush_usage() {
[[ -r /etc/nexusgate/agent.env && -f /opt/nexusgate-agent/agent.js ]] || return 0
if ! ( set -a; source /etc/nexusgate/agent.env; set +a
node /opt/nexusgate-agent/agent.js flush-usage ); then
printf '重启前未能上报入口计数,本次尚未上报的流量可能漏计;请检查 ng-agent doctor。\n' >&2
fi
}
install_certbot() {
if ! command -v certbot >/dev/null; then
if command -v apt-get >/dev/null; then apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq certbot
elif command -v dnf >/dev/null; then dnf install -y certbot
elif command -v apk >/dev/null; then apk add --no-cache certbot
else printf '无法安装 certbot,请使用 import 导入有效证书\n' >&2; exit 1; fi
fi
}
install_cloudflare_plugin() {
if certbot plugins 2>/dev/null | grep -q 'dns-cloudflare'; then return; fi
if command -v apt-get >/dev/null; then
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-certbot-dns-cloudflare
elif command -v dnf >/dev/null; then
dnf install -y python3-certbot-dns-cloudflare
elif command -v apk >/dev/null; then
apk add --no-cache certbot-dns-cloudflare
else
printf '无法自动安装 Cloudflare DNS 插件,请参照 Certbot 文档安装后重试\n' >&2; exit 1
fi
certbot plugins 2>/dev/null | grep -q 'dns-cloudflare' || { printf '当前 certbot 未发现 dns-cloudflare 插件;检查 Certbot 与插件是否来自同一安装来源\n' >&2; exit 1; }
}
activate_renewal() {
local cert="/etc/letsencrypt/live/$domain"
validate "$cert/fullchain.pem" "$cert/privkey.pem"
install -d -m 0700 "$target" || return 1
ln -sfn "$cert/fullchain.pem" "$target/fullchain.pem" || return 1
ln -sfn "$cert/privkey.pem" "$target/privkey.pem" || return 1
install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy || return 1
cat > /etc/letsencrypt/renewal-hooks/deploy/nexusgate-reload.sh <<'EOF'
#!/usr/bin/env bash
if [[ -r /etc/nexusgate/agent.env && -f /opt/nexusgate-agent/agent.js ]]; then
if ! ( set -a; source /etc/nexusgate/agent.env; set +a
node /opt/nexusgate-agent/agent.js flush-usage ); then
printf '证书续签后核心重启前计数上报失败,可能漏计流量;请检查 ng-agent doctor。\n' >&2
fi
fi
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
systemctl try-restart nexusgate-sing-box.service
elif command -v rc-service >/dev/null; then
rc-service nexusgate-sing-box status >/dev/null 2>&1 && rc-service nexusgate-sing-box restart || true
fi
EOF
chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/nexusgate-reload.sh
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
if systemctl is-enabled --quiet certbot.timer 2>/dev/null && systemctl start certbot.timer; then
# Distribution Certbot already runs renew. Keep only one scheduled job.
systemctl disable --now nexusgate-cert-renew.timer >/dev/null 2>&1 || true
printf '使用系统已有的 certbot.timer 自动续签。\n'
activate
return
fi
cat > /etc/systemd/system/nexusgate-cert-renew.service <<'EOF'
[Unit]
Description=Renew NexusGate node TLS certificate
[Service]
Type=oneshot
ExecStart=/usr/bin/env certbot renew --quiet
EOF
cat > /etc/systemd/system/nexusgate-cert-renew.timer <<'EOF'
[Unit]
Description=Check NexusGate node TLS certificates daily
[Timer]
OnCalendar=daily
RandomizedDelaySec=3h
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload && systemctl enable --now nexusgate-cert-renew.timer || return 1
elif command -v rc-service >/dev/null; then
install -d -m 0755 /etc/periodic/daily
printf '#!/bin/sh\ncertbot renew --quiet\n' > /etc/periodic/daily/nexusgate-cert-renew
chmod 0755 /etc/periodic/daily/nexusgate-cert-renew
rc-service crond start >/dev/null 2>&1 || true
fi
activate
}
issue_http() {
local -a registration=(--non-interactive --agree-tos)
if [[ -n "$1" ]]; then registration+=(--email "$1"); else registration+=(--register-unsafely-without-email); fi
install_certbot || return 1
certbot certonly --standalone --preferred-challenges http --cert-name "$domain" \
"${registration[@]}" -d "$domain" || return 1
activate_renewal
}
credential_token() {
local line
cf_token=''
while IFS= read -r line; do
if [[ "$line" =~ ^[[:space:]]*dns_cloudflare_api_token[[:space:]]*=(.*)$ ]]; then
cf_token="${BASH_REMATCH[1]}"
cf_token="${cf_token#"${cf_token%%[![:space:]]*}"}"
cf_token="${cf_token%"${cf_token##*[![:space:]]}"}"
return 0
fi
done < "$1"
return 1
}
verify_cf_token() {
local token="$1"
[[ -n "$token" && "$token" != *[$'\r\n']* ]] || { printf 'CF Token 不能为空或包含换行\n' >&2; return 1; }
printf '%s' "$token" | node -e '
const fs = require("node:fs");
const token = fs.readFileSync(0, "utf8");
if (/\s/.test(token)) { console.error("请只粘贴 Cloudflare API Token 的值,不要粘贴 Bearer 前缀或整条 curl 命令"); process.exit(1); }
fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(12000)
}).then(async response => {
const body = await response.json().catch(() => ({}));
if (!response.ok || !body.success || body.result?.status !== "active") {
const codes = (body.errors || []).map(item => item.code).filter(Number.isInteger).join(", ");
console.error(`Cloudflare Token 未通过验证(HTTP ${response.status}${codes ? `,代码 ${codes}` : ""})。请到 My Profile → API Tokens 新建对应 Zone 的 Edit zone DNS Token,仅粘贴令牌值;不要使用 Global API Key。`);
process.exitCode = 1;
} else console.log("Cloudflare Token 已验证有效");
}).catch(error => { console.error(`无法连接 Cloudflare API 验证令牌:${error.cause?.code || error.name};请检查出站网络`); process.exitCode = 2; });
'
}
issue_cloudflare() {
local credentials="$2" cf_token
[[ -f "$credentials" && -O "$credentials" ]] || { printf 'Cloudflare 凭据文件须由 root 拥有\n' >&2; return 1; }
chmod 0600 "$credentials"
credential_token "$credentials" || {
printf '凭据文件须包含 dns_cloudflare_api_token = ...\n' >&2; return 1;
}
verify_cf_token "$cf_token" || return 1
unset cf_token
install_certbot || return 1
install_cloudflare_plugin || return 1
local -a registration=(--non-interactive --agree-tos)
if [[ -n "$1" ]]; then registration+=(--email "$1"); else registration+=(--register-unsafely-without-email); fi
env -u CF_API_KEY -u CF_API_EMAIL -u CF_API_TOKEN -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY -u CLOUDFLARE_EMAIL \
certbot certonly --dns-cloudflare --dns-cloudflare-credentials "$credentials" --dns-cloudflare-propagation-seconds 30 \
--cert-name "$domain" "${registration[@]}" -d "$domain" || return 1
activate_renewal
}
port80_free() {
node -e 'const s=require("node:net").createServer();s.on("error",()=>process.exit(1));s.listen(80,"0.0.0.0",()=>s.close(()=>process.exit(0)))' >/dev/null 2>&1
}
setup() {
if [[ -f "/etc/letsencrypt/live/$domain/fullchain.pem" ]] &&
openssl x509 -in "/etc/letsencrypt/live/$domain/fullchain.pem" -checkend 2592000 -noout >/dev/null 2>&1; then
printf '发现已有有效证书,配置自动续签并同步面板。\n'
activate_renewal
return
fi
printf 'DNS 验证不需要公网 80 端口,但首次需输入一次 CF API Token;HTTP 验证无需 Token,但必须公网放行 80/TCP。\n'
local answer
if port80_free; then
read -r -p '验证方式:[1] Cloudflare DNS(推荐) [2] HTTP 80 端口,默认 1:' answer <&3
if [[ "$answer" == 2 ]]; then
if issue_http "$setup_email"; then return; fi
printf 'HTTP 验证失败:检查 DNS 灰云、域名 A/AAAA 指向以及机器防火墙和服务商安全组的 80/TCP;可继续改用 DNS 验证。\n' >&2
fi
else
printf '本机 80/TCP 已占用,使用 Cloudflare DNS 验证。\n'
fi
local credentials="/etc/nexusgate/cloudflare/$domain.ini" cf_token
if [[ -f "$credentials" && -O "$credentials" ]] && credential_token "$credentials" && verify_cf_token "$cf_token"; then
printf '继续使用已保存的 Cloudflare 凭据。\n'
else
printf '请到 Cloudflare → My Profile → API Tokens 创建 Edit zone DNS Token(只限此域名的 Zone),只粘贴 Token 值;输入不会回显。\n'
read -r -s -p 'CF API Token:' cf_token <&3; printf '\n'
cf_token="${cf_token#"${cf_token%%[![:space:]]*}"}"
cf_token="${cf_token%"${cf_token##*[![:space:]]}"}"
cf_token="${cf_token#Bearer }"
cf_token="${cf_token#bearer }"
cf_token="${cf_token#\"}"; cf_token="${cf_token%\"}"
verify_cf_token "$cf_token" || return 1
install -d -m 0700 /etc/nexusgate/cloudflare
local credential_tmp
credential_tmp="$(mktemp "/etc/nexusgate/cloudflare/.${domain}.XXXXXX")"
chmod 0600 "$credential_tmp"
printf 'dns_cloudflare_api_token = %s\n' "$cf_token" > "$credential_tmp"
mv -f -- "$credential_tmp" "$credentials"
fi
unset cf_token
issue_cloudflare "$setup_email" "$credentials"
}
case "$action" in
setup) setup ;;
import)
[[ $# -eq 4 ]] || { printf '用法:ng-agent cert import 域名 /path/fullchain.pem /path/privkey.pem\n' >&2; exit 1; }
validate "$3" "$4"
install -d -m 0700 "$target"
install -m 0600 "$3" "$target/fullchain.pem"
install -m 0600 "$4" "$target/privkey.pem"
activate ;;
issue)
[[ $# -eq 3 && "$3" == *@* ]] || { printf '用法:ng-agent cert issue 域名 邮箱(节点须放行 80/TCP,且没有其他程序占用)\n' >&2; exit 1; }
issue_http "$3" ;;
issue-cloudflare)
[[ $# -eq 4 && "$3" == *@* && -f "$4" ]] || { printf '用法:ng-agent cert issue-cloudflare 域名 邮箱 /root/cloudflare.ini\n' >&2; exit 1; }
issue_cloudflare "$3" "$4" ;;
status)
[[ -f "$target/fullchain.pem" ]] || { printf '未找到该节点证书\n' >&2; exit 1; }
validate "$target/fullchain.pem" "$target/privkey.pem"
openssl x509 -in "$target/fullchain.pem" -enddate -noout ;;
*) printf '用法:ng-agent cert [交互式申请] | ng-agent cert {issue 域名 邮箱|issue-cloudflare 域名 邮箱 /root/cloudflare.ini|import 域名 证书 私钥|status 域名}\n' >&2; exit 1 ;;
esac