Windows: official port -- UCRT static build, UTF-8 throughout, Low IL sandbox, ncurses TUI, installer; v0.6.0
Build
* cross build switches to pkgsCross.ucrt64 (shell-win.nix); curl (Schannel),
winpthreads and ncurses 6.6 are linked statically, so the release is
cst-agent.exe + busybox.exe (busybox-w32 Unicode) with no extra DLLs
* windres embeds a manifest with activeCodePage=UTF-8 and a version resource
* ncurses 6.6 Windows console driver patches (mouse button state, A_DIM,
wide-char declarations): scripts/patches/
* scripts/bootstrap_msys2.sh builds the same release natively in MSYS2
UCRT64; PR #1's MINGW64 notes now point there
* vendor/tre-regex (musl's TRE) provides <regex.h> on MinGW
UTF-8
* paths cross the system call boundary as UTF-16 (*W APIs); console code
pages set to 65001 and restored on exit; files are read and written in
binary mode
* child output that is not valid UTF-8 is decoded from the console, OEM or
ANSI system code page
* read_file hides CR; edit_file, apply_patch and write_file keep CRLF line
endings and a UTF-8 BOM
OS layer and processes
* os_dir_* and cst_fnmatch replace the POSIX-only directory walks in
subagent, plugins, skills, undo and fs_tools; realpath / path_under and
symlink rejection work on Windows
* every child goes through CreateProcessW with its own environment block
inside a Job Object, so timeouts and Esc end the whole process tree
Sandbox (auto mode, "auto · lowil")
* run_shell runs with a restricted Low integrity token; the workspace gets
an inheritable Low label once, the sandbox TEMP is labeled Low, and the
credentials file gets a no-read-up label
* drive roots, the profile and system folders are never labeled
* network commands still ask, since Low IL does not block the network;
Access denied and MSYS2's 0xc0000142 offer a rerun outside the sandbox
* /sandbox lists labeled folders, /sandbox reset-labels restores them
TUI
* raster console fonts are switched to Consolas / NSimSun while running
* the home logo falls back to text when the console font lacks U+2580
* mouse drag copy goes to the Windows clipboard
Distribution
* scripts/install_windows.ps1 installs a Gitea release into
%LOCALAPPDATA%\Programs\cst-agent and adds it to the user PATH;
cst-agent-update.cmd reruns it; scripts/release_windows.sh uploads the
zip with the token taken from the environment
* scripts/run_windows.bat is gone; docs/README-windows.md is rewritten
Tests
* new test_lowil, test_win_utf8 and test_os_dir; tests made portable
(cst_test_util.h)
* scripts/win_test.sh and scripts/winvm/ run the suite on English and
Chinese Windows Server VMs as Administrator, a standard user and a
Chinese user name: 29/29 on all six
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||
<settings pass="windowsPE">
|
||||
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<SetupUILanguage>
|
||||
<UILanguage>en-US</UILanguage>
|
||||
</SetupUILanguage>
|
||||
<InputLocale>en-US</InputLocale>
|
||||
<SystemLocale>en-US</SystemLocale>
|
||||
<UILanguage>en-US</UILanguage>
|
||||
<UserLocale>en-US</UserLocale>
|
||||
</component>
|
||||
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<DiskConfiguration>
|
||||
<Disk wcm:action="add">
|
||||
<DiskID>0</DiskID>
|
||||
<WillWipeDisk>true</WillWipeDisk>
|
||||
<CreatePartitions>
|
||||
<CreatePartition wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<Type>Primary</Type>
|
||||
<Extend>true</Extend>
|
||||
</CreatePartition>
|
||||
</CreatePartitions>
|
||||
<ModifyPartitions>
|
||||
<ModifyPartition wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<PartitionID>1</PartitionID>
|
||||
<Active>true</Active>
|
||||
<Format>NTFS</Format>
|
||||
<Label>Windows</Label>
|
||||
<Letter>C</Letter>
|
||||
</ModifyPartition>
|
||||
</ModifyPartitions>
|
||||
</Disk>
|
||||
</DiskConfiguration>
|
||||
<ImageInstall>
|
||||
<OSImage>
|
||||
<InstallFrom>
|
||||
<MetaData wcm:action="add">
|
||||
<Key>/IMAGE/NAME</Key>
|
||||
<Value>@IMAGE_NAME@</Value>
|
||||
</MetaData>
|
||||
</InstallFrom>
|
||||
<InstallTo>
|
||||
<DiskID>0</DiskID>
|
||||
<PartitionID>1</PartitionID>
|
||||
</InstallTo>
|
||||
</OSImage>
|
||||
</ImageInstall>
|
||||
<UserData>
|
||||
<AcceptEula>true</AcceptEula>
|
||||
<FullName>CST</FullName>
|
||||
<Organization>CST</Organization>
|
||||
</UserData>
|
||||
</component>
|
||||
</settings>
|
||||
<settings pass="specialize">
|
||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<ComputerName>cst-win</ComputerName>
|
||||
<TimeZone>China Standard Time</TimeZone>
|
||||
</component>
|
||||
</settings>
|
||||
<settings pass="oobeSystem">
|
||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<OOBE>
|
||||
<HideEULAPage>true</HideEULAPage>
|
||||
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
|
||||
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
|
||||
<ProtectYourPC>3</ProtectYourPC>
|
||||
<NetworkLocation>Work</NetworkLocation>
|
||||
</OOBE>
|
||||
<UserAccounts>
|
||||
<AdministratorPassword>
|
||||
<Value>@ADMIN_PASSWORD@</Value>
|
||||
<PlainText>true</PlainText>
|
||||
</AdministratorPassword>
|
||||
</UserAccounts>
|
||||
<AutoLogon>
|
||||
<Enabled>true</Enabled>
|
||||
<Username>Administrator</Username>
|
||||
<Password>
|
||||
<Value>@ADMIN_PASSWORD@</Value>
|
||||
<PlainText>true</PlainText>
|
||||
</Password>
|
||||
<LogonCount>1</LogonCount>
|
||||
</AutoLogon>
|
||||
<FirstLogonCommands>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "foreach ($d in Get-PSDrive -PSProvider FileSystem) { $p = Join-Path $d.Root 'setup.ps1'; if (Test-Path $p) { & $p; break } }"</CommandLine>
|
||||
<Description>Enable OpenSSH</Description>
|
||||
<RequiresUserInput>false</RequiresUserInput>
|
||||
</SynchronousCommand>
|
||||
</FirstLogonCommands>
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
@@ -0,0 +1,28 @@
|
||||
# Idempotent VM setup after install: test users, sshd access, console defaults.
|
||||
# Users come from C:\Windows\Temp\cst-users.txt ("name<TAB>password", UTF-8), deleted afterwards.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$list = 'C:\Windows\Temp\cst-users.txt'
|
||||
if (Test-Path $list) {
|
||||
foreach ($line in Get-Content -Path $list -Encoding UTF8) {
|
||||
$name, $pw = $line -split "`t", 2
|
||||
if (-not $name) { continue }
|
||||
$sec = ConvertTo-SecureString $pw -AsPlainText -Force
|
||||
if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) {
|
||||
Set-LocalUser -Name $name -Password $sec
|
||||
} else {
|
||||
New-LocalUser -Name $name -Password $sec -PasswordNeverExpires -AccountNeverExpires | Out-Null
|
||||
}
|
||||
foreach ($g in 'Users', 'OpenSSH Users') {
|
||||
Add-LocalGroupMember -Group $g -Member $name -ErrorAction SilentlyContinue
|
||||
}
|
||||
"user ok: $name"
|
||||
}
|
||||
Remove-Item -Force $list
|
||||
}
|
||||
|
||||
# the built-in rule only covers the Private profile; QEMU user networking is Public
|
||||
Set-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -Profile Any -Enabled True
|
||||
Set-SConfig -AutoLaunch $false
|
||||
New-Item -ItemType Directory -Force C:\cst-test | Out-Null
|
||||
"provision ok"
|
||||
@@ -0,0 +1,51 @@
|
||||
# Run cross-built test_*.exe inside the VM as the current user.
|
||||
# run_tests.ps1 BUNDLE [FILTER]
|
||||
# BUNDLE holds bin\ (exes, busybox.exe) and data\ (repo-relative fixtures).
|
||||
# Each run copies data\ into a per-user work dir and uses it as the cwd.
|
||||
param([Parameter(Mandatory = $true)][string]$Bundle, [string]$Filter = '*')
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
||||
$OutputEncoding = [Console]::OutputEncoding
|
||||
|
||||
$work = Join-Path $env:TEMP ('cst-test-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
New-Item -ItemType Directory -Force $work | Out-Null
|
||||
Copy-Item -Recurse -Force (Join-Path $Bundle 'data\*') $work
|
||||
$env:PATH = (Join-Path $Bundle 'bin') + ';' + $env:PATH
|
||||
|
||||
$acp = [Text.Encoding]::Default.CodePage
|
||||
"== user=$env:USERNAME acp=$acp work=$work"
|
||||
$fail = 0
|
||||
$pass = 0
|
||||
foreach ($exe in Get-ChildItem (Join-Path $Bundle 'bin') -Filter "test_$Filter.exe" | Sort-Object Name) {
|
||||
$name = $exe.BaseName
|
||||
$out = Join-Path $work "$name.out"
|
||||
$err = Join-Path $work "$name.err"
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
$p = Start-Process -FilePath $exe.FullName -WorkingDirectory $work -NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $out -RedirectStandardError $err
|
||||
$null = $p.Handle
|
||||
if (-not $p.WaitForExit(300000)) {
|
||||
& taskkill.exe /T /F /PID $p.Id | Out-Null
|
||||
$code = 'timeout'
|
||||
} else {
|
||||
$p.WaitForExit()
|
||||
$code = $p.ExitCode
|
||||
}
|
||||
$secs = [math]::Round($sw.Elapsed.TotalSeconds, 1)
|
||||
if ($code -eq 0) {
|
||||
$pass++
|
||||
"PASS $name (${secs}s)"
|
||||
} else {
|
||||
$fail++
|
||||
"FAIL $name (exit $code, ${secs}s)"
|
||||
foreach ($f in $out, $err) {
|
||||
if (Test-Path $f) {
|
||||
Get-Content -Path $f -Encoding UTF8 -Tail 25 | ForEach-Object { " $_" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"== $pass passed, $fail failed"
|
||||
Set-Location $env:TEMP
|
||||
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
|
||||
exit $fail
|
||||
@@ -0,0 +1,28 @@
|
||||
Start-Transcript -Path C:\setup.log -Append
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
if (-not (Get-Service sshd -ErrorAction SilentlyContinue)) {
|
||||
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
|
||||
}
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
Start-Service sshd
|
||||
|
||||
$keys = 'C:\ProgramData\ssh\administrators_authorized_keys'
|
||||
Get-Content (Join-Path $PSScriptRoot 'vm_key.pub') | Set-Content -Path $keys -Encoding ascii
|
||||
icacls $keys /inheritance:r /grant 'Administrators:F' /grant 'SYSTEM:F'
|
||||
|
||||
New-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -PropertyType String -Force `
|
||||
-Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
|
||||
if (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue) {
|
||||
# the built-in rule only covers the Private profile; QEMU user networking is Public
|
||||
Set-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -Profile Any -Enabled True
|
||||
} else {
|
||||
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' `
|
||||
-Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 -Profile Any
|
||||
}
|
||||
|
||||
powercfg /change monitor-timeout-ac 0
|
||||
powercfg /change standby-timeout-ac 0
|
||||
New-Item -Path C:\setup.done -ItemType File -Force
|
||||
Stop-Transcript
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Type text into the VM console via QMP.
|
||||
|
||||
type.py QMP_SOCKET TEXT (\\n = Enter)
|
||||
type.py QMP_SOCKET @FILE the file's first line, then Enter (secrets stay
|
||||
off the command line)
|
||||
type.py QMP_SOCKET --cad Ctrl+Alt+Del
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
SHIFTED = {
|
||||
'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7',
|
||||
'*': '8', '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left',
|
||||
'}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe',
|
||||
'<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent',
|
||||
}
|
||||
PLAIN = {
|
||||
' ': 'spc', '\n': 'ret', '\t': 'tab', '-': 'minus', '=': 'equal',
|
||||
'[': 'bracket_left', ']': 'bracket_right', '\\': 'backslash', ';': 'semicolon',
|
||||
"'": 'apostrophe', ',': 'comma', '.': 'dot', '/': 'slash', '`': 'grave_accent',
|
||||
}
|
||||
|
||||
|
||||
def keys_for(ch):
|
||||
if ch.isascii() and ch.isalpha():
|
||||
return (['shift'] if ch.isupper() else []) + [ch.lower()]
|
||||
if ch.isdigit():
|
||||
return [ch]
|
||||
if ch in PLAIN:
|
||||
return [PLAIN[ch]]
|
||||
if ch in SHIFTED:
|
||||
return ['shift', SHIFTED[ch]]
|
||||
raise SystemExit(f'unsupported char {ch!r}')
|
||||
|
||||
|
||||
def main():
|
||||
arg = sys.argv[2]
|
||||
if arg == '--cad':
|
||||
combos = [['ctrl', 'alt', 'delete']]
|
||||
else:
|
||||
if arg.startswith('@'):
|
||||
with open(arg[1:], encoding='utf-8') as fp:
|
||||
text = fp.readline().rstrip('\r\n') + '\n'
|
||||
else:
|
||||
text = arg.encode().decode('unicode_escape')
|
||||
combos = [keys_for(ch) for ch in text]
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.connect(sys.argv[1])
|
||||
f = s.makefile('rw')
|
||||
f.readline()
|
||||
f.write(json.dumps({'execute': 'qmp_capabilities'}) + '\n')
|
||||
f.flush()
|
||||
f.readline()
|
||||
for combo in combos:
|
||||
keys = [{'type': 'qcode', 'data': k} for k in combo]
|
||||
f.write(json.dumps({'execute': 'send-key', 'arguments': {'keys': keys}}) + '\n')
|
||||
f.flush()
|
||||
f.readline()
|
||||
time.sleep(0.03)
|
||||
|
||||
|
||||
main()
|
||||
Executable
+288
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env bash
|
||||
# Headless Windows Server 2025 Core test VMs (QEMU/KVM).
|
||||
#
|
||||
# vm.sh create download the evaluation ISO, install base.qcow2
|
||||
# vm.sh variants en.qcow2 / zh.qcow2 overlays on top of base.qcow2
|
||||
# (zh: zh-CN system locale, ANSI code page 936)
|
||||
# vm.sh start|stop|status|wait VARIANT
|
||||
# vm.sh ssh VARIANT [USER] [command...] USER: Administrator (default), tester, 测试
|
||||
# vm.sh put VARIANT SRC... DEST scp as Administrator
|
||||
# vm.sh ps VARIANT [USER] FILE.ps1 [args...] run a script (uploaded with a UTF-8 BOM)
|
||||
# vm.sh shot VARIANT [out.png] console screenshot
|
||||
# vm.sh type VARIANT TEXT type into the console (\n = Enter)
|
||||
# vm.sh login VARIANT unlock the console as Administrator
|
||||
#
|
||||
# State (ISO, disks, keys, passwords) lives in $CST_WINVM_DIR, default ~/winvm.
|
||||
# Keys and passwords are created there with mode 0600 and never printed.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
D="${CST_WINVM_DIR:-$HOME/winvm}"
|
||||
ISO_URL="https://aka.ms/WinServ2025iso-enus"
|
||||
IMAGE_NAME="Windows Server 2025 SERVERSTANDARDCORE"
|
||||
USERS=(tester 测试)
|
||||
|
||||
if ! command -v qemu-system-x86_64 >/dev/null || ! command -v sshpass >/dev/null ||
|
||||
! command -v socat >/dev/null || ! command -v xorriso >/dev/null; then
|
||||
if command -v nix-shell >/dev/null && [[ -z "${CST_WINVM_NIX:-}" ]]; then
|
||||
export CST_WINVM_NIX=1
|
||||
exec nix-shell -p qemu sshpass socat xorriso imagemagick python3 \
|
||||
--run "$(printf '%q ' "$0" "$@")"
|
||||
fi
|
||||
echo "need qemu-system-x86_64, sshpass, socat, xorriso" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
die() { echo "vm.sh: $*" >&2; exit 1; }
|
||||
|
||||
port_of() {
|
||||
case "$1" in
|
||||
base) echo 2221 ;;
|
||||
en) echo 2222 ;;
|
||||
zh) echo 2223 ;;
|
||||
*) die "unknown variant '$1' (base, en, zh)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pw_file() {
|
||||
case "$1" in
|
||||
Administrator) echo "$D/admin-password" ;;
|
||||
tester) echo "$D/tester-password" ;;
|
||||
测试) echo "$D/ceshi-password" ;;
|
||||
*) die "unknown user '$1'" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
new_password() { # prefix -> file, mixed classes for Windows complexity rules
|
||||
local f="$2"
|
||||
[[ -s "$f" ]] && return 0
|
||||
(umask 077; printf '%s%s9a' "$1" "$(head -c 256 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 16)" >"$f")
|
||||
}
|
||||
|
||||
running() {
|
||||
local pid
|
||||
pid="$(cat "$D/$1.pid" 2>/dev/null)" || return 1
|
||||
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
ssh_opts() {
|
||||
echo -p "$(port_of "$1")" -o UserKnownHostsFile="$D/known_hosts" \
|
||||
-o StrictHostKeyChecking=accept-new -o LogLevel=ERROR -o ConnectTimeout=10
|
||||
}
|
||||
|
||||
vm_ssh() { # variant user command...
|
||||
local v="$1" u="$2"
|
||||
shift 2
|
||||
# shellcheck disable=SC2046
|
||||
if [[ "$u" == Administrator ]]; then
|
||||
ssh -i "$D/vm_key" -o BatchMode=yes $(ssh_opts "$v") "Administrator@127.0.0.1" "$@"
|
||||
else
|
||||
SSHPASS="$(cat "$(pw_file "$u")")" sshpass -e ssh -o PubkeyAuthentication=no \
|
||||
-o PreferredAuthentications=password $(ssh_opts "$v") "$u@127.0.0.1" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
vm_put() { # variant src... dest
|
||||
local v="$1"
|
||||
shift
|
||||
local dest="${!#}"
|
||||
local srcs=("${@:1:$#-1}")
|
||||
# shellcheck disable=SC2046
|
||||
scp -q -r -i "$D/vm_key" -o BatchMode=yes $(ssh_opts "$v" | sed 's/^-p/-P/') \
|
||||
"${srcs[@]}" "Administrator@127.0.0.1:$dest"
|
||||
}
|
||||
|
||||
qemu_start() { # variant disk [extra args...]
|
||||
local v="$1" disk="$2"
|
||||
shift 2
|
||||
running "$v" && return 0
|
||||
[[ -f "$disk" ]] || die "missing $disk"
|
||||
qemu-system-x86_64 \
|
||||
-name "cst-win-$v" -machine q35,accel=kvm \
|
||||
-cpu host,hv_relaxed,hv_spinlocks=0x1fff,hv_vapic,hv_time \
|
||||
-smp 4 -m 6G -rtc base=localtime \
|
||||
-device ahci,id=ahci \
|
||||
-drive file="$disk",if=none,id=d0,cache=writeback,discard=unmap \
|
||||
-device ide-hd,drive=d0,bus=ahci.0 \
|
||||
-netdev user,id=n0,hostfwd=tcp:127.0.0.1:"$(port_of "$v")"-:22 \
|
||||
-device e1000e,netdev=n0 \
|
||||
-device qemu-xhci,id=xhci -device usb-tablet,bus=xhci.0 \
|
||||
-display none -qmp unix:"$D/$v.qmp",server,nowait \
|
||||
-pidfile "$D/$v.pid" -daemonize "$@"
|
||||
}
|
||||
|
||||
disk_of() {
|
||||
case "$1" in
|
||||
base) echo "$D/base.qcow2" ;;
|
||||
*) echo "$D/$1.qcow2" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
vm_wait() { # variant [timeout_s]
|
||||
local v="$1" t="${2:-900}" i
|
||||
for ((i = 0; i < t; i += 5)); do
|
||||
if vm_ssh "$v" Administrator 'Test-Path C:\setup.done' 2>/dev/null | grep -q True; then
|
||||
return 0
|
||||
fi
|
||||
running "$v" || die "$v is not running"
|
||||
sleep 5
|
||||
done
|
||||
die "$v: no SSH after ${t}s"
|
||||
}
|
||||
|
||||
vm_stop() {
|
||||
local v="$1" i
|
||||
running "$v" || return 0
|
||||
vm_ssh "$v" Administrator 'Stop-Computer -Force' >/dev/null 2>&1 || true
|
||||
for ((i = 0; i < 120; i++)); do
|
||||
running "$v" || { rm -f "$D/$v.pid" "$D/$v.qmp"; return 0; }
|
||||
sleep 1
|
||||
done
|
||||
printf '%s\n' '{"execute":"qmp_capabilities"}' '{"execute":"quit"}' |
|
||||
socat - UNIX-CONNECT:"$D/$v.qmp" >/dev/null || true
|
||||
rm -f "$D/$v.pid" "$D/$v.qmp"
|
||||
}
|
||||
|
||||
with_bom() { # file -> temp copy with a UTF-8 BOM (Windows PowerShell 5.1 reads ANSI otherwise)
|
||||
local out
|
||||
out="$(mktemp --suffix=.ps1)"
|
||||
printf '\xef\xbb\xbf' >"$out"
|
||||
sed '1s/^\xef\xbb\xbf//' "$1" >>"$out"
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
vm_ps() { # variant user file args...
|
||||
local v="$1" u="$2" f="$3"
|
||||
shift 3
|
||||
local tmp name
|
||||
tmp="$(with_bom "$f")"
|
||||
name="cst-$(basename "$f" .ps1)-$$.ps1"
|
||||
vm_put "$v" "$tmp" "C:/Windows/Temp/$name"
|
||||
rm -f "$tmp"
|
||||
vm_ssh "$v" Administrator "icacls C:\\Windows\\Temp\\$name /grant 'Users:RX' | Out-Null" >/dev/null
|
||||
local rc=0
|
||||
vm_ssh "$v" "$u" "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\Windows\\Temp\\$name $*" || rc=$?
|
||||
vm_ssh "$v" Administrator "Remove-Item -Force C:\\Windows\\Temp\\$name" >/dev/null 2>&1 || true
|
||||
return $rc
|
||||
}
|
||||
|
||||
provision() { # variant: users, sshd, console; idempotent
|
||||
local v="$1" u tmp
|
||||
tmp="$(mktemp)"
|
||||
chmod 600 "$tmp"
|
||||
for u in "${USERS[@]}"; do
|
||||
new_password "Tst!" "$(pw_file "$u")"
|
||||
printf '%s\t%s\n' "$u" "$(cat "$(pw_file "$u")")" >>"$tmp"
|
||||
done
|
||||
vm_put "$v" "$tmp" "C:/Windows/Temp/cst-users.txt"
|
||||
rm -f "$tmp"
|
||||
vm_ps "$v" Administrator "$HERE/provision.ps1"
|
||||
}
|
||||
|
||||
cmd_create() {
|
||||
mkdir -p "$D"
|
||||
chmod 700 "$D"
|
||||
[[ -f "$D/base.qcow2" ]] && die "$D/base.qcow2 exists"
|
||||
if [[ ! -s "$D/server2025.iso" ]]; then
|
||||
echo "==> downloading the Windows Server 2025 evaluation ISO (~8 GB)"
|
||||
curl -fL -C - -o "$D/server2025.iso" "$ISO_URL"
|
||||
fi
|
||||
[[ -f "$D/vm_key" ]] || ssh-keygen -q -t ed25519 -N "" -C cst-winvm -f "$D/vm_key"
|
||||
new_password "Cst!" "$D/admin-password"
|
||||
|
||||
local u
|
||||
u="$(mktemp -d)"
|
||||
chmod 700 "$u"
|
||||
cp "$HERE/setup.ps1" "$D/vm_key.pub" "$u/"
|
||||
(umask 077
|
||||
sed -e "s|@IMAGE_NAME@|$IMAGE_NAME|" -e "s|@ADMIN_PASSWORD@|$(cat "$D/admin-password")|g" \
|
||||
"$HERE/autounattend.xml.in" >"$u/autounattend.xml"
|
||||
xorriso -as mkisofs -quiet -J -r -V UNATTEND -o "$D/unattend.iso" "$u/" 2>/dev/null)
|
||||
rm -rf "$u"
|
||||
|
||||
qemu-img create -q -f qcow2 "$D/base.qcow2" 60G
|
||||
echo "==> unattended install (20-30 minutes)"
|
||||
qemu_start base "$D/base.qcow2" \
|
||||
-drive file="$D/server2025.iso",media=cdrom,if=none,id=cd0,readonly=on \
|
||||
-device ide-cd,drive=cd0,bus=ahci.1 \
|
||||
-drive file="$D/unattend.iso",media=cdrom,if=none,id=cd1,readonly=on \
|
||||
-device ide-cd,drive=cd1,bus=ahci.2 \
|
||||
-boot order=c,once=d
|
||||
vm_wait base 3600
|
||||
provision base
|
||||
vm_stop base
|
||||
echo "OK: $D/base.qcow2; next: vm.sh variants"
|
||||
}
|
||||
|
||||
cmd_variants() {
|
||||
[[ -f "$D/base.qcow2" ]] || die "no base.qcow2; run vm.sh create"
|
||||
running base && die "stop the base VM first"
|
||||
local v
|
||||
for v in en zh; do
|
||||
running "$v" && vm_stop "$v"
|
||||
rm -f "$D/$v.qcow2"
|
||||
qemu-img create -q -f qcow2 -b "$D/base.qcow2" -F qcow2 "$D/$v.qcow2"
|
||||
done
|
||||
qemu_start zh "$D/zh.qcow2"
|
||||
vm_wait zh
|
||||
vm_ssh zh Administrator 'Set-WinSystemLocale -SystemLocale zh-CN; Set-Culture zh-CN; Set-WinHomeLocation -GeoId 45; Restart-Computer -Force' || true
|
||||
sleep 20
|
||||
vm_wait zh
|
||||
echo "zh ANSI code page: $(vm_ssh zh Administrator '[Text.Encoding]::Default.CodePage')"
|
||||
vm_stop zh
|
||||
echo "OK: en.qcow2 zh.qcow2"
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
shift || true
|
||||
case "$cmd" in
|
||||
create) cmd_create ;;
|
||||
variants) cmd_variants ;;
|
||||
provision) provision "${1:?variant}" ;;
|
||||
start)
|
||||
qemu_start "${1:?variant}" "$(disk_of "$1")"
|
||||
vm_wait "$1"
|
||||
;;
|
||||
stop) vm_stop "${1:?variant}" ;;
|
||||
status) running "${1:?variant}" && echo running || echo stopped ;;
|
||||
wait) vm_wait "${1:?variant}" "${2:-900}" ;;
|
||||
ssh)
|
||||
v="${1:?variant}"
|
||||
shift
|
||||
u=Administrator
|
||||
if [[ $# -gt 0 ]] && [[ "$1" == Administrator || "$1" == tester || "$1" == 测试 ]]; then
|
||||
u="$1"
|
||||
shift
|
||||
fi
|
||||
vm_ssh "$v" "$u" "$@"
|
||||
;;
|
||||
put) vm_put "$@" ;;
|
||||
ps)
|
||||
v="${1:?variant}"
|
||||
shift
|
||||
u=Administrator
|
||||
if [[ "$1" == Administrator || "$1" == tester || "$1" == 测试 ]]; then
|
||||
u="$1"
|
||||
shift
|
||||
fi
|
||||
vm_ps "$v" "$u" "$@"
|
||||
;;
|
||||
shot)
|
||||
v="${1:?variant}"
|
||||
out="${2:-$D/$v.png}"
|
||||
printf '%s\n' '{"execute":"qmp_capabilities"}' \
|
||||
'{"execute":"screendump","arguments":{"filename":"'"$D/$v.ppm"'"}}' |
|
||||
socat - UNIX-CONNECT:"$D/$v.qmp" >/dev/null
|
||||
sleep 1
|
||||
magick "$D/$v.ppm" -resize '1024x1024>' "$out"
|
||||
rm -f "$D/$v.ppm"
|
||||
echo "$out"
|
||||
;;
|
||||
type) python3 "$HERE/type.py" "$D/${1:?variant}.qmp" "${2:?text}" ;;
|
||||
login)
|
||||
python3 "$HERE/type.py" "$D/${1:?variant}.qmp" --cad
|
||||
sleep 3
|
||||
python3 "$HERE/type.py" "$D/$1.qmp" "@$D/admin-password"
|
||||
;;
|
||||
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user