Files
pxe-server/webapp/services/share_accounts.py
cproudlock c2b0c3d35e Share accounts: an older live Blancco release warns instead of failing
A fresh server gets boot-tools/blancco from the USB, which can be an older
Blancco (7.15.1) than our preferences (7.20). The version guard refused to
patch its config.img and the exception failed the whole share apply, so the
first-boot playbook failed. refresh_live_prefs() now returns a warning for
config.img and still writes the served preferences.xml (UEFI path).
2026-10-17 12:40:00 -04:00

258 lines
9.5 KiB
Python

"""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
changed, warning = bu.refresh_live_prefs()
out = "Blancco preferences: " + ("updated" if changed else "unchanged")
return out + (f" (WARNING: {warning})" if warning else "")
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:]))