"""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 in our Blancco preferences). clonezilla Clonezilla's back up / restore entries in the iPXE menu (smb3://clonezilla:@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:]))