.
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Wed, 11 Nov 2026 01:21:54 +0000 (01:21 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Wed, 11 Nov 2026 01:21:54 +0000 (01:21 +0000)
developer/authored/AccountTree.copy/AccountTree_admin
developer/authored/AccountTree.copy/AccountTree_install [new file with mode: 0755]
developer/authored/AccountTree.copy/accounts_service.py [new file with mode: 0644]
developer/authored/AccountTree.copy/common.py
developer/authored/AccountTree.copy/database.py
developer/authored/AccountTree.copy/schema_system.sql
developer/authored/AccountTree.copy/storage.py
developer/authored/AccountTree.copy/storage_plug.py [new file with mode: 0644]
developer/authored/AccountTree.copy/system.py
developer/authored/AccountTree.copy/system_configuration.py [new file with mode: 0644]
shared/project-dependency/Man_In_Grey.init [new file with mode: 0644]

index fb094c9..2666d0f 100755 (executable)
@@ -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 <command> [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 <command> [argument ...]
                                  holding files are deleted only with
                                  delete-account-data
 
+  storage register <tree dir> [name <storage 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 <storage name>
+                                 retire that record
+  storage plug <device> [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 <storage name or device>
+                                 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 <key device>
+                                 unplug every storage the key at /dev/hidrawN unlocked.
+                                 Run by AccountTree-key@.service when the key is pulled
+                                 out
+
   account make <tree dir> <name> [uid <database uid>] [parent <name>] [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 <command> [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 <partition>, 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] ,"<maximum>") ,log ,preview)
diff --git a/developer/authored/AccountTree.copy/AccountTree_install b/developer/authored/AccountTree.copy/AccountTree_install
new file mode 100755 (executable)
index 0000000..08fb747
--- /dev/null
@@ -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 <<EOF
+Usage: AccountTree_install <command>
+       AccountTree_install preview <command>
+  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 (file)
index 0000000..dfd65d3
--- /dev/null
@@ -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/<name>, 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
index 60bebfd..804d240 100644 (file)
@@ -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):
index cbf93ac..d2eb4e2 100644 (file)
@@ -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
index 2dc372b..328c963 100644 (file)
@@ -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_<storage name>, 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,
index fb61077..a6219d6 100644 (file)
@@ -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 (file)
index 0000000..638ae08
--- /dev/null
@@ -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
+<storage root>/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/<storage name>.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/<LUKS 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()
index 6f900f7..385acc7 100644 (file)
@@ -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 (file)
index 0000000..dbde749
--- /dev/null
@@ -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@<kernel name>.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_<storage name>, 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 (file)
index 0000000..7933253
--- /dev/null
@@ -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