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:
@@ -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)
|
||||
wifi_ssid the Wi-Fi Blancco joins to license erasures in the GE
|
||||
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
|
||||
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(
|
||||
rb'(<wireless>\s*<enabled>[^<]*</enabled>\s*<ssid encrypted="false">)[^<]*'
|
||||
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():
|
||||
@@ -99,6 +105,8 @@ def apply(xml, settings=None):
|
||||
XML-escaped; the replacement is a function, so nothing in them is read
|
||||
as a regex group reference."""
|
||||
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")
|
||||
ssid = escape(s["wifi_ssid"]).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)
|
||||
if n != 1:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -988,6 +988,32 @@ def apply_prefs(version):
|
||||
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):
|
||||
"""A human-merged preferences.xml for a staged version: check it, keep it,
|
||||
and finish staging with it."""
|
||||
|
||||
255
webapp/services/share_accounts.py
Normal file
255
webapp/services/share_accounts.py
Normal 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:]))
|
||||
@@ -446,7 +446,7 @@ def _synth_block(item, ip, eol_indent=""):
|
||||
"echo Starting %s setup..." % label,
|
||||
r'start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe',
|
||||
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),
|
||||
"goto end",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user