Files
pxe-server/webapp/services/share_accounts.py
cproudlock fa79f76951 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).
2026-10-17 12:29:06 -04:00

256 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
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:]))