feat(scripts): add Set-CIGuestCredential.ps1
Stores a guest-VM credential into the LocalSystem keyring vault via a transient SYSTEM scheduled task, using the production venv's keyring (the backend ci_orchestrator reads). Fixes the per-user vault mismatch that made act_runner (LocalSystem) fail with "Credential not found in keyring" for cmdkey/UI-stored credentials. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
#Requires -RunAsAdministrator
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Stores a CI guest-VM credential into the LocalSystem keyring vault.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
act_runner runs as the LocalSystem service account. Windows Credential
|
||||||
|
Manager / keyring vaults are per-user, so a credential added with
|
||||||
|
`cmdkey` or the Credential Manager UI under an interactive user is
|
||||||
|
invisible to the runner. The orchestrator then fails with:
|
||||||
|
|
||||||
|
Credential '<target>' not found in keyring.
|
||||||
|
|
||||||
|
This script writes the credential into the SYSTEM vault using the
|
||||||
|
production venv's `keyring` itself (the exact backend
|
||||||
|
`ci_orchestrator` reads with `keyring.get_credential(target, None)`),
|
||||||
|
via a transient SYSTEM scheduled task. Using keyring for both write
|
||||||
|
and read avoids cmdkey/keyring format and persistence mismatches.
|
||||||
|
|
||||||
|
Known-issue references:
|
||||||
|
plans/A1-closeout.md (KeyError: 'BuildVMGuest')
|
||||||
|
plans/implementation-plan-A-B.md (keyring under SYSTEM)
|
||||||
|
|
||||||
|
The plaintext password unavoidably transits a temporary .py file.
|
||||||
|
The file is created with an Administrators/SYSTEM-only ACL, overwritten
|
||||||
|
with random bytes, then deleted. The password is never placed in a
|
||||||
|
scheduled-task argument or the command line.
|
||||||
|
|
||||||
|
.PARAMETER Target
|
||||||
|
Credential target name. Must match the runner env
|
||||||
|
GITEA_CI_GUEST_CRED_TARGET. Default: BuildVMGuest
|
||||||
|
|
||||||
|
.PARAMETER UserName
|
||||||
|
Guest login username in the form the transport expects (e.g.
|
||||||
|
'WINBUILD\ci_build', '.\ci_build', or 'ci_build').
|
||||||
|
|
||||||
|
.PARAMETER Password
|
||||||
|
Guest password as a SecureString. If omitted, prompted securely.
|
||||||
|
|
||||||
|
.PARAMETER VenvPython
|
||||||
|
Path to the production venv interpreter act_runner uses. Default:
|
||||||
|
CI_VENV_PYTHON env var, else F:\CI\python\venv\Scripts\python.exe
|
||||||
|
|
||||||
|
.PARAMETER SkipVerify
|
||||||
|
Skip the post-write read-back verification (also run as SYSTEM).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Set-CIGuestCredential.ps1 -UserName 'WINBUILD\ci_build'
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Non-interactive (e.g. from another provisioning script)
|
||||||
|
$pw = Read-Host 'pw' -AsSecureString
|
||||||
|
.\Set-CIGuestCredential.ps1 -Target BuildVMGuest -UserName '.\ci_build' -Password $pw
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Set-CIGuestCredential.ps1 -UserName '.\ci_build' -WhatIf
|
||||||
|
#>
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param(
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string] $Target = 'BuildVMGuest',
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string] $UserName,
|
||||||
|
|
||||||
|
[System.Security.SecureString] $Password,
|
||||||
|
|
||||||
|
[string] $VenvPython = $(if ($env:CI_VENV_PYTHON) { $env:CI_VENV_PYTHON }
|
||||||
|
else { 'F:\CI\python\venv\Scripts\python.exe' }),
|
||||||
|
|
||||||
|
[switch] $SkipVerify
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $VenvPython)) {
|
||||||
|
throw "venv interpreter not found: '$VenvPython'. Set -VenvPython or CI_VENV_PYTHON."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Password) {
|
||||||
|
$Password = Read-Host "Password for '$UserName'" -AsSecureString
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Run a script block as SYSTEM via a transient scheduled task ----------
|
||||||
|
function Invoke-AsSystem {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string] $TaskName,
|
||||||
|
[Parameter(Mandatory)] [string] $Execute,
|
||||||
|
[Parameter(Mandatory)] [string] $Argument,
|
||||||
|
[int] $TimeoutSec = 30
|
||||||
|
)
|
||||||
|
$action = New-ScheduledTaskAction -Execute $Execute -Argument $Argument
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' `
|
||||||
|
-LogonType ServiceAccount -RunLevel Highest
|
||||||
|
Register-ScheduledTask -TaskName $TaskName -Action $action `
|
||||||
|
-Principal $principal -Force | Out-Null
|
||||||
|
try {
|
||||||
|
Start-ScheduledTask -TaskName $TaskName
|
||||||
|
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||||
|
do {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$info = Get-ScheduledTaskInfo -TaskName $TaskName
|
||||||
|
$state = (Get-ScheduledTask -TaskName $TaskName).State
|
||||||
|
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
|
||||||
|
return $info.LastTaskResult
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false `
|
||||||
|
-ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Harden + shred helper for the transient secret file ------------------
|
||||||
|
function Remove-Securely {
|
||||||
|
param([Parameter(Mandatory)] [string] $Path)
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) { return }
|
||||||
|
try {
|
||||||
|
$len = (Get-Item -LiteralPath $Path).Length
|
||||||
|
if ($len -gt 0) {
|
||||||
|
$rnd = New-Object byte[] $len
|
||||||
|
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($rnd)
|
||||||
|
[IO.File]::WriteAllBytes($Path, $rnd)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpDir = $env:TEMP
|
||||||
|
$setPy = Join-Path $tmpDir ("setcred_{0}.py" -f ([guid]::NewGuid().ToString('N')))
|
||||||
|
$verPy = Join-Path $tmpDir ("vercred_{0}.py" -f ([guid]::NewGuid().ToString('N')))
|
||||||
|
$verOut = Join-Path $tmpDir ("vercred_{0}.txt" -f ([guid]::NewGuid().ToString('N')))
|
||||||
|
|
||||||
|
if (-not $PSCmdlet.ShouldProcess("$Target ($UserName)",
|
||||||
|
"store credential in SYSTEM keyring vault")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Decrypt SecureString only at the moment of writing the temp script.
|
||||||
|
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
|
||||||
|
$plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
|
||||||
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||||
|
|
||||||
|
# Python literals: pass via triple-quoted raw-ish strings. Reject quotes
|
||||||
|
# that would break out of the literal rather than risk silent corruption.
|
||||||
|
if ($UserName -match "'''" -or $plain -match "'''") {
|
||||||
|
throw "UserName/Password contains a triple-quote sequence; unsupported."
|
||||||
|
}
|
||||||
|
$setBody = @"
|
||||||
|
import keyring
|
||||||
|
keyring.set_password(r'''$Target''', r'''$UserName''', r'''$plain''')
|
||||||
|
print('OK')
|
||||||
|
"@
|
||||||
|
Set-Content -LiteralPath $setPy -Value $setBody -Encoding UTF8
|
||||||
|
Remove-Variable plain -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Lock the secret file down to SYSTEM + Administrators before it runs.
|
||||||
|
$acl = New-Object Security.AccessControl.FileSecurity
|
||||||
|
$acl.SetAccessRuleProtection($true, $false)
|
||||||
|
foreach ($id in 'SYSTEM', 'Administrators') {
|
||||||
|
$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule(
|
||||||
|
$id, 'FullControl', 'Allow')))
|
||||||
|
}
|
||||||
|
Set-Acl -LiteralPath $setPy -AclObject $acl
|
||||||
|
|
||||||
|
$rc = Invoke-AsSystem -TaskName 'CI-SetGuestCred' `
|
||||||
|
-Execute $VenvPython -Argument "`"$setPy`""
|
||||||
|
if ($rc -ne 0) {
|
||||||
|
throw "keyring.set_password task exited with code $rc."
|
||||||
|
}
|
||||||
|
Write-Host "Stored '$Target' (user '$UserName') in the SYSTEM vault." `
|
||||||
|
-ForegroundColor Green
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Securely -Path $setPy
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($SkipVerify) { return }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$verBody = @"
|
||||||
|
import keyring
|
||||||
|
c = keyring.get_credential(r'''$Target''', None)
|
||||||
|
open(r'''$verOut''', 'w', encoding='utf-8').write(
|
||||||
|
'NONE' if c is None else 'OK:' + c.username)
|
||||||
|
"@
|
||||||
|
Set-Content -LiteralPath $verPy -Value $verBody -Encoding UTF8
|
||||||
|
Invoke-AsSystem -TaskName 'CI-VerifyGuestCred' `
|
||||||
|
-Execute $VenvPython -Argument "`"$verPy`"" | Out-Null
|
||||||
|
|
||||||
|
$result = if (Test-Path -LiteralPath $verOut) {
|
||||||
|
(Get-Content -LiteralPath $verOut -Raw).Trim()
|
||||||
|
} else { 'NONE' }
|
||||||
|
|
||||||
|
if ($result -like 'OK:*') {
|
||||||
|
Write-Host "Verified: SYSTEM keyring returns user '$($result.Substring(3))'." `
|
||||||
|
-ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw "Verification failed: SYSTEM keyring.get_credential('$Target', None) " +
|
||||||
|
"returned None. Credential did not persist."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -LiteralPath $verPy -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $verOut -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user