Skip to content
Closed
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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
- name: Install required recovery and ingress test tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq age nginx openssl git-lfs
sudo apt-get install -y -qq age nginx openssl git-lfs acl
age --version
nginx -v
openssl version
Expand Down
79 changes: 79 additions & 0 deletions .github/workflows/hosting-readonly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Hosting read-only role
on:
push:
branches: ['fix/hosting-readonly-*']
pull_request:
paths:
- 'src/msg/hosting_runtime.py'
- 'src/msg/composition.py'
- 'src/msg/application.py'
- 'src/msg/daemon.py'
- 'src/msg/storage/**'
- 'src/msg/security/**'
- 'src/msg/extensions/hosting.py'
- 'tests/test_hosting_readonly*.py'
- 'deploy/msgd-hosting.service'
- '.github/workflows/hosting-readonly.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: hosting-readonly-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
hosting-role:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: msgtest
POSTGRES_PASSWORD: msgtest
POSTGRES_DB: postgres
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U msgtest"
--health-interval 10s --health-timeout 5s --health-retries 5
env:
MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.15'
allow-prereleases: true
cache: pip
- name: Record tested source and runtime
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
mkdir -p artifacts
python - <<'PY'
import json, os, pathlib, platform, subprocess
def git(*args):
return subprocess.check_output(['git', *args], text=True).strip()
pathlib.Path('artifacts/source.json').write_text(json.dumps({
'head': os.environ['HEAD_SHA'], 'checkout': git('rev-parse', 'HEAD'),
'tree': git('rev-parse', 'HEAD^{tree}'), 'python': platform.python_version(),
'run_id': os.environ['GITHUB_RUN_ID'], 'attempt': os.environ['GITHUB_RUN_ATTEMPT'],
'event': os.environ['GITHUB_EVENT_NAME'],
}, indent=2) + '\n')
PY
- name: Install isolated prerequisites
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq age git-lfs acl
python -m pip install -e '.[dev]'
- name: Verify read-only role and existing hosting contracts
run: >-
python -m pytest tests/test_hosting_readonly*.py
tests/test_hosting_same_origin.py tests/test_cli_hosting.py
--junitxml=artifacts/hosting.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: hosting-readonly
path: artifacts/
17 changes: 10 additions & 7 deletions deploy/msgd-hosting.service
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
[Unit]
Description=msg.lmm.best separate-origin static hosting
Description=msg.lmm.best read-only static hosting
After=network.target

[Service]
Type=simple
User=msgd
Group=msgd
WorkingDirectory=/var/lib/msgd
ExecStart=/opt/msgd/venv/bin/msgd hosting --listen 127.0.0.1 --port 8043
User=msgd-hosting
Group=msgd-hosting
WorkingDirectory=/
ExecStart=/opt/msgd/venv/bin/msgd --config-dir /etc/msgd-hosting hosting --listen 127.0.0.1 --port 8043
Restart=on-failure
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/msgd
InaccessiblePaths=/var/lib/msgd-root /etc/msgd/root
ReadOnlyPaths=/etc/msgd-hosting /var/lib/msgd/git/content /var/lib/msgd/blobs/sha256
InaccessiblePaths=/var/lib/msgd-root /etc/msgd /var/lib/msgd/service -/var/lib/msgd/transfers -/var/lib/msgd/git/repos
CapabilityBoundingSet=
RestrictSUIDSGID=true
LockPersonality=true

[Install]
WantedBy=multi-user.target
25 changes: 25 additions & 0 deletions docs/HOSTING_READONLY_PROGRESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Hosting 只读角色推进笔记

Refs #161 #80 #83 #85;PR #174,分支 `fix/hosting-readonly-161-20260929`。基线 main `261c8816cd581deaf69fc0c810b8fa6dad5ead5e` / tree `6171f6041ffd0addaf72eadd35acf562f478281f`。协调登记 #83 comment5887036549。

## 范围与实现

独立 HostingRuntime 只读装配、必要 PostgreSQL/GitContentStore 只读模式、部署模板与测试。共用原注册表安装顺序,保留契约元数据,同时去掉 operation 和 manifest 上的可执行回调;仍用原认证、当前授权与托管读取,不构造 Application/执行器/签名器或读取服务私钥。启动只核验现有 schema、root、release 来源和恢复隔离,不迁移、不补目录、不发布规则。daemon 只窄接线,Application 仅抽共用装配,不动凭据一次性交付和 executor/batch 政策。

#168 市场、#170 Git、#171 客户端、#172 凭据、#155 恢复源分支不覆盖。最终串行组合必须刷新 main,专项不能替代完整四分片/node-ID/conformance/wheel/sdist。

## 已核验云端证据

测试先行头 `00ba8f79cb25316bc33690f5fde96e2546176b21`,合成 checkout `03279378a3f1e003ae1fe7fce7681f4e6efc923a`,tree `4175b4a83312d87b1d3617c6caa8775c76cc8095`。run36547405429 / artifact11023705221,Python3.15.0rc2、attempt1,JUnit **7 failed /6 passed /0 errors /0 skipped**。新增7项因缺少只读能力准确失败,现有6项全过。下载 ZIP SHA256 `a13f0ceca4d7fab74d83de71e8254daf52560e4b96bcfa21a1e0de56dad0296d` 与提供方一致;逐 node 与 source.json 已核对。证据评论 #174 comment5887238998。

首版实现 `0f0e13d1fe7d152a9034b4028ae2e1e7e2d06966` / tree `6c34817242491b2631899fd3cce7dba69d7915d8` 的 run36548624138 出现12个 fixture error:PostgresMetadataStore 遗漏 require 导入。此轮不作为功能通过或红基线证据。补丁 `b1ab06c723a3bacd6c1a4ee8abddb54cbf4be31c` / tree `667462eeb5968a8728d57960e988f7972fcf3fbb` 已修正导入并触发 run36549575056;最终结论仍待真实报告。

## 本次补充验收

增加真实签名 private preview/ACL撤回(含 HEAD、Range、ETag)、错误 Host、不暴露 schema、持久/文件/悬空符号链接 quarantine、缺 public trust/schema 不修复、DB额外写权限/SET ROLE/SECURITY DEFINER 拒绝。

真实 UID65534 子进程不能读 fixture online.key 或写业务文件,但必须读公开页面与后续新发布的文本和二进制 preview。只在发布前配置内容组权限,发布后禁止靠 chmod 修复用例。已发现原 durable_write/mkstemp 的0600可能使新文件不可读,将以云端结果定位;不能全局改 durable_write 默认值(该工具也写秘密),内容组共享需显式配置。

工作流补齐 acl 依赖,完整 CI 只增加此工具,不删测试、不改四分片或 gate。项目执行全部在 Actions;本地只做源码文本/静态审查及下载证据读取。辅助 git 补丁生成仅独立 ci 分支,产品提交不含辅助脚本。

尚未合并或关闭 #161。没有生产部署、权限变更、Root/PIN、资金、真实外发、备份销毁或恢复 promotion。#64/#65/#84 等现场材料缺失不通过本项关闭。
17 changes: 3 additions & 14 deletions src/msg/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
from pathlib import Path
import hashlib
import hmac
import importlib

from msg.constants import ROOT_SPACE, ROOT_SUBJECT
from msg.composition import install_contracts
from msg.core.codec import loads, decode, unb64, b64, canonical, wire
from msg.core.cursors import CursorCodec
from msg.core.errors import Failure, require
Expand All @@ -18,7 +18,7 @@
from msg.core.registry import Registry
from msg.security.authentication import AuthenticationService
from msg.security.authorization import AuthorizationService
from msg.security.capabilities import install_capabilities, primary_ceiling, base_grants, temporary_ceiling
from msg.security.capabilities import primary_ceiling, base_grants, temporary_ceiling
from msg.security.certificates import CertificateValidator
from msg.security.crypto import Ed25519Signer
from msg.security.quarantine import active as quarantine_active
Expand All @@ -40,18 +40,7 @@ def __init__(self,settings, *, clock=None, selftest_run_id=None):
self.contents=None
self.executor=None
self._loaded=False
# Plugins are installed code, never resources, posts, or configuration expressions.
implemented=('identity','content','discussion','communication','discovery','achievements','recovery','sharing','money','offers','store','bounty','orders','delivery')
configured=set(settings.server.plugins)
require(configured<=set(implemented)|{'transfer','extensions','system','batch'},'unknown_plugin')
for plugin in implemented:
if plugin in configured:
importlib.import_module('msg.plugins.'+plugin).install(self)
for plugin in ('transfer','extensions','system','batch'):
if plugin in configured:
importlib.import_module('msg.plugins.'+plugin).install(self)
install_capabilities(self.registry)
self.registry.freeze()
install_contracts(self)

def primary_ceiling(self):
return primary_ceiling(self.registry,self.default_scope())
Expand Down
24 changes: 24 additions & 0 deletions src/msg/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,30 @@ async def sync_system_sources(tx,contents,now, *, source_root=None,namespace_roo
require(not missing,'system_source_deleted_requires_migration',details={'paths':sorted(missing)})


async def require_current_system_sources(tx, contents):
"""Check an installed release without migrations, publication or repair."""
root = Path(system_source_root())
require(tx.one('SELECT COUNT(*) FROM system_sources')[0] == len(RULE_SPECS),
'hosting_release_not_ready')
for rule_id, rid, _, _, default_path, max_bytes in RULE_SPECS:
relative = SOURCE_PATH_OVERRIDES.get(rule_id, default_path)
raw = (root/relative).read_bytes()
header = SOURCE_HEADER.match(raw.decode('utf-8'))
require(header is not None and header.group(1) == rule_id, 'system_source_invalid_header')
version = int(header.group(2))
row = tx.one('SELECT source_path,rule_id,source_kind,source_version,source_digest,revision_id '
'FROM system_sources WHERE resource_id=?', (rid,))
require(row is not None and row[:5] == ('docs/system/'+relative, rule_id, 'release',
version, digest(raw)), 'hosting_release_not_ready')
resource = await tx.resource(rid)
require(resource.revision == row[5], 'hosting_release_not_ready')
revision = await tx.revision(ResourceRef(id=rid, revision=resource.revision))
require(revision.source_kind == 'release' and revision.source_version == version and
revision.source_digest == digest(raw) and
await contents.read_bytes(revision.content, limit=max_bytes) == raw,
'hosting_release_not_ready')


def manifest():
return loads(files('msg.data').joinpath('bootstrap.json').read_bytes())

Expand Down
21 changes: 21 additions & 0 deletions src/msg/composition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""One trusted registration sequence for executable and metadata-only roles."""
import importlib

from msg.core.errors import require
from msg.security.capabilities import install_capabilities


PLUGIN_ORDER = ('identity', 'content', 'discussion', 'communication', 'discovery',
'achievements', 'recovery', 'sharing', 'money', 'offers', 'store',
'bounty', 'orders', 'delivery', 'transfer', 'extensions', 'system', 'batch')


def install_contracts(target):
# Installers declare contracts; they neither initialize storage nor run jobs.
configured = set(target.settings.server.plugins)
require(configured <= set(PLUGIN_ORDER), 'unknown_plugin')
for plugin in PLUGIN_ORDER:
if plugin in configured:
importlib.import_module('msg.plugins.' + plugin).install(target)
install_capabilities(target.registry)
target.registry.freeze()
15 changes: 12 additions & 3 deletions src/msg/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ def load_application(directory):
return Application(load_settings(directory))


def load_hosting(directory):
from msg.hosting_runtime import HostingRuntime
from msg.config import load_settings
return HostingRuntime(load_settings(directory))


async def worker_loop(app, *, once=False):
from msg.workers.effects import EffectWorker
from msg.workers.maintenance import run_maintenance
Expand Down Expand Up @@ -208,7 +214,8 @@ def main(argv=None):
if args.command=='ssh-session':
from msg.extensions.ssh import forced_session
return asyncio.run(forced_session(args.config_dir,args.credential))
app=load_application(args.config_dir)
app=(load_hosting(args.config_dir) if args.command=='hosting'
else load_application(args.config_dir))
if args.command=='backup':
from msg.admin.backups import backup
async def save():
Expand All @@ -230,8 +237,10 @@ async def save():
@asynccontextmanager
async def lifespan(asgi):
await app.load()
yield
await app.close()
try:
yield
finally:
await app.close()
asgi=hosting_app(app);asgi.router.lifespan_context=lifespan
uvicorn.run(asgi,host=args.listen,port=args.port,access_log=False,ws='none')
else:
Expand Down
2 changes: 2 additions & 0 deletions src/msg/extensions/hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ async def serve_hosted(service,request):
return None
site_path='/'+parts[0]+'/'+parts[1]
try:
marker=service.settings.config_dir/'recovery-drill.json'
require(not marker.exists() and not marker.is_symlink(),'recovery_quarantined')
async with service.metadata.transaction(write=False) as tx:
try:
rid=await tx.resolve(site_path)
Expand Down
Loading
Loading