diff --git a/deploy/msgd-hosting.service b/deploy/msgd-hosting.service index b11d3717..1206cdd4 100644 --- a/deploy/msgd-hosting.service +++ b/deploy/msgd-hosting.service @@ -1,5 +1,5 @@ [Unit] -Description=msg.lmm.best read-only hosted-content process +Description=MSG read-only hosted-content process After=network.target [Service] diff --git a/deploy/msgd-worker.service b/deploy/msgd-worker.service index 4c655c5e..ae130fea 100644 --- a/deploy/msgd-worker.service +++ b/deploy/msgd-worker.service @@ -1,5 +1,5 @@ [Unit] -Description=msg.lmm.best durable effects and retention worker +Description=MSG durable effects and retention worker After=network.target [Service] diff --git a/deploy/msgd-worker@.service b/deploy/msgd-worker@.service new file mode 100644 index 00000000..0e28773f --- /dev/null +++ b/deploy/msgd-worker@.service @@ -0,0 +1,32 @@ +[Unit] +Description=MSG durable effects and retention worker +After=network.target + +[Service] +Type=simple +User=msgd-%i +Group=msgd-%i +WorkingDirectory=/var/lib/msgd/%i +ExecStart=/usr/bin/msgd --instance %i worker +# Signal the parent first so in-flight work can drain before killing children. +KillSignal=SIGTERM +KillMode=mixed +TimeoutStopSec=90 +Restart=on-failure +RestartSec=3 +UMask=0077 +Environment=PGSERVICEFILE=/etc/msgd/%i/pg_service.conf +Environment=PGPASSFILE=/etc/msgd/%i/pgpass +RuntimeDirectory=msgd/%i +RuntimeDirectoryMode=0750 +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/msgd/%i /var/cache/msgd/%i /run/msgd/%i +InaccessiblePaths=/var/lib/private/msgd -/etc/msgd/%i/root +LimitCORE=0 +# Do not block user namespaces needed by bubblewrap. Isolation remains mandatory +# inside the runner; an unavailable sandbox fails closed rather than falling back. + +[Install] +WantedBy=multi-user.target diff --git a/deploy/msgd.service b/deploy/msgd.service index 066e4860..c3ae4c6a 100644 --- a/deploy/msgd.service +++ b/deploy/msgd.service @@ -1,5 +1,5 @@ [Unit] -Description=msg.lmm.best agent communication service +Description=MSG agent communication service After=network.target [Service] diff --git a/deploy/msgd@.service b/deploy/msgd@.service new file mode 100644 index 00000000..215f80e1 --- /dev/null +++ b/deploy/msgd@.service @@ -0,0 +1,32 @@ +[Unit] +Description=MSG agent communication service +After=network.target + +[Service] +Type=simple +User=msgd-%i +Group=msgd-%i +WorkingDirectory=/var/lib/msgd/%i +ExecStart=/usr/bin/msgd --instance %i serve +# Signal the parent first so in-flight work can drain before killing children. +KillSignal=SIGTERM +KillMode=mixed +TimeoutStopSec=90 +Restart=on-failure +RestartSec=3 +UMask=0077 +Environment=PGSERVICEFILE=/etc/msgd/%i/pg_service.conf +Environment=PGPASSFILE=/etc/msgd/%i/pgpass +RuntimeDirectory=msgd/%i +RuntimeDirectoryMode=0750 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/msgd/%i /var/cache/msgd/%i /run/msgd/%i +InaccessiblePaths=/var/lib/private/msgd -/etc/msgd/%i/root +RestrictSUIDSGID=true +LimitCORE=0 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/prepare-instance.sh b/deploy/prepare-instance.sh new file mode 100755 index 00000000..fd164702 --- /dev/null +++ b/deploy/prepare-instance.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Prepare a NEW named instance after init. Never migrate or unlock Root material. +set -eu +[ "$(id -u)" = 0 ] || { echo 'OS administrator required' >&2; exit 1; } +[ "$#" = 1 ] || { echo 'Usage: prepare-instance.sh INSTANCE' >&2; exit 1; } +instance=$1 +# Validate before interpolating the name into any path or account. +case "$instance" in ''|[!a-z]*|*[!a-z0-9_-]*) echo 'Invalid instance name' >&2; exit 1;; esac +[ "${#instance}" -le 26 ] || exit 1 +config=/etc/msgd/$instance +data=/var/lib/msgd/$instance +private=/var/lib/private/msgd/$instance/root +account=msgd-$instance +# Refuse mixed legacy/named data; changing ownership there can break a live service. +for legacy in /etc/msgd/msgd.toml /etc/msgd/server.toml /var/lib/msgd/git /var/lib/msgd/service; do + [ ! -e "$legacy" ] || { echo 'Migrate the legacy installation offline first.' >&2; exit 1; } +done +for directory in /etc/msgd /var/lib/msgd /var/lib/private /var/lib/private/msgd /var/lib/private/msgd/$instance "$config" "$data" "$private"; do + [ -d "$directory" ] && [ ! -L "$directory" ] || { echo "Missing or symlinked directory: $directory" >&2; exit 1; } +done +for tree in "$config" "$data" "$private"; do + [ -z "$(find "$tree" -type l -print -quit)" ] || { echo "Symlink in installation: $tree" >&2; exit 1; } +done +# No alias/config/database guessing: validate the installation selected by its name. +/usr/lib/msgd/python3.15/bin/python3.15 -I - "$instance" <<'PY' +import sys +from pathlib import Path +sys.path.insert(0, '/usr/lib/msgd/site-packages') +from msg.config import load_settings +from msg.paths import ServerPaths +p=ServerPaths.for_instance(sys.argv[1]) +s=load_settings(p.config) +assert s.server.content_dir==p.data/'git/content' +assert s.server.repositories_dir==p.data/'git/repos' +assert s.server.blob_dir==p.data/'blobs/sha256' +assert s.server.staging_dir==p.data/'transfers/staging' +assert s.server.service_keys_dir==p.data/'service' +assert s.root_private_dir==p.root +PY +if ! getent passwd "$account" >/dev/null; then + useradd --system --user-group --home-dir "$data" --no-create-home --shell /usr/bin/nologin "$account" +fi +install -d -m 0755 -o root -g root /var/cache/msgd /run/msgd +# Shared parent directories cannot be renamed by a service account. +chown root:root /etc/msgd /var/lib/msgd /var/cache/msgd /run/msgd +chmod 0755 /etc/msgd /var/lib/msgd /var/cache/msgd /run/msgd +chown root:root /var/lib/private/msgd /var/lib/private/msgd/$instance +chmod 0700 /var/lib/private/msgd /var/lib/private/msgd/$instance +chown -R root:root "$private" +chmod 0700 "$private" +find "$private" -type f -exec chmod 0600 {} + +chown -R "$account:$account" "$data" +chmod 0750 "$data" +chown -R "root:$account" "$data/service" +chmod 0750 "$data/service" +find "$data/service" -type f -exec chmod 0640 {} + +chown -R "root:$account" "$config" +chmod 0750 "$config" +find "$config" -type f -exec chmod 0640 {} + +install -d -m 0750 -o "$account" -g "$account" /var/cache/msgd/$instance /run/msgd/$instance +printf '%s\n' 'Prepared instance. Configure unique listen ports and database before enabling its units.' diff --git a/docs/FILESYSTEM_LAYOUT.md b/docs/FILESYSTEM_LAYOUT.md index 12925ec0..eff73439 100644 --- a/docs/FILESYSTEM_LAYOUT.md +++ b/docs/FILESYSTEM_LAYOUT.md @@ -55,3 +55,39 @@ Atomic replacement temporaries intentionally remain in the destination directory ## Account removal `msgd account archive SUBJECT_ID` requires the Root ceremony; `--allow-ssh` explicitly permits an OS-root SSH terminal for this command only. It presents an exact preview digest and requires the Root passphrase. It archives the user profile, revokes credentials, advances the authentication version, and records a Root-signed audit event. Authentication and SSH reject archived identities. Account, resource, and ledger history remain intact. Root/system identities, active Bank roles, and nonzero balances must be resolved before archival. + +## Named service instances + +New installations select a stable instance identifier, **not a domain name**: + +```sh +sudo msgd --instance main init --service-url https://example.org --postgres-dsn service=msgd-main --allow-ssh +sudo deploy/prepare-instance.sh main +sudo systemctl enable --now msgd@main msgd-worker@main +``` + +Names match `[a-z][a-z0-9_-]{0,25}`. `--instance` and `--config-dir` are mutually exclusive. Named initialization requires an explicit public origin and an explicit database; separate instances need separate databases, service keys, trust chains, accounts and listening ports. Set each instance's `listen`/`port` before enabling it. The example hostname is configuration, never an installation identifier. + +| Purpose | Named-instance path (`main`) | +| --- | --- | +| Configuration/public trust | `/etc/msgd/main`, `/etc/msgd/main/trust` | +| Persistent content, blobs, service keys | `/var/lib/msgd/main` | +| Root private state | `/var/lib/private/msgd/main/root` | +| Cache | `/var/cache/msgd/main` | +| Runtime | `/run/msgd/main` | +| Network/worker account | `msgd-main` | +| Systemd units | `msgd@main.service`, `msgd-worker@main.service` | + +Shared parent directories belong to OS root. The service owns only its instance's data directory. Root private state is outside every network-service-writable parent; the private hierarchy remains OS-root-owned and the units make it inaccessible. Never put Root keys below `/var/lib/msgd/main/root`: ownership of the parent would let the service replace that directory. `prepare-instance.sh` rejects mixed legacy/named installations, symlinks and storage-layout mismatches before changing permissions. The read-only hosting process still uses an explicit configuration directory; its credentials and paths must be isolated separately. + +Existing bare `/etc/msgd`, `/var/lib/msgd` and `/var/lib/msgd-root` installations remain readable and are **not moved automatically on upgrade**. Existing explicit custom configuration directories also retain their previous Root location. This compatibility does not make the old singleton layout the recommended layout for new installations. + +### Migration boundaries + +Migration is an offline administration operation, not initialization. Stop both the old web process and worker, take a restorable backup and preserve a protected copy of the existing Root envelope. Reject existing destination directories and symlinked source paths. Preserve database identity, Root/public trust, online keys, token secrets, ledger data and the exact signed `service_url`; never generate replacements. Move configuration and persistent content into the selected instance, rewrite only their host storage paths, and relocate the existing Root envelope into the OS-root-owned private hierarchy. Check ownership and service inability to read Root state before switching units. Keep a journal and retain the original directories until startup, authenticated requests, worker processing and ledger checks pass. If anything fails, stop the new units and restore the original paths and configuration. `prepare-instance.sh` does not perform this migration or silently reinterpret an existing instance. + +### Domains and signed identities + +A single logical instance with several DNS aliases is different from several independent instances. Filesystem instance names do not encode DNS, so adding a DNS name never requires changing these paths. The current signing protocol still binds certificates and requests to the configured canonical `service_url` (`target_service`); this change deliberately preserves that binding. Until an explicit trusted-origin/alias protocol is implemented, additional names must redirect clients to the canonical origin. Do not accept arbitrary Host headers, rewrite a signed service binding, or claim that a reverse-proxy alias creates a second independent MSG instance. Changing a canonical origin requires a separate identity/credential migration; directory moves alone do not change signatures. + +Native-package tmpfiles declarations create the shared data/cache/runtime containers without changing existing ownership or modes. The legacy deployment still runs `prepare-service.sh` after initialization and before enabling the legacy units; named deployments run `prepare-instance.sh` instead. This avoids a package upgrade or boot-time tmpfiles pass changing a named installation's OS-root-owned parent into a legacy service-owned directory. Instance systemd units recreate their own runtime child after reboot. diff --git a/packaging/build-native.py b/packaging/build-native.py index 10579163..96f3b1e2 100644 --- a/packaging/build-native.py +++ b/packaging/build-native.py @@ -98,7 +98,12 @@ def main(): # Build probes must not turn generated bytecode into package-owned files. for cache in private.rglob('__pycache__'): shutil.rmtree(cache) - for name in ('msgd.service', 'msgd-worker.service'): + for name in ( + 'msgd.service', + 'msgd-worker.service', + 'msgd@.service', + 'msgd-worker@.service', + ): target = root / 'usr/lib/systemd/system' / name target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(repo / 'deploy' / name, target) @@ -118,7 +123,14 @@ def main(): target = root / 'usr/lib/tmpfiles.d/msgd.conf' target.parent.mkdir(parents=True, exist_ok=True) target.write_text( - 'd /etc/msgd 0755 root root -\nd /var/lib/msgd 0750 msgd msgd -\nd /var/cache/msgd 0750 msgd msgd -\nd /run/msgd 0750 msgd msgd -\nd /var/lib/msgd-root 0700 root root -\n' + 'd /etc/msgd 0755 root root -\n' + # Create shared containers, preserving ownership/mode on upgrades. + # Legacy preparation owns its singleton; named preparation leaves + # containers OS-root-owned and owns only per-instance children. + 'd /var/lib/msgd - - - -\n' + 'd /var/cache/msgd - - - -\n' + 'd /run/msgd - - - -\n' + 'd /var/lib/private/msgd 0700 root root -\n' ) docs = root / 'usr/share/doc/msgd' docs.mkdir(parents=True) diff --git a/src/msg/admin/root.py b/src/msg/admin/root.py index 0d4e93c3..e07318f0 100644 --- a/src/msg/admin/root.py +++ b/src/msg/admin/root.py @@ -63,6 +63,18 @@ async def _provision(app, pin): """Initialize only an empty installation; partial state requires explicit recovery.""" settings = app.settings protected = settings.root_private_dir + if settings.config_dir.parent == Path('/etc/msgd') and protected.is_relative_to( + Path('/var/lib/private/msgd') + ): + # Named-instance Root state must never sit below a service-writable parent. + for directory in (Path('/var/lib/private'), *reversed(protected.parents[:3]), protected): + require(not directory.is_symlink(), 'unsafe_root_private_directory') + directory.mkdir(mode=0o700, exist_ok=True) + info = directory.stat() + require( + info.st_uid == 0 and info.st_mode & 0o022 == 0, + 'unsafe_root_private_directory', + ) marker = protected / 'initialization.pending' require( not root_envelope(settings.config_dir).exists() diff --git a/src/msg/config.py b/src/msg/config.py index 645f583f..57974d4b 100644 --- a/src/msg/config.py +++ b/src/msg/config.py @@ -13,7 +13,7 @@ from msg.core.errors import Failure, require from msg.core.models import MailConfig, ServerConfig, TransportLimits from msg.oauth_config import OAuthConfig, load_oauth -from msg.paths import ROOT_PRIVATE_DIR, SERVER_CONFIG_DIR +from msg.paths import ROOT_PRIVATE_DIR, SERVER_CONFIG_DIR, ServerPaths from msg.security.age_keys import encryption_key_id, public_from_recipient from msg.service_origin import service_origin @@ -44,6 +44,21 @@ def root_private_dir(config_dir: Path) -> Path: directory = Path(config_dir) if directory == SERVER_CONFIG_DIR: return ROOT_PRIVATE_DIR + if directory.parent == SERVER_CONFIG_DIR: + legacy = directory.parent / (directory.name + '-root') + # Existing explicit subdirectories were supported before named instances. + # Never make their Root envelope disappear merely by upgrading the CLI. + has_legacy = any( + (legacy / name).exists() for name in ('key.json', 'initialization.pending') + ) + if has_legacy and re.fullmatch(r'[a-z][a-z0-9_-]{0,25}', directory.name) is None: + return legacy + current = ServerPaths.for_instance(directory.name).root + has_current = any( + (current / name).exists() for name in ('key.json', 'initialization.pending') + ) + require(not (has_legacy and has_current), 'ambiguous_root_private_directory') + return legacy if has_legacy else current return directory.parent / (directory.name + '-root') diff --git a/src/msg/daemon.py b/src/msg/daemon.py index 26758229..a036edf1 100644 --- a/src/msg/daemon.py +++ b/src/msg/daemon.py @@ -13,7 +13,7 @@ from msg import __version__ from msg.core.codec import canonical, wire from msg.core.errors import Failure, require -from msg.paths import SERVER_CONFIG_DIR, SERVER_DATA_DIR +from msg.paths import SERVER_CONFIG_DIR, SERVER_DATA_DIR, ServerPaths def emit(value): @@ -141,13 +141,16 @@ def parser(): prog='msgd', description='Agent communication service and local administration' ) root.add_argument('--version', action='version', version=__version__) - root.add_argument('--config-dir', type=Path, default=SERVER_CONFIG_DIR) + location = root.add_mutually_exclusive_group() + location.add_argument('--config-dir', type=Path) + location.add_argument('--instance', help='Stable installation name, independent of domains') sub = root.add_subparsers(dest='command', required=True) init = sub.add_parser( 'init', help='Initialize from the physical local console; no PIN arguments' ) - init.add_argument('--data-dir', type=Path, default=SERVER_DATA_DIR) + init.add_argument('--data-dir', type=Path) init.add_argument('--service-url', required=True, help='Public origin of this installation.') + init.add_argument('--postgres-dsn', help='Independent PostgreSQL database for this instance') init.add_argument( '--allow-ssh', action='store_true', @@ -281,6 +284,18 @@ def parser(): def main(argv=None): args = parser().parse_args(argv) + try: + layout = ServerPaths.for_instance(args.instance) if args.instance else None + args.config_dir = layout.config if layout else args.config_dir or SERVER_CONFIG_DIR + if args.command == 'init': + if layout: + require(args.service_url is not None, 'instance_service_url_required') + require(args.postgres_dsn is not None, 'instance_postgres_dsn_required') + args.data_dir = args.data_dir or (layout.data if layout else SERVER_DATA_DIR) + args.postgres_dsn = args.postgres_dsn or 'service=msgd' + except Failure as exc: + emit({'status': 'error', 'error': {'code': exc.code}}) + return 2 server_modules = ('starlette', 'uvicorn', 'psycopg', 'valkey', 'aiohttp', 'dns', 'graphql') if any(importlib.util.find_spec(name) is None for name in server_modules): print( @@ -320,7 +335,12 @@ def main(argv=None): check = require_ssh_administrator if args.allow_ssh else require_local_console check(args.config_dir) - write_example(args.config_dir, args.data_dir, args.service_url) + write_example( + args.config_dir, + args.data_dir, + args.service_url, + postgres_dsn=args.postgres_dsn, + ) result = RootAdmin(args.config_dir, allow_ssh=args.allow_ssh).initialize() emit({'root_id': result}) return 0 diff --git a/src/msg/paths.py b/src/msg/paths.py index 5df9d9ac..8326fcdc 100644 --- a/src/msg/paths.py +++ b/src/msg/paths.py @@ -17,6 +17,31 @@ SERVER_RUNTIME_DIR = Path('/run/msgd') +@dataclass(frozen=True) +class ServerPaths: + """Stable installation identifiers, deliberately independent of DNS names.""" + + config: Path + data: Path + root: Path + cache: Path + runtime: Path + + @classmethod + def for_instance(cls, name): + require( + isinstance(name, str) and re.fullmatch(r'[a-z][a-z0-9_-]{0,25}', name) is not None, + 'invalid_instance_name', + ) + return cls( + SERVER_CONFIG_DIR / name, + SERVER_DATA_DIR / name, + Path('/var/lib/private/msgd') / name / 'root', + SERVER_CACHE_DIR / name, + SERVER_RUNTIME_DIR / name, + ) + + def private_directory(path): """Validate before chmod; never follow a symlink in an application directory.""" path = Path(path).absolute() diff --git a/tests/test_server_instances.py b/tests/test_server_instances.py new file mode 100644 index 00000000..169d9ac5 --- /dev/null +++ b/tests/test_server_instances.py @@ -0,0 +1,74 @@ +from pathlib import Path + +import pytest + +from msg.config import root_private_dir +from msg.core.errors import Failure +from msg.daemon import main, parser +from msg.paths import ServerPaths + + +def test_independent_instances_do_not_derive_paths_from_domains(): + a, b = ServerPaths.for_instance('main'), ServerPaths.for_instance('sandbox') + assert a.config == Path('/etc/msgd/main') + assert a.data == Path('/var/lib/msgd/main') + assert a.root == Path('/var/lib/private/msgd/main/root') + assert a.cache == Path('/var/cache/msgd/main') + assert a.runtime == Path('/run/msgd/main') + assert a != b + assert root_private_dir(a.config) == a.root + assert not a.root.is_relative_to(a.data) + assert root_private_dir(Path('/etc/msgd')) == Path('/var/lib/msgd-root') + assert root_private_dir(Path('/tmp/test-etc')) == Path('/tmp/test-etc-root') + + +@pytest.mark.parametrize('name', ['', '../main', 'a/b', 'msg.example.org', 'a' * 27, '-a', 'A']) +def test_instance_names_cannot_escape_shared_parents(name): + with pytest.raises(Failure, match='invalid_instance_name'): + ServerPaths.for_instance(name) + + +def test_instance_and_explicit_directory_are_exclusive(): + with pytest.raises(SystemExit): + parser().parse_args(['--instance', 'main', '--config-dir', '/tmp/other', 'serve']) + + +def test_new_instance_requires_explicit_origin_and_database_before_writing(capsys): + with pytest.raises(SystemExit) as absent_origin: + main(['--instance', 'main', 'init']) + assert absent_origin.value.code == 2 + assert '--service-url' in capsys.readouterr().err + assert main(['--instance', 'main', 'init', '--service-url', 'https://example.org']) == 2 + assert 'instance_postgres_dsn_required' in capsys.readouterr().out + + +def test_named_service_templates_isolate_accounts_and_root(): + base = Path(__file__).parents[1] / 'deploy' + for name in ('msgd@.service', 'msgd-worker@.service'): + unit = (base / name).read_text() + assert 'User=msgd-%i' in unit + assert '--instance %i' in unit + assert 'InaccessiblePaths=/var/lib/private/msgd' in unit + assert 'KillMode=mixed' in unit + assert 'ReadWritePaths=/var/lib/msgd/%i' in unit + assert 'msg.lmm.best' not in unit + + +def test_upgrade_preserves_existing_explicit_subdirectory_root(monkeypatch, tmp_path): + from types import SimpleNamespace + + import msg.config as config + + monkeypatch.setattr(config, 'SERVER_CONFIG_DIR', tmp_path / 'etc') + current = tmp_path / 'private/main/root' + monkeypatch.setattr( + config.ServerPaths, 'for_instance', lambda name: SimpleNamespace(root=current) + ) + legacy = tmp_path / 'etc/main-root' + legacy.mkdir(parents=True) + (legacy / 'key.json').write_text('existing sealed envelope') + assert config.root_private_dir(tmp_path / 'etc/main') == legacy + current.mkdir(parents=True) + (current / 'key.json').write_text('different sealed envelope') + with pytest.raises(Failure, match='ambiguous_root_private_directory'): + config.root_private_dir(tmp_path / 'etc/main')