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).
258 lines
9.5 KiB
Python
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:]))
|