Share accounts: per-site Samba passwords from the webapp

Tools > Share accounts sets the pxe-upload, blancco and clonezilla Samba
passwords. Saving one runs smbpasswd and writes it everywhere it is used:
startnet.cmd in boot.wim (pxe-upload), the served Blancco preferences and
the live config.img (blancco), the iPXE menu's Clonezilla entries
(clonezilla). Refused while a PC is imaging unless forced. Stored 0600 in
UPDATES_DIR/shares/accounts.json, never shown back.

- The stored values are the authority: the playbook only creates the
  Samba accounts, seeds the store from site-vars (pxe_upload_password,
  blancco_smb_password, clonezilla_smb_password) for accounts it does not
  hold, and runs "share_accounts apply" at the end of every run.
- Every startnet save re-pins SHAREPASS, so a restored snapshot cannot
  bring an old password back; new menu blocks use %SHAREUSER%/%SHAREPASS%.
- Shop floor unattend Order 4 reads the login from fetch-source.txt;
  Invoke-PostPpkg deletes that file once the PPKG is applied.
- Upload-Image.ps1 asks for the password unless -Pass is given.
- pxe_admin_password: the Linux pxe login. Required while the box still
  has the shipped "pxe"; set with chpasswd when it differs.
- Blancco site settings now also reach the live config.img (BIOS path).
This commit is contained in:
cproudlock
2026-10-17 12:29:06 -04:00
parent 5f7954ac88
commit fa79f76951
20 changed files with 1526 additions and 671 deletions

View File

@@ -202,8 +202,8 @@
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>4</Order> <Order>4</Order>
<CommandLine>powershell.exe -ep Bypass -Command "net use Z: \\172.16.9.1\enrollment /user:pxe-upload pxe; robocopy Z:\shopfloor-setup C:\Enrollment Fetch-StagingPayload.ps1 Verify-And-Heal-Staging.ps1 /R:2 /W:2; robocopy Z:\scripts C:\Enrollment run-enrollment.ps1 wait-for-internet.ps1 migrate-to-wifi.ps1 /R:2 /W:2"</CommandLine> <CommandLine>powershell.exe -ep Bypass -Command "$u='pxe-upload'; $p='pxe'; $f='C:\Enrollment\fetch-source.txt'; if (Test-Path $f) { $l=@(Get-Content $f); if ($l.Count -ge 3) { $u=$l[1].Trim(); $p=$l[2].Trim() } }; net use Z: \\172.16.9.1\enrollment /user:$u $p; robocopy Z:\shopfloor-setup C:\Enrollment Fetch-StagingPayload.ps1 Verify-And-Heal-Staging.ps1 /R:2 /W:2; robocopy Z:\scripts C:\Enrollment run-enrollment.ps1 wait-for-internet.ps1 migrate-to-wifi.ps1 /R:2 /W:2"</CommandLine>
<Description>Bootstrap self-heal: pull Fetch/Verify-Heal + enrollment scripts from the PXE share so Order 5/6 run after an early WinPE staging failure.</Description> <Description>Bootstrap self-heal: pull Fetch/Verify-Heal + enrollment scripts from the PXE share so Order 5/6 run after an early WinPE staging failure. Share login from fetch-source.txt (startnet writes it).</Description>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>5</Order> <Order>5</Order>

View File

@@ -199,6 +199,17 @@
# letters, digits . _ ~ - (it sits in a URI, a kernel command line and # letters, digits . _ ~ - (it sits in a URI, a kernel command line and
# a mount option list), 8 to 64 of them. # a mount option list), 8 to 64 of them.
clonezilla_smb_password: "clonezilla-backup" clonezilla_smb_password: "clonezilla-backup"
# The two other Samba accounts. pxe-upload: WinPE and Shop Floor setup
# on every imaging PC, image and app uploads. blancco: Blancco's report
# share. Same rules as clonezilla_smb_password. Empty: the shipped
# default (pxe / blancco). All three only SEED the webapp's Tools >
# Share accounts on the first run that sees them; after that the webapp
# owns them and a re-run keeps what it holds.
pxe_upload_password: ""
blancco_smb_password: ""
# The server's Linux login (pxe: SSH and sudo). Required while the box
# still has the shipped password "pxe"; set on every run it differs.
pxe_admin_password: ""
# The site, as stamped on Blancco erasure reports and the monthly CMMC # The site, as stamped on Blancco erasure reports and the monthly CMMC
# evidence packs (webapp BLANCCO_SITE). Empty: "Unnamed site", so a # evidence packs (webapp BLANCCO_SITE). Empty: "Unnamed site", so a
# missing name shows instead of another site's. Letters, digits, spaces # missing name shows instead of another site's. Letters, digits, spaces
@@ -377,6 +388,46 @@
site-specific value in playbook/site-vars.yml and re-run. site-specific value in playbook/site-vars.yml and re-run.
when: clonezilla_smb_password == "clonezilla-backup" when: clonezilla_smb_password == "clonezilla-backup"
- name: "Shares: check the pxe-upload and blancco Samba passwords"
assert:
that:
- pxe_upload_password is string and blancco_smb_password is string
- pxe_upload_password | length == 0 or pxe_upload_password is match('^[A-Za-z0-9._~-]{8,64}$')
- blancco_smb_password | length == 0 or blancco_smb_password is match('^[A-Za-z0-9._~-]{8,64}$')
fail_msg: >-
pxe_upload_password and blancco_smb_password must be empty or 8-64
characters from A-Z a-z 0-9 . _ ~ - (they go into startnet.cmd and
the Blancco preferences). Fix them in playbook/site-vars.yml and re-run.
quiet: yes
- name: "Admin: is the Linux pxe login still the shipped password?"
shell: |
h=$(getent shadow pxe | cut -d: -f2)
case "$h" in
'$6$'*) salt=$(printf '%s' "$h" | cut -d'$' -f3)
if [ "$(printf 'pxe' | openssl passwd -6 -salt "$salt" -stdin)" = "$h" ]; then echo DEFAULT; else echo SITE; fi ;;
*) echo UNKNOWN ;;
esac
args:
executable: /bin/bash
register: pxe_admin_state
changed_when: false
- name: "Admin: check pxe_admin_password"
assert:
that:
- pxe_admin_password is string
- pxe_admin_password | length > 0 or pxe_admin_state.stdout != 'DEFAULT'
- pxe_admin_password | length == 0 or (pxe_admin_password | length >= 8 and pxe_admin_password | length <= 128)
- pxe_admin_password | length == 0 or pxe_admin_password is not search('[\x00-\x1f]')
- pxe_admin_password != 'pxe'
fail_msg: >-
Set pxe_admin_password in playbook/site-vars.yml (8-128 characters,
no line breaks): the Linux login pxe (SSH and sudo) still has the
shipped password "pxe", the same on every server. Fix the file and
re-run.
quiet: yes
# --- Re-run safety (nothing below changes the box until these pass) --- # --- Re-run safety (nothing below changes the box until these pass) ---
- name: "Re-run safety: is imaging in progress? (SMB sessions, recent DHCP leases)" - name: "Re-run safety: is imaging in progress? (SMB sessions, recent DHCP leases)"
args: args:
@@ -1473,28 +1524,49 @@
valid users = pxe-upload valid users = pxe-upload
comment = Dell BIOS update bundle (read-only, served to WinPE) comment = Dell BIOS update bundle (read-only, served to WinPE)
- name: "Create Samba users (pxe-upload and blancco)" # The Samba passwords belong to the webapp (Tools > Share accounts):
# here the accounts are only created, with the site-vars value or the
# default; "Shares: apply the stored passwords" near the end sets them.
- name: "Create Samba users (pxe-upload, blancco, clonezilla)"
# clonezilla: system user, no home, no login shell; only [clonezilla]
# lists it in valid users.
shell: | shell: |
add() { # user password
pdbedit -L 2>/dev/null | grep -q "^$1:" && return 0
printf '%s\n%s\n' "$2" "$2" | smbpasswd -a -s "$1" >/dev/null && echo "created $1"
}
id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload
echo -e 'pxe\npxe' | smbpasswd -a pxe-upload -s
id blancco >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin blancco id blancco >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin blancco
echo -e 'blancco\nblancco' | smbpasswd -a blancco -s
args:
executable: /bin/bash
changed_when: false
- name: "Create the clonezilla Samba user ([clonezilla] share only)"
# System user, no home, no login shell. Only [clonezilla] lists it in
# valid users. smbpasswd runs every time so a changed
# clonezilla_smb_password takes effect (the menu is updated with it).
shell: |
id clonezilla >/dev/null 2>&1 || useradd -r -M -d /nonexistent -s /usr/sbin/nologin clonezilla id clonezilla >/dev/null 2>&1 || useradd -r -M -d /nonexistent -s /usr/sbin/nologin clonezilla
printf '%s\n%s\n' "$CZ_PW" "$CZ_PW" | smbpasswd -a -s clonezilla >/dev/null add pxe-upload "${UP_PW:-pxe}"
add blancco "${BL_PW:-blancco}"
add clonezilla "$CZ_PW"
environment: environment:
UP_PW: "{{ pxe_upload_password }}"
BL_PW: "{{ blancco_smb_password }}"
CZ_PW: "{{ clonezilla_smb_password }}" CZ_PW: "{{ clonezilla_smb_password }}"
args: args:
executable: /bin/bash executable: /bin/bash
changed_when: false register: smb_users
changed_when: "'created' in smb_users.stdout"
no_log: true
- name: "Admin: set the Linux pxe password from pxe_admin_password"
shell: |
h=$(getent shadow pxe | cut -d: -f2)
salt=$(printf '%s' "$h" | cut -d'$' -f3)
if [ -n "$salt" ] && [ "$(printf '%s' "$PW" | openssl passwd -6 -salt "$salt" -stdin)" = "$h" ]; then
echo UNCHANGED
else
printf 'pxe:%s\n' "$PW" | chpasswd && echo CHANGED
fi
environment:
PW: "{{ pxe_admin_password }}"
args:
executable: /bin/bash
register: pxe_admin_set
changed_when: "'CHANGED' in pxe_admin_set.stdout"
when: pxe_admin_password | length > 0
no_log: true no_log: true
- name: "Deploy nf_conntrack TCP timeout sysctl for PXE workload" - name: "Deploy nf_conntrack TCP timeout sysctl for PXE workload"
@@ -2001,12 +2073,6 @@
dest: "{{ web_root }}/blancco/blancco-progress.py" dest: "{{ web_root }}/blancco/blancco-progress.py"
mode: '0644' mode: '0644'
- name: "Ensure Samba user for Blancco reports exists (idempotent)"
shell: |
id blancco >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin blancco
pdbedit -L 2>/dev/null | grep -q '^blancco:' || (echo -e "blancco\nblancco" | smbpasswd -a -s blancco)
changed_when: false
- name: "Check for WinPE deployment content on USB" - name: "Check for WinPE deployment content on USB"
stat: stat:
path: "{{ usb_root }}/images" path: "{{ usb_root }}/images"
@@ -2422,6 +2488,28 @@
debug: debug:
msg: "{{ blancco_render.stdout_lines | select('search', 'CHANGED|WARNING|seeded') | list }}" msg: "{{ blancco_render.stdout_lines | select('search', 'CHANGED|WARNING|seeded') | list }}"
# The Samba passwords: seeded from site-vars only for accounts the
# webapp does not hold yet (Tools > Share accounts owns them after
# that), then set in Samba, startnet.cmd in boot.wim (this run injected
# the USB copy), the iPXE menu's Clonezilla entries (rendered from
# site-vars above) and the Blancco preferences. Passwords via env.
- name: "Shares: apply the stored passwords"
command: python3 -m services.share_accounts apply --seed
args:
chdir: /opt/pxe-webapp
environment:
WEB_ROOT: "{{ web_root }}"
SHARE_SEED_PXE_UPLOAD: "{{ pxe_upload_password }}"
SHARE_SEED_BLANCCO: "{{ blancco_smb_password }}"
SHARE_SEED_CLONEZILLA: "{{ clonezilla_smb_password }}"
no_log: true
register: share_apply
changed_when: share_apply.stdout_lines | select('search', 'updated|seeded') | list | length > 0
- name: "Shares: apply result"
debug:
msg: "{{ share_apply.stdout_lines }}"
# Hard gate: blancco-init.sh in initramfs drops to shell if the served # Hard gate: blancco-init.sh in initramfs drops to shell if the served
# preferences.xml is malformed or missing the SMB target. Fail the # preferences.xml is malformed or missing the SMB target. Fail the
# playbook here too so the problem surfaces at deploy time, not at the # playbook here too so the problem surfaces at deploy time, not at the

View File

@@ -13,6 +13,8 @@
# same, but it runs as Users and the HKLM write can be denied. # same, but it runs as Users and the HKLM write can be denied.
# 3. Once Monitor-IntuneProgress has written C:\Enrollment\sync-complete.txt, # 3. Once Monitor-IntuneProgress has written C:\Enrollment\sync-complete.txt,
# remove the "Shopfloor Intune Sync" logon task. # remove the "Shopfloor Intune Sync" logon task.
# Also, once the PPKG is applied, delete C:\Enrollment\fetch-source.txt (the
# pxe-upload share login startnet leaves for the first-logon scripts).
# When all three are done the task unregisters itself. # When all three are done the task unregisters itself.
# #
# Never throws: every step logs and the script exits 0. # Never throws: every step logs and the script exits 0.
@@ -47,6 +49,19 @@ if (Test-Path -LiteralPath $doneMarker) {
} }
} }
# 1b. fetch-source.txt holds the pxe-upload share password (startnet writes
# it for the first-logon Fetch/Verify scripts). Once the PPKG has run the
# PC is off the imaging LAN and the file has no use left.
$fetchSource = 'C:\Enrollment\fetch-source.txt'
if ((Test-Path -LiteralPath $appliedMarker) -and (Test-Path -LiteralPath $fetchSource)) {
try {
Remove-Item -LiteralPath $fetchSource -Force -ErrorAction Stop
Write-Host "Removed $fetchSource (share login)."
} catch {
Write-Warning "Could not remove ${fetchSource}: $_"
}
}
# 2. AutoLogonCount after the ShopFloor handover # 2. AutoLogonCount after the ShopFloor handover
$handedOver = $false $handedOver = $false
$countGone = $true $countGone = $true

File diff suppressed because it is too large Load Diff

View File

@@ -474,6 +474,6 @@ Write-Host ""
Write-Host "Snapshot complete: $out" Write-Host "Snapshot complete: $out"
Write-Host "" Write-Host ""
Write-Host "Next: copy that folder to the workstation. Easiest:" Write-Host "Next: copy that folder to the workstation. Easiest:"
Write-Host " net use Z: \\172.16.9.1\image-upload /user:pxe-upload pxe /persistent:no" Write-Host " net use Z: \\172.16.9.1\image-upload /user:pxe-upload <password> /persistent:no"
Write-Host " robocopy `"$out`" `"Z:\state-$Stage-$stamp`" /E /NFL /NDL /NJH /NJS" Write-Host " robocopy `"$out`" `"Z:\state-$Stage-$stamp`" /E /NFL /NDL /NJH /NJS"
Write-Host " net use Z: /delete /y" Write-Host " net use Z: /delete /y"

View File

@@ -33,7 +33,7 @@ from pathlib import Path
REPO_DIR = Path(__file__).resolve().parent REPO_DIR = Path(__file__).resolve().parent
PXE_HOST = "172.16.9.1" PXE_HOST = "172.16.9.1"
PXE_USER = "pxe" PXE_USER = "pxe"
PXE_PASS = "pxe" PXE_PASS = os.environ.get("PXE_PASS") or "pxe" # the site's pxe_admin_password
UPLOAD_DEST = "/home/pxe/image-upload" UPLOAD_DEST = "/home/pxe/image-upload"
IMAGE_BASE = "/srv/samba/winpeapps" IMAGE_BASE = "/srv/samba/winpeapps"

View File

@@ -37,7 +37,7 @@ REPO = os.path.dirname(HERE)
PXE_HOST = "172.16.9.1" PXE_HOST = "172.16.9.1"
PXE_USER = "pxe" PXE_USER = "pxe"
PXE_PASS = "pxe" PXE_PASS = os.environ.get("PXE_PASS") or "pxe" # the site's pxe_admin_password
MODELS_TXT = "/srv/samba/winpeapps/_shared/BIOS/models.txt" MODELS_TXT = "/srv/samba/winpeapps/_shared/BIOS/models.txt"
IMAGE_BASE = "/srv/samba/winpeapps" IMAGE_BASE = "/srv/samba/winpeapps"

View File

@@ -40,7 +40,7 @@ import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PXE_HOST = "172.16.9.1" PXE_HOST = "172.16.9.1"
PXE_USER = "pxe" PXE_USER = "pxe"
PXE_PASS = "pxe" PXE_PASS = os.environ.get("PXE_PASS") or "pxe" # the site's pxe_admin_password
GIT_OWNED, UNRECONCILED = "git-owned", "unreconciled" GIT_OWNED, UNRECONCILED = "git-owned", "unreconciled"

View File

@@ -70,7 +70,8 @@ Watch the server with `virt-viewer -c qemu:///system pxetest-server` (VNC on
127.0.0.1) or screenshots. On the installed system, first-boot output goes 127.0.0.1) or screenshots. On the installed system, first-boot output goes
to `/var/log/first-boot.log`. Once the GE NIC takes a DHCP lease on the NAT to `/var/log/first-boot.log`. Once the GE NIC takes a DHCP lease on the NAT
network, `status.sh` shows its address and `ssh pxe@<address>` works from network, `status.sh` shows its address and `ssh pxe@<address>` works from
the host. the host (password `pxetest-admin`, or `PXETEST_ADMIN_PASSWORD` given to
build-image.sh).
The offline package set must be built against the SAME ISO the image is The offline package set must be built against the SAME ISO the image is
built from (download-packages.sh resolves against that ISO's base). For a built from (download-packages.sh resolves against that ISO's base). For a

View File

@@ -11,6 +11,9 @@
# server with its GE NIC unplugged: virsh domif-setlink pxetest-server # server with its GE NIC unplugged: virsh domif-setlink pxetest-server
# 52:54:00:9e:10:01 down --config). # 52:54:00:9e:10:01 down --config).
# Env: PXETEST_PAYLOAD_ROOT (where offline-packages/, boot-files/... live) # Env: PXETEST_PAYLOAD_ROOT (where offline-packages/, boot-files/... live)
# PXETEST_ADMIN_PASSWORD (the server's pxe login, default pxetest-admin)
# PXETEST_UPLOAD_PASSWORD / _BLANCCO_PASSWORD / _CLONEZILLA_PASSWORD
# (optional share password seeds)
source "$(dirname "$0")/common.sh" source "$(dirname "$0")/common.sh"
SIZE=auto SIZE=auto
@@ -69,6 +72,16 @@ corp_nic_mac: "$SERVER_GE_MAC" # libvirt default NAT ("GE/corp")
YML YML
log "site vars: pxe_nic_mac=$SERVER_IMAGING_MAC corp_nic_mac=$SERVER_GE_MAC" log "site vars: pxe_nic_mac=$SERVER_IMAGING_MAC corp_nic_mac=$SERVER_GE_MAC"
fi fi
# The server's Linux login (required while it has the shipped "pxe") and,
# when set, seeds for the share accounts (Tools > Share accounts).
{
echo "pxe_admin_password: \"${PXETEST_ADMIN_PASSWORD:-pxetest-admin}\""
[ -n "${PXETEST_UPLOAD_PASSWORD:-}" ] && echo "pxe_upload_password: \"$PXETEST_UPLOAD_PASSWORD\""
[ -n "${PXETEST_BLANCCO_PASSWORD:-}" ] && echo "blancco_smb_password: \"$PXETEST_BLANCCO_PASSWORD\""
[ -n "${PXETEST_CLONEZILLA_PASSWORD:-}" ] && echo "clonezilla_smb_password: \"$PXETEST_CLONEZILLA_PASSWORD\""
true
} >>"$SITE_VARS"
log "site vars: pxe_admin_password set (PXETEST_ADMIN_PASSWORD, default pxetest-admin)"
"$REPO_ROOT/scripts/build-usb.sh" --yes --payload-root "$PXETEST_PAYLOAD_ROOT" \ "$REPO_ROOT/scripts/build-usb.sh" --yes --payload-root "$PXETEST_PAYLOAD_ROOT" \
--site-vars "$SITE_VARS" \ --site-vars "$SITE_VARS" \

View File

@@ -58,6 +58,7 @@ from services import (
imaging_status, imaging_status,
mcl_ppkg, mcl_ppkg,
nic_status, nic_status,
share_accounts,
system, system,
unattend, unattend,
upload_verify, upload_verify,
@@ -72,6 +73,7 @@ from views import images as images_views
from views import images_drivers as images_drivers_views from views import images_drivers as images_drivers_views
from views import images_unattend as images_unattend_views from views import images_unattend as images_unattend_views
from views import engineering_layer as engineering_layer_views from views import engineering_layer as engineering_layer_views
from views import share_accounts as share_accounts_views
from views import ipxe as ipxe_views from views import ipxe as ipxe_views
app = Flask(__name__) app = Flask(__name__)
@@ -99,6 +101,7 @@ app.register_blueprint(images_views.bp)
app.register_blueprint(images_drivers_views.bp) app.register_blueprint(images_drivers_views.bp)
app.register_blueprint(images_unattend_views.bp) # Unattend review/save/undo app.register_blueprint(images_unattend_views.bp) # Unattend review/save/undo
app.register_blueprint(engineering_layer_views.bp) # Engineer image: Engineering Layer upload app.register_blueprint(engineering_layer_views.bp) # Engineer image: Engineering Layer upload
app.register_blueprint(share_accounts_views.bp) # Tools > Share accounts (Samba passwords)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1605,6 +1608,13 @@ def _startnet_save_content(new_content, prior, action, detail):
# still let the operator save (the raw editor is the source of truth). # still let the operator save (the raw editor is the source of truth).
flash(f"Warning: could not snapshot prior startnet.cmd: {exc}", "warning") flash(f"Warning: could not snapshot prior startnet.cmd: {exc}", "warning")
# pxe-upload's password belongs to Tools > Share accounts: a restored
# snapshot or a hand edit cannot put an old one back.
pinned = share_accounts.pin_startnet(new_content)
if pinned != new_content:
flash("set SHAREPASS= was changed back to the pxe-upload password "
"(change it on Tools > Share accounts).", "warning")
new_content = pinned
ok, err = wim.update_startnet(config.BOOT_WIM, new_content) ok, err = wim.update_startnet(config.BOOT_WIM, new_content)
if not ok: if not ok:
flash(f"Failed to update boot.wim: {err}", "danger") flash(f"Failed to update boot.wim: {err}", "danger")

View File

@@ -4,6 +4,8 @@ Three values differ per site and are filled into our preferences.xml:
business_location printed on every erasure report (the site) business_location printed on every erasure report (the site)
wifi_ssid the Wi-Fi Blancco joins to license erasures in the GE wifi_ssid the Wi-Fi Blancco joins to license erasures in the GE
wifi_password Blancco cloud (the imaging LAN has no internet) wifi_password Blancco cloud (the imaging LAN has no internet)
A fourth, the password of the blancco Samba account in <network_share>,
comes from services/share_accounts.py (Tools > Share accounts).
They live in UPDATES_DIR/blancco/site-settings.json (0600, root). The repo's They live in UPDATES_DIR/blancco/site-settings.json (0600, root). The repo's
playbook/blancco-preferences.xml carries none of them; the playbook installs playbook/blancco-preferences.xml carries none of them; the playbook installs
@@ -38,6 +40,10 @@ _LOCATION = re.compile(rb"(<business_location>)[^<]*(</business_location>)")
_WIRELESS = re.compile( _WIRELESS = re.compile(
rb'(<wireless>\s*<enabled>[^<]*</enabled>\s*<ssid encrypted="false">)[^<]*' rb'(<wireless>\s*<enabled>[^<]*</enabled>\s*<ssid encrypted="false">)[^<]*'
rb'(</ssid>\s*<password encrypted="false">)[^<]*(</password>)') rb'(</ssid>\s*<password encrypted="false">)[^<]*(</password>)')
# The report share login: the blancco Samba account (Tools > Share accounts).
_SHARE = re.compile(
rb'(<network_share>\s*<username encrypted="false">blancco</username>\s*'
rb'<password encrypted="false">)[^<]*(</password>)')
def settings_path(): def settings_path():
@@ -99,6 +105,8 @@ def apply(xml, settings=None):
XML-escaped; the replacement is a function, so nothing in them is read XML-escaped; the replacement is a function, so nothing in them is read
as a regex group reference.""" as a regex group reference."""
s = settings or load() s = settings or load()
from services import share_accounts
share_pw = escape(share_accounts.load()["blancco"]).encode("utf-8")
loc = escape(s["business_location"] or "Unnamed site").encode("utf-8") loc = escape(s["business_location"] or "Unnamed site").encode("utf-8")
ssid = escape(s["wifi_ssid"]).encode("utf-8") ssid = escape(s["wifi_ssid"]).encode("utf-8")
pw = escape(s["wifi_password"]).encode("utf-8") pw = escape(s["wifi_password"]).encode("utf-8")
@@ -108,6 +116,9 @@ def apply(xml, settings=None):
out, n = _WIRELESS.subn(lambda m: m.group(1) + ssid + m.group(2) + pw + m.group(3), out) out, n = _WIRELESS.subn(lambda m: m.group(1) + ssid + m.group(2) + pw + m.group(3), out)
if n != 1: if n != 1:
raise ValueError("preferences have no single <wireless> ssid/password block") raise ValueError("preferences have no single <wireless> ssid/password block")
out, n = _SHARE.subn(lambda m: m.group(1) + share_pw + m.group(2), out)
if n != 1:
raise ValueError("preferences have no single <network_share> login for blancco")
return out return out

View File

@@ -988,6 +988,32 @@ def apply_prefs(version):
return meta return meta
def refresh_live_prefs():
"""Re-render the served preferences.xml (site settings, share login)
and write the same preferences into the live config.img, which the
BIOS (native) boot path reads instead. Returns True if a file changed.
release.json keeps describing the release when it did before."""
from services import blancco_prefs
with _lock:
root = blancco_root()
meta_path = os.path.join(root, "release.json")
meta = uc.read_json(meta_path, None)
meta_ok = bool(meta and meta.get("fingerprint") == _fingerprint(root))
changed = blancco_prefs.render()
cfg_path = os.path.join(root, "config.img")
cfg = _read(cfg_path)
if cfg is not None:
with open(blancco_prefs.served_prefs_path(), "rb") as f:
ours = f.read()
new = patch_config_img(cfg, ours)
if new != cfg:
_atomic_write(cfg_path, new)
changed = True
if changed and meta_ok:
_write_live_meta(meta)
return changed
def save_merged_prefs(version, data): def save_merged_prefs(version, data):
"""A human-merged preferences.xml for a staged version: check it, keep it, """A human-merged preferences.xml for a staged version: check it, keep it,
and finish staging with it.""" and finish staging with it."""

View File

@@ -0,0 +1,255 @@
"""Passwords of the three Samba accounts the imaging clients use.
pxe-upload WinPE (startnet.cmd in boot.wim) maps the image and
enrollment shares with it; startnet writes it to
C:\\Enrollment\\fetch-source.txt for the Shop Floor first-logon
scripts (deleted by Invoke-PostPpkg.ps1 when imaging is
done). People use it to upload images and apps.
blancco Blancco saves erasure reports to [blancco-reports] (the
<network_share> in our Blancco preferences).
clonezilla Clonezilla's back up / restore entries in the iPXE menu
(smb3://clonezilla:<password>@server/clonezilla/).
The stored values live in UPDATES_DIR/shares/accounts.json (0600, root) and
are the authority: apply() puts each one into Samba (smbpasswd) and into
every file that carries it, and the playbook runs apply at the end of every
run, so a re-run (which re-injects startnet.cmd from the USB and renders the
menu from site-vars) keeps them. The playbook seeds the file from site-vars
(pxe_upload_password, blancco_smb_password, clonezilla_smb_password) only
for accounts it does not hold yet.
The WinPE image and the iPXE menu are served to any imaging client, so
anything on the imaging LAN can read pxe-upload's and clonezilla's
passwords. A site password still keeps other sites' PCs and people out.
python3 -m services.share_accounts apply [--seed]
--seed reads SHARE_SEED_PXE_UPLOAD / _BLANCCO / _CLONEZILLA from the
environment (not argv: the passwords stay out of ps).
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta
import config
from services import updates_common as uc
ACCOUNTS = ("pxe-upload", "blancco", "clonezilla")
DEFAULTS = {"pxe-upload": "pxe", "blancco": "blancco", "clonezilla": "clonezilla-backup"}
SEED_ENV = {"pxe-upload": "SHARE_SEED_PXE_UPLOAD", "blancco": "SHARE_SEED_BLANCCO",
"clonezilla": "SHARE_SEED_CLONEZILLA"}
# Each password sits in a cmd `set`, a `net use` line, XML, a smb3:// URI
# and a kernel command line: only characters none of them treat specially.
PASSWORD = re.compile(r"^[A-Za-z0-9._~-]{8,64}$")
PASSWORD_RULE = "8 to 64 characters from A-Z a-z 0-9 . _ ~ -"
# A PC that reported progress this recently is treated as still imaging.
BUSY_WINDOW = timedelta(hours=2)
class ApplyFailed(Exception):
"""The password is stored, but putting it somewhere failed."""
_SHAREPASS = re.compile(r"^([ \t]*set[ \t]+SHAREPASS=)[^\r\n]*", re.IGNORECASE | re.MULTILINE)
_CZ_URI = re.compile(r"(smb3://clonezilla:)[^@\s\"]*(@)")
def state_path():
return os.path.join(uc.state_dir("shares"), "accounts.json")
def _stored():
data = uc.read_json(state_path(), None)
if not isinstance(data, dict):
return {}
return {a: data[a] for a in ACCOUNTS if isinstance(data.get(a), str) and data[a]}
def load():
"""Every account's current password (the default where none is stored)."""
out = dict(DEFAULTS)
out.update(_stored())
return out
def _write(values):
path = state_path()
tmp = path + ".tmp"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(values, f, indent=1)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
def public():
"""For the page: never the passwords, only whether each is the default."""
cur = load()
return [{"account": a, "default": cur[a] == DEFAULTS[a]} for a in ACCOUNTS]
def check(account, password):
if account not in ACCOUNTS:
raise ValueError(f"unknown account {account!r}")
if not isinstance(password, str) or not PASSWORD.match(password):
raise ValueError(f"the {account} password must be {PASSWORD_RULE}")
if password == DEFAULTS[account]:
raise ValueError(f"that is the shipped default for {account}, the same at every site")
def busy():
"""Sessions that look like a PC imaging now: running, updated within
BUSY_WINDOW. A password change mid-run breaks that PC's next net use."""
from services import imaging_status
cutoff = datetime.now().astimezone() - BUSY_WINDOW
out = []
for s in imaging_status.list_sessions(include_inferred=False):
if imaging_status.floor_state(s) != "running":
continue
try:
when = datetime.fromisoformat(s.get("last_updated") or "")
if when.tzinfo is None:
when = when.astimezone()
except (ValueError, TypeError):
continue
if when >= cutoff:
out.append(s.get("hostname") or s.get("serial") or s.get("mac") or "?")
return out
# --- where each password goes ----------------------------------------------
def _smbpasswd(account, password):
"""Set the Samba password (the account itself is the playbook's)."""
r = subprocess.run(["smbpasswd", "-s", "-a", account], input=f"{password}\n{password}\n",
capture_output=True, text=True, timeout=30)
if r.returncode != 0:
raise RuntimeError(f"smbpasswd {account} failed: {(r.stderr or r.stdout).strip()}")
def pin_startnet(content, password=None):
"""startnet.cmd text with `set SHAREPASS=` set to pxe-upload's password.
Every startnet save goes through this, so restoring an old snapshot
cannot bring an old password back."""
pw = password if password is not None else load()["pxe-upload"]
return _SHAREPASS.sub(lambda m: m.group(1) + pw, content)
def _apply_startnet(password):
from services import wim
if not os.path.isfile(config.BOOT_WIM):
return "boot.wim: not on this server yet (skipped)"
cur = wim.extract_startnet(config.BOOT_WIM)
if cur is None:
raise RuntimeError("could not read startnet.cmd from boot.wim")
if not _SHAREPASS.search(cur):
raise RuntimeError("startnet.cmd in boot.wim has no 'set SHAREPASS=' line; "
"set the password in the Boot menu editor")
new = pin_startnet(cur, password)
if new == cur:
return "boot.wim: unchanged"
wim.save_snapshot(cur, note="SHARE_PASSWORD")
ok, err = wim.update_startnet(config.BOOT_WIM, new)
if not ok:
raise RuntimeError(f"boot.wim update failed: {err}")
return "boot.wim: startnet.cmd updated"
def _apply_menu(password):
from services import blancco_update as bu
path = bu.menu_path()
if not os.path.isfile(path):
return "iPXE menu: not on this server yet (skipped)"
with bu._lock:
path, text, _ = bu._read_menu()
if not _CZ_URI.search(text):
return "iPXE menu: no Clonezilla share entries (skipped)"
new = _CZ_URI.sub(lambda m: m.group(1) + password + m.group(2), text)
if new == text:
return "iPXE menu: unchanged"
backup = bu._write_menu(path, new, "pre-share-password")
# The backup sits in the web root: it must not keep the old password.
os.remove(backup)
return "iPXE menu: Clonezilla entries updated"
def _apply_blancco():
from services import blancco_update as bu
return "Blancco preferences: " + ("updated" if bu.refresh_live_prefs() else "unchanged")
def apply(only=None, values=None):
"""Put the stored passwords everywhere. Returns a list of what happened.
only: one account, or None for all three."""
values = values or load()
done = []
for account in ACCOUNTS:
if only and account != only:
continue
_smbpasswd(account, values[account])
done.append(f"Samba {account}: set")
if account == "pxe-upload":
done.append(_apply_startnet(values[account]))
elif account == "blancco":
done.append(_apply_blancco())
elif account == "clonezilla":
done.append(_apply_menu(values[account]))
return done
def set_password(account, password, force=False):
"""Store one account's password and apply it. Refused while PCs image
unless force. Returns the list of what happened."""
check(account, password)
if not force:
running = busy()
if running:
raise RuntimeError("PCs are imaging now (" + ", ".join(running[:5])
+ (", ..." if len(running) > 5 else "")
+ "); wait for them to finish, or tick 'change anyway'")
stored = _stored()
stored[account] = password
_write(stored)
try:
return apply(only=account)
except Exception as e:
raise ApplyFailed(str(e)) from e
def seed_from_env():
"""Store site-vars values for accounts the file does not hold yet."""
stored = _stored()
seeded = []
for account in ACCOUNTS:
value = os.environ.get(SEED_ENV[account], "")
if account in stored or not value or value == DEFAULTS[account]:
continue
if not PASSWORD.match(value):
raise ValueError(f"site-vars password for {account} must be {PASSWORD_RULE}")
stored[account] = value
seeded.append(account)
if seeded:
_write(stored)
return seeded
def main(argv):
if not argv or argv[0] != "apply":
print(__doc__)
return 2
if "--seed" in argv[1:]:
for a in seed_from_env():
print(f"seeded {a} from site-vars")
for line in apply():
print(line)
for row in public():
if row["default"]:
print(f"WARNING: {row['account']} still has the shipped default password "
"(set one on Tools > Share accounts)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@@ -446,7 +446,7 @@ def _synth_block(item, ip, eol_indent=""):
"echo Starting %s setup..." % label, "echo Starting %s setup..." % label,
r'start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe', r'start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe',
r"for /l %%i in (1,1,2000000) do rem", r"for /l %%i in (1,1,2000000) do rem",
r"net use Z: \\%s\winpeapps\%s /user:pxe-upload pxe /persistent:no" r"net use Z: \\%s\winpeapps\%s /user:%%SHAREUSER%% %%SHAREPASS%% /persistent:no"
% (ip, image), % (ip, image),
"goto end", "goto end",
] ]

View File

@@ -78,6 +78,7 @@
{{ navlink('Boot menu', url_for('startnet_editor'), ep == 'startnet_editor') }} {{ navlink('Boot menu', url_for('startnet_editor'), ep == 'startnet_editor') }}
{{ navlink('Enrollment packages', url_for('enrollment'), ep == 'enrollment') }} {{ navlink('Enrollment packages', url_for('enrollment'), ep == 'enrollment') }}
{{ navlink('Clonezilla backups', url_for('clonezilla_backups'), ep == 'clonezilla_backups') }} {{ navlink('Clonezilla backups', url_for('clonezilla_backups'), ep == 'clonezilla_backups') }}
{{ navlink('Share accounts', url_for('share_accounts.page'), ep.startswith('share_accounts.')) }}
{{ navlink('Audit log', url_for('audit_log'), ep == 'audit_log') }} {{ navlink('Audit log', url_for('audit_log'), ep == 'audit_log') }}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}Share accounts - PXE Manager{% endblock %}
{# Tools > Share accounts: one row per Samba account the imaging clients use,
each with its own password form. Passwords are never shown back.
services/share_accounts.py. #}
{% block extra_head %}
<style>
.sec-h { padding: 18px 24px 12px; }
.sec-h .sub { margin-top: 2px; }
.acct { padding: 14px 24px 16px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; }
.acct-top { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
.acct-top .name { font-weight: 600; }
.acct-top .sub { flex: 1 1 18rem; min-width: 0; }
.acct form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 12px; margin: 0; }
.acct .fld { display: flex; flex-direction: column; gap: 4px; flex: 1 1 14rem; min-width: 0; max-width: 20rem; }
.acct label { font-size: 13px; color: var(--muted); margin: 0; }
.acct .force { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.busy { padding: 12px 24px; }
@media (max-width: 760px) {
.sec-h, .acct, .busy { padding-left: 16px; padding-right: 16px; }
.acct .fld { max-width: none; }
}
</style>
{% endblock %}
{% block content %}
<header class="ui-head">
<div>
<div class="eyebrow-t">Tools</div>
<h1>Share accounts</h1>
<div class="sub">The Samba logins the imaging PCs, Blancco and Clonezilla use on this server. Give each site its own: the shipped defaults are the same at every site.</div>
</div>
</header>
<div class="ui-stack">
{% if busy %}
<div class="notice busy" role="status">
{{ busy | length }} PC{{ 's' if busy | length != 1 }} imaging now ({{ busy[:5] | join(', ') }}{{ ', ...' if busy | length > 5 }}). A password change now breaks their next share connection, so it is refused unless you tick <strong>Change anyway</strong>.
</div>
{% endif %}
<section class="panel ui-list" aria-labelledby="acct-h">
<div class="sec-h"><h2 id="acct-h" class="sec">Accounts</h2>
<div class="sub">Passwords: {{ rule }}. Saving changes Samba and every file that carries the password (boot.wim, the PXE boot menu, the Blancco preferences) at once. They are never shown here.</div></div>
{% for a in accounts %}
<div class="acct">
<div class="acct-top">
<span class="name mono">{{ a.account }}</span>
{% if a.default %}{{ ui.chip('Default password', 'warn') }}{% else %}{{ ui.chip('Site password', 'ok') }}{% endif %}
<span class="sub">{{ used_by[a.account] }}</span>
</div>
<form method="post" action="{{ url_for('share_accounts.save', account=a.account) }}" autocomplete="off"
onsubmit="return confirm('Change the {{ a.account }} password? Anything that still uses the old one (a PC imaging now, a saved login) stops working.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="fld">
<label for="pw-{{ loop.index }}">New password</label>
<input type="password" class="form-control" id="pw-{{ loop.index }}" name="password" required
minlength="8" maxlength="64" pattern="[A-Za-z0-9._~\-]{8,64}" autocomplete="new-password">
</div>
<div class="fld">
<label for="cf-{{ loop.index }}">Again</label>
<input type="password" class="form-control" id="cf-{{ loop.index }}" name="confirm" required
minlength="8" maxlength="64" autocomplete="new-password">
</div>
{% if busy %}<label class="force"><input type="checkbox" name="force" value="1"> Change anyway</label>{% endif %}
<button type="submit" class="ui-btn pri sm">Save</button>
</form>
</div>
{% endfor %}
</section>
<section class="panel" style="padding: 16px 24px" aria-labelledby="note-h">
<h2 id="note-h" class="sec" style="font-size: 15px">Where else to change it</h2>
<ul class="sub" style="margin: 6px 0 0; padding-left: 20px">
<li><strong>pxe-upload</strong>: anyone who maps the shares by hand, and <span class="mono">Upload-Image.ps1</span> (it asks for the password).</li>
<li>The server's own Linux login (<span class="mono">pxe</span>) is not set here: it is <span class="mono">pxe_admin_password</span> in <span class="mono">site-vars.yml</span>.</li>
</ul>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,300 @@
"""Tools > Share accounts: the pxe-upload, blancco and clonezilla Samba
passwords, and every file each one goes into.
Run from the webapp folder:
PYTHONPATH=.:<deps> python -m unittest tests.test_share_accounts -v
"""
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta
from unittest import mock
_T = tempfile.mkdtemp(prefix="pxe-shares-test-")
for _k, _sub in {"SAMBA_SHARE": "samba", "CLONEZILLA_SHARE": "cz", "BLANCCO_REPORTS": "br",
"ENROLLMENT_SHARE": "enr", "WEB_ROOT": "www", "IMAGING_DIR": "img", "UPDATES_DIR": "upd",
"TFTP_ROOT": "tftp", "BIOS_DIR": "bios", "UPLOAD_DIR": "upl", "SAMBA_LOG_DIR": "smblog",
"STARTNET_BACKUPS_DIR": "snb"}.items():
os.environ.setdefault(_k, os.path.join(_T, _sub))
os.makedirs(os.environ[_k], exist_ok=True)
for _k in ("AUDIT_LOG", "HARDWARE_STATE", "BLANCCO_STATE", "DNSMASQ_LEASES", "APACHE_ACCESS_LOG",
"DNSMASQ_SYSLOG", "NIC_STATUS_FILE", "NIC_CONF", "DNSMASQ_NIC_CONF", "DNSMASQ_CONF", "CLONEZILLA_STATE"):
os.environ.setdefault(_k, os.path.join(_T, _k.lower()))
WEBAPP = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REPO = os.path.dirname(WEBAPP)
sys.path.insert(0, WEBAPP)
os.chdir(WEBAPP)
import config # noqa: E402
import app as A # noqa: E402
from services import blancco_prefs as BP # noqa: E402
from services import blancco_update as BU # noqa: E402
from services import share_accounts as SA # noqa: E402
from services import wim # noqa: E402
PW = "Site-Pass_2026"
def newc(files):
"""A newc cpio archive of {name: bytes} (what Blancco's config.img is)."""
out = b""
def entry(name, data, mode):
nb = name.encode() + b"\0"
hdr = b"070701" + b"".join(b"%08X" % v for v in (
1, mode, 0, 0, 1, 0, len(data), 0, 0, 0, 0, len(nb), 0))
blob = hdr + nb
blob += b"\0" * (-len(blob) % 4)
blob += data + b"\0" * (-len(data) % 4)
return blob
for name, data in files.items():
out += entry(name, data, 0o100644)
return out + entry("TRAILER!!!", b"", 0)
def cpio_file(data, name):
return BU.cpio_member(data, name)
def startnet_text():
with open(os.path.join(REPO, "playbook", "startnet.cmd"), encoding="utf-8") as f:
return f.read()
def menu_text():
with open(os.path.join(REPO, "playbook", "ipxe", "GetPxeScript.aspx"), encoding="utf-8") as f:
return f.read().replace("__CLONEZILLA_SMB_PASSWORD__", "clonezilla-backup")
class Fixture(unittest.TestCase):
def setUp(self):
for d in (config.UPDATES_DIR, config.WEB_ROOT, config.IMAGING_DIR, wim.BACKUPS_DIR):
shutil.rmtree(d, ignore_errors=True)
os.makedirs(d)
# boot.wim with the repo startnet.cmd
src = tempfile.mkdtemp(dir=_T)
os.makedirs(os.path.join(src, "Windows", "System32"))
with open(os.path.join(src, "Windows", "System32", "startnet.cmd"), "w", newline="\r\n") as f:
f.write(startnet_text().replace("\r\n", "\n"))
os.makedirs(os.path.dirname(config.BOOT_WIM))
subprocess.run(["wimcapture", src, config.BOOT_WIM, "--compress=none"], check=True,
capture_output=True)
# iPXE menu as the playbook renders it
os.makedirs(os.path.dirname(BU.menu_path()))
with open(BU.menu_path(), "w", newline="") as f:
f.write(menu_text())
# Blancco: repo preferences, served copy, and a live config.img
os.makedirs(BU.blancco_root(), exist_ok=True)
with open(os.path.join(REPO, "playbook", "blancco-preferences.xml"), "rb") as f:
repo = f.read()
with open(BP.repo_prefs_path(), "wb") as f:
f.write(repo)
BP.render()
with open(BP.served_prefs_path(), "rb") as f:
served = f.read()
prefs_in_img = served + b"\0" * 3000 + b"<!-- " + b"x" * 200 + b" -->\n"
self.cfg = newc({"report.key": b"KEY", "preferences.xml": prefs_in_img, "config.xml": b"<c/>"})
with open(os.path.join(BU.blancco_root(), "config.img"), "wb") as f:
f.write(self.cfg)
self.smb = mock.patch.object(SA, "_smbpasswd").start()
self.addCleanup(mock.patch.stopall)
def startnet(self):
return wim.extract_startnet(config.BOOT_WIM)
def menu(self):
with open(BU.menu_path()) as f:
return f.read()
def prefs_share_pw(self, data):
m = re.search(rb'<network_share>\s*<username encrypted="false">blancco</username>\s*'
rb'<password encrypted="false">([^<]*)</password>', data)
return m.group(1).decode()
class ShareAccounts(Fixture):
def test_defaults_and_public_hides_passwords(self):
self.assertEqual(SA.load(), SA.DEFAULTS)
pub = SA.public()
self.assertTrue(all(r["default"] for r in pub))
self.assertNotIn("pxe", json.dumps(pub).replace("pxe-upload", ""))
def test_rules(self):
for bad in ("short", "has space 123", "semi;colon12", "pct%PATH%xx", "a" * 65, "amp&ersand12"):
with self.assertRaises(ValueError, msg=bad):
SA.check("pxe-upload", bad)
with self.assertRaisesRegex(ValueError, "shipped default"):
SA.check("clonezilla", "clonezilla-backup")
with self.assertRaises(ValueError):
SA.check("root", PW)
SA.check("blancco", "Ok.pass_~-123")
def test_pxe_upload_goes_into_samba_and_startnet(self):
done = SA.set_password("pxe-upload", PW)
self.smb.assert_called_once_with("pxe-upload", PW)
self.assertIn("boot.wim: startnet.cmd updated", done)
sn = self.startnet()
self.assertRegex(sn, r"(?m)^set SHAREPASS=%s\r$" % re.escape(PW))
self.assertIn("set SHAREUSER=pxe-upload", sn)
self.assertEqual(sn.count("\r\n"), sn.count("\n")) # still CRLF
# Only that line changed.
a = startnet_text().replace("\r\n", "\n").splitlines()
b = sn.replace("\r\n", "\n").splitlines()
self.assertEqual([i for i, (x, y) in enumerate(zip(a, b)) if x != y],
[a.index("set SHAREPASS=pxe")])
# The prior startnet was snapshotted.
self.assertTrue(any(s["note"] == "SHARE_PASSWORD" for s in wim.list_snapshots()))
# Stored privately.
st = os.stat(SA.state_path())
self.assertEqual(stat.S_IMODE(st.st_mode), 0o600)
self.assertEqual(SA.load()["pxe-upload"], PW)
self.assertFalse(SA.public()[0]["default"])
def test_clonezilla_goes_into_the_menu_without_a_backup_left(self):
before = set(os.listdir(os.path.dirname(BU.menu_path())))
SA.set_password("clonezilla", PW)
m = self.menu()
self.assertEqual(m.count(f"smb3://clonezilla:{PW}@"), 2)
self.assertNotIn("clonezilla-backup", m)
self.assertEqual(set(os.listdir(os.path.dirname(BU.menu_path()))), before)
self.assertEqual(m, menu_text().replace("clonezilla:clonezilla-backup@", f"clonezilla:{PW}@"))
def test_blancco_goes_into_served_prefs_and_live_config_img(self):
SA.set_password("blancco", PW)
with open(BP.served_prefs_path(), "rb") as f:
served = f.read()
self.assertEqual(self.prefs_share_pw(served), PW)
with open(os.path.join(BU.blancco_root(), "config.img"), "rb") as f:
cfg = f.read()
self.assertEqual(len(cfg), len(self.cfg))
self.assertEqual(self.prefs_share_pw(cpio_file(cfg, "preferences.xml")), PW)
self.assertEqual(cpio_file(cfg, "report.key"), b"KEY")
# A release staged later gets it too (apply_prefs runs blancco_prefs.apply).
with open(BP.repo_prefs_path(), "rb") as f:
self.assertEqual(self.prefs_share_pw(BP.apply(f.read())), PW)
def test_apply_is_idempotent(self):
SA.set_password("pxe-upload", PW)
SA.set_password("clonezilla", PW + "x")
SA.set_password("blancco", PW + "y")
done = SA.apply()
self.assertIn("boot.wim: unchanged", done)
self.assertIn("iPXE menu: unchanged", done)
self.assertIn("Blancco preferences: unchanged", done)
def test_refused_while_a_pc_images(self):
now = datetime.now().astimezone()
with open(os.path.join(config.IMAGING_DIR, "ABC123.json"), "w") as f:
json.dump({"serial": "ABC123", "hostname": "BAY-7", "status": "in_progress",
"stage_index": 3, "last_updated": now.isoformat()}, f)
with open(os.path.join(config.IMAGING_DIR, "OLD1.json"), "w") as f:
json.dump({"serial": "OLD1", "status": "in_progress", "stage_index": 3,
"last_updated": (now - timedelta(hours=5)).isoformat()}, f)
self.assertEqual(SA.busy(), ["BAY-7"])
with self.assertRaisesRegex(RuntimeError, "BAY-7"):
SA.set_password("pxe-upload", PW)
self.assertEqual(SA.load()["pxe-upload"], "pxe") # nothing stored
self.smb.assert_not_called()
SA.set_password("pxe-upload", PW, force=True)
self.assertEqual(SA.load()["pxe-upload"], PW)
def test_seed_only_for_accounts_not_held(self):
SA.set_password("blancco", PW)
env = {"SHARE_SEED_PXE_UPLOAD": "Seed-Upload-1", "SHARE_SEED_BLANCCO": "Seed-Blancco-1",
"SHARE_SEED_CLONEZILLA": "clonezilla-backup"}
with mock.patch.dict(os.environ, env):
self.assertEqual(SA.seed_from_env(), ["pxe-upload"])
cur = SA.load()
self.assertEqual((cur["pxe-upload"], cur["blancco"], cur["clonezilla"]),
("Seed-Upload-1", PW, "clonezilla-backup"))
with mock.patch.dict(os.environ, {"SHARE_SEED_CLONEZILLA": "bad pw"}):
with self.assertRaises(ValueError):
SA.seed_from_env()
def test_cli_apply_seed(self):
with mock.patch.dict(os.environ, {"SHARE_SEED_CLONEZILLA": "Seed-Clone-1"}):
self.assertEqual(SA.main(["apply", "--seed"]), 0)
self.assertIn("smb3://clonezilla:Seed-Clone-1@", self.menu())
self.assertEqual(self.smb.call_count, 3)
def test_failure_after_store_is_reported_as_such(self):
self.smb.side_effect = RuntimeError("smbpasswd pxe-upload failed: no such user")
with self.assertRaises(SA.ApplyFailed):
SA.set_password("pxe-upload", PW)
def test_no_server_files_yet(self):
os.remove(config.BOOT_WIM)
os.remove(BU.menu_path())
os.remove(os.path.join(BU.blancco_root(), "config.img"))
done = SA.apply()
self.assertTrue(any("skipped" in d for d in done))
class StartnetPinning(Fixture):
def setUp(self):
super().setUp()
self.c = A.app.test_client()
html = self.c.get("/startnet").get_data(as_text=True)
self.tok = re.search(r'name="csrf-token" content="([^"]+)"', html).group(1)
def test_editor_save_cannot_bring_back_an_old_password(self):
SA.set_password("pxe-upload", PW)
old = startnet_text() # carries SHAREPASS=pxe, like an old snapshot
r = self.c.post("/startnet/save", data={"content": old, "_csrf_token": self.tok})
self.assertIn(r.status_code, (200, 302))
self.assertRegex(self.startnet(), r"(?m)^set SHAREPASS=%s\r$" % re.escape(PW))
def test_new_menu_block_uses_the_variables(self):
blk = wim._synth_block({"target": "x", "target_image": "gea-x", "label": "X"}, "172.16.9.1")
self.assertIn(r"net use Z: \\172.16.9.1\winpeapps\gea-x /user:%SHAREUSER% %SHAREPASS% /persistent:no", blk)
class Page(Fixture):
def setUp(self):
super().setUp()
self.c = A.app.test_client()
def tok(self):
html = self.c.get("/tools/share-accounts/").get_data(as_text=True)
return re.search(r'name="csrf-token" content="([^"]+)"', html).group(1)
def test_page_and_save(self):
r = self.c.get("/tools/share-accounts/")
self.assertEqual(r.status_code, 200)
html = r.get_data(as_text=True)
for a in SA.ACCOUNTS:
self.assertIn(a, html)
self.assertIn("Default password", html)
tok = self.tok()
r = self.c.post("/tools/share-accounts/clonezilla",
data={"password": PW, "confirm": PW, "_csrf_token": tok}, follow_redirects=True)
html = r.get_data(as_text=True)
self.assertIn("clonezilla password changed", html)
self.assertNotIn(PW, html)
self.assertIn(f"clonezilla:{PW}@", self.menu())
def test_mismatch_and_bad_password(self):
tok = self.tok()
r = self.c.post("/tools/share-accounts/blancco",
data={"password": PW, "confirm": PW + "z", "_csrf_token": tok}, follow_redirects=True)
self.assertIn("differ", r.get_data(as_text=True))
r = self.c.post("/tools/share-accounts/blancco",
data={"password": "bad", "confirm": "bad", "_csrf_token": tok}, follow_redirects=True)
self.assertIn("not changed", r.get_data(as_text=True))
self.assertEqual(SA.load()["blancco"], "blancco")
self.smb.assert_not_called()
def test_csrf_required(self):
r = self.c.post("/tools/share-accounts/blancco", data={"password": PW, "confirm": PW})
self.assertIn(r.status_code, (400, 403))
self.assertEqual(SA.load()["blancco"], "blancco")
if __name__ == "__main__":
unittest.main()

View File

@@ -45,14 +45,16 @@ def page():
@bp.route("/site-settings", methods=["POST"]) @bp.route("/site-settings", methods=["POST"])
def site_settings(): def site_settings():
"""Business location and licensing Wi-Fi: saved, then the served """Business location and licensing Wi-Fi: saved, then the served
preferences.xml is rendered again. The password is never shown back.""" preferences.xml and the live config.img are written again. The password
is never shown back."""
try: try:
with bu._lock: with bu._lock:
blancco_prefs.save(request.form.get("business_location", ""), blancco_prefs.save(request.form.get("business_location", ""),
request.form.get("wifi_ssid", ""), request.form.get("wifi_ssid", ""),
request.form.get("wifi_password", "")) request.form.get("wifi_password", ""))
changed = blancco_prefs.render() # Served preferences.xml and the live config.img (BIOS path).
except (OSError, ValueError) as e: changed = bu.refresh_live_prefs()
except (OSError, ValueError, bu.StageBlocked) as e:
flash(f"Blancco site settings not saved: {e}", "danger") flash(f"Blancco site settings not saved: {e}", "danger")
return _back() return _back()
s = blancco_prefs.public() s = blancco_prefs.public()

View File

@@ -0,0 +1,43 @@
"""Tools > Share accounts: the Samba passwords the imaging clients use
(pxe-upload, blancco, clonezilla). Logic in services/share_accounts.py."""
from flask import Blueprint, flash, redirect, render_template, request, url_for
from services import share_accounts as sa
from services.audit import audit
bp = Blueprint("share_accounts", __name__, url_prefix="/tools/share-accounts")
USED_BY = {
"pxe-upload": "WinPE and Shop Floor setup on every imaging PC; image and app uploads",
"blancco": "Blancco saving erasure reports to the blancco-reports share",
"clonezilla": "Clonezilla back up and restore (in the PXE boot menu)",
}
@bp.route("/")
def page():
return render_template("share_accounts.html", accounts=sa.public(), used_by=USED_BY,
busy=sa.busy(), rule=sa.PASSWORD_RULE)
@bp.route("/<account>", methods=["POST"])
def save(account):
pw = request.form.get("password", "")
if pw != request.form.get("confirm", ""):
flash(f"{account}: the two passwords differ; nothing changed.", "danger")
return redirect(url_for("share_accounts.page"))
force = request.form.get("force") == "1"
try:
done = sa.set_password(account, pw, force=force)
except sa.ApplyFailed as e:
audit("SHARE_PASSWORD_FAILED", f"{account}: {e}")
flash(f"{account} password stored, but applying it failed: {e}. "
"Fix the cause and save it again.", "danger")
return redirect(url_for("share_accounts.page"))
except (ValueError, RuntimeError, OSError) as e:
flash(f"{account} password not changed: {e}", "danger")
return redirect(url_for("share_accounts.page"))
audit("SHARE_PASSWORD", f"{account}{' (while PCs were imaging)' if force else ''}: " + "; ".join(done))
flash(f"{account} password changed. " + "; ".join(done) + ".", "success")
return redirect(url_for("share_accounts.page"))