msgctl 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/__init__.py
ADDED
msg/admin/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Local import of a root-signed custodial backup retirement record.
|
|
2
|
+
|
|
3
|
+
Never imported by a network adapter, plugin or worker. RootAdmin calls these use
|
|
4
|
+
cases only after require_local_console(); tests call them against disposable
|
|
5
|
+
databases. The record is verified again on every read, so this import is not a
|
|
6
|
+
trusted cache of a success flag.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from msg.constants import ROOT_SUBJECT
|
|
11
|
+
from msg.core.codec import digest, loads
|
|
12
|
+
from msg.core.errors import require
|
|
13
|
+
from msg.core.models import AuditEvent, Event, ResourceRef
|
|
14
|
+
from msg.plugins.common import new_id
|
|
15
|
+
from msg.security.backup_retirement import FIELDS, DOMAIN, check, root_verifier, setting_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def unsigned_statement(value):
|
|
19
|
+
require(isinstance(value, dict) and set(value) == FIELDS and value.get('domain') == DOMAIN,
|
|
20
|
+
'backup_retirement_invalid')
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def completed_migration(tx, subject_id, old_identity_key_id):
|
|
25
|
+
"""The one completed migration of this subject that retired exactly this old key."""
|
|
26
|
+
matches = [loads(body) for (body,) in tx.rows(
|
|
27
|
+
"SELECT body FROM custodial_upgrades WHERE subject=? AND status='completed' ORDER BY id",
|
|
28
|
+
(subject_id,)) if loads(body).get('old_identity_key_id') == old_identity_key_id]
|
|
29
|
+
require(len(matches) == 1, 'backup_retirement_migration_not_found')
|
|
30
|
+
return matches[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def import_record(tx, record, *, now, operator):
|
|
34
|
+
body = record.get('statement') if isinstance(record, dict) else None
|
|
35
|
+
require(isinstance(body, dict) and isinstance(body.get('subject_id'), str) and
|
|
36
|
+
isinstance(body.get('old_identity_key_id'), str), 'backup_retirement_invalid')
|
|
37
|
+
subject = body['subject_id']
|
|
38
|
+
details = completed_migration(tx, subject, body['old_identity_key_id'])
|
|
39
|
+
attestation = check(record, await root_verifier(tx), subject, details, now=now)
|
|
40
|
+
key = setting_key(subject, attestation.old_identity_key_id)
|
|
41
|
+
previous = tx.setting(key)
|
|
42
|
+
tx.set_setting(key, record)
|
|
43
|
+
data = {'subject_id': subject, 'old_identity_key_id': attestation.old_identity_key_id,
|
|
44
|
+
'old_encryption_key_id': attestation.old_encryption_key_id,
|
|
45
|
+
'backup_sets': list(attestation.backup_sets), 'record_digest': attestation.record_digest,
|
|
46
|
+
'signer_key_id': attestation.signer_key_id, 'operator': operator,
|
|
47
|
+
'scope': 'listed_backup_sets_only'}
|
|
48
|
+
event = Event(id=new_id('audit'), type='root.custodial.backup_retirement', time=now,
|
|
49
|
+
request_id=new_id('local'), actor=ROOT_SUBJECT, subject=subject,
|
|
50
|
+
resources=(ResourceRef(id=subject),), data=data)
|
|
51
|
+
await tx.append_audit(AuditEvent(event=event,
|
|
52
|
+
authority=(ResourceRef(id=tx.setting('active_root_certificate')),),
|
|
53
|
+
before_digest=digest(previous) if previous is not None else None,
|
|
54
|
+
after_digest=attestation.record_digest, previous_digest=None, entry_digest='',
|
|
55
|
+
result='imported'))
|
|
56
|
+
return attestation
|
msg/admin/backups.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Explicit local data backup; encrypted root material is a separate operation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import tomllib
|
|
10
|
+
import zipfile
|
|
11
|
+
import psycopg
|
|
12
|
+
from psycopg.conninfo import conninfo_to_dict,make_conninfo
|
|
13
|
+
import subprocess
|
|
14
|
+
from msg.core.codec import canonical,loads
|
|
15
|
+
from msg.core.errors import Failure,require
|
|
16
|
+
from msg.config import load_settings,write_example
|
|
17
|
+
from msg.admin.restore_database import restore_dump
|
|
18
|
+
from msg.storage.git import durable_write
|
|
19
|
+
|
|
20
|
+
_FORMAT = 'msg-data-backup-v4'
|
|
21
|
+
_KEYS = ('online.key', 'receipt.key', 'tokens.key')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _regular_tree(path):
|
|
25
|
+
"""Refuse links before copying secrets or bytes into an archive."""
|
|
26
|
+
require(path.is_dir() and not path.is_symlink(), 'backup_source_missing')
|
|
27
|
+
for root, directories, files in os.walk(path, followlinks=False):
|
|
28
|
+
for name in (*directories, *files):
|
|
29
|
+
entry=Path(root)/name
|
|
30
|
+
require(not entry.is_symlink(), 'backup_symlink_forbidden')
|
|
31
|
+
require(entry.is_dir() or entry.is_file(), 'backup_special_file_forbidden')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _db_refs(connection):
|
|
35
|
+
"""Revision and transfer chunk SQL pointers into Git/CAS."""
|
|
36
|
+
contents={}
|
|
37
|
+
revisions=[]
|
|
38
|
+
for revision_id, raw in connection.execute('SELECT id,body FROM revisions ORDER BY id'):
|
|
39
|
+
body=loads(raw)
|
|
40
|
+
content=body['content']
|
|
41
|
+
require(content['digest'].startswith('sha256:'), 'backup_reference_invalid')
|
|
42
|
+
contents[content['digest'][7:]]=content['size']
|
|
43
|
+
revisions.append(revision_id)
|
|
44
|
+
for (raw,) in connection.execute('SELECT body FROM chunks ORDER BY transfer_id,"offset"'):
|
|
45
|
+
content=loads(raw)['content']
|
|
46
|
+
require(content['digest'].startswith('sha256:'), 'backup_reference_invalid')
|
|
47
|
+
contents[content['digest'][7:]]=content['size']
|
|
48
|
+
from msg.market.references import content_references
|
|
49
|
+
for content in content_references(connection.execute):
|
|
50
|
+
require(content['digest'].startswith('sha256:'), 'backup_reference_invalid')
|
|
51
|
+
contents[content['digest'][7:]]=content['size']
|
|
52
|
+
live={row[0] for row in connection.execute('SELECT revision FROM resources WHERE revision IS NOT NULL')}
|
|
53
|
+
require(live.issubset(set(revisions)), 'backup_revision_missing')
|
|
54
|
+
return {'contents':contents, 'revisions':revisions}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _verify_storage(directory, refs, *, settings=None):
|
|
58
|
+
"""Verify archived bytes, Git objects and the SQL-referenced content index."""
|
|
59
|
+
content=(settings.server.content_dir if settings is not None else directory/'content')
|
|
60
|
+
blobs=(settings.server.blob_dir if settings is not None else directory/'blobs')
|
|
61
|
+
repos=(settings.server.repositories_dir if settings is not None else directory/'repositories')
|
|
62
|
+
require(content.is_dir() and (content/'private.git').is_dir() and
|
|
63
|
+
(content/'index').is_dir() and blobs.is_dir(),
|
|
64
|
+
'backup_storage_missing')
|
|
65
|
+
index_files={path.name:path for path in (content/'index').iterdir() if path.is_file()}
|
|
66
|
+
require(set(refs['contents']).issubset(index_files), 'backup_content_missing')
|
|
67
|
+
for key,index in index_files.items():
|
|
68
|
+
entry=loads(index.read_bytes())
|
|
69
|
+
size=refs['contents'].get(key,entry.get('size'))
|
|
70
|
+
require(len(key)==64 and all(c in '0123456789abcdef' for c in key) and
|
|
71
|
+
type(size) is int and size>=0, 'backup_reference_invalid')
|
|
72
|
+
require(entry.get('size')==size, 'backup_content_size_mismatch')
|
|
73
|
+
if entry.get('kind')=='binary':
|
|
74
|
+
target=blobs/key
|
|
75
|
+
require(target.is_file() and target.stat().st_size==size and
|
|
76
|
+
_hash(target)==key, 'backup_content_digest_mismatch')
|
|
77
|
+
else:
|
|
78
|
+
require(entry.get('kind')=='git', 'backup_content_kind_invalid')
|
|
79
|
+
oid=entry.get('oid')
|
|
80
|
+
require(type(oid) is str and len(oid) in (40,64) and
|
|
81
|
+
all(c in '0123456789abcdef' for c in oid), 'backup_git_oid_invalid')
|
|
82
|
+
status,actual_size,actual_hash=_git_blob_digest(content/'private.git',oid)
|
|
83
|
+
require(status==0 and actual_size==size and actual_hash==key,
|
|
84
|
+
'backup_git_content_mismatch')
|
|
85
|
+
for revision in refs['revisions']:
|
|
86
|
+
require(type(revision) is str and '/' not in revision and revision not in ('.','..'),
|
|
87
|
+
'backup_revision_invalid')
|
|
88
|
+
pointer=content/'revisions'/revision
|
|
89
|
+
require(pointer.is_file(), 'backup_revision_content_missing')
|
|
90
|
+
oid=pointer.read_text().strip()
|
|
91
|
+
require(len(oid) in (40,64) and all(c in '0123456789abcdef' for c in oid),
|
|
92
|
+
'backup_revision_oid_invalid')
|
|
93
|
+
process=subprocess.run(['git','--git-dir',str(content/'private.git'),
|
|
94
|
+
'cat-file','-e',oid+'^{commit}'],capture_output=True,check=False)
|
|
95
|
+
require(process.returncode==0, 'backup_revision_content_missing')
|
|
96
|
+
for repository in (repos.iterdir() if repos.exists() else ()):
|
|
97
|
+
require(repository.is_dir() and repository.name.endswith('.git'),
|
|
98
|
+
'backup_repository_invalid')
|
|
99
|
+
# ZIP stores files only. An empty bare repository still needs these
|
|
100
|
+
# structural directories; recreating them changes no Git object/ref.
|
|
101
|
+
for name in ('objects/info','objects/pack','refs/heads','refs/tags'):
|
|
102
|
+
(repository/name).mkdir(parents=True,exist_ok=True)
|
|
103
|
+
process=subprocess.run(['git','--git-dir',str(repository),'fsck','--full','--no-reflogs'],
|
|
104
|
+
capture_output=True,check=False)
|
|
105
|
+
require(process.returncode==0, 'backup_git_repository_invalid')
|
|
106
|
+
objects=repository/'lfs'/'objects'
|
|
107
|
+
if not objects.exists():
|
|
108
|
+
continue
|
|
109
|
+
for path in objects.rglob('*'):
|
|
110
|
+
if not path.is_file():
|
|
111
|
+
continue
|
|
112
|
+
oid=path.name
|
|
113
|
+
require(len(oid)==64 and all(c in '0123456789abcdef' for c in oid) and
|
|
114
|
+
path.relative_to(objects).parts==(oid[:2],oid[2:4],oid),
|
|
115
|
+
'backup_lfs_path_invalid')
|
|
116
|
+
canonical_blob=blobs/oid
|
|
117
|
+
require(canonical_blob.is_file() and _hash(path)==oid and
|
|
118
|
+
path.stat().st_size==canonical_blob.stat().st_size and
|
|
119
|
+
_hash(canonical_blob)==oid, 'backup_lfs_digest_mismatch')
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _relink_lfs(data_dir, settings):
|
|
123
|
+
"""ZIP cannot retain hardlinks; restore canonical shared-CAS link counts."""
|
|
124
|
+
repositories=settings.server.repositories_dir
|
|
125
|
+
if not repositories.exists():
|
|
126
|
+
return
|
|
127
|
+
for repository in repositories.iterdir():
|
|
128
|
+
objects=repository/'lfs'/'objects'
|
|
129
|
+
if not objects.exists():
|
|
130
|
+
continue
|
|
131
|
+
for path in objects.rglob('*'):
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
continue
|
|
134
|
+
shared=settings.server.blob_dir/path.name
|
|
135
|
+
require(path.stat().st_dev==shared.stat().st_dev,
|
|
136
|
+
'restore_lfs_shared_volume_required')
|
|
137
|
+
temporary=path.with_name('.relink-'+path.name)
|
|
138
|
+
try:
|
|
139
|
+
os.link(shared,temporary)
|
|
140
|
+
os.replace(temporary,path)
|
|
141
|
+
finally:
|
|
142
|
+
temporary.unlink(missing_ok=True)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _write_restored_config(source, settings, postgres_dsn):
|
|
146
|
+
"""Retain policy, but bind only to the new isolated storage and loopback."""
|
|
147
|
+
raw=tomllib.loads(source.read_text())
|
|
148
|
+
server={'service_url':settings.service_url,'listen':'127.0.0.1','port':8042}
|
|
149
|
+
for name in ('public_web_origin','temporary_ttl','transfer_ttl'):
|
|
150
|
+
if name in raw.get('server',{}):
|
|
151
|
+
server[name]=raw['server'][name]
|
|
152
|
+
storage={'postgres_dsn':postgres_dsn,
|
|
153
|
+
'content':str(settings.server.content_dir),
|
|
154
|
+
'repositories':str(settings.server.repositories_dir),
|
|
155
|
+
'blobs':str(settings.server.blob_dir),
|
|
156
|
+
'staging':str(settings.server.staging_dir),
|
|
157
|
+
'service_keys':str(settings.service_keys)}
|
|
158
|
+
sections={'server':server,'storage':storage}
|
|
159
|
+
for name in ('identity','money','limits','plugins','tools'):
|
|
160
|
+
if name in raw:
|
|
161
|
+
sections[name]=raw[name]
|
|
162
|
+
lines=[]
|
|
163
|
+
for section,values in sections.items():
|
|
164
|
+
lines.append(f'[{section}]')
|
|
165
|
+
for name,value in values.items():
|
|
166
|
+
lines.append(f'{name} = {canonical(value).decode()}')
|
|
167
|
+
lines.append('')
|
|
168
|
+
recovery=raw.get('recovery',{})
|
|
169
|
+
if recovery:
|
|
170
|
+
lines.append('[recovery]')
|
|
171
|
+
for name,value in recovery.items():
|
|
172
|
+
if name!='custodians':
|
|
173
|
+
lines.append(f'{name} = {canonical(value).decode()}')
|
|
174
|
+
lines.append('')
|
|
175
|
+
for custodian in recovery.get('custodians',()):
|
|
176
|
+
lines.append('[[recovery.custodians]]')
|
|
177
|
+
for name,value in custodian.items():
|
|
178
|
+
lines.append(f'{name} = {canonical(value).decode()}')
|
|
179
|
+
lines.append('')
|
|
180
|
+
path=settings.config_dir/'msgd.toml'
|
|
181
|
+
path.write_text('\n'.join(lines))
|
|
182
|
+
os.chmod(path,0o600)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _hash(path):
|
|
186
|
+
value=hashlib.sha256()
|
|
187
|
+
with path.open('rb') as source:
|
|
188
|
+
while chunk:=source.read(65536):value.update(chunk)
|
|
189
|
+
return value.hexdigest()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _git_blob_digest(repository, oid):
|
|
193
|
+
value=hashlib.sha256()
|
|
194
|
+
size=0
|
|
195
|
+
with subprocess.Popen(['git','--git-dir',str(repository),'cat-file','blob',oid],
|
|
196
|
+
stdout=subprocess.PIPE,stderr=subprocess.DEVNULL) as process:
|
|
197
|
+
while chunk:=process.stdout.read(1024*1024):
|
|
198
|
+
value.update(chunk);size+=len(chunk)
|
|
199
|
+
status=process.wait()
|
|
200
|
+
return status,size,value.hexdigest()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def backup(app,destination):
|
|
204
|
+
destination=Path(destination)
|
|
205
|
+
require(not destination.exists() and not destination.is_symlink(),'backup_destination_exists')
|
|
206
|
+
destination.parent.mkdir(parents=True,exist_ok=True)
|
|
207
|
+
with tempfile.TemporaryDirectory(prefix='msg-backup-',dir=destination.parent) as tmp:
|
|
208
|
+
directory=Path(tmp)
|
|
209
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
210
|
+
# PostgreSQL's transaction-scoped deployment advisory lock excludes
|
|
211
|
+
# other msgd writers while pg_dump uses its own MVCC connection.
|
|
212
|
+
# Storage objects are published before their SQL pointers commit.
|
|
213
|
+
await asyncio.to_thread(app.metadata.backup,directory/'metadata.dump')
|
|
214
|
+
with psycopg.connect(app.settings.server.postgres_dsn) as connection:
|
|
215
|
+
refs=_db_refs(connection)
|
|
216
|
+
for source,name in ((app.settings.server.content_dir,'content'),
|
|
217
|
+
(app.settings.server.repositories_dir,'repositories'),
|
|
218
|
+
(app.settings.server.blob_dir,'blobs'),
|
|
219
|
+
(app.settings.server.staging_dir,'staging')):
|
|
220
|
+
if source.exists():
|
|
221
|
+
_regular_tree(source)
|
|
222
|
+
await asyncio.to_thread(shutil.copytree,source,directory/name)
|
|
223
|
+
else:
|
|
224
|
+
(directory/name).mkdir()
|
|
225
|
+
_regular_tree(app.settings.service_keys)
|
|
226
|
+
await asyncio.to_thread(shutil.copytree,app.settings.service_keys,directory/'service')
|
|
227
|
+
require(all((directory/'service'/name).is_file() for name in _KEYS),
|
|
228
|
+
'service_keys_missing')
|
|
229
|
+
require(app.settings.trust_file.is_file() and not app.settings.trust_file.is_symlink(),
|
|
230
|
+
'backup_trust_missing')
|
|
231
|
+
shutil.copy2(app.settings.trust_file,directory/'root-public.json')
|
|
232
|
+
# Keep policy/configuration for review. Restore rewrites storage paths,
|
|
233
|
+
# disables Valkey and does not copy mail credentials.
|
|
234
|
+
config_source=(app.settings.config_dir/'msgd.toml'
|
|
235
|
+
if (app.settings.config_dir/'msgd.toml').exists()
|
|
236
|
+
else app.settings.config_dir/'server.toml')
|
|
237
|
+
require(config_source.is_file() and not config_source.is_symlink(),
|
|
238
|
+
'backup_configuration_missing')
|
|
239
|
+
shutil.copy2(config_source,directory/'source-config.toml')
|
|
240
|
+
_verify_storage(directory,refs)
|
|
241
|
+
files={str(path.relative_to(directory)):_hash(path) for path in directory.rglob('*') if path.is_file()}
|
|
242
|
+
manifest={'format':_FORMAT,'service_url':app.settings.service_url,'files':files,
|
|
243
|
+
'references':refs,'root_private_key_included':False,
|
|
244
|
+
'mail_credentials_included':False}
|
|
245
|
+
(directory/'manifest.json').write_bytes(canonical(manifest))
|
|
246
|
+
partial=directory/'archive.zip'
|
|
247
|
+
with zipfile.ZipFile(partial,'x',compression=zipfile.ZIP_DEFLATED) as archive:
|
|
248
|
+
for name in ('manifest.json',*sorted(files)):
|
|
249
|
+
archive.write(directory/name,name)
|
|
250
|
+
os.chmod(partial,0o600)
|
|
251
|
+
with partial.open('rb') as stream:os.fsync(stream.fileno())
|
|
252
|
+
os.link(partial,destination)
|
|
253
|
+
return {'status':'backup_created','path':str(destination),'sha256':_hash(destination),
|
|
254
|
+
'root_private_key_included':False}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def restore(source,config_dir,data_dir,*,postgres_dsn='service=msgd'):
|
|
258
|
+
"""Restore only into new locations and an empty PostgreSQL database."""
|
|
259
|
+
config_dir,data_dir=Path(config_dir),Path(data_dir)
|
|
260
|
+
require(not config_dir.exists() and not data_dir.exists(),'restore_target_exists')
|
|
261
|
+
config_dir.parent.mkdir(parents=True,exist_ok=True)
|
|
262
|
+
data_dir.parent.mkdir(parents=True,exist_ok=True)
|
|
263
|
+
with tempfile.TemporaryDirectory(prefix='msg-restore-',dir=data_dir.parent) as temp:
|
|
264
|
+
directory=Path(temp)
|
|
265
|
+
with zipfile.ZipFile(source) as archive:
|
|
266
|
+
names=archive.namelist()
|
|
267
|
+
require(len(names)==len(set(names)) and 'manifest.json' in names,'invalid_backup')
|
|
268
|
+
manifest=loads(archive.read('manifest.json'))
|
|
269
|
+
require(manifest.get('format')==_FORMAT and manifest.get('root_private_key_included') is False,
|
|
270
|
+
'invalid_backup')
|
|
271
|
+
require(set(names)==set(manifest['files'])|{'manifest.json'},'backup_file_mismatch')
|
|
272
|
+
for info in archive.infolist():
|
|
273
|
+
name=info.filename
|
|
274
|
+
require(not name.startswith('/') and '\\' not in name and
|
|
275
|
+
all(part not in {'','..','.'} for part in name.split('/')),'unsafe_backup_path')
|
|
276
|
+
require((info.external_attr>>16)&0o170000 not in {0o120000,0o060000,0o020000},'unsafe_backup_entry')
|
|
277
|
+
target=directory/name;target.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
|
278
|
+
with archive.open(info) as incoming,target.open('xb') as output:
|
|
279
|
+
shutil.copyfileobj(incoming,output,length=65536)
|
|
280
|
+
os.chmod(target,0o600)
|
|
281
|
+
if name!='manifest.json':require(_hash(target)==manifest['files'][name],'backup_digest_mismatch')
|
|
282
|
+
# Empty storage directories have no ZIP file entries.
|
|
283
|
+
for name in ('content','repositories','blobs','staging'):
|
|
284
|
+
(directory/name).mkdir(exist_ok=True)
|
|
285
|
+
require((directory/'metadata.dump').is_file() and
|
|
286
|
+
(directory/'root-public.json').is_file() and
|
|
287
|
+
(directory/'source-config.toml').is_file() and
|
|
288
|
+
all((directory/'service'/name).is_file() for name in _KEYS),
|
|
289
|
+
'backup_required_file_missing')
|
|
290
|
+
_verify_storage(directory,manifest['references'])
|
|
291
|
+
fields=conninfo_to_dict(postgres_dsn)
|
|
292
|
+
password=fields.pop('password',None)
|
|
293
|
+
env=os.environ.copy()
|
|
294
|
+
if password is not None:env['PGPASSWORD']=password
|
|
295
|
+
safe_dsn=make_conninfo(**fields)
|
|
296
|
+
with psycopg.connect(postgres_dsn) as connection:
|
|
297
|
+
require(connection.execute(
|
|
298
|
+
"SELECT 1 FROM information_schema.tables WHERE table_schema=current_schema() LIMIT 1"
|
|
299
|
+
).fetchone() is None,'restore_database_not_empty')
|
|
300
|
+
quarantine={'format':'msg-recovery-quarantine-v1','outbound_enabled':False,
|
|
301
|
+
'source_backup_sha256':_hash(Path(source)),
|
|
302
|
+
'revocation_replay':'required','authority':'health_only'}
|
|
303
|
+
restore_dump(directory/'metadata.dump',safe_dsn=safe_dsn,env=env,
|
|
304
|
+
quarantine=quarantine)
|
|
305
|
+
with psycopg.connect(postgres_dsn) as connection:
|
|
306
|
+
require(_db_refs(connection)==manifest['references'],
|
|
307
|
+
'backup_database_reference_mismatch')
|
|
308
|
+
row=connection.execute("SELECT value FROM settings WHERE key='runtime_config'").fetchone()
|
|
309
|
+
runtime=loads(row[0]) if row is not None else {}
|
|
310
|
+
require(type(runtime) is dict,'backup_runtime_config_invalid')
|
|
311
|
+
runtime['accept_writes']=False
|
|
312
|
+
runtime['cleanup_enabled']=False
|
|
313
|
+
connection.execute('''INSERT INTO settings(key,value) VALUES('runtime_config',%s)
|
|
314
|
+
ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value''',
|
|
315
|
+
(canonical(runtime).decode(),))
|
|
316
|
+
data_dir.mkdir(mode=0o700)
|
|
317
|
+
settings=write_example(config_dir,data_dir,manifest['service_url'],postgres_dsn=postgres_dsn)
|
|
318
|
+
# Install the fail-closed marker as soon as a runnable config exists,
|
|
319
|
+
# including if a later filesystem verification fails.
|
|
320
|
+
durable_write(config_dir/'recovery-drill.json',canonical({
|
|
321
|
+
**quarantine,'format':'msg-recovery-drill-v1'}),mode=0o600)
|
|
322
|
+
_write_restored_config(directory/'source-config.toml',settings,postgres_dsn)
|
|
323
|
+
settings=load_settings(config_dir)
|
|
324
|
+
for name,target in (('content',settings.server.content_dir),
|
|
325
|
+
('repositories',settings.server.repositories_dir),
|
|
326
|
+
('blobs',settings.server.blob_dir),
|
|
327
|
+
('staging',settings.server.staging_dir)):
|
|
328
|
+
if (directory/name).exists():
|
|
329
|
+
target.parent.mkdir(parents=True,exist_ok=True)
|
|
330
|
+
shutil.move(str(directory/name),target)
|
|
331
|
+
settings.service_keys.parent.mkdir(parents=True,exist_ok=True)
|
|
332
|
+
shutil.move(str(directory/'service'),settings.service_keys)
|
|
333
|
+
os.chmod(settings.service_keys,0o700)
|
|
334
|
+
settings.trust_file.parent.mkdir(parents=True,exist_ok=True)
|
|
335
|
+
shutil.move(str(directory/'root-public.json'),settings.trust_file)
|
|
336
|
+
os.chmod(settings.trust_file,0o444)
|
|
337
|
+
_relink_lfs(data_dir,settings)
|
|
338
|
+
_verify_storage(data_dir,manifest['references'],settings=settings)
|
|
339
|
+
return {'status':'restored','root_admin_material':'restore_separate_encrypted_root_backup_locally',
|
|
340
|
+
'mail':'disabled_until_configured','outbound':'disabled_recovery_drill',
|
|
341
|
+
'revocation_replay':'required','promotion':'blocked'}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Read-only lifecycle inspection and an isolated real-age history selftest."""
|
|
2
|
+
from dataclasses import replace
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tempfile import TemporaryDirectory
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from msg.core.codec import digest, loads, wire
|
|
10
|
+
from msg.core.errors import require
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def inspect_custodial_history(tx):
|
|
14
|
+
from msg.security.custodial_migration import snapshot
|
|
15
|
+
constraint = tx.one("""SELECT pg_get_constraintdef(oid) FROM pg_constraint
|
|
16
|
+
WHERE conrelid='custodial_vault'::regclass AND conname='custodial_vault_lifecycle_check'""")
|
|
17
|
+
require(constraint is not None and 'decrypt_only' in constraint[0], 'custodial_lifecycle_schema_missing')
|
|
18
|
+
states = []
|
|
19
|
+
for subject, status, challenge, body in tx.rows(
|
|
20
|
+
'SELECT subject,status,challenge,body FROM custodial_upgrades ORDER BY id'):
|
|
21
|
+
if status == 'failed':
|
|
22
|
+
continue
|
|
23
|
+
view = (await snapshot(tx, subject, status, loads(challenge), loads(body))).data
|
|
24
|
+
states.append({'subject': subject, 'challenge_id': view['challenge_id'], 'phase': view['phase'],
|
|
25
|
+
'unresolved_count': len(view['unresolved_revisions']), 'inventory_changed': view['inventory_changed'],
|
|
26
|
+
'retirement': view['retirement']})
|
|
27
|
+
return {'migrations': states, 'backup_retirement_verified': False,
|
|
28
|
+
'verification_scope': 'online_state_only'}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def check_custodial_history(app, now):
|
|
32
|
+
"""Call only in the existing selftest disposable Test Root database."""
|
|
33
|
+
from msg.client import ClientState, MsgClient
|
|
34
|
+
from msg.client_custodial import migrate, transition
|
|
35
|
+
from msg.client_recovery import _age
|
|
36
|
+
from msg.core.models import Resource, Revision
|
|
37
|
+
from msg.transports.client import HTTPTransport
|
|
38
|
+
from msg.transports.http import create_app
|
|
39
|
+
require(app.selftest_run_id is not None, 'isolated_selftest_required')
|
|
40
|
+
with TemporaryDirectory(prefix='msg-history-selftest-') as directory:
|
|
41
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app))) as http:
|
|
42
|
+
client = MsgClient(ClientState(Path(directory), server=app.settings.service_url),
|
|
43
|
+
HTTPTransport(app.settings.service_url, http=http), clock=lambda: now)
|
|
44
|
+
created = client.checked(await client.custodial('history-' + uuid4().hex[:12]))
|
|
45
|
+
subject = client.state.subject
|
|
46
|
+
ciphertext = _age('--encrypt', '--recipient', created.data['encryption_recipient'],
|
|
47
|
+
input_data=b'isolated historical plaintext')
|
|
48
|
+
# Explicit fixture fact, not a new privileged network operation. The
|
|
49
|
+
# normal custodial credential ceiling does not grant keystore writes.
|
|
50
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
51
|
+
folder = tx.one("SELECT id FROM resources WHERE parent=? AND name='keystore'", (subject,))[0]
|
|
52
|
+
resource = Resource(id='ks_' + uuid4().hex, type='keystore', type_version=1,
|
|
53
|
+
name='history.age', parent=folder, owner=subject, group='g_public', mode=0o600,
|
|
54
|
+
generation=0, revision=None, state='active', created_at=now, created_by=subject,
|
|
55
|
+
modified_at=now, modified_by=subject)
|
|
56
|
+
await tx.insert(resource)
|
|
57
|
+
blob = await app.contents.put_bytes(ciphertext, 'application/octet-stream')
|
|
58
|
+
revision = Revision(format_version=1, id='v_' + uuid4().hex, resource_id=resource.id,
|
|
59
|
+
parents=(), content=blob, relations=(), actor=subject, subject=subject, author=subject,
|
|
60
|
+
created_at=now, manifest_digest='')
|
|
61
|
+
revision = replace(revision, manifest_digest=digest({k: v for k, v in wire(revision).items()
|
|
62
|
+
if k not in {'manifest_digest', 'signature'}}))
|
|
63
|
+
await app.contents.pin(blob, revision.id)
|
|
64
|
+
await app.contents.commit_revision(folder, revision)
|
|
65
|
+
await tx.append_revision(revision)
|
|
66
|
+
await tx.replace(replace(resource, generation=1, revision=revision.id), 0)
|
|
67
|
+
tx.set_setting('keystore_format:' + revision.id, 'age')
|
|
68
|
+
pending = client.checked(await client.upgrade_custodial('history-self-' + uuid4().hex[:12],
|
|
69
|
+
external_ciphertexts_migrated=True))
|
|
70
|
+
if pending.data['status'] != 'pending_rewrap':
|
|
71
|
+
return False
|
|
72
|
+
result = await migrate(client)
|
|
73
|
+
if not result['inventory']['history_recoverable']:
|
|
74
|
+
return False
|
|
75
|
+
done = client.checked(await transition(client, 'finalize', external_ciphertexts_migrated=True))
|
|
76
|
+
return (done.data['history_recoverable'] and done.data['retirement']['online_encryption_key_deleted']
|
|
77
|
+
and not done.data['retirement']['backup_retired'] and client.state.token is None)
|