180 Commits

Author SHA1 Message Date
cproudlock
120553c975 Test: image re-import merges Deploy/Applications (MCL certs) and keeps the Engineering Layer 2026-10-16 21:00:52 -04:00
cproudlock
b9a4d0230d Engineer image: upload the Engineering Layer from the web app
New Engineering Layer tab on the engineer image page. A site uploads
Engineering_Install_v<ver>.exe (keeps prereqs) or a .zip of the whole
extra_engineering payload. The upload is streamed to disk, checked (MZ
header, no unsafe zip paths) and swapped in; the replaced payload is
archived (two kept) with a Restore button. Install-EngineeringLayer.ps1
stays repo-owned and now installs the newest Engineering_Install_v*.exe.

KB: Part 4 becomes Site Content (enrollment package, Engineering Layer,
Blancco site settings, Shop Floor apps) plus a new Adding Shop Floor
Apps section for preinstall.json/postppkg.json. WJ examples removed.
2026-10-16 20:48:25 -04:00
cproudlock
04db6d6605 Generic build for other GE sites: no WJ data, one shopfloor type, Windows USB builder
Everything West Jefferson specific moved out to a separate repo
(pxe-site-wj): NTLARS/UDC/heat-treat backups keyed by WJ machine numbers,
CMM and Wax/Trace bay lists, WJ OpenText profiles, site-config.json, the
WJ preinstall list, and the WJ shopfloor PC types (Collections,
NoCollections, Common, Keyence, CMM, Genspect, HeatTreat, Wax/Trace,
Display, PartMarker) with GE-Enforce, machine numbers and the desktop
layout. The part-marker bench tools moved to their own repo. Dead
scripts removed: build-proxmox-iso, deploy-bios, sync_hardware_models,
mirror-from-gold, pull-bios, startnet-template.cmd.

Shopfloor is one generic type, gea-shopfloor-standard, with today's
timing:
- PPKG at first logon (unattend Order 10, unchanged).
- Install-AppList.ps1 runs C:\PreInstall\preinstall.json during setup.
- A new SYSTEM task, GE Shopfloor Post-PPKG, runs postppkg.json once
  after the post-PPKG reboot. It also clears AutoLogonCount after the
  ShopFloor handover (the Users-level monitor can be denied that HKLM
  write) and removes the Intune Sync task when done.
- Both lists ship empty. A site adds entries and installers.
- WinPE choice 3 goes straight to the PPKG menu. Staging, fetch and heal
  carry only the generic payload. The webapp's shopfloor sub-menu editor
  is gone, and the imaging tiles name the new stages.
- The playbook deploys the empty lists only if missing. It now deploys
  the PowerShell 7 MSI the shopfloor unattend installs, which it never
  did before.

Blancco site settings move into the webapp (Updates > Blancco releases):
business location, licensing Wi-Fi SSID and password. They are rendered
into the served preferences.xml and applied to every staged release, and
are seeded from site-vars (site_name, blancco_wifi_ssid,
blancco_wifi_password) only while none exist. The repo XML carries no
site values; the plaintext Wi-Fi password is gone from it.

New site_name and site_timezone settings: report and evidence-pack site
name, server clock and webapp time zone. No more West Jefferson defaults
in webapp/config.py or the dashboard title. Sync-BPRT.ps1 gets the
server address filled in at download instead of WJ's GE address.

USB on Windows:
- scripts/Build-PxeUsb.ps1 runs the same checks as build-usb.sh. It
  writes one FAT32 CIDATA partition (ISO files plus patched grub.cfg plus
  payload) with diskpart and reads every file back by SHA-256.
- Tested with Windows PowerShell 5.1 in the win11 VM, and the layout
  installs and first-boots in the pxetest VM.
- scripts/stage-usb-share.sh stages the build folder on a share.
- site-content/ is optional for both builders.

KB docs/kb/How_To_Deploy_PXE_Server_New_Site.html: new-site deployment,
Windows only, with a site-vars.yml guide.

Checks: webapp tests pass (5 suites); the unattend lint and playbook
syntax checks pass; every .ps1 parses; there are no WJ hosts, IPs, names
or PC types in playbook/, webapp/, scripts/ or autoinstall/.
2026-10-16 20:37:22 -04:00
cproudlock
b11e678297 NICs: pick the imaging port at install, GE can go into any other port
Sites no longer need either MAC in site-vars.

pxe_nic_mac empty: before anything changes, the playbook runs
pxe-pick-nic --auto. It takes the one wired port with a link, accepts it
only if pxe-nic-check calls it isolated (no LLDP/CDP/802.1X, no other
DHCP server, at most 10 hosts heard in 65 s), and pins it. The choice is
saved in /etc/pxe-server/nic-pick.conf and never re-guessed at boot.
When it cannot tell (no link, two links, not isolated) nothing changes,
and the console banner says why and to run sudo pxe-pick-nic: pull the
imaging cable and plug it back in, and the port that blinked is the
imaging one. Both helpers are installed before the pick, so that works
after a refused first boot. The site's corp domains and allowed
switches are passed to the check.

corp_nic_mac empty: every other wired port (en*, eth*) is a catch-all
DHCP client, so GE can be plugged in at install or later.
- dnsmasq stays pinned with interface=pxe0. A new check refuses any
  dhcp-range outside the imaging subnet, and proxy ranges.
- The catch-all ports ignore offers from 172.16.9.1.
- UFW allows SSH and 9009 by source on any interface, and keeps those
  rules when the old any-interface rules are removed.
- pxe_nic_name must not look like en*/eth* in this mode.

pxe-netplan-apply.sh takes 3 or 5 args. Without a pinned GE NIC it:
- renames stray names (the old pxe0 after a re-pick, a leftover corp0)
  to enx<mac>, so the catch-all takes them, and restores them on revert;
- checks that every NIC that had a DHCP address before has one after,
  tracked by MAC.

With both MACs set (the WJ server), netplan, the dnsmasq pin and UFW
render byte-for-byte as before.

Tested in the pxetest VMs:
- Fresh install with no MACs and the GE link down: the imaging port was
  picked and pinned as pxe0 with 172.16.9.1, and dnsmasq bound DHCP to
  pxe0 only. GE plugged in afterwards got a lease, SSH worked, and the
  guard reported pxe0 isolated.
- --auto refuses with two links.
- The interactive pick works by pulling and replugging the link.
- A 3-arg re-apply is UNCHANGED.

Unit tests (fake sysfs) cover the re-pick and pinned-to-catch-all renames.
2026-10-16 15:56:47 -04:00
cproudlock
9af1c6d89a Capture site content from a live server so a new one can build every image
A freshly installed server could not build standard or engineer: about
8 GB they install from existed only on the running server.
scripts/capture-site-content.sh pulls it over SSH into the gitignored
payload:
- _shared/Applications/extra (Office, Adobe, OpenText, Java, fonts, Wi-Fi,
  Zscaler), without pxe-status/ (the playbook deploys that);
- each image's own Deploy/Applications files and folders (GE CA
  certificates, the Engineering Layer payload), without the repo-managed
  Install-EngineeringLayer.ps1 and without stale BPRT copies;
- the links images keep there, as links.txt;
- fresh bios-staging (*.exe plus models.txt, which the Dell update
  rewrites), boot.wim, and the known Blancco file set.

It dry-runs by default, compares by checksum (a Blancco config.img had the
same size and time as the stale copy), writes SHA256SUMS, and refuses what
FAT32 cannot hold: files of 4 GiB or more, Windows-illegal names, and
case-only name clashes.

build-usb.sh copies site-content/ unsplit and refuses to build without it
(unless --skip-site-content), or when SHA256SUMS is missing or does not
cover every file. The playbook checks SHA256SUMS, then merges the content
into winpeapps and recreates the links. It only links to folders that
exist, so it never creates an empty _shared BPRT, which would stop the
first MCL import from seeding it. Link lines are validated before anything
is copied.

pull-bios.sh is gone. It read a path the server no longer uses, with a
hard-coded password.
2026-10-16 15:17:42 -04:00
cproudlock
d63cc5761b Image import keeps the shared BPRT link and seeds BPRT on a fresh box
An MCL import merged Deploy/Applications with rsync -a, which replaced each
image's BPRT symlink with a plain copy of the vendor folder. That copy has
no site overlay, so standard/engineer enrollment lost
Register-BulkEnrollTask-Standard.ps1 (specialize Order 39).

Now Deploy/Applications is imported first and without BPRT:
- where _shared/Applications/BPRT exists it is left alone (the BPRT page
  keeps it current) and the image link is put back;
- on a box with none yet, the imported BPRT seeds it: vendor
  Select-PCConfig.ps1 kept as -vendor, our overlay added, BOM fix applied;
- the import refuses before anything moves when that folder would not work
  (vendor scripts missing, or no overlay copy to add).

The two Register scripts join Select-PCConfig.ps1 in the repo. The playbook
already syncs shopfloor-setup to the enrollment share, so
config.BPRT_OVERLAY_DIR finds all three there; overlay_sources now falls
back to it for every overlay file, not only the shim.
2026-10-16 14:56:33 -04:00
cproudlock
821a927365 Engineering Layer: survive the C:\Deploy cleanup; the employee gets the icon
Engineer builds left the employee without "Configure my Engineering
Layer". First logon ran C:\Deploy\Applications\extra_engineering\
Install-EngineeringLayer.ps1, and the script itself lived in C:\Deploy:
std/eng have no autologon, so a PC that rebooted before the employee
signed in had lost C:\Deploy to the orchestrator's DeleteDeployDir task,
and nothing installed, silently.

- engineer unattend, specialize Order 37 (free slot): run the script with
  -StageOnly, copying the payload and the script to C:\PreInstall\engineering
  while C:\Deploy exists (Path 119 chars)
- first logon Order 11: run it from C:\PreInstall\engineering, falling back
  to C:\Deploy if staging did not happen
- the script (now in the repo, playbook/engineering/; it lived only on the
  server): after the install, restore the Public desktop icon if it is
  missing and the PC is not configured yet (no C:\TMP\DUU_Complete.log), and
  register Active Setup so each later user gets the icon on their own
  desktop when the Public one is gone. v3.1 puts the icon on the Public
  desktop; clicking it (configure, from GE's V: share) deletes it for all
- playbook deploys the script to gea-engineer extra_engineering; the 650 MB
  payload stays site content (documented in SITE-CUSTOMIZATION.md)

Tested in the win11 VM with the exact unattend strings and a stand-in
installer that skips the icon: specialize staged payload and script;
C:\Deploy deleted; first logon installed from the staged copy, restored the
icon and registered Active Setup. The per-user step as a new user: icon on
their desktop when the PC is unconfigured and the Public icon is gone; none
when configured or when the Public icon is there. Lint clean. Not deployed:
unattends go out only through scripts/deploy-unattend.sh.
2026-10-16 12:37:50 -04:00
cproudlock
9d75d03b2d Standard/Engineer builds are done at OOBE on the imaging floor
Everything after OOBE needs the employee to log in, which happens off the
PXE network, so the floor waited forever at step 3 of 5. Now 3 steps:
WinPE, Windows setup installing apps, ready at OOBE (succeeded).

- both unattends: specialize Order 49 posts step 3 as succeeded
  (Path 182 chars; lint clean). Only that line changed.
- Send-ImagingStage.ps1: StageTotal 3; steps above the total (the old
  first-logon steps 4 and 5 still in the unattends) are logged and not
  posted, so they cannot reopen a finished build
- winpe-status-push.ps1: total 3 for gea-standard / gea-engineer
- floor tile step 3: "Ready at OOBE"

Tested in the win11 VM against the test server: steps 2 and 3 post,
step 3 shows succeeded, step 4 is skipped. Not deployed: the unattends
go out only through scripts/deploy-unattend.sh.
2026-10-16 10:09:40 -04:00
cproudlock
bca04f4ca2 Verified MCL uploads: hash every file, repair over HTTP, gate the import
Small Control JSONs have arrived empty or wrong more than once, and the old
check compared sizes only and skipped missing files. A copy of
Deploy/Control on the dev PC still holds two 0-byte JSONs.

Upload-Image.ps1 (tested on Windows PowerShell 5.1 in the win11 VM):
- refuses an unfinished MCL cache: exactly one <OS>-Production.tag (MCL
  writes it last), no Control file newer than it, selected OS matches,
  every Control/Tools JSON non-empty and valid
- checks payload against the SHA-256 MCL's own Control JSONs publish. MCL
  resumes downloads onto old files (FileMode.Append) and then trusts any
  file of the right size: the September junk-tail corruption. Offers to
  delete the bad cache files so MCL downloads them again
- hashes every file into a manifest, robocopies, POSTs the manifest
- re-sends bad small files over HTTP (no proxy), large ones by deleting
  the bad copy and robocopy /J (robocopy skips a same-size, same-time file
  as "modified" even with /IS /IT)

Webapp:
- services/upload_verify.py hashes the upload on the server's own disk in
  a background thread; reports missing, differing, bad-in-MCL-cache and
  stray files; PUT /api/upload/file writes temp, checks hash, renames
- import of the upload folder only after a passed verification, with
  every file unchanged (size, mtime, ctime, inode), no stray files, and
  the target the OS allows (LTSC: Shop Floor only; else Standard or
  Engineer; unknown OS: none). Import, verify and PUT share one lock
- paths matched case-insensitively against the disk, as Samba does
- import page shows the upload, MCL run, verification and stray files
  with a remove button; record of each import kept on the image

Reviewed adversarially; all 11 confirmed findings fixed with tests.
42 new unit tests. VM test: clean upload; same-size same-mtime damage on
3 files repaired in one round; stray file blocks import until removed;
Shop Floor refused for a Pro upload; import records and clears the
folder; missing tag, 0-byte JSON, junk-head WIM and late Control file
each refused with nothing uploaded.
2026-10-16 10:09:40 -04:00
cproudlock
576955785d Faster shopfloor-setup sync, prompt spacing, XP-era test client
- Deploy shopfloor-setup with rsync instead of the copy module: the
  ~4 GB tree took about 4 minutes on every re-run because copy checksums
  each file. rsync transfers only changes; changed only when it did.
- iPXE drops a trailing space, so 'Machine number:' ran into the typed
  digits. The space now comes from a hex 20 setting.
- tests/vm: pxetest-xp is a permanent client (SeaBIOS, qemu32 32-bit CPU,
  rtl8139, 1 GiB, IDE disk) for the i686 Clonezilla path; start.sh xp.
  Only one client may run at a time across all three.
2026-10-15 21:43:33 -04:00
cproudlock
23ee99a390 Clonezilla: warn while the default share password is in use; prepare-boot-tools.sh no longer fails on an empty tool folder
The final listing of an empty boot-tools/blancco (no Blancco ISO yet)
failed under pipefail and made the script exit 1 after a good download.
2026-10-15 15:32:10 -04:00
cproudlock
9d56a5369e Clonezilla back up / restore by machine number
Boot menu: "Clonezilla: back up this PC" and "restore this PC" ask for the
machine number and boot Clonezilla (64-bit with Debian shim, or 32-bit
i686 by cpuid) with ocs_repository=smb3://clonezilla:<pw>@server/clonezilla/,
ocs_prerun fetching clonezilla-scripts/pxe-cz.sh and ocs_live_run running
it. The script validates the machine number, saves
<machine>-<YYYY-MM-DD-HHMM> (server time), writes pxe-meta.txt, runs
ocs-chkimg, and restores only that machine's images with -k1 -r after a
typed YES. The old entry is "Clonezilla: manual".

Playbook: clonezilla Samba user for the [clonezilla] share only, password
from clonezilla_smb_password (validated, filled into the menu
placeholder), scripts installed to the web root.

Webapp: Clonezilla backups page lists image folders and zips grouped by
machine number with serial/model/version/check, serial mismatch flag,
delete, move to another machine, streamed tar download, disk space bar
and an explicit keep-last-N retention with preview. Tests and a VM test
plan added.
2026-10-15 15:26:30 -04:00
cproudlock
dde1aacccb Clonezilla for XP-era PCs: 32-bit build picked by CPU
Current Clonezilla is amd64-only. The menu now checks the CPU with iPXE's
cpuid --ext 29 (long mode): 64-bit CPUs get the current release (through
Debian's shim under Secure Boot), 32-bit-only CPUs (Pentium 4 without
EM64T, Pentium M, early Atom) get Clonezilla 3.2.0-5 i686, the last
32-bit release, from clonezilla-i686/. Without a cpuid command the 32-bit
build is used, which also runs on 64-bit CPUs; UEFI always gets the
64-bit build. A 'Clonezilla Live 32-bit' item forces it.
prepare-boot-tools.sh downloads it, the playbook deploys it.
2026-10-15 14:50:40 -04:00
cproudlock
54015566a7 Menu: Clonezilla boots with Secure Boot on (Debian shim); Memtest86+ removed
Clonezilla Live runs a Debian-signed kernel whose modules (usb-storage,
uas, exfat) are signed by the same build key, so the menu now starts it
through Debian's Microsoft-signed shim with the iPXE shim command, the
same way Blancco uses Ubuntu's. prepare-boot-tools.sh extracts that shim
from the Clonezilla release (EFI/boot/bootx64.efi -> clonezilla/
shimx64.efi) and warns when the shim is not Microsoft-signed or the
kernel not Debian-signed.

Memtest86+ is unsigned and needed Secure Boot off; memory tests now use
the Dell built-in diagnostics (F12 at power-on, Diagnostics), noted in
the menu. The playbook removes the old memtest web dir. Nothing on the
menu needs Secure Boot off any more; the 'disable Secure Boot' screen
remains only as the fallback when a signed path fails.
2026-10-15 14:47:25 -04:00
cproudlock
c8e0b4eace docs: plain ASCII in the new build-usb example 2026-10-15 14:39:21 -04:00
cproudlock
da6e62c084 Merge branch 'fix-build' into pxe-webapp-redesign 2026-10-15 14:38:48 -04:00
cproudlock
0b1625ed87 Build/playbook re-run safety: corp access, netplan rollback, first-boot retry
H1  corp_admin_cidrs (UFW 22/9009 on the corp NIC from these only; refuses
    when it would lock out the current SSH client) and corp_ssh_password_auth
    (sshd drop-in, passwords only from the imaging subnet). Defaults keep the
    current behaviour and print a loud warning.
H2  pxe-netplan-apply.sh: pre-flight refusal (pxe-MAC NIC holds the default
    route, a DHCP lease or the route to an SSH client), run from the USB
    before anything changes; post-apply checks (names, imaging IPv4, corp
    IPv4 back within 30 s, SSH route) and a 120 s systemd-run auto-revert.
H3  restore() puts the previous NIC names back (two-phase rename via
    temporary names, so swaps work) before generate + apply.
H4  a failed rename is fatal (restore + exit 1); the play drops the old
    any-interface UFW rules only after verifying both NICs and their
    per-interface SSH rules.
H5  first-boot checks the playbook exit code, retries at next boot (max 5),
    writes /var/lib/pxe-firstboot.failed and /etc/issue + /etc/motd banners.
M1  build-usb.sh verifies iPXE files against the playbook pins, every wheel
    against its own package's hashes, drops the unpinned pip fallback,
    requires --site-vars or --no-site-vars.
M2  webapp restarts when the code, wheels or unit changed.
M3  ignore-carrier on the imaging NIC; ip_nonlocal_bind kept for the
    apache/networkd boot race.
M4  menu suffix must match blancco_kver; missing kernel with no earlier
    build is fatal; a live menu with the staged Blancco test entry is not
    overwritten.
M5  build-proxmox-iso.sh is unsupported (hard exit); use build-usb --image.
M6  deploy-unattend.sh: password via SSHPASS env and sudo stdin only,
    %q-quoted remote args, sha256 check of the live file; lint-unattend.py
    reads PXE_SERVER/PXE_USER/PXE_PASS and uses sshpass -e.
M7  the playbook lints the unattend files before the force: yes copies
    (lint-unattend.py ships on CIDATA as scripts/); pre-commit ACMR.
M8  Blancco initramfs build: no ignore_errors, pipefail, trap cleanup.
Also: Send-ImagingStage.ps1 deploy, carrier hook install tasks
(pxe-nic-carrier, networkd-dispatcher), L1 imaging-active hold
(allow_disruptive) and no iPXE downgrade below an Updates-page install,
L2 iso-volid, L3 kver pin read from the playbook, L4 cp -rL, L5 refuse
>= 4 GiB WinPE files, L6 PIP_DIR wipe guard, L7 per-package wheel hashes,
no online fallback, L8 check-updates.py (apt_pkg/cached compare, all
components, exit 2 on no debs / bad answers).
2026-10-15 14:32:23 -04:00
cproudlock
b20e6be629 Merge branch 'fix-boot' into pxe-webapp-redesign 2026-10-15 14:27:35 -04:00
cproudlock
21ff95af78 NIC guard: ignore client EAPOL, re-check on carrier change, SMB-only lease cleanup
- pxe-nic-check: count 802.1X only from an authenticator (EAPOL type 0
  with EAP Request/Success/Failure). iPXE sends EAPOL-Start on every NIC
  open and Windows sends Start/Response, so a PXE-booting bay made the
  verdict 'ge' and the watch stopped dnsmasq for the floor.
- pxe-nic-check: join the STP/PAE/LLDP/CDP/PVST+ multicast groups on
  every capture socket, so CDP-only ports are heard on a plain NIC.
- pxe-nic-check --watch: if dnsmasq was (re)started inside the window,
  re-check (~6 s) before stopping it; skip when already stopped.
  New --state-cmd and --print-imaging.
- pxe-nic-carrier (+ pxe-nic-carrier.service): networkd-dispatcher hook
  for the imaging NIC only. Carrier loss stops dnsmasq; carrier gain
  restarts it through its guard if it was running or the hook stopped it.
- pxe-dhcp-hook.sh: clear only SMB (tcp 445/139) conntrack entries and
  sockets, not all traffic for the client, which raced iPXE's first
  HTTP fetch.
- GetPxeScript.aspx: every boot line has a failure branch (iPXE 2.0
  accepts an unsigned EFI kernel and fails at boot under Secure Boot,
  which aborted the script).
- Tests: client EAPOL, packet_mreq packing, watch race, shell helpers on
  stub PATH, lab scenarios eapol-client, br-eapol-client, race, carrier.
2026-10-15 14:26:39 -04:00
cproudlock
606659bf07 webapp: fix review findings (imaging types, NIC status, iPXE gates)
- imaging: coerce client payload and stored session fields to str/int,
  drop lists/dicts; tiles template guards pctype/current_stage; POST
  rejects non-object bodies and non-string serials.
- nic_status: normalise every check-file entry; load nic.conf as-is with
  a [nic] fallback; split interface= lists; keep the check's binding while
  fresh, resolve wildcard sockets from the dnsmasq pin (never True for an
  excluded NIC); mark vanished NICs down; include role bridges; stale or
  future-dated files downgrade to warnings and drop evidence.
- Overview renders the network pill in its own try and shows
  'Network: unknown' when status fails.
- ipxe_update: read every Authenticode signature (all WIN_CERTIFICATE
  entries plus nested SPC_NESTED_SIGNATURE) with openssl; shim swap and
  install need both Microsoft UEFI CA 2011 and 2023; install re-reads
  each file (snponly ipxe.efi only); staged.json schema version, older
  records shown stale and refused; uhci counts as native USB.
- backups describe their files; risky rollbacks need a confirm flag.
- mac_folders: guarded listdir, cached describe, configured TFTP root,
  warning for a shim without ipxe.efi.
- evidence pack: years 2000..2100, 400 days inclusive.
- a11y role=group on quick ranges; progress bar stays full during 'shim'.
- tests/test_review_fixes.py: 29 unittest cases incl. both real shims.
2026-10-15 14:20:24 -04:00
cproudlock
53d39bfc50 tests/vm: Blancco RAM note, menu keys 2026-10-15 11:39:01 -04:00
cproudlock
24c41d7e87 build-usb: count PPKGs/wheels/debs through symlinked payload dirs 2026-10-15 11:04:41 -04:00
cproudlock
2717439690 Merge branch 'pxe-webapp-redesign' into usb-build 2026-10-15 10:59:30 -04:00
cproudlock
bcd91b488a Offline install that lands fully patched: resolve against the ISO's own base
- autoinstall: dead mirror + fallback offline-install, so the installed
  base is exactly the ISO's; late-commands run offline-apt-install.sh on
  CIDATA packages/ (local apt repo, one transaction, errors fail the
  install, log /var/log/offline-apt-install.log); first boot no longer
  runs dpkg -i; real archive sources written back at the end.
- download-packages.sh --iso: unpacks the ISO's default install source
  into a docker image (pxe-iso-base:<version>) and downloads there: all
  updates of the base, the explicit list (zip included) and the newest
  pool packages with their closure; writes Packages + explicit.list and
  checks the set offline in a fresh base container (--network none).
- offline-apt-install.sh: new; also used by that check (--simulate).
- build-usb.sh: ships Packages, explicit.list and the script; refuses a
  set without them.
- tests/vm: pxetest-client-nosb (Secure Boot off, e1000e, SATA), one
  client at a time.
2026-10-15 10:59:30 -04:00
cproudlock
bab6927bdb prepare-boot-tools.sh: current Clonezilla (3.3.3-37) and Memtest86+ (8.10)
Clonezilla 3.2.1-6 is gone from the mirrors (the download failed and the
unzip aborted the script before Memtest). Memtest moved its downloads to
download/v<ver>/, and 8.x names the x86_64 UEFI binary mt86p_<ver>_x86_64
with no .efi suffix; the script now finds it.
2026-10-15 10:42:56 -04:00
cproudlock
c1a5fb3e02 Merge branch 'pxe-webapp-redesign' into usb-build 2026-10-15 09:57:15 -04:00
cproudlock
86b4c1f301 tests/vm: quiet ISO download progress 2026-10-15 09:52:08 -04:00
cproudlock
18f20b00e2 Offline bundle: pin and hash the webapp wheels, refuse unpinned ones, check for updates
- webapp/requirements.txt pins all eight wheels with sha256, at the fixed
  releases (lxml 6.1.3 for GHSA-vfmq-68hx-4jfw / PYSEC-2026-87, werkzeug
  3.1.9 for GHSA-g6x2-hccm-hh4m).
- download-packages.sh fetches exactly that set for Python 3.12 / x86_64
  into an emptied folder; PIP_DIR is honoured inside the container (own
  mount), files are handed back to the calling user, USE_DOCKER=1 forces
  the container on a noble host, an unknown package name now prints apt's
  error instead of failing silently, and linux-modules-extra-7.0.* is gone
  (it does not exist; 7.0 ships those modules in linux-modules).
- Playbook: the wheel installer refuses any wheel whose sha256 is not
  pinned, needs exactly one wheel per pin, and removes an older installed
  version by its RECORD first. The webapp copy is a full rsync every run
  (was cp with creates: app.py, which never updated an install).
- scripts/check-updates.py: offline debs against noble security/updates
  and the pins against PyPI + osv.dev; exit 1 when something needs
  updating. Run it before building a USB.

Tested: installer in a Python 3.12 venv (clean install, upgrade over lxml
6.0.4 + werkzeug 3.1.8 leaves no old dist-info, re-run is a no-op, a stray
wheel is refused); check-updates.py flags the April bundle (94 security
updates) and passes the pins.
2026-10-15 09:41:16 -04:00
cproudlock
6dc9761e67 build-usb: --site-vars puts the per-site vars on CIDATA; harness pins the VM NIC MACs
tests/vm/build-image.sh writes site-vars.yml with pxe_nic_mac (isolated
network NIC) and corp_nic_mac (NAT NIC) and passes it to build-usb.sh.
2026-10-15 08:56:45 -04:00
cproudlock
29d815fbeb Merge branch 'pxe-webapp-redesign' into usb-build 2026-10-15 08:55:40 -04:00
cproudlock
45cf7be37a build-usb: whole webapp, hard sfdisk failure, ISO-derived boot params, image-file mode; tests/vm harness
- Copy the whole webapp/ tree (minus __pycache__) in build-usb.sh and
  build-proxmox-iso.sh, so config, services/, views/ and
  blancco_evidence_monthly.py reach the server.
- sfdisk --append failure now aborts; the CIDATA partition is the one
  starting exactly at the computed sector, and nothing that starts
  before it is ever formatted (the old fallback hit the ISO's EFI
  partition).
- Volume id and El Torito load sizes come from the ISO
  (xorriso -report_el_torito as_mkisofs); grub.cfg must contain
  autoinstall after patching.
- --image FILE SIZE|auto builds the same layout in a file without root
  (mkfs.vfat -C + mtools, sfdisk on the file). Plus --payload-root,
  --skip-enrollment/-bios/-drivers/-boot-tools, --yes.
- tests/vm: libvirt harness (pxetest-server, pxetest-client, isolated
  pxetest-imaging network) with fetch-iso/build-image/create/start/
  status/stop/destroy.
2026-10-15 08:55:40 -04:00
cproudlock
ead3314f58 Two-NIC standard: pin imaging and GE NICs by MAC everywhere
Every server has an imaging NIC (static, the only one that serves
DHCP/TFTP/HTTP boot/SMB) and a GE corporate NIC (DHCP client, SSH and the
webapp on 9009). The playbook guessed the PXE NIC and the installer put
172.16.9.1/24 on every en* NIC, so a two-NIC box could answer on the GE
network.

- New per-site vars pxe_nic_mac / corp_nic_mac (required, from
  playbook/site-vars.yml or -e; template site-vars.example.yml), stable
  names pxe_nic_name / corp_nic_name (default pxe0 / corp0, overridable),
  pxe_server_ip, pxe_subnet, DHCP range. pxe_imaging_mac / pxe_ge_mac are
  derived from them. The play stops before touching anything if a MAC is
  unset, malformed, duplicated or not on the box (lists the box's NICs).
- Heuristic NIC detection and the single-NIC netplan task replaced by a
  two-NIC netplan template (match macaddress + set-name), installed by
  pxe-netplan-apply.sh: validate in a scratch root, back up, generate,
  rename, apply, restore the previous config on any failure. cloud-init
  network config disabled so it cannot rewrite it.
- dnsmasq: /etc/dnsmasq.d/pxe-nic-only.conf from the vars (interface=,
  except-interface=, no-dhcp-interface=); dnsmasq.conf IP/range from vars.
- UFW per interface: imaging NIC 22, 67/udp, 69/udp, 80, 4433, 445, 139,
  137/udp, 138/udp, 9009; GE NIC 22 and 9009 only. Old any-interface rules
  and the stale 9011 rule removed after the new ones are in.
- sysctl drop-in: IPv4/IPv6 forwarding off; ip_nonlocal_bind so Apache
  can bind the imaging IP before the address is up.
- Samba [global]: interfaces = 127.0.0.0/8 <imaging NIC>, bind interfaces
  only. Apache 80 and 4433 on the imaging IP only; 9009 stays on all (TODO:
  the webapp has no auth).
- autoinstall: DHCP only on en*, no static 172.16.9.1; apt geoip off and
  offline-install fallback now that the corp NIC may have a lease.
- Blancco UEFI/Secure Boot: blancco_kver (7.0.0-34-generic) drives the
  kernel, initramfs and modules builds instead of the running kernel;
  outputs vmlinuz-ubuntu-70, kexec-initrd-70.img, kmod-<kver>.tar.gz as the
  menu and blancco-init.sh expect; creates: guards keyed on the full kver.
  Ubuntu's shimx64.efi.signed published as blancco/shimx64.efi.
  download-packages.sh adds shim-signed, linux-generic-hwe-24.04 and the
  pinned linux-image/modules/modules-extra for BLANCCO_KVER.

Rendered with the live MACs and names, netplan generate output is
identical to the live config; pxe-nic-only.conf and the Samba binding
match live directive for directive.
2026-10-15 08:53:41 -04:00
cproudlock
689eebf9d1 Merge branch 'nic-guard' into pxe-webapp-redesign 2026-10-15 08:40:57 -04:00
cproudlock
d3a0f90ca1 Re-imaging the same PC: clear its stale SMB/conntrack state every time
A PC whose imaging failed and is re-imaged gets the same IP, and the
server still held state from the dead run, so net use failed (System
error 53). The April fix (18537ac) did not cover it:

- The dnsmasq hook only acted on 'add'/'del'. A re-image comes back to the
  existing lease, which dnsmasq reports as 'old', and only when the
  hostname/MAC changed. Now dnsmasq sets script-on-renewal, and the hook
  clears state on 'old' when the request comes from PXE firmware or iPXE
  (vendor class PXEClient), i.e. the machine just network-booted. A
  Windows lease renewal (MSFT 5.0) is left alone.
- The 1 h conntrack timeout never applied after a reboot: systemd-sysctl
  runs before ufw loads nf_conntrack, so the keys did not exist and the
  5-day default stayed. /etc/modules-load.d/pxe-conntrack.conf loads it
  first.
- smb-soft-reset.sh flushed neighbours on br-pxe, which no longer exists.

Live 2026-10-14. Hook tested on the server with an unused IP: network-boot
renewal clears, Windows renewal and no vendor class do not, add clears.
2026-10-14 17:19:33 -04:00
cproudlock
bc422649e9 NIC guard: dnsmasq never serves DHCP/PXE on the GE network
pxe-nic-check classifies each NIC from what it hears: LLDP/CDP/802.1X
from managed switches, foreign DHCP offers and their domain (one
DHCPDISCOVER on the imaging NIC; the GE NIC's networkd lease is read
instead), STP and ARP hosts for information. A kernel BPF filter keeps
bulk SMB traffic out of the capture loop; bridge ports are listened on
too, since LLDP and EAPOL stop at the port.

- dnsmasq drop-in runs --guard as ExecStartPre: exit 1 on a ge or
  foreign-dhcp imaging verdict, fail open (loudly) on bugs or no link
- pxe-nic-check.timer runs --watch every 5 min: stops dnsmasq on a bad
  imaging verdict, never starts it
- /run/pxe-nic-status.json (0644, atomic) for the webapp
- playbook installs it all and writes /etc/pxe-server/nic.conf from
  pxe_imaging_mac, pxe_ge_mac, pxe_corp_domain_suffixes,
  pxe_nic_allowed_switches
- unit tests plus a no-root network-namespace lab (veth pairs, a raw
  socket DHCP/LLDP peer and real dnsmasq); docs/pxe-nic-guard.md has the
  operator notes and the two floor tests
2026-10-14 17:18:53 -04:00
cproudlock
28fd9fe474 Fix the unattend that broke Engineer builds; gate unattend deploys on the linter
a94c203 added a RunSynchronous Path of 417 chars to both std/eng answer
files. Windows caps it at 259 and then rejects the WHOLE answer file, so
an Engineer build stopped at 'Windows could not parse or process unattend
answer file'. The live files were restored to their pre-a94c203 versions
the same day.

- The status steps now fit: Send-ImagingStage.ps1 -Persist copies itself
  out of C:\Deploy (no cmd copy chain), short PowerShell switches, default
  arguments left out. Longest Path is 168 chars; lint-unattend passes.
  NOT deployed: these files must go live only through deploy-unattend.sh.
- scripts/deploy-unattend.sh: the only route for an answer file to a PXE
  server. Lints first and refuses on any error, backs up, swaps
  atomically, lints the live copy and restores the backup if that fails.
- .githooks/pre-commit (git config core.hooksPath .githooks): refuses a
  commit whose staged unattend fails the linter. Verified by planting a
  352-char Path (blocked) and against the exact a94c203 file (rejected).
- Boot menu: also accept SecureBoot read as hex 01, and show the raw values
  in the warning so a wrong read can be diagnosed from the screen.
2026-10-14 17:14:18 -04:00
cproudlock
454e30ff6f Floor shows a staged BIOS update from the start of WinPE; NIC speed past 4.29 Gb/s
- winpe-status-push.ps1: check-bios.cmd stages the firmware update before
  the imaging menu, but startnet's 'BIOS firmware update' push only comes
  at the end of WinPE, so builds showed a plain 'Applying image' for the
  whole apply. The first push now reports the staged update when
  X:\bios-fired.flag exists, and the floor shows 'Applying image - BIOS
  update staged' (do not power off during the next reboot). Also: CIM
  fallback for the serial where wmic is missing, and standard/engineer
  always report 5 steps.
- Network pill: cdc_ncm (e.g. the WisdPI RTL8157 5G adapter) keeps link
  speed in a 32-bit bits/s field, so 5 Gb/s reads 705 Mb/s; show the real
  speed with a note.

Tested in the win11 VM (with and without the BIOS flag, and the late
startnet push).
2026-10-14 16:56:46 -04:00
cproudlock
0d69222f85 Overview: network status pill for the two NICs
A 'Network' pill next to 'Services' lists each physical NIC with its role
(imaging / GE network, from /etc/pxe-server/nic.conf or dnsmasq's
interface= pin), link, speed, addresses, and whether dnsmasq serves
DHCP/PXE on it. It reads pxe-nic-check's verdict file
(/run/pxe-nic-status.json) when present, and shows a red banner when the
imaging NIC is on the GE network or a network with another DHCP server,
or when dnsmasq answers DHCP on a non-imaging NIC. Without the file it
shows link and addresses only. Read-only.
2026-10-14 16:49:32 -04:00
cproudlock
63fafbfc73 Boot menu: Secure Boot check for WinPE, Blancco through the shim; tracked as a file
Live since 2026-10-14 for every client (all UEFI clients now run iPXE 2.0):
- WinPE skips the 'Secure Boot must be ENABLED' warning and its 5 s wait
  when iPXE reads Secure Boot as on (${efi/SecureBoot:uint8}); otherwise,
  or on an iPXE that cannot read it, it warns as before.
- Blancco and Blancco debug start through Ubuntu's shim (iPXE shim
  command), so they run with Secure Boot on; an iPXE without the command
  boots the kernel directly as before. The separate Secure Boot TEST entry
  is gone.
- Blancco has its own menu section; only Clonezilla and Memtest stay under
  'Secure Boot must be DISABLED'.

The playbook now installs playbook/ipxe/GetPxeScript.aspx instead of an
inline copy that had drifted from the live menu. Checked that the Blancco
Releases page still parses it (one item blancco, :blancco_efi block).
2026-10-14 16:43:54 -04:00
cproudlock
045a1fb97f Secure Boot iPXE chain is the default for every UEFI client
Live since 2026-10-14: dnsmasq hands every UEFI client ipxe-shim.efi
(iPXE's shim, dual-signed under Microsoft UEFI CA 2011 + 2023), which
loads ipxe.efi from the TFTP root, now the iPXE v2.0.0 snponly build
(firmware network and USB drivers; the full build kills USB keyboards).
The per-MAC test tags and folders are gone; the old Broadcom-signed
1.21.1 build stays as ipxe-bcm.efi, the rollback.

- playbook: pinned sha256 vars, the new dnsmasq default line, and a
  verified, non-optional task that installs ipxe-shim.efi, ipxe.efi
  (snponly), ipxe.pxe and ipxe-bcm.efi (was: copy ipxe.efi and the never
  served undionly.kpxe, errors ignored).
- scripts/fetch-ipxe.sh downloads the pinned tarball and shim into
  boot-files/ for the USB build; README/SETUP list the step.
- iPXE page: ipxe.efi is staged from snponly.efi, a build that drives USB
  itself is refused, and the snponly-shim/snponly root files are gone.

Tested: the playbook task against the real release files (installs the
live hashes; a tampered file stops it before anything is replaced),
ansible --syntax-check, staging, and the full-build refusal.
2026-10-14 16:37:49 -04:00
cproudlock
a94c20331a Imaging floor: Standard/Engineer builds report progress past WinPE
The floor's 8 stages are the shopfloor pipeline, and only shopfloor-setup
reports after WinPE, so every gea-standard / gea-engineer build sat at
1/8 forever with no type.

- winpe-status-push.ps1 reads the image from the Z: share mapping when no
  PCTYPE is set and reports those builds as 5 steps.
- Send-ImagingStage.ps1 (deployed to _shared\Applications\extra\pxe-status)
  posts steps 2-5 from the unattend: start and end of specialize, first
  logon, and setup complete (succeeded). It is copied to
  C:\ProgramData\PxeStatus first, because the bulk-enrollment task deletes
  C:\Deploy before first logon. Best-effort: always exits 0.
- Floor rows use Standard/Engineer labels and show the image as the kind.

Tested as SYSTEM in the win11 VM (PowerShell 5.1): the exact unattend
command line, stages 3-5 against a test server, a dead port (exit 0), and
the image regex.
2026-10-14 16:23:52 -04:00
cproudlock
81c2d85301 Webapp: iPXE page stages the dual-signed shim, knows snponly and per-MAC folders
Staging fetches ipxe-shimx64.efi from the ipxe/shim release (sha256 from
GitHub), and uses it instead of the tarball's 2011-only shim when it is
signed under the Microsoft UEFI CA 2023 and trusts the same iPXE CA;
otherwise the tarball shim stays, with the reason shown. snponly.efi and
snponly-shim.efi are managed alongside ipxe.efi and ipxe-shim.efi, each
iPXE build says whether it drives USB itself (kills USB keyboards), and
tftp-unique-root per-MAC folders are listed read-only.
2026-10-14 15:37:14 -04:00
cproudlock
4db9dc093f Webapp: Last 3 months evidence range, keep popovers on screen
Evidence pack gets a Last 3 months quick range (three full calendar
months). Opened menus are nudged back inside the window, so header
popovers no longer start off-screen on phones or a wrapped header.
Buttons and filter pills inside a menu keep their own styling.
2026-10-14 15:12:38 -04:00
cproudlock
2dbaaded44 Webapp: evidence pack date range, US month labels, hardware gap rows
Evidence pack takes ?from=&to= (inclusive, Eastern time) with From/To
pickers and quick ranges; ZIP is named ENT-056_<site>_<from>_to_<to>.
Month keys show as 'September 2026'. Hardware gap rows on the to-do
use the shared one-line grid with chips for the missing pack or BIOS.
2026-10-14 15:08:36 -04:00
cproudlock
a54df7ebd9 Webapp redesign: imaging floor at laptop widths, keyboard tips, placeholders
From the design review:
- Floor rows reserve 150px for actions instead of 190px; stage text is
  one line with the full text in a tip (safety text still wraps); the PC
  type hides first; rows stack only below 960px of panel width, with the
  actions on the status line instead of their own row.
- Tips also show on keyboard focus.
- Placeholders use the muted token (dark-theme contrast was 3.6:1).
- Save bar focus ring uses the save bar's accent (2.8:1 before).
2026-10-14 14:52:45 -04:00
cproudlock
2ed2cbe2e9 Webapp redesign: fixes from the functional regression test
- Unattend: a command's '...' menu stayed open after Move up/down and
  covered the next row's menu, so the next click could stage removing the
  wrong command. The menu now closes and focus returns to its button.
- '...' menus close on an outside click, on Escape and when another
  opens (static/app.js), for every page.
- Drivers: Remove asks to confirm again, as before the redesign. Long zip
  paths in a row body wrap (the page scrolled sideways at 390 px).
- Rollback review returns to the page it was opened from (Dell page or
  Updates overview), for both Cancel and the rollback itself.
- To-do: the ServiceNow period offers a year again (366 days).
- Imaging floor: a search that matches a log-only bay opens that group;
  its open state survives the live refresh. 'Clear all' states the
  number of session files it deletes, hidden finished ones included.
- Dashboard: the image key pattern is valid under the browser's v flag
  (client-side validation was silently skipped).
- Import: allowed_import_source() compares real paths, so
  '<upload dir>/../..' is refused. This path traversal predates the
  redesign; the import moves the source tree, so it mattered.
2026-10-14 14:48:10 -04:00
cproudlock
2bcc9d8016 Webapp redesign: fixes from the code review
Blocker
- /reports/export returned 500: REPORT_EXPORT_RANGES and _months_ago were
  deleted with the old reports route. Restored.

Major
- A session without stage_index/stage_total took down /, /imaging and
  /imaging/tiles (int filter on Undefined). The templates default both;
  saving a LAPS password for a session that no longer exists is refused
  (it created a bare session).
- /reports/todo parsed every report four times (23 s at 700 reports).
  blancco_report.parse() now caches by path, mtime and size: 0.09 s.
- Stored XSS through the session serial in the Clear confirm (the
  browser decodes &#39; before running an inline handler). The message
  moves to data-confirm, and update_session() only accepts serials of
  letters, digits, '.', '_' and '-' (400 from /imaging/status otherwise).
- Reports without a document id and with odd characters in the file name
  got a key the mark routes rejected, so they could never be cleared.
  report_key() now always produces a valid key.

Minor
- failure_note() handles a list-valued erasure target (500 on the report
  and to-do pages).
- _recently_marked() tolerates naive, 'Z' and non-dict state entries.
- Key regexes use fullmatch (a trailing newline was accepted).
- Unreadable reports are no longer counted as to tick / to destroy; the
  reports page already flags them as Unreadable.
- Rollback review recounts restorable files after reading the backups.
- Enrollment: the startnet fallback package (no or empty ppkg.conf) and a
  quoted SOURCE_PPKG are protected too.
- Backup upload overwrite check is case-insensitive (SMB share) and
  replaces the existing file rather than adding a case twin.
- Overview polling keeps the last good rows on an error and pauses while
  the tab is hidden. A shown or typed LAPS password survives the floor's
  refresh.
- hardware_dismiss only redirects to same-site paths; tiles ?limit is
  clamped; the Updates overview survives an unreadable Dell scan or iPXE
  state; describe() uses aversion(); a client cannot label its session
  'inferred'.
2026-10-14 14:42:21 -04:00
cproudlock
fd00d5ff89 Webapp redesign phase 6: the remaining pages
Restyled to the same row pattern (one line, details in an expandable
body, one action plus a menu, tokens for light and dark):
- hardware coverage, audit log (family chips, destructive actions in
  red, client-side filters, first 300 rows), Clonezilla backups,
  enrollment packages (active package marked and undeletable, MCL flags
  with a visible legend)
- image Overview, OS and packages, Unattend (sticky save bar), Import
- Dell, iPXE, Blancco and BPRT update pages; Dell history links to the
  rollback review instead of rolling back on one click
- boot menu editor (tabs, rows with a menu, lint chips that jump to the
  line, tokens instead of hard-coded dark colours) and the imaging
  session detail page

Fixes found on the way:
- BPRT batch Activate never asked to confirm: a hidden input named
  'confirm' shadowed window.confirm in the inline handler, so the form
  submitted without a prompt. It now calls window.confirm.
- Backup upload refused when a backup with that name exists, unless
  'Replace the existing backup' is ticked (it overwrote silently).
- app.js: em dashes in comments replaced (plain ASCII).
2026-10-14 14:29:03 -04:00
cproudlock
a6f95fcca1 Webapp redesign phase 5: Blancco reports, report and to-do
Reports (/reports):
- Grouped by month of the erasure date inside each report (not the file
  date), newest first; older months fold. Each month header shows the
  evidence pack state (builds on the 1st, to upload with Download, or
  uploaded).
- One line per report: erasure date, system serial, model, drives,
  result ('Successful, fallback' when a failed attempt fell back to
  another standard), cloud and ServiceNow state. The row body lists every
  drive and erasure attempt with the failure reason.
- Search by system serial, model, drive serial or file; filters for
  failed, not in cloud and to tick. 'To tick' uses the to-do's 30-day
  window; older unticked reports read 'Not ticked'.
- Export and the evidence pack download moved into header popovers.

Report (/reports/view/<file>):
- Header with serial, model and the per-drive result; a notice names
  the drive to destroy until its COD is recorded; Copy ServiceNow note.
- One card per drive with every attempt, a step track and the failure
  reason; steps and error counts fold. System, customer and provenance
  (integrity signature folded, copyable) on the side. Sizes in decimal
  units, as on the list.
- blancco_evidence.failure_note() is shared with the ServiceNow list.

To-do (/reports/todo):
- The ServiceNow tick list is now its first section (period and show
  ticked filters); /reports/servicenow redirects there.
- Five counters; one line per item with details in the row body.
- COD recorded, Mark uploaded and Resolved confirm first (the attribute
  quoting is safe for JSON) and can be undone for 24 hours from the
  page. The header total includes hardware gaps, matching the sidebar.
2026-10-14 14:00:54 -04:00
cproudlock
cc1e76f3ab Webapp redesign phase 4: Updates overview and rollback review
- GET /updates/: one card per updater (Dell, iPXE, Blancco, BPRT) with
  its state, a Check now / Open action and details in accordions; the
  shared download cap is a small popover.
- Dell applies list: one line per apply titled by its first change
  ('Latitude 5350 pack A11 to A12', model looked up in the maps) and
  'and n more'; the chip says how many written files are unchanged since
  the apply. The body lists every change, skipped item, error and file.
- dell_updates.file_status(): per file an apply wrote, unchanged /
  changed / missing by content hash, the same test rollback uses.
  describe(), history_view() and rollback_preview() build on it.
- GET /updates/dell/rollback/<id>: review a rollback before doing it,
  with the files that would be restored and a diff of each. The POST
  route is unchanged; both forms can return to the overview.
2026-10-14 13:54:41 -04:00
cproudlock
731d7b044d Webapp redesign phase 3: Drivers tab
- One line per entry: position, model tokens, pack, OS, size and status
  chips; family, friendly name, osId, folder, zip state, Dell hint and
  the verbatim lint findings are in the row body.
- Status chips compare the staged map with the saved one: New, Edited,
  Moved from #n, Removing, the worst current finding, and 'Clears on
  save' / 'Clears n' when the staged changes resolve this entry's
  findings. The toolbar and a sticky save bar show lint now and after.
- Removing an entry is staged with Undo remove instead of deleting the
  row; Undo edit puts an edited entry back.
- Reordering moved into the row menu (Move up / down / to position).
  The Move dialog shows the order after the move and re-lints the staged
  map with the move applied, listing any new errors before staging it.
- An edit that changes the tokens but keeps the old friendly name is
  flagged in the row and in the review step.
- Filters: Win11/Win10 with counts, 'Findings or unsaved', 'Zip
  problems' (missing, trap and case); a status key explains the chips.
- _image_header.html: new header and underline tabs for every image tab;
  the shared review modal and diff use the design tokens.
2026-10-14 13:51:04 -04:00
cproudlock
0b86255711 Webapp redesign phase 2: Overview and imaging floor
Imaging floor (/imaging, /imaging/tiles):
- One line per PC: status chip, serial with machine number, hostname
  and IP, stage text with a segmented stage track, one action plus a
  '...' menu. Everything else (description, error, timestamps, Intune
  ID + QR, Set category / ARTS links, LAPS QR, log tail) is in the row's
  expandable body.
- Order: failed, needs action (stage 7), in progress, done; log-only
  bays fold into one collapsed group. imaging_status.floor_state() and
  floor_order() compute this.
- Status filter buttons with live counts next to the text filter. SSE
  refresh, expanded-row memory and LAPS handling are unchanged.
- Stage text is never truncated; BIOS updates and erasing are bold.

Overview (/):
- Services popover, technician to-do line, the six most urgent floor
  rows (?limit=6 partial, refreshed every 15 s), and image cards with
  lint warnings, map errors and missing zips as separate chips. New,
  clone, delete and unattend stay on the cards' menus.

Copy buttons are now handled once in static/app.js, with a fallback for
plain-http pages where navigator.clipboard is unavailable.
2026-10-14 13:44:07 -04:00
cproudlock
35cad62733 Webapp redesign phase 1: shell, tokens and shared components
- static/pxe-ui.css: the redesign's colour tokens (light and dark),
  shell, chips, stage track, expandable rows, menus and buttons. The
  legacy tokens (--bg-card, --border, --text-light, ...) alias onto the
  new ones so pages not yet redesigned blend in.
- base.html: new sidebar (Operate, Images, Updates, Tools) with the GE
  Aerospace logo on the brand blue, to-do count, theme toggle with
  aria-pressed, and a menu button on narrow screens. The to-do banner is
  now a one-line strip. Skip link and <main> landmark added.
- _ui.html: Jinja macros for chip, track, the actions menu and the page
  header.
2026-10-14 13:38:05 -04:00
cproudlock
dfd57bef17 Webapp: fix six live bugs found in the page inventory
- Report view: the hero summarised only the first erasure, so a drive whose
  Purge failed and Clear fallback succeeded showed Failed while the list said
  Successful. It now uses the per-drive outcome (report_result) and lists the
  standards that wiped each drive.
- To-do and ServiceNow: reports with no usable document id could never be
  ticked or cleared (400). They are now tracked by file name (file:<name>).
- Imaging: the LAPS password field is masked, with a Show/Hide toggle.
- Imaging: succeeded sessions not updated for ACTIVE_WINDOW_HOURS are hidden
  from the dashboard (failed and in-progress always stay), with Show all.
- Enrollment: the active SFLD package can no longer be deleted, which left
  ppkg.conf pointing at a missing file; its row is marked Active.
- Import: confirm before importing, saying upload-folder sources are moved
  and same-name files in the image are overwritten.
2026-10-14 13:29:00 -04:00
cproudlock
24b4d9df66 Dell updates: say when the results predate the last apply
After an apply the tables still showed the check from before it, so a pack
just applied looked like it still needed updating until the next check.
2026-10-13 17:38:14 -04:00
cproudlock
aa026bace0 BIOS manifest: replace the 24250 line with QB24250, QC24250 and QC24251
'24250' also matched the Dell 24 All-in-One EC24250, which needs a different
BIOS (Dell_EC27250_EC24250). The three exact models are the ones the combined
file is built for. Found by the webapp's Dell check (ambiguous match).
2026-10-13 17:23:57 -04:00
cproudlock
6187219378 BPRT sync: point the page and docstrings at static/Sync-BPRT.ps1
Follow-up to 92f1e62, which moved the file but missed these references.
2026-10-13 17:12:51 -04:00
cproudlock
92f1e62966 BPRT sync: ship Sync-BPRT.ps1 with the webapp so the page can serve it
The download link 404'd on the server: deploys copy only webapp/, and the
script lived in scripts/windows/. One copy now, in webapp/static/.
2026-10-13 17:12:41 -04:00
cproudlock
ae806cf98e CMM imaging: goCMM 2.17.0.0 instead of 2.12.3
FileVersion detection on goCMM.exe so an older 2.x does not satisfy it. Same
NSIS installer and /S switch. Deployed to installers-post/cmm and the share's
shopfloor-setup copy; 2.12.3 moved to installers-post/cmm-archive so it is not
copied to every CMM image.
2026-10-13 17:10:09 -04:00
cproudlock
28cd977c9e goCMM restore: drop the 1.1 Installation Directory value on 2.x PCs
Per-bay backups were taken under goCMM 1.1, so goCMM.reg restores
Installation Directory = the 1.1 folder. goCMM 2.x never reads it, but the 2.x
installer sees it as '1.1 installed' and shows a dialog /S cannot answer,
which hung GE-Enforce on CMM11 and CMM12. Kept when 1.1 really is installed.
2026-10-13 16:58:55 -04:00
cproudlock
bfd5c5b75e Blancco init: optional blancco.base= so a staged release can be test-booted
Default is unchanged. Takes effect only after kexec-initrd-70.img is rebuilt.
2026-10-13 16:58:55 -04:00
cproudlock
dd9ee33cd5 Playbook: tools and Zscaler root CA the webapp Updates pages need
sbsigntool, zip and curl, the Zscaler root CA in the system store (GitHub is
TLS-intercepted on the corporate NIC; downloads.dell.com is not), and
/var/lib/pxe-webapp/updates.
2026-10-13 16:58:55 -04:00
cproudlock
397063f987 Webapp: Updates pages, image page tabs with review-before-save, BPRT sync
Updates group:
- Dell drivers and BIOS: check Dell's catalogs against all three driver maps
  and models.txt, download with a shared speed cap, repack, preview, apply
  with backups and rollback.
- iPXE boot files: check GitHub for the signed Secure Boot set, verify the
  sha256 and each file's signer, install atomically, roll back. Includes the
  by-hand procedure.
- Blancco: upload a release, stage it, test-boot it from the menu, activate,
  roll back.
- BPRT sync: validate a batch uploaded from the MediaCreator Lite cache
  (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site
  overlay files kept.

Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and
Packages tabs. Every save shows a diff first, keeps the file's format, makes a
backup and can be undone. Driver lint runs inline.

Enrollment: upload standard/engineer MCL packages, one per tenant/purpose,
with archive and restore. Hardware coverage counts engineer as Win11.
2026-10-13 16:58:55 -04:00
cproudlock
ddf0a2fdf2 BIOS manifest: update to Dell's current releases, fix wrong-platform lines
Scanned against Dell's per-model catalogs (CatalogIndexPC, 2026-10-09).
About 40 lines were behind, nine pointed at another platform's BIOS
(Latitude 7450 -> OptiPlex 7450, Latitude 5530/5540 -> Precision
5530/5540, Precision 5490/7770/7780 -> OptiPlex AIO, Precision 5550 ->
Latitude 5550, OptiPlex 7400/7410/7420 AIO -> Latitude 7x00/7x10/7x20),
and '7320 2-in-1 Detachable' never matched the WMI name. Specific lines
now precede general ones (5430 Rugged, 7090 Ultra, 3000 Thin Client)
because check-bios.cmd takes the first substring match.
2026-10-12 17:02:47 -04:00
cproudlock
6fac73c947 startnet: map Y: before status push and preflight on every path
The gea-standard/gea-engineer path (purpose -> tenant -> enroll_staged)
never mapped the enrollment share, so winpe-status-push.ps1 and
preflight.ps1 silently skipped those builds. Map it at :end if absent.
2026-10-09 09:59:11 -04:00
cproudlock
f0c049376c Report hardware from the BIOS check, so every WinPE path is covered
The hardware report went out from winpe-status-push.ps1 on the
enrollment share (Y:), but startnet.cmd never maps Y: on the
gea-standard / gea-engineer path (choices 1/2 go purpose -> tenant ->
enroll_staged), so those builds never reported. The same gap means
neither the status push nor preflight.ps1's driver check has ever run
for standard/engineer images.

The BIOS share is mapped by every WinPE boot before the menu, and
check-bios.cmd lives on it, so it now calls hardware-report.ps1 from
the same folder (best effort, output discarded). winpe-status-push.ps1
goes back to status only, so shopfloor bays are not counted twice. The
playbook deploys hardware-report.ps1 with the other BIOS files.
2026-10-09 09:46:11 -04:00
cproudlock
b691a82530 Hardware coverage: flag models the server has no drivers or BIOS for
WinPE now reports each machine's WMI model, manufacturer, Dell system
SKU and BIOS version to /hardware/seen (winpe-status-push.ps1, best
effort, wmic only). The webapp records every model seen and checks it
against what the server has:
  - each image's Deploy/Control/HardwareDriver.json, matched exactly as
    PESetup's GetDriverByModel does (as reimplemented in
    lint-driver-catalogue.py), plus whether the pack's zip is on disk;
  - the WinPE BIOS manifest (substring match like check-bios.cmd).

/hardware lists every model with per-image driver status and BIOS
status. A model missing a Win11 pack or a BIOS entry becomes item 5 on
the Technician To-Do (and the banner count) until the pack/BIOS is added
or it is dismissed. Virtual machines are ignored.

Also deploys the repo winpe-status-push.ps1 over the live copy, which
predated the -CurrentStage/-StageIndex parameters startnet.cmd passes
for the BIOS-flash stage, so that status post was failing.
2026-10-09 09:23:52 -04:00
cproudlock
e43ab2a652 webapp: 12-hour times (e.g. 2026-10-05 1:54 PM EDT)
CSV exports keep ISO 8601 24-hour timestamps.
2026-10-05 14:16:58 -04:00
cproudlock
83ba4865e8 Show every time in US Eastern (America/New_York)
Webapp: config.DISPLAY_TZ (default America/New_York) and a local_dt
filter that renders datetimes, ISO strings and Unix timestamps as e.g.
"2026-10-05 13:54 EDT", DST included. Used for the ServiceNow tick list,
technician to-do, imaging tiles and detail page; timestamp_fmt (reports,
backups, enrollment) goes through it too instead of relying on the
process TZ. CSV report_date is Eastern with its offset. Evidence-pack
months are Eastern calendar months, so a wipe late on the 31st lands in
that month rather than the next.

Blancco: init points the new root's /etc/localtime at America/New_York
so the GUI clock is local. Report timestamps keep their explicit
offset; report.utc_offset stays 0 since a fixed offset cannot follow DST.
Report file names stay UTC, as Blancco's own Save names them.
2026-10-05 14:16:14 -04:00
cproudlock
121577f090 webapp: wrap destruction notes instead of widening the table
The theme sets white-space: nowrap on every table cell, so the
ServiceNow tick list's destruction note (and the to-do failure column)
ran as one line and pushed the Done buttons off to the right. A
td.wrap utility lets prose cells wrap within 16-28rem.
2026-10-05 14:04:40 -04:00
cproudlock
6fcdc8ddb5 webapp: keep a tile's Log tail open across refreshes
Each SSE/poll refresh replaces the tiles' HTML, so the nested Log tail
<details> snapped shut on every update while a wipe was running. Its
open state is now remembered per tile in localStorage like the tile
itself, and its scroll position is kept: following the bottom keeps
following new lines, scrolled up stays put.
2026-10-05 14:02:51 -04:00
cproudlock
8c36d1ba5d blancco-init: post_status uses sed, not tr
The initrd's busybox has the tr applet but no /bin/tr link, so the
quiet boot printed "/init: line 140: tr: not found" and the stage 1/2
dashboard posts were skipped. sed is linked.
2026-10-05 14:01:26 -04:00
cproudlock
12f6f00cdb blancco-progress: take the final state from the report file
First normal (non-debug) run stuck at 6/7 "Report sent to cloud": outside
Blancco's debug mode Albus does not log erasureFinishedForEntity, Report
saving success or "Auto process reached the final step", which the
parser had been taken from a debug-mode journal.

A watcher thread now reads each new /reports/*.xml (auto mode always
saves there) once it is stable and posts 7: "Wipe complete" (succeeded)
or "Erasure FAILED: <model serial, ...>" (failed). Judged per drive, so
a failed Purge with a successful Clear fallback on the same drive is a
wipe. --outcome FILE prints the verdict for a report.
2026-10-05 13:59:09 -04:00
cproudlock
020f043d8b webapp: technician to-do for the manual CMMC steps; count fallbacks as wiped
Technician To-Do (sidebar link with a count, and a banner on every page
while anything is open) lists the playbook steps the server cannot do:
  1. wipes still to tick on the Onsite Bin RITM (ENT-045/159)
  2. monthly evidence packs to upload to Box (ENT-056/158), Mark uploaded
  3. drives that failed to wipe, to destroy (COD recorded)
  4. reports Blancco could not upload to the cloud (Resolved)
Marks are stored in the Blancco state file; the counts are cached for 60s
and refreshed on every mark, ServiceNow tick and cloud-status post.

Results are now judged per drive. Blancco keeps every attempt in the
report, so a NIST Purge that failed followed by a successful Clear
fallback on the same drive appeared as two entries, and the Reports page
rolled the whole report up to Failed. 20 such wipes since May showed as
Failed; they were wiped (the work instruction counts the fallback as a
success). The CSV marks those rows "Fallback: ... then erased with ...".
One genuine failure remains: HNMD1V3, Seagate Z500R4KG, 2026-08-11.
2026-10-05 13:50:26 -04:00
cproudlock
44a7eeae95 blancco: CMMC evidence pack, ServiceNow tick list, cloud-upload status
Automates the Blancco parts of the onsite-support CMMC playbook:

- ENT-056/158 evidence pack: /reports/evidence?month=YYYY-MM returns
  ENT-056_<Site>_<YYYY-MM>.zip (summary.csv, README, each report's XML
  and PDF). pxe-blancco-evidence.timer saves last month's pack to the
  reports share's evidence/ folder on the 1st (Persistent).
- ENT-045/159 ServiceNow tick list: /reports/servicenow lists recent
  reports as "Wiped" or "Blancco Wipe failed" with a pre-written
  destruction note, copy buttons and Done (CSRF-protected).
- Cloud upload: blancco-progress.py posts each report's upload result
  ("Sending report document id" -> "Report sent to MC") to
  /reports/cloud-status (CSRF-exempt like /imaging/status); the Reports
  page and the CSVs show it, so a wipe missing from BMP is visible.
- CSV gains failure_reason (exception messages, failed steps, error
  counts), business_location and cloud_upload.
- Preferences: business_location "West Jefferson" (config.BLANCCO_SITE)
  and pdf+xml reports; the report copier now copies the PDF too, named
  after its XML. .part and hidden files are not listed as reports.

State (cloud confirmations, ServiceNow ticks) is a JSON file at
/var/lib/pxe-webapp/blancco-state.json, off the report share.
2026-10-05 13:43:16 -04:00
cproudlock
eff956ae6c webapp: Blancco variant of the /imaging tiles
Sessions with source/pctype "blancco" (posted by blancco-init.sh and
blancco-progress.py) now render with their own stage labels
(Downloading, Starting, Licensing, Erasing, Erasure finished, Report
sent, Wipe complete; Erasure FAILED on failure), a WIPE badge with the
drive percentage instead of "no DeviceId", the current step beside the
stage, and an Erasure reports link. The drive percentage is folded into
the bar across stage 4. The LAPS QR card is hidden for Blancco tiles.

Also render the bar percentage as an integer: round() returns a float,
so every tile showed e.g. "57.0%".
2026-10-05 12:52:08 -04:00
cproudlock
19049e6b1f blancco: live erase progress on the /imaging dashboard
Each Blancco PC now gets a tile on /imaging, keyed by BIOS serial with
pctype "blancco", using the /imaging/status endpoint the WinPE clients
already post to (stage_total 7):

  1 Downloading Blancco, 2 Starting Blancco   (blancco-init.sh, wget)
  3 Waiting for licence / Licence OK (N left)
  4 Erasing: step N - <step> [NN%]
  5 Erasure finished / Report saved
  6 Report sent to cloud
  7 Done (succeeded), or Erasure FAILED (failed, sticks)

playbook/blancco-progress.py follows the AlbusSeven journal inside the
Blancco rootfs and maps its lines to those stages; patterns taken from
a real 7.20 auto-mode run and replayed with --replay/--dry-run, plus a
synthetic failed step. Init fetches it from the web root at boot and
installs it as blancco-progress.service; the playbook deploys it there.
The report copier adds a "Report copied to share" log line. Stage 1
resets a finished tile for the same serial. All posts are best effort.
2026-10-05 12:50:40 -04:00
cproudlock
a97faf9fad blancco-init: auto-save reports to the SMB share in process=auto
process=auto erases unattended but Albus forces auto_backup off outside
workflow/manual, so every report needed a manual Save to reach the
share. Debug SSH on a live 7.20 run showed Albus does keep each report
at /reports/<document_id>.xml ("Report saving success", "CRC verified")
and uploads it to MC, with /mnt/share already mounted.

Install blancco-report-sync as a systemd service in the new root: every
10s copy each finished /reports/*.xml (older than 5s, not yet copied)
to /mnt/share as YYYYMMDD_HHMMSS_report.xml (UTC, as a manual Save
names it), via a .part file and rename. Verified live: the auto-run
report landed as 20261005_164520_report.xml.
2026-10-05 12:46:48 -04:00
cproudlock
165d6d34a3 blancco-init: debug boots open SSH for root
With blancco.debug=1 the init now also creates /etc/debug.environment in
the new root (Blancco's isDebugMode, so 2_sshd starts sshd) and installs
root's authorized_keys from http://<server>/blancco/debug_authorized_keys.
Root is locked and sshd is key-only, so normal boots stay closed and a
debug boot without that file on the server stays closed too.

Used to find where Albus keeps the finished report in process=auto, which
never auto-backs-up to the share. Live GetPxeScript.aspx has a matching
"Blancco Drive Eraser (debug / SSH)" item (UEFI, 7.0 kernel, verbose);
not in the playbook menu.
2026-10-05 12:42:10 -04:00
cproudlock
b50ff36f71 blancco-preferences: back to process=auto until MC has a default workflow
process=workflow failed on the first real run: "failed to fetch the
default workflow ... there is no default workflow for user
api.wipecheck@ge.com". The GE Blancco console needs a default workflow
assigned to that account. Until then run auto, as live 7.15 did:
unattended erasure once licensed, report to the cloud automatically,
SMB share copy via Save (auto_backup is forced off outside
workflow/manual).
2026-10-05 12:32:54 -04:00
cproudlock
143c27c3bf blancco-preferences: run the cloud workflow process
Live 7.15 ran process=auto: it erased unattended but Albus forces
auto_backup off outside workflow/manual ("... not workflow or manual:
Report auto backup must be disabled"), so every report needed a manual
Save to reach the share. 7.20 was set to manual, which honours
auto_backup but waits at "Select devices" (seen on the first real run).

workflow is the only process that both runs unattended (the account's
default MC workflow, workflow default=true, fetched over WiFi) and
auto-backs-up the report ("Start file watcher for auto backup of
workflow process"). Live preferences.xml and config.img updated
(backups .bak-20261005-pre-workflow).
2026-10-05 12:29:38 -04:00
cproudlock
b3ed4cda81 blancco-init: give Blancco /run/initramfs from config.img on UEFI
On a native boot config.img is an initrd and archiso leaves its files in
/run/initramfs, where /opt/scripts/inc/1_reportKey, 2_certificate,
1_config and 2_flexible_container install report.key, ca.pem,
hybrid.ids and the flexible container. The Ubuntu-kernel UEFI path never
created /run/initramfs, so 1_reportKey wrote an empty /albus/report.key
and the rest were skipped (true since 7.15). Fetch config.img, unpack it
on a tmpfs mounted at the new root's /run (systemd keeps an existing
/run), and fail loudly if report.key is missing.

First real 7.20 run (Dell Pro 5 14, Panther Lake, 7.0 kernel) licensed
and erased, but did not start the cloud workflow on its own or save the
report without a click; the missing report key is the leading suspect.
2026-10-05 12:24:29 -04:00
cproudlock
14eab13a1a blancco-init: per-kernel kmod tarball, lazy umount of the report share
- Fetch kmod-$KVER.tar.gz first, falling back to kmod.tar.gz, so more
  than one boot kernel can share the web root. Live now boots the UEFI
  path on Ubuntu's 24.04 HWE 7.0.0-34-generic kernel: Panther Lake
  laptops (Dell Pro 14, AX211 on CNVi) need iwlwifi firmware API 100,
  which the 6.8 driver cannot load. 6.8 stays deployed as the rollback.
- The 10s DefaultTimeoutStopSec did not reach the /mnt/share mount: a
  mount unit's timeout is TimeoutSec=, defaulting to the start timeout.
  Add a mnt-share.mount drop-in with TimeoutSec=10s, LazyUnmount=yes and
  ForceUnmount=yes.

NOT in the playbook yet: it builds the initrd and kmod tarball from the
server's running kernel (6.8). The 7.0 artifacts on 172.16.9.1 were
built off-box; installing linux-generic-hwe-24.04 on the server is what
makes a playbook run reproduce them.
2026-10-05 12:17:11 -04:00
cproudlock
783f2fece0 blancco: quiet PXE boot, full trace with blancco.debug=1
The UEFI loader ran under set -x and printed modprobe -v, ip link and
ip addr, and neither kernel cmdline had quiet, so the screen scrolled
hundreds of lines before the Blancco GUI. Now:

- quiet loglevel=3 on both Blancco kernel lines (also quiets systemd)
- init clears the screen and prints one line per step
- blancco.debug=1 on the cmdline restores the old trace
- failure paths still print their diagnostics and drop to a shell

Live GetPxeScript.aspx changed to match (backup
.bak-20261005-pre-quiet) and kexec-initrd.img rebuilt.
2026-10-05 12:05:30 -04:00
cproudlock
1f5f9eb60a blancco-init: cap systemd stop jobs at 10s
Restarting Blancco 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 default. Reports are
written long before shutdown, so set DefaultTimeoutStopSec=10s in the
switched-to rootfs.
2026-10-05 12:02:43 -04:00
cproudlock
77655bbfcd pxe: ship Ubuntu iwlwifi firmware with the Blancco kmod tarball
The UEFI Blancco path boots the Ubuntu 6.8 kernel on Blancco's rootfs, so
iwlwifi loads firmware from the Blancco image. 7.20 dropped the API 86
ucode for AX201/AX210/AX211 and ships only API 89, which the 6.8 driver
cannot use: WiFi never came up, so no cloud licence (seen 2026-10-05).

kmod.tar.gz now also carries the host's lib/firmware/iwlwifi-* (183
files, .zst, which the kernel prefers over the image's .xz). Files only:
on the Blancco rootfs lib is a symlink to usr/lib. Live tarball rebuilt
(backup kmod.tar.gz.bak-20261005-pre-iwlfw); the task's creates: guard
means an existing server needs the file removed to pick this up.
2026-10-05 12:00:24 -04:00
cproudlock
372c3802e5 pxe: Blancco initrd pulls in module deps and NIC firmware
The kexec-initrd build copied a fixed set of module directories plus
drivers/net/mii.ko. Ubuntu ships that as mii.ko.zst, so the -e test
skipped it, and usbnet, r8152 and cdc_ether then failed to load with
"Unknown symbol mii_*": USB-C to RJ45 adapters never appeared (seen
2026-10-05, Dell USB-C adapter). igb lacked dca and i2c-algo-bit the
same way.

Walk the host modules.dep from the copied modules and copy every
dependency (73 on 6.8.0-71). Also copy rtl_nic, tigon, bnx2, bnx2x and
e100 firmware, which those drivers request at probe.
2026-10-05 11:56:16 -04:00
cproudlock
25f65424c7 blancco-init: load xhci_pci so USB-C NICs work, report why a link fails
The initrd has no udev, so only modules init names get loaded, and
xhci_pci is a module in the Ubuntu kernel. USB 3 / USB-C ports were
therefore dead in the UEFI Blancco path and a Dell USB-C to RJ45 adapter
never appeared; init took the onboard e1000e port with no cable.

- modprobe xhci_pci and xhci_pci_renesas before the NIC drivers
- bring up interfaces inside the carrier wait too, since USB NICs
  enumerate seconds after onboard ports
- on no-link, print each interface's driver and the NIC/firmware/link
  kernel messages before dropping to a shell

Live kexec-initrd.img also now carries the NIC firmware set from the
Blancco 7.20 early cpio (rtl_nic, tigon, bnx2x, ...) under /lib/firmware.
The playbook build does not add it yet.
2026-10-05 11:48:17 -04:00
cproudlock
29e0d97f59 blancco-init: use the cabled NIC, and stop hanging on a dead link
The UEFI init took the first interface in /sys/class/net and put
172.16.9.250 on it. On a PC with a second port, dock or USB NIC that can
be the unplugged one; wget then sat at "Connecting to 172.16.9.1:80"
forever (seen 2026-10-05 on a Dell, serial GGX6BN4). Bring every wired
interface up and use the first with carrier; with no link anywhere,
list the interfaces and drop to a shell. wget calls get -T 30 so a
stalled transfer fails loudly instead of hanging.

Live kexec-initrd.img was rebuilt with only init swapped (backup
kexec-initrd.img.bak-20261005-pre-carrier).
2026-10-05 11:38:57 -04:00
cproudlock
b474ac376e pxe: let BIOS Blancco boots load iwlwifi for cloud licensing
7.20 licenses through the Blancco cloud, and on the air-gapped PXE LAN
the only route out is WiFi INTERNETACCESS. The legacy BIOS cmdline still
blacklisted iwlwifi/iwlmvm, so BIOS boots could never license. btusb
stays blacklisted; rd.udev.timeout=10 bounds the udev hang iwlwifi
caused on some Intel machines. Live GetPxeScript.aspx changed to match
(backup .bak-20261005-pre-bios-wifi).
2026-10-05 10:14:57 -04:00
cproudlock
96c4e5dd11 webapp: export Blancco reports for the last 1/3/6/12 months
/reports/export?range=1m|3m|6m|12m&format=csv|zip. CSV has one row per
erased drive (system and drive serials, standard, result, times, licence
consumption id). ZIP holds the original XML/PDF reports plus summary.csv.
Reports are selected by the date inside each XML, falling back to mtime
only when it is missing. Exports are audit-logged.
2026-10-05 09:32:32 -04:00
cproudlock
678d10d5c2 blancco: upgrade to Drive Eraser 7.20.0
playbook/blancco-preferences.xml is now the vendor 7.20 preferences
(schema 4.20, "Non_GEAE_Wired" profile) with only our site overrides:
SMB report share on 172.16.9.1 with auto_backup, WiFi INTERNETACCESS for
cloud licensing (wired stays up for SMB), manual as the fallback process,
bootable report, screensaver 86400. workflow default=true is kept: the
cloud workflow erases unattended, as live 7.15 already did. No custom
fields, matching both the vendor image and live 7.15.

prepare-boot-tools.sh:
- 7.20 initramfs is an uncompressed early cpio (NIC firmware, itself
  .xz files) followed by the XZ archive. The old patch decompressed from
  byte 0, failed, warned and carried on, so PXE boots would drop network
  after copytoram. Walk the cpio to its trailer and patch the XZ part.
- Patch exactly one copytoram match or fail. The looser fallback pattern
  matches five hooks in 7.20.
- config.img now carries playbook/blancco-preferences.xml, so the BIOS
  (native kernel) and UEFI (HTTP) paths read the same settings. Refuses
  to build if the playbook preferences are for another Blancco version.
- The old global replace() also set the 802.1x, WiFi and repeater
  passwords to "blancco". Gone with the above.
- Keep preferences.xml size by refilling the NUL gap before the trailing
  filler comment (7.20), or trimming the comment (7.15).
- OUT_DIR can be overridden to stage a build outside boot-tools/.
2026-10-05 09:32:32 -04:00
cproudlock
737a729548 fix(shopfloor): clear imaging AutoLogonCount at ShopFloor handover
Run-ShopfloorSetup and Stage-Dispatcher top AutoLogonCount up to 10 so
SupportUser survives vendor-forced reboots. Sysinternals Autologon, which
lockdown uses to switch the bay to ShopFloor, leaves the counter in place,
so ShopFloor inherited the remainder. Windows counts it down on every
autologon and at 0 sets AutoAdminLogon=0: the bay boots to a ShopFloor
password prompt a few reboots after imaging.

Delete the counter once Winlogon shows ShopFloor:
- Monitor-IntuneProgress.ps1, on the poll that sees the handover
- SetShopfloorAutoLogon.bat, after sfld_autologon.ps1
- Force-Lockdown.bat, on success

The SupportUser top-ups stay. Tested in the win11 VM: the Monitor block
removes the counter for ShopFloor, keeps it for SupportUser, and is a
no-op when clean; both batch files leave ShopFloor autologon on with no
counter.
2026-10-02 10:06:42 -04:00
cproudlock
47f5d4603a fix(e10): --e10 passed bare silently skipped the controller
Three bugs, all found by actually running the thing rather than reading it.

1. `--e10` with no value did nothing. Making the flag optional-valued gave it
   an empty-string constant, and the code deciding whether to drive the
   controller used a truth test - so the documented `--e10` form printed the
   full translation and quietly skipped the hardware. No error, because
   nothing failed. Now gated on `is not None`, with a comment saying why.

2. A missing part program dumped a traceback. The top-level handler tidied
   e10 errors and re-raised everything else. Now a clean message that lists
   the part programs it can actually see, which also covers Windows path
   confusion.

3. Errors could vanish when piped. stdout is block-buffered and stderr is
   not, so the message printed before the output it referred to and a `| tail`
   dropped it entirely - looking like a silent failure. stdout is flushed
   before writing an error.
2026-08-27 13:55:00 -04:00
cproudlock
5e249e8ad0 feat(e10): run on Windows as well as Linux
Serial access moves behind open_port(), with a termios backend on POSIX and a
Win32 backend reached through ctypes. Deliberately no pyserial: these tools
have to run on locked-down, air-gapped shopfloor machines where installing a
package is a project of its own.

Verified on a real controller from both a Linux laptop and a Windows 11 PC.

Three things that made this harder than it should have been, all now handled:

  - CreateFileW returns a HANDLE, which is 64-bit. Without an explicit restype
    ctypes truncates it to c_int. The open then appears to succeed and every
    read and write silently operates on a bogus handle, which looks exactly
    like a dead cable. Every kernel32 call now declares argtypes and restype.

  - Defaulting to a fixed COM number was actively harmful. A wrong-but-real
    port opens without error and answers nothing, so defaulting to COM3 while
    the adapter was on COM4 produced the same silent symptom. Windows now
    auto-picks only when exactly one port looks like a USB virtual COM port,
    and otherwise refuses and lists what it found.

  - COM10 and above need the \\.\ prefix, which is harmless on COM1-9, so it
    is always applied.

Adds two diagnostics, because "no response" has too many possible causes to
guess at:
  ports    every serial port that actually exists. On Windows this reads
           SERIALCOMM from the registry, so a port absent here does not exist
           now, whatever Device Manager showed last week.
  doctor   walks the layers - discovery, open, write, read - and reports which
           one fails, including a warning when the configured port is an
           onboard one while a USB adapter is present.

Port selection is -d, then $E10_PORT, then the safe auto-pick. Friendlier
errors on both platforms for a missing port or a permissions problem.
2026-08-27 13:40:15 -04:00
cproudlock
eb4d067f12 feat(e10): SIC e10 part marker client, pattern generator and eDNC simulator
Groundwork for replacing a Telesis pinstamp with a SIC Marking e10 while
keeping the existing eDNC/MarkTelesis operator workflow. MarkTelesis has a
hardcoded marker-type enum and eDNC is serial-only, so the plan is a
translator that impersonates a Telesis controller on a COM port. These are
the pieces that translator will be built from, all verified against a real
e10 (a P63, firmware "E10 P63 (20140924)").

scripts/e10-client.py
  e10-V6 protocol client. Marking path and file transfer both use the BINARY
  protocol: the text protocol uses space as its delimiter, so it cannot carry
  a field cleared to a single space nor the space runs in real marked text,
  and it has no checksum. Includes fault decoding against the manual's fig 1,
  and a guard that refuses to overflow a variable's declared buffer.

scripts/make-e10-pattern.py
  Builds marking files with DECLARED variables. The controller's own protocol
  cannot create one: NEWFILE/INSERTTEXTLINE/SAVEFILE writes the reference but
  leaves the variable count at zero, and SETVAR then fails. Template-based,
  copying through byte-for-byte everything the format notes do not explain.

scripts/simulate-ednc.py
  Parses a part program, substitutes tokens and emits the byte-exact frames
  MarkTelesis would send, then translates them for the e10 and can drive one.
  The wire format came from disassembling MarkTelesis.exe rather than from a
  capture: frame builder at 0x401cf0, and the type letters at their call
  sites. Note there is no station ID, unlike the Backup470 dialect.

scripts/fake-telesis.py
  Answers as a Telesis controller so MarkTelesis will hold a conversation
  long enough to be recorded, with switches to force NAK and timeout paths.

reference/e10/
  52-file snapshot off the bench controller, used as the corpus for reversing
  the MARK_V5.0 format and for testing without hardware. ConfigCtrl is
  excluded and gitignored - it stores the supervisor password in cleartext.
  GE part programs are excluded for the same reason.

Three hardware findings worth knowing:
  - The controller TRUNCATES SILENTLY to a variable's declared buffer size
    while SETVAR still answers ok, and GETVAR echoes the full value back so
    it cannot detect this. Guarded client-side against the size in the file.
  - It refuses to mark when a referenced variable is unset, which makes an
    unset field fail loudly rather than marking the previous part's data.
  - Setting variables does not modify the stored file, so backups will not
    churn on every marked part.
2026-08-27 12:57:20 -04:00
cproudlock
f1755751bb Part marker: a misconfigured bay is now loud, and the listing loop is bounded
Every failure of Get-TelesisPortConfig was filed as "no Telesis marker
configured on this PC" and exited 0. That is correct for the many PCs which are
not marker bays, and wrong for a bay whose registry key EXISTS and could not be
read: a half-configured marker was silently never backed up again, which is
exactly the failure shape this feature was built to prevent. The absent-key case
now throws a distinguishable exception and stays quiet; anything else is logged
every cycle and counted as a skip.

Get-TelesisFileList was unbounded. The receiver drives that loop, so a
controller repeating a name or answering noise on a marginal cable would spin
for ever - and not harmlessly, because it holds the MARKING port while
GE-Enforce waits on the script with no timeout, so one chatty controller wedges
the enforcement cycle and the operator cannot mark a part. Now capped by count,
by elapsed time, and by repeat detection, all of which THROW: a partial listing
presented as a complete snapshot would be worse than failing.

Baud and Data Bits parse with TryParse. Note the narrower reason than it looks:
[int]'' is 0 in PowerShell, so empty and non-numeric values were already handled
by the zero-check. What was not handled is a numeric string too large for Int32,
which throws on the cast and would have been filed as "not a marker bay".

Verified in the win11 VM as SYSTEM, against both the old and new module: absent
key stays quiet, a key without Port Id is loud, garbage and out-of-range baud
both fall back to 9600, a real 19200 is still honoured, a repeating controller
stops at once and endless unique names stop at the cap.
2026-08-26 15:06:47 -04:00
cproudlock
8ba86ba560 Write through the mapped drive, record the UNC
The snapshot path stored against a marker read "W:\backup\partmarker\...". That
mapping exists only inside the SYSTEM logon session, only while the GE-Enforce
cycle is running, and only on that one bay - so the path recorded in ShopDB
resolved to nothing for the person most likely to need it.

Writing still goes through the drive letter, deliberately: that is the
connection GE-Enforce authenticated, and reaching the same server by another
name costs a second authentication or fails outright with error 1219. Only the
recorded path changes.

Resolving a mapped drive to its share needs all three of DisplayRoot, Root and
the WMI ProviderName, because which one is populated depends on how the drive
was made: net use mappings (what GE-Enforce creates) fill DisplayRoot, while a
New-PSDrive to a UNC leaves DisplayRoot EMPTY and puts the share in Root. A
local drive and an already-UNC path both fall through untouched.
2026-08-25 14:58:41 -04:00
cproudlock
f99ece1366 Make an unknown argument an error, not a shrug
The script had no CmdletBinding, so PowerShell dropped unrecognised named
arguments into $args instead of failing. A typo in a manifest entry Args -
"-WindowStartMinutes 45" - would have been ignored silently, and the capture
would have run at the wrong time with nothing in the log to explain it. That
is exactly how an hour went into debugging a time gate that was working.

Also adds WindowStartMinute so the window can open at :45 rather than on the
hour. A shift change is a moment, not an hour, and the first run of the day is
easiest to watch when it lands when somebody expects it.
2026-08-25 14:37:00 -04:00
cproudlock
ba4df185cd Put the marker entries where the share actually looks
Both entry files described the wrong scope. Checking the live share mirror:

Backup-PartMarker.ps1 belongs in common\scripts, not the partmarker scope,
because it imports ..\lib\TelesisMarker.psm1 and ..\lib\ShopdbBackupClient.psm1
and those live in common\lib. Backup-NtlarsSettings.ps1 sits there for the same
reason, and its entry already lists gea-shopfloor-partmarker in PCTypes -
scoping is by PCTypes, not by which directory the entry lives in. As written it
would have failed its imports on the first cycle.

eMxInfo healing is a NEW entry in the partmarker scope rather than a PCTypes
edit: those entries live in the collections and nocollections scopes, which a
marker bay never evaluates. The site already duplicates the config and the cmd
per scope, so markers follow that pattern - at the cost of the yearly rotation
now touching three copies, which the entry says out loud.

The cmd template also carried a stale source path. The deployed cmd reads
..\configs\eMxInfo.txt; the repo template still said the file sat beside it,
which would have exited 1 on every run.
2026-08-25 14:01:26 -04:00
cproudlock
bd131f6da9 Diagnose a share that prompts, when the same UNC path works
Shopfloor PCs intermittently lose S: and ask for credentials while navigating
the identical UNC path in Explorer works fine. Four different causes produce
that same appearance - a persistent reconnect that raced the network, a stored
credential whose target string does not cover the name form in use, two
identities contending for one server, or a password that rotated - and each
needs a different fix. From the desk they are indistinguishable.

Test-ShareAccess.ps1 collects what separates them: reachability of both name
forms, the mappings and the account each remembers, the exact credential target
strings, Kerberos tickets, live SMB sessions and the identities holding them,
and the SMBClient/Connectivity log. Then it names the causes the evidence
supports rather than leaving a wall of output to interpret.

Read-only by construction: it maps nothing, deletes nothing, stores no
credential, and prints usernames and a password-present flag but never a
password, because its log lands where techs read it. Safe on a bay while
somebody is working.

It reports which identity it is running as, because mappings and SMB sessions
are per logon session: run as the user to explain S:, as SYSTEM to explain W:
and the backup scripts. A drive missing in one is genuinely absent in the
other, and mistaking that for a fault is easy.

Also derives the marker backup's share root from the script's own location
rather than a hardcoded UNC. GE-Enforce mounts the share as W: with SFLD
credentials, and reaching the same server by a different name than the mount
used costs a second authentication - or fails outright with 1219 - for reasons
the error text never mentions. The script's own path is the one handle
guaranteed to name the connection that already exists.
2026-08-25 13:44:05 -04:00
cproudlock
18168b54f8 Say why a share write failed, because the symptom lies
This runs as SYSTEM, which authenticates to the network as the machine account.
A SYSTEM process reaching a UNC path without credentials gets an access-denied
that Test-Path reports as "not found", so a permissions problem reads as a
missing directory and sends the next person hunting the wrong thing. The
backups registry already carries that warning; the client had not acted on it.

On a write failure the log now names the likely cause, and the two conditions
that make it work: the entry must run under the GE-Enforce cycle, since the
share is mounted only for its duration and all SYSTEM processes share the logon
session that holds the connection, and the share ACL has to admit computer
accounts.

Verified against an unreachable UNC on a bay with a real controller: capture
succeeded, the write failed, the diagnostic was logged and the cycle was
counted as a missed backup.
2026-08-25 13:32:23 -04:00
cproudlock
b228a69364 Identify a marker by its device, without moving what NTLARS reports
A part marker has no machine number of its own - the device is what a backup
belongs to, and C:\Enrollment\asset-id.txt is what names it. Reading that file
inside the shared machine-number lookup looked like the tidy place for it and
was wrong: marker bays run eDNC too, so Backup-NtlarsSettings.ps1 reaches the
same lookup, and it would have started posting a machine's DNC settings under
the marker's asset number where today it exits quietly. The marker script asks
for the fallback explicitly instead, which is what -MachineNumberFallback is
for.

Also from review:

Returning a byte array bare lets the pipeline enumerate it, so an EMPTY file
came back as $null, ComputeHash threw, and with ErrorActionPreference=Continue
the manifest entry silently kept the previous file's hash - a corrupted
manifest stored as a good revision. Empty parameter files are plausible.

Reading registry values directly under StrictMode throws when one is absent, so
the "no Port Id" guard was unreachable and a partially configured marker bay
reported itself as having no marker at all, permanently and quietly. Values are
now read through a presence check with the fleet's own defaults.

The outside-window quiet state embedded the hour, so the say-once module said
it hourly. Command replies now have their checksum verified like data blocks
do, since a corrupted listing entry would otherwise be trusted and a file
requested under a name the controller does not have. A controller that
genuinely holds nothing is no longer reported as a failed capture. A snapshot
that could not be written to the share now counts as a missed backup like the
rest, and the header no longer claims orphaned snapshots get tidied - they are
kept on purpose, because a timed-out post may still have been recorded.
2026-08-25 13:21:45 -04:00
cproudlock
abbdc9873c Heal eMxInfo.txt on marker bays too
The eMxInfo entry on the share is scoped to collections and nocollections, so
a marker bay never heals. Marker bays run eDNC as well - 01-eDNC.ps1 installs
it before the Mark MSI - so the same vendor installer clobbers the same file
there, with nothing to restore it.

The fix is a scoping change rather than a new mechanism: add the marker PCType
to the existing entry. The paths it already writes are the right ones, because
mxTransactionDll.dll in the Mark overlay reads \Dnc\Server Files\eMxInfo.txt -
the Mark suite uses the DNC install, not its own directory.

The cmd variant additionally refreshes the copy that imaging drops into the
Mark directory. Nothing reads that path, so it is cosmetic; it is there so a
healed bay and a freshly imaged one are identical, rather than differing in a
way that costs somebody an afternoon later.
2026-08-25 13:10:29 -04:00
cproudlock
280a3b3ba4 Add the manifest entry that puts the marker backup on the fleet
Scoped to gea-shopfloor-partmarker so it reaches marker bays only. The script
checks the DNC\Mark registry itself as well, so a mis-scoped entry exits
quietly on an ordinary PC rather than erroring every five minutes.

The entry records what the share layout has to provide, because the script
resolves its two modules relative to its own location and one of them,
ShopdbBackupClient.psm1, is shared with the NTLARS backup and has changed. It
also records the schedule and the settings a bay needs, since all three are
places where a deployment can look successful and quietly do nothing.

Also counts a failed capture as a missed backup, not just a busy port. Port
contention resolves itself; a controller that will not answer does not, and
the two are worth telling apart in the count that reaches ShopDB.
2026-08-25 13:09:17 -04:00
cproudlock
e40a294193 Back up what a part marker holds, without the vendor tool
Telesis pinstamp controllers have never been backed up. The vendor's own
utilities, Backup470 and Backup420, are GUIs with no command line, so nothing
could capture a marker on a schedule - and a controller swap meant retyping
patterns from memory.

TelesisMarker.psm1 speaks the protocol directly, reversed from a capture of
Backup470 against a TMC470 and verified against hardware: a pattern pulled by
this code is byte-identical to the one the vendor tool saves, and a pattern
written back survives a power cycle. A TMC420 was captured the same way and
differs in exactly one respect - it exposes no parameter files - so one
implementation covers both families, with content classes chosen from a Type I
status probe rather than a per-model dialect.

Backup-PartMarker.ps1 captures everything the controller holds into a
timestamped snapshot on the share and records it in ShopDB against the marker
asset. Snapshots rather than a mirrored directory, because a mirror forces a
choice between deleting the only copy of a retired pattern and claiming a
pattern that no longer exists; a snapshot has neither problem, and the manifest
hash means an unchanged controller writes nothing.

The marker's serial port is shared with eDNC and is exclusive-open, so captures
happen only at shift changes - 14:00 normally, when someone is on site to fix a
problem, with 06:00 and 22:00 engaging only once the backup is overdue. The
capture yields the port within a second if the marker application starts
mid-run, and cycles missed to a busy port are counted and carried into the next
snapshot, since posting a no-op to keep the health badge green would hide
exactly the failure the badge exists to report.

TYPE 'R' deletes a file on the controller, immediately and without
confirmation. It is documented in the module header and deliberately not
implemented.
2026-08-25 13:02:06 -04:00
cproudlock
12c6c771dc Stage the two universal print drivers at imaging time
A bay comes off the line unable to build a printer queue. Set-ShopdbPrinters.ps1
creates queues against drivers that are already present and skips any that are
not, so a freshly imaged PC silently gets no printers until some later
enforcement cycle tops it up.

The two universals are 100 MB and resolve the overwhelming majority of the
fleet's printers. The four device-specific drivers are another 126 MB and are
deliberately NOT here - they belong only on bays that own that hardware, and
those bays pick them up from the runtime manifest.

NO WAVE GATE on these entries, unlike the runtime ones. Waves exist because ~300
bays share one runtime share and GE-Enforce spreads them across only five
minutes; imaging runs a handful of bays at a time on the isolated PXE LAN that
already moves multi-GB WIMs, so there is no stampede to prevent.

DETECTION IS THE SPOOLER'S OWN KEY, not pnputil, and the difference is not
cosmetic. Installing a printer driver is two steps: pnputil /add-driver stages
the package into the driver store, Add-PrinterDriver registers it with the
spooler. Get-PrinterDriver - which is what Set-ShopdbPrinters consults - reads
the spooler. Measured on a Win11 bay: after Remove-PrinterDriver the spooler
reports the driver gone while pnputil still lists the package, so a pnputil-
detected entry would report compliant forever while every queue kept skipping. A
broken bay that looks healthy is the worst outcome available here.

The payload is not in git, matching every other installer under preinstall/:
stage printer-drivers/ onto the PXE share alongside the rest.
2026-08-19 17:16:38 -04:00
cproudlock
d479e6823c Clear comments orphaned by today's unattend removals
The EAP-PEAP and Office comments outlived the entries they labelled, which
would send the next reader looking for commands that are not there. The
surviving wireless comment said 'Wi-Fi profiles' plural when only
INTERNETACCESS remains; it now records why the other two went and that
wlansvc has to be started first.
2026-08-19 16:07:37 -04:00
cproudlock
ad075c255c Actually deploy INTERNETACCESS, and skip the OOBE network page
INTERNETACCESS has never been deployed. wlansvc ships Manual/Stopped on
Windows 11, nothing in the unattend started it, and `netsh wlan add profile`
refuses to run without it:

    The Wireless AutoConfig Service (wlansvc) is not running.
    exit=1

Reproduced in the win11 VM. So specialize was adding no wireless profile at
all, and every bay reached OOBE with no known network - which is exactly the
wifi/network selection the tech has been clicking through.

Fixed by starting the service first. `net start`, NOT `sc start`: sc is
asynchronous and returns START_PENDING, and the VM showed netsh losing that
race and still reporting the service as not running. The command is idempotent
and always exits 0, so a machine with no wireless hardware is unaffected.

With the profile genuinely landing, HideWirelessSetupInOOBE is now true.

Dropped EAP-PEAP and BLUESSO from specialize while here. Both are installed by
the MCL ppkg - Install-GEWirelessSettings.ps1, with byte-identical EAP-PEAP.msi
(sha d90f2489045b6fdb) and BLUESSO.xml (sha d8d886db37a40054) - and BLUESSO
could never have bootstrapped anything: hidden SSID, connectionMode=manual,
eapGtc with passwordFromUser. INTERNETACCESS is the bootstrap path and it is
WPA2PSK with connectionMode=auto, so it needs no EAP module and no certificate.

The eight GE certificates at Orders 4-11 are also duplicated by the ppkg (as
Certificates_*.provxml runtime entries) and are left in place for now: that is
a separate cut, and the case for removing them rests on the pre-ppkg token
fetch being publicly signed, which is true today (mcl.dwcdn.geaerospace.com is
issued by Google Trust Services) but is not ours to depend on.

lint-unattend.py passes clean on all three live files and all three repo copies.
2026-08-19 16:04:56 -04:00
cproudlock
6e8b8a83a7 Deploy the site config bays actually read, and close the drift gate
The shortcut fix from e844ff3 has been live in git and absent from the floor
since 2026-08-06. site-config.json exists TWICE on the share, from one repo
source: enrollment/shopfloor-setup/ and enrollment/config/. Only the second one
is staged to a bay - startnet copies Y:\config\site-config.json to
W:\Enrollment\site-config.json - and it was the stale one.

So every bay imaged in the last two weeks came up without the Plant Apps startup
item and without the Defect_Tracker taskbar pin, while the drift report showed
site-config.json in sync, because it was reading the copy nothing consumes. A
green check on the wrong file is worse than no check.

Deployed the repo copy over it (backup on the server at
~/backups/site-config.json.bak-20260819) and marked BOTH destinations git-owned,
so neither can go stale behind the other.

The shopfloor unattend is reconciled the other way round. Live was 87 lines ahead
of the repo - the default-user startup-delay removal, the Windows Update
disables, the removable-media block that stops PPKG auto-detection at OOBE, and
the run-enrollment.ps1 path fix from C:\ to C:\Enrollment. The repo copy was a
201-line fossil. LIVE WINS: these files boot machines, and pushing the repo copy
over them is exactly the 2026-08-06 outage that prompted this tool. Adopted live
into the repo verbatim (lint clean) rather than merging by hand.

Also fixed a pair that could never pass: the engineer unattend was compared
against playbook/FlatUnattendW10.xml, which is the STANDARD answer file, so it
reported DIFFERS permanently. 6f86c81 added FlatUnattendW10-engineer.xml but did
not repoint the pair at it. A permanently red row is one nobody reads, which is
how the site-config gap stayed invisible next to it.

All twelve pairs are now git-owned and in sync, and the gate has nothing left
classified as "known bad" to hide behind.
2026-08-19 15:41:46 -04:00
cproudlock
6f86c81a44 Stop installing Zscaler twice, and reconcile the unattends with the share
Zscaler: Install-Zscaler.ps1 ships in all three MCL packages with
Zscaler-windows-4.7.0.317-installer-x64.msi bundled. It is tenant-aware
(geaerospace.com for GCCH, geaerospaceglobal.com for RoW, gegovdev.us for QA)
and passes enableFips=1.

Our FirstLogonCommands entry installed 4.5.0.337, two minor versions older,
without enableFips, and with userDomain hardcoded to geaerospace.com - wrong
for a RoW bay, which startnet's tenant sub-menu can produce. Two msiexec runs
against the same product at one logon also risk 1618. Removed, same reasoning
as Office.

THE BIGGER FIND, while removing it: this repo template was badly out of date
with the share, and the playbook deploys it with force: yes.

Missing here but present live: specialize Orders 39-48 - the bulk-enrollment
orchestrator registration (Order 39, Register-BulkEnrollTask-Standard.ps1), the
Default User startup-delay and Start-menu tweaks, the two NoAutoUpdate keys,
the 'vanilla' Shift+F10 fallback alias, and the removable-media block that stops
the PPKG being auto-detected at OOBE. Also missing: FirstLogonCommands Orders
4-10 (wuauserv disable, Default User hive edits, removable-media unblock).

A playbook run would therefore have stripped enrollment from every gea-standard
and gea-engineer bay. Both templates are now reconciled against the live share.

Engineer gets its own template. The two files differ only by FirstLogonCommands
Order 11, which installs the Engineering Layer - so deploying one shared file to
both, as the playbook did, silently removed that entry from gea-engineer on
every run. Split into FlatUnattendW10.xml and FlatUnattendW10-engineer.xml with
an explicit per-type loop.

lint-unattend.py passes clean on all three live files and all three repo copies.
2026-08-19 15:23:44 -04:00
cproudlock
2415e6cfd6 Drop the first-logon shutdown abort that never aborted anything
FirstLogonCommands Order 1 was `shutdown -a`, and it protected nothing.

The reboot it was aimed at is the one provtool schedules when the provisioning
package is applied - and Start-BulkEnrollOrchestrator.ps1 already aborts that
itself, on the line straight after provtool returns, with its own `shutdown /a`
and the comment "Abort the reboot that the provisioning package scheduled".
Our copy runs at logon, before the orchestrator has applied the package, so at
the moment it executes there is nothing pending to abort.

The reboot that actually interrupts first-logon work is the orchestrator's own
`shutdown /r /t 0 /f` at the end of its run. That one is immediate, so it has
no pending-shutdown window for `shutdown /a` to cancel, and it is deliberate -
the Entra ID join needs it.

So the entry was a no-op that read like protection. It cost real time this week:
its presence was taken as evidence that first-logon work was being guarded when
it was not.

Removing it changes no behaviour. What it was reaching for - first-logon work
surviving a mid-logon reboot - needs resumability instead, which is what the
retired Office payload now does and what zscaler.bat still lacks.

Order 2 (zscaler) is left numbered 2; FirstLogonCommands execute in ascending
Order and gaps are legal, so renumbering would be churn for no gain.

Live gea-standard and gea-engineer updated to match. lint-unattend.py passes
clean on all three live files and both repo copies. gea-shopfloor never had
this entry.
2026-08-19 15:16:25 -04:00
cproudlock
f3df6c7175 Stop installing Office twice on standard and engineer
Install-Office.ps1 ships inside all three MCL provisioning packages
(GCCH_Prod_MCL-PERS v2.15, -SH v2.10, -LOAN v1.3 and the RoW equivalents). It
moves M365 Office x86.zip out of C:\Deploy\Applications\BPRT, extracts it to
C:\WCDApps\Office and runs setup.exe /configure against the vendor's own
Configuration-x86.xml.

The unattend's FirstLogonCommands Order 3 was a second ODT run at that same
first logon. Click-to-Run is single-instance, so the two conflicted - which is
what "Office is not installing properly" looked like on both image types.

They also disagreed on what to install. The vendor package uses
O365ProPlusRetail, excludes Access/Groove/Lync/OneDrive/Teams, matches the OS
language, carries RemoveMSI and AppSettings, sets SharedComputerLicensing=1 for
the SH purpose and drops Outlook for LOAN. Ours used O365ProPlusNoTeamsRetail,
en-us, no RemoveMSI, no purpose awareness, and hardcoded
SharedComputerLicensing=0 - which would have broken shared computer activation
on every SH machine.

Removing the entry here matters as much as removing it live: the playbook
deploys this file to both image types with force: yes, so a playbook run would
have put the duplicate straight back.

The share payload is kept but retired, with a README recording why and what to
check before ever re-enabling it. Its install.bat and gea.xml were repaired
first (stage out of C:\Deploy ahead of the orchestrator's DeleteDeployDir task,
pin the ODT build to match v32.cab so no CDN lookup is attempted on the
isolated LAN, and register a self-deleting resume task since FirstLogonCommands
run only once) - VM-tested, then left in place unreferenced.

Live gea-standard and gea-engineer unattends updated to match; lint-unattend.py
passes clean on all three live files and both repo copies.
2026-08-19 15:13:32 -04:00
cproudlock
26d14b0684 Key the MTConnect runbook on machine numbers, not hostnames
The first draft identified every bay by PC name, taken from a 12 June map and
the machine list's Shop Floor PC Name column. Both are hand-built snapshots: a
PC that has since been replaced or re-imaged answers to a different name while
the machine it sits at has not moved. Following those names would eventually
mean renaming the wrong bay.

Machine number leads every table now, hostnames are marked as "where the fault
was last seen", and the reader is pointed at what the bay itself knows - the
eDNC registry first, then the enrollment file, then UDC - with ShopDB as the
live PC-to-machine record since the collector maintains it.

Also notes that either CSV should be generated from ShopDB rather than
hand-maintained, for the same reason.
2026-08-11 14:46:52 -04:00
cproudlock
cc20501e2e Runbook for the MTConnect follow-up work
The rename fix only changes what future renumbers do. Nine bays are already in
the state it prevents, the 3.25.3 adapter is staged but not on the SFLD share,
and two questions - whether DMG Mori needs a bundle at all, and whether the v2
manifest ever received the MTConnect entries - are unanswered.

All of it needs a machine the dev box cannot reach, so it is written as steps
to follow rather than something to run here: exact hashes to check before
copying, per-bay numbers from the hostname map, verification after each change,
and rollback for the share copy.

Notes the two things not proven on real hardware: that stopping the Makino
Adapter Manager keeps it from rewriting config on shutdown, and that the four
Fanuc bays with no agent device name are a different fault that a rename would
not fix.
2026-08-11 14:44:08 -04:00
cproudlock
0d9c4fdac4 Rename every copy of an MTConnect device name, not just the agent's
Set-MachineNumber rewrote one Devices.xml per variant and restarted the agent.
That is right for Okuma and eDNC, which keep the device name in that one file -
checked across 7 Okuma and 15 eDNC bay captures. Fanuc and Makino keep it in
several, and editing only the agent's copy leaves the adapter streaming under
the old identity.

It has already happened. Seven of thirty-four captured Fanuc bays have the
agent on the right machine number and the adapter still on the bay it was
imaged from: 4007 on a 7801 Toshulin, 3031 on a 7804, 2005 against 2006 on a
dual-spindle pair. Both captured Makino bays have Devices.xml on the machine
number and the other three files still on MAKINO-1.

Update-MTConnectVariantName renames across a declared set of files with the
owning services stopped first. Makino needs that ordering more than most: the
vendor's guide says the Adapter Manager rewrites the COMPLETE configuration
from memory as it stops, so an edit made while it runs is discarded, and that
the name must be identical in every file or the agent may not start.

It collects every name in play before rewriting rather than discovering one.
A bay half-renamed by the old code carries two at once, and converging on the
one that happened to be found leaves the other behind - which is the state this
is meant to end, not reproduce. Nothing is written if a service will not stop,
and whatever was stopped is started again.

Tested on Windows 11 against copies of the real captures: the 7801 bay (adapter
4007) converges to 7801 in two files and leaves the correct agent alone; the
7502 Makino bay converges all four; second runs report no changes. Service
ordering verified by process id, using stand-in services - sc.exe fakes named
after the real ones are not real services, so the service list is a parameter
defaulting to the production names.

Does not fix the bays already in this state; they need a run each.
2026-08-11 14:28:03 -04:00
cproudlock
54cbe6b5d6 Bring the share's common scripts under version control
Sixteen files that run on every shopfloor PC existed only on the SFLD share.
The cost showed up while debugging the NTLARS backup: the script that posts to
ShopDB could not be read, reviewed or diffed, so its behaviour was inferred
from log output for most of a day. It turned out to hold a silent fallback that
had been governing the whole fleet for months.

Imported as-is from tsgwp00525-v2, no edits:

  lib/ShopdbBackupClient.psm1        the shared backup client
  scripts/Backup-NtlarsSettings.ps1  converted to use it
  scripts/Set-ShopdbCollectorKey.ps1 collector credential delivery
  scripts/Test-RegExport.ps1         exercises the .reg codec with mocks
  scripts/Set-EventSaver*.ps1        kiosk power / screensaver / disable
  scripts/Setup-OpenText.*           OpenText install + toolbar
  scripts/Migrate-PCType.ps1, Select-KioskType.ps1, Set-FmsHostsEntry.ps1,
  scripts/ensure-vnc-firewall.ps1, Install-AcroReader.cmd, Install-Oracle11r2.cmd

lib/Install-FromManifest.ps1 is also updated from the share, which was 37 lines
AHEAD of this repo and purely additive: the Add-EnforceResult reporting added
during the kiosk API cutover, done live and never committed back. Nothing was
removed.

Checked for embedded secrets before committing; there are none.
Set-ShopdbCollectorKey deliberately reads its token from a sibling file on the
share rather than holding it, so the script is safe to track.

The share remains what actually runs. This makes it reviewable, and makes the
next drift visible as a diff rather than a surprise.
2026-08-11 12:36:38 -04:00
cproudlock
b075939c38 GE-Enforce: roll undated logs so retention can actually reach them
The prune drops a *.log by LastWriteTime. A log written to a fixed filename is
appended every cycle, so its LastWriteTime is always now, it can never be older
than any cutoff, and it grows without limit. ntlars-backup.log, eventsaver.log
and shopdb-collector-key.log all do this. On one part-marker PC eventsaver.log
had reached 11,000 lines and ntlars-backup.log 3,234, of which 3,217 were the
same "Throttled" line - the four events that mattered were unfindable without
grep -v.

Undated logs are now rolled to <name>-YYYYMMDD.log before the prune runs, which
stops them being written to and lets them age out on the existing 30 day
retention. The owning scripts need no change: they append with Add-Content or
Tee-Object, which recreate a missing file on the next write. This matters
because those three scripts are live-share artifacts, not in this repo.

Rolled under the date of its FIRST LINE, so the stamp matches the contents.
That is also the only trustworthy signal: CreationTime is not, because NTFS
file tunneling gives a recreated file the old creation time when it reappears
within 15 seconds, so a busy log keyed on that would look stale the instant it
rolled and would roll again every cycle.

A log with no parseable timestamp is left alone until it passes 5MB, so an
unrecognised format still cannot grow forever. Empty files and already-stamped
files are skipped. Rolling onto an existing target appends rather than
overwriting, so a second roll on one day loses neither side.

Verified against real files under pwsh, including the append branch and the
owner recreating the file afterwards. The first attempt keyed on CreationTime
and a second used [datetime]::TryParse with an untyped $null, which throws "no
overload" and would have made the whole roll a silent no-op; the ref is now
pre-typed.
2026-08-11 09:03:28 -04:00
cproudlock
6d5fee786c Proposal: fold in what a day of fixing the current pipeline taught
New section 9a - five design rules, each from a bug fixed on 2026-08-06 and each
cheap to honour in a new design but expensive to retrofit:

  Absent and empty are different. Four scripts tested config as
  "not null AND Count > 0", so an explicitly empty list fell through to a
  hardcoded default - a kiosk configured with "desktopApps": [] was given the
  full machine-tool set. Configuring "none" produced "everything".

  Deny by default. preinstall.json had 16 of 21 entries at PCTypes ['*'] and the
  SMB manifest 18 of 25 unfiltered. Both filters worked perfectly; they just were
  not applied.

  Gate at the point of action. Call-site gates on PC type were bypassed by the
  finalization phase, silently.

  The image carries configuration. WJ Shopfloor.lnk is inside the WIM, so no
  pipeline change removes it.

  The ppkg is a hard boundary. Chrome, RealVNC, Tanium, CyberArk and the rest come
  from the package - thinning the image does not thin them.

Also corrects a premise in section 4.2: a first-boot selection UI cannot assume
the API is reachable, because ShopDB needs the AESFMA wifi SSID and the imaging
LAN has no route to it. Points at Install-ShopdbKiosk-WhenOnline.ps1 as the
reusable shape - arm a task at imaging, do nothing until the API answers, act
once, verify, self-remove.

Plus two risks, the diagnostics collector in the reference map, and a gotchas
section: HardwareDriver.json defeats ConvertFrom-Json on every PowerShell
version, Compress-Archive caps at 2 GB against an 8 GB package, and PowerShell
Core normalises backslashes on Linux so bay scripts can be tested here.
2026-08-06 17:36:17 -04:00
cproudlock
0e1b80b903 preflight: stop ConvertFrom-Json choking on the driver catalogue
The driver check - the most valuable check in this script - has never actually
run. It died on:

  Driver check failed: Cannot convert the JSON string because a dictionary that
  was converted from the string contains the duplicated keys 'FileName' and
  'FileName'

HardwareDriver.json carries both casings of the same fields - fileName and
FileName, destinationDir and DestinationDir. ConvertFrom-Json treats object keys
case-insensitively and refuses the document. -AsHashtable would handle it but
that is PowerShell 6+, and WinPE runs 5.1. Confirmed it throws on PowerShell 7
too, so no version of ConvertFrom-Json can read this file as-is.

Pulls the four needed fields out of each entry by regex instead, preferring the
lowercase key and falling back to the capitalised one, and unescaping the
backslashes in destinationDir.

Tested against the real 44-entry catalogue, all three outcomes:
  OptiPlex Micro 7020, pack present  -> OK, win11_optiplexd13mlk7020_a09.zip
  same model, pack removed           -> FAIL, names the missing path
  Surface Laptop 7                   -> FAIL, no pack matches

Worth noting the check was failing SAFE - a WARN that reads like a tooling
glitch rather than a missing driver pack. It would have stayed invisible until a
bay imaged with no NIC.
2026-08-06 17:24:04 -04:00
cproudlock
8907a86c42 Collector: decode StartupApproved 0x04 and 0x05
The post-fix capture from 579C144 reported three entries as unknown(0x04) -
SecurityHealth, RtkAudUService, WavesSvc. The decoder only knew 02/06 enabled and
03/07 disabled. 04 is also enabled and 05 also disabled; without them the report
says 'unknown' for entries that are perfectly ordinary.
2026-08-06 17:16:57 -04:00
cproudlock
a7cb7164d9 Kiosks stop installing shopfloor apps, and empty config stops meaning everything
PREINSTALL SCOPE

Sixteen entries carried PCTypes ['*'], so a Display kiosk installed Adobe
Acrobat, OpenText HostExplorer, WJF Defect Tracker, the shopfloor serial drivers
and twelve legacy VC++ redistributables. Those are now scoped to the nine
machine-tool types.

A Display now installs 3 of 21 entries:
  PowerShell 7.5.4                    SFLD-ApplyDSCConfig runs pwsh.exe 7
  VC++ Redistributable 2015-2022 x64  modern x64 runtime Edge/WebView2/PS7 lean on
  Display Kiosk App                   already Display-scoped

Kept as wildcards deliberately - removing the x64 runtime risks breaking the
kiosk itself, and PS7 is a hard dependency of the DSC task. Everything else a
kiosk has no use for. Edited as targeted text replacement, 16 line pairs, so the
file's formatting and its long _comment blocks are untouched.

THE LAST OF THE EMPTY-MEANS-DEFAULT BUGS

Configure-PC.ps1 and 07-TaskbarLayout.ps1 had the same test as the two fixed
earlier: $null -ne $cfg -AND .Count -gt 0, so an explicitly empty list fell
through to a hardcoded shopfloor default. Configure-PC is the one that actually
bit - it runs in the finalization phase and put these in the all-users Startup
folder on 579C144, timestamped mid-image:

    Defect Tracker.lnk   16:46:03
    Plant Apps.lnk       16:46:03

Plant Apps launching msedge --new-window on a kiosk. eDNC was in the same
hardcoded list and only escaped because its Test-Path guard found no DncMain.exe.

08-EdgeDefaultBrowser.ps1 had it too, with a Plant Apps + WJ Shopfloor + Dashboard
tab fallback. Harmless today because the Display profiles configure a real tab,
but one edit away from biting.

Get-ProfileValue returns $null only when a key is absent from BOTH the profile
and site-config, so $null is the only honest "not configured" signal and an
empty array means what it says.

VERIFIED against the post-fix capture: the S: mapper Run key and the machine
number prompt task are both gone, and 'ShopDB Kiosk Bootstrap' registered
correctly with boot and time triggers.

Also: the collector's StartupApproved decoder now handles 0x04/0x05, which that
same capture surfaced as unknown.

Still baked into the WIM and unaffected by any of this: WJ Shopfloor.lnk, dated
April, sitting in the all-users Startup folder.
2026-08-06 16:58:53 -04:00
cproudlock
28efde7d76 Stop Display bays getting machine-tool shortcuts, prompts and the S: mapper
Three fixes from the 579C144 diagnostics, all the same shape: a Display kiosk
being treated as a machine-tool bay.

desktopApps: configuring "none" produced "everything"

  "Display-Dashboard": { "desktopApps": [] }

06-OrganizeDesktop.ps1 tested $null -ne $cfgApps -AND .Count -gt 0, so an
explicitly empty list fell through to the hardcoded fallback and the kiosk was
given UDC, eDNC, NTLARS, WJ Shopfloor and Defect_Tracker shortcuts.
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 and an empty array
means what it says. startupItems carried the same test - harmless today because
its else branch has no fallback list, but commented so the two cannot drift.

Machine-number prompt on a machine with no machine number

Register-CheckMachineNumberTask.ps1 armed 'Prompt Machine Number' (AtLogOn,
BUILTIN\Users) whenever the number was the 9999 placeholder - which is always
true on a Display, because startnet only collects a real number for machine-tool
types. A logon dialog on a kiosk with no keyboard. Now skips PC types that have
no machine number by design, and clears any stale task.

S: mapper on a share-less PC

Displays are Entra-joined with local accounts and no SFLD credentials, so mapping
S: can only fail, once per logon, forever. Run-ShopfloorSetup already gated the
CALL on $noEnforceTypes, but the bay registered it anyway at 15:07:55 with no
"Skipping" line in the log - so something in the finalization phase reaches the
registrar past that gate. Rather than chase the caller, the registrar now gates
itself and removes a stale Run entry. The call-site gate stays; this makes the
outcome correct regardless of who invokes it.

That bypass is worth understanding separately - the same pattern would defeat any
call-site gate in the finalization phase.

All three parse clean and are deployed byte-identical to the share.
2026-08-06 16:23:18 -04:00
cproudlock
d5cf255443 Kiosk bootstrap: look for the installer where staging actually puts it
The bootstrap looked for C:\Enrollment\kiosk\Install-ShopdbKiosk.ps1, but
startnet copies the type-specific tree to
C:\Enrollment\shopfloor-setup\gea-shopfloor-display\ - so the payload lands
beside the bootstrap, not under C:\Enrollment.

As written it would have logged "Installer not staged" on every 15-minute cycle
and never installed anything, with no error anywhere. Exactly the silent-failure
shape this whole day has been about, and it would have looked like the API being
unreachable.

Resolves via a -KioskRoot parameter defaulting to kiosk\ next to the script, so
the bootstrap and its payload travel together regardless of where the tree is
staged. display-type.txt still comes from EnrollmentRoot, which is correct - that
one really is at C:\Enrollment.
2026-08-06 16:12:41 -04:00
cproudlock
7ed30f9b85 Display: install the ShopDB enforce client once AESFMA is reachable
A PXE-imaged display ends up with no GE-Enforce client at all. Confirmed on
579C144, 2026-08-06:

    is the client installed?            NOT FOUND
    scheduled tasks that would run it?  NONE

Not a broken configuration - nothing had ever tried. Install-ShopdbKiosk.ps1
downloads itself from {BaseUrl}/installers/kiosk over HTTPS, and ShopDB is only
reachable after the bay joins the AESFMA wifi SSID, so it cannot run during
imaging. Nothing was arranged to run it afterwards.

09-Setup-Display.ps1 now registers 'ShopDB Kiosk Bootstrap' as a SYSTEM task at
boot and every 15 minutes. Install-ShopdbKiosk-WhenOnline.ps1 does nothing until
ShopDB answers, then runs the vendor installer once, verifies BaseUrl and the
enforce task exist, deletes the staged key and unregisters itself. A bay cabled
up days later still self-configures.

It deliberately does not reimplement the installer - it waits, invokes, verifies
and cleans up, so the vendor script can be replaced wholesale without touching
this.

The key file is LABELLED (collector=, fetch=) rather than positional. The two
tokens are not interchangeable and a mix-up is silent: a fetch token in the
collector slot leaves asset reporting broken while everything looks configured.
A bare unlabelled line is ignored rather than guessed at. Missing keys are not
fatal - the fetch token is unnecessary on an IP-allowlisted subnet, and the
installer skips the asset-report task rather than failing.

Staged on the share: the bootstrap, the vendor installer under kiosk\, and the
updated display setup. The key itself is NOT staged yet - see the commit
discussion; it needs to be labelled with which scope it carries first.
2026-08-06 15:58:15 -04:00
cproudlock
c4aeaaaa17 Collector: exclude the 8 GB package, fix base-URL detection, note the AESFMA dependency
Three defects found by the first real run, on 579C144.

16 GB collection, zip failed. run-enrollment harvests
C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
provisioning package existed TWICE under the trees being copied. Compress-Archive
cannot exceed 2 GB and died with "stream was too long". Copies now exclude
*.ppkg, *.wim and *.iso and cap individual files at 100 MB.

Base-URL detection matched the wrong value. The name pattern accepted anything
containing "base", so it picked up baseVersion=2.0.2 from
HKLM:\SOFTWARE\GE\SFLD\Credentials and then probed "2.0.2/api/docs" - making
both UNREACHABLE lines meaningless. Now matches the name exactly and requires the
value to start with http.

Unreachable was reported as a bare failure. ShopDB is only reachable once the bay
has joined the AESFMA wifi SSID, so on the imaging LAN or plain wired an
unreachable result is EXPECTED. It now says so, rather than looking like a fault.

The run still answered the question it was written for: no enforce client is
installed and nothing is scheduled to call the API, so this bay was never going
to reach GE-Enforce regardless of network.
2026-08-06 15:38:42 -04:00
cproudlock
54176e591f Collector: record whether each startup item is enabled or disabled
Disabling a startup item through Task Manager or Settings does not remove the Run
key or the Startup shortcut - Windows writes a flag into StartupApproved instead.
So the collector would have listed those entries as present with no indication
they had been switched off, and a bay where someone had already tidied up would
look identical to one where nothing autostarts.

Reads all five StartupApproved keys, including Run32 for the WOW64 view, and
decodes the first byte: 02/06 enabled, 03/07 disabled.

This matters for the current investigation because turning an item off is a
per-machine workaround - the next imaged bay installs and starts it again. The
fix is not installing it, which needs the app-to-autostart mapping this section
preserves.
2026-08-06 15:28:47 -04:00
cproudlock
d185e2b810 Collect everything needed to debug an imaged bay, in one pass
Every imaging investigation so far has meant walking to a bay and hand-copying
files, and each time discovering another thing we wished we had grabbed at the
same moment. This takes the lot. Read-only; changes nothing.

Collects: computer name (active AND pending, which is how you tell whether the
package's H<serial> -> F<serial> rename has landed or is still queued for the
next reboot), dsregcmd enrollment state, the package self-checks, installed
applications, everything that starts by itself, kiosk shortcut targets, Edge
policy, GE-Enforce config, drivers, disk, network, provisioning sessions, and the
whole of C:\Logs, Panther and the provisioning data plus the diagnostics evtx.

Two things it does deliberately:

Autostart is captured across all four surfaces - Run/RunOnce in BOTH registry
views, all Startup folders, non-Microsoft scheduled tasks, and auto-start
services outside C:\Windows. That combination is what identifies which installer
planted a given autostart entry. A 32-bit installer's Run key lands under
Wow6432Node where 64-bit tooling never looks, which is exactly how the old
Dashboard/Lobby autostart survived an earlier purge.

It TESTS the ShopDB GE-Enforce API rather than just reporting config. Config on
disk proves nothing - a client can be present, configured, and never once
succeed. It reports whether a client exists at all, whether anything is scheduled
to run it, the configured base URL, and then actually probes the endpoint. A 401
is a good result: it proves DNS, routing and TLS work and the service answered.
Only a timeout or DNS failure means unreachable. No token is sent.

Deliberately avoids Win32_Product - querying it triggers an MSI reconfigure of
every installed product, which is slow and can change the machine.

Run it BEFORE lockdown. What it captures is known-CURRENT, not known-good: a bay
straight off the line carries applications that should not be there, because
preinstall.json entries without a PCTypes filter install everywhere. The point is
to have an exact record of what imaging really produces so the unnecessary items
can be identified and filtered. After lockdown you cannot tell whether something
is absent because lockdown removed it or because imaging never installed it.

Staged on the enrollment share alongside the other shopfloor-setup scripts.
2026-08-06 15:24:25 -04:00
cproudlock
68f2ce29e4 Proposal: full reference map, and correct the LTSC autostart assumption
REFERENCES (section 13, was six lines, now a map)
Where every referenced thing lives, so the document is usable by someone without
today's context: this repo, the PXE server share, shopdb-flask, the on-bay log
locations, and the evidence bundles each claim came from. Plus re-derivation
recipes - pulling startnet out of boot.wim, extracting customizations.xml from an
8 GB .ppkg without unpacking it, reading the provisioning evtx, and the two
validation commands - and the 2026-08-06 commit list.

LTSC AUTOSTART - CORRECTED
Section 7.1 previously argued PESetup might already auto-start: AutoStartCountDown
is 31, disableauto.json is absent, and the operator on 579C144 clicked Next at 11
seconds, so nobody had waited long enough to find out.

Per the team's operational experience that is wrong for our media. LTSC builds
cannot autostart - the SELECT OS confirmation is mandatory regardless of the
countdown, and every image we produce is Windows 11 LTSC 24H2.

So the countdown is not a route to zero-touch. That promotes re-implementing the
imaging step from a contingency to the actual decision, and the risk table and
open questions now frame it as "re-implement, or accept one click per build" -
zero-touch being the only thing that requires it. The hands-off test is kept, but
only to confirm and record the behaviour first-hand.
2026-08-06 15:05:22 -04:00
cproudlock
f442ff2f65 Propose zero-touch, ShopDB-driven imaging
Design proposal, nothing built. Two ideas in one architecture: the image becomes
thin (partition, apply, join Entra, install the enforce client - everything else
is data pulled from the API and enforced by GE-Enforce), and imaging becomes a
job rather than a menu (open a machine in ShopDB, click Create this PC, pick the
zeroed box waiting in WinPE, and it images start to finish reporting progress
onto that machine's record).

Every claim about current behaviour is sourced from 2026-08-06 evidence -
decompiled binaries, live logs off bay 579C144, and the live share - and where
something is inferred it says so.

Covers: why the current early-decision chain fails silently (five worked examples
from one day); what already exists to build on; per-machine configuration records
replacing 147 .reg files and two CSVs, referencing payloads rather than embedding
them; the dual-NIC design with the firewall work that gates it; whether PESetup
needs replacing at all; the BPRT token shelf-life constraint on pre-imaged stock;
an ownership table; six delivery phases each useful alone; risks; and seven open
questions.

Two findings in here are worth acting on before any of it:

  disableauto.json is ABSENT from the media and AutoStartCountDown is 31, while
  the operator on 579C144 clicked Next at 11 seconds. PESetup may already
  auto-start and nobody has waited long enough to find out. One bay, hands off
  the keyboard, count to 40.

  dnsmasq is already safely bound (listen-address + bind-dynamic) so DHCP will
  not leak onto a GE-side NIC - but Samba binds to all interfaces and every ufw
  rule is ALLOW IN Anywhere, including SSH on a pxe/pxe account. That work gates
  dual-homing.
2026-08-06 15:02:08 -04:00
cproudlock
a0aceb4680 startnet: define the server address and share credentials once
The PXE server address appeared 14 times and the share credentials 13, so moving
the server or rotating the account meant editing every net use in the file and
rebuilding boot.wim - with no way to tell from a diff whether one had been
missed. Now set once at the top as PXESERVER / SHAREUSER / SHAREPASS.

Exactly one literal address remains, the definition on line 6. Prose in REM
comments is left alone so the paths stay readable.

This also reaches the post-boot self-heal for free. startnet already writes
C:\Enrollment\fetch-source.txt, and both Fetch-StagingPayload.ps1 and
Verify-And-Heal-Staging.ps1 read it (line1=UNC, line2=user, line3=pass) with
their own hardcoded values only as a fallback. Confirmed on 579C144:

  [13:30:35] [INFO] fetch-source.txt: UNC=\172.16.9.1\enrollment user=pxe-upload
  [13:30:36] [INFO] Mounting \172.16.9.1\enrollment as Z: (attempt 1/5)...

so the seeded file wins and the heal follows PXESERVER automatically. My earlier
claim that the heal scripts were hardcoded was wrong - they were already
config-driven and startnet was already seeding them. The hardcoding was all in
startnet itself.

Verified: set on line 13, first use on line 20; parens balance; every goto
resolves; 1005 CRLF lines with no bare LF; the deployed copy contains exactly one
literal address. boot.wim md5 7a9b832b.
2026-08-06 14:41:40 -04:00
cproudlock
bb08392b84 download-drivers: reuse an existing directory that differs only in case
Dell and GE manifests spell the same folder inconsistently - OptiPlex vs
Optiplex. Windows does not care, but the Samba share sits on a case-sensitive
filesystem, so a blind mkdir -p created a SECOND tree and the drivers split
between them.

That is exactly how the OptiPlex Micro 7020 pack went missing: the manifest asked
for OptiPlex/D13MLK while the 3.2 GB zip sat in Optiplex/D13MLK. PESetup found no
pack, logged a warning rather than an error, and the bay imaged with no network
drivers - so DNS failed at first boot and bulk enrollment could not reach the
CDN. Symptoms three steps from the cause.

mkdir_ci walks the path one component at a time and reuses whatever is already
there whatever its case, creating only genuinely new components. Callers must use
the RETURNED path, since it may differ in case from the requested one. Falls back
to plain mkdir -p rather than skipping a download if the resolve fails.

Both spellings still exist in the live catalogues; scripts/lint-driver-catalogue.py
reports a case mismatch as an error so the pair cannot silently drift again.
2026-08-06 14:38:54 -04:00
cproudlock
9d51c0b987 Ask tenant + PC purpose at the PXE menu; add the missing 3D kiosk display option
PC PURPOSE / TENANT FOR STANDARD AND ENGINEER

The capability was already there - the media carries all six MCL packages
(PERS/SH/LOAN x GCCH/RoW) and Start-BulkEnrollOrchestrator.ps1 picks one from
whatever Select-PCConfig.ps1 returns. The problem was WHEN it asks: a GUI at
first logon, so an otherwise unattended build stops at a dialog until someone
walks over.

Now startnet asks after Standard/Engineer is chosen, while the tech who knows
what the machine is for is standing at it, and writes {Tenant}_{Purpose} to
C:\Enrollment\pc-config.txt. A shim at the path the orchestrator already calls
returns that value and falls through to the vendor dialog when it is absent or
malformed - so "ask at first logon" stays available and bays imaged before this
are unaffected. The vendor script is preserved as Select-PCConfig-vendor.ps1;
replace THAT when the vendor ships a new one.

The tenant is not cosmetic. Insert-BPRTToPPKG.ps1 matches the bulk token on
Tenant AND Purpose, so this selects which token gets injected.

ONLY THE CHOICE IS PRE-SEEDED, deliberately. Injection stays at first logon
because it fetches an encrypted token table from mcl.dwcdn.geaerospace.com,
unreachable from the isolated imaging LAN - which is also why routing MCL
packages through run-enrollment.ps1 would not work: provtool would get a package
whose token is still the Exp_XXXXXXXX placeholder and join nothing.

Standard/engineer previously skipped staging entirely (no ppkg, no PCTYPE), so
the staging gate now also admits PCCONFIG.

3D PARTS KIOSK

The display submenu offered Dashboard and Lobby only. The GE-Enforce display
scope has always had a third entry - 3DPrintRoom -> /shopdb/parts-kiosk - so a
parts kiosk could only be set by editing display-type.txt by hand after imaging.
Added as option 3. The value is a KEY into that map, so the spelling matches
exactly.

Verified: startnet parens balance, every goto resolves, 994 CRLF lines with no
bare LF; shim parses clean. Deployed - boot.wim 4d16c946, shim staged with the
vendor dialog preserved.
2026-08-06 14:37:20 -04:00
cproudlock
68df59e117 Record who owns what, and report repo-vs-share drift
OWNERSHIP.md
Every expensive bug in this pipeline has had one shape: two systems setting the
same thing, last writer winning silently. Four happened on 2026-08-06 alone -
computer name (package vs run-enrollment), drive letters (PESetup vs a volume
finder), enrollment (package vs an at-logon -ManualFallback task that syspreped
finished machines), kiosk URLs (GE-Enforce vs site-config). Each is written down
with the evidence so the next person deletes a writer instead of adding one.

share-drift.py
The share is production and the repo is meant to describe it, but drift runs both
ways: live hand-edits nobody committed, and repo fixes never deployed. The
unattend outage lived only on the share while the repo copy was fine, and nothing
compared them.

Each mapped pair is classified. git-owned means the repo wins and the pair must
match - those fail the run. unreconciled means the two have genuinely diverged
and nobody has decided; reported, not failed. The unattends are unreconciled on
purpose: live is ~17 KB against ~12 KB in the repo, so a blind push would regress
production. Reads over SSH via base64 so BOM and CRLF survive the hop.

First run: 8 git-owned pairs all match, 4 known-unreconciled.

Run-ShopfloorSetup.ps1
Corrects a comment that was actively misleading. It claimed shopfloor PCs are
"vanilla by design" and that the orchestrator runs -ManualFallback to skip BPRT
injection and the package entirely. Shopfloor bays DO enrol - the SFLD package
joins Entra with its BPRT token and a human assigns the device category in
Intune. -ManualFallback runs sysprep /oobe /reboot, which is why wiring it to an
at-logon task destroyed the deployment chain.

The absent Entra wait is still correct, for a different reason: at that point the
bay is on the isolated PXE LAN with no route to Entra (579C144 held 172.16.9.81
and 172.24.19.142, neither in the production ranges). sync_intune retries until
the tech re-cables. "Entra ID Joined: false" right after imaging is normal.
2026-08-06 14:28:16 -04:00
cproudlock
d2200e8522 Preflight: check what PESetup fails on, before it fails on it
TWO SCRIPTS, DIFFERENT AUDIENCES

playbook/scripts/preflight.ps1 runs at the bay, called by startnet once the
media is mapped. It checks the four things that come straight out of PESetup's
own behaviour:

  secure boot   GatherDataSelection fails outright when SecurebootEnabled != 1
  disk >= 120GB MinRequiredSpaceWithoutCompression is 128849018880
  driver match  reimplements GetDriverByModel - family filter, untrimmed
                comma-separated substring tokens, first match wins - and checks
                the pack is actually on the media
  media age     media expires 30 days after build; Media.tag's timestamp is the
                local proxy

The driver check is the one that earns it. A miss is only a WARNING to PESetup,
so the bay images with no NIC, DNS fails at first boot, and enrollment cannot
reach the CDN - a symptom three steps removed from the cause. Advisory by
design: it reports and pauses on a blocker, the tech decides. Lives on the
enrollment share so it can be fixed without rebuilding boot.wim.

scripts/preflight.py runs on the server before a build day and aggregates
everything already built - driver catalogue lint, unattend lint, per-PCTYPE
media view verify - plus a new advisory firmware-coverage check that lists
catalogued models with no BIOS models.txt entry. That last one is how the
OptiPlex 7020 family sat uncovered: 127 catalogued models, 58 covered today.

First run: driver catalogues clean, all three unattends clean, firmware coverage
advisory only. PREFLIGHT PASSED.

Verified: both scripts parse clean (PowerShell parser / python), startnet parens
balance, every goto resolves, 915 CRLF lines with no bare LF. Deployed -
boot.wim md5 99fd3132, preflight.ps1 on the share.
2026-08-06 14:22:01 -04:00
cproudlock
8c21282024 Verify staging in WinPE, harvest imaging logs, make enrollment run once
Three changes aimed at the same failure mode: a bay that images green and is
silently unusable, diagnosed only by walking over and copying files off by hand.

VERIFY STAGING (startnet, at :pctype_done)
Checks pc-type.txt, Run-ShopfloorSetup.ps1, shopfloor-setup/common and
shopfloor-setup/<PCTYPE> exist on the applied volume before anything depends on
them, retries the small trees once, and prints a loud banner if the retry does
not fix it. The enrollment package is checked but never blindly re-pulled - it is
8 GB, so a miss is reported instead.
Done here because a tech is still at the machine: a short copy found in WinPE
costs 30 seconds, the same copy found at first logon costs 20 minutes, and found
never costs a rebuild. Four Display bays sat green at stage 2 for weeks.

HARVEST LOGS (startnet, after PESetup exits)
Collects X:\*.log, the generated X:\Unattend.xml, PESetup's own log from the
target's Panther directory and winpe-staging.log into
\<server>\enrollment\imaging-logs\<serial>\, plus a build-context.txt naming
PCTYPE, PPKG, machine number and media. All of it was being discarded at reboot.
Runs after PESetup exits so the logs are final, which means re-mapping Y: since
cleanup already dropped it. Best-effort throughout - a bay must never fail to
reboot because a log copy failed.

W: WAIT CAP 20 -> 45 MINUTES
270 polls instead of 120. Fine on NVMe either way, but a WIM apply can exceed 20
minutes on slow media, and the failure mode is the os_not_found banner plus
nothing staged. The loop still exits the moment the SYSTEM hive appears.

RUN-ENROLLMENT RUNS ONCE
Marker at C:\Enrollment\.ppkg-applied, written on exit 0 and also on 0x800700B7
ERROR_ALREADY_EXISTS. Observed running twice on 579C144; the second pass
re-applied a pending rename over the package's own and otherwise did nothing.

Verified: startnet parens balance, every goto resolves, 899 CRLF lines with no
bare LF; run-enrollment parses clean under the PowerShell parser. Deployed -
boot.wim md5 159c2a4d, live run-enrollment dce9d50a.
2026-08-06 14:18:25 -04:00
cproudlock
36be60e9ae run-enrollment: stop renaming the computer, the PPKG owns it
The package declares <DNSComputerName>F%SERIAL%</DNSComputerName>, so bays are
meant to come up as F<serial>. This script hardcoded a rename to E<serial>,
which is a second pending rename racing the package's own.

Observed on 579C144, 2026-08-06:

  13:31:55  run-enrollment  Rename-Computer -> E579C144      (pending)
  13:32:17  ppkg            Reboot/DeviceName -> F%SERIAL%   (pending)
            Provisioning-Diagnostics: "Reboot successfully scheduled.
            Device name set successfully."
  13:36:45  run-enrollment runs AGAIN -> E579C144            (overwrites F)
  ~13:37    reboot; E579C144 wins

Last writer wins at reboot, so the hardcoded prefix silently beat the package.
The bay came up E579C144 with the ppkg reporting no errors at all - 535
Provisioning-Diagnostics records, every one of them Level 4.

Nothing in this repo ever produced an F name; grep for a prefix rule or a
namePrefix setting finds nothing. The convention only works if naming is left to
the package, so the rename is removed rather than corrected to F. That also
makes it site-agnostic: a different package can name bays differently without a
script edit.

The double execution is a separate defect and is left alone for now. With the
rename gone a second run is harmless: provtool returns 0x800700B7
ERROR_ALREADY_EXISTS and changes nothing.
2026-08-06 14:06:58 -04:00
cproudlock
b96ff7e970 Retire the shopfloor-dashboard route; point Lobby at its own page
The dashboard moved to /shopdb/shopfloor. Every reference here still pointed at
/shopdb/shopfloor-dashboard/, which no longer resolves.

Display-Lobby was also pointing at the DASHBOARD url, not the lobby one, so lobby
screens showed the shop floor dashboard. The routes now follow the authoritative
map in shopdb-flask plugins/geenforce/seed_display_scope.py:

    Dashboard    -> /shopdb/shopfloor
    Lobby        -> /shopdb/tv
    3DPrintRoom  -> /shopdb/parts-kiosk   (no imaging-time menu option yet)

Changed: urls.shopfloorDashboard, Display-Dashboard.edgeHomepage,
Display-Lobby.edgeHomepage, and the Shopfloor Dashboard fallback in
08-EdgeDefaultBrowser.ps1.

Note these values are now a backstop rather than the primary path. Displays are a
single image whose variant comes from C:\Enrollment\display-type.txt, and the
GE-Enforce display scope delivered over the shopdb API writes the kiosk Startup
shortcut itself - preferring the server-side role from Settings > Dashboard
Defaults and falling back to display-type.txt. That dispatcher also SWEEPS
shortcuts matching shopfloor-dashboard, so a stale value here would have been
deleted on the next enforce cycle rather than honoured.

Edited by line position, not string match: the two edgeHomepage values were
textually identical and belong to different display blocks. Formatting preserved
- a json.dumps round-trip reflowed 632 lines and was reverted.
2026-08-06 12:30:35 -04:00
cproudlock
d04865da0f docs: record the PESetup version actually in production; match LogonCount to the live unattend
PESETUP-INTERNALS.md was written from a decompiled 4.0.0.17. The media in
production reports 4.0.0.20 in its own log. Rather than restate the document as
4.0.0.20, which would claim a re-derivation that has not happened, it now names
both: line-level claims are 4.0.0.17, and the behaviour re-observed on bay
579C144 on 2026-08-06 is listed so a reader knows which parts are confirmed
current - media drive Z:, W: created by PrepareDisk and used for every copy
destination, the fallback Deploy\FlatUnattendW10.xml being the unattend that
loads, and driver selection by model.

FlatUnattendW10-shopfloor.xml carried LogonCount 7 while the live shopfloor
unattend has 12, and Run-ShopfloorSetup.ps1's comment about topping up the
autologon budget already said 12. The live value is the real one, so the repo
follows it.

Both files still lint clean under scripts/lint-unattend.py.
2026-08-06 12:25:30 -04:00
cproudlock
2b6189f739 startnet: stage against PESetup's own W:, and stop reporting failed copies as successes
Brings the repo back in line with what is actually running in boot.wim, validated
end to end on bay 579C144 today.

WHAT THIS FILE NOW DOES DIFFERENTLY

Applied-volume detection. The volume finder and the diskpart letter reassignment
are gone. PESetup hardcodes W: in nine places and creates it during its own disk
preparation, so there is nothing to search for, and searching actively broke
staging: a scan run before PESetup finished disking returned the PREVIOUS
install, startnet relabelled that partition W:, and PESetup's own DISKPART then
erased it mid-copy. Timestamps from that failure are in the comment so nobody
rebuilds the finder from the same premise. The wait is back on
W:\Windows\System32\config\system, which only exists once the WIM apply has
written it.

Copy reporting. Eight sites echoed "Copied ..." unconditionally, so a run where
every robocopy exited 16 and moved zero bytes still printed six success lines.
They now branch on errorlevel 8 and say FAILED, including in the staging log.
%ERRORLEVEL% is no longer printed inside parenthesised blocks - it expands at
parse time there and showed a stale value. The WaxTrace test moved above its
mkdir, which sets its own errorlevel.

Per-PCTYPE media. Maps Z: to _media\<PCTYPE> and probes
Z:\Deploy\Control\HardwareDriver.json before trusting it, falling back to the
shared media with a warning.

findstr -> find. findstr.exe is not in this WinPE image. It failed with errorlevel
9009, which "if errorlevel 1" read as true, so an SFLD package took the non-SFLD
branch and never got its BPRT-tagged name.

VERIFIED ON A REAL BAY, from C:\Enrollment\winpe-staging.log:

    PPKG=GCCH_Prod_SFLD_NoOffice_US_Exp_20260831_v4.16.ppkg
    Copied PPKG GCCH_Prod_SFLD_v4.16.ppkg as GCCH_Prod_SFLD_NoOffice_US_Exp_...
    Shopfloor 44 files / common 17 / _ntlars 147 / display 1 / preinstall 85
    FAILED: 0 across every copy
2026-08-06 12:25:18 -04:00
cproudlock
385acfefa7 Lint unattend answer files before they reach a bay
Well-formed XML is not enough. Windows Setup validates against a schema and one
bad value invalidates the WHOLE answer file for its pass, so the machine stops at
a dialog with nothing configured and the only clue is an XPath buried in
C:\Windows\Panther\setupact.log.

On 2026-08-06 every shopfloor, standard and engineer build was failing exactly
that way:

    /settings/RunSynchronous/RunSynchronousCommand/[Order="16"]/Path
    Description = Value is invalid.   hrResult = 0x80220005   pass = specialize

An inlined "powershell.exe -Command ..." had grown to 676 characters in a field
capped at 259, and had been broken since the previous evening. Every check here
is mechanical and would have caught it before a bay was booted:

  path-too-long         RunSynchronousCommand/Path     > 259
  cmdline-too-long      SynchronousCommand/CommandLine > 1024
  description-too-long  Description                    > 256
  duplicate-element     a once-only element appearing twice
  not-well-formed       XML does not parse
  bom                   UTF-8 BOM (the live files have none, and python's
                        utf-8-sig silently ADDS one when writing them back)
  unknown-token         %token% PESetup will not substitute; %WINDIR% and the
                        other shell variables are excluded or it cries wolf

Reads over SSH via base64 so a BOM or CRLF survives the hop unchanged. Exits
non-zero on any ERROR so it can gate a deploy.

Immediately found a second live defect the manual review had missed: a 329-char
Description on gea-engineer Order 11, which would have failed the oobeSystem pass
once specialize started passing. All three live image types and both repo copies
now lint clean.
2026-08-06 12:24:59 -04:00
cproudlock
d5f7abc19f BIOS: cover the whole OptiPlex 7020 family, not just Tower Plus
An OptiPlex Micro 7020 reported "no update in catalog" at the WinPE menu. The
firmware was staged all along - OptiPlex_7020_1.22.1_SEMB.exe, 102 MB, sitting on
the share - but models.txt listed only "Tower Plus 7020". check-bios.cmd matches
with find /I, a substring test against the WMI model name, and "Tower Plus 7020"
is not a substring of "OptiPlex Micro 7020", so every Micro, SFF and Tower 7020
silently skipped its firmware update.

The 7010 family three lines above already shows the intended shape: one SEMB
package behind Micro, SFF Plus and Tower Plus entries. Dell packages 7020 the
same way, covering Micro, Micro Plus, SFF, SFF Plus, Tower and Tower Plus, so all
six now point at the one package and the stray Tower Plus line moves up to join
its family.

Verified by simulating the matcher over the manifest: all six variants resolve to
the 7020 package, no duplicate tokens, and 7010 still resolves to its own. Note
that "Small Form Factor 7020" spelled out does NOT match - the short form is
correct, since HardwareDriver.json's modelswminame field uses SFF 7020.

Deployed to the live share, where check-bios.cmd reads it directly off B:, so no
boot.wim rebuild is involved. Previous manifest kept at
/home/pxe/models-rollback-20260806.txt.
2026-08-06 11:10:13 -04:00
cproudlock
049dd7bde6 startnet: map the per-PCTYPE media view for shopfloor builds
Maps Z: to _media\<PCTYPE> instead of the shared gea-shopfloor media, so
PESetup's unfiltered CopyPackages stages only this type's payload rather than
every shopfloor payload on every bay.

The mapping is validated rather than assumed: it probes
Z:\Deploy\Control\HardwareDriver.json, which resolves only when the view exists
AND its symlinks are intact, so a dangling view cannot image silently off a
half-mapped drive. On any failure it falls back to the shared media with a
console warning - the bay still images, it just stages the union, which is
exactly today's behaviour. That fallback also covers the second WJ PXE box,
which has no _media views yet.

Z: is freed before the mapping. A leftover mapping makes net use fail with
"device already in use", and the probe would then pass against the OLD media -
a silent wrong-payload build, which is the failure this change exists to
prevent.

Views are built by scripts/build-pctype-media.py.
2026-08-06 11:03:12 -04:00
cproudlock
c7b17696d0 Build per-PCTYPE PESetup media views out of symlinks
CopyPackages copies the whole of <media>\Deploy\Applications to the target,
recursively, with no manifest and no filtering, so every bay built from the
shared gea-shopfloor media receives every shopfloor payload whatever its type.

The obvious fix - repoint a path inside the shared media as each bay picks its
type - races. Bays image concurrently and CopyPackages is fail-fast, so moving a
path while another bay is mid-copy either hands it the wrong payload or fails its
imaging outright, intermittently and unreproducibly.

Give each type its own media directory instead, built once and never mutated.
Everything in it is a symlink, so a type costs inodes rather than gigabytes and
the shared media stays untouched while bays read it. Samba already serves this
pattern: follow symlinks and wide links are on, and every image root reaches
Sources, Operating Systems, Out-of-box Drivers and Packages the same way.

Types come from the enrollment share's menu.json, the file the WinPE picker
already renders, so the media set cannot drift from the boot menu. Deliberately
NOT mirrored: the FlatUnattendW10.xml.pre-* backups and stray logs beside the
real unattend - the view is the clean set PESetup actually reads. Each type is
built into a scratch dir and swapped, so a bay mapping the path mid-refresh sees
the old tree or the new one, never a half-built one.

Applied on 172.16.9.1: 10 types, 190 links, all 60 critical paths resolve. An
SMB client sees ordinary directories and files - Sources, Control,
FlatUnattendW10.xml at 18078 bytes, HardwareDriver.json at 46113 - which is the
behaviour that actually matters, since PESetup reads this over SMB from WinPE.

Note for anyone extending this: passing the server-side script on stdin to
"echo pxe | sudo -S bash -s" is a silent no-op. Bash inherits the password pipe,
reads EOF, runs nothing and exits 0. It travels as a heredoc for that reason.
2026-08-06 10:56:18 -04:00
cproudlock
dfa026b4ee Lint the driver catalogue against PESetup's real matcher
A driver miss is only a warning: GetDriverByModel returns null, PESetup logs
"driver for [MODEL] not found" and images the machine anyway. The bay comes up
with no NIC and no WiFi, DNS fails at first boot, and bulk enrollment cannot
reach the CDN - symptoms far enough from the cause that the OptiPlex Micro 7020
pack sat missing and the Display MicroPC failures were blamed on a drive letter.

Reimplements the matcher from the decompiled source (docs/PESETUP-INTERNALS.md)
and reports what silently breaks it:

  virtual-platform  one such entry flips the tool into virtual-only mode and
                    hard-fails every physical machine
  empty-token       a trailing comma yields "", and Contains("") is true for
                    every model, so that entry swallows the catalogue
  token-whitespace  Split(',') does not trim, so " OptiPlex 3010" needs the
                    space present in the model string too
  case-mismatch     the share is case-sensitive; Optiplex vs OptiPlex splits the
                    tree and the pack is never found
  missing-zip       referenced pack absent
  family-mismatch   the family filter runs first, so a token whose line
                    contradicts the family field can never match
  shadowed          first match wins, so a later entry may be unreachable
  duplicate-token   osId is not part of the match, so a win10 pack can land on a
                    win11 build purely by ordering

--models resolves real WMI model strings through the same code, which is the
check that actually predicts a no-driver build. Exits non-zero on ERROR or
CRITICAL so it can gate a deploy.

Verified both ways: a synthetic catalogue carrying each defect reports all eight
and exits 1; the three live catalogues on 172.16.9.1 come back clean at 0. The
file listing needs find -L and the same anchoring as destinationDir - without
either, every zip check silently passes.
2026-08-06 10:46:28 -04:00
cproudlock
936902dc4d Document how PESetup.exe actually works
Written from the decompiled assembly rather than from observation. Three
long-standing beliefs about this tool are wrong, and each has cost real
debugging time:

W: is not a guess. PESetup hardcodes it in nine places - every copy
destination, the DISM offline sessions, bcdboot, reagentc - and creates it
during its own disk preparation. startnet.cmd's volume finder, diskpart
reassignment and W: wait loop are machinery built around a problem that does
not exist.

The copy steps do not filter. CopyPackages copies the whole of
Deploy\Applications recursively to W:\Deploy\Applications - no manifest, no
unattend parsing, no extension or size rules. Anything dropped there reaches the
target, which is the basis for staging our own payload without the enrollment
share. It is also fail-fast: one unreadable file fails the whole step.

A missing driver match is a WARNING, not an error. GetDriverByModel does a
substring test of comma-separated tokens with first-match-wins, the family
filter knows only Latitude, OptiPlex and Precision, and a single 'virtual
platform' entry in the catalogue fails every physical machine. A miss lets
imaging finish with no drivers, so no NIC, so DNS failures at first boot -
symptoms far from the cause, and easily misattributed to the drive letter.

Includes the bundle-extraction recipe so the next person can re-derive all of
this instead of trusting this document.
2026-08-06 08:04:48 -04:00
cproudlock
27e76467a8 manifest engine: resolve installer paths without the PS provider
Join-Path routes a drive-qualified path through the PowerShell provider, so
it returns null when that drive has gone away mid-cycle, and every consumer
then bound the null straight into Test-Path -LiteralPath and crashed the
entry with a message that names neither the entry nor the path.

Replace the six Join-Path $InstallerRoot sites with Join-InstallerPath, which
does plain string math, and have all six callers treat a null resolve as
"not found" and log it. Only the PS1 branch had a guard before, and it
covered a null Script value, not a null resolved path.

The helper deliberately avoids [IO.Path]::Combine: its separator and
rooted-path rules follow the host platform, so behaviour cannot be verified
off-Windows. It also rejects rooted values ("\x", "D:\x", "\\server\share"),
which Combine would have resolved outside the share root. Checked all 36
path values across the share manifests: none are rooted today.

Behaviour verified against the real manifest values (forward slashes,
backslashes, trailing-slash root, null, whitespace, rooted, UNC).
2026-08-03 17:23:00 -04:00
cproudlock
66c24b5d59 shopfloor: stop manifest entries hijacking GE-Enforce's drive letter
GE-Enforce mounts the SFLD share on W: and holds it for the whole enforce
cycle, passing W:\<pctype> as -InstallerRoot to Install-FromManifest. Two
scripts that run inside that cycle, as SYSTEM in the same drive namespace,
mapped W: for their own use and deleted it on exit:

  Restore-UDCData.ps1     manifest PS1 entry, mounts the UDC backup share
  Update-MachineNumber.ps1  "Apply Machine Number" task, same backup share

Once W: is gone, the next manifest entry's Join-Path resolves against a dead
drive qualifier. In Windows PowerShell 5.1 that emits nothing rather than
throwing, so the null lands in Test-Path -LiteralPath and the entry dies with
"Cannot bind argument to parameter 'LiteralPath' because it is null".

Observed on a collections bay: the controller-credential entry, which runs
immediately after UDC Data Restore, failed this way while the entry one line
earlier had resolved a W: path successfully one second before.

Restore-UDCData now uses R: and Update-MachineNumber uses N: for the UDC
backup share. Neither letter is referenced anywhere else in the tree; V: was
not an option because Restore-EDncReg and Update-MachineNumber's NTLARS
restore already use it.

The re-mount at GE-Enforce.ps1:239-244 does not cover this. It runs after the
manifest loop and blames idle SMB timeout, but the whole cycle took one
second, so timeout was never the cause.
2026-08-03 17:22:50 -04:00
cproudlock
1a175bc4fe FlatUnattend: revert broken prereqs block, keep OpenText shopfloor switch
The .NET3.5/VC++ prereqs block (11 RunSynchronousCommands with ~260-char doubled Paths + a long Description) made the answer file invalid for the specialize pass - 'Windows could not parse or process unattend answer file for pass [specialize]'. Rebuilt the template from the proven-working original with only the OpenText change (Order 19 -> Setup-OpenText.cmd, drop unattended.bat, short Description). Deployed to both live gea-standard + gea-engineer. The prereqs will be re-added correctly as a single short-Path wrapper cmd (install-prereqs.cmd) once imaging is confirmed.
2026-07-23 14:50:59 -04:00
cproudlock
01f524e8b3 std/eng OpenText: use shopfloor Setup-OpenText.ps1 (fans office+shopfloor profiles to all users)
Standard + Engineer imaged OpenText via the vendor Inno exe + unattended.bat, which left per-user profiles in SYSTEM's appdata (operators never saw keymaps/menus/macros) and only copied to Default+Shared. Switched their FlatUnattend to the shopfloor Setup-OpenText.cmd, which does the install via direct msiexec (OpenTextHostExplorer15x64.msi + ShopFloorx64.mst + SP1.msp) and fans the profile content (both office and shopfloor sets - Accessories/EB, Keymap, Menu, Profile) to ProgramData\Shared + Default user + every existing user. Kept both J2SE installs (Java). Dropped unattended.bat (its crude Default+Shared copy is superseded; NOTE its stale DNS SearchList reg add - old logon.ds.ge.com/rd.ds.ge.com domains - is no longer applied). Shopfloor opentext bundle staged into winpeapps/_shared/Applications/extra/opentext/.
2026-07-23 14:15:56 -04:00
cproudlock
3880151e71 webapp: shopfloor sub-menu tab + nav rename (the edits dropped by a failed git add)
The prior 'nest shopfloor menu as a tab' commit only captured the shopfloor_menu.html deletion - the git add also named the just-removed file, which errored the add and staged nothing else. This commits the actual integration that was already deployed to preview and prod: startnet_editor GET passes shopfloor_items/shopfloor_available, the Shopfloor Sub-Menu tab pane + sf-prefixed JS in startnet_editor.html, /shopfloor-menu redirects into the tab, and the sidebar 'startnet.cmd' -> 'Boot Menu' rename.
2026-07-23 13:58:08 -04:00
cproudlock
5f97e81dec webapp: nest shopfloor menu as a tab in the Boot Menu editor; rename nav to Boot Menu
The shopfloor sub-menu editor is now a 'Shopfloor Sub-Menu' tab inside the startnet editor (it IS a sub-menu of the boot menu), not a separate page - sf-prefixed IDs avoid clashing with the top-level Boot Menu tab. /shopfloor-menu GET + save now redirect back into that tab; standalone shopfloor_menu.html removed. Sidebar 'startnet.cmd' renamed to 'Boot Menu'.
2026-07-23 13:37:55 -04:00
cproudlock
626561a1fa shopfloor menu: data-driven from menu.json (picker + webapp editor)
Replace the hardcoded GEA Shopfloor PC-type sub-menu with a data-driven one:
- menu.json on the enrollment share lists the shopfloor items {key=PCTYPE, label, hint, enabled}; key must match a shopfloor-setup/gea-shopfloor-* handler dir.
- select-shopfloor-type.ps1 renders it in WinPE and writes the chosen PCTYPE (mirrors the CMM bay picker); startnet.cmd runs it and falls back to the baked-in menu if the share/picker is unavailable.
- Webapp /shopfloor-menu editor: reorder/rename/hide/add items; the PC-type is a dropdown of existing handler dirs (can't wire a choice to a non-existent type); writes menu.json. Nav link under Tools.
Kills the duplicated-knowledge problem (menu list was hardcoded in startnet AND the handler dirs AND site-config); add a PC-type = drop in the handler dir + it appears in the menu.
2026-07-23 13:27:08 -04:00
cproudlock
f1d9bdf478 startnet editor: harden _split_lines against doubled CRLF (\r\r\n)
A CRLF file re-CRLF'd (e.g. sed adding \r to already-CRLF lines) yields \r\r\n; the old _split_lines left a stray \r that split into a blank line between every line, so parse_boot_menu/settings/lint saw a garbled file and returned nothing. Now collapse any run of CR before a newline. Also re-deployed a clean-CRLF startnet.cmd into the live boot.wim (the earlier sed-based deploys had doubled the CR).
2026-07-23 11:42:51 -04:00
cproudlock
cc391529bd startnet editor v2: settings/menu forms, syntax-highlight+lint, backup/restore+diff
Rebuilt the /startnet editor (Fable/Opus 4-stage build) into tabs:
- Settings: server IP (+ menu timeout/default when a choice construct exists) as form fields; apply rewrites only the targeted tokens.
- Boot Menu: add/remove/reorder image entries; regenerates only the menu echo/dispatch + action blocks, refuses reorders that would desync the enrollment %choice% router.
- Raw: full-text editor (still source of truth) with line-number gutter, batch syntax highlighting, a lint panel (unmatched goto/label, CRLF), and diff-vs-current.
- History: timestamped snapshots on every save, per-row diff + restore.
New wim.py helpers (framework-free): parse/apply_settings, parse/apply_boot_menu, lint_startnet, save/list/read_snapshot, unified_diff; BACKUPS_DIR=/var/lib/pxe-webapp/startnet-backups.
Fable review fixed a CRITICAL pre-existing bug: update_startnet's newline=CRLF write retranslated posted CRLF into \r\r\n, corrupting boot.wim on every raw save; now normalizes to LF first. Also fixed a false CRLF lint warning (verbatim read) and menu payload validation. All JS inline (no CDN).
2026-07-23 11:37:24 -04:00
cproudlock
234295931e playbook: keep the driver store owned by pxe so download-drivers.py can push
download-drivers.py rsyncs driver packs into _shared/Out-of-box Drivers over ssh as the pxe user, but the store was created root:root so new model folders failed with Permission denied. Added a task to own the store pxe:pxe 0775 (root and the webapp still write it fine). Fixed live on 172.16.9.1 too (chown -R).
2026-07-23 11:13:11 -04:00
cproudlock
0ffdcc79ed enrollment: auto-activate uploaded ppkg via ppkg.conf (no boot.wim edit)
startnet.cmd now sources SOURCE_PPKG/PPKG_VER/PPKG_EXP from \\<pxe>\enrollment\ppkg.conf (baked-in values kept as fallback if the file is missing). The webapp writes ppkg.conf on upload for standard GCCH_..._v<ver>.ppkg files: version parsed from the filename, optional expiry field (blank keeps current), so a new ppkg goes live at next boot with no startnet/boot.wim edit. Enrollment page shows the active ppkg. ppkg.conf seeded on the share (v4.16) and the ppkg.conf-aware startnet.cmd deployed into boot.wim.
2026-07-23 11:02:43 -04:00
cproudlock
3b63697176 download-drivers: compare BIOS versions numerically, not as strings
parse_bios_catalog kept the 'latest' BIOS per model with a string compare, so e.g. '1.20.1' > '1.9.0' was False and it wrongly retained the older 1.9.0. Added _ver_tuple() and compare tuples of ints so the genuinely newest firmware wins.
2026-07-23 10:54:35 -04:00
cproudlock
0cb6b26c27 webapp import: checksum-aware sync (skip unchanged, update only changed)
Image import previously full-copied everything and rmtree'd existing target dirs on every run. Replaced the shutil copy/move/copytree/rmtree with deploy.sync_tree(), which shells out to rsync -a --checksum: files whose content already matches the target are skipped, only new or changed files are written, and existing target files not in the source are left untouched (merge, not mirror). move=True uses --remove-source-files (frees the SMB upload dir) and prunes emptied source dirs. Applies to the Deploy import, the _shared redirections, and the root-level items. Big re-imports now only rewrite what actually changed.
2026-07-23 10:45:58 -04:00
cproudlock
8fbae24b4d webapp: fix a11y contrast, heading hierarchy, prefix-safe modal actions
- Accessibility: dark ink (#00003d) on warning/success buttons, badges, and success alert - white text on #ff9500/#0ad64f failed WCAG AA (~2:1); dark-on-bright now passes.
- Heading hierarchy: sidebar brand h1 -> div.brand-title (with matching CSS selector), and standardized all page-header titles to a single <h1> per page (8 templates were h2).
- Prefix-safe modal JS: dashboard/backups/reports/enrollment delete+clone actions now build their form action from url_for(...'__K__'/'__F__').replace(...) instead of hardcoded paths, so they survive a URL-prefix mount.
2026-07-23 10:14:29 -04:00
cproudlock
18db077475 webapp: reskin to shopdb-flask design system
Adopt the shopdb-flask visual language across the PXE webapp (presentation only, Flask/Jinja logic unchanged):
- New static/pxe-theme.css: GE Aerospace palette (atmosphere-blue sidebar, sky-blue primary, avionics-green), Inter font stack, light/dark theming via data-theme + localStorage (key pxe-theme) with system-pref fallback, and card/button/table/form/badge/alert component styles layered over Bootstrap.
- base.html: shopdb-style sidebar (logo + title, nav sections, footer light/dark toggle) + theme boot script.
- All 13 content templates restyled to the new page-header + card/table/badge vocabulary; unattend_editor grouped per unattend-UX research.
- Fixed a pre-existing CRITICAL bug found during review: nested <form>s in image_config.html made Adopt submit the delete form and Delete-selected post every orphan filename regardless of checkboxes; split into standalone forms wired via the form= attribute.
Built by a Fable-orchestrated Opus workflow (17 agents). All 14 templates parse clean under Jinja2.
2026-07-23 10:05:09 -04:00
cproudlock
8df30017aa Playbook: reconcile live dnsmasq/samba/blancco hand-edits back into repo
The live PXE server carried working config the playbook would have clobbered on re-run. Committed back:
- dnsmasq: BIOS boot chain now the vendorclass PXEClient:Arch:00000 -> ipxe.pxe scheme (plus iPXE userclass HTTP chain), replacing the stale undionly.kpxe client-arch=0 scheme (undionly.kpxe was never staged in tftp-root). Bind by listen-address=172.16.9.1 + bind-dynamic instead of interface=+bind-interfaces.
- samba: add 'allow insecure wide links = yes' to the managed symlinks block (modern Samba silently disables wide links without it) and the [winpeapps_bios] read-only share.
- blancco iPXE menu: full BIOS/EFI arch split (direct-kernel vmlinuz-bde-linux / vmlinuz-ubuntu with ucode+kexec initrds) with a GRUB chain fallback, replacing the old single-line 'chain grubx64.efi'.
startnet.cmd was NOT a commit-back: repo is already ahead of the live boot.wim (deploy-pending).
2026-07-23 09:31:20 -04:00
cproudlock
b240cfea12 collections: add eDNC detection diagnostic scripts 2026-07-23 09:21:49 -04:00
cproudlock
e7be0f5391 gea-engineer: pre-stage .NET 3.5 + VC++ redists via FlatUnattend
Add RunSynchronous Orders 39-49: enable NetFx3 from staged sxs + install the VC++ 2005-2022 redistributables at specialize, before the first-logon engineering installer. Each command is if-exist guarded against the extra_engineering\prereqs payload, so gea-standard (which shares this template) skips them. Payloads staged on the share under gea-engineer Deploy\Applications\extra_engineering\prereqs.
2026-07-23 09:21:49 -04:00
cproudlock
0c4c2a245d Remove legacy ge-* image types (gea-only fleet)
Drop ge-standard/ge-engineer/ge-shopfloor-lockdown/ge-shopfloor-mce from image_types + standard_types, both startnet boot menus (choices + labels + net use), and the webapp IMAGE_TYPES/FRIENDLY_NAMES. Fleet is gea-* only; the empty ge-* winpeapps stub dirs were removed on the live share.
2026-07-23 09:21:49 -04:00
cproudlock
fdf63ab32f Shopfloor self-heal: bootstrap recovery scripts + Keyence/WaxTrace heal
Fixes Keyence (and CMM/WaxTrace) imaging where the target got only partial
data: the Y: SMB mount goes idle-dead during WIM apply, so WinPE staging dies
early (often just after site-config.json + ppkg). The first-logon self-heal
was meant to recover but its scripts were themselves staged past the death
point, so nothing ran.

- FlatUnattendW10-shopfloor.xml: new FirstLogonCommands Order-4 bootstrap that
  mounts the enrollment share fresh and pulls Fetch-StagingPayload /
  Verify-And-Heal-Staging / enrollment scripts into C:\Enrollment before the
  Order 5/6 heal runs. Recovery no longer depends on WinPE staging surviving.
  Keep CommandLine <=320 and Description <=252 chars: exceeding the unattend
  schema length limits makes the whole oobeSystem pass invalid (OOBE prompts,
  no autologon).

- Verify-And-Heal-Staging.ps1: add Keyence and WaxTrace heavy-payload heal
  branches (previously only CMM). Keyence re-pulls installers-post\keyence\
  <model> -> C:\KeyenceInstall\<model>; WaxTrace re-pulls the bundle (minus
  formtracepak) plus the bay-matched FORMTRACEPAK-V<ver>.iso.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:12:43 -04:00
cproudlock
64e1f7e088 Report-AssetToShopDB: log OS + boot time in the POST line
Adds os= and boot= to the POST log line so report-asset-*.log shows whether
the PC actually grabbed the OS version and LastBootUpTime (uptime source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:31:12 -04:00
cproudlock
287ec86c12 Report-AssetToShopDB: fleet-wide reporting, more fields, dual NIC
- collect logged-in user (console user via Win32_ComputerSystem, bare
  username), pc-type (C:\Enrollment\pc-type.txt), make/model, OS version
  (caption + DisplayVersion + build), last boot time (for uptime)
- report BOTH corp and controller NICs (physical only), each with MAC,
  tagged IsMachineNetwork; was corp-only before
- machine-number sourcing adds C:\Enrollment\cmm\cmmid.txt (CMM bay id)
  and skips the 9999 placeholder everywhere
- intended to run from common\ (every pc-type), not collections-only;
  api.asp patch-style update keeps it from clobbering other types

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:41:36 -04:00
cproudlock
85e7d91b1a CMM: imaging manifest installs goCMM 2.12.3 (was 1.1)
Future PXE images now install goCMM 2.12.3 (NSIS, /S silent, -> C:\Program Files
\goCMM, runs no-admin). Detection switched to File on goCMM.exe (new install
path). The tsgwp00525 SFLD enforce manifest is deliberately left on goCMM 1.1 so
already-deployed bays are NOT auto-upgraded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:45:25 -04:00
cproudlock
2461804fc8 CMM: Backup-CMM also captures the whole C:\Program Files\WAI tree
Adds a wai_backup_<PC>_<ts>.zip (robocopy /E of C:\Program Files\WAI + the x86
path) alongside the goCMM + PC-DMIS backups, indexed in cmm-backup-index.json.
Captures machine/controller content beyond the per-version PC-DMIS grab. Can be
multi-GB if WAI holds the full PC-DMIS 2016 install.

NOTE: this only CAPTURES it. sync-cmm-backups.sh + Restore-CMM still handle only
gocmm/pcdmis zips - staging/restoring the WAI zip needs those updated too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:40:01 -04:00
cproudlock
48bc609eb5 CMM/DODA: fix DODA-bay profile resolution + goCMM 2.12 DataFolder + settings converter
- Get-PCProfile: subtype-strip fallback. DODA bays set pc-subtype.txt=doda, so
  the profile key became "gea-shopfloor-cmm-doda" which matched NO profile/alias
  -> Get-PCProfile returned null -> callers fell to hardcoded defaults (no
  PC-DMIS desktop icons; Defect Tracker / WJ Shopfloor / Plant Apps force-started).
  Now an unmatched compound key falls back to the bare pc-type (-> CMM). VM-tested:
  gea-shopfloor-cmm/doda + CMM/doda resolve to CMM (7 apps, PC-DMIS present);
  non-CMM unaffected.
- 09-Setup-CMM Step 2.5c: Active Setup seed for goCMM 2.12 DataFolder. goCMM 2.12
  stores its shared-data-dir in HKCU\Software\General Electric\goCMM\DataFolder
  (decompiled: RegistrySettings uses Registry.CurrentUser - per-user). Imaging as
  SupportUser wouldn't reach the ShopFloor operator's HKCU. Active Setup runs the
  StubPath once per user at first logon -> every user gets DataFolder=C:\geaofi\.
  VM-tested: StubPath writes the value with the trailing backslash intact.
- Convert-goCMMSettings.ps1: converts legacy goCMM 1.1 ApplicationSettings.xml ->
  goCMM 2.12 goCMMSettings.xml schema. VM-tested: output byte-identical to a real
  goCMM-2.12-produced CMM10 goCMMSettings.xml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:59:08 -04:00
cproudlock
132c57ab39 CMM: create C:\GE PC-DMIS FRONT END at imaging + move regserver helper to Public desktop
- Ensure-PCDMISFrontEnd.ps1/.bat: standalone fix for already-imaged bays -
  create C:\GE PC-DMIS FRONT END + grant Users/Auth Users Modify. PCDToIGES.exe
  writes its error log there in its catch block; on a fresh bay the dir is absent
  (legacy front-end setup isn't part of imaging), so ANY PCDToIGES error becomes
  an unhandled DirectoryNotFoundException that crashes the export and masks the
  real cause (confirmed live on a CMM bay).
- 09-Setup-CMM Step 2.5b: create that dir + ACL at imaging for every CMM bay.
- 09-Setup-CMM Step 2.5: Register-PCDMIS-COM.bat now lands on the Public desktop
  (visible to operator or SupportUser) instead of SupportUser-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:56:00 -04:00
cproudlock
99deaa6882 CMM: add PC-DMIS 2026.1 desktop shortcut + taskbar pin (site-config)
The CMM pcProfile listed PC-DMIS 2016 and 2019 R2 in desktopApps/taskbarPins but
not 2026.1, so freshly imaged 2026 bays (CMM11/12) got no PC-DMIS shortcut. Add
the 2026.1 entries pointing at C:\Program Files\Hexagon\PC-DMIS 2026.1 64-bit\
PCDLRN.exe. Test-Path gated by the shortcut generator, so it is a no-op on
2016/2019 bays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:49:55 -04:00
cproudlock
948d013539 preinstall: add VC++ 2015-2022 x64 redist (PC-DMIS 2026 vcruntime140_1.dll)
The x64 redist ladder in preinstall stopped at 2013, so 64-bit PC-DMIS 2026 had
no vcruntime140_1.dll (introduced in VC++ 2019/14.20) and PCDLRN.exe failed to
launch: "vcruntime140_1.dll was not found". The patched PC-DMIS MSI bypasses
Hexagon's Burn bundle, which would otherwise have pulled the runtime in - and the
existing 2010/2012 x64 entries (added for PC-DMIS 2016/2019) never got a
2015-2022 successor.

Add VC++ 2015-2022 x64 (vc_redist.x64.exe 14.44.35211, /install /quiet
/norestart), PCTypes ["*"], File-detected on vcruntime140_1.dll so it is
version-independent. Installer staged on the share at
pre-install/installers/vcredist/2022-x64/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:43:35 -04:00
cproudlock
6e218f3daf CMM/DODA: Install-DODA does the full unattended runtime+app install
The vendor GE_DODA_build5_Installer.exe is a multi-app NSIS bundler whose /S
silent mode is broken (nested sub-install -> MSI 1619); VM-confirmed it only
works interactively. So Install-DODA now drives the bundled components directly,
all silent:
  python-2.7.16 msi /qn  -> C:\Python27
  openjdk-1.8.0.232 msi /qn -> C:\Program Files\RedHat
  gs927w64.exe /S        -> Ghostscript 9.27
  vc_redist /quiet
  doda_build*.zip        -> C:\Apps\DODA  (+ writes .settings the zip lacks)
Then sets AFDA_INSTALL_DIR + Java PATH, precompiles .py->.pyc as admin, and
grants Users+Auth Users Full on C:\Apps\DODA so the locked-down operator can run
DovetailAnalysis.exe (writes .pyc/PreProcess/$TMP). Each step detects-and-skips;
idempotent. VM-validated end to end incl a non-admin operator-paths test.

Runtime binaries (python/jdk/gs/vc msis + doda zip) are staged on the share, not
committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:54:15 -04:00
cproudlock
b5b644a360 CMM/DODA: restore on DODA bays + grant Full on whole C:\Apps\DODA
- Restore-CMM: drop the skip-on-doda gate. DODA bays now restore the
  config-version PC-DMIS + goCMM settings like any other bay (they have backups
  now; DODA itself installs separately to C:\Apps\DODA and is unaffected).
- sync-cmm-backups.sh: update the stale "do not back up DODA bays" note.
- Install-DODA.ps1: grant Users + Authenticated Users Full on the WHOLE
  C:\Apps\DODA (was PreProcess only) - DODA writes output/temp throughout the
  folder as the locked-down operator. /T covers PreProcess.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:22:06 -04:00
cproudlock
bc89ba1cf3 CMM: add Register-PCDMIS-COM.bat desktop helper for post-license COM regserver
PC-DMIS COM must be registered for goCMM to connect, but Pcdlrn.exe /regserver
no-ops until PC-DMIS is licensed - which is a manual post-image step
(clmadmin.exe). So we cannot register at imaging time. Instead 09-Setup-CMM
drops a self-elevating one-click helper on the SupportUser desktop (a
pre-existing profile; avoids 06-OrganizeDesktop's Public-desktop sweep). The
tech runs it after activating the license; safe to re-run. Falls back to Public
Desktop if the SupportUser profile is absent. Copy happens before the Step 3
staging cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:10:44 -04:00
cproudlock
1487abdba5 CMM/DODA: enable on bays 4/10/11/12, fix PCTypes match, grant PreProcess ACL
Three fixes so DODA actually deploys on the four bays that need it:

- cmm-bay-config.csv: doda=yes for CMM4, CMM10, CMM11, CMM12 (was no on all
  bays). Drives doda.txt -> startnet pc-subtype.txt=doda -> the cmm-doda path.
- cmm-manifest.json: DODA entry PCTypes "cmm-doda" -> "gea-shopfloor-cmm-doda".
  The old value never matched: Test-PCTypeMatches builds the PC identity set as
  {gea-shopfloor-cmm, gea-shopfloor-cmm-doda, CMM} (the alias of the cmm type is
  bare CMM, no subtype variant), so "cmm-doda" was in no set and the entry was
  silently skipped. Must be the exact Type-SubType string.
- Install-DODA.ps1: grant Users + Authenticated Users Full on C:\Apps\DODA\
  PreProcess (icacls, SIDs, OI/CI inherit), applied every run. MergeFiles.exe
  writes there as the locked-down operator; without it the merge step fails.

Not yet pushed to the SFLD/enrollment share (server unreachable at commit time).
Not smoke-tested - no pwsh available here; logic-traced only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:35:39 -04:00
cproudlock
7af66575d0 CMM: gocmm-debug - capture the real startup NRE (PC-DMIS COM not registered)
debug.log from the bay shows the part group enumerates fine (14 .geop files
opened), then goCMM connects to PC-DMIS over COM and throws:

  System.ArgumentNullException: Value cannot be null. Parameter name: type
     at System.Activator.CreateInstance(Type type)
     at GEAE.Common.CMM.CMMInterfaces.PCDMIS.PCDMIS.<ConnectToSoftware>b__43_0()
  -> System.NullReferenceException at ConnectToSoftware(...)

Root cause: PC-DMIS automation server is not COM-registered, so
Type.GetTypeFromProgID returns null -> CreateInstance(null) throws -> the NRE
is the downstream symptom. Not part-group, not permissions, not calibration.

- PROBE 4: pull the .NET Runtime / Application Error crash stack for goCMM from
  the Application log so the next run captures the null in one shot.
- PROBE 5: (a) part-group UNC reachability; (b) PC-DMIS COM registration check
  (PCDLRN.* ProgID -> CLSID -> LocalServer32) that names the missing registration
  and the Pcdlrn.exe /regserver fix; plus install presence for context.
- .bat header documents the COM root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:31:11 -04:00
cproudlock
8e11f0824a CMM: gocmm-debug probes the startup NRE (part-group match), checks real exe
goCMM matches the registry 'Selected Part Group' against the
ApplicationSettings.xml <PartGroup FullName> entries with a case-sensitive
compare. No match -> SelectedPartGroup null -> "Object reference not set to
an instance of an object" at start. This is a different failure from the
registry SecurityException the script already probes.

- PROBE 3: read the reg value (32-bit view) + every FullName in the XML,
  Ordinal-compare, and report exact / case-only / no-match / missing-XML.
  Case-only and no-match name the d441abd canonicalization fix as the remedy.
- Version check now looks for the real goCMM.exe, not the nonexistent
  GEAOperatorFriendlyInterface.exe (was a false MISSING).
- .bat header documents both failure modes it now diagnoses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:08:00 -04:00
750 changed files with 47395 additions and 79644 deletions

20
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,20 @@
#!/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

14
.gitignore vendored
View File

@@ -118,3 +118,17 @@ playbook/shopfloor-setup/gea-shopfloor-partmarker/PartMarker/Mark/utilpassword.t
# HeatTreat per-machine DNC .reg exports (6601-6604) - contain DNC FtpPasswd
# credentials. Deployed via the enrollment share from the working tree.
playbook/shopfloor-setup/gea-shopfloor-heattreat/reg/*.reg
# SIC e10 controller config files carry the programmer/supervisor password
# in cleartext. Never commit them. See reference/e10/README.md
reference/e10/**/ConfigCtrl*
reference/e10/**/*.kind1
# GE part programs carry part numbers and marking content. Keep them out of
# git; they live in ~/pxe-images/e10-testing/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/

29
Build-PxeUsb.cmd Normal file
View File

@@ -0,0 +1,29 @@
@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

14
Capture-SiteContent.cmd Normal file
View File

@@ -0,0 +1,14 @@
@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,265 +1,251 @@
# 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.
## 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.
### Boot Chain
```
Client PXE boot (UEFI Secure Boot)
-> iPXE (TFTP, Broadcom-signed for Secure Boot)
-> iPXE boot menu (HTTP, port 4433)
-> User selects boot option:
├── Windows PE -> wimboot -> boot.wim -> startnet.cmd -> Samba share -> Image deployment
├── Clonezilla -> vmlinuz/initrd -> Disk cloning/imaging
├── Blancco -> Native kernel + initramfs -> NIST 800-88 drive erasure
└── Memtest86+ -> Memory diagnostics
```
### Services
| Service | Port | Purpose |
|-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (172.16.9.10-100, 12h lease) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
| Samba | 445/tcp | Deployment content + Clonezilla + Blancco|
| Flask Webapp| 9009/tcp | Web management interface |
### Network
- **PXE server IP:** `172.16.9.1/24`
- **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)
## Quick Start
### Prerequisites
**On your workstation (internet-connected):**
- Ubuntu 24.04 (or Linux Mint / similar) for downloading packages
- Ubuntu Server 24.04 ISO
- GE Aerospace Media Creator LITE (for WinPE images)
- USB drive >= 8 GB (32+ GB if bundling WinPE images)
**GE Access Packages (MyAccess portal):**
- EPM Rufus Exception Request
- EPM DT Functions
- DLP - Encrypted Removable (USB) Long Term Access
### Step 1: Download Offline Packages
```bash
./scripts/download-packages.sh
```
Downloads all .deb packages and Python wheels for offline installation (~140 MB of debs, ~20 MB of wheels).
### Step 2: Prepare Boot Tools (optional)
```bash
./scripts/prepare-boot-tools.sh /path/to/blancco.iso /path/to/clonezilla.zip /path/to/memtest.bin
```
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 3: Build the USB
```bash
sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso
```
Creates a bootable USB with two partitions:
- **Partition 1:** Ubuntu Server installer
- **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools)
### Step 4: Install on Target Server
1. Insert USB into the target machine
2. Press F12 and boot from USB
3. Ubuntu auto-installs with no interaction
4. After reboot, the first-boot script:
- Installs all offline .deb packages
- Runs the Ansible playbook (configures dnsmasq, Apache, Samba, UFW, webapp)
- Configures static IP `172.16.9.1/24`
5. Move the server's wired NIC to the isolated PXE switch
### Step 5: Access the Web Interface
Open `http://172.16.9.1:9009` from any machine on the isolated network.
## Web Management Interface
The Flask webapp (port 9009) provides a browser-based management UI:
- **Dashboard** — Service status overview, disk usage, connected DHCP clients
- **Image Import** — Import WinPE deployment images from USB drives
- **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)
- **Clonezilla Backups** — Upload, download, and manage disk backup images
- **Blancco Reports** — View, download, and manage drive erasure reports (auto-collected via Samba)
- **Image Config** — Per-image configuration editor (drivers, OS packages, hardware models)
- **Enrollment** — Upload, download, and manage GCCH bulk enrollment packages
- **Audit Log** — Activity history for all write operations (imports, edits, deletes)
### Image Types Supported
| 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) |
## Project Structure
```
pxe-server/
├── autoinstall/
│ ├── user-data # Cloud-init autoinstall config + first-boot script
│ └── meta-data # Cloud-init metadata (required, empty)
├── playbook/
│ ├── pxe_server_setup.yml # Ansible playbook: all server configuration
│ ├── 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/
│ │ ├── ge-aerospace-logo.svg # GE Aerospace branding
│ │ ├── favicon.ico # Browser favicon
│ │ ├── app.js # Frontend JavaScript
│ │ ├── bootstrap.min.css # Bootstrap 5 (bundled offline)
│ │ ├── bootstrap.bundle.min.js
│ │ ├── bootstrap-icons.min.css
│ │ └── fonts/ # Icon fonts (woff/woff2)
│ └── templates/
│ ├── base.html # Layout with GE branding and sidebar nav
│ ├── dashboard.html # Service status and overview
│ ├── import.html # USB image import wizard
│ ├── unattend_editor.html # XML editor for unattend files
│ ├── startnet_editor.html # startnet.cmd WIM editor
│ ├── image_config.html # Per-image driver and hardware config
│ ├── enrollment.html # GCCH bulk enrollment packages
│ ├── backups.html # Clonezilla backup management
│ ├── reports.html # Blancco erasure reports
│ └── audit.html # Activity audit log
├── docs/
│ └── shopfloor-display-imaging-guide.md # End-user imaging guide
├── boot-tools/ # Extracted boot tool files (gitignored)
│ ├── blancco/ # Blancco Drive Eraser
│ ├── clonezilla/ # Clonezilla Live
│ └── memtest/ # Memtest86+
├── 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 installer USB (2-partition)
│ ├── build-proxmox-iso.sh # Builds self-contained Proxmox installer ISO
│ ├── prepare-boot-tools.sh # Extracts and patches boot tool files
│ ├── download-packages.sh # Downloads offline .debs + pip wheels
│ ├── download-drivers.py # Downloads Dell drivers directly from dell.com
│ ├── deploy-bios.sh # Pushes BIOS updates to enrollment share
│ ├── pull-bios.sh # Pulls BIOS binaries from upstream cache
│ ├── sync_hardware_models.py # Syncs hardware model configs across images
│ ├── 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
├── startnet-template.cmd # startnet.cmd template (synced with playbook copy)
├── README.md # This file
└── SETUP.md # Detailed setup guide
```
## Proxmox Deployment
A single ISO can be built for deploying the PXE server in a Proxmox VM:
### Build the ISO
```bash
# Prerequisites (on build workstation)
sudo apt install xorriso p7zip-full
# Build the installer ISO
./scripts/build-proxmox-iso.sh /path/to/ubuntu-24.04-live-server-amd64.iso
```
This creates `pxe-server-proxmox.iso` containing the Ubuntu installer, autoinstall config, all offline packages, the Ansible playbook, webapp, and boot tools.
### Deploy on Proxmox
1. Upload `pxe-server-proxmox.iso` to Proxmox storage (Datacenter -> Storage -> ISO Images)
2. Create a new VM:
- **OS:** Linux 6.x kernel
- **BIOS:** OVMF (UEFI) or SeaBIOS
- **Memory:** 4096 MB
- **CPU:** 2+ cores
- **Disk:** 40+ GB (VirtIO SCSI)
- **Network:** Bridge connected to your isolated PXE network
3. Attach the ISO as CD-ROM and start the VM
4. Ubuntu auto-installs with zero interaction (~10-15 minutes)
5. After reboot, first-boot configures all PXE services automatically
6. Access the web interface at `http://172.16.9.1:9009`
### Import WinPE 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.
## 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 (no authentication) for ease of use on the isolated network.
## Blancco Drive Erasure
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.
Reports are viewable and downloadable from the web interface at `http://172.16.9.1:9009/reports`.
## Notes
- Run `download-packages.sh` before building USB — it downloads all offline `.deb` packages including wimtools (needed for startnet.cmd editing)
- The webapp uses session-based CSRF tokens on all POST forms and API endpoints
## Commit History
| Commit | Description |
|---------|--------------------------------------------------------------------|
| 5791bd1 | Initial project setup: automated PXE server provisioning |
| cee4ecd | Add web management UI, offline packages, WinPE consolidation |
| 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 |
# 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.
## 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.
### Boot Chain
```
Client PXE boot (UEFI Secure Boot)
-> iPXE (TFTP, Broadcom-signed for Secure Boot)
-> iPXE boot menu (HTTP, port 4433)
-> User selects boot option:
├── Windows PE -> wimboot -> boot.wim -> startnet.cmd -> Samba share -> Image deployment
├── Clonezilla -> vmlinuz/initrd -> Disk cloning/imaging
├── Blancco -> Native kernel + initramfs -> NIST 800-88 drive erasure
(memory test: Dell F12 > Diagnostics)
```
### Services
| Service | Port | Purpose |
|-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (172.16.9.10-100, 12h lease) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
| Samba | 445/tcp | Deployment content + Clonezilla + Blancco|
| Flask Webapp| 9009/tcp | Web management interface |
### Network
- **PXE server IP:** `172.16.9.1/24`
- **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)
## Quick Start
### Prerequisites
**On your workstation (internet-connected):**
- Ubuntu 24.04 (or Linux Mint / similar) for downloading packages
- Ubuntu Server 24.04 ISO
- GE Aerospace Media Creator LITE (for WinPE images)
- USB drive >= 8 GB (32+ GB if bundling WinPE images)
**GE Access Packages (MyAccess portal):**
- EPM Rufus Exception Request
- EPM DT Functions
- DLP - Encrypted Removable (USB) Long Term Access
### Step 1: Download Offline Packages
```bash
./scripts/download-packages.sh --iso /path/to/ubuntu-24.04.x-live-server-amd64.iso
./scripts/check-updates.py # expect: PASSED, 0 security updates
./scripts/fetch-ipxe.sh
```
`fetch-ipxe.sh` downloads the Secure Boot iPXE chain into `boot-files/`, pinned by sha256: the iPXE release
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
playbook installs it as `ipxe-bcm.efi`, the rollback boot file.
Downloads all .deb packages and Python wheels for offline installation (~140 MB of debs, ~20 MB of wheels).
### Step 2: Prepare Boot Tools (optional)
```bash
./scripts/prepare-boot-tools.sh /path/to/blancco.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.
### Step 3: Build the USB
```bash
sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso
```
On a Windows PC instead: `scripts/stage-usb-share.sh` here, then
`Build-PxeUsb.ps1 -DiskNumber N -NoSiteVars` there (see SETUP.md, "Building
the USB on a Windows PC").
`build-usb.sh` creates a bootable USB with two partitions:
- **Partition 1:** Ubuntu Server installer
- **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools)
### Step 4: Install on Target Server
1. Insert USB into the target machine
2. Press F12 and boot from USB
3. Ubuntu auto-installs with no interaction
4. After reboot, the first-boot script:
- Installs all offline .deb packages
- Runs the Ansible playbook (configures dnsmasq, Apache, Samba, UFW, webapp)
- Configures static IP `172.16.9.1/24`
5. Move the server's wired NIC to the isolated PXE switch
### Step 5: Access the Web Interface
Open `http://172.16.9.1:9009` from any machine on the isolated network.
## Web Management Interface
The Flask webapp (port 9009) provides a browser-based management UI:
- **Dashboard** — Service status overview, disk usage, connected DHCP clients
- **Image Import** — Import WinPE deployment images from USB drives
- **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)
- **Clonezilla Backups** — Upload, download, and manage disk backup images
- **Blancco Reports** — View, download, and manage drive erasure reports (auto-collected via Samba)
- **Image Config** — Per-image configuration editor (drivers, OS packages, hardware models)
- **Enrollment** — Upload, download, and manage GCCH bulk enrollment packages
- **Audit Log** — Activity history for all write operations (imports, edits, deletes)
### Image Types Supported
| 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) |
## Project Structure
```
pxe-server/
├── autoinstall/
│ ├── user-data # Cloud-init autoinstall config + first-boot script
│ └── meta-data # Cloud-init metadata (required, empty)
├── playbook/
│ ├── pxe_server_setup.yml # Ansible playbook: all server configuration
│ ├── 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/
│ │ ├── ge-aerospace-logo.svg # GE Aerospace branding
│ │ ├── favicon.ico # Browser favicon
│ │ ├── app.js # Frontend JavaScript
│ │ ├── bootstrap.min.css # Bootstrap 5 (bundled offline)
│ │ ├── bootstrap.bundle.min.js
│ │ ├── bootstrap-icons.min.css
│ │ └── fonts/ # Icon fonts (woff/woff2)
│ └── templates/
│ ├── base.html # Layout with GE branding and sidebar nav
│ ├── dashboard.html # Service status and overview
│ ├── import.html # USB image import wizard
│ ├── unattend_editor.html # XML editor for unattend files
│ ├── startnet_editor.html # startnet.cmd WIM editor
│ ├── image_config.html # Per-image driver and hardware config
│ ├── enrollment.html # GCCH bulk enrollment packages
│ ├── backups.html # Clonezilla backup management
│ ├── reports.html # Blancco erasure reports
│ └── audit.html # Activity audit log
├── docs/
│ └── shopfloor-display-imaging-guide.md # End-user imaging guide
├── boot-tools/ # Extracted boot tool files (gitignored)
│ ├── 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 installer USB (2-partition)
│ ├── 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 and patches boot tool files
│ ├── 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
│ ├── 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 # This file
└── SETUP.md # Detailed setup guide
```
## Proxmox Deployment
Build a disk image with
`scripts/build-usb.sh --site-vars site-vars.yml --image pxe-usb.img auto UBUNTU.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
on the corp bridge; their MACs go in `site-vars.yml`.
### Import WinPE 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.
## 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 (no authentication) for ease of use on the isolated network.
## Blancco Drive Erasure
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.
Reports are viewable and downloadable from the web interface at `http://172.16.9.1:9009/reports`.
## Notes
- 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
- The webapp uses session-based CSRF tokens on all POST forms and API endpoints
## Commit History
| Commit | Description |
|---------|--------------------------------------------------------------------|
| 5791bd1 | Initial project setup: automated PXE server provisioning |
| cee4ecd | Add web management UI, offline packages, WinPE consolidation |
| 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,234 +1,317 @@
# 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.
## Architecture
```
Client PXE boot
-> Broadcom signed iPXE (Secure Boot)
-> iPXE boot menu (HTTP, port 4433)
├── Windows PE -> wimboot -> boot.wim -> startnet.cmd -> Samba -> Image deployment
├── Clonezilla -> vmlinuz/initrd -> Disk cloning/imaging
├── Blancco -> Native kernel + initramfs -> NIST 800-88 drive erasure (auto-reports)
└── Memtest86+ -> Memory diagnostics
```
### Services on the PXE Server
| Service | Port | Purpose |
|-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (172.16.9.10-100) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
| Samba | 445/tcp | Deployment content + backup + reports |
| Flask Webapp| 9009/tcp | Web management interface |
## Prerequisites
### Hardware
- Server or PC with >= 8 GB RAM, >= 250 GB disk, one wired NIC
- USB thumb drive >= 8 GB (32+ GB if bundling WinPE images)
### Software (on your workstation)
- Ubuntu Server 24.04 ISO — https://ubuntu.com/download/server
- Linux workstation (Ubuntu/Mint) for running download and build scripts
- GE Aerospace Media Creator LITE (for caching WinPE images)
### GE Access Packages
- EPM Rufus Exception Request
- EPM DT Functions
- DLP - Encrypted Removable (USB) Long Term Access
## Setup Process
### Step 1: Download Offline Packages (one-time, requires internet)
```bash
./scripts/download-packages.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.
**Packages included:**
- **Server:** dnsmasq, apache2, samba, ufw, cron
- **Automation:** ansible
- **Tools:** wimtools, unzip, p7zip-full
- **Python:** python3-pip, python3-venv
- **Network:** network-manager, wpasupplicant, wireless-tools, linux-firmware
### Step 2: Prepare Boot Tools (optional)
```bash
./scripts/prepare-boot-tools.sh /path/to/blancco.iso /path/to/clonezilla.zip /path/to/memtest.bin
```
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.
### Step 3: Build the USB
```bash
# Basic — server only (import WinPE images later)
sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso
# With WinPE images bundled (single USB, larger drive needed)
sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04.iso /path/to/winpe-images
```
This creates a bootable USB with:
- **Partition 1:** Ubuntu Server installer
- **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools)
### Step 4: Boot the Target Machine
1. Insert the USB into the target machine
2. Press F12 (or vendor boot key) and select the USB
3. Ubuntu auto-installs — no interaction needed
4. After reboot, the first-boot script installs all .deb packages and runs the Ansible playbook
5. PXE services (dnsmasq, Apache, Samba) are configured automatically
6. Flask webapp starts on port 9009
### Step 5: Connect to Isolated Network
Move the server's wired NIC to the isolated switch for PXE clients.
### Step 6: Import WinPE Content (if not bundled in Step 3)
**Option A:** Use the web interface at `http://172.16.9.1:9009` to import from USB.
**Option B:** Manual copy:
```bash
sudo mkdir -p /mnt/usb2
sudo mount /dev/sdb2 /mnt/usb2
sudo cp -r /mnt/usb2/. /srv/samba/winpeapps/gea-standard
sudo umount /mnt/usb2
```
## Web Management Interface
Access at `http://172.16.9.1:9009` from any machine on the isolated network.
| Page | URL Path | Purpose |
|-------------------|-------------|-----------------------------------------------|
| Dashboard | / | Service status, disk usage, DHCP clients |
| 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) |
| Clonezilla Backups| /backups | Upload/download/manage disk backup images |
| Blancco Reports | /reports | View/download drive erasure reports |
| Image Config | /images/*/config | Per-image driver, OS, and hardware config |
| Enrollment | /enrollment | GCCH bulk enrollment package management |
| Audit Log | /audit | Activity history for all write operations |
## Verification
1. Connect a test workstation to the isolated switch
2. Set Network Boot (PXE) as first boot in BIOS/UEFI
3. Boot — the client should pull an IP from 10.9.100.x
4. iPXE loads, fetches the boot script from port 4433
5. Select an option from the boot menu:
- **Windows PE**: Boots via wimboot + boot.wim, maps Samba shares, begins deployment
- **Clonezilla**: Boots Clonezilla Live for disk imaging
- **Blancco**: Boots Drive Eraser, auto-saves reports to server
- **Memtest86+**: Runs memory diagnostics
## Testing with KVM
```bash
# Download Ubuntu ISO
wget -O ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso \
https://releases.ubuntu.com/noble/ubuntu-24.04.3-live-server-amd64.iso
# Launch test VM
sudo ./test-vm.sh ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso
# Watch progress (Ctrl+] to detach)
sudo virsh console pxe-test
# 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
│ └── memtest/ # Memtest86+
├── 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-proxmox-iso.sh # Builds self-contained Proxmox installer ISO
│ ├── 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
│ ├── deploy-bios.sh # Pushes BIOS updates to enrollment share
│ ├── pull-bios.sh # Pulls BIOS binaries from upstream cache
│ ├── sync_hardware_models.py # Syncs hardware model configs across images
│ ├── 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
├── startnet-template.cmd # startnet.cmd template (synced with playbook copy)
├── 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.
# 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.
## Architecture
```
Client PXE boot
-> Broadcom signed iPXE (Secure Boot)
-> iPXE boot menu (HTTP, port 4433)
├── Windows PE -> wimboot -> boot.wim -> startnet.cmd -> Samba -> Image deployment
├── Clonezilla -> vmlinuz/initrd -> Disk cloning/imaging
├── Blancco -> Native kernel + initramfs -> NIST 800-88 drive erasure (auto-reports)
(memory test: Dell F12 > Diagnostics)
```
### Services on the PXE Server
| Service | Port | Purpose |
|-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (172.16.9.10-100) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
| Samba | 445/tcp | Deployment content + backup + reports |
| Flask Webapp| 9009/tcp | Web management interface |
## Prerequisites
### Hardware
- Server or PC with >= 8 GB RAM, >= 250 GB disk, one wired NIC
- USB thumb drive >= 8 GB (32+ GB if bundling WinPE images)
### Software (on your workstation)
- Ubuntu Server 24.04 ISO — https://ubuntu.com/download/server
- Linux workstation (Ubuntu/Mint) for running download and build scripts
- GE Aerospace Media Creator LITE (for caching WinPE images)
### GE Access Packages
- EPM Rufus Exception Request
- EPM DT Functions
- DLP - Encrypted Removable (USB) Long Term Access
## Setup Process
### Step 1: Download Offline Packages (one-time, requires internet)
```bash
./scripts/download-packages.sh --iso /path/to/ubuntu-24.04.x-live-server-amd64.iso
./scripts/check-updates.py # expect: PASSED, 0 security updates
./scripts/fetch-ipxe.sh
```
`fetch-ipxe.sh` downloads the Secure Boot iPXE chain into `boot-files/`, pinned by sha256: the iPXE release
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
playbook installs it as `ipxe-bcm.efi`, the rollback boot file.
`download-packages.sh` needs docker (no sudo) and the SAME server ISO that
`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
unpacks the ISO's install image into a docker image and resolves there:
every update of an installed package, the explicit package list
(ansible, dnsmasq, apache2, samba, wimtools, zip, the HWE kernel, ...) and
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
of the base before the script finishes. On the server, autoinstall's
late-commands run `scripts/offline-apt-install.sh` on it: one apt
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
to `pip-wheels/`. About 500 debs (~1.6 GB) + 8 wheels.
**Packages included:**
- **Server:** dnsmasq, apache2, samba, ufw, cron
- **Automation:** ansible
- **Tools:** wimtools, unzip, p7zip-full
- **Python:** python3-pip, python3-venv
- **Network:** network-manager, wpasupplicant, wireless-tools, linux-firmware
### Step 2: Prepare Boot Tools (optional)
```bash
./scripts/prepare-boot-tools.sh /path/to/blancco.iso
```
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.
### Step 2b: Capture site content from a running server
```bash
./scripts/capture-site-content.sh # dry run: lists what would change
./scripts/capture-site-content.sh --apply # pull it
```
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`).
The first MCL image import on the new server then seeds `_shared/Applications/BPRT`, adding our overlay scripts from the repo.
### Step 3: Build the USB
```bash
# Basic: server only (import WinPE images later). site-vars.yml holds the
# two NIC MACs (copy playbook/site-vars.example.yml); --site-vars or
# --no-site-vars is required.
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
# 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
# Same layout into a disk-image file, no root (dd it to a stick later, or
# boot it in a VM: see tests/vm/README.md; on Proxmox: qm importdisk).
# SIZE is e.g. 16G or "auto".
./scripts/build-usb.sh --site-vars site-vars.yml --image pxe-usb.img auto /path/to/ubuntu-24.04.iso
```
Options: `--payload-root DIR` takes offline-packages/, pip-wheels/,
boot-files/, boot-tools/, enrollment/, bios-staging/ and drivers-staging/
from another checkout; `--skip-enrollment`, `--skip-bios`,
`--skip-drivers`, `--skip-boot-tools` leave those out; `--yes` skips the
prompt. The build refuses payloads that do not match their pins: the iPXE
files against the playbook's `ipxe_*_sha256`, each wheel against its own
package's hashes in `webapp/requirements.txt` (no PyPI fallback), and the
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.
This creates a bootable USB with:
- **Partition 1:** Ubuntu Server installer
- **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools)
#### Building the USB on a Windows PC
`scripts/Build-PxeUsb.ps1` builds the same stick on Windows, with the same
checks, no Linux tools needed. It writes ONE FAT32 partition labelled
CIDATA holding the Ubuntu ISO's files (boot menu patched for autoinstall)
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
compared by SHA-256.
On the build PC, put the repo and payload on the share:
```bash
./scripts/stage-usb-share.sh # -> ~/pxe-images/pxe-usb (hard links)
```
On the Windows PC, in an elevated PowerShell (Z: = the pxe-images share):
```powershell
Z:\pxe-usb\scripts\Build-PxeUsb.ps1 # lists USB disks
Z:\pxe-usb\scripts\Build-PxeUsb.ps1 -DiskNumber 3 -NoSiteVars # or -SiteVars FILE
```
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.
### Step 4: Boot the Target Machine
1. Insert the USB into the target machine
2. Press F12 (or vendor boot key) and select the USB
3. Ubuntu auto-installs — no interaction needed
4. After reboot, the first-boot script runs the Ansible playbook (the .debs
were installed by the installer); it pins the two NICs by MAC
5. PXE services (dnsmasq, Apache, Samba) are configured automatically
6. Flask webapp starts on port 9009
7. If the playbook fails, `/var/lib/pxe-firstboot.failed` and a banner on
the console and at SSH login point at `/var/log/first-boot.log`; first
boot retries at the next reboot (5 attempts)
### Step 5: Connect to Isolated Network
During the install plug in ONLY the imaging switch (GE unplugged). With
`pxe_nic_mac` empty in site-vars, the first boot picks the one wired port
with a link, checks it is isolated (no GE switch, no 802.1X, no other DHCP
server) and pins it. Then plug the GE cable into any other port: it becomes
a DHCP client for SSH and the webapp, and dnsmasq never serves it. If the
first boot cannot tell which port is the imaging one, the console says so:
log in and run `sudo pxe-pick-nic`, pull the imaging cable and plug it back
in. Sites that prefer to pin both ports by MAC set `pxe_nic_mac` and
`corp_nic_mac` instead; both cables can then be in from the start.
### Step 6: Import WinPE Content (if not bundled in Step 3)
**Option A:** Use the web interface at `http://172.16.9.1:9009` to import from USB.
**Option B:** Manual copy:
```bash
sudo mkdir -p /mnt/usb2
sudo mount /dev/sdb2 /mnt/usb2
sudo cp -r /mnt/usb2/. /srv/samba/winpeapps/gea-standard
sudo umount /mnt/usb2
```
## Web Management Interface
Access at `http://172.16.9.1:9009` from any machine on the isolated network.
| Page | URL Path | Purpose |
|-------------------|-------------|-----------------------------------------------|
| Dashboard | / | Service status, disk usage, DHCP clients |
| 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) |
| Clonezilla Backups| /backups | Upload/download/manage disk backup images |
| Blancco Reports | /reports | View/download drive erasure reports |
| Image Config | /images/*/config | Per-image driver, OS, and hardware config |
| Enrollment | /enrollment | GCCH bulk enrollment package management |
| Audit Log | /audit | Activity history for all write operations |
## Verification
1. Connect a test workstation to the isolated switch
2. Set Network Boot (PXE) as first boot in BIOS/UEFI
3. Boot — the client should pull an IP from 10.9.100.x
4. iPXE loads, fetches the boot script from port 4433
5. Select an option from the boot menu:
- **Windows PE**: Boots via wimboot + boot.wim, maps Samba shares, begins deployment
- **Clonezilla**: Boots Clonezilla Live for disk imaging
- **Blancco**: Boots Drive Eraser, auto-saves reports to server
- **Memory test**: not on the menu; Dell PCs have one built in (F12 at power-on, Diagnostics)
## Testing with KVM
```bash
# Download Ubuntu ISO
wget -O ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso \
https://releases.ubuntu.com/noble/ubuntu-24.04.3-live-server-amd64.iso
# Launch test VM
sudo ./test-vm.sh ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso
# Watch progress (Ctrl+] to detach)
sudo virsh console pxe-test
# 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,19 +9,40 @@ autoinstall:
variant: ""
timezone: America/New_York
# Network: static IP for isolated PXE LAN (no internet/DHCP needed)
# Network: DHCP only, on every wired NIC. On the standard two-NIC box the
# 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:
version: 2
ethernets:
any-eth:
match:
name: "en*"
addresses:
- 172.16.9.1/24
dhcp4: false
dhcp4: true
dhcp6: false
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:
layout:
@@ -44,34 +65,63 @@ autoinstall:
# Installer-stage late commands
late-commands:
# Step 1: Install deb packages from CIDATA USB
# Step 1: Upgrade and install from the offline set on CIDATA
# (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 -c '
curtin in-target --target=/target -- bash -euc '
mkdir -p /mnt/cidata
CIDATA_DEV=$(blkid -L CIDATA)
if [ -n "$CIDATA_DEV" ]; then
mount "$CIDATA_DEV" /mnt/cidata
if compgen -G "/mnt/cidata/packages/*.deb" > /dev/null; then
cp /mnt/cidata/packages/*.deb /tmp/
dpkg -i /tmp/*.deb 2>/dev/null || true
dpkg -i /tmp/*.deb 2>/dev/null || true
dpkg --configure -a 2>/dev/null || true
if command -v nmcli >/dev/null; then
systemctl enable NetworkManager
fi
fi
umount /mnt/cidata
mount -o ro "$CIDATA_DEV" /mnt/cidata
set +e
bash /mnt/cidata/offline-apt-install.sh /mnt/cidata/packages 2>&1 | tee /var/log/offline-apt-install.log
rc=${PIPESTATUS[0]}
set -e
umount /mnt/cidata
if [ "$rc" -ne 0 ]; then
echo "offline package install FAILED (rc=$rc), see /var/log/offline-apt-install.log" >&2
exit "$rc"
fi
if command -v nmcli >/dev/null; then
systemctl enable NetworkManager
fi
'
# Step 2: Disable networkd-wait-online (hangs on air-gapped network)
- ln -sf /dev/null /target/etc/systemd/system/systemd-networkd-wait-online.service
# Step 3: Create first-boot.sh
# Step 3: Create first-boot.sh. rc.local runs it on every boot until the
# 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 '
cat <<"EOF" > /opt/first-boot.sh
#!/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)
ROOT_DEV=$(findmnt -n -o SOURCE /)
ROOT_DISK=$(lsblk -n -o PKNAME "$(readlink -f "$ROOT_DEV")" | tail -1)
@@ -82,37 +132,79 @@ autoinstall:
pvresize "$PV_PART" 2>&1 || true
fi
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)
if [ -n "$CIDATA_DEV" ]; then
mkdir -p /mnt/usb
mount "$CIDATA_DEV" /mnt/usb
if compgen -G "/mnt/usb/packages/*.deb" > /dev/null; then
dpkg -i /mnt/usb/packages/*.deb 2>/dev/null || true
dpkg -i /mnt/usb/packages/*.deb 2>/dev/null || true
dpkg --configure -a 2>/dev/null || true
if mount "$CIDATA_DEV" /mnt/usb; then
if [ -f /mnt/usb/playbook/pxe_server_setup.yml ]; then
cd /mnt/usb/playbook
rm -f "$STATE/nic-hint"
ansible-playbook -i localhost, -c local pxe_server_setup.yml
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
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
sed -i "s|^/opt/first-boot.sh.*|# &|" /etc/rc.local
if [ "$RC" -eq 0 ]; then
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
chmod +x /opt/first-boot.sh
'
# Step 4: Create rc.local
# Step 4: Create rc.local (the log is appended to, one block per attempt)
- |
curtin in-target --target=/target -- bash -c '
cat <<"EOF" > /etc/rc.local
#!/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
EOF
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:
disable_root: false

View File

@@ -29,7 +29,7 @@ systems.
|---------------|-----------|------------------------------------------------------|
| dnsmasq | 67/udp | DHCP. Hands out IPs and the next-server (TFTP) info. |
| dnsmasq | 69/udp | TFTP. Serves `ipxe.efi` to firmware PXE clients. |
| Apache | 80/tcp | HTTP. Serves wimboot, boot.wim, Blancco binaries, Clonezilla, memtest, and proxies the webapp on /. |
| Apache | 80/tcp | HTTP. Serves wimboot, boot.wim, Blancco binaries, Clonezilla, and proxies the webapp on /. |
| 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. |
| Flask webapp | 9009/tcp | Browser UI for image import, unattend editing, log/report viewing. |
@@ -112,9 +112,23 @@ WinPE; the Blancco and Clonezilla utilities require Secure Boot disabled.
c. Clonezilla Live:
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.
d. Memtest86+:
iPXE loads memtest.efi.
Memory testing is not on the menu: Dell PCs have a built-in test
(F12 at power-on, Diagnostics). Memtest86+ was removed because it is
unsigned and needed Secure Boot off.
```
## Windows imaging lifecycle (gea-* images)
@@ -179,9 +193,7 @@ PXE clients (UEFI -> iPXE -> Blancco) -+--> Drive erasure + report upload
## Where things live
- Ansible playbook: `playbook/pxe_server_setup.yml`. Single file, idempotent.
- 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
- WinPE startup script: `playbook/startnet.cmd`. The playbook injects this into `Windows/System32/startnet.cmd` inside the
`boot.wim` it deploys to `/var/www/html/win11/sources/`.
- iPXE menu script source: embedded in the playbook (`Create GetPxeScript.aspx`
task); deployed to `/var/www/html/Altiris/iPXE/GetPxeScript.aspx`.

95
docs/OWNERSHIP.md Normal file
View File

@@ -0,0 +1,95 @@
# Who owns what
Every bug that cost a day in this pipeline has had the same shape: two systems
both setting the same thing, and whichever ran last silently won. This file says
who owns each concern so the next person deletes the loser instead of adding a
third writer.
All four examples below are real, from 2026-08-06.
## Computer name - the PPKG owns it
The provisioning package declares:
```xml
<DNSComputerName>F%SERIAL%</DNSComputerName>
```
so a bay comes up as `F<serial>`.
`run-enrollment.ps1` used to also run `Rename-Computer -NewName "E$serial"`.
Both are pending renames; last writer wins at reboot. The script ran twice, and
its second run landed after the package had already queued `F579C144`, so the
bay came up `E579C144` with the package reporting no errors at all.
**Rule:** nothing in this repo renames a machine. If the naming convention
changes, it changes in the package.
## Drive letters - PESetup owns them
PESetup hardcodes `W:` in nine places - every copy destination, both DISM
offline sessions, `bcdboot`, `reagentc` - and creates it during its own disk
preparation.
`startnet.cmd` briefly had a volume finder that scanned for "the applied
Windows volume" and `diskpart`-assigned it to `W:`. On a re-image it found the
*previous* install, relabelled a partition PESetup was about to erase, and every
staging copy failed into a volume that no longer existed.
**Rule:** wait for `W:\Windows\System32\config\system` - the hive only exists
once the WIM apply has written it. Never run `diskpart` while PESetup is
running.
See `docs/PESETUP-INTERNALS.md`.
## Enrollment - the PPKG owns it, the orchestrator drives reboots
The SFLD package joins Entra using the BPRT token in
`0__Accounts_Azure.provxml`. A human then assigns the device category in Intune.
The vendor's `Start-BulkEnrollOrchestrator.ps1` has two branches. The normal one
aborts the package's own reboot, registers `AutoSecondReboot`, and drives the
Entra join to completion. The `-ManualFallback` one runs `sysprep /oobe /reboot`
- it is an interactive escape hatch for handing a machine back to OOBE, meant to
be triggered by a person.
The shopfloor unattend registered `-ManualFallback` as an at-logon scheduled
task. So OOBE completed, autologon fired, and four seconds later the machine
syspreped itself back to OOBE, losing the deployment chain permanently.
**Rule:** shopfloor bays enrol. Never wire `-ManualFallback` to anything
automatic.
## Kiosk URLs - GE-Enforce owns them
`plugins/geenforce/seed_display_scope.py` in shopdb-flask is authoritative:
| display-type.txt | route |
|---|---|
| `Dashboard` | `/shopdb/shopfloor` |
| `Lobby` | `/shopdb/tv` |
| `3DPrintRoom` | `/shopdb/parts-kiosk` |
Its dispatcher prefers the server-side role from Settings > Dashboard Defaults
(resolved by device IP), falls back to `C:\Enrollment\display-type.txt`, writes
the Startup shortcut itself, and **sweeps** any shortcut matching
`shopfloor-dashboard` or `/shopdb/`.
`site-config.json` also carries `edgeHomepage` per display type. Those are a
backstop for the window before the kiosk installer runs - a stale value there
gets deleted on the next enforce cycle rather than honoured.
**Rule:** if a kiosk points at the wrong page, fix the GE-Enforce scope first.
Keep `site-config.json` correct, but do not expect it to win.
## Repo vs share
The share is production; the repo is meant to describe it. Drift runs both ways -
live hand-edits nobody committed, and repo fixes never deployed.
`scripts/share-drift.py` classifies every mapped pair as `git-owned` (repo wins,
safe to push) or `unreconciled` (diverged, nobody has decided). It reports and
never writes. Run it before a build day; `scripts/preflight.py` covers the rest.
The unattends are `unreconciled` on purpose: the live copies are ~17 KB and the
repo copies ~12 KB, so pushing the repo would regress production.

278
docs/PESETUP-INTERNALS.md Normal file
View File

@@ -0,0 +1,278 @@
# PESetup.exe internals
What GE Image Setup actually does, start to finish. Written from the decompiled
assembly, not from observation, because several long-standing beliefs about this
tool turned out to be wrong and cost weeks of debugging.
**Version documented:** 4.0.0.17 (`Sources/PESetup.exe`, PE32+ native apphost
wrapping a .NET 6 single-file bundle, 468 embedded files).
**Version in production:** 4.0.0.20, as of 2026-08-06 — the media reports it in
its own log (`AppVersion: 4.0.0.20`). Everything in this document was verified
against a 4.0.0.20 run on bay 579C144 that day: the media drive resolved to `Z:\`,
`W:` was created by `PrepareDisk` and used for every copy destination, the fallback
unattend at `Deploy\FlatUnattendW10.xml` was the one loaded, and driver selection
matched `win11_optiplexd13mlk7020_a09.zip` by model. The decompiled detail below has
not been re-derived from the 4.0.0.20 binary, so treat exact line-level claims as
4.0.0.17 and the observed behaviour as current.
## How to re-derive this
The managed code is not directly readable - the outer PE has no managed
metadata, so ILSpy refuses it. Extract `PESetup.dll` from the bundle first:
```python
# .NET single-file bundle: signature is SHA-256 of ".net core bundle";
# the int64 EIGHT BYTES BEFORE it is the bundle header offset.
SIG = bytes([0x8b,0x12,0x02,0xb9,0x6a,0x61,0x20,0x38,
0x72,0x7b,0x93,0x02,0x14,0xd7,0xa0,0x32])
i = data.find(SIG)
header = struct.unpack_from('<q', data, i - 8)[0]
# header: uint32 major, uint32 minor, int32 count, 7-bit-prefixed bundle id,
# then (v2+) 4 x int64 deps/runtimeconfig, then uint64 flags.
# each entry: int64 offset, int64 size, (v6+) int64 compressedSize,
# byte type, 7-bit-prefixed path. Compressed entries are raw
# deflate (zlib.decompress(raw, -15)).
```
Then `ilspycmd -t <TypeName> PESetup.dll`.
## Paths it hardcodes
From `PESetup.Models.GlobalSettings`. All are relative to the MEDIA drive
(`Z:\`, whichever winpeapps share startnet mapped) unless stated.
```
ControlDir \Deploy\Control
ToolsDir \Deploy\Tools\GE
SoftwarePackagesDir \Deploy\Applications
HWPackagesDir \Deploy\HW_Apps
UnattendFile \Deploy\Tools\GE\XML\FlatUnattendW10.xml
UnattendFile2 \Deploy\FlatUnattendW10.xml (fallback)
DiskPartDir <exe dir>\DiskPart
ConfigSkipPackagesFile skip.json
ConfigDisableAutoStartFile disableauto.json
ExpirationDuration 30 days
AutoStartCountDown 31
MinRequiredSpaceWithoutCompression 128849018880 (120 GB)
```
**`W:` IS HARDCODED, EVERYWHERE.** Not derived, not configurable, not a
convention this project invented:
```csharp
CopyPackages destDir = "W:\\" + SoftwarePackagesDir
CopyHWPackages destDir = "W:\\" + HWPackagesDir
CopyTools destDir = "W:\\GE"
CopyDrivers destinationPath = "W:\\Drivers\\" + <zip name without extension>
ApplyImage destination = "W:\\"
CreateBCD "W:\\Windows\\System32\\bcdboot.exe", "W:\\Windows /l en-US"
ApplyPackages DismApi.OpenOfflineSession("W:\\")
CopyLogs tags -> "w:\\windows\\system32\\"
```
PESetup's own disk preparation is what makes W: exist. Any downstream script
that hunts for "the applied volume" is solving a problem this tool does not
have. `diskpart list volume` on a machine mid-image shows `Volume 0 W Windows
NTFS 237GB Healthy`.
## The five working steps
Keys as they appear in the log: `workingstep_gatherdata`, `workingstep_selectos`,
`workingstep_prepare`, `workingstep_copy`, `workingstep_apply`,
`workingstep_finalize`, `workingstep_reboot`.
### 1. GATHER DATA
Four operations, all automatic:
| Operation | What it establishes |
|---|---|
| `GatherDataMediaDrive` | `ImageInfo.MediaDrive`, e.g. `Z:\` |
| `GatherDataBootDrive` | `ImageInfo.BootDrive`, always `X:\` (WinPE RAM disk) |
| `GatherDataSelection` | BIOS version/type, serial, secure boot, OS, languages, model, **driver** |
| `GatherDataImageDisk` | picks the physical disk and logs its partitions |
`GatherDataSelection` is where most decisions are made:
1. **Secure boot is mandatory.** `SecurebootEnabled != 1` fails the step outright.
2. Reads `Control\LanguagePacks.json`.
3. `SystemHelpers.GetModel()` and `GetManufacturer()` off WMI.
4. Reads `Control\HardwareDriver.json` and `Control\hw_applications.json`.
NOTE: it reads **HardwareDriver.json**, not the `hw_drivers.json` that also
sits in that folder. Editing the wrong one changes nothing.
5. **Virtual platform check.** If ANY driver entry has a manufacturer containing
"virtual platform", the tool goes into virtual-only mode: it takes
`list[0]` as the driver and then REQUIRES the machine to look virtual
(model/serial/BIOS containing VIRTUAL, VMWARE, XEN, QEMU, VirtualBox, vmw).
On real hardware that is a hard failure. A stray "virtual platform" entry in
the catalogue therefore breaks imaging for every physical machine.
6. Otherwise `GetDriverByModel` (below).
7. If `Control\skip.json` exists, package installation is skipped.
### 2. SELECT OS
Operator-facing. Warns when the media holds an LTSC image ("99% of the time only
for ShopFloor"). Also shows days-to-expiry - the media expires 30 days after
build (`ExpirationDuration`).
### 3. PREPARE
| Operation | What it does |
|---|---|
| `PrepareDoDisking` | copies `<exedir>\DiskPart\diskpartEFI.txt` to `X:\diskpartEFI<n>.txt`, then `cmd /c DISKPART /S X:\diskpartEFI<n>.txt` |
| `PrepareUnattend` | loads the unattend, substitutes, saves to `X:\Unattend.xml` |
`PrepareUnattend` in detail:
```csharp
text = MediaDrive + UnattendFile; // \Deploy\Tools\GE\XML\FlatUnattendW10.xml
if (!File.Exists(text)) text = MediaDrive + UnattendFile2; // \Deploy\FlatUnattendW10.xml
xml.Load(text);
xml.InnerXml = xml.InnerXml.Replace("%serialnumber%", imageinfo.Serialnumber);
xml.InnerXml = xml.InnerXml.Replace("*arch*", arch);
SetPackages(arch, xml, imageinfo); // needs Control\Packages.xml
xml.Save("X:\\Unattend.xml");
```
Two tokens are substituted: `%serialnumber%` and `*arch*`. `SetPackages` merges
in servicing packages from `Control\Packages.xml` and `Control\PackageGroups.xml`.
The unattend that is USED is the one on the media at `Tools\GE\XML\` if present,
otherwise `Deploy\FlatUnattendW10.xml`. A shopfloor variant only takes effect if
it occupies one of those two paths.
### 4. COPY
Four operations. **None of them filters, and none of them reads the unattend.**
| Operation | Source | Destination | Rule |
|---|---|---|---|
| `CopyTools` | `Z:\Deploy\Tools\GE` | `W:\GE` | whole directory, recursive |
| `CopyPackages` | `Z:\Deploy\Applications` | `W:\Deploy\Applications` | whole directory, recursive |
| `CopyHWPackages` | `Z:\Deploy\HW_Apps` | `W:\Deploy\HW_Apps` | whole directory; **skipped if `IsVirtual` or `HWApps == null`** |
| `CopyDrivers` | one `.zip` chosen by model | `W:\Drivers\<zipname>` | **unzipped**, not copied |
`ExecuteInternalCopy` walks `GetDirectoriesRecursive(source, includeSubDirs:
true)` then `Directory.GetFiles(item, "*")`. Every file, every subdirectory. It
clears the read-only attribute on each copy.
**Consequences worth designing around:**
- Anything dropped into `Deploy\Applications` on the media lands on the target.
No manifest, no registration, no unattend reference required.
- The copy is FAIL-FAST. One exception on one file returns false and fails the
whole step with `copy_packages_error`. A locked or unreadable file in
`Applications` fails imaging, it does not get skipped.
- Progress is computed from total directory size up front, so bulky additions
visibly lengthen this phase.
### 5. APPLY
| Operation | What it does |
|---|---|
| `ApplyImage` | `install.wim` from `OperatingSystem.json`'s `destinationDir`, at `wimindex`, applied to `W:\` |
| `ApplyPackages` | DISM offline session on `W:\`, adds servicing packages; logs to `X:\ApplyOSPatch_<n>.log` |
| `ApplyLanguagePacks` | same pattern, `X:\ApplyLanguagePack_<n>.log` |
| `ApplyUnattend` | DISM offline session on `W:\`, applies `X:\Unattend.xml`; logs to `X:\ApplyUnattend.log` |
| `CreateBCD` | `W:\Windows\System32\bcdboot.exe W:\Windows /l en-US` |
| `CopyWinRE` | `reagentc /setreimage /path T:\Recovery\WindowsRE /target W:\Windows` |
All DISM work uses `W:\imagetemp` as scratch and deletes it afterwards.
### 6. FINALIZE
`CopyLogs`:
- `CopyTagFiles(ToolsDir)` - every `*.tag` from the tools dir to
**`w:\windows\system32\`**. This is how build/media tags reach the OS.
- `CopyLogFiles("X:\\")` and `CopyXMLFile("X:\\")` - the PESetup log and the
generated unattend are preserved.
## Driver selection, in full
This is the part most worth understanding, because a miss is nearly silent.
```csharp
// GatherDataSelection
List<HardwareDriversRootObject> list =
JSONHelpers.ReadJSON_Driver(MediaDrive + ControlDir + "\\HardwareDriver.json");
...
imageinfo.HWDriver = GetDriverByModel(list, imageinfo.Model);
if (imageinfo.HWDriver == null) {
// status = Warning, message "driver for [MODEL] not found"
return operationResult;
}
```
```csharp
private static HardwareDriversRootObject? GetDriverByModel(List<...> drivers, string model)
{
string modelFamily = "";
if (model.ToUpper().Contains("LATITUDE")) modelFamily = "Latitude";
if (model.ToUpper().Contains("OPTIPLEX")) modelFamily = "Optiplex";
if (model.ToUpper().Contains("PRECISION")) modelFamily = "Precision";
return drivers.Where(d => {
if (!string.IsNullOrEmpty(modelFamily) &&
!d.family.ToLower().Contains(modelFamily.ToLower())) return false;
foreach (string token in d.modelswminame.Split(','))
if (model.ToLower().Contains(token.ToLower())) return true; // SUBSTRING
return false;
}).ToList()?.FirstOrDefault();
}
```
Then:
```csharp
sourceFilePath = MediaDrive + HWDriver.destinationDir.Replace("*destinationdir*","")
+ "\\" + HWDriver.fileName;
destinationPath = "W:\\Drivers\\" + Path.GetFileNameWithoutExtension(sourceFilePath);
StartUnzipAsync(...)
```
### Four traps in that logic
1. **A miss is a WARNING, not a failure.** Imaging continues and the machine
comes up with no drivers - no NIC, no WiFi, so DNS fails and anything
network-dependent at first boot fails with it. Symptoms appear far from the
cause.
2. **Matching is substring, first match wins.** A token like `7020` matches any
model string containing 7020. Order in the JSON decides ties.
3. **The family filter knows only three Dell lines.** Anything else - MicroPCs,
NUCs, non-Dell - skips the filter and depends entirely on
`modelswminame` substrings being right.
4. **One "virtual platform" entry hijacks the whole catalogue** (see GATHER
DATA step 5) and fails every physical machine.
## What this means for startnet.cmd
Three beliefs encoded in `startnet.cmd` do not survive contact with the source:
- *"The applied volume might not be W:."* PESetup hardcodes W: in nine places
and creates it during disk prep. The volume finder, the diskpart
reassignment and the `W:` wait loop are machinery around a non-problem.
- *"We must copy our payload from the enrollment share after imaging."* Anything
in `Deploy\Applications` on the media is copied to `W:\Deploy\Applications`
by PESetup itself, and is readable at `C:\Deploy\Applications` at first boot.
- *"Display MicroPCs failed because the applied volume was not W:."* Worth
re-testing. A missing `HardwareDriver.json` match produces the same
end-state - no drivers, no network - via a completely different route, and
that route only logs a warning.
## Files PESetup reads from the media
```
Deploy\Control\HardwareDriver.json driver catalogue (NOT hw_drivers.json)
Deploy\Control\hw_applications.json per-model applications
Deploy\Control\OperatingSystem.json OS list, wim path, wimindex
Deploy\Control\LanguagePacks.json language packs
Deploy\Control\packages.json servicing packages
Deploy\Control\Packages.xml unattend package merge
Deploy\Control\PackageGroups.xml package grouping
Deploy\Control\Media.tag media identity
Deploy\Control\skip.json presence = skip package install
Deploy\Control\disableauto.json presence = disable autostart
Deploy\Tools\GE\XML\FlatUnattendW10.xml preferred unattend
Deploy\FlatUnattendW10.xml fallback unattend
```

View File

@@ -20,13 +20,67 @@ contribute a `config/sites/<sitename>.yaml` template back to the repo.
### 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 |
|-------------------|----------------------|--------------------------------------------------------------------------------|
| 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) |
| DHCP range | 172.16.9.10-100 | `playbook/pxe_server_setup.yml` (dnsmasq config) |
| DHCP range | 172.16.9.10-100 | `playbook/site-vars.yml` (`pxe_dhcp_start`, `pxe_dhcp_end`) |
| 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) |
### 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
| Value | Default | Where it lives |
@@ -64,6 +118,31 @@ 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.
### 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)
`playbook/shopfloor-setup/` contains per-PC-type install logic. The current
@@ -80,8 +159,7 @@ A site without CMMs can drop the CMM type and its installers entirely.
### GCCH bulk-enrollment PPKGs
PPKG filenames are baked into `playbook/startnet.cmd` and
`startnet-template.cmd`. They are tied to the GE Aerospace tenant. Other
PPKG filenames are baked into `playbook/startnet.cmd`. They are tied to the GE Aerospace tenant. Other
sites/tenants will have their own PPKGs with different filenames.
| PPKG enrollment menu choice | Default filename |
@@ -146,7 +224,9 @@ Blob storage account.
- `\\172.16.9.1\image-upload` as the destination
- `C:\ProgramData\GEAerospace\MediaCreator\Cache\` as the source
Update both for a different site.
Update both for a different site (`-Server`, `-CachePath`; `-WebPort` if the
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

View File

@@ -0,0 +1,695 @@
# Zero-touch, ShopDB-driven imaging - proposal
**Status:** proposal, nothing built
**Written:** 2026-08-06
**Author's note:** every claim about current behaviour below is from evidence
gathered on 2026-08-06 - decompiled binaries, live logs off bay 579C144, and the
live share. Where something is inferred rather than observed it says so.
---
## 1. What we want
Two ideas, one architecture.
**A. The image becomes thin.** It carries only what must happen at image time -
partition, apply the WIM, join Entra, install the enforce client. Everything else
(PC type, subtype, tool association, apps, settings) is data, pulled at first
boot from the ShopDB-Flask API and enforced continuously by GE-Enforce.
**B. Imaging becomes a job, not a menu.** Open a machine in ShopDB, click
*Create this PC*, pick the zeroed box that is sitting in WinPE waiting, and it
images start to finish with nobody touching the keyboard - reporting progress
onto that machine's record in real time.
Between them: no menus in WinPE, no per-type media, no decisions baked into text
files by whoever happened to be standing at the bay.
---
## 2. Why - what today actually costs
Today every decision is made in WinPE, written to files, and carried through a
chain where each link can fail silently:
```
startnet.cmd -> unattend (4 passes) -> FirstLogonCommands (18 orders)
-> ppkg -> DSC -> GE-Enforce
```
On 2026-08-06 alone, that chain produced:
| Failure | Root cause | How long it hid |
|---|---|---|
| Every build stopped at a dialog | one unattend `Path` at 676 chars against a 259 limit | ~1 day, and it masked the next two |
| Bays staged nothing, silently | a volume scan raced PESetup's `diskpart` and staged into a partition being erased | weeks (4 Display bays sat "green") |
| Deployment destroyed 4s after autologon | an at-logon task ran `-ManualFallback`, which is `sysprep /oobe /reboot` | until traced through Panther |
| Machines named `E<serial>` not `F<serial>` | our script re-applied a rename over the package's own | unknown |
| Six copies reported success having moved 0 bytes | unconditional `echo` after `robocopy` | weeks |
The pattern is identical in every case: **a decision made early, carried far, and
verified nowhere.** Moving decisions to a running, networked machine with an API
and a UI makes them recoverable - re-runnable, correctable, and observable.
---
## 3. What we already have to build on
This is not a greenfield project. Most of the mechanism exists.
### 3.1 ShopDB-Flask
- Assets keyed by serial; CMM bays, measuring tools, machine numbers already modelled
- Collector API: `POST /shopdb/api/collector/computers` (X-API-Key), ingests inventory by serial
- GE-Enforce plugin: scopes, manifests, **content-addressed payload store**
(`instance/geenforce/payloads/<sha256>`), `GET /api/geenforce/payload/<sha256>`
- Display scope (`plugins/geenforce/seed_display_scope.py`) already proves the pattern:
read a local subtype, prefer a **server-side role resolved by device IP**, pull
payloads over HTTPS, write the kiosk shortcut, sweep stale ones, self-heal each cycle
- Per-device role API: `GET /api/dashboarddefaults/display-role`
### 3.2 PXE server
- Per-bay state: `/var/log/pxe-imaging/<serial>.json` with stage history
- `winpe-status-push.ps1` already POSTs stage updates from WinPE
- Data-driven menus already: `menu.json` on the share, rendered by
`select-shopfloor-type.ps1`, edited by the webapp
- Imaging dashboard with per-bay tiles
- As of today: staging verification, imaging-log harvest to
`enrollment/imaging-logs/<serial>/`, `preflight.ps1` at the bay,
`preflight.py` + `lint-unattend.py` + `lint-driver-catalogue.py` +
`share-drift.py` on the server
### 3.3 Hardware
- Second NIC already present and unused: `enp0s31f6` (onboard, `e1000e`, currently down)
- PXE LAN currently on a USB adapter, `enx34c8d6b11010`
- That adapter was silently capped at USB 2.0 (327 Mb/s measured); moved to a
USB 3 port on 2026-08-06 and now runs at line-rate gigabit (**937 Mb/s measured**)
---
## 4. Target architecture
### 4.1 Three (or one) pre-imaged types
Proposed: **Displays, Inspection, Shopfloor** as pre-imaged stock, with the
subtype chosen later by the user at first boot.
**Open question worth settling early:** if the base image is identical and all
configuration comes from the API, what actually differs between the three?
Drivers are selected by PESetup per *model*, not per type. Unless disk layout or
a must-be-offline application differs, **one image** is better - three media trees
mean three things to keep in sync, and drift is what caused the day-long outage.
### 4.2 First-boot selection
A friendly PS1 runs when no PC type is recorded yet:
```
Inspection -> CMM | Genspect | Wax Trace | Keyence
└─ CMM / Keyence / WaxTrace -> which measuring tool?
(list served from ShopDB assets, not a CSV)
Displays -> Lobby | Dashboard | 3D Print Kiosk
Shopfloor -> Collections | No Collections | Common | Heattreat | Part Marker | ...
```
The tool selection is the important half: it drives PC-DMIS version, FormTracePak
version, DODA flag, NTLARS/eDNC registry, UDC settings, machine number.
**The network constraint that shapes this.** ShopDB is only reachable once the
bay has joined the **AESFMA wifi SSID** - not on the imaging LAN, and not
necessarily at first boot. So a first-boot selection UI cannot assume the API is
there. It must either work offline and sync later, or wait. Confirmed on
579C144: the bay held `172.16.9.81` and `172.24.19.142`, neither in the
production ranges, and `sync_intune` sat retrying every 30 seconds.
**The pattern that solves it, already built and worth reusing.**
`Install-ShopdbKiosk-WhenOnline.ps1` is a SYSTEM task armed at imaging that does
nothing until ShopDB answers, then acts once, verifies, and unregisters itself.
Any step in the new architecture that needs the API - selection sync, config
pull, enrollment completion - can use the same shape rather than assuming
connectivity.
**Design points:**
- **Server is the source of truth.** The selection POSTs to ShopDB keyed on
serial; the local file is a cache. A mis-selection is then fixable from a
browser, not by walking to the bay.
- **Re-runnable.** Available from the Start menu, shows what was chosen and what
was installed. People pick wrong; that must not mean re-imaging.
- **Audited.** ShopDB records who chose what and when - that is what separates
"misconfigured" from "chose wrong" six months later.
- **Do not require the LAPS password.** Handing a shop-floor user the local admin
password to run a selection dialog is a real control change, and the app runs
elevated. Prefer a SYSTEM-run UI on the logon desktop, or a kiosk shell.
### 4.3 Per-machine configuration records
Today a machine's identity is scattered:
| What | Where today | Size |
|---|---|---|
| eDNC / NTLARS registry | `_ntlars-backups/` - 147 per-bay `.reg` files | ~1.5 MB total |
| WaxTrace bay config | `bay-config.csv` - 15 bays, 7 FTPak versions, per-bay user ID | small |
| CMM bay config | `cmm-bay-config.csv` -> version.txt, doda.txt | small |
| UDC settings | `pre-install/udc-backups/`, per-bay | ~240 MB per bay |
| Machine number | `machine-number.txt` written by startnet | trivial |
Every bay gets **all** of it staged, then picks its own.
Proposed: one record per machine in ShopDB.
```
ShopDB asset (key: serial and/or machine number)
├─ pctype + subtype -> selects the GE-Enforce scope
├─ tool association -> CMM bay, Keyence model, FTPak version, DODA
├─ machine number -> UDC / eDNC naming
└─ payload refs (sha256) -> NTLARS .reg, UDC backup, per-bay settings
```
**Reference payloads, do not embed them.** NTLARS `.reg` files carry binary
values and UDC backups are hundreds of MB. GE-Enforce already has a
content-addressed blob store with an HTTP fetch endpoint - the record names the
payload, the store holds it.
This also removes a live bug class. `Restore-UDCData.ps1` and
`Update-MachineNumber.ps1` each mount SMB backup shares on their own drive
letters, which collided with GE-Enforce's `W:` and killed the manifest entry
running next (fixed in `66c24b5` by moving them to `R:` and `N:`). Payloads over
HTTPS remove drive-letter juggling entirely.
Migration is a one-time import of two CSVs and 147 `.reg` files. A script, not a
project.
---
## 5. Zero-touch: ShopDB-triggered imaging
### 5.1 Flow
```
1. Zeroed PC, boot order = IPv4 only. PXE boots into WinPE.
2. WinPE reports serial + MAC + model, then WAITS.
3. ShopDB shows it under "awaiting assignment".
4. Operator opens the target machine -> "Create this PC" -> picks the waiting box.
5. WinPE polls, receives a job:
{ pctype, subtype, tool/bay, tenant, purpose, target asset id }
and images with NO menus.
6. Progress streams to that machine's record in real time.
7. On completion the PC binds to the asset record.
```
Every menu in `startnet.cmd` becomes a field in the job. If no job exists, it
falls back to today's interactive menu and displays its own serial so one can be
created.
### 5.2 Safety - this is a remote-triggered disk wipe
Clicking a web button destroys a machine, and the target is chosen from a list.
The nightmare is picking the wrong row.
**Mitigation:** the waiting PC displays a short code on screen; the job cannot be
claimed unless that code matches what the operator confirms in ShopDB. Cheap, and
it makes wrong-machine wipes structurally hard rather than merely unlikely.
Additionally: a job may only target a machine currently in the waiting state, and
the wipe must be logged against both the operator and the asset.
### 5.3 Real-time record updates
Each phase already produces data; it simply is not joined up.
| Phase | Existing source | Lands on the record as |
|---|---|---|
| PXE boot | dnsmasq lease + WinPE check-in | serial, MAC, model, "imaging started" |
| WIM apply | `winpe-status-push.ps1` | live stage / progress |
| Staging | `winpe-staging.log` + harvested bundle | what payload actually landed |
| First boot | collector API | CPU, RAM, disk, OS build |
| Enrollment | `criticalChecks.json`, `TokenMatch.json` | Entra join state, tenant, purpose |
| Steady state | GE-Enforce report | installed apps, drift, self-heal events |
End state is one timeline per machine: *imaged 14:32 -> staged
gea-shopfloor-cmm, bay CMM4 -> enrolled GCCH_SH -> PC-DMIS 2019 R2 installed ->
enforcing clean since*.
**Two rules so it does not rot:**
1. **Push, best-effort.** If ShopDB is unreachable the bay must still image.
Queue events on the PXE server and drain them; the harvested logs are the
durable local copy.
2. **One writer per field.** Imaging status from the PXE server, inventory from
the collector, app state from GE-Enforce. Two writers on one field is the
exact bug that ate 2026-08-06.
---
## 6. Network design
### 6.1 Dual-homed PXE server
- **PXE side** - isolated `172.16.9.0/24`, dnsmasq DHCP bound to that adapter
- **GE side** - corporate, reachable from anywhere; carries ShopDB API traffic,
the job queue, status pushes, and (if wanted) the BPRT token fetch
The onboard `enp0s31f6` is free for this today.
### 6.2 Security prerequisites - these gate the whole thing
Checked 2026-08-06:
**Already safe.** dnsmasq has `listen-address=172.16.9.1` + `bind-dynamic`, so it
binds only to the interface holding that address. DHCP will **not** leak onto a
GE-side NIC. This was the failure I most expected and it is already handled.
**Not safe yet:**
```
smb.conf no `interfaces =` / `bind interfaces only` -> Samba listens on ALL interfaces
ufw every rule is ALLOW IN Anywhere, not interface-scoped:
22/tcp SSH (account is pxe/pxe)
445/tcp shares (pxe-upload, password in scripts)
69/udp TFTP
9009,9011 webapp
```
The moment a GE-side interface comes up, all of that is corporate-reachable.
**Required before dual-homing:**
1. Scope every UFW rule to the PXE interface (`ufw allow in on <pxe-if> ...`)
2. `interfaces = <pxe-if>` + `bind interfaces only = yes` in `smb.conf`
3. Expose deliberately on the GE side only what must be - realistically the
webapp, behind real authentication
4. Reconsider SSH credentials if the host becomes corporate-reachable
*(Correction to an earlier note: the shares are **not** guest-accessible -
`guest ok = no` on every share, with `map to guest = bad user`. They require the
`pxe-upload` account. That account's password is in scripts, so it is not a
control once 445 is corporate-reachable.)*
### 6.3 What dual-homing unlocks
- Job queue sync and live status without the share as intermediary
- BPRT token fetch (`mcl.dwcdn.geaerospace.com`) reachable at imaging time
- Possibly the Entra join during imaging rather than after re-cabling - see §8.2
---
## 7. The PESetup question
### 7.1 Autostart does not apply to our media
`AutoStartCountDown = 31` exists in the decompiled settings, and
`disableauto.json` - whose *presence* disables autostart - is absent from the
media. On the surface that suggests a 31-second countdown, and the observed log
is consistent with an operator simply clicking before it fired:
```
11:30:07.74 'Selection View' started (LTSC warning displayed)
11:30:18.63 Btn 'Next' pressed.
```
**However:** per the team's operational experience, **LTSC builds cannot
autostart**. The SELECT OS step warns when the media holds an LTSC image
("99% of the time only for ShopFloor. Are you sure you want 'LTSC'?") and that
confirmation is mandatory regardless of the countdown. Our media is
`Windows 11 LTSC 24H2` (`OperatingSystem.json`, build 26100, id 20), so every
image we produce hits it.
Consequence: **the countdown is not a route to zero-touch for us.** The 40-second
hands-off test is still worth doing once to confirm the behaviour first-hand and
record it - but plan on the answer being "it waits".
That promotes §7.2 from a contingency to the actual decision.
### 7.2 Re-implementation - now the likely path, and tractable
Nothing in the mechanism is proprietary - only the data, which we already own and
already parse.
| PESetup step | Equivalent |
|---|---|
| `PrepareDisk` | `diskpart /s diskpartEFI.txt` |
| `PrepareUnattend` | token substitution (`%serialnumber%`, `*arch*`) + `Packages.xml` merge |
| `CopyTools` / `CopyPackages` / `CopyHWPackages` | robocopy |
| `CopyDrivers` | select by model from `HardwareDriver.json`, unzip |
| `ApplyImage` | `DISM /Apply-Image` per `OperatingSystem.json` |
| `ApplyPackages` / `ApplyLanguagePacks` | `DISM /Add-Package` |
| `ApplyUnattend` | `DISM /Apply-Unattend` |
| `CreateBCD` / `CopyWinRE` | `bcdboot`, `reagentc` |
A few hundred lines of PowerShell. We would also get to fix the traps documented
in `docs/PESETUP-INTERNALS.md`: substring first-match-wins driver matching, a
family filter that knows only Latitude/OptiPlex/Precision, and a driver miss
being a **warning** rather than a failure.
**The cost is ownership, not effort.** `Release.json` points at MCL auto-update
URLs, so GE expects this media to self-update. Diverging means tracking their
format changes indefinitely, plus a likely compliance conversation about the
mandated imaging tool. Since the LTSC gate means autostart will not save us
(§7.1), this is a decision to take deliberately rather than a fallback.
### 7.3 What we would keep either way
Image-time preflight stays valuable regardless: secure boot (PESetup hard-fails
without it), >=120 GB disk (`MinRequiredSpaceWithoutCompression`), driver match
for the model, media age (30-day expiry). Already built as
`playbook/scripts/preflight.ps1`.
---
## 8. Enrollment
### 8.1 How it works today
- Six MCL packages on the media: `PERS` / `SH` / `LOAN` x `GCCH` / `RoW`, plus
the SFLD package for shopfloor
- `Start-BulkEnrollOrchestrator.ps1` asks tenant + purpose via
`Select-PCConfig.ps1` (a GUI), then applies the matching package
- `Insert-BPRTToPPKG.ps1` fetches an **encrypted token table** from
`mcl.dwcdn.geaerospace.com`, decrypts with a static passphrase
(PBKDF2 310k / SHA-256 / AES-CBC), matches on **Tenant AND Purpose**, injects
the token and stamps the real expiry into the filename
(`Exp_XXXXXXXX` -> `Exp_yyyyMMdd`)
- The package itself sets `<DNSComputerName>F%SERIAL%</DNSComputerName>` and
performs the Entra join
- A human then assigns the device category in Intune
As of 2026-08-06 the tenant/purpose choice can be pre-seeded at the PXE menu
(`C:\Enrollment\pc-config.txt`, consumed by a shim at the path the orchestrator
already calls). Injection deliberately stays at first logon - it needs internet.
### 8.2 The constraint that shapes pre-imaged stock
The BPRT token is stamped into the package with an expiry. A PC imaged, powered
off, and left in a cupboard for three months has a **dead token** and will never
join - and the failure looks identical to "not on the production network yet",
because `Entra ID Joined: false` right after imaging is normal.
Three options, pick deliberately:
1. **Join at imaging time** - requires the production network at the bench
(which dual-homing could provide), and pre-imaged stock is then already joined
2. **Join at first power-on** - cleaner logistically, but stock has a shelf life
bounded by token expiry
3. **Re-inject on demand** - first boot detects a dead token and fetches a fresh
one; needs the machine to reach the token blob
Whichever is chosen, the *silent* failure must be closed: a check that
distinguishes "token expired" from "no network yet".
---
## 9. Ownership model
Four times on 2026-08-06 two systems owned one thing and the last writer won
silently. A clean slate is the moment to fix that. See `docs/OWNERSHIP.md`.
| Concern | Owner | Not |
|---|---|---|
| Computer name | the provisioning package (`F%SERIAL%`) | any script in this repo |
| Drive letters during imaging | PESetup (`W:`, hardcoded 9 places) | anything running `diskpart` concurrently |
| Entra enrollment | the package + orchestrator | `run-enrollment.ps1` |
| Kiosk / display targets | GE-Enforce scope | `site-config.json` (backstop only) |
| Imaging status | PXE server | the collector |
| Inventory | collector API | the PXE server |
| Installed app state | GE-Enforce reports | anything else |
| Job queue | **ShopDB** (owns assets) | the PXE webapp (executor only) |
---
## 9a. Design rules learned the hard way (2026-08-06)
A day of fixing the current pipeline produced five rules. They are cheap to
honour in a new design and expensive to retrofit.
### Absent and empty are different
Four separate scripts tested config as
`$null -ne $cfg -and $cfg.Count -gt 0`, so an explicitly empty list fell through
to a hardcoded default. A Display kiosk configured with
```json
"desktopApps": [], "startupItems": [], "taskbarPins": []
```
was given UDC, eDNC, NTLARS, WJ Shopfloor, Defect Tracker and Plant Apps -
**configuring "none" produced "everything"**. Plant Apps launched
`msedge --new-window` on a screen with no keyboard.
Measured, not assumed: an empty JSON array round-trips as `Object[]` with
`Count = 0`, and an absent key as `$null`. They are distinguishable; the code
simply conflated them.
*Rule: in the API model, "no items" and "not configured" must be different
values on the wire, and the default for an unrecognised or missing scope is
NOTHING, not a fallback set.*
### Deny by default, always
`preinstall.json` had 16 of 21 entries at `PCTypes: ["*"]`, so a kiosk installed
Adobe, OpenText, Defect Tracker, the serial drivers and twelve legacy VC++
redistributables. The SMB manifest had 18 of 25 entries unfiltered. In both
cases the filter worked perfectly - it simply was not applied.
*Rule: every payload declares which PC types it is for. An undeclared payload
installs nowhere and fails validation, rather than installing everywhere.*
### Gate at the point of action, not the call site
`Run-ShopfloorSetup.ps1` gated both the S: mapper and the machine-number prompt
on PC type. Both registered anyway on a Display, with no "Skipping" line in the
log - something in the finalization phase reaches those registrars past the
call-site gate. The fix was to gate inside each registrar.
*Rule: authority checks belong with the thing being done. A caller-side check is
a convenience, never the control.*
### The image itself carries configuration
`WJ Shopfloor.lnk`, dated April, sits in the all-users Startup folder of every
bay - **inside the WIM**. No script change removes it, and no amount of thinning
the pipeline touches it.
*Rule: "thin image" must include auditing what is baked into the WIM, not just
what the pipeline adds afterwards.*
### The provisioning package is a hard boundary
These are installed by the ppkg, not by anything PXE controls:
```
Google Chrome (+2 updater tasks) RealVNC Tanium
CyberArk EPM .NET 3.5 Report IP Sysinternals Autologon DSC
PowerShell 7 x64 AND x86
```
*Rule: thinning the image does not thin the package. Anything that must not be
on a kiosk and comes from the ppkg needs a package change or an explicit
post-install removal - it cannot be solved by scoping our own payloads.*
---
## 10. Phased delivery
Each phase is useful standing alone. No phase requires the next.
**Phase 0 - settle the unknowns (hours)**
- Confirm the LTSC autostart gate first-hand (one bay, hands off, 40 seconds)
and record it - expected to wait, per operational experience
- Decide: re-implement the imaging step, or accept one click per build
- Decide: one image or three
- Decide: join at imaging time, at first power-on, or re-inject
**Phase 1 - visibility (small)**
- WinPE reports serial / MAC / model on boot; "awaiting assignment" list in the webapp
- Relay imaging status to ShopDB over the GE side
- *Useful immediately: you can see what is booting and what it did.*
**Phase 2 - dual-home safely (small, gating)**
- Interface-scope UFW, bind Samba, bring up `enp0s31f6`
- Nothing else proceeds safely until this is done
**Phase 3 - job queue**
- Job model + API in ShopDB (create / claim / complete), with the on-screen
confirmation code
- WinPE polls once where the menu is today; falls back to the menu if no job
**Phase 4 - first-boot selection**
- Selection UI, server-backed lists, POST the choice, re-runnable
- Retire the WinPE sub-menus for subtype and tool association
**Phase 5 - per-machine records**
- Import the CSVs and `.reg` files into ShopDB with payload refs
- Retire `bay-config.csv`, `cmm-bay-config.csv`, and staging all 147 `.reg` files
to every bay
**Phase 6 - thin the image**
- Remove per-type media (`_media/<pctype>`), the staging block, the Office ppkg
variants, the CMM bay picker, the WaxTrace ISO cherry-pick
- WinPE's job becomes: partition, apply, join, install enforce client, reboot
---
## 11. Risks
| Risk | Why it matters | Mitigation |
|---|---|---|
| Remote-triggered wipe hits the wrong machine | destructive, irreversible | on-screen confirmation code; job may only target a waiting machine; log against operator and asset |
| ShopDB becomes a single point of failure | today a bay configures from the share with ShopDB down | GE-Enforce ETag / last-known-good cache; explicit failure UI, never a silent stall |
| Dual-homing exposes the PXE server | SSH `pxe/pxe`, SMB, TFTP currently `ALLOW IN Anywhere` | Phase 2 gates everything |
| BPRT expiry on shelf stock | silent, indistinguishable from "no network yet" | pick a join strategy (§8.2) and add an explicit expiry check |
| Selection UI becomes the new single point of correctness | wrong subtype = wrong PC-DMIS, wrong bay config | re-runnable, server-corrected, audited |
| Re-implementing PESetup | ongoing MCL format tracking + a likely compliance conversation | scope it against "one click per build" - zero-touch is the only thing that needs it |
| Big payloads over the corporate network | PC-DMIS, FormTracePak ISOs (~2 GB each), Keyence | GE-Enforce supports `smb` / `http` / `inline` per entry - decide per payload |
| Three images drift apart | drift caused the 2026-08-06 outage | prefer one image; `share-drift.py` guards what remains |
| API unreachable at first boot | ShopDB needs AESFMA; the imaging LAN cannot reach it | wait-for-network task pattern (§4.2); never assume connectivity |
| ppkg-delivered apps on a kiosk | Chrome, RealVNC, Tanium etc. are outside PXE control | package change or explicit post-install removal - scoping our payloads cannot fix it |
---
## 12. Open questions
1. One image or three? What genuinely differs between them?
2. Join at imaging time, at first power-on, or re-inject on demand?
3. Is the LTSC confirmation truly unskippable? (expected yes - it decides
whether zero-touch requires re-implementing the imaging step)
4. Is replacing the GE-mandated imaging tool acceptable, if it comes to that?
5. Does the selection UI run as SYSTEM (no LAPS password), or as an admin user?
6. Which payloads stay on SMB and which move to HTTPS?
7. Who operates the job queue day to day - is "Create this PC" a technician
action, or does it need approval?
---
## 13. Where everything referenced here lives
### 13.1 This repo (`/home/camp/projects/pxe`)
| Path | What |
|---|---|
| `docs/PESETUP-INTERNALS.md` | decompiled imaging tool behaviour; the source for every PESetup claim above |
| `docs/OWNERSHIP.md` | who owns what, with the 2026-08-06 collisions |
| `playbook/startnet.cmd` | the WinPE script this proposal thins out - menus, staging, verify, log harvest |
| `playbook/scripts/preflight.ps1` | bay-side checks (secure boot, disk, driver match, media age) |
| `playbook/shopfloor-setup/run-enrollment.ps1` | applies the SFLD package via `provtool` |
| `playbook/shopfloor-setup/Run-ShopfloorSetup.ps1` | post-boot setup; the enrollment-model comment is here |
| `playbook/shopfloor-setup/Fetch-StagingPayload.ps1`, `Verify-And-Heal-Staging.ps1` | first-logon self-heal; read `C:\Enrollment\fetch-source.txt` |
| `playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1` | the pre-seed shim (vendor GUI preserved as `-vendor.ps1` on the share) |
| `playbook/shopfloor-setup/BIOS/{check-bios.cmd,models.txt}` | firmware matching |
| `scripts/preflight.py` | runs every server-side lint in one command |
| `scripts/lint-unattend.py` | schema limits: Path 259, CommandLine 1024, Description 256 |
| `scripts/lint-driver-catalogue.py` | reimplements `GetDriverByModel` |
| `scripts/share-drift.py` | repo vs live share, git-owned vs unreconciled |
| `playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1` | one-pass bay diagnostics - identity, enrollment, installed apps, all four autostart surfaces, GE-Enforce reachability |
| `scripts/build-pctype-media.py` | per-PCTYPE media views (retired by Phase 6) |
### 13.2 PXE server (`pxe@172.16.9.1`)
| Path | What |
|---|---|
| `/srv/samba/winpeapps/<image>/Deploy/Control/` | `HardwareDriver.json`, `OperatingSystem.json`, `Media.tag`, `Release.json` |
| `/srv/samba/winpeapps/<image>/Deploy/FlatUnattendW10.xml` | the answer file that actually boots machines |
| `/srv/samba/winpeapps/_shared/Applications/BPRT/` | the six MCL packages, `Insert-BPRTToPPKG.ps1`, `Start-BulkEnrollOrchestrator.ps1`, `Select-PCConfig*.ps1` |
| `/srv/samba/winpeapps/_shared/BIOS/` | firmware EXEs + `models.txt` (served as the `winpeapps_bios` share) |
| `/srv/samba/winpeapps/_media/<pctype>/` | per-PCTYPE media views |
| `/srv/samba/enrollment/scripts/` | `run-enrollment.ps1`, `preflight.ps1`, `winpe-status-push.ps1` |
| `/srv/samba/enrollment/shopfloor-setup/` | per-PC-type trees, `menu.json`, `_ntlars-backups/` (147 `.reg`) |
| `/srv/samba/enrollment/shopfloor-setup/gea-shopfloor-waxtrace/bay-config.csv` | 15 bays, FTPak versions |
| `/srv/samba/enrollment/installers-post/cmm/cmm-bay-config.csv` | CMM bay -> version, DODA |
| `/srv/samba/enrollment/pre-install/udc-backups/` | per-bay UDC settings |
| `/srv/samba/enrollment/ppkgs/` | the SFLD package (8.2 GB) |
| `/srv/samba/enrollment/imaging-logs/<serial>/` | harvested imaging logs (added 2026-08-06) |
| `/var/www/html/win11/sources/boot.wim` | the booted WinPE image |
| `/var/log/pxe-imaging/<serial>.json` | per-bay stage history |
| `/opt/pxe-webapp/` | Flask webapp (unit `pxe-webapp`, :9009) |
| `/etc/dnsmasq.conf`, `/etc/samba/smb.conf` | the binding config §6.2 depends on |
### 13.3 shopdb-flask (`/home/camp/projects/shopdb-flask`)
| Path | What |
|---|---|
| `plugins/geenforce/seed_display_scope.py` | the pattern this proposal generalises - subtype map, server role by IP, sweep |
| `plugins/geenforce/client/` | `ShopdbEnforceClient.psm1`, `Invoke-ShopdbEnforce.ps1` |
| `shopdb/core/api/` | collector endpoint, dashboard-defaults display-role |
### 13.4 Evidence behind the claims in this document
| Path | What it shows |
|---|---|
| `/home/camp/pxe-images/now/pesetuplog.txt` | PESetup 4.0.0.20 run - media drive, disking, copy, driver selection, the `Btn 'Next'` timing in §7.1 |
| `/home/camp/pxe-images/now/debug.txt` | the WinPE console for the failed volume-finder run |
| `/home/camp/pxe-images/pxe2/Panther/` | `setupact.log` / `setuperr.log` naming the unattend `Path` failure |
| `/home/camp/pxe-images/debug2/` | the autologon registry state and the sysprep-loop timeline |
| `/home/camp/pxe-images/debug3/Logs/BPRT/` | the 13 package components, `criticalChecks.json`, `packageInfo.json` |
| `/home/camp/pxe-images/debug3/Logs/PPKG/` | the package itself, `provisioning-sessions.json`, the diagnostics evtx |
| `/home/camp/pxe-images/debug3/Logs/enrollment.log` | the double run and the rename race in §9 |
| `/home/camp/pxe-images/debug3/Logs/FilteredReportIP.log` | proves the bay had no route to Entra (§8.2) |
### 13.5 On-bay locations (for anyone diagnosing one)
```
C:\Enrollment\ staged payload, pc-type.txt, display-type.txt,
pc-config.txt, fetch-source.txt, winpe-staging.log
C:\Deploy\Applications\BPRT\ the packages + orchestrator, copied by PESetup
C:\Logs\BPRT\<Component>\ per-component package logs (UTF-16)
C:\Logs\BPRT\criticalChecks.json PPKG matched / Entra joined / OOBE execution
C:\Logs\PPKG\ provisioning diagnostics + session state
C:\Logs\SFLD\ DSC deployment, sync_intune, shopfloor-setup
C:\Logs\enrollment.log run-enrollment.ps1
C:\Windows\Panther\ setupact.log, setuperr.log, unattend.xml, PESetup*.log
C:\Windows\Panther\UnattendGC\ oobeSystem pass
```
### 13.5a Gotchas any new tooling will hit
- **`HardwareDriver.json` cannot be read by `ConvertFrom-Json`.** It carries both
casings of the same fields (`fileName`/`FileName`, `destinationDir`/
`DestinationDir`) and PowerShell rejects the document - on 5.1 *and* 7.
`-AsHashtable` is PS6+ and WinPE runs 5.1. Extract fields by regex, or parse in
Python where duplicate keys are tolerated.
- **`Compress-Archive` caps at 2 GB**, and `run-enrollment` harvests the 8 GB
provisioning package into `C:\Logs\PPKG`, so any log collection must exclude
`*.ppkg`.
- **Backslash-vs-forward-slash**: PowerShell Core normalises `\` to `/` on Linux,
so `Join-Path` with Windows separators works cross-platform - handy for testing
bay scripts on the dev box.
### 13.6 How to re-derive things
```bash
# read a file off the share
sshpass -p pxe ssh -o StrictHostKeyChecking=no pxe@172.16.9.1 'cat <path>'
smbclient //172.16.9.1/enrollment -U pxe-upload%pxe -c 'ls'
# what is actually inside the booted WinPE
sudo wimextract /var/www/html/win11/sources/boot.wim 1 \
/Windows/System32/startnet.cmd --dest-dir=/tmp/x
sudo wimdir /var/www/html/win11/sources/boot.wim 1 | grep -i <name>
# a .ppkg is a WIM - pull out the spec without extracting 8 GB
7z l <pkg>.ppkg
7z e <pkg>.ppkg -o<dir> "Multivariant/0/customizations.xml" \
"Multivariant/0/Prov/RunTime/0__Accounts_Azure.provxml"
# provisioning diagnostics (python-evtx is installed)
python3 -c "from Evtx.Evtx import Evtx;
[print(r.xml()) for r in Evtx('Provisioning-Diagnostics-Admin.evtx').records()]"
# decompile PESetup - the bundle-extraction recipe is in PESETUP-INTERNALS.md
ilspycmd -t <TypeName> PESetup.dll
# validate before deploying
./scripts/preflight.py # every server-side lint
./scripts/share-drift.py --diff # repo vs live
```
### 13.7 The 2026-08-06 commits this document draws on
```
dfa026b lint-driver-catalogue c7b1769 build-pctype-media
049dd7b startnet per-PCTYPE media d5f7abc BIOS 7020 family
385acfe lint-unattend 2b6189f startnet W: wait + honest reporting
d04865d PESetup version + LogonCount b96ff7e retire shopfloor-dashboard route
36be60e run-enrollment stops renaming 8c21282 staging verify + log harvest
d2200e8 preflight (bay + server) 68df59e OWNERSHIP.md + share-drift
9d51c0b purpose menu + 3D kiosk a0aceb4 PXESERVER consolidation
bb08392 download-drivers case fix
```

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,296 @@
# MTConnect follow-up runbook
Written 2026-08-11. Everything here needs a machine this box cannot reach - the
SFLD share, the middle-man PC, or a shopfloor bay - so it is yours to run.
Work in the order given. Part 1 changes what future installs get; parts 2 and 3
fix bays that are already wrong; parts 4 and 5 are questions to answer before
any more code is written.
### Identify every bay by machine number
The machine number is the only identifier that stays put. A PC gets replaced or
re-imaged and its name changes; the machine it sits at does not. Both machine-to-PC
sources used to build this document are point-in-time snapshots and will already
have drifted.
Every PC name below came from `mtconnect-hostname-machine-map.csv` (12 June),
the `Shop Floor PC Name` column of `West Jefferson_Machines.csv`, or the variant
captures. Treat them as "this is where the fault was last seen" - useful for
finding the bay, never for deciding what to write into it.
**The bay knows its own number.** In order of authority:
```powershell
# 1. eDNC registry - what GE-Enforce trusts post-imaging
(Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General' -Name MachineNo).MachineNo
# 2. the enrollment file imaging writes
Get-Content C:\Enrollment\machine-number.txt
# 3. UDC
(Get-Content C:\ProgramData\UDC\udc_settings.json -Raw | ConvertFrom-Json).GeneralSettings.MachineNumber
```
Or dot-source the lib and ask for both at once:
```powershell
. C:\GE Aerospace\lib\Update-MachineNumber.ps1
Get-CurrentMachineNumber # @{ Udc = ...; Ednc = ... }
```
**ShopDB is the live PC-to-machine record**, maintained by the collector rather
than by hand, so it beats any CSV:
```
https://tsgwp00525.wjs.geaerospace.net/shopdb/api/computers/by-hostname/<HOSTNAME>
```
If those disagree with each other, or with the machine plate, stop and resolve
that first - a rename applied to the wrong number is worse than the mismatch
you started with.
---
## Part 1. Push the Fanuc adapter 3.25.3 to the SFLD share
The staged bundle here is already on 3.25.3.0 (`sha256` starts `6e46dcd51affa9e0`).
The live share is not. Until this is copied, new installs still get 3.23.0.0.
**1.1** From the middle-man PC, with `Z:` on the dev share:
```
Z:\mtconnect-bundles\fanuc\Adapter\
```
should contain `FanucCppAdapterWinX64.exe` at **3,369,984 bytes**. Confirm before copying:
```powershell
(Get-Item Z:\mtconnect-bundles\fanuc\Adapter\FanucCppAdapterWinX64.exe).Length
(Get-FileHash Z:\mtconnect-bundles\fanuc\Adapter\FanucCppAdapterWinX64.exe -Algorithm SHA256).Hash.Substring(0,16)
# expect 3369984 and 6E46DCD51AFFA9E0
```
**1.2** Back up what is on the share now, then copy the three files:
```powershell
$share = '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\main\machineapps\mtconnect-bundles\fanuc\Adapter'
Copy-Item $share\FanucCppAdapterWinX64.exe "$share\..\FanucCppAdapterWinX64-3.23.0-replaced-$(Get-Date -f yyyyMMdd).exe"
Copy-Item Z:\mtconnect-bundles\fanuc\Adapter\FanucCppAdapterWinX64.exe $share -Force
Copy-Item Z:\mtconnect-bundles\fanuc\Adapter\Fwlib64.dll $share -Force
Copy-Item Z:\mtconnect-bundles\fanuc\Adapter\fwlibe64.dll $share -Force
```
**1.3** Verify on the share:
```powershell
(Get-FileHash $share\FanucCppAdapterWinX64.exe -Algorithm SHA256).Hash.Substring(0,16) # 6E46DCD51AFFA9E0
```
`adapter.xml` is deliberately NOT copied. The config vocabulary is identical
between 3.23 and 3.25.3, and the share's copy carries site settings.
**Rollback:** copy the `-3.23.0-replaced-` file back over `FanucCppAdapterWinX64.exe`.
---
## Part 2. Fix the two Makino bays
Both have the agent on the machine number and the other three files still on
`MAKINO-1`. The fixed `Update-MachineNumber.ps1` converges them.
| machine | PC when captured | confirm at the bay |
|---|---|---|
| **7502** | `G5W5V7V3ESF` | eDNC reg / enrollment file / plate |
| **7506** | `G4H8KF33ESF` | eDNC reg / enrollment file / plate |
Find the current PC for a machine number in ShopDB rather than trusting the
name above; if the PC was replaced, the new one may never have had the bad
rename applied, in which case check its four files before doing anything.
**2.1** On the bay, elevated PowerShell:
```powershell
. C:\GE Aerospace\lib\Update-MachineNumber.ps1 # or wherever the lib landed
$r = Update-MTConnectVariantName -NewNumber '7502' `
-Root 'C:\Makino-MTConnect' `
-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' }
) `
-Services @('Makino Adapter Manager','Makino MTConnect Agent')
$r.Updated; $r.Errors
```
Use the bay's own number - do not paste 7502 onto 7506.
**2.2** Verify all four agree, and nothing still says MAKINO:
```powershell
Select-String -Path C:\Makino-MTConnect\Agent\Devices.xml,`
C:\Makino-MTConnect\Agent\Devices.EDM.xml,`
C:\Makino-MTConnect\Data\AdapterDataFile.xml,`
C:\Makino-MTConnect\Agent\Agent.cfg `
-Pattern 'MAKINO-\d+|7502'
```
**2.3** Confirm it is serving:
```powershell
(Invoke-WebRequest http://localhost:5000/current -UseBasicParsing).Content -match '<MTConnectDevices'
```
**Watch for:** the vendor guide says the Adapter Manager rewrites its whole
configuration from memory when it stops. The function stops it first, but this
has never been proven on real hardware. If the names revert after a reboot,
stop here and tell me - that changes the approach.
---
## Part 3. Fix the seven Fanuc bays
The agent and the adapter disagree. Three have a correct agent and a stale
adapter; four have no agent device name at all and need looking at before any
rename (Part 4).
**Rename these three.** The "correct number" column is what the June map said -
it is a starting point, not authority. Confirm from the bay itself (see the
note at the top) before running anything:
| machine (per map) | adapter says | agent says | PC when captured |
|---|---|---|---|
| **7801** | 4007 | 7801 | `G9N2JNZ3ESF` |
| **7804** | 3031 | 7804 | `GG1J98Y3ESF` |
| **2005 or 2006** | 2005 | 2006 | `GC7ZN7V3ESF` |
`GC7ZN7V3ESF` is the one to be careful with: 2005 and 2006 are a Hwacheon
dual-spindle pair sharing a PC, so two machines legitimately answer to one
hostname and the map and the agent disagree about which. Read the machine plate
and the eDNC registry before running it.
**3.1** On the bay, elevated:
```powershell
. C:\GE Aerospace\lib\Update-MachineNumber.ps1
$r = Update-MTConnectVariantName -NewNumber '7801' `
-Root 'C:\MTConnect' `
-Targets @(
@{ Path='Agent\devices.xml'; Kind='attr' },
@{ Path='Adapter\devices.xml'; Kind='attr' },
@{ Path='Adapter\adapter.xml'; Kind='elem' }
) `
-Services @('MTConnect Adapter Fanuc','MTConnect Agent Fanuc')
$r.Updated; $r.Errors
```
**3.2** Verify the three files agree:
```powershell
Select-String -Path C:\MTConnect\Agent\devices.xml,C:\MTConnect\Adapter\devices.xml `
-Pattern 'name="[^"]+"' | Select-Object -First 2
Select-String -Path C:\MTConnect\Adapter\adapter.xml -Pattern '<Device(Name|ID|UUID)>[^<]+'
```
All should read the same number. Then the HTTP probe as in 2.3 (port 5000).
---
## Part 4. Four Fanuc bays with no agent device name
| machine (per map) | adapter says | PC when captured |
|---|---|---|
| **2013** | 4007 | `GJBJC724ESF` |
| **7606** | 3126 | `G7QLY5X3ESF` |
| **4002** | 4002 | `G6JLMSZ2ESF` |
| **2001** | 3115 | `GB07T5X3ESF` |
These are not a rename problem - the agent's `devices.xml` has no `<Device
name=...>` at all, so the agent has nothing to serve. Do NOT run Part 3 on them.
On each, collect and send back:
```powershell
Get-Service 'MTConnect*' | Select-Object Name,Status,StartType
Get-Content C:\MTConnect\Agent\devices.xml -TotalCount 20
Test-Path C:\MTConnect\Agent\devices.xml
(Invoke-WebRequest http://localhost:5000/current -UseBasicParsing -TimeoutSec 5).StatusCode
Get-Content C:\MTConnect\Agent\agent.log -Tail 30
```
Note three of the four also disagree with the hostname map on the number
(4007 vs 2013, 3126 vs 7606, 3115 vs 2001), so these bays may have been
re-purposed without a full reconfigure.
---
## Part 5. Two questions to settle
**5.1 DMG Mori - do they need a bundle at all?**
Five machines, all flagged `1,2,3` in the machine list, no bundle among the
five we ship, and no captured variant. Their controllers are `DMG Mori F31i
A5/B5` - Fanuc 31i underneath - so the Fanuc adapter may already serve them.
| machine | PC per the machine list |
|---|---|
| WJ-7401 | `G1KDN7X2ESF` |
| WJ-7402 | `G1KCR7X2ESF` |
| WJ-7403 | `G1HYK7X2ESF` |
| WJ-7404 | `G1HGQ7X2ESF` |
| WJ-7405 | `G1KLM7X2ESF` |
Those names come from the `Shop Floor PC Name` column of the OneDrive machine
list and are subject to the same staleness - look the machine up in ShopDB for
the PC that is actually on it today.
On any one of them:
```powershell
Get-Service | Where-Object { $_.Name -match 'MTConnect|Makino|Coordinator' } |
Select-Object Name,Status,StartType
Get-ChildItem C:\ -Directory | Where-Object Name -match 'MTConnect|DMG|Coordinator'
(Invoke-WebRequest http://localhost:5000/current -UseBasicParsing -TimeoutSec 5).StatusCode
```
If they run the Fanuc bundle already, no new variant is needed and the DMG
Coordinator MSI in the OneDrive zip can be ignored. If they collect some other
way, or not at all, that is the answer to whether a sixth bundle is worth
building.
**5.2 Does the live v2 manifest carry the MTConnect entries?**
You are on v2. The concern is that the MTConnect entries were left behind in
the dead v1 manifest, in which case v2 bays are not getting MTConnect from the
manifest engine at all.
```powershell
$m = Get-Content '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\main\machineapps\machineapps-manifest.json' -Raw | ConvertFrom-Json
$m.Applications.Count
$m.Applications | ForEach-Object { $_.Name }
```
Expect 8 applications, five of them MTConnect (fanuc, okuma, edncStandard,
edncDualSpindle, makino). Anything less and the missing ones never made it to
v2.
---
## A note on the CSVs
Neither machine-to-PC list will age well: `mtconnect-hostname-machine-map.csv`
(12 June) and the `Shop Floor PC Name` column in `West Jefferson_Machines.csv`
are both hand-built snapshots, and every PC swap silently invalidates a row.
ShopDB now maintains the PC-to-machine link from collector reports, so it
corrects itself as bays are re-imaged. Anything that needs to go from a machine
number to "which PC is on it today" should ask ShopDB; if a CSV is still wanted
for offline use, generate it from ShopDB rather than editing it by hand.
## What I still owe you
- Auto-detection of controller and variant for a new machine. The inputs exist
(`mtconnect-bay-config.csv` has controller IP and FOCAS port per machine, the
machine list has the controller model, the collector already knows the machine
number at imaging time) - design pending.
- The code fix is committed and pushed as `0d9c4fd` on `pxe-webapp-redesign`.
It changes future renames only; it does not reach out and fix these bays.

203
docs/pxe-nic-guard.md Normal file
View File

@@ -0,0 +1,203 @@
# 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

@@ -0,0 +1,414 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- 1. windowsPE is intentionally empty -->
<settings pass="windowsPE" />
<!-- 2. Offline servicing (drivers) -->
<settings pass="offlineServicing">
<component name="Microsoft-Windows-PnpCustomizationsNonWinPE"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<DriverPaths>
<PathAndCredentials wcm:action="add" wcm:keyValue="1">
<Path>W:\Drivers</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="2">
<Path>W:\Deploy\Applications\extra\printdrivers\BROTHER\UNIV-PS-01181\PS\64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="3">
<Path>W:\Deploy\Applications\extra\printdrivers\HP</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="4">
<Path>W:\Deploy\Applications\extra\printdrivers\XEROX\UNIV_5.1035.2.0_PS_x64_Driver</Path>
</PathAndCredentials>
</DriverPaths>
</component>
</settings>
<!-- 3. specialize: computer naming + RunSynchronous all in ONE component -->
<settings pass="specialize">
<!-- 3a. Shell-Setup for naming/owner/org -->
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<ComputerName>H%serialnumber%</ComputerName>
<RegisteredOrganization>GE Aerospace</RegisteredOrganization>
<RegisteredOwner>GE</RegisteredOwner>
<TimeZone>Eastern Standard Time</TimeZone>
</component>
<!-- 3b. RunSynchronous for all of your installers, copies, etc. -->
<component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<RunSynchronous>
<!-- INTERNETACCESS bootstrap profile. BLUESSO and EAP-PEAP were removed:
the MCL ppkg installs both, and BLUESSO cannot bootstrap (hidden
SSID, manual connect, user password). wlansvc must be started
first or netsh silently fails. -->
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<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>
</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">
<Order>3</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
<Description>Add generic WiFi profile</Description>
</RunSynchronousCommand>
<!-- Certificates -->
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_External_Root_CA_2_1.cer"</Path>
<Description>Install External Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>5</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer"</Path>
<Description>Install External Intermediate Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>6</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer"</Path>
<Description>Install Enterprise Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>7</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Device Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>8</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Server Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>9</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer"</Path>
<Description>Install SmartCard Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>10</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer"</Path>
<Description>Install User Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>11</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer"</Path>
<Description>Install Aerospace Enterprise Root CA</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>12</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt"</Path>
<Description>Install Zscaler Certificate</Description>
</RunSynchronousCommand>
<!-- Fonts -->
<RunSynchronousCommand wcm:action="add">
<Order>13</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\3OF9.TTF" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy 3OF9 Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>14</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "3 of 9 Barcode" /t REG_SZ /d "3OF9.TTF" /f</Path>
<Description>Register 3OF9 Barcode Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>15</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39AzaleaNarrow3.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>16</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39AzaleaNarrow3" /t REG_SZ /d "Code39AzaleaNarrow3.ttf" /f</Path>
<Description>Register Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>17</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39Azalea.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>18</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39Azalea" /t REG_SZ /d "Code39Azalea.ttf" /f</Path>
<Description>Register Code39 Azalea Font</Description>
</RunSynchronousCommand>
<!-- OpenText installers -->
<RunSynchronousCommand wcm:action="add">
<Order>19</Order>
<Path>cmd /c "C:\Deploy\Applications\extra\opentext\Setup-OpenText.cmd"</Path>
<Description>Install OpenText HostExplorer SP1 (shopfloor method)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>20</Order>
<Path>C:\Deploy\Applications\extra\opentext\J2SE_Runtime_Environment_1.6.0_22_Static_Config_V2_Co-Exist.EXE /silent /norestart</Path>
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
</RunSynchronousCommand>
<!-- Adobe -->
<RunSynchronousCommand wcm:action="add">
<Order>22</Order>
<Path>msiexec /i "C:\Deploy\Applications\extra\adobe\AcroRead.msi" TRANSFORMS="C:\Deploy\Applications\extra\adobe\AcroRead.mst" /quiet /norestart</Path>
<Description>Install Adobe</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>23</Order>
<Path>msiexec /p "C:\Deploy\Applications\extra\adobe\AcroRdrDCUpd2500120531.msp" /quiet /norestart</Path>
<Description>Apply Adobe Reader Update</Description>
</RunSynchronousCommand>
<!-- Java JRE 8 Enterprise Installation -->
<RunSynchronousCommand wcm:action="add">
<Order>26</Order>
<Path>C:\Deploy\Applications\extra\java\jre-8u441-windows-i586.exe /s INSTALLDIR="C:\Program Files (x86)\Java\jre1.8.0_441" STATIC=1 AUTO_UPDATE=0 REBOOT=0 SPONSORS=0 WEB_JAVA=0 /L "C:\Windows\Logs\java-install.log"</Path>
<Description>Install Java JRE 8 Update 441 with Enterprise Settings and Logging</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Updates via Registry (64-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>27</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (64-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Updates via Registry (32-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>28</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (32-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Update Check (64-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>29</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (64-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Update Check (32-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>30</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (32-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Cortana -->
<RunSynchronousCommand wcm:action="add">
<Order>31</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v AllowCortana /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana</Description>
</RunSynchronousCommand>
<!-- Disable Web Search in Start Menu -->
<RunSynchronousCommand wcm:action="add">
<Order>33</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v DisableWebSearch /t REG_DWORD /d 1 /f</Path>
<Description>Disable Web Search in Start Menu</Description>
</RunSynchronousCommand>
<!-- Disable Bing / web search suggestions in Start menu.
BingSearchEnabled is documented at HKCU\Software\Microsoft\Windows\CurrentVersion\Search
(not HKLM), so the old HKLM policy placement was a no-op. The current documented
suppression is DisableSearchBoxSuggestions=1 at Software\Policies\Microsoft\Windows\Explorer.
Writing it into the Default User hive so every newly-created account inherits it. -->
<RunSynchronousCommand wcm:action="add">
<Order>34</Order>
<Path>cmd /c reg load HKU\TempDU C:\Users\Default\NTUSER.DAT &amp; reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f &amp; reg unload HKU\TempDU</Path>
<Description>Disable Bing/web search suggestions (Default User hive)</Description>
</RunSynchronousCommand>
<!-- Disable Search Web when searching Windows -->
<RunSynchronousCommand wcm:action="add">
<Order>35</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v ConnectedSearchUseWeb /t REG_DWORD /d 0 /f</Path>
<Description>Disable Connected Search Use Web</Description>
</RunSynchronousCommand>
<!-- Disable Cortana Speech Recognition -->
<RunSynchronousCommand wcm:action="add">
<Order>36</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\InputPersonalization" /v AllowInputPersonalization /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana Speech Recognition</Description>
</RunSynchronousCommand>
<!-- Disable Search Highlights (news/trending) -->
<RunSynchronousCommand wcm:action="add">
<Order>38</Order>
<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>
</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">
<Order>39</Order>
<Path>powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Deploy\Applications\BPRT\Register-BulkEnrollTask-Standard.ps1"</Path>
<Description>Add bulk enrollment orchestrator to Run key</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>40</Order>
<Path>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</Path>
<Description>Load Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>41</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /t REG_DWORD /d 0 /f</Path>
<Description>Disable startup delay for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>42</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /t REG_DWORD /d 0 /f</Path>
<Description>Disable wait for idle state for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>43</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v StartShownOnUpgrade /t REG_DWORD /d 1 /f</Path>
<Description>Prevent Start menu from opening on first login</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>44</Order>
<Path>reg.exe unload HKU\TempDefault</Path>
<Description>Unload Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>45</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v NoAutoUpdate /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (NoAutoUpdate)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>46</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v AUOptions /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (AUOptions)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>47</Order>
<Path>reg.exe add "HKLM\Software\Microsoft\Command Processor" /v AutoRun /t REG_SZ /d "doskey vanilla=powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\Deploy\Applications\BPRT\Show-VanillaConfirmation.ps1" /f</Path>
<Description>Define vanilla CMD alias for manual OOBE fallback via Shift+F10</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>48</Order>
<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>
</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>
</component>
</settings>
<!-- 4. oobeSystem: hide OEM/EULA screens -->
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>false</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipUserOOBE>false</SkipUserOOBE>
<SkipMachineOOBE>false</SkipMachineOOBE>
</OOBE>
<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>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc config wuauserv start= disabled</CommandLine>
<Description>disable windows update</Description>
<Order>4</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc stop wuauserv</CommandLine>
<Description>disable windows update</Description>
<Order>5</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</CommandLine>
<Description>Load Default User registry hive to reverse startup delay</Description>
<Order>6</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /f</CommandLine>
<Description>Remove startup delay override from Default User</Description>
<Order>7</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /f</CommandLine>
<Description>Remove wait for idle state override from Default User</Description>
<Order>8</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe unload HKU\TempDefault</CommandLine>
<Description>Unload Default User registry hive</Description>
<Order>9</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /f</CommandLine>
<Description>Restore removable media access after OOBE PPKG block</Description>
<Order>10</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<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>
<!-- Installs from the copy specialize Order 37 staged in
C:\PreInstall\engineering, so it works after a reboot has removed
C:\Deploy (DeleteDeployDir). Falls back to C:\Deploy if staging did not
happen. The installer (.NET 3.5, VC++, Cygwin, DUU) can reboot part way
through; the script is idempotent and marker-gated, and it makes sure
the "Configure my Engineering Layer" icon reaches the employee.
Description is capped at 256 chars, so the detail lives here. -->
<Description>Engineering Layer: install from the staged copy</Description>
</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>
</component>
</settings>
</unattend>

View File

@@ -1,196 +1,288 @@
<?xml version="1.0"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
</settings>
<settings pass="offlineServicing">
<component name="Microsoft-Windows-PnpCustomizationsNonWinPE" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<DriverPaths>
<PathAndCredentials xmlns="" wcm:keyValue="1" action="add">
<Path>W:\Drivers</Path>
</PathAndCredentials>
</DriverPaths>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<ComputerName>H%serialnumber%</ComputerName>
<RegisteredOrganization>GE Aerospace</RegisteredOrganization>
<RegisteredOwner>GE</RegisteredOwner>
<TimeZone>Eastern Standard Time</TimeZone>
</component>
<component name="Microsoft-Windows-Deployment" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_External_Root_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\Root'"</Path>
<Description>Install External Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install External Intermediate Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\Root'"</Path>
<Description>Install Enterprise Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install Enterprise Device Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>5</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install Enterprise Server Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>6</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install Enterprise SmartCard Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>7</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install Enterprise User Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>8</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer' -CertStoreLocation 'Cert:\LocalMachine\Root'"</Path>
<Description>Install Aerospace Enterprise Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>9</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt' -CertStoreLocation 'Cert:\LocalMachine\Root'"</Path>
<Description>Install Zscaler Commercial Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Path>reg.exe add "HKLM\System\CurrentControlSet\Control\Network\NewNetworkWindowOff" /f </Path>
<Description>Disable Network Windows</Description>
<Order>10</Order>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>11</Order>
<Path>netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
<Description>Install INTERNETACCESS WiFi Profile</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>12</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Enable-PSRemoting -Force -SkipNetworkProfileCheck"</Path>
<Description>Enable WinRM</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>13</Order>
<Path>reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v BypassNRO /t REG_DWORD /d 1 /f</Path>
<Description>Bypass OOBE network requirement</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>14</Order>
<Path>reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v SkipMachineOOBE /t REG_DWORD /d 1 /f</Path>
<Description>Skip machine OOBE phase</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>15</Order>
<Path>reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v SkipUserOOBE /t REG_DWORD /d 1 /f</Path>
<Description>Skip user OOBE phase</Description>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<InputLocale>en-US</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Password>
<Value>Pa55word</Value>
<PlainText>true</PlainText>
</Password>
<Name>SupportUser</Name>
<Group>Administrators</Group>
<DisplayName>SupportUser</DisplayName>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Password>
<Value>Pa55word</Value>
<PlainText>true</PlainText>
</Password>
<Enabled>true</Enabled>
<Username>SupportUser</Username>
<LogonCount>7</LogonCount>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>cmd.exe /c powercfg /change monitor-timeout-ac 0 &amp; powercfg /change monitor-timeout-dc 0 &amp; powercfg /change standby-timeout-ac 0 &amp; powercfg /change standby-timeout-dc 0</CommandLine>
<Description>Disable display and sleep timeout during setup</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>2</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -Command "Get-NetAdapter -Physical | Where-Object { $_.InterfaceDescription -match 'Wi-Fi|Wireless' } | Set-NetIPInterface -InterfaceMetric 10; Get-NetAdapter -Physical | Where-Object { $_.InterfaceDescription -notmatch 'Wi-Fi|Wireless' } | Set-NetIPInterface -InterfaceMetric 100"</CommandLine>
<Description>Prioritize WiFi over ethernet</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -Command "Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private"</CommandLine>
<Description>Set network profile to Private</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>4</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Fetch-StagingPayload.ps1"</CommandLine>
<Description>Fetch bulk staging (shopfloor-setup tree + preinstall bundle) from the PXE share on a fresh mount, BEFORE the production-network switch takes the bay off the imaging LAN. Detailed log at C:\Logs\Fetch\.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>5</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Verify-And-Heal-Staging.ps1"</CommandLine>
<Description>Verify every imaging payload arrived and re-pull anything missing from the PXE share (incl the CMM bundle + selected-bay backup) while still on the imaging LAN, BEFORE wait-for-internet switches the bay to the production network. Log at C:\Logs\Fetch\.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>6</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\wait-for-internet.ps1"</CommandLine>
<Description>Prompt to connect production network then wait for TCP 443 connectivity</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>7</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\migrate-to-wifi.ps1"</CommandLine>
<Description>Migrate from wired to WiFi if WiFi adapter present, else stay on wired</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>8</Order>
<CommandLine>msiexec.exe /i "C:\PreInstall\installers\powershell7\PowerShell-7.5.4-win-x64.msi" /qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1</CommandLine>
<Description>Install PowerShell 7 BEFORE PPKG so Intune SetupCredentials Win32App finds pwsh.exe (race fix)</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>9</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\run-enrollment.ps1"</CommandLine>
<Description>Run GCCH Enrollment</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>10</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Run-ShopfloorSetup.ps1"</CommandLine>
<Description>Run shopfloor PC type setup</Description>
</SynchronousCommand>
</FirstLogonCommands>
<TimeZone>Eastern Standard Time</TimeZone>
</component>
</settings>
</unattend>
<?xml version="1.0"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
</settings>
<settings pass="offlineServicing">
<component name="Microsoft-Windows-PnpCustomizationsNonWinPE" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<DriverPaths>
<PathAndCredentials xmlns="" wcm:keyValue="1" action="add">
<Path>W:\Drivers</Path>
</PathAndCredentials>
</DriverPaths>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<ComputerName>H%serialnumber%</ComputerName>
<RegisteredOrganization>GE Aerospace</RegisteredOrganization>
<RegisteredOwner>GE</RegisteredOwner>
<TimeZone>Eastern Standard Time</TimeZone>
</component>
<component name="Microsoft-Windows-Deployment" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_External_Root_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\Root'"</Path>
<Description>Install External Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install External Intermediate Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\Root'"</Path>
<Description>Install Enterprise Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install Enterprise Device Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>5</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install Enterprise Server Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>6</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install Enterprise SmartCard Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>7</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer' -CertStoreLocation 'Cert:\LocalMachine\CA'"</Path>
<Description>Install Enterprise User Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>8</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer' -CertStoreLocation 'Cert:\LocalMachine\Root'"</Path>
<Description>Install Aerospace Enterprise Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>9</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Import-Certificate -FilePath 'C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt' -CertStoreLocation 'Cert:\LocalMachine\Root'"</Path>
<Description>Install Zscaler Commercial Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Path>reg.exe add "HKLM\System\CurrentControlSet\Control\Network\NewNetworkWindowOff" /f </Path>
<Description>Disable Network Windows</Description>
<Order>10</Order>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>11</Order>
<Path>netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
<Description>Install INTERNETACCESS WiFi Profile</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>12</Order>
<Path>powershell.exe -ExecutionPolicy Bypass -Command "Enable-PSRemoting -Force -SkipNetworkProfileCheck"</Path>
<Description>Enable WinRM</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>13</Order>
<Path>reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v BypassNRO /t REG_DWORD /d 1 /f</Path>
<Description>Bypass OOBE network requirement</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>14</Order>
<Path>reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v SkipMachineOOBE /t REG_DWORD /d 1 /f</Path>
<Description>Skip machine OOBE phase</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>15</Order>
<Path>reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v SkipUserOOBE /t REG_DWORD /d 1 /f</Path>
<Description>Skip user OOBE phase</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>17</Order>
<Path>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</Path>
<Description>Load Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>18</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /t REG_DWORD /d 0 /f</Path>
<Description>Disable startup delay for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>19</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /t REG_DWORD /d 0 /f</Path>
<Description>Disable wait for idle state for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>20</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v StartShownOnUpgrade /t REG_DWORD /d 1 /f</Path>
<Description>Prevent Start menu from opening on first login</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>21</Order>
<Path>reg.exe unload HKU\TempDefault</Path>
<Description>Unload Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>22</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v NoAutoUpdate /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (NoAutoUpdate)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>23</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v AUOptions /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (AUOptions)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>24</Order>
<Path>reg.exe add "HKLM\Software\Microsoft\Command Processor" /v AutoRun /t REG_SZ /d "doskey vanilla=powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\Deploy\Applications\BPRT\Show-VanillaConfirmation.ps1" /f</Path>
<Description>Define vanilla CMD alias for manual OOBE fallback via Shift+F10</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>25</Order>
<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>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<InputLocale>en-US</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="*arch*" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Password>
<Value>Pa55word</Value>
<PlainText>true</PlainText>
</Password>
<Name>SupportUser</Name>
<Group>Administrators</Group>
<DisplayName>SupportUser</DisplayName>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Password>
<Value>Pa55word</Value>
<PlainText>true</PlainText>
</Password>
<Enabled>true</Enabled>
<Username>SupportUser</Username>
<LogonCount>12</LogonCount>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>cmd.exe /c powercfg /change monitor-timeout-ac 0 &amp; powercfg /change monitor-timeout-dc 0 &amp; powercfg /change standby-timeout-ac 0 &amp; powercfg /change standby-timeout-dc 0</CommandLine>
<Description>Disable display and sleep timeout during setup</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>2</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -Command "Get-NetAdapter -Physical | Where-Object { $_.InterfaceDescription -match 'Wi-Fi|Wireless' } | Set-NetIPInterface -InterfaceMetric 10; Get-NetAdapter -Physical | Where-Object { $_.InterfaceDescription -notmatch 'Wi-Fi|Wireless' } | Set-NetIPInterface -InterfaceMetric 100"</CommandLine>
<Description>Prioritize WiFi over ethernet</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -Command "Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private"</CommandLine>
<Description>Set network profile to Private</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>4</Order>
<CommandLine>powershell.exe -ep Bypass -Command "net use Z: \\172.16.9.1\enrollment /user:pxe-upload pxe; robocopy Z:\shopfloor-setup C:\Enrollment Fetch-StagingPayload.ps1 Verify-And-Heal-Staging.ps1 /R:2 /W:2; robocopy Z:\scripts C:\Enrollment run-enrollment.ps1 wait-for-internet.ps1 migrate-to-wifi.ps1 /R:2 /W:2"</CommandLine>
<Description>Bootstrap self-heal: pull Fetch/Verify-Heal + enrollment scripts from the PXE share so Order 5/6 run after an early WinPE staging failure.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>5</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Fetch-StagingPayload.ps1"</CommandLine>
<Description>Fetch bulk staging (shopfloor-setup tree + preinstall bundle) from the PXE share on a fresh mount, BEFORE the production-network switch takes the bay off the imaging LAN. Detailed log at C:\Logs\Fetch\.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>6</Order>
<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>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>7</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\wait-for-internet.ps1"</CommandLine>
<Description>Prompt to connect production network then wait for TCP 443 connectivity</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>8</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\migrate-to-wifi.ps1"</CommandLine>
<Description>Migrate from wired to WiFi if WiFi adapter present, else stay on wired</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>9</Order>
<CommandLine>msiexec.exe /i "C:\PreInstall\installers\powershell7\PowerShell-7.5.4-win-x64.msi" /qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1</CommandLine>
<Description>Install PowerShell 7 BEFORE PPKG so Intune SetupCredentials Win32App finds pwsh.exe (race fix)</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>10</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\run-enrollment.ps1"</CommandLine>
<!-- run-enrollment.ps1 lives in C:\Enrollment: startnet stages it and
the .ppkg there. The earlier C:\run-enrollment.ps1 path pointed at a
file that never existed, so this step silently did nothing.
Keep prose in comments - Description is capped at 256 chars and an
over-length one invalidates the whole answer file for its pass. -->
<Description>Run GCCH Enrollment</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>11</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Run-ShopfloorSetup.ps1"</CommandLine>
<Description>Run shopfloor PC type setup</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc config wuauserv start= disabled</CommandLine>
<Description>disable windows update</Description>
<Order>12</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc stop wuauserv</CommandLine>
<Description>disable windows update</Description>
<Order>13</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</CommandLine>
<Description>Load Default User registry hive to reverse startup delay</Description>
<Order>14</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /f</CommandLine>
<Description>Remove startup delay override from Default User</Description>
<Order>15</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /f</CommandLine>
<Description>Remove wait for idle state override from Default User</Description>
<Order>16</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe unload HKU\TempDefault</CommandLine>
<Description>Unload Default User registry hive</Description>
<Order>17</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /f</CommandLine>
<Description>Restore removable media access after OOBE PPKG block</Description>
<Order>18</Order>
</SynchronousCommand>
</FirstLogonCommands>
<TimeZone>Eastern Standard Time</TimeZone>
</component>
</settings>
</unattend>

View File

@@ -1,307 +1,392 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- 1. windowsPE is intentionally empty -->
<settings pass="windowsPE" />
<!-- 2. Offline servicing (drivers) -->
<settings pass="offlineServicing">
<component name="Microsoft-Windows-PnpCustomizationsNonWinPE"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<DriverPaths>
<PathAndCredentials wcm:action="add" wcm:keyValue="1">
<Path>W:\Drivers</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="2">
<Path>W:\Deploy\Applications\extra\printdrivers\BROTHER\UNIV-PS-01181\PS\64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="3">
<Path>W:\Deploy\Applications\extra\printdrivers\HP</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="4">
<Path>W:\Deploy\Applications\extra\printdrivers\XEROX\UNIV_5.1035.2.0_PS_x64_Driver</Path>
</PathAndCredentials>
</DriverPaths>
</component>
</settings>
<!-- 3. specialize: computer naming + RunSynchronous all in ONE component -->
<settings pass="specialize">
<!-- 3a. Shell-Setup for naming/owner/org -->
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<ComputerName>H%serialnumber%</ComputerName>
<RegisteredOrganization>GE Aerospace</RegisteredOrganization>
<RegisteredOwner>GE</RegisteredOwner>
<TimeZone>Eastern Standard Time</TimeZone>
</component>
<!-- 3b. RunSynchronous for all of your installers, copies, etc. -->
<component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<RunSynchronous>
<!-- EAP-PEAP MSI -->
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>msiexec /i "C:\Deploy\Applications\extra\wireless\EAP-PEAP.msi" /quiet /norestart</Path>
<Description>Install EAP-PEAP</Description>
</RunSynchronousCommand>
<!-- Wi-Fi profiles -->
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\BLUESSO.xml" user=all</Path>
<Description>Add BLUESSO WiFi profile</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
<Description>Add generic WiFi profile</Description>
</RunSynchronousCommand>
<!-- Certificates -->
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_External_Root_CA_2_1.cer"</Path>
<Description>Install External Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>5</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer"</Path>
<Description>Install External Intermediate Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>6</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer"</Path>
<Description>Install Enterprise Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>7</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Device Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>8</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Server Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>9</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer"</Path>
<Description>Install SmartCard Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>10</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer"</Path>
<Description>Install User Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>11</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer"</Path>
<Description>Install Aerospace Enterprise Root CA</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>12</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt"</Path>
<Description>Install Zscaler Certificate</Description>
</RunSynchronousCommand>
<!-- Fonts -->
<RunSynchronousCommand wcm:action="add">
<Order>13</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\3OF9.TTF" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy 3OF9 Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>14</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "3 of 9 Barcode" /t REG_SZ /d "3OF9.TTF" /f</Path>
<Description>Register 3OF9 Barcode Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>15</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39AzaleaNarrow3.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>16</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39AzaleaNarrow3" /t REG_SZ /d "Code39AzaleaNarrow3.ttf" /f</Path>
<Description>Register Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>17</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39Azalea.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>18</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39Azalea" /t REG_SZ /d "Code39Azalea.ttf" /f</Path>
<Description>Register Code39 Azalea Font</Description>
</RunSynchronousCommand>
<!-- OpenText installers -->
<RunSynchronousCommand wcm:action="add">
<Order>19</Order>
<Path>C:\Deploy\Applications\extra\opentext\opentext_hostexplorer_sp1_15.0_v01.exe /quiet /norestart</Path>
<Description>Install OpenText HostExplorer SP1</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>20</Order>
<Path>C:\Deploy\Applications\extra\opentext\J2SE_Runtime_Environment_1.6.0_22_Static_Config_V2_Co-Exist.EXE /silent /norestart</Path>
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>21</Order>
<Path>C:\Deploy\Applications\extra\opentext\unattended.bat</Path>
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
</RunSynchronousCommand>
<!-- Adobe -->
<RunSynchronousCommand wcm:action="add">
<Order>22</Order>
<Path>msiexec /i "C:\Deploy\Applications\extra\adobe\AcroRead.msi" TRANSFORMS="C:\Deploy\Applications\extra\adobe\AcroRead.mst" /quiet /norestart</Path>
<Description>Install Adobe</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>23</Order>
<Path>msiexec /p "C:\Deploy\Applications\extra\adobe\AcroRdrDCUpd2500120531.msp" /quiet /norestart</Path>
<Description>Apply Adobe Reader Update</Description>
</RunSynchronousCommand>
<!-- Java JRE 8 Enterprise Installation -->
<RunSynchronousCommand wcm:action="add">
<Order>26</Order>
<Path>C:\Deploy\Applications\extra\java\jre-8u441-windows-i586.exe /s INSTALLDIR="C:\Program Files (x86)\Java\jre1.8.0_441" STATIC=1 AUTO_UPDATE=0 REBOOT=0 SPONSORS=0 WEB_JAVA=0 /L "C:\Windows\Logs\java-install.log"</Path>
<Description>Install Java JRE 8 Update 441 with Enterprise Settings and Logging</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Updates via Registry (64-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>27</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (64-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Updates via Registry (32-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>28</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (32-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Update Check (64-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>29</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (64-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Update Check (32-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>30</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (32-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Cortana -->
<RunSynchronousCommand wcm:action="add">
<Order>31</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v AllowCortana /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana</Description>
</RunSynchronousCommand>
<!-- Disable Web Search in Start Menu -->
<RunSynchronousCommand wcm:action="add">
<Order>33</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v DisableWebSearch /t REG_DWORD /d 1 /f</Path>
<Description>Disable Web Search in Start Menu</Description>
</RunSynchronousCommand>
<!-- Disable Bing / web search suggestions in Start menu.
BingSearchEnabled is documented at HKCU\Software\Microsoft\Windows\CurrentVersion\Search
(not HKLM), so the old HKLM policy placement was a no-op. The current documented
suppression is DisableSearchBoxSuggestions=1 at Software\Policies\Microsoft\Windows\Explorer.
Writing it into the Default User hive so every newly-created account inherits it. -->
<RunSynchronousCommand wcm:action="add">
<Order>34</Order>
<Path>cmd /c reg load HKU\TempDU C:\Users\Default\NTUSER.DAT &amp; reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f &amp; reg unload HKU\TempDU</Path>
<Description>Disable Bing/web search suggestions (Default User hive)</Description>
</RunSynchronousCommand>
<!-- Disable Search Web when searching Windows -->
<RunSynchronousCommand wcm:action="add">
<Order>35</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v ConnectedSearchUseWeb /t REG_DWORD /d 0 /f</Path>
<Description>Disable Connected Search Use Web</Description>
</RunSynchronousCommand>
<!-- Disable Cortana Speech Recognition -->
<RunSynchronousCommand wcm:action="add">
<Order>36</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\InputPersonalization" /v AllowInputPersonalization /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana Speech Recognition</Description>
</RunSynchronousCommand>
<!-- Disable Search Highlights (news/trending) -->
<RunSynchronousCommand wcm:action="add">
<Order>38</Order>
<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>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<!-- 4. oobeSystem: hide OEM/EULA screens -->
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>false</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>false</HideWirelessSetupInOOBE>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipUserOOBE>false</SkipUserOOBE>
<SkipMachineOOBE>false</SkipMachineOOBE>
</OOBE>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>shutdown -a</CommandLine>
<Description>Cancel any scheduled shutdown from Office installation</Description>
</SynchronousCommand>
<!-- Install Zscaler -->
<SynchronousCommand wcm:action="add">
<Order>2</Order>
<CommandLine>C:\Deploy\Applications\extra\zscaler\zscaler.bat</CommandLine>
<Description>Install Zscaler Client Connector</Description>
</SynchronousCommand>
<!-- Install Microsoft Office -->
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<CommandLine>cmd /c "cd /d C:\Deploy\Applications\extra\office\ &amp;&amp; install.bat"</CommandLine>
<Description>Install Microsoft Office</Description>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- 1. windowsPE is intentionally empty -->
<settings pass="windowsPE" />
<!-- 2. Offline servicing (drivers) -->
<settings pass="offlineServicing">
<component name="Microsoft-Windows-PnpCustomizationsNonWinPE"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<DriverPaths>
<PathAndCredentials wcm:action="add" wcm:keyValue="1">
<Path>W:\Drivers</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="2">
<Path>W:\Deploy\Applications\extra\printdrivers\BROTHER\UNIV-PS-01181\PS\64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="3">
<Path>W:\Deploy\Applications\extra\printdrivers\HP</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="4">
<Path>W:\Deploy\Applications\extra\printdrivers\XEROX\UNIV_5.1035.2.0_PS_x64_Driver</Path>
</PathAndCredentials>
</DriverPaths>
</component>
</settings>
<!-- 3. specialize: computer naming + RunSynchronous all in ONE component -->
<settings pass="specialize">
<!-- 3a. Shell-Setup for naming/owner/org -->
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<ComputerName>H%serialnumber%</ComputerName>
<RegisteredOrganization>GE Aerospace</RegisteredOrganization>
<RegisteredOwner>GE</RegisteredOwner>
<TimeZone>Eastern Standard Time</TimeZone>
</component>
<!-- 3b. RunSynchronous for all of your installers, copies, etc. -->
<component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<RunSynchronous>
<!-- INTERNETACCESS bootstrap profile. BLUESSO and EAP-PEAP were removed:
the MCL ppkg installs both, and BLUESSO cannot bootstrap (hidden
SSID, manual connect, user password). wlansvc must be started
first or netsh silently fails. -->
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<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>
</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">
<Order>3</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
<Description>Add generic WiFi profile</Description>
</RunSynchronousCommand>
<!-- Certificates -->
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_External_Root_CA_2_1.cer"</Path>
<Description>Install External Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>5</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer"</Path>
<Description>Install External Intermediate Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>6</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer"</Path>
<Description>Install Enterprise Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>7</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Device Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>8</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Server Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>9</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer"</Path>
<Description>Install SmartCard Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>10</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer"</Path>
<Description>Install User Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>11</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer"</Path>
<Description>Install Aerospace Enterprise Root CA</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>12</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt"</Path>
<Description>Install Zscaler Certificate</Description>
</RunSynchronousCommand>
<!-- Fonts -->
<RunSynchronousCommand wcm:action="add">
<Order>13</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\3OF9.TTF" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy 3OF9 Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>14</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "3 of 9 Barcode" /t REG_SZ /d "3OF9.TTF" /f</Path>
<Description>Register 3OF9 Barcode Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>15</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39AzaleaNarrow3.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>16</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39AzaleaNarrow3" /t REG_SZ /d "Code39AzaleaNarrow3.ttf" /f</Path>
<Description>Register Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>17</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39Azalea.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>18</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39Azalea" /t REG_SZ /d "Code39Azalea.ttf" /f</Path>
<Description>Register Code39 Azalea Font</Description>
</RunSynchronousCommand>
<!-- OpenText installers -->
<RunSynchronousCommand wcm:action="add">
<Order>19</Order>
<Path>cmd /c "C:\Deploy\Applications\extra\opentext\Setup-OpenText.cmd"</Path>
<Description>Install OpenText HostExplorer SP1 (shopfloor method)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>20</Order>
<Path>C:\Deploy\Applications\extra\opentext\J2SE_Runtime_Environment_1.6.0_22_Static_Config_V2_Co-Exist.EXE /silent /norestart</Path>
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
</RunSynchronousCommand>
<!-- Adobe -->
<RunSynchronousCommand wcm:action="add">
<Order>22</Order>
<Path>msiexec /i "C:\Deploy\Applications\extra\adobe\AcroRead.msi" TRANSFORMS="C:\Deploy\Applications\extra\adobe\AcroRead.mst" /quiet /norestart</Path>
<Description>Install Adobe</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>23</Order>
<Path>msiexec /p "C:\Deploy\Applications\extra\adobe\AcroRdrDCUpd2500120531.msp" /quiet /norestart</Path>
<Description>Apply Adobe Reader Update</Description>
</RunSynchronousCommand>
<!-- Java JRE 8 Enterprise Installation -->
<RunSynchronousCommand wcm:action="add">
<Order>26</Order>
<Path>C:\Deploy\Applications\extra\java\jre-8u441-windows-i586.exe /s INSTALLDIR="C:\Program Files (x86)\Java\jre1.8.0_441" STATIC=1 AUTO_UPDATE=0 REBOOT=0 SPONSORS=0 WEB_JAVA=0 /L "C:\Windows\Logs\java-install.log"</Path>
<Description>Install Java JRE 8 Update 441 with Enterprise Settings and Logging</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Updates via Registry (64-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>27</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (64-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Updates via Registry (32-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>28</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (32-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Update Check (64-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>29</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (64-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Update Check (32-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>30</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (32-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Cortana -->
<RunSynchronousCommand wcm:action="add">
<Order>31</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v AllowCortana /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana</Description>
</RunSynchronousCommand>
<!-- Disable Web Search in Start Menu -->
<RunSynchronousCommand wcm:action="add">
<Order>33</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v DisableWebSearch /t REG_DWORD /d 1 /f</Path>
<Description>Disable Web Search in Start Menu</Description>
</RunSynchronousCommand>
<!-- Disable Bing / web search suggestions in Start menu.
BingSearchEnabled is documented at HKCU\Software\Microsoft\Windows\CurrentVersion\Search
(not HKLM), so the old HKLM policy placement was a no-op. The current documented
suppression is DisableSearchBoxSuggestions=1 at Software\Policies\Microsoft\Windows\Explorer.
Writing it into the Default User hive so every newly-created account inherits it. -->
<RunSynchronousCommand wcm:action="add">
<Order>34</Order>
<Path>cmd /c reg load HKU\TempDU C:\Users\Default\NTUSER.DAT &amp; reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f &amp; reg unload HKU\TempDU</Path>
<Description>Disable Bing/web search suggestions (Default User hive)</Description>
</RunSynchronousCommand>
<!-- Disable Search Web when searching Windows -->
<RunSynchronousCommand wcm:action="add">
<Order>35</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v ConnectedSearchUseWeb /t REG_DWORD /d 0 /f</Path>
<Description>Disable Connected Search Use Web</Description>
</RunSynchronousCommand>
<!-- Disable Cortana Speech Recognition -->
<RunSynchronousCommand wcm:action="add">
<Order>36</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\InputPersonalization" /v AllowInputPersonalization /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana Speech Recognition</Description>
</RunSynchronousCommand>
<!-- Disable Search Highlights (news/trending) -->
<RunSynchronousCommand wcm:action="add">
<Order>38</Order>
<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>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>39</Order>
<Path>powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Deploy\Applications\BPRT\Register-BulkEnrollTask-Standard.ps1"</Path>
<Description>Add bulk enrollment orchestrator to Run key</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>40</Order>
<Path>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</Path>
<Description>Load Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>41</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /t REG_DWORD /d 0 /f</Path>
<Description>Disable startup delay for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>42</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /t REG_DWORD /d 0 /f</Path>
<Description>Disable wait for idle state for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>43</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v StartShownOnUpgrade /t REG_DWORD /d 1 /f</Path>
<Description>Prevent Start menu from opening on first login</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>44</Order>
<Path>reg.exe unload HKU\TempDefault</Path>
<Description>Unload Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>45</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v NoAutoUpdate /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (NoAutoUpdate)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>46</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v AUOptions /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (AUOptions)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>47</Order>
<Path>reg.exe add "HKLM\Software\Microsoft\Command Processor" /v AutoRun /t REG_SZ /d "doskey vanilla=powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\Deploy\Applications\BPRT\Show-VanillaConfirmation.ps1" /f</Path>
<Description>Define vanilla CMD alias for manual OOBE fallback via Shift+F10</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>48</Order>
<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>
</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>
</component>
</settings>
<!-- 4. oobeSystem: hide OEM/EULA screens -->
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>false</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipUserOOBE>false</SkipUserOOBE>
<SkipMachineOOBE>false</SkipMachineOOBE>
</OOBE>
<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>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc config wuauserv start= disabled</CommandLine>
<Description>disable windows update</Description>
<Order>4</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc stop wuauserv</CommandLine>
<Description>disable windows update</Description>
<Order>5</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</CommandLine>
<Description>Load Default User registry hive to reverse startup delay</Description>
<Order>6</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /f</CommandLine>
<Description>Remove startup delay override from Default User</Description>
<Order>7</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /f</CommandLine>
<Description>Remove wait for idle state override from Default User</Description>
<Order>8</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe unload HKU\TempDefault</CommandLine>
<Description>Unload Default User registry hive</Description>
<Order>9</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /f</CommandLine>
<Description>Restore removable media access after OOBE PPKG block</Description>
<Order>10</Order>
</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>
</component>
</settings>
</unattend>

View File

@@ -7,35 +7,46 @@
# NIC driver set), download Blancco rootfs (squashfs), overlay-mount, and
# switch_root into Blancco's userspace.
#
# Verbose trace + shell-on-NIC-failure because silent hangs during Blancco
# PXE boot are painful to debug. set -x goes to /dev/console so the screen
# 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
# Quiet by default: one line per step. Add blancco.debug=1 to the kernel
# cmdline for the full trace (set -x, modprobe -v, ip link/addr). Failures
# always print their diagnostics and drop to sh so the operator can
# investigate without re-imaging.
exec >/dev/console 2>&1
set -x
echo ""
echo "============================================"
echo " Blancco PXE loader (verbose)"
echo "============================================"
echo ""
export PATH=/bin:/sbin
mount -t proc proc /proc
mount -t sysfs sysfs /sys
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
KVER=$(uname -r)
echo "Running kernel: $KVER"
ls /lib/modules/ 2>/dev/null
dbg echo "Running kernel: $KVER"
dbg ls /lib/modules/
echo "[1/5] Loading NIC drivers via modprobe (resolves deps automatically)..."
echo " [1/5] Network drivers"
# 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).
# 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 \
xhci_pci xhci_pci_renesas \
mii libphy ptp \
e1000 e1000e igb igc ixgbe ixgbevf i40e ice iavf \
tg3 bnx2 bnx2x bnxt_en b44 \
@@ -46,15 +57,19 @@ for drv in \
alx atl1c atl1e atl2 \
via_rhine via_velocity forcedeth \
pegasus dm9601 asix ax88179_178a cdc_ether cdc_ncm rndis_host; do
modprobe -v "$drv" 2>/dev/null && echo " OK $drv" || true
if [ -n "$DEBUG" ]; then
modprobe -v "$drv" 2>/dev/null && echo " OK $drv" || true
else
modprobe "$drv" >/dev/null 2>&1 || true
fi
done
sleep 3
echo "[2/5] /sys/class/net after driver load:"
ls /sys/class/net/ || true
ip link || true
echo " [2/5] Waiting for a network link"
dbg ls /sys/class/net/
dbg ip link
echo " Waiting up to 60s for non-lo interface..."
dbg echo " Waiting up to 60s for non-lo interface..."
IFACE=""
for i in $(seq 1 60); do
for n in /sys/class/net/*; do
@@ -76,21 +91,87 @@ if [ -z "$IFACE" ]; then
exec /bin/sh
fi
echo " IFACE=$IFACE, bringing up..."
# The first interface to appear is not necessarily the cabled one: a
# 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
sleep 2
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
sleep 1
echo " IP: 172.16.9.250 SERVER: $SERVER"
ip addr
dbg echo " IP: 172.16.9.250 SERVER: $SERVER"
dbg ip addr
echo "[3/5] Downloading airootfs.sfs (~756 MB)..."
wget -O /tmp/airootfs.sfs http://$SERVER/blancco/arch/x86_64/airootfs.sfs 2>&1
echo " [3/5] Downloading Blancco (~850 MB)"
post_status 1 "Downloading Blancco"
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; }
echo "[4/5] Mounting rootfs + overlay..."
echo " [4/5] Preparing system"
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 loop 2>/dev/null
@@ -100,8 +181,12 @@ mount -t tmpfs -o size=50% tmpfs /run/upper
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
echo "[5/5] Fetching kmod tarball + config..."
wget -O /tmp/kmod.tar.gz http://$SERVER/blancco/kmod.tar.gz 2>&1
echo " [5/5] Loading drivers and settings"
# Modules (+ iwlwifi firmware) for the running kernel. kmod-$KVER.tar.gz
# 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)
mkdir -p /run/newroot/albus
@@ -111,8 +196,8 @@ mkdir -p /run/newroot/albus
# /albus/preferences.save (factory defaults, empty network_share) so
# erasure reports never reach SMB. Fail loud here instead.
PREFS=/run/newroot/albus/preferences.xml
if ! wget -O "$PREFS" "http://$SERVER/blancco/preferences.xml"; then
echo "ERROR: preferences.xml download failed from http://$SERVER/blancco/preferences.xml"
if ! wget -q -T 30 -O "$PREFS" "http://$SERVER$BASE/preferences.xml"; then
echo "ERROR: preferences.xml download failed from http://$SERVER$BASE/preferences.xml"
echo "Reports would fall back to factory defaults (no SMB target)."
echo "Dropping to shell - check Apache + network."
exec /bin/sh
@@ -132,11 +217,29 @@ fi
# fires, we want the fallback to still have the right SMB target.
cp -f "$PREFS" /run/newroot/albus/preferences.save
if ! wget -O /run/newroot/albus/config.xml "http://$SERVER/blancco/config-clean.xml"; then
if ! wget -q -T 30 -O /run/newroot/albus/config.xml "http://$SERVER$BASE/config-clean.xml"; then
echo "ERROR: config.xml (license container) download failed."
exec /bin/sh
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
# Don't pin a single Xorg driver - hardware varies per site (Intel iGPU,
# NVIDIA GK208, AMD, etc). Let Xorg auto-pick. With nomodeset + KMS
@@ -168,6 +271,24 @@ HandleLidSwitchDocked=ignore
HandlePowerKey=ignore
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.
# Numbered 00-* to run before vendor xinitrc.d hooks.
mkdir -p /run/newroot/etc/X11/xinit/xinitrc.d
@@ -180,6 +301,115 @@ XEOF
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
# 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 /sys /run/newroot/sys
mount --move /dev /run/newroot/dev
@@ -196,5 +426,6 @@ 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/autosleep 2>/dev/null || true
echo "Switching root..."
echo " Starting Blancco..."
post_status 2 "Starting Blancco"
exec switch_root /run/newroot /sbin/init

View File

@@ -1,49 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- FASDHFOSGHSVASLGHASDLASDFUILEFHLKJDKLFJGKLXCJGHFKLJDGHKLFJ -->
<root>
<blancco type="albus-preferences" version="4.15"/>
<blancco type="albus-preferences" version="4.20"/>
<version>
<generation>7</generation>
<major>15</major>
<minor>1</minor>
<major>20</major>
<minor>0</minor>
</version>
<configuration>
<device_enrollment_detection>
<persistent_software>false</persistent_software>
</device_enrollment_detection>
<integrity>qJ4hjOqd1NaMbEPsbmaz+8gZlG1duEXinTXI+Wrsc00=</integrity>
<configuration dect="3.15.0" imported="false">
<processes type="array" key="process">
<process>workflow</process>
<process>auto</process>
<process selected="true">auto</process>
<process>semi</process>
<process selected="true">manual</process>
<process>manual</process>
</processes>
<hardware_tests type="array" key="test" enabled="false">
<test enabled="true" mandatory="false" auto="true" threshold="60">battery</test>
<hardware_tests type="array" key="test" enabled="true">
<test enabled="false" mandatory="false" auto="true" threshold="60">battery</test>
<test enabled="true" mandatory="false" auto="true">cpu</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="false" threshold="50" duration="600">battery_discharge</test>
<test enabled="true" mandatory="false" auto="false">bios_logo</test>
<test enabled="true" mandatory="false" auto="false" threshold="80" duration="180">cpu_stress</test>
<test enabled="true" mandatory="false" auto="false">display</test>
<test enabled="true" mandatory="false" auto="false" formfactor="compact" language="us">keyboard</test>
<test enabled="true" mandatory="false" auto="false">microphone</test>
<test enabled="true" mandatory="false" auto="false" targets="">network</test>
<test enabled="true" mandatory="false" auto="false" tests="R">optical_devices</test>
<test enabled="true" mandatory="false" auto="false">sound</test>
<test enabled="true" mandatory="false" auto="false">pointing_devices</test>
<test enabled="true" mandatory="false" auto="false">sim_presence</test>
<test enabled="true" mandatory="false" auto="false">speaker</test>
<test enabled="true" mandatory="false" auto="false">touchscreen</test>
<test enabled="true" mandatory="false" auto="false" tests="R">usb_ports</test>
<test enabled="true" mandatory="false" auto="false">webcam</test>
<test enabled="true" mandatory="false" auto="false">wifi</test>
<test enabled="false" mandatory="false" auto="false" threshold="50" duration="600">battery_discharge</test>
<test enabled="false" mandatory="false" auto="false">bios_logo</test>
<test enabled="false" mandatory="false" auto="false" threshold="80" duration="180">cpu_stress</test>
<test enabled="false" mandatory="false" auto="false">display</test>
<test enabled="false" mandatory="false" auto="false" formfactor="compact" language="us">keyboard</test>
<test enabled="false" mandatory="false" auto="false">microphone</test>
<test enabled="false" mandatory="false" auto="false" targets="">network</test>
<test enabled="false" mandatory="false" auto="false" tests="R">optical_devices</test>
<test enabled="false" mandatory="false" auto="false">sound</test>
<test enabled="false" mandatory="false" auto="false">pointing_devices</test>
<test enabled="false" mandatory="false" auto="false">sim_presence</test>
<test enabled="false" mandatory="false" auto="false">speaker</test>
<test enabled="false" mandatory="false" auto="false">touchscreen</test>
<test enabled="false" mandatory="false" auto="false" tests="R">usb_ports</test>
<test enabled="false" mandatory="false" auto="false">webcam</test>
<test enabled="false" mandatory="false" auto="false">wifi</test>
</hardware_tests>
<layouts type="array" key="layout">
<layout selected="true">us</layout>
<layout>be</layout>
@@ -71,7 +64,6 @@
<layout>se</layout>
<layout>sk</layout>
</layouts>
<locales type="array" key="locale">
<locale selected="true">en_US</locale>
<locale>de_DE</locale>
@@ -88,119 +80,110 @@
<locale>zh_CN</locale>
<locale>zh_TW</locale>
</locales>
<erasure_standards key="standard" type="array">
<erasure_standards type="array" key="standard">
<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="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_gs" name="BSI-GS"/>
<standard enabled="true" id="bsi_gse" name="BSI-GSE"/>
<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="crypto_erase" name="Sanitize Cryptographic Erasure"/>
<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="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 enabled="true" id="firmware" name="Firmware Based Erasure"/>
<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="firmware" name="Firmware Based Erasure"/>
<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="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="tcg_crypto_erase" name="TCG Cryptographic Erasure"/>
<standard enabled="true" id="opnavinst" name="OPNAVINST 5239.1A"/>
<standard enabled="true" id="peter_gutmann" name="Peter Gutmann's Algorithm"/>
<standard enabled="true" id="random_byte_3x" name="Random Byte Overwrite (3x)"/>
<standard enabled="true" id="rcmp_tssit" name="RCMP TSSIT OPS-II"/>
<standard enabled="true" id="us_army" name="U.S. Army AR380-19"/>
<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 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="nist_purge_rev2" name="NIST 800-88r2 Purge"/>
<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="navso" name="Navy Staff Office Publication (NAVSO P-5239-26)"/>
<standard enabled="true" id="opnavinst" name="OPNAVINST 5239.1A"/>
<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="crypto_erase" name="Sanitize Cryptographic Erasure"/>
<standard enabled="true" id="tcg_crypto_erase" name="TCG Cryptographic Erasure"/>
<standard enabled="true" id="us_army" name="U.S. Army AR380-19"/>
</erasure_standards>
<verification_standards key="standard" type="array">
<standard enabled="true" id="all_bytes_same" pattern="0x00" name="All bytes the same"/>
<verification_standards type="array" key="standard">
<standard enabled="true" id="all_bytes_same" name="All bytes the same" pattern="0x00"/>
<standard enabled="true" id="all_ones" name="All ones"/>
<standard enabled="true" id="all_sectors_same" selected="true" name="All sectors the same"/>
<standard selected="true" enabled="true" id="all_sectors_same" name="All sectors the same"/>
<standard enabled="true" id="all_zeros" name="All zeros"/>
</verification_standards>
<adapters key="adapter" type="array"/>
<network>
<enabled>false</enabled>
<enabled>true</enabled>
<dhcp>true</dhcp>
<sshd>false</sshd>
<looping>false</looping>
<address/>
<mask/>
<gateway/>
<dns1/>
<dns2/>
<vlan/>
<address></address>
<mask></mask>
<gateway></gateway>
<dns1></dns1>
<dns2></dns2>
<vlan></vlan>
</network>
<network_security>
<enabled>false</enabled>
<fallback>false</fallback>
<protocols key="protocol" type="array">
<certificate>false</certificate>
<protocols type="array" key="protocol">
<protocol selected="true">peap</protocol>
<protocol>tls</protocol>
</protocols>
<certificate>false</certificate>
<identity encrypted="false"/>
<password encrypted="false"/>
<identity encrypted="false"></identity>
<password encrypted="false"></password>
</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>
<username encrypted="false">blancco</username>
<password encrypted="false">blancco</password>
<domain/>
<domain></domain>
<hostname>172.16.9.1</hostname>
<path>blancco-reports</path>
<protocols key="protocol" type="array">
<protocols type="array" key="protocol">
<protocol selected="true">smb</protocol>
</protocols>
</network_share>
<workflow default="false" offline="false"/>
<proxy>
<proxy_address encrypted="false"></proxy_address>
<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>
<drive_self_test type="short">false</drive_self_test>
<max_simultaneous_erasures>50</max_simultaneous_erasures>
<remove_hidden_areas>false</remove_hidden_areas>
<remove_hidden_areas>true</remove_hidden_areas>
<preserve_recovery_partition>false</preserve_recovery_partition>
<store_erasure_progress>false</store_erasure_progress>
<remapped mandatory="false" threshold="0">false</remapped>
<remapped mandatory="false" threshold="0">true</remapped>
<action_if_not_possible>interrupt</action_if_not_possible>
<fail_on_remapped_sectors>false</fail_on_remapped_sectors>
<fail_on_erasure_errors threshold="5">true</fail_on_erasure_errors>
<fail_on_read_errors threshold="5">true</fail_on_read_errors>
<verification>10</verification>
<enforced_rules key="rule" type="array">
<enforced_rules type="array" key="rule">
<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"/>
</enforced_rules>
<drive_self_test type="short">false</drive_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_timeout threshold="1">false</fail_on_timeout>
@@ -219,36 +202,29 @@
<allow_tcg_reset_write_pointers>true</allow_tcg_reset_write_pointers>
<allow_tcg_unmap>true</allow_tcg_unmap>
</erasure>
<fingerprint>
<enabled>false</enabled>
<location>67000</location>
</fingerprint>
<bootable_report>
<enabled>true</enabled>
</bootable_report>
<format_disk>
<enabled>false</enabled>
<type>ntfs</type>
</format_disk>
<spin_down_idle_disk>
<enabled>false</enabled>
</spin_down_idle_disk>
<raid>
<show_logical_disks>false</show_logical_disks>
<show_logical_disks>true</show_logical_disks>
<enable_passthrough>true</enable_passthrough>
<kill_logical_disks>true</kill_logical_disks>
<kill_logical_disks>false</kill_logical_disks>
</raid>
<hotplug>
<enabled>false</enabled>
<timeout>30</timeout>
<timeout>60</timeout>
</hotplug>
<external_device_service>
<enabled>false</enabled>
<port>80</port>
@@ -264,28 +240,24 @@
<test enabled="true">webcam</test>
</hardware_tests>
</external_device_service>
<license>
<provider>bios</provider>
<provider_report>bios</provider_report>
<provider>clurichaun</provider>
<provider_report>clurichaun</provider_report>
<edition>Enterprise Volume Edition</edition>
<offline></offline>
<mode>Drive Eraser</mode>
<offline/>
</license>
<image>
<description/>
<creation_date>2025-03-10</creation_date>
<description>BMP Internet - June 2026</description>
<creation_date>2026-06-22</creation_date>
</image>
<customer_data>
<business_location/>
<customer_license>General Electric Company</customer_license>
<erasure_person/>
<erasure_provider/>
<business_name/>
<business_location></business_location>
<customer_license>GE Aerospace</customer_license>
<erasure_person></erasure_person>
<erasure_provider></erasure_provider>
<business_name>Non_GEAE_Wired</business_name>
</customer_data>
<settings>
<gui>
<erasure_console>local</erasure_console>
@@ -295,11 +267,11 @@
<ssd_erasure_exception>true</ssd_erasure_exception>
<report_view locked="true">standard</report_view>
<erasure_settings locked="false"/>
<show_customer>false</show_customer>
<show_customer>true</show_customer>
<show_operator>false</show_operator>
<show_partitions>false</show_partitions>
<screensaver locked="false">
<timeout>30</timeout>
<timeout>86400</timeout>
<show_exceptions>false</show_exceptions>
</screensaver>
<hexviewer>
@@ -309,7 +281,7 @@
<drive_selection_percentage>5</drive_selection_percentage>
<reconfigure_controller_mode>
<timeout enabled="false">30</timeout>
<default_focus_on_proceed>true</default_focus_on_proceed>
<default_focus_on_proceed>false</default_focus_on_proceed>
</reconfigure_controller_mode>
<disable_sid_block_authentication>
<timeout enabled="true">0</timeout>
@@ -323,8 +295,8 @@
<accessibility>
<available>false</available>
<enabled>true</enabled>
<speaking_rate>80</speaking_rate>
<key_echo>true</key_echo>
<speaking_rate>80</speaking_rate>
</accessibility>
<sound_level>70</sound_level>
<input_method>
@@ -334,82 +306,55 @@
</input_method>
</gui>
</settings>
<custom_fields type="array" key="custom_field"/>
<exclude_verification_ranges type="array" key="exclude_verification_range"/>
<report>
<per_drive>false</per_drive>
<auto_backup>true</auto_backup>
<extensions type="array" key="extension">
<extension selected="true">xml</extension>
<extension>xml</extension>
<extension selected="true">pdf+xml</extension>
</extensions>
<utc_offset>0</utc_offset>
<network_info>true</network_info>
<custom_signature>
<enabled>false</enabled>
<label/>
<label></label>
</custom_signature>
<drive_life_estimation>false</drive_life_estimation>
</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>
<enabled>true</enabled>
<ssid encrypted="false">INTERNETACCESS</ssid>
<password encrypted="false">Acc3ssGr@nted22</password>
<ssid encrypted="false"></ssid>
<password encrypted="false"></password>
<key_mgmt>WPA-PSK</key_mgmt>
<scan_ssid>0</scan_ssid>
</wireless>
<cd_eject>
<after_boot>true</after_boot>
<after_boot>false</after_boot>
<after_erasure>false</after_erasure>
<after_report>false</after_report>
<at_shutdown>false</at_shutdown>
</cd_eject>
<system_shutdown>
<after_erasure>false</after_erasure>
<after_successful_erasure>false</after_successful_erasure>
</system_shutdown>
<system_restart>
<after_erasure>false</after_erasure>
<after_successful_erasure>false</after_successful_erasure>
</system_restart>
<device_enrollment_detection>
<persistent_software>false</persistent_software>
</device_enrollment_detection>
<vnc_remote>
<enabled>false</enabled>
<mode>viewer</mode>
<repeater_hostname/>
<repeater_hostname></repeater_hostname>
<port>5900</port>
<password encrypted="false"/>
<password encrypted="false"></password>
</vnc_remote>
<nvme_over_fabrics type="array" key="connection" enabled="false"/>
<nvme_over_fabrics enabled="false" type="array" key="connection"/>
<update>
<enabled>false</enabled>
<intervals type="array" key="interval">
@@ -420,6 +365,6 @@
</intervals>
<url>https://cdn.blancco.com/products/drive_eraser/download/ota/drive_eraser.json</url>
</update>
<adapters type="array" key="adapter"/>
</configuration>
</root>
<!-- JFLKHGDJLKFHGJCXLKGJFLKDJKLHFELIUFDSALDSAHGLSAVSHGSOFHDSAF -->

View File

@@ -0,0 +1,285 @@
#!/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

@@ -0,0 +1,28 @@
-----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

@@ -0,0 +1,473 @@
#!/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

@@ -0,0 +1,190 @@
# 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

@@ -0,0 +1,96 @@
# 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

@@ -0,0 +1,198 @@
#!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

@@ -1,23 +0,0 @@
@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

@@ -1,43 +0,0 @@
@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

@@ -1,46 +0,0 @@
@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

@@ -1,38 +0,0 @@
# 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

@@ -1,11 +0,0 @@
@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

@@ -1,361 +0,0 @@
#!/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

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

View File

@@ -1,113 +0,0 @@
@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

@@ -0,0 +1,13 @@
{
"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,213 +1,13 @@
{
"Version": "1.0",
"Site": "West Jefferson",
"Applications": [
{
"_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.",
"Name": "PowerShell 7.5.4",
"Installer": "powershell7\\PowerShell-7.5.4-win-x64.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1",
"DetectionMethod": "Registry",
"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": ["*"]
},
{
"_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": ["*"]
},
{
"_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": ["*"]
},
{
"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": ["*"]
},
{
"_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": ["*"]
},
{
"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": ["*"]
},
{
"_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": ["*"]
},
{
"_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": ["*"]
},
{
"_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": ["*"]
},
{
"_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": ["*"]
},
{
"_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": ["*"]
},
{
"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": ["*"]
},
{
"_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": ["*"]
},
{
"_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": ["*"]
},
{
"_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": ["*"]
},
{
"_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": ["*"]
}
]
"_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'.",
"_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

@@ -0,0 +1,18 @@
# 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,10 +3,15 @@
# pxe-dhcp-hook.sh - dnsmasq dhcp-script hook.
#
# Runs every time a PXE client gets/changes/releases a DHCP lease on
# 172.16.9.0/24. Flushes conntrack entries and drops any lingering
# TCP sockets for that client IP. Prevents stale server-side state from
# causing "System error 53 - network path not found" when a WinPE client
# re-images the same machine without a clean SMB session teardown.
# 172.16.9.0/24. Flushes that client's SMB (TCP 445 and 139) conntrack
# entries and drops its lingering SMB sockets. Prevents stale server-side
# state from causing "System error 53 - network path not found" when a
# WinPE client re-images the same machine without a clean SMB session
# 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:
# $1 = action (add | old | del)
@@ -14,10 +19,21 @@
# $3 = client IP
# $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.
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"
mac="$2"
ip="$3"
@@ -25,13 +41,26 @@ host="${4:-}"
[ -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
add|del)
/usr/sbin/conntrack -D -s "$ip" >/dev/null 2>&1
/usr/sbin/conntrack -D -d "$ip" >/dev/null 2>&1
/usr/bin/ss -K "dst = $ip" >/dev/null 2>&1
/usr/bin/ss -K "src = $ip" >/dev/null 2>&1
logger -t pxe-dhcp-hook "cleared stale state for $action $ip ($mac${host:+ $host})"
clear_state "$action"
;;
old)
case "${DNSMASQ_VENDOR_CLASS:-}" in
PXEClient*) clear_state "old (network boot, ${DNSMASQ_VENDOR_CLASS%%:UNDI*})" ;;
esac
;;
esac

View File

@@ -0,0 +1,516 @@
#!/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

@@ -0,0 +1,145 @@
#!/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

@@ -0,0 +1,16 @@
# 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

@@ -0,0 +1,17 @@
# 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

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

View File

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

File diff suppressed because it is too large Load Diff

170
playbook/scripts/preflight.ps1 Executable file
View File

@@ -0,0 +1,170 @@
# preflight.ps1 - check the things PESetup fails on, before it fails on them.
#
# Called by startnet.cmd once the media is mapped. Prints a short report a tech
# can read at the bay and exits non-zero if a check is fatal.
#
# WHY EACH CHECK IS HERE - all four come from PESetup's own behaviour
# (docs/PESETUP-INTERNALS.md, decompiled 4.0.0.17, observed on 4.0.0.20):
#
# Secure boot GatherDataSelection fails the step outright when
# SecurebootEnabled != 1. Hard failure, minutes into a build.
# Disk size MinRequiredSpaceWithoutCompression is 128849018880 (120 GB).
# Driver match GetDriverByModel returns null on a miss and PESetup logs a
# WARNING and keeps going. The bay images with no drivers, so no
# NIC, so DNS fails at first boot and enrollment cannot reach the
# CDN. The symptom appears far from the cause - this is the check
# that earns the script.
# Media age The media expires 30 days after build. PESetup shows days-left
# on a screen nobody reads.
#
# Lives on the enrollment share so it can be fixed without rebuilding boot.wim.
[CmdletBinding()]
param(
[string]$MediaDrive = 'Z:',
[int]$MinDiskGB = 120,
[int]$MediaWarnDays = 25
)
$ErrorActionPreference = 'Continue'
$fatal = 0
$warn = 0
function Ok { param($m) Write-Host (" [ OK ] " + $m) }
function Warn { param($m) Write-Host (" [WARN] " + $m); $script:warn++ }
function Fail { param($m) Write-Host (" [FAIL] " + $m); $script:fatal++ }
Write-Host ""
Write-Host "======== Pre-imaging checks ========"
# --- 1. Secure boot ------------------------------------------------------
# Confirm-SecureBootUEFI is not always present in WinPE; read the state the
# firmware exposes instead.
try {
$sb = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State' `
-Name UEFISecureBootEnabled -ErrorAction Stop
if ($sb.UEFISecureBootEnabled -eq 1) {
Ok "Secure boot enabled"
} else {
Fail "Secure boot is OFF. PESetup will fail at GatherData. Enable it in BIOS."
}
} catch {
Warn "Could not read secure boot state - if this is a legacy/CSM boot, PESetup will fail."
}
# --- 2. Disk size --------------------------------------------------------
try {
$disk = Get-CimInstance Win32_DiskDrive -ErrorAction Stop |
Where-Object { $_.MediaType -like '*Fixed*' } |
Sort-Object Index | Select-Object -First 1
if ($disk) {
$gb = [math]::Round($disk.Size / 1GB, 1)
if ($gb -ge $MinDiskGB) {
Ok ("Disk 0 is {0} GB ({1})" -f $gb, $disk.Model)
} else {
Fail ("Disk 0 is only {0} GB; PESetup needs {1} GB. ({2})" -f $gb, $MinDiskGB, $disk.Model)
}
} else {
Fail "No fixed disk found. PESetup has nothing to image."
}
} catch {
Warn "Could not enumerate disks: $_"
}
# --- 3. Driver match for THIS model --------------------------------------
# Reimplements GetDriverByModel: family filter first (it knows only Latitude,
# OptiPlex and Precision), then a substring test of comma-separated tokens,
# first match wins. Tokens are NOT trimmed, matching the C#.
try {
$model = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).Model
$catalogue = Join-Path $MediaDrive 'Deploy\Control\HardwareDriver.json'
if (-not (Test-Path $catalogue)) {
Warn "HardwareDriver.json not found at $catalogue - cannot check drivers."
} else {
# NOT ConvertFrom-Json. HardwareDriver.json carries both casings of the
# same fields - "fileName" and "FileName", "destinationDir" and
# "DestinationDir". Windows PowerShell 5.1 (which is what WinPE runs)
# treats object keys case-insensitively and throws:
# "cannot convert the JSON string because a dictionary that was
# converted from the string contains the duplicated keys 'FileName'
# and 'FileName'"
# -AsHashtable would handle it but that is PowerShell 6+. So pull the
# four fields we need out of each entry by text instead. Prefers the
# lowercase key, falls back to the capitalised one.
$raw = Get-Content $catalogue -Raw
$entries = @()
foreach ($chunk in ([regex]::Split($raw, '\}\s*,\s*\{'))) {
$get = {
param($names)
foreach ($n in $names) {
$m = [regex]::Match($chunk, '"' + $n + '"\s*:\s*"((?:[^"\\]|\\.)*)"')
if ($m.Success) { return $m.Groups[1].Value -replace '\\\\', '\' }
}
return ''
}
$e = [pscustomobject]@{
modelswminame = (& $get @('modelswminame','models'))
family = (& $get @('family'))
fileName = (& $get @('fileName','FileName'))
destinationDir = (& $get @('destinationDir','DestinationDir'))
}
if ($e.modelswminame) { $entries += $e }
}
if (-not $entries.Count) {
Warn "Could not extract any entries from $catalogue - driver check skipped."
}
$family = ''
if ($model.ToUpper().Contains('LATITUDE')) { $family = 'Latitude' }
if ($model.ToUpper().Contains('OPTIPLEX')) { $family = 'Optiplex' }
if ($model.ToUpper().Contains('PRECISION')) { $family = 'Precision' }
$hit = $null
foreach ($e in $entries) {
if ($family -and ($e.family -notlike "*$family*")) { continue }
foreach ($tok in ([string]$e.modelswminame).Split(',')) {
if ($tok -and $model.ToLower().Contains($tok.ToLower())) { $hit = $e; break }
}
if ($hit) { break }
}
if ($hit) {
$zip = Join-Path $MediaDrive (([string]$hit.destinationDir) -replace '\*destinationdir\*\\?','')
$zip = Join-Path $zip $hit.fileName
if (Test-Path $zip) {
Ok ("Driver pack for '{0}': {1}" -f $model, $hit.fileName)
} else {
Fail ("Driver pack for '{0}' is listed but MISSING on the media: {1}" -f $model, $hit.fileName)
}
} else {
Fail ("NO driver pack matches '{0}'. PESetup logs this as a warning only - the bay will image with NO network drivers." -f $model)
}
}
} catch {
Warn "Driver check failed: $_"
}
# --- 4. Media age --------------------------------------------------------
# Approximate: PESetup expires media 30 days after build and Media.tag is
# rewritten when the media is rebuilt, so its timestamp is the best local proxy.
try {
$tag = Join-Path $MediaDrive 'Deploy\Control\Media.tag'
if (Test-Path $tag) {
$age = [int]((Get-Date) - (Get-Item $tag).LastWriteTime).TotalDays
if ($age -ge $MediaWarnDays) {
Warn ("Media is about {0} days old; it expires at 30. Rebuild it soon." -f $age)
} else {
Ok ("Media is about {0} days old" -f $age)
}
}
} catch { }
Write-Host "===================================="
if ($fatal -gt 0) {
Write-Host ""
Write-Host " $fatal BLOCKING problem(s) found. Imaging this bay will not work."
Write-Host ""
exit 1
}
if ($warn -gt 0) { Write-Host " $warn warning(s), no blockers." }
else { Write-Host " All checks passed." }
Write-Host ""
exit 0

View File

@@ -1,142 +0,0 @@
# 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,6 +6,11 @@ set "BIOSDIR=%~dp0"
set "FLASH=%BIOSDIR%Flash64W.exe"
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
echo Flash64W.exe not found, skipping BIOS check.
set "BIOS_STATUS=Skipped (Flash64W.exe missing)"

View File

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

@@ -0,0 +1,48 @@
# 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

@@ -0,0 +1,42 @@
# 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,84 @@
<#
.SYNOPSIS
Return the tenant + PC purpose, pre-seeded if WinPE already asked.
.DESCRIPTION
Start-BulkEnrollOrchestrator.ps1 calls this at first logon and captures
stdout:
$pcType = powershell.exe -File 'C:\Deploy\Applications\BPRT\Select-PCConfig.ps1' -LogoPath '...'
It uses the answer twice - to pick which MCL package to apply, and to select
which bulk token Insert-BPRTToPPKG.ps1 injects (it matches on Tenant AND
Purpose). The vendor's version asks with a GUI, which means an otherwise
unattended build stops at a dialog until somebody walks over.
startnet.cmd now asks the same question at the PXE menu, while the tech who
knows what the machine is for is standing at it, and writes the answer to
C:\Enrollment\pc-config.txt. This shim returns that value when present and
falls through to the vendor dialog when it is not - so nothing is lost if the
tech picks "ask at first logon", or on a bay imaged before this existed.
ONLY the choice is pre-seeded. Token injection still happens at first logon:
Insert-BPRTToPPKG.ps1 fetches an encrypted token table from
mcl.dwcdn.geaerospace.com, which is unreachable from the isolated imaging
LAN.
.OUTPUTS
"{Tenant}_{PCType}" - Tenant GCCH|RoW, PCType PERS|SH|LOAN.
Identical contract to the vendor script.
.NOTES
The vendor script is kept alongside as Select-PCConfig-vendor.ps1. Replace
THAT file when the vendor ships a new one; this shim only needs changing if
the return contract changes.
#>
param(
[Parameter(Mandatory = $false)]
[string]$LogoPath
)
$seedFile = 'C:\Enrollment\pc-config.txt'
$vendor = Join-Path $PSScriptRoot 'Select-PCConfig-vendor.ps1'
$logDir = 'C:\Logs\BPRT\Orchestrator'
$shimLog = Join-Path $logDir 'Select-PCConfig-shim.log'
function Log {
param([string]$Message)
try {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
Add-Content -Path $shimLog -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message)
} catch { }
}
# Valid values, so a typo in the seed file falls back to the dialog instead of
# handing the orchestrator a string that matches no token and no package.
$validTenants = @('GCCH', 'RoW')
$validPurposes = @('PERS', 'SH', 'LOAN')
if (Test-Path $seedFile) {
$seed = (Get-Content $seedFile -First 1 -ErrorAction SilentlyContinue)
if ($seed) { $seed = $seed.Trim() }
$parts = if ($seed) { $seed.Split('_') } else { @() }
if ($parts.Count -eq 2 -and $validTenants -contains $parts[0] -and $validPurposes -contains $parts[1]) {
Log "Pre-seeded value '$seed' from $seedFile - dialog skipped."
Write-Output $seed
exit 0
}
Log "Ignoring malformed pre-seed '$seed' in $seedFile; falling back to the dialog."
}
if (Test-Path $vendor) {
Log "No usable pre-seed; showing the vendor dialog."
if ($LogoPath) { & $vendor -LogoPath $LogoPath }
else { & $vendor }
exit $LASTEXITCODE
}
# Neither a seed nor the vendor dialog. Say so in the log rather than returning
# something invented - the orchestrator would inject the wrong token.
Log "ERROR: no pre-seed at $seedFile and no vendor script at $vendor."
exit 1

View File

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

View File

@@ -1,15 +1,18 @@
# Run-ShopfloorSetup.ps1 - Main dispatcher for shopfloor PC type setup.
# Run-ShopfloorSetup.ps1 - shopfloor setup (gea-shopfloor-standard).
#
# Called by the unattend FirstLogonCommands as SupportUser (admin), after
# Order 10 has already applied the PPKG (run-enrollment.ps1).
# Flow:
# 1. PreInstall apps (Oracle, VC++, OpenText, UDC) -- from local bundle
# 2. Type-specific scripts (eDNC, ACLs, CMM share apps, etc.)
# 3. Deferred baseline (desktop org, taskbar pins)
# 4. Copy desktop tools (sync_intune, Configure-PC, Set-MachineNumber)
# 5. Run enrollment (PPKG install + wait for completion)
# 6. Register sync_intune as @logon scheduled task
# 7. Reboot -- PPKG file operations complete, sync_intune fires on next logon
#
# Called by the unattend FirstLogonCommands as SupportUser (admin).
# 1. Site app list "during setup": C:\PreInstall\preinstall.json
# (Shopfloor\Install-AppList.ps1; empty in the generic build)
# 2. Copy desktop tools (sync_intune, Force-Lockdown, SetShopfloorAutoLogon)
# 3. Register the "Shopfloor Intune Sync" logon task (Monitor-IntuneProgress:
# waits for the PPKG/Intune lockdown, then hands the PC to ShopFloor)
# 4. Register "GE Shopfloor Post-PPKG" (SYSTEM, at startup): installs
# C:\PreInstall\postppkg.json once after the reboot (empty in the
# generic build) and clears AutoLogonCount after the ShopFloor handover
# 5. Network gate, run-enrollment (a no-op when Order 10 applied the PPKG),
# then Monitor-IntuneProgress -PostPpkg: settle and reboot
# --- Transcript logging ---
$logDir = 'C:\Logs\SFLD'
@@ -62,7 +65,7 @@ cmd /c "shutdown /a 2>nul" *>$null
# done work and continues from where it left off.
#
# Also top up AutoLogonCount so the SupportUser autologon budget
# (LogonCount=7 from unattend XML) survives extra unplanned reboots.
# (LogonCount=12 from unattend XML) survives extra unplanned reboots.
$selfResumeKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
$selfResumeName = 'ResumeRunShopfloorSetup'
$selfResumeCmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + $PSCommandPath + '"'
@@ -94,9 +97,51 @@ $enrollDir = "C:\Enrollment"
$typeFile = Join-Path $enrollDir "pc-type.txt"
$setupDir = Join-Path $enrollDir "shopfloor-setup"
# NOTE: there is deliberately NO wait for an Entra join here - but NOT because
# shopfloor bays skip enrollment. They do enrol: the SFLD provisioning package
# joins Entra using the BPRT token it carries, and a human then assigns the
# device category in Intune.
#
# The wait is absent because the join cannot happen yet. At this point the bay is
# still on the isolated PXE LAN with no route to Entra - observed on 579C144
# 2026-08-06, holding 172.16.9.81 and 172.24.19.142, neither in the production
# ranges. sync_intune retries every 30 seconds until the tech re-cables to
# production, which is the right place to wait. An earlier version blocked here
# for 45 minutes and then warned about a failure that had not happened.
# "Entra ID Joined: false" in C:\Logs\BPRT\criticalChecks.json straight after
# imaging is therefore NORMAL, not a fault.
#
# CORRECTION (2026-08-06): this comment previously claimed shopfloor PCs are
# "vanilla by design" and that the orchestrator runs with -ManualFallback to skip
# BPRT injection and the package entirely. That was wrong and dangerous.
# -ManualFallback runs sysprep /oobe /reboot, so wiring it to an at-logon task
# syspreped finished machines seconds after autologon and destroyed the
# deployment chain. See docs/OWNERSHIP.md.
if (-not (Test-Path $typeFile)) {
Write-Host "No pc-type.txt found - skipping shopfloor setup."
exit 0
# A missing pc-type.txt means one of two very different things, and the old
# blanket "skip + exit 0" hid the bad one for weeks: four Display bays sat
# at imaging stage 2 with a green exit code and nobody noticed.
# - no C:\Enrollment at all -> this machine was never staged by WinPE
# (pre-imaging, or the staging block never ran). That is a FAILURE on a
# machine that has clearly just been imaged, so say so loudly.
# - C:\Enrollment exists but no pc-type.txt -> staging ran and the write
# failed. Also a failure.
$stagingLog = Join-Path $enrollDir 'winpe-staging.log'
$detail = if (Test-Path $enrollDir) {
"C:\Enrollment exists but pc-type.txt is missing - WinPE staging ran but did not write it. Check $stagingLog."
} else {
"C:\Enrollment does not exist - WinPE staging never ran. The Windows volume was probably not found in startnet.cmd, so pc-type.txt, the enrollment package and shopfloor-setup were ALL skipped."
}
Write-Host ""
Write-Host "================================================================"
Write-Host " FAILED: no pc-type.txt at $typeFile"
Write-Host " $detail"
Write-Host " Shopfloor setup cannot run. This PC is imaged but NOT configured."
Write-Host "================================================================"
Write-Host ""
Report-Stage -Stage 'Run-ShopfloorSetup: FAILED - no pc-type.txt' -Index 2 -Status 'failed' -Error_ $detail
exit 1
}
$pcType = (Get-Content $typeFile -First 1).Trim()
@@ -105,149 +150,27 @@ if (-not $pcType) {
exit 0
}
$subtypeFile = Join-Path $enrollDir "pc-subtype.txt"
$pcSubType = ''
if (Test-Path $subtypeFile) {
$pcSubType = (Get-Content $subtypeFile -First 1).Trim()
}
Write-Host "Shopfloor PC Type: $pcType"
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
}
# --- 1. Site app list, during setup ---
Report-Stage -Stage 'Run-ShopfloorSetup: installing the site app list' -Index 3
$appList = Join-Path $setupDir 'Shopfloor\Install-AppList.ps1'
if (Test-Path -LiteralPath $appList) {
cmd /c "shutdown /a 2>nul" *>$null
Write-Host "Running deferred baseline: $name"
try {
& $script
& $appList -ListPath 'C:\PreInstall\preinstall.json' -Phase 'PreInstall'
} catch {
Write-Warning "Deferred script $name failed: $_"
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 setup complete for $pcType."
# --- Copy utility scripts to SupportUser desktop ---
foreach ($tool in @('sync_intune.bat', 'Configure-PC.bat', 'Force-Lockdown.bat', 'SetShopfloorAutoLogon.bat')) {
# --- 2. Copy utility scripts to SupportUser desktop ---
foreach ($tool in @('sync_intune.bat', 'Force-Lockdown.bat', 'SetShopfloorAutoLogon.bat')) {
$src = Join-Path $setupDir "Shopfloor\$tool"
if (Test-Path $src) {
Copy-Item -Path $src -Destination "C:\Users\SupportUser\Desktop\$tool" -Force
@@ -255,28 +178,7 @@ foreach ($tool in @('sync_intune.bat', 'Configure-PC.bat', 'Force-Lockdown.bat',
}
}
# 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 ---
# --- 3. Register sync_intune as persistent @logon scheduled task ---
# Must be registered BEFORE enrollment because Install-ProvisioningPackage
# triggers an immediate reboot that kills run-enrollment.ps1. The task
# registration must survive the PPKG reboot, so we do it here while
@@ -288,13 +190,12 @@ if ($needsMachineNumberHelper) {
# PPKG reboot, and is irrelevant to the Intune lifecycle.
$taskName = 'Shopfloor Intune Sync'
$monitorScript = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1'
$configureScript = Join-Path $setupDir 'Shopfloor\Configure-PC.ps1'
if (Test-Path -LiteralPath $monitorScript) {
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -NoExit -ExecutionPolicy Bypass -File `"$monitorScript`" -AsTask -ConfigureScript `"$configureScript`""
-Argument "-NoProfile -NoExit -ExecutionPolicy Bypass -File `"$monitorScript`" -AsTask"
$trigger = New-ScheduledTaskTrigger -AtLogOn
@@ -325,94 +226,13 @@ if (Test-Path -LiteralPath $monitorScript) {
Write-Warning "Monitor-IntuneProgress.ps1 not found at $monitorScript"
}
# Set auto-logon to expire after 4 more logins (2 needed for sync_intune
# pre-reboot + post-reboot, plus 2 margin for unexpected reboots from
# Windows Update, PPKG file operations, or script crashes).
# (AutoLogonCount intentionally not set -- see comment at top of script)
# --- 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: $_"
}
# --- 4. Post-PPKG app list + AutoLogonCount cleanup (SYSTEM task) ---
# Registered before the PPKG step: the PPKG reboot can kill this script.
$registerPost = Join-Path $setupDir 'Shopfloor\Register-PostPpkgTask.ps1'
if (Test-Path -LiteralPath $registerPost) {
try { & $registerPost } catch { Write-Warning "Post-PPKG task registration failed: $_" }
} else {
Write-Warning "Register-GEEnforce.ps1 not found - no ongoing enforcement will run on this PC"
}
# Map S: drive on user logon for every account in BUILTIN\Users. The
# vendor 'SFLD - Consume Credentials' task is principal-restricted and
# does not fire for the ShopFloor end-user, so this parallel task fills
# the gap. Cross-PC-type because every shopfloor account needs S:.
# 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"
Write-Warning "Register-PostPpkgTask.ps1 not found at $registerPost"
}
# --- Run enrollment (PPKG install) ---

View File

@@ -1,598 +0,0 @@
# 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

@@ -1,127 +0,0 @@
# 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

@@ -1,15 +0,0 @@
# 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

@@ -1,101 +0,0 @@
# 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

@@ -1,634 +0,0 @@
# 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'
if ($null -ne $cfgApps -and $cfgApps.Count -gt 0) {
$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'
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

@@ -1,156 +0,0 @@
# 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'
if ($null -ne $cfgPins -and $cfgPins.Count -gt 0) {
$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

@@ -1,319 +0,0 @@
# 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 = @()
if ($null -ne $cfgTabs -and $cfgTabs.Count -gt 0) {
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-dashboard/'
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

@@ -1,89 +0,0 @@
# 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

@@ -1,115 +0,0 @@
# 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

@@ -1,129 +0,0 @@
# 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

@@ -1,23 +0,0 @@
@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

@@ -1,504 +0,0 @@
# 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'
if ($null -ne $cfgItems -and $cfgItems.Count -gt 0) {
$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,6 +70,10 @@ if "%RC%"=="0" (
if not exist "C:\Enrollment" mkdir "C:\Enrollment"
> "C:\Enrollment\force-lockdown-applied.txt" echo %DATE% %TIME%
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.

View File

@@ -0,0 +1,364 @@
# 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

@@ -0,0 +1,86 @@
# 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

@@ -1,200 +0,0 @@
# 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

@@ -1,149 +0,0 @@
# 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': $_" }
# 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

@@ -1,79 +0,0 @@
# Register-MapSfldShare.ps1 - Stage Map-SfldShare.ps1 + register an
# HKLM\Run entry that maps S: for any interactive user (SupportUser,
# ShopFloor, any future end-user accounts).
#
# Why HKLM\Run instead of a scheduled task: Run fires at Explorer
# startup in the logged-in user's interactive session with their full
# token + HKCU mounted. No principal/LogonType/group-SID plumbing, no
# "task fires in session 0 but drive not visible to Explorer" class of
# bugs. Works for every BUILTIN\Users member with no extra logic.
#
# Why not the vendor's ConsumeCredentials.ps1: it calls
# New-StoredCredential -Persist LocalMachine (needs admin) before net
# use. ShopFloor is non-admin, so the cred-store fails and net use has
# no auth. Our Map-SfldShare.ps1 reads HKLM creds directly and passes
# them inline to net use /user: -- no Credential Manager needed.
$ErrorActionPreference = 'Continue'
$installRoot = 'C:\Program Files\GE\SfldShare'
$mapScript = Join-Path $installRoot 'Map-SfldShare.ps1'
$logDir = 'C:\Logs\SFLD'
$logFile = Join-Path $logDir 'register-mapshare.log'
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
$runValue = 'GE Map SFLD Share'
$legacyTask = 'GE Shopfloor Map S: Drive'
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
}
Write-RegLog '=== Register-MapSfldShare start ==='
# Stage our Map-SfldShare.ps1 to a persistent location
if (-not (Test-Path $installRoot)) {
New-Item -Path $installRoot -ItemType Directory -Force | Out-Null
}
$src = Join-Path $PSScriptRoot 'lib\Map-SfldShare.ps1'
if (Test-Path $src) {
Copy-Item -Path $src -Destination $mapScript -Force
Write-RegLog "Staged $src -> $mapScript"
} else {
Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"
exit 1
}
# Remove the legacy scheduled task if it exists (left behind by older
# imaging runs that used the scheduled-task approach).
if (Get-ScheduledTask -TaskName $legacyTask -ErrorAction SilentlyContinue) {
try {
Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -ErrorAction Stop
Write-RegLog "Removed legacy scheduled task '$legacyTask'"
} catch {
Write-RegLog "Failed to remove legacy task '$legacyTask': $_"
}
}
# Register HKLM\Run entry. Runs at Explorer startup for every
# interactive user in that user's session.
try {
$command = '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"' -f `
"$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe", $mapScript
if (-not (Test-Path $runKey)) {
New-Item -Path $runKey -Force | Out-Null
}
New-ItemProperty -Path $runKey -Name $runValue -Value $command -PropertyType String -Force | Out-Null
Write-RegLog "Set $runKey\$runValue = $command"
} catch {
Write-RegLog "FAILED to register Run key: $_"
exit 1
}
Write-RegLog '=== Register-MapSfldShare end ==='
exit 0

View File

@@ -0,0 +1,32 @@
# 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,3 +10,8 @@ if %errorlevel% neq 0 (
"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

@@ -1,51 +0,0 @@
'----------------------------------------------------------------------
' 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

@@ -1,35 +0,0 @@
'----------------------------------------------------------------------
' 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

@@ -1,56 +0,0 @@
'----------------------------------------------------------------------
' 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

@@ -1,9 +0,0 @@
[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

@@ -1,13 +0,0 @@
[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

@@ -1,23 +0,0 @@
[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

@@ -1,474 +0,0 @@
[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

View File

@@ -1,106 +0,0 @@
[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

@@ -1,122 +0,0 @@
[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

@@ -1,122 +0,0 @@
[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

@@ -1,128 +0,0 @@
[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

View File

@@ -1,122 +0,0 @@
# 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
}
}
}
}
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

@@ -1,100 +0,0 @@
# 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

@@ -1,68 +0,0 @@
# Map-SfldShare.ps1 - Map S: drive on user logon using SFLD creds from HKLM.
#
# Runs as the interactive user (BUILTIN\Users, Limited) so the drive
# mapping lands in the logged-in user's session. Reads username/password
# directly from HKLM:\SOFTWARE\GE\SFLD\Credentials\* and passes them
# inline to net use -- no Windows Credential Manager involvement.
#
# Why not the vendor's ConsumeCredentials.ps1: it calls
# New-StoredCredential -Persist LocalMachine which requires admin.
# ShopFloor is a non-admin user, so the cred-store step fails silently
# and the subsequent net use (which relies on those stored creds) has
# no authentication. Direct net use /user: bypasses the issue entirely.
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) {
try { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$logFile = Join-Path $logDir 'map-share.log'
function Write-MapLog {
param([string]$Message)
$line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $env:USERNAME, $Message
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
}
Write-MapLog '=== Map-SfldShare start ==='
$credsBase = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
if (-not (Test-Path $credsBase)) {
Write-MapLog 'No HKLM SFLD credentials yet - exiting'
exit 0
}
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 = $null
$share = $null
try { $drive = $p.DriveLetter } catch {}
try { $share = $p.ShareName } catch {}
if ([string]::IsNullOrWhiteSpace($drive) -or [string]::IsNullOrWhiteSpace($share)) { continue }
$drive = $drive.TrimEnd(':') + ':'
$share = $share.TrimStart('\')
$uncPath = "\\$($p.TargetHost)\$share"
# Skip if already mapped to the right target
$existing = & net use $drive 2>&1
if ($LASTEXITCODE -eq 0 -and ($existing -join "`n") -match [regex]::Escape($uncPath)) {
Write-MapLog "$drive already mapped to $uncPath - skipping"
continue
}
& net use $drive /delete /y 2>$null | Out-Null
$out = & net use $drive $uncPath /user:$($p.Username) $($p.Password) /persistent:yes 2>&1
if ($LASTEXITCODE -eq 0) {
Write-MapLog "Mapped $drive -> $uncPath"
} else {
Write-MapLog "FAILED $drive -> $uncPath (exit $LASTEXITCODE): $out"
}
}
Write-MapLog '=== Map-SfldShare end ==='
exit 0

View File

@@ -249,6 +249,27 @@ function Get-Phase1 {
# the force-lockdown marker has been written by sfld_autologon.ps1.
# When either signal is present, push idx=8 / status=succeeded so the
# 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) {
$lockdownApplied = $false
try {
@@ -926,12 +947,9 @@ function Format-Snapshot {
@{ Ok = $Snap.Phase3.InstallComplete; Failed = $false }
)
# Phase 4 (per-script Application Install) was retired 2026-04-28:
# apps now deploy via the GE-Enforce manifest engine off
# \\tsgwp00525\...\machineapps\, gated on AESFMA WiFi + S: mount
# 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.
# site apps install from the post-PPKG list after the reboot
# (Invoke-PostPpkg.ps1). The monitor's responsibility ends at
# "DSC bootstrap + creds + lockdown landed, time to reboot."
$p4Done = ($Snap.Phase5.ConsumeCredsTask -and $Snap.Phase5.CredsPopulated)
$p4Status = Get-PhaseStatus @(
@@ -1229,7 +1247,7 @@ if ($AsTask -and (Test-Path -LiteralPath $syncCompleteMarker)) {
# For these types, enrollment is "done" once Phase 1 (Identity) completes
# and policies start arriving - there is no Phase 2-5 to wait for.
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
$noDscTypes = @('Display', 'gea-shopfloor-display')
$noDscTypes = @()
$skipDsc = $false
if (Test-Path $pcTypeFile) {
$pcType = (Get-Content $pcTypeFile -First 1).Trim()
@@ -1287,29 +1305,14 @@ try {
$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
Format-Snapshot -Snap $snap -LastSync $lastSync -NextRetrigger $nextRetrigger
Write-Host ""
Write-Host $qrText
# Final state: every phase landed. Apps are no longer in the gate -
# they deploy via the GE-Enforce manifest engine post-reboot, gated
# on AESFMA WiFi + S: drive mount which only happen after we hand
# off to the ShopFloor autologon session. So this gate is now:
# the site's post-PPKG list installs them after the reboot. So this
# gate is now:
# - CredsPopulated: SFLD share creds in HKLM (Machine-Enforce,
# Acrobat-Enforce, CMM-Enforce, manifest
# engine all need these)

View File

@@ -1,88 +0,0 @@
# 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

@@ -1,44 +0,0 @@
# 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
# 2. Next to this script ($PSScriptRoot\QRCoder.dll)
# 3. Current working directory
# 4. WJ canonical staging path (kept for cross-site portability)
# 4. The standard staging path (C:\Enrollment\shopfloor-setup\Shopfloor)
# ----------------------------------------------------------------------------
function Resolve-QRCoderDll {
param([string]$Override)

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