From 9ca5da452376b5087ec2e13ccf7a1c134ebd387e Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Fri, 2 Oct 2026 13:26:27 +0800 Subject: [PATCH] docs: document Gitea registry OAuth fix and add manual OCI pusher --- .gitignore | 1 + docs/gitea-registry-fix.md | 185 ++++++++++++++++++++++++++++++++++++ scripts/push-oci-manual.ps1 | 126 ++++++++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 docs/gitea-registry-fix.md create mode 100644 scripts/push-oci-manual.ps1 diff --git a/.gitignore b/.gitignore index 1e225d7..6e8dbfa 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ scripts/* !scripts/havline-install.sh !scripts/upgrade.sh !scripts/upgrade-hub.sh +!scripts/push-oci-manual.ps1 # 本地压缩包与导出产物不入库(如 web/src.rar) *.rar *.zip diff --git a/docs/gitea-registry-fix.md b/docs/gitea-registry-fix.md new file mode 100644 index 0000000..08f7658 --- /dev/null +++ b/docs/gitea-registry-fix.md @@ -0,0 +1,185 @@ +# Gitea Registry 镜像拉取问题与修复方案 + +> 记录时间:2026-10-02 +> 适用范围:`jayhub.top/mrlee/havline` 与飞牛应用 `Fnos-Havline` + +## 一、当前状态 + +Havline `1.9.0` 的多架构镜像已经通过 Registry HTTP API 上传成功: + +| 平台 | 子 manifest | +| --- | --- | +| linux/amd64 | `sha256:83b31003aaa84435bdfc02e5e436d7e23f71e184326002ff8cdc6bba84e4c77c` | +| linux/arm64 | `sha256:5f30e660bd8ec4563bfe225c69bc5a1cb80b1766655494c2d7bd863f43aa4b06` | +| 多架构索引 | `sha256:6eed17af17349bea312b5e1fd8d6fbbde074d157a9e8687a734d9371c59a8db2` | + +Registry 中当前 tag: + +```text +mrlee/havline:1.9.0 +``` + +使用带认证的 Registry API 可以正常读取 OCI index。Docker CLI 的 +`push` 与 `pull` 仍然失败: + +```text +failed to authorize: failed to fetch oauth token: +authorization server did not include a token in the response +``` + +## 二、根因 + +Gitea 当前生成的 Registry 认证地址是 HTTP: + +```text +WWW-Authenticate: Bearer realm="http://jayhub.top/v2/token", +service="container_registry",scope="*" +``` + +实际 token 接口已经通过 HTTPS 提供服务。Docker 遇到 HTTP 到 HTTPS 的 +跨协议跳转时,会丢掉用于换取 token 的 Basic 认证信息,最终拿不到带权限 +的 token。 + +同时,匿名请求得到的 token 没有 `pull` scope: + +```text +scope=[] +user=-1 +``` + +这意味着飞牛设备在没有 Registry 登录凭据时,即使服务器修好 realm,也 +仍然会被公开包的认证策略拦截。仓库和包本身虽然是 public,但当前 Gitea +Registry 仍要求客户端登录。 + +## 三、服务器端修复 + +### 1. 修正 Gitea 的 ROOT_URL + +编辑 Gitea 的 `app.ini`,把 `ROOT_URL` 从 HTTP 改为 HTTPS: + +```ini +[server] +ROOT_URL = https://jayhub.top/ +``` + +### 2. 确保 Nginx 传递正确协议 + +在 Gitea 的反向代理配置中加入: + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +``` + +如果你使用的是宝塔、1Panel 或飞牛自带的反代,请在对应站点配置中确认 +`X-Forwarded-Proto` 没有被覆盖为 `http`。 + +### 3. 重启 Gitea + +```bash +docker restart gitea +``` + +如果容器名不同,先执行: + +```bash +docker ps --format '{{.Names}} {{.Image}}' +``` + +### 4. 验证 realm + +```bash +curl -sSI https://jayhub.top/v2/ | grep -i www-authenticate +``` + +期望结果: + +```text +WWW-Authenticate: Bearer realm="https://jayhub.top/v2/token", +service="container_registry",scope="*" +``` + +### 5. 验证 Docker 拉取 + +```bash +docker pull jayhub.top/mrlee/havline:1.9.0 +``` + +如果本机已经登录过 `jayhub.top`,先执行一次: + +```bash +docker logout jayhub.top +docker login jayhub.top +``` + +## 四、飞牛端仍被 401 拦截时的处理 + +### 方案 A:在飞牛设备登录 Registry(推荐用于继续使用 Gitea) + +在飞牛终端中执行: + +```bash +docker login jayhub.top -u mrlee +``` + +密码建议使用 Gitea Personal Access Token,而不是账号密码。Token 权限 +至少需要: + +```text +read:package +``` + +登录后再安装 `havline-1.9.0.fpk`。这种方式需要每台安装设备执行一次登录, +不适合完全没有终端操作经验的使用者。 + +### 方案 B:改为离线镜像包(推荐给最终用户) + +如果希望完全绕开 Gitea Registry 的认证和 realm 问题,可以把 `docker save` +生成的镜像归档放进飞牛 `.fpk`,并在安装回调中执行 `docker load`。这样 +安装过程不依赖外网 Registry,也不需要在飞牛设备上保存 Registry 凭据。 + +这个方案的代价是 `.fpk` 体积会显著增加,需要同时评估飞牛应用中心的上传 +限制和升级包体积。 + +### 方案 C:改用允许匿名拉取的公共 Registry + +如果继续发布给普通用户使用,建议把镜像推送到 Docker Hub、GHCR 或其它 +允许匿名拉取多架构镜像的 Registry,再把飞牛 Compose 中的镜像地址改成 +对应的公共地址。这个方案的用户体验最好,但需要额外的 Registry 账号和 +发布流程。 + +## 五、应急手动推送工具 + +在 Gitea realm 修好之前,可以使用下面的脚本绕过 Docker 客户端直接推送: + +```powershell +docker buildx build ` + --platform linux/amd64 ` + --build-arg VERSION_FILE=VERSION ` + --output type=oci,dest=build\havline-amd64.oci.tar . + +tar -xf build\havline-amd64.oci.tar -C build\havline-amd64 + +pwsh scripts/push-oci-manual.ps1 ` + -LayoutDir build\havline-amd64 ` + -Platform linux/amd64 ` + -Tag 1.9.0 +``` + +脚本会从 Git 凭据存储中读取 `jayhub.top` 的登录信息,通过 HTTPS 请求 +Bearer token,再直接调用 Registry HTTP API 上传缺失 blob 和 manifest。 + +这个脚本只是应急工具,不能替代服务器端的 `ROOT_URL` 修复。服务器修好 +之后,应恢复使用标准的 `docker buildx build --push`。 + +## 六、发布前检查清单 + +```text +[ ] Gitea ROOT_URL 已改为 https://jayhub.top/ +[ ] Nginx 已传递 X-Forwarded-Proto https +[ ] curl /v2/ 返回的 realm 是 https +[ ] docker pull 在干净客户端上成功 +[ ] 飞牛设备无需额外操作即可安装 +[ ] amd64 与 arm64 镜像都能被设备拉取 +``` diff --git a/scripts/push-oci-manual.ps1 b/scripts/push-oci-manual.ps1 new file mode 100644 index 0000000..0eb7a0d --- /dev/null +++ b/scripts/push-oci-manual.ps1 @@ -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)