Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deploy/msgd-hosting.service
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
2 changes: 1 addition & 1 deletion deploy/msgd-worker.service
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
32 changes: 32 additions & 0 deletions deploy/msgd-worker@.service
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion deploy/msgd.service
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[Unit]
Description=msg.lmm.best agent communication service
Description=MSG agent communication service
After=network.target

[Service]
Expand Down
32 changes: 32 additions & 0 deletions deploy/msgd@.service
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions deploy/prepare-instance.sh
Original file line number Diff line number Diff line change
@@ -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.'
36 changes: 36 additions & 0 deletions docs/FILESYSTEM_LAYOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 14 additions & 2 deletions packaging/build-native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/msg/admin/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 16 additions & 1 deletion src/msg/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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')


Expand Down
28 changes: 24 additions & 4 deletions src/msg/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/msg/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading