iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Kubernetes

防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線系列 第 7

Day 7:打造內部私有資產庫:部署 Nexus 3 並配置 Outbound Proxy

  • 分享至 

  • xImage
  •  

快樂路徑:從建帳號到 outbound proxy 落地


0. 前提

目的:確認 CRC 已啟動,且 $env:KUBECONFIG 為空。第 5 步的 oc login 會寫入該變數指向的檔案,非空時會覆寫 system:admin 憑證。

crc status
"KUBECONFIG = [$env:KUBECONFIG]"

CRC VM: Running
KUBECONFIG = []

$env:KUBECONFIG 非空就先清掉。


變數

目的:集中定義後續各步共用的名稱與路徑。$workDir 帶時間戳,避免與前次執行的產物混在同一目錄。

$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kubeconfig = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"

$ns         = "nexus-proxy"
$deployerSa = "nexus-deployer"
$nexusSa    = "nexus-nexus3"
$chartVer   = "5.23.0"
$apiServer  = "https://api.crc.testing:6443"

$workDir = Join-Path "$env:USERPROFILE\nexus-run" (Get-Date -Format "yyyyMMdd-HHmmss")
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
"workDir = $workDir"

1. 確認身分

目的:確認起點身分為 system:admin。第 2–4 步需要叢集層級權限。

& $OC --kubeconfig $kubeconfig whoami

system:admin

2. 建 namespace

目的:建立本次部署的 namespace。此為叢集層級操作。

& $OC --kubeconfig $kubeconfig create namespace $ns

namespace/nexus-proxy created

3. 建部署帳號

目的:建立限縮於單一 namespace 的部署身分,取代全程使用 system:admin。edit 涵蓋後續需要的 Secret、StatefulSet、Service、Route 與 Helm release 記錄。

& $OC --kubeconfig $kubeconfig create sa $deployerSa -n $ns
& $OC --kubeconfig $kubeconfig adm policy add-role-to-user edit -z $deployerSa -n $ns

serviceaccount/nexus-deployer created
clusterrole.rbac.authorization.k8s.io/edit added: "nexus-deployer"

4. 授權 anyuid

目的:授權給 Chart 稍後建立的 SA。Nexus 容器以 UID 200 執行,預設的 restricted-v3 不允許。RoleBinding 的 subjects 不做存在性檢查,此步不需等 SA 存在。

& $OC --kubeconfig $kubeconfig adm policy add-scc-to-user anyuid -z $nexusSa -n $ns

$b = & $OC --kubeconfig $kubeconfig get rolebinding -n $ns -o json | ConvertFrom-Json
$b.items | Where-Object { $_.roleRef.name -like "system:openshift:scc:*" } |
    Select-Object @{n='role';e={$_.roleRef.name}}, @{n='subject';e={($_.subjects | ForEach-Object { $_.name }) -join ','}}

clusterrole.rbac.authorization.k8s.io/system:openshift:scc:anyuid added: "nexus-nexus3"

role                        subject
----                        -------
system:openshift:scc:anyuid nexus-nexus3

5. 切換身分

目的:取得部署帳號的 token 並登入。第 6–11 步以該身分執行。

$token = & $OC --kubeconfig $kubeconfig create token $deployerSa -n $ns --duration=8h
& $OC login --token=$token --server=$apiServer --insecure-skip-tls-verify=true
& $OC whoami

Logged into "https://api.crc.testing:6443" as "system:serviceaccount:nexus-proxy:nexus-deployer"
system:serviceaccount:nexus-proxy:nexus-deployer

以下到第 11 步不帶 --kubeconfig

6. 確認權限

目的:確認部署帳號足以完成第 7–11 步,且未取得叢集層級權限。以權限查詢驗證,不以第 3 步的輸出訊息驗證。

& $OC auth can-i create statefulset -n $ns
& $OC auth can-i create route -n $ns
& $OC auth can-i create namespace

yes
yes
Warning: resource 'namespaces' is not namespace scoped
no

整段退出碼是 1。

7. 確認欄位路徑與 storageClass

目的:確認本版 Chart 的欄位路徑,以及 storageClass 名稱。路徑錯誤時 Helm 不報錯、值被忽略;storageClass 名稱錯誤時 PVC 停在 Pending,而該狀態要到第 9 步之後才顯現。

helm repo add stevehipwell https://stevehipwell.github.io/helm-charts/
helm repo update

$refPath = Join-Path $workDir "chart-values-ref.yaml"
$ref = helm show values stevehipwell/nexus3 --version $chartVer | Out-String
[System.IO.File]::WriteAllText($refPath, $ref, (New-Object System.Text.UTF8Encoding $false))

Get-Content $refPath | Select-String -Pattern "seccompProfile" -SimpleMatch

& $OC get storageclass -o custom-columns=NAME:.metadata.name,DEFAULT:.metadata.annotations."storageclass\.kubernetes\.io/is-default-class",RECLAIM:.reclaimPolicy

seccompProfile 命中兩次(用 podSecurityContext 底下那個,約 157 行;另一個在 config.job 底下)
crc-csi-hostpath-provisioner true Retain

8. values 與 Secret

目的:寫入 values 與 admin 密碼。Chart 只引用 Secret 不生成,需先存在。寫檔用 WriteAllText 以避免 BOM,values.yaml 要交給 YAML parser。

$values = @"
persistence:
  enabled: true
  storageClass: crc-csi-hostpath-provisioner
  size: 10Gi
rootPassword:
  secret: nexus-admin
  key: password
podSecurityContext:
  seccompProfile: null
"@
$valuesPath = Join-Path $workDir "values.yaml"
[System.IO.File]::WriteAllText($valuesPath, $values, (New-Object System.Text.UTF8Encoding $false))

$b = [System.IO.File]::ReadAllBytes($valuesPath)
"bytes = $($b.Length); first4 = $(($b[0..3] | ForEach-Object { $_.ToString('X2') }) -join ' ')"

bytes = 181; first4 = 70 65 72 73

$nexusPass = [System.Guid]::NewGuid().ToString()
$pwB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($nexusPass))

@"
apiVersion: v1
kind: Secret
metadata:
  name: nexus-admin
  namespace: $ns
type: Opaque
data:
  password: $pwB64
"@ | & $OC apply -f -

$back = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
    (& $OC get secret nexus-admin -n $ns -o jsonpath='{.data.password}')))
"round-trip match = $($back -eq $nexusPass)"

secret/nexus-admin created
round-trip match = True

9. 安裝

目的:部署 Nexus 並等待容器就緒。就緒判定看 containerStatuses[].ready,不看 status.phase

$env:KUBECONFIG = ""
helm install nexus stevehipwell/nexus3 -n $ns -f $valuesPath --version $chartVer

STATUS: deployed,chart 5.23.0 / app 3.93.0

$deadline = (Get-Date).AddMinutes(8)
$last = ""
while ($true) {
    $raw = & $OC get pod -n $ns -o json 2>$null
    if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) {
        "[{0:HH:mm:ss}] (查詢失敗,重試)" -f (Get-Date)
    } else {
        $pods = ($raw | ConvertFrom-Json).items
        if ($pods.Count -eq 0) {
            $snap = "(namespace 內尚無 Pod)"; $allReady = $false
        } else {
            $snap = ($pods | ForEach-Object {
                $r = @($_.status.containerStatuses | Where-Object { $_.ready }).Count
                $t = @($_.status.containerStatuses).Count
                "$($_.metadata.name) $r/$t $($_.status.phase)"
            }) -join " | "
            $allReady = -not ($pods | Where-Object {
                (-not $_.status.containerStatuses) -or
                ($_.status.containerStatuses | Where-Object { -not $_.ready })
            })
        }
        if ($snap -ne $last) { "[{0:HH:mm:ss}] {1}" -f (Get-Date), $snap; $last = $snap }
        if ($allReady) { "ALL READY"; break }
    }
    if ((Get-Date) -gt $deadline) { throw "等待 Pod 就緒逾時(8 分鐘)" }
    Start-Sleep -Seconds 10
}

Pending0/1 Running1/1 RunningALL READY,約 1–2 分鐘

10. 檢核

目的:確認 PVC、SCC、執行身分、密碼來源四項與預期一致。

& $OC get pvc -n $ns
& $OC get pod nexus-nexus3-0 -n $ns -o jsonpath='{.metadata.annotations.openshift\.io/scc}'
& $OC exec nexus-nexus3-0 -n $ns -c nexus3 -- id
$ss = & $OC get statefulset nexus-nexus3 -n $ns -o json | ConvertFrom-Json
($ss.spec.template.spec.containers | Where-Object { $_.name -eq 'nexus3' }).env |
    Where-Object { $_.name -like 'NEXUS_SECURITY_*' } |
    ForEach-Object {
        if ($_.value) { "$($_.name)=$($_.value)" }
        else { "$($_.name)=<secretKeyRef: $($_.valueFrom.secretKeyRef.name)/$($_.valueFrom.secretKeyRef.key)>" }
    }

PVC STATUSBound(CAPACITY 顯示的是節點磁碟容量,不是 10Gi)
anyuid
uid=200(nexus) gid=200(nexus) groups=200(nexus)
NEXUS_SECURITY_RANDOMPASSWORD=false
NEXUS_SECURITY_INITIAL_PASSWORD=<secretKeyRef: nexus-admin/password>

11. Route

目的:建立對外入口,供工作站從叢集外呼叫 API。host 從 Route 讀出,不自行拼接。

& $OC create route edge nexus --service=nexus-nexus3 --port=8081 `
    --insecure-policy=Redirect -n $ns

$nexusUrl = "https://" + (& $OC get route nexus -n $ns -o jsonpath='{.spec.host}')
$nexusUrl

route.route.openshift.io/nexus created
https://nexus-nexus-proxy.apps-crc.testing

12. 憑證與認證

目的:設定自簽憑證的處理方式,並從 Secret 取回密碼組出認證字串。密碼重新讀取,不沿用第 8 步的變數。

if (-not ('TrustAllCertsPolicy' -as [type])) {
    Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;
public class TrustAllCertsPolicy : ICertificatePolicy {
    public bool CheckValidationResult(
        ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; }
}
"@
}
[System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy

$nexusPass = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
    (& $OC get secret nexus-admin -n $ns -o jsonpath='{.data.password}')))
$b64cred = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("admin:$nexusPass"))
"cred length = $($b64cred.Length)"

cred length = 56

13. API helper

目的:定義 EULA 與 ExtDirect 共用的呼叫函式。認證走設定檔不進指令列,暫存檔無 BOM 且刪除於 finally

function Invoke-NexusApi {
    param([string]$Url, [string]$Method = "GET", [string]$JsonBody = $null)
    $cfg = Join-Path $env:TEMP ("nx-{0}.cfg" -f [guid]::NewGuid())
    $tmp = $null
    try {
        [System.IO.File]::WriteAllText($cfg, "header = `"Authorization: Basic $b64cred`"",
                                       (New-Object System.Text.UTF8Encoding $false))
        $a = @("-K", $cfg, "-k", "-s", "--fail-with-body", "-X", $Method,
               "-H", "Content-Type: application/json", $Url)
        if ($JsonBody) {
            $tmp = Join-Path $env:TEMP ("nx-{0}.json" -f [guid]::NewGuid())
            [System.IO.File]::WriteAllText($tmp, $JsonBody, (New-Object System.Text.UTF8Encoding $false))
            $a += @("--data-binary", "@$tmp")
        }
        $raw = & curl.exe @a
        $script:lastExit = $LASTEXITCODE
        return ($raw -join "`n")
    } finally {
        if (Test-Path $cfg) { Remove-Item $cfg -Force }
        if ($tmp -and (Test-Path $tmp)) { Remove-Item $tmp -Force }
    }
}

14. 等可寫

目的:確認 Nexus 可接受寫入請求。容器就緒不等於後端可寫,readinessProbe 只涵蓋讀取。

$deadline = (Get-Date).AddMinutes(10)
$lastError = '(尚未探測)'
$probes = 0
while ($true) {
    $probes++
    try {
        $r = Invoke-WebRequest -Uri "$nexusUrl/service/rest/v1/status/writable" `
                               -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
        if ($r.StatusCode -eq 200) { break }
        $lastError = "HTTP $($r.StatusCode)"
    } catch {
        $lastError = $_.Exception.Message
        if ($_.Exception.InnerException) { $lastError += " / 內層:$($_.Exception.InnerException.Message)" }
    }
    if ((Get-Date) -gt $deadline) { throw "等待就緒逾時。最後失敗:$lastError" }
    Start-Sleep -Seconds 5
}
"Nexus 可寫 (probes=$probes)"

Nexus 可寫 (probes=1)

15. 讀 EULA disclaimer

目的:取得原始 disclaimer 字串,供第 16 步原封不動送回。後端要求逐字元相符,手寫會回 500。

$eulaRaw = Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/system/eula"
"exit=$lastExit"
$eula = $eulaRaw | ConvertFrom-Json
"簽署前 accepted = $($eula.accepted); disclaimer 長度 = $($eula.disclaimer.Length)"

exit=0
簽署前 accepted = False; disclaimer 長度 = 348

16. 簽署

目的:送出 EULA 簽署請求。

$eulaBody = [ordered]@{ disclaimer = $eula.disclaimer; accepted = $true } |
            ConvertTo-Json -Depth 10 -Compress
$post = Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/system/eula" -Method POST -JsonBody $eulaBody
"exit=$lastExit; body=<$post>"

exit=0; body=<>

17. 確認

目的:以獨立的一次 GET 確認 accepted 已切換。POST 回 204 且無 body,只表示請求被接受。

$after = (Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/system/eula" | ConvertFrom-Json)
"簽署後 accepted = $($after.accepted)"

簽署後 accepted = True

18. 存 before

目的:記錄設定前的狀態,作為第 21 步的對照基準。

$readBody = [ordered]@{
    action="coreui_HttpSettings"; method="read"; data=$null; type="rpc"; tid=1
} | ConvertTo-Json -Depth 10 -Compress

$beforeRaw = Invoke-NexusApi -Url "$nexusUrl/service/extdirect" -Method POST -JsonBody $readBody
"exit=$lastExit"
$beforeRaw
[System.IO.File]::WriteAllText((Join-Path $workDir "before.json"), $beforeRaw,
                               (New-Object System.Text.UTF8Encoding $false))

exit=0,httpEnabled / httpHost / httpPortnull,檔案 530 bytes

19. 組 payload

目的:組出 update 的 payload 並確認序列化結果。四個 *Enabled 全部帶入——開關未帶入時,其管轄欄位不會寫入,而回應仍為 success: true

$sentData = [ordered]@{
    httpEnabled      = $true
    httpHost         = "proxy.internal.local"
    httpPort         = 3128
    httpAuthEnabled  = $false
    httpsEnabled     = $true
    httpsHost        = "proxy.internal.local"
    httpsPort        = 3128
    httpsAuthEnabled = $false
    nonProxyHosts    = @("*.cluster.local", "localhost")
}

$updBody = [ordered]@{
    action="coreui_HttpSettings"; method="update"; data=@($sentData); type="rpc"; tid=1
} | ConvertTo-Json -Depth 10 -Compress

$updBody
"depth truncation = $($updBody -match 'OrderedDictionary|Hashtable')"
"nonProxyHosts is array = $($updBody -match '"nonProxyHosts":\[')"

depth truncation = False
nonProxyHosts is array = True

20. 送出

目的:送出 outbound proxy 設定。

$updRaw = Invoke-NexusApi -Url "$nexusUrl/service/extdirect" -Method POST -JsonBody $updBody
"exit=$lastExit"
$updRaw

exit=0,"success":true,四個 host/port 有值,四個 auth 欄位 null

21. 回讀核對

目的:以獨立的一次 read 逐欄位確認送出值已寫入。不採用 update 自身的回顯,該回顯與寫入屬同一次呼叫。

$afterRaw = Invoke-NexusApi -Url "$nexusUrl/service/extdirect" -Method POST -JsonBody $readBody
$afterRaw
[System.IO.File]::WriteAllText((Join-Path $workDir "after.json"), $afterRaw,
                               (New-Object System.Text.UTF8Encoding $false))

$stored = ($afterRaw | ConvertFrom-Json).result.data

$governedBy = @{
    httpHost  = 'httpEnabled';  httpPort  = 'httpEnabled'
    httpsHost = 'httpsEnabled'; httpsPort = 'httpsEnabled'
}

$mismatch = @()
foreach ($k in @($sentData.Keys)) {
    $sent = $sentData[$k]; $got = $stored.$k

    if ($sent -is [bool] -and -not $sent -and $null -eq $got) { continue }
    if ($governedBy.ContainsKey($k) -and -not $sentData[$governedBy[$k]]) { continue }

    $sL = ($sent -is [System.Collections.IEnumerable]) -and ($sent -isnot [string])
    $gL = ($got  -is [System.Collections.IEnumerable]) -and ($got  -isnot [string])
    $ok = if ($sL -or $gL) {
        ((@($sent) | Sort-Object) -join "`n") -eq ((@($got) | Sort-Object) -join "`n")
    } else { $sent -eq $got }

    if (-not $ok) {
        $gt = if ($null -eq $got) { '(null)' } else { "$got" }
        $mismatch += [pscustomobject]@{ 欄位=$k; 送出="$sent"; 回存=$gt }
    }
}

if ($mismatch.Count -gt 0) {
    $mismatch | Format-Table -AutoSize | Out-String | Write-Host
    throw "有 $($mismatch.Count) 個欄位未落地"
}

Write-Host "9 個欄位全數回讀相符,組態已寫入"

9 個欄位全數回讀相符,組態已寫入,after.json 593 bytes

22. 收工

目的:確認產物齊全、無殘留行程,並記錄 current-context 的變動。

Get-ChildItem $workDir | Select-Object Name, Length
Get-Process -Name "oc" -ErrorAction SilentlyContinue
& $OC config current-context

values.yaml 181 / before.json 530 / after.json 593 / chart-values-ref.yaml
oc 行程查無結果
nexus-proxy/api-crc-testing:6443/system:serviceaccount:nexus-proxy:nexus-deployer


快樂路徑的技術說明

目的:記錄快樂路徑腳本中六處設計的實測依據,供日後升版或改寫腳本時核對。不含操作步驟,操作步驟見快樂路徑本身。

腳本中的作法 對應步驟 省略後的結果
disclaimer 取自 GET 回應 15–16 HTTP 500
使用 /service/extdirect 18–21 CE 無其他可用途徑
nonProxyHosts@() 19 拋出型別例外
四個 *Enabled 全部帶入 19 回應 success: true,欄位未寫入
寫入後另發一次讀取請求 17、21 以同一次呼叫的回應驗證自己
比對前兩側排序 21 設定成功但誤報不一致

測試環境:OpenShift 4.21.14、Chart stevehipwell/nexus3 5.23.0、Nexus 3.93.0-06(COMMUNITY)。部分結論在 3.94.0-12 / Chart 5.24.0 上複驗,結果一致。

ExtDirect 為未公開文件化的內部介面,更換版本後欄位與行為需重新核對。


一、EULA disclaimer 必須取自 GET 回應

目的:說明第 15–16 步為何不能寫死 disclaimer 字串,以及不遵守時的實際錯誤形式。

全新實例的 acceptedfalse,對外存取相關功能受限。簽署端點為 POST /service/rest/v1/system/eula,payload 需包含 disclaimeraccepted: true

GET /service/rest/v1/system/eula 的回應:

{
  "accepted" : false,
  "disclaimer" : "Use of Sonatype Nexus Repository - Community Edition is governed by the End User License Agreement at https://links.sonatype.com/products/nxrm/ce-eula. By returning the value from ‘accepted:false’ to ‘accepted:true’, you acknowledge that you have read and agree to the End User License Agreement at https://links.sonatype.com/products/nxrm/ce-eula."
}

長度 348 字元。字串中的 ‘accepted:false’‘accepted:true’ 使用排版引號 U+2018 / U+2019,與鍵盤輸入的 U+0027 為不同字元。編輯器的自動校正會將其替換為直引號。

後端要求逐字元相符。將該 348 字元字串截去末尾 1 個字元後送出,回應為:

HTTP/1.1 500 Server Error
ERROR: (ID 8cb4de16-8a78-40da-b31f-3d5ad95d771c)
java.lang.IllegalArgumentException: Invalid EULA disclaimer

引號替換造成 2 個字元差異,結果相同。換行符號(LF/CRLF)與多餘空白預期會造成同樣結果,未實測。

因此 disclaimer 由 GET 取得後直接送回,轉義交由 ConvertTo-Json 處理,寫入無 BOM UTF-8 暫存檔後傳送。

冪等性

目的:確認此段腳本可否在已簽署的環境重複執行,以決定測試時是否需要另起實例。

對已簽署的實例重送同一份 disclaimer,回應 HTTP 204。回讀結果與送出前一致(accepted: true,disclaimer 內容不變),既有 proxy repository 未受影響。

在現有環境執行此段可用於驗證流程,不會變更既有設定。


二、CE 沒有可用的公開 REST 端點

目的:說明第 18–21 步為何使用未公開文件化的 ExtDirect,而非公開 REST API。此節為排除論證,結論綁定 Nexus 版本與版別。

Sonatype 文件〈HTTP Request and Proxy Settings〉記載的唯一途徑為 Administration 介面的 Settings → System → HTTP。該畫面儲存時呼叫的是 /service/extdirect

各版本的公開端點可由該實例自身的 OpenAPI 定義清點:

$spec  = curl.exe -k -s "$nexusUrl/service/rest/swagger.json" | ConvertFrom-Json
$paths = $spec.paths.PSObject.Properties.Name
$paths.Count
$paths | Where-Object { $_ -match 'http|proxy|setting|system' } | Sort-Object

3.93.0 共 255 個端點。關鍵字篩選的命中項目:

端點 用途
/v1/repositories/{format}/proxy 各 repository 格式的 proxy 類型設定,與系統層級 outbound proxy 無關。25 種格式 × 2 條,共 50 條
/v1/repositorySettings repository 清單
/v1/system/eula/license/node 系統資訊

/v1/http 在 CE 未註冊

目的:排除第一條候選途徑。此端點存在於官方文件,需確認它在 CE 上不可用而非權限問題。

Sonatype 有一支文件化的 HTTP Configuration API,端點為 /service/rest/v1/http。該端點在此實例上的狀態:

curl.exe -k -s -o NUL -w "%{http_code}`n" -u "admin:$nexusPass" "$nexusUrl/service/rest/v1/http"
# → 404

$paths -contains '/v1/http'                    # → False
$paths | Where-Object { $_ -like '*/http*' }   # → (無輸出)

回應為 404 而非 403,表示端點未註冊而非權限不足。swagger 中無任何符合 */http* 的路徑;上述篩選字串涵蓋 /v1/http,該路徑未出現於命中清單。

該 API 於 3.71.0 加入,release note 標示為 Nexus Repository Pro 管理者功能。Pro 版未實測。

Script API 與檔案系統

目的:排除其餘兩條候選途徑——透過 Groovy 腳本設定,以及透過設定檔或啟動參數設定。

GET  /service/rest/v1/script          → 200,body 為 [ ]
POST /service/rest/v1/script          → 410 Gone
GET  /service/rest/v1/configuration   → 400,body 為零位元組

410 的 body:

{"name":"probe-should-be-rejected","result":"Creating and updating scripts is disable"}

(原文缺一個 d。)Sonatype 自 3.21.2 起將 Groovy 腳本引擎預設停用,受影響的資源回 410,唯讀與執行類操作不受影響。

啟用途徑為 $data-dir/etc/nexus.properties。該檔內容:

nexus.datastore.enabled=true
nexus.loadAsOSS=true

nexus.scripts.allowCreationINSTALL4J_ADD_VM_PARAMS 中亦無 proxy 相關系統屬性。

該檔案與 JVM 參數皆不含 proxy 設定,可知 outbound proxy 設定僅存於 config datastore,無法透過掛載 ConfigMap 或修改啟動參數設定。

排除結果:公開 REST 端點在 CE 未註冊、Script API 無法建立腳本、設定不在檔案系統、JVM 參數未使用。可用途徑僅剩 ExtDirect。

ExtDirect 無相容性承諾。升版或改用 Pro 後應重跑 swagger 清點與 /v1/http 探測。


三、nonProxyHosts 的型別

目的:說明第 19 步為何使用 @()。此為型別錯誤,會在送出當下報錯,屬可自行發現的一類。

此欄位定義不經過 proxy 直連的網域。後端要求 JSON 陣列,非逗號分隔字串。

"nonProxyHosts": "*.cluster.local,localhost"      ← 型別錯誤
"nonProxyHosts": ["*.cluster.local", "localhost"] ← 正確

傳入字串的錯誤訊息:

Expected BEGIN_ARRAY but was STRING at path *.nonProxyHosts

該回應的 HTTP 狀態碼為 200,頂層無 success 欄位,typeexception--fail-with-body 無法攔截此類錯誤,需以 result.success 判斷。

錯誤訊息中的反序列化目標型別為 org.sonatype.nexus.coreui.HttpSettingsXO。例外鏈為 com.google.gson.JsonSyntaxExceptionjava.lang.IllegalStateException,序列化器為 Gson。

同一設定在三個介面的型別各異:UI 為逐行輸入的清單;ExtDirect 為 JSON 陣列;Groovy CoreApi 接收 Java http.nonProxyHosts 的萬用字元樣式。逗號分隔的形式來自 Nexus 2 的 XML 設定與 JVM 系統屬性。


四、欄位落地取決於對應的開關

目的:說明第 19 步為何四個 *Enabled 全部帶入,包含兩個值為 false 的。此為不會報錯的一類,回應仍為成功。

nonProxyHosts 改為陣列後重送,回應:

{"tid":1,"action":"coreui_HttpSettings","method":"update","result":{"success":true,"data":{"userAgentSuffix":null,"timeout":null,"retries":null,"httpEnabled":null,"httpHost":null,"httpPort":null,"httpAuthEnabled":null,...,"nonProxyHosts":["*.cluster.local","localhost"]}},"type":"rpc"}

successtrue。請求送出 9 個欄位,回存的 data 有 20 個欄位,除 nonProxyHosts 外其餘 19 個為 null,包含已送出的 httpHosthttpPorthttpsHosthttpsPort 及四個開關本身。

回應中無警告或欄位忽略提示。

開關對應關係

目的:確立哪個欄位由哪個開關管轄,供第 21 步的 $governedBy 對照表使用。

於拋棄式實例分次送出,每次 update 後立即 read 回讀比對:

開關 true false
httpEnabledhttpHosthttpPort 落地,逐字相符 null
httpsEnabledhttpsHosthttpsPort 落地,逐字相符 null
httpAuthEnabledhttpAuthUsernameNtlmHostNtlmDomain 落地 null
httpsAuthEnabled → 同上三個 落地 null
(無開關)nonProxyHosts 內容一致

各組欄位由各自的開關管轄。開關為 false 時底下欄位回存 null;開關為 true 時欄位依送出值落地。開關本身送 false 時回存為 null,falsenull 在此語意等價。

nonProxyHosts 無對應開關,因此在 httpEnabled=false 的請求中為唯一寫入成功的欄位。

實務影響

目的:說明此規則為何需要在腳本層面處理,而非僅記錄於文件。

此規則未見於公開文件。未帶入或誤帶 false 的開關,其管轄欄位不會寫入,而回應仍為 success: true。問題在後續使用 proxy repository 時才顯現。

另有兩項影響腳本寫法:

  • httpAuthPasswordhttpsAuthPassword 回讀為固定字串 #~NXRM~PLACEHOLDER~PASSWORD~#,核對邏輯需跳過。
  • 全新實例的 nonProxyHosts 基準值為 null 而非 [],設定後再清空才會變為 []。此項僅一次觀測。

五、驗證需要獨立的第二次請求

目的:說明第 17 步與第 21 步為何各自另發一次讀取請求,而非採用前一次呼叫的回應。

腳本中有兩處:第 17 步以另一次 GET 確認 EULA 的 accepted,第 21 步以另一次 read 核對 proxy 欄位。兩處皆不採用前一次呼叫的回應。

update 的回顯與寫入屬同一次呼叫;read 為獨立呼叫。EULA 的 POST 回 204 且無 body,僅表示請求被接受。

read 的回傳格式與 updateresult.data 相同,可直接比對。

read 讀出的 payload 不可直接修改後回送。密碼欄位讀出為佔位字串,Nexus 是否將其識別為「維持原密碼」僅一次測試(結果為保留),未複驗。腳本每次送出完整組出的 payload,以避免該結論有誤時清除出站密碼。

第三層驗證

目的:標示本文驗證範圍的上界,以及超出範圍的那一步長什麼樣。

accepted: true 僅確認狀態切換。功能層級的驗證為 proxy repository 的實際拉取:

curl.exe -k -s -o NUL -w "%{http_code}`n" "$nexusUrl/repository/npm-proxy/express"

在 EULA 已簽署且 proxy repository 運作中的實例上,此請求回 200。

快樂路徑無法執行此驗證:其使用的 proxy.internal.local 不存在,且該實例未建立任何 proxy repository。上述 200 為正式實例的量測結果。


六、nonProxyHosts 回存順序不保證

目的:說明第 21 步的兩處 Sort-Object 為必要而非防禦性寫法,避免日後被當作冗餘移除。

第一次執行快樂路徑,送出 ["*.cluster.local","localhost"],回存順序一致。

第二次執行,payload、Chart 版本、Nexus 版本均相同:

送出:["*.cluster.local","localhost"]
回存:["localhost","*.cluster.local"]

順序反轉。

較早在拋棄式實例上以三個元素測試:

送出: zzz.example.com, aaa.example.com, mmm.example.com
回存: aaa.example.com, zzz.example.com, mmm.example.com

回存順序既非送出順序亦非字典序。

三次觀測支持的結論為回存順序不保證。成因未確認;未測試重複元素,無法判斷後端是否使用 set。

比對前不排序會在設定成功的情況下對 nonProxyHosts 誤報。


未驗證項目

目的:標示本文結論的邊界,避免被引用到未支撐的範圍。

  • 組態寫入 datastore 不等於執行中的 Nexus 已套用,亦不等於封包經過該 proxy。本文驗證範圍止於寫入與回讀相符。
  • EULA 未簽署時受限的功能範圍未量測。快樂路徑在簽署前未探測 ExtDirect 或 proxy repository。原始 403 為初次建置時記錄,未留存完整標頭與內文。全新實例僅確認 accepted = false
  • disclaimer 不相符的成因僅測試截斷。換行符號與多餘空白未測。
  • 帶認證的 proxy 僅一次觀測。httpAuthEnabled 相關欄位與密碼佔位字串的 round-trip 行為未複驗。
  • Pro 版未測。/v1/http 的 404 僅證明 CE 未註冊該端點。

小結

目的:歸納三種回應形式與各自對應的驗證方式,供處理其他 Nexus API 時參照。

回應 含義 驗證方式
type: exception,HTTP 200 請求未被接受 判斷 result.success
success: true 請求被接受,寫入結果未知 另發 read 逐欄位比對
HTTP 204,無 body 請求被接受 另發 GET 讀取狀態

三者的共通處理方式為:寫入後另發一次讀取請求,逐欄位比對送出值。

參考文件


上一篇
Day 6:Helm 在 OpenShift 上的生存法則:openshift.enabled 與 anyuid SCC 的手動授權
下一篇
Day 8:Gitea部署到 git push
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言