From: Thomas Walker Lynch Date: Wed, 11 Nov 2026 01:21:54 +0000 (+0000) Subject: . X-Git-Url: https://git.reasoningtechnology.com/?a=commitdiff_plain;h=d53ccff1830875a5d05c8dca4a9a1527e4be7685;p=Account-Tree . --- diff --git a/developer/authored/AccountTree.copy/AccountTree_admin b/developer/authored/AccountTree.copy/AccountTree_admin index fb094c9..2666d0f 100755 --- a/developer/authored/AccountTree.copy/AccountTree_admin +++ b/developer/authored/AccountTree.copy/AccountTree_admin @@ -3,7 +3,7 @@ """ AccountTree_admin -The administrator's program. It runs as root, and calls the work functions +The administrator's program. It runs under root, and calls the work functions directly, so it works whether or not the AccountTree server is running. A person manages the child accounts of his own login account with the AccountTree program. @@ -44,6 +44,10 @@ Usage: AccountTree_admin [argument ...] the range of system uids AccountTree gives out (default 2000 to 59999) system show print the system database, and verify it + system configure write the systemd units and udev rules that plug and + unplug removable storage, and reload systemd and udev. + A file of the same name that AccountTree did not make + is reported and left alone system delete delete the system database; refused while any system account is the same account as a storage account @@ -59,6 +63,34 @@ Usage: AccountTree_admin [argument ...] holding files are deleted only with delete-account-data + storage register [name ] + record a removable storage, while it is unlocked and + mounted: its LUKS2 partition, the FIDO2 key enrolled + in it, its storage database, and where it is mounted. + The storage name is the mount directory's name unless + given + storage unregister + retire that record + storage plug [passphrase] + unlock a registered storage's partition with the key + plugged in, mount it, attach it, and offer its login + accounts on the login screen. Pulling the key out + then unplugs it. passphrase asks for the passphrase on + this terminal instead, and watches no key. Run by + AccountTree-plug@.service when the drive is plugged in + storage plug all plug every registered storage plugged in and locked. + Run by AccountTree-key-arrive.service when a key is + plugged in + storage unplug + end every process of its accounts, whoever is at the + console, unmount their homes, hide them from the login + screen, unmount and lock the storage, and power the + drive off: once its light is out it is safe to pull + storage unplug key + unplug every storage the key at /dev/hidrawN unlocked. + Run by AccountTree-key@.service when the key is pulled + out + account make [uid ] [parent ] [make-system-account] make a storage account, with its home in account_data; its database uid is the next counting down from @@ -100,6 +132,11 @@ Usage: AccountTree_admin [argument ...] The order on a new system: system make, storage make, account make, attach. To undo: storage delete, then system delete. + +For a removable storage, once: enroll the key with systemd-cryptenroll +--fido2-device=auto --fido2-with-client-pin=no , then system configure, +then storage register while it is mounted. From then on plugging the drive in, and +touching the key, plugs it; pulling the key out unplugs it. """ import sys ,os @@ -109,6 +146,8 @@ import common import system import storage import same_account +import storage_plug +import system_configuration from database import DatabaseError # first words that take a second word to make the command @@ -199,7 +238,7 @@ def CLI(argv=None): command ,rest = f"{command} {rest[0]}" ,rest[1:] if command not in READ_ONLY and os.geteuid() != 0 and not preview: - print("Error: AccountTree_admin changes the system and must be run as root." ,file=sys.stderr) + print("Error: AccountTree_admin changes the system and must be run under root." ,file=sys.stderr) return 1 log = common.Log(preview) @@ -255,6 +294,29 @@ def CLI(argv=None): elif command == "detach" and len(rest) == 1: header(command ,rest[0] ,preview) storage.detach(rest[0] ,log ,preview) + elif command == "system configure" and not rest: + header(command ,None ,preview) + system_configuration.configure(log ,preview) + elif command == "storage register" and rest: + (tree_dir,) ,keyword = split_keyword(rest ,1 ,() ,("name",)) + header(command ,tree_dir ,preview) + storage_plug.register(tree_dir ,keyword.get("name") ,log ,preview) + elif command == "storage unregister" and len(rest) == 1: + header(command ,None ,preview) + storage_plug.unregister(rest[0] ,log ,preview) + elif command == "storage plug" and rest == ["all"]: + header(command ,None ,preview) + storage_plug.plug_all(log ,preview) + elif command == "storage plug" and rest: + (device,) ,keyword = split_keyword(rest ,1 ,("passphrase",)) + header(command ,None ,preview) + storage_plug.plug(device ,"passphrase" in keyword ,log ,preview) + elif command == "storage unplug" and len(rest) == 2 and rest[0] == "key": + header(command ,None ,preview) + storage_plug.unplug_key(rest[1] ,log ,preview) + elif command == "storage unplug" and len(rest) == 1: + header(command ,None ,preview) + storage_plug.unplug(rest[0] ,log ,preview) elif command == "limit set" and len(rest) == 2: header(command ,None ,preview) system.limit_set(rest[0] ,integer(rest[1] ,"") ,log ,preview) diff --git a/developer/authored/AccountTree.copy/AccountTree_install b/developer/authored/AccountTree.copy/AccountTree_install new file mode 100755 index 0000000..08fb747 --- /dev/null +++ b/developer/authored/AccountTree.copy/AccountTree_install @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# -*- mode: sh; indent-tabs-mode: nil; sh-basic-offset: 2 -*- +# +# AccountTree_install +# +# Install AccountTree from the directory this script is in, the project's +# consumer/AccountTree, into /usr/local/lib/AccountTree, where only root can +# change it. The systemd units run AccountTree_admin under root, so the copy +# they run must be one that no other account can change. +# +# The install directory is made to hold exactly the files of the consumer +# directory: new and changed files are copied, owned by root, and files the +# consumer directory no longer has are deleted. Python's __pycache__ is left +# out. Then system configure writes the units and the udev rule, so that they +# name the installed AccountTree_admin. +# +# Run it under root. Whoever installs code that root runs can act as root, so +# installing is root's own work. + +INSTALL_DIR=/usr/local/lib/AccountTree + +usage(){ + cat < + AccountTree_install preview + help | -h | --help print this message (also printed when given no arguments) + run install from $(source_dir) into $INSTALL_DIR, + then run AccountTree_admin system configure + preview run print what run would do, change nothing +EOF +} + +source_dir(){ + cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd +} + +# the files to install, relative to the source dir, one per line +file_list(){ + ( cd "$1" && find . -name __pycache__ -prune -o -type f -print | sed 's|^\./||' | sort ) +} + +# mode for an installed file: programs 0755, everything else 0644 +file_mode(){ + if [ -x "$1" ]; then echo 0755; else echo 0644; fi +} + +install_tree(){ + local preview=$1 source=$2 + local count_new=0 count_replaced=0 count_deleted=0 count_unchanged=0 + local verb_made=made verb_new=installed verb_replaced=replaced verb_deleted=deleted + if [ "$preview" = yes ]; then + verb_made="would make" verb_new="would install" verb_replaced="would replace" verb_deleted="would delete" + fi + + if [ ! -d "$INSTALL_DIR" ]; then + printf ' %-16s%s\n' "$verb_made" "$INSTALL_DIR" + [ "$preview" = yes ] || install -d -o root -g root -m 0755 "$INSTALL_DIR" || return 1 + fi + + local f mode + while IFS= read -r f; do + mode=$(file_mode "$source/$f") + if [ ! -e "$INSTALL_DIR/$f" ]; then + printf ' %-16s%s\n' "$verb_new" "$f"; count_new=$((count_new + 1)) + elif ! cmp -s "$source/$f" "$INSTALL_DIR/$f" || [ "$(stat -c %a "$INSTALL_DIR/$f")" != "${mode#0}" ] \ + || [ "$(stat -c %u:%g "$INSTALL_DIR/$f")" != 0:0 ]; then + printf ' %-16s%s\n' "$verb_replaced" "$f"; count_replaced=$((count_replaced + 1)) + else + count_unchanged=$((count_unchanged + 1)); continue + fi + if [ "$preview" != yes ]; then + install -D -o root -g root -m "$mode" "$source/$f" "$INSTALL_DIR/$f" || return 1 + fi + done < <(file_list "$source") + + # files the consumer directory no longer has + [ -d "$INSTALL_DIR" ] && while IFS= read -r f; do + if [ ! -e "$source/$f" ]; then + printf ' %-16s%s\n' "$verb_deleted" "$f"; count_deleted=$((count_deleted + 1)) + [ "$preview" = yes ] || rm -f -- "$INSTALL_DIR/$f" || return 1 + fi + done < <(file_list "$INSTALL_DIR") + [ "$preview" = yes ] || find "$INSTALL_DIR" -mindepth 1 -type d -empty -delete + + echo "summary:" + printf ' %4d %s\n' "$count_new" "$verb_new" "$count_replaced" "$verb_replaced" \ + "$count_deleted" "$verb_deleted" "$count_unchanged" "unchanged" +} + +CLI(){ + if [ $# -eq 0 ] || [ "$1" = help ] || [ "$1" = -h ] || [ "$1" = --help ]; then + usage; return 0 + fi + local preview=no + if [ "$1" = preview ]; then preview=yes; shift; fi + if [ "$1" != run ] || [ $# -ne 1 ]; then + echo "Error: unknown command '$*'; run 'AccountTree_install help' for the commands." >&2 + return 1 + fi + if [ "$(id -u)" -ne 0 ]; then + echo "Error: AccountTree_install installs code that root runs, and must be run under root." >&2 + return 1 + fi + + local source + source=$(source_dir) || return 1 + if [ ! -f "$source/AccountTree_admin" ]; then + echo "Error: $source holds no AccountTree_admin; run this script from the consumer directory it was promoted to." >&2 + return 1 + fi + if [ "$source" = "$INSTALL_DIR" ]; then + echo "Error: this is the installed copy; run the AccountTree_install in the project's consumer directory." >&2 + return 1 + fi + + local word="" + [ "$preview" = yes ] && word="preview " + echo "AccountTree_install ${word}run from $source to $INSTALL_DIR" + install_tree "$preview" "$source" || return 1 + + if [ "$preview" = yes ]; then + "$INSTALL_DIR/AccountTree_admin" preview system configure 2>/dev/null \ + || echo " would run $INSTALL_DIR/AccountTree_admin system configure" + else + "$INSTALL_DIR/AccountTree_admin" system configure + fi +} + +# run the CLI only when executed, so that another script can source the functions +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + CLI "$@" +fi diff --git a/developer/authored/AccountTree.copy/accounts_service.py b/developer/authored/AccountTree.copy/accounts_service.py new file mode 100644 index 0000000..dfd65d3 --- /dev/null +++ b/developer/authored/AccountTree.copy/accounts_service.py @@ -0,0 +1,97 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +accounts_service - whether an account is offered on the login screen + +GDM, and the other login screens that use AccountsService, list the accounts +AccountsService reports. AccountsService keeps a file for each account it +knows, /var/lib/AccountsService/users/, in the key file format: + + [User] + Session=gnome + SystemAccount=true + +An account whose file says SystemAccount=true is left off the list; it can +still be logged into by typing its name. AccountTree sets the key and leaves +every other key in the file as it was. + + is_hidden_set(name ,hidden) set SystemAccount for one account + +The directory has been /var/lib/AccountsService on Arch, Fedora and Debian; +one accountsservice release on Arch made /var/lib/accountsservice instead, so +both are looked for. +""" + +import os +from pathlib import Path + +DIRECTORY_LIST = (Path("/var/lib/AccountsService/users") ,Path("/var/lib/accountsservice/users")) +SECTION = "[User]" +KEY = "SystemAccount" + + +class AccountsServiceError(Exception): + pass + + +def user_directory(): + """ + The directory AccountsService keeps its account files in, or None when this + system has no AccountsService. + """ + for d in DIRECTORY_LIST: + if d.is_dir(): + return d + return None + +def text_with_key(text ,hidden): + """ + The key file text with SystemAccount set in its [User] section, every other + line kept. A file without a [User] section gets one. + """ + value = "true" if hidden else "false" + line_list = text.splitlines() + result ,in_user ,done = [] ,False ,False + for line in line_list: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + if in_user and not done: + # at the end of the [User] section, before the blank lines that end it + at = len(result) + while at > 0 and not result[at - 1].strip(): at -= 1 + result.insert(at ,f"{KEY}={value}"); done = True + in_user = stripped == SECTION + result.append(line); continue + if in_user and stripped.split("=" ,1)[0].strip() == KEY: + if not done: + result.append(f"{KEY}={value}"); done = True + continue + result.append(line) + if not done: + if not any(l.strip() == SECTION for l in line_list): + if result and result[-1].strip(): result.append("") + result.append(SECTION) + result.append(f"{KEY}={value}") + return "\n".join(result) + "\n" + +def is_hidden_set(name ,hidden): + """ + Set SystemAccount=true (hidden) or false for the account. Returns whether + the file changed, or None on a system without AccountsService. + """ + d = user_directory() + if d is None: + return None + path = d / name + try: + text = path.read_text(encoding="utf-8") if path.exists() else "" + new_text = text_with_key(text ,hidden) + if new_text == text: + return False + temporary = path.with_name(f".{name}.AccountTree") + with open(temporary ,"w" ,encoding="utf-8") as f: + f.write(new_text) + os.chmod(temporary ,0o644) + os.replace(temporary ,path) + except OSError as error: + raise AccountsServiceError(f"{path}: {error}") + return True diff --git a/developer/authored/AccountTree.copy/common.py b/developer/authored/AccountTree.copy/common.py index 60bebfd..804d240 100644 --- a/developer/authored/AccountTree.copy/common.py +++ b/developer/authored/AccountTree.copy/common.py @@ -36,6 +36,8 @@ class Log: "made system account": "would make system account" ,"set": "would set" ,"deleted": "would delete", "deleted system account": "would delete system account" ,"deleted system group": "would delete system group", "retired": "would retire" ,"mapped": "would map" ,"branched": "would branch", + "unlocked": "would unlock" ,"locked": "would lock" ,"powered off": "would power off", + "ended": "would end" ,"watching": "would watch" ,"written": "would write" ,"reloaded": "would reload", } def __init__(self ,preview): diff --git a/developer/authored/AccountTree.copy/database.py b/developer/authored/AccountTree.copy/database.py index cbf93ac..d2eb4e2 100644 --- a/developer/authored/AccountTree.copy/database.py +++ b/developer/authored/AccountTree.copy/database.py @@ -44,6 +44,24 @@ KIND = { "system": (HERE / "schema_system.sql" ,"system_uuid"), } +# Tables added to a kind's schema after databases of that kind were first made. +# Opening an existing database makes each one that is missing. A table made +# this way is empty, so the record's replay, built from the schema file, still +# matches; the statements are taken from the schema file itself, between the +# marker lines, so the two cannot drift apart. +def schema_added_text(kind) -> str: + text = KIND[kind][0].read_text(encoding="utf-8") + start = "CREATE TABLE IF NOT EXISTS" + if start not in text: + return "" + tail = text[text.index(start):] + # the added statements end where the next table without IF NOT EXISTS begins + end = tail.find("\nCREATE TABLE ") + while end != -1 and tail[end + 1:].startswith("CREATE TABLE IF NOT EXISTS"): + end = tail.find("\nCREATE TABLE " ,end + 1) + body = tail if end == -1 else tail[:end] + return "\n".join(line for line in body.splitlines() if not line.startswith("--")) + SYSTEM_DIR = Path("/var/lib/AccountTree") SYSTEM_DATABASE_PATH = SYSTEM_DIR / "system.sqlite3" @@ -101,6 +119,9 @@ class Database: connection.row_factory = sqlite3.Row database = cls(connection ,path ,kind) database.check_standard() + added = schema_added_text(kind) + if added: + connection.executescript(added) return database @classmethod diff --git a/developer/authored/AccountTree.copy/schema_system.sql b/developer/authored/AccountTree.copy/schema_system.sql index 2dc372b..328c963 100644 --- a/developer/authored/AccountTree.copy/schema_system.sql +++ b/developer/authored/AccountTree.copy/schema_system.sql @@ -77,6 +77,26 @@ CREATE TABLE account_limit ( maximum INTEGER NOT NULL ); +-- Each removable storage registered here: the LUKS2 partition that holds it, +-- found by its LUKS uuid, the storage database it carries, and where it is +-- mounted when it is unlocked. The storage name names the device-mapper +-- device, AccountTree_, and the log lines. A row is never +-- deleted; unregistering retires it. This table was added after the first +-- system databases were made, so its statements say IF NOT EXISTS, and +-- opening a system database runs them (see schema_added_text in database.py). +CREATE TABLE IF NOT EXISTS storage_device ( + id INTEGER PRIMARY KEY, + storage_name TEXT NOT NULL, + luks_uuid TEXT NOT NULL, + database_uuid TEXT NOT NULL, + mount_dir TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('registered' ,'retired')), + registered TEXT NOT NULL, + retired TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS storage_device_luks_registered ON storage_device(luks_uuid) WHERE state = 'registered'; +CREATE UNIQUE INDEX IF NOT EXISTS storage_device_name_registered ON storage_device(storage_name) WHERE state = 'registered'; + -- Every change. hash covers previous_hash, series, description, change and nonce. CREATE TABLE transaction_log ( series INTEGER PRIMARY KEY, diff --git a/developer/authored/AccountTree.copy/storage.py b/developer/authored/AccountTree.copy/storage.py index fb61077..a6219d6 100644 --- a/developer/authored/AccountTree.copy/storage.py +++ b/developer/authored/AccountTree.copy/storage.py @@ -164,7 +164,7 @@ def storage_delete(tree_dir ,delete_account_data ,forget_other_systems ,log ,pre home_count = sum(1 for _ in account_data.iterdir()) if not act(log ,preview ,str(account_data) ,shutil.rmtree ,account_data): return log.line("deleted" ,str(account_data) ,f"{home_count} home" + ("" if home_count == 1 else "s")) - short = db.short() + short ,database_uuid = db.short() ,db.uuid() db.close() for entry in sorted(tree_dir.iterdir()): if entry.name.startswith("database.sqlite3"): @@ -173,6 +173,12 @@ def storage_delete(tree_dir ,delete_account_data ,forget_other_systems ,log ,pre if not act(log ,preview ,str(tree_dir) ,tree_dir.rmdir): return log.line("deleted" ,str(tree_dir)) + # ---- its record as a removable storage + if system_db is not None: + import storage_plug + for r in system_db.rows("SELECT * FROM storage_device WHERE database_uuid = ? AND state = 'registered'" ,(database_uuid,)): + storage_plug.unregister(r["storage_name"] ,log ,preview) + # -------------------------------------------------------------------------------- # account make diff --git a/developer/authored/AccountTree.copy/storage_plug.py b/developer/authored/AccountTree.copy/storage_plug.py new file mode 100644 index 0000000..638ae08 --- /dev/null +++ b/developer/authored/AccountTree.copy/storage_plug.py @@ -0,0 +1,733 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +storage_plug - plugging and unplugging removable storage, opened by a key + +A removable storage is a LUKS2 partition holding a file system whose +/home/AccountTree is a tree dir. Registering it records, in the +system database's storage_device table, its LUKS uuid, the storage database it +carries, its storage name and the directory it is mounted at. + + register(tree_dir ,name) record a storage that is unlocked and mounted now + unregister(name) retire the record + plug(device) unlock with a key, mount, attach, offer its + login accounts on the login screen, and watch + the key that unlocked it + plug_all() plug every registered storage present and locked + unplug(spec) end its accounts' processes, unmount their homes, + hide its accounts, unmount, lock, power off + unplug_key(key) unplug every storage that key unlocked + +Which key unlocked a storage. The LUKS2 header holds a systemd-fido2 token: +the credential id of the key it was enrolled with, and a salt. A FIDO2 key +answers for a credential only if it made that credential, so plug offers the +header to each key present in turn, one at a time, naming the key's device +(fido2-device=/dev/hidrawN). systemd-cryptsetup first asks the key, without a +touch, whether it holds the credential; a key that does not is passed over at +once, and the key that does turns blue and waits for its button. The device +whose attempt opened the partition is the key. plug records it in +/run/AccountTree/plugged/.json and starts +AccountTree-key@hidrawN.service, which is bound to the key's device unit: +when the key is pulled out, systemd stops that service, and its ExecStop runs +unplug_key. + +Runtime records are kept under /run, which is emptied at each boot, because a +storage unlocked before a reboot is locked after it. + +Everything is done under a lock, /run/AccountTree/lock, so that the plug +started by the drive and the plug started by the key, which arrive together +at boot, take turns. +""" + +import os ,re ,json ,time ,fcntl ,signal ,shutil ,subprocess ,sqlite3 +from pathlib import Path + +import database +from database import Database ,DatabaseError +import kernel +import accounts_service +from accounts_service import AccountsServiceError +from common import SEPARATOR ,TREE_DIR_NAME ,act ,tree_dir_problem +from system import open_system_database +import same_account +import storage + +RUN_DIR = Path("/run/AccountTree") +PLUGGED_DIR = RUN_DIR / "plugged" +LOCK_PATH = RUN_DIR / "lock" +MAPPER_PREFIX = "AccountTree_" +# instanced by the kernel name of the key's device, such as hidraw3 +KEY_UNIT_TEMPLATE = "AccountTree-key@{}.service" +MOUNT_OPTION = "nosuid,nodev" +# how long processes are given to end: after logind ends their sessions, +# after SIGTERM, and after SIGKILL +SESSION_WAIT_SECOND = 5 +TERM_WAIT_SECOND = 10 +KILL_WAIT_SECOND = 5 + +NAME_CHARACTER = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") + + +# -------------------------------------------------------------------------------- +# running programs + +def program(name ,*fallback): + """ + The path of a program, from PATH or from the fallback paths, or None. + """ + found = shutil.which(name) + if found: return found + for f in fallback: + if os.access(f ,os.X_OK): return f + return None + +def systemd_cryptsetup_path(): + return program("systemd-cryptsetup" ,"/usr/lib/systemd/systemd-cryptsetup" ,"/lib/systemd/systemd-cryptsetup") + +def run(argument ,input_text=None ,timeout=None): + """ + Run a program; returns (exit status ,stdout ,stderr). A program that cannot + be started gives status 127. + """ + try: + r = subprocess.run(argument ,input=input_text ,capture_output=True ,text=True ,timeout=timeout) + return r.returncode ,r.stdout ,r.stderr + except FileNotFoundError: + return 127 ,"" ,f"{argument[0]}: not found" + except subprocess.TimeoutExpired: + return 124 ,"" ,f"{argument[0]}: no answer within {timeout} seconds" + +def last_line(text): + line_list = [l for l in text.strip().splitlines() if l.strip()] + return line_list[-1] if line_list else "" + +def run_logged(log ,preview ,subject ,argument ,input_text=None ,timeout=None): + """ + Run a program that changes the system; in a preview, do nothing. Returns + True when it succeeded, or would be run; logs its last line of error output + and returns False when it failed. + """ + if preview: return True + status ,out ,err = run(argument ,input_text ,timeout) + if status != 0: + log.error(subject ,f"{os.path.basename(argument[0])} failed, status {status}: {last_line(err) or last_line(out)}") + return False + return True + + +# -------------------------------------------------------------------------------- +# the lock + +class Lock: + depth = 0 + fd = None + + def __enter__(self): + if Lock.depth == 0: + RUN_DIR.mkdir(mode=0o700 ,exist_ok=True) + Lock.fd = os.open(str(LOCK_PATH) ,os.O_RDWR | os.O_CREAT | os.O_CLOEXEC ,0o600) + fcntl.flock(Lock.fd ,fcntl.LOCK_EX) + Lock.depth += 1 + return self + + def __exit__(self ,*_): + Lock.depth -= 1 + if Lock.depth == 0: + os.close(Lock.fd) + Lock.fd = None + + +# -------------------------------------------------------------------------------- +# block devices, LUKS and mounts + +def mount_source(path): + """ + The source of the mount whose mount point is path, from + /proc/self/mountinfo, or None. In each line the mount point is the fifth + field, and the source follows the file system type after the field '-'. + """ + target = os.path.abspath(str(path)) + for line in Path("/proc/self/mountinfo").read_text().splitlines(): + field = line.split(" ") + if len(field) < 10 or "-" not in field: continue + point = re.sub(r"\\([0-7]{3})" ,lambda m: chr(int(m.group(1) ,8)) ,field[4]) + if point == target: + i = field.index("-") + return field[i + 2] + return None + +def block_name(device): + """ + The kernel's name of a block device, such as sdb1 or dm-0, from its path. + """ + return os.path.basename(os.path.realpath(str(device))) + +def dm_name(kernel_name): + try: + return Path(f"/sys/class/block/{kernel_name}/dm/name").read_text().strip() + except OSError: + return None + +def dm_slave_list(kernel_name): + try: + return sorted(p.name for p in Path(f"/sys/class/block/{kernel_name}/slaves").iterdir()) + except OSError: + return [] + +def dm_holder_list(kernel_name): + """ + The device-mapper devices opened on a partition, such as the one LUKS makes. + """ + try: + return sorted(p.name for p in Path(f"/sys/class/block/{kernel_name}/holders").iterdir()) + except OSError: + return [] + +def disk_of(kernel_name): + """ + The whole disk a partition is on: sdb for sdb1. A whole disk is its own disk. + """ + path = Path(f"/sys/class/block/{kernel_name}") + try: + real = path.resolve() + except OSError: + return None + if (real / "partition").exists(): + return real.parent.name + return kernel_name + +def luks_uuid(device): + status ,out ,_ = run(["cryptsetup" ,"luksUUID" ,str(device)]) + return out.strip() if status == 0 and out.strip() else None + +def luks_fido2_token_list(device): + """ + The systemd-fido2 tokens of a LUKS2 header, as dictionaries, from + cryptsetup luksDump --dump-json-metadata. + """ + status ,out ,err = run(["cryptsetup" ,"luksDump" ,"--dump-json-metadata" ,str(device)]) + if status != 0: + raise ValueError(f"cannot read the LUKS2 header of {device}: {last_line(err)}") + token = json.loads(out).get("tokens" ,{}) + return [t for t in token.values() if t.get("type") == "systemd-fido2"] + +def key_list(): + """ + The FIDO2 keys plugged in: each hidraw device that udev marks + ID_SECURITY_TOKEN=1, which systemd's 60-fido-id.rules sets for a device + that speaks FIDO. + """ + result = [] + for p in sorted(Path("/dev").glob("hidraw*")): + status ,out ,_ = run(["udevadm" ,"info" ,"--query=property" ,f"--name={p}"]) + if status == 0 and "ID_SECURITY_TOKEN=1" in out.splitlines(): + result.append(str(p)) + return result + +def is_device_unit_active(unit): + status ,out ,_ = run(["systemctl" ,"show" ,"--property=ActiveState" ,"--value" ,unit]) + return status == 0 and out.strip() == "active" + + +# -------------------------------------------------------------------------------- +# records + +def registration_by_name(system_db ,name): + return system_db.row("SELECT * FROM storage_device WHERE storage_name = ? AND state = 'registered'" ,(name,)) + +def registration_by_luks_uuid(system_db ,uuid_text): + return system_db.row("SELECT * FROM storage_device WHERE luks_uuid = ? AND state = 'registered'" ,(uuid_text,)) + +def registration_list(system_db): + return system_db.rows("SELECT * FROM storage_device WHERE state = 'registered' ORDER BY storage_name") + +def plugged_path(name): + return PLUGGED_DIR / f"{name}.json" + +def plugged_read(name): + try: + return json.loads(plugged_path(name).read_text()) + except (OSError ,ValueError): + return None + +def plugged_write(record): + PLUGGED_DIR.mkdir(mode=0o700 ,parents=True ,exist_ok=True) + path = plugged_path(record["storage_name"]) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(record ,indent=2)) + os.replace(temporary ,path) + +def plugged_list(): + try: + return [r for r in (plugged_read(p.stem) for p in sorted(PLUGGED_DIR.glob("*.json"))) if r] + except OSError: + return [] + +def tree_dir_of(registration): + return Path(registration["mount_dir"]) / "home" / TREE_DIR_NAME + +def system_map_row_list(system_db ,database_uuid): + return system_db.rows("SELECT * FROM system_map WHERE database_uuid = ? AND state = 'in use' ORDER BY system_account_name", + (database_uuid,)) + +def is_child_account(name): + return SEPARATOR in name + + +# -------------------------------------------------------------------------------- +# register and unregister + +def register(tree_dir ,name ,log ,preview): + """ + Record a removable storage. It is given by its tree dir, while it is + unlocked and mounted, so that everything recorded is read from the storage + itself: the LUKS2 partition under the mount, its LUKS uuid, the key enrolled + in its header, and its database uuid. + """ + problem = tree_dir_problem(tree_dir) + if problem: + log.error(str(tree_dir) ,problem); return + tree_dir = Path(os.path.abspath(tree_dir)) + root = tree_dir.parent.parent + if root == Path("/"): + log.error(str(tree_dir) ,"is on the file system the system runs from, which is attached at boot and never unplugged"); return + if not kernel.is_mount_point(root): + log.error(str(root) ,"not a mount point; register a storage while it is unlocked and mounted"); return + source = mount_source(root) + mapped = block_name(source) if source else None + if not mapped or dm_name(mapped) is None: + log.error(str(root) ,f"mounted from {source}, which is not an unlocked LUKS2 partition"); return + slave = dm_slave_list(mapped) + if len(slave) != 1: + log.error(str(root) ,f"{source} is made from {slave or 'nothing'}, not from one partition"); return + partition = Path("/dev") / slave[0] + uuid_text = luks_uuid(partition) + if uuid_text is None: + log.error(str(partition) ,"not a LUKS partition"); return + log.line("found" ,str(partition) ,f"LUKS uuid {uuid_text}, unlocked as {dm_name(mapped)}, mounted at {root}") + + try: + token = luks_fido2_token_list(partition) + except ValueError as error: + log.error(str(partition) ,str(error)); return + if not token: + log.error(str(partition) ,"no FIDO2 key is enrolled in its header. Enroll one, touching the key when it" + f" blinks: systemd-cryptenroll --fido2-device=auto --fido2-with-client-pin=no {partition}" + " then register again"); return + for t in token: + if t.get("fido2-clientPin-required"): + log.line("warning" ,str(partition) ,f"the key in key slot {','.join(t.get('keyslots' ,[]))} was enrolled" + " needing its FIDO2 PIN, which an automatic plug has no screen to ask for. Enroll it again with" + " --fido2-with-client-pin=no; a key with its own PIN pad, such as the OnlyKey, keeps its PIN") + log.line("found" ,f"{len(token)} FIDO2 key{'s' if len(token) != 1 else ''} enrolled" ,str(partition)) + + if name is None: name = root.name + if not name or not set(name) <= NAME_CHARACTER: + log.error(name or "(empty)" ,"a storage name is letters, digits, '_', '.' and '-'; give one with the keyword name"); return + + system_db = open_system_database(log) + if system_db is None: return + storage_db = storage.open_storage_database(tree_dir ,log) + if storage_db is None: return + + by_name = registration_by_name(system_db ,name) + by_uuid = registration_by_luks_uuid(system_db ,uuid_text) + if by_uuid is not None: + if by_uuid["storage_name"] == name and by_uuid["mount_dir"] == str(root) and by_uuid["database_uuid"] == storage_db.uuid(): + log.line("kept" ,name ,"already registered") + else: + log.error(name ,f"LUKS uuid {uuid_text} is registered as {by_uuid['storage_name']}; unregister it first"); return + elif by_name is not None: + log.error(name ,f"the storage name is registered for LUKS uuid {by_name['luks_uuid']}; give another with the keyword name"); return + else: + with system_db.transaction(f"register storage {name}" ,preview) as t: + t.execute("INSERT INTO storage_device(storage_name ,luks_uuid ,database_uuid ,mount_dir ,state ,registered)" + " VALUES (? ,? ,? ,? ,'registered' ,?)" ,(name ,uuid_text ,storage_db.uuid() ,str(root) ,database.now())) + log.line("recorded" ,f"storage {name}" ,f"LUKS uuid {uuid_text}, database {storage_db.short()}, mounted at {root}") + + if not system_map_row_list(system_db ,storage_db.uuid()): + log.line("warning" ,name ,"none of its storage accounts is the same account as a system account here," + " so plugging it in mounts no home") + + import system_configuration + system_configuration.rule_write(system_db ,log ,preview) + + # the storage is open now under another name, or under no AccountTree record + plugged = plugged_read(name) + if plugged is None: + plugged_record = {"storage_name": name ,"luks_uuid": uuid_text ,"partition": str(partition), + "mapper": dm_name(mapped) ,"mount_dir": str(root) ,"key": None ,"plugged": database.now()} + if not preview: plugged_write(plugged_record) + log.line("recorded" ,str(plugged_path(name)) ,"unlocked outside AccountTree, so no key is watched;" + f" unplug it this time with 'AccountTree_admin storage unplug {name}'") + +def unregister(name ,log ,preview): + system_db = open_system_database(log) + if system_db is None: return + r = registration_by_name(system_db ,name) + if r is None: + log.error(name ,"no registered storage of that name"); return + with system_db.transaction(f"unregister storage {name}" ,preview) as t: + t.execute("UPDATE storage_device SET state = 'retired' ,retired = ? WHERE id = ?" ,(database.now() ,r["id"])) + log.line("retired" ,f"storage {name}" ,f"LUKS uuid {r['luks_uuid']}") + import system_configuration + system_configuration.rule_write(system_db ,log ,preview) + + +# -------------------------------------------------------------------------------- +# plug + +def plug(device ,passphrase ,log ,preview): + with Lock(): + return plug_locked(device ,passphrase ,log ,preview) + +def plug_all(log ,preview): + """ + Plug every registered storage whose partition is present and not unlocked. + Run when a key is plugged in, for a drive that arrived before its key. + """ + with Lock(): + system_db = open_system_database(log) + if system_db is None: return + found = False + for r in registration_list(system_db): + device = Path("/dev/disk/by-uuid") / r["luks_uuid"] + if not device.exists(): continue + found = True + plug_locked(str(device) ,False ,log ,preview) + if not found: + log.line("skipped" ,"plug all" ,"no registered storage is plugged in") + +def plug_locked(device ,passphrase ,log ,preview): + system_db = open_system_database(log) + if system_db is None: return + if not os.path.exists(device): + log.error(str(device) ,"no such device"); return + uuid_text = luks_uuid(device) + if uuid_text is None: + log.line("skipped" ,str(device) ,"not a LUKS partition"); return + r = registration_by_luks_uuid(system_db ,uuid_text) + if r is None: + log.line("skipped" ,str(device) ,f"LUKS uuid {uuid_text} is not a registered storage"); return + name = r["storage_name"] + partition = Path("/dev") / block_name(device) + mount_dir = Path(r["mount_dir"]) + + # unlock, unless it is unlocked already + holder = [h for h in dm_holder_list(partition.name) if dm_name(h)] + key_used = None + if holder: + mapper = dm_name(holder[0]) + previous = plugged_read(name) + key_used = previous.get("key") if previous and previous.get("mapper") == mapper else None + log.line("kept" ,f"storage {name}" ,f"already unlocked as {mapper}") + else: + mapper = MAPPER_PREFIX + name + if passphrase: + if preview: + log.line("unlocked" ,f"storage {name}" ,"would ask for the passphrase") + else: + # cryptsetup asks on the terminal it was run from + status = subprocess.run(["cryptsetup" ,"open" ,str(partition) ,mapper]).returncode + if status != 0: + log.error(f"storage {name}" ,f"cryptsetup open failed, status {status}"); return + log.line("unlocked" ,f"storage {name}" ,f"as {mapper}, by passphrase; no key is watched") + else: + key_used = key_unlock(partition ,mapper ,name ,log ,preview) + if key_used is None: return + + # mount + mapped_device = Path("/dev/mapper") / mapper + if kernel.is_mount_point(mount_dir): + log.line("kept" ,str(mount_dir) ,"already mounted") + else: + if not mount_dir.exists(): + if not act(log ,preview ,str(mount_dir) ,os.makedirs ,mount_dir ,0o700): return + if not act(log ,preview ,str(mount_dir) ,os.chmod ,mount_dir ,0o700): return + log.line("made" ,str(mount_dir) ,"root only, mode 0700") + if not run_logged(log ,preview ,str(mount_dir) ,["mount" ,"-o" ,MOUNT_OPTION ,str(mapped_device) ,str(mount_dir)]): return + log.line("mounted" ,str(mount_dir) ,f"{mapped_device}, {MOUNT_OPTION}") + + record = {"storage_name": name ,"luks_uuid": uuid_text ,"partition": str(partition) ,"mapper": mapper, + "mount_dir": str(mount_dir) ,"key": key_used ,"plugged": database.now()} + if not preview: plugged_write(record) + + # attach, and offer its login accounts on the login screen + tree_dir = tree_dir_of(r) + error_before = log.error_count + storage.attach(str(tree_dir) ,False ,log ,preview) + if log.error_count == error_before: + is_hidden_set_all(system_db ,r["database_uuid"] ,False ,log ,preview) + + if key_used: + key_watch(key_used ,name ,log ,preview) + +def key_unlock(partition ,mapper ,name ,log ,preview): + """ + Offer the partition to each key plugged in, one at a time. Returns the + device of the key that opened it, or None. + """ + tool = systemd_cryptsetup_path() + if tool is None: + log.error(f"storage {name}" ,"systemd-cryptsetup is not installed"); return None + key = key_list() + if not key: + log.line("waiting" ,f"storage {name}" ,"no key is plugged in; plugging one in unlocks it"); return None + if preview: + log.line("unlocked" ,f"storage {name}" ,f"would offer it to {', '.join(key)}, one at a time") + return key[0] + for k in key: + log.line("offered" ,f"storage {name}" ,f"to the key at {k}; touch it when it lights") + # headless: never fall back to asking for a passphrase, which no one is + # shown here; the administrator unlocks by passphrase with the keyword + status ,out ,err = run([tool ,"attach" ,mapper ,str(partition) ,"none" ,f"fido2-device={k},headless=yes"] ,timeout=120) + if status == 0: + log.line("unlocked" ,f"storage {name}" ,f"as {mapper}, by the key at {k}") + return k + log.line("refused" ,f"the key at {k}" ,last_line(err) or last_line(out) or f"status {status}") + log.error(f"storage {name}" ,"no key plugged in opened it") + return None + +def key_watch(key ,name ,log ,preview): + """ + Start the service bound to the key's device unit, so that pulling the key + out unplugs what it unlocked. + """ + kernel_name = os.path.basename(key) + device_unit = f"dev-{kernel_name}.device" + unit = KEY_UNIT_TEMPLATE.format(kernel_name) + if not preview and not is_device_unit_active(device_unit): + log.line("warning" ,f"storage {name}" ,f"systemd has no {device_unit}, so pulling the key out will not unplug it;" + " run 'AccountTree_admin system configure', which tags keys for systemd") + return + if not run_logged(log ,preview ,unit ,["systemctl" ,"start" ,"--no-block" ,unit]): return + log.line("watching" ,key ,f"{unit}; pulling the key out unplugs {name}") + +def is_hidden_set_all(system_db ,database_uuid ,hidden ,log ,preview): + """ + Offer or hide the storage's login accounts on the login screen. Child + accounts are entered from their parent, never through the login screen, and + are always hidden. + """ + changed = False + for m in system_map_row_list(system_db ,database_uuid): + name = m["system_account_name"] + value = hidden or is_child_account(name) + if preview: + log.line("set" ,name ,f"SystemAccount={'true' if value else 'false'} for the login screen"); continue + try: + is_changed = accounts_service.is_hidden_set(name ,value) + except AccountsServiceError as error: + log.error(name ,str(error)); continue + if is_changed is None: + log.line("skipped" ,name ,"this system has no AccountsService"); return + how = ("hidden from" if value else "offered on") + " the login screen" + if is_changed: + log.line("set" ,name ,how); changed = True + else: + log.line("kept" ,name ,how) + if changed: + # accounts-daemon reads an account's file when it loads the account + run(["systemctl" ,"try-restart" ,"accounts-daemon.service"]) + + +# -------------------------------------------------------------------------------- +# unplug + +def unplug(spec ,log ,preview): + with Lock(): + return unplug_locked(spec ,log ,preview) + +def unplug_key(key ,log ,preview): + """ + Unplug every storage the key unlocked. Run by the key's service when the key + is pulled out. + """ + if not key.startswith("/"): key = "/" + key + with Lock(): + found = False + for record in plugged_list(): + if record.get("key") == key: + found = True + unplug_locked(record["storage_name"] ,log ,preview) + if not found: + log.line("skipped" ,key ,"it unlocked no storage plugged in now") + +def registration_of(system_db ,spec): + """ + The registration a spec names: a storage name, or a device path. A + partition named by /dev/disk/by-uuid/, or one a runtime record + holds, is found even after the drive is gone, when the device can no longer + be read. + """ + r = registration_by_name(system_db ,spec) + if r is not None: return r + path = Path(spec) + # the drive's service names its partition, /dev/sdb1, which is gone by the + # time a drive pulled out is unplugged; the runtime record remembers it + for record in plugged_list(): + if record.get("partition") == str(path): + return registration_by_name(system_db ,record["storage_name"]) + if path.parent == Path("/dev/disk/by-uuid"): + return registration_by_luks_uuid(system_db ,path.name) + if path.exists(): + uuid_text = luks_uuid(path) + if uuid_text: return registration_by_luks_uuid(system_db ,uuid_text) + return None + +def unplug_locked(spec ,log ,preview): + system_db = open_system_database(log) + if system_db is None: return + r = registration_of(system_db ,spec) + if r is None: + # the drive's own service stops after the key's unplug has powered it off; + # by then its record is gone and its partition cannot be read + log.line("skipped" ,str(spec) ,"nothing AccountTree plugged in is on it"); return + name = r["storage_name"] + mount_dir = Path(r["mount_dir"]) + record = plugged_read(name) + mapper = record["mapper"] if record else MAPPER_PREFIX + name + row_list = system_map_row_list(system_db ,r["database_uuid"]) + home_mounted = [m for m in row_list if kernel.is_mount_point(Path("/home") / m["system_account_name"])] + is_open = Path(f"/dev/mapper/{mapper}").exists() + if not (record or home_mounted or is_open or kernel.is_mount_point(mount_dir)): + log.line("skipped" ,f"storage {name}" ,"not plugged in"); return + + # 1. end every process of its accounts, whoever is at the console + process_end([m["system_uid"] for m in row_list] ,log ,preview) + + # 2. unmount their homes, and record the detach while the storage can be read + for m in home_mounted: + target = Path("/home") / m["system_account_name"] + mount_remove(target ,log ,preview) + detach_record(system_db ,r ,log ,preview) + + # 3. hide its accounts from the login screen + is_hidden_set_all(system_db ,r["database_uuid"] ,True ,log ,preview) + + # 4. unmount the storage and lock it + if kernel.is_mount_point(mount_dir): + if not preview: os.sync() + mount_remove(mount_dir ,log ,preview) + if is_open: + if run_logged(log ,preview ,mapper ,["cryptsetup" ,"close" ,mapper]): + log.line("locked" ,f"storage {name}" ,f"{mapper} closed") + elif run_logged(log ,preview ,mapper ,["cryptsetup" ,"close" ,"--deferred" ,mapper]): + log.line("locked" ,f"storage {name}" ,f"{mapper} closes when the last file open on it is closed") + + # 5. power the drive off, so that its light going out says it is safe to pull + partition = Path(record["partition"]) if record else Path("/dev/disk/by-uuid") / r["luks_uuid"] + if partition.exists(): + disk = disk_of(block_name(partition)) + tool = program("udisksctl") + if disk is None: + pass + elif tool is None: + log.line("skipped" ,f"/dev/{disk}" ,"udisksctl is not installed, so the drive is locked but still powered") + elif run_logged(log ,preview ,f"/dev/{disk}" ,[tool ,"power-off" ,"-b" ,f"/dev/{disk}" ,"--no-user-interaction"]): + log.line("powered off" ,f"/dev/{disk}" ,"safe to pull out") + if not preview: + try: + plugged_path(name).unlink() + except FileNotFoundError: + pass + +def process_uid_set(pid_path): + """ + The real, effective, saved and file system uids of a process. + """ + try: + for line in (pid_path / "status").read_text().splitlines(): + if line.startswith("Uid:"): + return {int(x) for x in line.split()[1:]} + except (OSError ,ValueError): + pass + return set() + +def is_zombie(pid_path): + """ + Whether a process has ended and waits only for its parent to collect its + exit status. It holds no file and no mount, so it is not waited for. + """ + try: + stat_text = (pid_path / "stat").read_text() + except OSError: + return True + # the command name is in parentheses and can hold spaces; the state follows it + return stat_text[stat_text.rindex(")") + 2:].startswith("Z") + +def process_list(uid_set): + result = [] + for p in Path("/proc").iterdir(): + if p.name.isdigit() and process_uid_set(p) & uid_set and not is_zombie(p): + result.append(int(p.name)) + return result + +def process_wait(uid_set ,second): + deadline = time.monotonic() + second + while time.monotonic() < deadline: + if not process_list(uid_set): return [] + time.sleep(0.2) + return process_list(uid_set) + +def process_end(uid_list ,log ,preview): + """ + End every process running under the uids: logind ends their sessions; what + remains is sent SIGTERM, and what remains after that, SIGKILL. + """ + uid_set = set(uid_list) + if not uid_set: return + pid = process_list(uid_set) + if not pid: + log.line("kept" ,"processes" ,"none runs under its accounts"); return + if preview: + log.line("ended" ,f"{len(pid)} processes" ,"of its accounts"); return + for u in sorted(uid_set): + run(["loginctl" ,"terminate-user" ,str(u)]) + remaining = process_wait(uid_set ,SESSION_WAIT_SECOND) + for sig ,wait in ((signal.SIGTERM ,TERM_WAIT_SECOND) ,(signal.SIGKILL ,KILL_WAIT_SECOND)): + if not remaining: break + for p in remaining: + try: + os.kill(p ,sig) + except ProcessLookupError: + pass + remaining = process_wait(uid_set ,wait) + if remaining: + log.error("processes" ,f"{len(remaining)} still run under its accounts: {', '.join(map(str ,remaining[:8]))}") + else: + log.line("ended" ,f"{len(pid)} processes" ,"of its accounts") + +def mount_remove(target ,log ,preview): + """ + Unmount target; when something still holds it open, detach it from the + tree so that no one new can reach it, and the kernel finishes when the last + holder lets go. + """ + if preview: + log.line("unmounted" ,str(target)); return True + if kernel.LIBC.umount2(os.fsencode(str(target)) ,0) == 0: + log.line("unmounted" ,str(target)); return True + if kernel.LIBC.umount2(os.fsencode(str(target)) ,kernel.MNT_DETACH) == 0: + log.line("unmounted" ,str(target) ,"something held it open, so it was detached, and closes when that lets go") + return True + log.error(str(target) ,f"umount failed: {os.strerror(kernel.ctypes.get_errno())}") + return False + +def detach_record(system_db ,r ,log ,preview): + """ + Record the detach in both databases while the storage can still be read. + When the drive has been pulled out first, the storage database cannot be + written; the next attach finds the head it last recorded. + """ + tree_dir = tree_dir_of(r) + storage_db = None + try: + storage_db = Database.open(database.storage_database_path(tree_dir) ,"storage") + same_account.system_seen_record(storage_db ,system_db.uuid() ,log ,preview) + series = same_account.head_seen_record(system_db ,storage_db ,"detached" ,None ,preview) + log.line("recorded" ,f"database {storage_db.short()} detached" ,f"head at series {series} remembered here") + except (OSError ,sqlite3.Error ,DatabaseError) as error: + log.line("skipped" ,f"storage {r['storage_name']}" ,f"the detach was not recorded, the storage cannot be read: {error}") + finally: + # an open database file would hold the storage's file system busy + if storage_db is not None: storage_db.close() diff --git a/developer/authored/AccountTree.copy/system.py b/developer/authored/AccountTree.copy/system.py index 6f900f7..385acc7 100644 --- a/developer/authored/AccountTree.copy/system.py +++ b/developer/authored/AccountTree.copy/system.py @@ -10,6 +10,7 @@ child accounts each system account may have. """ import os +from pathlib import Path import database from database import Database @@ -77,6 +78,13 @@ def system_show(): " WHERE database_uuid = h.database_uuid) ORDER BY h.seen"): print(f" database {h['database_uuid'][:8]} series {h['series']} {h['event']} {h['seen']}" + (f" ({h['continuity']})" if h["continuity"] else "")) + device_row = db.rows("SELECT * FROM storage_device WHERE state = 'registered' ORDER BY storage_name") + if device_row: + print("removable storage") + for r in device_row: + plugged = Path(f"/run/AccountTree/plugged/{r['storage_name']}.json").exists() + print(f" {r['storage_name']:<26}LUKS uuid {r['luks_uuid']} database {r['database_uuid'][:8]}" + f" at {r['mount_dir']}" + (" (plugged in)" if plugged else "")) limit_row = db.rows("SELECT * FROM account_limit ORDER BY system_uid") if limit_row: print("limits") diff --git a/developer/authored/AccountTree.copy/system_configuration.py b/developer/authored/AccountTree.copy/system_configuration.py new file mode 100644 index 0000000..dbde749 --- /dev/null +++ b/developer/authored/AccountTree.copy/system_configuration.py @@ -0,0 +1,183 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +system_configuration - the systemd units and udev rules AccountTree places + + configure(log ,preview) write the units and the rules, then have + systemd and udev read them again + rule_write(system_db ,log ,preview) + write the rules from the registered storage + +Each file begins with MARK. A file AccountTree writes is replaced only when it +carries the mark; a file of the same name without it belongs to someone else, +and is reported and left alone. This is the rule the installer is to follow +for all system configuration. + +How a plug reaches AccountTree. udev runs the rules when a device appears. + + A FIDO2 key, a hidraw device that systemd's 60-fido-id.rules marks + ID_SECURITY_TOKEN=1, is tagged for systemd, so that systemd makes a device + unit for it, dev-hidrawN.device, active while the key is plugged in. It also + wants AccountTree-key-arrive.service, which plugs any registered storage + that arrived before its key. + + A registered storage's LUKS2 partition, matched by its LUKS uuid, wants + AccountTree-plug@.service, such as AccountTree-plug@sdb1. The + service is bound to the partition's device unit: it starts when the drive + arrives, running storage plug, and systemd stops it when the drive goes, + running storage unplug. The partition, and the unlocked device + AccountTree_, are marked UDISKS_IGNORE, so that the desktop's + file manager neither asks for a passphrase nor offers its own eject, which + cannot end the accounts' processes first. + + When a key unlocks a storage, plug starts AccountTree-key@hidrawN.service, + bound to the key's device unit; pulling the key out stops it, and its + ExecStop unplugs every storage that key unlocked. + +A rule that tags a device must tag it on every event but remove. A change +event that left the tag off would make systemd take the device unit away, and +with it every service bound to it, which would unplug the storage. +""" + +import os ,stat ,subprocess +from pathlib import Path + +from common import act +from storage_plug import registration_list ,run ,run_logged ,program + +MARK = "# made by AccountTree; AccountTree_admin system configure replaces this file" +UNIT_DIR = Path("/etc/systemd/system") +RULE_PATH = Path("/etc/udev/rules.d/90-AccountTree.rules") +TRUE_PATH = "/usr/bin/true" + + +def admin_path(): + """ + The AccountTree_admin the units are to run: the one running now. + """ + return Path(os.path.realpath(os.path.join(os.path.dirname(os.path.realpath(__file__)) ,"AccountTree_admin"))) + +def path_changer(path): + """ + Why someone other than root can change the program at path, or the + directories it is in, or None. The units run it under root. + """ + p = Path(path) + for q in [p] + list(p.parents): + try: + st = q.stat() + except OSError as error: + return f"{q}: {error}" + if st.st_uid != 0: + return f"{q} is owned by uid {st.st_uid}" + if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH) and not (q.is_dir() and st.st_mode & stat.S_ISVTX): + return f"{q} can be written by its group or by others" + return None + +def quoted(path): + return '"' + str(path).replace("%" ,"%%") + '"' + +def unit_text(): + admin = quoted(admin_path()) + return { + "AccountTree-plug@.service": f"""{MARK} +[Unit] +Description=AccountTree storage on /dev/%i +BindsTo=dev-%i.device +After=dev-%i.device + +[Service] +Type=oneshot +RemainAfterExit=yes +# waits for a touch of the key +TimeoutStartSec=180 +ExecStart={admin} storage plug /dev/%i +ExecStop={admin} storage unplug /dev/%i +""", + "AccountTree-key@.service": f"""{MARK} +[Unit] +Description=AccountTree key on /dev/%i +BindsTo=dev-%i.device +After=dev-%i.device + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart={TRUE_PATH} +ExecStop={admin} storage unplug key /dev/%i +""", + "AccountTree-key-arrive.service": f"""{MARK} +[Unit] +Description=AccountTree plug of storage waiting for a key + +[Service] +Type=oneshot +TimeoutStartSec=300 +ExecStart={admin} storage plug all +""", + } + +def rule_text(system_db): + line = [MARK ,"" , + "# a FIDO2 key: a systemd device unit while it is plugged in, and a plug of any storage waiting for it", + 'SUBSYSTEM=="hidraw", ACTION!="remove", ENV{ID_SECURITY_TOKEN}=="1", TAG+="systemd",' + ' ENV{SYSTEMD_WANTS}+="AccountTree-key-arrive.service"', + "" , + "# the device a storage is unlocked as: kept out of the file manager", + 'SUBSYSTEM=="block", ENV{DM_NAME}=="AccountTree_*", ENV{UDISKS_IGNORE}="1"', + ] + for r in registration_list(system_db): + line += ["" ,f"# storage {r['storage_name']}, mounted at {r['mount_dir']}", + f'SUBSYSTEM=="block", ACTION!="remove", ENV{{ID_FS_UUID}}=="{r["luks_uuid"]}", ENV{{UDISKS_IGNORE}}="1",' + ' TAG+="systemd", ENV{SYSTEMD_WANTS}+="AccountTree-plug@%k.service"'] + return "\n".join(line) + "\n" + +def file_write(path ,text ,log ,preview): + """ + Write a configuration file that carries MARK. Returns True when the file + holds text afterwards, or would. + """ + if path.exists(): + try: + old = path.read_text(encoding="utf-8") + except OSError as error: + log.error(str(path) ,str(error)); return False + if not old.startswith(MARK): + log.error(str(path) ,"exists and was not made by AccountTree; it is left as it is"); return False + if old == text: + log.line("kept" ,str(path) ,"unchanged"); return True + def write(): + temporary = path.with_name(f".{path.name}.AccountTree") + temporary.write_text(text ,encoding="utf-8") + os.chmod(temporary ,0o644) + os.replace(temporary ,path) + if not act(log ,preview ,str(path) ,write): return False + log.line("written" ,str(path)) + return True + +def rule_write(system_db ,log ,preview): + if not RULE_PATH.parent.is_dir(): + log.line("skipped" ,str(RULE_PATH) ,"this system has no /etc/udev/rules.d"); return False + if not file_write(RULE_PATH ,rule_text(system_db) ,log ,preview): return False + if program("udevadm") and run_logged(log ,preview ,"udev" ,["udevadm" ,"control" ,"--reload"]): + log.line("reloaded" ,"udev rules") + return True + +def configure(log ,preview): + from system import open_system_database + system_db = open_system_database(log) + if system_db is None: return + admin = admin_path() + problem = path_changer(admin) + if problem: + log.line("warning" ,str(admin) ,f"the units run this program under root, but {problem}; until AccountTree is" + " installed where only root can change it, whoever can change it can act as root") + if not UNIT_DIR.is_dir(): + log.error(str(UNIT_DIR) ,"no such directory; is this system run by systemd?"); return + for name ,text in unit_text().items(): + file_write(UNIT_DIR / name ,text ,log ,preview) + if run_logged(log ,preview ,"systemd" ,["systemctl" ,"daemon-reload"]): + log.line("reloaded" ,"systemd units") + if not rule_write(system_db ,log ,preview): return + # tag the keys plugged in now, so that systemd has their device units + if program("udevadm") and run_logged(log ,preview ,"udev" ,["udevadm" ,"trigger" ,"--action=change" ,"--subsystem-match=hidraw"]): + log.line("reloaded" ,"the keys plugged in now" ,"udev ran its rules for them again") diff --git a/shared/project-dependency/Man_In_Grey.init b/shared/project-dependency/Man_In_Grey.init new file mode 100644 index 0000000..7933253 --- /dev/null +++ b/shared/project-dependency/Man_In_Grey.init @@ -0,0 +1,29 @@ +# -*- mode: sh; sh-shell: bash; -*- +# init files are bash script + +# Man_In_Grey.init +# -------------------------------------------------------------------------------- +# Put Man_In_Grey's promoted library on PYTHONPATH, so the project's code can +# import Journal·Interface, Journal and Language. +# +# The link Man_In_Grey points at Man_In_Grey's consumer/. Only the composer +# side reads PYTHONPATH: the gate starts the guard and the interpreter with +# /usr/bin/python3 -E, which ignores it. +# +# Setup sources the init files in name order, so this file comes before +# Python.init and python3 is not yet the project's build here. The test is +# therefore for the file, not an import. The library imports cbor2, which the +# project's Python needs: python3 -m pip install cbor2 + +Man_In_Grey_dp="$REPO_HOME/shared/project-dependency/Man_In_Grey/Man_In_Grey" +if [[ ! -f "$Man_In_Grey_dp/Journal·Interface.py" ]]; then + if [[ "$ROLE" == "administrator" ]]; then + echo "Man_In_Grey.init: no $Man_In_Grey_dp/Journal·Interface.py; promote Man_In_Grey, or link its consumer/ here" + fi +else + case ":${PYTHONPATH:-}:" in + *":$Man_In_Grey_dp:"*) ;; + *) export PYTHONPATH="$Man_In_Grey_dp${PYTHONPATH:+:$PYTHONPATH}" ;; + esac +fi +unset Man_In_Grey_dp \ No newline at end of file