93 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
829 changed files with 40601 additions and 87376 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

6
.gitignore vendored
View File

@@ -126,3 +126,9 @@ 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`.

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

File diff suppressed because one or more lines are too long

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

@@ -61,6 +61,14 @@
<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>
@@ -250,6 +258,16 @@
<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>
@@ -301,6 +319,11 @@
<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>
@@ -324,6 +347,11 @@
<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>
@@ -364,15 +392,21 @@
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>11</Order>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Deploy\Applications\extra_engineering\Install-EngineeringLayer.ps1"</CommandLine>
<!-- Copies the 653 MB payload out of C:\Deploy to C:\PreInstall\engineering
BEFORE installing: the bulk-enrollment orchestrator registers a
DeleteDeployDir task that removes C:\Deploy on the next boot, and this
installer (.NET 3.5, VC++, Cygwin, DUU) can reboot part way through.
Idempotent, marker-gated. Description is capped at 256 chars, so the
detail lives here. -->
<Description>Engineering Layer: stage payload out of C:\Deploy then install</Description>
<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>

View File

@@ -213,7 +213,7 @@
<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 from the PXE share (CMM/Keyence/WaxTrace bundles + bay backup) on the imaging LAN before the production-network switch. Log C:\Logs\Fetch.</Description>
<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>

View File

@@ -61,6 +61,14 @@
<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>
@@ -301,6 +309,11 @@
<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>
@@ -324,6 +337,11 @@
<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>
@@ -362,6 +380,11 @@
<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>

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

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,56 +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 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Micro Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
SFF 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
SFF Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
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 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

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

View File

@@ -6,7 +6,7 @@
# (Y:) early, then idles for many minutes while the full Windows image applies.
# 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'
@@ -147,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
@@ -297,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
@@ -330,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
@@ -367,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,646 +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'
# An EMPTY configured list means "no desktop apps" and must be honoured.
# Get-ProfileValue returns $null only when the key is absent from both the
# profile and site-config, so $null is the real "not configured" signal.
#
# The old test also required .Count -gt 0, which meant "desktopApps": []
# fell through to the hardcoded shopfloor list below - so a Display kiosk,
# which explicitly configures an empty list, was given UDC, eDNC, NTLARS,
# WJ Shopfloor and Defect_Tracker shortcuts. Configuring "none" produced
# "everything". Observed on 579C144, 2026-08-06.
if ($null -ne $cfgApps) {
$apps = @($cfgApps | ForEach-Object {
$entry = @{ Name = $_.name; Kind = $_.kind }
if ($_.kind -eq 'exe') { $entry.ExePath = $_.exePath }
if ($_.kind -eq 'existing') { $entry.SourceName = $_.sourceName }
$entry
})
} else {
$apps = @(
@{ Name = 'UDC'; Kind = 'exe'; ExePath = 'C:\Program Files\UDC\UDC.exe' }
@{ Name = 'eDNC'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\Dnc\bin\DncMain.exe' }
@{ Name = 'NTLARS'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\Dnc\Common\NTLARS.exe' }
@{ Name = 'WJ Shopfloor'; Kind = 'existing'; SourceName = 'WJ Shopfloor.lnk' }
@{ Name = 'Defect_Tracker'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\WJF_Defect_Tracker\Defect_Tracker.exe' }
)
}
# Lazy folder creation - only create Shopfloor Tools\ the first time
# we have an app that's actually going to land in it. PC types with
# nothing installed get no empty folder.
$ensureToolsDir = {
if (-not (Test-Path -LiteralPath $shopfloorToolsDir)) {
try {
New-Item -ItemType Directory -Path $shopfloorToolsDir -Force -ErrorAction Stop | Out-Null
return $true
} catch {
Write-Warning "Failed to create $shopfloorToolsDir : $_"
return $false
}
}
return $true
}
foreach ($app in $apps) {
$dest = Join-Path $shopfloorToolsDir "$($app.Name).lnk"
switch ($app.Kind) {
'exe' {
if (Test-Path -LiteralPath $app.ExePath) {
if (-not (& $ensureToolsDir)) { break }
if (New-ShopfloorLnk -Path $dest -Target $app.ExePath) {
Write-Host " created: $($app.Name) -> $dest"
}
} else {
Write-Host " skip: $($app.Name) - target not installed ($($app.ExePath))" -ForegroundColor DarkGray
}
}
'existing' {
$src = Find-ExistingLnk $app.SourceName
if ($src) {
# Skip if the sweep already moved the source into the
# destination folder (src == dest -> "cannot overwrite
# the item with itself").
if ((Resolve-Path -LiteralPath $src -ErrorAction SilentlyContinue).Path -eq
(Join-Path $shopfloorToolsDir $app.SourceName)) {
Write-Host " exists: $($app.Name) (already in Shopfloor Tools)"
} else {
if (-not (& $ensureToolsDir)) { break }
try {
Copy-Item -LiteralPath $src -Destination $dest -Force -ErrorAction Stop
Write-Host " copied: $($app.Name) from $src"
} catch {
Write-Warning "Failed to copy $src -> $dest : $_"
}
}
} else {
Write-Host " skip: $($app.Name) - no source .lnk found" -ForegroundColor DarkGray
}
}
}
}
}
# ============================================================================
# Phase 3: Remove empty category folders
#
# If the sweep classified nothing into a given category (e.g. no Office
# on a Display PC, no Shopfloor Tools apps on a kiosk), we don't want an
# empty folder cluttering the desktop. The scheduled-task sweep also runs
# this so categories that go empty between logons self-heal.
# ============================================================================
function Remove-EmptyCategoryFolders {
foreach ($cat in @('Office', 'Shopfloor Tools', 'Web Links')) {
$dir = Join-Path $publicDesktop $cat
if (-not (Test-Path -LiteralPath $dir)) { continue }
$contents = @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue)
if ($contents.Count -eq 0) {
try {
Remove-Item -LiteralPath $dir -Force -ErrorAction Stop
Write-Host " removed empty: $cat\"
} catch {
Write-Warning "Failed to remove empty $dir : $_"
}
}
}
}
# ============================================================================
# Phase 4: Edge default browser + startup tabs (delegated to 08)
#
# 08-EdgeDefaultBrowser.ps1 resolves startup-tab URLs from .url files on
# the Public Desktop (or in Web Links\ after the sweep). The .url files
# are delivered by DSC AFTER this initial shopfloor setup, so the first
# run at imaging time won't find them (falls back to hardcoded URLs,
# Plant Apps gets skipped). By calling 08 from inside 06, every SYSTEM
# scheduled-task logon re-run of 06 also re-runs 08 -- so after DSC drops
# the .url files and the next sweep files them into Web Links\, 08
# picks them up and updates the Edge policy. Self-healing.
# ============================================================================
function Invoke-EdgeDefaultBrowser {
$edgeScript = Join-Path $scriptDir '08-EdgeDefaultBrowser.ps1'
if (-not (Test-Path -LiteralPath $edgeScript)) {
Write-Host " 08-EdgeDefaultBrowser.ps1 not found - skipping"
return
}
try {
& $edgeScript
} catch {
Write-Warning "08-EdgeDefaultBrowser.ps1 failed: $_"
}
}
# ============================================================================
# Scheduled task registration (re-runs 06 at every logon as SYSTEM)
# ============================================================================
function Register-SweepScheduledTask {
param([string]$ScriptPath)
$taskName = 'Organize Public Desktop'
if (-not (Test-Path -LiteralPath $ScriptPath)) {
Write-Warning "Cannot register scheduled task - script not found at $ScriptPath"
return
}
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$ScriptPath`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Run as SYSTEM so the script can (a) pass its admin check at the
# top, (b) write to Public Desktop / ProgramData Start Menu / the
# Default User profile without Access Denied, and (c) register the
# next iteration of itself idempotently. Earlier versions used
# -GroupId 'BUILTIN\Users' -RunLevel Limited which fired at logon
# but the script exited immediately on the non-admin check.
$principal = New-ScheduledTaskPrincipal `
-UserId 'NT AUTHORITY\SYSTEM' `
-LogonType ServiceAccount `
-RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5)
# -ErrorAction Stop is required because Register-ScheduledTask emits
# non-terminating errors on failure (Access Denied etc), which try/catch
# would otherwise miss and we'd falsely claim success.
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Force `
-ErrorAction Stop | Out-Null
Write-Host "Scheduled task '$taskName' registered (runs at every logon)."
} catch {
Write-Warning "Failed to register scheduled task : $_"
}
}
# ============================================================================
# Main
# ============================================================================
Write-Host ""
Write-Host "=== Phase 0: create Office shortcuts (if Office is installed) ==="
# Delegated to 05-OfficeShortcuts.ps1. Office is installed via ppkg but
# doesn't finish streaming until AFTER the first reboot, so the first
# imaging-time run of 06 finds no Office and this no-ops. On the second
# logon (after reboot), Office is installed and 05 creates the shortcuts
# so Phase 1 below can sweep them into the Office\ folder.
$officeScript = Join-Path $scriptDir '05-OfficeShortcuts.ps1'
if (Test-Path -LiteralPath $officeScript) {
try { & $officeScript } catch { Write-Warning "05-OfficeShortcuts.ps1 failed: $_" }
} else {
Write-Host " 05-OfficeShortcuts.ps1 not found - skipping"
}
Write-Host ""
Write-Host "=== Phase 1: sweep loose shortcuts into category folders ==="
Invoke-DesktopSweep -DesktopPath $publicDesktop
Write-Host ""
Write-Host "=== Phase 2: populate Shopfloor Tools with app shortcuts ==="
Add-ShopfloorToolsApps
Write-Host ""
Write-Host "=== Phase 3: auto-apply startup items from PC profile ==="
# Creates .lnk files in AllUsers Startup folder based on the profile's
# startupItems. This runs as SYSTEM at every logon via the sweep scheduled
# task, so any item the tech removes via Configure-PC would bounce right
# back unless we remember the removal. startup-overrides.json tracks labels
# the tech has explicitly suppressed; items in that list are skipped here
# and re-enabling them via Configure-PC removes them from the list.
$startupDir = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup'
$overridesPath = 'C:\ProgramData\GE\Shopfloor\startup-overrides.json'
$suppressed = @()
if (Test-Path -LiteralPath $overridesPath) {
try {
$overrides = Get-Content -LiteralPath $overridesPath -Raw | ConvertFrom-Json
if ($overrides.suppressed) { $suppressed = @($overrides.suppressed) }
} catch {
Write-Warning " Failed to parse $overridesPath : $_"
}
}
$cfgStartup = Get-ProfileValue 'startupItems'
# Same rule as desktopApps: $null = not configured, empty array = configured as
# none. Harmless here today because the else branch has no hardcoded fallback,
# but kept consistent so the two keys cannot drift apart again.
if ($null -ne $cfgStartup -and $cfgStartup.Count -gt 0) {
if (-not (Test-Path $startupDir)) {
New-Item -ItemType Directory -Path $startupDir -Force | Out-Null
}
$edgePath = @(
'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files\Microsoft\Edge\Application\msedge.exe'
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
foreach ($si in $cfgStartup) {
if ($suppressed -contains $si.label) {
Write-Host " suppressed: $($si.label) (tech disabled via Configure-PC)"
continue
}
$lnkPath = Join-Path $startupDir "$($si.label).lnk"
# Skip if already exists (idempotent - don't overwrite user changes)
if (Test-Path -LiteralPath $lnkPath) {
Write-Host " exists: $($si.label)"
continue
}
switch ($si.type) {
'exe' {
if (Test-Path -LiteralPath $si.target) {
if (New-ShopfloorLnk -Path $lnkPath -Target $si.target) {
Write-Host " added: $($si.label)"
}
} else {
Write-Host " skip: $($si.label) - target not installed" -ForegroundColor DarkGray
}
}
'existing' {
$src = Find-ExistingLnk $si.sourceLnk
if ($src) {
try {
Copy-Item -LiteralPath $src -Destination $lnkPath -Force
Write-Host " added: $($si.label)"
} catch { Write-Warning " failed: $($si.label) - $_" }
} else {
Write-Host " skip: $($si.label) - source not found" -ForegroundColor DarkGray
}
}
'url' {
$url = $null
if ($si.urlKey -and $siteConfig -and $siteConfig.urls) {
$url = $siteConfig.urls.$($si.urlKey)
}
if ($url -and $edgePath) {
if (New-ShopfloorLnk -Path $lnkPath -Target $edgePath -Arguments "--new-window `"$url`"") {
Write-Host " added: $($si.label)"
}
} else {
Write-Host " skip: $($si.label) - URL or Edge not available" -ForegroundColor DarkGray
}
}
}
}
} else {
Write-Host " (no startup items in profile)"
}
Write-Host ""
Write-Host "=== Phase 4: remove empty category folders ==="
Remove-EmptyCategoryFolders
Write-Host ""
Write-Host "=== Phase 5: Edge default browser + startup tabs ==="
Invoke-EdgeDefaultBrowser
Write-Host ""
Write-Host "=== Phase 6: register logon sweeper scheduled task ==="
Register-SweepScheduledTask -ScriptPath $scriptPath
Write-Host ""
Write-Host "=== 06-OrganizeDesktop session end ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) ==="
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -1,164 +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'
# An EMPTY configured list means "no pins" and must be honoured. Get-ProfileValue
# returns $null only when the key is absent from BOTH the profile and
# site-config, so $null is the real "not configured" signal. Requiring
# .Count -gt 0 as well makes "taskbarPins": [] fall through to the hardcoded
# shopfloor list - configuring "none" producing "everything", the same bug that
# put Plant Apps and Defect Tracker in a Display kiosk's Startup folder
# (Configure-PC.ps1) and machine-tool shortcuts on its desktop
# (06-OrganizeDesktop.ps1).
if ($null -ne $cfgPins) {
$pinSpec = @($cfgPins | ForEach-Object {
@{
Name = $_.name
Path = $_.lnkPath
Literal = [Environment]::ExpandEnvironmentVariables($_.lnkPath)
}
})
} else {
$pinSpec = @(
@{
Name = 'Microsoft Edge'
Path = '%ALLUSERSPROFILE%\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk'
Literal = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk'
}
@{
Name = 'WJ Shopfloor'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\WJ Shopfloor.lnk'
Literal = (Join-Path $shopfloorToolsDir 'WJ Shopfloor.lnk')
}
@{
Name = 'UDC'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\UDC.lnk'
Literal = (Join-Path $shopfloorToolsDir 'UDC.lnk')
}
@{
Name = 'eDNC'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\eDNC.lnk'
Literal = (Join-Path $shopfloorToolsDir 'eDNC.lnk')
}
@{
Name = 'NTLARS'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\NTLARS.lnk'
Literal = (Join-Path $shopfloorToolsDir 'NTLARS.lnk')
}
@{
Name = 'Defect_Tracker'
Path = '%PUBLIC%\Desktop\Shopfloor Tools\Defect_Tracker.lnk'
Literal = (Join-Path $shopfloorToolsDir 'Defect_Tracker.lnk')
}
)
}
# ============================================================================
# Build the pin list - skip any whose .lnk is missing
# ============================================================================
Write-Host ""
Write-Host "Checking which Shopfloor Tools shortcuts exist..."
$pinPaths = @()
foreach ($pin in $pinSpec) {
if (Test-Path -LiteralPath $pin.Literal) {
Write-Host " pin: $($pin.Name) -> $($pin.Literal)"
$pinPaths += $pin.Path
} else {
Write-Host " skip: $($pin.Name) - not found at $($pin.Literal)" -ForegroundColor DarkGray
}
}
if ($pinPaths.Count -eq 0) {
Write-Warning "No pins to apply (nothing found in Shopfloor Tools\). Did 06-OrganizeDesktop.ps1 run first?"
exit 0
}
# ============================================================================
# Write LayoutModification.xml
# ============================================================================
$pinXml = ($pinPaths | ForEach-Object {
" <taskbar:DesktopApp DesktopApplicationLinkPath=`"$_`"/>"
}) -join "`r`n"
$layoutXml = @"
<?xml version="1.0" encoding="utf-8"?>
<LayoutModificationTemplate
xmlns="http://schemas.microsoft.com/Start/2014/LayoutModification"
xmlns:defaultlayout="http://schemas.microsoft.com/Start/2014/FullDefaultLayout"
xmlns:start="http://schemas.microsoft.com/Start/2014/StartLayout"
xmlns:taskbar="http://schemas.microsoft.com/Start/2014/TaskbarLayout"
Version="1">
<CustomTaskbarLayoutCollection PinListPlacement="Replace">
<defaultlayout:TaskbarLayout>
<taskbar:TaskbarPinList>
$pinXml
</taskbar:TaskbarPinList>
</defaultlayout:TaskbarLayout>
</CustomTaskbarLayoutCollection>
</LayoutModificationTemplate>
"@
try {
if (-not (Test-Path -LiteralPath $defaultUserShell)) {
New-Item -ItemType Directory -Path $defaultUserShell -Force -ErrorAction Stop | Out-Null
}
# -ErrorAction Stop so Access Denied becomes a catchable terminating error
# instead of silently falling through to the success message.
Set-Content -LiteralPath $layoutXmlPath -Value $layoutXml -Encoding UTF8 -Force -ErrorAction Stop
Write-Host ""
Write-Host "Wrote taskbar layout with $($pinPaths.Count) pin(s) to:"
Write-Host " $layoutXmlPath"
Write-Host ""
Write-Host "Pins will apply on first logon of any NEW user profile."
} catch {
Write-Warning "Failed to write $layoutXmlPath : $_"
exit 1
}
exit 0

View File

@@ -1,325 +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 = @()
# Empty configured list = "no startup tabs", and must be honoured. $null (key
# absent from both profile and site-config) is the only "not configured" signal.
# Requiring .Count -gt 0 would make "edgeStartupTabs": [] fall through to the
# Plant Apps + WJ Shopfloor + Dashboard fallback below - the same
# configuring-none-gives-everything bug fixed in Configure-PC.ps1,
# 06-OrganizeDesktop.ps1 and 07-TaskbarLayout.ps1.
if ($null -ne $cfgTabs) {
foreach ($tab in $cfgTabs) {
$fallback = if ($tab.fallbackUrlKey -and $siteConfig.urls) { $siteConfig.urls.$($tab.fallbackUrlKey) } else { '' }
$url = Resolve-StartupUrl -BaseName $tab.baseName -Fallback $fallback
if ($url) { $startupTabs += $url }
}
} else {
$plantApps = Resolve-StartupUrl -BaseName 'Plant Apps' -Fallback 'https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications'
if ($plantApps) { $startupTabs += $plantApps }
$shopFloorHome = Resolve-StartupUrl -BaseName 'WJ Shop Floor Homepage' -Fallback 'https://tsgwp00525.wjs.geaerospace.net'
if ($shopFloorHome) { $startupTabs += $shopFloorHome }
$dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor'
if ($dashboard) { $startupTabs += $dashboard }
}
# Resolve homepage: profile can override (e.g. Lab has a different homepage
# than Standard). Falls back to the first startup tab if not specified.
$edgeHomepage = if ($pcProfile -and $pcProfile.edgeHomepage) { $pcProfile.edgeHomepage } else { $null }
if ($startupTabs.Count -eq 0) {
Write-Warning "No startup tab URLs resolved - skipping Edge startup config."
} else {
$edgePolKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Edge'
if (-not (Test-Path $edgePolKey)) {
New-Item -Path $edgePolKey -Force | Out-Null
}
# RestoreOnStartup = 4 means "open a specific set of URLs" (the list
# lives under the RestoreOnStartupURLs subkey below).
Set-ItemProperty -Path $edgePolKey -Name 'RestoreOnStartup' -Value 4 -Type DWord -Force
# Build RestoreOnStartupURLs subkey: numbered string values "1","2","3"
$urlsKey = Join-Path $edgePolKey 'RestoreOnStartupURLs'
if (Test-Path $urlsKey) {
# Wipe any prior entries so we don't leak stale tabs if the list
# shrinks between runs
Remove-Item -Path $urlsKey -Recurse -Force
}
New-Item -Path $urlsKey -Force | Out-Null
for ($i = 0; $i -lt $startupTabs.Count; $i++) {
$name = [string]($i + 1)
Set-ItemProperty -Path $urlsKey -Name $name -Value $startupTabs[$i] -Type String -Force
}
# Homepage: profile override > first startup tab. Home button opens it, not NTP.
$homepageUrl = if ($edgeHomepage) { $edgeHomepage } else { $startupTabs[0] }
Write-Host "Homepage set to: $homepageUrl"
Set-ItemProperty -Path $edgePolKey -Name 'HomepageLocation' -Value $homepageUrl -Type String -Force
Set-ItemProperty -Path $edgePolKey -Name 'HomepageIsNewTabPage' -Value 0 -Type DWord -Force
Set-ItemProperty -Path $edgePolKey -Name 'ShowHomeButton' -Value 1 -Type DWord -Force
Write-Host ""
Write-Host "Edge startup tabs set ($($startupTabs.Count) tab(s)):"
for ($i = 0; $i -lt $startupTabs.Count; $i++) {
Write-Host " $($i + 1). $($startupTabs[$i])"
}
Write-Host "Homepage set to: $($startupTabs[0])"
}
# ============================================================================
# Chrome: same homepage + startup tabs as Edge
#
# The PPKG installs Chrome alongside Edge. Apply the same profile-driven
# homepage and startup tabs so both browsers behave identically. Uses the
# same $startupTabs and $homepageUrl resolved above.
# HKLM:\SOFTWARE\Policies\Google\Chrome mirrors the Edge policy structure.
# ============================================================================
Write-Host ""
Write-Host "Configuring Chrome startup tabs..."
$chromePath = @(
'C:\Program Files\Google\Chrome\Application\chrome.exe',
'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $chromePath) {
Write-Host " Chrome not installed - skipping." -ForegroundColor DarkGray
} elseif ($startupTabs.Count -eq 0) {
Write-Host " No startup tabs resolved - skipping Chrome config." -ForegroundColor DarkGray
} else {
$chromePolKey = 'HKLM:\SOFTWARE\Policies\Google\Chrome'
if (-not (Test-Path $chromePolKey)) {
New-Item -Path $chromePolKey -Force | Out-Null
}
Set-ItemProperty -Path $chromePolKey -Name 'RestoreOnStartup' -Value 4 -Type DWord -Force
$urlsKey = Join-Path $chromePolKey 'RestoreOnStartupURLs'
if (Test-Path $urlsKey) {
Remove-Item -Path $urlsKey -Recurse -Force
}
New-Item -Path $urlsKey -Force | Out-Null
for ($i = 0; $i -lt $startupTabs.Count; $i++) {
Set-ItemProperty -Path $urlsKey -Name ([string]($i + 1)) -Value $startupTabs[$i] -Type String -Force
}
$chromeHomepage = if ($homepageUrl) { $homepageUrl } elseif ($edgeHomepage) { $edgeHomepage } else { $startupTabs[0] }
Set-ItemProperty -Path $chromePolKey -Name 'HomepageLocation' -Value $chromeHomepage -Type String -Force
Set-ItemProperty -Path $chromePolKey -Name 'HomepageIsNewTabPage' -Value 0 -Type DWord -Force
Set-ItemProperty -Path $chromePolKey -Name 'ShowHomeButton' -Value 1 -Type DWord -Force
Write-Host " Chrome startup tabs set ($($startupTabs.Count) tab(s)), homepage: $chromeHomepage"
}
Write-Host ""
Write-Host "================================================================"
Write-Host "08-EdgeDefaultBrowser.ps1 complete."
Write-Host "Edge + Chrome homepage and startup tabs configured."
Write-Host "Machine-wide GPO - applies to all users on next browser launch."
Write-Host "================================================================"
exit 0

View File

@@ -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,518 +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'
# An EMPTY configured list means "no startup items" and must be honoured.
# Get-ProfileValue returns $null only when the key is absent from BOTH the
# profile and site-config, so $null is the real "not configured" signal.
#
# The old test also required .Count -gt 0, so "startupItems": [] fell through to
# the hardcoded list below and a Display kiosk was given UDC, eDNC, Defect
# Tracker, WJ Shopfloor and Plant Apps startup shortcuts - Plant Apps launching
# Edge in a new window on a kiosk. Confirmed on 579C144 2026-08-06:
#
# C:\ProgramData\...\StartUp\Defect Tracker.lnk 16:46:03
# C:\ProgramData\...\StartUp\Plant Apps.lnk 16:46:03
#
# Configuring "none" produced "everything". Same bug as desktopApps in
# 06-OrganizeDesktop.ps1 and taskbarPins in 07-TaskbarLayout.ps1.
if ($null -ne $cfgItems) {
$items = @()
$num = 0
foreach ($si in $cfgItems) {
$num++
switch ($si.type) {
'exe' {
$target = $si.target
$items += @{
Num = $num
Label = $si.label
Detail = (Split-Path -Leaf $target)
Available = (Test-Path $target)
CreateLnk = [scriptblock]::Create("return (New-StartupLnk -Name '$($si.label)' -Target '$target')")
}
}
'existing' {
$srcLnk = $si.sourceLnk
$label = $si.label
$items += @{
Num = $num
Label = $label
Detail = 'shortcut'
Available = $true
CreateLnk = [scriptblock]::Create(@"
`$src = @(
(Join-Path `$publicDesktop '$srcLnk'),
(Join-Path (Join-Path `$publicDesktop 'Shopfloor Tools') '$srcLnk')
) | Where-Object { Test-Path -LiteralPath `$_ } | Select-Object -First 1
if (`$src) {
`$dst = Join-Path `$startupDir '$label.lnk'
try { Copy-Item -LiteralPath `$src -Destination `$dst -Force; return `$true }
catch { Write-Warning "Failed to copy $label`: `$_"; return `$false }
} else {
Write-Warning "$srcLnk not found on desktop"
return `$false
}
"@)
}
}
'url' {
$urlKey = $si.urlKey
$label = $si.label
$fallback = if ($urlKey -and $siteConfig.urls) { $siteConfig.urls.$urlKey } else { '' }
$items += @{
Num = $num
Label = $label
Detail = 'opens in Edge'
Available = [bool]$edgePath
CreateLnk = [scriptblock]::Create(@"
`$fallback = '$fallback'
`$url = Get-UrlFromFile '$label'
if (-not `$url) {
Write-Host " $label .url not found on desktop, using known URL."
`$url = `$fallback
}
Write-Host " URL: `$url"
return (New-StartupLnk -Name '$label' -Target `$edgePath -Arguments "--new-window ```"`$url```"")
"@)
}
}
}
}
} else {
$items = @(
@{
Num = 1
Label = 'UDC'
Detail = 'UDC.exe'
Available = (Test-Path 'C:\Program Files\UDC\UDC.exe')
CreateLnk = {
return (New-StartupLnk -Name 'UDC' -Target 'C:\Program Files\UDC\UDC.exe')
}
}
@{
Num = 2
Label = 'eDNC'
Detail = 'DncMain.exe'
Available = (Test-Path 'C:\Program Files (x86)\Dnc\bin\DncMain.exe')
CreateLnk = {
return (New-StartupLnk -Name 'eDNC' -Target 'C:\Program Files (x86)\Dnc\bin\DncMain.exe')
}
}
@{
Num = 3
Label = 'Defect Tracker'
Detail = 'ClickOnce app'
Available = $true
CreateLnk = {
$src = @(
(Join-Path $publicDesktop 'Defect_Tracker.lnk'),
(Join-Path (Join-Path $publicDesktop 'Shopfloor Tools') 'Defect_Tracker.lnk')
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if ($src) {
$dst = Join-Path $startupDir 'Defect Tracker.lnk'
try { Copy-Item -LiteralPath $src -Destination $dst -Force; return $true }
catch { Write-Warning "Failed to copy Defect Tracker: $_"; return $false }
} else {
Write-Warning "Defect_Tracker.lnk not found on desktop"
return $false
}
}
}
@{
Num = 4
Label = 'WJ Shopfloor'
Detail = 'HostExplorer session'
Available = $true
CreateLnk = {
$src = @(
(Join-Path $publicDesktop 'WJ Shopfloor.lnk'),
(Join-Path (Join-Path $publicDesktop 'Shopfloor Tools') 'WJ Shopfloor.lnk')
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if ($src) {
$dst = Join-Path $startupDir 'WJ Shopfloor.lnk'
try { Copy-Item -LiteralPath $src -Destination $dst -Force; return $true }
catch { Write-Warning "Failed to copy WJ Shopfloor: $_"; return $false }
} else {
Write-Warning "WJ Shopfloor.lnk not found on desktop"
return $false
}
}
}
@{
Num = 5
Label = 'Plant Apps'
Detail = 'opens in Edge'
Available = [bool]$edgePath
CreateLnk = {
$fallback = 'https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications'
$url = Get-UrlFromFile 'Plant Apps'
if (-not $url) {
Write-Host " Plant Apps .url not found on desktop, using known URL."
$url = $fallback
}
Write-Host " URL: $url"
return (New-StartupLnk -Name 'Plant Apps' -Target $edgePath -Arguments "--new-window `"$url`"")
}
}
)
}
# Machine-number logon tasks (item 6 toggle controls both)
# 2026-05-24: split into user-context Prompt + SYSTEM-context Apply.
# 'Check Machine Number' is the legacy single-task name kept for
# backward-detection on bays imaged before the split.
$machineNumPromptTask = 'Prompt Machine Number'
$machineNumApplyTask = 'Apply Machine Number'
$machineNumLegacyTask = 'Check Machine Number'
# ============================================================================
# Interactive UI
# ============================================================================
Clear-Host
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Configure PC" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# --- Section 1: Machine number ---
Write-Host ""
Write-Host " MACHINE NUMBER" -ForegroundColor Yellow
Write-Host " ----------------------------------------"
if ($needsMachineNumber) {
if ($currentUdc) { Write-Host " UDC : $currentUdc" -ForegroundColor Red }
if ($currentEdnc) { Write-Host " eDNC : $currentEdnc" -ForegroundColor Red }
Write-Host ""
$newNum = Read-Host " Enter new machine number (digits only, Enter to skip)"
$newNum = $newNum.Trim()
if ($newNum -and $newNum -match '^\d+$') {
$site = if ($siteConfig) { $siteConfig.siteName } else { 'West Jefferson' }
$mnResult = Update-MachineNumber -NewNumber $newNum -Site $site
if ($mnResult.UdcUpdated) {
Write-Host " UDC : $currentUdc -> $newNum" -ForegroundColor Green
$currentUdc = $newNum
}
if ($mnResult.EdncUpdated) {
Write-Host " eDNC : $currentEdnc -> $newNum" -ForegroundColor Green
$currentEdnc = $newNum
}
foreach ($err in $mnResult.Errors) { Write-Warning " $err" }
if ($mnResult.UdcUpdated) { Write-Host " UDC.exe relaunched." }
$needsMachineNumber = ($currentUdc -eq '9999' -or $currentEdnc -eq '9999')
} elseif ($newNum) {
Write-Host " Invalid (digits only). Skipped." -ForegroundColor Yellow
} else {
Write-Host " Skipped (still 9999)." -ForegroundColor DarkGray
}
} else {
if ($currentUdc) { Write-Host " UDC : $currentUdc" -ForegroundColor Green }
if ($currentEdnc) { Write-Host " eDNC : $currentEdnc" -ForegroundColor Green }
}
# --- Section 2: Startup items + machine-number logon task ---
# Skipped when -MachineNumberOnly (called from sync_intune -AsTask after
# completion -- startup items already auto-applied by 06-OrganizeDesktop).
if (-not $MachineNumberOnly) {
Write-Host ""
Write-Host " AUTO-STARTUP ITEMS" -ForegroundColor Yellow
Write-Host " ----------------------------------------"
Write-Host " Toggle which items start at user logon:"
Write-Host ""
$existingStartup = @(Get-ChildItem -LiteralPath $startupDir -Filter '*.lnk' -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty BaseName)
foreach ($item in $items) {
$on = if ($existingStartup -contains $item.Label) { '[ON]' } else { '[ ]' }
$avail = if ($item.Available) { '' } else { ' (not installed)' }
Write-Host " $($item.Num). $on $($item.Label) - $($item.Detail)$avail"
}
# Item 6: machine number logon prompt. "ON" if EITHER the new Prompt task OR
# the legacy Check Machine Number task is registered.
$machineNumTaskExists = [bool](
(Get-ScheduledTask -TaskName $machineNumPromptTask -ErrorAction SilentlyContinue) -or
(Get-ScheduledTask -TaskName $machineNumLegacyTask -ErrorAction SilentlyContinue)
)
$mnOn = if ($machineNumTaskExists) { '[ON]' } else { '[ ]' }
Write-Host " 6. $mnOn Prompt standard user for machine number if 9999"
Write-Host ""
Write-Host " Enter numbers to toggle (e.g. 1,2,6), Enter to keep current:"
$selection = Read-Host " Selection"
$selection = $selection.Trim()
if ($selection) {
$selected = $selection -split '[,\s]+' | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }
# Process startup items 1-5
$suppressed = @(Get-SuppressedStartup)
$suppressedChanged = $false
foreach ($item in $items) {
if ($selected -notcontains $item.Num) { continue }
$existingLnk = Join-Path $startupDir "$($item.Label).lnk"
if (Test-Path -LiteralPath $existingLnk) {
# Toggling OFF: remove the .lnk AND record the suppression so
# the logon sweep in 06-OrganizeDesktop doesn't re-create it.
try {
Remove-Item -LiteralPath $existingLnk -Force
Write-Host " $($item.Label): REMOVED from startup" -ForegroundColor Yellow
} catch { Write-Warning " Failed to remove $($item.Label): $_" }
if ($suppressed -notcontains $item.Label) {
$suppressed += $item.Label
$suppressedChanged = $true
}
} else {
# Toggling ON: drop any suppression first so the sweep won't
# fight us next logon, then create the .lnk.
if ($suppressed -contains $item.Label) {
$suppressed = @($suppressed | Where-Object { $_ -ne $item.Label })
$suppressedChanged = $true
}
if (-not $item.Available) {
Write-Host " $($item.Label): not installed, cannot add to startup" -ForegroundColor DarkGray
continue
}
$result = & $item.CreateLnk
if ($result) {
Write-Host " $($item.Label): ADDED to startup" -ForegroundColor Green
}
}
}
if ($suppressedChanged) {
try {
Set-SuppressedStartup -Labels $suppressed
Write-Host " (saved override state to $overridesPath)" -ForegroundColor DarkGray
} catch {
Write-Warning " Failed to write override state: $_"
}
}
# Process item 6: machine number logon task
if ($selected -contains 6) {
if ($machineNumTaskExists) {
# Toggle OFF - remove Prompt + Apply (new design) AND the legacy
# Check Machine Number task name (in case this bay was imaged
# before the split and never re-imaged).
$removed = @()
foreach ($t in @($machineNumPromptTask, $machineNumApplyTask, $machineNumLegacyTask)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
$removed += $t
}
} catch { Write-Warning " Failed to remove '$t': $_" }
}
if ($removed) {
Write-Host " Machine number logon prompt: REMOVED ($($removed -join ', '))" -ForegroundColor Yellow
}
$machineNumTaskExists = $false
} else {
# Toggle ON - register logon task
# Defer task registration to the shared registrar so this code
# path always matches the imaging-time path. Registrar installs
# BOTH the user-context "Prompt Machine Number" task and the
# SYSTEM-context "Apply Machine Number" task, sets the SDDL on
# Apply so Limited users can schtasks /run it, and cleans up
# any legacy "Check Machine Number" task name.
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$registrar = Join-Path $scriptDir 'Register-CheckMachineNumberTask.ps1'
if (-not (Test-Path -LiteralPath $registrar)) {
$registrar = 'C:\Enrollment\shopfloor-setup\Shopfloor\Register-CheckMachineNumberTask.ps1'
}
if (Test-Path -LiteralPath $registrar) {
try {
& $registrar
Write-Host " Machine number logon prompt: ENABLED" -ForegroundColor Green
Write-Host " (Prompt user-task + Apply SYSTEM-task registered;" -ForegroundColor DarkGray
Write-Host " will auto-disable after machine number is set)" -ForegroundColor DarkGray
$machineNumTaskExists = $true
} catch {
Write-Warning " Register-CheckMachineNumberTask failed: $_"
}
} else {
Write-Warning " Register-CheckMachineNumberTask.ps1 not found at $registrar"
}
}
}
} else {
Write-Host " No changes." -ForegroundColor DarkGray
}
} # end if (-not $MachineNumberOnly)
# --- Summary ---
Write-Host ""
Write-Host " CURRENT STATE" -ForegroundColor Cyan
Write-Host " ----------------------------------------"
$final = @(Get-ChildItem -LiteralPath $startupDir -Filter '*.lnk' -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty BaseName)
Write-Host " Startup items:"
if ($final.Count -eq 0) {
Write-Host " (none)"
} else {
foreach ($f in $final) { Write-Host " - $f" }
}
$mnState = if ($machineNumTaskExists) { 'ON (prompts at logon if 9999)' } else { 'OFF' }
Write-Host " Machine number logon prompt: $mnState"
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Done" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Completed: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
try { Stop-Transcript | Out-Null } catch {}
Write-Host "Press any key to close..."
try { [void][Console]::ReadKey($true) } catch { [void](Read-Host) }

View File

@@ -70,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,179 +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': $_" }
# PC types that have no machine number by design. Arming the prompt on these
# guarantees a dialog nobody can answer: startnet only collects a machine number
# for the machine-tool types, so everything else is left on the 9999 placeholder
# and the check below would otherwise fire on every one of them.
#
# Observed on 579C144 (a Display) 2026-08-06: PreInstall logged
# "Machine number: 9999 (default placeholder)" and the logon prompt armed itself
# on a kiosk with no keyboard.
$noMachineNumberTypes = @(
'gea-shopfloor-display'
)
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
$pcType = ''
if (Test-Path -LiteralPath $pcTypeFile) {
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue)
if ($pcType) { $pcType = $pcType.Trim() }
}
if ($pcType -and ($noMachineNumberTypes -contains $pcType)) {
Write-RegLog "PC type '$pcType' has no machine number by design. Not registering the prompt."
foreach ($t in @($promptTaskName, $applyTaskName)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$t'"
}
} catch {}
}
return
}
# Only arm the tasks if the bay was imaged with the 9999 placeholder. If
# the tech entered a real machine number during PXE imaging it's already
# in C:\Enrollment\machine-number.txt; no prompt needed on first logon.
$mnFile = 'C:\Enrollment\machine-number.txt'
$mnAtImaging = '9999'
if (Test-Path -LiteralPath $mnFile) {
$raw = (Get-Content -LiteralPath $mnFile -First 1 -ErrorAction SilentlyContinue)
if ($raw) { $mnAtImaging = $raw.Trim() }
}
Write-RegLog "Imaging-time machine-number.txt = '$mnAtImaging'"
if ($mnAtImaging -ne '9999') {
Write-RegLog "Machine number is real ('$mnAtImaging' != 9999). Not registering tasks."
foreach ($t in @($promptTaskName, $applyTaskName)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$t'"
}
} catch {}
}
Write-RegLog '=== Register-CheckMachineNumberTask end (no-op) ==='
exit 0
}
# Resolve script paths. Prefer the staged shopfloor-setup tree on C:
# (where Run-ShopfloorSetup ran from); fall back to the same dir as this
# Register script if invoked standalone.
function Resolve-Script {
param([string]$LeafName)
$p = Join-Path $PSScriptRoot $LeafName
if (Test-Path -LiteralPath $p) { return $p }
$p = "C:\Enrollment\shopfloor-setup\Shopfloor\$LeafName"
if (Test-Path -LiteralPath $p) { return $p }
return $null
}
$promptScript = Resolve-Script 'Prompt-MachineNumber.ps1'
$applyScript = Resolve-Script 'Apply-MachineNumber.ps1'
if (-not $promptScript) { Write-RegLog "Prompt-MachineNumber.ps1 not found - cannot register"; exit 1 }
if (-not $applyScript) { Write-RegLog "Apply-MachineNumber.ps1 not found - cannot register"; exit 1 }
Write-RegLog "Prompt script: $promptScript"
Write-RegLog "Apply script: $applyScript"
# --- Prompt task (user-context, GUI) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$promptScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Group SID S-1-5-32-545 = BUILTIN\Users (catches ShopFloor + support/admin
# users that log in interactively). RunLevel Limited - no elevation; the
# actual writes happen in the SYSTEM Apply task below.
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskName $promptTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$promptTaskName' (AtLogOn, BUILTIN\Users, Limited)"
} catch {
Write-RegLog "FAILED to register '$promptTaskName': $_"
exit 1
}
# --- Apply task (SYSTEM, on-demand) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$applyScript`""
# No trigger - the Prompt task starts this via schtasks /run /tn.
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 10)
Register-ScheduledTask -TaskName $applyTaskName -Action $action -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$applyTaskName' (on-demand, SYSTEM, Highest)"
# Default SDDL on a SYSTEM-owned task only grants Admins + SYSTEM
# FullAccess - BUILTIN\Users can't see or run it via schtasks /run.
# Add an ACE granting BUILTIN\Users GenericRead + GenericExecute so the
# user-context Prompt task can trigger this Apply task on demand. They
# still can't modify/delete it - only read+execute.
try {
$svc = New-Object -ComObject Schedule.Service
$svc.Connect()
$taskObj = $svc.GetFolder('\').GetTask($applyTaskName)
# GenericRead = 0x80000000 (GR), GenericExecute = 0x20000000 (GX)
# BU = BUILTIN\Users
$newSd = 'O:BAG:BAD:(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGX;;;BU)'
# SetSecurityDescriptor flag 0 = default, persists DACL change.
$taskObj.SetSecurityDescriptor($newSd, 0)
Write-RegLog "Granted BUILTIN\Users GR+GX on '$applyTaskName' (so Limited users can schtasks /run)"
} catch {
Write-RegLog "FAILED to set task SDDL on '$applyTaskName': $_ (Limited users may not be able to trigger Apply)"
}
} catch {
Write-RegLog "FAILED to register '$applyTaskName': $_"
exit 1
}
Write-RegLog '=== Register-CheckMachineNumberTask end ==='
exit 0

View File

@@ -1,115 +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'
# PC types that are self-contained and must NOT map S:. A Display kiosk is
# Entra-joined with local accounts and no SFLD credentials, so the mapping can
# only ever fail - once per logon, forever.
#
# Run-ShopfloorSetup.ps1 already gates the CALL to this script on the same list,
# but on 579C144 (2026-08-06) the Run entry was registered on a Display anyway:
#
# [15:07:55] Set HKLM:\...\Run\GE Map SFLD Share = ...Map-SfldShare.ps1
#
# with no "Skipping S: drive logon mapper" in the log, so something in the
# finalization phase reaches this script past the call-site gate. Gating here as
# well makes the outcome correct regardless of who invokes it.
$selfContainedTypes = @('Display', 'gea-shopfloor-display')
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
$pcType = ''
if (Test-Path -LiteralPath $pcTypeFile) {
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue)
if ($pcType) { $pcType = $pcType.Trim() }
}
if ($pcType -and ($selfContainedTypes -contains $pcType)) {
$d = 'C:\Logs\SFLD'
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force -EA SilentlyContinue | Out-Null }
Add-Content -Path (Join-Path $d 'register-mapshare.log') -EA SilentlyContinue `
-Value ("[{0}] [INFO] PC type '{1}' is self-contained - not registering the S: mapper." -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $pcType)
Write-Host "PC type '$pcType' is self-contained - skipping S: drive mapper."
# Remove a stale entry from an earlier image or an earlier code path.
try {
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
if ((Get-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -EA SilentlyContinue)) {
Remove-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -Force -EA Stop
Write-Host " removed stale 'GE Map SFLD Share' Run entry."
}
} catch { }
return
}
$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,144 +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
}
}
}
# Subtype-strip fallback: a compound key like "gea-shopfloor-cmm-doda" (DODA
# CMM bays set pc-subtype.txt=doda) has no profile of its own. Fall back to
# the BARE pc-type ("gea-shopfloor-cmm" -> "CMM"). Without this the profile
# resolves to $null and callers silently use hardcoded defaults (wrong
# desktop icons + unwanted startup items).
if (-not $pcProfile -and $pcSubtype) {
$bare = $pcType
$pcProfile = $siteConfig.pcProfiles.$bare
if (-not $pcProfile) {
foreach ($g in $pcProfileAliasGroups) {
if ($g -icontains $bare) {
foreach ($alias in $g) {
if ($alias -ieq $bare) { continue }
$candidate = $siteConfig.pcProfiles.$alias
if ($candidate) { $pcProfile = $candidate; break }
}
break
}
}
}
if ($pcProfile) { $profileKey = $bare }
}
}
if ($pcProfile) {
Write-Host "PC profile: $profileKey" -ForegroundColor Cyan
} elseif ($profileKey) {
Write-Host "PC profile '$profileKey' not found in site-config.json - using site defaults" -ForegroundColor DarkGray
}

View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

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