feat(cert,scripts,docs): Linux 原生安装包与证书申请三处修复(DNS 解析器 / Buypass 下线 / ZeroSSL 报错与密钥脱敏)(v1.2.13)
CI / verify (push) Canceled after 0s
CI / verify (push) Canceled after 0s
- Linux 安装包:scripts/build-linux.ps1 交叉编译出 fonu-<版本>-linux-{amd64,arm64}.tar.gz(二进制 + migrations + systemd 安装脚本 + 运行时资源 + sha256),scripts/fonu-install.sh 负责装机(/opt/fonu、/var/lib/fonu、/etc/fonu/fonu.env 首次生成随机会话密钥)并新增 --reset-admin;装完直接打印初始管理员口令
- 证书 DNS-01:lego 默认读系统 resolv.conf(容器里是 Docker 的 127.0.0.11,不返回权威 NS)导致 could not determine authoritative nameservers;现显式传 AddRecursiveNameservers,默认 223.5.5.5/1.1.1.1/8.8.8.8,设置项 acme_dns_resolvers 可覆盖(system = 回到系统解析器)
- Buypass 已停止签发(2025-10-15)并终止服务(2026-04-15):从可选 CA 移除,历史记录仍可展示,直接提交会被拦下并给出中文原因
- ZeroSSL:422 时带出 error.type/error.code(非 JSON 退回截断正文);同时修掉网络错误把含 access_key 的完整 URL 写进任务日志与 app.log 的密钥泄露
- 测试:解析器列表解析 3 例 + ZeroSSL EAB 4 例(含不泄露密钥断言);gofmt / go build / go vet / go test 全绿
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
构建 Fonu 的 Linux 安装包(tar.gz:二进制 + migrations + systemd 安装脚本)。
|
||||
|
||||
.DESCRIPTION
|
||||
与 Dockerfile 同源的三步:
|
||||
1) 前端构建(VITE_APP_VERSION 取根目录 VERSION,dist 会被 //go:embed 进二进制)
|
||||
2) 交叉编译 fonu-agent 双架构到 internal/frp/agentbin(主程序构建时内嵌)
|
||||
3) CGO_ENABLED=0 交叉编译主程序并打包
|
||||
依赖是纯 Go 的(modernc.org/sqlite),所以 Windows 上就能产出 Linux 包,不需要 Docker/WSL。
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/build-linux.ps1 # 双架构
|
||||
./scripts/build-linux.ps1 -Arch amd64 # 只出 amd64
|
||||
./scripts/build-linux.ps1 -SkipWeb # 前端已构建过,跳过 npm
|
||||
#>
|
||||
param(
|
||||
[ValidateSet('amd64', 'arm64', 'both')] [string]$Arch = 'both',
|
||||
[string]$OutDir = 'dist',
|
||||
[switch]$SkipWeb,
|
||||
[switch]$SkipAgent
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
$version = (Get-Content (Join-Path $root 'VERSION') -Raw).Trim()
|
||||
if (-not $version) { throw 'VERSION 文件为空,无法确定版本号' }
|
||||
Write-Host "==> Fonu $version" -ForegroundColor Cyan
|
||||
|
||||
# ---- 1) 前端:dist 缺失会让主程序编译失败(//go:embed all:web/dist)
|
||||
if (-not $SkipWeb) {
|
||||
Push-Location (Join-Path $root 'web')
|
||||
try {
|
||||
if (-not (Test-Path 'node_modules')) {
|
||||
Write-Host '==> npm ci(首次构建,需要网络)'
|
||||
npm ci
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm ci 失败(exit $LASTEXITCODE)" }
|
||||
}
|
||||
$env:VITE_APP_VERSION = $version
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) { throw "前端构建失败(exit $LASTEXITCODE)" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item Env:VITE_APP_VERSION -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $root 'cmd/fonu/web/dist/index.html'))) {
|
||||
throw '缺少 cmd/fonu/web/dist/index.html:请先构建前端(或去掉 -SkipWeb)'
|
||||
}
|
||||
|
||||
# ---- 2) fonu-agent 双架构:主程序会内嵌这两个文件,必须在构建主程序前就位
|
||||
if (-not $SkipAgent) {
|
||||
Write-Host '==> 交叉编译 fonu-agent(amd64/arm64)'
|
||||
$env:CGO_ENABLED = '0'
|
||||
$env:GOOS = 'linux'
|
||||
foreach ($a in @('amd64', 'arm64')) {
|
||||
$env:GOARCH = $a
|
||||
go build -trimpath -o "internal/frp/agentbin/fonu-agent-linux-$a" ./cmd/fonu-agent
|
||||
if ($LASTEXITCODE -ne 0) { throw "fonu-agent($a) 构建失败(exit $LASTEXITCODE)" }
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 3) 主程序 + 打包
|
||||
$archs = if ($Arch -eq 'both') { @('amd64', 'arm64') } else { @($Arch) }
|
||||
$outPath = Join-Path $root $OutDir
|
||||
New-Item -ItemType Directory -Force -Path $outPath | Out-Null
|
||||
|
||||
try {
|
||||
foreach ($a in $archs) {
|
||||
$name = "fonu-$version-linux-$a"
|
||||
$stage = Join-Path $outPath $name
|
||||
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'migrations') | Out-Null
|
||||
|
||||
Write-Host "==> 交叉编译 fonu(linux/$a)"
|
||||
$env:CGO_ENABLED = '0'
|
||||
$env:GOOS = 'linux'
|
||||
$env:GOARCH = $a
|
||||
go build -trimpath `
|
||||
-ldflags "-s -w -X github.com/fonu/fonu/internal/version.Version=$version" `
|
||||
-o (Join-Path $stage 'fonu') ./cmd/fonu
|
||||
if ($LASTEXITCODE -ne 0) { throw "fonu($a) 构建失败(exit $LASTEXITCODE)" }
|
||||
|
||||
# 包内容与 Dockerfile 的运行时 stage 对齐:migrations 必须与二进制同级(程序按工作目录找)
|
||||
Copy-Item (Join-Path $root 'migrations/*.sql') (Join-Path $stage 'migrations') -Force
|
||||
Copy-Item (Join-Path $root 'scripts/fonu-install.sh') (Join-Path $stage 'install.sh') -Force
|
||||
Copy-Item (Join-Path $root 'VERSION') $stage -Force
|
||||
Copy-Item (Join-Path $root 'LICENSE') $stage -Force
|
||||
if (Test-Path (Join-Path $root 'web/assets/image')) {
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'web/assets') | Out-Null
|
||||
Copy-Item -Recurse -Force (Join-Path $root 'web/assets/image') (Join-Path $stage 'web/assets')
|
||||
}
|
||||
|
||||
$tarball = Join-Path $outPath "$name.tar.gz"
|
||||
if (Test-Path $tarball) { Remove-Item -Force $tarball }
|
||||
tar -czf $tarball -C $outPath $name
|
||||
if ($LASTEXITCODE -ne 0) { throw "打包失败(exit $LASTEXITCODE)" }
|
||||
|
||||
$hash = (Get-FileHash $tarball -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $name.tar.gz" | Out-File -Encoding ascii "$tarball.sha256"
|
||||
$sizeMB = [math]::Round((Get-Item $tarball).Length / 1MB, 2)
|
||||
Write-Host (" {0}.tar.gz {1} MB sha256 {2}" -f $name, $sizeMB, $hash.Substring(0, 16)) -ForegroundColor Green
|
||||
}
|
||||
} finally {
|
||||
# 交叉编译用的环境变量必须复位,否则后续 go vet/test 会继续按 linux 目标跑
|
||||
Remove-Item Env:GOOS, Env:GOARCH, Env:CGO_ENABLED -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "==> 产物在 $outPath" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fonu Linux 安装脚本(随 tar.gz 一起分发)
|
||||
#
|
||||
# sudo bash ./install.sh 安装并启动
|
||||
# sudo bash ./install.sh --port 8080 换监听端口
|
||||
# sudo bash ./install.sh --uninstall 卸载(保留数据目录)
|
||||
# sudo bash ./install.sh --uninstall --purge 卸载并删除数据目录
|
||||
#
|
||||
# 用 `bash ./install.sh` 而不是 `./install.sh`:Windows 上打出来的 tar 不带执行位
|
||||
# (本机 tar 不支持 --mode,待装本时无法保留 +x),显式调 bash 就不需要先 chmod。
|
||||
#
|
||||
# 关于初始口令:Fonu 首次启动会随机生成一个 16 位管理员口令(用户名 admin),
|
||||
# 打进攻日志与日志文件(<数据目录>/logs/app.log),本脚本装完会直接把它打印出来。
|
||||
# 忘了 / 想重新初始化:sudo bash ./install.sh --reset-admin
|
||||
#
|
||||
# 安装布局(与 Docker 版同一套环境变量,见 Dockerfile 的 ENV 块):
|
||||
# /opt/fonu/fonu 主程序
|
||||
# /opt/fonu/migrations/*.sql 迁移脚本(程序按工作目录找它,必须与二进制同级)
|
||||
# /var/lib/fonu/ 数据目录(库、日志、证书、nginx 与 frp 的运行数据)
|
||||
# /etc/fonu/fonu.env 环境变量(含随机生成的会话密钥,0600)
|
||||
# /etc/systemd/system/fonu.service
|
||||
set -euo pipefail
|
||||
|
||||
PREFIX=/opt/fonu
|
||||
DATA_DIR=/var/lib/fonu
|
||||
ENV_DIR=/etc/fonu
|
||||
ENV_FILE=$ENV_DIR/fonu.env
|
||||
UNIT=/etc/systemd/system/fonu.service
|
||||
SERVICE_NAME=fonu
|
||||
PORT=6893
|
||||
UNINSTALL=0
|
||||
PURGE=0
|
||||
RESET_ADMIN=0
|
||||
|
||||
usage() {
|
||||
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--prefix) PREFIX=${2:?}; shift 2 ;;
|
||||
--data) DATA_DIR=${2:?}; shift 2 ;;
|
||||
--port) PORT=${2:?}; shift 2 ;;
|
||||
--uninstall) UNINSTALL=1; shift ;;
|
||||
--purge) PURGE=1; shift ;;
|
||||
--reset-admin) RESET_ADMIN=1; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "未知参数:$1" >&2; usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "请用 root 运行(sudo bash ./install.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$UNINSTALL" -eq 1 ]; then
|
||||
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
||||
rm -f "$UNIT"
|
||||
systemctl daemon-reload
|
||||
rm -rf "$PREFIX"
|
||||
if [ "$PURGE" -eq 1 ]; then
|
||||
rm -rf "$DATA_DIR" "$ENV_DIR"
|
||||
echo "已卸载,并删除了数据目录 $DATA_DIR 与 $ENV_DIR"
|
||||
else
|
||||
echo "已卸载。数据目录保留在 $DATA_DIR(如需一并删除:--uninstall --purge)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 从日志里拽出最新一次生成的管理员口令(应用启动时写的是 JSON 行,stdout 与 app.log 都有)
|
||||
print_initial_password() {
|
||||
local line
|
||||
line=$(journalctl -u "$SERVICE_NAME" -o cat --no-pager 2>/dev/null \
|
||||
| grep -o '"password":"[^"]*"' | tail -n 1)
|
||||
if [ -z "$line" ]; then
|
||||
line=$(grep -o '"password":"[^"]*"' "$DATA_DIR/logs/app.log" 2>/dev/null | tail -n 1)
|
||||
fi
|
||||
if [ -n "$line" ]; then
|
||||
pw=$(printf '%s' "$line" | sed 's/^"password":"//; s/"$//')
|
||||
echo " 初始口令:$pw"
|
||||
echo " 用户名:admin(首次登录后请在「设置」里改掉)"
|
||||
echo " 这条口令只在首次启动时生成一次,日志文件:$DATA_DIR/logs/app.log"
|
||||
else
|
||||
echo " 未从日志里读到初始口令(可能不是首次启动);手动查看:"
|
||||
echo " journalctl -u $SERVICE_NAME --no-pager | grep -i password | tail -n 3"
|
||||
fi
|
||||
}
|
||||
|
||||
# 重置管理员:删掉 admins 行后重启,启动流程会重新生成一个随机口令并写进日志
|
||||
reset_admin() {
|
||||
if [ ! -f "$DATA_DIR/fonu.db" ]; then
|
||||
echo "找不到数据库 $DATA_DIR/fonu.db,无需重置" >&2
|
||||
exit 1
|
||||
fi
|
||||
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
local backup="$DATA_DIR/fonu.db.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$DATA_DIR/fonu.db" "$backup"
|
||||
if command -v sqlite3 >/dev/null 2>&1; then
|
||||
sqlite3 "$DATA_DIR/fonu.db" 'DELETE FROM sessions; DELETE FROM admins;'
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$DATA_DIR/fonu.db" <<'PY'
|
||||
import sqlite3, sys
|
||||
conn = sqlite3.connect(sys.argv[1])
|
||||
conn.execute('DELETE FROM sessions')
|
||||
conn.execute('DELETE FROM admins')
|
||||
conn.commit()
|
||||
PY
|
||||
else
|
||||
echo "需要 sqlite3 或 python3 才能改库(已备份到 $backup)" >&2
|
||||
systemctl start "$SERVICE_NAME" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "==> 已清空管理员与先前的会话(数据库已备份:$backup)"
|
||||
systemctl start "$SERVICE_NAME"
|
||||
sleep 2
|
||||
systemctl is-active --quiet "$SERVICE_NAME" || {
|
||||
echo "服务未能启动,请看:journalctl -u $SERVICE_NAME -n 50 --no-pager" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "==> 已重置,下面是新生成的管理员口令:"
|
||||
print_initial_password
|
||||
exit 0
|
||||
}
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
if [ "$RESET_ADMIN" -eq 1 ]; then
|
||||
reset_admin
|
||||
fi
|
||||
|
||||
for f in fonu migrations; do
|
||||
if [ ! -e "$SCRIPT_DIR/$f" ]; then
|
||||
echo "安装包不完整:缺少 $f(请在解压后的目录里运行 ./install.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 架构自检:避免把 amd64 包装到 arm64 机器上,起来才报 exec format error
|
||||
case "$(uname -m)" in
|
||||
x86_64) PKG_ARCH=amd64 ;;
|
||||
aarch64|arm64) PKG_ARCH=arm64 ;;
|
||||
*) PKG_ARCH=unknown ;;
|
||||
esac
|
||||
case "$SCRIPT_DIR" in
|
||||
*"-linux-amd64") BIN_ARCH=amd64 ;;
|
||||
*"-linux-arm64") BIN_ARCH=arm64 ;;
|
||||
*) BIN_ARCH=$PKG_ARCH ;;
|
||||
esac
|
||||
if [ "$PKG_ARCH" != unknown ] && [ "$BIN_ARCH" != "$PKG_ARCH" ]; then
|
||||
echo "警告:当前机器是 $PKG_ARCH,但这个包看起来是 $BIN_ARCH(可能无法运行)" >&2
|
||||
fi
|
||||
|
||||
# nginx 的 mime.types 路径各发行版不同(Debian/Ubuntu 在 /etc/nginx,RHEL 系在 /usr/share/nginx)
|
||||
MIME_TYPES=/etc/nginx/mime.types
|
||||
[ -f "$MIME_TYPES" ] || MIME_TYPES=/usr/share/nginx/mime.types
|
||||
|
||||
echo "==> 安装到 $PREFIX(数据目录 $DATA_DIR,监听 :$PORT)"
|
||||
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
|
||||
install -d -m 0755 "$PREFIX"
|
||||
install -d -m 0750 "$DATA_DIR"
|
||||
install -d -m 0750 "$ENV_DIR"
|
||||
install -d -m 0755 "$PREFIX/migrations"
|
||||
|
||||
install -m 0755 "$SCRIPT_DIR/fonu" "$PREFIX/fonu"
|
||||
rm -f "$PREFIX"/migrations/*.sql
|
||||
cp "$SCRIPT_DIR"/migrations/*.sql "$PREFIX/migrations/"
|
||||
install -m 0644 "$SCRIPT_DIR/VERSION" "$PREFIX/VERSION" 2>/dev/null || true
|
||||
|
||||
# 环境变量只在首次安装时生成:重装不会换掉会话密钥(换了所有人都会掉线)
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
SECRET=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Fonu 环境变量(由 install.sh 生成)。修改后:systemctl restart $SERVICE_NAME
|
||||
FONU_DATA_DIR=$DATA_DIR
|
||||
FONU_LISTEN=:$PORT
|
||||
FONU_SESSION_SECRET=$SECRET
|
||||
FONU_NGINX_MIME_TYPES=$MIME_TYPES
|
||||
TZ=${TZ:-Asia/Shanghai}
|
||||
EOF
|
||||
chmod 0600 "$ENV_FILE"
|
||||
echo "==> 已生成 $ENV_FILE(含随机会话密钥)"
|
||||
else
|
||||
echo "==> 保留已有 $ENV_FILE(如需改端口请编辑它后重启服务)"
|
||||
fi
|
||||
|
||||
cat > "$UNIT" <<EOF
|
||||
[Unit]
|
||||
Description=Fonu 自托管网关管理面板
|
||||
Documentation=https://github.com/fonu/fonu
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Fonu 会管理同机的 nginx(改配置 / reload / 绑 80·443)并拉起 frpc,需要相应权限
|
||||
User=root
|
||||
WorkingDirectory=$PREFIX
|
||||
EnvironmentFile=$ENV_FILE
|
||||
ExecStart=$PREFIX/fonu
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
# 日志进 journald:journalctl -u $SERVICE_NAME -f
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "$SERVICE_NAME"
|
||||
|
||||
sleep 1
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
echo
|
||||
echo "==> 安装完成,服务已启动"
|
||||
echo " 访问:http://${IP:-<本机IP>}:$PORT"
|
||||
print_initial_password
|
||||
echo " 状态:systemctl status $SERVICE_NAME"
|
||||
echo " 日志:journalctl -u $SERVICE_NAME -f"
|
||||
echo " 升级:先停止服务,再重跑本脚本(数据与密钥都会保留)"
|
||||
else
|
||||
echo "服务未能启动,请看日志:journalctl -u $SERVICE_NAME -n 50 --no-pager" >&2
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user