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/client_secrets.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Local encryption helpers. Neither plaintext nor private keys reach a transport."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tempfile
|
|
6
|
+
from msg.core.codec import b64,unb64,wire
|
|
7
|
+
from msg.core.errors import require
|
|
8
|
+
from msg.core.models import ResourceRef
|
|
9
|
+
from msg.security.sealed_box import generate_key,encrypt,decrypt,FORMAT
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_private(path,content):
|
|
13
|
+
path=Path(path)
|
|
14
|
+
path.parent.mkdir(parents=True,exist_ok=True)
|
|
15
|
+
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
|
16
|
+
try:
|
|
17
|
+
with os.fdopen(fd,'wb') as output:
|
|
18
|
+
output.write(content);output.flush();os.fsync(output.fileno())
|
|
19
|
+
except BaseException:
|
|
20
|
+
path.unlink(missing_ok=True)
|
|
21
|
+
raise
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def encryption_keygen(path):
|
|
25
|
+
private,public=generate_key()
|
|
26
|
+
write_private(path,private)
|
|
27
|
+
return {'format':FORMAT,'recipient':b64(public),'private_key_file':str(path)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def put_secret(client,name,path,recipient):
|
|
31
|
+
# The msg envelope is bounded. Already encrypted large age/OpenPGP files can
|
|
32
|
+
# instead use transfer + keystore.put(source=...), without loading them here.
|
|
33
|
+
with Path(path).open('rb') as source:
|
|
34
|
+
plaintext=source.read(524289)
|
|
35
|
+
require(len(plaintext)<=524288,'use_streaming_age_or_openpgp')
|
|
36
|
+
sealed=encrypt(plaintext,unb64(recipient,limit=32))
|
|
37
|
+
with tempfile.TemporaryDirectory(prefix='encrypted-',dir=client.state.directory) as temp:
|
|
38
|
+
ciphertext=Path(temp)/'ciphertext';write_private(ciphertext,sealed)
|
|
39
|
+
uploaded=client.checked(await client.upload(ciphertext,media_type='application/octet-stream'))
|
|
40
|
+
return await client.call('keystore.put',{'name':name,'format':FORMAT,'source':wire(uploaded.output)})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def get_secret(client,resource,output,private_key):
|
|
44
|
+
path=Path(private_key)
|
|
45
|
+
require(path.is_file() and not path.is_symlink() and path.stat().st_uid==os.geteuid() and
|
|
46
|
+
path.stat().st_mode&0o077==0,'unsafe_encryption_key_permissions')
|
|
47
|
+
private=path.read_bytes()
|
|
48
|
+
require(len(private)==32,'invalid_encryption_key')
|
|
49
|
+
entry=client.checked(await client.call('keystore.get',{'id':resource}))
|
|
50
|
+
require(entry.data['format']==FORMAT,'external_decryptor_required')
|
|
51
|
+
require(entry.data['size']<=1048576,'ciphertext_envelope_too_large')
|
|
52
|
+
require(not Path(output).exists(),'download_target_exists')
|
|
53
|
+
with tempfile.TemporaryDirectory(prefix='encrypted-',dir=client.state.directory) as temp:
|
|
54
|
+
ciphertext=Path(temp)/'ciphertext'
|
|
55
|
+
await client.download(entry.output,ciphertext)
|
|
56
|
+
plaintext=decrypt(ciphertext.read_bytes(),private)
|
|
57
|
+
write_private(output,plaintext)
|
|
58
|
+
return {'id':entry.data['id'],'revision':entry.data['revision'],'path':str(output),'decrypted_locally':True}
|
msg/client_tokens.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Protected one-use credential journals; no network authority comes from an ID."""
|
|
2
|
+
from functools import wraps
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
|
|
6
|
+
from msg.client_upgrade import locked_state
|
|
7
|
+
from msg.core.codec import b64, loads, unb64
|
|
8
|
+
from msg.core.errors import Failure, require
|
|
9
|
+
|
|
10
|
+
JOURNAL_OPERATIONS = {
|
|
11
|
+
'temporary.json': ('identity.temporary', 3),
|
|
12
|
+
'custodial-bootstrap.json': ('identity.custodial_create', 2),
|
|
13
|
+
'token-rotation.json': ('identity.token_rotate', 2),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def remove_journal(path):
|
|
18
|
+
"""The local credential must already be durable before removing its intent."""
|
|
19
|
+
path.unlink()
|
|
20
|
+
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
21
|
+
try:
|
|
22
|
+
os.fsync(fd)
|
|
23
|
+
finally:
|
|
24
|
+
os.close(fd)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def token_operation(method):
|
|
28
|
+
"""Serialize with identity upgrades too, without blocking the event loop."""
|
|
29
|
+
@wraps(method)
|
|
30
|
+
async def locked(self, *args, **kwargs):
|
|
31
|
+
self._require_token_secret_transport()
|
|
32
|
+
with locked_state(self.state):
|
|
33
|
+
require(not any((self.state.directory/name).exists() or
|
|
34
|
+
(self.state.directory/name).is_symlink() for name in
|
|
35
|
+
('identity-upgrade.json', 'custodial-upgrade.json')),
|
|
36
|
+
'identity_recovery_pending')
|
|
37
|
+
self._token_journal()
|
|
38
|
+
return await method(self, *args, **kwargs)
|
|
39
|
+
return locked
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_journal(state):
|
|
43
|
+
paths = [state.directory/name for name in JOURNAL_OPERATIONS]
|
|
44
|
+
present = [p for p in paths if p.exists() or p.is_symlink()]
|
|
45
|
+
require(len(present) <= 1, 'multiple_token_journals')
|
|
46
|
+
if not present:
|
|
47
|
+
return None, None
|
|
48
|
+
path = present[0]
|
|
49
|
+
try:
|
|
50
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
51
|
+
except OSError:
|
|
52
|
+
raise Failure('unsafe_token_journal') from None
|
|
53
|
+
try:
|
|
54
|
+
info = os.fstat(fd)
|
|
55
|
+
require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and
|
|
56
|
+
info.st_nlink == 1 and info.st_mode & 0o077 == 0, 'unsafe_token_journal')
|
|
57
|
+
with os.fdopen(fd, 'rb', closefd=False) as stream:
|
|
58
|
+
raw = stream.read(8193)
|
|
59
|
+
finally:
|
|
60
|
+
os.close(fd)
|
|
61
|
+
require(len(raw) <= 8192, 'invalid_token_journal')
|
|
62
|
+
try:
|
|
63
|
+
saved = loads(raw)
|
|
64
|
+
require(isinstance(saved, dict), 'invalid_token_journal')
|
|
65
|
+
operation, version = JOURNAL_OPERATIONS[path.name]
|
|
66
|
+
require(saved.get('operation') == operation and saved.get('contract_version') == version,
|
|
67
|
+
'legacy_token_journal_requires_manual_resolution')
|
|
68
|
+
# Older journals stay recoverable using their pre-bound secret, but cannot
|
|
69
|
+
# silently generate replacement keys. Newly written intentions also bind
|
|
70
|
+
# the origin and local key pair, including before any network call.
|
|
71
|
+
if 'server' in saved:
|
|
72
|
+
require(saved['server'] == state.server, 'token_journal_server_mismatch')
|
|
73
|
+
for field in ('nonce', 'recovery_secret'):
|
|
74
|
+
require(len(unb64(saved[field], limit=64)) >= 32, 'invalid_token_journal')
|
|
75
|
+
for field in ('credential_id', 'subject_id', 'request_id'):
|
|
76
|
+
require(isinstance(saved[field], str) and 0 < len(saved[field]) <= 160,
|
|
77
|
+
'invalid_token_journal')
|
|
78
|
+
recovery = saved.get('recovery')
|
|
79
|
+
if recovery is not None:
|
|
80
|
+
require(isinstance(recovery, dict), 'invalid_token_journal')
|
|
81
|
+
for field in ('nonce', 'new_recovery_secret'):
|
|
82
|
+
require(len(unb64(recovery[field], limit=64)) >= 32, 'invalid_token_journal')
|
|
83
|
+
for field in ('request_id', 'credential_id'):
|
|
84
|
+
require(isinstance(recovery[field], str) and 0 < len(recovery[field]) <= 160,
|
|
85
|
+
'invalid_token_journal')
|
|
86
|
+
require(state.subject is None or saved['subject_id'] == state.subject,
|
|
87
|
+
'token_journal_subject_mismatch')
|
|
88
|
+
if operation == 'identity.temporary':
|
|
89
|
+
require(state.signer is not None and state.encryption_recipient is not None,
|
|
90
|
+
'token_journal_key_missing')
|
|
91
|
+
if 'public_key' in saved:
|
|
92
|
+
require(saved['public_key'] == b64(state.signer.public_key) and
|
|
93
|
+
saved.get('encryption_recipient') == state.encryption_recipient,
|
|
94
|
+
'token_journal_key_mismatch')
|
|
95
|
+
except Failure:
|
|
96
|
+
raise
|
|
97
|
+
except (KeyError, ValueError, TypeError):
|
|
98
|
+
raise Failure('invalid_token_journal') from None
|
|
99
|
+
return path, saved
|
msg/client_upgrade.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Durable same-subject upgrade intent; status recovery never reuses old authority."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import stat
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from msg.core.codec import b64, canonical, loads, wire
|
|
9
|
+
from msg.core.errors import Failure, require
|
|
10
|
+
from msg.security.crypto import Ed25519Signer
|
|
11
|
+
from msg.storage.git import durable_write
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@contextmanager
|
|
15
|
+
def upgrade_lock(directory):
|
|
16
|
+
"""A crash releases the OS lock; a second caller fails instead of blocking an event loop."""
|
|
17
|
+
try:
|
|
18
|
+
import fcntl
|
|
19
|
+
except ImportError:
|
|
20
|
+
raise Failure('upgrade_lock_unavailable') from None
|
|
21
|
+
try:
|
|
22
|
+
fd = os.open(directory/'identity-upgrade.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
23
|
+
except OSError:
|
|
24
|
+
raise Failure('unsafe_upgrade_lock') from None
|
|
25
|
+
try:
|
|
26
|
+
info = os.fstat(fd)
|
|
27
|
+
require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and
|
|
28
|
+
info.st_nlink == 1 and info.st_mode & 0o077 == 0, 'unsafe_upgrade_lock')
|
|
29
|
+
try:
|
|
30
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
31
|
+
except BlockingIOError:
|
|
32
|
+
raise Failure('identity_upgrade_busy', retryable=True) from None
|
|
33
|
+
yield
|
|
34
|
+
finally:
|
|
35
|
+
os.close(fd)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def locked_state(state):
|
|
41
|
+
"""Refresh accepted credentials under the same lock used by every transition."""
|
|
42
|
+
with upgrade_lock(state.directory):
|
|
43
|
+
fresh = type(state)(state.directory, server=state.server)
|
|
44
|
+
state.__dict__.update(fresh.__dict__)
|
|
45
|
+
yield
|
|
46
|
+
|
|
47
|
+
def read_intent(path):
|
|
48
|
+
try:
|
|
49
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
50
|
+
except OSError:
|
|
51
|
+
raise Failure('unsafe_upgrade_journal') from None
|
|
52
|
+
try:
|
|
53
|
+
info = os.fstat(fd)
|
|
54
|
+
require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and
|
|
55
|
+
info.st_nlink == 1 and info.st_mode & 0o077 == 0, 'unsafe_upgrade_journal')
|
|
56
|
+
with os.fdopen(fd, 'rb', closefd=False) as stream:
|
|
57
|
+
raw = stream.read(8193)
|
|
58
|
+
finally:
|
|
59
|
+
os.close(fd)
|
|
60
|
+
require(len(raw) <= 8192, 'invalid_upgrade_journal')
|
|
61
|
+
try:
|
|
62
|
+
value = loads(raw)
|
|
63
|
+
require(isinstance(value, dict) and set(value) == {
|
|
64
|
+
'version', 'server', 'subject_id', 'handle', 'public_key',
|
|
65
|
+
'encryption_recipient', 'credential_id', 'request_id'}, 'invalid_upgrade_journal')
|
|
66
|
+
require(value['version'] == 1 and all(isinstance(value[name], str) and value[name]
|
|
67
|
+
for name in value if name != 'version'), 'invalid_upgrade_journal')
|
|
68
|
+
require(re.fullmatch('[0-9a-f]{32}', value['request_id']) is not None,
|
|
69
|
+
'invalid_upgrade_journal')
|
|
70
|
+
except (ValueError, TypeError, KeyError):
|
|
71
|
+
raise Failure('invalid_upgrade_journal') from None
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def confirm_local_identity(state, pending, result, journal):
|
|
76
|
+
require(result.status == 'ok' and result.subject == pending['subject_id'] and
|
|
77
|
+
result.data.get('subject_id') == pending['subject_id'] and
|
|
78
|
+
result.data.get('key_id') == state.signer.key_id and
|
|
79
|
+
result.data.get('handle') == pending['handle'] and
|
|
80
|
+
result.data.get('encryption_recipient') == pending['encryption_recipient'] and
|
|
81
|
+
isinstance(result.data.get('certificate_id'), str) and bool(result.data['certificate_id']),
|
|
82
|
+
'upgrade_result_mismatch')
|
|
83
|
+
if result.operation == 'identity.upgrade_result':
|
|
84
|
+
require(result.data.get('status') == 'completed' and
|
|
85
|
+
result.data.get('upgrade_request_id') == pending['request_id'],
|
|
86
|
+
'upgrade_result_mismatch')
|
|
87
|
+
else:
|
|
88
|
+
require(result.operation == 'identity.upgrade' and result.request_id == pending['request_id'],
|
|
89
|
+
'upgrade_result_mismatch')
|
|
90
|
+
# Do not remove the recovery journal until the local identity is durable.
|
|
91
|
+
# A crash after this write but before unlink is recovered with the new key.
|
|
92
|
+
state.accept_identity(result)
|
|
93
|
+
journal.unlink()
|
|
94
|
+
fd = os.open(state.directory, os.O_RDONLY | os.O_DIRECTORY)
|
|
95
|
+
try:
|
|
96
|
+
os.fsync(fd)
|
|
97
|
+
finally:
|
|
98
|
+
os.close(fd)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def pending_upgrade(state):
|
|
103
|
+
path = state.directory/'identity-upgrade.json'
|
|
104
|
+
if not (path.exists() or path.is_symlink()):
|
|
105
|
+
return None
|
|
106
|
+
pending = read_intent(path)
|
|
107
|
+
require(pending['server'] == state.server and pending['subject_id'] == state.subject and
|
|
108
|
+
re.fullmatch(r'[a-z][a-z0-9-]{1,40}', pending['handle']), 'upgrade_journal_mismatch')
|
|
109
|
+
return {'status': 'pending', 'handle': pending['handle'], 'request_id': pending['request_id'],
|
|
110
|
+
'resume': 'msg identity upgrade'}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def upgrade_identity(client, handle=None):
|
|
114
|
+
state = client.state
|
|
115
|
+
client._require_token_secret_transport()
|
|
116
|
+
with locked_state(state):
|
|
117
|
+
journal = state.directory/'identity-upgrade.json'
|
|
118
|
+
resuming = journal.exists() or journal.is_symlink()
|
|
119
|
+
pending = read_intent(journal) if resuming else None
|
|
120
|
+
if handle is None:
|
|
121
|
+
require(pending is not None, 'upgrade_handle_required')
|
|
122
|
+
handle = pending['handle']
|
|
123
|
+
require(isinstance(handle, str) and re.fullmatch(r'[a-z][a-z0-9-]{1,40}', handle)
|
|
124
|
+
and handle not in {'root', 'online-ca'}, 'invalid_handle')
|
|
125
|
+
if resuming:
|
|
126
|
+
require(state.signer is not None and state.encryption_recipient is not None,
|
|
127
|
+
'upgrade_journal_key_missing')
|
|
128
|
+
require((pending['server'], pending['subject_id'], pending['handle'],
|
|
129
|
+
pending['public_key'], pending['encryption_recipient']) ==
|
|
130
|
+
(state.server, state.subject, handle, b64(state.signer.public_key),
|
|
131
|
+
state.encryption_recipient), 'upgrade_journal_mismatch')
|
|
132
|
+
require(state.token is None or state.token[0] == pending['credential_id'],
|
|
133
|
+
'upgrade_journal_mismatch')
|
|
134
|
+
else:
|
|
135
|
+
require(state.subject is not None and state.token is not None, 'temporary_identity_required')
|
|
136
|
+
require(not any((state.directory/name).exists() or (state.directory/name).is_symlink() for name in (
|
|
137
|
+
'temporary.json', 'custodial-bootstrap.json', 'token-rotation.json',
|
|
138
|
+
'custodial-upgrade.json')), 'identity_recovery_pending')
|
|
139
|
+
if state.signer is None:
|
|
140
|
+
state.save_signer(Ed25519Signer.generate())
|
|
141
|
+
recipient = state.ensure_encryption_key()
|
|
142
|
+
pending = {'version': 1, 'server': state.server, 'subject_id': state.subject,
|
|
143
|
+
'handle': handle, 'public_key': b64(state.signer.public_key),
|
|
144
|
+
'encryption_recipient': recipient, 'credential_id': state.token[0],
|
|
145
|
+
'request_id': uuid4().hex}
|
|
146
|
+
# Only a credential ID is stored here, never the old token or a private key.
|
|
147
|
+
durable_write(journal, canonical(pending), mode=0o600)
|
|
148
|
+
|
|
149
|
+
async def recover():
|
|
150
|
+
return await client.call('identity.upgrade_result',
|
|
151
|
+
{'upgrade_request_id': pending['request_id']}, signer=state.signer,
|
|
152
|
+
subject=pending['subject_id'], certificates=())
|
|
153
|
+
|
|
154
|
+
if resuming:
|
|
155
|
+
recovered = await recover()
|
|
156
|
+
if recovered.status == 'ok':
|
|
157
|
+
return confirm_local_identity(state, pending, recovered, journal)
|
|
158
|
+
# A not-yet-bound new key can occur after a pre-send crash. Only the
|
|
159
|
+
# still-valid original credential may submit the fixed intention.
|
|
160
|
+
if state.token is None or recovered.error.code not in {
|
|
161
|
+
'credential_not_found', 'not_found', 'invalid_signature',
|
|
162
|
+
'credential_ceiling', 'self_custody_signature_required',
|
|
163
|
+
'upgrade_not_completed'}:
|
|
164
|
+
return recovered
|
|
165
|
+
|
|
166
|
+
require(state.token is not None, 'temporary_identity_required')
|
|
167
|
+
signed = {name: pending[name] for name in ('subject_id', 'handle', 'public_key',
|
|
168
|
+
'encryption_recipient')}
|
|
169
|
+
arguments = {name: pending[name] for name in ('handle', 'public_key', 'encryption_recipient')}
|
|
170
|
+
arguments['possession_proof'] = wire(state.signer.sign(canonical(signed), purpose='upgrade'))
|
|
171
|
+
try:
|
|
172
|
+
result = await client.call('identity.upgrade', arguments,
|
|
173
|
+
request_id=pending['request_id'], contract_version=2)
|
|
174
|
+
except Failure as exc:
|
|
175
|
+
if exc.code != 'transport_uncertain':
|
|
176
|
+
raise
|
|
177
|
+
recovered = await recover()
|
|
178
|
+
if recovered.status != 'ok':
|
|
179
|
+
raise exc
|
|
180
|
+
result = recovered
|
|
181
|
+
if result.status == 'error' and result.error.code in {
|
|
182
|
+
'credential_revoked', 'credential_expired', 'not_temporary'}:
|
|
183
|
+
recovered = await recover()
|
|
184
|
+
if recovered.status == 'ok':
|
|
185
|
+
result = recovered
|
|
186
|
+
if result.status == 'ok':
|
|
187
|
+
return confirm_local_identity(state, pending, result, journal)
|
|
188
|
+
return result
|
msg/config.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Server configuration and client configuration have separate roots."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from msg.core.errors import require
|
|
12
|
+
from msg.core.models import MailConfig, ServerConfig, TransportLimits
|
|
13
|
+
from msg.security.age_keys import public_from_recipient,encryption_key_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True,slots=True)
|
|
17
|
+
class RecoveryCustodian:
|
|
18
|
+
id: str
|
|
19
|
+
name: str
|
|
20
|
+
recipient: str
|
|
21
|
+
fingerprint: str
|
|
22
|
+
description: str | None = None
|
|
23
|
+
policy_ref: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True,slots=True)
|
|
27
|
+
class MoneyConfig:
|
|
28
|
+
enabled: bool = True
|
|
29
|
+
currency_id: str = 'primary'
|
|
30
|
+
display_name: str = 'MSG'
|
|
31
|
+
code: str = 'MSG'
|
|
32
|
+
scale: int = 6
|
|
33
|
+
transfer_fee: int = 0
|
|
34
|
+
allow_overdraft: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def root_private_dir(config_dir: Path) -> Path:
|
|
38
|
+
"""Root material is outside the network service configuration tree."""
|
|
39
|
+
directory = Path(config_dir)
|
|
40
|
+
if directory == Path('/etc/msgd'):
|
|
41
|
+
return Path('/var/lib/msgd-root')
|
|
42
|
+
return directory.parent / (directory.name + '-root')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def server_config_file(config_dir: Path) -> Path:
|
|
46
|
+
"""Prefer the current name, but keep an existing legacy install readable."""
|
|
47
|
+
directory = Path(config_dir)
|
|
48
|
+
current, legacy = directory/'msgd.toml', directory/'server.toml'
|
|
49
|
+
require(not (current.exists() and legacy.exists()), 'ambiguous_server_configuration')
|
|
50
|
+
return legacy if legacy.exists() else current
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True,slots=True)
|
|
54
|
+
class Settings:
|
|
55
|
+
server: ServerConfig
|
|
56
|
+
service_url: str
|
|
57
|
+
listen: str='127.0.0.1'
|
|
58
|
+
port: int=8042
|
|
59
|
+
public_web_origin: str | None=None
|
|
60
|
+
temporary_ttl: int=3600
|
|
61
|
+
credential_delivery_recovery_window: int=900
|
|
62
|
+
transfer_ttl: int=86400
|
|
63
|
+
base_certificate_ttl: int=2592000
|
|
64
|
+
max_part_bytes: int=65536
|
|
65
|
+
worker_isolation: str='bubblewrap'
|
|
66
|
+
tool_timeout_ms: int=10000
|
|
67
|
+
tool_max_response_bytes: int=4194304
|
|
68
|
+
tool_methods: tuple[str,...]=('GET','HEAD')
|
|
69
|
+
tool_ports: tuple[int,...]=(80,443)
|
|
70
|
+
recovery_custodians: tuple[RecoveryCustodian,...]=()
|
|
71
|
+
money: MoneyConfig=MoneyConfig()
|
|
72
|
+
hosting_base_capacity_bytes: int=10*1024*1024
|
|
73
|
+
hosting_recovery_marker: Path | None=None
|
|
74
|
+
hosting_content_group_read: bool=False
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def config_dir(self):
|
|
78
|
+
return self.server.config_dir
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def trust_file(self):
|
|
82
|
+
return self.config_dir/'trust'/'root.json'
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def service_keys(self):
|
|
86
|
+
return self.server.service_keys_dir
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def root_private_dir(self):
|
|
90
|
+
return root_private_dir(self.config_dir)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def repositories_dir(self):
|
|
94
|
+
return self.server.repositories_dir
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_settings(config_dir=Path('/etc/msgd')):
|
|
98
|
+
config_dir=Path(config_dir)
|
|
99
|
+
path=server_config_file(config_dir)
|
|
100
|
+
require(path.is_file(),'configuration_missing')
|
|
101
|
+
data=tomllib.loads(path.read_text())
|
|
102
|
+
require(set(data)<={'server','storage','limits','plugins','tools','recovery','identity','money','hosting'},'unknown_configuration_section')
|
|
103
|
+
identity=data.get('identity',{})
|
|
104
|
+
require(isinstance(identity,dict) and set(identity)<={'credential_delivery_recovery_window'},
|
|
105
|
+
'unknown_identity_configuration')
|
|
106
|
+
window=identity.get('credential_delivery_recovery_window','15m')
|
|
107
|
+
require(isinstance(window,str) and len(window)<=3 and
|
|
108
|
+
re.fullmatch(r'[1-9][0-9]*m',window) is not None,
|
|
109
|
+
'invalid_credential_delivery_recovery_window')
|
|
110
|
+
window_minutes=int(window[:-1])
|
|
111
|
+
require(1<=window_minutes<=60,'invalid_credential_delivery_recovery_window')
|
|
112
|
+
hosting=data.get('hosting',{})
|
|
113
|
+
require(isinstance(hosting,dict) and set(hosting)<={'base_capacity_bytes','recovery_marker','content_group_read'},
|
|
114
|
+
'unknown_hosting_configuration')
|
|
115
|
+
hosting_base=hosting.get('base_capacity_bytes',10*1024*1024)
|
|
116
|
+
require(type(hosting_base) is int and 0 < hosting_base <= 1024**4,
|
|
117
|
+
'invalid_hosting_capacity')
|
|
118
|
+
content_group_read=hosting.get('content_group_read',False)
|
|
119
|
+
require(type(content_group_read) is bool,'invalid_content_group_read')
|
|
120
|
+
recovery_marker=hosting.get('recovery_marker')
|
|
121
|
+
require(recovery_marker is None or (isinstance(recovery_marker,str) and
|
|
122
|
+
Path(recovery_marker).is_absolute() and '..' not in Path(recovery_marker).parts and
|
|
123
|
+
not any(ord(c)<32 for c in recovery_marker)), 'invalid_hosting_recovery_marker')
|
|
124
|
+
money=data.get('money',{})
|
|
125
|
+
require(isinstance(money,dict) and set(money)<=set(MoneyConfig.__dataclass_fields__),
|
|
126
|
+
'unknown_money_configuration')
|
|
127
|
+
fixed={'enabled':True,'currency_id':'primary','scale':6,
|
|
128
|
+
'transfer_fee':0,'allow_overdraft':False}
|
|
129
|
+
for name,expected in fixed.items():
|
|
130
|
+
value=money.get(name,expected)
|
|
131
|
+
require(type(value) is type(expected) and value==expected,
|
|
132
|
+
'invalid_money_configuration')
|
|
133
|
+
display_name=money.get('display_name','MSG')
|
|
134
|
+
code=money.get('code','MSG')
|
|
135
|
+
require(type(display_name) is str and 1<=len(display_name)<=80 and
|
|
136
|
+
display_name==display_name.strip() and all(ord(char)>=32 for char in display_name),
|
|
137
|
+
'invalid_money_display_name')
|
|
138
|
+
require(type(code) is str and re.fullmatch(r'[A-Z][A-Z0-9]{1,15}',code) is not None,
|
|
139
|
+
'invalid_money_code')
|
|
140
|
+
server=data.get('server',{})
|
|
141
|
+
require(set(server)<={'service_url','listen','port','public_web_origin','temporary_ttl','transfer_ttl'},
|
|
142
|
+
'unknown_server_configuration')
|
|
143
|
+
service=server.get('service_url','https://msg.lmm.best')
|
|
144
|
+
require(isinstance(service,str),'invalid_service_url')
|
|
145
|
+
service=service.rstrip('/')
|
|
146
|
+
url=urlsplit(service)
|
|
147
|
+
require(url.scheme in {'https','http'} and url.hostname and not url.username and not url.password and not url.query and not url.fragment and not url.path,
|
|
148
|
+
'invalid_service_url')
|
|
149
|
+
require(type(server.get('port',8042)) is int and 1<=server.get('port',8042)<=65535,'invalid_listen_port')
|
|
150
|
+
require(isinstance(server.get('listen','127.0.0.1'),str) and server.get('listen','127.0.0.1'),'invalid_listen_address')
|
|
151
|
+
for key,default in (('temporary_ttl',3600),('transfer_ttl',86400)):
|
|
152
|
+
require(type(server.get(key,default)) is int and server.get(key,default)>0,'invalid_ttl')
|
|
153
|
+
store=data.get('storage',{})
|
|
154
|
+
require(set(store)<={'postgres_dsn','valkey_url','content','repositories','blobs','staging','service_keys'},
|
|
155
|
+
'unknown_storage_configuration')
|
|
156
|
+
postgres_dsn=store.get('postgres_dsn')
|
|
157
|
+
require(isinstance(postgres_dsn,str) and bool(postgres_dsn.strip()) and not any(ord(c)<32 for c in postgres_dsn),
|
|
158
|
+
'invalid_postgres_dsn')
|
|
159
|
+
# libpq service files keep credentials outside the service configuration.
|
|
160
|
+
if postgres_dsn.startswith(('postgresql://','postgres://')):
|
|
161
|
+
try:
|
|
162
|
+
pg_url=urlsplit(postgres_dsn)
|
|
163
|
+
require(bool(pg_url.hostname) and not pg_url.fragment,'invalid_postgres_dsn')
|
|
164
|
+
except ValueError:
|
|
165
|
+
require(False,'invalid_postgres_dsn')
|
|
166
|
+
else:
|
|
167
|
+
require(postgres_dsn.startswith('service=') and len(postgres_dsn.split())==1 and
|
|
168
|
+
len(postgres_dsn)>len('service='),'invalid_postgres_dsn')
|
|
169
|
+
valkey_url=store.get('valkey_url')
|
|
170
|
+
if valkey_url is not None:
|
|
171
|
+
require(isinstance(valkey_url,str),'invalid_valkey_url')
|
|
172
|
+
try:
|
|
173
|
+
cache_url=urlsplit(valkey_url)
|
|
174
|
+
require(cache_url.scheme in {'redis','rediss','unix'} and
|
|
175
|
+
(bool(cache_url.hostname) if cache_url.scheme!='unix' else bool(cache_url.path)) and
|
|
176
|
+
not cache_url.fragment,'invalid_valkey_url')
|
|
177
|
+
except ValueError:
|
|
178
|
+
require(False,'invalid_valkey_url')
|
|
179
|
+
content_dir=Path(store.get('content','/var/lib/msgd/git/content'))
|
|
180
|
+
# Existing installations with an explicit old content path keep their old
|
|
181
|
+
# sibling repository and embedded blob locations until an operator migrates.
|
|
182
|
+
modern_layout=content_dir.name=='content' and content_dir.parent.name=='git'
|
|
183
|
+
repository_default=(content_dir.parent/'repos' if modern_layout else
|
|
184
|
+
content_dir.parent/'repositories')
|
|
185
|
+
blob_default=(content_dir.parent.parent/'blobs'/'sha256' if modern_layout else
|
|
186
|
+
content_dir/'binary')
|
|
187
|
+
service_keys_default=(config_dir/'service' if (config_dir/'service').exists() and
|
|
188
|
+
not modern_layout else
|
|
189
|
+
(content_dir.parent.parent if modern_layout else content_dir.parent)/'service')
|
|
190
|
+
require(all(isinstance(store.get(key,str(default)),str) and
|
|
191
|
+
Path(store.get(key,str(default))).is_absolute()
|
|
192
|
+
for key,default in (('content',content_dir),('repositories',repository_default),
|
|
193
|
+
('blobs',blob_default),('staging',Path('/var/lib/msgd/transfers/staging')),
|
|
194
|
+
('service_keys',service_keys_default))),
|
|
195
|
+
'storage_paths_must_be_absolute')
|
|
196
|
+
limits=data.get('limits',{})
|
|
197
|
+
require(set(limits)<={'request_bytes','response_bytes','path_bytes','part_bytes'},'unknown_limit')
|
|
198
|
+
for value in limits.values():
|
|
199
|
+
require(type(value) is int and value>=256,'invalid_limit')
|
|
200
|
+
mail_file=config_dir/'mail.toml'
|
|
201
|
+
mail=None
|
|
202
|
+
if mail_file.exists():
|
|
203
|
+
raw=tomllib.loads(mail_file.read_text())
|
|
204
|
+
require(set(raw)<={'enabled','host','port','tls','sender','credential_file'},'unknown_mail_configuration')
|
|
205
|
+
if raw.get('enabled',False):
|
|
206
|
+
require(raw.get('tls') in {'starttls','tls'},'mail_tls_required')
|
|
207
|
+
mail=MailConfig(enabled=True,host=raw['host'],port=raw.get('port',587),tls=raw['tls'],sender=raw['sender'],
|
|
208
|
+
credential_file=Path(raw['credential_file']) if raw.get('credential_file') else None)
|
|
209
|
+
recovery=data.get('recovery',{})
|
|
210
|
+
require(isinstance(recovery,dict) and set(recovery)<={'custodians'},'unknown_recovery_configuration')
|
|
211
|
+
raw_custodians=recovery.get('custodians',[])
|
|
212
|
+
require(isinstance(raw_custodians,list) and len(raw_custodians)<=16,'invalid_recovery_custodians')
|
|
213
|
+
custodians=[]
|
|
214
|
+
for item in raw_custodians:
|
|
215
|
+
require(isinstance(item,dict) and set(item)<={'id','name','recipient','description','policy_ref'},
|
|
216
|
+
'unknown_recovery_custodian_field')
|
|
217
|
+
require(all(name in item for name in ('id','name','recipient')),
|
|
218
|
+
'invalid_recovery_custodian')
|
|
219
|
+
require(type(item['id']) is str and re.fullmatch(r'[a-z][a-z0-9-]{1,63}',item['id']) is not None and
|
|
220
|
+
type(item['name']) is str and 1<=len(item['name'])<=100,
|
|
221
|
+
'invalid_recovery_custodian')
|
|
222
|
+
require(all(value is None or (type(value) is str and len(value)<=500)
|
|
223
|
+
for value in (item.get('description'),item.get('policy_ref'))),
|
|
224
|
+
'invalid_recovery_custodian')
|
|
225
|
+
public=public_from_recipient(item['recipient'])
|
|
226
|
+
custodians.append(RecoveryCustodian(id=item['id'],name=item['name'],
|
|
227
|
+
recipient=item['recipient'],fingerprint=encryption_key_id(public),
|
|
228
|
+
description=item.get('description'),policy_ref=item.get('policy_ref')))
|
|
229
|
+
require(len({item.id for item in custodians})==len(custodians),'duplicate_recovery_custodian')
|
|
230
|
+
require(set(data.get('plugins',{}))<={'enabled'},'unknown_plugin_configuration')
|
|
231
|
+
plugins=tuple(data.get('plugins',{}).get('enabled',('identity','content','discussion','communication','discovery','achievements','recovery','sharing','money','offers','store','bounty','orders','delivery','transfer','extensions','system','batch')))
|
|
232
|
+
require(all(isinstance(name,str) for name in plugins) and len(set(plugins))==len(plugins),'invalid_plugin_list')
|
|
233
|
+
require('identity' in plugins,'identity_plugin_required')
|
|
234
|
+
tools=data.get('tools',{})
|
|
235
|
+
require(set(tools)<={'isolation','timeout_ms','max_response_bytes','methods','ports'},'unknown_tool_configuration')
|
|
236
|
+
require(tools.get('isolation','bubblewrap')=='bubblewrap','unsafe_tool_worker')
|
|
237
|
+
for name,default in (('timeout_ms',10000),('max_response_bytes',4194304)):
|
|
238
|
+
require(type(tools.get(name,default)) is int and tools.get(name,default)>0,'invalid_tool_limit')
|
|
239
|
+
require(isinstance(tools.get('methods',[]),(list,tuple)) and all(m in {'GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'} for m in tools.get('methods',[])),'invalid_tool_methods')
|
|
240
|
+
require(isinstance(tools.get('ports',[]),(list,tuple)) and all(type(p) is int and 1<=p<=65535 for p in tools.get('ports',[])),'invalid_tool_ports')
|
|
241
|
+
public_web=server.get('public_web_origin')
|
|
242
|
+
if public_web is not None:
|
|
243
|
+
require(isinstance(public_web,str),'invalid_hosting_origin')
|
|
244
|
+
hosted=urlsplit(public_web)
|
|
245
|
+
require(hosted.scheme in {'https','http'} and hosted.hostname and not hosted.username and not hosted.password and not hosted.path and not hosted.query and not hosted.fragment,'invalid_hosting_origin')
|
|
246
|
+
require(public_web is None or public_web.rstrip('/')==service.rstrip('/'),
|
|
247
|
+
'hosting_origin_must_match_service')
|
|
248
|
+
return Settings(server=ServerConfig(config_dir=config_dir,
|
|
249
|
+
postgres_dsn=postgres_dsn,valkey_url=valkey_url,
|
|
250
|
+
content_dir=content_dir,
|
|
251
|
+
repositories_dir=Path(store.get('repositories',repository_default)),
|
|
252
|
+
blob_dir=Path(store.get('blobs',blob_default)),
|
|
253
|
+
staging_dir=Path(store.get('staging','/var/lib/msgd/transfers/staging')),plugins=plugins,
|
|
254
|
+
service_keys_dir=Path(store.get('service_keys',service_keys_default)),
|
|
255
|
+
limits=TransportLimits(max_request_bytes=limits.get('request_bytes',1048576),
|
|
256
|
+
max_response_bytes=limits.get('response_bytes',1048576),max_path_bytes=limits.get('path_bytes',8192),
|
|
257
|
+
encodings=frozenset({'j','gz'})),mail=mail),service_url=service,listen=server.get('listen','127.0.0.1'),
|
|
258
|
+
port=server.get('port',8042),public_web_origin=public_web,
|
|
259
|
+
temporary_ttl=server.get('temporary_ttl',3600),transfer_ttl=server.get('transfer_ttl',86400),
|
|
260
|
+
credential_delivery_recovery_window=window_minutes*60,
|
|
261
|
+
max_part_bytes=limits.get('part_bytes',65536),tool_timeout_ms=tools.get('timeout_ms',10000),
|
|
262
|
+
tool_max_response_bytes=tools.get('max_response_bytes',4194304),
|
|
263
|
+
tool_methods=tuple(tools.get('methods',('GET','HEAD'))),tool_ports=tuple(tools.get('ports',(80,443))),
|
|
264
|
+
recovery_custodians=tuple(custodians),
|
|
265
|
+
money=MoneyConfig(display_name=display_name,code=code),
|
|
266
|
+
hosting_base_capacity_bytes=hosting_base,
|
|
267
|
+
hosting_recovery_marker=Path(recovery_marker) if recovery_marker is not None else None,
|
|
268
|
+
hosting_content_group_read=content_group_read)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def write_example(config_dir,data_dir,service_url='https://msg.lmm.best',*,postgres_dsn='service=msgd',valkey_url=None):
|
|
272
|
+
"""Local install helper: writes no private key or default PIN."""
|
|
273
|
+
config_dir,data_dir=Path(config_dir),Path(data_dir)
|
|
274
|
+
config_dir.mkdir(parents=True,exist_ok=True)
|
|
275
|
+
path=server_config_file(config_dir)
|
|
276
|
+
if not path.exists():
|
|
277
|
+
path.write_text(f'''[server]
|
|
278
|
+
service_url = {json.dumps(service_url)}
|
|
279
|
+
listen = "127.0.0.1"
|
|
280
|
+
port = 8042
|
|
281
|
+
|
|
282
|
+
[identity]
|
|
283
|
+
credential_delivery_recovery_window = "15m"
|
|
284
|
+
|
|
285
|
+
[money]
|
|
286
|
+
enabled = true
|
|
287
|
+
currency_id = "primary"
|
|
288
|
+
display_name = "MSG"
|
|
289
|
+
code = "MSG"
|
|
290
|
+
scale = 6
|
|
291
|
+
transfer_fee = 0
|
|
292
|
+
allow_overdraft = false
|
|
293
|
+
|
|
294
|
+
[hosting]
|
|
295
|
+
base_capacity_bytes = 10485760
|
|
296
|
+
content_group_read = false
|
|
297
|
+
|
|
298
|
+
[storage]
|
|
299
|
+
postgres_dsn = {json.dumps(postgres_dsn)}
|
|
300
|
+
{f'valkey_url = {json.dumps(valkey_url)}' if valkey_url is not None else '# valkey_url = "redis://127.0.0.1:6379/0"'}
|
|
301
|
+
content = {json.dumps(str(data_dir/"git"/"content"))}
|
|
302
|
+
repositories = {json.dumps(str(data_dir/"git"/"repos"))}
|
|
303
|
+
blobs = {json.dumps(str(data_dir/"blobs"/"sha256"))}
|
|
304
|
+
staging = {json.dumps(str(data_dir/"transfers"/"staging"))}
|
|
305
|
+
service_keys = {json.dumps(str(data_dir/"service"))}
|
|
306
|
+
|
|
307
|
+
[limits]
|
|
308
|
+
request_bytes = 1048576
|
|
309
|
+
response_bytes = 1048576
|
|
310
|
+
path_bytes = 8192
|
|
311
|
+
part_bytes = 65536
|
|
312
|
+
|
|
313
|
+
[tools]
|
|
314
|
+
isolation = "bubblewrap"
|
|
315
|
+
methods = ["GET", "HEAD"]
|
|
316
|
+
ports = [80, 443]
|
|
317
|
+
''')
|
|
318
|
+
return load_settings(config_dir)
|