feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 或 sudo 运行" >&2
exit 1
fi
AGENT_BINARY=${1:-havline-agent}
PREFIX=/usr/local/bin/havline-agent
CONF_DIR=/etc/havline-agent
CONF_FILE="$CONF_DIR/agent.env"
UNIT=/etc/systemd/system/havline-agent.service
mkdir -p "$CONF_DIR" /var/lib/havline-agent/certs
systemctl stop havline-agent 2>/dev/null || true
TMP_PREFIX="${PREFIX}.new"
# 迁移旧版 Agent 留在 nginx include 范围内的 <配置>.versions 目录(保留历史版本内容)
for NGINX_DIR in /etc/nginx/sites-enabled /etc/nginx/conf.d; do
[ -d "$NGINX_DIR" ] || continue
for LEGACY in "$NGINX_DIR"/*.versions; do
[ -d "$LEGACY" ] || continue
TARGET="$NGINX_DIR/.$(basename "$LEGACY")"
[ -e "$TARGET" ] || mv "$LEGACY" "$TARGET" || true
done
done
install -m 0755 "$AGENT_BINARY" "$TMP_PREFIX"
if [ ! -x "$TMP_PREFIX" ]; then
echo "agent 二进制安装失败:$TMP_PREFIX 不存在或不可执行" >&2
exit 1
fi
AGENT_VERSION=$($TMP_PREFIX -version 2>/dev/null) || {
echo "agent 二进制无法执行:$TMP_PREFIX" >&2
rm -f "$TMP_PREFIX"
exit 1
}
mv -f "$TMP_PREFIX" "$PREFIX"
if [ ! -x "$PREFIX" ]; then
echo "agent 原子替换失败:$PREFIX 不存在或不可执行" >&2
exit 1
fi
printf 'agent version: %s\n' "$AGENT_VERSION"
if [ ! -f "$CONF_FILE" ]; then
umask 077
# 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(与 Havline 内嵌副本保持同一份逻辑)
printf 'HAVLINE_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
fi
# 默认只监听回环;显式删除旧公网监听,由中央 Havline 通过 SSH 隧道访问。
cp "$CONF_FILE" "$CONF_FILE.previous" 2>/dev/null || true
touch "$CONF_FILE"
sed -i '/^HAVLINE_AGENT_LISTEN=/d; /^HAVLINE_AGENT_ALLOW_REMOTE=/d' "$CONF_FILE"
printf 'HAVLINE_AGENT_LISTEN=127.0.0.1:7700\nHAVLINE_AGENT_ALLOW_REMOTE=0\n' >> "$CONF_FILE"
cat > "$UNIT" <<'UNIT'
[Unit]
Description=Havline FRPS management agent
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/havline-agent/agent.env
ExecStart=/usr/local/bin/havline-agent
Restart=on-failure
RestartSec=3
User=root
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl restart havline-agent
systemctl enable havline-agent
TOKEN=$(sed -n 's/^HAVLINE_AGENT_TOKEN=//p' "$CONF_FILE")
echo "havline-agent 已安装并启动"
echo "API: SSH tunnel -> 127.0.0.1:7700"
echo "Token: $TOKEN"
+111
View File
@@ -0,0 +1,111 @@
<#
.SYNOPSIS
构建 Havline 的 Linux 安装包(tar.gz:二进制 + migrations + systemd 安装脚本)。
.DESCRIPTION
与 Dockerfile 同源的三步:
1) 前端构建(VITE_APP_VERSION 取根目录 VERSION,dist 会被 //go:embed 进二进制)
2) 交叉编译 havline-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 "==> Havline $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/havline/web/dist/index.html'))) {
throw '缺少 cmd/havline/web/dist/index.html:请先构建前端(或去掉 -SkipWeb)'
}
# ---- 2) havline-agent 双架构:主程序会内嵌这两个文件,必须在构建主程序前就位
if (-not $SkipAgent) {
Write-Host '==> 交叉编译 havline-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/havline-agent-linux-$a" ./cmd/havline-agent
if ($LASTEXITCODE -ne 0) { throw "havline-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 = "havline-$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 "==> 交叉编译 havline(linux/$a)"
$env:CGO_ENABLED = '0'
$env:GOOS = 'linux'
$env:GOARCH = $a
go build -trimpath `
-ldflags "-s -w -X github.com/havline/havline/internal/version.Version=$version" `
-o (Join-Path $stage 'havline') ./cmd/havline
if ($LASTEXITCODE -ne 0) { throw "havline($a) 构建失败(exit $LASTEXITCODE)" }
# 包内容与 Dockerfile 的运行时 stage 对齐:migrations 必须与二进制同级(程序按工作目录找)
Copy-Item (Join-Path $root 'migrations/*.sql') (Join-Path $stage 'migrations') -Force
Copy-Item (Join-Path $root 'scripts/havline-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
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
# Havline 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。
#
# 关于初始口令:Havline 首次启动会随机生成一个 16 位管理员口令(用户名 admin),
# 打进攻日志与日志文件(<数据目录>/logs/app.log),本脚本装完会直接把它打印出来。
# 忘了 / 想重新初始化:sudo bash ./install.sh --reset-admin
#
# 安装布局(与 Docker 版同一套环境变量,见 Dockerfile 的 ENV 块):
# /opt/havline/havline 主程序
# /opt/havline/migrations/*.sql 迁移脚本(程序按工作目录找它,必须与二进制同级)
# /var/lib/havline/ 数据目录(库、日志、证书、nginx 与 frp 的运行数据)
# /etc/havline/havline.env 环境变量(含随机生成的会话密钥,0600)
# /etc/systemd/system/havline.service
set -euo pipefail
PREFIX=/opt/havline
DATA_DIR=/var/lib/havline
ENV_DIR=/etc/havline
ENV_FILE=$ENV_DIR/havline.env
UNIT=/etc/systemd/system/havline.service
SERVICE_NAME=havline
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/havline.db" ]; then
echo "找不到数据库 $DATA_DIR/havline.db,无需重置" >&2
exit 1
fi
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
local backup="$DATA_DIR/havline.db.bak.$(date +%Y%m%d-%H%M%S)"
cp "$DATA_DIR/havline.db" "$backup"
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$DATA_DIR/havline.db" 'DELETE FROM sessions; DELETE FROM admins;'
elif command -v python3 >/dev/null 2>&1; then
python3 - "$DATA_DIR/havline.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 havline 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/havline" "$PREFIX/havline"
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
# Havline 环境变量(由 install.sh 生成)。修改后:systemctl restart $SERVICE_NAME
HAVLINE_DATA_DIR=$DATA_DIR
HAVLINE_LISTEN=:$PORT
HAVLINE_SESSION_SECRET=$SECRET
HAVLINE_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=Havline 自托管网关管理面板
Documentation=https://github.com/havline/havline
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Havline 会管理同机的 nginx(改配置 / reload / 绑 80·443)并拉起 frpc,需要相应权限
User=root
WorkingDirectory=$PREFIX
EnvironmentFile=$ENV_FILE
ExecStart=$PREFIX/havline
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
+126
View File
@@ -0,0 +1,126 @@
<#
Manual OCI layout pusher for registries whose token realm is misconfigured.
This script is an emergency fallback. It reads Docker's OCI layout output,
obtains a Bearer token over HTTPS from the Git credential store, and uploads
the blobs and image manifest directly through the Registry HTTP API.
#>
param(
[Parameter(Mandatory = $true)][string]$LayoutDir,
[Parameter(Mandatory = $true)][string]$Platform,
[Parameter(Mandatory = $true)][string]$Tag,
[string]$Registry = "jayhub.top",
[string]$Repository = "mrlee/havline",
[string]$CredentialHost = "jayhub.top"
)
$ErrorActionPreference = "Stop"
$script:Registry = $Registry
$script:Repository = $Repository
function Get-RegistryBasicAuth {
$cred = "protocol=https`nhost=$CredentialHost`n`n" | git credential fill 2>$null
$values = @{}
$cred -split "`n" | ForEach-Object {
if ($_ -match '^(.*?)=(.*)$') { $values[$matches[1]] = $matches[2] }
}
if (-not $values.username -or -not $values.password) {
throw "No Git credential found for $CredentialHost"
}
$raw = $values.username + ":" + $values.password
return "Basic " + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($raw))
}
function Get-BearerToken([string]$Scope) {
$basic = Get-RegistryBasicAuth
$url = "https://$Registry/v2/token?service=container_registry&scope=$Scope"
$response = Invoke-RestMethod -Uri $url -Headers @{ Authorization = $basic }
if (-not $response.token) { throw "Registry did not return a token" }
return $response.token
}
function Get-BlobPath([string]$LayoutRoot, [string]$Digest) {
return Join-Path $LayoutRoot ("blobs\sha256\" + ($Digest -replace '^sha256:', ''))
}
function Read-JsonBlob([string]$LayoutRoot, [string]$Digest) {
$path = Get-BlobPath $LayoutRoot $Digest
return Get-Content $path -Raw | ConvertFrom-Json
}
function Find-PlatformManifest($Index, [string]$Arch, [string]$Os) {
foreach ($entry in $Index.manifests) {
if ($entry.platform.architecture -eq $Arch -and $entry.platform.os -eq $Os) {
return $entry
}
}
return $null
}
function Resolve-ImageManifest([string]$LayoutRoot, [string]$Arch, [string]$Os) {
$index = Get-Content (Join-Path $LayoutRoot "index.json") -Raw | ConvertFrom-Json
foreach ($entry in $index.manifests) {
$document = Read-JsonBlob $LayoutRoot $entry.digest
if ($document.mediaType -eq "application/vnd.oci.image.manifest.v1+json") {
return @{ Digest = $entry.digest; Document = $document }
}
$match = Find-PlatformManifest $document $Arch $Os
if ($match) {
return @{ Digest = $match.digest; Document = (Read-JsonBlob $LayoutRoot $match.digest) }
}
}
throw "No image manifest found for $Os/$Arch"
}
function Test-BlobExists([string]$Digest, [string]$Token) {
$url = "https://$Registry/v2/$Repository/blobs/$Digest"
$response = Invoke-WebRequest -Uri $url -Method Head -Headers @{ Authorization = "Bearer $Token" } -SkipHttpErrorCheck
return $response.StatusCode -eq 200
}
function Push-Blob([string]$LayoutRoot, [string]$Digest, [string]$Token) {
if (Test-BlobExists $Digest $Token) {
Write-Output "Blob already exists: $Digest"
return
}
$postUrl = "https://$Registry/v2/$Repository/blobs/uploads/"
$post = Invoke-WebRequest -Uri $postUrl -Method Post -Headers @{ Authorization = "Bearer $Token" } -SkipHttpErrorCheck
if ($post.StatusCode -ne 202) { throw "Create upload session failed: HTTP $($post.StatusCode)" }
$location = $post.Headers["Location"]
if ($location -is [array]) { $location = $location[0] }
if ($location -notmatch '^https?://') { $location = "https://$Registry$location" }
$separator = "?"
if ($location -match '\?') { $separator = "&" }
$putUrl = $location + $separator + "digest=" + $Digest
$path = Get-BlobPath $LayoutRoot $Digest
$curlArgs = @(
"-sS", "-o", "NUL", "-w", "%{http_code}", "-X", "PUT",
"-H", "Authorization: Bearer $Token",
"-H", "Content-Type: application/octet-stream",
"--data-binary", ("@" + $path), $putUrl
)
$status = (& curl.exe @curlArgs).Trim()
if ($status -ne "201") { throw "Upload blob failed: $Digest -> HTTP $status" }
Write-Output "Uploaded blob: $Digest"
}
function Push-Manifest([string]$TagValue, [string]$MediaType, [string]$Json, [string]$Token) {
$url = "https://$Registry/v2/$Repository/manifests/$TagValue"
$response = Invoke-WebRequest -Uri $url -Method Put -Headers @{ Authorization = "Bearer $Token" } -ContentType $MediaType -Body ([Text.Encoding]::UTF8.GetBytes($Json)) -SkipHttpErrorCheck
if ($response.StatusCode -ne 201) {
throw "Push manifest failed: HTTP $($response.StatusCode) $($response.Content)"
}
return $response.Headers["Docker-Content-Digest"]
}
$parts = $Platform -split "/"
if ($parts.Count -ne 2) { throw "Platform must look like linux/amd64" }
$layoutRoot = (Resolve-Path $LayoutDir).Path
$manifest = Resolve-ImageManifest $layoutRoot $parts[1] $parts[0]
$allBlobs = @($manifest.Document.config.digest) + @($manifest.Document.layers | ForEach-Object { $_.digest })
$token = Get-BearerToken "repository:$Repository`:push,pull"
foreach ($blob in $allBlobs) { Push-Blob $layoutRoot $blob $token }
$manifestJson = Get-Content (Get-BlobPath $layoutRoot $manifest.Digest) -Raw
$digest = Push-Manifest $Tag $manifest.Document.mediaType $manifestJson $token
Write-Output "Manifest: $digest"
Write-Output ("Tag: {0}/{1}:{2}" -f $Registry, $Repository, $Tag)
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Havline 一键升级(适用于「只有编排文件」的部署目录,即 docker-compose.hub.yml 那种)
#
# sudo bash upgrade-hub.sh
#
# 可选环境变量:
# COMPOSE_FILE=docker-compose.hub.yml # 要用的编排文件
# REPO=http://.../mrlee/Havline.git # 源码仓库
# BRANCH=main # 分支
#
# 它做四件事:
# 1) 尽量取到仓库里的 VERSION,作为本次镜像 tag(有 tag 才能回滚);
# 取不到就用时间戳当 tag,同样是唯一的、可回滚的
# 2) docker compose up -d --build —— 构建上下文是 Git 地址,会重新拉最新分支
# 3) 轮询 /api/version 确认服务起来
# 4) 打印「回滚到上一个 tag」的命令
set -euo pipefail
REPO="${REPO:-https://jayhub.top/mrlee/Havline.git}"
BRANCH="${BRANCH:-main}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.hub.yml}"
APP_CONTAINER="${APP_CONTAINER:-havline}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6893/api/version}"
if ! command -v docker >/dev/null 2>&1; then
echo "没有找到 docker:这个脚本要在运行 Havline 容器的那台机器上执行" >&2
exit 1
fi
if [ ! -f "$COMPOSE_FILE" ]; then
echo "找不到 $COMPOSE_FILE:请把它放在本脚本同目录(或用 COMPOSE_FILE=... 指定)" >&2
exit 1
fi
# 升级前容器用的镜像 tag,升级失败时可以切回去
previous_image=$(docker inspect --format '{{.Config.Image}}' "$APP_CONTAINER" 2>/dev/null || echo '')
echo "==> 当前:${previous_image:-(容器未运行)}"
# 取版本号:优先仓库里的 VERSION;没 git 就退回时间戳(同样唯一,可回滚)
version=""
if command -v git >/dev/null 2>&1; then
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
if git clone --depth 1 --branch "$BRANCH" "$REPO" "$work/src" >/dev/null 2>&1 && [ -f "$work/src/VERSION" ]; then
version="$(tr -d ' \t\n\r' < "$work/src/VERSION")"
echo "==> 仓库版本:$version"
fi
fi
if [ -z "$version" ]; then
version="$(date +%Y%m%d-%H%M%S)"
echo "==> 未能从仓库读到 VERSION(可能没装 git),改用时间戳 tag:$version"
fi
export HAVLINE_VERSION="$version"
echo "==> 构建并重建:havline:$version(构建上下文是 Git 地址,会自动拉最新 $BRANCH;有缓存时只重编改动的部分)"
docker compose -f "$COMPOSE_FILE" up -d --build
echo '==> 等待服务就绪…'
for _ in $(seq 1 30); do
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
echo "==> 升级完成"
echo " 当前版本:$(curl -fsS "$HEALTH_URL" 2>/dev/null)"
if [ -n "$previous_image" ]; then
echo " 回滚:HAVLINE_VERSION=${previous_image#havline:} docker compose -f $COMPOSE_FILE up -d"
fi
echo " 日志:docker logs --tail 50 $APP_CONTAINER"
exit 0
fi
sleep 2
done
echo "服务在 60 秒内没有响应 $HEALTH_URL,请看:docker logs --tail 100 $APP_CONTAINER" >&2
if [ -n "$previous_image" ]; then
echo "如需回滚:HAVLINE_VERSION=${previous_image#havline:} docker compose -f $COMPOSE_FILE up -d" >&2
fi
exit 1
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Havline 容器升级:在部署机(NAS)上直接构建,不需要再从别处传镜像包
#
# sudo bash scripts/upgrade.sh # 拉取最新源码 → 构建 → 重建容器
# sudo bash scripts/upgrade.sh --no-pull # 不拉代码,用当前源码重建
# sudo bash scripts/upgrade.sh --rollback 1.2.13 # 回滚到已存在的镜像 tag(不构建)
#
# 用 COMPOSE_FILE 指定编排文件(默认 docker-compose.yml):
# COMPOSE_FILE=docker-compose.host.yml sudo bash scripts/upgrade.sh
#
# 数据在 havline-data 卷里,重建容器不会丢。每次构建按仓库 VERSION 打一个新 tag
# (旧的 tag 会留着),所以出问题可以直接 --rollback 回上一个版本。
set -euo pipefail
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
APP_CONTAINER="${APP_CONTAINER:-havline}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6893/api/version}"
NO_PULL=0
ROLLBACK_TAG=""
usage() {
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
while [ $# -gt 0 ]; do
case "$1" in
--no-pull) NO_PULL=1; shift ;;
--rollback) ROLLBACK_TAG="${2:?--rollback 需要一个镜像 tag,例如 1.2.13}"; shift 2 ;;
-h|--help) usage 0 ;;
*) echo "未知参数:$1" >&2; usage 1 ;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "没有找到 docker 命令:这个脚本要在运行 Havline 容器的那台机器上执行" >&2
exit 1
fi
if [ ! -f "$COMPOSE_FILE" ]; then
echo "找不到 $COMPOSE_FILE:请在仓库根目录运行(或用 COMPOSE_FILE=... 指定编排文件)" >&2
exit 1
fi
# 当前容器实际使用的镜像 tag,升级前后各打印一次,回滚时心里有数
current_image() {
docker inspect --format '{{.Config.Image}}' "$APP_CONTAINER" 2>/dev/null || echo '(容器未运行)'
}
echo "==> 升级前:$(current_image)"
if [ -n "$ROLLBACK_TAG" ]; then
if ! docker image inspect "havline:$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "本机没有 havline:$ROLLBACK_TAG;已有 tag:$(docker images --format '{{.Tag}}' havline | tr '\n' ' ')" >&2
exit 1
fi
HAVLINE_VERSION="$ROLLBACK_TAG" docker compose -f "$COMPOSE_FILE" up -d
echo "==> 已切回 havline:$ROLLBACK_TAG"
else
if [ "$NO_PULL" -eq 0 ]; then
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo '==> git pull --ff-only'
git pull --ff-only
else
echo '==> 当前目录不是 git 仓库,跳过拉取(请先自行更新源码)'
fi
fi
if [ ! -f VERSION ]; then
echo '找不到 VERSION 文件:请在仓库根目录运行' >&2
exit 1
fi
HAVLINE_VERSION="$(tr -d ' \t\n\r' < VERSION)"
export HAVLINE_VERSION
echo "==> 构建并重建:havline:$HAVLINE_VERSION(首次构建较慢,之后有构建缓存)"
docker compose -f "$COMPOSE_FILE" up -d --build
fi
echo '==> 等待服务就绪…'
for _ in $(seq 1 30); do
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
echo "==> 升级后:$(current_image)"
echo " 版本接口:$(curl -fsS "$HEALTH_URL" 2>/dev/null)"
echo " 日志:docker logs --tail 50 $APP_CONTAINER"
exit 0
fi
sleep 2
done
echo "服务在 60 秒内没有响应 $HEALTH_URL,请看:docker logs --tail 100 $APP_CONTAINER" >&2
exit 1