3 Commits

Author SHA1 Message Date
cproudlock
2ec097069c fix(shopfloor): S: mapper review fixes - headless task, drift guard, pre-flight, re-seed
Independent review in the win11 VM found four things worth fixing before
the fleet sync:

- The task action ran powershell -WindowStyle Hidden, which on Win11
  (Windows Terminal as console host) flashes a window and steals
  foreground for ~0.5 s on every fire. Action is now
  conhost.exe --headless powershell.exe ..., verified silent.
- If the share copy of Map-SfldShare.ps1 changes without the manifest
  DetectionValue being bumped, the engine calls the registrar every
  cycle. The registrar now takes an idempotent fast path (staged hash
  matches, task present with our action -> re-stamp and exit, no task
  churn) and logs one WARN per hash when -ExpectedHash disagrees with
  the share file.
- Replacing a wrong or unreadable mapping now pre-flights the new
  mapping on a spare letter and keeps the old S: when that fails
  (except on 1219, where the old session is what blocks the new one).
- Credential Manager is re-seeded whenever the HKLM credential changes
  (password rotation) and after every repair, instead of once per
  profile. Set-State no longer wipes the stored hash on suspect writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
2026-09-04 13:37:37 -04:00
cproudlock
ca9b0de89c feat(shopfloor): Repair-AppShortcuts.ps1 for Defect Tracker pins after MSI upgrade
An MSI major upgrade only fixes the shortcuts it owns. The copies we
place (Shopfloor Tools folder, per-user taskbar pin) are advertised
shortcuts carrying the old ProductCode, so they stop resolving on
locked-down bays where the operator cannot unpin. Delete the stale copy
and write a plain shortcut to the real exe, read back and verified;
MSI-owned advertised shortcuts are left for MSI self-repair.

Wired on the SFLD share as a common manifest entry (Type=PS1, Always,
same PCTypes as the WJF Defect Tracker install, ordered after it).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
2026-09-04 12:51:23 -04:00
cproudlock
01cf99da01 fix(shopfloor): self-healing S: drive mapper to stop mid-shift drive loss
S: was mapped once per logon (HKLM\Run -> Map-SfldShare.ps1) with no
re-check, so any SMB session break the redirector could not silently
re-authenticate left S: dead until the user logged out and back in.

Replace the Run entry with a scheduled task (BUILTIN\Users, Limited)
that fires at logon, every 3 min, on unlock/console-connect, and on
network-connect. The mapper now probes S: with a real timed directory
read (not net use output parsing, which reports a dead drive as
healthy), only repairs after two consecutive failed probes with TCP 445
reachable, and never touches a working drive. Credentials go through
New-SmbMapping / CredWrite instead of the net use command line, and a
Domain Password credential is seeded so Windows' own reconnect and UNC
access share one identity (kills the 1219 conflict class).

Register-MapSfldShare stamps HKLM\SOFTWARE\GE\SfldShare\MapperHash with
the staged script's SHA256 after success, so GE-Enforce can (re)register
it fleet-wide via a common manifest entry and re-fire on version bumps.

Verified in the win11 VM: healthy no-op with an open handle held across
runs, repair of deleted / wrong-target / present-but-unreadable drives,
unaided recovery after a NIC bounce and on the 3-min interval, and the
full SYSTEM-from-share registrar path flipping detection to installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
2026-09-04 12:18:33 -04:00
830 changed files with 87846 additions and 40582 deletions

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# Refuse a commit that stages an unattend answer file the linter rejects.
# A bad answer file stops every bay imaged with it at "Windows could not
# parse or process unattend answer file". Enable with:
# git config core.hooksPath .githooks
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
mapfile -t FILES < <(git diff --cached --name-only --diff-filter=ACMR | grep -iE '(^|/)(Flat)?Unattend[^/]*\.xml$' || true)
[ ${#FILES[@]} -eq 0 ] && exit 0
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
STAGED=()
for f in "${FILES[@]}"; do
out="$TMP/$(echo "$f" | tr '/' '_')"
git show ":$f" > "$out" # lint what is staged, not the working copy
STAGED+=("$out")
done
if ! python3 "$ROOT/scripts/lint-unattend.py" --local "${STAGED[@]}"; then
echo "pre-commit: unattend lint failed for: ${FILES[*]}" >&2
exit 1
fi

6
.gitignore vendored
View File

@@ -126,9 +126,3 @@ reference/e10/**/*.kind1
# GE part programs carry part numbers and marking content. Keep them out of # GE part programs carry part numbers and marking content. Keep them out of
# git; they live in ~/pxe-images/e10-testing/partprograms/ # git; they live in ~/pxe-images/e10-testing/partprograms/
reference/e10/partprograms/ reference/e10/partprograms/
# Per-site playbook values (NIC MACs); template: playbook/site-vars.example.yml
playbook/site-vars.yml
# Site content pulled from a live server (scripts/capture-site-content.sh)
site-content/

View File

@@ -1,29 +0,0 @@
@echo off
rem Build the PXE server installer USB. Double-click this file in the build
rem folder (copied to a local disk, e.g. C:\pxe-usb). It asks for admin
rem rights, lists the USB disks and asks which one to erase and build.
rem Runs scripts\Build-PxeUsb.ps1 -Interactive with the execution policy
rem bypassed for that one window only.
setlocal
set "PS1=%~dp0scripts\Build-PxeUsb.ps1"
if not exist "%PS1%" (
echo Cannot find %PS1%
echo Run this from the pxe-usb build folder.
pause
exit /b 1
)
if "%~d0"=="\\" goto :network
net use %~d0 >nul 2>&1 && goto :network
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','\"%PS1%\"','-Interactive'"
exit /b 0
:network
echo This build folder is on a network drive (%~d0).
echo An administrator window cannot see network drives mapped in this session.
echo Copy the whole pxe-usb folder to a local disk first, for example:
echo.
echo robocopy "%~dp0." C:\pxe-usb /E
echo.
echo then double-click C:\pxe-usb\Build-PxeUsb.cmd
pause
exit /b 1

View File

@@ -1,14 +0,0 @@
@echo off
rem Pull the site content (Office, Adobe, certificates, Engineering Layer,
rem BIOS files, boot.wim, Blancco) from a running PXE server into this
rem build folder. Double-click it; it asks for the server address and the
rem pxe password. Runs scripts\Capture-SiteContent.ps1 -Interactive.
setlocal
set "PS1=%~dp0scripts\Capture-SiteContent.ps1"
if not exist "%PS1%" (
echo Cannot find %PS1%
echo Run this from the pxe-usb build folder.
pause
exit /b 1
)
powershell -NoProfile -ExecutionPolicy Bypass -File "%PS1%" -Interactive

516
README.md
View File

@@ -1,251 +1,265 @@
# GE Aerospace PXE Boot Server # GE Aerospace PXE Boot Server
Automated, air-gapped PXE boot server for deploying GE Aerospace Windows images. Built on Ubuntu 24.04 Server with zero-touch provisioning via autoinstall and Ansible. Automated, air-gapped PXE boot server for deploying GE Aerospace Windows images. Built on Ubuntu 24.04 Server with zero-touch provisioning via autoinstall and Ansible.
## Overview ## Overview
This project provides a complete, repeatable build process for a PXE boot server that serves Windows PE images to client machines on an isolated network. Everything runs offline after initial setup — no internet required on the target server. This project provides a complete, repeatable build process for a PXE boot server that serves Windows PE images to client machines on an isolated network. Everything runs offline after initial setup — no internet required on the target server.
### Boot Chain ### Boot Chain
``` ```
Client PXE boot (UEFI Secure Boot) Client PXE boot (UEFI Secure Boot)
-> iPXE (TFTP, Broadcom-signed for Secure Boot) -> iPXE (TFTP, Broadcom-signed for Secure Boot)
-> iPXE boot menu (HTTP, port 4433) -> iPXE boot menu (HTTP, port 4433)
-> User selects boot option: -> User selects boot option:
├── Windows PE -> wimboot -> boot.wim -> startnet.cmd -> Samba share -> Image deployment ├── Windows PE -> wimboot -> boot.wim -> startnet.cmd -> Samba share -> Image deployment
├── Clonezilla -> vmlinuz/initrd -> Disk cloning/imaging ├── Clonezilla -> vmlinuz/initrd -> Disk cloning/imaging
├── Blancco -> Native kernel + initramfs -> NIST 800-88 drive erasure ├── Blancco -> Native kernel + initramfs -> NIST 800-88 drive erasure
(memory test: Dell F12 > Diagnostics) └── Memtest86+ -> Memory diagnostics
``` ```
### Services ### Services
| Service | Port | Purpose | | Service | Port | Purpose |
|-------------|-----------|------------------------------------------| |-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (172.16.9.10-100, 12h lease) | | dnsmasq | 67/udp | DHCP (172.16.9.10-100, 12h lease) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) | | dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) | | Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) | | Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
| Samba | 445/tcp | Deployment content + Clonezilla + Blancco| | Samba | 445/tcp | Deployment content + Clonezilla + Blancco|
| Flask Webapp| 9009/tcp | Web management interface | | Flask Webapp| 9009/tcp | Web management interface |
### Network ### Network
- **PXE server IP:** `172.16.9.1/24` - **PXE server IP:** `172.16.9.1/24`
- **DHCP range:** `172.16.9.10` - `172.16.9.100` - **DHCP range:** `172.16.9.10` - `172.16.9.100`
- **Firewall:** UFW deny-by-default, only service ports open (22, 67, 69, 80, 445, 4433, 9009) - **Firewall:** UFW deny-by-default, only service ports open (22, 67, 69, 80, 445, 4433, 9009)
## Quick Start ## Quick Start
### Prerequisites ### Prerequisites
**On your workstation (internet-connected):** **On your workstation (internet-connected):**
- Ubuntu 24.04 (or Linux Mint / similar) for downloading packages - Ubuntu 24.04 (or Linux Mint / similar) for downloading packages
- Ubuntu Server 24.04 ISO - Ubuntu Server 24.04 ISO
- GE Aerospace Media Creator LITE (for WinPE images) - GE Aerospace Media Creator LITE (for WinPE images)
- USB drive >= 8 GB (32+ GB if bundling WinPE images) - USB drive >= 8 GB (32+ GB if bundling WinPE images)
**GE Access Packages (MyAccess portal):** **GE Access Packages (MyAccess portal):**
- EPM Rufus Exception Request - EPM Rufus Exception Request
- EPM DT Functions - EPM DT Functions
- DLP - Encrypted Removable (USB) Long Term Access - DLP - Encrypted Removable (USB) Long Term Access
### Step 1: Download Offline Packages ### Step 1: Download Offline Packages
```bash ```bash
./scripts/download-packages.sh --iso /path/to/ubuntu-24.04.x-live-server-amd64.iso ./scripts/download-packages.sh
./scripts/check-updates.py # expect: PASSED, 0 security updates ```
./scripts/fetch-ipxe.sh
``` Downloads all .deb packages and Python wheels for offline installation (~140 MB of debs, ~20 MB of wheels).
`fetch-ipxe.sh` downloads the Secure Boot iPXE chain into `boot-files/`, pinned by sha256: the iPXE release ### Step 2: Prepare Boot Tools (optional)
tarball (its snponly build becomes `ipxe.efi`) and iPXE's dual-signed shim (Microsoft UEFI CA 2011 + 2023),
which every UEFI client boots as `ipxe-shim.efi`. Keep the old Broadcom-signed `boot-files/ipxe.efi` too: the ```bash
playbook installs it as `ipxe-bcm.efi`, the rollback boot file. ./scripts/prepare-boot-tools.sh /path/to/blancco.iso /path/to/clonezilla.zip /path/to/memtest.bin
```
Downloads all .deb packages and Python wheels for offline installation (~140 MB of debs, ~20 MB of wheels).
Extracts and configures boot tool files (Blancco, Clonezilla, Memtest86+). Automatically patches Blancco's config.img to auto-save erasure reports to the PXE server's Samba share.
### Step 2: Prepare Boot Tools (optional)
### Step 3: Build the USB
```bash
./scripts/prepare-boot-tools.sh /path/to/blancco.iso ```bash
``` sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso
```
Extracts and configures boot tool files (Blancco, Clonezilla with Debian's signed shim for Secure Boot). Automatically patches Blancco's config.img to auto-save erasure reports to the PXE server's Samba share.
Creates a bootable USB with two partitions:
### Step 3: Build the USB - **Partition 1:** Ubuntu Server installer
- **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools)
```bash
sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso ### Step 4: Install on Target Server
```
1. Insert USB into the target machine
On a Windows PC instead: `scripts/stage-usb-share.sh` here, then 2. Press F12 and boot from USB
`Build-PxeUsb.ps1 -DiskNumber N -NoSiteVars` there (see SETUP.md, "Building 3. Ubuntu auto-installs with no interaction
the USB on a Windows PC"). 4. After reboot, the first-boot script:
- Installs all offline .deb packages
`build-usb.sh` creates a bootable USB with two partitions: - Runs the Ansible playbook (configures dnsmasq, Apache, Samba, UFW, webapp)
- **Partition 1:** Ubuntu Server installer - Configures static IP `172.16.9.1/24`
- **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools) 5. Move the server's wired NIC to the isolated PXE switch
### Step 4: Install on Target Server ### Step 5: Access the Web Interface
1. Insert USB into the target machine Open `http://172.16.9.1:9009` from any machine on the isolated network.
2. Press F12 and boot from USB
3. Ubuntu auto-installs with no interaction ## Web Management Interface
4. After reboot, the first-boot script:
- Installs all offline .deb packages The Flask webapp (port 9009) provides a browser-based management UI:
- Runs the Ansible playbook (configures dnsmasq, Apache, Samba, UFW, webapp)
- Configures static IP `172.16.9.1/24` - **Dashboard** — Service status overview, disk usage, connected DHCP clients
5. Move the server's wired NIC to the isolated PXE switch - **Image Import** — Import WinPE deployment images from USB drives
- **Unattend Editor** — Edit Windows unattend.xml files per image type (XML syntax highlighting)
### Step 5: Access the Web Interface - **startnet.cmd Editor** — Modify the startnet.cmd inside boot.wim without Windows (uses wimtools)
- **Clonezilla Backups** — Upload, download, and manage disk backup images
Open `http://172.16.9.1:9009` from any machine on the isolated network. - **Blancco Reports** — View, download, and manage drive erasure reports (auto-collected via Samba)
- **Image Config** — Per-image configuration editor (drivers, OS packages, hardware models)
## Web Management Interface - **Enrollment** — Upload, download, and manage GCCH bulk enrollment packages
- **Audit Log** — Activity history for all write operations (imports, edits, deletes)
The Flask webapp (port 9009) provides a browser-based management UI:
### Image Types Supported
- **Dashboard** — Service status overview, disk usage, connected DHCP clients
- **Image Import** — Import WinPE deployment images from USB drives | Image Type | Domain | Description |
- **Unattend Editor** — Edit Windows unattend.xml files per image type (XML syntax highlighting) |----------------------|-----------------|-------------------------|
- **startnet.cmd Editor** — Modify the startnet.cmd inside boot.wim without Windows (uses wimtools) | gea-standard | geaerospace.com | Standard desktop |
- **Clonezilla Backups** — Upload, download, and manage disk backup images | gea-engineer | geaerospace.com | Engineering desktop |
- **Blancco Reports** — View, download, and manage drive erasure reports (auto-collected via Samba) | gea-shopfloor | geaerospace.com | Shop floor kiosk |
- **Image Config** — Per-image configuration editor (drivers, OS packages, hardware models) | ge-standard | ge.com | Standard desktop |
- **Enrollment** — Upload, download, and manage GCCH bulk enrollment packages | ge-engineer | ge.com | Engineering desktop |
- **Audit Log** — Activity history for all write operations (imports, edits, deletes) | ge-shopfloor-lockdown| ge.com | Shop floor (locked) |
| ge-shopfloor-mce | ge.com | Shop floor (MCE) |
### Image Types Supported
## Project Structure
| Image Type | Domain | Description |
|----------------------|-----------------|-------------------------| ```
| gea-standard | geaerospace.com | Standard desktop | pxe-server/
| gea-engineer | geaerospace.com | Engineering desktop | ├── autoinstall/
| gea-shopfloor | geaerospace.com | Shop floor kiosk | │ ├── user-data # Cloud-init autoinstall config + first-boot script
| ge-standard | ge.com | Standard desktop | │ └── meta-data # Cloud-init metadata (required, empty)
| ge-engineer | ge.com | Engineering desktop | ├── playbook/
| ge-shopfloor-lockdown| ge.com | Shop floor (locked) | │ ├── pxe_server_setup.yml # Ansible playbook: all server configuration
| ge-shopfloor-mce | ge.com | Shop floor (MCE) | │ ├── inventory.ini # Ansible inventory
│ ├── startnet.cmd # WinPE startup script (injected into boot.wim)
## Project Structure │ ├── blancco-init.sh # Custom initramfs for Blancco PXE boot
│ ├── blancco-preferences.xml # Blancco auto-report preferences
``` │ ├── check-bios.cmd # Pre-imaging BIOS update checker
pxe-server/ │ ├── FlatUnattendW10-shopfloor.xml # Shopfloor unattend.xml
├── autoinstall/ │ └── shopfloor-setup/ # PC type setup scripts (Standard, CMM, Display, etc.)
│ ├── user-data # Cloud-init autoinstall config + first-boot script ├── webapp/
│ └── meta-data # Cloud-init metadata (required, empty) │ ├── app.py # Flask application (~1600 lines)
├── playbook/ │ ├── requirements.txt # Python deps (flask, lxml)
│ ├── pxe_server_setup.yml # Ansible playbook: all server configuration │ ├── static/
│ ├── inventory.ini # Ansible inventory │ │ ├── ge-aerospace-logo.svg # GE Aerospace branding
│ ├── startnet.cmd # WinPE startup script (injected into boot.wim) │ │ ├── favicon.ico # Browser favicon
│ ├── blancco-init.sh # Custom initramfs for Blancco PXE boot │ │ ├── app.js # Frontend JavaScript
│ ├── blancco-preferences.xml # Blancco auto-report preferences │ │ ├── bootstrap.min.css # Bootstrap 5 (bundled offline)
│ ├── check-bios.cmd # Pre-imaging BIOS update checker │ │ ├── bootstrap.bundle.min.js
│ ├── FlatUnattendW10-shopfloor.xml # Shopfloor unattend.xml │ │ ├── bootstrap-icons.min.css
│ └── shopfloor-setup/ # PC type setup scripts (Standard, CMM, Display, etc.) │ │ └── fonts/ # Icon fonts (woff/woff2)
├── webapp/ │ └── templates/
│ ├── app.py # Flask application (~1600 lines) │ ├── base.html # Layout with GE branding and sidebar nav
│ ├── requirements.txt # Python deps (flask, lxml) │ ├── dashboard.html # Service status and overview
│ ├── static/ │ ├── import.html # USB image import wizard
│ │ ├── ge-aerospace-logo.svg # GE Aerospace branding │ ├── unattend_editor.html # XML editor for unattend files
│ │ ├── favicon.ico # Browser favicon │ ├── startnet_editor.html # startnet.cmd WIM editor
│ │ ├── app.js # Frontend JavaScript │ ├── image_config.html # Per-image driver and hardware config
│ │ ├── bootstrap.min.css # Bootstrap 5 (bundled offline) │ ├── enrollment.html # GCCH bulk enrollment packages
│ │ ├── bootstrap.bundle.min.js │ ├── backups.html # Clonezilla backup management
│ │ ├── bootstrap-icons.min.css │ ├── reports.html # Blancco erasure reports
│ │ └── fonts/ # Icon fonts (woff/woff2) │ └── audit.html # Activity audit log
│ └── templates/ ├── docs/
│ ├── base.html # Layout with GE branding and sidebar nav │ └── shopfloor-display-imaging-guide.md # End-user imaging guide
│ ├── dashboard.html # Service status and overview ├── boot-tools/ # Extracted boot tool files (gitignored)
│ ├── import.html # USB image import wizard │ ├── blancco/ # Blancco Drive Eraser
│ ├── unattend_editor.html # XML editor for unattend files │ ├── clonezilla/ # Clonezilla Live
│ ├── startnet_editor.html # startnet.cmd WIM editor │ └── memtest/ # Memtest86+
│ ├── image_config.html # Per-image driver and hardware config ├── boot-files/ # WinPE boot files (boot.wim, wimboot, ipxe.efi, BCD)
│ ├── enrollment.html # GCCH bulk enrollment packages ├── offline-packages/ # .deb files (gitignored, built by download-packages.sh)
│ ├── backups.html # Clonezilla backup management ├── pip-wheels/ # Python wheels (gitignored, built by download-packages.sh)
│ ├── reports.html # Blancco erasure reports ├── enrollment/ # PPKGs and run-enrollment.ps1 (gitignored)
│ └── audit.html # Activity audit log ├── bios-staging/ # Dell BIOS update binaries (gitignored)
├── docs/ ├── scripts/ # Build, deploy, and helper scripts
│ └── shopfloor-display-imaging-guide.md # End-user imaging guide │ ├── build-usb.sh # Builds the installer USB (2-partition)
├── boot-tools/ # Extracted boot tool files (gitignored) │ ├── build-proxmox-iso.sh # Builds self-contained Proxmox installer ISO
│ ├── blancco/ # Blancco Drive Eraser │ ├── prepare-boot-tools.sh # Extracts and patches boot tool files
│ └── clonezilla/ # Clonezilla Live │ ├── download-packages.sh # Downloads offline .debs + pip wheels
├── boot-files/ # WinPE boot files (boot.wim, wimboot, ipxe.efi, BCD) │ ├── download-drivers.py # Downloads Dell drivers directly from dell.com
├── offline-packages/ # .deb files (gitignored, built by download-packages.sh) │ ├── deploy-bios.sh # Pushes BIOS updates to enrollment share
├── pip-wheels/ # Python wheels (gitignored, built by download-packages.sh) │ ├── pull-bios.sh # Pulls BIOS binaries from upstream cache
├── enrollment/ # PPKGs and run-enrollment.ps1 (gitignored) │ ├── sync_hardware_models.py # Syncs hardware model configs across images
├── bios-staging/ # Dell BIOS update binaries (gitignored) │ ├── Upload-Image.ps1 # Windows: upload MCL cache to PXE via SMB
├── scripts/ # Build, deploy, and helper scripts │ └── Download-Drivers.ps1 # Windows: download hardware drivers from GE CDN
│ ├── build-usb.sh # Builds the installer USB (2-partition) ├── config/ # Site-specific configuration overrides
│ ├── Build-PxeUsb.ps1 # Windows: builds the installer USB (one FAT32 partition) ├── startnet-template.cmd # startnet.cmd template (synced with playbook copy)
│ ├── stage-usb-share.sh # Puts repo + payload on the pxe-images share for it ├── README.md # This file
│ ├── prepare-boot-tools.sh # Extracts and patches boot tool files └── SETUP.md # Detailed setup guide
│ ├── download-packages.sh # Downloads offline .debs + pip wheels ```
│ ├── download-drivers.py # Downloads Dell drivers directly from dell.com
│ ├── capture-site-content.sh # Pulls site content + fresh payload from a live server ## Proxmox Deployment
│ ├── Upload-Image.ps1 # Windows: upload MCL cache to PXE via SMB
│ └── Download-Drivers.ps1 # Windows: download hardware drivers from GE CDN A single ISO can be built for deploying the PXE server in a Proxmox VM:
├── config/ # Site-specific configuration overrides
├── README.md # This file ### Build the ISO
└── SETUP.md # Detailed setup guide
``` ```bash
# Prerequisites (on build workstation)
## Proxmox Deployment sudo apt install xorriso p7zip-full
Build a disk image with # Build the installer ISO
`scripts/build-usb.sh --site-vars site-vars.yml --image pxe-usb.img auto UBUNTU.iso` ./scripts/build-proxmox-iso.sh /path/to/ubuntu-24.04-live-server-amd64.iso
and import it on the Proxmox host (`qm importdisk <vmid> pxe-usb.img ```
<storage>`), attached after the system disk in the boot order. The VM needs
two NICs: one on an isolated imaging bridge (no other DHCP server) and one This creates `pxe-server-proxmox.iso` containing the Ubuntu installer, autoinstall config, all offline packages, the Ansible playbook, webapp, and boot tools.
on the corp bridge; their MACs go in `site-vars.yml`.
### Deploy on Proxmox
### Import WinPE Images
1. Upload `pxe-server-proxmox.iso` to Proxmox storage (Datacenter -> Storage -> ISO Images)
After the server is running, import deployment images via the web interface at `http://172.16.9.1:9009/import` or by mounting a USB drive with WinPE content. 2. Create a new VM:
- **OS:** Linux 6.x kernel
## Samba Shares - **BIOS:** OVMF (UEFI) or SeaBIOS
- **Memory:** 4096 MB
| Share | Path | Purpose | - **CPU:** 2+ cores
|-----------------|---------------------------|--------------------------------| - **Disk:** 40+ GB (VirtIO SCSI)
| winpeapps | /srv/samba/winpeapps | WinPE deployment images | - **Network:** Bridge connected to your isolated PXE network
| clonezilla | /srv/samba/clonezilla | Clonezilla disk backup images | 3. Attach the ISO as CD-ROM and start the VM
| blancco-reports | /srv/samba/blancco-reports| Blancco erasure reports (auto) | 4. Ubuntu auto-installs with zero interaction (~10-15 minutes)
| enrollment | /srv/samba/enrollment | GCCH bulk enrollment packages | 5. After reboot, first-boot configures all PXE services automatically
| image-upload | /home/pxe/image-upload | Image upload staging area | 6. Access the web interface at `http://172.16.9.1:9009`
All shares use guest access (no authentication) for ease of use on the isolated network. ### Import WinPE Images
## Blancco Drive Erasure After the server is running, import deployment images via the web interface at `http://172.16.9.1:9009/import` or by mounting a USB drive with WinPE content.
Blancco Drive Eraser 7.15.1 boots via a native Ubuntu kernel with a custom initramfs (`blancco-init.sh`) that downloads and mounts the Blancco rootfs over HTTP. XML erasure reports are automatically saved to the PXE server's Samba share (`blancco-reports`). The server supports BMC cloud licensing for Blancco activation over WiFi. ## Samba Shares
Reports are viewable and downloadable from the web interface at `http://172.16.9.1:9009/reports`. | Share | Path | Purpose |
|-----------------|---------------------------|--------------------------------|
## Notes | winpeapps | /srv/samba/winpeapps | WinPE deployment images |
| clonezilla | /srv/samba/clonezilla | Clonezilla disk backup images |
- Run `download-packages.sh --iso <the server ISO>` before building the USB: it builds the offline apt repo (every update for the ISO's base plus the explicit packages, wimtools included) that the installer patches and installs from | blancco-reports | /srv/samba/blancco-reports| Blancco erasure reports (auto) |
- The webapp uses session-based CSRF tokens on all POST forms and API endpoints | enrollment | /srv/samba/enrollment | GCCH bulk enrollment packages |
| image-upload | /home/pxe/image-upload | Image upload staging area |
## Commit History
All shares use guest access (no authentication) for ease of use on the isolated network.
| Commit | Description |
|---------|--------------------------------------------------------------------| ## Blancco Drive Erasure
| 5791bd1 | Initial project setup: automated PXE server provisioning |
| cee4ecd | Add web management UI, offline packages, WinPE consolidation | Blancco Drive Eraser 7.15.1 boots via a native Ubuntu kernel with a custom initramfs (`blancco-init.sh`) that downloads and mounts the Blancco rootfs over HTTP. XML erasure reports are automatically saved to the PXE server's Samba share (`blancco-reports`). The server supports BMC cloud licensing for Blancco activation over WiFi.
| f614596 | Fix unattend.xml path to match actual image structure |
| e7313c2 | Add multi-boot PXE menu, Clonezilla backups, GE Aerospace branding| Reports are viewable and downloadable from the web interface at `http://172.16.9.1:9009/reports`.
| 89b5834 | Add wimtools and startnet.cmd editor for boot.wim modification |
| 05dbb7e | Add Blancco erasure reports Samba share and webapp viewer | ## Notes
| ef75839 | Auto-patch Blancco config.img for network report storage |
| 92c9b0f | Fix review findings: offline assets, security, audit logging | - Run `download-packages.sh` before building USB — it downloads all offline `.deb` packages including wimtools (needed for startnet.cmd editing)
| 725c8f4 | Change webapp to port 9009, add test VM script | - The webapp uses session-based CSRF tokens on all POST forms and API endpoints
| f3a384f | Add Proxmox ISO builder, CSRF protection, boot-files integration |
| 1a5c4f7 | Eliminate USB requirement for WinPE PXE boot | ## Commit History
| dd2fec5 | Blancco PXE boot via Ubuntu kernel switch_root |
| 86660a0 | Remove UEFI HTTP Boot config from dnsmasq | | Commit | Description |
| 6d0e6ee | BIOS check fix, parallel downloads, shopfloor hardening | |---------|--------------------------------------------------------------------|
| 7616549 | Shopfloor PC type system, webapp enhancements | | 5791bd1 | Initial project setup: automated PXE server provisioning |
| b7cd097 | Blancco 7.15.1 upgrade: native kernel boot, BMC cloud licensing | | cee4ecd | Add web management UI, offline packages, WinPE consolidation |
| 163e58a | Fix dnsmasq reboot cron: use /etc/cron.d/ instead of crontab | | f614596 | Fix unattend.xml path to match actual image structure |
| e7313c2 | Add multi-boot PXE menu, Clonezilla backups, GE Aerospace branding|
| 89b5834 | Add wimtools and startnet.cmd editor for boot.wim modification |
| 05dbb7e | Add Blancco erasure reports Samba share and webapp viewer |
| ef75839 | Auto-patch Blancco config.img for network report storage |
| 92c9b0f | Fix review findings: offline assets, security, audit logging |
| 725c8f4 | Change webapp to port 9009, add test VM script |
| f3a384f | Add Proxmox ISO builder, CSRF protection, boot-files integration |
| 1a5c4f7 | Eliminate USB requirement for WinPE PXE boot |
| dd2fec5 | Blancco PXE boot via Ubuntu kernel switch_root |
| 86660a0 | Remove UEFI HTTP Boot config from dnsmasq |
| 6d0e6ee | BIOS check fix, parallel downloads, shopfloor hardening |
| 7616549 | Shopfloor PC type system, webapp enhancements |
| b7cd097 | Blancco 7.15.1 upgrade: native kernel boot, BMC cloud licensing |
| 163e58a | Fix dnsmasq reboot cron: use /etc/cron.d/ instead of crontab |

551
SETUP.md
View File

@@ -1,317 +1,234 @@
# PXE Server Setup Guide # PXE Server Setup Guide
Automated build process for deploying an Ubuntu-based PXE boot server that hosts GE Aerospace Windows PE images. The entire setup is air-gapped — no internet required on the target machine. Automated build process for deploying an Ubuntu-based PXE boot server that hosts GE Aerospace Windows PE images. The entire setup is air-gapped — no internet required on the target machine.
## Architecture ## Architecture
``` ```
Client PXE boot Client PXE boot
-> Broadcom signed iPXE (Secure Boot) -> Broadcom signed iPXE (Secure Boot)
-> iPXE boot menu (HTTP, port 4433) -> iPXE boot menu (HTTP, port 4433)
├── Windows PE -> wimboot -> boot.wim -> startnet.cmd -> Samba -> Image deployment ├── Windows PE -> wimboot -> boot.wim -> startnet.cmd -> Samba -> Image deployment
├── Clonezilla -> vmlinuz/initrd -> Disk cloning/imaging ├── Clonezilla -> vmlinuz/initrd -> Disk cloning/imaging
├── Blancco -> Native kernel + initramfs -> NIST 800-88 drive erasure (auto-reports) ├── Blancco -> Native kernel + initramfs -> NIST 800-88 drive erasure (auto-reports)
(memory test: Dell F12 > Diagnostics) └── Memtest86+ -> Memory diagnostics
``` ```
### Services on the PXE Server ### Services on the PXE Server
| Service | Port | Purpose | | Service | Port | Purpose |
|-------------|-----------|------------------------------------------| |-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (172.16.9.10-100) | | dnsmasq | 67/udp | DHCP (172.16.9.10-100) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) | | dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) | | Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) | | Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
| Samba | 445/tcp | Deployment content + backup + reports | | Samba | 445/tcp | Deployment content + backup + reports |
| Flask Webapp| 9009/tcp | Web management interface | | Flask Webapp| 9009/tcp | Web management interface |
## Prerequisites ## Prerequisites
### Hardware ### Hardware
- Server or PC with >= 8 GB RAM, >= 250 GB disk, one wired NIC - Server or PC with >= 8 GB RAM, >= 250 GB disk, one wired NIC
- USB thumb drive >= 8 GB (32+ GB if bundling WinPE images) - USB thumb drive >= 8 GB (32+ GB if bundling WinPE images)
### Software (on your workstation) ### Software (on your workstation)
- Ubuntu Server 24.04 ISO — https://ubuntu.com/download/server - Ubuntu Server 24.04 ISO — https://ubuntu.com/download/server
- Linux workstation (Ubuntu/Mint) for running download and build scripts - Linux workstation (Ubuntu/Mint) for running download and build scripts
- GE Aerospace Media Creator LITE (for caching WinPE images) - GE Aerospace Media Creator LITE (for caching WinPE images)
### GE Access Packages ### GE Access Packages
- EPM Rufus Exception Request - EPM Rufus Exception Request
- EPM DT Functions - EPM DT Functions
- DLP - Encrypted Removable (USB) Long Term Access - DLP - Encrypted Removable (USB) Long Term Access
## Setup Process ## Setup Process
### Step 1: Download Offline Packages (one-time, requires internet) ### Step 1: Download Offline Packages (one-time, requires internet)
```bash ```bash
./scripts/download-packages.sh --iso /path/to/ubuntu-24.04.x-live-server-amd64.iso ./scripts/download-packages.sh
./scripts/check-updates.py # expect: PASSED, 0 security updates ```
./scripts/fetch-ipxe.sh
``` Downloads all .deb packages (ansible, dnsmasq, apache2, samba, wimtools, etc.) into `offline-packages/` and Python wheels (flask, lxml) into `pip-wheels/`. Approximately 252 packages (~140 MB) + 8 Python wheels.
`fetch-ipxe.sh` downloads the Secure Boot iPXE chain into `boot-files/`, pinned by sha256: the iPXE release **Packages included:**
tarball (its snponly build becomes `ipxe.efi`) and iPXE's dual-signed shim (Microsoft UEFI CA 2011 + 2023), - **Server:** dnsmasq, apache2, samba, ufw, cron
which every UEFI client boots as `ipxe-shim.efi`. Keep the old Broadcom-signed `boot-files/ipxe.efi` too: the - **Automation:** ansible
playbook installs it as `ipxe-bcm.efi`, the rollback boot file. - **Tools:** wimtools, unzip, p7zip-full
- **Python:** python3-pip, python3-venv
`download-packages.sh` needs docker (no sudo) and the SAME server ISO that - **Network:** network-manager, wpasupplicant, wireless-tools, linux-firmware
`build-usb.sh` will use. The install is offline (autoinstall uses a dead
mirror on purpose), so the server's base is exactly that ISO's; the script ### Step 2: Prepare Boot Tools (optional)
unpacks the ISO's install image into a docker image and resolves there:
every update of an installed package, the explicit package list ```bash
(ansible, dnsmasq, apache2, samba, wimtools, zip, the HWE kernel, ...) and ./scripts/prepare-boot-tools.sh /path/to/blancco.iso /path/to/clonezilla.zip /path/to/memtest.bin
the newest version of what the installer adds from the ISO's pool (kernel, ```
grub, openssh-server). `offline-packages/` becomes an apt repository
(debs + `Packages` + `explicit.list`), checked offline in a fresh container Extracts boot files for Blancco, Clonezilla, and Memtest86+ into the `boot-tools/` directory. Automatically patches Blancco's `config.img` to auto-save erasure reports to the PXE server's Samba share.
of the base before the script finishes. On the server, autoinstall's
late-commands run `scripts/offline-apt-install.sh` on it: one apt ### Step 3: Build the USB
transaction from the local repo only, any error fails the install (log:
`/var/log/offline-apt-install.log`). The webapp's pinned, hashed wheels go ```bash
to `pip-wheels/`. About 500 debs (~1.6 GB) + 8 wheels. # Basic — server only (import WinPE images later)
sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso
**Packages included:**
- **Server:** dnsmasq, apache2, samba, ufw, cron # With WinPE images bundled (single USB, larger drive needed)
- **Automation:** ansible sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04.iso /path/to/winpe-images
- **Tools:** wimtools, unzip, p7zip-full ```
- **Python:** python3-pip, python3-venv
- **Network:** network-manager, wpasupplicant, wireless-tools, linux-firmware This creates a bootable USB with:
- **Partition 1:** Ubuntu Server installer
### Step 2: Prepare Boot Tools (optional) - **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools)
```bash ### Step 4: Boot the Target Machine
./scripts/prepare-boot-tools.sh /path/to/blancco.iso
``` 1. Insert the USB into the target machine
2. Press F12 (or vendor boot key) and select the USB
Extracts boot files for Blancco and Clonezilla (64-bit with Debian's signed shim, so it boots with Secure Boot on, plus the last 32-bit release for XP-era PCs) into the `boot-tools/` directory. Automatically patches Blancco's `config.img` to auto-save erasure reports to the PXE server's Samba share. 3. Ubuntu auto-installs — no interaction needed
4. After reboot, the first-boot script installs all .deb packages and runs the Ansible playbook
### Step 2b: Capture site content from a running server 5. PXE services (dnsmasq, Apache, Samba) are configured automatically
6. Flask webapp starts on port 9009
```bash
./scripts/capture-site-content.sh # dry run: lists what would change ### Step 5: Connect to Isolated Network
./scripts/capture-site-content.sh --apply # pull it
``` Move the server's wired NIC to the isolated switch for PXE clients.
Pulls what neither the repo nor the MCL image import brings, from a working PXE server (default 172.16.9.1, SSH key login as `pxe`): `_shared/Applications/extra` (Office, Adobe, OpenText, Java, fonts, Wi-Fi, Zscaler), the GE CA certificates and the Engineering Layer payload in each image's `Deploy/Applications`, and the links images keep there, into `site-content/`. It also refreshes `bios-staging/` (BIOS files and `models.txt`), `boot-files/boot.wim` and the Blancco files in `boot-tools/blancco/` from the server. It writes `site-content/SHA256SUMS`, which the playbook checks before restoring anything, and refuses content a FAT32 USB cannot hold. Without it a new server cannot build Standard or Engineer (`build-usb.sh` refuses unless you pass `--skip-site-content`). ### Step 6: Import WinPE Content (if not bundled in Step 3)
The first MCL image import on the new server then seeds `_shared/Applications/BPRT`, adding our overlay scripts from the repo. **Option A:** Use the web interface at `http://172.16.9.1:9009` to import from USB.
### Step 3: Build the USB **Option B:** Manual copy:
```bash
```bash sudo mkdir -p /mnt/usb2
# Basic: server only (import WinPE images later). site-vars.yml holds the sudo mount /dev/sdb2 /mnt/usb2
# two NIC MACs (copy playbook/site-vars.example.yml); --site-vars or sudo cp -r /mnt/usb2/. /srv/samba/winpeapps/gea-standard
# --no-site-vars is required. sudo umount /mnt/usb2
sudo ./scripts/build-usb.sh --site-vars site-vars.yml /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso ```
# With WinPE images bundled (single USB, larger drive needed; no file may ## Web Management Interface
# be 4 GiB or more, FAT32)
sudo ./scripts/build-usb.sh --site-vars site-vars.yml /dev/sdX /path/to/ubuntu-24.04.iso /path/to/winpe-images Access at `http://172.16.9.1:9009` from any machine on the isolated network.
# Same layout into a disk-image file, no root (dd it to a stick later, or | Page | URL Path | Purpose |
# boot it in a VM: see tests/vm/README.md; on Proxmox: qm importdisk). |-------------------|-------------|-----------------------------------------------|
# SIZE is e.g. 16G or "auto". | Dashboard | / | Service status, disk usage, DHCP clients |
./scripts/build-usb.sh --site-vars site-vars.yml --image pxe-usb.img auto /path/to/ubuntu-24.04.iso | Image Import | /import | Import WinPE images from USB drives |
``` | Unattend Editor | /unattend | Edit Windows unattend.xml per image type |
| startnet.cmd | /startnet | Edit startnet.cmd inside boot.wim (wimtools) |
Options: `--payload-root DIR` takes offline-packages/, pip-wheels/, | Clonezilla Backups| /backups | Upload/download/manage disk backup images |
boot-files/, boot-tools/, enrollment/, bios-staging/ and drivers-staging/ | Blancco Reports | /reports | View/download drive erasure reports |
from another checkout; `--skip-enrollment`, `--skip-bios`, | Image Config | /images/*/config | Per-image driver, OS, and hardware config |
`--skip-drivers`, `--skip-boot-tools` leave those out; `--yes` skips the | Enrollment | /enrollment | GCCH bulk enrollment package management |
prompt. The build refuses payloads that do not match their pins: the iPXE | Audit Log | /audit | Activity history for all write operations |
files against the playbook's `ipxe_*_sha256`, each wheel against its own
package's hashes in `webapp/requirements.txt` (no PyPI fallback), and the ## Verification
ISO against the volume id `offline-packages/iso-volid` was resolved for. The ISO volume label and El Torito load sizes are read from the
ISO, so any 24.04.x point release works. 1. Connect a test workstation to the isolated switch
2. Set Network Boot (PXE) as first boot in BIOS/UEFI
This creates a bootable USB with: 3. Boot — the client should pull an IP from 10.9.100.x
- **Partition 1:** Ubuntu Server installer 4. iPXE loads, fetches the boot script from port 4433
- **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools) 5. Select an option from the boot menu:
- **Windows PE**: Boots via wimboot + boot.wim, maps Samba shares, begins deployment
#### Building the USB on a Windows PC - **Clonezilla**: Boots Clonezilla Live for disk imaging
- **Blancco**: Boots Drive Eraser, auto-saves reports to server
`scripts/Build-PxeUsb.ps1` builds the same stick on Windows, with the same - **Memtest86+**: Runs memory diagnostics
checks, no Linux tools needed. It writes ONE FAT32 partition labelled
CIDATA holding the Ubuntu ISO's files (boot menu patched for autoinstall) ## Testing with KVM
and the payload; it boots in UEFI mode only (Secure Boot works) and is at
most 32 GB (the FAT32 size Windows formats). Every file is read back and ```bash
compared by SHA-256. # Download Ubuntu ISO
wget -O ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso \
On the build PC, put the repo and payload on the share: https://releases.ubuntu.com/noble/ubuntu-24.04.3-live-server-amd64.iso
```bash # Launch test VM
./scripts/stage-usb-share.sh # -> ~/pxe-images/pxe-usb (hard links) sudo ./test-vm.sh ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso
```
# Watch progress (Ctrl+] to detach)
On the Windows PC, in an elevated PowerShell (Z: = the pxe-images share): sudo virsh console pxe-test
```powershell # After install: ssh pxe@172.16.9.1 / http://172.16.9.1:9009
Z:\pxe-usb\scripts\Build-PxeUsb.ps1 # lists USB disks
Z:\pxe-usb\scripts\Build-PxeUsb.ps1 -DiskNumber 3 -NoSiteVars # or -SiteVars FILE # Clean up
``` sudo ./test-vm.sh --destroy
```
It refuses a disk that is not on the USB bus or is the boot/system disk,
and asks you to type the disk number before erasing it. ## Project Structure
### Step 4: Boot the Target Machine ```
pxe-server/
1. Insert the USB into the target machine ├── autoinstall/
2. Press F12 (or vendor boot key) and select the USB │ ├── user-data # Cloud-init autoinstall + first-boot script
3. Ubuntu auto-installs — no interaction needed │ └── meta-data # Cloud-init metadata (required, empty)
4. After reboot, the first-boot script runs the Ansible playbook (the .debs ├── playbook/
were installed by the installer); it pins the two NICs by MAC │ ├── pxe_server_setup.yml # Ansible: dnsmasq, Apache, Samba, iPXE, UFW, webapp
5. PXE services (dnsmasq, Apache, Samba) are configured automatically │ ├── inventory.ini # Ansible inventory
6. Flask webapp starts on port 9009 │ ├── startnet.cmd # WinPE startup script (injected into boot.wim)
7. If the playbook fails, `/var/lib/pxe-firstboot.failed` and a banner on │ ├── blancco-init.sh # Custom initramfs for Blancco PXE boot
the console and at SSH login point at `/var/log/first-boot.log`; first │ ├── blancco-preferences.xml # Blancco auto-report preferences
boot retries at the next reboot (5 attempts) │ ├── check-bios.cmd # Pre-imaging BIOS update checker
│ ├── FlatUnattendW10-shopfloor.xml # Shopfloor unattend.xml
### Step 5: Connect to Isolated Network │ └── shopfloor-setup/ # PC type setup scripts (Standard, CMM, Display, etc.)
├── webapp/
During the install plug in ONLY the imaging switch (GE unplugged). With │ ├── app.py # Flask application (~1600 lines)
`pxe_nic_mac` empty in site-vars, the first boot picks the one wired port │ ├── requirements.txt # Python deps (flask, lxml)
with a link, checks it is isolated (no GE switch, no 802.1X, no other DHCP │ ├── static/ # CSS, JS, fonts, logo (all bundled offline)
server) and pins it. Then plug the GE cable into any other port: it becomes │ └── templates/ # Jinja2 HTML templates (10 pages)
a DHCP client for SSH and the webapp, and dnsmasq never serves it. If the ├── docs/
first boot cannot tell which port is the imaging one, the console says so: │ └── shopfloor-display-imaging-guide.md # End-user imaging guide
log in and run `sudo pxe-pick-nic`, pull the imaging cable and plug it back ├── boot-tools/ # Extracted boot files (gitignored, built by prepare-boot-tools.sh)
in. Sites that prefer to pin both ports by MAC set `pxe_nic_mac` and │ ├── blancco/ # Blancco Drive Eraser
`corp_nic_mac` instead; both cables can then be in from the start. │ ├── clonezilla/ # Clonezilla Live
│ └── memtest/ # Memtest86+
### Step 6: Import WinPE Content (if not bundled in Step 3) ├── boot-files/ # WinPE boot files (boot.wim, wimboot, ipxe.efi, BCD)
├── offline-packages/ # .deb files (gitignored, built by download-packages.sh)
**Option A:** Use the web interface at `http://172.16.9.1:9009` to import from USB. ├── pip-wheels/ # Python wheels (gitignored, built by download-packages.sh)
├── enrollment/ # PPKGs and run-enrollment.ps1 (gitignored)
**Option B:** Manual copy: ├── bios-staging/ # Dell BIOS update binaries (gitignored)
```bash ├── scripts/ # Build, deploy, and helper scripts
sudo mkdir -p /mnt/usb2 │ ├── build-usb.sh # Builds the 2-partition installer USB
sudo mount /dev/sdb2 /mnt/usb2 │ ├── build-proxmox-iso.sh # Builds self-contained Proxmox installer ISO
sudo cp -r /mnt/usb2/. /srv/samba/winpeapps/gea-standard │ ├── prepare-boot-tools.sh # Extracts/patches boot tools from ISOs
sudo umount /mnt/usb2 │ ├── download-packages.sh # Downloads all offline packages
``` │ ├── download-drivers.py # Downloads Dell drivers from dell.com
│ ├── deploy-bios.sh # Pushes BIOS updates to enrollment share
## Web Management Interface │ ├── pull-bios.sh # Pulls BIOS binaries from upstream cache
│ ├── sync_hardware_models.py # Syncs hardware model configs across images
Access at `http://172.16.9.1:9009` from any machine on the isolated network. │ ├── Upload-Image.ps1 # Windows: upload MCL cache to PXE via SMB
│ └── Download-Drivers.ps1 # Windows: download hardware drivers from GE CDN
| Page | URL Path | Purpose | ├── config/ # Site-specific configuration overrides
|-------------------|-------------|-----------------------------------------------| ├── startnet-template.cmd # startnet.cmd template (synced with playbook copy)
| Dashboard | / | Service status, disk usage, DHCP clients | ├── README.md # Project overview
| Image Import | /import | Import WinPE images from USB drives | └── SETUP.md # Detailed setup guide (this file)
| Unattend Editor | /unattend | Edit Windows unattend.xml per image type | ```
| startnet.cmd | /startnet | Edit startnet.cmd inside boot.wim (wimtools) |
| Clonezilla Backups| /backups | Upload/download/manage disk backup images | ## Image Types
| Blancco Reports | /reports | View/download drive erasure reports |
| Image Config | /images/*/config | Per-image driver, OS, and hardware config | | Image Type | Domain | Description |
| Enrollment | /enrollment | GCCH bulk enrollment package management | |----------------------|-----------------|-------------------------|
| Audit Log | /audit | Activity history for all write operations | | gea-standard | geaerospace.com | Standard desktop |
| gea-engineer | geaerospace.com | Engineering desktop |
## Verification | gea-shopfloor | geaerospace.com | Shop floor kiosk |
| ge-standard | ge.com | Standard desktop |
1. Connect a test workstation to the isolated switch | ge-engineer | ge.com | Engineering desktop |
2. Set Network Boot (PXE) as first boot in BIOS/UEFI | ge-shopfloor-lockdown| ge.com | Shop floor (locked) |
3. Boot — the client should pull an IP from 10.9.100.x | ge-shopfloor-mce | ge.com | Shop floor (MCE) |
4. iPXE loads, fetches the boot script from port 4433
5. Select an option from the boot menu: ## Network Configuration
- **Windows PE**: Boots via wimboot + boot.wim, maps Samba shares, begins deployment
- **Clonezilla**: Boots Clonezilla Live for disk imaging - PXE server static IP: `172.16.9.1/24`
- **Blancco**: Boots Drive Eraser, auto-saves reports to server - DHCP range: `172.16.9.10` - `172.16.9.100`
- **Memory test**: not on the menu; Dell PCs have one built in (F12 at power-on, Diagnostics) - Lease time: 12 hours
- DNS: `8.8.8.8` (passed to clients, not used by server)
## Testing with KVM - Firewall: UFW deny-by-default, allow 67/udp 69/udp 80/tcp 445/tcp 4433/tcp 9009/tcp
```bash ## Samba Shares
# Download Ubuntu ISO
wget -O ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso \ | Share | Path | Purpose |
https://releases.ubuntu.com/noble/ubuntu-24.04.3-live-server-amd64.iso |-----------------|----------------------------|-------------------------------|
| winpeapps | /srv/samba/winpeapps | WinPE deployment images |
# Launch test VM | clonezilla | /srv/samba/clonezilla | Clonezilla disk backup images |
sudo ./test-vm.sh ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso | blancco-reports | /srv/samba/blancco-reports | Blancco erasure reports (auto)|
| enrollment | /srv/samba/enrollment | GCCH bulk enrollment packages |
# Watch progress (Ctrl+] to detach) | image-upload | /home/pxe/image-upload | Image upload staging area |
sudo virsh console pxe-test
All shares use guest access for the isolated network.
# After install: ssh pxe@172.16.9.1 / http://172.16.9.1:9009
# Clean up
sudo ./test-vm.sh --destroy
```
## Project Structure
```
pxe-server/
├── autoinstall/
│ ├── user-data # Cloud-init autoinstall + first-boot script
│ └── meta-data # Cloud-init metadata (required, empty)
├── playbook/
│ ├── pxe_server_setup.yml # Ansible: dnsmasq, Apache, Samba, iPXE, UFW, webapp
│ ├── inventory.ini # Ansible inventory
│ ├── startnet.cmd # WinPE startup script (injected into boot.wim)
│ ├── blancco-init.sh # Custom initramfs for Blancco PXE boot
│ ├── blancco-preferences.xml # Blancco auto-report preferences
│ ├── check-bios.cmd # Pre-imaging BIOS update checker
│ ├── FlatUnattendW10-shopfloor.xml # Shopfloor unattend.xml
│ └── shopfloor-setup/ # PC type setup scripts (Standard, CMM, Display, etc.)
├── webapp/
│ ├── app.py # Flask application (~1600 lines)
│ ├── requirements.txt # Python deps (flask, lxml)
│ ├── static/ # CSS, JS, fonts, logo (all bundled offline)
│ └── templates/ # Jinja2 HTML templates (10 pages)
├── docs/
│ └── shopfloor-display-imaging-guide.md # End-user imaging guide
├── boot-tools/ # Extracted boot files (gitignored, built by prepare-boot-tools.sh)
│ ├── blancco/ # Blancco Drive Eraser
│ └── clonezilla/ # Clonezilla Live
├── boot-files/ # WinPE boot files (boot.wim, wimboot, ipxe.efi, BCD)
├── offline-packages/ # .deb files (gitignored, built by download-packages.sh)
├── pip-wheels/ # Python wheels (gitignored, built by download-packages.sh)
├── enrollment/ # PPKGs and run-enrollment.ps1 (gitignored)
├── bios-staging/ # Dell BIOS update binaries (gitignored)
├── scripts/ # Build, deploy, and helper scripts
│ ├── build-usb.sh # Builds the 2-partition installer USB
│ ├── Build-PxeUsb.ps1 # Windows: builds the installer USB (one FAT32 partition)
│ ├── stage-usb-share.sh # Puts repo + payload on the pxe-images share for it
│ ├── prepare-boot-tools.sh # Extracts/patches boot tools from ISOs
│ ├── download-packages.sh # Downloads all offline packages
│ ├── download-drivers.py # Downloads Dell drivers from dell.com
│ ├── capture-site-content.sh # Pulls site content + fresh payload from a live server
│ ├── Upload-Image.ps1 # Windows: upload MCL cache to PXE via SMB
│ └── Download-Drivers.ps1 # Windows: download hardware drivers from GE CDN
├── config/ # Site-specific configuration overrides
├── README.md # Project overview
└── SETUP.md # Detailed setup guide (this file)
```
## Image Types
| Image Type | Domain | Description |
|----------------------|-----------------|-------------------------|
| gea-standard | geaerospace.com | Standard desktop |
| gea-engineer | geaerospace.com | Engineering desktop |
| gea-shopfloor | geaerospace.com | Shop floor kiosk |
| ge-standard | ge.com | Standard desktop |
| ge-engineer | ge.com | Engineering desktop |
| ge-shopfloor-lockdown| ge.com | Shop floor (locked) |
| ge-shopfloor-mce | ge.com | Shop floor (MCE) |
## Network Configuration
- PXE server static IP: `172.16.9.1/24`
- DHCP range: `172.16.9.10` - `172.16.9.100`
- Lease time: 12 hours
- DNS: `8.8.8.8` (passed to clients, not used by server)
- Firewall: UFW deny-by-default, allow 67/udp 69/udp 80/tcp 445/tcp 4433/tcp 9009/tcp
## Samba Shares
| Share | Path | Purpose |
|-----------------|----------------------------|-------------------------------|
| winpeapps | /srv/samba/winpeapps | WinPE deployment images |
| clonezilla | /srv/samba/clonezilla | Clonezilla disk backup images |
| blancco-reports | /srv/samba/blancco-reports | Blancco erasure reports (auto)|
| enrollment | /srv/samba/enrollment | GCCH bulk enrollment packages |
| image-upload | /home/pxe/image-upload | Image upload staging area |
All shares use guest access for the isolated network.

View File

@@ -9,40 +9,19 @@ autoinstall:
variant: "" variant: ""
timezone: America/New_York timezone: America/New_York
# Network: DHCP only, on every wired NIC. On the standard two-NIC box the # Network: static IP for isolated PXE LAN (no internet/DHCP needed)
# GE corporate NIC gets an address and the imaging NIC (no DHCP server on
# the imaging LAN) stays without one. No static 172.16.9.1 here: matched
# by name it would land on EVERY NIC, the GE one included, and the box
# would answer as 172.16.9.1 on the corporate network. First boot's
# playbook replaces this file with the imaging NIC pinned by MAC
# (pxe_nic_mac in playbook/site-vars.yml, or picked by its link when
# empty; corp_nic_mac optional). optional: the
# install and boot never wait for a lease (air-gapped is fine).
network: network:
version: 2 version: 2
ethernets: ethernets:
any-eth: any-eth:
match: match:
name: "en*" name: "en*"
dhcp4: true addresses:
- 172.16.9.1/24
dhcp4: false
dhcp6: false dhcp6: false
optional: true optional: true
# The install is always offline, even when the corp NIC has a lease and
# internet: the mirror points at a closed local port, so subiquity's
# mirror check fails and it installs from the ISO alone (no "security
# updates" step from the archive). The base is then exactly the ISO's,
# which is what download-packages.sh resolved the offline set against;
# late-commands patch it from CIDATA. The real archive sources are
# written back at the end of late-commands.
apt:
geoip: false
preserve_sources_list: false
primary:
- arches: [default]
uri: "http://127.0.0.1:9/ubuntu"
fallback: offline-install
# Storage configuration # Storage configuration
storage: storage:
layout: layout:
@@ -65,63 +44,34 @@ autoinstall:
# Installer-stage late commands # Installer-stage late commands
late-commands: late-commands:
# Step 1: Upgrade and install from the offline set on CIDATA # Step 1: Install deb packages from CIDATA USB
# (packages/ is an apt repo: debs + Packages + explicit.list). Any apt
# error fails the install; the log is /var/log/offline-apt-install.log.
- | - |
curtin in-target --target=/target -- bash -euc ' curtin in-target --target=/target -- bash -c '
mkdir -p /mnt/cidata mkdir -p /mnt/cidata
CIDATA_DEV=$(blkid -L CIDATA) CIDATA_DEV=$(blkid -L CIDATA)
mount -o ro "$CIDATA_DEV" /mnt/cidata if [ -n "$CIDATA_DEV" ]; then
set +e mount "$CIDATA_DEV" /mnt/cidata
bash /mnt/cidata/offline-apt-install.sh /mnt/cidata/packages 2>&1 | tee /var/log/offline-apt-install.log if compgen -G "/mnt/cidata/packages/*.deb" > /dev/null; then
rc=${PIPESTATUS[0]} cp /mnt/cidata/packages/*.deb /tmp/
set -e dpkg -i /tmp/*.deb 2>/dev/null || true
umount /mnt/cidata dpkg -i /tmp/*.deb 2>/dev/null || true
if [ "$rc" -ne 0 ]; then dpkg --configure -a 2>/dev/null || true
echo "offline package install FAILED (rc=$rc), see /var/log/offline-apt-install.log" >&2 if command -v nmcli >/dev/null; then
exit "$rc" systemctl enable NetworkManager
fi fi
if command -v nmcli >/dev/null; then fi
systemctl enable NetworkManager umount /mnt/cidata
fi fi
' '
# Step 2: Disable networkd-wait-online (hangs on air-gapped network) # Step 2: Disable networkd-wait-online (hangs on air-gapped network)
- ln -sf /dev/null /target/etc/systemd/system/systemd-networkd-wait-online.service - ln -sf /dev/null /target/etc/systemd/system/systemd-networkd-wait-online.service
# Step 3: Create first-boot.sh. rc.local runs it on every boot until the # Step 3: Create first-boot.sh
# playbook succeeds (at most MAX attempts). A failure leaves
# /var/lib/pxe-firstboot.failed and a banner on the console (/etc/issue)
# and at SSH login (/etc/motd) pointing at /var/log/first-boot.log.
- | - |
curtin in-target --target=/target -- bash -c ' curtin in-target --target=/target -- bash -c '
cat <<"EOF" > /opt/first-boot.sh cat <<"EOF" > /opt/first-boot.sh
#!/bin/bash #!/bin/bash
MAX=5
STATE=/var/lib/pxe-firstboot
FAILED=/var/lib/pxe-firstboot.failed
mkdir -p "$STATE"
N=$(( $(cat "$STATE/attempts" 2>/dev/null || echo 0) + 1 ))
echo "$N" > "$STATE/attempts"
echo "=== first-boot attempt $N of $MAX, $(date -Is) ==="
banner_off() {
for f in /etc/issue /etc/motd; do
[ -f "$f" ] && sed -i "/^### PXE FIRST-BOOT BEGIN/,/^### PXE FIRST-BOOT END/d" "$f"
done
}
banner_on() {
banner_off
for f in /etc/issue /etc/motd; do
{
echo "### PXE FIRST-BOOT BEGIN"
echo "!!! PXE server setup (first-boot playbook) FAILED: $1"
echo "!!! Log: /var/log/first-boot.log Marker: $FAILED"
echo "!!! $2"
echo "### PXE FIRST-BOOT END"
} >> "$f"
done
}
# Expand root LV to full disk BEFORE playbook (playbook copies ~130GB of drivers+PPKGs) # Expand root LV to full disk BEFORE playbook (playbook copies ~130GB of drivers+PPKGs)
ROOT_DEV=$(findmnt -n -o SOURCE /) ROOT_DEV=$(findmnt -n -o SOURCE /)
ROOT_DISK=$(lsblk -n -o PKNAME "$(readlink -f "$ROOT_DEV")" | tail -1) ROOT_DISK=$(lsblk -n -o PKNAME "$(readlink -f "$ROOT_DEV")" | tail -1)
@@ -132,79 +82,37 @@ autoinstall:
pvresize "$PV_PART" 2>&1 || true pvresize "$PV_PART" 2>&1 || true
fi fi
lvextend -r -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv 2>&1 || true lvextend -r -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv 2>&1 || true
RC=1
WHY="CIDATA (the installer USB) not found"
CIDATA_DEV=$(blkid -L CIDATA) CIDATA_DEV=$(blkid -L CIDATA)
if [ -n "$CIDATA_DEV" ]; then if [ -n "$CIDATA_DEV" ]; then
mkdir -p /mnt/usb mkdir -p /mnt/usb
if mount "$CIDATA_DEV" /mnt/usb; then mount "$CIDATA_DEV" /mnt/usb
if [ -f /mnt/usb/playbook/pxe_server_setup.yml ]; then if compgen -G "/mnt/usb/packages/*.deb" > /dev/null; then
cd /mnt/usb/playbook dpkg -i /mnt/usb/packages/*.deb 2>/dev/null || true
rm -f "$STATE/nic-hint" dpkg -i /mnt/usb/packages/*.deb 2>/dev/null || true
ansible-playbook -i localhost, -c local pxe_server_setup.yml dpkg --configure -a 2>/dev/null || true
RC=$?
WHY="ansible-playbook exit code $RC"
# pxe-pick-nic leaves the reason it could not pick the imaging NIC
[ "$RC" -ne 0 ] && [ -s "$STATE/nic-hint" ] && WHY="$(head -1 "$STATE/nic-hint")"
cd /
else
WHY="no playbook/pxe_server_setup.yml on CIDATA"
fi
umount /mnt/usb
else
WHY="could not mount CIDATA ($CIDATA_DEV)"
fi fi
if [ -f /mnt/usb/playbook/pxe_server_setup.yml ]; then
cd /mnt/usb/playbook
ansible-playbook -i localhost, -c local pxe_server_setup.yml
fi
umount /mnt/usb
fi fi
if [ "$RC" -eq 0 ]; then sed -i "s|^/opt/first-boot.sh.*|# &|" /etc/rc.local
echo "=== first-boot: playbook succeeded on attempt $N ==="
rm -f "$FAILED"
banner_off
sed -i "s|^/opt/first-boot.sh.*|# &|" /etc/rc.local
exit 0
fi
echo "=== first-boot: FAILED ($WHY), attempt $N of $MAX ==="
printf "attempt=%s of %s\nwhen=%s\nreason=%s\nlog=/var/log/first-boot.log\n" "$N" "$MAX" "$(date -Is)" "$WHY" > "$FAILED"
if [ "$N" -ge "$MAX" ]; then
sed -i "s|^/opt/first-boot.sh.*|# &|" /etc/rc.local
banner_on "$WHY (attempt $N of $MAX)" "Gave up retrying. Fix it, then run: sudo /opt/first-boot.sh after rm $STATE/attempts"
else
banner_on "$WHY (attempt $N of $MAX)" "It runs again at the next boot (fix the cause first, e.g. playbook/site-vars.yml on the USB)."
fi
exit "$RC"
EOF EOF
chmod +x /opt/first-boot.sh chmod +x /opt/first-boot.sh
' '
# Step 4: Create rc.local (the log is appended to, one block per attempt) # Step 4: Create rc.local
- | - |
curtin in-target --target=/target -- bash -c ' curtin in-target --target=/target -- bash -c '
cat <<"EOF" > /etc/rc.local cat <<"EOF" > /etc/rc.local
#!/bin/bash #!/bin/bash
/opt/first-boot.sh >> /var/log/first-boot.log 2>&1 & /opt/first-boot.sh > /var/log/first-boot.log 2>&1 &
exit 0 exit 0
EOF EOF
chmod +x /etc/rc.local chmod +x /etc/rc.local
' '
# Step 5: Real archive sources for later online maintenance (the
# install used a dead mirror on purpose, see apt: above).
- |
rm -f /target/etc/apt/sources.list.d/*.list /target/etc/apt/sources.list.d/*.curtin.orig
echo "# Ubuntu sources have moved to /etc/apt/sources.list.d/ubuntu.sources" > /target/etc/apt/sources.list
cat > /target/etc/apt/sources.list.d/ubuntu.sources <<"EOF"
Types: deb
URIs: http://archive.ubuntu.com/ubuntu/
Suites: noble noble-updates noble-backports
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
Types: deb
URIs: http://security.ubuntu.com/ubuntu/
Suites: noble-security
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
user-data: user-data:
disable_root: false disable_root: false

View File

@@ -29,7 +29,7 @@ systems.
|---------------|-----------|------------------------------------------------------| |---------------|-----------|------------------------------------------------------|
| dnsmasq | 67/udp | DHCP. Hands out IPs and the next-server (TFTP) info. | | dnsmasq | 67/udp | DHCP. Hands out IPs and the next-server (TFTP) info. |
| dnsmasq | 69/udp | TFTP. Serves `ipxe.efi` to firmware PXE clients. | | dnsmasq | 69/udp | TFTP. Serves `ipxe.efi` to firmware PXE clients. |
| Apache | 80/tcp | HTTP. Serves wimboot, boot.wim, Blancco binaries, Clonezilla, and proxies the webapp on /. | | Apache | 80/tcp | HTTP. Serves wimboot, boot.wim, Blancco binaries, Clonezilla, memtest, and proxies the webapp on /. |
| Apache | 4433/tcp | HTTP. Serves `Altiris/iPXE/GetPxeScript.aspx` (the iPXE menu script). | | Apache | 4433/tcp | HTTP. Serves `Altiris/iPXE/GetPxeScript.aspx` (the iPXE menu script). |
| Samba | 445/tcp | SMB shares. WinPE mounts these to fetch image content during deployment. | | Samba | 445/tcp | SMB shares. WinPE mounts these to fetch image content during deployment. |
| Flask webapp | 9009/tcp | Browser UI for image import, unattend editing, log/report viewing. | | Flask webapp | 9009/tcp | Browser UI for image import, unattend editing, log/report viewing. |
@@ -112,23 +112,9 @@ WinPE; the Blancco and Clonezilla utilities require Secure Boot disabled.
c. Clonezilla Live: c. Clonezilla Live:
iPXE loads vmlinuz + initrd over HTTP, fetches filesystem.squashfs. iPXE loads vmlinuz + initrd over HTTP, fetches filesystem.squashfs.
With Secure Boot on, the Debian-signed kernel starts through Debian's
Microsoft-signed shim (clonezilla/shimx64.efi, iPXE shim command).
The menu checks the CPU (iPXE cpuid --ext 29): 64-bit CPUs get the
current release, 32-bit-only CPUs (XP-era Pentium 4/M, early Atom) get
clonezilla-i686/ (3.2.0-5, the last 32-bit Clonezilla) over legacy BIOS.
"Clonezilla: back up / restore this PC" ask for the machine number,
mount \\<server>\clonezilla as the clonezilla Samba user
(ocs_repository smb3://, password from clonezilla_smb_password) and
run clonezilla-scripts/pxe-cz.sh (ocs_prerun downloads it,
ocs_live_run runs it): images are <machine>-<YYYY-MM-DD-HHMM> with
pxe-meta.txt, checked after saving; restore lists that machine's
images and restores with -k1 -r (larger disks get proportionally
larger partitions). "Clonezilla: manual" is the interactive mode.
Memory testing is not on the menu: Dell PCs have a built-in test d. Memtest86+:
(F12 at power-on, Diagnostics). Memtest86+ was removed because it is iPXE loads memtest.efi.
unsigned and needed Secure Boot off.
``` ```
## Windows imaging lifecycle (gea-* images) ## Windows imaging lifecycle (gea-* images)
@@ -193,7 +179,9 @@ PXE clients (UEFI -> iPXE -> Blancco) -+--> Drive erasure + report upload
## Where things live ## Where things live
- Ansible playbook: `playbook/pxe_server_setup.yml`. Single file, idempotent. - Ansible playbook: `playbook/pxe_server_setup.yml`. Single file, idempotent.
- WinPE startup script: `playbook/startnet.cmd`. The playbook injects this into `Windows/System32/startnet.cmd` inside the - WinPE startup script (template): `playbook/startnet.cmd` (canonical) and
`startnet-template.cmd` (mirror copy at repo root for editor convenience).
The playbook injects this into `Windows/System32/startnet.cmd` inside the
`boot.wim` it deploys to `/var/www/html/win11/sources/`. `boot.wim` it deploys to `/var/www/html/win11/sources/`.
- iPXE menu script source: embedded in the playbook (`Create GetPxeScript.aspx` - iPXE menu script source: embedded in the playbook (`Create GetPxeScript.aspx`
task); deployed to `/var/www/html/Altiris/iPXE/GetPxeScript.aspx`. task); deployed to `/var/www/html/Altiris/iPXE/GetPxeScript.aspx`.

View File

@@ -20,67 +20,13 @@ contribute a `config/sites/<sitename>.yaml` template back to the repo.
### Network ### Network
The imaging NIC (isolated imaging LAN, static 172.16.9.1, the only NIC that
serves DHCP/TFTP/HTTP boot/SMB) is pinned by MAC. The GE corporate side (DHCP
client; SSH and the webapp on 9009 only) is optional. Both MACs in
`playbook/site-vars.yml` (copy `playbook/site-vars.example.yml`) may be left
empty:
- `pxe_nic_mac` empty: the first boot picks the imaging NIC itself
(`pxe-pick-nic --auto`). It takes the one wired port with a link, and only
if `pxe-nic-check` calls it isolated, then pins it; the choice is in
`/etc/pxe-server/nic-pick.conf` and is never re-guessed at boot. So during
the install plug in ONLY the imaging switch. When it cannot tell (no link,
two links, or that port sees GE) it changes nothing and the console says
to run `sudo pxe-pick-nic`: pull the imaging cable and plug it back in,
and the port that blinked is the imaging one. Run it again after swapping
a dead USB NIC, then re-run the playbook.
- `corp_nic_mac` empty: every other wired port (`en*`, `eth*`) is a DHCP
client, so the GE cable can go into any of them, at install or later.
dnsmasq is pinned to the imaging NIC (`interface=`) and serves only the
imaging subnet, and those ports ignore DHCP offers from 172.16.9.1. UFW
allows SSH and 9009 by source, not by interface.
- `corp_nic_mac` set: that port alone is the GE NIC, named `corp0` (as on
the WJ server).
The first-boot playbook pins the result in netplan (names `pxe0` / `corp0`,
overridable), dnsmasq, UFW and Samba.
| Value | Default | Where it lives | | Value | Default | Where it lives |
|-------------------|----------------------|--------------------------------------------------------------------------------| |-------------------|----------------------|--------------------------------------------------------------------------------|
| PXE server IP | 172.16.9.1 | `playbook/pxe_server_setup.yml` (dnsmasq config, iPXE script, samba conf, webapp env), `playbook/startnet.cmd` (mount paths), `boot-tools/blancco/grub-blancco.cfg` (TFTP/HTTP URLs) | | PXE server IP | 172.16.9.1 | `playbook/pxe_server_setup.yml` (dnsmasq config, iPXE script, samba conf, webapp env), `playbook/startnet.cmd` (mount paths), `boot-tools/blancco/grub-blancco.cfg` (TFTP/HTTP URLs) |
| PXE subnet | 172.16.9.0/24 | Same as above, plus `playbook/pxe_server_setup.yml` (UFW rules) | | PXE subnet | 172.16.9.0/24 | Same as above, plus `playbook/pxe_server_setup.yml` (UFW rules) |
| DHCP range | 172.16.9.10-100 | `playbook/site-vars.yml` (`pxe_dhcp_start`, `pxe_dhcp_end`) | | DHCP range | 172.16.9.10-100 | `playbook/pxe_server_setup.yml` (dnsmasq config) |
| NIC MACs / names | (picked at install) / pxe0, corp0 | `playbook/site-vars.yml` (`pxe_nic_mac`, `corp_nic_mac`, `pxe_nic_name`, `corp_nic_name`) |
| Hostname | pxeserver | `autoinstall/user-data` (identity.hostname) | | Hostname | pxeserver | `autoinstall/user-data` (identity.hostname) |
### Access from the GE corporate network
SSH (password auth, the shared `pxe` password) and the webapp on 9009 (no
login, runs as root) are reachable on the corp NIC. By default from ALL of
GE, as before, so a re-run never locks anyone out; the play prints a loud
warning on every run while that is so. Set these in `playbook/site-vars.yml`:
| Var | Default | Effect |
|--------------------------|---------|--------|
| `corp_admin_cidrs` | `[]` | List of admin subnets/hosts, e.g. `["10.134.48.0/24", "3.12.34.56/32"]`. When non-empty, UFW allows 22 and 9009 from these only (on `corp_nic_name` when `corp_nic_mac` is set, else on any interface) and drops the any-source rules. The play refuses (nothing changed) if an entry is invalid or your own SSH session is not inside one of them. The imaging NIC keeps its rules. |
| `corp_ssh_password_auth` | `true` | `false` adds `/etc/ssh/sshd_config.d/10-pxe-corp-auth.conf`: `PasswordAuthentication no`, re-allowed by a `Match Address` block for `pxe_subnet` and localhost. Keys only from GE. The play refuses while `pxe` has no `~/.ssh/authorized_keys`, and removes the drop-in again if `sshd -t` rejects it. |
| `allow_disruptive` | `false` | Re-runs while imaging is active (SMB sessions on 445, or DHCP leases granted in the last `pxe_busy_window_min` = 30 min) do not bounce the NICs or restart dnsmasq/Apache/Samba; a needed network change stops the play. `-e allow_disruptive=true` overrides. |
Recommended for a production box: `corp_admin_cidrs` set to the admin
desks' subnet, then (after `ssh-copy-id`) `corp_ssh_password_auth: false`.
### Re-running the playbook over SSH (from the GE side)
The network step refuses before changing anything when the NIC that
`pxe_nic_mac` names holds the default route, a DHCP lease, or the route to
your SSH client (swapped MACs or cables). When it does apply, it arms a
120 s auto-revert timer (`pxe-netplan-revert-*`), checks the names, the
imaging address and that the corp NIC (or, without one pinned, every other
port that had an address) gets its IPv4 back within 30 s, and
restores the previous netplan config AND NIC names on any failure. Backups:
`/var/backups/netplan-<timestamp>/`.
### Identity and credentials ### Identity and credentials
| Value | Default | Where it lives | | Value | Default | Where it lives |
@@ -118,31 +64,6 @@ The repo defines seven image types (see `playbook/pxe_server_setup.yml`,
Trim or extend this list and the matching `shopfloor_types` filter as needed. Trim or extend this list and the matching `shopfloor_types` filter as needed.
### Engineering Layer (gea-engineer)
Handled by the repo; nothing to work out per site except the payload.
- `playbook/engineering/Install-EngineeringLayer.ps1` is deployed by the
playbook to `gea-engineer/Deploy/Applications/extra_engineering/`.
- `FlatUnattendW10-engineer.xml` runs it twice:
- specialize Order 37 (`-StageOnly`) copies the payload and the script to
`C:\PreInstall\engineering` while `C:\Deploy` exists;
- first logon Order 11 installs from that copy. The bulk-enrollment
`DeleteDeployDir` task removes `C:\Deploy` on the next boot, so a PC
that reboots before the employee signs in would otherwise install
nothing and show no icon.
- After the install it makes sure the "Configure my Engineering Layer"
icon is on the Public desktop (until the PC is configured), and registers
Active Setup so a later user gets the icon if someone else already used
it. The employee clicks it once, on the GE network: it maps V: and runs
`install_local_new.cmd` from GE's share, which removes the icon.
- Log on the PC: `C:\Logs\Engineering\install-engineering-layer.log`.
The site supplies the payload beside the script, in
`gea-engineer/Deploy/Applications/extra_engineering/`:
`engineering_layer/Engineering_Install_v3.1.exe` and `prereqs/`
(.NET 3.5 sxs cabs, VC++ redists), about 650 MB.
### PC types (shopfloor sub-types) ### PC types (shopfloor sub-types)
`playbook/shopfloor-setup/` contains per-PC-type install logic. The current `playbook/shopfloor-setup/` contains per-PC-type install logic. The current
@@ -159,7 +80,8 @@ A site without CMMs can drop the CMM type and its installers entirely.
### GCCH bulk-enrollment PPKGs ### GCCH bulk-enrollment PPKGs
PPKG filenames are baked into `playbook/startnet.cmd`. They are tied to the GE Aerospace tenant. Other PPKG filenames are baked into `playbook/startnet.cmd` and
`startnet-template.cmd`. They are tied to the GE Aerospace tenant. Other
sites/tenants will have their own PPKGs with different filenames. sites/tenants will have their own PPKGs with different filenames.
| PPKG enrollment menu choice | Default filename | | PPKG enrollment menu choice | Default filename |
@@ -224,9 +146,7 @@ Blob storage account.
- `\\172.16.9.1\image-upload` as the destination - `\\172.16.9.1\image-upload` as the destination
- `C:\ProgramData\GEAerospace\MediaCreator\Cache\` as the source - `C:\ProgramData\GEAerospace\MediaCreator\Cache\` as the source
Update both for a different site (`-Server`, `-CachePath`; `-WebPort` if the Update both for a different site.
webapp is not on 9009). The script checks every file on the server through
the webapp before the Image Import page will accept the upload.
## Recommended forward state ## Recommended forward state

File diff suppressed because one or more lines are too long

View File

@@ -1,203 +0,0 @@
# PXE NIC guard
The PXE server's DHCP/PXE (dnsmasq) must never serve on the GE corporate
network. Each server has two NICs:
| Role | Network | Address |
|---------|-----------------------------|----------------------------------|
| imaging | isolated imaging switch | static 172.16.9.1/24, dnsmasq |
| ge | GE corporate network | DHCP client (systemd-networkd) |
`/etc/dnsmasq.d/pxe-nic-only.conf` pins dnsmasq to the imaging NIC by name.
That stops dnsmasq answering on the GE NIC, but not on the imaging NIC when
that NIC itself ends up on the wrong network: a cable swap, an imaging
switch uplinked into a GE port, or a second PXE server on the same switch.
The NIC guard catches those.
## What is installed
| File | Purpose |
|-----------------------------------------------------------|---------------------------------|
| `/usr/local/sbin/pxe-nic-check` | the classifier |
| `/etc/pxe-server/nic.conf` | which NIC is which |
| `/etc/systemd/system/dnsmasq.service.d/nic-guard.conf` | guard before every dnsmasq start|
| `/etc/systemd/system/pxe-nic-check.{service,timer}` | watch every 5 min |
| `/usr/local/sbin/pxe-nic-carrier` | re-check on imaging cable moves |
| `/etc/networkd-dispatcher/<state>.d/50-pxe-nic-carrier` | calls it on link changes |
| `/etc/systemd/system/pxe-nic-carrier.service` | the re-check after carrier gain |
| `/run/pxe-nic-status.json` | last result, read by the webapp |
Sources: `playbook/pxe-server-helpers/`, installed by `pxe_server_setup.yml`
(vars `pxe_nic_mac`, `corp_nic_mac`, `pxe_corp_domain_suffixes`,
`pxe_nic_allowed_switches`; `pxe_imaging_mac` / `pxe_ge_mac` are derived
from the first two). The same MACs pin the NICs in netplan
(`playbook/templates/netplan-pxe.yaml.j2`, names `pxe0` / `corp0`) and in
`/etc/dnsmasq.d/pxe-nic-only.conf`. With `pxe_nic_mac` empty the playbook
picks the imaging NIC at install time with `pxe-pick-nic --auto`, which
runs this check on the one port with a link (`--iface X --probe`, 65 s)
and accepts only "isolated". With `corp_nic_mac` empty `ge_mac` is blank
and the GE NIC is whichever other port holds a DHCP lease.
## How it decides
It listens on each NIC (raw socket, sends nothing; it joins the STP,
802.1X, LLDP, CDP and PVST+ multicast groups so a NIC that is not
promiscuous still hears them) for:
- LLDP and CDP: managed switches announce themselves. GE switches do; the
unmanaged imaging switch does not.
- 802.1X EAPOL from an authenticator: GE ports ask for port
authentication (EAP Request, Success or Failure). EAPOL that clients
send is ignored: iPXE sends three EAPOL-Starts every time it opens a
NIC and Windows sends Starts and EAP Responses, so a bay PXE-booting on
the imaging switch is not mistaken for a GE port. Those frames show up
only as "N client EAPOL frames ignored".
- DHCP OFFER/ACK from any other server.
- STP and the count of distinct ARP hosts: reported, never decisive.
On the imaging NIC it also sends one DHCPDISCOVER and collects offers for
6 s. On the GE NIC it reads the networkd lease instead (no traffic).
| Verdict | Meaning |
|----------------|------------------------------------------------------------|
| `isolated` | no foreign DHCP, no LLDP/CDP/EAPOL |
| `ge` | LLDP, CDP or EAPOL seen, or a DHCP server whose domain ends in a corp suffix (`geaerospace.net`, `ge.com`; whole labels, so `notgeaerospace.net` does not count) |
| `foreign-dhcp` | another DHCP server, no corp domain |
| `no-link` | no carrier |
| `error` | the check itself failed |
Healthy: imaging NIC `isolated` (or `no-link`), GE NIC `ge`.
Our own dnsmasq never counts. An offer whose server id is our own address
but comes from a MAC that is not ours is a second PXE server and DOES count.
If the imaging switch is itself a managed switch that sends LLDP/CDP, put
its LLDP system name, CDP device id or chassis MAC in `allowed_switches`
(`pxe_nic_allowed_switches`), or the guard will block dnsmasq. 802.1X is
never allowed.
## Modes
- `pxe-nic-check` - check every NIC (60 s), print a summary. `--json`,
`--write`, `--probe` (imaging DHCP probe), `--iface NAME`, `--window S`.
- `pxe-nic-check --guard` - dnsmasq `ExecStartPre`. Imaging NIC only,
about 6 s. Exit 1 = do not serve: dnsmasq does not start. A bug or no
link exits 0 (fail open) with an error line in the journal.
- `pxe-nic-check --watch` - the timer. On a bad imaging verdict it runs
`systemctl stop dnsmasq` and logs CRIT. It never starts dnsmasq. If
dnsmasq was (re)started during the 60 s window (an operator, or the
carrier hook, restarted it and its guard passed), the watch re-checks
for about 6 s and stops dnsmasq only if that still says blocked; if
dnsmasq is already stopped it only logs.
- `pxe-nic-carrier` - the networkd-dispatcher hook. Only the imaging NIC
(or its bridge ports) counts; GE NIC events are ignored. Carrier lost:
dnsmasq is stopped at once if it was running. Carrier back: after 2 s,
`pxe-nic-carrier.service` restarts dnsmasq if it was running or the hook
stopped it, so the guard checks the new network before dnsmasq serves
again; dnsmasq that an operator or the watch stopped stays stopped.
A bridge with several ports stops dnsmasq when any one of them loses
carrier. Log: `journalctl -t pxe-nic-carrier`.
Every start or restart of dnsmasq runs the guard, so `systemctl restart
dnsmasq` and the `@reboot` cron restart take about 6 s longer than before.
## Reading it
sudo pxe-nic-check # full summary, 60 s
cat /run/pxe-nic-status.json # last guard/watch result
journalctl -u dnsmasq -b | grep pxe-nic-check
journalctl -u pxe-nic-check -b
journalctl -t pxe-nic-carrier -b # cable unplug / replug
## dnsmasq will not start
1. `journalctl -u dnsmasq -b | grep pxe-nic-check` shows the evidence, e.g.
`REFUSING to start dnsmasq: imaging NIC br-pxe verdict ge: LLDP from
WJ-IDF2 port Gi1/0/14`.
2. Check the imaging cabling: the imaging NIC must go to the imaging
switch only, and that switch must not be uplinked anywhere.
3. `sudo pxe-nic-check` until the imaging NIC reads `isolated`.
4. `sudo systemctl start dnsmasq` (runs the guard again).
## Floor tests (run with the floor idle)
All three tests stop imaging for a few minutes. Have console or GE-side SSH
access to the server; do not rely on the imaging network for the session.
### (a) Rogue DHCP on the imaging switch: expect the alarm
1. Confirm the baseline: `sudo pxe-nic-check --probe` shows the imaging
NIC `isolated` and `dnsmasq_bound` true.
2. Plug a device that runs a DHCP server into a free port of the IMAGING
switch (a spare home router with DHCP on, LAN port only, nothing in its
WAN port; or a laptop running dnsmasq).
3. Run the watch now instead of waiting up to 5 min:
`sudo systemctl start pxe-nic-check.service`
4. Expect:
- `journalctl -u pxe-nic-check -n 20` has a CRIT line `imaging NIC is
NOT isolated, stopping dnsmasq: ... verdict foreign-dhcp: DHCP from
192.168.x.1, ...` (or `ge` if the router sends LLDP).
- `systemctl is-active dnsmasq` is `inactive`.
- `/run/pxe-nic-status.json` has `"dhcp_guard": {"state": "blocked", ...}`.
5. Prove the guard too: `sudo systemctl start dnsmasq` must FAIL, and
`journalctl -u dnsmasq -n 20` shows `REFUSING to start dnsmasq`.
6. Unplug the rogue device. `sudo pxe-nic-check --probe` shows `isolated`.
`sudo systemctl start dnsmasq` starts. PXE-boot one client to confirm.
### (b) Cable swap into a GE port: expect the guard to refuse dnsmasq
dnsmasq must be stopped BEFORE the cable moves, so it never serves on GE.
1. `sudo systemctl stop dnsmasq`; `systemctl is-active dnsmasq` must say
`inactive`. Do not reboot during the test (the `@reboot` cron restarts
dnsmasq; the guard would still block it, but do not test that way).
2. Move the IMAGING NIC's cable from the imaging switch into a live GE
network port. Wait for link: `ip -br link` shows the imaging NIC `UP`.
The carrier hook sees the link come up and runs the guard once (one
DHCPDISCOVER) to refresh the status file; with dnsmasq stopped it does
not start it. `journalctl -t pxe-nic-carrier -n 5` shows `dnsmasq was
not running, left stopped`.
3. Run the guard by hand first:
`sudo pxe-nic-check --guard; echo "exit $?"`
Expect `exit 1` and a `REFUSING to start dnsmasq` line naming LLDP/CDP,
802.1X, or `DHCP from 10.x.x.x, domain ....geaerospace.net`.
This sends one DHCPDISCOVER on the GE port, the same as any PC being
plugged in. To send nothing, use `--no-probe --window 60` instead and
rely on LLDP/CDP/802.1X (switches announce every 30-60 s).
**If it exits 0, STOP: do not start dnsmasq. Move the cable back and
send the output of `sudo pxe-nic-check --probe --json`.**
4. Only after step 3 exited 1: `sudo systemctl start dnsmasq`. Expect
`Job for dnsmasq.service failed`; `systemctl is-active dnsmasq` says
`failed`; `journalctl -u dnsmasq -n 20` shows `REFUSING`.
5. Move the cable back to the imaging switch. `sudo pxe-nic-check --probe`
shows the imaging NIC `isolated`. `sudo systemctl start dnsmasq`
starts. PXE-boot one client to confirm.
### (c) Imaging cable unplugged and replugged: expect stop, re-check, start
1. dnsmasq running, imaging NIC `isolated`.
2. Unplug the imaging NIC's cable. Within a second
`journalctl -t pxe-nic-carrier -n 5` shows `carrier lost on imaging NIC
... stopped dnsmasq` and `systemctl is-active dnsmasq` is `inactive`.
3. Plug it back into the imaging switch. About 10 s later the journal
shows `guard passed, dnsmasq (re)started` and dnsmasq is `active`.
Had it gone into a GE port instead, the guard refuses: the journal
shows `dnsmasq NOT started` and dnsmasq stays down.
## Status file
`/run/pxe-nic-status.json`, mode 0644, replaced atomically:
{"checked_at": "2026-10-14T16:47:09-04:00", "duration_s": 60,
"dhcp_guard": {"state": "allowed"|"blocked"|"unknown", "reason": "..."},
"interfaces": [{"iface": "br-pxe", "mac": "aa:bb:..", "role": "imaging"|"ge"|"other",
"link": "up"|"down", "speed_mbps": 5000, "addrs": ["172.16.9.1/24"],
"verdict": "isolated"|"ge"|"foreign-dhcp"|"no-link"|"error", "evidence": ["..."],
"dhcp_servers": [{"server": "10.1.2.3", "domain": "x.geaerospace.net", "own": false}],
"switch": {"lldp": [{"system": "...", "port": "...", "vlan": null}],
"cdp": [{"device": "...", "port": "..."}], "eapol": false, "stp": false},
"arp_hosts": 3, "dnsmasq_bound": true}]}
`dnsmasq_bound` is null when it cannot be told (no `ss`). A bridge
(br-pxe) is reported as one interface; its ports are listened on too,
because LLDP and 802.1X frames stop at the port and never reach the bridge.

View File

@@ -61,14 +61,6 @@
<Path>cmd /c sc config wlansvc start= auto &amp; net start wlansvc &amp; exit /b 0</Path> <Path>cmd /c sc config wlansvc start= auto &amp; net start wlansvc &amp; exit /b 0</Path>
<Description>Start WLAN AutoConfig so the WiFi profile can be added</Description> <Description>Start WLAN AutoConfig so the WiFi profile can be added</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<!-- PXE imaging floor progress (best-effort, never fails setup). -Persist copies the script
out of C:\Deploy, which the bulk-enrollment task deletes before first logon.
RunSynchronous Path max 259 chars: check with scripts/lint-unattend.py (local mode). -->
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>powershell.exe -NoP -EP Bypass -File C:\Deploy\Applications\extra\pxe-status\Send-ImagingStage.ps1 -Persist -PCType gea-engineer -StageIndex 2 -Stage "Windows setup: installing apps"</Path>
<Description>PXE status: stage 2 of 5</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>3</Order> <Order>3</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path> <Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
@@ -258,16 +250,6 @@
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v EnableDynamicContentInWSB /t REG_DWORD /d 0 /f</Path> <Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v EnableDynamicContentInWSB /t REG_DWORD /d 0 /f</Path>
<Description>Disable Search Highlights</Description> <Description>Disable Search Highlights</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>37</Order>
<Path>powershell.exe -NoP -EP Bypass -File "C:\Deploy\Applications\extra_engineering\Install-EngineeringLayer.ps1" -StageOnly</Path>
<!-- Copies the Engineering Layer payload and the script itself to
C:\PreInstall\engineering now, while C:\Deploy certainly exists. The
first-logon install (oobeSystem Order 11) runs from there: a PC that
reboots before the employee signs in has lost C:\Deploy to the
orchestrator's DeleteDeployDir task by then. -->
<Description>Engineering Layer: stage payload out of C:\Deploy</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>39</Order> <Order>39</Order>
@@ -319,11 +301,6 @@
<Path>reg.exe add "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /t REG_DWORD /d 1 /f</Path> <Path>reg.exe add "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /t REG_DWORD /d 1 /f</Path>
<Description>Block all removable media reads to prevent PPKG auto-detection at OOBE</Description> <Description>Block all removable media reads to prevent PPKG auto-detection at OOBE</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>49</Order>
<Path>powershell.exe -NoP -EP Bypass -File C:\ProgramData\PxeStatus\Send-ImagingStage.ps1 -PCType gea-engineer -StageIndex 3 -Status succeeded -Stage "Ready at OOBE - hand to the employee"</Path>
<Description>PXE status: stage 3 of 3, done (the rest needs the employee to log in)</Description>
</RunSynchronousCommand>
</RunSynchronous> </RunSynchronous>
</component> </component>
</settings> </settings>
@@ -347,11 +324,6 @@
<SkipMachineOOBE>false</SkipMachineOOBE> <SkipMachineOOBE>false</SkipMachineOOBE>
</OOBE> </OOBE>
<FirstLogonCommands> <FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<CommandLine>powershell.exe -NoP -EP Bypass -File C:\ProgramData\PxeStatus\Send-ImagingStage.ps1 -PCType gea-engineer -StageIndex 4 -Stage "First logon: installing engineering layer"</CommandLine>
<Description>PXE status: stage 4 of 5</Description>
<Order>1</Order>
</SynchronousCommand>
@@ -392,21 +364,15 @@
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>11</Order> <Order>11</Order>
<CommandLine>cmd /c if exist "C:\PreInstall\engineering\Install-EngineeringLayer.ps1" (powershell.exe -NoP -EP Bypass -File "C:\PreInstall\engineering\Install-EngineeringLayer.ps1") else (powershell.exe -NoP -EP Bypass -File "C:\Deploy\Applications\extra_engineering\Install-EngineeringLayer.ps1")</CommandLine> <CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Deploy\Applications\extra_engineering\Install-EngineeringLayer.ps1"</CommandLine>
<!-- Installs from the copy specialize Order 37 staged in <!-- Copies the 653 MB payload out of C:\Deploy to C:\PreInstall\engineering
C:\PreInstall\engineering, so it works after a reboot has removed BEFORE installing: the bulk-enrollment orchestrator registers a
C:\Deploy (DeleteDeployDir). Falls back to C:\Deploy if staging did not DeleteDeployDir task that removes C:\Deploy on the next boot, and this
happen. The installer (.NET 3.5, VC++, Cygwin, DUU) can reboot part way installer (.NET 3.5, VC++, Cygwin, DUU) can reboot part way through.
through; the script is idempotent and marker-gated, and it makes sure Idempotent, marker-gated. Description is capped at 256 chars, so the
the "Configure my Engineering Layer" icon reaches the employee. detail lives here. -->
Description is capped at 256 chars, so the detail lives here. --> <Description>Engineering Layer: stage payload out of C:\Deploy then install</Description>
<Description>Engineering Layer: install from the staged copy</Description>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>powershell.exe -NoP -EP Bypass -File C:\ProgramData\PxeStatus\Send-ImagingStage.ps1 -PCType gea-engineer -StageIndex 5 -Stage "Setup complete - enrollment continues on the PC" -Status succeeded</CommandLine>
<Description>PXE status: stage 5 of 5</Description>
<Order>12</Order>
</SynchronousCommand>
</FirstLogonCommands> </FirstLogonCommands>
</component> </component>
</settings> </settings>

View File

@@ -213,7 +213,7 @@
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>6</Order> <Order>6</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Verify-And-Heal-Staging.ps1"</CommandLine> <CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Verify-And-Heal-Staging.ps1"</CommandLine>
<Description>Verify + re-pull any missing imaging payload (shopfloor setup scripts, site app lists and installers) from the PXE share on the imaging LAN before the production-network switch. Log C:\Logs\Fetch.</Description> <Description>Verify + re-pull any missing imaging payload from the PXE share (CMM/Keyence/WaxTrace bundles + bay backup) on the imaging LAN before the production-network switch. Log C:\Logs\Fetch.</Description>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>7</Order> <Order>7</Order>

View File

@@ -61,14 +61,6 @@
<Path>cmd /c sc config wlansvc start= auto &amp; net start wlansvc &amp; exit /b 0</Path> <Path>cmd /c sc config wlansvc start= auto &amp; net start wlansvc &amp; exit /b 0</Path>
<Description>Start WLAN AutoConfig so the WiFi profile can be added</Description> <Description>Start WLAN AutoConfig so the WiFi profile can be added</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<!-- PXE imaging floor progress (best-effort, never fails setup). -Persist copies the script
out of C:\Deploy, which the bulk-enrollment task deletes before first logon.
RunSynchronous Path max 259 chars: check with scripts/lint-unattend.py (local mode). -->
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>powershell.exe -NoP -EP Bypass -File C:\Deploy\Applications\extra\pxe-status\Send-ImagingStage.ps1 -Persist -PCType gea-standard -StageIndex 2 -Stage "Windows setup: installing apps"</Path>
<Description>PXE status: stage 2 of 5</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>3</Order> <Order>3</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path> <Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
@@ -309,11 +301,6 @@
<Path>reg.exe add "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /t REG_DWORD /d 1 /f</Path> <Path>reg.exe add "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /t REG_DWORD /d 1 /f</Path>
<Description>Block all removable media reads to prevent PPKG auto-detection at OOBE</Description> <Description>Block all removable media reads to prevent PPKG auto-detection at OOBE</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>49</Order>
<Path>powershell.exe -NoP -EP Bypass -File C:\ProgramData\PxeStatus\Send-ImagingStage.ps1 -PCType gea-standard -StageIndex 3 -Status succeeded -Stage "Ready at OOBE - hand to the employee"</Path>
<Description>PXE status: stage 3 of 3, done (the rest needs the employee to log in)</Description>
</RunSynchronousCommand>
</RunSynchronous> </RunSynchronous>
</component> </component>
</settings> </settings>
@@ -337,11 +324,6 @@
<SkipMachineOOBE>false</SkipMachineOOBE> <SkipMachineOOBE>false</SkipMachineOOBE>
</OOBE> </OOBE>
<FirstLogonCommands> <FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<CommandLine>powershell.exe -NoP -EP Bypass -File C:\ProgramData\PxeStatus\Send-ImagingStage.ps1 -PCType gea-standard -StageIndex 4 -Stage "First logon"</CommandLine>
<Description>PXE status: stage 4 of 5</Description>
<Order>1</Order>
</SynchronousCommand>
@@ -380,11 +362,6 @@
<Description>Restore removable media access after OOBE PPKG block</Description> <Description>Restore removable media access after OOBE PPKG block</Description>
<Order>10</Order> <Order>10</Order>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>powershell.exe -NoP -EP Bypass -File C:\ProgramData\PxeStatus\Send-ImagingStage.ps1 -PCType gea-standard -StageIndex 5 -Stage "Setup complete - enrollment continues on the PC" -Status succeeded</CommandLine>
<Description>PXE status: stage 5 of 5</Description>
<Order>11</Order>
</SynchronousCommand>
</FirstLogonCommands> </FirstLogonCommands>
</component> </component>
</settings> </settings>

View File

@@ -7,46 +7,35 @@
# NIC driver set), download Blancco rootfs (squashfs), overlay-mount, and # NIC driver set), download Blancco rootfs (squashfs), overlay-mount, and
# switch_root into Blancco's userspace. # switch_root into Blancco's userspace.
# #
# Quiet by default: one line per step. Add blancco.debug=1 to the kernel # Verbose trace + shell-on-NIC-failure because silent hangs during Blancco
# cmdline for the full trace (set -x, modprobe -v, ip link/addr). Failures # PXE boot are painful to debug. set -x goes to /dev/console so the screen
# always print their diagnostics and drop to sh so the operator can # shows every step; if no NIC appears after the modprobe sweep, we dump
# dmesg / lspci / /proc/modules and drop to sh so the operator can
# investigate without re-imaging. # investigate without re-imaging.
exec >/dev/console 2>&1 exec >/dev/console 2>&1
set -x
echo ""
echo "============================================"
echo " Blancco PXE loader (verbose)"
echo "============================================"
echo ""
export PATH=/bin:/sbin export PATH=/bin:/sbin
mount -t proc proc /proc mount -t proc proc /proc
mount -t sysfs sysfs /sys mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev 2>/dev/null || mount -t tmpfs tmpfs /dev mount -t devtmpfs devtmpfs /dev 2>/dev/null || mount -t tmpfs tmpfs /dev
DEBUG=""
grep -q 'blancco.debug=1' /proc/cmdline && DEBUG=1
# dbg CMD...: run only in debug mode
dbg() { [ -n "$DEBUG" ] && "$@"; return 0; }
if [ -n "$DEBUG" ]; then
set -x
else
printf '\033c' # clear the kernel messages off the screen
dmesg -n 1 2>/dev/null || true
fi
echo ""
echo " Blancco Drive Eraser - loading from $( [ -n "$DEBUG" ] && echo "PXE (debug)" || echo "PXE")"
echo ""
mkdir -p /tmp /run /run/lower /run/upper /run/work /run/newroot mkdir -p /tmp /run /run/lower /run/upper /run/work /run/newroot
KVER=$(uname -r) KVER=$(uname -r)
dbg echo "Running kernel: $KVER" echo "Running kernel: $KVER"
dbg ls /lib/modules/ ls /lib/modules/ 2>/dev/null
echo " [1/5] Network drivers" echo "[1/5] Loading NIC drivers via modprobe (resolves deps automatically)..."
# Throw the full common-NIC driver list at the wall. modprobe resolves the # Throw the full common-NIC driver list at the wall. modprobe resolves the
# deps from /lib/modules/$KVER/modules.dep (built by prepare-boot-tools). # deps from /lib/modules/$KVER/modules.dep (built by prepare-boot-tools).
# Anything missing is silently ignored; whatever matches PCI IDs will bind. # Anything missing is silently ignored; whatever matches PCI IDs will bind.
# USB host controllers first: xhci_pci is a module in the Ubuntu kernel
# (CONFIG_USB_XHCI_PCI=m) and there is no udev here, so without this no
# USB 3 / USB-C port works and USB-C to RJ45 adapters never appear.
for drv in \ for drv in \
xhci_pci xhci_pci_renesas \
mii libphy ptp \ mii libphy ptp \
e1000 e1000e igb igc ixgbe ixgbevf i40e ice iavf \ e1000 e1000e igb igc ixgbe ixgbevf i40e ice iavf \
tg3 bnx2 bnx2x bnxt_en b44 \ tg3 bnx2 bnx2x bnxt_en b44 \
@@ -57,19 +46,15 @@ for drv in \
alx atl1c atl1e atl2 \ alx atl1c atl1e atl2 \
via_rhine via_velocity forcedeth \ via_rhine via_velocity forcedeth \
pegasus dm9601 asix ax88179_178a cdc_ether cdc_ncm rndis_host; do pegasus dm9601 asix ax88179_178a cdc_ether cdc_ncm rndis_host; do
if [ -n "$DEBUG" ]; then modprobe -v "$drv" 2>/dev/null && echo " OK $drv" || true
modprobe -v "$drv" 2>/dev/null && echo " OK $drv" || true
else
modprobe "$drv" >/dev/null 2>&1 || true
fi
done done
sleep 3 sleep 3
echo " [2/5] Waiting for a network link" echo "[2/5] /sys/class/net after driver load:"
dbg ls /sys/class/net/ ls /sys/class/net/ || true
dbg ip link ip link || true
dbg echo " Waiting up to 60s for non-lo interface..." echo " Waiting up to 60s for non-lo interface..."
IFACE="" IFACE=""
for i in $(seq 1 60); do for i in $(seq 1 60); do
for n in /sys/class/net/*; do for n in /sys/class/net/*; do
@@ -91,87 +76,21 @@ if [ -z "$IFACE" ]; then
exec /bin/sh exec /bin/sh
fi fi
# The first interface to appear is not necessarily the cabled one: a echo " IFACE=$IFACE, bringing up..."
# second onboard port, a dock or a USB NIC can come first, and a static
# IP on a dead port leaves wget at "Connecting" forever. Bring every
# wired interface up and use the first with carrier.
for n in /sys/class/net/*; do
name="${n##*/}"
case "$name" in lo|wl*) continue ;; esac
ip link set "$name" up 2>/dev/null || ifconfig "$name" up 2>/dev/null || true
done
dbg echo " Waiting up to 30s for a cabled interface..."
CARRIER=""
for i in $(seq 1 30); do
for n in /sys/class/net/*; do
name="${n##*/}"
case "$name" in lo|wl*) continue ;; esac
# USB NICs enumerate seconds after onboard ports: bring up late ones too
[ "$(cat "$n/operstate" 2>/dev/null)" = "down" ] && { ip link set "$name" up 2>/dev/null || ifconfig "$name" up 2>/dev/null || true; }
[ "$(cat "$n/carrier" 2>/dev/null)" = "1" ] && CARRIER="$name" && break 2
done
sleep 1
echo -n "."
done
echo ""
if [ -z "$CARRIER" ]; then
echo "ERROR: no wired interface has link. Interfaces seen:"
for n in /sys/class/net/*; do
drv=$(readlink "$n/device/driver" 2>/dev/null)
echo " ${n##*/} carrier=$(cat "$n/carrier" 2>/dev/null) driver=${drv##*/}"
done
echo "=== kernel messages (NIC / firmware / link) ==="
dmesg | grep -i -E 'eth|enp|enx|r8152|r8169|e1000e|igc|cdc|firmware|link' | tail -20
echo "Check the cable, then type 'exit' to reboot."
exec /bin/sh
fi
IFACE="$CARRIER"
echo " using $IFACE"
ip link set "$IFACE" up || ifconfig "$IFACE" up ip link set "$IFACE" up || ifconfig "$IFACE" up
sleep 2 sleep 2
SERVER=172.16.9.1 SERVER=172.16.9.1
# Where the release files (rootfs, preferences, licence container,
# config.img) come from. Default /blancco, the active release. The webapp's
# test menu entry adds blancco.base=/blancco/staged/<version> to boot a
# staged release before it is activated. The kmod tarball, progress
# reporter and debug key are not part of a release and stay in /blancco.
BASE=/blancco
for arg in $(cat /proc/cmdline); do
case "$arg" in
blancco.base=/blancco/*) BASE="${arg#blancco.base=}" ;;
esac
done
case "$BASE" in
*..*|*[!A-Za-z0-9._/-]*) BASE=/blancco ;;
esac
BASE="${BASE%/}"
[ "$BASE" = /blancco ] || echo " release under test: $BASE"
# Coarse progress for the webapp's /imaging dashboard (same endpoint the
# WinPE imaging clients use). Stage 1 also resets a finished tile for this
# serial. Best effort: a webapp outage must never stop a wipe.
post_status() {
# sed, not tr: the initrd's busybox has no tr link
serial=$(sed 's/["\\]//g' /sys/class/dmi/id/product_serial 2>/dev/null)
[ -n "$serial" ] || return 0
wget -q -T 5 -O /dev/null --header 'Content-Type: application/json' \
--post-data "{\"serial\":\"$serial\",\"pctype\":\"blancco\",\"source\":\"blancco\",\"current_stage\":\"$2\",\"stage_index\":$1,\"stage_total\":7,\"status\":\"in_progress\",\"ip\":\"172.16.9.250\"}" \
"http://$SERVER:9009/imaging/status" 2>/dev/null || true
}
ifconfig "$IFACE" 172.16.9.250 netmask 255.255.255.0 up ifconfig "$IFACE" 172.16.9.250 netmask 255.255.255.0 up
sleep 1 sleep 1
dbg echo " IP: 172.16.9.250 SERVER: $SERVER" echo " IP: 172.16.9.250 SERVER: $SERVER"
dbg ip addr ip addr
echo " [3/5] Downloading Blancco (~850 MB)" echo "[3/5] Downloading airootfs.sfs (~756 MB)..."
post_status 1 "Downloading Blancco" wget -O /tmp/airootfs.sfs http://$SERVER/blancco/arch/x86_64/airootfs.sfs 2>&1
wget -T 30 -O /tmp/airootfs.sfs http://$SERVER$BASE/arch/x86_64/airootfs.sfs 2>&1
[ -s /tmp/airootfs.sfs ] || { echo "ERROR: download failed"; exec /bin/sh; } [ -s /tmp/airootfs.sfs ] || { echo "ERROR: download failed"; exec /bin/sh; }
echo " [4/5] Preparing system" echo "[4/5] Mounting rootfs + overlay..."
modprobe overlay 2>/dev/null || insmod /lib/modules/$KVER/kernel/fs/overlayfs/overlay.ko 2>/dev/null modprobe overlay 2>/dev/null || insmod /lib/modules/$KVER/kernel/fs/overlayfs/overlay.ko 2>/dev/null
modprobe squashfs 2>/dev/null || insmod /lib/modules/$KVER/kernel/fs/squashfs/squashfs.ko 2>/dev/null modprobe squashfs 2>/dev/null || insmod /lib/modules/$KVER/kernel/fs/squashfs/squashfs.ko 2>/dev/null
modprobe loop 2>/dev/null modprobe loop 2>/dev/null
@@ -181,12 +100,8 @@ mount -t tmpfs -o size=50% tmpfs /run/upper
mkdir -p /run/upper/upper /run/upper/work mkdir -p /run/upper/upper /run/upper/work
mount -t overlay overlay -o lowerdir=/run/lower,upperdir=/run/upper/upper,workdir=/run/upper/work /run/newroot mount -t overlay overlay -o lowerdir=/run/lower,upperdir=/run/upper/upper,workdir=/run/upper/work /run/newroot
echo " [5/5] Loading drivers and settings" echo "[5/5] Fetching kmod tarball + config..."
# Modules (+ iwlwifi firmware) for the running kernel. kmod-$KVER.tar.gz wget -O /tmp/kmod.tar.gz http://$SERVER/blancco/kmod.tar.gz 2>&1
# lets several boot kernels share one web root; kmod.tar.gz is the
# original single-kernel name, kept as the fallback.
wget -q -T 30 -O /tmp/kmod.tar.gz "http://$SERVER/blancco/kmod-$KVER.tar.gz" 2>/dev/null ||
wget -q -T 30 -O /tmp/kmod.tar.gz http://$SERVER/blancco/kmod.tar.gz 2>&1
[ -s /tmp/kmod.tar.gz ] && (cd /run/newroot && gunzip -c /tmp/kmod.tar.gz | tar xf - && rm -f /tmp/kmod.tar.gz) [ -s /tmp/kmod.tar.gz ] && (cd /run/newroot && gunzip -c /tmp/kmod.tar.gz | tar xf - && rm -f /tmp/kmod.tar.gz)
mkdir -p /run/newroot/albus mkdir -p /run/newroot/albus
@@ -196,8 +111,8 @@ mkdir -p /run/newroot/albus
# /albus/preferences.save (factory defaults, empty network_share) so # /albus/preferences.save (factory defaults, empty network_share) so
# erasure reports never reach SMB. Fail loud here instead. # erasure reports never reach SMB. Fail loud here instead.
PREFS=/run/newroot/albus/preferences.xml PREFS=/run/newroot/albus/preferences.xml
if ! wget -q -T 30 -O "$PREFS" "http://$SERVER$BASE/preferences.xml"; then if ! wget -O "$PREFS" "http://$SERVER/blancco/preferences.xml"; then
echo "ERROR: preferences.xml download failed from http://$SERVER$BASE/preferences.xml" echo "ERROR: preferences.xml download failed from http://$SERVER/blancco/preferences.xml"
echo "Reports would fall back to factory defaults (no SMB target)." echo "Reports would fall back to factory defaults (no SMB target)."
echo "Dropping to shell - check Apache + network." echo "Dropping to shell - check Apache + network."
exec /bin/sh exec /bin/sh
@@ -217,29 +132,11 @@ fi
# fires, we want the fallback to still have the right SMB target. # fires, we want the fallback to still have the right SMB target.
cp -f "$PREFS" /run/newroot/albus/preferences.save cp -f "$PREFS" /run/newroot/albus/preferences.save
if ! wget -q -T 30 -O /run/newroot/albus/config.xml "http://$SERVER$BASE/config-clean.xml"; then if ! wget -O /run/newroot/albus/config.xml "http://$SERVER/blancco/config-clean.xml"; then
echo "ERROR: config.xml (license container) download failed." echo "ERROR: config.xml (license container) download failed."
exec /bin/sh exec /bin/sh
fi fi
# On a native boot config.img is an initrd, its files land in the
# initramfs root and archiso keeps them in /run/initramfs, where Blancco's
# /opt/scripts/inc/* pick up report.key (report signing), certificate.ca,
# hybrid.ids and flexible_container. This path never had them, so
# 1_reportKey wrote an empty /albus/report.key. Recreate the layout on a
# tmpfs at the new root's /run; systemd leaves an existing /run mounted.
mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs /run/newroot/run
mkdir -p /run/newroot/run/initramfs
if wget -q -T 30 -O /tmp/config.img "http://$SERVER$BASE/config.img" &&
(cd /run/newroot/run/initramfs && cpio -id < /tmp/config.img 2>/dev/null) &&
[ -s /run/newroot/run/initramfs/report.key ]; then
dbg ls -la /run/newroot/run/initramfs
else
echo "ERROR: config.img (report key, certificate) download or unpack failed."
exec /bin/sh
fi
rm -f /tmp/config.img
mkdir -p /run/newroot/etc/X11/xorg.conf.d mkdir -p /run/newroot/etc/X11/xorg.conf.d
# Don't pin a single Xorg driver - hardware varies per site (Intel iGPU, # Don't pin a single Xorg driver - hardware varies per site (Intel iGPU,
# NVIDIA GK208, AMD, etc). Let Xorg auto-pick. With nomodeset + KMS # NVIDIA GK208, AMD, etc). Let Xorg auto-pick. With nomodeset + KMS
@@ -271,24 +168,6 @@ HandleLidSwitchDocked=ignore
HandlePowerKey=ignore HandlePowerKey=ignore
XEOF XEOF
# Restart after an erasure stalled on "A stop job is running for
# /mnt/share": the network goes down before the SMB report share is
# unmounted, and the CIFS umount waits out the 90s timeout. A mount unit's
# timeout is TimeoutSec= (default: the START timeout), so
# DefaultTimeoutStopSec alone does not reach it. Lazy + forced umount
# detaches the dead share at once; reports are written long before this.
mkdir -p /run/newroot/etc/systemd/system.conf.d /run/newroot/etc/systemd/system/mnt-share.mount.d
cat > /run/newroot/etc/systemd/system.conf.d/10-fast-stop.conf << XEOF
[Manager]
DefaultTimeoutStopSec=10s
XEOF
cat > /run/newroot/etc/systemd/system/mnt-share.mount.d/10-fast-umount.conf << XEOF
[Mount]
TimeoutSec=10s
LazyUnmount=yes
ForceUnmount=yes
XEOF
# Disable Xorg screensaver + DPMS so display stays on during long erasures. # Disable Xorg screensaver + DPMS so display stays on during long erasures.
# Numbered 00-* to run before vendor xinitrc.d hooks. # Numbered 00-* to run before vendor xinitrc.d hooks.
mkdir -p /run/newroot/etc/X11/xinit/xinitrc.d mkdir -p /run/newroot/etc/X11/xinit/xinitrc.d
@@ -301,115 +180,6 @@ XEOF
chmod +x /run/newroot/etc/X11/xinit/xinitrc.d/00-no-screen-blank.sh chmod +x /run/newroot/etc/X11/xinit/xinitrc.d/00-no-screen-blank.sh
mkdir -p /run/newroot/proc /run/newroot/sys /run/newroot/dev /run/newroot/run /run/newroot/tmp mkdir -p /run/newroot/proc /run/newroot/sys /run/newroot/dev /run/newroot/run /run/newroot/tmp
# Auto-save reports to the SMB share. process=auto never writes the share
# (Albus forces auto_backup off outside workflow/manual) but does keep each
# report in /reports; a small service copies them across.
mkdir -p /run/newroot/usr/local/sbin /run/newroot/etc/systemd/system/multi-user.target.wants
cat > /run/newroot/usr/local/sbin/blancco-report-sync << 'XEOF'
#!/bin/bash
# Copy Blancco reports to the SMB report share.
#
# In process=auto Albus saves each report to /reports/<document_id>.xml
# (and .pdf when pdf+xml is selected) and uploads it to the cloud, but never
# writes the share: auto_backup is forced off outside workflow/manual mode.
# Albus has the share mounted at /mnt/share already, so copy each finished
# file there, named the way a manual Save names it (UTC
# YYYYMMDD_HHMMSS_report.<ext>, the time taken from the XML so the PDF of
# the same report gets the same name).
# Usage: blancco-report-sync [--once]
DONE=/var/lib/blancco-report-sync
mkdir -p "$DONE"
while :; do
for f in /reports/*.xml /reports/*.pdf; do
[ -s "$f" ] || continue
base=$(basename "$f")
[ -e "$DONE/$base" ] && continue
# Leave files Albus may still be writing.
[ $(( $(date +%s) - $(stat -c %Y "$f") )) -ge 5 ] || continue
mountpoint -q /mnt/share || continue
id=${base%.*}
ext=${base##*.}
ref="/reports/$id.xml"
[ -f "$ref" ] || ref="$f"
name="$(date -u -r "$ref" +%Y%m%d_%H%M%S)_report.$ext"
if cp "$f" "/mnt/share/$name.part" && mv -f "/mnt/share/$name.part" "/mnt/share/$name"; then
touch "$DONE/$base"
echo "copied $base to $name"
serial=$(tr -d '"\\' 2>/dev/null < /sys/class/dmi/id/product_serial)
[ -n "$serial" ] && curl -s -m 5 -o /dev/null -H 'Content-Type: application/json' \
-d "{\"serial\":\"$serial\",\"log_lines\":[\"Report copied to share: $name\"]}" \
http://172.16.9.1:9009/imaging/status || true
else
rm -f "/mnt/share/$name.part"
echo "copy of $base failed, will retry"
fi
done
[ "$1" = "--once" ] && exit 0
sleep 10
done
XEOF
chmod 755 /run/newroot/usr/local/sbin/blancco-report-sync
cat > /run/newroot/etc/systemd/system/blancco-report-sync.service << 'XEOF'
[Unit]
Description=Copy Blancco reports to the SMB report share
[Service]
ExecStart=/usr/local/sbin/blancco-report-sync
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
XEOF
ln -sf /etc/systemd/system/blancco-report-sync.service \
/run/newroot/etc/systemd/system/multi-user.target.wants/blancco-report-sync.service
# Live erase progress for the /imaging dashboard: follows the AlbusSeven
# journal and posts stages and drive percentages. Fetched at boot so the
# server copy (playbook/blancco-progress.py) is the only one.
if wget -q -T 10 -O /run/newroot/usr/local/sbin/blancco-progress "http://$SERVER/blancco/blancco-progress.py"; then
chmod 755 /run/newroot/usr/local/sbin/blancco-progress
cat > /run/newroot/etc/systemd/system/blancco-progress.service << 'XEOF'
[Unit]
Description=Report Blancco erase progress to the PXE webapp
[Service]
ExecStart=/usr/bin/python3 /usr/local/sbin/blancco-progress
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
XEOF
ln -sf /etc/systemd/system/blancco-progress.service \
/run/newroot/etc/systemd/system/multi-user.target.wants/blancco-progress.service
else
echo " (no blancco-progress.py on server: no live progress on the dashboard)"
fi
# Site timezone for Blancco's own clock (GUI, logs). Report timestamps keep
# an explicit UTC offset; report.utc_offset in the preferences stays 0
# because it is a fixed offset and cannot follow daylight saving.
if [ -e /run/newroot/usr/share/zoneinfo/America/New_York ]; then
ln -sf /usr/share/zoneinfo/America/New_York /run/newroot/etc/localtime
fi
# Debug boots only: Blancco's own debug mode (/etc/debug.environment makes
# isDebugMode true, so 2_sshd starts sshd) plus an SSH key for root.
# Root is locked and sshd is key-only, so without the key there is no way
# in. The key is served from the PXE server and never baked in here.
if [ -n "$DEBUG" ]; then
touch /run/newroot/etc/debug.environment
mkdir -p /run/newroot/root/.ssh
if wget -q -T 10 -O /run/newroot/root/.ssh/authorized_keys "http://$SERVER/blancco/debug_authorized_keys"; then
chmod 700 /run/newroot/root/.ssh
chmod 600 /run/newroot/root/.ssh/authorized_keys
echo " debug: sshd on, root key installed"
else
echo " debug: no debug_authorized_keys on server, SSH stays closed"
fi
fi
mount --move /proc /run/newroot/proc mount --move /proc /run/newroot/proc
mount --move /sys /run/newroot/sys mount --move /sys /run/newroot/sys
mount --move /dev /run/newroot/dev mount --move /dev /run/newroot/dev
@@ -426,6 +196,5 @@ mount --bind /dev/null /run/newroot/sys/power/disk 2>/dev/null || true
mount --bind /dev/null /run/newroot/sys/power/mem_sleep 2>/dev/null || true mount --bind /dev/null /run/newroot/sys/power/mem_sleep 2>/dev/null || true
mount --bind /dev/null /run/newroot/sys/power/autosleep 2>/dev/null || true mount --bind /dev/null /run/newroot/sys/power/autosleep 2>/dev/null || true
echo " Starting Blancco..." echo "Switching root..."
post_status 2 "Starting Blancco"
exec switch_root /run/newroot /sbin/init exec switch_root /run/newroot /sbin/init

View File

@@ -1,42 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- FASDHFOSGHSVASLGHASDLASDFUILEFHLKJDKLFJGKLXCJGHFKLJDGHKLFJ --> <!-- FASDHFOSGHSVASLGHASDLASDFUILEFHLKJDKLFJGKLXCJGHFKLJDGHKLFJ -->
<root> <root>
<blancco type="albus-preferences" version="4.20"/> <blancco type="albus-preferences" version="4.15"/>
<version> <version>
<generation>7</generation> <generation>7</generation>
<major>20</major> <major>15</major>
<minor>0</minor> <minor>1</minor>
</version> </version>
<integrity>qJ4hjOqd1NaMbEPsbmaz+8gZlG1duEXinTXI+Wrsc00=</integrity>
<configuration dect="3.15.0" imported="false"> <configuration>
<device_enrollment_detection>
<persistent_software>false</persistent_software>
</device_enrollment_detection>
<processes type="array" key="process"> <processes type="array" key="process">
<process>workflow</process> <process>workflow</process>
<process selected="true">auto</process> <process>auto</process>
<process>semi</process> <process>semi</process>
<process>manual</process> <process selected="true">manual</process>
</processes> </processes>
<hardware_tests type="array" key="test" enabled="true">
<test enabled="false" mandatory="false" auto="true" threshold="60">battery</test> <hardware_tests type="array" key="test" enabled="false">
<test enabled="true" mandatory="false" auto="true" threshold="60">battery</test>
<test enabled="true" mandatory="false" auto="true">cpu</test> <test enabled="true" mandatory="false" auto="true">cpu</test>
<test enabled="false" mandatory="false" auto="true" passes="0">memory</test> <test enabled="false" mandatory="false" auto="true" passes="0">memory</test>
<test enabled="true" mandatory="false" auto="true">motherboard</test> <test enabled="true" mandatory="false" auto="true">motherboard</test>
<test enabled="false" mandatory="false" auto="false" threshold="50" duration="600">battery_discharge</test> <test enabled="true" mandatory="false" auto="false" threshold="50" duration="600">battery_discharge</test>
<test enabled="false" mandatory="false" auto="false">bios_logo</test> <test enabled="true" mandatory="false" auto="false">bios_logo</test>
<test enabled="false" mandatory="false" auto="false" threshold="80" duration="180">cpu_stress</test> <test enabled="true" mandatory="false" auto="false" threshold="80" duration="180">cpu_stress</test>
<test enabled="false" mandatory="false" auto="false">display</test> <test enabled="true" mandatory="false" auto="false">display</test>
<test enabled="false" mandatory="false" auto="false" formfactor="compact" language="us">keyboard</test> <test enabled="true" mandatory="false" auto="false" formfactor="compact" language="us">keyboard</test>
<test enabled="false" mandatory="false" auto="false">microphone</test> <test enabled="true" mandatory="false" auto="false">microphone</test>
<test enabled="false" mandatory="false" auto="false" targets="">network</test> <test enabled="true" mandatory="false" auto="false" targets="">network</test>
<test enabled="false" mandatory="false" auto="false" tests="R">optical_devices</test> <test enabled="true" mandatory="false" auto="false" tests="R">optical_devices</test>
<test enabled="false" mandatory="false" auto="false">sound</test> <test enabled="true" mandatory="false" auto="false">sound</test>
<test enabled="false" mandatory="false" auto="false">pointing_devices</test> <test enabled="true" mandatory="false" auto="false">pointing_devices</test>
<test enabled="false" mandatory="false" auto="false">sim_presence</test> <test enabled="true" mandatory="false" auto="false">sim_presence</test>
<test enabled="false" mandatory="false" auto="false">speaker</test> <test enabled="true" mandatory="false" auto="false">speaker</test>
<test enabled="false" mandatory="false" auto="false">touchscreen</test> <test enabled="true" mandatory="false" auto="false">touchscreen</test>
<test enabled="false" mandatory="false" auto="false" tests="R">usb_ports</test> <test enabled="true" mandatory="false" auto="false" tests="R">usb_ports</test>
<test enabled="false" mandatory="false" auto="false">webcam</test> <test enabled="true" mandatory="false" auto="false">webcam</test>
<test enabled="false" mandatory="false" auto="false">wifi</test> <test enabled="true" mandatory="false" auto="false">wifi</test>
</hardware_tests> </hardware_tests>
<layouts type="array" key="layout"> <layouts type="array" key="layout">
<layout selected="true">us</layout> <layout selected="true">us</layout>
<layout>be</layout> <layout>be</layout>
@@ -64,6 +71,7 @@
<layout>se</layout> <layout>se</layout>
<layout>sk</layout> <layout>sk</layout>
</layouts> </layouts>
<locales type="array" key="locale"> <locales type="array" key="locale">
<locale selected="true">en_US</locale> <locale selected="true">en_US</locale>
<locale>de_DE</locale> <locale>de_DE</locale>
@@ -80,110 +88,119 @@
<locale>zh_CN</locale> <locale>zh_CN</locale>
<locale>zh_TW</locale> <locale>zh_TW</locale>
</locales> </locales>
<erasure_standards type="array" key="standard">
<erasure_standards key="standard" type="array">
<standard enabled="true" id="afssi_5020" name="Air Force System Security Instruction 5020"/> <standard enabled="true" id="afssi_5020" name="Air Force System Security Instruction 5020"/>
<standard enabled="true" id="aperiodic_random" name="Aperiodic random overwrite"/> <standard enabled="true" id="aperiodic_random" name="Aperiodic random overwrite"/>
<standard enabled="true" id="bl_ssd" name="Blancco SSD Erasure"/>
<standard enabled="true" id="bruce_schneier" name="Bruce Schneier's Algorithm"/>
<standard enabled="true" id="bsi_vs" name="BSI-2011-VS"/> <standard enabled="true" id="bsi_vs" name="BSI-2011-VS"/>
<standard enabled="true" id="bsi_gs" name="BSI-GS"/> <standard enabled="true" id="bsi_gs" name="BSI-GS"/>
<standard enabled="true" id="bsi_gse" name="BSI-GSE"/> <standard enabled="true" id="bsi_gse" name="BSI-GSE"/>
<standard enabled="true" id="bsi_gsk" name="BSI-GSK"/> <standard enabled="true" id="bsi_gsk" name="BSI-GSK"/>
<standard enabled="true" id="bl_ssd" name="Blancco SSD Erasure"/>
<standard enabled="true" id="bruce_schneier" name="Bruce Schneier's Algorithm"/>
<standard enabled="true" id="cesg_higher" name="CESG CPA - Higher Level"/> <standard enabled="true" id="cesg_higher" name="CESG CPA - Higher Level"/>
<standard enabled="true" id="crypto_erase" name="Sanitize Cryptographic Erasure"/>
<standard enabled="true" id="dod" name="DoD 5220.22-M"/> <standard enabled="true" id="dod" name="DoD 5220.22-M"/>
<standard enabled="true" id="dod_ece" name="DoD 5220.22-M ECE"/> <standard enabled="true" id="dod_ece" name="DoD 5220.22-M ECE"/>
<standard enabled="true" id="extended_firmware" name="Extended Firmware Based Erasure"/>
<standard enabled="true" id="firmware" name="Firmware Based Erasure"/>
<standard enabled="true" id="hmg_higher" name="HMG Infosec Standard 5, Higher Standard"/>
<standard enabled="true" id="hmg_lower" name="HMG Infosec Standard 5, Lower Standard"/>
<standard enabled="true" id="ieee_clear" name="IEEE 2883-2022 Clear"/>
<standard enabled="true" id="ieee_purge" name="IEEE 2883-2022 Purge"/>
<standard enabled="false" id="nist_pc" name="NIST 800-88" pattern="0x00" pattern_type="static"/>
<standard enabled="true" id="nist_clear" name="NIST 800-88 Clear" pattern="0x00" pattern_type="static"/> <standard enabled="true" id="nist_clear" name="NIST 800-88 Clear" pattern="0x00" pattern_type="static"/>
<standard selected="true" enabled="true" id="nist_purge" name="NIST 800-88 Purge"/> <standard selected="true" enabled="true" id="nist_purge" name="NIST 800-88 Purge"/>
<standard enabled="true" id="nist_clear_rev2" name="NIST 800-88r2 Clear" pattern="0x00" pattern_type="static"/> <standard enabled="true" id="firmware" name="Firmware Based Erasure"/>
<standard enabled="true" id="nist_purge_rev2" name="NIST 800-88r2 Purge"/> <standard enabled="true" id="extended_firmware" name="Extended Firmware Based Erasure"/>
<standard enabled="true" id="hmg_lower" name="HMG Infosec Standard 5, Lower Standard"/>
<standard enabled="true" id="hmg_higher" name="HMG Infosec Standard 5, Higher Standard"/>
<standard enabled="true" id="nsa" name="NSA 130-1"/> <standard enabled="true" id="nsa" name="NSA 130-1"/>
<standard enabled="true" id="ncsc" name="National Computer Security Center (NCSC-TG-025)"/> <standard enabled="true" id="ncsc" name="National Computer Security Center (NCSC-TG-025)"/>
<standard enabled="true" id="navso" name="Navy Staff Office Publication (NAVSO P-5239-26)"/> <standard enabled="true" id="navso" name="Navy Staff Office Publication (NAVSO P-5239-26)"/>
<standard enabled="true" id="tcg_crypto_erase" name="TCG Cryptographic Erasure"/>
<standard enabled="true" id="opnavinst" name="OPNAVINST 5239.1A"/> <standard enabled="true" id="opnavinst" name="OPNAVINST 5239.1A"/>
<standard enabled="true" id="peter_gutmann" name="Peter Gutmann's Algorithm"/> <standard enabled="true" id="peter_gutmann" name="Peter Gutmann's Algorithm"/>
<standard enabled="true" id="rcmp_tssit" name="RCMP TSSIT OPS-II"/>
<standard enabled="true" id="random_byte_3x" name="Random Byte Overwrite (3x)"/> <standard enabled="true" id="random_byte_3x" name="Random Byte Overwrite (3x)"/>
<standard enabled="true" id="crypto_erase" name="Sanitize Cryptographic Erasure"/> <standard enabled="true" id="rcmp_tssit" name="RCMP TSSIT OPS-II"/>
<standard enabled="true" id="tcg_crypto_erase" name="TCG Cryptographic Erasure"/>
<standard enabled="true" id="us_army" name="U.S. Army AR380-19"/> <standard enabled="true" id="us_army" name="U.S. Army AR380-19"/>
<standard enabled="true" id="ieee_clear" name="IEEE 2883-2022 Clear"/>
<standard enabled="true" id="ieee_purge" name="IEEE 2883-2022 Purge"/>
<standard enabled="false" id="nist_pc" name="NIST 800-88" pattern="0x00" pattern_type="static"/>
</erasure_standards> </erasure_standards>
<verification_standards type="array" key="standard">
<standard enabled="true" id="all_bytes_same" name="All bytes the same" pattern="0x00"/> <verification_standards key="standard" type="array">
<standard enabled="true" id="all_bytes_same" pattern="0x00" name="All bytes the same"/>
<standard enabled="true" id="all_ones" name="All ones"/> <standard enabled="true" id="all_ones" name="All ones"/>
<standard selected="true" enabled="true" id="all_sectors_same" name="All sectors the same"/> <standard enabled="true" id="all_sectors_same" selected="true" name="All sectors the same"/>
<standard enabled="true" id="all_zeros" name="All zeros"/> <standard enabled="true" id="all_zeros" name="All zeros"/>
</verification_standards> </verification_standards>
<adapters key="adapter" type="array"/>
<network> <network>
<enabled>true</enabled> <enabled>false</enabled>
<dhcp>true</dhcp> <dhcp>true</dhcp>
<sshd>false</sshd> <sshd>false</sshd>
<looping>false</looping> <looping>false</looping>
<address></address> <address/>
<mask></mask> <mask/>
<gateway></gateway> <gateway/>
<dns1></dns1> <dns1/>
<dns2></dns2> <dns2/>
<vlan></vlan> <vlan/>
</network> </network>
<network_security> <network_security>
<enabled>false</enabled> <enabled>false</enabled>
<fallback>false</fallback> <fallback>false</fallback>
<certificate>false</certificate> <protocols key="protocol" type="array">
<protocols type="array" key="protocol">
<protocol selected="true">peap</protocol> <protocol selected="true">peap</protocol>
<protocol>tls</protocol> <protocol>tls</protocol>
</protocols> </protocols>
<identity encrypted="false"></identity> <certificate>false</certificate>
<password encrypted="false"></password> <identity encrypted="false"/>
<password encrypted="false"/>
</network_security> </network_security>
<proxy>
<proxy_address encrypted="false"/>
<proxy_port encrypted="false"/>
<proxy_username encrypted="false"/>
<proxy_password encrypted="false"/>
</proxy>
<mc>
<username encrypted="false"/>
<password encrypted="false"/>
<hostname/>
<port/>
<timeout>20</timeout>
<validate_remote_certificate>true</validate_remote_certificate>
</mc>
<network_share> <network_share>
<username encrypted="false">blancco</username> <username encrypted="false">blancco</username>
<password encrypted="false">blancco</password> <password encrypted="false">blancco</password>
<domain></domain> <domain/>
<hostname>172.16.9.1</hostname> <hostname>172.16.9.1</hostname>
<path>blancco-reports</path> <path>blancco-reports</path>
<protocols type="array" key="protocol"> <protocols key="protocol" type="array">
<protocol selected="true">smb</protocol> <protocol selected="true">smb</protocol>
</protocols> </protocols>
</network_share> </network_share>
<proxy>
<proxy_address encrypted="false"></proxy_address> <workflow default="false" offline="false"/>
<proxy_port encrypted="false"></proxy_port>
<proxy_username encrypted="false"></proxy_username>
<proxy_password encrypted="false"></proxy_password>
</proxy>
<mc>
<username encrypted="true">k7vMT3SelitU7wIfPSTGZiZa/mNcMlwpPsxGNaBDc2Y1s7VsOBHXEsopfRupMP+shwmLq/sKDIgGR3v9hGrN5A1gmDvvMw/Z+ClZmGIeqhzomR34yUncQA/6GXFq6jc4CNtwXQwKRAvw+stQpE9RYqNvtgFyfmQufOrz75xQyejLXUDmA7KpS6ycdXQxzFKUoqRJOEGlM6I2efjEKkjgKQrvkj8nuRDHw5g5hYXyapKXJhJn7fbwK+AWN+vKx4cceSWVM0wqvofq5BnYj+7hyD5RHphoUI1NqDYnhj2qj8dt3nGKi6EpaXtGJTnXmXplOxbT/PLtZ2afYkxZWRH96negSweOJ4fYkRcJQONe8wGukufSLh/pQcY3NNwkJv7UNh2tC4ogyEKo+E8+n3beImrUWybl6FlUk2lnyQYJFihnnolepze4w5JmPZ2yTEbRi94OUj+bdNEt4ZqRBK2HuDfCI/jzz7h+ytyA2H2+SZWQGjr/oy/RN15a6sgzS5T7Nys+yAbXAH8HSMQm5KUEtLWSSICUpNbRsBOcC+oQhoJnCzWNafj0w5gY/uTNjBm9F0Yd+kjdcO2kqnBF6aeFaLWX6HkTh1DCsMyIJtOr5wZYUmLLtRwgsruhauSbkkQxJA5iy9QsC2Kv+SSACeTLU4WP99Q5MEQIWb+Tjx/S4z6qISgMhxhu4ioteopClaBSizn2oBd/y6gdp7Fz0FK8JSOmPx+pwT3QrDULoK07O5w=</username>
<password encrypted="true">eHTwqUlvjRsTYm5ljXTb8uN4hiG6ceE5OIBO0PA+TSMCwmI9qD6co8tmDHCwRdiDdZH2bssDAW40ZPQNfxTdKbDHZ0hunEcQob0xnrr8668L4OkZlWx5V17k8KXB5QgBUd0v1ALnvp9m2SQbsnP6IljTtIL+cZNnfrpmSrvPoGFe8fuoxwMrRE9PZett0DHuYnsnaaP7lD/hAWy8hONsp6MbegS0YA7vZi56cFSrpFnPwjWbNxYREfO3FY6kxxOZoslGARLKLS9//EA0DmOzN4HcXhKhVsiIIbNS1rUEpZi8PsAjS9EKPBMaD2riKlmQYkcw4gIis8Cdj649TsqG5+VESRtXtGCelaGPKSZJaeh7x3Obh6Me+TYQBN5gx4YBUB8Ao11MJR1i5cZj0vdYLPknIsZ9EaFOtRozIo5S37sd7uhgsLPX9pUKJBiDMy+a/CP07rqlKS7pI4+onhInRapU1hwTaBQuO1DxvjhtPXwoPrz0aadmq3jXREhs1YRiJ0bIpZcTA6c9zWYWBSQQ/kfbBB18BSEBZmKfUq42K/8ZDmGuhS/rVIXKLU14/VL9vMqngMfxZ9rBLzTQobXmkt9dKTO9VKxd90P3j3epTQXQsPZ2hwW1rF7NlRA3il2iL94aRAgOGWGe0P9O18VmKEd9uGKWVjXid6vNKBK5m5JHBMoN5n7eXxo9uKQEn8Ex6CKN7vMVvbms25RcUiHUOg==</password>
<hostname>classic.eu-west-1.blancco.cloud</hostname>
<port>443</port>
<timeout>20</timeout>
<validate_remote_certificate>false</validate_remote_certificate>
</mc>
<workflow offline="false" default="true"></workflow>
<erasure> <erasure>
<drive_self_test type="short">false</drive_self_test>
<max_simultaneous_erasures>50</max_simultaneous_erasures> <max_simultaneous_erasures>50</max_simultaneous_erasures>
<remove_hidden_areas>true</remove_hidden_areas> <remove_hidden_areas>false</remove_hidden_areas>
<preserve_recovery_partition>false</preserve_recovery_partition> <preserve_recovery_partition>false</preserve_recovery_partition>
<store_erasure_progress>false</store_erasure_progress> <store_erasure_progress>false</store_erasure_progress>
<remapped mandatory="false" threshold="0">true</remapped> <remapped mandatory="false" threshold="0">false</remapped>
<action_if_not_possible>interrupt</action_if_not_possible> <action_if_not_possible>interrupt</action_if_not_possible>
<fail_on_remapped_sectors>false</fail_on_remapped_sectors> <fail_on_remapped_sectors>false</fail_on_remapped_sectors>
<fail_on_erasure_errors threshold="5">true</fail_on_erasure_errors> <fail_on_erasure_errors threshold="5">true</fail_on_erasure_errors>
<fail_on_read_errors threshold="5">true</fail_on_read_errors> <fail_on_read_errors threshold="5">true</fail_on_read_errors>
<verification>10</verification> <verification>10</verification>
<enforced_rules type="array" key="rule"> <enforced_rules key="rule" type="array">
<rule enabled="false" name="enforced_standard" standard_id="bl_ssd" target="ssd"/> <rule enabled="false" name="enforced_standard" standard_id="bl_ssd" target="ssd"/>
<rule enabled="true" name="fallback_nist_standard" standard_id="nist_purge" fallback_id="nist_clear"/> <rule enabled="true" name="fallback_nist_standard" standard_id="nist_purge" fallback_id="nist_clear"/>
</enforced_rules> </enforced_rules>
<drive_self_test type="short">false</drive_self_test>
<fail_on_failed_self_test>false</fail_on_failed_self_test> <fail_on_failed_self_test>false</fail_on_failed_self_test>
<fail_on_low_erasure_speed threshold="1">false</fail_on_low_erasure_speed> <fail_on_low_erasure_speed threshold="1">false</fail_on_low_erasure_speed>
<fail_on_timeout threshold="1">false</fail_on_timeout> <fail_on_timeout threshold="1">false</fail_on_timeout>
@@ -202,29 +219,36 @@
<allow_tcg_reset_write_pointers>true</allow_tcg_reset_write_pointers> <allow_tcg_reset_write_pointers>true</allow_tcg_reset_write_pointers>
<allow_tcg_unmap>true</allow_tcg_unmap> <allow_tcg_unmap>true</allow_tcg_unmap>
</erasure> </erasure>
<fingerprint> <fingerprint>
<enabled>false</enabled> <enabled>false</enabled>
<location>67000</location> <location>67000</location>
</fingerprint> </fingerprint>
<bootable_report> <bootable_report>
<enabled>true</enabled> <enabled>true</enabled>
</bootable_report> </bootable_report>
<format_disk> <format_disk>
<enabled>false</enabled> <enabled>false</enabled>
<type>ntfs</type> <type>ntfs</type>
</format_disk> </format_disk>
<spin_down_idle_disk> <spin_down_idle_disk>
<enabled>false</enabled> <enabled>false</enabled>
</spin_down_idle_disk> </spin_down_idle_disk>
<raid> <raid>
<show_logical_disks>true</show_logical_disks> <show_logical_disks>false</show_logical_disks>
<enable_passthrough>true</enable_passthrough> <enable_passthrough>true</enable_passthrough>
<kill_logical_disks>false</kill_logical_disks> <kill_logical_disks>true</kill_logical_disks>
</raid> </raid>
<hotplug> <hotplug>
<enabled>false</enabled> <enabled>false</enabled>
<timeout>60</timeout> <timeout>30</timeout>
</hotplug> </hotplug>
<external_device_service> <external_device_service>
<enabled>false</enabled> <enabled>false</enabled>
<port>80</port> <port>80</port>
@@ -240,24 +264,28 @@
<test enabled="true">webcam</test> <test enabled="true">webcam</test>
</hardware_tests> </hardware_tests>
</external_device_service> </external_device_service>
<license> <license>
<provider>clurichaun</provider> <provider>bios</provider>
<provider_report>clurichaun</provider_report> <provider_report>bios</provider_report>
<edition>Enterprise Volume Edition</edition> <edition>Enterprise Volume Edition</edition>
<offline></offline>
<mode>Drive Eraser</mode> <mode>Drive Eraser</mode>
<offline/>
</license> </license>
<image> <image>
<description>BMP Internet - June 2026</description> <description/>
<creation_date>2026-06-22</creation_date> <creation_date>2025-03-10</creation_date>
</image> </image>
<customer_data> <customer_data>
<business_location></business_location> <business_location/>
<customer_license>GE Aerospace</customer_license> <customer_license>General Electric Company</customer_license>
<erasure_person></erasure_person> <erasure_person/>
<erasure_provider></erasure_provider> <erasure_provider/>
<business_name>Non_GEAE_Wired</business_name> <business_name/>
</customer_data> </customer_data>
<settings> <settings>
<gui> <gui>
<erasure_console>local</erasure_console> <erasure_console>local</erasure_console>
@@ -267,11 +295,11 @@
<ssd_erasure_exception>true</ssd_erasure_exception> <ssd_erasure_exception>true</ssd_erasure_exception>
<report_view locked="true">standard</report_view> <report_view locked="true">standard</report_view>
<erasure_settings locked="false"/> <erasure_settings locked="false"/>
<show_customer>true</show_customer> <show_customer>false</show_customer>
<show_operator>false</show_operator> <show_operator>false</show_operator>
<show_partitions>false</show_partitions> <show_partitions>false</show_partitions>
<screensaver locked="false"> <screensaver locked="false">
<timeout>86400</timeout> <timeout>30</timeout>
<show_exceptions>false</show_exceptions> <show_exceptions>false</show_exceptions>
</screensaver> </screensaver>
<hexviewer> <hexviewer>
@@ -281,7 +309,7 @@
<drive_selection_percentage>5</drive_selection_percentage> <drive_selection_percentage>5</drive_selection_percentage>
<reconfigure_controller_mode> <reconfigure_controller_mode>
<timeout enabled="false">30</timeout> <timeout enabled="false">30</timeout>
<default_focus_on_proceed>false</default_focus_on_proceed> <default_focus_on_proceed>true</default_focus_on_proceed>
</reconfigure_controller_mode> </reconfigure_controller_mode>
<disable_sid_block_authentication> <disable_sid_block_authentication>
<timeout enabled="true">0</timeout> <timeout enabled="true">0</timeout>
@@ -295,8 +323,8 @@
<accessibility> <accessibility>
<available>false</available> <available>false</available>
<enabled>true</enabled> <enabled>true</enabled>
<key_echo>true</key_echo>
<speaking_rate>80</speaking_rate> <speaking_rate>80</speaking_rate>
<key_echo>true</key_echo>
</accessibility> </accessibility>
<sound_level>70</sound_level> <sound_level>70</sound_level>
<input_method> <input_method>
@@ -306,55 +334,82 @@
</input_method> </input_method>
</gui> </gui>
</settings> </settings>
<custom_fields type="array" key="custom_field"/>
<exclude_verification_ranges type="array" key="exclude_verification_range"/>
<report> <report>
<per_drive>false</per_drive> <per_drive>false</per_drive>
<auto_backup>true</auto_backup> <auto_backup>true</auto_backup>
<extensions type="array" key="extension"> <extensions type="array" key="extension">
<extension>xml</extension> <extension selected="true">xml</extension>
<extension selected="true">pdf+xml</extension>
</extensions> </extensions>
<utc_offset>0</utc_offset> <utc_offset>0</utc_offset>
<network_info>true</network_info> <network_info>true</network_info>
<custom_signature> <custom_signature>
<enabled>false</enabled> <enabled>false</enabled>
<label></label> <label/>
</custom_signature> </custom_signature>
<drive_life_estimation>false</drive_life_estimation> <drive_life_estimation>false</drive_life_estimation>
</report> </report>
<custom_fields type="array" key="custom_field">
<custom_field type="input" mandatory="true" locked="false" per_drive="false" show_in_editor="false" pattern="" hint="">
<name>custom_field_1</name>
<title>GERITM Number</title>
<value/>
</custom_field>
<custom_field type="input" mandatory="true" locked="false" per_drive="false" show_in_editor="false" pattern="" hint="">
<name>custom_field_2</name>
<title>Device Name</title>
<value/>
</custom_field>
<custom_field type="input" mandatory="true" locked="false" per_drive="false" show_in_editor="false" pattern="" hint="">
<name>custom_field_3</name>
<title>Device Serial Number</title>
<value/>
</custom_field>
<custom_field type="input" mandatory="false" locked="true" per_drive="false" show_in_editor="false" pattern="" hint="">
<name>custom_field_4</name>
<title>Version</title>
<value>EVE_20250310</value>
</custom_field>
</custom_fields>
<exclude_verification_ranges type="array" key="exclude_verification_range"/>
<wireless> <wireless>
<enabled>true</enabled> <enabled>true</enabled>
<ssid encrypted="false"></ssid> <ssid encrypted="false">INTERNETACCESS</ssid>
<password encrypted="false"></password> <password encrypted="false">Acc3ssGr@nted22</password>
<key_mgmt>WPA-PSK</key_mgmt> <key_mgmt>WPA-PSK</key_mgmt>
<scan_ssid>0</scan_ssid> <scan_ssid>0</scan_ssid>
</wireless> </wireless>
<cd_eject> <cd_eject>
<after_boot>false</after_boot> <after_boot>true</after_boot>
<after_erasure>false</after_erasure> <after_erasure>false</after_erasure>
<after_report>false</after_report> <after_report>false</after_report>
<at_shutdown>false</at_shutdown> <at_shutdown>false</at_shutdown>
</cd_eject> </cd_eject>
<system_shutdown> <system_shutdown>
<after_erasure>false</after_erasure> <after_erasure>false</after_erasure>
<after_successful_erasure>false</after_successful_erasure> <after_successful_erasure>false</after_successful_erasure>
</system_shutdown> </system_shutdown>
<system_restart> <system_restart>
<after_erasure>false</after_erasure> <after_erasure>false</after_erasure>
<after_successful_erasure>false</after_successful_erasure> <after_successful_erasure>false</after_successful_erasure>
</system_restart> </system_restart>
<device_enrollment_detection>
<persistent_software>false</persistent_software>
</device_enrollment_detection>
<vnc_remote> <vnc_remote>
<enabled>false</enabled> <enabled>false</enabled>
<mode>viewer</mode> <mode>viewer</mode>
<repeater_hostname></repeater_hostname> <repeater_hostname/>
<port>5900</port> <port>5900</port>
<password encrypted="false"></password> <password encrypted="false"/>
</vnc_remote> </vnc_remote>
<nvme_over_fabrics enabled="false" type="array" key="connection"/>
<nvme_over_fabrics type="array" key="connection" enabled="false"/>
<update> <update>
<enabled>false</enabled> <enabled>false</enabled>
<intervals type="array" key="interval"> <intervals type="array" key="interval">
@@ -365,6 +420,6 @@
</intervals> </intervals>
<url>https://cdn.blancco.com/products/drive_eraser/download/ota/drive_eraser.json</url> <url>https://cdn.blancco.com/products/drive_eraser/download/ota/drive_eraser.json</url>
</update> </update>
<adapters type="array" key="adapter"/>
</configuration> </configuration>
</root> </root>
<!-- JFLKHGDJLKFHGJCXLKGJFLKDJKLHFELIUFDSALDSAHGLSAVSHGSOFHDSAF -->

View File

@@ -1,285 +0,0 @@
#!/usr/bin/env python3
"""Report Blancco erase progress to the PXE webapp's /imaging dashboard.
Runs inside the Blancco rootfs (installed by blancco-init.sh as a systemd
service). Follows the AlbusSeven journal and maps its log lines onto the
same /imaging/status payload the WinPE imaging clients post, so each
wiping PC gets a tile keyed by BIOS serial with pctype "blancco".
Stages (stage_total 7): 1 booting and 2 starting are posted by
blancco-init.sh; this script posts 3 waiting for licence, 4 erasing,
5 report saved, 6 report sent / on share, 7 done. A failed erasure step
marks the session failed.
The final state comes from the report itself, not the journal: outside
Blancco's debug mode Albus does not log "erasureFinishedForEntity",
"Report saving success" or "Auto process reached the final step", so a
watcher on /reports (where auto mode always saves the report) reads each
new report and posts 7 "Wipe complete" or "Erasure FAILED".
Usage: blancco-progress.py [--replay FILE] [--dry-run]
"""
import glob
import json
import os
import re
import subprocess
import sys
import threading
import time
import urllib.request
import xml.etree.ElementTree as ET
STATUS_URL = "http://172.16.9.1:9009/imaging/status"
CLOUD_URL = "http://172.16.9.1:9009/reports/cloud-status"
STAGE_TOTAL = 7
# Progress lines arrive in bursts; post at most this often unless the
# stage itself changes.
MIN_POST_INTERVAL = 3.0
def read(path):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return ""
def pxe_iface():
"""(mac, ip) of the interface on the PXE LAN, or blanks."""
try:
out = subprocess.run(["ip", "-o", "-4", "addr"], capture_output=True,
text=True, timeout=5).stdout
except (OSError, subprocess.SubprocessError):
return "", ""
for line in out.splitlines():
m = re.search(r"^\d+:\s+(\S+)\s+inet (172\.16\.9\.\d+)/", line)
if m:
return read(f"/sys/class/net/{m.group(1)}/address"), m.group(2)
return "", ""
REPORTS_DIR = "/reports"
def report_outcome(path):
"""(wiped: bool, failed_drives: list[str]) from a Blancco report XML.
Per drive: wiped if any of its erasures succeeded, so a failed NIST
Purge followed by a successful Clear fallback still counts as wiped.
None if the report has no erasures (hardware-only report).
"""
root = ET.parse(path).getroot()
drives = {}
for i, er in enumerate(root.iter("entries")):
if er.get("name") != "erasure":
continue
state, serial, model = "", "", ""
for e in er:
if e.tag == "entry" and e.get("name") == "state":
state = (e.text or "").strip()
if e.tag == "entries" and e.get("name") == "target":
for t in e:
if t.get("name") == "serial":
serial = (t.text or "").strip()
elif t.get("name") == "model":
model = (t.text or "").strip()
key = serial or f"#{i}"
ok = state.lower() == "successful"
prev = drives.get(key, (False, ""))
drives[key] = (prev[0] or ok, f"{model} {serial}".strip())
if not drives:
return None
failed = [label for ok, label in drives.values() if not ok]
return (not failed, failed)
SERIAL = read("/sys/class/dmi/id/product_serial") or read("/etc/machine-id")
MODEL = " ".join(x for x in (read("/sys/class/dmi/id/sys_vendor"),
read("/sys/class/dmi/id/product_name")) if x)
# (pattern, stage_index, stage text or callable(match) -> text, status)
RULES = [
(re.compile(r"Waiting for MC connectivity"), 3,
"Waiting for licence (cloud)", None),
(re.compile(r"ClurichaunLicenseProvider: ([1-9]\d*) licenses available for product id 1\b"), 3,
lambda m: f"Licence OK ({m.group(1)} left)", None),
(re.compile(r"startErasure: success"), 4, "Erasing", None),
(re.compile(r"About to start Step (\d+) \((.*?)\)\.?$"), 4,
lambda m: f"Erasing: step {m.group(1)} - {m.group(2).split(' or ')[0][:60]}", None),
(re.compile(r"Step \d+ \(.*\) failed|Erasure step .* failed\b|erasure failed", re.I), 4,
"Erasure FAILED", "failed"),
(re.compile(r"erasureFinishedForEntity"), 5, "Erasure finished", None),
(re.compile(r"\[report\.manager\].*Report saving success"), 5, "Report saved", None),
(re.compile(r"Report sent to MC"), 6, "Report sent to cloud", None),
(re.compile(r"Auto process reached the final step"), 7, "Done", "succeeded"),
]
# Cloud upload of a report: "[comm.manager] Sending report document id: <id>"
# then "Report sent to MC" (or an upload failure).
SENDING = re.compile(r"Sending report document id: ([0-9a-fA-F-]{36})")
SENT = re.compile(r"Report sent to MC")
SEND_FAILED = re.compile(r"(report (upload|send|sending)[^\n]*fail|failed to (send|upload) report)", re.I)
# Drive progress, e.g. "FORMAT NVM progress: 75%". Excludes the self-test
# completion and volume lines that also carry a percentage.
PROGRESS = re.compile(r"\bprogress[^%\d]{0,20}(\d{1,3})%", re.I)
class Reporter:
def __init__(self, dry_run=False):
self.dry_run = dry_run
self.mac, self.ip = pxe_iface()
self.stage_index = 2
self.stage = "Blancco started"
self.status = "in_progress"
self.last_post = 0.0
self.pending = []
def post(self, force=False):
now = time.monotonic()
if not force and now - self.last_post < MIN_POST_INTERVAL:
return
payload = {
"serial": SERIAL,
"mac": self.mac,
"ip": self.ip,
"pctype": "blancco",
"hostname_target": MODEL,
"current_stage": self.stage,
"stage_index": self.stage_index,
"stage_total": STAGE_TOTAL,
"status": self.status,
"source": "blancco",
}
if self.pending:
payload["log_lines"] = self.pending[-20:]
if self.dry_run:
print(json.dumps(payload))
else:
req = urllib.request.Request(
STATUS_URL, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=5).read()
except OSError:
return # webapp unreachable: keep the lines for the next post
self.pending = []
self.last_post = now
def cloud(self, document_id, sent, detail):
"""Tell the webapp whether a report reached the Blancco cloud."""
payload = {"document_id": document_id, "serial": SERIAL,
"sent": sent, "detail": detail[:200]}
if self.dry_run:
print(json.dumps({"cloud": payload}))
return
req = urllib.request.Request(
CLOUD_URL, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=5).read()
except OSError:
pass
def line(self, text):
text = text.strip()
if not text:
return
m = SENDING.search(text)
if m:
self.sending = m.group(1)
elif getattr(self, "sending", None):
if SENT.search(text):
self.cloud(self.sending, True, text)
self.sending = None
elif SEND_FAILED.search(text):
self.cloud(self.sending, False, text)
self.sending = None
for pat, idx, stage, status in RULES:
m = pat.search(text)
if not m:
continue
if self.status == "failed" and status != "failed":
return # a failure sticks for the rest of the run
if idx < self.stage_index and status != "failed":
return # late licence chatter must not rewind the tile
new_stage = stage(m) if callable(stage) else stage
changed = (idx, new_stage) != (self.stage_index, self.stage)
self.stage_index = max(self.stage_index, idx) if status != "failed" else idx
self.stage = new_stage
if status:
self.status = status
self.pending.append(text[:200])
if changed:
self.post(force=True)
return
m = PROGRESS.search(text)
if m and self.stage_index == 4 and "Self-Test" not in text:
base = self.stage.split(" [")[0]
self.stage = f"{base} [{m.group(1)}%]"
self.post()
def finish(self, path):
"""Final stage from a finished report on disk."""
try:
outcome = report_outcome(path)
except (OSError, ET.ParseError):
return False
if outcome is None:
return True # nothing erased in this report; keep watching
wiped, failed = outcome
name = os.path.basename(path)
self.stage_index = 7
if wiped:
self.stage, self.status = "Wipe complete", "succeeded"
else:
self.stage, self.status = "Erasure FAILED: " + ", ".join(failed)[:120], "failed"
self.pending.append(f"Report {name}: {'all drives wiped' if wiped else 'failed: ' + ', '.join(failed)}")
self.post(force=True)
return True
def watch_reports(rep, interval=5, stable=5):
"""Post the final stage for each new report Albus saves in /reports."""
seen = set(glob.glob(os.path.join(REPORTS_DIR, "*.xml")))
while True:
time.sleep(interval)
for path in sorted(glob.glob(os.path.join(REPORTS_DIR, "*.xml"))):
if path in seen:
continue
try:
st = os.stat(path)
except OSError:
continue
if st.st_size == 0 or time.time() - st.st_mtime < stable:
continue # Albus may still be writing it
if rep.finish(path):
seen.add(path)
def main():
dry_run = "--dry-run" in sys.argv
rep = Reporter(dry_run=dry_run)
if "--outcome" in sys.argv:
print(report_outcome(sys.argv[sys.argv.index("--outcome") + 1]))
return
if "--replay" in sys.argv:
with open(sys.argv[sys.argv.index("--replay") + 1]) as f:
for text in f:
rep.line(text)
rep.post(force=True)
return
rep.post(force=True)
threading.Thread(target=watch_reports, args=(rep,), daemon=True).start()
proc = subprocess.Popen(
["journalctl", "-f", "-b", "-o", "cat", "-t", "AlbusSeven", "-n", "all"],
stdout=subprocess.PIPE, text=True, bufsize=1)
for text in proc.stdout:
rep.line(text)
if rep.status in ("succeeded", "failed") and rep.stage_index == STAGE_TOTAL:
rep.post(force=True)
if __name__ == "__main__":
main()

View File

@@ -1,28 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIE0zCCA7ugAwIBAgIJANu+mC2Jt3uTMA0GCSqGSIb3DQEBCwUAMIGhMQswCQYD
VQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTERMA8GA1UEBxMIU2FuIEpvc2Ux
FTATBgNVBAoTDFpzY2FsZXIgSW5jLjEVMBMGA1UECxMMWnNjYWxlciBJbmMuMRgw
FgYDVQQDEw9ac2NhbGVyIFJvb3QgQ0ExIjAgBgkqhkiG9w0BCQEWE3N1cHBvcnRA
enNjYWxlci5jb20wHhcNMTQxMjE5MDAyNzU1WhcNNDIwNTA2MDAyNzU1WjCBoTEL
MAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExETAPBgNVBAcTCFNhbiBK
b3NlMRUwEwYDVQQKEwxac2NhbGVyIEluYy4xFTATBgNVBAsTDFpzY2FsZXIgSW5j
LjEYMBYGA1UEAxMPWnNjYWxlciBSb290IENBMSIwIAYJKoZIhvcNAQkBFhNzdXBw
b3J0QHpzY2FsZXIuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
qT7STSxZRTgEFFf6doHajSc1vk5jmzmM6BWuOo044EsaTc9eVEV/HjH/1DWzZtcr
fTj+ni205apMTlKBW3UYR+lyLHQ9FoZiDXYXK8poKSV5+Tm0Vls/5Kb8mkhVVqv7
LgYEmvEY7HPY+i1nEGZCa46ZXCOohJ0mBEtB9JVlpDIO+nN0hUMAYYdZ1KZWCMNf
5J/aTZiShsorN2A38iSOhdd+mcRM4iNL3gsLu99XhKnRqKoHeH83lVdfu1XBeoQz
z5V6gA3kbRvhDwoIlTBeMa5l4yRdJAfdpkbFzqiwSgNdhbxTHnYYorDzKfr2rEFM
dsMU0DHdeAZf711+1CunuQIDAQABo4IBCjCCAQYwHQYDVR0OBBYEFLm33UrNww4M
hp1d3+wcBGnFTpjfMIHWBgNVHSMEgc4wgcuAFLm33UrNww4Mhp1d3+wcBGnFTpjf
oYGnpIGkMIGhMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTERMA8G
A1UEBxMIU2FuIEpvc2UxFTATBgNVBAoTDFpzY2FsZXIgSW5jLjEVMBMGA1UECxMM
WnNjYWxlciBJbmMuMRgwFgYDVQQDEw9ac2NhbGVyIFJvb3QgQ0ExIjAgBgkqhkiG
9w0BCQEWE3N1cHBvcnRAenNjYWxlci5jb22CCQDbvpgtibd7kzAMBgNVHRMEBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw0NdJh8w3NsJu4KHuVZUrmZgIohnTm0j+
RTmYQ9IKA/pvxAcA6K1i/LO+Bt+tCX+C0yxqB8qzuo+4vAzoY5JEBhyhBhf1uK+P
/WVWFZN/+hTgpSbZgzUEnWQG2gOVd24msex+0Sr7hyr9vn6OueH+jj+vCMiAm5+u
kd7lLvJsBu3AO3jGWVLyPkS3i6Gf+rwAp1OsRrv3WnbkYcFf9xjuaf4z0hRCrLN2
xFNjavxrHmsH8jPHVvgc1VD0Opja0l/BRVauTrUaoW6tE+wFG5rEcPGS80jjHK4S
pB5iDj2mUZH1T8lzYtuZy0ZPirxmtsk3135+CKNa2OCAhhFjE0xd
-----END CERTIFICATE-----

View File

@@ -1,473 +0,0 @@
#!/bin/bash
# PXE Clonezilla: back up or restore a PC by its machine number.
#
# The iPXE menu (GetPxeScript.aspx, "Clonezilla: back up / restore this
# PC") boots Clonezilla live with:
# ocs_prerun="wget ... -O /tmp/pxe-cz.sh http://<server>/clonezilla-scripts/pxe-cz.sh"
# ocs_repository="smb3://clonezilla:<password>@<server>/clonezilla/"
# ocs_live_run="bash /tmp/pxe-cz.sh"
# pxe_cz_mode=save|restore pxe_machine=<typed text> pxe_end=1
# Clonezilla runs the prerun (this download), mounts the repository on
# /home/partimag, then runs this script as root on the console.
#
# Images are named <machine>-<YYYY-MM-DD-HHMM> (server time, site zone),
# with pxe-meta.txt (machine, serial, model, disk, date, Clonezilla
# version, 32/64-bit, check result) next to Clonezilla's own files. The
# PXE webapp (Tools > Clonezilla backups) reads both.
#
# Runs under Clonezilla live's bash (3.3.3 amd64 and 3.2.0-5 i686, both
# Debian with coreutils, util-linux, dmidecode, wget). Plain ASCII only.
set -u
# PXE_CZ_* overrides exist only for testing this script off Clonezilla.
OCSROOT="${PXE_CZ_OCSROOT:-/home/partimag}"
CMDLINE_FILE="${PXE_CZ_CMDLINE:-/proc/cmdline}"
SYS_BLOCK="${PXE_CZ_SYS_BLOCK:-/sys/block}"
SITE_TZ="America/New_York"
SERVER="$(sed -n 's/.*ocs_prerun="[^"]*http:\/\/\([^/":]*\).*/\1/p' "$CMDLINE_FILE" 2>/dev/null)"
[ -n "$SERVER" ] || SERVER=172.16.9.1
CMDLINE=" $(cat "$CMDLINE_FILE" 2>/dev/null) "
# --- output helpers -------------------------------------------------------
line() { echo "======================================================================"; }
banner() {
echo
line
for l in "$@"; do echo " $l"; done
line
echo
}
# Ask what to do at the end. Success: power off after 60 s unless a key is
# pressed. Failure: wait, the operator has to read the message.
finish() {
local ok="$1" ans
sync
while true; do
echo
echo " [Enter] power off r = reboot s = shell (type exit to come back)"
if [ "$ok" = "yes" ]; then
echo -n " Powering off in 60 seconds... "
if ! read -r -t 60 ans; then ans=""; echo; fi
else
echo -n " Choice: "
read -r ans || ans=""
fi
case "$ans" in
r|R) umount "$OCSROOT" 2>/dev/null; sync; reboot; sleep 30 ;;
s|S) bash -i ;;
*) umount "$OCSROOT" 2>/dev/null; sync; poweroff; sleep 30 ;;
esac
done
}
fail() {
banner "FAILED: $1" "${@:2}"
finish no
exit 1
}
# --- machine number -------------------------------------------------------
# Letters, digits and dash, starting with a letter or digit, up to 32.
valid_machine() {
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,31}$ ]]
}
# The text typed at the iPXE prompt sits between pxe_machine= and the
# pxe_end=1 sentinel, so a typed space shows up here and is rejected
# instead of being split into another kernel parameter.
machine_from_cmdline() {
local rest
case "$CMDLINE" in
*" pxe_machine="*" pxe_end=1 "*) ;;
*) echo ""; return ;;
esac
rest="${CMDLINE#* pxe_machine=}"
echo "${rest%% pxe_end=1 *}"
}
# Sets MACHINE (upper case). Not run in $(...): finish() reads the
# keyboard and must write to the console.
get_machine() {
local m
m="$(machine_from_cmdline)"
if [ -z "$m" ] || ! valid_machine "$m"; then
if [ -n "$m" ]; then
banner "The machine number typed at the boot menu is not valid:" \
" [$m]" "Use letters, digits and dash only (for example 6501 or WJ-6501)."
else
banner "No machine number was given at the boot menu."
fi
while true; do
echo -n " Machine number (letters, digits, dash; empty = stop): "
read -r m || m=""
[ -n "$m" ] || finish no
valid_machine "$m" && break
echo " [$m] is not a valid machine number: use letters, digits and dash only."
done
fi
MACHINE="$(echo "$m" | tr '[:lower:]' '[:upper:]')"
}
# --- time -----------------------------------------------------------------
# The PC's clock is often wrong (dead CMOS battery on old PCs, local time
# on Windows PCs), so take the time from the PXE server's HTTP Date header
# and set the live system clock from it. Falls back to the PC clock.
TIME_SOURCE="pc-clock"
sync_clock() {
local hdr epoch
hdr="$(wget -q -S --spider -T 10 -t 2 "http://$SERVER/clonezilla-scripts/pxe-cz.sh" 2>&1 \
| sed -n 's/^ *Date: *//p' | head -1)"
[ -n "$hdr" ] || return 0
epoch="$(date -u -d "$hdr" +%s 2>/dev/null)" || return 0
[ -n "$epoch" ] || return 0
date -u -s "@$epoch" >/dev/null 2>&1 && TIME_SOURCE="pxe-server"
}
site_date() {
if [ -e "/usr/share/zoneinfo/$SITE_TZ" ]; then
TZ="$SITE_TZ" date "$@"
else
date "$@"
fi
}
# --- hardware -------------------------------------------------------------
dmi() {
local v
v="$(dmidecode -s "$1" 2>/dev/null | grep -v '^#' | head -1 | tr -d '\r' | sed 's/^ *//; s/ *$//')"
echo "$v"
}
# Whole disks a backup can come from or a restore can go to: no CD, loop,
# RAM, zram, device-mapper, md, eMMC boot areas, or empty card readers.
list_disks() {
local d n size
for d in "$SYS_BLOCK"/*; do
n="${d##*/}"
case "$n" in
loop*|sr*|ram*|zram*|dm-*|md*|fd*|nbd*|*boot[0-9]|*rpmb) continue ;;
esac
size="$(cat "$d/size" 2>/dev/null)"
[ -n "$size" ] && [ "$size" -gt 0 ] 2>/dev/null || continue
echo "$n"
done
}
disk_bytes() { echo $(( $(cat "$SYS_BLOCK/$1/size" 2>/dev/null || echo 0) * 512 )); }
disk_model() {
local m
m="$(cat "$SYS_BLOCK/$1/device/model" 2>/dev/null | sed 's/^ *//; s/ *$//')"
[ -n "$m" ] || m="$(lsblk -dn -o MODEL "/dev/$1" 2>/dev/null | sed 's/^ *//; s/ *$//')"
[ -n "$m" ] || m="unknown model"
echo "$m"
}
disk_tran() { lsblk -dn -o TRAN "/dev/$1" 2>/dev/null | sed 's/ //g'; }
human() {
local b="$1"
awk -v b="$b" 'BEGIN { if (b >= 1e12) printf "%.1f TB", b/1e12; else printf "%.0f GB", b/1e9 }'
}
describe_disk() {
local n="$1" t
t="$(disk_tran "$n")"
echo "/dev/$n $(human "$(disk_bytes "$n")") $(disk_model "$n")${t:+ ($t)}"
}
# pick_disk "prompt" -> disk name on stdout. One disk: that one.
pick_disk() {
local prompt="$1" disks n i ans
mapfile -t disks < <(list_disks)
if [ "${#disks[@]}" -eq 0 ]; then
echo ""
return
fi
if [ "${#disks[@]}" -eq 1 ]; then
echo "${disks[0]}"
return
fi
while true; do
{
echo
echo " $prompt"
i=1
for n in "${disks[@]}"; do
echo " $i) $(describe_disk "$n")"
i=$((i + 1))
done
echo -n " Number (1-${#disks[@]}): "
} >&2
read -r ans || ans=""
if [[ "$ans" =~ ^[0-9]+$ ]] && [ "$ans" -ge 1 ] && [ "$ans" -le "${#disks[@]}" ]; then
echo "${disks[$((ans - 1))]}"
return
fi
echo " Type a number from the list." >&2
done
}
live_version() {
local v=""
if [ -e /etc/ocs/ocs-live.conf ]; then
# shellcheck disable=SC1091
v="$(. /etc/ocs/ocs-live.conf >/dev/null 2>&1; echo "${drbl_ocs_live_ver:-}")"
fi
[ -n "$v" ] || v="clonezilla $(dpkg-query -W -f='${Version}' clonezilla 2>/dev/null)"
echo "$v"
}
bits() {
case "$(uname -m)" in
x86_64|amd64) echo 64 ;;
*) echo 32 ;;
esac
}
# set_meta FILE KEY VALUE: replace or add one key=value line.
set_meta() {
local f="$1" k="$2" v="$3" tmp
v="$(echo "$v" | tr -d '\r\n')"
tmp="$(mktemp /tmp/pxe-meta.XXXXXX)"
[ -e "$f" ] && grep -v "^$k=" "$f" > "$tmp"
echo "$k=$v" >> "$tmp"
cat "$tmp" > "$f"
rm -f "$tmp"
}
get_meta() {
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1
}
repo_ready() {
mountpoint -q "$OCSROOT" 2>/dev/null || grep -qs " $OCSROOT cifs " /proc/mounts
}
# --- backup ---------------------------------------------------------------
do_save() {
local m="$1" disk stamp img n meta rc chk
disk="$(pick_disk "This PC has more than one disk. Which one do you want to back up?")"
[ -n "$disk" ] || fail "No disk found to back up." "Check the disk is connected and shows in the BIOS."
stamp="$(site_date +%Y-%m-%d-%H%M)"
img="$m-$stamp"
n=2
while [ -e "$OCSROOT/$img" ]; do
img="$m-$stamp-$n"
n=$((n + 1))
done
banner "BACKUP of machine $m" \
"Disk: $(describe_disk "$disk")" \
"Image: $img" \
"To: \\\\$SERVER\\clonezilla\\$img"
ocs-sr -q2 -batch -j2 -z9p -i 0 -sfsck -scs -senc -p command savedisk "$img" "$disk"
rc=$?
meta="$OCSROOT/$img/pxe-meta.txt"
if [ ! -d "$OCSROOT/$img" ]; then
fail "Clonezilla did not create the image $img (exit code $rc)." \
"See the messages above. Nothing was saved."
fi
: > "$meta"
set_meta "$meta" machine "$m"
set_meta "$meta" date "$(site_date '+%Y-%m-%d %H:%M %Z')"
set_meta "$meta" date_utc "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
set_meta "$meta" time_source "$TIME_SOURCE"
set_meta "$meta" serial "$(dmi system-serial-number)"
set_meta "$meta" manufacturer "$(dmi system-manufacturer)"
set_meta "$meta" model "$(dmi system-product-name)"
set_meta "$meta" disk "$disk"
set_meta "$meta" disk_model "$(disk_model "$disk")"
set_meta "$meta" disk_size_bytes "$(disk_bytes "$disk")"
set_meta "$meta" clonezilla "$(live_version)"
set_meta "$meta" bits "$(bits)"
if [ -d /sys/firmware/efi ]; then set_meta "$meta" firmware uefi; else set_meta "$meta" firmware bios; fi
if [ "$rc" -ne 0 ]; then
set_meta "$meta" save failed
set_meta "$meta" check not-run
fail "Clonezilla could not save machine $m (exit code $rc)." \
"The partial image $img is marked as failed on the share;" \
"delete it in the webapp (Tools > Clonezilla backups) and try again."
fi
set_meta "$meta" save ok
banner "Checking the saved image $img ..."
ocs-chkimg -b "$img"
chk=$?
set_meta "$meta" check_date "$(site_date '+%Y-%m-%d %H:%M %Z')"
if [ "$chk" -ne 0 ]; then
set_meta "$meta" check failed
fail "The image $img was saved but its CHECK FAILED (exit code $chk)." \
"Do not rely on it. Back this PC up again."
fi
set_meta "$meta" check ok
sync
banner "SUCCESS: machine $m backed up and checked." \
"Image: $img" \
"Serial: $(get_meta "$meta" serial) Model: $(get_meta "$meta" model)"
finish yes
}
# --- restore --------------------------------------------------------------
is_cz_image() {
local d="$1"
[ -d "$d" ] || return 1
[ -f "$d/disk" ] || [ -f "$d/parts" ] || return 1
local f
for f in "$d"/*; do
case "${f##*/}" in
*-ptcl-img*|*.dd-img*|*-img.*|clonezilla-img) return 0 ;;
esac
done
return 1
}
# Images of one machine, newest first: <machine>-YYYY-MM-DD-HHMM[-n] by
# name, plus any image whose pxe-meta.txt says machine=<machine>.
machine_images() {
local m="$1" d n mm
for d in "$OCSROOT"/*/; do
d="${d%/}"
n="${d##*/}"
is_cz_image "$d" || continue
mm="$(get_meta "$d/pxe-meta.txt" machine | tr '[:lower:]' '[:upper:]')"
if [ "$mm" = "$m" ]; then
echo "$n"
elif [ -z "$mm" ] && echo "$n" | tr '[:lower:]' '[:upper:]' | grep -qE "^$m-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}(-[0-9]+)?$"; then
echo "$n"
fi
done | sort_newest
}
# Sort names by their date stamp (the last YYYY-MM-DD-HHMM[-n]), newest first.
sort_newest() {
local n key
while read -r n; do
key="$(echo "$n" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}(-[0-9]+)?$')"
echo "${key:-0000}|$n"
done | sort -r | cut -d'|' -f2-
}
image_disks() { tr -s ' \t' '\n' < "$OCSROOT/$1/disk" 2>/dev/null | grep -v '^$'; }
image_disk_bytes() {
local img="$1" src="$2" b sec
b="$(get_meta "$OCSROOT/$img/pxe-meta.txt" disk_size_bytes)"
if [[ "$b" =~ ^[0-9]+$ ]]; then echo "$b"; return; fi
# Clonezilla's sfdisk dump: "last-lba: N" (GPT) or the partitions' end.
sec="$(sed -n 's/^last-lba: *//p' "$OCSROOT/$img/$src-pt.sf" 2>/dev/null | head -1)"
if [[ "$sec" =~ ^[0-9]+$ ]]; then echo $(( (sec + 34) * 512 )); return; fi
echo 0
}
do_restore() {
local m="$1" imgs i n ans img src srcb tgt tgtb meta chkopt rc mo se ck
mapfile -t imgs < <(machine_images "$m")
if [ "${#imgs[@]}" -eq 0 ]; then
fail "No images for machine $m on \\\\$SERVER\\clonezilla." \
"Check the machine number, or look in the webapp (Tools > Clonezilla backups)."
fi
while true; do
echo
echo " Images of machine $m (newest first):"
i=1
for n in "${imgs[@]}"; do
meta="$OCSROOT/$n/pxe-meta.txt"
mo="$(get_meta "$meta" model)"; se="$(get_meta "$meta" serial)"; ck="$(get_meta "$meta" check)"
echo " $i) $n ${mo:-model ?} serial ${se:-?} check: ${ck:-?}"
i=$((i + 1))
done
echo -n " Image to restore (1-${#imgs[@]}, q = quit): "
read -r ans || ans=q
case "$ans" in q|Q) finish no ;; esac
if [[ "$ans" =~ ^[0-9]+$ ]] && [ "$ans" -ge 1 ] && [ "$ans" -le "${#imgs[@]}" ]; then
img="${imgs[$((ans - 1))]}"
break
fi
echo " Type a number from the list."
done
meta="$OCSROOT/$img/pxe-meta.txt"
if [ "$(get_meta "$meta" save)" = "failed" ]; then
fail "The image $img is marked as a FAILED backup and cannot be restored."
fi
mapfile -t src < <(image_disks "$img")
if [ "${#src[@]}" -ne 1 ]; then
fail "The image $img holds ${#src[@]} disks (${src[*]:-none})." \
"Restore it with Clonezilla: manual (boot menu)."
fi
srcb="$(image_disk_bytes "$img" "${src[0]}")"
tgt="$(pick_disk "Which disk do you want to restore $img to? (ALL DATA ON IT WILL BE ERASED)")"
[ -n "$tgt" ] || fail "No disk found to restore to."
tgtb="$(disk_bytes "$tgt")"
if [ "$srcb" -gt 0 ] && [ "$tgtb" -lt "$srcb" ]; then
fail "The target disk is SMALLER than the disk the image came from." \
"Image disk: $(human "$srcb")" "Target disk: $(describe_disk "$tgt")" \
"Use a disk at least as large, or Clonezilla: manual (expert mode, -icds)."
fi
banner "RESTORE machine $m" \
"Image: $img (saved $(get_meta "$meta" date))" \
"From: $(get_meta "$meta" model) serial $(get_meta "$meta" serial) disk $(human "$srcb")" \
"Target: $(describe_disk "$tgt")" \
"" \
"EVERYTHING on /dev/$tgt will be ERASED and replaced by this image." \
"This PC: $(dmi system-product-name) serial $(dmi system-serial-number)"
echo -n " Type YES (capitals) to erase /dev/$tgt and restore: "
read -r ans || ans=""
if [ "$ans" != "YES" ]; then
banner "Cancelled. Nothing was written."
finish no
fi
# A larger target: -k1 recreates the partition table proportionally
# (every partition scaled by target/source size) and -r grows each
# filesystem to its new partition, so the extra space is used. The
# default (-k0) copies the table as-is and leaves the rest unallocated.
# The image was checked after it was saved; check again only if that
# check did not pass.
chkopt=""
[ "$(get_meta "$meta" check)" = "ok" ] && chkopt="-scr"
ocs-sr -g auto -e1 auto -e2 -r -j2 -k1 $chkopt -batch -p command restoredisk "$img" "$tgt"
rc=$?
if [ "$rc" -ne 0 ]; then
fail "Clonezilla could not restore $img to /dev/$tgt (exit code $rc)." \
"The disk may be partly written: do not boot from it until a restore succeeds."
fi
sync
banner "SUCCESS: $img restored to /dev/$tgt." "At the next start, boot this PC from its disk (not the network)."
finish yes
}
# --- main -----------------------------------------------------------------
MODE="$(echo "$CMDLINE" | sed -n 's/.* pxe_cz_mode=\([a-z]*\) .*/\1/p')"
case "$MODE" in
save|restore) ;;
*) fail "Unknown mode [$MODE] on the kernel command line (pxe_cz_mode=save or restore)." ;;
esac
if ! repo_ready; then
fail "The backup share \\\\$SERVER\\clonezilla is not mounted on $OCSROOT." \
"Check the PXE server is up and the clonezilla Samba account works" \
"(playbook var clonezilla_smb_password), then boot this option again."
fi
sync_clock
MACHINE=""
get_machine
case "$MODE" in
save) do_save "$MACHINE" ;;
restore) do_restore "$MACHINE" ;;
esac

View File

@@ -1,190 +0,0 @@
# Install-EngineeringLayer.ps1 - install the Engineering Layer before the MCL
# orchestrator's cleanup removes C:\Deploy, and make sure the employee gets
# the "Configure my Engineering Layer" icon.
#
# THE RACE THIS SOLVES
# The bulk-enrollment orchestrator registers a DeleteDeployDir startup task that
# does "rmdir /s /q C:\Drivers & rmdir /s /q C:\GEA & rmdir /s /q C:\Deploy" on
# the NEXT boot. The engineering payload (529 MB installer + .NET 3.5 sxs + VC++
# redists, 653 MB total) lives under C:\Deploy\Applications\extra_engineering.
# Standard/Engineer builds have no autologon, so a PC that reboots between OOBE
# and the employee's first sign-in loses C:\Deploy before the first-logon
# install - and with it this script, which used to be called from there too.
# Then nothing installed, no error anywhere, and no icon.
#
# So, in two steps (both from FlatUnattendW10-engineer.xml):
# specialize -StageOnly : copy the payload AND this script to
# C:\PreInstall\engineering while C:\Deploy certainly exists.
# first logon (no switch, run from C:\PreInstall\engineering): install
# from the staged copy. C:\Deploy is not needed any more.
# Run from C:\Deploy (an older unattend) it still stages first, as before.
#
# THE ICON
# Engineering_Install_v3.1 copies "Configure my Engineering Layer.lnk" to
# C:\Users\Public\Desktop. The employee clicks it once (on the GE network: it
# maps V: and runs install_local_new.cmd from there), which writes
# C:\TMP\DUU_Complete.log and deletes the Public icon. After the install this
# script puts the icon back if it is missing and the PC is not configured
# yet, and registers an Active Setup component: at each user's first sign-in
# Windows runs Ensure-EngineeringIcon.ps1 in that user's session, which puts
# the icon on that user's desktop if the PC is still not configured and the
# Public icon is gone (someone else clicked it, or it never appeared).
#
# Idempotent. Safe to re-run: it skips the copy when the staged payload is
# already complete and skips the install when the marker is present (the
# icon check still runs).
[CmdletBinding()]
param(
[string]$Source = 'C:\Deploy\Applications\extra_engineering',
# C:\PreInstall is already the convention for payload that must outlive
# imaging - the unattend installs PowerShell 7 from C:\PreInstall\installers.
[string]$StagingRoot = 'C:\PreInstall\engineering',
[string]$LogDir = 'C:\Logs\Engineering',
# Relative to StagingRoot. Empty: the newest
# engineering_layer\Engineering_Install_v<version>.exe (uploaded on the
# PXE webapp, engineer image > Engineering Layer).
[string]$Installer = '',
[switch]$StageOnly
)
$ErrorActionPreference = 'Continue'
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
$log = Join-Path $LogDir 'install-engineering-layer.log'
$marker = Join-Path $StagingRoot '.installed'
$IconName = 'Configure my Engineering Layer.lnk'
$IconSource = 'C:\TMP\Engineering_Install\Imaging\packages\' + $IconName
$PublicIcon = 'C:\Users\Public\Desktop\' + $IconName
$Configured = 'C:\TMP\DUU_Complete.log' # written by install_local_new.cmd
$ActiveSetup = 'HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\GEA-EngineeringLayerIcon'
function Write-EngLog {
param([string]$Message, [string]$Level = 'INFO')
$line = '[{0}] [{1}] [{2}] {3}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $env:USERNAME, $Message
Add-Content -Path $log -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
# Icon for whoever signs in next. Called after every install attempt that
# left the layer installed (fresh or already marked).
function Set-EngineeringIcon {
if (Test-Path -LiteralPath $Configured) {
Write-EngLog 'PC already configured (DUU_Complete.log) - no icon needed.'
return
}
if (-not (Test-Path -LiteralPath $IconSource)) {
Write-EngLog "Icon source missing: $IconSource (installer layout changed?)" 'ERROR'
return
}
if (Test-Path -LiteralPath $PublicIcon) {
Write-EngLog 'Public desktop icon present.'
} else {
Copy-Item -LiteralPath $IconSource -Destination $PublicIcon -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $PublicIcon) { Write-EngLog 'Public desktop icon was missing - restored.' 'WARN' }
else { Write-EngLog "Could not restore $PublicIcon" 'ERROR' }
}
# Per-user fallback, run by Windows once per user at first sign-in.
$ensure = Join-Path $StagingRoot 'Ensure-EngineeringIcon.ps1'
$body = @(
'# Written by Install-EngineeringLayer.ps1; run once per user by Active Setup.'
'# Puts "Configure my Engineering Layer" on this user''s desktop when the PC'
'# is not configured yet and the Public desktop icon is gone.'
"`$src = '$IconSource'"
"if (Test-Path -LiteralPath '$Configured') { exit 0 }"
"if (Test-Path -LiteralPath '$PublicIcon') { exit 0 }"
'if (-not (Test-Path -LiteralPath $src)) { exit 0 }'
'$desk = [Environment]::GetFolderPath(''Desktop'')'
'if ($desk) { Copy-Item -LiteralPath $src -Destination (Join-Path $desk (Split-Path $src -Leaf)) -Force -ErrorAction SilentlyContinue }'
'exit 0'
)
Set-Content -Path $ensure -Value $body -Encoding ascii -Force
New-Item -Path $ActiveSetup -Force | Out-Null
Set-ItemProperty -Path $ActiveSetup -Name '(Default)' -Value 'GE Aerospace Engineering Layer icon'
Set-ItemProperty -Path $ActiveSetup -Name 'Version' -Value '1,0'
Set-ItemProperty -Path $ActiveSetup -Name 'IsInstalled' -Value 1 -Type DWord
Set-ItemProperty -Path $ActiveSetup -Name 'StubPath' -Value ('powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $ensure + '"')
Write-EngLog 'Active Setup registered: each new user gets the icon if the PC is still unconfigured.'
}
Write-EngLog '=== Install-EngineeringLayer starting ==='
Write-EngLog "Source: $Source"
Write-EngLog "Staged: $StagingRoot"
Write-EngLog "Running from: $PSCommandPath"
if (Test-Path -LiteralPath $marker) {
Write-EngLog 'Marker present - Engineering Layer already installed.'
if (-not $StageOnly) { Set-EngineeringIcon }
exit 0
}
# --- stage the payload out of C:\Deploy -------------------------------------
function Find-Installer([string]$Dir) {
Get-ChildItem -LiteralPath (Join-Path $Dir 'engineering_layer') -Filter 'Engineering_Install_v*.exe' -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^Engineering_Install_v(\d+(\.\d+)*)\.exe$' } |
Sort-Object { $v = ($_.Name -replace '^Engineering_Install_v', '') -replace '\.exe$', ''; if ($v -notmatch '\.') { $v += '.0' }; [version]$v } -Descending |
Select-Object -First 1
}
if (-not $Installer) {
$found = Find-Installer $StagingRoot
if (-not $found) { $found = Find-Installer $Source }
$Installer = if ($found) { 'engineering_layer\' + $found.Name } else { 'engineering_layer\Engineering_Install_v3.1.exe' }
}
Write-EngLog "Installer: $Installer"
$stagedExe = Join-Path $StagingRoot $Installer
if (Test-Path -LiteralPath $stagedExe) {
Write-EngLog 'Payload already staged outside C:\Deploy - skipping copy.'
} else {
if (-not (Test-Path -LiteralPath $Source)) {
Write-EngLog "Source not found: $Source" 'ERROR'
Write-EngLog 'C:\Deploy was removed (DeleteDeployDir) before the payload was staged. Re-stage from the share:' 'ERROR'
Write-EngLog ' \\172.16.9.1\winpeapps\gea-engineer\Deploy\Applications\extra_engineering' 'ERROR'
exit 2
}
Write-EngLog 'Copying payload out of C:\Deploy (this is the whole point - do not skip)'
New-Item -ItemType Directory -Force -Path $StagingRoot | Out-Null
# robocopy, not Copy-Item: 653 MB, restartable, and it reports properly.
# Exit codes 0-7 are success, 8+ are failure - inverted vs everything else.
# The copy includes this script, so first logon can run it from here.
$rc = Start-Process -FilePath 'robocopy.exe' `
-ArgumentList @("`"$Source`"", "`"$StagingRoot`"", '/E', '/R:2', '/W:5', '/NFL', '/NDL', '/NJH', '/NJS') `
-Wait -PassThru -WindowStyle Hidden
if ($rc.ExitCode -ge 8) {
Write-EngLog "robocopy failed with exit $($rc.ExitCode) - payload NOT staged" 'ERROR'
exit 3
}
Write-EngLog "Payload staged (robocopy exit $($rc.ExitCode))"
}
if (-not (Test-Path -LiteralPath $stagedExe)) {
Write-EngLog "Installer missing after staging: $stagedExe" 'ERROR'
exit 4
}
if ($StageOnly) {
Write-EngLog 'StageOnly requested - payload is safe from cleanup, not installing.'
exit 0
}
# --- install -----------------------------------------------------------------
# The prereqs (.NET 3.5 sxs + VC++ redists) ship alongside; v3.1 installs them
# itself and is idempotent, so they are staged for it rather than invoked here.
Write-EngLog "Running $Installer (this takes a while - .NET 3.5, VC++, Cygwin, DUU)"
try {
$p = Start-Process -FilePath $stagedExe -ArgumentList '/S' -Wait -PassThru
Write-EngLog "Installer exit code: $($p.ExitCode)"
if ($p.ExitCode -eq 0 -or $p.ExitCode -eq 3010) {
if ($p.ExitCode -eq 3010) { Write-EngLog 'Installer requests a reboot (3010)' 'WARN' }
Set-Content -Path $marker -Value (Get-Date -Format 'o') -Encoding ascii -Force
Write-EngLog 'Engineering Layer installed. Marker written.'
Set-EngineeringIcon
exit 0
}
Write-EngLog "Installer returned $($p.ExitCode) - NOT marking as installed" 'ERROR'
exit 5
} catch {
Write-EngLog "Installer failed to start: $_" 'ERROR'
exit 6
}

View File

@@ -1,96 +0,0 @@
# Send-ImagingStage.ps1 - posts one progress step of a gea-standard /
# gea-engineer build to the PXE webapp's /imaging/status endpoint, so the
# imaging floor follows these builds past WinPE (the shopfloor build has its
# own reporting in Shopfloor\lib\Send-PxeStatus.ps1).
#
# Called from the image's FlatUnattendW10.xml (specialize RunSynchronous and
# oobeSystem FirstLogonCommands). Keep those command lines short: Windows
# rejects the whole unattend if a RunSynchronous Path exceeds 259 characters
# (check with scripts/lint-unattend.py --local). Deployed to
# _shared\Applications\extra\pxe-status\ on the PXE server, which PESetup
# copies to C:\Deploy\Applications\extra\pxe-status\.
#
# Best-effort: never blocks or fails Windows setup. Every error is swallowed,
# the script always exits 0, and each attempt is logged to
# C:\Logs\send-imaging-stage.log.
#
# Three steps: 1 WinPE, 2 Windows setup installing apps, 3 ready at OOBE
# (succeeded). Everything after OOBE needs the employee to log in, which
# happens off the PXE network, so PXE cannot see it. Steps above
# StageTotal (the old first-logon steps 4 and 5 still in the unattends) are
# logged and not posted, so they cannot reopen a finished build.
param(
[Parameter(Mandatory = $true)][string]$Stage,
[int]$StageIndex = 0,
[int]$StageTotal = 3,
[string]$PCType = '',
[ValidateSet('in_progress', 'succeeded', 'failed')][string]$Status = 'in_progress',
[string]$PxeServer = '172.16.9.1',
[int]$Port = 9009,
[int]$TimeoutSec = 5,
# Copy this script to C:\ProgramData\PxeStatus first: the bulk-enrollment
# task deletes C:\Deploy before first logon, when the later steps run.
[switch]$Persist
)
$logFile = 'C:\Logs\send-imaging-stage.log'
function Log([string]$msg) {
try {
if (-not (Test-Path 'C:\Logs')) { New-Item -ItemType Directory -Path 'C:\Logs' -Force | Out-Null }
"$(Get-Date -Format s) $msg" | Out-File -FilePath $logFile -Append -Encoding utf8
} catch { }
}
if ($Persist) {
try {
New-Item -ItemType Directory -Path 'C:\ProgramData\PxeStatus' -Force | Out-Null
Copy-Item -LiteralPath $PSCommandPath -Destination 'C:\ProgramData\PxeStatus\Send-ImagingStage.ps1' -Force
} catch { Log "persist failed: $($_.Exception.Message)" }
}
if ($StageIndex -gt $StageTotal) {
Log "SKIP stage $StageIndex of $StageTotal (after OOBE; PXE does not track it): $Stage"
exit 0
}
try {
$serial = $null
try { $serial = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber } catch {
try { $serial = (Get-WmiObject -Class Win32_BIOS -ErrorAction Stop).SerialNumber } catch { }
}
if ([string]::IsNullOrWhiteSpace($serial)) { $serial = $env:COMPUTERNAME }
$serial = ($serial -as [string]).Trim()
$mac = $null
try {
$nic = Get-NetAdapter -Physical -ErrorAction Stop |
Where-Object { $_.Status -eq 'Up' -and $_.MediaType -eq '802.3' } | Select-Object -First 1
if ($nic) { $mac = $nic.MacAddress -replace '-', ':' }
} catch { }
$payload = @{
serial = $serial
hostname_target = $env:COMPUTERNAME
current_stage = $Stage
stage_index = $StageIndex
stage_total = $StageTotal
status = $Status
}
if ($PCType) { $payload.pctype = $PCType }
if ($mac) { $payload.mac = $mac }
$body = $payload | ConvertTo-Json -Compress
$uri = "http://${PxeServer}:${Port}/imaging/status"
try {
$resp = Invoke-WebRequest -Uri $uri -Method POST -Body $body -ContentType 'application/json' `
-UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop
Log "OK $($resp.StatusCode) $body"
} catch {
# Off the imaging network (or the server is down): nothing to report to.
Log "FAIL $($_.Exception.Message) $body"
}
} catch {
Log "ERROR $($_.Exception.Message)"
}
exit 0

View File

@@ -1,198 +0,0 @@
#!ipxe
set server 172.16.9.1
:menu
menu GE Aerospace PXE Boot Menu
item --gap -- ---- Windows Deployment ----
item winpe Windows PE (Image Deployment)
item --gap -- ---- Drive Erase ----
item blancco Blancco Drive Eraser
item blancco_debug Blancco Drive Eraser (debug / SSH)
item --gap -- ---- Utilities ----
item cz_backup Clonezilla: back up this PC
item cz_restore Clonezilla: restore this PC
item clonezilla Clonezilla: manual (USB or other location)
item clonezilla32 Clonezilla: manual, 32-bit (XP-era PCs, forced)
item --gap -- Memory test: Dell PCs press F12 at power-on, Diagnostics
item --gap -- ----
item reboot Reboot
item exit Exit to BIOS
choose --default winpe --timeout 30000 target && goto ${target}
:winpe
# iPXE 2.0 reads the firmware variable; older iPXE cannot (empty), so it
# warns as before. Secure Boot confirmed on: no warning, no wait.
iseq ${efi/SecureBoot:uint8} 1 && goto winpe_boot ||
iseq ${efi/SecureBoot:hex} 01 && goto winpe_boot ||
echo
echo Secure Boot is OFF on this PC (or this iPXE cannot tell).
echo (iPXE read SecureBoot as: [${efi/SecureBoot:hex}] [${efi/SecureBoot:uint8}])
echo Windows deployment requires Secure Boot to be ENABLED.
echo If you disabled it earlier, re-enable it in BIOS Setup now.
echo
prompt --timeout 5000 Press any key to continue (auto-boot in 5s)... && goto winpe_boot || goto winpe_boot
:winpe_boot
kernel http://${server}/win11/wimboot gui
initrd http://${server}/win11/EFI/Microsoft/Boot/boot.stl EFI/Microsoft/Boot/Boot.stl
initrd http://${server}/win11/EFI/Microsoft/Boot/BCD EFI/Microsoft/Boot/BCD
initrd http://${server}/win11/EFI/Boot/bootx64.efi EFI/Boot/bootx64.efi
initrd http://${server}/win11/Boot/boot.sdi Boot/boot.sdi
initrd http://${server}/win11/sources/boot.wim sources/boot.wim
# A command that fails without || aborts the whole iPXE script (the PC
# drops to firmware with no explanation), so every boot line has one.
boot || goto winpe_failed
:winpe_failed
echo
echo Windows PE boot FAILED (see the error above).
echo Check that the PXE server is up and the win11 boot files are staged.
prompt Press any key to return to menu... && goto menu || goto menu
# --- Clonezilla by machine number (back up / restore) ---------------------
# The image share is \\${server}\clonezilla (Samba account clonezilla,
# the playbook fills in its password). Clonezilla mounts it on
# /home/partimag (ocs_repository, SMB 3), downloads
# clonezilla-scripts/pxe-cz.sh (ocs_prerun) and runs it (ocs_live_run):
# it checks the machine number, saves <machine>-<YYYY-MM-DD-HHMM> or lists
# that machine's images to restore, and writes pxe-meta.txt.
:cz_backup
set cz_mode save
goto cz_machine
:cz_restore
set cz_mode restore
goto cz_machine
:cz_machine
# iPXE cannot check characters: pxe-cz.sh accepts letters, digits and dash
# and asks again there. Empty: ask again. Esc / Ctrl-C: back to the menu.
echo
# iPXE drops a trailing space from a command line, so the space after the
# colon comes from a setting (0x20) rather than the literal text.
set sp:hex 20
echo -n Machine number:${sp:string} && read czmachine || goto menu
isset ${czmachine} || goto cz_machine
# Same CPU check as the manual entry below (cpuid --ext 29 = 64-bit).
cpuid --ext 29 && goto cz_auto64 || goto cz_auto32
:cz_auto64
set base http://${server}/clonezilla
kernel ${base}/vmlinuz boot=live username=user union=overlay config components noswap edd=on nomodeset nodmraid locales=en_US.UTF-8 keyboard-layouts=us ocs_prerun="wget -q -T 15 -t 5 -O /tmp/pxe-cz.sh http://${server}/clonezilla-scripts/pxe-cz.sh" ocs_repository="smb3://clonezilla:__CLONEZILLA_SMB_PASSWORD__@${server}/clonezilla/" echo_ocs_repository=no ocs_live_run="bash /tmp/pxe-cz.sh" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=${base}/filesystem.squashfs pxe_cz_mode=${cz_mode} pxe_machine=${czmachine} pxe_end=1 || goto secureboot_warn
initrd ${base}/initrd.img || goto boot_failed
# Secure Boot ON: Debian's shim, as in :clonezilla64 below.
shim ${base}/shimx64.efi ||
boot || goto secureboot_warn
:cz_auto32
# 3.2.0-5 i686 over legacy BIOS only; UEFI goes to the 64-bit build.
iseq ${platform} efi && goto cz_auto64 ||
set base http://${server}/clonezilla-i686
kernel ${base}/vmlinuz boot=live username=user union=overlay config components noswap edd=on nomodeset nodmraid locales=en_US.UTF-8 keyboard-layouts=us ocs_prerun="wget -q -T 15 -t 5 -O /tmp/pxe-cz.sh http://${server}/clonezilla-scripts/pxe-cz.sh" ocs_repository="smb3://clonezilla:__CLONEZILLA_SMB_PASSWORD__@${server}/clonezilla/" echo_ocs_repository=no ocs_live_run="bash /tmp/pxe-cz.sh" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=${base}/filesystem.squashfs pxe_cz_mode=${cz_mode} pxe_machine=${czmachine} pxe_end=1 || goto boot_failed
initrd ${base}/initrd.img || goto boot_failed
boot || goto boot_failed
# --- Clonezilla manual (interactive: USB, SSH, NFS or any share) ------------
:clonezilla
# 64-bit CPUs (cpuid extended bit 29 = long mode) get the current Clonezilla;
# older 32-bit-only CPUs (Pentium 4 without EM64T, Pentium M, early Atom:
# many XP-era PCs) get the last 32-bit release. If this iPXE has no cpuid
# command the check fails and the 32-bit build is used, which also runs
# on 64-bit CPUs.
cpuid --ext 29 && goto clonezilla64 || goto clonezilla32
:clonezilla64
set base http://${server}/clonezilla
kernel ${base}/vmlinuz boot=live username=user union=overlay config components noswap edd=on nomodeset nodmraid locales= keyboard-layouts= ocs_live_run="ocs-live-general" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=${base}/filesystem.squashfs || goto secureboot_warn
initrd ${base}/initrd.img || goto boot_failed
# Secure Boot ON: Clonezilla runs a Debian-signed kernel, started through
# Debian's Microsoft-signed shim (from the Clonezilla release, staged as
# clonezilla/shimx64.efi by prepare-boot-tools.sh), the same way Blancco
# uses Ubuntu's. Its modules (usb-storage, uas, exfat) are signed by the
# same build key, so USB drives work. Without the shim command (legacy
# BIOS, old iPXE) the line fails harmlessly and the kernel boots directly.
shim ${base}/shimx64.efi ||
boot || goto secureboot_warn
:clonezilla32
# Clonezilla 3.2.0-5 i686 (the last 32-bit release; later ones are amd64
# only), for legacy-BIOS XP-era PCs. A UEFI PC is always 64-bit and its
# firmware cannot start a 32-bit kernel, so UEFI goes to the 64-bit build.
iseq ${platform} efi && goto clonezilla64 ||
set base http://${server}/clonezilla-i686
kernel ${base}/vmlinuz boot=live username=user union=overlay config components noswap edd=on nomodeset nodmraid locales= keyboard-layouts= ocs_live_run="ocs-live-general" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=${base}/filesystem.squashfs || goto boot_failed
initrd ${base}/initrd.img || goto boot_failed
boot || goto boot_failed
:blancco
iseq ${platform} pcbios && goto blancco_bios || goto blancco_efi
:blancco_bios
echo Loading Blancco for legacy BIOS (canonical)...
kernel http://${server}/blancco/vmlinuz-bde-linux initrd=initramfs-bde-linux.img,config.img,intel-ucode.img,amd-ucode.img quiet loglevel=3 archisobasedir=arch archiso_http_srv=http://${server}/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=btusb rd.udev.timeout=10 systemd.battery-check=0 || goto blancco_bios_failed
initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto blancco_bios_failed
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_bios_failed
initrd --name config.img http://${server}/blancco/config.img || goto blancco_bios_failed
initrd --name initramfs-bde-linux.img http://${server}/blancco/initramfs-bde-linux.img || goto blancco_bios_failed
boot || goto blancco_bios_failed
:blancco_bios_failed
echo
echo Blancco BIOS boot FAILED.
prompt Press any key to return to menu... && goto menu
:blancco_debug
iseq ${platform} pcbios && goto blancco_bios ||
echo Loading Blancco UEFI (debug: verbose, SSH for root)...
kernel http://${server}/blancco/vmlinuz-ubuntu-70 initrd=kexec-initrd-70.img ip=dhcp nomodeset vga=normal console=tty0 i915.modeset=0 nouveau.modeset=0 modprobe.blacklist=nouveau,nvidiafb,nvidia,nvidia_drm,mei_me,btusb,dcdbas libata.allow_tpm=1 rd.udev.timeout=10 efi=noruntime usbcore.autosuspend=-1 pcie_aspm=off e1000e.IntMode=0 e1000e.eee_enable=0 consoleblank=0 systemd.battery-check=0 systemd.mask=suspend.target,sleep.target,hibernate.target,hybrid-sleep.target no_console_suspend mem_sleep_default=s2idle blancco.debug=1 || goto blancco_efi_grub
initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto blancco_efi_grub
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_efi_grub
initrd --name kexec-initrd-70.img http://${server}/blancco/kexec-initrd-70.img || goto blancco_efi_grub
# Secure Boot ON: iPXE 2.0 starts the Canonical-signed kernel through
# Ubuntu's Microsoft-signed shim. Older iPXE has no shim command: the
# line fails harmlessly and the kernel boots directly (Secure Boot off).
shim http://${server}/blancco/shimx64.efi ||
boot || goto blancco_efi_grub
:blancco_efi
echo Loading Blancco UEFI (direct kernel)...
kernel http://${server}/blancco/vmlinuz-ubuntu-70 initrd=kexec-initrd-70.img ip=dhcp quiet loglevel=3 nomodeset vga=normal console=tty0 i915.modeset=0 nouveau.modeset=0 modprobe.blacklist=nouveau,nvidiafb,nvidia,nvidia_drm,mei_me,btusb,dcdbas libata.allow_tpm=1 rd.udev.timeout=10 efi=noruntime usbcore.autosuspend=-1 pcie_aspm=off e1000e.IntMode=0 e1000e.eee_enable=0 consoleblank=0 systemd.battery-check=0 systemd.mask=suspend.target,sleep.target,hibernate.target,hybrid-sleep.target no_console_suspend mem_sleep_default=s2idle || goto blancco_efi_grub
initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto blancco_efi_grub
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_efi_grub
initrd --name kexec-initrd-70.img http://${server}/blancco/kexec-initrd-70.img || goto blancco_efi_grub
# Secure Boot ON: iPXE 2.0 starts the Canonical-signed kernel through
# Ubuntu's Microsoft-signed shim. Older iPXE has no shim command: the
# line fails harmlessly and the kernel boots directly (Secure Boot off).
shim http://${server}/blancco/shimx64.efi ||
boot || goto blancco_efi_grub
:blancco_efi_grub
echo Direct kernel boot failed - trying GRUB chain fallback...
chain http://${server}/blancco/grubx64.efi || goto secureboot_warn
:boot_failed
echo
echo Download FAILED (see the error above). Check the PXE server and retry.
prompt Press any key to return to menu... && goto menu || goto menu
:secureboot_warn
echo
echo ======================================================
echo This option could not start with Secure Boot enabled.
echo
echo 1. Reboot this machine
echo 2. Press F2 / Del to enter BIOS Setup
echo 3. Disable Secure Boot
echo 4. Save and exit BIOS
echo 5. PXE boot again and select this option
echo
echo Re-enable Secure Boot after completing the task.
echo ======================================================
echo
prompt Press any key to return to menu...
goto menu
:reboot
reboot
:exit
exit

View File

@@ -0,0 +1,23 @@
@echo off
REM Install-AcroReader.cmd - Install Adobe Acrobat Reader DC + DC update patch
REM
REM Two-step install: base MSI with enterprise transform, then DC update patch.
REM Same version as the GEA-Engineering image (specialize pass Orders 22-23).
REM Files staged by startnet.cmd from enrollment share to C:\PreInstall\installers\adobe\.
echo Installing Adobe Acrobat Reader DC...
msiexec /i "%~dp0AcroRead.msi" TRANSFORMS="%~dp0AcroRead.mst" /quiet /norestart
if %errorlevel% neq 0 if %errorlevel% neq 3010 (
echo Acrobat Reader MSI failed with exit code %errorlevel%
exit /b %errorlevel%
)
echo Applying Adobe Reader DC update patch...
msiexec /p "%~dp0AcroRdrDCUpd2500120531.msp" /quiet /norestart
if %errorlevel% neq 0 if %errorlevel% neq 3010 (
echo Acrobat Reader patch failed with exit code %errorlevel%
exit /b %errorlevel%
)
echo Adobe Acrobat Reader DC installed successfully.
exit /b 0

View File

@@ -0,0 +1,43 @@
@echo off
REM Install-KioskApp.cmd - Install Lobby Display or Dashboard kiosk app
REM
REM Reads C:\Enrollment\display-type.txt to determine which app to install.
REM Only runs on Display PCs (PCTypes filter in preinstall.json).
REM Both installers live alongside this script in the staged display\ subtree.
set "TYPEFILE=C:\Enrollment\display-type.txt"
if not exist "%TYPEFILE%" (
echo No display-type.txt found - skipping kiosk app install.
exit /b 0
)
set /p DISPLAYTYPE=<"%TYPEFILE%"
if /i "%DISPLAYTYPE%"=="Lobby" (
set "INSTALLER=%~dp0GEAerospaceLobbyDisplaySetup.exe"
set "APPNAME=Lobby Display"
) else if /i "%DISPLAYTYPE%"=="Dashboard" (
set "INSTALLER=%~dp0GEAerospaceDashboardSetup.exe"
set "APPNAME=Dashboard"
) else (
echo Unknown display type: %DISPLAYTYPE%
exit /b 1
)
if not exist "%INSTALLER%" (
echo %APPNAME% installer not found: %INSTALLER%
exit /b 1
)
echo Installing %APPNAME%...
"%INSTALLER%" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /LOG="C:\Logs\PreInstall\%APPNAME%-install.log"
set EXITCODE=%errorlevel%
if %EXITCODE% equ 0 (
echo %APPNAME% installed successfully.
) else (
echo %APPNAME% exited with code %EXITCODE%.
)
exit /b %EXITCODE%

View File

@@ -0,0 +1,46 @@
@echo off
REM Install-Drivers.cmd - Adds bundled INF driver packages to the Windows driver store
REM via pnputil. Runs from C:\PreInstall\installers\drivers\ where each vendor
REM subdir (prolific\, startech\) sits alongside this script. /subdirs walks into
REM them so one call covers every bundled driver.
REM
REM Writes C:\ProgramData\PXEDrivers\drivers-installed.marker on success so the
REM preinstall runner's File DetectionMethod can skip re-runs.
setlocal
set LOGDIR=C:\Logs\PreInstall
set LOG=%LOGDIR%\Install-Drivers.log
set MARKER_DIR=C:\ProgramData\PXEDrivers
set MARKER=%MARKER_DIR%\drivers-installed.marker
if not exist "%LOGDIR%" mkdir "%LOGDIR%"
if not exist "%MARKER_DIR%" mkdir "%MARKER_DIR%"
echo ================================================================ >> "%LOG%"
echo [%date% %time%] Install-Drivers session start >> "%LOG%"
echo Source dir: %~dp0 >> "%LOG%"
echo ================================================================ >> "%LOG%"
REM pnputil /add-driver with /subdirs recurses into vendor subdirs. /install
REM binds the driver to any matching hardware present now; drivers without a
REM matching device still land in the driver store and bind on plug-in later.
pnputil.exe /add-driver "%~dp0*.inf" /subdirs /install >> "%LOG%" 2>&1
set RC=%ERRORLEVEL%
echo [%date% %time%] pnputil exit code: %RC% >> "%LOG%"
REM pnputil exit codes:
REM 0 success
REM 259 ERROR_NO_MORE_ITEMS - no INFs matched (treat as failure)
REM 3010 at least one driver installed, reboot recommended (treat as success)
if "%RC%"=="0" goto :ok
if "%RC%"=="3010" goto :ok
echo [%date% %time%] FAILED (exit %RC%) >> "%LOG%"
exit /b %RC%
:ok
echo [%date% %time%] SUCCESS >> "%LOG%"
echo installed %date% %time% > "%MARKER%"
exit /b 0

View File

@@ -0,0 +1,38 @@
# OpenText HostExplorer ShopFloor - install scripts
These three files are git-tracked snapshots of what lives at runtime
in `/home/camp/pxe-images/main/dependencies/opentext/`. The full
runtime tree also contains the bundled installer binaries (~106 MB
total) which are intentionally NOT in git:
```
OpenTextHostExplorer15x64.msi ~16 MB
OpenTextHostExplorer15x64.cab ~86 MB
OpenTextHostExplorer15x64_ServicePack1.msp ~4.4 MB
ShopFloorx64.mst ~20 KB
Profile/ 5 .hep connection profiles
Accessories/EB/ 6 .ebs/.ebx macros
HostExplorer/Keymap/ 2 .kmv keymaps
HostExplorer/Menu/ 2 .hmv menu layouts
W10shortcuts/ 4 .lnk public-desktop shortcuts
```
The canonical source for everything (scripts AND binaries) is
`/home/camp/pxe-images/main/dependencies/opentext/`. The files in
this dir are mirrors that exist so changes to the install logic and
the version stamp end up in git history. When editing:
1. Edit `/home/camp/pxe-images/main/dependencies/opentext/Setup-OpenText.ps1`
(or `.cmd` / `version.txt`)
2. Re-run `bash playbook/sync-preinstall.sh` to push the runtime tree
to the live PXE server
3. `cp` the changed file(s) into `playbook/preinstall/opentext/` here
so git picks up the change
4. Re-upload the changed file(s) to Azure Blob at
`prod/main/dependencies/opentext/<filename>`
To bump OpenText version: edit `version.txt` only - the value flows
through to Setup-OpenText.ps1 (which reads it at runtime), to
Install-OpenText.ps1 (the DSC wrapper, which downloads version.txt
first as a cheap detection check), and to the registry marker at
`HKLM:\SOFTWARE\GE\OpenText\Installed`.

View File

@@ -0,0 +1,11 @@
@echo off
REM Setup-OpenText.cmd - tiny launcher for Setup-OpenText.ps1
REM
REM The PXE PreInstall runner only invokes Type:MSI or Type:EXE entries from
REM preinstall.json. This .cmd lives next to Setup-OpenText.ps1 in the staged
REM opentext\ subtree and just hands off to PowerShell with bypass policy.
REM Both files end up at C:\PreInstall\installers\opentext\ via xcopy /E from
REM the WinPE staging step in startnet.cmd.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Setup-OpenText.ps1"
exit /b %errorlevel%

View File

@@ -0,0 +1,361 @@
#!/usr/bin/env pwsh
# Setup-OpenText.ps1 - OpenText HostExplorer 15 SP1 ShopFloor installer + profile
# deployment, callable from both PXE PreInstall and Intune DSC paths.
#
# WHY THIS EXISTS:
# The vendor-supplied OpenText.exe (Inno Setup wrapper built by WJDT) bundles
# the install steps but its [Files] section deploys per-user content to
# {userappdata} - which resolves to SYSTEM's profile under DSC and to a single
# user under PreInstall. As a result the operator (logging in via Azure AD)
# never sees the profiles, keymaps, menus, or macros - only the installed
# binaries. This script replaces OpenText.exe entirely, doing the same install
# steps via direct msiexec calls AND fanning the per-user content out to:
# - %ProgramData%\Hummingbird\Connectivity\15.00\Shared\
# - C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00\
# - Each existing user profile under C:\Users\
#
# INVOKED BY:
# - PreInstall: Setup-OpenText.cmd wrapper (because the runner only knows MSI/EXE)
# - DSC: Install-OpenText.ps1 downloads the bundled tree from blob, then
# invokes this script with -SourceDir <temp dir>
#
# DETECTION:
# Skips if HKLM:\SOFTWARE\GE\OpenText\Installed = $expectedVersion. Marker is
# written at the end of a successful run, so the runner / DSC wrapper can
# no-op on subsequent invocations.
[CmdletBinding()]
param(
# Override when invoked from a temp dir (DSC path) where the bundled files were
# just downloaded. Defaults to $PSScriptRoot, but resolved INSIDE the script body
# below - PowerShell evaluates `param([string]$X = $PSScriptRoot)` at parameter-
# binding time, when $PSScriptRoot may not yet be populated, so the default winds
# up as an empty string. Setting it in the body works because $PSScriptRoot is
# reliably populated by then.
[string]$SourceDir
)
$ErrorActionPreference = 'Stop'
if (-not $SourceDir) {
$SourceDir = $PSScriptRoot
}
# Normalize $SourceDir to a canonical absolute path. The CMD shim passes
# "%~dp0..\apps\opentext" which embeds a literal "..". msiexec / the Windows
# Installer service fail to open the package with that unresolved segment
# (exit 1619, ERROR_INSTALL_PACKAGE_OPEN_FAILED), even though every other
# .NET / PowerShell API resolves it fine. Resolve-Path collapses ".." into
# a clean drive-rooted path before any msiexec invocation.
if (Test-Path -LiteralPath $SourceDir) {
try { $SourceDir = (Resolve-Path -LiteralPath $SourceDir).ProviderPath } catch {}
}
# --- Inline site-config reader (this script runs from C:\PreInstall\installers\opentext\,
# NOT from C:\Enrollment\shopfloor-setup\, so it can't dot-source Get-PCProfile.ps1) ---
function Get-SiteConfig {
$configPath = 'C:\Enrollment\site-config.json'
if (-not (Test-Path $configPath)) { return $null }
try {
return Get-Content $configPath -Raw | ConvertFrom-Json
} catch {
return $null
}
}
# --- Logging (set up FIRST so any startup error - missing version.txt, broken
# bundled file, etc. - lands in the log file instead of disappearing into the
# runner's stdout void) ---
$logDir = 'C:\Logs\PreInstall'
$logFile = Join-Path $logDir 'Setup-OpenText.log'
$msiLog = Join-Path $logDir 'Setup-OpenText-msi.log'
$mspLog = Join-Path $logDir 'Setup-OpenText-msp.log'
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
function Write-SetupLog {
param([string]$Message)
$line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message"
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
Write-SetupLog "================================================================"
Write-SetupLog "=== Setup-OpenText.ps1 starting ==="
Write-SetupLog "================================================================"
Write-SetupLog "SourceDir: $SourceDir"
Write-SetupLog "PSScriptRoot: $PSScriptRoot"
Write-SetupLog "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
# --- Config ---
# Version is read from version.txt next to this script. ONE source of truth: bumping
# version.txt is the only edit needed when shipping a new OpenText build. Setup-
# OpenText.ps1 itself, Install-OpenText.ps1 (DSC wrapper), and the registry marker
# all derive their notion of "expected version" from this file.
$versionFile = Join-Path $SourceDir 'version.txt'
Write-SetupLog "Looking for version.txt at: $versionFile"
if (-not (Test-Path $versionFile)) {
Write-SetupLog "ERROR: version.txt not found in $SourceDir - cannot determine expected version."
Write-SetupLog "Directory listing of $SourceDir :"
if (Test-Path $SourceDir) {
Get-ChildItem $SourceDir -ErrorAction SilentlyContinue | ForEach-Object {
Write-SetupLog " $($_.Name) $($_.Length) bytes"
}
} else {
Write-SetupLog " (SourceDir does not exist)"
}
exit 1
}
$expectedVersion = (Get-Content -Path $versionFile -Raw -ErrorAction Stop).Trim()
if (-not $expectedVersion) {
Write-SetupLog "ERROR: version.txt at $versionFile is empty."
exit 1
}
Write-SetupLog "Expected version (from version.txt): $expectedVersion"
# --- Detection: skip if already deployed at expected version ---
$markerKey = 'HKLM:\SOFTWARE\GE\OpenText'
$markerVal = 'Installed'
if (Test-Path $markerKey) {
$installed = (Get-ItemProperty -Path $markerKey -Name $markerVal -ErrorAction SilentlyContinue).$markerVal
if ($installed -eq $expectedVersion) {
Write-SetupLog "OpenText $expectedVersion already deployed (marker present) - skipping."
exit 0
}
Write-SetupLog "OpenText marker mismatch (found '$installed', expected '$expectedVersion') - re-deploying."
}
else {
Write-SetupLog "OpenText marker not present - first install."
}
# --- Verify bundled files are where we expect ---
$msiPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.msi'
$cabPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.cab'
$mspPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64_ServicePack1.msp'
$mstPath = Join-Path $SourceDir 'ShopFloorx64.mst'
foreach ($f in @($msiPath, $cabPath, $mspPath, $mstPath)) {
if (-not (Test-Path $f)) {
Write-SetupLog "ERROR: required file not found: $f"
exit 1
}
}
# --- Step 1: Install the base MSI with the ShopFloor transform ---
# NOTE: We deliberately do NOT pass REBOOT=ReallySuppress here even though we do
# for the VC++ MSIs. OpenText HostExplorer installs shell extensions that hook
# explorer.exe, and the MSI uses Restart Manager to ask explorer to close so the
# in-use shell DLLs can be replaced. With REBOOT=ReallySuppress, RM closes
# explorer.exe but interprets "restart explorer" as a reboot action and refuses
# to relaunch it - leaving the user without a desktop. /norestart on its own
# prevents the actual Windows reboot but lets RM cleanly close-and-relaunch
# explorer mid-install. msiexec still returns 3010 ("reboot would be needed"),
# which we treat as success below.
Write-SetupLog ""
Write-SetupLog "Step 1: Installing OpenTextHostExplorer15x64.msi with ShopFloorx64.mst..."
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
$msiArgs = "/i `"$msiPath`" TRANSFORMS=`"$mstPath`" /qn /norestart /L*v `"$msiLog`""
Write-SetupLog " msiexec.exe $msiArgs"
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'msiexec.exe'
$psi.Arguments = $msiArgs
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
$msiExit = $proc.ExitCode
Write-SetupLog " msiexec exit code: $msiExit"
if ($msiExit -ne 0 -and $msiExit -ne 3010) {
Write-SetupLog "ERROR: base MSI install failed (exit $msiExit). See $msiLog"
exit 1
}
if ($msiExit -eq 3010) {
Write-SetupLog " (3010 = reboot needed but suppressed)"
}
# --- Step 2: Apply Service Pack 1 patch ---
# Same Restart Manager rationale as Step 1 - skip REBOOT=ReallySuppress so RM
# can relaunch explorer.exe after replacing the patched shell extension DLLs.
Write-SetupLog ""
Write-SetupLog "Step 2: Applying SP1 patch..."
if (Test-Path $mspLog) { Remove-Item $mspLog -Force -ErrorAction SilentlyContinue }
$mspArgs = "/p `"$mspPath`" /qn /norestart /L*v `"$mspLog`""
Write-SetupLog " msiexec.exe $mspArgs"
$psi.Arguments = $mspArgs
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
$mspExit = $proc.ExitCode
Write-SetupLog " msiexec exit code: $mspExit"
if ($mspExit -ne 0 -and $mspExit -ne 3010) {
Write-SetupLog "ERROR: SP1 patch failed (exit $mspExit). See $mspLog"
exit 1
}
# --- Step 3: Deploy profiles, keymaps, menus, accessories ---
# Source layout (bundled with this script):
# $SourceDir\Profile\*.hep
# $SourceDir\Accessories\EB\*.eb*
# $SourceDir\HostExplorer\Keymap\*.kmv
# $SourceDir\HostExplorer\Menu\*.hmv
#
# Target layouts (Hummingbird-canonical):
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Profile\
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Accessories\EB\
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Keymap\
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Menu\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\Profile\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\Accessories\EB\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Keymap\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Menu\
#
# We deploy to ProgramData\Shared (system-wide fallback), Default User (template
# inherited by every NEW user profile), and every existing user profile (so
# already-created accounts like SupportUser get them immediately).
Write-SetupLog ""
Write-SetupLog "Step 3: Deploying profiles/keymaps/menus/macros..."
# --- Resolve exclude lists from site-config.json (falls back to West Jefferson defaults) ---
$siteConfig = Get-SiteConfig
$profileExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeProfiles) {
@($siteConfig.opentext.excludeProfiles)
} else {
@('WJ_Office.hep', 'IBM_qks.hep', 'mmcs.hep') # West Jefferson defaults
}
$shortcutExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeShortcuts) {
@($siteConfig.opentext.excludeShortcuts)
} else {
@('WJ_Office.lnk', 'IBM_qks.lnk', 'mmcs.lnk')
}
if ($siteConfig) {
Write-SetupLog "Site config loaded - profile excludes: $($profileExcludes -join ', ')"
Write-SetupLog "Site config loaded - shortcut excludes: $($shortcutExcludes -join ', ')"
} else {
Write-SetupLog "No site-config.json found - using West Jefferson defaults for excludes"
}
# Map of source subdir -> destination subdir relative to the Hummingbird root.
# Optional Exclude list drops specific filenames from both the source-to-dest
# copy AND from the destination if they were left over from a prior install.
$contentMap = @(
@{
Src = 'Profile'
Dst = 'Profile'
Exclude = $profileExcludes
}
@{ Src = 'Accessories\EB'; Dst = 'Accessories\EB' }
@{ Src = 'HostExplorer\Keymap'; Dst = 'HostExplorer\Keymap' }
@{ Src = 'HostExplorer\Menu'; Dst = 'HostExplorer\Menu' }
)
function Copy-HummingbirdContent {
param(
[string]$RootDst, # Hummingbird root, e.g. C:\ProgramData\Hummingbird\Connectivity\15.00\Shared
[string]$Label
)
foreach ($entry in $contentMap) {
$srcPath = Join-Path $SourceDir $entry.Src
if (-not (Test-Path $srcPath)) { continue }
$dstPath = Join-Path $RootDst $entry.Dst
New-Item -Path $dstPath -ItemType Directory -Force | Out-Null
# Remove any previously-deployed excluded files from the destination
# - handles the case where a PC got them from an older install.
if ($entry.Exclude) {
foreach ($name in $entry.Exclude) {
$stale = Join-Path $dstPath $name
if (Test-Path -LiteralPath $stale) {
try {
Remove-Item -LiteralPath $stale -Force -ErrorAction Stop
Write-SetupLog " $Label : removed stale $name"
} catch {
Write-SetupLog " $Label : failed to remove $stale : $_"
}
}
}
}
$files = Get-ChildItem -Path $srcPath -File -ErrorAction SilentlyContinue
if ($entry.Exclude) {
$files = @($files | Where-Object { $entry.Exclude -notcontains $_.Name })
}
foreach ($f in $files) {
Copy-Item -Path $f.FullName -Destination $dstPath -Force
}
Write-SetupLog " $Label : $($entry.Src) -> $dstPath ($($files.Count) files)"
}
}
# 3a. ProgramData Shared
$sharedRoot = Join-Path $env:ProgramData 'Hummingbird\Connectivity\15.00\Shared'
Copy-HummingbirdContent -RootDst $sharedRoot -Label 'Shared'
# 3b. Default User (template for new user profiles)
$defaultUserRoot = 'C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00'
Copy-HummingbirdContent -RootDst $defaultUserRoot -Label 'Default User'
# 3c. Every existing user profile under C:\Users\
$skipNames = @('Default', 'Default User', 'Public', 'defaultuser0', 'All Users', 'WDAGUtilityAccount')
$userDirs = Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue |
Where-Object { $skipNames -notcontains $_.Name -and (Test-Path "$($_.FullName)\AppData\Roaming") }
foreach ($u in $userDirs) {
$userRoot = Join-Path $u.FullName 'AppData\Roaming\Hummingbird\Connectivity\15.00'
Copy-HummingbirdContent -RootDst $userRoot -Label $u.Name
}
# --- Step 4: Public Desktop shortcuts ---
# Uses $shortcutExcludes (resolved from site-config.json above) to skip
# deploying unwanted .lnk files AND remove any a prior install left behind.
Write-SetupLog ""
Write-SetupLog "Step 4: Deploying public desktop shortcuts..."
$shortcutSrc = Join-Path $SourceDir 'W10shortcuts'
$publicDesktop = 'C:\Users\Public\Desktop'
# Clean up stale copies from prior installs first
foreach ($name in $shortcutExcludes) {
$stale = Join-Path $publicDesktop $name
if (Test-Path -LiteralPath $stale) {
try {
Remove-Item -LiteralPath $stale -Force -ErrorAction Stop
Write-SetupLog " removed stale desktop shortcut: $name"
} catch {
Write-SetupLog " failed to remove stale $stale : $_"
}
}
}
if (Test-Path $shortcutSrc) {
$lnkFiles = Get-ChildItem -Path $shortcutSrc -Filter '*.lnk' -File -ErrorAction SilentlyContinue
foreach ($l in $lnkFiles) {
if ($shortcutExcludes -contains $l.Name) {
Write-SetupLog " skip (excluded): $($l.Name)"
continue
}
Copy-Item -Path $l.FullName -Destination $publicDesktop -Force
Write-SetupLog " $($l.Name) -> $publicDesktop"
}
}
# --- Step 5: Write registry marker ---
Write-SetupLog ""
Write-SetupLog "Step 5: Writing registry marker..."
if (-not (Test-Path $markerKey)) {
New-Item -Path $markerKey -Force | Out-Null
}
Set-ItemProperty -Path $markerKey -Name $markerVal -Value $expectedVersion -Force
Set-ItemProperty -Path $markerKey -Name 'InstalledAt' -Value (Get-Date -Format 'o') -Force
Write-SetupLog ""
Write-SetupLog "================================================================"
Write-SetupLog "=== OpenText HostExplorer ShopFloor $expectedVersion deployed ==="
Write-SetupLog "================================================================"
exit 0

View File

@@ -0,0 +1 @@
15.0.SP1.2

View File

@@ -0,0 +1,113 @@
@echo off
REM Install-Oracle11r2.cmd
REM Expands the GE Oracle Client 11.2 Administrator zip to a temp dir and
REM runs Oracle Universal Installer silently with the GE-customized
REM response file.
REM
REM Accepted zip locations (first hit wins):
REM %~dp0Oracle_OracleDatabase_11r2_V03.zip (preinstall flat)
REM %~dp0..\apps\Oracle_OracleDatabase_11r2_V03.zip (SFLD share, enforcer)
REM
REM Called by:
REM - preinstall runner (Type=EXE, image build time)
REM - Install-FromManifest.ps1 (Type=CMD, runtime enforcer)
REM
REM Oracle 11.2 OUI exit codes worth knowing:
REM 0 = success
REM 3 = success but with warnings
REM 1 = general failure
REM 6 = silent install requested but missing / bad response file
setlocal enabledelayedexpansion
set "LOG=C:\Logs\OracleClient\install.log"
if not exist "C:\Logs\OracleClient" mkdir "C:\Logs\OracleClient"
REM Emit a datestamp
for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value 2^>nul ^| find "="') do set LDT=%%I
set "STAMP=!LDT:~0,14!"
echo [%STAMP%] Install-Oracle11r2.cmd starting >> "%LOG%"
set "SRC_ZIP=%~dp0Oracle_OracleDatabase_11r2_V03.zip"
if not exist "%SRC_ZIP%" set "SRC_ZIP=%~dp0..\apps\Oracle_OracleDatabase_11r2_V03.zip"
set "STAGING=%TEMP%\oracle-11r2-install"
set "CLIENT_DIR=%STAGING%\Oracle_OracleDatabase_11r2_V03\client"
set "RSP=%CLIENT_DIR%\response\ge_client_install.rsp"
if not exist "%SRC_ZIP%" (
echo [%STAMP%] ERROR: zip not found at %SRC_ZIP% >> "%LOG%"
echo ERROR: zip not found at %SRC_ZIP%
exit /b 2
)
echo [%STAMP%] Expanding %SRC_ZIP% to %STAGING% >> "%LOG%"
if exist "%STAGING%" rmdir /s /q "%STAGING%" >nul 2>&1
mkdir "%STAGING%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ^
"try { Expand-Archive -Path '%SRC_ZIP%' -DestinationPath '%STAGING%' -Force -ErrorAction Stop; exit 0 } catch { Write-Error $_; exit 1 }" ^
>> "%LOG%" 2>&1
if not exist "%CLIENT_DIR%\setup.exe" (
echo [%STAMP%] ERROR: expanded setup.exe not found at %CLIENT_DIR%\setup.exe >> "%LOG%"
exit /b 3
)
if not exist "%RSP%" (
echo [%STAMP%] ERROR: response file missing at %RSP% >> "%LOG%"
exit /b 4
)
echo [%STAMP%] Running OUI silent install (this takes 2-8 minutes) >> "%LOG%"
"%CLIENT_DIR%\setup.exe" -silent -waitforcompletion -nowait ^
-ignoreSysPrereqs ^
-responseFile "%RSP%" >> "%LOG%" 2>&1
set RC=%ERRORLEVEL%
echo [%STAMP%] OUI exit code: %RC% >> "%LOG%"
REM --- Drop GE-customized tnsnames.ora / sqlnet.ora / ldap.ora into ORACLE_HOME.
REM The zip ships these in client\ora\ but Oracle's setup.exe does not consume
REM them - that copy is the GE Wise wrapper's job, which we bypass. Without
REM these, every Oracle-dependent app (eDNC/UDC/NTLARS/CMM tooling) fails with
REM ORA-12154 "TNS could not resolve the connect identifier specified".
REM
REM Per-file override: if %~dp0<name>.ora exists (deployed alongside this
REM wrapper on the PXE server / SFLD share), use it instead of the bundled
REM copy. Lets us push an updated tnsnames.ora without repackaging the zip.
REM
REM Only run when OUI succeeded (RC 0 or 3). On failure ORACLE_HOME may not
REM be fully populated and the copy targets may not exist.
if %RC%==0 goto :do_ora_copy
if %RC%==3 goto :do_ora_copy
goto :skip_ora_copy
:do_ora_copy
set "ORA_DST=C:\Apps\product\11.2.0\client_1\network\admin"
if not exist "%ORA_DST%" (
echo [%STAMP%] WARN: %ORA_DST% does not exist after OUI - skipping .ora copy >> "%LOG%"
goto :skip_ora_copy
)
for %%F in (tnsnames.ora sqlnet.ora ldap.ora) do (
set "ORA_SRC=%~dp0%%F"
if not exist "!ORA_SRC!" set "ORA_SRC=%CLIENT_DIR%\ora\%%F"
if exist "!ORA_SRC!" (
echo [%STAMP%] Copying %%F from !ORA_SRC! to %ORA_DST%\%%F >> "%LOG%"
copy /Y "!ORA_SRC!" "%ORA_DST%\%%F" >> "%LOG%" 2>&1
) else (
echo [%STAMP%] WARN: %%F not found in either %~dp0 or %CLIENT_DIR%\ora >> "%LOG%"
)
)
:skip_ora_copy
REM Cleanup staging dir to reclaim ~1.5 GB - OUI copies everything to ORACLE_HOME
echo [%STAMP%] Cleaning up staging dir >> "%LOG%"
rmdir /s /q "%STAGING%" >nul 2>&1
REM OUI returns 0 for success, 3 for success-with-warnings. Treat both as OK.
if %RC%==3 (
echo [%STAMP%] OUI reported warnings but install succeeded - returning 0 >> "%LOG%"
exit /b 0
)
exit /b %RC%

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
{
"Version": "1.0",
"_comment": "Site app list, installed after the post-PPKG reboot (once, as SYSTEM). Empty by default. Add one object per app to Applications and put its installer under pre-install/installers/<folder>/ on the PXE server (enrollment share). See docs/kb/How_To_Deploy_PXE_Server_New_Site.html, 'Adding shopfloor apps'.",
"_example": {
"Name": "Example App 1.2",
"Installer": "exampleapp\\ExampleApp.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{00000000-0000-0000-0000-000000000000}"
},
"Applications": []
}

View File

@@ -1,13 +1,245 @@
{ {
"Version": "1.0", "Version": "1.0",
"_comment": "Site app list, installed during setup (same logon as the PPKG, before the post-PPKG reboot). Empty by default. Add one object per app to Applications and put its installer under pre-install/installers/<folder>/ on the PXE server (enrollment share). See docs/kb/How_To_Deploy_PXE_Server_New_Site.html, 'Adding shopfloor apps'.", "Site": "West Jefferson",
"_example": { "Applications": [
"Name": "Example App 1.2", {
"Installer": "exampleapp\\ExampleApp.msi", "_comment": "PowerShell 7.5.4 - installed BEFORE PPKG via FlatUnattendW10-shopfloor.xml FirstLogonCommand Order 6 (race fix: Intune SetupCredentials Win32App install command starts with pwsh.exe; if PS7 not yet installed when that Win32App fires, it errors with FILE_NOT_FOUND 0x80070002 and IME's GRS retry never re-fires under V3Processor). This entry is a backstop - no-op via ProductCode detection if unattend Order 6 already installed it. PreEnrollment flag is informational; runner does not currently filter on it.",
"Type": "MSI", "Name": "PowerShell 7.5.4",
"InstallArgs": "/qn /norestart", "Installer": "powershell7\\PowerShell-7.5.4-win-x64.msi",
"DetectionMethod": "Registry", "Type": "MSI",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{00000000-0000-0000-0000-000000000000}" "InstallArgs": "/qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1",
}, "DetectionMethod": "Registry",
"Applications": [] "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{E8159677-ACF8-4D64-9D36-5C36B8BBEA39}",
"PreEnrollment": true,
"PCTypes": ["*"]
},
{
"_comment": "Oracle Client 11.2 Administrator - installed first because downstream apps (eDNC/NTLARS/UDC and CMM tooling) link against the Oracle home and fail cold if it's missing. Installer is a .cmd wrapper (Type=EXE is the preinstall runner's shim for non-MSI launchers, same pattern as OpenText Setup-OpenText.cmd). The wrapper expects Oracle_OracleDatabase_11r2_V03.zip (686 MB) staged next to it, unpacks to %TEMP%, runs Oracle Universal Installer silently with ge_client_install.rsp, then cleans up the staging dir. OUI exit 3 is treated as success (warnings-but-ok). Detection via the registered home key; downstream upgrades or version pins are handled by the runtime enforcer's Oracle Client 11.2 manifest entry in common/manifest.json. Scoped to the DNC-bearing PC types (collections, nocollections, partmarker, heattreat) plus CMM, whose metrology tooling links the Oracle home; non-DNC types (Genspect, Keyence, WaxAndTrace, Display, Timeclock, Lab) do not get it.",
"Name": "Oracle Client 11.2",
"Installer": "oracle\\Install-Oracle11r2.cmd",
"Type": "EXE",
"InstallArgs": "",
"LogFile": "C:\\Logs\\OracleClient\\install.log",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Oracle\\KEY_OraClient11g_home1",
"DetectionName": "ORACLE_HOME_NAME",
"DetectionValue": "OraClient11g_home1",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-partmarker", "gea-shopfloor-heattreat", "gea-shopfloor-cmm"]
},
{
"_comment": "VC++ 2008 SP1 x86 - the bootstrapper (vcredist2008_x86.exe) ignores /norestart and triggers an immediate Windows reboot when files are in use (per Aaron Stebner's MSDN docs). Fix: install the extracted vc_red.msi directly with REBOOT=ReallySuppress, which IS hard-honored by Windows Installer. msiexec may return 3010 (would-have-rebooted-but-suppressed) but won't actually reboot. cab name 'vc_red.cab' is hardcoded in the MSI's Media table - do not rename.",
"Name": "VC++ Redistributable 2008 x86",
"Installer": "vcredist/2008/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{9BE518E6-ECC6-35A9-88E4-87755C07200F}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2010 x86 - same fix as 2008. Bootstrapper ignores /norestart; extracted MSI with REBOOT=ReallySuppress does not.",
"Name": "VC++ Redistributable 2010 x86",
"Installer": "vcredist/2010/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F0C3E5D1-1ADE-321E-8167-68EF0DE699A5}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2012 x86 Minimum Runtime - extracted from vcredist2012_x86.exe Burn bundle. Same REBOOT=ReallySuppress fix.",
"Name": "VC++ Redistributable 2012 x86 (Minimum)",
"Installer": "vcredist/2012-min/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BD95A8CD-1D9F-35AD-981A-3E7925026EBB}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"Name": "VC++ Redistributable 2012 x86 (Additional)",
"Installer": "vcredist/2012-add/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{B175520C-86A2-35A7-8619-86DC379688B9}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2013 x86 Minimum Runtime - extracted from vcredist2013_x86.exe Burn bundle.",
"Name": "VC++ Redistributable 2013 x86 (Minimum)",
"Installer": "vcredist/2013-min/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{13A4EE12-23EA-3371-91EE-EFB36DDFFF3E}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"Name": "VC++ Redistributable 2013 x86 (Additional)",
"Installer": "vcredist/2013-add/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F8CFEB22-A2E7-3971-9EDA-4B11EDEFC185}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2013 x64 Minimum Runtime - required by Keyence VR-6000 Series Software. Extracted from the Keyence installer's Windows Installer cache. Same REBOOT=ReallySuppress pattern as the x86 variants.",
"Name": "VC++ Redistributable 2013 x64 (Minimum)",
"Installer": "vcredist/2013-x64-min/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{A749D8E6-B613-3BE3-8F5F-045C84EBA29B}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2013 x64 Additional Runtime - required by Keyence VR-6000 Series Software. Pairs with the Minimum Runtime above.",
"Name": "VC++ Redistributable 2013 x64 (Additional)",
"Installer": "vcredist/2013-x64-add/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{929FBD26-9020-399B-9A7A-751D61F0B942}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2010 x64 - required by PC-DMIS 2016/2019 R2 on CMM PCs. PCDLRN.exe links against msvcr100.dll and the VS 2010 MFC DLLs which are only provided by this redistributable. Extracted from the PC-DMIS 2016 bundle's attached container (a1 payload). Silent install: /q /norestart. Detection: Uninstall key under the native x64 hive with fixed product GUID.",
"Name": "VC++ Redistributable 2010 x64",
"Installer": "vcredist/2010-x64/vcredist_x64.exe",
"Type": "EXE",
"InstallArgs": "/q /norestart",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{1D8E6291-B0D5-35EC-8441-6616F567A0F7}",
"DetectionName": "DisplayVersion",
"DetectionValue": "10.0.40219",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2012 x64 - required by PC-DMIS 2016/2019 R2. Same rationale as 2010 x64; PC-DMIS links against msvcr110.dll / MFC110. Extracted from the PC-DMIS 2016 bundle's attached container (a2 payload). Detection on the Minimum Runtime GUID (the main redist wrapper installs both Minimum and Additional sub-packages).",
"Name": "VC++ Redistributable 2012 x64",
"Installer": "vcredist/2012-x64/vcredist_x64.exe",
"Type": "EXE",
"InstallArgs": "/q /norestart",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{5AF4E09F-5C9B-3AAF-B731-544D3DC821DD}",
"DetectionName": "DisplayVersion",
"DetectionValue": "11.0.51106",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2015-2022 x86 - extracted from vcredist2015_2017_2019_2022_x86.exe Burn bundle. The bundle contains 2022 14.44.35211 plus 8 chained KB updates for older 2015/2017/2019 releases. We install only the 2022 Min+Add MSIs - the CRT v140 ABI is shared across 2015/2017/2019/2022, so the latest pair covers all four versions on Windows 10/11.",
"Name": "VC++ Redistributable 2022 x86 (Minimum)",
"Installer": "vcredist/2022-min/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{922480B5-CAEB-4B1B-AAA4-9716EFDCE26B}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"Name": "VC++ Redistributable 2022 x86 (Additional)",
"Installer": "vcredist/2022-add/installer.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{C18FB403-1E88-43C8-AD8A-CED50F23DE8B}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2015-2022 x64 - required by PC-DMIS 2026 (PCDLRN.exe links vcruntime140_1.dll, added in VC++ 2019 / v14.20). The x64 redist line above previously stopped at 2013, so 64-bit PC-DMIS 2026 was missing its runtime and PCDLRN.exe failed to start with 'vcruntime140_1.dll was not found'. The patched PC-DMIS MSI bypasses Hexagon's Burn bundle, which is what would otherwise have pulled this in. Full vc_redist.x64.exe (14.44.35211, 2015-2022 shared CRT). Modern bootstrapper honors /norestart. Detect on the DLL itself so it is version-independent.",
"Name": "VC++ Redistributable 2015-2022 x64",
"Installer": "vcredist/2022-x64/vc_redist.x64.exe",
"Type": "EXE",
"InstallArgs": "/install /quiet /norestart",
"DetectionMethod": "File",
"DetectionPath": "C:\\Windows\\System32\\vcruntime140_1.dll",
"PCTypes": ["*"]
},
{
"_comment": "Adobe Acrobat Reader DC - same version as GEA-Engineering image. Two-step install via Install-AcroReader.cmd wrapper: base MSI with enterprise transform (AcroRead.mst) then DC update patch (AcroRdrDCUpd2500120531.msp). Source files on PXE server at /srv/samba/winpeapps/gea-engineer/Deploy/Applications/extra/adobe/ - copy to /srv/samba/enrollment/preinstall/installers/adobe/.",
"Name": "Adobe Acrobat Reader DC",
"Installer": "adobe\\Install-AcroReader.cmd",
"Type": "EXE",
"InstallArgs": "",
"DetectionMethod": "File",
"DetectionPath": "C:\\Program Files (x86)\\Adobe\\Acrobat Reader DC\\Reader\\AcroRd32.exe",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "OpenText HostExplorer 15 SP1 ShopFloor - replaced the WJDT-built OpenText.exe Inno wrapper with our own Setup-OpenText.ps1 that does the same install steps (msiexec base + ShopFloor.mst transform + SP1 .msp patch) PLUS fans the per-user profile/keymap/menu/macro content out to Default User and every existing user profile. The Inno wrapper deployed per-user content to {userappdata} which resolves to whichever user is running the installer (SYSTEM under DSC, single user under PreInstall) so Azure-AD users never saw the profiles. Setup-OpenText.cmd is a tiny launcher that hands off to Setup-OpenText.ps1 because the runner only handles MSI/EXE types. All bundled files live in vcredist-style subtree at dependencies/opentext/ and get xcopied through WinPE staging. NO DetectionMethod here on purpose - Setup-OpenText.ps1 owns its own version check by reading version.txt next to itself and comparing to HKLM:\\SOFTWARE\\GE\\OpenText\\Installed; the runner always invokes the wrapper (cost: ~1s of PowerShell launch when up-to-date) so the version constant lives in exactly one place: dependencies/opentext/version.txt.",
"Name": "OpenText HostExplorer ShopFloor",
"Installer": "opentext\\Setup-OpenText.cmd",
"Type": "EXE",
"InstallArgs": "",
"LogFile": "C:\\Logs\\PreInstall\\Setup-OpenText.log",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "UDC_Setup.exe spawns a hidden WPF window (UDC.exe) after install and never exits, so the runner needs KillAfterDetection: true to terminate UDC_Setup.exe + UDC.exe once the registry detection passes. This is an OPT-IN flag - normal installers should NOT set it because killing msiexec mid-install leaves msiserver holding the install mutex and the next msiexec call returns 1618 (Oracle hit this exact bug).",
"Name": "UDC",
"Installer": "UDC_Setup.exe",
"Type": "EXE",
"InstallArgs": "\"West Jefferson\" -9999",
"KillAfterDetection": true,
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC",
"PCTypes": ["gea-shopfloor-collections"],
"PCTypesStrict": true,
"_pcTypesNote": "UDC = the C in 'collections'. nocollections does NOT collect data so MUST NOT install UDC. PCTypesStrict bypasses the alias-expansion matcher so a nocollections PC's myNames (which transitively contains gea-shopfloor-collections via the Standard group) still won't match this entry."
},
{
"_comment": "Display kiosk app (Lobby Display or Dashboard). Install-KioskApp.cmd wrapper reads C:\\Enrollment\\display-type.txt to determine which installer to run. Both GEAerospaceLobbyDisplaySetup.exe and GEAerospaceDashboardSetup.exe must be staged in the display\\ subtree alongside the wrapper. Inno Setup /VERYSILENT is idempotent so no detection needed.",
"Name": "Display Kiosk App",
"Installer": "display\\Install-KioskApp.cmd",
"Type": "EXE",
"InstallArgs": "",
"LogFile": "C:\\Logs\\PreInstall\\Dashboard-install.log",
"PCTypes": ["Display"]
},
{
"_comment": "WJF Defect Tracker - replaces old ClickOnce deployment. MSI creates desktop + Start Menu shortcuts. Enforcer on SFLD share handles version updates post-imaging. TARGETDIR explicit because VS deployment MSI defaults to random path without it.",
"Name": "WJF Defect Tracker",
"Installer": "WJF_Defect_Tracker.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress TARGETDIR=\"C:\\Program Files (x86)\\WJF_Defect_Tracker\"",
"DetectionMethod": "File",
"DetectionPath": "C:\\Program Files (x86)\\WJF_Defect_Tracker\\Defect_Tracker.exe",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "Shopfloor Standard serial-port drivers: StarTech PCIe serial adapter (MosChip-based) + Prolific PL2303 USB-to-serial. Install-Drivers.cmd runs pnputil /add-driver with /subdirs /install so every bundled INF under drivers/ lands in the Windows driver store and auto-binds to matching hardware present now or plugged in later. Installed on every PC type (PCTypes ['*']) because serial hardware turns up across bays; a serial driver on a PC without the hardware is harmless - it just sits in the driver store until matching hardware is plugged in.",
"Name": "Shopfloor Serial Drivers",
"Installer": "drivers\\Install-Drivers.cmd",
"Type": "EXE",
"InstallArgs": "",
"LogFile": "C:\\Logs\\PreInstall\\Install-Drivers.log",
"DetectionMethod": "File",
"DetectionPath": "C:\\ProgramData\\PXEDrivers\\drivers-installed.marker",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "HP universal print driver, staged at imaging so a bay comes off the line able to build an HP queue without waiting on the SFLD share. Set-ShopdbPrinters.ps1 creates queues against drivers that are already present and SKIPS any that are not, so without this a freshly imaged bay silently gets no printers until a later enforcement cycle tops it up. NO WAVE GATE HERE, deliberately: waves exist because ~300 bays share one runtime share, whereas imaging runs a handful of bays at a time on the isolated PXE LAN that already moves multi-GB WIMs. Detection is the SPOOLER's driver key, not pnputil - pnputil only proves the package reached the driver store, and Get-PrinterDriver (what Set-ShopdbPrinters consults) reads the spooler, so a pnputil check goes green on a half-install. Payload is NOT in git: stage printer-drivers/ onto the PXE share like the other installer payloads.",
"Name": "ShopDB Printer Driver - HP Universal PS",
"Installer": "printer-drivers\\Install-ShopdbPrinterDrivers.ps1",
"Type": "PS1",
"InstallArgs": "-Only 'HP Universal Printing PS'",
"LogFile": "C:\\Logs\\PreInstall\\printer-drivers.log",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Print\\Environments\\Windows x64\\Drivers\\Version-3\\HP Universal Printing PS",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "Xerox universal print driver. Same reasoning as the HP entry above. These two universals are 100 MB combined and resolve the overwhelming majority of the fleet's printers; the four device-specific drivers (DesignJet, Zebra ZT411, Epson C3500, FARGO card printer) are another 126 MB and are deliberately NOT staged at imaging - they belong only on the bays that have that hardware, and those bays pick them up from the runtime manifest on their next enforcement cycle.",
"Name": "ShopDB Printer Driver - Xerox Global PCL6",
"Installer": "printer-drivers\\Install-ShopdbPrinterDrivers.ps1",
"Type": "PS1",
"InstallArgs": "-Only 'Xerox Global Print Driver PCL6'",
"LogFile": "C:\\Logs\\PreInstall\\printer-drivers.log",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Print\\Environments\\Windows x64\\Drivers\\Version-3\\Xerox Global Print Driver PCL6",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}
]
} }

View File

@@ -1,18 +0,0 @@
# dnsmasq drop-in: refuse to start DHCP/PXE unless the imaging NIC is on
# the isolated imaging network.
#
# Installed as /etc/systemd/system/dnsmasq.service.d/nic-guard.conf by
# pxe_server_setup.yml. pxe-nic-check --guard listens on the imaging NIC
# for 5 s and sends one DHCPDISCOVER (~6 s total), then exits:
# 0 safe to serve (isolated, no link, or the check itself broke - it
# fails open on bugs and says so loudly in the journal)
# 1 DO NOT serve: a foreign DHCP server or a managed/corporate switch
# (LLDP, CDP, 802.1X) is on the imaging NIC. dnsmasq does not start.
#
# Why did dnsmasq not start? journalctl -u dnsmasq -b | grep pxe-nic-check
# Full picture: pxe-nic-check (or the webapp status page)
# Guard does not run on "systemctl reload"; every start/restart runs it,
# including the @reboot cron restart.
[Service]
ExecStartPre=/usr/local/sbin/pxe-nic-check --guard

View File

@@ -3,15 +3,10 @@
# pxe-dhcp-hook.sh - dnsmasq dhcp-script hook. # pxe-dhcp-hook.sh - dnsmasq dhcp-script hook.
# #
# Runs every time a PXE client gets/changes/releases a DHCP lease on # Runs every time a PXE client gets/changes/releases a DHCP lease on
# 172.16.9.0/24. Flushes that client's SMB (TCP 445 and 139) conntrack # 172.16.9.0/24. Flushes conntrack entries and drops any lingering
# entries and drops its lingering SMB sockets. Prevents stale server-side # TCP sockets for that client IP. Prevents stale server-side state from
# state from causing "System error 53 - network path not found" when a # causing "System error 53 - network path not found" when a WinPE client
# WinPE client re-images the same machine without a clean SMB session # re-images the same machine without a clean SMB session teardown.
# teardown.
#
# SMB only, on purpose: the lease that fires this hook is iPXE's, and its
# first HTTP fetch of the boot menu is racing this script. Killing every
# socket or conntrack entry for the client IP broke that fetch.
# #
# dnsmasq calls this with argv: # dnsmasq calls this with argv:
# $1 = action (add | old | del) # $1 = action (add | old | del)
@@ -19,21 +14,10 @@
# $3 = client IP # $3 = client IP
# $4 = hostname (may be empty) # $4 = hostname (may be empty)
# #
# A re-image usually comes back to the SAME lease (same MAC, same IP), which
# dnsmasq reports as "old", not "add" - and only at all because dnsmasq.conf
# sets script-on-renewal. "old" also fires when a running Windows PC renews
# its lease, so on "old" we only clear state when the request came from PXE
# firmware or iPXE (vendor class PXEClient...): that machine has just
# rebooted into a network boot, so any server-side session for its IP is
# dead. A renewal from Windows (vendor class "MSFT 5.0") is left alone.
#
# Must exit quickly; dnsmasq waits for it. Logs to syslog via logger. # Must exit quickly; dnsmasq waits for it. Logs to syslog via logger.
set +e set +e
# dnsmasq's environment may lack sbin; keep any PATH it gave us first.
PATH="${PATH:+$PATH:}/usr/sbin:/usr/bin:/sbin:/bin"
action="$1" action="$1"
mac="$2" mac="$2"
ip="$3" ip="$3"
@@ -41,26 +25,13 @@ host="${4:-}"
[ -z "$ip" ] && exit 0 [ -z "$ip" ] && exit 0
clear_state() {
local port
for port in 445 139; do
# client -> our share, and anything the other way round
conntrack -D -p tcp -s "$ip" --dport "$port" >/dev/null 2>&1
conntrack -D -p tcp -d "$ip" --sport "$port" >/dev/null 2>&1
done
# our SMB sockets (local port 445/139) whose peer is this client
ss -K -t "( sport = :445 or sport = :139 ) and dst = $ip" >/dev/null 2>&1
logger -t pxe-dhcp-hook "cleared stale SMB state for $1 $ip ($mac${host:+ $host})"
}
case "$action" in case "$action" in
add|del) add|del)
clear_state "$action" /usr/sbin/conntrack -D -s "$ip" >/dev/null 2>&1
;; /usr/sbin/conntrack -D -d "$ip" >/dev/null 2>&1
old) /usr/bin/ss -K "dst = $ip" >/dev/null 2>&1
case "${DNSMASQ_VENDOR_CLASS:-}" in /usr/bin/ss -K "src = $ip" >/dev/null 2>&1
PXEClient*) clear_state "old (network boot, ${DNSMASQ_VENDOR_CLASS%%:UNDI*})" ;; logger -t pxe-dhcp-hook "cleared stale state for $action $ip ($mac${host:+ $host})"
esac
;; ;;
esac esac

View File

@@ -1,516 +0,0 @@
#!/bin/bash
# pxe-netplan-apply.sh - install the PXE server's netplan config without
# ever leaving the box unreachable on the corporate side.
#
# Usage:
# pxe-netplan-apply.sh STAGED_YAML PXE_NAME PXE_MAC [CORP_NAME CORP_MAC]
# pxe-netplan-apply.sh --preflight STAGED_YAML PXE_NAME PXE_MAC [CORP_NAME CORP_MAC]
# pxe-netplan-apply.sh --revert BACKUP_DIR (the auto-revert timer)
#
# Without CORP_NAME/CORP_MAC (corp_nic_mac empty in the playbook) only the
# imaging NIC is pinned and renamed; the other wired ports are catch-all
# DHCP clients that keep their names. Every pre-flight refusal about the
# imaging NIC applies the same.
#
# Env (set by pxe_server_setup.yml):
# PXE_SUBNET imaging LAN, e.g. 172.16.9.0/24 (SSH peers inside it may
# legitimately come in on the imaging NIC)
# PXE_SSH_CLIENT IP of the admin's SSH client ($SSH_CONNECTION field 1);
# every established sshd peer is checked as well
# PXE_NETPLAN_REVERT_AFTER seconds before the auto-revert fires (120)
# PXE_NETPLAN_CORP_WAIT seconds to wait for the corp IPv4 again (30)
#
# Steps (apply):
# 1. validate STAGED_YAML in a scratch root (netplan generate --root-dir);
# nothing on the box changes if it is invalid
# 2. stop here (UNCHANGED) if it is already installed, no other file
# configures wired NICs, and both NICs carry their stable names
# 3. pre-flight: REFUSE (exit 1, nothing changed) when the NIC that
# PXE_MAC names holds the default route, a DHCP lease, or the route to
# an SSH client outside PXE_SUBNET. That NIC becomes static with no
# gateway, so any of these means it is the corp-cabled one (swapped
# MACs) and applying would cut the admin off.
# 4. back up /etc/netplan to /var/backups/netplan-<timestamp>/, record the
# current NIC names, and arm an auto-revert (systemd-run timer) that
# restores the backup unless step 8 cancels it
# 5. install it (other wired configs moved aside), netplan generate
# 6. rename the NICs to their stable names (the .link files netplan
# writes do it on every later boot)
# 7. netplan apply
# 8. check: both names present, the imaging NIC has its IPv4, the corp
# NIC has IPv4 again if it had one before (PXE_NETPLAN_CORP_WAIT s),
# SSH clients still route out of the corp side; then cancel the revert
# Any failure in 5-8 restores the backup AND the previous NIC names, then
# exits 1. Prints CHANGED / UNCHANGED (or NEEDED for --preflight) as the
# last line.
set -u
NETPLAN_DIR=${NETPLAN_DIR:-/etc/netplan}
SYSFS_NET=${SYSFS_NET:-/sys/class/net}
BACKUP_ROOT=${BACKUP_ROOT:-/var/backups}
LEASE_DIR=${LEASE_DIR:-/run/systemd/netif/leases}
LOCK_FILE=${LOCK_FILE:-/run/pxe-netplan-apply.lock}
REVERT_AFTER=${PXE_NETPLAN_REVERT_AFTER:-120}
CORP_WAIT=${PXE_NETPLAN_CORP_WAIT:-30}
PXE_WAIT=${PXE_NETPLAN_PXE_WAIT:-20}
TARGET=$NETPLAN_DIR/50-cloud-init.yaml
SELF=$(readlink -f "$0")
log() { echo "pxe-netplan-apply: $*"; }
# --- NIC helpers -------------------------------------------------------------
# Name of the physical NIC that has MAC $1 (empty if none).
iface_of() {
local d
for d in "$SYSFS_NET"/*; do
[ -e "$d/device" ] || continue
if [ "$(cat "$d/address" 2>/dev/null)" = "$1" ]; then
basename "$d"
return 0
fi
done
return 0
}
has_v4() {
ip -4 -o addr show dev "$1" scope global 2>/dev/null | grep -q ' inet '
}
default_route_devs() {
{ ip -4 route show default; ip -6 route show default; } 2>/dev/null |
awk '{for (i = 1; i < NF; i++) if ($i == "dev") print $(i + 1)}' | sort -u
}
# DHCP-assigned address (networkd and NetworkManager both flag those
# "dynamic"), or a networkd lease file for the link.
has_dhcp_lease() {
local idx
ip -o addr show dev "$1" 2>/dev/null | grep -qw dynamic && return 0
idx=$(cat "$SYSFS_NET/$1/ifindex" 2>/dev/null)
[ -n "$idx" ] && [ -s "$LEASE_DIR/$idx" ] && return 0
return 1
}
route_dev() {
ip -o route get "$1" 2>/dev/null |
awk '{for (i = 1; i < NF; i++) if ($i == "dev") {print $(i + 1); exit}}'
}
ip2int() {
local a b c d
[[ "$1" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 1
a=${BASH_REMATCH[1]} b=${BASH_REMATCH[2]} c=${BASH_REMATCH[3]} d=${BASH_REMATCH[4]}
echo $(( (a << 24) | (b << 16) | (c << 8) | d ))
}
in_subnet() {
local ipn netn len mask
[ -n "${2:-}" ] || return 1
ipn=$(ip2int "$1") || return 1
netn=$(ip2int "${2%/*}") || return 1
len=${2#*/}
[[ "$len" =~ ^[0-9]+$ ]] && [ "$len" -le 32 ] || return 1
mask=$(( len == 0 ? 0 : (0xFFFFFFFF << (32 - len)) & 0xFFFFFFFF ))
[ $(( ipn & mask )) -eq $(( netn & mask )) ]
}
# Admin SSH clients: PXE_SSH_CLIENT, our own SSH_CONNECTION, and every
# established peer of sshd (port 22). IPv4-mapped IPv6 is unwrapped.
ssh_peers() {
{
[ -n "${PXE_SSH_CLIENT:-}" ] && echo "$PXE_SSH_CLIENT"
[ -n "${SSH_CONNECTION:-}" ] && echo "${SSH_CONNECTION%% *}"
ss -Htn state established '( sport = :22 )' 2>/dev/null | awk '{print $NF}' |
sed -E 's/:[0-9]+$//; s/^\[//; s/\]$//'
} | sed -E 's/^::ffff://' | grep -E '^[0-9a-fA-F.:]+$' | sort -u
}
# --- pre-flight --------------------------------------------------------------
# 0 = safe to apply. Prints every reason it is not.
preflight() {
local pif cif peer dev bad=0
pif=$(iface_of "$PXE_MAC")
if [ -z "$pif" ]; then
log "REFUSED: no physical NIC has pxe_nic_mac $PXE_MAC"
return 1
fi
if [ -n "$CORP_MAC" ]; then
cif=$(iface_of "$CORP_MAC")
if [ -z "$cif" ]; then
log "REFUSED: no physical NIC has corp_nic_mac $CORP_MAC"
return 1
fi
fi
if default_route_devs | grep -qxF "$pif"; then
log "REFUSED: $pif (pxe_nic_mac $PXE_MAC) holds the default route"
bad=1
fi
if has_dhcp_lease "$pif"; then
log "REFUSED: $pif (pxe_nic_mac $PXE_MAC) has a DHCP lease"
bad=1
fi
while IFS= read -r peer; do
[ -n "$peer" ] || continue
dev=$(route_dev "$peer")
[ "$dev" = "$pif" ] || continue
if in_subnet "$peer" "${PXE_SUBNET:-}"; then
continue # an admin on the imaging LAN keeps 172.16.9.1
fi
log "REFUSED: the SSH client $peer is reached through $pif (pxe_nic_mac $PXE_MAC)"
bad=1
done < <(ssh_peers)
if [ "$bad" -ne 0 ]; then
log "The NIC named by pxe_nic_mac looks like the GE corporate one: the MACs are"
log "probably swapped, or the cables are. It would become"
log "static ${PXE_SUBNET:-imaging} with no gateway and cut this box off. Nothing was changed."
log "NICs now: $(for d in "$SYSFS_NET"/*; do [ -e "$d/device" ] && printf '%s=%s ' "$(basename "$d")" "$(cat "$d/address" 2>/dev/null)"; done)"
return 1
fi
return 0
}
# --- renaming ----------------------------------------------------------------
link_rename() {
local cur=$1 new=$2
log "renaming $cur to $new"
if ip link set dev "$cur" down &&
ip link set dev "$cur" name "$new" &&
ip link set dev "$new" up; then
return 0
fi
log "ERROR: rename $cur -> $new failed"
ip link set dev "$cur" up 2>/dev/null
ip link set dev "$new" up 2>/dev/null
return 1
}
# set_names MAC1 NAME1 [MAC2 NAME2]: give each MAC its name. A NIC that must
# change goes through a temporary name first, so a swap (pxe0 <-> corp0)
# works in both directions. Temporary names carry this run's PID, so they
# never collide with a name left over from an earlier (failed) pass. A pair
# with an empty MAC or name is skipped (no pinned GE NIC).
set_names() {
local -a macs=() want=() cur=()
local i
while [ $# -ge 2 ]; do
if [ -n "$1" ] && [ -n "$2" ]; then
macs+=("$1"); want+=("$2")
fi
shift 2
done
RENAME_SEQ=$(( ${RENAME_SEQ:-0} + 1 ))
for i in "${!macs[@]}"; do
cur[i]=$(iface_of "${macs[i]}")
if [ -z "${cur[i]}" ]; then
log "ERROR: no NIC with MAC ${macs[i]}"
return 1
fi
done
for i in "${!macs[@]}"; do
[ "${cur[i]}" = "${want[i]}" ] && continue
link_rename "${cur[i]}" "pxt$$-$RENAME_SEQ$i" || return 1
cur[i]=pxt$$-$RENAME_SEQ$i
done
for i in "${!macs[@]}"; do
[ "${cur[i]}" = "${want[i]}" ] && continue
link_rename "${cur[i]}" "${want[i]}" || return 1
done
for i in "${!macs[@]}"; do
if [ "$(iface_of "${macs[i]}")" != "${want[i]}" ]; then
log "ERROR: MAC ${macs[i]} is not named ${want[i]} after renaming"
return 1
fi
done
return 0
}
names_ok() {
[ "$(iface_of "$PXE_MAC")" = "$PXE_NAME" ] || return 1
if [ -n "$CORP_MAC" ]; then
[ "$(iface_of "$CORP_MAC")" = "$CORP_NAME" ]
else
[ -z "$(stray_names)" ]
fi
}
# No pinned GE NIC: "MAC NAME" for every other physical NIC whose name the
# catch-all (en*, eth*) would not match - the previous imaging NIC still
# called pxe0 after a re-pick, or corp0 from a pinned config. Each gets the
# kernel-style MAC name enx<mac>, so it becomes a catch-all DHCP client and
# pxe0 is free for the new imaging NIC.
stray_names() {
local d n m
[ -z "$CORP_MAC" ] || return 0
for d in "$SYSFS_NET"/*; do
[ -e "$d/device" ] || continue
n=${d##*/}
m=$(cat "$d/address" 2>/dev/null)
[ "$m" = "$PXE_MAC" ] && continue
[ "$(cat "$d/type" 2>/dev/null)" = 1 ] || continue
[ -e "$d/wireless" ] || [ -e "$d/phy80211" ] || [ -e "$d/bridge" ] && continue
case "$n" in en*|eth*) continue ;; esac
echo "$m enx${m//:/}"
done
return 0
}
# MACs of the physical NICs other than the imaging one that hold a DHCP
# (dynamic) IPv4: the GE side. They must have one again after the apply,
# or an admin on the GE side may have been cut off. Static addresses (the
# previous imaging NIC's 172.16.9.1) do not count. By MAC: names change.
other_v4_macs() {
local d n m
for d in "$SYSFS_NET"/*; do
[ -e "$d/device" ] || continue
n=${d##*/}
m=$(cat "$d/address" 2>/dev/null)
[ "$m" = "$PXE_MAC" ] && continue
ip -4 -o addr show dev "$n" scope global dynamic 2>/dev/null | grep -q ' inet ' && echo "$m"
done
return 0
}
# --- backup / restore --------------------------------------------------------
# Other netplan files that configure wired NICs (installer leftovers, an
# en* DHCP match): they would put a second config on the two NICs. Files
# without "ethernets" (WiFi, NetworkManager wifi profiles) are left alone.
other_files() {
find "$NETPLAN_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) \
! -path "$TARGET" -exec grep -l 'ethernets' {} + 2>/dev/null | sort
}
# State of one apply, next to (not inside) the backup, so restoring the
# backup never copies it into /etc/netplan.
write_state() {
{
echo "NETPLAN_DIR=$NETPLAN_DIR"
echo "PXE_MAC=$PXE_MAC"
echo "ORIG_PXE_IF=$ORIG_PXE_IF"
echo "CORP_MAC=$CORP_MAC"
echo "ORIG_CORP_IF=$ORIG_CORP_IF"
echo "EXTRA=$EXTRA"
} >"$BACKUP.state"
}
read_state() {
local k v
while IFS='=' read -r k v; do
case "$k" in
NETPLAN_DIR) [[ "$v" =~ ^/[A-Za-z0-9/._-]*$ ]] && NETPLAN_DIR=$v ;;
PXE_MAC|CORP_MAC) [[ "$v" =~ ^([0-9a-f]{2}:){5}[0-9a-f]{2}$ ]] && printf -v "$k" '%s' "$v" ;;
ORIG_PXE_IF|ORIG_CORP_IF) [[ "$v" =~ ^[A-Za-z0-9._-]{0,15}$ ]] && printf -v "$k" '%s' "$v" ;;
EXTRA) [[ "$v" =~ ^(([0-9a-f]{2}:){5}[0-9a-f]{2}=[A-Za-z0-9._-]{1,15},?)*$ ]] && EXTRA=$v ;;
esac
done <"$BACKUP.state"
TARGET=$NETPLAN_DIR/50-cloud-init.yaml
}
cancel_revert() {
[ -n "${REVERT_UNIT:-}" ] || return 0
systemctl stop "$REVERT_UNIT.timer" "$REVERT_UNIT.service" >/dev/null 2>&1
if systemctl is-active --quiet "$REVERT_UNIT.timer" 2>/dev/null; then
log "WARNING: could not cancel $REVERT_UNIT.timer (the committed marker still stops it)"
fi
}
# Put the backed-up config AND the previous NIC names back.
restore() {
local rc=0
log "restoring the previous config from $BACKUP"
find "$NETPLAN_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -delete
cp -a "$BACKUP/." "$NETPLAN_DIR/"
if [ -n "${ORIG_PXE_IF:-}" ]; then
local -a back=()
local pair
for pair in ${EXTRA//,/ }; do back+=("${pair%%=*}" "${pair#*=}"); done
set_names "$PXE_MAC" "$ORIG_PXE_IF" "$CORP_MAC" "${ORIG_CORP_IF:-}" "${back[@]}" ||
{ log "ERROR: could not put the previous NIC names back"; rc=1; }
fi
netplan generate && netplan apply || { log "ERROR: netplan apply of the restored config failed"; rc=1; }
touch "$BACKUP.reverted"
return $rc
}
fail() {
log "ERROR: $*"
restore
cancel_revert
log "the previous config is back in place; nothing applied"
exit 1
}
# --- modes -------------------------------------------------------------------
MODE=apply
case "${1:-}" in
--preflight) MODE=preflight; shift ;;
--revert)
[ $# -eq 2 ] || { echo "usage: $0 --revert BACKUP_DIR" >&2; exit 2; }
BACKUP=${2%/}
[ -d "$BACKUP" ] && [ -f "$BACKUP.state" ] || { log "revert: no backup/state at $BACKUP"; exit 1; }
exec 9>"$LOCK_FILE"
flock -w 600 9 || log "revert: lock still held after 600 s; reverting anyway"
if [ -e "$BACKUP.committed" ] || [ -e "$BACKUP.reverted" ]; then
log "revert: $BACKUP already committed or reverted; nothing to do"
exit 0
fi
PXE_MAC="" CORP_MAC="" ORIG_PXE_IF="" ORIG_CORP_IF="" EXTRA=""
read_state
log "revert: the apply was not confirmed within its window"
restore
exit $?
;;
esac
if [ $# -ne 3 ] && [ $# -ne 5 ]; then
echo "usage: $0 [--preflight] STAGED_YAML PXE_NAME PXE_MAC [CORP_NAME CORP_MAC]" >&2
exit 2
fi
STAGED=$1
PXE_NAME=$2
PXE_MAC=${3,,}
CORP_NAME=${4:-}
CORP_MAC=${5:-}
CORP_MAC=${CORP_MAC,,}
# 1. validate in a scratch root
SCRATCH=$(mktemp -d)
trap 'rm -rf "$SCRATCH"' EXIT
mkdir -p "$SCRATCH/etc/netplan"
install -m 600 "$STAGED" "$SCRATCH/etc/netplan/50-cloud-init.yaml"
if ! netplan generate --root-dir "$SCRATCH"; then
log "ERROR: $STAGED does not validate; nothing changed"
exit 1
fi
# 2. already in place?
if cmp -s "$STAGED" "$TARGET" && [ -z "$(other_files)" ] && names_ok; then
log "netplan already in place, $PXE_NAME${CORP_NAME:+ and $CORP_NAME} present"
echo UNCHANGED
exit 0
fi
# 3. pre-flight
if ! preflight; then
exit 1
fi
if [ "$MODE" = preflight ]; then
log "pre-flight passed; the netplan config would change"
echo NEEDED
exit 0
fi
exec 9>"$LOCK_FILE"
flock -w 60 9 || { log "ERROR: another pxe-netplan-apply holds $LOCK_FILE; nothing changed"; exit 1; }
# 4. back up, record names, arm the auto-revert
ORIG_PXE_IF=$(iface_of "$PXE_MAC")
ORIG_CORP_IF=""
[ -n "$CORP_MAC" ] && ORIG_CORP_IF=$(iface_of "$CORP_MAC")
CORP_HAD_V4=0
[ -n "$ORIG_CORP_IF" ] && has_v4 "$ORIG_CORP_IF" && CORP_HAD_V4=1
OTHERS_V4=""
[ -z "$CORP_MAC" ] && OTHERS_V4=$(other_v4_macs)
# NICs to move off a name the catch-all would not match, recorded with
# their current names for the revert ("mac=name,...").
EXTRA=""
FREE=()
while read -r m newname; do
[ -n "$m" ] || continue
EXTRA="${EXTRA:+$EXTRA,}$m=$(iface_of "$m")"
FREE+=("$m" "$newname")
done < <(stray_names)
BACKUP=$BACKUP_ROOT/netplan-$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP"
cp -a "$NETPLAN_DIR/." "$BACKUP/"
write_state
log "previous config saved in $BACKUP (NICs: $ORIG_PXE_IF${ORIG_CORP_IF:+, $ORIG_CORP_IF})"
REVERT_UNIT=""
if [ "$REVERT_AFTER" -gt 0 ] 2>/dev/null && command -v systemd-run >/dev/null; then
REVERT_UNIT=pxe-netplan-revert-$(basename "$BACKUP" | tr -cd 'A-Za-z0-9-')
if systemd-run --quiet --unit="$REVERT_UNIT" --on-active="$REVERT_AFTER" \
--timer-property=AccuracySec=1s "$SELF" --revert "$BACKUP"; then
log "auto-revert armed: $REVERT_UNIT.timer restores $BACKUP in ${REVERT_AFTER}s unless this run confirms"
else
log "WARNING: could not arm the auto-revert timer; relying on the in-script checks"
REVERT_UNIT=""
fi
else
log "WARNING: auto-revert off (no systemd-run or PXE_NETPLAN_REVERT_AFTER=0)"
fi
# An SSH drop (HUP) must not stop us half-way; TERM/INT restore.
trap '' HUP
trap 'fail "interrupted"' INT TERM
# 5. install, moving aside the other wired configs (an en* DHCP match would
# put a DHCP client on the imaging NIC too)
while IFS= read -r f; do
[ -n "$f" ] || continue
log "moving aside $f (copy in $BACKUP)"
rm -f "$f"
done < <(other_files)
install -m 600 "$STAGED" "$TARGET"
netplan generate || fail "netplan generate failed"
# 6. stable names now, so dnsmasq/UFW/Samba (which use them) work before
# the next reboot. Does not depend on the netplan version renaming.
[ ${#FREE[@]} -gt 0 ] && log "freeing names for the catch-all: $EXTRA"
set_names "$PXE_MAC" "$PXE_NAME" "$CORP_MAC" "$CORP_NAME" "${FREE[@]}" || fail "NIC rename failed"
# 7. apply
netplan apply || fail "netplan apply failed"
# 8. check
names_ok || fail "$PXE_NAME${CORP_NAME:+ / $CORP_NAME} not on MAC $PXE_MAC${CORP_MAC:+ / $CORP_MAC} after apply"
ok=0
for _ in $(seq 1 "$PXE_WAIT"); do
if has_v4 "$PXE_NAME"; then ok=1; break; fi
sleep 1
done
[ "$ok" -eq 1 ] || fail "$PXE_NAME has no IPv4 address after ${PXE_WAIT}s"
for m in $OTHERS_V4; do
ok=0
for _ in $(seq 1 "$CORP_WAIT"); do
n=$(iface_of "$m")
if [ -n "$n" ] && has_v4 "$n"; then ok=1; break; fi
sleep 1
done
[ "$ok" -eq 1 ] || fail "the NIC with MAC $m had a DHCP IPv4 before and has none after ${CORP_WAIT}s"
done
if [ -z "$CORP_MAC" ]; then
:
elif [ "$CORP_HAD_V4" -eq 1 ]; then
ok=0
for _ in $(seq 1 "$CORP_WAIT"); do
if has_v4 "$CORP_NAME"; then ok=1; break; fi
sleep 1
done
[ "$ok" -eq 1 ] || fail "$CORP_NAME had IPv4 before and has none after ${CORP_WAIT}s"
elif ! has_v4 "$CORP_NAME"; then
log "WARNING: $CORP_NAME has no IPv4 address (it had none before either: corp cable unplugged or no DHCP)"
fi
while IFS= read -r peer; do
[ -n "$peer" ] || continue
dev=$(route_dev "$peer")
if [ "$dev" = "$PXE_NAME" ] && ! in_subnet "$peer" "${PXE_SUBNET:-}"; then
fail "the SSH client $peer now routes through $PXE_NAME"
fi
if [ -z "$dev" ]; then
fail "no route to the SSH client $peer after apply"
fi
done < <(ssh_peers)
touch "$BACKUP.committed"
cancel_revert
trap - INT TERM
ip -br addr show dev "$PXE_NAME" 2>/dev/null
[ -n "$CORP_NAME" ] && ip -br addr show dev "$CORP_NAME" 2>/dev/null
echo CHANGED
exit 0

View File

@@ -1,145 +0,0 @@
#!/bin/bash
#
# pxe-nic-carrier - re-check the imaging NIC whenever its cable moves.
#
# The dnsmasq guard (pxe-nic-check --guard) runs only when dnsmasq starts,
# and the watch timer only every 5 min. If the imaging cable is moved into
# a GE port while dnsmasq runs, dnsmasq would answer DHCP on the GE LAN
# until the next watch. This hook closes that gap:
#
# carrier lost dnsmasq is stopped at once (if it was running) and a
# marker remembers that this hook stopped it.
# carrier back pxe-nic-carrier.service runs "pxe-nic-carrier --gain":
# after a 2 s settle, if dnsmasq is running or the marker
# is set, "systemctl restart dnsmasq" - its ExecStartPre
# guard checks the NIC while dnsmasq is down and refuses
# to start it on the GE network. Otherwise it only runs
# the guard to refresh /run/pxe-nic-status.json. dnsmasq
# that an operator or the watch stopped stays stopped.
#
# Only the imaging NIC (and, for a bridge, its ports) counts, as resolved
# by "pxe-nic-check --print-imaging" from /etc/pxe-server/nic.conf or the
# dnsmasq interface= pin. Events on any other NIC (the GE NIC) exit at
# once and touch nothing. Link state is read from sysfs, not from the
# event name, and remembered per interface, so the same transition seen
# twice (carrier, then routable) acts once.
#
# Called by networkd-dispatcher with IFACE and STATE in the environment
# (installed as /etc/networkd-dispatcher/<state>.d/50-pxe-nic-carrier for
# carrier, degraded, degraded-carrier, enslaved, routable, no-carrier and
# off), or by hand: pxe-nic-carrier IFACE. Logs: journalctl -t pxe-nic-carrier
#
# The dispatcher waits for this script, so the event path never runs a
# check itself: loss is two systemctl calls, gain hands off to the service.
set -u
CHECK="${PXE_NIC_CHECK:-/usr/local/sbin/pxe-nic-check}"
SYS_NET="${PXE_SYS_NET:-/sys/class/net}"
RUN="${PXE_NIC_CARRIER_RUN:-/run/pxe-nic-carrier}"
SYSTEMCTL="${PXE_SYSTEMCTL:-systemctl}"
SETTLE="${PXE_NIC_CARRIER_SETTLE:-2}"
GAIN_UNIT="pxe-nic-carrier.service"
TAG="pxe-nic-carrier"
PATH="${PATH:+$PATH:}/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"
log() { # log PRIORITY MESSAGE
logger -t "$TAG" -p "daemon.$1" -- "$2" 2>/dev/null || \
echo "$TAG: $2" >&2
}
imaging_ifaces() {
"$CHECK" --print-imaging 2>/dev/null
}
carrier_of() { # 1 if the interface has carrier, else 0 (down reads EINVAL)
local c
c=$(cat "$SYS_NET/$1/carrier" 2>/dev/null) || c=0
[ "$c" = 1 ] && echo 1 || echo 0
}
any_carrier() { # any imaging interface or port with carrier
local i
for i in "$@"; do
[ "$(carrier_of "$i")" = 1 ] && return 0
done
return 1
}
dnsmasq_running() { # active, starting or reloading
case "$("$SYSTEMCTL" is-active dnsmasq 2>/dev/null)" in
active|activating|reloading) return 0 ;;
esac
return 1
}
on_event() {
local iface="$1" list=() match="" i now prev
mapfile -t list < <(imaging_ifaces)
for i in "${list[@]}"; do
[ -n "$i" ] && [ "$i" = "$iface" ] && match=1
done
[ -n "$match" ] || exit 0 # not the imaging NIC: hands off
mkdir -p "$RUN" || exit 0
exec 9>"$RUN/event.lock"
flock -w 10 9 || log warning "lock busy, handling $iface anyway"
now=$(carrier_of "$iface")
prev=$(cat "$RUN/link.$iface" 2>/dev/null) || prev=unknown
echo "$now" > "$RUN/link.$iface"
[ "$now" = "$prev" ] && exit 0 # same state again: already handled
if [ "$now" = 0 ]; then
# an in-flight gain check must not restart dnsmasq after this
"$SYSTEMCTL" stop "$GAIN_UNIT" >/dev/null 2>&1
if dnsmasq_running; then
: > "$RUN/stopped-dnsmasq"
"$SYSTEMCTL" stop --no-block dnsmasq
log warning "carrier lost on imaging NIC $iface (${STATE:-event}): stopped dnsmasq; it restarts after the guard passes when the link returns"
else
log info "carrier lost on imaging NIC $iface (${STATE:-event}); dnsmasq not running"
fi
else
log notice "carrier up on imaging NIC $iface (${STATE:-event}, was $prev): re-checking before dnsmasq serves"
"$SYSTEMCTL" restart --no-block "$GAIN_UNIT"
fi
exit 0
}
on_gain() {
local list=() rc
sleep "$SETTLE"
mapfile -t list < <(imaging_ifaces)
if [ "${#list[@]}" -eq 0 ] || [ -z "${list[0]}" ]; then
log err "imaging NIC unknown, nothing to re-check"
exit 0
fi
if ! any_carrier "${list[@]}"; then
log info "imaging NIC ${list[0]} lost carrier again, nothing to do"
exit 0
fi
if dnsmasq_running || [ -e "$RUN/stopped-dnsmasq" ]; then
# restart = stop, then the ExecStartPre guard with dnsmasq down
"$SYSTEMCTL" restart dnsmasq
rc=$?
rm -f "$RUN/stopped-dnsmasq"
if [ "$rc" -eq 0 ]; then
log notice "imaging NIC ${list[0]}: guard passed, dnsmasq (re)started"
else
log crit "imaging NIC ${list[0]}: dnsmasq NOT started (guard refused or start failed, rc $rc); see journalctl -u dnsmasq. Fix the cabling, then systemctl start dnsmasq"
fi
else
"$CHECK" --guard >/dev/null 2>&1
rc=$?
log info "imaging NIC ${list[0]}: dnsmasq was not running, left stopped (guard rc $rc, status refreshed)"
fi
exit 0
}
case "${1:-}" in
--gain) on_gain ;;
-h|--help) sed -n '2,36p' "$0"; exit 0 ;;
*) on_event "${1:-${IFACE:-}}" ;;
esac

View File

@@ -1,16 +0,0 @@
# Started (never enabled) by pxe-nic-carrier, the networkd-dispatcher
# hook, when the imaging NIC gets carrier back. Re-checks the imaging NIC
# before dnsmasq serves again: if dnsmasq was running (or the hook stopped
# it on carrier loss), "systemctl restart dnsmasq" so its ExecStartPre
# guard decides; otherwise it only refreshes /run/pxe-nic-status.json.
# The hook restarts this unit on every carrier gain and stops it on a
# carrier loss, so the newest event wins.
[Unit]
Description=PXE NIC carrier change: re-check the imaging NIC before dnsmasq serves
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/pxe-nic-carrier --gain
TimeoutStartSec=120

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +0,0 @@
# Periodic imaging-NIC watch (pxe-nic-check.timer runs this every 5 min).
# Listens 60 s on each NIC, probes the imaging NIC for foreign DHCP, writes
# /run/pxe-nic-status.json for the webapp, and STOPS dnsmasq if the imaging
# NIC turns out to be on the GE network or next to another DHCP server.
# It never starts dnsmasq: after fixing the cabling, start it by hand
# (systemctl start dnsmasq), which re-runs the guard. If dnsmasq was
# restarted inside the window it re-checks (~6 s) before stopping it.
[Unit]
Description=PXE NIC watch: keep dnsmasq off the GE corporate network
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/pxe-nic-check --watch
TimeoutStartSec=180
Nice=5

View File

@@ -1,11 +0,0 @@
[Unit]
Description=PXE NIC watch every 5 minutes
[Timer]
OnBootSec=2min
OnActiveSec=1min
OnUnitActiveSec=5min
AccuracySec=15s
[Install]
WantedBy=timers.target

View File

@@ -1,297 +0,0 @@
#!/bin/bash
# pxe-pick-nic - choose the imaging NIC by its cable, not by its MAC.
#
# Usage:
# sudo pxe-pick-nic interactive: pull the imaging switch cable and
# plug it back in; the port that blinked is it
# pxe-pick-nic --auto for the first-boot playbook: the one wired
# port with a link, if it is isolated
# pxe-pick-nic --show print the saved choice
#
# The choice is saved in /etc/pxe-server/nic-pick.conf (imaging_mac=...).
# The playbook uses it when site-vars.yml leaves pxe_nic_mac empty, and pins
# that MAC from then on: the box never re-guesses at boot, so a moved cable
# can never move 172.16.9.1 and DHCP onto another port.
#
# A port is accepted only when pxe-nic-check calls it "isolated": no GE
# switch (LLDP/CDP), no 802.1X port authentication, and no other DHCP
# server answering one DHCPDISCOVER. It listens CHECK_WINDOW seconds
# (default 65: a Cisco switch sends CDP every 60 s). --auto also refuses a
# port where more than ARP_MAX (default 10) hosts were heard: an imaging
# switch at install time is nearly silent, a GE access VLAN is not, and
# that holds even when the switch sends no LLDP/CDP and the probe is lost
# (STP still listening). Anything else is refused and nothing is saved.
# The check uses the site's corp_domain_suffixes and allowed_switches
# (PXE_PICK_SUFFIXES / PXE_PICK_ALLOWED from the playbook, else
# /etc/pxe-server/nic.conf), so a managed imaging switch can be allowed.
#
# Install rule for sites: during the install plug in ONLY the imaging
# switch. Plug the GE cable into any other port afterwards; the catch-all
# netplan entry makes it a DHCP client and dnsmasq never serves it.
#
# Exit: 0 chosen (or --show found one), 1 refused / could not choose,
# 2 usage.
set -u
CONF=${PXE_PICK_CONF:-/etc/pxe-server/nic-pick.conf}
HINT=${PXE_PICK_HINT:-/var/lib/pxe-firstboot/nic-hint}
SYSFS_NET=${SYSFS_NET:-/sys/class/net}
NIC_CHECK=${PXE_NIC_CHECK:-/usr/local/sbin/pxe-nic-check}
CHECK_WINDOW=${CHECK_WINDOW:-65}
ARP_MAX=${PXE_PICK_ARP_MAX:-10}
NIC_CONF=${PXE_NIC_CONF:-/etc/pxe-server/nic.conf}
SETTLE=${PXE_PICK_SETTLE:-8}
WAIT=${PXE_PICK_WAIT:-180}
log() { echo "pxe-pick-nic: $*"; }
# Physical wired Ethernet ports: a device behind them, ARP type 1, not
# wireless, not a bridge.
wired() {
local d n
for d in "$SYSFS_NET"/*; do
n=${d##*/}
[ -e "$d/device" ] || continue
[ "$(cat "$d/type" 2>/dev/null)" = 1 ] || continue
[ -e "$d/wireless" ] || [ -e "$d/phy80211" ] && continue
[ -e "$d/bridge" ] && continue
echo "$n"
done
}
mac_of() { cat "$SYSFS_NET/$1/address" 2>/dev/null; }
# 1 = link, 0 = none. carrier is unreadable while the port is admin-down.
carrier() { [ "$(cat "$SYSFS_NET/$1/carrier" 2>/dev/null)" = 1 ] && echo 1 || echo 0; }
# Bring every wired port up (no address is added) so its link can be seen.
links_up() {
local n up=0
for n in $(wired); do
if ! ip link show dev "$n" 2>/dev/null | grep -q '[<,]UP[,>]'; then
ip link set dev "$n" up 2>/dev/null && up=1
fi
done
[ "$up" -eq 1 ] && sleep "$SETTLE"
return 0
}
table() {
local n
for n in $(wired); do
printf ' %-16s %s link %s\n' "$n" "$(mac_of "$n")" \
"$([ "$(carrier "$n")" = 1 ] && echo up || echo down)"
done
}
# A pxe-nic-check config with only the site settings that matter here
# (never the MAC keys: the port under test is given with --iface).
check_config() {
local f=$1 suffixes=${PXE_PICK_SUFFIXES:-} allowed=${PXE_PICK_ALLOWED:-}
if [ -z "$suffixes$allowed" ] && [ -f "$NIC_CONF" ]; then
suffixes=$(sed -n 's/^[[:space:]]*corp_domain_suffixes[[:space:]]*=[[:space:]]*//p' "$NIC_CONF" | tail -1)
allowed=$(sed -n 's/^[[:space:]]*allowed_switches[[:space:]]*=[[:space:]]*//p' "$NIC_CONF" | tail -1)
fi
{
echo "[nic]"
[ -n "$suffixes" ] && echo "corp_domain_suffixes = $suffixes"
[ -n "$allowed" ] && echo "allowed_switches = $allowed"
} >"$f"
}
# check IFACE [strict]: prints the verdict, returns 0 only for "isolated"
# (with strict, also only when at most ARP_MAX hosts were heard).
check() {
local out verdict arp why cfg
if [ ! -f "$NIC_CHECK" ]; then
log "ERROR: $NIC_CHECK not found"
return 1
fi
cfg=$(mktemp)
check_config "$cfg"
log "checking $1 is isolated: listening ${CHECK_WINDOW}s for GE switches and DHCP servers..."
out=$(python3 "$NIC_CHECK" --iface "$1" --probe --window "$CHECK_WINDOW" --json --config "$cfg" 2>/dev/null)
rm -f "$cfg"
read -r verdict arp why < <(python3 -c '
import json, sys
try:
e = json.load(sys.stdin)["interfaces"][0]
ev = "; ".join(x for x in e["evidence"] if not x.endswith("ARP hosts seen"))
print(e["verdict"], int(e.get("arp_hosts") or 0), ev or "-")
except Exception as ex:
print("error", 0, "pxe-nic-check gave no usable result: %s" % ex)
' <<<"$out")
if [ "$verdict" = isolated ]; then
if [ "$arp" -gt "$ARP_MAX" ]; then
log "$1: isolated by its switch and DHCP, but $arp hosts were heard (more than $ARP_MAX)"
if [ "${2:-}" = strict ]; then
log "REFUSED: a busy LAN is not an imaging switch at install time; it may be a GE port"
log "with LLDP/CDP off. Pick it by its cable (sudo pxe-pick-nic) or set pxe_nic_mac."
return 1
fi
log "WARNING: make sure this is the imaging switch, not a GE port with LLDP/CDP turned off."
else
log "$1: isolated (no GE switch, no 802.1X, no other DHCP server, $arp hosts heard)"
fi
return 0
fi
log "$1: verdict $verdict: $why"
case "$verdict" in
ge) log "That port is on the GE network (or a managed switch not in allowed_switches). It must never be the imaging port." ;;
foreign-dhcp) log "Another DHCP server answers on that port (a second PXE server, or a routed LAN)." ;;
no-link) log "That port lost its link during the check." ;;
esac
return 1
}
save() {
local n=$1 mac
mac=$(mac_of "$n")
mkdir -p "$(dirname "$CONF")"
{
echo "# Written by pxe-pick-nic $(date -Is). The imaging NIC; the playbook"
echo "# pins it when site-vars.yml leaves pxe_nic_mac empty."
echo "imaging_mac=$mac"
echo "picked_iface=$n"
} >"$CONF.tmp" && mv "$CONF.tmp" "$CONF"
rm -f "$HINT"
log "saved: imaging NIC = $n ($mac) in $CONF"
}
hint() {
mkdir -p "$(dirname "$HINT")" 2>/dev/null
{ printf '%s\n' "$*" >"$HINT"; } 2>/dev/null
}
saved_mac() {
sed -n 's/^imaging_mac=\(\([0-9a-f]\{2\}:\)\{5\}[0-9a-f]\{2\}\)$/\1/p' "$CONF" 2>/dev/null | tail -1
}
# --- modes ---------------------------------------------------------------------
mode_show() {
local mac
mac=$(saved_mac)
[ -n "$mac" ] || { log "no saved choice in $CONF"; return 1; }
echo "$mac"
}
mode_auto() {
local mac n with=() names
mac=$(saved_mac)
if [ -n "$mac" ]; then
for n in $(wired); do
if [ "$(mac_of "$n")" = "$mac" ]; then
log "using the saved choice: $n ($mac)"
echo "$mac"
return 0
fi
done
log "the saved imaging NIC $mac is not on this box any more"
hint "The saved imaging NIC $mac is gone (dongle replaced?). Log in and run: sudo pxe-pick-nic"
return 1
fi
links_up
for n in $(wired); do
[ "$(carrier "$n")" = 1 ] && with+=("$n")
done
log "wired ports:"
table
if [ ${#with[@]} -eq 0 ]; then
log "REFUSED: no wired port has a link. Plug the imaging switch into the port to use."
hint "Could not pick the imaging port: no port has a link. Plug in the imaging switch, then log in and run: sudo pxe-pick-nic"
return 1
fi
if [ ${#with[@]} -gt 1 ]; then
names="${with[*]}"
log "REFUSED: ${#with[@]} ports have a link ($names). During the install only the imaging switch may be plugged in."
hint "Could not pick the imaging port: ${#with[@]} ports have a link ($names). Log in and run: sudo pxe-pick-nic"
return 1
fi
n=${with[0]}
if ! check "$n" strict; then
hint "Could not pick the imaging port: $n is not isolated (see /var/log/first-boot.log). Plug only the imaging switch in, then log in and run: sudo pxe-pick-nic"
return 1
fi
save "$n" >&2
mac_of "$n"
}
# Wait until exactly one port changes its link; print "NAME NEWSTATE".
wait_change() {
local -A before=()
local n t=0 changed=()
for n in $(wired); do before[$n]=$(carrier "$n"); done
while [ "$t" -lt $((WAIT * 2)) ]; do
sleep 0.5
t=$((t + 1))
changed=()
for n in "${!before[@]}"; do
[ "$(carrier "$n")" != "${before[$n]}" ] && changed+=("$n")
done
if [ ${#changed[@]} -eq 1 ]; then
sleep 1 # a flapping connector settles
n=${changed[0]}
echo "$n $(carrier "$n")"
return 0
fi
if [ ${#changed[@]} -gt 1 ]; then
log "more than one port changed (${changed[*]}); move one cable at a time" >&2
return 1
fi
done
log "nothing changed in ${WAIT}s" >&2
return 1
}
mode_interactive() {
local n state again ans
[ -t 0 ] || { log "interactive mode needs a terminal (use --auto from scripts)"; return 2; }
[ "$(id -u)" -eq 0 ] || { log "run it as root: sudo pxe-pick-nic"; return 2; }
links_up
echo
echo "Pick the imaging NIC: the port cabled to the isolated imaging switch."
echo "Wired ports now:"
table
echo
echo "Pull the imaging switch cable out of this server now"
echo "(or, if it is not plugged in yet, plug it in now). Waiting up to ${WAIT}s..."
read -r n state < <(wait_change) || return 1
[ -n "${n:-}" ] || return 1
if [ "$state" = 0 ]; then
echo "$n lost its link. Plug the cable back into the SAME port..."
read -r again state < <(wait_change) || return 1
if [ "${again:-}" != "$n" ] || [ "$state" != 1 ]; then
log "expected $n to come back up, saw ${again:-nothing} $state; try again"
return 1
fi
fi
sleep "$SETTLE"
echo "Imaging port: $n ($(mac_of "$n"))"
check "$n" || { log "nothing saved"; return 1; }
read -rp "Use $n ($(mac_of "$n")) as the imaging NIC? [y/N] " ans
[[ "$ans" =~ ^[Yy]$ ]] || { log "nothing saved"; return 1; }
save "$n"
if [ -e /var/lib/pxe-firstboot.failed ] && [ -x /opt/first-boot.sh ]; then
read -rp "Run the server setup again now? [Y/n] " ans
if [[ ! "$ans" =~ ^[Nn]$ ]]; then
rm -f /var/lib/pxe-firstboot/attempts
/opt/first-boot.sh 2>&1 | tee -a /var/log/first-boot.log
return "${PIPESTATUS[0]}"
fi
echo "Run it later with: sudo rm -f /var/lib/pxe-firstboot/attempts; sudo /opt/first-boot.sh"
else
echo "Saved. If the server is already set up, re-run the playbook from the"
echo "installer USB to move the imaging config onto this port."
fi
return 0
}
case "${1:-}" in
--auto) mode_auto ;;
--show) mode_show ;;
"") mode_interactive ;;
-h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "usage: $0 [--auto | --show]" >&2; exit 2 ;;
esac

View File

@@ -15,7 +15,7 @@
# Step 3: restart smbd (full smbd restart, kills all child sessions) # Step 3: restart smbd (full smbd restart, kills all child sessions)
# Step 4: kill any leftover smbd child processes that survived restart # Step 4: kill any leftover smbd child processes that survived restart
# Step 5: flush conntrack for 172.16.9.0/24 (kernel connection tracking) # Step 5: flush conntrack for 172.16.9.0/24 (kernel connection tracking)
# Step 6: flush ARP / neighbour cache # Step 6: flush ARP / neighbour cache on br-pxe
# Step 7: drop TCP sockets on port 445 via ss -K # Step 7: drop TCP sockets on port 445 via ss -K
# Step 8: restart dnsmasq (DHCP/TFTP state as a last resort before reboot) # Step 8: restart dnsmasq (DHCP/TFTP state as a last resort before reboot)
@@ -65,8 +65,8 @@ else
fi fi
pause "Step 5 done" pause "Step 5 done"
echo "=== Step 6/8: flush ARP / neighbour cache ===" echo "=== Step 6/8: flush ARP / neighbour cache on br-pxe ==="
ip neigh flush all 2>&1 || true ip neigh flush dev br-pxe 2>&1 || ip neigh flush all 2>&1 || true
pause "Step 6 done" pause "Step 6 done"
echo "=== Step 7/8: drop tcp sockets on port 445 ===" echo "=== Step 7/8: drop tcp sockets on port 445 ==="

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
# select-waxtrace-asset.ps1 - Arrow-key bay picker for wax/trace imaging.
#
# Reads bay-config.csv on the PXE share to build the menu of known bays.
# Falls back to INDEX.csv (cal-disc index) if bay-config.csv is missing.
# Operator picks with Up/Down arrows + Enter. Always appends an
# "Other (new bay)" option at the end for unlisted bays - selecting it
# falls back to a free-text prompt.
#
# Writes the chosen asset tag to $OutFile (one line, no trailing newline).
# startnet.cmd reads that file back into the MACHINENUM batch var.
#
# Runs in WinPE PowerShell. Win10/11 WinPE ships powershell.exe with
# System.Console.ReadKey support. Tested 2026-05-18.
#
# Exit codes:
# 0 = asset tag written to $OutFile
# 1 = user cancelled (Esc) - $OutFile not written
# 2 = no readable bay source AND no fallback entered
param(
[string]$IndexPath = 'Y:\installers-post\waxtrace\bay-config.csv',
[Parameter(Mandatory=$true)][string]$OutFile
)
$ErrorActionPreference = 'Continue'
function Read-BayList {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return @() }
try {
$rows = @(Import-Csv -LiteralPath $Path)
# bay-config.csv has asset_tag,ftpak_version,model,user_id,hw_sn,hw_id,host,notes
# INDEX.csv (legacy) has asset_tag,unit_serial,probe_part,...
$isBayCfg = $rows.Count -gt 0 -and ($rows[0].PSObject.Properties.Name -contains 'ftpak_version')
return $rows | Sort-Object -Property asset_tag | ForEach-Object {
if ($isBayCfg) {
[PSCustomObject]@{
asset_tag = $_.asset_tag
col1 = $_.ftpak_version
col2 = $_.model
col3 = $_.user_id
schema = 'bay-config'
}
} else {
[PSCustomObject]@{
asset_tag = $_.asset_tag
col1 = $_.unit_serial
col2 = $_.probe_part
col3 = ''
schema = 'index'
}
}
}
} catch {
return @()
}
}
function Show-Menu {
param([object[]]$Items, [int]$Selected, [string]$Title, [string]$Schema)
Clear-Host
Write-Host ""
Write-Host " ============================================================"
Write-Host " $Title"
Write-Host " ============================================================"
Write-Host ""
Write-Host " Up / Down arrows = navigate, Enter = select, Esc = cancel"
Write-Host ""
if ($Schema -eq 'bay-config') {
Write-Host (" {0,-10} {1,-8} {2,-10} {3}" -f 'ASSET','FTPAK','MODEL','USER ID')
Write-Host (" {0,-10} {1,-8} {2,-10} {3}" -f '-----','-----','-----','-------')
} else {
Write-Host (" {0,-10} {1,-14} {2}" -f 'ASSET','SERIAL','PROBE')
Write-Host (" {0,-10} {1,-14} {2}" -f '-----','------','-----')
}
for ($i = 0; $i -lt $Items.Count; $i++) {
$item = $Items[$i]
if ($item -is [string]) {
$line = $item
} elseif ($Schema -eq 'bay-config') {
$line = "{0,-10} {1,-8} {2,-10} {3}" -f $item.asset_tag, $item.col1, $item.col2, $item.col3
} else {
$line = "{0,-10} {1,-14} {2}" -f $item.asset_tag, $item.col1, $item.col2
}
if ($i -eq $Selected) {
Write-Host (" > " + $line) -ForegroundColor Black -BackgroundColor White
} else {
Write-Host (" " + $line)
}
}
Write-Host ""
}
# Try bay-config.csv first; fall back to INDEX.csv if missing OR if the
# explicit -IndexPath argument points to INDEX.csv (legacy callers).
$bays = @(Read-BayList -Path $IndexPath)
if ($bays.Count -eq 0 -and $IndexPath -notmatch 'INDEX\.csv$') {
$fallback = 'Y:\installers-post\waxtrace\calibrations\INDEX.csv'
if (Test-Path -LiteralPath $fallback) {
Write-Host " (no bay-config.csv at $IndexPath - falling back to $fallback)"
$bays = @(Read-BayList -Path $fallback)
}
}
$schema = if ($bays.Count -gt 0) { $bays[0].schema } else { 'bay-config' }
$menuItems = @()
foreach ($b in $bays) { $menuItems += $b }
$menuItems += '** Other (new bay - enter asset tag manually) **'
$sel = 0
while ($true) {
Show-Menu -Items $menuItems -Selected $sel -Title 'Wax/Trace Asset Tag' -Schema $schema
$key = [System.Console]::ReadKey($true)
switch ($key.Key) {
'UpArrow' { if ($sel -gt 0) { $sel-- } }
'DownArrow' { if ($sel -lt ($menuItems.Count - 1)) { $sel++ } }
'Enter' {
if ($sel -eq ($menuItems.Count - 1)) {
Write-Host ""
$manual = Read-Host " Enter asset tag (e.g. WJRP9999) or blank to abort"
if ($manual) {
$manual = $manual.Trim().ToUpper()
Set-Content -LiteralPath $OutFile -Value $manual -NoNewline -Encoding ascii
Write-Host ""
Write-Host " Saved asset tag: $manual"
Start-Sleep -Seconds 1
exit 0
} else {
exit 1
}
} else {
$pick = $bays[$sel].asset_tag
Set-Content -LiteralPath $OutFile -Value $pick -NoNewline -Encoding ascii
Write-Host ""
Write-Host " Selected: $pick"
Start-Sleep -Seconds 1
exit 0
}
}
'Escape' { exit 1 }
}
}

View File

@@ -6,11 +6,6 @@ set "BIOSDIR=%~dp0"
set "FLASH=%BIOSDIR%Flash64W.exe" set "FLASH=%BIOSDIR%Flash64W.exe"
set "MANIFEST=%BIOSDIR%models.txt" set "MANIFEST=%BIOSDIR%models.txt"
REM --- Report this machine to the PXE webapp (Hardware Coverage) ---
REM Every WinPE boot maps this share, whatever image is picked next, so this
REM is the one place that sees standard/engineer builds too. Best effort.
if exist "%BIOSDIR%hardware-report.ps1" powershell -NoProfile -ExecutionPolicy Bypass -File "%BIOSDIR%hardware-report.ps1" >NUL 2>&1
if exist "%FLASH%" goto :flash_ok if exist "%FLASH%" goto :flash_ok
echo Flash64W.exe not found, skipping BIOS check. echo Flash64W.exe not found, skipping BIOS check.
set "BIOS_STATUS=Skipped (Flash64W.exe missing)" set "BIOS_STATUS=Skipped (Flash64W.exe missing)"

View File

@@ -1,58 +0,0 @@
# hardware-report.ps1 - Report this machine's hardware to the PXE webapp so
# its Hardware Coverage page can flag models with no driver pack or BIOS.
#
# Called by check-bios.cmd, which every WinPE boot runs from the BIOS share
# before the imaging menu, so it reports every boot - including ones that
# never pick an image - rather than only those that reach the Y: status push.
#
# Sends the exact WMI model PESetup matches driver packs on, the Dell
# system SKU (what Dell's catalogs key on) and the BIOS version. Runs in
# WinPE's stripped PowerShell: wmic only, no CIM cmdlets. Best effort:
# logs to X:\Windows\Temp\hardware-report.log and never fails the boot.
param(
[string]$PxeServer = $(if ($env:PXESERVER) { $env:PXESERVER } else { '172.16.9.1' }),
[int]$Port = 9009,
[int]$TimeoutSec = 5
)
$logFile = 'X:\Windows\Temp\hardware-report.log'
function Log {
param([string]$msg)
try { "$(Get-Date -Format s) $msg" | Out-File -FilePath $logFile -Append -Encoding utf8 } catch { }
}
function Get-WmicValue {
param([string]$Class, [string]$Property)
try {
$line = (& wmic $Class get $Property /value 2>$null |
Where-Object { $_ -match '=' } | Select-Object -First 1)
if ($line) { return ($line -split '=', 2)[1].Trim() }
} catch { Log "wmic $Class $Property exception: $_" }
return ''
}
$hw = @{
serial = Get-WmicValue 'bios' 'SerialNumber'
model = Get-WmicValue 'computersystem' 'Model'
manufacturer = Get-WmicValue 'computersystem' 'Manufacturer'
sku = Get-WmicValue 'computersystem' 'SystemSKUNumber'
bios_version = Get-WmicValue 'bios' 'SMBIOSBIOSVersion'
}
Log "model=$($hw.model) sku=$($hw.sku) bios=$($hw.bios_version) serial=$($hw.serial)"
if (-not $hw.model) {
Log "no model - nothing to report"
exit 0
}
try {
$resp = Invoke-WebRequest -Uri "http://${PxeServer}:${Port}/hardware/seen" -Method POST `
-Body ($hw | ConvertTo-Json -Compress) -ContentType 'application/json' `
-UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop
Log "OK http=$($resp.StatusCode)"
} catch {
Log "ERR $($_.Exception.Message)"
}
exit 0

View File

@@ -1,73 +1,56 @@
# ModelSubstring|BIOSFile|Version # ModelSubstring|BIOSFile|Version
13 Plus PB13250|Dell_Pro_PA13250_PA14250_PB13250_PB14250_PB16250_2.18.1.exe|2.18.1 13 Plus PB13250|Dell_Pro_PA13250_PA14250_PB13250_PB14250_PB16250_2.8.1.exe|2.8.1
14 MC14250|Dell_Pro_Max_MC16250_MC14250_1.19.2.exe|1.19.2 14 MC14250|Dell_Pro_Max_MC16250_MC14250_1.9.0.exe|1.9.0
14 PC14250|Dell_Pro_PC14250_PC16250_1.18.0.exe|1.18.0 14 PC14250|Dell_Pro_PC14250_PC16250_1.7.0.exe|1.7.0
14 Premium MA14250|Dell_Pro_Max_MA14250_MA16250_1.13.1.exe|1.13.1 14 Premium MA14250|Dell_Pro_Max_MA14250_MA16250_1.7.1.exe|1.7.1
16 Plus MB16250|Dell_Pro_Max_MB16250_MB18250_3.3.2.exe|3.3.2 16 Plus MB16250|Dell_Pro_Max_MB16250_MB18250_2.2.2.exe|2.2.2
QB24250|Dell_Pro_Plus_QB24250_QC24250_QC24251_Combine_1.18.0.exe|1.18.0 24250|Dell_Pro_Plus_QB24250_QC24250_QC24251_1.9.2.exe|1.9.2
QC24250|Dell_Pro_Plus_QB24250_QC24250_QC24251_Combine_1.18.0.exe|1.18.0 5430|Latitude_5430_7330_Rugged_1.41.0.exe|1.41.0
QC24251|Dell_Pro_Plus_QB24250_QC24250_QC24251_Combine_1.18.0.exe|1.18.0 7080|OptiPlex_7080_1.37.0.exe|1.37.0
5430 Rugged|Latitude_5430_7330_Rugged_1.45.0.exe|1.45.0 7090|OptiPlex_7090_1.40.0.exe|1.40.0
7330 Rugged|Latitude_5430_7330_Rugged_1.45.0.exe|1.45.0 7220 Rugged|Latitude_7220_Rugged_Extreme_Tablet_1.50.0.exe|1.50.0
Latitude 5430|Latitude_5430_1.41.0.exe|1.41.0 7230 Rugged Extreme Tablet|Latitude_7230_1.30.1.exe|1.30.1
7080|OptiPlex_7080_1.39.0.exe|1.39.0 7320 2-in-1 Detachable|Dell_Latitude_7320_Detachable_1.45.0_64.exe|1.45.0
7090 Ultra|OptiPlex_7090_UFF_1.44.0.exe|1.44.0 7350 Detachable|Latitude_7350_Detachable_1.9.1.exe|1.9.1
7090|OptiPlex_7090_1.44.0.exe|1.44.0 7400 AIO|Latitude_7X00_1.43.0.exe|1.43.0
7220 Rugged|Latitude_7220_Rugged_Extreme_Tablet_1.53.0.exe|1.53.0 AIO Plus 7410|Latitude_7X10_1.43.0.exe|1.43.0
7230 Rugged Extreme Tablet|Latitude_7230_1.34.0.exe|1.34.0 AIO Plus 7420|Latitude_7X20_1.48.0.exe|1.48.0
7320 Detachable|Dell_Latitude_7320_Detachable_1.49.0_64.exe|1.49.0 Latitude 5330|Latitude_5330_1.33.0.exe|1.33.0
7350 Detachable|Latitude_7350_Detachable_1.24.0.exe|1.24.0 Latitude 5340|Latitude_5340_1.27.1.exe|1.27.1
7400 AIO|OptiPlex_7400_1.1.61.exe|1.1.61 Latitude 5350|Latitude_5350_1.20.0.exe|1.20.0
7400 All-in-One|OptiPlex_7400_1.1.61.exe|1.1.61 Latitude 5440|Latitude_5440_Precision_3480_1.28.1.exe|1.28.1
AIO Plus 7410|OptiPlex_7410_1.37.0.exe|1.37.0 Latitude 5450|Latitude_5450_Precision_3490_1.20.1.exe|1.20.1
AIO Plus 7420|OptiPlex_7420_1.27.0.exe|1.27.0 Latitude 5530|Precision_5530_1.42.0.exe|1.42.0
Latitude 5330|Latitude_5330_1.38.0.exe|1.38.0 Latitude 5540|Precision_5540_1.42.0.exe|1.42.0
Latitude 5340|Latitude_5340_1.31.0.exe|1.31.0 Latitude 7430|Latitude_7X30_1.38.0.exe|1.38.0
Latitude 5350|Latitude_5350_1.26.0.exe|1.26.0 Latitude 7440|Latitude_7X40_1.28.1.exe|1.28.1
Latitude 5440|Latitude_5440_Precision_3480_1.34.0.exe|1.34.0 Latitude 7450|OptiPlex_7450_1.34.0.exe|1.34.0
Latitude 5450|Latitude_5450_Precision_3490_1.26.0.exe|1.26.0 Micro 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
Latitude 5530|Precision_3570_Latitude_5530_1.38.0.exe|1.38.0 SFF Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
Latitude 5540|Precision_3580_Latitude_5540_1.31.0.exe|1.31.0 Tower Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
Latitude 7430|Latitude_7X30_1.42.0.exe|1.42.0 Micro 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Latitude 7440|Latitude_7X40_1.34.0.exe|1.34.0 Micro Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Latitude 7450|Latitude_7X50_1.26.0.exe|1.26.0 SFF 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Micro 7010|OptiPlex_7010_1.38.0_SEMB.exe|1.38.0 SFF Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
SFF Plus 7010|OptiPlex_7010_1.38.0_SEMB.exe|1.38.0 Tower 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower Plus 7010|OptiPlex_7010_1.38.0_SEMB.exe|1.38.0 Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Micro 7020|OptiPlex_7020_1.28.1_SEMB.exe|1.28.1 Micro QCM1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2
Micro Plus 7020|OptiPlex_7020_1.28.1_SEMB.exe|1.28.1 OptiPlex 3000|OptiPlex_3000_1.38.0.exe|1.38.0
SFF 7020|OptiPlex_7020_1.28.1_SEMB.exe|1.28.1 OptiPlex 7000|OptiPlex_7000_1.38.0.exe|1.38.0
SFF Plus 7020|OptiPlex_7020_1.28.1_SEMB.exe|1.28.1 Precision 5490|OptiPlex_5490_AIO_1.45.0.exe|1.45.0
Tower 7020|OptiPlex_7020_1.28.1_SEMB.exe|1.28.1 Precision 5550|Precision_3590_3591_Latitude_5550_1.8.0.exe|1.8.0
Tower Plus 7020|OptiPlex_7020_1.28.1_SEMB.exe|1.28.1 Precision 5570|XPS9520_Precision5570_1.39.0_QSL0.exe|1.39.0
Micro QCM1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.19.3.exe|1.19.3 Precision 5680|Precision_5680_1_27_0.exe|1.27.0
Tower Plus QBT1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.19.3.exe|1.19.3 Precision 5690|Precision_5690_1.18.0.exe|1.18.0
OptiPlex 3000 Thin Client|OptiPlex_TC_1.37.0.exe|1.37.0 Precision 5820 Tower|Precision_5820_2.48.0.exe|2.48.0
OptiPlex 3000|OptiPlex_3000_1.42.0.exe|1.42.0 Precision 5860 Tower|Precision_5860_3.5.0.exe|3.5.0
OptiPlex 7000|OptiPlex_7000_1.42.0.exe|1.42.0 Precision 7560|Precision_7X60_1.44.1.exe|1.44.1
Precision 5490|Precision_5490_1.24.0.exe|1.24.0 Precision 7670|Precision_7X70_1.8.0.exe|1.8.0
Precision 5550|Precision_5550_1.42.0.exe|1.42.0 Precision 7680|Precision_7X80_1.9.0.exe|1.9.0
Precision 5570|XPS9520_Precision5570_1.42.0_QSL0.exe|1.42.0 Precision 7770|OptiPlex_7770_7470_1.40.0.exe|1.40.0
Precision 5680|Precision_5680_1_29_0.exe|1.29.0 Precision 7780|OptiPlex_7780_7480_1.43.0.exe|1.43.0
Precision 5690|Precision_5690_1.26.0.exe|1.26.0 Precision 7820 Tower|Precision_7820_7920_2.50.0.exe|2.50.0
Precision 5820 Tower|Precision_5820_2.50.2.exe|2.50.2 Precision 7865 Tower|Precision_7865_1.6.1.exe|1.6.1
Precision 5860 Tower|Precision_5860_3.10.0.exe|3.10.0 Precision 7875 Tower|Precision_7875_SHP_02.07.03.exe|2.7.3
Precision 7560|Precision_7X60_1.49.0.exe|1.49.0 Rugged 14 RB14250|Dell_Pro_Rugged_RB14250_RA13250_1.13.1.exe|1.13.1
Precision 7670|Precision_7x70_1.40.0.exe|1.40.0 Tower Plus QBT1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2
Precision 7680|Precision_7X80_1.32.0.exe|1.32.0
Precision 7770|Precision_7x70_1.40.0.exe|1.40.0
Precision 7780|Precision_7X80_1.32.0.exe|1.32.0
Precision 7820 Tower|Precision_7820_7920_2.53.1.exe|2.53.1
Precision 7865 Tower|Precision_7865_1.29.0.exe|1.29.0
Precision 7875 Tower|Precision_7875_SHP_02.12.00.exe|2.12.0
Rugged 14 RB14250|Dell_Pro_Rugged_RB14250_RA13250_1.19.1.exe|1.19.1
P314260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2
P316260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2
P514260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2
P516260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2
P713260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2
P703260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2
P714260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2
P704260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2
PW514260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2
PW516260|Dell_Pro_P31x260_P51x260_P7xx260_Dell_Pro_Precision_PW51x260_2.4.2.exe|2.4.2

View File

@@ -1,48 +0,0 @@
# Register-BulkEnrollTask-Standard.ps1 - register the BulkEnrollmentOrchestrator
# logon task for the gea-standard and gea-engineer images.
#
# WHY THIS IS A FILE AND NOT AN INLINE COMMAND
# This ran inline as "powershell.exe -Command ..." in the unattend's specialize
# pass at RunSynchronousCommand Order 39. RunSynchronousCommand/Path is capped at
# 259 characters by the unattend schema; the inline form was 604, so Windows
# Setup rejected the ENTIRE answer file:
#
# /settings/RunSynchronous/RunSynchronousCommand/[Order="39"]/Path
# Description = Value is invalid. hrResult = 0x80220005 pass = specialize
#
# One over-length value invalidates every pass, so the machine stops at a dialog
# with nothing configured.
#
# WHY THERE ARE TWO OF THESE SCRIPTS
# gea-shopfloor uses Register-BulkEnrollTask.ps1, which launches the orchestrator
# as a PowerShell script with -ManualFallback: shopfloor bays are vanilla by
# design, so the orchestrator skips BPRT injection and the provisioning package.
# gea-standard and gea-engineer enrol normally and call the .cmd wrapper instead.
# All three image types share ONE Deploy\Applications\BPRT directory (a symlink
# to _shared\Applications\BPRT), so the two variants need distinct filenames.
#
# PESetup's CopyPackages copies the whole of <media>\Deploy\Applications to the
# target before the image is applied, so this script is present at
# C:\Deploy\Applications\BPRT\ by the time specialize runs. See
# docs/PESETUP-INTERNALS.md.
$ErrorActionPreference = 'Stop'
$taskName = 'BulkEnrollmentOrchestrator'
$orchestrator = 'C:\Deploy\Applications\BPRT\Start-BulkEnrollOrchestrator.cmd'
# Self-deleting task: schtasks /Delete drops the registration, then the
# orchestrator runs. cmd's & chains them so the delete happens even if the
# orchestrator later reboots the machine.
$argument = '/c schtasks /Delete /TN {0} /F & "{1}"' -f $taskName, $orchestrator
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $argument
$trigger = New-ScheduledTaskTrigger -AtLogOn
# S-1-5-32-544 = BUILTIN\Administrators. GroupId + Highest so it runs elevated
# for whichever admin logs on first, without naming an account.
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-544' -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 3)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force
Write-Host "Registered scheduled task '$taskName'."

View File

@@ -1,42 +0,0 @@
# Register-BulkEnrollTask.ps1 - register the BulkEnrollmentOrchestrator logon task.
#
# WHY THIS IS A FILE AND NOT AN INLINE COMMAND
# This ran as an inline "powershell.exe -Command ..." in the unattend's
# specialize pass, at RunSynchronousCommand Order 16. The unattend schema caps
# RunSynchronousCommand/Path at 259 characters; the inline form had grown to 676,
# so Windows Setup rejected the WHOLE answer file:
#
# /settings/RunSynchronous/RunSynchronousCommand/[Order="16"]/Path
# Description = Value is invalid. hrResult = 0x80220005 pass = specialize
# "Windows could not parse or process unattend answer file ... The answer file
# is invalid."
#
# One over-length value blocks every pass, so the machine stops at a dialog with
# nothing configured. Every shopfloor build from 2026-08-05 evening onward failed
# this way. Keep the unattend's Path short and put logic here.
#
# PESetup's CopyPackages copies the whole of <media>\Deploy\Applications to the
# target before the image is applied, so this script is present at
# C:\Deploy\Applications\BPRT\ by the time specialize runs. See
# docs/PESETUP-INTERNALS.md.
$ErrorActionPreference = 'Stop'
$taskName = 'BulkEnrollmentOrchestrator'
$orchestrator = 'C:\Deploy\Applications\BPRT\Start-BulkEnrollOrchestrator.ps1'
# Self-deleting task: schtasks /Delete removes the registration, then the
# orchestrator runs. cmd's & chains them so the delete happens even if the
# orchestrator later reboots the machine.
$argument = '/c schtasks /Delete /TN {0} /F & powershell.exe -NoProfile -ExecutionPolicy Bypass -File "{1}" -ManualFallback' -f $taskName, $orchestrator
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $argument
$trigger = New-ScheduledTaskTrigger -AtLogOn
# S-1-5-32-544 = BUILTIN\Administrators. GroupId + Highest so it runs elevated
# for whichever admin logs on first, without naming an account.
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-544' -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 3)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force
Write-Host "Registered scheduled task '$taskName'."

View File

@@ -0,0 +1,409 @@
<#
.SYNOPSIS
Collect everything needed to diagnose an imaged bay, into one zip.
.DESCRIPTION
Read-only. Changes nothing on the machine.
Written because every imaging investigation so far has meant walking to a bay
and hand-copying files, and each time we discovered another thing we wished
we had grabbed at the same moment. This takes the lot in one pass.
Run it on a freshly imaged bay BEFORE lockdown.
This captures KNOWN-CURRENT, not known-good. A bay straight off the line has
applications on it that should not be there - preinstall.json entries without
a PCTypes filter install everywhere, so a Display bay picks up Adobe,
OpenText and Defect Tracker. The point of collecting is to have an exact
record of what imaging really produces, so the unnecessary items can be
identified and filtered out.
Before lockdown specifically, because afterwards you cannot tell whether
something is absent because lockdown removed it or because imaging never
installed it.
.PARAMETER Destination
Where to drop the zip. Defaults to C:\Logs. Point it at a share to collect
centrally, e.g. -Destination \\172.16.9.1\enrollment\imaging-logs
(only reachable while the bay is still on the imaging LAN).
.PARAMETER Label
Optional tag folded into the filename, e.g. -Label pre-lockdown.
.EXAMPLE
.\Collect-ImagingDiagnostics.ps1 -Label pre-lockdown
.EXAMPLE
.\Collect-ImagingDiagnostics.ps1 -Destination \\172.16.9.1\enrollment\imaging-logs
#>
[CmdletBinding()]
param(
[string]$Destination = 'C:\Logs',
[string]$Label = ''
)
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$serial = try { (Get-CimInstance Win32_BIOS).SerialNumber.Trim() } catch { 'unknown' }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$name = if ($Label) { "$serial-$Label-$stamp" } else { "$serial-$stamp" }
$work = Join-Path $env:TEMP "imgdiag-$name"
New-Item -ItemType Directory -Path $work -Force | Out-Null
function Section { param([string]$File, [scriptblock]$Body)
$p = Join-Path $work $File
try { & $Body 2>&1 | Out-File -FilePath $p -Encoding utf8 -Width 500 }
catch { "COLLECTION ERROR: $_" | Out-File -FilePath $p -Encoding utf8 }
Write-Host (" {0}" -f $File)
}
function CopyTree { param([string]$Src, [string]$Dst)
# /XF *.ppkg and /MAX are not optional. run-enrollment harvests
# C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
# provisioning package exists TWICE under the trees we copy. Without these
# the collection is 16 GB and Compress-Archive dies with "stream was too
# long" - it cannot exceed 2 GB.
if (Test-Path $Src) {
$d = Join-Path $work $Dst
New-Item -ItemType Directory -Path $d -Force | Out-Null
robocopy $Src $d /E /R:0 /W:0 /NFL /NDL /NJH /NJS /XF *.ppkg *.wim *.iso /MAX:104857600 | Out-Null
Write-Host (" {0}\ <- {1}" -f $Dst, $Src)
}
}
Write-Host ""
Write-Host "Collecting imaging diagnostics for $serial ..."
Write-Host ""
# --- 1. Identity ---------------------------------------------------------
# ActiveComputerName is the name in use; ComputerName is the PENDING one. They
# differ when a rename is queued for the next reboot - which is exactly how the
# H<serial> -> F<serial> transition works, so capturing both tells you whether
# the package's rename landed or is still waiting.
Section 'identity.txt' {
'== computer names =='
'Active (in use): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -EA SilentlyContinue).ComputerName
'Pending (next boot): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -EA SilentlyContinue).ComputerName
'NV Hostname: ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' -EA SilentlyContinue).'NV Hostname'
''
'== hardware =='
Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer, Model, Domain, PartOfDomain, TotalPhysicalMemory | Format-List
Get-CimInstance Win32_BIOS | Select-Object SerialNumber, SMBIOSBIOSVersion, ReleaseDate | Format-List
Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, InstallDate, LastBootUpTime | Format-List
''
'== uptime / install =='
'Windows installed: ' + (Get-CimInstance Win32_OperatingSystem).InstallDate
}
# --- 2. Enrollment state -------------------------------------------------
# dsregcmd is the authority on whether the Entra join actually happened.
# criticalChecks.json is the package's own self-assessment and the two can
# disagree - "Entra ID Joined: false" right after imaging is normal because the
# bay has not reached the production network yet.
Section 'enrollment.txt' {
'== dsregcmd /status =='
& dsregcmd /status
''
'== package self-checks =='
foreach ($f in 'C:\Logs\BPRT\criticalChecks.json','C:\Logs\BPRT\packageInfo.json',
'C:\Logs\BPRT\Orchestrator\TokenMatch.json') {
if (Test-Path $f) { "--- $f ---"; Get-Content $f -Raw }
}
''
'== staged selections =='
foreach ($f in 'C:\Enrollment\pc-type.txt','C:\Enrollment\display-type.txt',
'C:\Enrollment\pc-config.txt','C:\Enrollment\machine-number.txt',
'C:\Enrollment\fetch-source.txt') {
if (Test-Path $f) { "$f = " + ((Get-Content $f -First 1) -replace 'pxe$','<redacted>') }
}
}
# --- 3. Installed applications ------------------------------------------
# Registry Uninstall keys, both views. Deliberately NOT Win32_Product: querying
# it triggers an MSI reconfigure of every installed product, which is slow and
# can actually change the machine.
Section 'installed-apps.txt' {
$paths = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
Get-ItemProperty $paths -EA SilentlyContinue |
Where-Object { $_.DisplayName } |
Sort-Object DisplayName |
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation |
Format-Table -AutoSize
}
# --- 4. Everything that starts by itself --------------------------------
# THE section for "why did this app launch". Installers plant their own startup
# entries, so an app being installed IS an app being started - there is no
# separate switch. Capturing all four surfaces shows which app planted what.
# Note both registry views: a 32-bit installer's Run key lands under
# Wow6432Node and 64-bit tooling never sees it.
Section 'autostart.txt' {
'== HKLM Run / RunOnce (native + WOW64) =='
foreach ($k in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
''
'== HKCU Run / RunOnce (current user) =='
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
''
'== Startup folders =='
foreach ($d in "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp",
"$env:AppData\Microsoft\Windows\Start Menu\Programs\StartUp") {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name, Length, LastWriteTime | Format-Table -AutoSize }
}
''
'== per-user Startup folders (all profiles) =='
Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue | ForEach-Object {
$d = Join-Path $_.FullName 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup'
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name | Format-Table -AutoSize }
}
''
'== enabled/disabled state (StartupApproved) =='
# Disabling a startup item via Task Manager or Settings does NOT remove the
# Run key or the Startup shortcut - it writes a flag here. So an entry can
# appear above and still be switched off. First byte 02/06 = enabled,
# 03/07 = disabled. Capturing this is what tells "imaging installed it and
# it runs" apart from "imaging installed it and somebody turned it off",
# which matters because the fix is not to install it at all.
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder') {
if (Test-Path $k) {
"--- $k ---"
$props = Get-Item $k
foreach ($n in $props.Property) {
$v = (Get-ItemProperty $k -Name $n).$n
$state = if ($v -is [byte[]] -and $v.Length -ge 1) {
switch ($v[0]) { 2 {'ENABLED'} 4 {'ENABLED'} 6 {'ENABLED'} 3 {'disabled'} 5 {'disabled'} 7 {'disabled'} default {"unknown(0x{0:X2})" -f $v[0]} }
} else { 'unknown' }
"{0,-10} {1}" -f $state, $n
}
}
}
''
'== scheduled tasks (non-Microsoft) =='
Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskPath -notlike '\Microsoft\*' } |
Select-Object TaskPath, TaskName, State,
@{n='Triggers';e={ ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName }) -join ',' }},
@{n='Action';e={ ($_.Actions | ForEach-Object { $_.Execute }) -join ',' }} |
Sort-Object TaskPath, TaskName | Format-Table -AutoSize -Wrap
''
'== services set to auto-start (non-Microsoft paths) =='
Get-CimInstance Win32_Service -EA SilentlyContinue |
Where-Object { $_.StartMode -eq 'Auto' -and $_.PathName -notmatch 'C:\\Windows\\' } |
Select-Object Name, DisplayName, State, StartMode, PathName |
Sort-Object Name | Format-Table -AutoSize -Wrap
}
# --- 5. Kiosk / display specifics ---------------------------------------
Section 'display-kiosk.txt' {
'== kiosk shortcuts anywhere in Startup =='
Get-ChildItem 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp' -EA SilentlyContinue |
ForEach-Object {
$sh = (New-Object -ComObject WScript.Shell).CreateShortcut($_.FullName)
"{0}`n target: {1}`n args: {2}" -f $_.Name, $sh.TargetPath, $sh.Arguments
}
''
'== Edge policies =='
foreach ($k in 'HKLM:\SOFTWARE\Policies\Microsoft\Edge',
'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
}
# --- 6. GE-Enforce -------------------------------------------------------
Section 'ge-enforce.txt' {
'== registry config =='
foreach ($k in 'HKLM:\SOFTWARE\GE\SFLD\Credentials','HKLM:\SOFTWARE\GE\SFLD\DSC',
'HKLM:\SOFTWARE\GE\ShopDB') {
# values only - do not dump anything that looks like a secret
if (Test-Path $k) {
"--- $k ---"
Get-Item $k | Select-Object -ExpandProperty Property | ForEach-Object {
$v = (Get-ItemProperty $k -Name $_).$_
if ($_ -match '(?i)key|token|secret|password|sas') { "$_ = <redacted, length $($v.ToString().Length)>" }
else { "$_ = $v" }
}
}
}
''
'== enforce client / DSC on disk =='
foreach ($d in 'C:\ProgramData\SFLD','C:\Deploy\Applications\BPRT') {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse -Depth 1 | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
}
# --- 6b. IS IT ACTUALLY TALKING TO THE SHOPDB GE-ENFORCE API? -----------
# Config on disk proves nothing - the client can be present, configured, and
# never once succeed. This answers the actual question: is there a client, is it
# scheduled, has it run, and can this bay reach the endpoint right now.
#
# A 401 from the manifest endpoint is a GOOD result here: it proves DNS, routing
# and TLS all work and the service answered. Only a timeout or DNS failure means
# genuinely unreachable. No token is sent, so nothing here can enrol or change
# anything.
Section 'geenforce-api.txt' {
'== is the client installed? =='
$clientPaths = @(
'C:\Program Files\ShopDB', 'C:\ProgramData\ShopDB',
'C:\Deploy\Applications\BPRT\ShopdbEnforceClient.psm1',
'C:\Enrollment\ShopdbEnforceClient.psm1'
)
$found = $false
foreach ($p in $clientPaths) {
if (Test-Path $p) { $found = $true; "FOUND: $p"
Get-ChildItem $p -Recurse -EA SilentlyContinue |
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
if (-not $found) { 'NOT FOUND - no ShopDB enforce client on this machine.' }
''
'== scheduled tasks that would run it =='
$tasks = Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskName -match '(?i)shopdb|ge-?enforce|asset report' }
if ($tasks) {
foreach ($t in $tasks) {
$i = $t | Get-ScheduledTaskInfo -EA SilentlyContinue
"{0}\{1} State={2} LastRun={3} LastResult={4} NextRun={5}" -f `
$t.TaskPath, $t.TaskName, $t.State, $i.LastRunTime, $i.LastTaskResult, $i.NextRunTime
($t.Actions | ForEach-Object { " action: $($_.Execute) $($_.Arguments)" })
}
} else { 'NONE - nothing scheduled to call the API.' }
''
'== configured base URL =='
$base = $null
foreach ($k in 'HKLM:\SOFTWARE\GE\ShopDB','HKLM:\SOFTWARE\GE\SFLD\Credentials') {
if (Test-Path $k) {
$p = Get-ItemProperty $k
foreach ($n in $p.PSObject.Properties.Name) {
# Match the NAME tightly and validate the VALUE looks like a URL.
# A loose "base" match picked up baseVersion=2.0.2 and the probe
# then tried to fetch "2.0.2/api/docs".
if ($n -match '(?i)^(baseurl|serverurl|shopdburl|endpoint|url)$') {
"$k\$n = $($p.$n)"
if (-not $base -and "$($p.$n)" -match '^https?://') { $base = $p.$n }
}
}
}
}
if (-not $base) { '(no BaseUrl configured - falling back to the known prod URL for the reachability test)' }
if (-not $base) { $base = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' }
''
"== can this bay reach it right now? (base: $base) =="
$targets = @(
@{ Name = 'API docs'; Url = "$base/api/docs" },
@{ Name = 'GE-Enforce manifest'; Url = "$base/api/geenforce/manifest?pctype=gea-shopfloor-display" }
)
foreach ($t in $targets) {
try {
$sw = [Diagnostics.Stopwatch]::StartNew()
$r = Invoke-WebRequest -Uri $t.Url -UseBasicParsing -TimeoutSec 20 -EA Stop
$sw.Stop()
"{0,-22} HTTP {1} in {2} ms <- reachable" -f $t.Name, $r.StatusCode, $sw.ElapsedMilliseconds
} catch {
$code = try { $_.Exception.Response.StatusCode.value__ } catch { $null }
if ($code) {
"{0,-22} HTTP {1} <- REACHABLE (service answered; 401/403 just means no token was sent)" -f $t.Name, $code
} else {
"{0,-22} UNREACHABLE: {1}" -f $t.Name, $_.Exception.Message
' NOTE: ShopDB is only reachable once the bay has joined the AESFMA wifi SSID.'
' On the imaging LAN or plain wired, unreachable here is EXPECTED, not a fault.'
}
}
}
''
'== name resolution / route =='
try {
$h = ([uri]$base).Host
"host: $h"
Resolve-DnsName $h -EA Stop | Select-Object Name, Type, IPAddress | Format-Table -AutoSize
Test-NetConnection -ComputerName $h -Port 443 -InformationLevel Detailed -WarningAction SilentlyContinue |
Select-Object ComputerName, RemoteAddress, TcpTestSucceeded, PingSucceeded | Format-List
} catch { "DNS/route check failed: $_" }
''
'== client logs, if any =='
foreach ($d in 'C:\Logs\ShopDB','C:\Logs\GE-Enforce','C:\ProgramData\ShopDB\Logs') {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
}
# --- 7. Drivers, disk, network ------------------------------------------
Section 'drivers-disk-network.txt' {
'== third-party drivers =='
& pnputil /enum-drivers
''
'== disk layout =='
Get-Disk -EA SilentlyContinue | Format-Table -AutoSize
Get-Partition -EA SilentlyContinue | Format-Table -AutoSize
Get-Volume -EA SilentlyContinue | Format-Table -AutoSize
''
'== network =='
Get-NetAdapter -EA SilentlyContinue | Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress | Format-Table -AutoSize
Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | Select-Object InterfaceAlias, IPAddress, PrefixOrigin | Format-Table -AutoSize
Get-DnsClientServerAddress -AddressFamily IPv4 -EA SilentlyContinue | Format-Table -AutoSize
}
# --- 8. Provisioning session state --------------------------------------
Section 'provisioning-sessions.txt' {
'== sessions =='
$k = 'HKLM:\SOFTWARE\Microsoft\Provisioning\Sessions'
if (Test-Path $k) {
Get-ChildItem $k | ForEach-Object {
$p = Get-ItemProperty $_.PSPath
"{0} State={1} RebootCount={2}" -f $_.PSChildName, $p.State, $p.RebootCount
}
} else { '(no provisioning sessions key)' }
}
# --- 9. Log trees --------------------------------------------------------
CopyTree 'C:\Logs' 'Logs'
CopyTree 'C:\Windows\Panther' 'Panther'
CopyTree 'C:\ProgramData\Microsoft\Provisioning' 'ProvisioningData'
foreach ($f in 'C:\Enrollment\winpe-staging.log','C:\Enrollment\setupcomplete.log') {
if (Test-Path $f) { Copy-Item $f $work -Force -EA SilentlyContinue }
}
# Enrollment dir listing only - it holds an 8 GB package we do not want.
Section 'enrollment-dir-listing.txt' {
if (Test-Path 'C:\Enrollment') {
Get-ChildItem 'C:\Enrollment' -Recurse -EA SilentlyContinue |
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize
}
}
# --- 10. Provisioning event log -----------------------------------------
try {
$evtx = Join-Path $work 'Provisioning-Diagnostics-Admin.evtx'
& wevtutil epl 'Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin' $evtx /ow:true 2>$null
if (Test-Path $evtx) { Write-Host ' Provisioning-Diagnostics-Admin.evtx' }
} catch { }
# --- zip -----------------------------------------------------------------
New-Item -ItemType Directory -Path $Destination -Force -EA SilentlyContinue | Out-Null
$zip = Join-Path $Destination "imgdiag-$name.zip"
try {
Compress-Archive -Path (Join-Path $work '*') -DestinationPath $zip -Force -EA Stop
Remove-Item $work -Recurse -Force -EA SilentlyContinue
Write-Host ""
Write-Host "Wrote $zip"
Write-Host ("Size: {0:N1} MB" -f ((Get-Item $zip).Length / 1MB))
} catch {
Write-Host ""
Write-Host "Could not zip to $Destination : $_"
Write-Host "Raw collection left at: $work"
}
Write-Host ""

View File

@@ -6,7 +6,7 @@
# (Y:) early, then idles for many minutes while the full Windows image applies. # (Y:) early, then idles for many minutes while the full Windows image applies.
# Samba's `deadtime` drops idle sessions, so by the time WinPE reached the # Samba's `deadtime` drops idle sessions, so by the time WinPE reached the
# copies the Y: mount was dead and most copies failed (symptom: a bay with # copies the Y: mount was dead and most copies failed (symptom: a bay with
# almost nothing staged). Doing the bulk copy here - at # only site-config.json staged, then nothing). Doing the bulk copy here - at
# first logon, in full Windows, on a FRESH share mount with no prior idle - # first logon, in full Windows, on a FRESH share mount with no prior idle -
# sidesteps that entirely. # sidesteps that entirely.
# #
@@ -18,9 +18,12 @@
# #
# WHAT IT FETCHES (generic bulk - Phase 1) # WHAT IT FETCHES (generic bulk - Phase 1)
# \\<server>\enrollment\shopfloor-setup\Run-ShopfloorSetup.ps1 -> C:\Enrollment\ # \\<server>\enrollment\shopfloor-setup\Run-ShopfloorSetup.ps1 -> C:\Enrollment\
# \\<server>\enrollment\shopfloor-setup\Shopfloor -> C:\Enrollment\shopfloor-setup\ # \\<server>\enrollment\shopfloor-setup\{backup_lockdown.bat,Shopfloor,common,
# \\<server>\enrollment\pre-install\{preinstall.json,postppkg.json,installers} # _ntlars-backups,gea-shopfloor-<pctype>} -> C:\Enrollment\shopfloor-setup\
# \\<server>\enrollment\pre-install\{preinstall.json,installers,udc-backups}
# -> C:\PreInstall\ # -> C:\PreInstall\
# (Heavy per-type payloads - CMM/Keyence/WaxTrace - are still staged in WinPE
# for now; Phase 2 moves those here too.)
# #
# LOGGING # LOGGING
# Verbose transcript + a per-item table to C:\Logs\Fetch\. Every robocopy logs # Verbose transcript + a per-item table to C:\Logs\Fetch\. Every robocopy logs
@@ -144,13 +147,21 @@ $PIN = 'C:\PreInstall'
Fetch-Item -Label 'Run-ShopfloorSetup.ps1' -SrcDir 'shopfloor-setup' -DstDir $ENR -Files @('Run-ShopfloorSetup.ps1') Fetch-Item -Label 'Run-ShopfloorSetup.ps1' -SrcDir 'shopfloor-setup' -DstDir $ENR -Files @('Run-ShopfloorSetup.ps1')
# Verify-And-Heal-Staging runs as its own unattend step (right after this Fetch, # Verify-And-Heal-Staging runs as its own unattend step (right after this Fetch,
# before the production-network switch) to re-pull anything that did not arrive. # before the production-network switch) to re-pull anything that did not arrive -
# Pull the small script itself here so it is on disk for that step. # including the heavy CMM payload Fetch does not carry. Pull the small script
# itself here so it is on disk for that step.
Fetch-Item -Label 'Verify-And-Heal-Staging.ps1' -SrcDir 'shopfloor-setup' -DstDir $ENR -Files @('Verify-And-Heal-Staging.ps1') Fetch-Item -Label 'Verify-And-Heal-Staging.ps1' -SrcDir 'shopfloor-setup' -DstDir $ENR -Files @('Verify-And-Heal-Staging.ps1')
Fetch-Item -Label 'backup_lockdown.bat' -SrcDir 'shopfloor-setup' -DstDir $SFD -Files @('backup_lockdown.bat')
Fetch-Item -Label 'Shopfloor baseline' -SrcDir 'shopfloor-setup\Shopfloor' -DstDir (Join-Path $SFD 'Shopfloor') -Recurse Fetch-Item -Label 'Shopfloor baseline' -SrcDir 'shopfloor-setup\Shopfloor' -DstDir (Join-Path $SFD 'Shopfloor') -Recurse
# site app lists + their installers (and the PowerShell 7 MSI) Fetch-Item -Label 'common' -SrcDir 'shopfloor-setup\common' -DstDir (Join-Path $SFD 'common') -Recurse
Fetch-Item -Label 'app lists' -SrcDir 'pre-install' -DstDir $PIN -Files @('preinstall.json', 'postppkg.json') Fetch-Item -Label '_ntlars-backups' -SrcDir 'shopfloor-setup\_ntlars-backups' -DstDir (Join-Path $SFD '_ntlars-backups') -Recurse
if ($pcType) {
Fetch-Item -Label "type:$pcType" -SrcDir "shopfloor-setup\$pcType" -DstDir (Join-Path $SFD $pcType) -Recurse
}
# preinstall bundle
Fetch-Item -Label 'preinstall.json' -SrcDir 'pre-install' -DstDir $PIN -Files @('preinstall.json')
Fetch-Item -Label 'preinstall installers' -SrcDir 'pre-install\installers' -DstDir (Join-Path $PIN 'installers') -Recurse Fetch-Item -Label 'preinstall installers' -SrcDir 'pre-install\installers' -DstDir (Join-Path $PIN 'installers') -Recurse
Fetch-Item -Label 'udc-backups' -SrcDir 'pre-install\udc-backups' -DstDir (Join-Path $PIN 'udc-backups') -Recurse
# --- Unmount --- # --- Unmount ---
cmd /c "net use $drive /delete /y >nul 2>&1" cmd /c "net use $drive /delete /y >nul 2>&1"

View File

@@ -1,18 +1,15 @@
# Run-ShopfloorSetup.ps1 - shopfloor setup (gea-shopfloor-standard). # Run-ShopfloorSetup.ps1 - Main dispatcher for shopfloor PC type setup.
# #
# Called by the unattend FirstLogonCommands as SupportUser (admin), after
# Order 10 has already applied the PPKG (run-enrollment.ps1).
# Flow: # Flow:
# 1. Site app list "during setup": C:\PreInstall\preinstall.json # 1. PreInstall apps (Oracle, VC++, OpenText, UDC) -- from local bundle
# (Shopfloor\Install-AppList.ps1; empty in the generic build) # 2. Type-specific scripts (eDNC, ACLs, CMM share apps, etc.)
# 2. Copy desktop tools (sync_intune, Force-Lockdown, SetShopfloorAutoLogon) # 3. Deferred baseline (desktop org, taskbar pins)
# 3. Register the "Shopfloor Intune Sync" logon task (Monitor-IntuneProgress: # 4. Copy desktop tools (sync_intune, Configure-PC, Set-MachineNumber)
# waits for the PPKG/Intune lockdown, then hands the PC to ShopFloor) # 5. Run enrollment (PPKG install + wait for completion)
# 4. Register "GE Shopfloor Post-PPKG" (SYSTEM, at startup): installs # 6. Register sync_intune as @logon scheduled task
# C:\PreInstall\postppkg.json once after the reboot (empty in the # 7. Reboot -- PPKG file operations complete, sync_intune fires on next logon
# generic build) and clears AutoLogonCount after the ShopFloor handover #
# 5. Network gate, run-enrollment (a no-op when Order 10 applied the PPKG), # Called by the unattend FirstLogonCommands as SupportUser (admin).
# then Monitor-IntuneProgress -PostPpkg: settle and reboot
# --- Transcript logging --- # --- Transcript logging ---
$logDir = 'C:\Logs\SFLD' $logDir = 'C:\Logs\SFLD'
@@ -150,27 +147,149 @@ if (-not $pcType) {
exit 0 exit 0
} }
Write-Host "Shopfloor PC Type: $pcType" $subtypeFile = Join-Path $enrollDir "pc-subtype.txt"
$pcSubType = ''
# --- 1. Site app list, during setup --- if (Test-Path $subtypeFile) {
Report-Stage -Stage 'Run-ShopfloorSetup: installing the site app list' -Index 3 $pcSubType = (Get-Content $subtypeFile -First 1).Trim()
$appList = Join-Path $setupDir 'Shopfloor\Install-AppList.ps1'
if (Test-Path -LiteralPath $appList) {
cmd /c "shutdown /a 2>nul" *>$null
try {
& $appList -ListPath 'C:\PreInstall\preinstall.json' -Phase 'PreInstall'
} catch {
Write-Warning "Install-AppList failed: $_"
}
} else {
Write-Warning "Install-AppList.ps1 not found at $appList"
} }
cmd /c "shutdown /a 2>nul" *>$null
Report-Stage -Stage 'Run-ShopfloorSetup: site app list done' -Index 4 Write-Host "Shopfloor PC Type: $pcType$(if ($pcSubType) { " / $pcSubType" })"
# Scripts to skip in the alphabetical baseline loop. Each is either run
# explicitly in the finalization phase below, or invoked internally by
# another script:
#
# 05 - Office shortcuts. Invoked by 06 as Phase 0. Office isn't installed
# until after the PPKG + reboot, so 05 no-ops initially. 06's SYSTEM
# logon task re-runs it on the next boot.
# 06 - Desktop org. Phase 2 needs eDNC/NTLARS on disk (installed by
# type-specific 01-eDNC.ps1). Run in finalization phase.
# 07 - Taskbar pin layout. Reads 06's output. Run in finalization phase.
# 08 - Edge default browser + startup tabs. Invoked by 06 as Phase 4.
# Reads .url files delivered by DSC (after Intune enrollment). 06's
# SYSTEM logon task re-runs it to pick them up.
$skipInBaseline = @(
'05-OfficeShortcuts.ps1',
'06-OrganizeDesktop.ps1',
'07-TaskbarLayout.ps1',
'08-EdgeDefaultBrowser.ps1',
# Machine number flow: split into two scripts registered as scheduled
# tasks by Register-CheckMachineNumberTask.ps1. Prompt runs as the
# logged-in user (GUI), Apply runs as SYSTEM (privileged writes).
# Neither should run in baseline pass.
'Prompt-MachineNumber.ps1',
'Apply-MachineNumber.ps1',
'Check-MachineNumber.ps1',
'Configure-PC.ps1'
)
# Scripts run AFTER type-specific scripts complete. 05 and 08 are NOT
# here because 06 calls them internally as sub-phases.
$runAfterTypeSpecific = @(
'06-OrganizeDesktop.ps1',
'07-TaskbarLayout.ps1'
)
# --- Run Shopfloor baseline scripts first (skipping deferred ones) ---
$baselineDir = Join-Path $setupDir "Shopfloor"
if (Test-Path $baselineDir) {
$scripts = Get-ChildItem -Path $baselineDir -Filter "*.ps1" -File | Sort-Object Name
foreach ($script in $scripts) {
if ($skipInBaseline -contains $script.Name) {
Write-Host "Skipping baseline: $($script.Name) (runs in finalization phase)"
continue
}
cmd /c "shutdown /a 2>nul" *>$null
Write-Host "Running baseline: $($script.Name)"
try {
& $script.FullName
} catch {
Write-Warning "Baseline script $($script.Name) failed: $_"
}
}
}
# --- PCType dir alias resolution (2026-05-04 rename reorg) -------------
# Fleet PCs may have pc-type.txt = legacy "Standard"/"CMM"/etc OR new
# gea-shopfloor-* names. Repo dirs have been renamed to new names; this
# helper resolves either form to the actual on-disk dir under $setupDir.
$pcTypeDirAliases = @(
@('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'),
@('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'),
@('Standard-Timeclock', 'gea-shopfloor-common'),
@('CMM', 'gea-shopfloor-cmm'),
@('Keyence', 'gea-shopfloor-keyence'),
@('Lab', 'gea-shopfloor-common'),
@('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'),
@('Heattreat', 'gea-shopfloor-heattreat')
)
function Resolve-PCTypeDir {
param([string]$BaseDir, [string]$Name)
$primary = Join-Path $BaseDir $Name
if (Test-Path $primary) { return $primary }
foreach ($g in $pcTypeDirAliases) {
if ($g -icontains $Name) {
foreach ($alias in $g) {
if ($alias -ieq $Name) { continue }
$candidate = Join-Path $BaseDir $alias
if (Test-Path $candidate) { return $candidate }
}
}
}
return $null
}
# --- Run type-specific scripts (if not just baseline Shopfloor) ---
if ($pcType -ne "Shopfloor") {
$typeDir = Resolve-PCTypeDir -BaseDir $setupDir -Name $pcType
if ($typeDir -and (Test-Path $typeDir)) {
# Only run numbered scripts (01-eDNC.ps1, 02-MachineNumberACLs.ps1).
# Unnumbered .ps1 files (Set-MachineNumber.ps1) are desktop tools,
# not installer scripts, and must not auto-run during setup.
$scripts = Get-ChildItem -Path $typeDir -Filter "*.ps1" -File |
Where-Object { $_.Name -match '^\d' } |
Sort-Object Name
foreach ($script in $scripts) {
cmd /c "shutdown /a 2>nul" *>$null
Write-Host "Running $pcType setup: $($script.Name)"
try {
& $script.FullName
} catch {
Write-Warning "Script $($script.Name) failed: $_"
}
}
} else {
Write-Host "No type-specific scripts found for $pcType."
}
}
# --- Finalization: run deferred baseline scripts (desktop org, taskbar pins)
# ---
# These needed to wait until all apps (eDNC, NTLARS, UDC, OpenText) were
# installed by the baseline + type-specific phases above. 06 internally
# calls 05 (Office shortcuts) and 08 (Edge config) as sub-phases, so we
# only need to invoke 06 and 07 explicitly here.
foreach ($name in $runAfterTypeSpecific) {
$script = Join-Path $baselineDir $name
if (-not (Test-Path $script)) {
Write-Warning "Deferred script not found: $script"
continue
}
cmd /c "shutdown /a 2>nul" *>$null
Write-Host "Running deferred baseline: $name"
try {
& $script
} catch {
Write-Warning "Deferred script $name failed: $_"
}
}
Write-Host "Shopfloor setup complete for $pcType." Write-Host "Shopfloor setup complete for $pcType."
# --- 2. Copy utility scripts to SupportUser desktop --- # --- Copy utility scripts to SupportUser desktop ---
foreach ($tool in @('sync_intune.bat', 'Force-Lockdown.bat', 'SetShopfloorAutoLogon.bat')) { foreach ($tool in @('sync_intune.bat', 'Configure-PC.bat', 'Force-Lockdown.bat', 'SetShopfloorAutoLogon.bat')) {
$src = Join-Path $setupDir "Shopfloor\$tool" $src = Join-Path $setupDir "Shopfloor\$tool"
if (Test-Path $src) { if (Test-Path $src) {
Copy-Item -Path $src -Destination "C:\Users\SupportUser\Desktop\$tool" -Force Copy-Item -Path $src -Destination "C:\Users\SupportUser\Desktop\$tool" -Force
@@ -178,7 +297,28 @@ foreach ($tool in @('sync_intune.bat', 'Force-Lockdown.bat', 'SetShopfloorAutoLo
} }
} }
# --- 3. Register sync_intune as persistent @logon scheduled task --- # Machine-number-using PC types (collections + nocollections, plus their
# legacy Standard-Machine alias) get the Set-MachineNumber helper on the
# SupportUser desktop. Timeclock / Lab / common variants don't use a
# machine number, so the helper has nothing to do there.
$needsMachineNumberHelper = $false
if ($pcType -ieq 'Standard' -and $pcSubType -ne 'Timeclock') { $needsMachineNumberHelper = $true }
if ($pcType -ieq 'gea-shopfloor-collections' -or $pcType -ieq 'gea-shopfloor-nocollections') { $needsMachineNumberHelper = $true }
if ($needsMachineNumberHelper) {
$helperSrc = Resolve-PCTypeDir -BaseDir $setupDir -Name 'gea-shopfloor-collections'
if (-not $helperSrc) { $helperSrc = Resolve-PCTypeDir -BaseDir $setupDir -Name 'Standard' }
foreach ($helper in @('Set-MachineNumber.bat', 'Set-MachineNumber.ps1')) {
if ($helperSrc) {
$src = Join-Path $helperSrc $helper
if (Test-Path $src) {
Copy-Item -Path $src -Destination "C:\Users\SupportUser\Desktop\$helper" -Force
Write-Host "$helper copied to SupportUser desktop."
}
}
}
}
# --- Register sync_intune as persistent @logon scheduled task ---
# Must be registered BEFORE enrollment because Install-ProvisioningPackage # Must be registered BEFORE enrollment because Install-ProvisioningPackage
# triggers an immediate reboot that kills run-enrollment.ps1. The task # triggers an immediate reboot that kills run-enrollment.ps1. The task
# registration must survive the PPKG reboot, so we do it here while # registration must survive the PPKG reboot, so we do it here while
@@ -190,12 +330,13 @@ foreach ($tool in @('sync_intune.bat', 'Force-Lockdown.bat', 'SetShopfloorAutoLo
# PPKG reboot, and is irrelevant to the Intune lifecycle. # PPKG reboot, and is irrelevant to the Intune lifecycle.
$taskName = 'Shopfloor Intune Sync' $taskName = 'Shopfloor Intune Sync'
$monitorScript = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1' $monitorScript = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1'
$configureScript = Join-Path $setupDir 'Shopfloor\Configure-PC.ps1'
if (Test-Path -LiteralPath $monitorScript) { if (Test-Path -LiteralPath $monitorScript) {
try { try {
$action = New-ScheduledTaskAction ` $action = New-ScheduledTaskAction `
-Execute 'powershell.exe' ` -Execute 'powershell.exe' `
-Argument "-NoProfile -NoExit -ExecutionPolicy Bypass -File `"$monitorScript`" -AsTask" -Argument "-NoProfile -NoExit -ExecutionPolicy Bypass -File `"$monitorScript`" -AsTask -ConfigureScript `"$configureScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn $trigger = New-ScheduledTaskTrigger -AtLogOn
@@ -226,13 +367,98 @@ if (Test-Path -LiteralPath $monitorScript) {
Write-Warning "Monitor-IntuneProgress.ps1 not found at $monitorScript" Write-Warning "Monitor-IntuneProgress.ps1 not found at $monitorScript"
} }
# --- 4. Post-PPKG app list + AutoLogonCount cleanup (SYSTEM task) --- # Set auto-logon to expire after 4 more logins (2 needed for sync_intune
# Registered before the PPKG step: the PPKG reboot can kill this script. # pre-reboot + post-reboot, plus 2 margin for unexpected reboots from
$registerPost = Join-Path $setupDir 'Shopfloor\Register-PostPpkgTask.ps1' # Windows Update, PPKG file operations, or script crashes).
if (Test-Path -LiteralPath $registerPost) { # (AutoLogonCount intentionally not set -- see comment at top of script)
try { & $registerPost } catch { Write-Warning "Post-PPKG task registration failed: $_" }
# --- Register cross-PC-type enforcers (Acrobat, etc.) ---
# These run on every logon regardless of PC type, mounting the SFLD share
# for version-pinned app enforcement. Initial install already handled by
# preinstall flow; enforcers only kick in when detection fails.
# Wired-disable / re-enable dance retired after PXE LAN renumber to
# 172.16.9.0/24. GE Report IP filters Get-NetIPAddress on StartsWith("10.")
# so PXE LAN addresses are no longer caught - wired NIC can stay up
# through the whole imaging chain without leaking to the GE webhook.
$commonSetupDir = Join-Path $setupDir 'common'
# --- Register the unified GE-Enforce scheduled task ---
# Single dispatcher for all PC-type ongoing-update enforcement. Reads
# per-pctype manifest.json from the tsgwp00525 share and processes
# common + per-type + per-type-subtype manifests in order.
#
# Display PCs are excluded: their kiosk user cannot reach the SFLD
# share, and everything Display needs (kiosk EXE + Edge policies) is
# baked at imaging time (preinstall.json Install-KioskApp + 09-Setup-
# Display.ps1). No ongoing share-dependent enforcement on Displays.
$noEnforceTypes = @('Display', 'gea-shopfloor-display')
$registerGE = Join-Path $commonSetupDir 'Register-GEEnforce.ps1'
if ($noEnforceTypes -contains $pcType) {
Write-Host ""
Write-Host "=== Skipping GE-Enforce registration ($pcType is self-contained) ==="
} elseif (Test-Path -LiteralPath $registerGE) {
Write-Host ""
Write-Host "=== Registering unified GE Shopfloor enforcer ==="
try {
$enforcerRuntime = Join-Path $commonSetupDir 'GE-Enforce.ps1'
$libSource = Join-Path $commonSetupDir 'lib\Install-FromManifest.ps1'
# Stage enforcer runtime so the scheduled task can reach it post-imaging.
$runtimeDir = 'C:\Program Files\GE\Shopfloor'
$runtimeLib = Join-Path $runtimeDir 'lib'
foreach ($d in @($runtimeDir, $runtimeLib)) {
if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null }
}
Copy-Item -LiteralPath $enforcerRuntime -Destination (Join-Path $runtimeDir 'GE-Enforce.ps1') -Force
Copy-Item -LiteralPath $libSource -Destination (Join-Path $runtimeLib 'Install-FromManifest.ps1') -Force
& $registerGE -EnforcerPath (Join-Path $runtimeDir 'GE-Enforce.ps1')
} catch {
Write-Warning "GE-Enforce registration failed: $_"
}
} else { } else {
Write-Warning "Register-PostPpkgTask.ps1 not found at $registerPost" Write-Warning "Register-GEEnforce.ps1 not found - no ongoing enforcement will run on this PC"
}
# Register the 'GE Map SFLD Share' task: keeps S: alive for every account
# in BUILTIN\Users (logon + every 3 min + unlock/console connect + network
# up), with a real read probe and a confirm-then-repair path. The vendor
# 'SFLD - Consume Credentials' task DOES fire for ShopFloor (principal is
# the Users group) but maps S: once per logon only; nothing re-maps it
# mid-shift, which is why S: stayed dead until re-logon. Our task is
# idempotent next to it (healthy S: is never touched). GE-Enforce also
# (re)registers it fleet-wide via the common manifest entry.
# Display PCs skipped: kiosk user has no SFLD creds, S: map would fail
# every logon. Self-contained Display has no share dependency.
$registerMapShare = Join-Path $setupDir 'Shopfloor\Register-MapSfldShare.ps1'
if ($noEnforceTypes -contains $pcType) {
Write-Host ""
Write-Host "=== Skipping S: drive logon mapper ($pcType is self-contained) ==="
} elseif (Test-Path -LiteralPath $registerMapShare) {
Write-Host ""
Write-Host "=== Registering S: drive logon mapper ==="
try { & $registerMapShare } catch { Write-Warning "Map-SfldShare registration failed: $_" }
} else {
Write-Host "Register-MapSfldShare.ps1 not found (optional) - skipping"
}
# --- Check Machine Number logon prompt ---
# Auto-register the "Check Machine Number" scheduled task. Bays imaged with
# the 9999 placeholder will prompt the first ShopFloor end-user logon to
# enter the real machine number; on success Update-MachineNumber.ps1 pulls
# the per-machine NTLARS .reg + UDC settings JSON + UDC data backup from
# SFLD and the task self-unregisters. Self-disables once the number is
# real, so safe to always register here.
# Skipped for self-contained types (Display) that have no machine number.
$registerCheckMN = Join-Path $setupDir 'Shopfloor\Register-CheckMachineNumberTask.ps1'
if ($noEnforceTypes -contains $pcType) {
Write-Host ""
Write-Host "=== Skipping Check Machine Number task ($pcType has no machine number) ==="
} elseif (Test-Path -LiteralPath $registerCheckMN) {
Write-Host ""
Write-Host "=== Registering Check Machine Number logon task ==="
try { & $registerCheckMN } catch { Write-Warning "Check-MachineNumber registration failed: $_" }
} else {
Write-Host "Register-CheckMachineNumberTask.ps1 not found (optional) - skipping"
} }
# --- Run enrollment (PPKG install) --- # --- Run enrollment (PPKG install) ---

View File

@@ -0,0 +1,598 @@
# 00-PreInstall-MachineApps.ps1 - Install pre-staged apps from C:\PreInstall (Shopfloor baseline)
#
# Reads C:\PreInstall\preinstall.json (staged by startnet.cmd during WinPE phase) and
# installs each app whose PCTypes filter matches the current PC type. Detection runs first
# so already-installed apps are skipped. Numbered 00- so it runs before all other Shopfloor
# baseline scripts - apps need to exist before later scripts (e.g. 01-eDNC.ps1) reference
# them or rely on their state.
#
# Failure mode: log + continue. Intune DSC (Simple-Install.ps1) is the safety net for any
# install that fails here.
#
# Logs to C:\Logs\PreInstall\install.log (wiped each run).
$preInstallDir = "C:\PreInstall"
$jsonPath = Join-Path $preInstallDir "preinstall.json"
$installerDir = Join-Path $preInstallDir "installers"
$logDir = "C:\Logs\PreInstall"
$logFile = Join-Path $logDir "install.log"
# --- Inline site-config reader (same pattern as Setup-OpenText.ps1; this script
# runs from C:\Enrollment\shopfloor-setup\Shopfloor\ but Get-PCProfile.ps1 may
# not be available yet, so keep the lookup self-contained) ---
function Get-SiteConfig {
$configPath = 'C:\Enrollment\site-config.json'
if (-not (Test-Path $configPath)) { return $null }
try {
return Get-Content $configPath -Raw | ConvertFrom-Json
} catch {
return $null
}
}
# --- Setup logging ---
# IMPORTANT: do NOT wipe the log on each run. The runner can get killed by an
# installer-triggered reboot mid-execution. On the next autologon, the dispatcher
# re-runs us - if we wipe the log here, we destroy the forensic record from the
# previous (interrupted) run. Instead, append a session header so runs are visible
# but history is preserved.
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
function Write-PreInstallLog {
param(
[Parameter(Mandatory=$true, Position=0)]
[string]$Message,
[Parameter(Position=1)]
[ValidateSet('INFO','WARN','ERROR')]
[string]$Level = 'INFO'
)
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$line = "[$stamp] [$Level] $Message"
Write-Host $line
# Synchronous write-through so each line hits disk immediately, even if a system
# reboot kills the process right after this call returns. Bypasses the OS write
# cache via FileOptions.WriteThrough.
try {
$fs = New-Object System.IO.FileStream(
$logFile,
[System.IO.FileMode]::Append,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::Read,
4096,
[System.IO.FileOptions]::WriteThrough
)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($line + "`r`n")
$fs.Write($bytes, 0, $bytes.Length)
$fs.Flush()
$fs.Dispose()
}
catch {
# Last-resort fallback if the FileStream open fails for any reason
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
}
}
# Session header so multiple runs are visually distinguishable in the log
Write-PreInstallLog "================================================================"
Write-PreInstallLog "=== Runner session start (PID $PID) ==="
Write-PreInstallLog "================================================================"
# --- Bail early if no preinstall bundle staged ---
if (-not (Test-Path $jsonPath)) {
Write-PreInstallLog "No preinstall.json at $jsonPath - skipping (no bundle staged for this PC)."
exit 0
}
# --- Read PCTYPE and optional PCSUBTYPE from C:\Enrollment\ ---
$pcTypeFile = "C:\Enrollment\pc-type.txt"
if (-not (Test-Path $pcTypeFile)) {
Write-PreInstallLog "No pc-type.txt at $pcTypeFile - skipping" "WARN"
exit 0
}
$pcType = (Get-Content $pcTypeFile -First 1).Trim()
if (-not $pcType) {
Write-PreInstallLog "pc-type.txt is empty - skipping" "WARN"
exit 0
}
$pcSubtype = ''
$subtypeFile = "C:\Enrollment\pc-subtype.txt"
if (Test-Path $subtypeFile) {
$pcSubtype = (Get-Content $subtypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
$pcProfileKey = if ($pcSubtype) { "$pcType-$pcSubtype" } else { $pcType }
Write-PreInstallLog "PC type: $pcType$(if ($pcSubtype) { " (subtype: $pcSubtype, profile: $pcProfileKey)" })"
# --- Parse JSON ---
try {
$config = Get-Content $jsonPath -Raw | ConvertFrom-Json
} catch {
Write-PreInstallLog "Failed to parse $jsonPath : $_" "ERROR"
exit 0
}
if (-not $config.Applications) {
Write-PreInstallLog "No Applications in preinstall.json"
exit 0
}
Write-PreInstallLog "Staged installer dir: $installerDir"
Write-PreInstallLog "Found $($config.Applications.Count) app entries in preinstall.json"
# --- Site-name override: replace 'West Jefferson' in InstallArgs if
# site-config says otherwise. UDC_Setup.exe expects the spaced site
# name in quotes as arg 1, with a dash-prefixed machine number as arg 2.
$siteConfig = Get-SiteConfig
if ($siteConfig -and $siteConfig.siteName -and $siteConfig.siteName -ne 'West Jefferson') {
Write-PreInstallLog "Site config loaded - siteName: $($siteConfig.siteName)"
foreach ($app in $config.Applications) {
if ($app.InstallArgs -and $app.InstallArgs -match 'West Jefferson') {
$app.InstallArgs = $app.InstallArgs -replace 'West Jefferson', $siteConfig.siteName
Write-PreInstallLog " Overrode site name in $($app.Name) args: $($app.InstallArgs)"
}
}
} else {
Write-PreInstallLog "No site-config override for siteName (using defaults in preinstall.json)"
}
# --- Machine-number override: replace "9999" in UDC InstallArgs if tech entered a number ---
$machineNumFile = 'C:\Enrollment\machine-number.txt'
if (Test-Path -LiteralPath $machineNumFile) {
$machineNum = (Get-Content -LiteralPath $machineNumFile -First 1 -ErrorAction SilentlyContinue).Trim()
if ($machineNum -and $machineNum -ne '9999' -and $machineNum -match '^\d+$') {
Write-PreInstallLog "Machine number from PXE menu: $machineNum"
foreach ($app in $config.Applications) {
if ($app.InstallArgs -and $app.InstallArgs -match '9999') {
$app.InstallArgs = $app.InstallArgs -replace '9999', $machineNum
Write-PreInstallLog " Overrode machine number in $($app.Name) args: $($app.InstallArgs)"
}
}
} else {
Write-PreInstallLog "Machine number: $machineNum (default placeholder)"
}
} else {
Write-PreInstallLog "No machine-number.txt found (using 9999 default)"
}
# --- Pre-stage UDC settings backup if available. UDC_Setup.exe tries to
# pull udc_settings_<num>.json from \\tsgwp00525\shared\SPC\UDC\Settings_Backups\,
# but at imaging time that share is unreachable (no SFLD creds yet).
# We carry a local mirror of Settings_Backups on the PXE server at
# Y:\pre-install\udc-backups\. Pre-creating C:\ProgramData\UDC\udc_settings.json
# before UDC_Setup.exe runs means the installer's File.Copy (overwrite:true)
# would overwrite it IF the share were reachable, but since it isn't, our
# pre-staged file survives and UDC launches with correct settings.
# UDC payload (settings backups + webserver settings) lives only in the
# collections per-pc-type dir - UDC is the "C" of "collections". On nocoll
# bays the dir doesn't exist; Test-Path skips silently.
$udcCollDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'gea-shopfloor-collections'
if ($machineNum -and $machineNum -ne '9999') {
$udcBackupDir = Join-Path $udcCollDir 'udc-backups'
$udcBackup = Join-Path $udcBackupDir "udc_settings_$machineNum.json"
$udcTarget = 'C:\ProgramData\UDC\udc_settings.json'
if (Test-Path -LiteralPath $udcBackup) {
if (-not (Test-Path 'C:\ProgramData\UDC')) {
New-Item -Path 'C:\ProgramData\UDC' -ItemType Directory -Force | Out-Null
}
Copy-Item -Path $udcBackup -Destination $udcTarget -Force
Write-PreInstallLog "Pre-staged UDC settings from $udcBackup -> $udcTarget"
} else {
Write-PreInstallLog "No UDC settings backup for machine $machineNum at $udcBackup (skipping - normal for nocoll bays)"
}
}
$udcWebSrc = Join-Path $udcCollDir 'udc_webserver_settings.json'
$udcWebDst = 'C:\ProgramData\UDC\udc_webserver_settings.json'
if (Test-Path -LiteralPath $udcWebSrc) {
if (-not (Test-Path 'C:\ProgramData\UDC')) {
New-Item -Path 'C:\ProgramData\UDC' -ItemType Directory -Force | Out-Null
}
Copy-Item -Path $udcWebSrc -Destination $udcWebDst -Force
Write-PreInstallLog "Pre-staged UDC webserver settings from $udcWebSrc -> $udcWebDst"
} else {
Write-PreInstallLog "No UDC webserver settings file at $udcWebSrc (skipping - normal for nocoll bays)"
}
# --- Suppress Windows Defender Firewall "Allow access" prompts globally for
# this preinstall pass. Oracle's OUI extracts a JRE to a per-run
# %TEMP%\OraInstall<timestamp>\jdk\jre\bin\java.exe and binds a localhost
# port, which trips the unsigned-app firewall dialog. The temp path is
# timestamp-randomized so a path-based -Program rule can't match it.
# Turning off NotifyOnListen stops the prompt without weakening the
# firewall's actual block decisions.
try {
Set-NetFirewallProfile -Profile Domain,Public,Private -NotifyOnListen False -ErrorAction Stop
Write-PreInstallLog "Firewall NotifyOnListen disabled (Domain/Public/Private)"
} catch {
Write-PreInstallLog "Failed to disable firewall NotifyOnListen: $_" "WARN"
}
# --- Pre-enable .NET Framework 3.5. Oracle 11.2 OUI uses a .NET 2.0/3.5
# component on its welcome path; on Win10/11 NetFx3 is OFF by default
# and the first 3.5 API call pops the "Download and install this feature"
# dialog. Enable-WindowsOptionalFeature pulls payload from Windows Update
# when available; on hosts without internet at preinstall time this will
# fail and Oracle will still prompt (TODO: stage NetFx3 sxs cab on image
# and pass -Source for true air-gap support). Idempotent no-op when
# already enabled. Mirrors the per-PC enable in CMM/09-Setup-CMM.ps1.
try {
$netfx = Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -ErrorAction Stop
if ($netfx.State -eq 'Enabled') {
Write-PreInstallLog ".NET Framework 3.5 already enabled"
} else {
Write-PreInstallLog ".NET Framework 3.5 state is $($netfx.State) - enabling now (may take a minute)..."
$result = Enable-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -All -NoRestart -ErrorAction Stop
Write-PreInstallLog " Enable-WindowsOptionalFeature RestartNeeded=$($result.RestartNeeded)"
}
} catch {
Write-PreInstallLog "Failed to enable .NET 3.5: $_" "WARN"
Write-PreInstallLog " Continuing - Oracle and other 3.5-dependent installers may surface a Windows feature-install prompt"
}
# --- Pre-create Windows Firewall rules for UDC + MTConnect Agent so the
# installer doesn't pop firewall-allow dialogs during silent install.
# Rules are idempotent (New-NetFirewallRule -ErrorAction SilentlyContinue
# on existing rule name is a no-op in practice; we remove-then-add).
$fwRules = @(
@{ Name = 'UDC'; Program = 'C:\Program Files\UDC\UDC.exe' },
@{ Name = 'UDC MTConnect Agent'; Program = 'C:\ProgramData\UDC\MTConnect_UDC\Agent\agent.exe' }
)
foreach ($r in $fwRules) {
try {
Remove-NetFirewallRule -DisplayName $r.Name -ErrorAction SilentlyContinue
New-NetFirewallRule -DisplayName $r.Name -Direction Inbound -Program $r.Program `
-Action Allow -Profile Any -ErrorAction Stop | Out-Null
Write-PreInstallLog "Firewall rule created: $($r.Name) -> $($r.Program)"
} catch {
Write-PreInstallLog "Firewall rule '$($r.Name)' failed: $_" "WARN"
}
}
# --- Detection helper (mirrors Simple-Install.ps1's Test-ApplicationInstalled) ---
function Test-AppInstalled {
param($App)
if (-not $App.DetectionMethod) {
return $false
}
try {
switch ($App.DetectionMethod) {
"Registry" {
if (-not (Test-Path $App.DetectionPath)) {
return $false
}
if ($App.DetectionName) {
$value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
if (-not $value) {
return $false
}
if ($App.DetectionValue) {
return ($value.$($App.DetectionName) -eq $App.DetectionValue)
}
return $true
}
return $true
}
"File" {
return Test-Path $App.DetectionPath
}
default {
Write-PreInstallLog " Unknown detection method: $($App.DetectionMethod)" "WARN"
return $false
}
}
} catch {
Write-PreInstallLog " Detection check threw: $_" "WARN"
return $false
}
}
# --- Iterate apps ---
$installed = 0
$skipped = 0
$failed = 0
foreach ($app in $config.Applications) {
# Cancel any reboot a previous installer scheduled (some respect /norestart, some
# don't - VC++ 2008's bootstrapper sometimes triggers an immediate Windows reboot
# despite the flag). Doing this BEFORE each install protects the rest of the loop.
cmd /c "shutdown /a 2>nul" *>$null
Write-PreInstallLog "==> $($app.Name)"
# Filter by PCTypes - matches on wildcard, base type, composite type-subtype
# key, OR any alias-equivalent name. The 2026-05-04 rename reorg gave us
# gea-shopfloor-* PC type strings that need to match legacy ["Standard"],
# ["CMM"], etc filters in preinstall.json (and vice versa: a future
# preinstall.json that uses gea-shopfloor-* should still match a fleet PC
# whose pc-type.txt is still "Standard").
$allowedTypes = @($app.PCTypes)
$aliasGroups = @(
@('Standard', 'Standard-Machine','Standard-Timeclock','gea-shopfloor-collections','gea-shopfloor-nocollections','gea-shopfloor-common'),
@('Standard-Machine', 'gea-shopfloor-collections','gea-shopfloor-nocollections'),
@('Standard-Timeclock', 'gea-shopfloor-common'),
@('CMM', 'gea-shopfloor-cmm'),
@('Keyence', 'gea-shopfloor-keyence'),
@('Lab', 'gea-shopfloor-common'),
@('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'),
@('Heattreat', 'gea-shopfloor-heattreat'),
@('PartMarker', 'gea-shopfloor-partmarker')
)
$myNames = New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase)
foreach ($n in @($pcType, $pcProfileKey) | Where-Object { $_ }) {
[void]$myNames.Add($n)
foreach ($g in $aliasGroups) {
if ($g -icontains $n) { foreach ($x in $g) { [void]$myNames.Add($x) } }
}
}
# PCTypesStrict=true bypasses the alias-expansion matcher and requires
# the actual pcType (or composite pcProfileKey) to literally equal one
# of the allowedTypes entries. Used by UDC because the alias graph
# transitively connects gea-shopfloor-collections <-> nocollections via
# the legacy 'Standard' group, which would otherwise cause UDC to install
# on nocoll bays even with PCTypes=['gea-shopfloor-collections'].
$matchesType = ($allowedTypes -contains '*')
if (-not $matchesType) {
if ($app.PCTypesStrict) {
foreach ($t in $allowedTypes) {
if (($pcType -ieq $t) -or ($pcProfileKey -ieq $t)) { $matchesType = $true; break }
}
} else {
foreach ($t in $allowedTypes) {
if ($myNames.Contains($t)) { $matchesType = $true; break }
foreach ($g in $aliasGroups) {
if ($g -icontains $t) {
foreach ($x in $g) { if ($myNames.Contains($x)) { $matchesType = $true; break } }
}
if ($matchesType) { break }
}
}
}
}
if (-not $matchesType) {
Write-PreInstallLog " PCTypes filter excludes '$pcProfileKey' (allowed: $($allowedTypes -join ', ')) - skipping"
$skipped++
continue
}
# Detection check
if (Test-AppInstalled -App $app) {
Write-PreInstallLog " Already installed - skipping"
$skipped++
continue
}
# Locate installer file
$installerPath = Join-Path $installerDir $app.Installer
if (-not (Test-Path $installerPath)) {
Write-PreInstallLog " Installer file not found: $installerPath" "ERROR"
$failed++
continue
}
Write-PreInstallLog " Installing from $installerPath"
if ($app.InstallArgs) {
Write-PreInstallLog " InstallArgs: $($app.InstallArgs)"
}
# Per-app verbose msiexec log path - written by /L*v on MSI installs and tailed
# into the runner log on failure so we don't need to dig through C:\Logs manually.
$safeName = $app.Name -replace '[^a-zA-Z0-9]','_'
$msiLog = Join-Path $logDir "msi-$safeName.log"
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
try {
# We use [System.Diagnostics.Process]::Start() directly instead of Start-Process
# because PowerShell 5.1's Start-Process -PassThru has a bug where the returned
# Process object's OS handle is disposed when control returns to the script,
# causing ExitCode to always read as $null even after WaitForExit() succeeds.
# Calling Process.Start() directly returns a Process object with a live handle
# that exposes ExitCode correctly. We poll HasExited so UDC_Setup.exe (which
# hangs forever after spawning a hidden WPF window) can be killed via the
# detection-during-install path.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
if ($app.Type -eq "MSI") {
$psi.FileName = "msiexec.exe"
$psi.Arguments = "/i `"$installerPath`""
if ($app.InstallArgs) {
$psi.Arguments += " " + $app.InstallArgs
}
$psi.Arguments += " /L*v `"$msiLog`""
Write-PreInstallLog " msiexec verbose log: $msiLog"
}
elseif ($app.Type -eq "EXE") {
$psi.FileName = $installerPath
if ($app.InstallArgs) {
$psi.Arguments = $app.InstallArgs
}
# If the JSON entry specifies a LogFile (per-installer log written by
# the EXE itself - e.g. Setup-OpenText.ps1 -> C:\Logs\PreInstall\Setup-
# OpenText.log), surface it on failure the same way we surface the
# msiexec /L*v verbose log for MSI installs. Lets EXE wrappers actually
# report what went wrong inside.
if ($app.LogFile) {
Write-PreInstallLog " Installer log: $($app.LogFile)"
}
}
else {
Write-PreInstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR"
$failed++
continue
}
$proc = [System.Diagnostics.Process]::Start($psi)
# Poll for completion: process exit OR detection success (whichever happens first)
$timeoutSec = 600 # 10 min hard cap per app
$pollInterval = 5
$elapsed = 0
$killedAfterDetect = $false
while ($elapsed -lt $timeoutSec) {
if ($proc.HasExited) { break }
# Detection-during-install kill is OPT-IN via "KillAfterDetection: true"
# in the JSON entry. It's only safe for installers that hang forever after
# creating their detection markers (UDC_Setup.exe spawns a hidden WPF window
# and never exits). For normal installers (Oracle, msiexec, etc.) the
# detection registry key often gets written midway through the install AND
# msiexec is still doing post-install cleanup AND msiserver is still holding
# the install mutex - killing msiexec at that point leaves the system in a
# bad state and the NEXT msiexec call returns 1618 ERROR_INSTALL_ALREADY_
# RUNNING. So opt-out by default; only kill apps that explicitly need it.
if ($app.KillAfterDetection -and (Test-AppInstalled -App $app)) {
Write-PreInstallLog " Detection passed at $elapsed s - killing installer to advance (KillAfterDetection)"
try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch { }
# UDC's installer auto-launches UDC.exe silently; it's UDC.exe
# (not UDC_Setup) that actually writes udc_settings.json from
# the CLI args. Killing it immediately after the detection
# marker appears loses the MachineNumber write. Wait (up to
# 15s) for settings.json to reflect the target number, then
# kill. Works uniformly for tech-typed numbers and the 9999
# placeholder - whichever was passed as arg 2 is what we
# expect to see persist.
if ($app.Name -eq "UDC") {
$udcJson = 'C:\ProgramData\UDC\udc_settings.json'
$targetNum = $null
if ($app.InstallArgs -match '-(\d+)\s*$') { $targetNum = $matches[1] }
$waitSec = 0
while ($waitSec -lt 15) {
if (Test-Path $udcJson) {
try {
$mn = (Get-Content $udcJson -Raw -ErrorAction Stop |
ConvertFrom-Json).GeneralSettings.MachineNumber
if ($mn -and $mn -eq $targetNum) {
Write-PreInstallLog " UDC persisted MachineNumber=$mn after $waitSec s"
break
}
} catch { }
}
Start-Sleep -Seconds 1
$waitSec++
}
if ($waitSec -ge 15) {
Write-PreInstallLog " UDC did not persist MachineNumber within 15 s - killing anyway" "WARN"
}
Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
try { $_.Kill(); $_.WaitForExit(2000) | Out-Null } catch { }
}
}
$killedAfterDetect = $true
break
}
Start-Sleep -Seconds $pollInterval
$elapsed += $pollInterval
}
if (-not $proc.HasExited -and -not $killedAfterDetect) {
Write-PreInstallLog " TIMEOUT after $timeoutSec seconds - killing installer" "ERROR"
try { $proc.Kill() } catch { }
if ($app.Name -eq "UDC") {
Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
try { $_.Kill() } catch { }
}
}
$failed++
continue
}
if ($killedAfterDetect) {
Write-PreInstallLog " SUCCESS (verified via detection during install)"
$installed++
}
else {
# Start-Process -PassThru returns a Process object whose ExitCode is null
# until WaitForExit() is called - even if HasExited is already true. Without
# this, every install that goes through the exit-code branch gets reported
# as "Exit code - FAILED" (empty) regardless of the real result.
try { $proc.WaitForExit() } catch { }
$exitCode = $proc.ExitCode
if ($exitCode -eq 0 -or $exitCode -eq 3010) {
Write-PreInstallLog " Exit code $exitCode after $elapsed s - SUCCESS"
if ($exitCode -eq 3010) {
Write-PreInstallLog " (Reboot required for $($app.Name))"
}
$installed++
}
else {
Write-PreInstallLog " Exit code $exitCode - FAILED" "ERROR"
# If the JSON entry specifies a LogFile for an EXE install (e.g.
# Setup-OpenText.ps1 writes its own log at C:\Logs\PreInstall\Setup-
# OpenText.log), tail it here so the runner log surfaces the actual
# cause without us having to dig through C:\Logs manually.
if ($app.Type -eq "EXE" -and $app.LogFile -and (Test-Path $app.LogFile)) {
Write-PreInstallLog " --- last 30 lines of $($app.LogFile) ---"
Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object {
Write-PreInstallLog " $_"
}
Write-PreInstallLog " --- end installer log tail ---"
}
# Surface the *meaningful* lines from the verbose msiexec log (not just
# the tail, which is rollback/cleanup noise). The actual root-cause lines
# are: MSI error notes (Note: 1: <code>), failed action returns (return
# value 3), errors logged by custom actions, and any line containing the
# word "Error". Grep these out so the runner log shows the smoking gun.
if ($app.Type -eq "MSI" -and (Test-Path $msiLog)) {
Write-PreInstallLog " --- meaningful lines from $msiLog ---"
$patterns = @(
'Note: 1: ',
'return value 3',
'Error \d+\.',
'CustomAction .* returned actual error',
'Failed to ',
'Installation failed',
'Calling custom action',
'1: 2262', # Database not found
'1: 2203', # Cannot open database
'1: 2330' # Insufficient permissions
)
$regex = ($patterns -join '|')
$matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue |
Select-Object -First 30
if ($matches) {
foreach ($m in $matches) {
Write-PreInstallLog " $($m.Line.Trim())"
}
} else {
Write-PreInstallLog " (no error patterns matched - falling back to last 25 lines)"
Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object {
Write-PreInstallLog " $_"
}
}
Write-PreInstallLog " --- end MSI log scan ---"
}
$failed++
}
}
} catch {
Write-PreInstallLog " Install threw: $_" "ERROR"
$failed++
}
}
Write-PreInstallLog "============================================"
Write-PreInstallLog "PreInstall complete: $installed installed, $skipped skipped, $failed failed"
Write-PreInstallLog "============================================"
# Final reboot cancel - if the last installer in the loop scheduled one, the
# dispatcher's later `shutdown /a` won't fire until the next baseline script starts.
# Cancel here so control returns cleanly to Run-ShopfloorSetup.ps1.
cmd /c "shutdown /a 2>nul" *>$null
exit 0

View File

@@ -0,0 +1,127 @@
# 03-ShellDefaults.ps1 - Shell appearance defaults for all new user profiles.
#
# Applies the following tweaks so shopfloor PCs look/behave closer to the
# Win10 baseline and don't reach out to Bing from the Start menu:
# 1. Taskbar aligned to the left (TaskbarAl=0). Per-user setting, written
# into the Default User hive so every NEW profile inherits it.
# 2. Start menu "Recommended" section hidden (HideRecommendedSection=1).
# 3. Web results + Bing search-box suggestions killed in Start/Search.
# 4. Cortana disabled via policy (no-op on LTSC 2024 since Cortana isn't
# shipped, but set anyway for belt-and-suspenders).
#
# 2-4 are HKLM policy values - W11 LTSC / Enterprise honour them; Home/Pro
# do not. Fine here since the fleet is LTSC 2024.
#
# Existing profiles (SupportUser, pre-existing end-user accounts) are NOT
# modified for TaskbarAl - the Default User hive is a template copied on
# profile creation, not a live cascade. The HKLM policies affect everyone.
$ErrorActionPreference = 'Continue'
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host "ERROR: 03-ShellDefaults.ps1 must run as Administrator." -ForegroundColor Red
exit 1
}
# ---- 1. HKLM policy DWORDs (all LTSC-honoured) -------------------------------
# Each entry: KeyPath, ValueName, ValueData. Key is created if missing.
$hklmPolicies = @(
# Hide Start menu "Recommended" section.
@{ Key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Explorer'
Name = 'HideRecommendedSection'; Value = 1 }
# Kill web results in Start/taskbar search.
@{ Key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Search'
Name = 'DisableWebSearch'; Value = 1 }
@{ Key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Search'
Name = 'ConnectedSearchUseWeb'; Value = 0 }
# Disable Cortana (policy; LTSC doesn't ship Cortana but this is harmless).
@{ Key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Search'
Name = 'AllowCortana'; Value = 0 }
# Kill Bing-powered search-box suggestions (per-keystroke telemetry).
@{ Key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Explorer'
Name = 'DisableSearchBoxSuggestions'; Value = 1 }
)
foreach ($p in $hklmPolicies) {
try {
if (-not (Test-Path $p.Key)) {
New-Item -Path $p.Key -Force | Out-Null
}
Set-ItemProperty -Path $p.Key -Name $p.Name -Value $p.Value -Type DWord -Force
Write-Host ("Set {0}\{1} = {2}" -f $p.Key, $p.Name, $p.Value)
} catch {
Write-Warning ("Failed to set {0}\{1}: {2}" -f $p.Key, $p.Name, $_)
}
}
# ---- 2. Set Adobe Acrobat Reader as default PDF viewer ----
# OEMDefaultAssociations.xml is read when a new user profile is created.
# Deploying it before ShopFloor's first logon means the profile lands with
# Acrobat as the PDF handler instead of Edge. Existing profiles (SupportUser)
# are not affected. DISM /import-defaultappassociations writes the XML to
# C:\Windows\System32\OEMDefaultAssociations.xml and sets the policy key.
$assocXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<DefaultAssociations>
<Association Identifier=".pdf" ProgId="Acrobat.Document.DC" ApplicationName="Adobe Acrobat" />
</DefaultAssociations>
"@
$assocPath = Join-Path $env:TEMP 'DefaultAssociations.xml'
try {
Set-Content -Path $assocPath -Value $assocXml -Encoding UTF8 -Force
$out = & dism.exe /online /import-defaultappassociations:"$assocPath" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Set .pdf default to Acrobat.Document.DC via DISM"
} else {
Write-Warning "DISM import-defaultappassociations returned $LASTEXITCODE"
}
Remove-Item $assocPath -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to set default PDF viewer: $_"
}
# ---- 3. Taskbar left-align via Default User hive ----
$defaultHive = 'C:\Users\Default\NTUSER.DAT'
$mountPoint = 'HKU\SFLDDefault'
$regPath = 'Registry::HKEY_USERS\SFLDDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced'
if (-not (Test-Path -LiteralPath $defaultHive)) {
Write-Warning "Default User hive not found at $defaultHive - skipping taskbar tweak."
exit 0
}
# Unload a stale mount from a previous run, if any. reg unload returns
# non-zero when nothing is mounted; ignore that.
& reg.exe unload $mountPoint *> $null
$loadOutput = & reg.exe load $mountPoint $defaultHive 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Warning "reg load failed ($LASTEXITCODE): $loadOutput"
exit 1
}
try {
if (-not (Test-Path $regPath)) {
New-Item -Path $regPath -Force | Out-Null
}
Set-ItemProperty -Path $regPath -Name 'TaskbarAl' -Value 0 -Type DWord -Force
Write-Host "Set Default User TaskbarAl=0 (left-aligned)"
} catch {
Write-Warning "Failed to set TaskbarAl: $_"
} finally {
# Force GC so PowerShell drops its handles on the hive before unload,
# otherwise reg unload fails with "The process cannot access the file".
[gc]::Collect()
[gc]::WaitForPendingFinalizers()
[gc]::Collect()
& reg.exe unload $mountPoint *> $null
if ($LASTEXITCODE -ne 0) {
Write-Warning "reg unload returned $LASTEXITCODE - Default User hive may still be mounted"
}
}
exit 0

View File

@@ -0,0 +1,15 @@
# 04-NetworkAndWinRM.ps1 -- Set network profiles to Private and enable WinRM (baseline)
# --- Transcript ---
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) { try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch {} }
try { Start-Transcript -Path (Join-Path $logDir '04-NetworkAndWinRM.log') -Append -Force | Out-Null } catch {}
# --- Set all network profiles to Private ---
Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private
Write-Host "All network profiles set to Private."
# --- Enable and configure WinRM ---
Enable-PSRemoting -Force -SkipNetworkProfileCheck
Write-Host "WinRM enabled."
try { Stop-Transcript | Out-Null } catch {}

View File

@@ -0,0 +1,101 @@
# 05-OfficeShortcuts.ps1 - Public desktop + Default User Start Menu shortcuts for
# the core Office apps (Excel, Word, PowerPoint).
#
# Only runs if Office is actually installed - self-detects via EXE existence so it
# silently no-ops on PC types whose ppkg didn't include Office (Display kiosks,
# Wax/Trace, etc.). Office Click-to-Run installs the binaries but doesn't create
# desktop shortcuts; this script fills that gap.
#
# Shortcuts land in two places:
# - C:\Users\Public\Desktop\ - visible to all users immediately
# - C:\Users\Default\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\
# - inherited by every NEW user profile
# (Azure AD logons after the device is
# enrolled)
#
# Idempotent: overwrites existing .lnk files each run.
# --- Transcript ---
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) { try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch {} }
try { Start-Transcript -Path (Join-Path $logDir '05-OfficeShortcuts.log') -Append -Force | Out-Null } catch {}
$officeApps = @(
@{ Exe = 'EXCEL.EXE'; Name = 'Excel' }
@{ Exe = 'WINWORD.EXE'; Name = 'Word' }
@{ Exe = 'POWERPNT.EXE'; Name = 'PowerPoint' }
)
# Office binary location varies by install type and version:
# - Office 2019+ / M365 / LTSC Click-to-Run: <PF>\Microsoft Office\root\Office16\
# - Office 2016 MSI (legacy): <PF>\Microsoft Office\Office16\ (no root\)
# - Office 2013 MSI: <PF>\Microsoft Office\Office15\
# - x86 Office on 64-bit Windows: Program Files (x86)\...
# The standard ShopFloor Office ppkg is x86 Click-to-Run so normal case
# is "Program Files (x86)\Microsoft Office\root\Office16\EXCEL.EXE", but
# we check all variants so the script works across SKUs.
$officeSearchRoots = @(
'C:\Program Files\Microsoft Office\root\Office16',
'C:\Program Files (x86)\Microsoft Office\root\Office16',
'C:\Program Files\Microsoft Office\Office16',
'C:\Program Files (x86)\Microsoft Office\Office16',
'C:\Program Files\Microsoft Office\Office15',
'C:\Program Files (x86)\Microsoft Office\Office15'
)
$officeRoot = $null
foreach ($base in $officeSearchRoots) {
if (Test-Path (Join-Path $base 'EXCEL.EXE')) {
$officeRoot = $base
break
}
}
if (-not $officeRoot) {
Write-Host "No Office install detected - skipping shortcut creation."
Write-Host "Searched:"
foreach ($p in $officeSearchRoots) {
$hit = if (Test-Path $p) { "(dir exists)" } else { "(missing)" }
Write-Host " $p $hit"
}
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
Write-Host "Office detected at: $officeRoot"
$publicDesktop = 'C:\Users\Public\Desktop'
$defaultStart = 'C:\Users\Default\AppData\Roaming\Microsoft\Windows\Start Menu\Programs'
if (-not (Test-Path $defaultStart)) {
New-Item -Path $defaultStart -ItemType Directory -Force | Out-Null
}
$shell = New-Object -ComObject WScript.Shell
foreach ($app in $officeApps) {
$exePath = Join-Path $officeRoot $app.Exe
if (-not (Test-Path $exePath)) {
Write-Warning "$($app.Name) EXE not found at $exePath - skipping."
continue
}
foreach ($dest in @($publicDesktop, $defaultStart)) {
$lnkPath = Join-Path $dest "$($app.Name).lnk"
try {
$sc = $shell.CreateShortcut($lnkPath)
$sc.TargetPath = $exePath
$sc.WorkingDirectory = $officeRoot
$sc.IconLocation = "$exePath,0"
$sc.Save()
Write-Host " $($app.Name) -> $lnkPath"
}
catch {
Write-Warning "Failed to create $lnkPath : $($_.Exception.Message)"
}
}
}
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) | Out-Null
Write-Host "Office shortcuts created."
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -0,0 +1,646 @@
# 06-OrganizeDesktop.ps1 - Single source of truth for the Public Desktop
# layout. Builds three category folders and fills them, so end users see
# a clean desktop instead of 20+ loose icons.
#
# Creates three subfolders under C:\Users\Public\Desktop:
# Office\ - Excel, Word, PowerPoint, Outlook, OneNote, etc.
# Shopfloor Tools\ - UDC, eDNC, HostExplorer, MarkZebra, PC-DMIS, Hexagon,
# WJ Shopfloor, Defect_Tracker, NTLARS, etc.
# Web Links\ - .url shortcuts (internal portals)
#
# Two phases:
# PHASE 1 (Invoke-DesktopSweep): walks everything at the desktop ROOT,
# classifies each .lnk / .url by filename / extension / .lnk target, and
# moves it into the right category folder. Unknown items are LEFT AT
# THE ROOT on purpose. eDNC.lnk and NTLARS.lnk are allowlisted to stay
# at the root - end users click them too often to bury in a folder.
#
# PHASE 2 (Add-ShopfloorToolsApps): materializes specific app shortcuts
# into Shopfloor Tools\ from known .exe paths (UDC, eDNC, NTLARS). For
# MSI-advertised shortcuts that don't have a resolvable target (WJ
# Shopfloor, Defect_Tracker), finds the existing .lnk wherever it lives
# and copies it in. This runs AFTER the sweep so it overwrites any
# stale copies the sweeper may have moved in.
#
# Also registers an "Organize Public Desktop" scheduled task that re-runs
# the sweep (phase 1) at every logon. Phase 2 doesn't re-run - the
# shortcuts it creates are stable and 06 on first run populates them.
#
# 07-TaskbarLayout.ps1 reads the result of this script and writes
# LayoutModification.xml to pin specific shortcuts to the taskbar.
$ErrorActionPreference = 'Continue'
# ============================================================================
# Admin check - writing to C:\Users\Public\Desktop\* and registering
# system-wide scheduled tasks both require elevation. Fail fast if not
# admin so we don't spam half-successful garbage across the output.
# ============================================================================
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host ""
Write-Host "ERROR: 06-OrganizeDesktop.ps1 must run as Administrator." -ForegroundColor Red
Write-Host " Re-run from an elevated PowerShell:" -ForegroundColor Red
Write-Host " Start -> type 'powershell' -> right-click -> Run as administrator" -ForegroundColor Red
Write-Host ""
exit 1
}
# Transcript. 06 runs as a SYSTEM scheduled task at every logon via the
# 'Organize Public Desktop' task, so without a transcript its decisions
# (what got added, what got suppressed) are invisible. Append mode keeps
# a history across logons; session header + PID makes individual runs
# easy to find.
$transcriptDir = 'C:\Logs\SFLD'
if (-not (Test-Path $transcriptDir)) {
try { New-Item -ItemType Directory -Path $transcriptDir -Force | Out-Null } catch {}
}
$transcriptPath = Join-Path $transcriptDir '06-OrganizeDesktop.log'
try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {}
Write-Host ""
Write-Host "================================================================"
Write-Host "=== 06-OrganizeDesktop session start (PID $PID, $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) ==="
Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Write-Host "================================================================"
# ============================================================================
# Cleanup: remove 'Shopfloor Intune Sync' task after sync completes
#
# Monitor-IntuneProgress.ps1 registers that task as BUILTIN\Users + RunLevel
# Limited (needs a user context to show the QR code / Configure-PC prompt),
# and a Limited task cannot Unregister-ScheduledTask itself. So when sync
# completes it just writes C:\Enrollment\sync-complete.txt as a marker and
# the task stays around firing on every logon as a no-op.
#
# 06 runs as SYSTEM via its own sweep task, which DOES have permission to
# delete any task. When we see the marker, drop the stale task so Task
# Scheduler doesn't keep the dead entry forever.
# ============================================================================
$syncCompleteMarker = 'C:\Enrollment\sync-complete.txt'
$syncTaskName = 'Shopfloor Intune Sync'
if (Test-Path -LiteralPath $syncCompleteMarker) {
$syncTask = Get-ScheduledTask -TaskName $syncTaskName -ErrorAction SilentlyContinue
if ($syncTask) {
try {
Unregister-ScheduledTask -TaskName $syncTaskName -Confirm:$false -ErrorAction Stop
Write-Host "Removed stale '$syncTaskName' task (sync complete, marker present)."
} catch {
Write-Warning "Failed to remove '$syncTaskName' task: $_"
}
}
}
# Load site config + PC profile
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
$publicDesktop = 'C:\Users\Public\Desktop'
$shopfloorToolsDir = Join-Path $publicDesktop 'Shopfloor Tools'
$scriptPath = $MyInvocation.MyCommand.Path
$scriptDir = Split-Path -Parent $scriptPath
# Filenames that always stay at the desktop root regardless of classification.
# Empty right now - every shortcut lives inside a category folder for a
# clean end-user desktop. Add entries here if a future shortcut needs to
# stay pinned at root.
$keepAtRoot = @()
# ============================================================================
# Phase 1: Sweep loose shortcuts at desktop root into category folders
# ============================================================================
function Invoke-DesktopSweep {
param([string]$DesktopPath)
if (-not (Test-Path -LiteralPath $DesktopPath)) {
Write-Host "Public desktop not found at $DesktopPath - skipping sweep."
return
}
# Category definitions. Order matters: first match wins. Each category
# can classify by file Extension, file Name (regex), or .lnk Target
# (regex, resolved via WScript.Shell).
$categories = [ordered]@{
'Office' = @{
Name = @(
'^(Excel|Word|PowerPoint|Outlook|OneNote|Access|Publisher|OneDrive)(\s|$)'
)
Target = @(
'\\(EXCEL|WINWORD|POWERPNT|OUTLOOK|ONENOTE|MSACCESS|MSPUB)\.EXE$',
'\\OneDrive\.exe$'
)
}
'Shopfloor Tools' = @{
Name = @(
'^UDC',
'eDNC', '\bDNC\b', 'DncMain', 'GE DNC', 'NTLARS',
'Host\s*Explorer', 'TN3270', 'TN5250', 'HE\s*3270', 'HE\s*5250',
# OpenText / 'WJ Shopfloor' / 'ShopFloor' shortcuts left on
# the desktop intentionally. The actual filename varies by
# OpenText profile (e.g. 'WJ Shopfloor OpenText.lnk') so the
# taskbar pin path mismatch silently skipped these. Leaving
# them at the public desktop top level instead.
'Defect[_\s-]?Tracker',
'MarkZebra', 'Zebra',
'PC-?DMIS',
'Hexagon', 'CLM\s*Tools',
'Blancco',
'Keyence'
)
Target = @(
'\\UDC\.exe$',
'DncMain\.exe$', 'NTLARS\.exe$', 'DNCRemote',
'HostExplorer\.exe$', 'hostex32\.exe$', 'humicon15\.exe$',
'PC-?DMIS',
'Hexagon',
'Defect_Tracker\.application$'
)
}
'Web Links' = @{
Extension = @('.url')
}
}
# Category folders are created LAZILY - only when we're about to move
# something into them - so a Display / Wax-Trace PC without Office
# doesn't get an empty Office\ folder littering the desktop. See
# Remove-EmptyCategoryFolders at the end of the script for the
# post-sweep cleanup pass that finishes the job.
# WScript.Shell for resolving .lnk targets
$shell = $null
try { $shell = New-Object -ComObject WScript.Shell } catch {}
# Only sweep files at the desktop root, never recurse into the category
# folders themselves (that would loop things back).
$items = @(Get-ChildItem -LiteralPath $DesktopPath -File -ErrorAction SilentlyContinue)
$moved = 0
$skipped = 0
foreach ($item in $items) {
# Allowlist check: never sweep items the user wants to keep visible
if ($keepAtRoot -contains $item.Name) {
Write-Host " keep: $($item.Name) (allowlisted)"
continue
}
$category = $null
# 1. Extension match (cheapest)
foreach ($cat in $categories.Keys) {
$exts = $categories[$cat].Extension
if ($exts -and ($exts -contains $item.Extension)) {
$category = $cat
break
}
}
# 2. File name regex match
if (-not $category) {
foreach ($cat in $categories.Keys) {
$pats = $categories[$cat].Name
if (-not $pats) { continue }
foreach ($p in $pats) {
if ($item.BaseName -match $p) {
$category = $cat
break
}
}
if ($category) { break }
}
}
# 3. .lnk target resolution
if (-not $category -and $item.Extension -ieq '.lnk' -and $shell) {
try {
$sc = $shell.CreateShortcut($item.FullName)
$target = $sc.TargetPath
if ($target) {
foreach ($cat in $categories.Keys) {
$pats = $categories[$cat].Target
if (-not $pats) { continue }
foreach ($p in $pats) {
if ($target -match $p) {
$category = $cat
break
}
}
if ($category) { break }
}
}
} catch {}
}
if ($category) {
$destDir = Join-Path $DesktopPath $category
if (-not (Test-Path -LiteralPath $destDir)) {
try {
New-Item -ItemType Directory -Path $destDir -Force -ErrorAction Stop | Out-Null
} catch {
Write-Warning "Failed to create category folder '$category' : $_"
continue
}
}
$destPath = Join-Path $destDir $item.Name
try {
Move-Item -LiteralPath $item.FullName -Destination $destPath -Force -ErrorAction Stop
Write-Host " moved: $($item.Name) -> $category\"
$moved++
} catch {
Write-Warning "Failed to move $($item.Name) : $_"
}
} else {
$skipped++
}
}
if ($shell) {
try { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) | Out-Null } catch {}
}
Write-Host "Sweep complete: $moved moved, $skipped unclassified (left at root)."
}
# ============================================================================
# Phase 2: Materialize specific app shortcuts into Shopfloor Tools\
#
# Runs AFTER the sweep. Creates fresh .lnk files for apps we can target by
# .exe path (UDC, eDNC, NTLARS) and copies existing .lnk files for apps
# that are MSI-advertised and have no resolvable target (WJ Shopfloor,
# Defect_Tracker). Overwrites whatever the sweeper may have moved in -
# this is intentional because fresh shortcuts are more reliable than
# whatever install crud might be floating around.
#
# Each app is CONDITIONAL - if its source isn't present on this PC, the
# entry is silently skipped. Safe to run on all shopfloor PC types.
# ============================================================================
function New-ShopfloorLnk {
param(
[string]$Path,
[string]$Target,
[string]$Arguments = '',
[string]$WorkingDirectory = ''
)
$wsh = New-Object -ComObject WScript.Shell
try {
$sc = $wsh.CreateShortcut($Path)
$sc.TargetPath = $Target
if ($Arguments) { $sc.Arguments = $Arguments }
if ($WorkingDirectory) { $sc.WorkingDirectory = $WorkingDirectory }
elseif ($Target) {
$parent = Split-Path -Parent $Target
if ($parent) { $sc.WorkingDirectory = $parent }
}
$sc.IconLocation = "$Target,0"
$sc.Save()
return $true
} catch {
Write-Warning "Failed to create $Path : $_"
return $false
} finally {
try { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($wsh) | Out-Null } catch {}
}
}
# Look up an existing .lnk file across the paths where installers / ppkgs
# typically drop them. Used for apps where we don't build the .lnk from
# an .exe (because the target is an MSI-advertised Darwin descriptor).
function Find-ExistingLnk {
param([string]$Filename)
$candidates = @(
(Join-Path $publicDesktop $Filename),
(Join-Path $shopfloorToolsDir $Filename),
"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\$Filename",
"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Shopfloor Tools\$Filename"
)
foreach ($c in $candidates) {
if (Test-Path -LiteralPath $c) { return $c }
}
return $null
}
function Add-ShopfloorToolsApps {
# App registry - each entry describes how to materialize one shortcut
# into Shopfloor Tools\. Folder creation is deferred until we know we
# have at least one app to put in it (lazy creation, so PC types with
# nothing installed don't get an empty Shopfloor Tools folder).
#
# Kind = 'exe' -> build a fresh .lnk from ExePath
# Kind = 'existing' -> copy an existing .lnk via Find-ExistingLnk
$cfgApps = Get-ProfileValue 'desktopApps'
# An EMPTY configured list means "no desktop apps" and must be honoured.
# Get-ProfileValue returns $null only when the key is absent from both the
# profile and site-config, so $null is the real "not configured" signal.
#
# The old test also required .Count -gt 0, which meant "desktopApps": []
# fell through to the hardcoded shopfloor list below - so a Display kiosk,
# which explicitly configures an empty list, was given UDC, eDNC, NTLARS,
# WJ Shopfloor and Defect_Tracker shortcuts. Configuring "none" produced
# "everything". Observed on 579C144, 2026-08-06.
if ($null -ne $cfgApps) {
$apps = @($cfgApps | ForEach-Object {
$entry = @{ Name = $_.name; Kind = $_.kind }
if ($_.kind -eq 'exe') { $entry.ExePath = $_.exePath }
if ($_.kind -eq 'existing') { $entry.SourceName = $_.sourceName }
$entry
})
} else {
$apps = @(
@{ Name = 'UDC'; Kind = 'exe'; ExePath = 'C:\Program Files\UDC\UDC.exe' }
@{ Name = 'eDNC'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\Dnc\bin\DncMain.exe' }
@{ Name = 'NTLARS'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\Dnc\Common\NTLARS.exe' }
@{ Name = 'WJ Shopfloor'; Kind = 'existing'; SourceName = 'WJ Shopfloor.lnk' }
@{ Name = 'Defect_Tracker'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\WJF_Defect_Tracker\Defect_Tracker.exe' }
)
}
# Lazy folder creation - only create Shopfloor Tools\ the first time
# we have an app that's actually going to land in it. PC types with
# nothing installed get no empty folder.
$ensureToolsDir = {
if (-not (Test-Path -LiteralPath $shopfloorToolsDir)) {
try {
New-Item -ItemType Directory -Path $shopfloorToolsDir -Force -ErrorAction Stop | Out-Null
return $true
} catch {
Write-Warning "Failed to create $shopfloorToolsDir : $_"
return $false
}
}
return $true
}
foreach ($app in $apps) {
$dest = Join-Path $shopfloorToolsDir "$($app.Name).lnk"
switch ($app.Kind) {
'exe' {
if (Test-Path -LiteralPath $app.ExePath) {
if (-not (& $ensureToolsDir)) { break }
if (New-ShopfloorLnk -Path $dest -Target $app.ExePath) {
Write-Host " created: $($app.Name) -> $dest"
}
} else {
Write-Host " skip: $($app.Name) - target not installed ($($app.ExePath))" -ForegroundColor DarkGray
}
}
'existing' {
$src = Find-ExistingLnk $app.SourceName
if ($src) {
# Skip if the sweep already moved the source into the
# destination folder (src == dest -> "cannot overwrite
# the item with itself").
if ((Resolve-Path -LiteralPath $src -ErrorAction SilentlyContinue).Path -eq
(Join-Path $shopfloorToolsDir $app.SourceName)) {
Write-Host " exists: $($app.Name) (already in Shopfloor Tools)"
} else {
if (-not (& $ensureToolsDir)) { break }
try {
Copy-Item -LiteralPath $src -Destination $dest -Force -ErrorAction Stop
Write-Host " copied: $($app.Name) from $src"
} catch {
Write-Warning "Failed to copy $src -> $dest : $_"
}
}
} else {
Write-Host " skip: $($app.Name) - no source .lnk found" -ForegroundColor DarkGray
}
}
}
}
}
# ============================================================================
# Phase 3: Remove empty category folders
#
# If the sweep classified nothing into a given category (e.g. no Office
# on a Display PC, no Shopfloor Tools apps on a kiosk), we don't want an
# empty folder cluttering the desktop. The scheduled-task sweep also runs
# this so categories that go empty between logons self-heal.
# ============================================================================
function Remove-EmptyCategoryFolders {
foreach ($cat in @('Office', 'Shopfloor Tools', 'Web Links')) {
$dir = Join-Path $publicDesktop $cat
if (-not (Test-Path -LiteralPath $dir)) { continue }
$contents = @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue)
if ($contents.Count -eq 0) {
try {
Remove-Item -LiteralPath $dir -Force -ErrorAction Stop
Write-Host " removed empty: $cat\"
} catch {
Write-Warning "Failed to remove empty $dir : $_"
}
}
}
}
# ============================================================================
# Phase 4: Edge default browser + startup tabs (delegated to 08)
#
# 08-EdgeDefaultBrowser.ps1 resolves startup-tab URLs from .url files on
# the Public Desktop (or in Web Links\ after the sweep). The .url files
# are delivered by DSC AFTER this initial shopfloor setup, so the first
# run at imaging time won't find them (falls back to hardcoded URLs,
# Plant Apps gets skipped). By calling 08 from inside 06, every SYSTEM
# scheduled-task logon re-run of 06 also re-runs 08 -- so after DSC drops
# the .url files and the next sweep files them into Web Links\, 08
# picks them up and updates the Edge policy. Self-healing.
# ============================================================================
function Invoke-EdgeDefaultBrowser {
$edgeScript = Join-Path $scriptDir '08-EdgeDefaultBrowser.ps1'
if (-not (Test-Path -LiteralPath $edgeScript)) {
Write-Host " 08-EdgeDefaultBrowser.ps1 not found - skipping"
return
}
try {
& $edgeScript
} catch {
Write-Warning "08-EdgeDefaultBrowser.ps1 failed: $_"
}
}
# ============================================================================
# Scheduled task registration (re-runs 06 at every logon as SYSTEM)
# ============================================================================
function Register-SweepScheduledTask {
param([string]$ScriptPath)
$taskName = 'Organize Public Desktop'
if (-not (Test-Path -LiteralPath $ScriptPath)) {
Write-Warning "Cannot register scheduled task - script not found at $ScriptPath"
return
}
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$ScriptPath`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Run as SYSTEM so the script can (a) pass its admin check at the
# top, (b) write to Public Desktop / ProgramData Start Menu / the
# Default User profile without Access Denied, and (c) register the
# next iteration of itself idempotently. Earlier versions used
# -GroupId 'BUILTIN\Users' -RunLevel Limited which fired at logon
# but the script exited immediately on the non-admin check.
$principal = New-ScheduledTaskPrincipal `
-UserId 'NT AUTHORITY\SYSTEM' `
-LogonType ServiceAccount `
-RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5)
# -ErrorAction Stop is required because Register-ScheduledTask emits
# non-terminating errors on failure (Access Denied etc), which try/catch
# would otherwise miss and we'd falsely claim success.
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Force `
-ErrorAction Stop | Out-Null
Write-Host "Scheduled task '$taskName' registered (runs at every logon)."
} catch {
Write-Warning "Failed to register scheduled task : $_"
}
}
# ============================================================================
# Main
# ============================================================================
Write-Host ""
Write-Host "=== Phase 0: create Office shortcuts (if Office is installed) ==="
# Delegated to 05-OfficeShortcuts.ps1. Office is installed via ppkg but
# doesn't finish streaming until AFTER the first reboot, so the first
# imaging-time run of 06 finds no Office and this no-ops. On the second
# logon (after reboot), Office is installed and 05 creates the shortcuts
# so Phase 1 below can sweep them into the Office\ folder.
$officeScript = Join-Path $scriptDir '05-OfficeShortcuts.ps1'
if (Test-Path -LiteralPath $officeScript) {
try { & $officeScript } catch { Write-Warning "05-OfficeShortcuts.ps1 failed: $_" }
} else {
Write-Host " 05-OfficeShortcuts.ps1 not found - skipping"
}
Write-Host ""
Write-Host "=== Phase 1: sweep loose shortcuts into category folders ==="
Invoke-DesktopSweep -DesktopPath $publicDesktop
Write-Host ""
Write-Host "=== Phase 2: populate Shopfloor Tools with app shortcuts ==="
Add-ShopfloorToolsApps
Write-Host ""
Write-Host "=== Phase 3: auto-apply startup items from PC profile ==="
# Creates .lnk files in AllUsers Startup folder based on the profile's
# startupItems. This runs as SYSTEM at every logon via the sweep scheduled
# task, so any item the tech removes via Configure-PC would bounce right
# back unless we remember the removal. startup-overrides.json tracks labels
# the tech has explicitly suppressed; items in that list are skipped here
# and re-enabling them via Configure-PC removes them from the list.
$startupDir = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup'
$overridesPath = 'C:\ProgramData\GE\Shopfloor\startup-overrides.json'
$suppressed = @()
if (Test-Path -LiteralPath $overridesPath) {
try {
$overrides = Get-Content -LiteralPath $overridesPath -Raw | ConvertFrom-Json
if ($overrides.suppressed) { $suppressed = @($overrides.suppressed) }
} catch {
Write-Warning " Failed to parse $overridesPath : $_"
}
}
$cfgStartup = Get-ProfileValue 'startupItems'
# Same rule as desktopApps: $null = not configured, empty array = configured as
# none. Harmless here today because the else branch has no hardcoded fallback,
# but kept consistent so the two keys cannot drift apart again.
if ($null -ne $cfgStartup -and $cfgStartup.Count -gt 0) {
if (-not (Test-Path $startupDir)) {
New-Item -ItemType Directory -Path $startupDir -Force | Out-Null
}
$edgePath = @(
'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files\Microsoft\Edge\Application\msedge.exe'
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
foreach ($si in $cfgStartup) {
if ($suppressed -contains $si.label) {
Write-Host " suppressed: $($si.label) (tech disabled via Configure-PC)"
continue
}
$lnkPath = Join-Path $startupDir "$($si.label).lnk"
# Skip if already exists (idempotent - don't overwrite user changes)
if (Test-Path -LiteralPath $lnkPath) {
Write-Host " exists: $($si.label)"
continue
}
switch ($si.type) {
'exe' {
if (Test-Path -LiteralPath $si.target) {
if (New-ShopfloorLnk -Path $lnkPath -Target $si.target) {
Write-Host " added: $($si.label)"
}
} else {
Write-Host " skip: $($si.label) - target not installed" -ForegroundColor DarkGray
}
}
'existing' {
$src = Find-ExistingLnk $si.sourceLnk
if ($src) {
try {
Copy-Item -LiteralPath $src -Destination $lnkPath -Force
Write-Host " added: $($si.label)"
} catch { Write-Warning " failed: $($si.label) - $_" }
} else {
Write-Host " skip: $($si.label) - source not found" -ForegroundColor DarkGray
}
}
'url' {
$url = $null
if ($si.urlKey -and $siteConfig -and $siteConfig.urls) {
$url = $siteConfig.urls.$($si.urlKey)
}
if ($url -and $edgePath) {
if (New-ShopfloorLnk -Path $lnkPath -Target $edgePath -Arguments "--new-window `"$url`"") {
Write-Host " added: $($si.label)"
}
} else {
Write-Host " skip: $($si.label) - URL or Edge not available" -ForegroundColor DarkGray
}
}
}
}
} else {
Write-Host " (no startup items in profile)"
}
Write-Host ""
Write-Host "=== Phase 4: remove empty category folders ==="
Remove-EmptyCategoryFolders
Write-Host ""
Write-Host "=== Phase 5: Edge default browser + startup tabs ==="
Invoke-EdgeDefaultBrowser
Write-Host ""
Write-Host "=== Phase 6: register logon sweeper scheduled task ==="
Register-SweepScheduledTask -ScriptPath $scriptPath
Write-Host ""
Write-Host "=== 06-OrganizeDesktop session end ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) ==="
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -0,0 +1,164 @@
# 07-TaskbarLayout.ps1 - Minimal taskbar pinner.
#
# Reads the shortcuts that 06-OrganizeDesktop.ps1 created in
# C:\Users\Public\Desktop\Shopfloor Tools\ and writes a LayoutModification.xml
# that pins them to the taskbar along with Microsoft Edge.
#
# This script does NOT create, move, or copy any shortcuts - all shortcut
# management lives in 06. 07 is the last-mile taskbar config only.
#
# Pin order (left to right): Edge, WJ Shopfloor, UDC, eDNC, NTLARS, Defect_Tracker.
#
# LayoutModification.xml is written to the Default User profile shell
# directory, which means the pins apply on FIRST LOGON of any new user
# profile. Existing profiles don't re-read the Default User template, so
# they won't pick up the pins without a manual re-pin or profile delete.
# This is a Windows design limitation - syspin.exe style hacks are the
# only workaround for existing profiles, and they're unsupported.
$ErrorActionPreference = 'Continue'
# ============================================================================
# Admin check - writing to the Default User profile requires elevation.
# ============================================================================
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host ""
Write-Host "ERROR: 07-TaskbarLayout.ps1 must run as Administrator." -ForegroundColor Red
Write-Host " Re-run from an elevated PowerShell." -ForegroundColor Red
Write-Host ""
exit 1
}
# Load site config + PC profile
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
$publicDesktop = 'C:\Users\Public\Desktop'
$shopfloorToolsDir = Join-Path $publicDesktop 'Shopfloor Tools'
$defaultUserShell = 'C:\Users\Default\AppData\Local\Microsoft\Windows\Shell'
$layoutXmlPath = Join-Path $defaultUserShell 'LayoutModification.xml'
# ============================================================================
# Pin list: exact ordered list of shortcut names to pin. Each entry points
# at a .lnk file via a Windows environment-variable-expanded path. Edge is
# special-cased to its default Start Menu location since Windows maintains
# it; all other entries reference C:\Users\Public\Desktop\Shopfloor Tools\
# which 06-OrganizeDesktop.ps1 populates.
# ============================================================================
$cfgPins = Get-ProfileValue 'taskbarPins'
# An EMPTY configured list means "no pins" and must be honoured. Get-ProfileValue
# returns $null only when the key is absent from BOTH the profile and
# site-config, so $null is the real "not configured" signal. Requiring
# .Count -gt 0 as well makes "taskbarPins": [] fall through to the hardcoded
# shopfloor list - configuring "none" producing "everything", the same bug that
# put Plant Apps and Defect Tracker in a Display kiosk's Startup folder
# (Configure-PC.ps1) and machine-tool shortcuts on its desktop
# (06-OrganizeDesktop.ps1).
if ($null -ne $cfgPins) {
$pinSpec = @($cfgPins | ForEach-Object {
@{
Name = $_.name
Path = $_.lnkPath
Literal = [Environment]::ExpandEnvironmentVariables($_.lnkPath)
}
})
} else {
$pinSpec = @(
@{
Name = 'Microsoft Edge'
Path = '%ALLUSERSPROFILE%\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk'
Literal = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk'
}
@{
Name = 'WJ Shopfloor'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\WJ Shopfloor.lnk'
Literal = (Join-Path $shopfloorToolsDir 'WJ Shopfloor.lnk')
}
@{
Name = 'UDC'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\UDC.lnk'
Literal = (Join-Path $shopfloorToolsDir 'UDC.lnk')
}
@{
Name = 'eDNC'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\eDNC.lnk'
Literal = (Join-Path $shopfloorToolsDir 'eDNC.lnk')
}
@{
Name = 'NTLARS'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\NTLARS.lnk'
Literal = (Join-Path $shopfloorToolsDir 'NTLARS.lnk')
}
@{
Name = 'Defect_Tracker'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\Defect_Tracker.lnk'
Literal = (Join-Path $shopfloorToolsDir 'Defect_Tracker.lnk')
}
)
}
# ============================================================================
# Build the pin list - skip any whose .lnk is missing
# ============================================================================
Write-Host ""
Write-Host "Checking which Shopfloor Tools shortcuts exist..."
$pinPaths = @()
foreach ($pin in $pinSpec) {
if (Test-Path -LiteralPath $pin.Literal) {
Write-Host " pin: $($pin.Name) -> $($pin.Literal)"
$pinPaths += $pin.Path
} else {
Write-Host " skip: $($pin.Name) - not found at $($pin.Literal)" -ForegroundColor DarkGray
}
}
if ($pinPaths.Count -eq 0) {
Write-Warning "No pins to apply (nothing found in Shopfloor Tools\). Did 06-OrganizeDesktop.ps1 run first?"
exit 0
}
# ============================================================================
# Write LayoutModification.xml
# ============================================================================
$pinXml = ($pinPaths | ForEach-Object {
" <taskbar:DesktopApp DesktopApplicationLinkPath=`"$_`"/>"
}) -join "`r`n"
$layoutXml = @"
<?xml version="1.0" encoding="utf-8"?>
<LayoutModificationTemplate
xmlns="http://schemas.microsoft.com/Start/2014/LayoutModification"
xmlns:defaultlayout="http://schemas.microsoft.com/Start/2014/FullDefaultLayout"
xmlns:start="http://schemas.microsoft.com/Start/2014/StartLayout"
xmlns:taskbar="http://schemas.microsoft.com/Start/2014/TaskbarLayout"
Version="1">
<CustomTaskbarLayoutCollection PinListPlacement="Replace">
<defaultlayout:TaskbarLayout>
<taskbar:TaskbarPinList>
$pinXml
</taskbar:TaskbarPinList>
</defaultlayout:TaskbarLayout>
</CustomTaskbarLayoutCollection>
</LayoutModificationTemplate>
"@
try {
if (-not (Test-Path -LiteralPath $defaultUserShell)) {
New-Item -ItemType Directory -Path $defaultUserShell -Force -ErrorAction Stop | Out-Null
}
# -ErrorAction Stop so Access Denied becomes a catchable terminating error
# instead of silently falling through to the success message.
Set-Content -LiteralPath $layoutXmlPath -Value $layoutXml -Encoding UTF8 -Force -ErrorAction Stop
Write-Host ""
Write-Host "Wrote taskbar layout with $($pinPaths.Count) pin(s) to:"
Write-Host " $layoutXmlPath"
Write-Host ""
Write-Host "Pins will apply on first logon of any NEW user profile."
} catch {
Write-Warning "Failed to write $layoutXmlPath : $_"
exit 1
}
exit 0

View File

@@ -0,0 +1,325 @@
# 08-EdgeDefaultBrowser.ps1 - Set Microsoft Edge as the default browser
# and PDF handler for all new user profiles, and configure startup tabs
# so Edge opens with Plant Apps / Shop Floor Homepage / Shopfloor Dashboard
# when the end user first launches it.
#
# WHY THIS EXISTS:
# The West Jefferson shopfloor ppkg installs Google Chrome alongside Edge.
# On first URL click in a new Azure AD user profile, Windows pops a "Choose
# your default app" picker because multiple browsers are installed and
# nothing has been marked as default. End users hit this on every fresh PC
# and it derails the shopfloor workflow.
#
# HOW IT WORKS:
# Two layers, belt-and-suspenders:
#
# 1. DISM default-app-associations XML
# Writes an XML file mapping http/https/.htm/.html/.pdf/.svg/etc to
# Edge's ProgIds, then runs:
# dism /Online /Import-DefaultAppAssociations:<xml>
# This imports the XML into the Default User profile template. Any
# NEW user profile created after this point inherits the associations
# on first logon. Microsoft-supported, works across Win10/Win11.
#
# 2. Group Policy registry key
# HKLM:\SOFTWARE\Policies\Microsoft\Windows\System\DefaultAssociationsConfiguration
# points at the same XML file. This is the "Set a default associations
# configuration file" GPO. With this set, Windows re-applies the XML
# on every logon, not just once at profile creation - so Windows
# update defaults-reset and similar edge cases don't un-do us.
#
# CAVEATS:
# - Applies to NEW profiles on first logon. An existing profile that's
# already been logged in (e.g. SupportUser) won't pick up the change
# without a manual Settings > Default apps visit. Acceptable because
# SupportUser isn't the end-user account.
# - Does not UNinstall Chrome. Chrome remains available, it's just not
# the default handler for URLs.
# - Microsoft blocks programmatic UserChoice hash tampering since Win10
# 1703. The DISM + policy route is the only supported path, and it
# only fires for new profiles - that's by design.
$ErrorActionPreference = 'Stop'
# ----------------------------------------------------------------------------
# Admin check
# ----------------------------------------------------------------------------
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host ""
Write-Host "ERROR: 08-EdgeDefaultBrowser.ps1 must run as Administrator." -ForegroundColor Red
Write-Host " Re-run from an elevated PowerShell." -ForegroundColor Red
Write-Host ""
exit 1
}
# Load site config + PC profile
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
# ----------------------------------------------------------------------------
# Sanity: Edge must be installed for this to mean anything
# ----------------------------------------------------------------------------
$edgeCandidates = @(
'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files\Microsoft\Edge\Application\msedge.exe'
)
$edgePath = $edgeCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $edgePath) {
Write-Host "Microsoft Edge not found in either Program Files location - skipping default browser config."
exit 0
}
Write-Host "Found Edge at: $edgePath"
# ----------------------------------------------------------------------------
# XML: Edge as default for web browsing + PDFs + common image formats
# ----------------------------------------------------------------------------
$xml = @'
<?xml version="1.0" encoding="UTF-8"?>
<DefaultAssociations>
<Association Identifier=".htm" ProgId="MSEdgeHTM" ApplicationName="Microsoft Edge" />
<Association Identifier=".html" ProgId="MSEdgeHTM" ApplicationName="Microsoft Edge" />
<Association Identifier=".mht" ProgId="MSEdgeMHT" ApplicationName="Microsoft Edge" />
<Association Identifier=".mhtml" ProgId="MSEdgeMHT" ApplicationName="Microsoft Edge" />
<Association Identifier=".pdf" ProgId="MSEdgePDF" ApplicationName="Microsoft Edge" />
<Association Identifier=".svg" ProgId="MSEdgeSVG" ApplicationName="Microsoft Edge" />
<Association Identifier=".webp" ProgId="MSEdgeHTM" ApplicationName="Microsoft Edge" />
<Association Identifier="http" ProgId="MSEdgeHTM" ApplicationName="Microsoft Edge" />
<Association Identifier="https" ProgId="MSEdgeHTM" ApplicationName="Microsoft Edge" />
<Association Identifier="ftp" ProgId="MSEdgeHTM" ApplicationName="Microsoft Edge" />
<Association Identifier="microsoft-edge" ProgId="MSEdgeHTM" ApplicationName="Microsoft Edge" />
</DefaultAssociations>
'@
# Write to a stable location that persists across reimaging (C:\Enrollment is
# the canonical staging dir in this repo, survives logoff/logon, referenced
# by the GP policy below so it must be a path every user-session can read).
$xmlDir = 'C:\Enrollment'
$xmlPath = Join-Path $xmlDir 'edge-default-associations.xml'
if (-not (Test-Path -LiteralPath $xmlDir)) {
New-Item -ItemType Directory -Path $xmlDir -Force | Out-Null
}
Set-Content -LiteralPath $xmlPath -Value $xml -Encoding UTF8 -Force
Write-Host "Wrote associations XML to: $xmlPath"
# ----------------------------------------------------------------------------
# Layer 1: DISM import into the Default User profile template
# ----------------------------------------------------------------------------
Write-Host ""
Write-Host "Layer 1: dism /Online /Import-DefaultAppAssociations..."
& dism.exe /Online /Import-DefaultAppAssociations:"$xmlPath" | Out-Null
$dismExit = $LASTEXITCODE
if ($dismExit -eq 0) {
Write-Host " OK - defaults imported into Default User template."
} else {
Write-Warning " dism returned exit code $dismExit - import may not have applied."
}
# ----------------------------------------------------------------------------
# Layer 2: Group Policy registry key - enforces XML on every logon
# ----------------------------------------------------------------------------
Write-Host ""
Write-Host "Layer 2: Setting DefaultAssociationsConfiguration policy..."
$polKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
if (-not (Test-Path $polKey)) {
New-Item -Path $polKey -Force | Out-Null
}
Set-ItemProperty -Path $polKey -Name 'DefaultAssociationsConfiguration' -Value $xmlPath -Type String -Force
Write-Host " OK - HKLM:\SOFTWARE\Policies\Microsoft\Windows\System\DefaultAssociationsConfiguration = $xmlPath"
Write-Host ""
Write-Host "================================================================"
Write-Host "Edge default browser config applied."
Write-Host "================================================================"
# ----------------------------------------------------------------------------
# Edge startup tabs + homepage
#
# End users should see Plant Apps, the Shop Floor Homepage, and the
# Shopfloor Dashboard open in three tabs the moment Edge launches. Rather
# than hardcoding URLs in this script, we read them out of the .url files
# that already live on the Public Desktop (copied there by ppkg). This
# way, if an admin renames or retargets a .url file later, the script
# picks up the new URL on the next imaging run without a code change.
#
# Fallback: if the .url file is missing, we fall back to a hardcoded URL
# (only for the ones we know for sure). Plant Apps has no fallback because
# I don't have its URL memorized.
#
# Machine policies (all under HKLM:\SOFTWARE\Policies\Microsoft\Edge):
# RestoreOnStartup = 4 (open a specific set of URLs)
# RestoreOnStartupURLs = subkey with "1","2","3"... = URLs in tab order
# HomepageLocation = first startup URL (home button opens same)
# HomepageIsNewTabPage = 0 (home button opens HomepageLocation, not NTP)
# ShowHomeButton = 1 (show the home button in the toolbar)
# ----------------------------------------------------------------------------
Write-Host ""
Write-Host "Configuring Edge startup tabs..."
function Get-UrlFromFile {
param([string]$Path)
try {
$content = Get-Content -LiteralPath $Path -ErrorAction Stop
$urlLine = $content | Where-Object { $_ -match '^URL=' } | Select-Object -First 1
if ($urlLine) {
return ($urlLine -replace '^URL=', '').Trim()
}
} catch {}
return $null
}
function Resolve-StartupUrl {
param(
[string]$BaseName, # .url filename without extension
[string]$Fallback = ''
)
# Look in Public Desktop root first, then Web Links subfolder (06's
# sweeper may have moved it there already).
$candidates = @(
"C:\Users\Public\Desktop\$BaseName.url",
"C:\Users\Public\Desktop\Web Links\$BaseName.url"
)
foreach ($c in $candidates) {
if (Test-Path -LiteralPath $c) {
$url = Get-UrlFromFile $c
if ($url) {
Write-Host " resolved $BaseName -> $url (from $c)"
return $url
}
}
}
if ($Fallback) {
Write-Host " fallback $BaseName -> $Fallback (.url file not found)" -ForegroundColor DarkGray
return $Fallback
}
Write-Warning " $BaseName : no .url file found and no fallback - will be skipped"
return $null
}
# Resolve startup tabs: profile > site-wide > hardcoded fallback
# Different PC types can have different tabs (e.g. Lab has Webmail
# instead of Plant Apps, Standard-Machine has Plant Apps).
$cfgTabs = Get-ProfileValue 'edgeStartupTabs'
$startupTabs = @()
# Empty configured list = "no startup tabs", and must be honoured. $null (key
# absent from both profile and site-config) is the only "not configured" signal.
# Requiring .Count -gt 0 would make "edgeStartupTabs": [] fall through to the
# Plant Apps + WJ Shopfloor + Dashboard fallback below - the same
# configuring-none-gives-everything bug fixed in Configure-PC.ps1,
# 06-OrganizeDesktop.ps1 and 07-TaskbarLayout.ps1.
if ($null -ne $cfgTabs) {
foreach ($tab in $cfgTabs) {
$fallback = if ($tab.fallbackUrlKey -and $siteConfig.urls) { $siteConfig.urls.$($tab.fallbackUrlKey) } else { '' }
$url = Resolve-StartupUrl -BaseName $tab.baseName -Fallback $fallback
if ($url) { $startupTabs += $url }
}
} else {
$plantApps = Resolve-StartupUrl -BaseName 'Plant Apps' -Fallback 'https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications'
if ($plantApps) { $startupTabs += $plantApps }
$shopFloorHome = Resolve-StartupUrl -BaseName 'WJ Shop Floor Homepage' -Fallback 'https://tsgwp00525.wjs.geaerospace.net'
if ($shopFloorHome) { $startupTabs += $shopFloorHome }
$dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor'
if ($dashboard) { $startupTabs += $dashboard }
}
# Resolve homepage: profile can override (e.g. Lab has a different homepage
# than Standard). Falls back to the first startup tab if not specified.
$edgeHomepage = if ($pcProfile -and $pcProfile.edgeHomepage) { $pcProfile.edgeHomepage } else { $null }
if ($startupTabs.Count -eq 0) {
Write-Warning "No startup tab URLs resolved - skipping Edge startup config."
} else {
$edgePolKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Edge'
if (-not (Test-Path $edgePolKey)) {
New-Item -Path $edgePolKey -Force | Out-Null
}
# RestoreOnStartup = 4 means "open a specific set of URLs" (the list
# lives under the RestoreOnStartupURLs subkey below).
Set-ItemProperty -Path $edgePolKey -Name 'RestoreOnStartup' -Value 4 -Type DWord -Force
# Build RestoreOnStartupURLs subkey: numbered string values "1","2","3"
$urlsKey = Join-Path $edgePolKey 'RestoreOnStartupURLs'
if (Test-Path $urlsKey) {
# Wipe any prior entries so we don't leak stale tabs if the list
# shrinks between runs
Remove-Item -Path $urlsKey -Recurse -Force
}
New-Item -Path $urlsKey -Force | Out-Null
for ($i = 0; $i -lt $startupTabs.Count; $i++) {
$name = [string]($i + 1)
Set-ItemProperty -Path $urlsKey -Name $name -Value $startupTabs[$i] -Type String -Force
}
# Homepage: profile override > first startup tab. Home button opens it, not NTP.
$homepageUrl = if ($edgeHomepage) { $edgeHomepage } else { $startupTabs[0] }
Write-Host "Homepage set to: $homepageUrl"
Set-ItemProperty -Path $edgePolKey -Name 'HomepageLocation' -Value $homepageUrl -Type String -Force
Set-ItemProperty -Path $edgePolKey -Name 'HomepageIsNewTabPage' -Value 0 -Type DWord -Force
Set-ItemProperty -Path $edgePolKey -Name 'ShowHomeButton' -Value 1 -Type DWord -Force
Write-Host ""
Write-Host "Edge startup tabs set ($($startupTabs.Count) tab(s)):"
for ($i = 0; $i -lt $startupTabs.Count; $i++) {
Write-Host " $($i + 1). $($startupTabs[$i])"
}
Write-Host "Homepage set to: $($startupTabs[0])"
}
# ============================================================================
# Chrome: same homepage + startup tabs as Edge
#
# The PPKG installs Chrome alongside Edge. Apply the same profile-driven
# homepage and startup tabs so both browsers behave identically. Uses the
# same $startupTabs and $homepageUrl resolved above.
# HKLM:\SOFTWARE\Policies\Google\Chrome mirrors the Edge policy structure.
# ============================================================================
Write-Host ""
Write-Host "Configuring Chrome startup tabs..."
$chromePath = @(
'C:\Program Files\Google\Chrome\Application\chrome.exe',
'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $chromePath) {
Write-Host " Chrome not installed - skipping." -ForegroundColor DarkGray
} elseif ($startupTabs.Count -eq 0) {
Write-Host " No startup tabs resolved - skipping Chrome config." -ForegroundColor DarkGray
} else {
$chromePolKey = 'HKLM:\SOFTWARE\Policies\Google\Chrome'
if (-not (Test-Path $chromePolKey)) {
New-Item -Path $chromePolKey -Force | Out-Null
}
Set-ItemProperty -Path $chromePolKey -Name 'RestoreOnStartup' -Value 4 -Type DWord -Force
$urlsKey = Join-Path $chromePolKey 'RestoreOnStartupURLs'
if (Test-Path $urlsKey) {
Remove-Item -Path $urlsKey -Recurse -Force
}
New-Item -Path $urlsKey -Force | Out-Null
for ($i = 0; $i -lt $startupTabs.Count; $i++) {
Set-ItemProperty -Path $urlsKey -Name ([string]($i + 1)) -Value $startupTabs[$i] -Type String -Force
}
$chromeHomepage = if ($homepageUrl) { $homepageUrl } elseif ($edgeHomepage) { $edgeHomepage } else { $startupTabs[0] }
Set-ItemProperty -Path $chromePolKey -Name 'HomepageLocation' -Value $chromeHomepage -Type String -Force
Set-ItemProperty -Path $chromePolKey -Name 'HomepageIsNewTabPage' -Value 0 -Type DWord -Force
Set-ItemProperty -Path $chromePolKey -Name 'ShowHomeButton' -Value 1 -Type DWord -Force
Write-Host " Chrome startup tabs set ($($startupTabs.Count) tab(s)), homepage: $chromeHomepage"
}
Write-Host ""
Write-Host "================================================================"
Write-Host "08-EdgeDefaultBrowser.ps1 complete."
Write-Host "Edge + Chrome homepage and startup tabs configured."
Write-Host "Machine-wide GPO - applies to all users on next browser launch."
Write-Host "================================================================"
exit 0

View File

@@ -0,0 +1,89 @@
# 09-Install-PrinterInstallerMap.ps1 - imaging-time placement of the
# PrinterInstallerMap.exe tool plus a Public Desktop shortcut.
# Runs for every shopfloor PC type (Standard, CMM, Keyence, Display,
# etc.) so a tech can launch the printer installer at any bay without
# going through Edge. Numbered 09 to run after 06-OrganizeDesktop
# (which creates Shopfloor Tools folder) and 08-EdgeDefaultBrowser.
#
# Why this exists: the legacy printer-deploy flow was a web map
# (printerinstallermap.asp) that handed users a per-selection .bat to
# download and run. New Edge policy blocks both .bat downloads and
# unsigned .exe runs. PrinterInstallerMap is a forked installer with the
# map UX baked into a custom Inno wizard page; baking the EXE onto the
# image at PXE time means it never goes through a browser (no MOTW, no
# SmartScreen prompt). UAC still fires because PrivilegesRequired=admin
# and supportuser creds satisfy it - that is the only remaining gate.
#
# Layout this script produces on a Standard PC:
# C:\Tools\PrinterInstallerMap\PrinterInstallerMap.exe
# C:\Users\Public\Desktop\Shopfloor Tools\Printer Installer.lnk
#
# The Shopfloor Tools desktop folder is also created/maintained by
# 06-OrganizeDesktop.ps1; this script tolerates either order (it creates
# the folder if absent so it can run before 06).
#
# Idempotent: re-imaging or re-running the script overwrites the EXE and
# rewrites the shortcut. Drop-in newer PrinterInstallerMap.exe alongside
# this script in the imaging payload, re-image the PC, the desktop link
# now points to the new build.
#
# Source EXE expected at $PSScriptRoot\PrinterInstallerMap.exe (placed
# next to this script when the imaging payload lands). Until the Inno
# compile + sign+ship step is wired in (see ../../docs/ + the inno repo
# feature/printer-map branch), this script no-ops with a warning if the
# EXE is missing - re-image will pick it up once the build artifact is
# dropped in.
$ErrorActionPreference = 'Continue'
$src = Join-Path $PSScriptRoot 'PrinterInstallerMap.exe'
$installDir = 'C:\Tools\PrinterInstallerMap'
$installExe = Join-Path $installDir 'PrinterInstallerMap.exe'
$publicDesktop = 'C:\Users\Public\Desktop'
$shopfloorTools = Join-Path $publicDesktop 'Shopfloor Tools'
$shortcutPath = Join-Path $shopfloorTools 'Printer Installer.lnk'
if (-not (Test-Path -LiteralPath $src)) {
Write-Warning "Install-PrinterInstallerMap: source EXE not found at $src - skipping. Re-image after the build artifact is dropped alongside this script."
exit 0
}
# Stage EXE
if (-not (Test-Path -LiteralPath $installDir)) {
New-Item -Path $installDir -ItemType Directory -Force | Out-Null
}
try {
Copy-Item -LiteralPath $src -Destination $installExe -Force -ErrorAction Stop
Write-Host "Install-PrinterInstallerMap: staged EXE -> $installExe"
} catch {
Write-Error "Install-PrinterInstallerMap: failed to copy EXE: $_"
exit 1
}
# Ensure Shopfloor Tools folder exists. 06-OrganizeDesktop.ps1 also
# creates this; we create it here so this script does not depend on
# 06 having run first.
if (-not (Test-Path -LiteralPath $shopfloorTools)) {
try {
New-Item -Path $shopfloorTools -ItemType Directory -Force | Out-Null
} catch {
Write-Warning "Install-PrinterInstallerMap: could not create $shopfloorTools : $_"
}
}
# Create / overwrite shortcut
try {
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut($shortcutPath)
$lnk.TargetPath = $installExe
$lnk.IconLocation = "$installExe,0"
$lnk.WorkingDirectory = $installDir
$lnk.Description = 'Install network printers from the WJ site map'
$lnk.Save()
Write-Host "Install-PrinterInstallerMap: shortcut -> $shortcutPath"
} catch {
Write-Error "Install-PrinterInstallerMap: failed to create shortcut: $_"
exit 1
}
exit 0

View File

@@ -0,0 +1,115 @@
# Apply-MachineNumber.ps1 - SYSTEM-context worker for the two-task machine
# number flow. Triggered on-demand by Prompt-MachineNumber.ps1 via
# `schtasks /run /tn "WT-Apply-MachineNumber"`. Reads the requested number
# from a file the GUI script wrote, invokes Update-MachineNumber as SYSTEM
# (full HKLM + ProgramData access), writes a result JSON for the GUI to
# display, then cleans up.
#
# Why SYSTEM:
# The eDNC reg key (HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\
# General\MachineNo) and UDC settings JSON live in HKLM + ProgramData
# respectively - both require admin to write. The OLD design granted
# BUILTIN\Users SetValue + Modify via 02-MachineNumberACLs.ps1, but that
# was fragile (timing race with eDNC install, ACL silently failed on
# some bays) AND a security hole (any user could mess with the machine
# identity). Two-task design: GUI gathers input as logged-in user, SYSTEM
# does the actual write.
#
# Files:
# C:\Logs\SFLD\machine-number-request.txt - input, single line, new number
# C:\Logs\SFLD\machine-number-result.json - output, status fields for GUI
# C:\Logs\SFLD\Apply-MachineNumber.log - transcript
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path -LiteralPath $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcript = Join-Path $logDir 'Apply-MachineNumber.log'
try { Start-Transcript -Path $transcript -Append -Force | Out-Null } catch {}
$requestFile = Join-Path $logDir 'machine-number-request.txt'
$resultFile = Join-Path $logDir 'machine-number-result.json'
function Write-Result {
param([hashtable]$Body)
$Body | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $resultFile -Encoding ascii -Force
}
Write-Host "Apply-MachineNumber.ps1 starting $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
try {
if (-not (Test-Path -LiteralPath $requestFile)) {
Write-Warning "No request file at $requestFile - nothing to apply."
Write-Result @{ Status = 'NoRequest'; Errors = @("request file missing: $requestFile") }
exit 0
}
$newNumber = (Get-Content -LiteralPath $requestFile -First 1 -ErrorAction Stop).Trim()
Write-Host "Requested new machine number: $newNumber"
if ($newNumber -notmatch '^\d+$') {
Write-Warning "Request is not digits-only: '$newNumber'"
Write-Result @{ Status = 'BadInput'; Requested = $newNumber; Errors = @("Not digits only: '$newNumber'") }
Remove-Item -LiteralPath $requestFile -Force -ErrorAction SilentlyContinue
exit 1
}
# Dot-source the shared helper. Update-MachineNumber.ps1 now has
# -ErrorAction Stop on the writes so failures actually throw.
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
. "$PSScriptRoot\lib\Update-MachineNumber.ps1"
$site = if ($siteConfig) { $siteConfig.siteName } else { 'West Jefferson' }
$mnResult = Update-MachineNumber -NewNumber $newNumber -Site $site
$resultBody = @{
Status = if ($mnResult.Errors.Count -eq 0) { 'OK' } else { 'PartialErrors' }
Requested = $newNumber
Site = $site
UdcUpdated = [bool]$mnResult.UdcUpdated
EdncUpdated = [bool]$mnResult.EdncUpdated
OldUdc = $mnResult.OldUdc
OldEdnc = $mnResult.OldEdnc
UdcSettingsRestored = [bool]$mnResult.UdcSettingsRestored
UdcRestored = [bool]$mnResult.UdcRestored
MTConnectUpdated = $mnResult.MTConnectUpdated
MachineNumberTxtUpdated = [bool]$mnResult.MachineNumberTxtUpdated
Errors = $mnResult.Errors
AppliedAt = (Get-Date -Format 'o')
AppliedAs = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
}
Write-Result -Body $resultBody
Write-Host "Update-MachineNumber result:"
Write-Host " UdcUpdated = $($mnResult.UdcUpdated)"
Write-Host " EdncUpdated = $($mnResult.EdncUpdated)"
Write-Host " Errors = $($mnResult.Errors.Count)"
if ($mnResult.Errors) { $mnResult.Errors | ForEach-Object { Write-Host " FAILED: $_" } }
Remove-Item -LiteralPath $requestFile -Force -ErrorAction SilentlyContinue
# On clean success, also unregister the Prompt logon task. Prompt itself
# tries to self-unregister but it runs as a Limited user (BUILTIN\Users)
# and silently fails on Unregister-ScheduledTask (no delete right on a
# SYSTEM-registered task). We're running as SYSTEM here, so we can.
# Idempotent if Prompt already unregistered itself somehow.
if ($mnResult.Errors.Count -eq 0 -and $mnResult.EdncUpdated) {
try {
if (Get-ScheduledTask -TaskName 'Prompt Machine Number' -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName 'Prompt Machine Number' -Confirm:$false -ErrorAction Stop
Write-Host "Unregistered 'Prompt Machine Number' task (SYSTEM cleanup)."
}
} catch {
Write-Host "Could not unregister 'Prompt Machine Number': $_"
}
}
Write-Host "Apply-MachineNumber.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
} catch {
Write-Warning "Apply threw: $_"
Write-Result @{ Status = 'Exception'; Errors = @("$_") }
} finally {
try { Stop-Transcript | Out-Null } catch {}
}

View File

@@ -0,0 +1,129 @@
# Check-MachineNumber.ps1 - Logon-triggered check for placeholder machine
# number. If UDC or eDNC are still at 9999, pops an InputBox for the user
# to enter the real number. On success, unregisters the scheduled task so
# the prompt never appears again.
#
# Runs as the LOGGED-IN USER (not SYSTEM) because it needs to show GUI.
# Writing to ProgramData + HKLM is possible because 02-MachineNumberACLs.ps1
# pre-granted BUILTIN\Users write access on those specific targets during
# imaging.
#
# Registered/unregistered by Configure-PC.ps1 (item 6 in the toggle list).
# --- Transcript logging ---
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcriptPath = Join-Path $logDir 'Check-MachineNumber.log'
try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {}
Write-Host "Check-MachineNumber.ps1 starting $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
. "$PSScriptRoot\lib\Update-MachineNumber.ps1"
Add-Type -AssemblyName Microsoft.VisualBasic
Add-Type -AssemblyName System.Windows.Forms
$taskName = 'Check Machine Number'
$site = if ($siteConfig) { $siteConfig.siteName } else { 'West Jefferson' }
# --- Read current values ---
$currentMN = Get-CurrentMachineNumber
$currentUdc = $currentMN.Udc
$currentEdnc = $currentMN.Ednc
# --- Check if placeholder ---
Write-Host "UDC machine number: $(if ($currentUdc) { $currentUdc } else { '(not found)' })"
Write-Host "eDNC machine number: $(if ($currentEdnc) { $currentEdnc } else { '(not found)' })"
if ($currentUdc -ne '9999' -and $currentEdnc -ne '9999') {
Write-Host "Machine number is set (not 9999). Unregistering task and exiting."
try {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
} catch {}
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
Write-Host "Placeholder 9999 detected - showing prompt."
# --- Show prompt ---
$promptLines = @()
$promptLines += "The machine number on this PC is still set to the"
$promptLines += "placeholder value (9999). Please enter the correct"
$promptLines += "machine number for this workstation."
$promptLines += ""
if ($currentUdc) { $promptLines += "Current UDC: $currentUdc" }
if ($currentEdnc) { $promptLines += "Current eDNC: $currentEdnc" }
$promptLines += ""
$promptLines += "Enter the new Machine Number:"
$prompt = $promptLines -join "`n"
$new = [Microsoft.VisualBasic.Interaction]::InputBox($prompt, "Set Machine Number", "")
if ([string]::IsNullOrWhiteSpace($new)) {
Write-Host "User cancelled. Will prompt again next logon."
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
$new = $new.Trim()
# --- Validate ---
if ($new -notmatch '^\d+$') {
Write-Host "Invalid input: '$new' (not digits only). Will prompt again next logon."
[System.Windows.Forms.MessageBox]::Show(
"Machine number must be digits only.`n`nYou entered: '$new'`n`nThe prompt will appear again at next logon.",
"Invalid Machine Number",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
) | Out-Null
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
# --- Update via shared helper ---
$mnResult = Update-MachineNumber -NewNumber $new -Site $site
$results = @()
if ($mnResult.UdcUpdated) { $results += "UDC updated to $new" }
if ($mnResult.EdncUpdated) { $results += "eDNC updated to $new" }
foreach ($err in $mnResult.Errors) { $results += $err -replace '^', 'FAILED: ' }
# --- Show result ---
$summary = ($results -join "`n") + "`n`nTo apply eDNC changes, restart any running DncMain.exe.`n`nFull log: C:\Logs\SFLD\Check-MachineNumber.log"
# Force the MessageBox to topmost + take focus so it isn't hidden behind
# other windows. Without this, the result dialog can render off-screen or
# behind the FormTracePak / DNC windows and the tech misses it.
$tmpForm = New-Object System.Windows.Forms.Form
$tmpForm.TopMost = $true
$tmpForm.WindowState = 'Minimized'
$tmpForm.ShowInTaskbar = $false
$tmpForm.Opacity = 0
$tmpForm.Show()
[System.Windows.Forms.MessageBox]::Show(
$tmpForm,
$summary,
"Machine Number Updated",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Information
) | Out-Null
$tmpForm.Close()
# --- Unregister task on success ---
Write-Host "Results: $($results -join '; ')"
$anyFail = $results | Where-Object { $_ -match 'FAILED' }
if (-not $anyFail) {
Write-Host "All updates succeeded. Unregistering logon task."
try {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
} catch {}
} else {
Write-Host "Some updates failed. Task stays registered - will prompt again next logon."
}
Write-Host "Check-MachineNumber.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -0,0 +1,23 @@
@echo off
REM Configure-PC.bat - Launches Configure-PC.ps1 in an elevated PowerShell.
REM SupportUser desktop tool for setting machine number + auto-startup items.
title Configure PC
set "SCRIPT=%~dp0Configure-PC.ps1"
if exist "%SCRIPT%" goto :found
set "SCRIPT=C:\Users\SupportUser\Desktop\Configure-PC.ps1"
if exist "%SCRIPT%" goto :found
set "SCRIPT=C:\Enrollment\shopfloor-setup\Shopfloor\Configure-PC.ps1"
if exist "%SCRIPT%" goto :found
echo ERROR: Configure-PC.ps1 not found
pause
exit /b 1
:found
echo Launching: %SCRIPT%
powershell -NoProfile -Command "Start-Process powershell.exe -Verb RunAs -ArgumentList '-NoProfile','-NoExit','-ExecutionPolicy','Bypass','-File','%SCRIPT%'"
exit /b

View File

@@ -0,0 +1,518 @@
# Configure-PC.ps1 - Interactive tool for SupportUser to configure a
# shopfloor PC after imaging. Two sections:
#
# 1. Machine number - if UDC/eDNC are still at placeholder 9999, prompt
# to set the real number right now.
#
# 2. Auto-startup items + machine-number logon prompt - toggle which apps
# start automatically for all users, and optionally register a logon
# task that prompts STANDARD users for the machine number if it's
# still 9999 when they log in (for the case where SupportUser skips
# it here and the end user needs to set it themselves).
#
# Startup .lnk files go in the AllUsers Startup folder:
# C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\
#
# Run via Configure-PC.bat on the SupportUser desktop.
param(
# When set, only show the machine number section (skip startup items).
# Used by sync_intune -AsTask after completion -- startup items are
# already auto-applied by 06-OrganizeDesktop from the PC profile.
[switch]$MachineNumberOnly
)
$ErrorActionPreference = 'Continue'
# --- Transcript logging ---
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcriptPath = Join-Path $logDir 'Configure-PC.log'
try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {}
Write-Host "Transcript: $transcriptPath"
Write-Host "Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
# Load site config + PC profile (resolves pc-type.txt + pc-subtype.txt
# into a profile from site-config.json's pcProfiles section)
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
. "$PSScriptRoot\lib\Update-MachineNumber.ps1"
$startupDir = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup'
$publicDesktop = 'C:\Users\Public\Desktop'
$overridesPath = 'C:\ProgramData\GE\Shopfloor\startup-overrides.json'
# Persist user-toggled startup overrides so the logon-triggered sweep
# (06-OrganizeDesktop.ps1 Phase 3) doesn't re-create .lnks the tech
# explicitly removed. Labels in $suppressed are the ones currently OFF.
function Get-SuppressedStartup {
if (-not (Test-Path -LiteralPath $overridesPath)) { return @() }
try {
$data = Get-Content -LiteralPath $overridesPath -Raw | ConvertFrom-Json
if ($data.suppressed) { return @($data.suppressed) }
} catch {
Write-Warning " Failed to parse $overridesPath : $_"
}
return @()
}
function Set-SuppressedStartup {
param([string[]]$Labels)
$dir = Split-Path -Parent $overridesPath
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$payload = @{ suppressed = @($Labels | Sort-Object -Unique) }
$payload | ConvertTo-Json | Set-Content -LiteralPath $overridesPath -Encoding UTF8
}
# ============================================================================
# Helpers
# ============================================================================
function Get-UrlFromFile {
param([string]$BaseName)
$candidates = @(
(Join-Path $publicDesktop "$BaseName.url"),
(Join-Path (Join-Path $publicDesktop 'Web Links') "$BaseName.url")
)
foreach ($c in $candidates) {
if (-not (Test-Path -LiteralPath $c)) { continue }
try {
$content = Get-Content -LiteralPath $c -ErrorAction Stop
$urlLine = $content | Where-Object { $_ -match '^URL=' } | Select-Object -First 1
if ($urlLine) { return ($urlLine -replace '^URL=', '').Trim() }
} catch {}
}
return $null
}
function New-StartupLnk {
param(
[string]$Name,
[string]$Target,
[string]$Arguments = '',
[string]$WorkingDirectory = ''
)
$path = Join-Path $startupDir "$Name.lnk"
$wsh = New-Object -ComObject WScript.Shell
try {
$sc = $wsh.CreateShortcut($path)
$sc.TargetPath = $Target
if ($Arguments) { $sc.Arguments = $Arguments }
if ($WorkingDirectory) { $sc.WorkingDirectory = $WorkingDirectory }
elseif ($Target) {
$parent = Split-Path -Parent $Target
if ($parent) { $sc.WorkingDirectory = $parent }
}
$sc.Save()
return $true
} catch {
Write-Warning "Failed to create startup shortcut '$Name': $_"
return $false
} finally {
try { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($wsh) | Out-Null } catch {}
}
}
# ============================================================================
# Machine number - read current state
# ============================================================================
$currentMN = Get-CurrentMachineNumber
$currentUdc = $currentMN.Udc
$currentEdnc = $currentMN.Ednc
$needsMachineNumber = ($currentUdc -eq '9999' -or $currentEdnc -eq '9999')
# ============================================================================
# Edge path (for URL-based startup items)
# ============================================================================
$edgePath = @(
'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files\Microsoft\Edge\Application\msedge.exe'
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
# ============================================================================
# Startup item definitions
# Resolved from: pcProfile.startupItems > siteConfig.startupItems > hardcoded
# ============================================================================
$cfgItems = Get-ProfileValue 'startupItems'
# An EMPTY configured list means "no startup items" and must be honoured.
# Get-ProfileValue returns $null only when the key is absent from BOTH the
# profile and site-config, so $null is the real "not configured" signal.
#
# The old test also required .Count -gt 0, so "startupItems": [] fell through to
# the hardcoded list below and a Display kiosk was given UDC, eDNC, Defect
# Tracker, WJ Shopfloor and Plant Apps startup shortcuts - Plant Apps launching
# Edge in a new window on a kiosk. Confirmed on 579C144 2026-08-06:
#
# C:\ProgramData\...\StartUp\Defect Tracker.lnk 16:46:03
# C:\ProgramData\...\StartUp\Plant Apps.lnk 16:46:03
#
# Configuring "none" produced "everything". Same bug as desktopApps in
# 06-OrganizeDesktop.ps1 and taskbarPins in 07-TaskbarLayout.ps1.
if ($null -ne $cfgItems) {
$items = @()
$num = 0
foreach ($si in $cfgItems) {
$num++
switch ($si.type) {
'exe' {
$target = $si.target
$items += @{
Num = $num
Label = $si.label
Detail = (Split-Path -Leaf $target)
Available = (Test-Path $target)
CreateLnk = [scriptblock]::Create("return (New-StartupLnk -Name '$($si.label)' -Target '$target')")
}
}
'existing' {
$srcLnk = $si.sourceLnk
$label = $si.label
$items += @{
Num = $num
Label = $label
Detail = 'shortcut'
Available = $true
CreateLnk = [scriptblock]::Create(@"
`$src = @(
(Join-Path `$publicDesktop '$srcLnk'),
(Join-Path (Join-Path `$publicDesktop 'Shopfloor Tools') '$srcLnk')
) | Where-Object { Test-Path -LiteralPath `$_ } | Select-Object -First 1
if (`$src) {
`$dst = Join-Path `$startupDir '$label.lnk'
try { Copy-Item -LiteralPath `$src -Destination `$dst -Force; return `$true }
catch { Write-Warning "Failed to copy $label`: `$_"; return `$false }
} else {
Write-Warning "$srcLnk not found on desktop"
return `$false
}
"@)
}
}
'url' {
$urlKey = $si.urlKey
$label = $si.label
$fallback = if ($urlKey -and $siteConfig.urls) { $siteConfig.urls.$urlKey } else { '' }
$items += @{
Num = $num
Label = $label
Detail = 'opens in Edge'
Available = [bool]$edgePath
CreateLnk = [scriptblock]::Create(@"
`$fallback = '$fallback'
`$url = Get-UrlFromFile '$label'
if (-not `$url) {
Write-Host " $label .url not found on desktop, using known URL."
`$url = `$fallback
}
Write-Host " URL: `$url"
return (New-StartupLnk -Name '$label' -Target `$edgePath -Arguments "--new-window ```"`$url```"")
"@)
}
}
}
}
} else {
$items = @(
@{
Num = 1
Label = 'UDC'
Detail = 'UDC.exe'
Available = (Test-Path 'C:\Program Files\UDC\UDC.exe')
CreateLnk = {
return (New-StartupLnk -Name 'UDC' -Target 'C:\Program Files\UDC\UDC.exe')
}
}
@{
Num = 2
Label = 'eDNC'
Detail = 'DncMain.exe'
Available = (Test-Path 'C:\Program Files (x86)\Dnc\bin\DncMain.exe')
CreateLnk = {
return (New-StartupLnk -Name 'eDNC' -Target 'C:\Program Files (x86)\Dnc\bin\DncMain.exe')
}
}
@{
Num = 3
Label = 'Defect Tracker'
Detail = 'ClickOnce app'
Available = $true
CreateLnk = {
$src = @(
(Join-Path $publicDesktop 'Defect_Tracker.lnk'),
(Join-Path (Join-Path $publicDesktop 'Shopfloor Tools') 'Defect_Tracker.lnk')
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if ($src) {
$dst = Join-Path $startupDir 'Defect Tracker.lnk'
try { Copy-Item -LiteralPath $src -Destination $dst -Force; return $true }
catch { Write-Warning "Failed to copy Defect Tracker: $_"; return $false }
} else {
Write-Warning "Defect_Tracker.lnk not found on desktop"
return $false
}
}
}
@{
Num = 4
Label = 'WJ Shopfloor'
Detail = 'HostExplorer session'
Available = $true
CreateLnk = {
$src = @(
(Join-Path $publicDesktop 'WJ Shopfloor.lnk'),
(Join-Path (Join-Path $publicDesktop 'Shopfloor Tools') 'WJ Shopfloor.lnk')
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if ($src) {
$dst = Join-Path $startupDir 'WJ Shopfloor.lnk'
try { Copy-Item -LiteralPath $src -Destination $dst -Force; return $true }
catch { Write-Warning "Failed to copy WJ Shopfloor: $_"; return $false }
} else {
Write-Warning "WJ Shopfloor.lnk not found on desktop"
return $false
}
}
}
@{
Num = 5
Label = 'Plant Apps'
Detail = 'opens in Edge'
Available = [bool]$edgePath
CreateLnk = {
$fallback = 'https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications'
$url = Get-UrlFromFile 'Plant Apps'
if (-not $url) {
Write-Host " Plant Apps .url not found on desktop, using known URL."
$url = $fallback
}
Write-Host " URL: $url"
return (New-StartupLnk -Name 'Plant Apps' -Target $edgePath -Arguments "--new-window `"$url`"")
}
}
)
}
# Machine-number logon tasks (item 6 toggle controls both)
# 2026-05-24: split into user-context Prompt + SYSTEM-context Apply.
# 'Check Machine Number' is the legacy single-task name kept for
# backward-detection on bays imaged before the split.
$machineNumPromptTask = 'Prompt Machine Number'
$machineNumApplyTask = 'Apply Machine Number'
$machineNumLegacyTask = 'Check Machine Number'
# ============================================================================
# Interactive UI
# ============================================================================
Clear-Host
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Configure PC" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# --- Section 1: Machine number ---
Write-Host ""
Write-Host " MACHINE NUMBER" -ForegroundColor Yellow
Write-Host " ----------------------------------------"
if ($needsMachineNumber) {
if ($currentUdc) { Write-Host " UDC : $currentUdc" -ForegroundColor Red }
if ($currentEdnc) { Write-Host " eDNC : $currentEdnc" -ForegroundColor Red }
Write-Host ""
$newNum = Read-Host " Enter new machine number (digits only, Enter to skip)"
$newNum = $newNum.Trim()
if ($newNum -and $newNum -match '^\d+$') {
$site = if ($siteConfig) { $siteConfig.siteName } else { 'West Jefferson' }
$mnResult = Update-MachineNumber -NewNumber $newNum -Site $site
if ($mnResult.UdcUpdated) {
Write-Host " UDC : $currentUdc -> $newNum" -ForegroundColor Green
$currentUdc = $newNum
}
if ($mnResult.EdncUpdated) {
Write-Host " eDNC : $currentEdnc -> $newNum" -ForegroundColor Green
$currentEdnc = $newNum
}
foreach ($err in $mnResult.Errors) { Write-Warning " $err" }
if ($mnResult.UdcUpdated) { Write-Host " UDC.exe relaunched." }
$needsMachineNumber = ($currentUdc -eq '9999' -or $currentEdnc -eq '9999')
} elseif ($newNum) {
Write-Host " Invalid (digits only). Skipped." -ForegroundColor Yellow
} else {
Write-Host " Skipped (still 9999)." -ForegroundColor DarkGray
}
} else {
if ($currentUdc) { Write-Host " UDC : $currentUdc" -ForegroundColor Green }
if ($currentEdnc) { Write-Host " eDNC : $currentEdnc" -ForegroundColor Green }
}
# --- Section 2: Startup items + machine-number logon task ---
# Skipped when -MachineNumberOnly (called from sync_intune -AsTask after
# completion -- startup items already auto-applied by 06-OrganizeDesktop).
if (-not $MachineNumberOnly) {
Write-Host ""
Write-Host " AUTO-STARTUP ITEMS" -ForegroundColor Yellow
Write-Host " ----------------------------------------"
Write-Host " Toggle which items start at user logon:"
Write-Host ""
$existingStartup = @(Get-ChildItem -LiteralPath $startupDir -Filter '*.lnk' -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty BaseName)
foreach ($item in $items) {
$on = if ($existingStartup -contains $item.Label) { '[ON]' } else { '[ ]' }
$avail = if ($item.Available) { '' } else { ' (not installed)' }
Write-Host " $($item.Num). $on $($item.Label) - $($item.Detail)$avail"
}
# Item 6: machine number logon prompt. "ON" if EITHER the new Prompt task OR
# the legacy Check Machine Number task is registered.
$machineNumTaskExists = [bool](
(Get-ScheduledTask -TaskName $machineNumPromptTask -ErrorAction SilentlyContinue) -or
(Get-ScheduledTask -TaskName $machineNumLegacyTask -ErrorAction SilentlyContinue)
)
$mnOn = if ($machineNumTaskExists) { '[ON]' } else { '[ ]' }
Write-Host " 6. $mnOn Prompt standard user for machine number if 9999"
Write-Host ""
Write-Host " Enter numbers to toggle (e.g. 1,2,6), Enter to keep current:"
$selection = Read-Host " Selection"
$selection = $selection.Trim()
if ($selection) {
$selected = $selection -split '[,\s]+' | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }
# Process startup items 1-5
$suppressed = @(Get-SuppressedStartup)
$suppressedChanged = $false
foreach ($item in $items) {
if ($selected -notcontains $item.Num) { continue }
$existingLnk = Join-Path $startupDir "$($item.Label).lnk"
if (Test-Path -LiteralPath $existingLnk) {
# Toggling OFF: remove the .lnk AND record the suppression so
# the logon sweep in 06-OrganizeDesktop doesn't re-create it.
try {
Remove-Item -LiteralPath $existingLnk -Force
Write-Host " $($item.Label): REMOVED from startup" -ForegroundColor Yellow
} catch { Write-Warning " Failed to remove $($item.Label): $_" }
if ($suppressed -notcontains $item.Label) {
$suppressed += $item.Label
$suppressedChanged = $true
}
} else {
# Toggling ON: drop any suppression first so the sweep won't
# fight us next logon, then create the .lnk.
if ($suppressed -contains $item.Label) {
$suppressed = @($suppressed | Where-Object { $_ -ne $item.Label })
$suppressedChanged = $true
}
if (-not $item.Available) {
Write-Host " $($item.Label): not installed, cannot add to startup" -ForegroundColor DarkGray
continue
}
$result = & $item.CreateLnk
if ($result) {
Write-Host " $($item.Label): ADDED to startup" -ForegroundColor Green
}
}
}
if ($suppressedChanged) {
try {
Set-SuppressedStartup -Labels $suppressed
Write-Host " (saved override state to $overridesPath)" -ForegroundColor DarkGray
} catch {
Write-Warning " Failed to write override state: $_"
}
}
# Process item 6: machine number logon task
if ($selected -contains 6) {
if ($machineNumTaskExists) {
# Toggle OFF - remove Prompt + Apply (new design) AND the legacy
# Check Machine Number task name (in case this bay was imaged
# before the split and never re-imaged).
$removed = @()
foreach ($t in @($machineNumPromptTask, $machineNumApplyTask, $machineNumLegacyTask)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
$removed += $t
}
} catch { Write-Warning " Failed to remove '$t': $_" }
}
if ($removed) {
Write-Host " Machine number logon prompt: REMOVED ($($removed -join ', '))" -ForegroundColor Yellow
}
$machineNumTaskExists = $false
} else {
# Toggle ON - register logon task
# Defer task registration to the shared registrar so this code
# path always matches the imaging-time path. Registrar installs
# BOTH the user-context "Prompt Machine Number" task and the
# SYSTEM-context "Apply Machine Number" task, sets the SDDL on
# Apply so Limited users can schtasks /run it, and cleans up
# any legacy "Check Machine Number" task name.
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$registrar = Join-Path $scriptDir 'Register-CheckMachineNumberTask.ps1'
if (-not (Test-Path -LiteralPath $registrar)) {
$registrar = 'C:\Enrollment\shopfloor-setup\Shopfloor\Register-CheckMachineNumberTask.ps1'
}
if (Test-Path -LiteralPath $registrar) {
try {
& $registrar
Write-Host " Machine number logon prompt: ENABLED" -ForegroundColor Green
Write-Host " (Prompt user-task + Apply SYSTEM-task registered;" -ForegroundColor DarkGray
Write-Host " will auto-disable after machine number is set)" -ForegroundColor DarkGray
$machineNumTaskExists = $true
} catch {
Write-Warning " Register-CheckMachineNumberTask failed: $_"
}
} else {
Write-Warning " Register-CheckMachineNumberTask.ps1 not found at $registrar"
}
}
}
} else {
Write-Host " No changes." -ForegroundColor DarkGray
}
} # end if (-not $MachineNumberOnly)
# --- Summary ---
Write-Host ""
Write-Host " CURRENT STATE" -ForegroundColor Cyan
Write-Host " ----------------------------------------"
$final = @(Get-ChildItem -LiteralPath $startupDir -Filter '*.lnk' -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty BaseName)
Write-Host " Startup items:"
if ($final.Count -eq 0) {
Write-Host " (none)"
} else {
foreach ($f in $final) { Write-Host " - $f" }
}
$mnState = if ($machineNumTaskExists) { 'ON (prompts at logon if 9999)' } else { 'OFF' }
Write-Host " Machine number logon prompt: $mnState"
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Done" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Completed: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
try { Stop-Transcript | Out-Null } catch {}
Write-Host "Press any key to close..."
try { [void][Console]::ReadKey($true) } catch { [void](Read-Host) }

View File

@@ -70,10 +70,6 @@ if "%RC%"=="0" (
if not exist "C:\Enrollment" mkdir "C:\Enrollment" if not exist "C:\Enrollment" mkdir "C:\Enrollment"
> "C:\Enrollment\force-lockdown-applied.txt" echo %DATE% %TIME% > "C:\Enrollment\force-lockdown-applied.txt" echo %DATE% %TIME%
echo Marker written: C:\Enrollment\force-lockdown-applied.txt echo Marker written: C:\Enrollment\force-lockdown-applied.txt
REM Clear the imaging AutoLogonCount: Autologon leaves it in place and
REM ShopFloor autologon switches itself off when it counts down to 0.
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoLogonCount /f /reg:64 >nul 2>&1
echo Cleared leftover AutoLogonCount.
) )
echo. echo.

View File

@@ -1,364 +0,0 @@
# Install-AppList.ps1 - install the apps in a site app list (shopfloor).
#
# -ListPath C:\PreInstall\preinstall.json during setup (Run-ShopfloorSetup,
# same logon as the PPKG)
# C:\PreInstall\postppkg.json after the post-PPKG reboot
# (Invoke-PostPpkg.ps1, SYSTEM)
# -Phase name for the log folder: C:\Logs\<Phase>\install.log
#
# Both lists are EMPTY in the generic build; a site adds its apps:
# { "Name": "Foo 1.2", "Installer": "foo\\Foo.msi", "Type": "MSI" | "EXE",
# "InstallArgs": "/qn /norestart",
# "DetectionMethod": "Registry" | "File", "DetectionPath": "...",
# optional: "DetectionName", "DetectionValue", "LogFile",
# "KillAfterDetection": true, "PCTypes": ["gea-shopfloor-standard"] }
# Installers come from <list folder>\installers\ (staged from the PXE server's
# enrollment share, pre-install\installers\). Detection runs first, so an app
# that is already there is skipped and re-runs are safe.
#
# Never fails the caller: a missing or empty list, a bad JSON file or a failed
# install is logged and the script exits 0.
param(
[string]$ListPath = 'C:\PreInstall\preinstall.json',
[string]$Phase = 'PreInstall'
)
$installerDir = Join-Path (Split-Path -Parent $ListPath) 'installers'
$logDir = "C:\Logs\$Phase"
$logFile = Join-Path $logDir 'install.log'
# Appended, never wiped: an installer-triggered reboot can kill a run, and the
# next run must not destroy that record.
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
function Write-ListLog {
param(
[Parameter(Mandatory=$true, Position=0)]
[string]$Message,
[Parameter(Position=1)]
[ValidateSet('INFO','WARN','ERROR')]
[string]$Level = 'INFO'
)
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$line = "[$stamp] [$Level] $Message"
Write-Host $line
# Write-through, so each line is on disk even if a reboot follows.
try {
$fs = New-Object System.IO.FileStream(
$logFile,
[System.IO.FileMode]::Append,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::Read,
4096,
[System.IO.FileOptions]::WriteThrough
)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($line + "`r`n")
$fs.Write($bytes, 0, $bytes.Length)
$fs.Flush()
$fs.Dispose()
}
catch {
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
}
}
Write-ListLog "================================================================"
Write-ListLog "=== $Phase session start (PID $PID): $ListPath ==="
Write-ListLog "================================================================"
if (-not (Test-Path -LiteralPath $ListPath)) {
Write-ListLog "No app list at $ListPath - nothing to install."
exit 0
}
$pcType = ''
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
if (Test-Path $pcTypeFile) {
$pcType = "$(Get-Content $pcTypeFile -First 1 -ErrorAction SilentlyContinue)".Trim()
}
Write-ListLog "PC type: $(if ($pcType) { $pcType } else { '(unknown)' })"
try {
$config = Get-Content -LiteralPath $ListPath -Raw | ConvertFrom-Json
} catch {
Write-ListLog "Failed to parse $ListPath : $_" "ERROR"
exit 0
}
$apps = @($config.Applications | Where-Object { $_ })
if ($apps.Count -eq 0) {
Write-ListLog "The list has no applications - nothing to install."
exit 0
}
Write-ListLog "Installer dir: $installerDir"
Write-ListLog "$($apps.Count) app entries"
# --- Detection helper ---
function Test-AppInstalled {
param($App)
if (-not $App.DetectionMethod) {
return $false
}
try {
switch ($App.DetectionMethod) {
"Registry" {
if (-not (Test-Path $App.DetectionPath)) {
return $false
}
if ($App.DetectionName) {
$value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
if (-not $value) {
return $false
}
if ($App.DetectionValue) {
return ($value.$($App.DetectionName) -eq $App.DetectionValue)
}
return $true
}
return $true
}
"File" {
return Test-Path $App.DetectionPath
}
default {
Write-ListLog " Unknown detection method: $($App.DetectionMethod)" "WARN"
return $false
}
}
} catch {
Write-ListLog " Detection check threw: $_" "WARN"
return $false
}
}
# --- Iterate apps ---
$installed = 0
$skipped = 0
$failed = 0
foreach ($app in $apps) {
# Cancel any reboot a previous installer scheduled (some respect /norestart, some
# don't - VC++ 2008's bootstrapper sometimes triggers an immediate Windows reboot
# despite the flag). Doing this BEFORE each install protects the rest of the loop.
cmd /c "shutdown /a 2>nul" *>$null
Write-ListLog "==> $($app.Name)"
# PCTypes: absent, '*' or the PC's type = install. (One generic type,
# gea-shopfloor-standard; the filter is there for sites that add types.)
$allowedTypes = @($app.PCTypes | Where-Object { $_ })
if ($allowedTypes.Count -gt 0 -and -not ($allowedTypes -contains '*') -and -not ($allowedTypes -icontains $pcType)) {
Write-ListLog " PCTypes excludes '$pcType' (allowed: $($allowedTypes -join ', ')) - skipping"
$skipped++
continue
}
# Detection check
if (Test-AppInstalled -App $app) {
Write-ListLog " Already installed - skipping"
$skipped++
continue
}
# Locate installer file
$installerPath = Join-Path $installerDir $app.Installer
if (-not (Test-Path $installerPath)) {
Write-ListLog " Installer file not found: $installerPath" "ERROR"
$failed++
continue
}
Write-ListLog " Installing from $installerPath"
if ($app.InstallArgs) {
Write-ListLog " InstallArgs: $($app.InstallArgs)"
}
# Per-app verbose msiexec log path - written by /L*v on MSI installs and tailed
# into the runner log on failure so we don't need to dig through C:\Logs manually.
$safeName = $app.Name -replace '[^a-zA-Z0-9]','_'
$msiLog = Join-Path $logDir "msi-$safeName.log"
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
try {
# We use [System.Diagnostics.Process]::Start() directly instead of Start-Process
# because PowerShell 5.1's Start-Process -PassThru has a bug where the returned
# Process object's OS handle is disposed when control returns to the script,
# causing ExitCode to always read as $null even after WaitForExit() succeeds.
# Calling Process.Start() directly returns a Process object with a live handle
# that exposes ExitCode correctly. We poll HasExited so UDC_Setup.exe (which
# hangs forever after spawning a hidden WPF window) can be killed via the
# detection-during-install path.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
if ($app.Type -eq "MSI") {
$psi.FileName = "msiexec.exe"
$psi.Arguments = "/i `"$installerPath`""
if ($app.InstallArgs) {
$psi.Arguments += " " + $app.InstallArgs
}
$psi.Arguments += " /L*v `"$msiLog`""
Write-ListLog " msiexec verbose log: $msiLog"
}
elseif ($app.Type -eq "EXE") {
$psi.FileName = $installerPath
if ($app.InstallArgs) {
$psi.Arguments = $app.InstallArgs
}
# If the JSON entry specifies a LogFile (per-installer log written by
# the EXE itself - e.g. Setup-OpenText.ps1 -> C:\Logs\PreInstall\Setup-
# OpenText.log), surface it on failure the same way we surface the
# msiexec /L*v verbose log for MSI installs. Lets EXE wrappers actually
# report what went wrong inside.
if ($app.LogFile) {
Write-ListLog " Installer log: $($app.LogFile)"
}
}
else {
Write-ListLog " Unsupported Type: $($app.Type) - skipping" "ERROR"
$failed++
continue
}
$proc = [System.Diagnostics.Process]::Start($psi)
# 1618 = another MSI install is running (after the PPKG its own
# installs can still be going). Wait and retry, up to 5 times.
for ($try = 1; $app.Type -eq "MSI" -and $try -le 5; $try++) {
$proc.WaitForExit(15000) | Out-Null
if (-not $proc.HasExited -or $proc.ExitCode -ne 1618) { break }
Write-ListLog " Another installation is in progress (1618) - retry $try of 5 in 60 s" "WARN"
Start-Sleep -Seconds 60
$proc = [System.Diagnostics.Process]::Start($psi)
}
# Poll for completion: process exit OR detection success (whichever happens first)
$timeoutSec = 600 # 10 min hard cap per app
$pollInterval = 5
$elapsed = 0
$killedAfterDetect = $false
while ($elapsed -lt $timeoutSec) {
if ($proc.HasExited) { break }
# Detection-during-install kill is OPT-IN via "KillAfterDetection: true"
# in the JSON entry. It's only safe for installers that hang forever after
# creating their detection markers (UDC_Setup.exe spawns a hidden WPF window
# and never exits). For normal installers (Oracle, msiexec, etc.) the
# detection registry key often gets written midway through the install AND
# msiexec is still doing post-install cleanup AND msiserver is still holding
# the install mutex - killing msiexec at that point leaves the system in a
# bad state and the NEXT msiexec call returns 1618 ERROR_INSTALL_ALREADY_
# RUNNING. So opt-out by default; only kill apps that explicitly need it.
if ($app.KillAfterDetection -and (Test-AppInstalled -App $app)) {
Write-ListLog " Detection passed at $elapsed s - killing installer to advance (KillAfterDetection)"
try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch { }
$killedAfterDetect = $true
break
}
Start-Sleep -Seconds $pollInterval
$elapsed += $pollInterval
}
if (-not $proc.HasExited -and -not $killedAfterDetect) {
Write-ListLog " TIMEOUT after $timeoutSec seconds - killing installer" "ERROR"
try { $proc.Kill() } catch { }
$failed++
continue
}
if ($killedAfterDetect) {
Write-ListLog " SUCCESS (verified via detection during install)"
$installed++
}
else {
# Start-Process -PassThru returns a Process object whose ExitCode is null
# until WaitForExit() is called - even if HasExited is already true. Without
# this, every install that goes through the exit-code branch gets reported
# as "Exit code - FAILED" (empty) regardless of the real result.
try { $proc.WaitForExit() } catch { }
$exitCode = $proc.ExitCode
if ($exitCode -eq 0 -or $exitCode -eq 3010) {
Write-ListLog " Exit code $exitCode after $elapsed s - SUCCESS"
if ($exitCode -eq 3010) {
Write-ListLog " (Reboot required for $($app.Name))"
}
$installed++
}
else {
Write-ListLog " Exit code $exitCode - FAILED" "ERROR"
# If the JSON entry specifies a LogFile for an EXE install (e.g.
# Setup-OpenText.ps1 writes its own log at C:\Logs\PreInstall\Setup-
# OpenText.log), tail it here so the runner log surfaces the actual
# cause without us having to dig through C:\Logs manually.
if ($app.Type -eq "EXE" -and $app.LogFile -and (Test-Path $app.LogFile)) {
Write-ListLog " --- last 30 lines of $($app.LogFile) ---"
Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object {
Write-ListLog " $_"
}
Write-ListLog " --- end installer log tail ---"
}
# Surface the *meaningful* lines from the verbose msiexec log (not just
# the tail, which is rollback/cleanup noise). The actual root-cause lines
# are: MSI error notes (Note: 1: <code>), failed action returns (return
# value 3), errors logged by custom actions, and any line containing the
# word "Error". Grep these out so the runner log shows the smoking gun.
if ($app.Type -eq "MSI" -and (Test-Path $msiLog)) {
Write-ListLog " --- meaningful lines from $msiLog ---"
$patterns = @(
'Note: 1: ',
'return value 3',
'Error \d+\.',
'CustomAction .* returned actual error',
'Failed to ',
'Installation failed',
'Calling custom action',
'1: 2262', # Database not found
'1: 2203', # Cannot open database
'1: 2330' # Insufficient permissions
)
$regex = ($patterns -join '|')
$matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue |
Select-Object -First 30
if ($matches) {
foreach ($m in $matches) {
Write-ListLog " $($m.Line.Trim())"
}
} else {
Write-ListLog " (no error patterns matched - falling back to last 25 lines)"
Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object {
Write-ListLog " $_"
}
}
Write-ListLog " --- end MSI log scan ---"
}
$failed++
}
}
} catch {
Write-ListLog " Install threw: $_" "ERROR"
$failed++
}
}
Write-ListLog "============================================"
Write-ListLog "$Phase complete: $installed installed, $skipped skipped, $failed failed"
Write-ListLog "============================================"
# Final reboot cancel - if the last installer in the loop scheduled one, the
# caller's later `shutdown /a` may not come in time. Cancel here so control
# returns cleanly to the caller.
cmd /c "shutdown /a 2>nul" *>$null
exit 0

View File

@@ -1,86 +0,0 @@
# Invoke-PostPpkg.ps1 - run by the "GE Shopfloor Post-PPKG" task (SYSTEM,
# at startup and logon). Three jobs, each done once:
#
# 1. Install the site's post-PPKG app list, C:\PreInstall\postppkg.json
# (Install-AppList.ps1; empty in the generic build). Only once the PPKG
# has been applied (C:\Enrollment\.ppkg-applied, written by
# run-enrollment.ps1), i.e. on the boot after the PPKG reboot.
# Done marker: C:\Enrollment\postppkg-done.txt
# 2. After the ShopFloor handover (Winlogon DefaultUserName ShopFloor*),
# delete the imaging AutoLogonCount. Left in place, ShopFloor inherits
# it and Windows turns autologon off when it reaches 0: the bay stops at
# a ShopFloor password prompt a few reboots later. The monitor tries the
# same, but it runs as Users and the HKLM write can be denied.
# 3. Once Monitor-IntuneProgress has written C:\Enrollment\sync-complete.txt,
# remove the "Shopfloor Intune Sync" logon task.
# When all three are done the task unregisters itself.
#
# Never throws: every step logs and the script exits 0.
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\SFLD'
New-Item -Path $logDir -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
try { Start-Transcript -Path (Join-Path $logDir 'postppkg.log') -Append -Force | Out-Null } catch {}
$appliedMarker = 'C:\Enrollment\.ppkg-applied'
$doneMarker = 'C:\Enrollment\postppkg-done.txt'
$syncComplete = 'C:\Enrollment\sync-complete.txt'
$listPath = 'C:\PreInstall\postppkg.json'
$taskName = 'GE Shopfloor Post-PPKG'
$syncTask = 'Shopfloor Intune Sync'
Write-Host "=== Invoke-PostPpkg $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
# 1. post-PPKG app list
if (Test-Path -LiteralPath $doneMarker) {
Write-Host "Post-PPKG list already done ($(Get-Content $doneMarker -First 1))."
} elseif (-not (Test-Path -LiteralPath $appliedMarker)) {
Write-Host "PPKG not applied yet ($appliedMarker missing) - waiting for the next boot."
} else {
$runner = Join-Path $PSScriptRoot 'Install-AppList.ps1'
if (Test-Path -LiteralPath $runner) {
try { & $runner -ListPath $listPath -Phase 'PostPpkg' } catch { Write-Warning "Install-AppList failed: $_" }
Set-Content -Path $doneMarker -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -ErrorAction SilentlyContinue
Write-Host "Post-PPKG list done (log: C:\Logs\PostPpkg\install.log)."
} else {
Write-Warning "Install-AppList.ps1 not found next to this script ($runner)."
}
}
# 2. AutoLogonCount after the ShopFloor handover
$handedOver = $false
$countGone = $true
try {
$wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
$wl = Get-ItemProperty $wlKey -ErrorAction Stop
$handedOver = ($wl.DefaultUserName -like 'ShopFloor*')
if ($handedOver -and $null -ne $wl.AutoLogonCount) {
Remove-ItemProperty $wlKey -Name AutoLogonCount -ErrorAction Stop
Write-Host "Removed leftover AutoLogonCount ($($wl.AutoLogonCount)) after the ShopFloor handover."
}
$countGone = ($null -eq (Get-ItemProperty $wlKey -ErrorAction Stop).AutoLogonCount)
} catch {
Write-Warning "AutoLogonCount check failed: $_"
$countGone = $false
}
# 3. the monitor's logon task, once it has finished
$syncDone = Test-Path -LiteralPath $syncComplete
if ($syncDone -and (Get-ScheduledTask -TaskName $syncTask -ErrorAction SilentlyContinue)) {
try {
Unregister-ScheduledTask -TaskName $syncTask -Confirm:$false -ErrorAction Stop
Write-Host "Removed the '$syncTask' task (sync-complete.txt present)."
} catch {
Write-Warning "Could not remove '$syncTask': $_"
}
}
if ((Test-Path -LiteralPath $doneMarker) -and $handedOver -and $countGone -and $syncDone) {
Write-Host "All done - unregistering '$taskName'."
try { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction Stop } catch { Write-Warning "Could not unregister: $_" }
} else {
Write-Host ("Still to do: list={0} handover={1} autologoncount_cleared={2} sync_complete={3}" -f `
(Test-Path -LiteralPath $doneMarker), $handedOver, $countGone, $syncDone)
}
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -0,0 +1,200 @@
# Prompt-MachineNumber.ps1 - User-context GUI script for the two-task
# machine number flow. Triggered AtLogOn for any BUILTIN\Users member.
#
# Flow:
# 1. Read current UDC + eDNC values (read-only - no privileges needed).
# 2. If neither is 9999, unregister self and exit (this PC is set up).
# 3. Show InputBox for new machine number.
# 4. Write number to C:\Logs\SFLD\machine-number-request.txt.
# 5. Trigger the SYSTEM-context Apply-MachineNumber task via
# schtasks /run. SYSTEM has full HKLM + ProgramData access so the
# actual write happens with proper privileges - the prompted user
# never needs HKLM write rights (security improvement over the old
# 02-MachineNumberACLs.ps1 ACL-grant hack).
# 6. Poll for C:\Logs\SFLD\machine-number-result.json (30s timeout).
# 7. Show result MessageBox. Unregister self on success.
#
# Why this script doesn't do the writes itself: GUI is required (InputBox),
# but GUI requires user-context (SYSTEM can't render to user desktop on
# modern Windows). The user-context dialog gathers input; the SYSTEM task
# does privileged writes.
# --- Transcript ---
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path -LiteralPath $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcript = Join-Path $logDir 'Prompt-MachineNumber.log'
try { Start-Transcript -Path $transcript -Append -Force | Out-Null } catch {}
Write-Host "Prompt-MachineNumber.ps1 starting $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
. "$PSScriptRoot\lib\Update-MachineNumber.ps1"
Add-Type -AssemblyName Microsoft.VisualBasic
Add-Type -AssemblyName System.Windows.Forms
$taskName = 'Prompt Machine Number'
$applyTaskName = 'Apply Machine Number'
$requestFile = Join-Path $logDir 'machine-number-request.txt'
$resultFile = Join-Path $logDir 'machine-number-result.json'
$site = if ($siteConfig) { $siteConfig.siteName } else { 'West Jefferson' }
# --- Read current values (read-only, no perms needed) ---
$currentMN = Get-CurrentMachineNumber
$currentUdc = $currentMN.Udc
$currentEdnc = $currentMN.Ednc
Write-Host "UDC machine number: $(if ($currentUdc) { $currentUdc } else { '(not found)' })"
Write-Host "eDNC machine number: $(if ($currentEdnc) { $currentEdnc } else { '(not found)' })"
if ($currentUdc -ne '9999' -and $currentEdnc -ne '9999') {
Write-Host "Machine number is set (not 9999). Unregistering Prompt task and exiting."
try { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
Write-Host "Placeholder 9999 detected - showing prompt."
# --- Show prompt ---
$promptLines = @()
$promptLines += "The machine number on this PC is still set to the"
$promptLines += "placeholder value (9999). Please enter the correct"
$promptLines += "machine number for this workstation."
$promptLines += ""
if ($currentUdc) { $promptLines += "Current UDC: $currentUdc" }
if ($currentEdnc) { $promptLines += "Current eDNC: $currentEdnc" }
$promptLines += ""
$promptLines += "Enter the new Machine Number:"
$prompt = $promptLines -join "`n"
$new = [Microsoft.VisualBasic.Interaction]::InputBox($prompt, "Set Machine Number", "")
if ([string]::IsNullOrWhiteSpace($new)) {
Write-Host "User cancelled. Will prompt again next logon."
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
$new = $new.Trim()
if ($new -notmatch '^\d+$') {
Write-Host "Invalid input: '$new' (not digits only). Showing error and re-prompting next logon."
[System.Windows.Forms.MessageBox]::Show(
"Machine number must be digits only.`n`nYou entered: '$new'`n`nThe prompt will appear again at next logon.",
"Invalid Machine Number",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
) | Out-Null
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
# --- Hand off to SYSTEM task ---
# Clean any stale request / result files first so we read fresh ones.
Remove-Item -LiteralPath $requestFile, $resultFile -Force -ErrorAction SilentlyContinue
try {
Set-Content -LiteralPath $requestFile -Value $new -Encoding ascii -Force -ErrorAction Stop
} catch {
[System.Windows.Forms.MessageBox]::Show(
"Could not write request file at $requestFile`n`n$_`n`nThe prompt will appear again at next logon.",
"Machine Number Request Failed",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
) | Out-Null
try { Stop-Transcript | Out-Null } catch {}
exit 1
}
Write-Host "Wrote $requestFile with '$new'. Triggering SYSTEM apply task..."
& schtasks.exe /run /tn $applyTaskName 2>&1 | ForEach-Object { Write-Host " schtasks: $_" }
# --- Wait for result ---
$deadline = (Get-Date).AddSeconds(60)
$result = $null
while ((Get-Date) -lt $deadline) {
if (Test-Path -LiteralPath $resultFile) {
try {
$result = Get-Content -LiteralPath $resultFile -Raw -ErrorAction Stop | ConvertFrom-Json
break
} catch { Start-Sleep -Milliseconds 200 }
}
Start-Sleep -Milliseconds 500
}
# Make the result MessageBox topmost so it shows above the FormTracePak /
# DNC windows and isn't missed.
$tmpForm = New-Object System.Windows.Forms.Form
$tmpForm.TopMost = $true
$tmpForm.WindowState = 'Minimized'
$tmpForm.ShowInTaskbar = $false
$tmpForm.Opacity = 0
$tmpForm.Show()
if (-not $result) {
Write-Host "Timed out waiting for SYSTEM apply task to produce result file ($resultFile)."
[System.Windows.Forms.MessageBox]::Show(
$tmpForm,
"Timed out waiting for the SYSTEM update task to complete.`n`nCheck:`n C:\Logs\SFLD\Apply-MachineNumber.log`n C:\Logs\SFLD\Prompt-MachineNumber.log`n`nThe prompt will appear again at next logon.",
"Machine Number Update Timed Out",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Warning
) | Out-Null
$tmpForm.Close()
try { Stop-Transcript | Out-Null } catch {}
exit 1
}
# Build summary from result JSON
$lines = @()
$lines += "Requested: $($result.Requested)"
$lines += ""
if ($result.UdcUpdated) { $lines += "UDC updated to $($result.Requested)" } else { $lines += "UDC: not updated (UDC may not be installed)" }
if ($result.EdncUpdated) { $lines += "eDNC updated to $($result.Requested)" } else { $lines += "eDNC: not updated" }
if ($result.UdcSettingsRestored) { $lines += "UDC settings restored from SFLD" }
if ($result.UdcRestored) { $lines += "UDC live data restored from SFLD" }
if ($result.MachineNumberTxtUpdated) { $lines += "machine-number.txt updated" }
if ($result.MTConnectUpdated -and $result.MTConnectUpdated.Count -gt 0) {
$lines += ""
$lines += "MTConnect Devices.xml updates:"
$result.MTConnectUpdated | ForEach-Object { $lines += " - $_" }
}
if ($result.Errors -and $result.Errors.Count -gt 0) {
$lines += ""
$lines += "FAILURES:"
$result.Errors | ForEach-Object { $lines += " - $_" }
}
$lines += ""
$lines += "Status: $($result.Status)"
$lines += "Logs: C:\Logs\SFLD\Apply-MachineNumber.log"
$lines += " C:\Logs\SFLD\Prompt-MachineNumber.log"
$lines += ""
$lines += "To apply eDNC changes, restart any running DncMain.exe."
$summary = $lines -join "`n"
$icon = if ($result.Status -eq 'OK') { [System.Windows.Forms.MessageBoxIcon]::Information } else { [System.Windows.Forms.MessageBoxIcon]::Warning }
[System.Windows.Forms.MessageBox]::Show(
$tmpForm,
$summary,
"Machine Number Update Result",
[System.Windows.Forms.MessageBoxButtons]::OK,
$icon
) | Out-Null
$tmpForm.Close()
# Clean up result file for the next round.
Remove-Item -LiteralPath $resultFile -Force -ErrorAction SilentlyContinue
# Only unregister the Prompt task on full success (no errors AND eDNC
# updated to the requested value). If anything failed, leave it registered
# for next logon retry.
if ($result.Status -eq 'OK' -and $result.EdncUpdated) {
Write-Host "All updates succeeded. Unregistering Prompt task."
try { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
} else {
Write-Host "Some updates failed or skipped. Prompt task stays registered for next logon retry."
}
Write-Host "Prompt-MachineNumber.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -0,0 +1,179 @@
# Register-CheckMachineNumberTask.ps1 - Register the two-task machine
# number flow at imaging time:
#
# 1. "Prompt Machine Number" - AtLogOn, BUILTIN\Users, Limited.
# Shows InputBox + writes new number to a request file, then triggers
# the SYSTEM task via schtasks /run.
#
# 2. "Apply Machine Number" - on-demand only (no trigger), SYSTEM,
# RunLevel Highest. Reads the request file, calls Update-MachineNumber
# with full HKLM + ProgramData access, writes a result JSON, removes
# the request file. No GUI - the Prompt task polls the result file
# and displays the dialog.
#
# Replaces the old single-task design that ran as the logged-in user with
# pre-granted BUILTIN\Users HKLM ACLs (02-MachineNumberACLs.ps1). That
# approach was fragile (timing race with eDNC install, silent ACL skip)
# and a security hole (any user could write to the machine-identity reg
# key). With SYSTEM doing the actual writes, no ACL grants needed.
#
# Idempotent: safe to re-run. Existing tasks are overwritten.
#
# File kept named Register-CheckMachineNumberTask.ps1 (rather than
# Register-MachineNumberTasks.ps1) so Run-ShopfloorSetup's existing
# discovery doesn't need editing.
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
$logFile = Join-Path $logDir 'register-checkmn.log'
function Write-RegLog {
param([string]$Message)
$line = '[{0}] [INFO] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
Write-RegLog '=== Register-CheckMachineNumberTask start ==='
$promptTaskName = 'Prompt Machine Number'
$applyTaskName = 'Apply Machine Number'
$oldTaskName = 'Check Machine Number' # legacy, removed below
# Clean up the legacy single-task name from prior imaging cycles.
try {
if (Get-ScheduledTask -TaskName $oldTaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $oldTaskName -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered legacy task '$oldTaskName'"
}
} catch { Write-RegLog "Could not unregister legacy '$oldTaskName': $_" }
# PC types that have no machine number by design. Arming the prompt on these
# guarantees a dialog nobody can answer: startnet only collects a machine number
# for the machine-tool types, so everything else is left on the 9999 placeholder
# and the check below would otherwise fire on every one of them.
#
# Observed on 579C144 (a Display) 2026-08-06: PreInstall logged
# "Machine number: 9999 (default placeholder)" and the logon prompt armed itself
# on a kiosk with no keyboard.
$noMachineNumberTypes = @(
'gea-shopfloor-display'
)
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
$pcType = ''
if (Test-Path -LiteralPath $pcTypeFile) {
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue)
if ($pcType) { $pcType = $pcType.Trim() }
}
if ($pcType -and ($noMachineNumberTypes -contains $pcType)) {
Write-RegLog "PC type '$pcType' has no machine number by design. Not registering the prompt."
foreach ($t in @($promptTaskName, $applyTaskName)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$t'"
}
} catch {}
}
return
}
# Only arm the tasks if the bay was imaged with the 9999 placeholder. If
# the tech entered a real machine number during PXE imaging it's already
# in C:\Enrollment\machine-number.txt; no prompt needed on first logon.
$mnFile = 'C:\Enrollment\machine-number.txt'
$mnAtImaging = '9999'
if (Test-Path -LiteralPath $mnFile) {
$raw = (Get-Content -LiteralPath $mnFile -First 1 -ErrorAction SilentlyContinue)
if ($raw) { $mnAtImaging = $raw.Trim() }
}
Write-RegLog "Imaging-time machine-number.txt = '$mnAtImaging'"
if ($mnAtImaging -ne '9999') {
Write-RegLog "Machine number is real ('$mnAtImaging' != 9999). Not registering tasks."
foreach ($t in @($promptTaskName, $applyTaskName)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$t'"
}
} catch {}
}
Write-RegLog '=== Register-CheckMachineNumberTask end (no-op) ==='
exit 0
}
# Resolve script paths. Prefer the staged shopfloor-setup tree on C:
# (where Run-ShopfloorSetup ran from); fall back to the same dir as this
# Register script if invoked standalone.
function Resolve-Script {
param([string]$LeafName)
$p = Join-Path $PSScriptRoot $LeafName
if (Test-Path -LiteralPath $p) { return $p }
$p = "C:\Enrollment\shopfloor-setup\Shopfloor\$LeafName"
if (Test-Path -LiteralPath $p) { return $p }
return $null
}
$promptScript = Resolve-Script 'Prompt-MachineNumber.ps1'
$applyScript = Resolve-Script 'Apply-MachineNumber.ps1'
if (-not $promptScript) { Write-RegLog "Prompt-MachineNumber.ps1 not found - cannot register"; exit 1 }
if (-not $applyScript) { Write-RegLog "Apply-MachineNumber.ps1 not found - cannot register"; exit 1 }
Write-RegLog "Prompt script: $promptScript"
Write-RegLog "Apply script: $applyScript"
# --- Prompt task (user-context, GUI) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$promptScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Group SID S-1-5-32-545 = BUILTIN\Users (catches ShopFloor + support/admin
# users that log in interactively). RunLevel Limited - no elevation; the
# actual writes happen in the SYSTEM Apply task below.
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskName $promptTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$promptTaskName' (AtLogOn, BUILTIN\Users, Limited)"
} catch {
Write-RegLog "FAILED to register '$promptTaskName': $_"
exit 1
}
# --- Apply task (SYSTEM, on-demand) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$applyScript`""
# No trigger - the Prompt task starts this via schtasks /run /tn.
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 10)
Register-ScheduledTask -TaskName $applyTaskName -Action $action -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$applyTaskName' (on-demand, SYSTEM, Highest)"
# Default SDDL on a SYSTEM-owned task only grants Admins + SYSTEM
# FullAccess - BUILTIN\Users can't see or run it via schtasks /run.
# Add an ACE granting BUILTIN\Users GenericRead + GenericExecute so the
# user-context Prompt task can trigger this Apply task on demand. They
# still can't modify/delete it - only read+execute.
try {
$svc = New-Object -ComObject Schedule.Service
$svc.Connect()
$taskObj = $svc.GetFolder('\').GetTask($applyTaskName)
# GenericRead = 0x80000000 (GR), GenericExecute = 0x20000000 (GX)
# BU = BUILTIN\Users
$newSd = 'O:BAG:BAD:(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGX;;;BU)'
# SetSecurityDescriptor flag 0 = default, persists DACL change.
$taskObj.SetSecurityDescriptor($newSd, 0)
Write-RegLog "Granted BUILTIN\Users GR+GX on '$applyTaskName' (so Limited users can schtasks /run)"
} catch {
Write-RegLog "FAILED to set task SDDL on '$applyTaskName': $_ (Limited users may not be able to trigger Apply)"
}
} catch {
Write-RegLog "FAILED to register '$applyTaskName': $_"
exit 1
}
Write-RegLog '=== Register-CheckMachineNumberTask end ==='
exit 0

View File

@@ -0,0 +1,177 @@
# Register-MapSfldShare.ps1 - stage Map-SfldShare.ps1 and register the
# 'GE Map SFLD Share' scheduled task that keeps S: alive for every
# interactive BUILTIN\Users member (ShopFloor, SupportUser, ...).
#
# Replaces the HKLM\Run entry of the same name. Run fired once per logon and
# had no way to repair S: mid-shift; a task can fire at logon, on an
# interval, on unlock / console connect and when the network comes up.
# Principal is the Users GROUP with RunLevel Limited, so it runs in each
# logged-on member's own session with a non-elevated token: the mapping
# lands where Explorer and the apps look for it.
param(
[int]$IntervalMinutes = 3,
[string]$InstallRoot = 'C:\Program Files\GE\SfldShare',
# SHA256 the manifest entry detects on. When it differs from the share
# file the manifest was not bumped with the script; warn loudly once.
[string]$ExpectedHash = ''
)
$ErrorActionPreference = 'Continue'
$taskName = 'GE Map SFLD Share'
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
$runValue = 'GE Map SFLD Share'
$legacyTask = 'GE Shopfloor Map S: Drive'
$mapScript = Join-Path $InstallRoot 'Map-SfldShare.ps1'
$logDir = 'C:\Logs\SFLD'
$logFile = Join-Path $logDir 'register-mapshare.log'
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
function Write-RegLog {
param([string]$Message)
$line = '[{0}] [INFO] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
# Self-contained PC types never get S: (Display kiosk: local accounts, no
# SFLD creds, mapping could only fail). Gate here as well as at the call
# site - see the 579C144 note in the previous version.
$selfContainedTypes = @('Display', 'gea-shopfloor-display')
$pcType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
$pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -EA SilentlyContinue)
if ($pcType) { $pcType = $pcType.Trim() }
}
if ($pcType -and ($selfContainedTypes -contains $pcType)) {
Write-RegLog "PC type '$pcType' is self-contained - not registering the S: mapper."
Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue
if (Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue) { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -EA SilentlyContinue }
exit 0
}
# --- stage the script ----------------------------------------------------
$src = Join-Path $PSScriptRoot 'lib\Map-SfldShare.ps1'
if (-not (Test-Path $src)) { Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"; exit 1 }
$srcHash = (Get-FileHash -Path $src -Algorithm SHA256).Hash
$stampKey = 'HKLM:\SOFTWARE\GE\SfldShare'
# Manifest / share drift guard. The GE-Enforce entry detects on
# MapperHash == DetectionValue, so a share edit without a DetectionValue bump
# makes the engine call this every cycle. Say so once per hash, and take the
# fast path below so that loop costs nothing and never churns the task.
if ($ExpectedHash -and ($ExpectedHash -ne $srcHash)) {
$warned = (Get-ItemProperty -Path $stampKey -Name DriftWarnedHash -EA SilentlyContinue).DriftWarnedHash
if ($warned -ne $srcHash) {
Write-RegLog "WARN: share Map-SfldShare.ps1 is $srcHash but the manifest DetectionValue is $ExpectedHash - bump DetectionValue in common/manifest.json or the engine re-runs this every cycle"
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
Set-ItemProperty -Path $stampKey -Name DriftWarnedHash -Value $srcHash -Type String -EA SilentlyContinue
}
}
# Fast path: staged copy already matches the share and the task exists with
# our action -> nothing to (re)do. Re-stamp and leave (no Register -Force,
# so a live task is never torn down for no reason).
$taskNow = Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue
if ($taskNow -and (Test-Path $mapScript) -and
((Get-FileHash -Path $mapScript -Algorithm SHA256).Hash -eq $srcHash) -and
($taskNow.Actions[0].Execute -like '*conhost.exe') -and
($taskNow.Actions[0].Arguments -like "*$mapScript*")) {
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
Set-ItemProperty -Path $stampKey -Name MapperHash -Value $srcHash -Type String -EA SilentlyContinue
exit 0
}
Write-RegLog '=== Register-MapSfldShare start ==='
if (-not (Test-Path $InstallRoot)) { New-Item -Path $InstallRoot -ItemType Directory -Force | Out-Null }
Copy-Item -Path $src -Destination $mapScript -Force
Write-RegLog "Staged $src -> $mapScript"
# --- retire the older mechanisms -----------------------------------------
if (Get-ItemProperty -Path $runKey -Name $runValue -EA SilentlyContinue) {
Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue
Write-RegLog "Removed HKLM\Run '$runValue' (superseded by the scheduled task)"
}
if (Get-ScheduledTask -TaskName $legacyTask -EA SilentlyContinue) {
Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -EA SilentlyContinue
Write-RegLog "Removed legacy task '$legacyTask'"
}
# --- task ------------------------------------------------------------------
try {
# conhost --headless, not powershell -WindowStyle Hidden: on Win11 the
# default console host is Windows Terminal, which ignores the hosted
# app's Hidden style and flashes a window + steals foreground for
# ~0.5 s on every fire (measured in the VM). Headless conhost shows
# nothing and moves focus nowhere.
$ps = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\conhost.exe" `
-Argument ('--headless "{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}"' -f $ps, $mapScript)
$ns = 'Root/Microsoft/Windows/TaskScheduler'
$triggers = New-Object System.Collections.ArrayList
# 1. logon (any user)
[void]$triggers.Add((New-ScheduledTaskTrigger -AtLogOn))
# 2. interval, forever
$rep = New-ScheduledTaskTrigger -Once -At (Get-Date).Date -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
[void]$triggers.Add($rep)
# 3. console connect (1) + session unlock (8)
$ssCls = Get-CimClass -ClassName MSFT_TaskSessionStateChangeTrigger -Namespace $ns
foreach ($state in 1, 8) {
$t = New-CimInstance -CimClass $ssCls -ClientOnly
$t.StateChange = $state
$t.Delay = 'PT10S'
[void]$triggers.Add($t)
}
# 4. network profile connected (NetworkProfile/Operational 10000) - wifi
# roam / cable back / VPN up. Short delay so DNS is usable.
$evCls = Get-CimClass -ClassName MSFT_TaskEventTrigger -Namespace $ns
$ev = New-CimInstance -CimClass $evCls -ClientOnly
$ev.Subscription = '<QueryList><Query Id="0" Path="Microsoft-Windows-NetworkProfile/Operational"><Select Path="Microsoft-Windows-NetworkProfile/Operational">*[System[(EventID=10000)]]</Select></Query></QueryList>'
$ev.Delay = 'PT15S'
[void]$triggers.Add($ev)
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
-StartWhenAvailable `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-Hidden
Register-ScheduledTask -TaskName $taskName `
-Action $action -Trigger $triggers.ToArray() -Principal $principal -Settings $settings `
-Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered task '$taskName' (BUILTIN\Users, Limited; logon + every $IntervalMinutes min + unlock/connect + network-up)"
} catch {
Write-RegLog "FAILED to register task: $_"
exit 1
}
# --- stamp -----------------------------------------------------------------
# Written only after staging + registration both succeeded. The GE-Enforce
# manifest entry detects on MapperHash == SHA256 of Map-SfldShare.ps1, so a
# new script version (new hash) or a failed registration (no stamp) re-fires
# this registrar on the next cycle.
try {
$hash = (Get-FileHash -Path $mapScript -Algorithm SHA256).Hash
$stampKey = 'HKLM:\SOFTWARE\GE\SfldShare'
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
Set-ItemProperty -Path $stampKey -Name MapperHash -Value $hash -Type String
Set-ItemProperty -Path $stampKey -Name RegisteredAt -Value (Get-Date -Format 's') -Type String
Set-ItemProperty -Path $stampKey -Name TaskName -Value $taskName -Type String
Write-RegLog "Stamped $stampKey MapperHash=$hash"
} catch {
Write-RegLog "Stamp write failed (registrar will re-run next cycle): $_"
}
Write-RegLog '=== Register-MapSfldShare end ==='
exit 0

View File

@@ -1,32 +0,0 @@
# Register-PostPpkgTask.ps1 - register the "GE Shopfloor Post-PPKG" task.
#
# Called by Run-ShopfloorSetup.ps1 BEFORE the PPKG step (the PPKG reboot can
# kill the caller). Copies the runner out of C:\Enrollment (the bulk-enroll
# cleanup may remove staging folders) to C:\Program Files\GE\Shopfloor and
# registers a SYSTEM task that runs Invoke-PostPpkg.ps1 at every startup and
# logon until it has nothing left to do; then it unregisters itself.
#
# Idempotent: re-registering replaces the task.
$ErrorActionPreference = 'Stop'
$runtimeDir = 'C:\Program Files\GE\Shopfloor'
$taskName = 'GE Shopfloor Post-PPKG'
New-Item -Path $runtimeDir -ItemType Directory -Force | Out-Null
foreach ($f in @('Install-AppList.ps1', 'Invoke-PostPpkg.ps1')) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $f) -Destination (Join-Path $runtimeDir $f) -Force
}
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$runtimeDir\Invoke-PostPpkg.ps1`""
$triggers = @(
(New-ScheduledTaskTrigger -AtStartup),
(New-ScheduledTaskTrigger -AtLogOn)
)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 2)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $triggers `
-Principal $principal -Settings $settings -Force | Out-Null
Write-Host "Registered '$taskName' (SYSTEM, at startup and logon): $runtimeDir\Invoke-PostPpkg.ps1"

View File

@@ -10,8 +10,3 @@ if %errorlevel% neq 0 (
"C:\Program Files\PowerShell\7\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\Sysinternals\sfld_autologon.ps1" "C:\Program Files\PowerShell\7\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\Sysinternals\sfld_autologon.ps1"
REM 2) Clear any leftover imaging AutoLogonCount. Imaging tops it up for
REM SupportUser and Autologon leaves it in place, so ShopFloor autologon
REM would switch itself off again when it counts down to 0.
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoLogonCount /f /reg:64 >nul 2>&1

View File

@@ -0,0 +1,51 @@
'----------------------------------------------------------------------
' This macro was created by the macro recorder.
' Macro File: Office.ebs
' Date: Wed May 18 09:55:44 2016
' Recorded for profile: WJ_Office
'----------------------------------------------------------------------
Sub Main
Dim HostExplorer as Object
Dim MyHost as Object
Dim Rc as Integer
Dim iPSUpdateTimeout
Dim iWaitForStringTimeout
On Error goto GenericErrorHandler
Set HostExplorer = CreateObject("HostExplorer") ' Initialize HostExplorer Object
Set MyHost = HostExplorer.HostFromProfile("WJ_Office") ' Set object for the desired session
If MyHost is Nothing Then Goto NoSession
iPSUpdateTimeout = 60 ' WaitPSUpdated timeout set to 60 seconds
iWaitForStringTimeout = 60 ' WaitForString timeout set to 60 seconds
Rc = MyHost.WaitForString( "Username:", -1, iWaitForStringTimeout, TRUE )
If Rc = 0 Then Goto OnWaitForStringTimeout
Rc = MyHost.Keys("shop_pc^M")
Rc = MyHost.WaitPSUpdated( iPSUpdateTimeout, TRUE )
If Rc <> 0 Then Goto OnWaitPSUpdatedTimeout
Rc = MyHost.WaitForString( "Do you have a barcode reader?", -1, iWaitForStringTimeout, TRUE )
If Rc = 0 Then Goto OnWaitForStringTimeout
Exit Sub
'-------------------- Runtime Error Handlers --------------------
GenericErrorHandler:
Msgbox "Error " & Err & " : """ & Error(Err) & """ has occurred on line " & Erl-1 & "." & Chr(10) & "Unable to continue macro execution.", 16, "HostExplorer Basic Macro Error"
Exit Sub
NoSession:
Msgbox "Profile ""WJ_Office"" is not running." & Chr(10) & "Unable to execute macro.", 16, "HostExplorer Macro Error"
Exit Sub
OnWaitPSUpdatedTimeout:
Msgbox "Timeout occured waiting for host to update screen." & Chr(10) & "Unable to continue macro execution.", 16, "HostExplorer Basic Macro Error"
Exit Sub
OnWaitForStringTimeout:
Msgbox "Timeout occured waiting for string on host screen." & Chr(10) & "Unable to continue macro execution.", 16, "HostExplorer Basic Macro Error"
Exit Sub
End Sub

View File

@@ -0,0 +1,35 @@
'----------------------------------------------------------------------
' This macro was created by the macro recorder.
' Macro File: mmcs.ebs
' Date: Tue Jun 04 08:56:51 2013
' Recorded for profile: mmcs
'----------------------------------------------------------------------
Sub Main
Dim HostExplorer as Object
Dim MyHost as Object
Dim iIdleTime
Dim iPSUpdateTime
On Error goto ErrorCheck
Set HostExplorer = CreateObject("HostExplorer") ' Initialize HostExplorer Object
Set MyHost = HostExplorer.HostFromProfile("mmcs") ' Set object for the desired session
iPSUpdateTime = 60 ' PS Update wait time set to 60 seconds
iIdleTime = 2000 ' Idle time set to 2000 milliseconds
MyHost.WaitPSUpdated(iPSUpdateTime)
Rc = MyHost.WaitForString("Username:", -1, 9999, TRUE)
MyHost.Keys("mmcswj^M")
MyHost.WaitPSUpdated(iPSUpdateTime)
Rc = MyHost.WaitForString("| Badge : |", -1, 9999, TRUE)
Exit Sub
ErrorCheck:
if (Err = 440) Then
Msgbox "The specified session is not running.", 16, "Hummingbird Macro Error"
End If
Exit Sub
End Sub

View File

@@ -0,0 +1,56 @@
'----------------------------------------------------------------------
' This macro was created by the macro recorder.
' Macro File: shopfloor.ebs
' Date: Wed May 18 09:57:00 2016
' Recorded for profile: WJ Shopfloor
'----------------------------------------------------------------------
Sub Main
Dim HostExplorer as Object
Dim MyHost as Object
Dim Rc as Integer
Dim iPSUpdateTimeout
Dim iWaitForStringTimeout
On Error goto GenericErrorHandler
Set HostExplorer = CreateObject("HostExplorer") ' Initialize HostExplorer Object
Set MyHost = HostExplorer.HostFromProfile("WJ Shopfloor") ' Set object for the desired session
If MyHost is Nothing Then Goto NoSession
iPSUpdateTimeout = 60 ' WaitPSUpdated timeout set to 60 seconds
iWaitForStringTimeout = 60 ' WaitForString timeout set to 60 seconds
Rc = MyHost.WaitForString( "Username:", -1, iWaitForStringTimeout, TRUE )
If Rc = 0 Then Goto OnWaitForStringTimeout
Rc = MyHost.Keys("shop_xmi^M")
Rc = MyHost.WaitPSUpdated( iPSUpdateTimeout, TRUE )
If Rc <> 0 Then Goto OnWaitPSUpdatedTimeout
Rc = MyHost.WaitForString( "Password:", -1, iWaitForStringTimeout, TRUE )
If Rc = 0 Then Goto OnWaitForStringTimeout
Rc = MyHost.Keys("dnc123^M")
Rc = MyHost.WaitPSUpdated( iPSUpdateTimeout, TRUE )
If Rc <> 0 Then Goto OnWaitPSUpdatedTimeout
Rc = MyHost.WaitForString( "| EXIT | |", -1, iWaitForStringTimeout, TRUE )
If Rc = 0 Then Goto OnWaitForStringTimeout
Exit Sub
'-------------------- Runtime Error Handlers --------------------
GenericErrorHandler:
Msgbox "Error " & Err & " : """ & Error(Err) & """ has occurred on line " & Erl-1 & "." & Chr(10) & "Unable to continue macro execution.", 16, "HostExplorer Basic Macro Error"
Exit Sub
NoSession:
Msgbox "Profile ""WJ Shopfloor"" is not running." & Chr(10) & "Unable to execute macro.", 16, "HostExplorer Macro Error"
Exit Sub
OnWaitPSUpdatedTimeout:
Msgbox "Timeout occured waiting for host to update screen." & Chr(10) & "Unable to continue macro execution.", 16, "HostExplorer Basic Macro Error"
Exit Sub
OnWaitForStringTimeout:
Msgbox "Timeout occured waiting for string on host screen." & Chr(10) & "Unable to continue macro execution.", 16, "HostExplorer Basic Macro Error"
Exit Sub
End Sub

View File

@@ -0,0 +1,9 @@
[KEYMAP]
Normal Entries=1
Normal0=Backspace,Delete
Shift Entries=0
Ctrl Entries=0
ShiftCtrl Entries=0
Alt Entries=0
ShiftAlt Entries=0
AltCtrl Entries=0

View File

@@ -0,0 +1,13 @@
[KEYMAP]
Normal Entries=5
Normal0=F4,Pf4
Normal1=Subtract,Num-Pad-Minus
Normal2=Multiply,Num-Pad-*
Normal3=Divide,Num-Pad-/
Normal4=Backspace,Delete
Shift Entries=0
Ctrl Entries=0
ShiftCtrl Entries=0
Alt Entries=0
ShiftAlt Entries=0
AltCtrl Entries=0

View File

@@ -0,0 +1,23 @@
[Version]
AssemblyVersion=1
[Console]
Title=HostExplorer Menu Editor
IconPath=HumCSSPlugins.HumCSSActiveTunnelsPlugin
IconID=IDI_CSS_CONSOLE
[Attributes]
SupportAssemblyEditing=1
SupportSeparator=1
ParentLevels=100
[Parent Node]
name=Menu
[SubParent Node]
name=Submenu
[Child Node]
name=Menu Option
[Messages]
Create New Parent=Create New Menu
Create New SubParent=Create New Submenu
Insert New Parent=Insert New Menu
Insert New SubParent=Insert New Submenu
[Root Group]
Name=Menu

View File

@@ -0,0 +1,474 @@
[version]
AssemblyVersion=1
[nls]
filename=menunls$LANGUAGE_EXTENSION$.hma
[Console]
Title=HostExplorer Menu Editor
IconPath=HumCSSPlugins.HumCSSActiveTunnelsPlugin
IconID=IDI_CSS_CONSOLE
[Attributes]
SupportAssemblyEditing=1
SupportEnablingDisablingItems=0
SupportSeparator=1
[Parent Node]
Nameid=Parent
[SubParent Node]
Nameid=SubParent
[Child Node]
Nameid=Child
[Messages]
Nameid1=Create New Parent
Nameid2=Create New SubParent
Nameid3=Insert New Parent
Nameid4=Insert New SubParent
[root group]
Name=Menu
item1=File
item2=Edit
item3=Transfer
item4=Fonts
item5=options
item6=tools
item7=view
item8=window
item9=help
[Separator]
name=
id=0
[File]
Nameid=File
id=4071
Parent=1
item1=new session
item2=duplicate session
item3=open session
item4=open session in same window
item5=save session profile
item6=close session
item7=separator
item8=open layout
item9=save layout
item10=separator
item11=connect
item12=disconnect
item13=separator
item14=print screen
item15=print multiple screens
item16=save screen
item17=send screen
item18=separator
item19=screen capture
item20=separator
item21=Recent Sessions Submenu
item22=separator
item23=exit all
[New Session]
nameid=New Session
id=Dlg-New-Session
[Duplicate Session]
nameid=Duplicate Session
id=Duplicate-Session
[Open Session]
nameid=Open Session
id=Dlg-Open-Session
[Open Session In Same Window]
nameid=Open Session In Same Window
id=Dlg-Open-Session-In-Same-Window
[Save Session Profile]
nameid=Save Session Profile
id=Dlg-Save-Profile
[Close Session]
nameid=Close Session
id=Dlg-Close-Session
[open layout]
nameid=Open Layout
id=Dlg-Open-Layout
[save layout]
nameid=Save Layout
id=Dlg-Save-Layout
[connect]
nameid=connect
id=Connect
[disconnect]
nameid=disconnect
id=Disconnect
[print screen]
nameid=print screen
id=Dlg-Print-Screen
[print multiple screens]
nameid=print multiple screens
id=Dlg-Print-Multiple-Screens
[save screen]
nameid=Save Screen
id=Dlg-Save-Screen
[send screen]
nameid=Send Screen
id=Send-Screen
[screen capture]
nameid=Screen Capture
id=Toggle-Capture
[Recent Sessions Submenu]
nameid=Recent Sessions
id=4187
Parent=1
item1=recent sessions
[recent sessions]
nameid=Recent Sessions
id=Recent-Sessions
[Exit all]
nameid=exit all
id=Dlg-Exit
[Edit]
Nameid=Edit
id=4072
Parent=1
item1=copy
item2=copy append
item3=paste
item4=separator
item5=select all
item6=separator
item7=edit find
item8=separator
item9=clear display
item10=clear all
item11=soft terminal reset
[undo]
nameid=undo
id=Edit-Undo
[redo]
nameid=redo
id=Edit-Redo
[cut]
nameid=cut
id=Edit-Cut
[Copy]
nameid=copy
id=Edit-Copy
[Copy append]
nameid=Copy Append
id=Edit-Copy-Append
[Paste]
nameid=Paste
id=Edit-Paste
[Paste continue]
nameid=Paste Continue
id=Edit-Paste-Continue
[Select All]
nameid=Select All
id=Edit-SelectAll
[Edit Options]
nameid=Options
id=Dlg-Options
[edit find]
nameid=Find
id=Dlg-Find
[clear display]
nameid=Clear Display
id=Clear-Display
[clear all]
nameid=Clear All
id=Clear-Buffer
[soft terminal reset]
nameid=Soft Terminal Reset
id=Power-On-Reset
[Transfer]
Nameid=Transfer
id=4073
Parent=1
item1=send
item2=receive
[send]
nameid=Send File to Host
id=Dlg-Upload
[receive]
nameid=Receive File from Host
id=Dlg-Download
[Fonts]
Nameid=Fonts
id=4074
Parent=1
item1=next larger
item2=next smaller
item3=choose font
item4=maximize font
[next larger]
nameid=next larger font
id=Font-Larger
[next smaller]
nameid=Next smaller font
id=Font-Smaller
[choose font]
nameid=font
id=Dlg-Font-Select
[maximize font]
nameid=Maximize font
id=Maximize-Font
[options]
nameid=Options
id=4075
Parent=1
item1=global options
item2=api settings
item3=separator
item4=keyboard mapping
item5=quick keys
item6=separator
item7=session properties
[global options]
nameid=Global Options
id=Dlg-Global
[api settings]
nameid=API Settings
id=Dlg-API-Settings
[keyboard mapping]
nameid=Keyboard Mapping
id=Dlg-Keyboard-Mapper
[quick keys]
nameid=Quick Keys
id=Dlg-Quick-Key-Editor
[session properties]
nameid=session Properties
id=Dlg-Edit-Session-Profile
[tools]
nameid=Tools
id=4076
Parent=1
item1=Macro Submenu
item2=Quick Script Submenu
item3=separator
item4=Customizetoolbar
item5=Customizemenu
item6=Customizesessionproperties
[Macro Submenu]
nameid=Macro
id=4177
Parent=1
item1=edit macro
item2=run macro
item3=separator
item4=start recording
item5=pause recording
item6=resume recording
item7=stop recording
item8=cancel recording
[Edit macro]
nameid=Macro Edit
id=Dlg-Edit-Macro
[run macro]
nameid=run
id=Dlg-Run-Macro
[start recording]
nameid=Start Recording
id=Record-Macro
[pause recording]
nameid=Pause Recording
id=Toggle-Recording-Pause
[resume recording]
nameid=Resume Recording
id=Resume-Recording-Macro
[stop recording]
nameid=Stop Recording
id=End-Recording
[cancel recording]
nameid=Cancel Recording
id=Cancel-Macro-Recording
[Quick Script Submenu]
nameid=Quick Script
id=4091
Parent=1
item1=edit qs
item2=run qs
item3=stop qs
item4=separator
item5=start recording qs
item6=pause recording qs
item7=resume recording qs
item8=stop recording qs
item9=cancel recording qs
[Customizetoolbar]
nameid=Customize Toolbar
id=Dlg-Toolbars
[Customizemenu]
nameid=Customize Menu
id=Dlg-Customize-Menus
[CustomizeSessionProperties]
nameid=Customize Session Properties
id=Dlg-Customize-Session-Properties
[Edit qs]
nameid=Edit
id=Dlg-QuickScript-Editor
[run qs]
nameid=Run
id=Dlg-Run-QuickScript
[stop qs]
nameid=Stop
id=Stop-QuickScript
[start recording qs]
nameid=Start Recording
id=Record-QuickScript
[pause recording qs]
nameid=Pause Recording
id=Pause-Recording-QuickScript
[resume recording qs]
nameid=Resume Recording
id=Resume-Recording-QuickScript
[stop recording qs]
nameid=Stop Recording
id=Stop-Recording-QuickScript
[cancel recording qs]
nameid=Cancel Recording
id=Cancel-Recording-QuickScript
[view]
nameid=view
id=4178
Parent=1
item1=hotspots
item2=row and column indicator
item3=cross hair cursor
item4=full screen
[hotspots]
nameid=Hotspots
id=Toggle-View-Hotspots
[Row and Column Indicator]
nameid=Row and Column Indicator
id=Toggle-Row-And-Column-Indicator
[cross hair cursor]
nameid=Cross-Hair Cursor
id=Toggle-CrossHair-Cursor
[full screen]
nameid=Full Screen
id=Toggle-Full-Screen
[window]
nameid=Window
id=4179
Parent=1
item1=cascade
item2=next session
item3=previous session
item4=separator
item5=session window list
[cascade]
nameid=Cascade
id=Cascade-Session-Windows
[next session]
nameid=next session
id=Next-Session
[previous session]
nameid=previous session
id=Prev-Session
[session window list]
name=<List Of Windows>
id=4142
[help]
nameid=Help
id=4180
Parent=1
item1=contents
item2=separator
item3=about
[contents]
nameid=Contents
id=Help-Index
[about]
nameid=about
id=Help-About

Binary file not shown.

View File

@@ -0,0 +1,106 @@
[PROFILE]
Session Properties=Default 3270
Menu=Default 3270
Auto Run Macro Delay Time=1
Gateway Name=ibmqks.ae.ge.com
Session Keep Alive Interval=600
PC Keyboard Type=5
IND$FILE Parms=0,0,1,1,0,0,0,257,253,2048,200,430,430,0,1252,"IND$FILE","","","",0
SSL/TLS Current User Certificate=1
Window Title=%s - %p (%h)
WinInfo Default Restored=0 0 0 0 70 405 822 595 0 0 0=20 10 0 0 400 0 0 0 0 3 2 2 0 HE_Bitmap| 0 0
WinInfo Default Maximized=0 0 0 0 -8 -8 1936 1048 0 0 0=26 12 0 0 400 0 0 0 0 3 2 2 0 HE_Bitmap| 0 0
Print Header2=Page: %# Document Name: %F
QuickKey=Default
Never Run Profile=Off
Profile File Version=200
Space Allocation Parms=0,0,0,0
Kerberos Use Hummingbird Kerberos=On
Cryptographic Mode=1
IP Port=992
Security Option=2
SSL/TLS User Cipher Suites=DEFAULT
[HEBar-59440]
sizeFloatCX=505
sizeFloatCY=28
[Toolbars Layout-Summary]
Bars=3
ScreenCX=1920
ScreenCY=1080
[Session Options]
Last Selected Page=139
[Toolbars]
ToolBarVersion=6
bar0=Default 3270 ToolBar.tb3
[Toolbars Layout-Bar0]
BarID=59393
[Toolbars Layout-Bar1]
BarID=59419
Bars=3
Bar#0=0
Bar#1=59440
Bar#2=0
[Toolbars Layout-Bar2]
BarID=59440
XPos=-2
YPos=-2
Docking=1
MRUDockID=59419
MRUDockLeftPos=-2
MRUDockTopPos=-2
MRUDockRightPos=512
MRUDockBottomPos=26
MRUFloatStyle=8196
MRUFloatXPos=-1
MRUFloatYPos=1025
[Hotspots]
ShowTips=1
[ShortcutReplacers]
Size=0
[Site Info]
EnableCache=Yes
RefreshCacheOnConnect=0
AlwaysUploadUsing=0
AlwaysDownloadUsing=0
ProfileVersion=5
SecurityType=0
Port=21
SSL Implicit Port=990
Directory2=000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
SFTPProxyAddress=localhost
SFTPProxyPort=0
SFTPWriteSize=256
GssApiServiceName=host
GssApiProtLevel=P
GssApiIssueCCC=No
GssApiPBSZ=16000
GssApiKerbClient=0
GssApiUseHMS2MIT=No
PassiveMode=2
AllowServer2ServerTransfer=0
KeepAlive=No
AutoDetectServerType=Yes
KeepAliveTime=0
FirewallPort=21
FirewallType=0
NetworkTimeout=120
ConvertGMTTime=0
ResolveLinks=1
RootToInitialDir=0
ChangeDirChangesRoot=0
DropVmsVersion=0
LocalHourOffset=0
LocalMinuteOffset=0
TemporaryProfile=0
SSLVersion=3
SSLReuse=0
SSLUserCertificateMode=0
SSLCloseOnNegotiateionFail=0
SSLAcceptServerSelfSignedCertificates=Yes
SSLAcceptServerUnverifiedCertificates=Yes
SSLDataChannelProtected=Yes
SSLCryptographicMode=1
AllowResume=No
EPSV4=0
[Stats]
NumVisits=0

View File

@@ -0,0 +1,122 @@
[PROFILE]
Session Terminal Type=2
Session Properties=Default VT
Menu=Default VT
Auto Run Macro Delay Time=1
Gateway Name=wjfms3.apps.wlm.geaerospace.net
Session Keep Alive Interval=600
PC Keyboard Type=5
Tabstops='8 16 24 32 40 48 56 64 72 '
VT BG Colors='1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 8 7 7 9 15 14 10 2 10 '
Default VT Recv Path=$MYDOCUMENTS$\
SSL/TLS Version=3
SSL/TLS Current User Certificate=1
Window Title=%s - %p (%h)
WinInfo Default Restored=0 0 0 0 176 21 892 619 0 0 0=-17 0 0 0 400 0 0 0 0 3 2 2 0 Courier New| 0 0
WinInfo Default Maximized=0 0 0 0 -8 -8 1634 1066 0 0 0=30 15 0 0 700 0 0 0 1 3 2 2 0 HE_Bitmap| 0 0
Print Header2=Page: %# Document Name: %F
QuickKey=Super Key F6
Never Run Profile=Off
dwVTFlags1=29369344
VT FG Colors='7 7 9 15 14 10 2 10 0 1 2 3 4 5 15 7 8 9 10 11 12 13 14 15 15 0 0 1 0 0 0 0 0 0 0 '
Connect Timeout=60
Disable Secure Connection Warning=Yes
Profile File Version=200
Cryptographic Mode=1
Display RowCol Indicator=Off
VT Scrollback Save Attribs=Off
VT True Display Mode=1
Status Line=1
Kerberos Use Hummingbird Kerberos=On
Keyboard=Office
Auto Start Quick-Key=shopfloor.ebs
On Disconnect=0
Allow sleep while connected=0
Toolbar Scheme Name=Default VT
Save Profile on Window Close=Off
Toolbar Scheme Modified=Default VT:30026745--1815298816
Save Toolbar Info on Exit=Off
Save Mode=0
[Session Options]
Last Selected Page=4000
[HEBar-59440]
[Toolbars Layout-Summary]
Bars=3
ScreenCX=1600
ScreenCY=1200
[Toolbars]
ToolBarVersion=5
bar0=Default VT ToolBar.tbv
[Toolbars Layout-Bar0]
BarID=59393
Visible=0
[Toolbars Layout-Bar1]
BarID=59419
Bars=3
Bar#0=0
Bar#1=59440
Bar#2=0
[Toolbars Layout-Bar2]
BarID=59440
XPos=-2
YPos=-2
Docking=1
MRUDockID=59419
MRUDockLeftPos=-2
MRUDockTopPos=-2
MRUDockRightPos=541
MRUDockBottomPos=26
MRUFloatStyle=8196
MRUFloatXPos=-2147483648
MRUFloatYPos=0
[Hotspots]
ShowTips=1
[ShortcutReplacers]
Size=0
[Site Info]
EnableCache=Yes
RefreshCacheOnConnect=0
AlwaysUploadUsing=0
AlwaysDownloadUsing=0
ProfileVersion=5
SecurityType=0
Port=21
SSL Implicit Port=990
Directory2=000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
SFTPProxyAddress=localhost
SFTPProxyPort=0
SFTPWriteSize=256
GssApiServiceName=host
GssApiProtLevel=P
GssApiIssueCCC=No
GssApiPBSZ=16000
GssApiKerbClient=0
GssApiUseHMS2MIT=No
PassiveMode=2
AllowServer2ServerTransfer=0
KeepAlive=No
AutoDetectServerType=Yes
KeepAliveTime=0
FirewallPort=21
FirewallType=0
NetworkTimeout=120
ConvertGMTTime=0
ResolveLinks=1
RootToInitialDir=0
ChangeDirChangesRoot=0
DropVmsVersion=0
LocalHourOffset=0
LocalMinuteOffset=0
TemporaryProfile=0
SSLVersion=3
SSLReuse=0
SSLUserCertificateMode=0
SSLCloseOnNegotiateionFail=0
SSLAcceptServerSelfSignedCertificates=Yes
SSLAcceptServerUnverifiedCertificates=Yes
SSLDataChannelProtected=Yes
SSLCryptographicMode=1
AllowResume=No
EPSV4=0
[Stats]
NumVisits=0

View File

@@ -0,0 +1,122 @@
[PROFILE]
Session Terminal Type=2
Session Properties=Default VT
Menu=Default VT
Auto Run Macro Delay Time=1
Gateway Name=wjfms3.apps.wlm.geaerospace.net
Session Keep Alive Interval=600
PC Keyboard Type=5
Tabstops='8 16 24 32 40 48 56 64 72 '
VT BG Colors='1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 8 7 7 9 15 14 10 2 10 '
Default VT Recv Path=$MYDOCUMENTS$\
SSL/TLS Version=3
SSL/TLS Current User Certificate=1
Window Title=%s - %p (%h)
WinInfo Default Restored=0 0 0 0 22 22 892 619 0 0 0=20 10 0 0 400 0 0 0 0 3 2 2 0 HE_Bitmap| 0 0
WinInfo Default Maximized=0 0 0 0 -8 -8 1634 1066 0 0 0=30 15 0 0 700 0 0 0 1 3 2 2 0 HE_Bitmap| 0 0
Print Header2=Page: %# Document Name: %F
QuickKey=Super Key F6
Never Run Profile=Off
dwVTFlags1=29369344
VT FG Colors='7 7 9 15 14 10 2 10 0 1 2 3 4 5 15 7 8 9 10 11 12 13 14 15 15 0 0 1 0 0 0 0 0 0 0 '
Connect Timeout=60
Disable Secure Connection Warning=Yes
Profile File Version=200
Cryptographic Mode=1
Display RowCol Indicator=Off
VT Scrollback Save Attribs=Off
VT True Display Mode=1
Status Line=1
Kerberos Use Hummingbird Kerberos=On
Keyboard=Office
Auto Start Quick-Key=Office.ebs
On Disconnect=0
Allow sleep while connected=0
Toolbar Scheme Name=Default VT
Save Profile on Window Close=Off
Toolbar Scheme Modified=Default VT:30026745--1815298816
Save Toolbar Info on Exit=Off
Save Mode=0
[Session Options]
Last Selected Page=4000
[HEBar-59440]
[Toolbars Layout-Summary]
Bars=3
ScreenCX=1600
ScreenCY=1200
[Toolbars]
ToolBarVersion=5
bar0=Default VT ToolBar.tbv
[Toolbars Layout-Bar0]
BarID=59393
Visible=0
[Toolbars Layout-Bar1]
BarID=59419
Bars=3
Bar#0=0
Bar#1=59440
Bar#2=0
[Toolbars Layout-Bar2]
BarID=59440
XPos=-2
YPos=-2
Docking=1
MRUDockID=59419
MRUDockLeftPos=-2
MRUDockTopPos=-2
MRUDockRightPos=541
MRUDockBottomPos=26
MRUFloatStyle=8196
MRUFloatXPos=-2147483648
MRUFloatYPos=0
[Hotspots]
ShowTips=1
[ShortcutReplacers]
Size=0
[Site Info]
EnableCache=Yes
RefreshCacheOnConnect=0
AlwaysUploadUsing=0
AlwaysDownloadUsing=0
ProfileVersion=5
SecurityType=0
Port=21
SSL Implicit Port=990
Directory2=000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
SFTPProxyAddress=localhost
SFTPProxyPort=0
SFTPWriteSize=256
GssApiServiceName=host
GssApiProtLevel=P
GssApiIssueCCC=No
GssApiPBSZ=16000
GssApiKerbClient=0
GssApiUseHMS2MIT=No
PassiveMode=2
AllowServer2ServerTransfer=0
KeepAlive=No
AutoDetectServerType=Yes
KeepAliveTime=0
FirewallPort=21
FirewallType=0
NetworkTimeout=120
ConvertGMTTime=0
ResolveLinks=1
RootToInitialDir=0
ChangeDirChangesRoot=0
DropVmsVersion=0
LocalHourOffset=0
LocalMinuteOffset=0
TemporaryProfile=0
SSLVersion=3
SSLReuse=0
SSLUserCertificateMode=0
SSLCloseOnNegotiateionFail=0
SSLAcceptServerSelfSignedCertificates=Yes
SSLAcceptServerUnverifiedCertificates=Yes
SSLDataChannelProtected=Yes
SSLCryptographicMode=1
AllowResume=No
EPSV4=0
[Stats]
NumVisits=0

View File

@@ -0,0 +1,128 @@
[PROFILE]
Session Terminal Type=2
Never Run Profile=Off
Session Properties=Default VT
Menu=Default VT
Auto Run Macro Delay Time=1
Gateway Name=wjfms3.apps.wlm.geaerospace.net
Session Keep Alive Interval=600
dwVTFlags1=29369344
PC Keyboard Type=5
Keyboard=ATM-VT420
Tabstops='8 16 24 32 40 48 56 64 72 80 88 96 104 112 120 128 136 144 152 160 168 176 184 192 200 208 216 224 232 240 248 256 264 272 280 288 296 '
Default VT Recv Path=$MYDOCUMENTS$\
SSL/TLS Version=3
SSL/TLS Current User Certificate=1
Window Title=%s - %p (%h)
WinInfo Default Restored=0 0 0 0 10 10 757 491 0 0 0=16 9 0 0 400 0 0 0 0 3 2 2 0 HE_Bitmap| 0 0
WinInfo Default Maximized=0 0 0 0 -8 -8 1382 744 0 0 0=26 13 0 0 400 0 0 0 0 3 2 2 0 HE_Bitmap| 0 0
Window State=2
Use Specific PRTSCR Printer=On
PrtScr DevMode=1025,1024,156,1497,33565,1,256,2794,1240,0,1,4,300,1,1,300,1,0,0,1,0,0,0,0,ZDesigner TLP 3842,User defined
PrtScr Driver=winspool
PrtScr Device=ZDesigner TLP 3842
PrtScr Output=LPT1:
Print Header2=Page: %# Document Name: %F
Use Specific VT Printer=On
VT DevMode=1025,1024,156,1497,323347,1,256,190,914,0,1,4,300,1,1,300,1,0,0,1,0,0,0,0,ZDesigner TLP 3842,User defined
VT Driver=winspool
VT Device=ZDesigner TLP 3842
VT Output=USB001
Printer Target=1
VT Printer Bypass Windows Print Driver=Off
VT Printer Use Default Font=On
VT Printer Margins Left=320
VT Printer Margins Right=0
VT Printer Margins Bottom=0
VT Printer Margins Top=0
QuickKey=ID
Auto Start Quick-Key=mmcs.ebs
VT Auto Wrap=On
Profile File Version=200
Kerberos Use Hummingbird Kerberos=On
Cryptographic Mode=1
Save Mode=0
[HEBar-59440]
sizeFloatCX=534
sizeFloatCY=28
[Toolbars Layout-Summary]
Bars=3
ScreenCX=1366
ScreenCY=768
[Session Options]
Last Selected Page=4000
[Toolbars]
ToolBarVersion=5
bar0=Default VT ToolBar.tbv
[Toolbars Layout-Bar0]
BarID=59393
[Toolbars Layout-Bar1]
BarID=59419
Bars=3
Bar#0=0
Bar#1=59440
Bar#2=0
[Toolbars Layout-Bar2]
BarID=59440
XPos=-2
YPos=-2
Docking=1
MRUDockID=59419
MRUDockLeftPos=-2
MRUDockTopPos=-2
MRUDockRightPos=541
MRUDockBottomPos=26
MRUFloatStyle=8196
MRUFloatXPos=-1
MRUFloatYPos=0
[Hotspots]
ShowTips=1
[ShortcutReplacers]
Size=0
[Site Info]
EnableCache=Yes
RefreshCacheOnConnect=0
AlwaysUploadUsing=0
AlwaysDownloadUsing=0
ProfileVersion=5
SecurityType=0
Port=21
SSL Implicit Port=990
Directory2=000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
SFTPProxyAddress=localhost
SFTPProxyPort=0
SFTPWriteSize=256
GssApiServiceName=host
GssApiProtLevel=P
GssApiIssueCCC=No
GssApiPBSZ=16000
GssApiKerbClient=0
GssApiUseHMS2MIT=No
PassiveMode=2
AllowServer2ServerTransfer=0
KeepAlive=No
AutoDetectServerType=Yes
KeepAliveTime=0
FirewallPort=21
FirewallType=0
NetworkTimeout=120
ConvertGMTTime=0
ResolveLinks=1
RootToInitialDir=0
ChangeDirChangesRoot=0
DropVmsVersion=0
LocalHourOffset=0
LocalMinuteOffset=0
TemporaryProfile=0
SSLVersion=1
SSLReuse=0
SSLUserCertificateMode=0
SSLCloseOnNegotiateionFail=0
SSLAcceptServerSelfSignedCertificates=Yes
SSLAcceptServerUnverifiedCertificates=Yes
SSLDataChannelProtected=Yes
SSLCryptographicMode=1
AllowResume=No
EPSV4=0
[Stats]
NumVisits=0

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,144 @@
# Get-PCProfile.ps1 - Shared helper for resolving the active PC profile
# from site-config.json. Dot-source this from any script that needs
# profile-aware configuration:
#
# . "$PSScriptRoot\lib\Get-PCProfile.ps1" (from Shopfloor\ scripts)
# . "$PSScriptRoot\Get-PCProfile.ps1" (from lib\ scripts)
#
# After dot-sourcing, these variables are available:
# $siteConfig - full parsed site-config.json (or $null)
# $pcType - from pc-type.txt (e.g. "Standard", "CMM", "Display")
# $pcSubtype - from pc-subtype.txt (e.g. "Timeclock", "Machine", "Lobby")
# $profileKey - lookup key (e.g. "Standard-Machine", "CMM", "Display-Lobby")
# $pcProfile - the matched profile object from pcProfiles, or $null
#
# Resolution order for any config key (e.g. startupItems):
# 1. $pcProfile.startupItems (profile-specific override)
# 2. $siteConfig.startupItems (site-wide default)
# 3. (hardcoded fallback in the calling script)
#
# Usage pattern in consuming scripts:
# $items = Get-ProfileValue 'startupItems'
# if ($null -ne $items) { ... } else { <hardcoded fallback> }
#
# Get-ProfileValue distinguishes "not set" ($null) from "explicitly empty"
# (@()) so a profile can opt out of a site-wide default by setting the
# key to []. Do NOT use truthiness checks like `if ($pcProfile.startupItems)`
# because an empty array is falsy and would fall through to site defaults.
function Get-ProfileValue {
param([Parameter(Mandatory)][string]$Key)
if ($pcProfile -and ($pcProfile.PSObject.Properties.Name -contains $Key)) {
return ,@($pcProfile.$Key)
}
if ($siteConfig -and ($siteConfig.PSObject.Properties.Name -contains $Key)) {
return ,@($siteConfig.$Key)
}
return $null
}
function Get-SiteConfig {
$configPath = 'C:\Enrollment\site-config.json'
if (-not (Test-Path -LiteralPath $configPath)) {
Write-Host "site-config.json not found - using defaults" -ForegroundColor DarkGray
return $null
}
try {
return (Get-Content -LiteralPath $configPath -Raw -ErrorAction Stop | ConvertFrom-Json)
} catch {
Write-Warning "Failed to parse site-config.json: $_"
return $null
}
}
$siteConfig = Get-SiteConfig
# Read PC type + sub-type from the files startnet.cmd wrote during imaging
$pcType = ''
$pcSubtype = ''
$typeFile = 'C:\Enrollment\pc-type.txt'
$subtypeFile = 'C:\Enrollment\pc-subtype.txt'
if (Test-Path -LiteralPath $typeFile) {
$pcType = (Get-Content -LiteralPath $typeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
if (Test-Path -LiteralPath $subtypeFile) {
$pcSubtype = (Get-Content -LiteralPath $subtypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
# Display sub-type fallback: if pc-subtype.txt is absent (post-rename-reorg
# default) but display-type.txt exists, use it as the subtype. Lets the
# Display-Lobby / Display-Dashboard / gea-shopfloor-display-{lobby,dashboard}
# profile keys resolve correctly for Display PCs.
$displayTypeFile = 'C:\Enrollment\display-type.txt'
if (-not $pcSubtype -and ($pcType -ieq 'gea-shopfloor-display' -or $pcType -ieq 'Display') -and (Test-Path -LiteralPath $displayTypeFile)) {
$pcSubtype = (Get-Content -LiteralPath $displayTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
# Build the profile key: "Standard-Machine", "CMM", "Display-Lobby", etc.
$profileKey = if ($pcSubtype) { "$pcType-$pcSubtype" } else { $pcType }
# 2026-05-03 rename: when the legacy key (e.g. "Standard-Machine") is not
# present in pcProfiles, fall through to the new gea-shopfloor-* equivalent
# (e.g. "gea-shopfloor-collections"). Vice versa: a fleet PC that already
# writes "gea-shopfloor-collections" finds its profile under either key.
$pcProfileAliasGroups = @(
@('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'),
@('Standard-Timeclock', 'gea-shopfloor-common'),
@('Lab', 'gea-shopfloor-common'),
@('CMM', 'gea-shopfloor-cmm'),
@('Keyence', 'gea-shopfloor-keyence'),
@('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'),
@('Display-Lobby', 'gea-shopfloor-display-Lobby', 'gea-shopfloor-display-lobby'),
@('Display-Dashboard', 'gea-shopfloor-display-Dashboard', 'gea-shopfloor-display-dashboard'),
@('Heattreat', 'gea-shopfloor-heattreat')
)
# Look up the profile in pcProfiles. Fall back to $null (callers use
# site-wide defaults or hardcoded values).
$pcProfile = $null
if ($siteConfig -and $siteConfig.pcProfiles -and $profileKey) {
$pcProfile = $siteConfig.pcProfiles.$profileKey
if (-not $pcProfile) {
foreach ($g in $pcProfileAliasGroups) {
if ($g -icontains $profileKey) {
foreach ($alias in $g) {
if ($alias -ieq $profileKey) { continue }
$candidate = $siteConfig.pcProfiles.$alias
if ($candidate) { $pcProfile = $candidate; break }
}
break
}
}
}
# Subtype-strip fallback: a compound key like "gea-shopfloor-cmm-doda" (DODA
# CMM bays set pc-subtype.txt=doda) has no profile of its own. Fall back to
# the BARE pc-type ("gea-shopfloor-cmm" -> "CMM"). Without this the profile
# resolves to $null and callers silently use hardcoded defaults (wrong
# desktop icons + unwanted startup items).
if (-not $pcProfile -and $pcSubtype) {
$bare = $pcType
$pcProfile = $siteConfig.pcProfiles.$bare
if (-not $pcProfile) {
foreach ($g in $pcProfileAliasGroups) {
if ($g -icontains $bare) {
foreach ($alias in $g) {
if ($alias -ieq $bare) { continue }
$candidate = $siteConfig.pcProfiles.$alias
if ($candidate) { $pcProfile = $candidate; break }
}
break
}
}
}
if ($pcProfile) { $profileKey = $bare }
}
}
if ($pcProfile) {
Write-Host "PC profile: $profileKey" -ForegroundColor Cyan
} elseif ($profileKey) {
Write-Host "PC profile '$profileKey' not found in site-config.json - using site defaults" -ForegroundColor DarkGray
}

View File

@@ -0,0 +1,100 @@
# Invoke-FilteredReportIP.ps1
# Reports this bay's GE corp IP to the GE Tines webhook with a tight
# subnet filter so PXE LAN (10.9.100.x) or any other 10.x stray address
# never leaks. Mirrors the payload shape of GE's Intune-deployed
# ReportIPAddresses_v2.ps1 so the receiving Tines workflow accepts it.
#
# WHY:
# GE's signed Report IP script filters Get-NetIPAddress with
# $_.StartsWith("10.") - too broad for WJ where PXE LAN is also 10.x.
# Calling our own POST with a narrow filter guarantees the webhook
# only ever sees the bay's AESFMA corp 10.x address.
#
# ALLOWED RANGES (WJ-specific - update if site moves to a new VLAN):
# 10.134.48.0/23 (10.134.48.0 - 10.134.49.255)
# 10.48.249.0/26 (10.48.249.0 - 10.48.249.63)
param(
[switch]$Force,
[int]$MaxAttempts = 6,
[int]$RetrySeconds = 10
)
$ErrorActionPreference = 'Continue'
$logFile = 'C:\Logs\FilteredReportIP.log'
New-Item -ItemType Directory -Path 'C:\Logs' -Force -ErrorAction SilentlyContinue | Out-Null
function Log([string]$msg) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"$ts $msg" | Tee-Object -FilePath $logFile -Append
}
# Allowed ranges (CIDR -> first IP int + prefix bits)
$allowedRanges = @(
@{ Network = '10.134.48.0'; PrefixLen = 23 },
@{ Network = '10.48.249.0'; PrefixLen = 26 }
)
function ConvertTo-Uint32($ip) {
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
[Array]::Reverse($bytes)
return [BitConverter]::ToUInt32($bytes, 0)
}
function Test-InAllowedRange([string]$ip) {
try {
$ipInt = ConvertTo-Uint32 $ip
foreach ($r in $allowedRanges) {
$netInt = ConvertTo-Uint32 $r.Network
$mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $true }
}
} catch {}
return $false
}
Log "=== Filtered Report IP fire ==="
$allIPs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' } |
Select-Object -ExpandProperty IPAddress
Log "All IPv4 addresses on this bay: $($allIPs -join ', ')"
$match = @($allIPs | Where-Object { Test-InAllowedRange $_ })
if ($match.Count -eq 0) {
Log "No IP in allowed ranges (10.134.48.0/23 or 10.48.249.0/26). Not POSTing."
exit 2
}
$reportIP = $match[0]
Log "Reporting IP: $reportIP"
$statusFile = "$env:ProgramData\GEA\FilteredReportIP\last-ip.txt"
$lastIP = ''
if (Test-Path $statusFile) { $lastIP = (Get-Content $statusFile -ErrorAction SilentlyContinue) -join '' }
if (-not $Force -and $lastIP -eq $reportIP) {
Log "IP unchanged from last POST ($lastIP). Skipping."
exit 0
}
$webhookUrl = 'https://tines.apps.geaerospace.com/webhook/aa891efac08bab3d077d9956ab6614d0/c687048cb0ee35c33760ad14cb9257eb'
$body = @{
host = $env:COMPUTERNAME
fqdn = "$env:COMPUTERNAME.device.geaerospace.net"
IP = $reportIP
force_update = if ($Force) { 'True' } else { 'False' }
}
for ($i = 1; $i -le $MaxAttempts; $i++) {
try {
Log "POST attempt $i/$MaxAttempts ..."
$resp = Invoke-WebRequest -Uri $webhookUrl -Method POST -Body $body -TimeoutSec 60 -UseBasicParsing -ErrorAction Stop
Log "POST succeeded. StatusCode=$($resp.StatusCode) Content=$($resp.Content)"
New-Item -ItemType Directory -Path (Split-Path $statusFile) -Force -ErrorAction SilentlyContinue | Out-Null
Set-Content -Path $statusFile -Value $reportIP -Force
exit 0
} catch {
Log "POST attempt $i failed: $_"
if ($i -lt $MaxAttempts) { Start-Sleep -Seconds $RetrySeconds }
}
}
Log "All $MaxAttempts attempts failed - giving up."
exit 1

View File

@@ -0,0 +1,305 @@
# Map-SfldShare.ps1 - keep the SFLD share drive (S:) alive in the interactive
# user's session. Run by the 'GE Map SFLD Share' scheduled task as the
# logged-on user (BUILTIN\Users, Limited): at logon, every few minutes, on
# unlock / console connect, and when a network profile connects.
#
# Rules (the drive is never touched while it works):
# healthy -> exit, no changes.
# no live mapping -> map now (nothing to interrupt).
# mapped but unreadable -> repair only after $ConfirmFailures consecutive
# failed probes, and only if TCP 445 answers.
# mapped to wrong target -> same confirm-then-repair path.
#
# Creds: HKLM:\SOFTWARE\GE\SFLD\Credentials\* entries that carry DriveLetter +
# ShareName (written by the vendor SetupCredentials package). The password is
# never put on a command line: New-SmbMapping runs in-process and the
# Credential Manager seed uses CredWrite via P/Invoke. The seed is what lets
# Windows' own logon-time restore and the redirector's transparent reconnect
# authenticate, so both stop producing red-X drives.
param(
[int]$ConfirmFailures = 2,
[int]$ProbeTimeoutSec = 8,
[switch]$Now # repair on first failed probe (manual / logon use)
)
$ErrorActionPreference = 'Continue'
# --- single instance per session ---------------------------------------
$mutex = New-Object System.Threading.Mutex($false, 'Local\GE-MapSfldShare')
if (-not $mutex.WaitOne(0)) { exit 0 }
# --- logging -----------------------------------------------------------
$logDir = 'C:\Logs\SFLD'
try {
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force -EA Stop | Out-Null }
$probeFile = Join-Path $logDir ('.w-' + [guid]::NewGuid().ToString('N'))
[IO.File]::WriteAllText($probeFile, 'x'); Remove-Item $probeFile -Force -EA SilentlyContinue
} catch {
$logDir = Join-Path $env:LOCALAPPDATA 'GE\SfldShare'
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force -EA SilentlyContinue | Out-Null }
}
$logFile = Join-Path $logDir 'map-share.log'
function Write-MapLog {
param([string]$Message, [string]$Level = 'INFO')
$line = '[{0}] [{1}] [{2}] {3}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $env:USERNAME, $Level, $Message
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
# --- per-user state (fail counter, last state) --------------------------
$stateRoot = 'HKCU:\SOFTWARE\GE\SfldShare'
function Get-State {
param([string]$Letter)
$k = Join-Path $stateRoot $Letter
if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null }
$p = Get-ItemProperty -Path $k -EA SilentlyContinue
return @{
Key = $k
FailCount = [int]($p.FailCount)
LastState = [string]$p.LastState
CredHash = [string]$p.CredHash # SHA256(user + password) last seeded
}
}
function Set-State {
param([string]$Key, [int]$FailCount, [string]$LastState, [string]$CredHash = '')
Set-ItemProperty -Path $Key -Name FailCount -Value $FailCount -Type DWord -EA SilentlyContinue
Set-ItemProperty -Path $Key -Name LastState -Value $LastState -Type String -EA SilentlyContinue
Set-ItemProperty -Path $Key -Name LastRun -Value (Get-Date -Format 's') -Type String -EA SilentlyContinue
# only overwrite the seeded-credential hash when the caller supplies one
if ($CredHash) { Set-ItemProperty -Path $Key -Name CredHash -Value $CredHash -Type String -EA SilentlyContinue }
}
function Get-CredHash {
param([string]$User, [string]$Password)
$sha = [System.Security.Cryptography.SHA256]::Create()
$b = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("$User`n$Password"))
$sha.Dispose()
return ([BitConverter]::ToString($b) -replace '-', '')
}
# --- probes -------------------------------------------------------------
function Test-Tcp445 {
param([string]$HostName, [int]$TimeoutMs = 3000)
try {
$c = New-Object System.Net.Sockets.TcpClient
$ar = $c.BeginConnect($HostName, 445, $null, $null)
$ok = $ar.AsyncWaitHandle.WaitOne($TimeoutMs)
if ($ok) { $c.EndConnect($ar) }
$c.Close()
return $ok
} catch { return $false }
}
# Real read of the drive root in a runspace with a hard timeout. Test-Path on
# a dead redirector connection can block for 30 s or more.
function Test-DriveReadable {
param([string]$Drive, [int]$TimeoutSec)
$ps = [powershell]::Create()
$null = $ps.AddScript({
param($d)
try {
$e = [System.IO.Directory]::EnumerateFileSystemEntries("$d\").GetEnumerator()
$null = $e.MoveNext()
'OK'
} catch { 'ERR: ' + $_.Exception.GetType().Name + ': ' + $_.Exception.Message }
}).AddArgument($Drive)
$h = $ps.BeginInvoke()
if ($h.AsyncWaitHandle.WaitOne($TimeoutSec * 1000)) {
$r = $ps.EndInvoke($h); $ps.Dispose()
return [string]$r[0]
}
try { $ps.Stop() } catch {}
$ps.Dispose()
return 'TIMEOUT'
}
# --- credential manager seed (CredWrite, no command line) ---------------
$script:credTypeLoaded = $false
function Set-StoredDomainCredential {
param([string]$Target, [string]$User, [string]$Password)
if (-not $script:credTypeLoaded) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class GeCredStore {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CREDENTIAL {
public uint Flags; public uint Type; public string TargetName; public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist;
public uint AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName;
}
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool CredWrite([In] ref CREDENTIAL cred, uint flags);
public static int Write(string target, string user, string password) {
byte[] blob = System.Text.Encoding.Unicode.GetBytes(password);
CREDENTIAL c = new CREDENTIAL();
c.Type = 2; // CRED_TYPE_DOMAIN_PASSWORD
c.Persist = 2; // CRED_PERSIST_LOCAL_MACHINE
c.TargetName = target; c.UserName = user; c.Comment = "GE SFLD share";
c.CredentialBlobSize = (uint)blob.Length;
c.CredentialBlob = Marshal.AllocHGlobal(blob.Length);
Marshal.Copy(blob, 0, c.CredentialBlob, blob.Length);
bool ok = CredWrite(ref c, 0);
int err = ok ? 0 : Marshal.GetLastWin32Error();
Marshal.FreeHGlobal(c.CredentialBlob);
return err;
}
}
'@ -ErrorAction Stop
$script:credTypeLoaded = $true
}
return [GeCredStore]::Write($Target, $User, $Password)
}
# --- mapping ------------------------------------------------------------
function New-Mapping {
param([string]$Drive, [string]$Unc, [string]$User, [string]$Password, [string]$HostName, [bool]$PreFlight = $false)
$letter = $Drive.TrimEnd(':')
# When a mapping (wrong or unreadable) is being replaced, prove the new
# one can be made BEFORE deleting the old: map the target to a spare
# letter, then drop it. A failed pre-flight leaves the old mapping in
# place. 1219 is the exception - it means the old mapping's own session
# is what blocks the new identity, so deleting it is the only way.
if ($PreFlight) {
$used = @((Get-PSDrive -PSProvider FileSystem -EA SilentlyContinue).Name) + @((Get-SmbMapping -EA SilentlyContinue).LocalPath | ForEach-Object { $_.TrimEnd(':') })
$spare = $null
foreach ($c in [char[]]'ZYXVUTQPONMLKJIHG') { if ($used -notcontains [string]$c) { $spare = "${c}:"; break } }
if ($spare) {
try {
New-SmbMapping -LocalPath $spare -RemotePath $Unc -UserName $User -Password $Password -Persistent $false -ErrorAction Stop | Out-Null
$ok = Test-DriveReadable -Drive $spare -TimeoutSec 8
& net use $spare /delete /y 2>&1 | Out-Null
if ($ok -ne 'OK') { return "pre-flight via $spare mapped but unreadable ($ok) - old $Drive left in place" }
} catch {
$m = $_.Exception.Message
if ($m -notmatch '1219|more than one user name|Multiple connections') {
return "pre-flight via $spare failed: $m - old $Drive left in place"
}
Write-MapLog "pre-flight hit 1219 - the existing $Drive session blocks the new identity; replacing it" 'WARN'
}
}
}
& net use $Drive /delete /y 2>&1 | Out-Null
Remove-Item -Path "HKCU:\Network\$letter" -Recurse -Force -EA SilentlyContinue
try {
New-SmbMapping -LocalPath $Drive -RemotePath $Unc -UserName $User -Password $Password -Persistent $true -ErrorAction Stop | Out-Null
return $null
} catch {
$msg = $_.Exception.Message
if ($msg -match '1219|more than one user name|Multiple connections') {
# stale session to the same host under another identity pins the
# credential; the hidden IPC$ connection is the pin.
Write-MapLog "1219 credential conflict on $HostName - dropping IPC`$ and retrying" 'WARN'
& net use "\\$HostName\IPC`$" /delete /y 2>&1 | Out-Null
try {
New-SmbMapping -LocalPath $Drive -RemotePath $Unc -UserName $User -Password $Password -Persistent $true -ErrorAction Stop | Out-Null
return $null
} catch { return $_.Exception.Message }
}
return $msg
}
}
# --- main ---------------------------------------------------------------
$credsBase = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
if (-not (Test-Path $credsBase)) { Write-MapLog 'No HKLM SFLD credentials yet - exiting'; exit 0 }
$entries = @()
foreach ($entry in (Get-ChildItem -Path $credsBase -ErrorAction SilentlyContinue)) {
$p = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue
if (-not $p -or -not $p.TargetHost -or -not $p.Username -or -not $p.Password) { continue }
$drive = [string]$p.DriveLetter; $share = [string]$p.ShareName
if ([string]::IsNullOrWhiteSpace($drive) -or [string]::IsNullOrWhiteSpace($share)) { continue }
$entries += [pscustomobject]@{
Drive = $drive.Trim().TrimEnd(':').ToUpper() + ':'
Unc = '\\{0}\{1}' -f $p.TargetHost, $share.TrimStart('\').TrimEnd('\')
Host = [string]$p.TargetHost
User = [string]$p.Username
Pass = [string]$p.Password
}
}
if ($entries.Count -eq 0) { exit 0 }
$exit = 0
foreach ($e in $entries) {
$letter = $e.Drive.TrimEnd(':')
$st = Get-State -Letter $letter
$mapping = Get-SmbMapping -LocalPath $e.Drive -ErrorAction SilentlyContinue
$present = [bool]$mapping
$target = if ($mapping) { [string]$mapping.RemotePath } else { '' }
$wrong = $present -and ($target.TrimEnd('\') -ne $e.Unc)
# Seed Credential Manager whenever the HKLM credential differs from what
# this profile last seeded (first run, or a password rotation), and again
# after every repair. Keeps Windows' own logon-time restore authenticating.
$credHash = Get-CredHash -User $e.User -Password $e.Pass
if ($st.CredHash -ne $credHash) {
$rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass
if ($rc -eq 0) {
Write-MapLog "Seeded Credential Manager for $($e.Host) as $($e.User)$(if ($st.CredHash) { ' (credential changed)' })"
Set-State -Key $st.Key -FailCount $st.FailCount -LastState $st.LastState -CredHash $credHash
} else { Write-MapLog "CredWrite for $($e.Host) failed (win32 $rc)" 'WARN' }
}
$probe = 'SKIPPED'
if ($present -and -not $wrong) { $probe = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec }
if ($present -and -not $wrong -and $probe -eq 'OK') {
if ($st.LastState -ne 'healthy') { Write-MapLog "$($e.Drive) healthy -> $($e.Unc)" }
Set-State -Key $st.Key -FailCount 0 -LastState 'healthy'
continue
}
# --- not healthy --------------------------------------------------
if (-not $present) {
Write-MapLog "$($e.Drive) has no live mapping - mapping now"
} elseif ($wrong) {
$st.FailCount++
Write-MapLog "$($e.Drive) points at '$target' not '$($e.Unc)' ($($st.FailCount)/$ConfirmFailures)" 'WARN'
} else {
$st.FailCount++
Write-MapLog "$($e.Drive) mapped (status $($mapping.Status)) but probe failed: $probe ($($st.FailCount)/$ConfirmFailures)" 'WARN'
}
if ($present -and -not $Now -and $st.FailCount -lt $ConfirmFailures) {
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'suspect'
continue
}
if (-not (Test-Tcp445 -HostName $e.Host)) {
Write-MapLog "$($e.Host):445 unreachable - not repairing $($e.Drive) this run" 'WARN'
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'unreachable'
$exit = 2
continue
}
Write-MapLog "Repairing $($e.Drive) -> $($e.Unc)$(if ($present) { ' (pre-flight first, existing mapping kept on failure)' })"
$err = New-Mapping -Drive $e.Drive -Unc $e.Unc -User $e.User -Password $e.Pass -HostName $e.Host -PreFlight $present
if ($err) {
Write-MapLog "Repair FAILED $($e.Drive): $err" 'ERROR'
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed'
$exit = 1
continue
}
$verify = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec
if ($verify -eq 'OK') {
Write-MapLog "Repaired $($e.Drive) -> $($e.Unc) (verified readable)"
# re-seed after every repair so the stored credential matches what
# just proved to work
$rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass
if ($rc -ne 0) { Write-MapLog "CredWrite after repair failed (win32 $rc)" 'WARN' }
Set-State -Key $st.Key -FailCount 0 -LastState 'healthy' -CredHash $credHash
} else {
Write-MapLog "Mapped $($e.Drive) but verify failed: $verify" 'ERROR'
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed'
$exit = 1
}
}
$mutex.ReleaseMutex() | Out-Null
exit $exit

View File

@@ -249,27 +249,6 @@ function Get-Phase1 {
# the force-lockdown marker has been written by sfld_autologon.ps1. # the force-lockdown marker has been written by sfld_autologon.ps1.
# When either signal is present, push idx=8 / status=succeeded so the # When either signal is present, push idx=8 / status=succeeded so the
# dashboard card flips to 100% green. # dashboard card flips to 100% green.
# Lockdown handover also ends the imaging autologon budget.
# Run-ShopfloorSetup tops AutoLogonCount up to 10 for SupportUser, and the
# Sysinternals Autologon run that switches the bay to ShopFloor leaves it
# in place. ShopFloor then inherited the remainder and Windows turned
# autologon off when it reached 0 - the bay parked at a ShopFloor password
# prompt a few reboots after imaging. Delete it as soon as Winlogon shows
# ShopFloor. This task runs as Users (Limited) and the HKLM write can be
# denied; the SYSTEM "GE Shopfloor Post-PPKG" task (Invoke-PostPpkg.ps1)
# does the same cleanup at every startup until it has succeeded.
try {
$wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
$wlNow = Get-ItemProperty $wlKey -ErrorAction Stop
if ($wlNow.DefaultUserName -like 'ShopFloor*' -and $null -ne $wlNow.AutoLogonCount) {
Remove-ItemProperty $wlKey -Name AutoLogonCount -ErrorAction Stop
Write-Host "Removed leftover AutoLogonCount ($($wlNow.AutoLogonCount)) after ShopFloor handover"
}
} catch {
Write-Warning "Could not clear AutoLogonCount: $_"
}
if (-not $script:cache.LockdownCompletePushed) { if (-not $script:cache.LockdownCompletePushed) {
$lockdownApplied = $false $lockdownApplied = $false
try { try {
@@ -947,9 +926,12 @@ function Format-Snapshot {
@{ Ok = $Snap.Phase3.InstallComplete; Failed = $false } @{ Ok = $Snap.Phase3.InstallComplete; Failed = $false }
) )
# Phase 4 (per-script Application Install) was retired 2026-04-28: # Phase 4 (per-script Application Install) was retired 2026-04-28:
# site apps install from the post-PPKG list after the reboot # apps now deploy via the GE-Enforce manifest engine off
# (Invoke-PostPpkg.ps1). The monitor's responsibility ends at # \\tsgwp00525\...\machineapps\, gated on AESFMA WiFi + S: mount
# "DSC bootstrap + creds + lockdown landed, time to reboot." # which both happen post-reboot. The monitor's responsibility ends
# at "DSC bootstrap + creds + lockdown landed, time to reboot."
# The manifest engine takes over from there on the next ShopFloor
# logon.
$p4Done = ($Snap.Phase5.ConsumeCredsTask -and $Snap.Phase5.CredsPopulated) $p4Done = ($Snap.Phase5.ConsumeCredsTask -and $Snap.Phase5.CredsPopulated)
$p4Status = Get-PhaseStatus @( $p4Status = Get-PhaseStatus @(
@@ -1247,7 +1229,7 @@ if ($AsTask -and (Test-Path -LiteralPath $syncCompleteMarker)) {
# For these types, enrollment is "done" once Phase 1 (Identity) completes # For these types, enrollment is "done" once Phase 1 (Identity) completes
# and policies start arriving - there is no Phase 2-5 to wait for. # and policies start arriving - there is no Phase 2-5 to wait for.
$pcTypeFile = 'C:\Enrollment\pc-type.txt' $pcTypeFile = 'C:\Enrollment\pc-type.txt'
$noDscTypes = @() $noDscTypes = @('Display', 'gea-shopfloor-display')
$skipDsc = $false $skipDsc = $false
if (Test-Path $pcTypeFile) { if (Test-Path $pcTypeFile) {
$pcType = (Get-Content $pcTypeFile -First 1).Trim() $pcType = (Get-Content $pcTypeFile -First 1).Trim()
@@ -1305,14 +1287,29 @@ try {
$qrRefreshed = [bool]($qrText -notmatch 'not yet Azure AD joined') $qrRefreshed = [bool]($qrText -notmatch 'not yet Azure AD joined')
} }
# Filtered Report IP shim - POSTs this bay's GE corp IP to the
# Tines webhook directly with a tight subnet filter. Fires each
# tick until it succeeds once (script's own status-file dedup
# prevents duplicate POSTs). Bypasses the timing window where
# GE's Intune-deployed Report IP misses (it may run before the
# SCEP cert + AESFMA join land).
if (-not $filteredReportIpSucceeded) {
$filteredScript = Join-Path $PSScriptRoot 'Invoke-FilteredReportIP.ps1'
if (Test-Path $filteredScript) {
$rc = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $filteredScript 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { $filteredReportIpSucceeded = $true }
}
}
Clear-Host Clear-Host
Format-Snapshot -Snap $snap -LastSync $lastSync -NextRetrigger $nextRetrigger Format-Snapshot -Snap $snap -LastSync $lastSync -NextRetrigger $nextRetrigger
Write-Host "" Write-Host ""
Write-Host $qrText Write-Host $qrText
# Final state: every phase landed. Apps are no longer in the gate - # Final state: every phase landed. Apps are no longer in the gate -
# the site's post-PPKG list installs them after the reboot. So this # they deploy via the GE-Enforce manifest engine post-reboot, gated
# gate is now: # on AESFMA WiFi + S: drive mount which only happen after we hand
# off to the ShopFloor autologon session. So this gate is now:
# - CredsPopulated: SFLD share creds in HKLM (Machine-Enforce, # - CredsPopulated: SFLD share creds in HKLM (Machine-Enforce,
# Acrobat-Enforce, CMM-Enforce, manifest # Acrobat-Enforce, CMM-Enforce, manifest
# engine all need these) # engine all need these)

View File

@@ -0,0 +1,88 @@
# Restore-EDncReg.ps1 - Import a per-machine eDNC .reg backup from a folder.
#
# Two callers:
# 1. Standard/03-RestoreEDncConfig.ps1 (imaging-time, source = local PXE copy)
# 2. Shopfloor/lib/Update-MachineNumber.ps1 (post-image 9999 -> real, source
# = tsgwp00525 SFLD share mounted via existing SFLD credential pattern)
#
# The .reg files live as <num>-<host>.reg or <num>.reg. Returns the path
# that was imported, or $null if nothing was found/imported. Errors are
# caught and logged via Write-Host (no throwing); callers continue.
#
# Dot-source with:
# . "$PSScriptRoot\lib\Restore-EDncReg.ps1" (from Shopfloor\)
# . "$PSScriptRoot\..\Shopfloor\lib\Restore-EDncReg.ps1" (from Standard\)
function Mount-SFLDShare {
<#
.SYNOPSIS
Mounts a \\server\share path using SFLD creds from
HKLM:\SOFTWARE\GE\SFLD\Credentials\*. Returns $true on success,
$false if creds are missing or mount fails.
#>
param(
[Parameter(Mandatory)][string]$SharePath,
[string]$DriveLetter = 'V:'
)
$server = ($SharePath -replace '^\\\\', '') -split '\\' | Select-Object -First 1
$basePath = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
if (-not (Test-Path $basePath)) { return $false }
$cred = $null
foreach ($entry in Get-ChildItem -Path $basePath -ErrorAction SilentlyContinue) {
$props = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue
if (-not $props -or -not $props.TargetHost) { continue }
if ($props.TargetHost -eq $server -or
$props.TargetHost -like "$server.*" -or
$server -like "$($props.TargetHost).*") {
$cred = $props; break
}
}
if (-not $cred -or -not $cred.Username -or -not $cred.Password) { return $false }
& net use $DriveLetter /delete /y 2>$null | Out-Null
$null = & net use $DriveLetter $SharePath /user:$($cred.Username) $($cred.Password) /persistent:no 2>&1
return ($LASTEXITCODE -eq 0)
}
function Import-EDncRegBackup {
<#
.SYNOPSIS
Looks for <MachineNumber>-*.reg or <MachineNumber>.reg in SourceRoot
and runs `reg import` on it. Caller is responsible for mounting any
network share first; this function works on whatever local or drive-
letter path is handed in.
.PARAMETER SourceRoot
Directory containing the .reg backups.
.PARAMETER MachineNumber
The target machine number (digits). Used as a filename prefix.
.OUTPUTS
Path of the imported file, or $null if none found / import failed.
#>
param(
[Parameter(Mandatory)][string]$SourceRoot,
[Parameter(Mandatory)][string]$MachineNumber
)
if (-not (Test-Path -LiteralPath $SourceRoot)) {
Write-Host " Restore-EDncReg: source root not found: $SourceRoot"
return $null
}
$candidate = Get-ChildItem -Path $SourceRoot -Filter "$MachineNumber.reg" -File -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $candidate) {
Write-Host " Restore-EDncReg: no backup for machine $MachineNumber in $SourceRoot"
return $null
}
Write-Host " Restore-EDncReg: importing $($candidate.FullName)"
$out = & reg.exe import "$($candidate.FullName)" 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " Restore-EDncReg: reg import failed (exit $LASTEXITCODE): $out"
return $null
}
Write-Host " Restore-EDncReg: imported OK"
return $candidate.FullName
}

View File

@@ -0,0 +1,44 @@
# Set-OpenTextAutoStart.ps1 - place WJ Shopfloor.lnk in the All Users
# Startup folder so HostExplorer's "WJ Shopfloor" session launches at
# every login. Idempotent: re-running is a no-op when the .lnk already
# exists at the same path.
#
# Used by per-pc-type 09-Setup scripts for shopfloor types whose only
# business app is OpenText (common, waxtrace, genspect, heattreat).
# collections + nocollections do NOT auto-start OpenText - their techs
# pick which apps via Configure-PC.ps1.
#
# Source .lnk is created by the OpenText preinstall (Setup-OpenText.ps1)
# on the public desktop. If the .lnk is missing, log a warning and exit
# 0 - imaging chain still continues; auto-start can be re-attempted on a
# subsequent login by re-running this script.
$ErrorActionPreference = 'Continue'
$startupDir = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp'
$publicDesktop = 'C:\Users\Public\Desktop'
$candidates = @(
Join-Path $publicDesktop 'WJ Shopfloor.lnk'
Join-Path (Join-Path $publicDesktop 'Shopfloor Tools') 'WJ Shopfloor.lnk'
)
$src = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $src) {
Write-Warning "WJ Shopfloor.lnk not found on public desktop - OpenText auto-start NOT configured."
Write-Warning " Searched: $($candidates -join ' ; ')"
Write-Warning " Setup-OpenText.ps1 should create it during preinstall - check OpenText install state."
return
}
if (-not (Test-Path -LiteralPath $startupDir)) {
New-Item -Path $startupDir -ItemType Directory -Force | Out-Null
}
$dst = Join-Path $startupDir 'WJ Shopfloor.lnk'
try {
Copy-Item -LiteralPath $src -Destination $dst -Force
Write-Host "OpenText auto-start enabled: $dst (source: $src)"
} catch {
Write-Warning "Failed to copy WJ Shopfloor.lnk to startup: $_"
}

View File

@@ -31,7 +31,7 @@ $ErrorActionPreference = 'Continue'
# 1. -QRCoderDllPath if explicitly passed # 1. -QRCoderDllPath if explicitly passed
# 2. Next to this script ($PSScriptRoot\QRCoder.dll) # 2. Next to this script ($PSScriptRoot\QRCoder.dll)
# 3. Current working directory # 3. Current working directory
# 4. The standard staging path (C:\Enrollment\shopfloor-setup\Shopfloor) # 4. WJ canonical staging path (kept for cross-site portability)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
function Resolve-QRCoderDll { function Resolve-QRCoderDll {
param([string]$Override) param([string]$Override)

View File

@@ -0,0 +1,531 @@
# Update-MachineNumber.ps1 - Shared helper for reading and updating the
# machine number in UDC and eDNC. Dot-source from any script that needs
# machine-number operations:
#
# . "$PSScriptRoot\lib\Update-MachineNumber.ps1" (from Shopfloor\ scripts)
# . "$PSScriptRoot\..\Shopfloor\lib\Update-MachineNumber.ps1" (from Standard\ scripts)
# . "$PSScriptRoot\Update-MachineNumber.ps1" (from lib\ scripts)
#
# Exported functions:
# Get-CurrentMachineNumber - returns @{ Udc = $string_or_null; Ednc = $string_or_null }
# Update-MachineNumber - updates both, returns @{ UdcUpdated = $bool; EdncUpdated = $bool; Errors = @() }
#
# Both handle missing files/keys gracefully (app not installed = skip, not error).
$script:UdcSettingsPath = 'C:\ProgramData\UDC\udc_settings.json'
$script:UdcExePath = 'C:\Program Files\UDC\UDC.exe'
$script:EdncRegPath = 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General'
function Get-CurrentMachineNumber {
<#
.SYNOPSIS
Reads the current machine number from UDC settings JSON and eDNC registry.
.OUTPUTS
Hashtable with keys Udc ($string or $null) and Ednc ($string or $null).
#>
$result = @{ Udc = $null; Ednc = $null }
if (Test-Path $script:UdcSettingsPath) {
try {
$json = Get-Content $script:UdcSettingsPath -Raw | ConvertFrom-Json
$result.Udc = $json.GeneralSettings.MachineNumber
} catch {}
}
if (Test-Path $script:EdncRegPath) {
try {
$result.Ednc = (Get-ItemProperty -Path $script:EdncRegPath -Name MachineNo -ErrorAction Stop).MachineNo
} catch {}
}
return $result
}
function Update-MTConnectVariantName {
<#
.SYNOPSIS
Renames an MTConnect device across every file of a variant that carries it.
.DESCRIPTION
Makino and Fanuc do not fit the one-file-one-service shape the other
variants do.
Per the vendor's "Installation Configuration and Trouble Shooting" guide,
the device name lives in several files that must be identical or the agent
may not start, and the Adapter Manager service rewrites the COMPLETE
configuration when it stops - so an edit made while it runs is thrown away.
Makino, from a real bay capture:
Agent\Devices.xml <Device name="7502" uuid="7502" id="MC41">
Agent\Devices.EDM.xml <Device name="MAKINO-1" uuid="MAKINO-1" id="EDM41">
Data\AdapterDataFile.xml <Name>MAKINO-1</Name>
Agent\Agent.cfg Adapters { MAKINO-1 { Host=localhost ... } }
Fanuc, likewise:
Agent\devices.xml <Device id="3101" name="3101" uuid="3101">
Adapter\devices.xml the adapter's own generated copy
Adapter\adapter.xml <DeviceName>/<DeviceID>/<DeviceUUID>3101
Editing only the agent's copy leaves the adapter streaming under the old
identity. Seven of thirty-four captured Fanuc bays are in that state -
agent on the right machine number, adapter still on the bay it was
imaged from (4007 on a 7801 Toshulin, 3031 on a 7804).
The vendor's own path is ConfigurationManager.exe, a GUI tool, which a
remote renumber cannot drive. This does what it does, in the required
order: stop the manager and the agent, rewrite, start again.
Anything that cannot be renamed is reported rather than half-applied -
a bay whose files disagree is worse than one still on its old name.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$NewNumber,
[Parameter(Mandatory)][hashtable[]]$Targets,
# Overridable so the rename can be exercised against a captured bay.
[Parameter(Mandatory)][string]$Root,
# Whatever owns the files goes down first - for Makino the Adapter
# Manager rewrites its configuration from memory as it stops, and for
# Fanuc the adapter holds its own copy. Overridable only so a test can
# drive the stop/start ordering with real services.
[string[]]$Services = @()
)
$out = @{ Updated = @(); Errors = @() }
$present = @($Targets |
ForEach-Object { @{ Path = (Join-Path $Root $_.Path); Kind = $_.Kind } } |
Where-Object { Test-Path -LiteralPath $_.Path })
if (-not $present) { return $out }
# Collect EVERY name in play, not just one. A bay half-renamed by the old
# code carries two at once - Devices.xml already on the machine number while
# the adapter files still say MAKINO-n - and replacing only the name we
# happened to discover leaves the other behind, which is the very state this
# is meant to end. Gather them all, then converge every file on the target.
$names = New-Object System.Collections.Generic.HashSet[string]
foreach ($t in $present) {
$raw = Get-Content -LiteralPath $t.Path -Raw -ErrorAction SilentlyContinue
if (-not $raw) { continue }
switch ($t.Kind) {
'attr' { if ($raw -match '<Device[^>]+name="([^"]+)"') { [void]$names.Add($matches[1]) } }
'name' { if ($raw -match '<Name>\s*([^<]+?)\s*</Name>') { [void]$names.Add($matches[1]) } }
'cfg' {
# The adapter block's key is the bare line inside Adapters { }.
if ($raw -match '(?ms)Adapters\s*\{\s*\r?\n\s*([^\s{]+)') { [void]$names.Add($matches[1]) }
}
}
}
[void]$names.Remove($NewNumber)
if ($names.Count -eq 0) { return $out } # every file already on the target
# Agent is stopped too, so it re-reads Agent.cfg on the way back up.
$stopped = @()
foreach ($name in $Services) {
$svc = Get-Service -Name $name -ErrorAction SilentlyContinue
if (-not $svc) { continue }
if ($svc.Status -ne 'Stopped') {
try {
Stop-Service -Name $name -Force -ErrorAction Stop
$stopped += $name
} catch {
$out.Errors += "MTConnect: could not stop '$name' ($_); nothing was changed"
foreach ($back in $stopped) {
try { Start-Service -Name $back -ErrorAction Stop } catch {}
}
return $out
}
}
}
foreach ($t in $present) {
try {
$content = Get-Content -LiteralPath $t.Path -Raw -ErrorAction Stop
$updated = $content
$from = @()
foreach ($old in $names) {
$escaped = [regex]::Escape($old)
switch ($t.Kind) {
'attr' { $next = $updated -replace ('"' + $escaped + '"'), ('"' + $NewNumber + '"') }
'name' { $next = $updated -replace ('<Name>\s*' + $escaped + '\s*</Name>'), ("<Name>$NewNumber</Name>") }
# The adapter block is keyed by a bare name on its own line.
'cfg' { $next = $updated -replace ('(?m)^(\s*)' + $escaped + '(\s*)$'), ("`${1}$NewNumber`${2}") }
# <DeviceName>/<DeviceID>/<DeviceUUID> in the Fanuc adapter config
'elem' { $next = $updated -replace ('(<Device(?:Name|ID|UUID)>)\s*' + $escaped + '\s*(</Device(?:Name|ID|UUID)>)'), ("`${1}$NewNumber`${2}") }
}
if ($next -ne $updated) { $from += $old; $updated = $next }
}
if ($updated -eq $content) { continue } # this file was already done
Set-Content -LiteralPath $t.Path -Value $updated -NoNewline -ErrorAction Stop
$out.Updated += "$($t.Path) ($($from -join ', ') -> $NewNumber)"
} catch {
$out.Errors += "MTConnect: rewrite failed for $($t.Path): $_"
}
}
foreach ($name in $stopped) {
try { Start-Service -Name $name -ErrorAction Stop }
catch { $out.Errors += "MTConnect: restart of '$name' failed: $_" }
}
return $out
}
function Update-MachineNumber {
<#
.SYNOPSIS
Updates UDC + eDNC machine number in one step. Stops UDC before writing,
relaunches after.
.PARAMETER NewNumber
The new machine number (digits only - caller validates).
.PARAMETER Site
Site name passed to UDC.exe -site argument. Defaults to 'West Jefferson'.
.OUTPUTS
Hashtable: @{ UdcUpdated = $bool; EdncUpdated = $bool; Errors = @() }
#>
param(
[Parameter(Mandatory)]
[string]$NewNumber,
[string]$Site = 'West Jefferson'
)
$out = @{ UdcUpdated = $false; EdncUpdated = $false; Errors = @(); RegImported = $null; OldUdc = $null; OldEdnc = $null }
# If the machine number is changing (placeholder->real OR real->real
# reassignment after a duplicate-image), pull per-machine state for the
# new number from the SFLD share: NTLARS .reg, UDC settings, UDC live
# data. The live-data restore is idempotent via one-shot migrated/
# consumption, so it stays safe on reassign too.
$current = Get-CurrentMachineNumber
$out.OldUdc = $current.Udc
$out.OldEdnc = $current.Ednc
$isChanging = ($current.Udc -ne $NewNumber) -or ($current.Ednc -ne $NewNumber)
if ($isChanging -and $NewNumber -ne '9999') {
$sharePath = $null
$siteCfgPath = 'C:\Enrollment\site-config.json'
if (Test-Path $siteCfgPath) {
try {
$cfg = Get-Content $siteCfgPath -Raw | ConvertFrom-Json
# Alias-aware lookup: prefer new key, fall back to legacy.
# PowerShell 5.1 has no null-coalesce operator.
$sharePath = $cfg.pcProfiles.'gea-shopfloor-collections'.ntlarsBackupSharePath
if (-not $sharePath) { $sharePath = $cfg.pcProfiles.'Standard-Machine'.ntlarsBackupSharePath }
} catch {}
}
if ($sharePath) {
try {
. (Join-Path $PSScriptRoot 'Restore-EDncReg.ps1')
$mounted = Mount-SFLDShare -SharePath $sharePath -DriveLetter 'V:'
if ($mounted) {
try {
$out.RegImported = Import-EDncRegBackup -SourceRoot 'V:\' -MachineNumber $NewNumber
} finally {
& net use V: /delete /y 2>$null | Out-Null
}
} else {
Write-Host " Update-MachineNumber: SFLD share unreachable - skipping restore."
}
} catch {
$out.Errors += "ntlars restore failed: $_"
}
}
# --- UDC settings JSON restore: pull udc_settings_<NewNumber>.json
# from the SFLD UDC settings_backups share. At imaging time
# 00-PreInstall-MachineApps.ps1 pulls this from the local
# C:\Enrollment mirror, but a 9999-placeholder PC has no real
# pre-stage. Once the tech sets the real number we have SFLD
# creds, so go direct to the canonical share. ---
$udcSettingsSharePath = $null
if ($cfg) {
try {
$udcSettingsSharePath = $cfg.pcProfiles.'gea-shopfloor-collections'.udcSettingsSharePath
if (-not $udcSettingsSharePath) { $udcSettingsSharePath = $cfg.pcProfiles.'Standard-Machine'.udcSettingsSharePath }
} catch {}
}
if ($udcSettingsSharePath) {
try {
$mountedUdcSet = Mount-SFLDShare -SharePath $udcSettingsSharePath -DriveLetter 'X:'
if ($mountedUdcSet) {
try {
$srcSettings = Join-Path 'X:\' "udc_settings_$NewNumber.json"
if (Test-Path -LiteralPath $srcSettings) {
Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
try { $_.Kill(); $_.WaitForExit(5000) | Out-Null } catch {}
}
Start-Sleep -Milliseconds 500
$localUdcDir = 'C:\ProgramData\UDC'
if (-not (Test-Path $localUdcDir)) { New-Item -ItemType Directory -Path $localUdcDir -Force | Out-Null }
Copy-Item -LiteralPath $srcSettings -Destination $script:UdcSettingsPath -Force -ErrorAction Stop
$out.UdcSettingsRestored = $true
Write-Host " Update-MachineNumber: UDC settings restored from $srcSettings"
} else {
Write-Host " Update-MachineNumber: no udc_settings_$NewNumber.json on settings_backups share"
}
} finally {
& net use X: /delete /y 2>$null | Out-Null
}
} else {
Write-Host " Update-MachineNumber: UDC settings_backups share unreachable - skipping settings restore."
}
} catch {
$out.Errors += "UDC settings restore failed: $_"
}
}
# --- UDC data restore: pull CurrentData.json + ArchivedData/ from
# the per-bay backup at <udcBackupSharePath>\<NewNumber>\.
# One-shot: after successful restore, the live backup at the root
# is moved into <NewNumber>\migrated\<timestamp>\ so it can't be
# replayed on subsequent reboots or reused for a future PC at the
# same bay. The 'isPlaceholder' guard above ensures this whole
# block only ever fires once per PC's lifetime (placeholder->real
# transition). ---
$udcSharePath = $null
if ($cfg) {
try {
$udcSharePath = $cfg.pcProfiles.'gea-shopfloor-collections'.udcBackupSharePath
if (-not $udcSharePath) { $udcSharePath = $cfg.pcProfiles.'Standard-Machine'.udcBackupSharePath }
} catch {}
}
if ($udcSharePath) {
try {
# N:, never W:. GE-Enforce owns W: for its whole cycle, and this
# runs as SYSTEM in the same drive namespace - taking W: here kills
# the share out from under an in-flight enforce cycle.
$mountedUdc = Mount-SFLDShare -SharePath $udcSharePath -DriveLetter 'N:'
if ($mountedUdc) {
try {
$bayDir = Join-Path 'N:\' $NewNumber
$srcCur = Join-Path $bayDir 'CurrentData.json'
$srcArc = Join-Path $bayDir 'ArchivedData'
if (Test-Path -LiteralPath $srcCur) {
Write-Host " Update-MachineNumber: UDC backup found at $bayDir - restoring."
# Stop UDC pre-emptively so CurrentData.json isn't locked
Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
try { $_.Kill(); $_.WaitForExit(5000) | Out-Null } catch {}
}
Start-Sleep -Milliseconds 500
$localUdcDir = 'C:\ProgramData\UDC'
if (-not (Test-Path $localUdcDir)) { New-Item -ItemType Directory -Path $localUdcDir -Force | Out-Null }
$localArc = Join-Path $localUdcDir 'ArchivedData'
# Copy
Copy-Item -LiteralPath $srcCur -Destination (Join-Path $localUdcDir 'CurrentData.json') -Force -ErrorAction Stop
if (Test-Path -LiteralPath $srcArc) {
if (Test-Path -LiteralPath $localArc) { Remove-Item -LiteralPath $localArc -Recurse -Force -ErrorAction SilentlyContinue }
Copy-Item -LiteralPath $srcArc -Destination $localArc -Recurse -Force -ErrorAction Stop
}
# Move live backup -> migrated/<timestamp>/ (one-shot consumption)
$stamp = (Get-Date -Format 'yyyy-MM-ddTHH-mm-ssZ')
$migDir = Join-Path $bayDir 'migrated'
$migStamp = Join-Path $migDir $stamp
if (-not (Test-Path -LiteralPath $migDir)) { New-Item -ItemType Directory -Path $migDir -Force | Out-Null }
if (-not (Test-Path -LiteralPath $migStamp)) { New-Item -ItemType Directory -Path $migStamp -Force | Out-Null }
Move-Item -LiteralPath $srcCur -Destination (Join-Path $migStamp 'CurrentData.json') -Force -ErrorAction Stop
if (Test-Path -LiteralPath $srcArc) {
Move-Item -LiteralPath $srcArc -Destination (Join-Path $migStamp 'ArchivedData') -Force -ErrorAction Stop
}
$bakManifest = Join-Path $bayDir 'backup.manifest.json'
if (Test-Path -LiteralPath $bakManifest) {
Move-Item -LiteralPath $bakManifest -Destination (Join-Path $migStamp 'backup.manifest.json') -Force -ErrorAction SilentlyContinue
}
# Audit manifest in migrated/<stamp>/
$localArcInfo = if (Test-Path $localArc) { Get-ChildItem $localArc -Recurse -File -ErrorAction SilentlyContinue } else { @() }
$restoreManifest = [ordered]@{
RestoredAt = (Get-Date -Format 'o')
DestinationHostname = $env:COMPUTERNAME
DestinationUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
MachineNumber = $NewNumber
CurrentDataBytes = (Get-Item (Join-Path $localUdcDir 'CurrentData.json')).Length
ArchivedDataFiles = $localArcInfo.Count
ArchivedDataBytes = ($localArcInfo | Measure-Object Length -Sum).Sum
RestoredVia = 'Update-MachineNumber.ps1'
}
$restoreManifest | ConvertTo-Json | Set-Content -Path (Join-Path $migStamp 'restore.manifest.json') -Encoding UTF8
$out.UdcRestored = $true
Write-Host " Update-MachineNumber: UDC restore OK (consumed -> migrated\$stamp\)"
} else {
Write-Host " Update-MachineNumber: no UDC backup at $bayDir (fresh PC, no prior data)"
}
} finally {
& net use N: /delete /y 2>$null | Out-Null
}
} else {
Write-Host " Update-MachineNumber: UDC backup share unreachable - skipping UDC restore."
}
} catch {
$out.Errors += "UDC restore failed: $_"
}
}
}
# --- Stop UDC before editing its JSON (avoid stale shutdown write) ---
Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
try { $_.Kill(); $_.WaitForExit(5000) | Out-Null } catch {}
}
Start-Sleep -Seconds 1
# --- Update UDC settings JSON ---
# -ErrorAction Stop on the WRITE so PermissionDenied / IO errors become
# terminating and actually hit the catch block. Without this, the cmdlet
# writes a non-terminating error (visible in transcript) but flow
# continues + $out.UdcUpdated is set to $true, leading the dialog to
# report "UDC updated" when the file write actually failed.
if (Test-Path $script:UdcSettingsPath) {
try {
$json = Get-Content $script:UdcSettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json
$json.GeneralSettings.MachineNumber = $NewNumber
$json | ConvertTo-Json -Depth 99 | Set-Content -Path $script:UdcSettingsPath -Encoding UTF8 -ErrorAction Stop
$out.UdcUpdated = $true
} catch {
$out.Errors += "UDC update failed: $_"
}
}
# --- Update eDNC registry ---
# Same -ErrorAction Stop reasoning as above. Set-ItemProperty's
# PermissionDenied is non-terminating by default; without -ErrorAction
# Stop, the catch block never fires and $out.EdncUpdated=$true gets set
# despite the write failing. This is the bug that made the 13:35:39
# tech run on FGY07FZ3 report "eDNC updated to 3005 / All updates
# succeeded" while the actual reg value stayed at 9999.
if (Test-Path $script:EdncRegPath) {
try {
Set-ItemProperty -Path $script:EdncRegPath -Name MachineNo -Value $NewNumber -Type String -Force -ErrorAction Stop
$out.EdncUpdated = $true
} catch {
$out.Errors += "eDNC update failed: $_"
}
}
# --- Relaunch UDC with new args ---
if ((Test-Path $script:UdcExePath) -and $out.UdcUpdated) {
try {
# UDC.exe arg signature: quoted site name (with space), then
# dash-prefixed machine number. Example: UDC.exe "West Jefferson" -7605
Start-Process -FilePath $script:UdcExePath -ArgumentList @("`"$Site`"", "-$NewNumber")
} catch {
$out.Errors += "UDC relaunch failed: $_"
}
}
# --- Update MTConnect Devices.xml (per-variant) + restart agent services ---
# MTConnect deploys via the GE-Enforce manifest engine + the
# Install_MTConnect_ExisDir_BatConvert.ps1 wrapper. Devices.xml on disk
# has the machine number embedded in the <Device name= uuid= id=> attrs.
# When the tech changes 9999 -> real number, those attrs need to follow,
# OR MTConnect data will keep reporting under the old/wrong identity.
#
# We do an inline substitution rather than re-running the full wrapper
# because the tech may not have credential delegation to the SFLD share
# from their interactive session.
$out.MTConnectUpdated = @()
# Drive this off "is the service installed?" rather than file existence:
# Fanuc and Okuma both deploy to C:\MTConnect\Agent\, distinguishable only
# by the registered service name (NTFS is case-insensitive so the two
# devices.xml / Devices.xml entries collapse to the same file). Without
# this filter, the Okuma branch on an Okuma PC sees the file already
# rewritten by the (no-op) Fanuc branch and skips the service restart.
# Okuma and eDNC keep the name in one file only - verified across 7 Okuma
# and 15 eDNC bay captures - so the simple edit is right for them. Fanuc and
# Makino are handled below instead.
$mtcVariants = @(
@{ Service='MTConnect Agent Okuma'; Path='C:\MTConnect\Agent\Devices.xml' },
@{ Service='MTConnect eDNC Agent'; Path='C:\MTConnect_eDNC\Agent\Devices.xml' }
)
foreach ($v in $mtcVariants) {
$svc = Get-Service -Name $v.Service -ErrorAction SilentlyContinue
if (-not $svc) { continue }
if (-not (Test-Path -LiteralPath $v.Path)) { continue }
try {
$content = Get-Content -LiteralPath $v.Path -Raw -ErrorAction Stop
# Find the Device root's name= attr to discover the OLD identifier
if ($content -notmatch '<Device[^>]+name="([^"]+)"') { continue }
$oldName = $matches[1]
# Most variants encode machine number as the trailing digit run:
# Fanuc/Makino: name="9999" -> trailing 9999
# OKUMA: name="loc9999" -> trailing 9999, prefix 'loc'
# eDNC: name="eDNC_OKUMA9999" -> trailing 9999, prefix 'eDNC_OKUMA'
if ($oldName -notmatch '^(.*?)(\d+)$') { continue }
$prefix = $matches[1]
$oldDigits = $matches[2]
if ($oldDigits -eq $NewNumber) { continue } # already correct
$oldFull = "$prefix$oldDigits"
$newFull = "$prefix$NewNumber"
$oldEsc = [regex]::Escape($oldFull)
# Quoted attr value: name="X" / uuid="X" / id="X"
$content = $content -replace ('"' + $oldEsc + '"'), ('"' + $newFull + '"')
# OKUMA-style with serial after dot: uuid="locX.<serial>"
$content = $content -replace ('"' + $oldEsc + '\.'), ('"' + $newFull + '.')
Set-Content -LiteralPath $v.Path -Value $content -NoNewline -ErrorAction Stop
$out.MTConnectUpdated += "$($v.Path) ($oldFull -> $newFull)"
try { Restart-Service -Name $v.Service -Force -ErrorAction Stop }
catch { $out.Errors += "Restart $($v.Service) failed: $_" }
} catch {
$out.Errors += "MTConnect Devices.xml update failed for $($v.Path): $_"
}
}
# --- Multi-file variants: rename every copy, services stopped first ---
# Fanuc and Makino each keep the device name in more than one file, and the
# agent's copy alone is not enough - the adapter goes on streaming under the
# old identity, which is how seven captured Fanuc bays ended up with the
# agent on the right machine number and the adapter on the bay it was
# imaged from.
$multiFileVariants = @(
@{
Probe = 'MTConnect Agent Fanuc'
Root = 'C:\MTConnect'
Services = @('MTConnect Adapter Fanuc', 'MTConnect Agent Fanuc')
Targets = @(
@{ Path = 'Agent\devices.xml'; Kind = 'attr' },
@{ Path = 'Adapter\devices.xml'; Kind = 'attr' },
@{ Path = 'Adapter\adapter.xml'; Kind = 'elem' }
)
},
@{
Probe = 'Makino MTConnect Agent'
Root = 'C:\Makino-MTConnect'
Services = @('Makino Adapter Manager', 'Makino MTConnect Agent')
Targets = @(
@{ Path = 'Agent\Devices.xml'; Kind = 'attr' },
@{ Path = 'Agent\Devices.EDM.xml'; Kind = 'attr' },
@{ Path = 'Data\AdapterDataFile.xml'; Kind = 'name' },
@{ Path = 'Agent\Agent.cfg'; Kind = 'cfg' }
)
}
)
foreach ($variant in $multiFileVariants) {
if (-not (Get-Service -Name $variant.Probe -ErrorAction SilentlyContinue)) { continue }
$renamed = Update-MTConnectVariantName -NewNumber $NewNumber `
-Root $variant.Root -Targets $variant.Targets -Services $variant.Services
$out.MTConnectUpdated += $renamed.Updated
$out.Errors += $renamed.Errors
}
# Keep C:\Enrollment\machine-number.txt in sync. Post-imaging GE-Enforce
# prefers eDNC reg, but imaging-time scripts (Install-FromManifest
# TargetMachineNumbers filter, 01-eDNC.ps1, 03-RestoreEDncConfig.ps1)
# still read this file. Avoid drift on reassign.
try {
$mnFile = 'C:\Enrollment\machine-number.txt'
$mnDir = Split-Path -Parent $mnFile
if (-not (Test-Path $mnDir)) { New-Item -ItemType Directory -Path $mnDir -Force | Out-Null }
Set-Content -Path $mnFile -Value $NewNumber -Encoding UTF8 -NoNewline
$out.MachineNumberTxtUpdated = $true
} catch {
$out.Errors += "machine-number.txt update failed: $_"
}
return $out
}

View File

@@ -0,0 +1,213 @@
# Stage-Dispatcher.ps1 - Chain controller for the shopfloor imaging pipeline.
#
# Reads C:\Enrollment\setup-stage.txt to know which step of the imaging
# process we're in, launches the right script, advances the stage, and
# re-registers itself via RunOnce so the next logon picks up the next step.
#
# STAGE CHAIN:
# "shopfloor-setup" -> Run-ShopfloorSetup.ps1 (installs apps, deferred org)
# Run-ShopfloorSetup reboots via shutdown /r /t 10;
# dispatcher has ~10 seconds to write the next stage
# and re-register RunOnce before the machine goes down.
#
# "sync-intune" -> Monitor-IntuneProgress.ps1 -Unattended
# Monitor exits 2 = pre-reboot Y pressed (no reboot yet)
# -> dispatcher re-registers RunOnce, initiates reboot
# Monitor exits 0 = post-reboot install complete
# -> dispatcher advances to configure-pc
#
# "configure-pc" -> Configure-PC.ps1 (startup items, machine number)
# -> dispatcher deletes stage file. Chain complete.
#
# (file missing) -> exit 0. Setup is done, nothing to do.
#
# ENTRY POINT:
# Registered as a RunOnce value by run-enrollment.ps1 after PPKG install.
# Each stage re-registers RunOnce for the next boot. RunOnce entries are
# consumed by Windows after execution, so no cleanup needed.
#
# LOGGING:
# Appends to C:\Logs\SFLD\stage-dispatcher.log via Start-Transcript.
$ErrorActionPreference = 'Continue'
# --- Transcript ---
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcriptPath = Join-Path $logDir 'stage-dispatcher.log'
try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {}
Write-Host ""
Write-Host "================================================================"
Write-Host "=== Stage-Dispatcher.ps1 $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
Write-Host "================================================================"
Write-Host "Running as: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)"
# --- Paths ---
$stageFile = 'C:\Enrollment\setup-stage.txt'
$dispatcherPath = $MyInvocation.MyCommand.Path
$enrollDir = 'C:\Enrollment'
$shopfloorDir = Join-Path $enrollDir 'shopfloor-setup\Shopfloor'
$runOnceKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
# --- Stage file ---
if (-not (Test-Path -LiteralPath $stageFile)) {
Write-Host "No stage file found at $stageFile - setup complete, nothing to do."
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
$stage = (Get-Content -LiteralPath $stageFile -First 1).Trim()
Write-Host "Current stage: $stage"
# --- Helper: re-register this dispatcher to run at next logon ---
function Register-NextRun {
try {
Set-ItemProperty -Path $runOnceKey -Name 'ShopfloorSetup' `
-Value "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$dispatcherPath`"" `
-Type String -Force -ErrorAction Stop
Write-Host "RunOnce registered for next logon."
} catch {
Write-Warning "Failed to register RunOnce: $_"
}
}
# --- Stage dispatch ---
switch ($stage) {
'shopfloor-setup' {
Write-Host ""
Write-Host "--- Stage: shopfloor-setup ---"
$script = Join-Path $enrollDir 'Run-ShopfloorSetup.ps1'
if (-not (Test-Path -LiteralPath $script)) {
Write-Warning "Run-ShopfloorSetup.ps1 not found at $script"
break
}
# Defensive: top up AutoLogonCount so SupportUser keeps auto-logging
# in across any vendor-installer-forced reboots during this stage.
# The unattend XML sets LogonCount=7 at install; typical imaging burns
# through several reboots (Office, Oracle, FormTracePak forced reboot,
# Run-ShopfloorSetup explicit reboot, stage advances) and the unplanned
# FormTracePak reboot can push the counter past 0 - clearing
# AutoAdminLogon and leaving the bay parked at the login screen with
# the dispatcher unable to fire. Set the counter to 10 every time this
# stage runs so the budget is restored. When sync-intune finishes the
# whole pipeline, AutoAdminLogon is left to decrement to 0 naturally;
# by then lockdown's own Autologon.exe has taken over for ShopFloor.
try {
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' `
-Name 'AutoLogonCount' -Value 10 -Type DWord -ErrorAction Stop
Write-Host "Topped up AutoLogonCount to 10 for SupportUser autologon resilience."
} catch {
Write-Warning "Failed to top up AutoLogonCount: $_"
}
# Defensive: re-register RunOnce BEFORE calling Run-ShopfloorSetup.
# Setup chains we don't control (FormTracePak Setup.exe, eDNC MSI,
# any vendor installer that forces an immediate reboot) can cut
# the script off mid-flight. Without this, the dispatcher never
# returns from & $script and the post-call Register-NextRun never
# fires, leaving the next boot with no RunOnce + a stalled image.
# With this defensive register the next boot re-fires the same
# dispatcher, which re-reads the still-'shopfloor-setup' stage
# file, re-runs Run-ShopfloorSetup (every step is idempotent +
# detects already-installed state), and converges. Once
# Run-ShopfloorSetup returns normally we re-register again below
# before advancing to the next stage - cheap, idempotent.
Register-NextRun
# -FromDispatcher bypasses the stage-file gate at the top of
# Run-ShopfloorSetup (which would otherwise see the stage file
# and exit immediately thinking it should defer to us).
& $script -FromDispatcher
# Cancel whatever reboot Run-ShopfloorSetup scheduled (shutdown /r
# /t 10) so we can advance the stage and re-register RunOnce WITHOUT
# racing a 10-second fuse. Then reboot on our own terms.
Write-Host "Run-ShopfloorSetup.ps1 finished. Canceling its reboot so we can advance safely."
cmd /c "shutdown /a 2>nul" *>$null
Set-Content -LiteralPath $stageFile -Value 'sync-intune' -Force
Register-NextRun
Write-Host "Stage advanced to sync-intune. Rebooting."
shutdown /r /t 5
}
'sync-intune' {
Write-Host ""
Write-Host "--- Stage: sync-intune ---"
$monitor = Join-Path $shopfloorDir 'lib\Monitor-IntuneProgress.ps1'
if (-not (Test-Path -LiteralPath $monitor)) {
Write-Warning "Monitor-IntuneProgress.ps1 not found at $monitor"
break
}
# Launch the monitor in THIS console window (interactive - tech sees
# QR code + status table). -Unattended makes it exit with a status
# code instead of prompting/rebooting, so we keep control here.
Write-Host "Launching Intune sync monitor (unattended mode)..."
& $monitor -Unattended
$monitorExit = $LASTEXITCODE
Write-Host "Monitor exited with code: $monitorExit"
switch ($monitorExit) {
0 {
# Post-reboot install complete. All milestones reached.
# Chain directly to Configure-PC (same session, no reboot).
Write-Host "Post-reboot install complete. Launching Configure-PC..."
Set-Content -LiteralPath $stageFile -Value 'configure-pc' -Force
$configScript = Join-Path $shopfloorDir 'Configure-PC.ps1'
if (Test-Path -LiteralPath $configScript) {
& $configScript
} else {
Write-Warning "Configure-PC.ps1 not found at $configScript"
}
# Chain complete.
Write-Host "Imaging chain complete. Removing stage file."
Remove-Item -LiteralPath $stageFile -Force -ErrorAction SilentlyContinue
}
2 {
# Pre-reboot deployment done. User confirmed reboot.
# Re-register RunOnce so we come back after the reboot to
# monitor the post-reboot install phase.
Write-Host "Pre-reboot phase complete. Rebooting..."
Register-NextRun
# Stage stays at 'sync-intune' - on next boot the monitor
# will detect post-reboot state and monitor until done.
shutdown /r /t 5
}
default {
Write-Warning "Unexpected monitor exit code: $monitorExit"
Write-Host "Re-registering for retry on next logon."
Register-NextRun
}
}
}
'configure-pc' {
Write-Host ""
Write-Host "--- Stage: configure-pc ---"
$configScript = Join-Path $shopfloorDir 'Configure-PC.ps1'
if (Test-Path -LiteralPath $configScript) {
& $configScript
} else {
Write-Warning "Configure-PC.ps1 not found at $configScript"
}
Write-Host "Imaging chain complete. Removing stage file."
Remove-Item -LiteralPath $stageFile -Force -ErrorAction SilentlyContinue
}
default {
Write-Warning "Unknown stage: '$stage'. Removing stage file."
Remove-Item -LiteralPath $stageFile -Force -ErrorAction SilentlyContinue
}
}
Write-Host ""
Write-Host "=== Stage-Dispatcher.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
try { Stop-Transcript | Out-Null } catch {}

View File

@@ -6,13 +6,18 @@ shopfloor PC actually arrived - and re-pull (heal) anything missing from the
enrollment share. Runs in full Windows (reliable network), so it is immune to the enrollment share. Runs in full Windows (reliable network), so it is immune to the
WinPE samba-idle-drop that loses copies during the WIM apply. WinPE samba-idle-drop that loses copies during the WIM apply.
Covers the Fetch payload: Run-ShopfloorSetup.ps1, the shopfloor-setup\Shopfloor Covers the generic Fetch payload (shopfloor-setup tree + preinstall bundle) AND
tree, and the pre-install bundle (preinstall.json, postppkg.json, installers). the heavy per-type payloads that Fetch-StagingPayload does NOT pull today:
- CMM C:\CMM-Install (+ selected bay's backup C:\CMM-Install\backups\<cmmid>)
- Keyence C:\KeyenceInstall\<model> (MSI + Data*.cab)
- WaxTrace C:\WaxTrace-Install (bundle + bay-matched FormTracePak ISO)
These are the ones that silently go missing when WinPE staging runs out of time
(idle-dead Y: mount) before reboot.
Designed to be: Designed to be:
- run manually on a problem PC (Verify-And-Heal-Staging.bat), or - run manually on a problem PC (Verify-And-Heal-Staging.bat), or
- run as its own unattend step after Fetch-StagingPayload, so a bay is never - called from the pre-install phase before 00-PreInstall-MachineApps so a bay is
left under-provisioned. never left under-provisioned.
Idempotent. Uses robocopy per item, which compares size + timestamp on every Idempotent. Uses robocopy per item, which compares size + timestamp on every
file, so it re-pulls anything MISSING or PARTIAL (e.g. a truncated MSI that file, so it re-pulls anything MISSING or PARTIAL (e.g. a truncated MSI that
@@ -55,9 +60,10 @@ if (-not $SharePass) { $SharePass=$defPass }
# --- identity --- # --- identity ---
function ReadTxt($p){ if (Test-Path -LiteralPath $p) { (Get-Content -LiteralPath $p -First 1 -EA 0).Trim() } else { '' } } function ReadTxt($p){ if (Test-Path -LiteralPath $p) { (Get-Content -LiteralPath $p -First 1 -EA 0).Trim() } else { '' } }
$pcType = ReadTxt 'C:\Enrollment\pc-type.txt' $pcType = ReadTxt 'C:\Enrollment\pc-type.txt'
$cmmid = ReadTxt 'C:\Enrollment\cmm\cmmid.txt'
Log "=== Verify-And-Heal-Staging ===" Log "=== Verify-And-Heal-Staging ==="
Log "share=$ShareUnc user=$ShareUser pcType=$(if($pcType){$pcType}else{'(none)'}) verifyOnly=$VerifyOnly" Log "share=$ShareUnc user=$ShareUser pcType=$(if($pcType){$pcType}else{'(none)'}) cmmid=$(if($cmmid){$cmmid}else{'(none)'}) verifyOnly=$VerifyOnly"
# --- expected payload manifest ------------------------------------------------- # --- expected payload manifest -------------------------------------------------
# Each: Label, Src (under share), Dst, Mode (File|Dir), Verify (path that must # Each: Label, Src (under share), Dst, Mode (File|Dir), Verify (path that must
@@ -70,8 +76,46 @@ function Add-Item($Label,$Src,$Dst,$Mode,$Verify,$Files=$null,$Optional=$false,$
$ENR='C:\Enrollment'; $SFD='C:\Enrollment\shopfloor-setup'; $PIN='C:\PreInstall' $ENR='C:\Enrollment'; $SFD='C:\Enrollment\shopfloor-setup'; $PIN='C:\PreInstall'
Add-Item 'Run-ShopfloorSetup.ps1' 'shopfloor-setup' $ENR 'File' (Join-Path $ENR 'Run-ShopfloorSetup.ps1') @('Run-ShopfloorSetup.ps1') Add-Item 'Run-ShopfloorSetup.ps1' 'shopfloor-setup' $ENR 'File' (Join-Path $ENR 'Run-ShopfloorSetup.ps1') @('Run-ShopfloorSetup.ps1')
Add-Item 'Shopfloor baseline' 'shopfloor-setup\Shopfloor' (Join-Path $SFD 'Shopfloor') 'Dir' (Join-Path $SFD 'Shopfloor') Add-Item 'Shopfloor baseline' 'shopfloor-setup\Shopfloor' (Join-Path $SFD 'Shopfloor') 'Dir' (Join-Path $SFD 'Shopfloor')
Add-Item 'app lists' 'pre-install' $PIN 'File' (Join-Path $PIN 'preinstall.json') @('preinstall.json', 'postppkg.json') Add-Item 'common' 'shopfloor-setup\common' (Join-Path $SFD 'common') 'Dir' (Join-Path $SFD 'common')
Add-Item '_ntlars-backups' 'shopfloor-setup\_ntlars-backups' (Join-Path $SFD '_ntlars-backups') 'Dir' (Join-Path $SFD '_ntlars-backups') $null $true
if ($pcType) {
Add-Item "type:$pcType" "shopfloor-setup\$pcType" (Join-Path $SFD $pcType) 'Dir' (Join-Path $SFD $pcType)
}
Add-Item 'preinstall.json' 'pre-install' $PIN 'File' (Join-Path $PIN 'preinstall.json') @('preinstall.json')
Add-Item 'preinstall installers' 'pre-install\installers' (Join-Path $PIN 'installers') 'Dir' (Join-Path $PIN 'installers') Add-Item 'preinstall installers' 'pre-install\installers' (Join-Path $PIN 'installers') 'Dir' (Join-Path $PIN 'installers')
Add-Item 'udc-backups' 'pre-install\udc-backups' (Join-Path $PIN 'udc-backups') 'Dir' (Join-Path $PIN 'udc-backups') $null $true
# --- heavy CMM payload (the gap) ---
if ($pcType -eq 'gea-shopfloor-cmm') {
Add-Item 'CMM bundle' 'installers-post\cmm' 'C:\CMM-Install' 'Dir' 'C:\CMM-Install\cmm-manifest.json' $null $false 'backups'
if ($cmmid) {
Add-Item "CMM backup ($cmmid)" "installers-post\cmm\backups\$cmmid" "C:\CMM-Install\backups\$cmmid" 'Dir' "C:\CMM-Install\backups\$cmmid" $null $true
}
}
# --- heavy Keyence payload (same gap as CMM: WinPE-only staged, never re-pulled
# by Fetch-StagingPayload). Only the selected model bundle lands under
# C:\KeyenceInstall\<model>. Verify on the model manifest so a missing/partial
# Data1.cab (the 700 MB - 2 GB payload msiexec SECREPAIR-hashes) gets re-pulled. ---
if ($pcType -eq 'gea-shopfloor-keyence') {
$kmodel = ReadTxt 'C:\Enrollment\keyence-model.txt'
if (-not $kmodel) { $kmodel = 'vr6000' }
Add-Item "Keyence bundle ($kmodel)" "installers-post\keyence\$kmodel" "C:\KeyenceInstall\$kmodel" 'Dir' "C:\KeyenceInstall\$kmodel\manifest.json"
}
# --- heavy WaxTrace payload (same gap as CMM/Keyence). Two parts, mirroring the
# three-step WinPE stage: (1) the bundle minus the formtracepak\ ISO dir, and
# (2) ONLY the bay's matched FORMTRACEPAK-V<ver>.iso, keyed on the version
# resolve-bay-config wrote to C:\Enrollment\waxtrace\version.txt during WinPE.
# If version.txt is missing (mount died before the resolver ran) the ISO cannot
# be re-pulled here - the bundle+resolver still heal, and resolve-bay-config can
# be re-run manually to regenerate version.txt then re-run this heal. ---
if ($pcType -eq 'gea-shopfloor-waxtrace') {
Add-Item 'WaxTrace bundle' 'installers-post\waxtrace' 'C:\WaxTrace-Install' 'Dir' 'C:\WaxTrace-Install\waxtrace-manifest.json' $null $false 'formtracepak'
$wtver = ReadTxt 'C:\Enrollment\waxtrace\version.txt'
if ($wtver) {
Add-Item "WaxTrace FTPak V$wtver" 'installers-post\waxtrace\formtracepak' 'C:\WaxTrace-Install\formtracepak' 'File' "C:\WaxTrace-Install\formtracepak\FORMTRACEPAK-V$wtver.iso" @("FORMTRACEPAK-V$wtver.iso")
} else {
Log 'WaxTrace: version.txt absent - cannot heal the bay-specific FormTracePak ISO (re-run resolve-bay-config then re-run heal)' 'WARN'
}
}
# --- robocopy-based verify/heal ----------------------------------------------- # --- robocopy-based verify/heal -----------------------------------------------
# Presence alone is NOT trusted: a partially transferred file (e.g. a truncated # Presence alone is NOT trusted: a partially transferred file (e.g. a truncated

View File

@@ -0,0 +1,240 @@
REGEDIT4
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC]
"COMPUTERNAME"="GGT8K673ESF"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Btr]
"BTR Rate"=""
"Seq Search"=""
"Auto Rewind"=""
"BCC"=""
"CMNT"=""
"CmntLag"=dword:00000000
"DisableScrnSvr"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\DatC]
"Debug"="NO"
"Multi"="NO"
"WorkStations"="0"
"WS1"=""
"WS2"=""
"WS3"=""
"WS4"=""
"WS5"=""
"WS6"=""
"Files Threshold"="200"
"Any"="NO"
"DaysOld"="7"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\DNC2]
"MPRelay"=""
"ProgIdLimit"=""
"PMC-NB"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\eFocas]
"IpAddr"="192.168.1.1"
"SocketNo"="8193"
"DualPath"=""
"Path1Name"=""
"Path2Name"=""
"Danobat"="NO"
"DataServer"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\FMS]
"FMSHostPrimary"="WJFMS3.AE.GE.COM"
"FMSHostSecondary"="10.233.112.158"
"FMSSocketBase"="5003"
"FMSTimeOut"=dword:0000000a
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\FtpDnld]
"Target"=""
"Username"=""
"Password"=""
"DirectoryPath"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\General]
"Site"="WestJefferson"
"Cnc"="Fanuc 30"
"NcIF"="SERIAL"
"MachineNo"="0000"
"Debug"="ON"
"Uploads"="NO"
"Scanner"="YES"
"HostType"="WILM"
"DvUpldDir"=""
"Ncedt"="NO"
"Maint"="YES"
"Mode"="Small"
"Unit/Area"=""
"Dripfeed"="NO"
"ChangeWorkstation"="NO"
"CWRegPath"="C:\\Program Files\\Dnc"
"FtpFileSel"="PC"
"DvDnldDir"=""
"RemindEnable"="NO"
"RemindBkupHost"=""
"RemindBkupFolder"=""
"Print"="NO"
"FixTorque"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\HeatTreat]
"FtpHost"=""
"FtpAccount"=""
"9030IpAddr"=""
"9030Register"=""
"CycleFilePath"=""
"FtpPasswd"=""
"9030Register2"=""
"9030Register3"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Hssb]
"ProgIdLimit"=""
"KeyCheck"=""
"DelHighIds"=""
"StdPmcG"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Mark]
"Port Id"="COM2"
"Baud"="9600"
"Parity"="None"
"Data Bits"="8"
"Stop Bits"="1"
"Message Type"="V"
"Debug"="OFF"
"MarkerType"="2Line"
"DncPatterns"="NO"
"CageCode"=""
"DataHost"=""
"DataPath"=""
"MarkMasterPath"=""
"Port Id2"=""
"Baud2"=""
"Parity2"=""
"Data Bits2"=""
"Stop Bits2"=""
"DisableWeight"="YES"
"DisableBarcode"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\MX]
"FtpHostType"="VMS"
"FtpHostPrimary"="tsgwp00525.us.ae.ge.com"
"FtpHostSecondary"="tsgwp00525.us.ae.ge.com"
"FtpAccount"="geaeevendale\\sfwj0ashp"
"FtpPasswd"="qxOG8q1QnR"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\NTSHR]
"ShrHost"=""
"ShrFolder"=""
"ShrExt"=""
"ShrFolder2"=""
"ShrFolder3"=""
"ProgIdLimit"=""
"Deletes"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\PaintBooth]
"DataHost"=""
"DataFolder"=""
"PlcIpAddress"=""
"PollRate"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Plant3]
"Host"=""
"Path"=""
"Account"=""
"Password"=""
"EnableAutomation"="NO"
"MachineType"="Lathe"
"HostType"="Windows"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\PPDCS]
"Primary"="wjfms3.ae.ge.com"
"Secondary"="wjfms3.ae.ge.com"
"UserName"="DCP_SHOPWJ"
"Password"="QSy1Go"
"Port Id"="COM1"
"Baud"="9600"
"Data Bits"="8"
"Parity"="None"
"Stop Bits"="1"
"Start Char"="DC2"
"Wait Time"="250"
"Timeout"="10"
"Files Threshold"="5"
"FrontEnd"="PPMON"
"TQM9030"="NO"
"TextMode Menu"="NO"
"TreeDisplay"="YES"
"CLMShare"=""
"ShareFile"=""
"SharePoll"=""
"MDMacroVar"=""
"TQMCaron"="NO"
"CycleStart Inhibits"="YES"
"EnableSharePoll"="NO"
"WaitForCncFile"=""
"HostType"="VMS"
"HostPath"=""
"Port Id2"=""
"ShareHost"=""
"SharePollUnits"="msec"
"FileAge"=""
"HostPath2"=""
"SearchSubfolders"=""
"ManualDataBadge"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\QUAL]
"UserName"=""
"Password"=""
"Primary"=""
"Secondary"=""
"SocketNo"=""
"Timeout"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Serial]
"Port Id"="COM2"
"Baud"="9600"
"Parity"="None"
"Data Bits"="8"
"Stop Bits"="1"
"CRLF"="NO"
"EOL Delay"="NO"
"MC2000Dels"="NO"
"EOT"="NO"
"EOL Delay msec"="0"
"DeleteLT9000"=""
"SwapSize"=""
"2Saddle"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\TncRemo]
"TncFolder"=""
"TncExt"=""
"TncIpAddr"=""
"Port"=""
"Medium"=""
"MjtLaser"=""
"HFolder"=""
"IFolder"=""
"DFolder"=""
"TABFolder"=""
"TFolder"=""
"TCHFolder"=""
"PFolder"=""
"PNTFolder"=""
"CDTFolder"=""
"AFolder"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\TQM9030]
"Port Id"=""
"Baud"=""
"Parity"=""
"Data Bits"=""
"Stop Bits"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\TQMCaron]
"Port Id"=""
"Baud"=""
"Parity"=""
"Data Bits"=""
"Stop Bits"=""

View File

@@ -0,0 +1,240 @@
REGEDIT4
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC]
"COMPUTERNAME"="G3ZH3SZ2ESF"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Btr]
"BTR Rate"="300"
"Seq Search"="NO"
"Auto Rewind"="YES"
"BCC"="NO"
"CMNT"="NO"
"CmntLag"=dword:00000000
"DisableScrnSvr"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\DatC]
"Debug"="NO"
"Multi"="NO"
"WorkStations"="0"
"WS1"=""
"WS2"=""
"WS3"=""
"WS4"=""
"WS5"=""
"WS6"=""
"Files Threshold"="200"
"Any"="NO"
"DaysOld"="7"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\DNC2]
"MPRelay"=""
"ProgIdLimit"=""
"PMC-NB"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\eFocas]
"IpAddr"="169.254.0.1"
"SocketNo"="8192"
"DualPath"=""
"Path1Name"=""
"Path2Name"=""
"Danobat"=""
"DataServer"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\FMS]
"FMSHostPrimary"="WJFMS3.AE.GE.COM"
"FMSHostSecondary"="10.233.112.158"
"FMSSocketBase"="5003"
"FMSTimeOut"=dword:0000000a
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\FtpDnld]
"Target"=""
"Username"=""
"Password"=""
"DirectoryPath"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\General]
"Site"="WestJefferson"
"Cnc"="MARKER"
"NcIF"="SERIAL"
"MachineNo"="0600"
"Debug"="ON"
"Uploads"="NO"
"Scanner"="YES"
"HostType"="WILM"
"DvUpldDir"=""
"Ncedt"="NO"
"Maint"="YES"
"Mode"="Small"
"Unit/Area"=""
"Dripfeed"="NO"
"ChangeWorkstation"="NO"
"CWRegPath"="C:\\Program Files\\Dnc"
"FtpFileSel"="Host"
"DvDnldDir"=""
"RemindEnable"="NO"
"RemindBkupHost"=""
"RemindBkupFolder"=""
"Print"="NO"
"FixTorque"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\HeatTreat]
"FtpHost"=""
"FtpAccount"=""
"9030IpAddr"=""
"9030Register"=""
"CycleFilePath"=""
"FtpPasswd"=""
"9030Register2"=""
"9030Register3"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Hssb]
"ProgIdLimit"=""
"KeyCheck"=""
"DelHighIds"=""
"StdPmcG"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Mark]
"Port Id"="COM4"
"Baud"="9600"
"Parity"="None"
"Data Bits"="8"
"Stop Bits"="1"
"Message Type"="V"
"Debug"="ON"
"MarkerType"="TMC420"
"DncPatterns"="YES"
"CageCode"=""
"DataHost"=""
"DataPath"=""
"MarkMasterPath"=""
"Port Id2"=""
"Baud2"=""
"Parity2"=""
"Data Bits2"=""
"Stop Bits2"=""
"DisableWeight"="NO"
"DisableBarcode"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\MX]
"FtpHostType"="Windows"
"FtpHostPrimary"="tsgwp00525.us.ae.ge.com"
"FtpHostSecondary"="tsgwp00525.us.ae.ge.com"
"FtpAccount"="geaeevendale\\sfwj0ashp"
"FtpPasswd"="qxOG8q1QnR"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\NTSHR]
"ShrHost"=""
"ShrFolder"=""
"ShrExt"=""
"ShrFolder2"=""
"ShrFolder3"=""
"ProgIdLimit"=""
"Deletes"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\PaintBooth]
"DataHost"=""
"DataFolder"=""
"PlcIpAddress"=""
"PollRate"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Plant3]
"Host"=""
"Path"=""
"Account"=""
"Password"=""
"EnableAutomation"="NO"
"MachineType"="Lathe"
"HostType"="Windows"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\PPDCS]
"Primary"=""
"Secondary"=""
"UserName"=""
"Password"=""
"Port Id"="COM2"
"Baud"=""
"Data Bits"=""
"Parity"=""
"Stop Bits"=""
"Start Char"=""
"Wait Time"="250"
"Timeout"="10"
"Files Threshold"="5"
"FrontEnd"=""
"TQM9030"="NO"
"TextMode Menu"="NO"
"TreeDisplay"="NO"
"CLMShare"=""
"ShareFile"=""
"SharePoll"=""
"MDMacroVar"=""
"TQMCaron"="NO"
"CycleStart Inhibits"="NO"
"EnableSharePoll"=""
"WaitForCncFile"=""
"HostType"="VMS"
"HostPath"=""
"Port Id2"="COM4"
"ShareHost"=""
"SharePollUnits"="msec"
"FileAge"=""
"HostPath2"=""
"SearchSubfolders"=""
"ManualDataBadge"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\QUAL]
"UserName"=""
"Password"=""
"Primary"=""
"Secondary"=""
"SocketNo"=""
"Timeout"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Serial]
"Port Id"="COM4"
"Baud"="9600"
"Parity"="None"
"Data Bits"="8"
"Stop Bits"="1"
"CRLF"="NO"
"EOL Delay"="NO"
"MC2000Dels"="NO"
"EOT"="NO"
"EOL Delay msec"="0"
"DeleteLT9000"=""
"SwapSize"=""
"2Saddle"="NO"
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\TncRemo]
"TncFolder"=""
"TncExt"=""
"TncIpAddr"=""
"Port"=""
"Medium"=""
"MjtLaser"=""
"HFolder"=""
"IFolder"=""
"DFolder"=""
"TABFolder"=""
"TFolder"=""
"TCHFolder"=""
"PFolder"=""
"PNTFolder"=""
"CDTFolder"=""
"AFolder"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\TQM9030]
"Port Id"=""
"Baud"=""
"Parity"=""
"Data Bits"=""
"Stop Bits"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\TQMCaron]
"Port Id"=""
"Baud"=""
"Parity"=""
"Data Bits"=""
"Stop Bits"=""

Some files were not shown because too many files have changed in this diff Show More