diff --git a/.github/workflows/build-windows-a11y-ami.yml b/.github/workflows/build-windows-a11y-ami.yml new file mode 100644 index 0000000..911283a --- /dev/null +++ b/.github/workflows/build-windows-a11y-ami.yml @@ -0,0 +1,227 @@ +name: Build Windows A11y AMI + +on: + workflow_dispatch: + inputs: + ami_name: + description: "AMI 版本標籤 (例如 2026-05-01)" + required: true + +permissions: + contents: read + +env: + AWS_REGION: ap-northeast-1 + RETENTION_COUNT: 3 + +jobs: + build: + name: build windows a11y ami + environment: windows-a11y + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + outputs: + ami_id: ${{ steps.create-image.outputs.ami_id }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_GITHUB_ACTION_ROLE }} + aws-region: ${{ env.AWS_REGION }} + + - name: Launch build instance + id: launch + run: | + STACK_NAME="windows-a11y-build-${{ github.event.inputs.ami_name }}" + echo "stack_name=${STACK_NAME}" >> "$GITHUB_OUTPUT" + aws cloudformation deploy \ + --stack-name "${STACK_NAME}" \ + --template-file cloudformation/windows-a11y-instance-template.yml \ + --parameter-overrides \ + AmiId=${{ vars.BASE_AMI_ID }} \ + InstanceType=${{ vars.INSTANCE_TYPE }} \ + SubnetId=${{ vars.SUBNET_ID }} \ + SecurityGroupId=${{ vars.SECURITY_GROUP_ID }} \ + InstanceProfileName=${{ vars.INSTANCE_PROFILE_NAME }} \ + KeyName=${{ vars.KEY_NAME }} \ + InstanceName="windows-a11y-build-${{ github.event.inputs.ami_name }}" \ + --no-fail-on-empty-changeset + INSTANCE_ID=$(aws cloudformation describe-stacks --stack-name "${STACK_NAME}" --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text) + echo "instance_id=${INSTANCE_ID}" >> "$GITHUB_OUTPUT" + + - name: Wait for instance and SSM registration + run: | + aws ec2 wait instance-status-ok --instance-ids "${{ steps.launch.outputs.instance_id }}" + for i in $(seq 1 30); do + STATE=$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=${{ steps.launch.outputs.instance_id }}" --query "InstanceInformationList[0].PingStatus" --output text) + if [ "$STATE" == "Online" ]; then + echo "SSM agent online." + exit 0 + fi + sleep 15 + done + echo "SSM agent did not come online in time." + exit 1 + + - name: Install Windows Updates (repeat until converged) + run: | + for i in $(seq 1 5); do + OUTPUT=$(bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/install-updates.ps1 3600) + echo "${OUTPUT}" + if echo "${OUTPUT}" | grep -q "No updates found."; then + echo "No further updates." + break + fi + if echo "${OUTPUT}" | grep -q "REBOOT_REQUIRED=true"; then + echo "Rebooting instance for updates (pass ${i})..." + aws ec2 reboot-instances --instance-ids "${{ steps.launch.outputs.instance_id }}" + sleep 30 + aws ec2 wait instance-status-ok --instance-ids "${{ steps.launch.outputs.instance_id }}" + fi + done + echo "WINDOWS_UPDATE_DATE=$(date -u +%Y-%m-%d)" >> "$GITHUB_ENV" + + - name: Install/update Chrome, Firefox, NVDA + id: software + run: | + OUTPUT=$(bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/install-software.ps1 1800 | tr -d '\r') + echo "${OUTPUT}" + echo "chrome_version=$(echo "${OUTPUT}" | grep -oP 'VERSION_GOOGLECHROME=\K.*')" >> "$GITHUB_OUTPUT" + echo "firefox_version=$(echo "${OUTPUT}" | grep -oP 'VERSION_FIREFOX=\K.*')" >> "$GITHUB_OUTPUT" + echo "nvda_version=$(echo "${OUTPUT}" | grep -oP 'VERSION_NVDA=\K.*')" >> "$GITHUB_OUTPUT" + + - name: Configure RDP and display language + run: | + bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/configure-system.ps1 900 + aws ec2 reboot-instances --instance-ids "${{ steps.launch.outputs.instance_id }}" + sleep 30 + aws ec2 wait instance-status-ok --instance-ids "${{ steps.launch.outputs.instance_id }}" + + - name: Set up coseeing and user accounts + run: | + OUTPUT=$(bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/setup-accounts.ps1 300 | tr -d '\r') + COSEEING_PASSWORD=$(echo "${OUTPUT}" | grep -oP 'ACCOUNT_PASSWORD_COSEEING=\K.*') + USER_PASSWORD=$(echo "${OUTPUT}" | grep -oP 'ACCOUNT_PASSWORD_USER=\K.*') + echo "::add-mask::${COSEEING_PASSWORD}" + echo "::add-mask::${USER_PASSWORD}" + + for ACCOUNT in coseeing user; do + if [ "$ACCOUNT" == "coseeing" ]; then PASSWORD="${COSEEING_PASSWORD}"; else PASSWORD="${USER_PASSWORD}"; fi + SECRET_NAME="windows-a11y/${{ github.event.inputs.ami_name }}/${ACCOUNT}" + SECRET_JSON=$(jq -n --arg u "$ACCOUNT" --arg p "$PASSWORD" '{username:$u,password:$p}') + if aws secretsmanager describe-secret --secret-id "${SECRET_NAME}" >/dev/null 2>&1; then + aws secretsmanager put-secret-value --secret-id "${SECRET_NAME}" --secret-string "${SECRET_JSON}" >/dev/null + else + aws secretsmanager create-secret --name "${SECRET_NAME}" --secret-string "${SECRET_JSON}" >/dev/null + fi + done + + - name: Verify environment before imaging + run: | + bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/verify-environment.ps1 300 + + - name: Stop instance + run: | + aws ec2 stop-instances --instance-ids "${{ steps.launch.outputs.instance_id }}" + aws ec2 wait instance-stopped --instance-ids "${{ steps.launch.outputs.instance_id }}" + + - name: Create AMI + id: create-image + run: | + AMI_NAME="windows-a11y-${{ github.event.inputs.ami_name }}" + IMAGE_ID=$(aws ec2 create-image \ + --instance-id "${{ steps.launch.outputs.instance_id }}" \ + --name "${AMI_NAME}" \ + --description "Windows Server 2025 A11y test environment - ${AMI_NAME}" \ + --query 'ImageId' --output text) + aws ec2 wait image-available --image-ids "${IMAGE_ID}" + echo "ami_id=${IMAGE_ID}" >> "$GITHUB_OUTPUT" + + - name: Tag AMI and snapshots + run: | + SNAPSHOT_IDS=$(aws ec2 describe-images --image-ids "${{ steps.create-image.outputs.ami_id }}" --query 'Images[0].BlockDeviceMappings[].Ebs.SnapshotId' --output text) + aws ec2 create-tags --resources "${{ steps.create-image.outputs.ami_id }}" ${SNAPSHOT_IDS} --tags \ + Key=Name,Value="windows-a11y-${{ github.event.inputs.ami_name }}" \ + Key=BuildDate,Value="$(date -u +%Y-%m-%d)" \ + Key=WindowsUpdateDate,Value="${WINDOWS_UPDATE_DATE}" \ + Key=ChromeVersion,Value="${{ steps.software.outputs.chrome_version }}" \ + Key=FirefoxVersion,Value="${{ steps.software.outputs.firefox_version }}" \ + Key=NvdaVersion,Value="${{ steps.software.outputs.nvda_version }}" \ + Key=PipelineVersion,Value="${{ github.sha }}" + + - name: Clean up old AMIs beyond retention + run: | + bash scripts/windows-a11y/cleanup-old-amis.sh "windows-a11y-" "${{ env.RETENTION_COUNT }}" + + - name: Terminate build instance + if: always() + run: | + aws cloudformation delete-stack --stack-name "${{ steps.launch.outputs.stack_name }}" + aws cloudformation wait stack-delete-complete --stack-name "${{ steps.launch.outputs.stack_name }}" + + verify: + name: verify new ami boots and accepts rdp + needs: build + environment: windows-a11y + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_GITHUB_ACTION_ROLE }} + aws-region: ${{ env.AWS_REGION }} + + - name: Launch verification instance from new AMI + id: launch + run: | + STACK_NAME="windows-a11y-verify-${{ github.event.inputs.ami_name }}" + echo "stack_name=${STACK_NAME}" >> "$GITHUB_OUTPUT" + aws cloudformation deploy \ + --stack-name "${STACK_NAME}" \ + --template-file cloudformation/windows-a11y-instance-template.yml \ + --parameter-overrides \ + AmiId=${{ needs.build.outputs.ami_id }} \ + InstanceType=${{ vars.VERIFY_INSTANCE_TYPE }} \ + SubnetId=${{ vars.SUBNET_ID }} \ + SecurityGroupId=${{ vars.SECURITY_GROUP_ID }} \ + InstanceProfileName=${{ vars.INSTANCE_PROFILE_NAME }} \ + KeyName=${{ vars.KEY_NAME }} \ + InstanceName="windows-a11y-verify-${{ github.event.inputs.ami_name }}" \ + --no-fail-on-empty-changeset + INSTANCE_ID=$(aws cloudformation describe-stacks --stack-name "${STACK_NAME}" --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text) + PUBLIC_IP=$(aws cloudformation describe-stacks --stack-name "${STACK_NAME}" --query "Stacks[0].Outputs[?OutputKey=='PublicIp'].OutputValue" --output text) + echo "instance_id=${INSTANCE_ID}" >> "$GITHUB_OUTPUT" + echo "public_ip=${PUBLIC_IP}" >> "$GITHUB_OUTPUT" + + - name: Wait for instance and SSM registration + run: | + aws ec2 wait instance-status-ok --instance-ids "${{ steps.launch.outputs.instance_id }}" + for i in $(seq 1 30); do + STATE=$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=${{ steps.launch.outputs.instance_id }}" --query "InstanceInformationList[0].PingStatus" --output text) + if [ "$STATE" == "Online" ]; then + exit 0 + fi + sleep 15 + done + exit 1 + + - name: Verify accounts and software on launched AMI + run: | + bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/verify-environment.ps1 300 + + - name: Terminate verification instance + if: always() + run: | + aws cloudformation delete-stack --stack-name "${{ steps.launch.outputs.stack_name }}" + aws cloudformation wait stack-delete-complete --stack-name "${{ steps.launch.outputs.stack_name }}" diff --git a/cloudformation/windows-a11y-instance-template.yml b/cloudformation/windows-a11y-instance-template.yml new file mode 100644 index 0000000..6ef8a8e --- /dev/null +++ b/cloudformation/windows-a11y-instance-template.yml @@ -0,0 +1,62 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Windows A11y build/verification EC2 instance (ordinary shared-tenancy - Windows Server does not require a Dedicated Host) + +Parameters: + AmiId: + Type: AWS::EC2::Image::Id + Description: Windows AMI to launch (AWS public Windows Server 2025 base AMI for builds, or a windows-a11y-* AMI for verification) + InstanceType: + Type: String + Default: m5.xlarge + Description: EC2 instance type + DiskSize: + Type: Number + Default: 100 + Description: Size of the root EBS volume in GB + SubnetId: + Type: AWS::EC2::Subnet::Id + Description: Public subnet ID to launch the instance into + SecurityGroupId: + Type: AWS::EC2::SecurityGroup::Id + Description: RDP security group ID created manually per docs/windows-a11y-aws-manual-setup.md + InstanceProfileName: + Type: String + Description: SSM instance profile name created manually per docs/windows-a11y-aws-manual-setup.md + KeyName: + Type: AWS::EC2::KeyPair::KeyName + Description: EC2 KeyPair for emergency access + InstanceName: + Type: String + Description: Name tag for the instance + +Resources: + WindowsInstance: + Type: AWS::EC2::Instance + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: true + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Ref InstanceName + +Outputs: + InstanceId: + Description: The Instance ID + Value: !Ref WindowsInstance + + PublicIp: + Description: Public IP address of the instance + Value: !GetAtt WindowsInstance.PublicIp diff --git a/docs/windows-a11y-aws-manual-setup.md b/docs/windows-a11y-aws-manual-setup.md new file mode 100644 index 0000000..27434e1 --- /dev/null +++ b/docs/windows-a11y-aws-manual-setup.md @@ -0,0 +1,153 @@ +# Windows A11y Pipeline — One-Time AWS Console Setup + +Do this once before the first build (or again only if the office/VPN CIDR changes, +or you want to rotate the base AMI). Everything here is manual, GUI-driven setup — +the recurring build itself is fully automated by `.github/workflows/build-windows-a11y-ami.yml`. + +## 1. Look up the Windows Server 2025 base AMI ID + +1. AWS Console → **EC2** → make sure the region selector (top right) is set to **Asia Pacific (Tokyo) ap-northeast-1**. +2. Left sidebar → **AMI Catalog**. +3. Search box → type `Windows Server 2025`. +4. Under **Quick Start AMIs**, find the entry published by **Amazon** named something like + `Microsoft Windows Server 2025 Full Base` (NOT the "Core" edition — Core has no GUI shell, + and Chrome/Firefox/NVDA need the full desktop experience). +5. Click it, copy the **AMI ID** shown (e.g. `ami-0123456789abcdef0`). + - Alternative lookup: **Systems Manager** → **Parameter Store** → search + `/aws/service/ami-windows-latest/Windows_Server-2025-English-Full-Base` → **Value** tab shows the + current AMI ID AWS publishes for this edition. +6. Record this value — it becomes the `BASE_AMI_ID` GitHub variable in step 5. + +## 2. Create the RDP security group + +1. AWS Console → **EC2** → left sidebar → **Security Groups** → **Create security group**. +2. **Security group name**: `windows-a11y-rdp` +3. **Description**: `Allow RDP access to Windows A11y build/verify instances` +4. **VPC**: the default VPC in `ap-northeast-1`. +5. **Inbound rules** → **Add rule**: + - Type: `RDP` (protocol TCP and port 3389 auto-fill) + - Source: `Custom` → enter your office/VPN CIDR block (e.g. `203.0.113.0/24`) — do **not** use `0.0.0.0/0`. + - Description: `Office VPN RDP access` +6. **Outbound rules**: leave the default (all traffic allowed) — the instance needs outbound HTTPS for + Windows Update, Chocolatey, and the SSM agent. +7. **Tags**: `Name` = `windows-a11y-rdp`. +8. Click **Create security group**. Copy the resulting **Security group ID** (e.g. `sg-0123456789abcdef0`). +9. Record this value — it becomes the `SECURITY_GROUP_ID` GitHub variable in step 5. + +If the office CIDR ever changes, edit this security group's inbound rule directly — nothing else +in the pipeline needs to change. + +## 3. Create the SSM instance role + +1. AWS Console → **IAM** → left sidebar → **Roles** → **Create role**. +2. **Trusted entity type**: `AWS service`. +3. **Use case**: `EC2`. +4. **Permissions policies**: search for and check `AmazonSSMManagedInstanceCore`. +5. **Role name**: `windows-a11y-ssm-instance-role` +6. **Description**: `EC2 instance role granting SSM Run Command access for Windows A11y build/verify instances` +7. Click **Create role**. + +Creating an EC2 role through the console automatically creates a matching **instance profile** +with the same name — `windows-a11y-ssm-instance-role` is both the role name and the instance +profile name you'll use. Record it — it becomes the `INSTANCE_PROFILE_NAME` GitHub variable in step 5. + +## 4. Extend the GitHub Actions deploy role's permissions + +The pipeline assumes the same role every other workflow in this repo already uses +(`secrets.AWS_GITHUB_ACTION_ROLE`). It needs a few more permissions. + +1. AWS Console → **IAM** → **Roles** → find the role that backs `AWS_GITHUB_ACTION_ROLE` + (check the GitHub repo's Actions secret value, or ask whoever manages OIDC federation, for the exact role name). +2. Open it → **Add permissions** → **Create inline policy** → **JSON** tab → paste: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "WindowsA11yEc2Lifecycle", + "Effect": "Allow", + "Action": [ + "ec2:RunInstances", + "ec2:TerminateInstances", + "ec2:StopInstances", + "ec2:StartInstances", + "ec2:RebootInstances", + "ec2:DescribeInstances", + "ec2:DescribeInstanceStatus", + "ec2:CreateImage", + "ec2:DeregisterImage", + "ec2:DescribeImages", + "ec2:DescribeSnapshots", + "ec2:DeleteSnapshot", + "ec2:CreateTags" + ], + "Resource": "*" + }, + { + "Sid": "WindowsA11yIamPassRole", + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": "arn:aws:iam::*:role/windows-a11y-ssm-instance-role" + }, + { + "Sid": "WindowsA11ySsm", + "Effect": "Allow", + "Action": [ + "ssm:SendCommand", + "ssm:GetCommandInvocation", + "ssm:ListCommandInvocations", + "ssm:DescribeInstanceInformation" + ], + "Resource": "*" + }, + { + "Sid": "WindowsA11ySecretsManager", + "Effect": "Allow", + "Action": [ + "secretsmanager:CreateSecret", + "secretsmanager:PutSecretValue", + "secretsmanager:DescribeSecret" + ], + "Resource": "arn:aws:secretsmanager:ap-northeast-1:*:secret:windows-a11y/*" + } + ] +} +``` + +3. **Next** → **Policy name**: `windows-a11y-pipeline-policy` → **Create policy**. + +## 5. Secrets Manager naming convention (reference only — nothing to create by hand) + +The build workflow creates/updates these automatically every run; there is nothing to +provision here. For reference, the naming convention is: + +- `windows-a11y//coseeing` +- `windows-a11y//user` + +Each holds a JSON secret shaped `{"username": "...", "password": "..."}`. The IAM policy in +step 4 already scopes secret creation to the `windows-a11y/*` path. After a build, view them +in **Secrets Manager** console under that prefix. + +## 6. Create the GitHub Environment + +1. GitHub repo → **Settings** → **Environments** → **New environment** → name it `windows-a11y`. +2. Add these **Variables**: + + | Variable | Value | Where it comes from | + |---|---|---| + | `BASE_AMI_ID` | e.g. `ami-0123456789abcdef0` | Step 1 | + | `SECURITY_GROUP_ID` | e.g. `sg-0123456789abcdef0` | Step 2 | + | `INSTANCE_PROFILE_NAME` | `windows-a11y-ssm-instance-role` | Step 3 | + | `INSTANCE_TYPE` | `m5.xlarge` | fixed default | + | `VERIFY_INSTANCE_TYPE` | `m5.large` | fixed default | + | `AVAILABILITY_ZONE` | `ap-northeast-1c` | fixed default | + | `SUBNET_ID` | e.g. `subnet-0123456789abcdef0` | EC2 console → Subnets → filter by the default VPC + Availability Zone `ap-northeast-1c` → copy Subnet ID | + | `KEY_NAME` | `deploy_key` | reusing the existing key pair already used by `launch-ec2-instance.yml` | + + Note: `AVAILABILITY_ZONE` is not read by the CloudFormation template or the workflow — it exists + purely as a lookup aid for the operator to pick the correct `SUBNET_ID` above; the AZ actually used + is whichever one the chosen subnet belongs to. + +No environment secrets are needed — the office/VPN CIDR was only needed once, to type into the +security group's inbound rule in step 2. diff --git a/scripts/windows-a11y/cleanup-old-amis.sh b/scripts/windows-a11y/cleanup-old-amis.sh new file mode 100644 index 0000000..d07f47b --- /dev/null +++ b/scripts/windows-a11y/cleanup-old-amis.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Keeps the N most recently created AMIs (and their snapshots) matching a Name tag prefix. +# Usage: cleanup-old-amis.sh + +NAME_PREFIX="${1:?Usage: cleanup-old-amis.sh }" +KEEP_COUNT="${2:?Usage: cleanup-old-amis.sh }" + +mapfile -t AMI_IDS < <( + aws ec2 describe-images \ + --owners self \ + --filters "Name=name,Values=${NAME_PREFIX}*" \ + --query 'sort_by(Images, &CreationDate)[].ImageId' \ + --output text | tr '\t' '\n' +) + +TOTAL=${#AMI_IDS[@]} +echo "Found ${TOTAL} AMI(s) matching prefix '${NAME_PREFIX}', keeping ${KEEP_COUNT} most recent." + +if (( TOTAL <= KEEP_COUNT )); then + echo "Nothing to clean up." + exit 0 +fi + +DELETE_COUNT=$((TOTAL - KEEP_COUNT)) +for ((i = 0; i < DELETE_COUNT; i++)); do + AMI_ID="${AMI_IDS[$i]}" + + mapfile -t SNAPSHOT_IDS < <( + aws ec2 describe-images --image-ids "${AMI_ID}" \ + --query 'Images[0].BlockDeviceMappings[].Ebs.SnapshotId' \ + --output text | tr '\t' '\n' + ) + + echo "Deregistering ${AMI_ID} and deleting ${#SNAPSHOT_IDS[@]} snapshot(s)." + aws ec2 deregister-image --image-id "${AMI_ID}" + + for SNAPSHOT_ID in "${SNAPSHOT_IDS[@]}"; do + [[ -n "${SNAPSHOT_ID}" && "${SNAPSHOT_ID}" != "None" ]] || continue + aws ec2 delete-snapshot --snapshot-id "${SNAPSHOT_ID}" + done +done diff --git a/scripts/windows-a11y/configure-system.ps1 b/scripts/windows-a11y/configure-system.ps1 new file mode 100644 index 0000000..881b11c --- /dev/null +++ b/scripts/windows-a11y/configure-system.ps1 @@ -0,0 +1,22 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$logPrefix = '[configure-system]' + +Write-Output "$logPrefix Enabling Remote Desktop..." +Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections' -Value 0 +Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' + +Write-Output "$logPrefix Installing Traditional Chinese language pack..." +if (-not (Get-InstalledLanguage -Language zh-TW -ErrorAction SilentlyContinue)) { + Install-Language -Language zh-TW -ErrorAction Stop +} + +Write-Output "$logPrefix Setting system display language to zh-TW..." +Set-SystemPreferredUILanguage -Language zh-TW +Set-WinSystemLocale -SystemLocale zh-TW +Set-Culture -CultureInfo zh-TW + +Write-Output "$logPrefix Display language configured. A reboot is required for the UI language change to fully apply." +Write-Output "REBOOT_REQUIRED=true" diff --git a/scripts/windows-a11y/install-software.ps1 b/scripts/windows-a11y/install-software.ps1 new file mode 100644 index 0000000..d9a0489 --- /dev/null +++ b/scripts/windows-a11y/install-software.ps1 @@ -0,0 +1,33 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$logPrefix = '[install-software]' + +if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Output "$logPrefix Installing Chocolatey..." + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + $env:Path = "$env:Path;C:\ProgramData\chocolatey\bin" +} + +$packages = @('googlechrome', 'firefox', 'nvda') +foreach ($package in $packages) { + Write-Output "$logPrefix Installing/updating $package..." + choco upgrade $package -y --no-progress | Out-Null + if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { + throw "$logPrefix choco upgrade $package failed with exit code $LASTEXITCODE" + } +} + +Write-Output "$logPrefix Installed package versions:" +$versionLines = @(choco list --limit-output | + Where-Object { $_ -match '^(googlechrome|firefox|nvda)\|' }) +if ($versionLines.Count -lt $packages.Count) { + throw "$logPrefix Expected $($packages.Count) installed package versions but found $($versionLines.Count): $($versionLines -join '; ')" +} +foreach ($line in $versionLines) { + $parts = $line -split '\|' + Write-Output "VERSION_$($parts[0].ToUpper())=$($parts[1])" +} diff --git a/scripts/windows-a11y/install-updates.ps1 b/scripts/windows-a11y/install-updates.ps1 new file mode 100644 index 0000000..417ac3b --- /dev/null +++ b/scripts/windows-a11y/install-updates.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$logPrefix = '[install-updates]' + +Write-Output "$logPrefix Searching for updates..." +$updateSession = New-Object -ComObject Microsoft.Update.Session +$updateSearcher = $updateSession.CreateUpdateSearcher() +$searchResult = $updateSearcher.Search("IsInstalled=0 and IsHidden=0") + +if ($searchResult.Updates.Count -eq 0) { + Write-Output "$logPrefix No updates found." + Write-Output "REBOOT_REQUIRED=false" + exit 0 +} + +$updatesToDownload = New-Object -ComObject Microsoft.Update.UpdateColl +foreach ($update in $searchResult.Updates) { + Write-Output "$logPrefix Found update: $($update.Title)" + if (-not $update.EulaAccepted) { $update.AcceptEula() } + $updatesToDownload.Add($update) | Out-Null +} + +Write-Output "$logPrefix Downloading $($updatesToDownload.Count) update(s)..." +$downloader = $updateSession.CreateUpdateDownloader() +$downloader.Updates = $updatesToDownload +$downloadResult = $downloader.Download() +if ($downloadResult.ResultCode -ne 2) { + throw "$logPrefix Download failed with result code $($downloadResult.ResultCode)" +} + +$updatesToInstall = New-Object -ComObject Microsoft.Update.UpdateColl +foreach ($update in $updatesToDownload) { + if ($update.IsDownloaded) { $updatesToInstall.Add($update) | Out-Null } +} + +Write-Output "$logPrefix Installing $($updatesToInstall.Count) update(s)..." +$installer = $updateSession.CreateUpdateInstaller() +$installer.Updates = $updatesToInstall +$installResult = $installer.Install() + +Write-Output "$logPrefix Install result code: $($installResult.ResultCode)" +Write-Output "$logPrefix Reboot required: $($installResult.RebootRequired)" + +if ($installResult.ResultCode -ne 2 -and $installResult.ResultCode -ne 3) { + throw "$logPrefix Install failed with result code $($installResult.ResultCode)" +} + +if ($installResult.RebootRequired) { + Write-Output "REBOOT_REQUIRED=true" +} else { + Write-Output "REBOOT_REQUIRED=false" +} diff --git a/scripts/windows-a11y/setup-accounts.ps1 b/scripts/windows-a11y/setup-accounts.ps1 new file mode 100644 index 0000000..3685d7b --- /dev/null +++ b/scripts/windows-a11y/setup-accounts.ps1 @@ -0,0 +1,59 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$logPrefix = '[setup-accounts]' + +function New-RandomPassword { + $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%' + $length = 24 + $rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new() + try { + $result = New-Object System.Text.StringBuilder + $byte = [byte[]]::new(1) + while ($result.Length -lt $length) { + $rng.GetBytes($byte) + # Reject bytes that would introduce modulo bias for this charset over a 256-value byte range. + if ($byte[0] -lt (256 - (256 % $chars.Length))) { + [void]$result.Append($chars[$byte[0] % $chars.Length]) + } + } + $result.ToString() + } finally { + $rng.Dispose() + } +} + +function Set-LocalAccount { + param( + [string]$Name, + [switch]$IsAdmin + ) + + $password = New-RandomPassword + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + + $existing = Get-LocalUser -Name $Name -ErrorAction SilentlyContinue + if ($existing) { + Write-Output "$logPrefix Account $Name exists, resetting password." + Set-LocalUser -Name $Name -Password $securePassword -PasswordNeverExpires:$true + } else { + Write-Output "$logPrefix Creating account $Name." + New-LocalUser -Name $Name -Password $securePassword -PasswordNeverExpires:$true -AccountNeverExpires | Out-Null + } + + if ($IsAdmin) { + if (-not (Get-LocalGroupMember -Group 'Administrators' -Member $Name -ErrorAction SilentlyContinue)) { + Add-LocalGroupMember -Group 'Administrators' -Member $Name + } + } else { + if (Get-LocalGroupMember -Group 'Administrators' -Member $Name -ErrorAction SilentlyContinue) { + Remove-LocalGroupMember -Group 'Administrators' -Member $Name + } + } + + Write-Output "ACCOUNT_PASSWORD_$($Name.ToUpper())=$password" +} + +Set-LocalAccount -Name 'coseeing' -IsAdmin +Set-LocalAccount -Name 'user' diff --git a/scripts/windows-a11y/ssm-run.sh b/scripts/windows-a11y/ssm-run.sh new file mode 100644 index 0000000..b74d1bd --- /dev/null +++ b/scripts/windows-a11y/ssm-run.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Sends a local PowerShell script to an EC2 instance via SSM Run Command and waits for completion. +# Usage: ssm-run.sh [timeout-seconds] + +INSTANCE_ID="${1:?Usage: ssm-run.sh [timeout-seconds]}" +SCRIPT_PATH="${2:?Usage: ssm-run.sh [timeout-seconds]}" +TIMEOUT_SECONDS="${3:-1800}" + +SCRIPT_CONTENT=$(cat "${SCRIPT_PATH}") + +COMMAND_ID=$(aws ssm send-command \ + --instance-ids "${INSTANCE_ID}" \ + --document-name "AWS-RunPowerShellScript" \ + --parameters "{\"commands\":$(jq -Rs '[.]' <<< "${SCRIPT_CONTENT}"),\"executionTimeout\":[\"${TIMEOUT_SECONDS}\"]}" \ + --query 'Command.CommandId' \ + --output text) + +echo "Sent SSM command ${COMMAND_ID} for ${SCRIPT_PATH}" >&2 + +ELAPSED=0 +POLL_INTERVAL=10 +STATUS="Pending" +while (( ELAPSED < TIMEOUT_SECONDS + 60 )); do + STATUS=$(aws ssm get-command-invocation \ + --command-id "${COMMAND_ID}" \ + --instance-id "${INSTANCE_ID}" \ + --query 'Status' \ + --output text 2>/dev/null || echo "Pending") + + case "${STATUS}" in + Success|Failed|Cancelled|TimedOut) break ;; + esac + + sleep "${POLL_INTERVAL}" + ELAPSED=$((ELAPSED + POLL_INTERVAL)) +done + +STDOUT_CONTENT=$(aws ssm get-command-invocation \ + --command-id "${COMMAND_ID}" \ + --instance-id "${INSTANCE_ID}" \ + --query 'StandardOutputContent' \ + --output text) + +echo "${STDOUT_CONTENT}" + +if [[ "${STATUS}" != "Success" ]]; then + STDERR_CONTENT=$(aws ssm get-command-invocation \ + --command-id "${COMMAND_ID}" \ + --instance-id "${INSTANCE_ID}" \ + --query 'StandardErrorContent' \ + --output text) + echo "SSM command ${COMMAND_ID} ended with status ${STATUS}" >&2 + echo "${STDERR_CONTENT}" >&2 + exit 1 +fi diff --git a/scripts/windows-a11y/verify-environment.ps1 b/scripts/windows-a11y/verify-environment.ps1 new file mode 100644 index 0000000..b8bf77c --- /dev/null +++ b/scripts/windows-a11y/verify-environment.ps1 @@ -0,0 +1,29 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$results = [ordered]@{} + +$results.ChromeInstalled = Test-Path 'C:\Program Files\Google\Chrome\Application\chrome.exe' +$results.FirefoxInstalled = Test-Path 'C:\Program Files\Mozilla Firefox\firefox.exe' +$results.NvdaInstalled = Test-Path 'C:\Program Files (x86)\NVDA\nvda.exe' + +$rdpValue = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections').fDenyTSConnections +$results.RdpEnabled = ($rdpValue -eq 0) + +$results.CoseeingIsAdmin = [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'coseeing' -ErrorAction SilentlyContinue) +$results.UserIsNotAdmin = -not [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'user' -ErrorAction SilentlyContinue) +$results.UserAccountExists = [bool](Get-LocalUser -Name 'user' -ErrorAction SilentlyContinue) +$results.DisplayLanguage = (Get-WinSystemLocale).Name + +$checks = @('ChromeInstalled','FirefoxInstalled','NvdaInstalled','RdpEnabled','CoseeingIsAdmin','UserIsNotAdmin','UserAccountExists') +$allPassed = -not ($checks | Where-Object { $results[$_] -ne $true }) +$results.AllChecksPassed = $allPassed + +$json = $results | ConvertTo-Json -Compress +Write-Output "VERIFY_RESULT_JSON=$json" + +if (-not $allPassed) { + throw "Environment verification failed: $json" +}