506 lines
19 KiB
Python
Executable File
506 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
OpenKE Firmware Packager for Ender-3 V3 KE and Creality Nebula Pad.
|
|
|
|
Packages OpenKE kernel (xImage) and rootfs (rootfs.squashfs) along with the board-specific
|
|
RTOS firmware (zero.bin) into a stock CrealityOS-supported encrypted 7z OTA (.img) update.
|
|
|
|
CRITICAL REQUIREMENT (from /etc/ota_bin/local_ota_update.sh):
|
|
OTA_UNZIP_FILE_NAME=${OTA_FILE_NAME%.img*}
|
|
ota_site=${OTA_FILE_PATH}/${OTA_UNZIP_FILE_NAME}
|
|
ota_site_config=$ota_site/ota_config.in
|
|
The top-level folder inside the 7z archive MUST EXACTLY MATCH the .img filename (minus .img),
|
|
and the version must match Creality's dotted versioning (e.g. 1.1.0.34 > 1.1.0.30).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Dict, List, Sequence, Tuple
|
|
|
|
# Fixed hardware partition limits for X2000 Creality display boards
|
|
KERNEL_PART_MAX_BYTES = 8 * 1024 * 1024 # 8 MiB (mmcblk0p5 / mmcblk0p6)
|
|
ROOTFS_PART_MAX_BYTES = 500 * 1024 * 1024 # 500 MiB (mmcblk0p7 / mmcblk0p8)
|
|
RTOS_PART_MAX_BYTES = 4 * 1024 * 1024 # 4 MiB (mmcblk0p3 / mmcblk0p4)
|
|
|
|
CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
|
|
|
DEFAULT_VERSION = "1.1.0.34"
|
|
DEFAULT_OPENKE_ARTIFACTS = Path("..") / "OpenKE" / "artifacts" / "buildroot-halley5-v30-image"
|
|
|
|
# Target profiles
|
|
# NOTE: Filenames must follow Creality's native pattern so master-server and local_ota_update.sh
|
|
# recognize them and find the extracted ota_config.in.
|
|
TARGET_PROFILES: Dict[str, Dict[str, str]] = {
|
|
"ke": {
|
|
"board_name": "F005",
|
|
"output_pattern": "Ender-3_V3_KE_F005_ota_img_V{version}.img",
|
|
"zero_bin": "assets/zero.bin",
|
|
"description": "Creality Ender-3 V3 KE (Target F005)",
|
|
},
|
|
"nebula": {
|
|
"board_name": "NEBULA",
|
|
"output_pattern": "NEBULA_ota_img_V{version}.img",
|
|
"zero_bin": "assets/zero_nebula.bin",
|
|
"description": "Creality Nebula Smart Kit / Nebula Pad (Target NEBULA)",
|
|
},
|
|
}
|
|
|
|
|
|
def derive_creality_password(board_name: str, salt: str = "cxswfile") -> str:
|
|
"""
|
|
Derives Creality's standard MD5-crypt password for a given board:
|
|
mkpasswd -m md5 "${BOARD_NAME}C3_7e_bz" -S cxswfile
|
|
Implemented in pure Python to eliminate platform dependencies.
|
|
"""
|
|
key = f"{board_name}C3_7e_bz"
|
|
magic = "$1$"
|
|
pw = key.encode("utf-8")
|
|
s = salt.encode("utf-8")
|
|
|
|
ctx = hashlib.md5(pw + magic.encode("utf-8") + s)
|
|
alt = hashlib.md5(pw + s + pw).digest()
|
|
|
|
for i in range(len(pw), 0, -16):
|
|
ctx.update(alt[: min(i, 16)])
|
|
|
|
i = len(pw)
|
|
while i > 0:
|
|
if i & 1:
|
|
ctx.update(b"\x00")
|
|
else:
|
|
ctx.update(pw[:1])
|
|
i >>= 1
|
|
|
|
digest = ctx.digest()
|
|
|
|
for idx in range(1000):
|
|
c = hashlib.md5()
|
|
if idx & 1:
|
|
c.update(pw)
|
|
else:
|
|
c.update(digest)
|
|
if idx % 3:
|
|
c.update(s)
|
|
if idx % 7:
|
|
c.update(pw)
|
|
if idx & 1:
|
|
c.update(digest)
|
|
else:
|
|
c.update(pw)
|
|
digest = c.digest()
|
|
|
|
b64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
|
|
def to64(v: int, n: int) -> str:
|
|
ret = []
|
|
for _ in range(n):
|
|
ret.append(b64[v & 0x3F])
|
|
v >>= 6
|
|
return "".join(ret)
|
|
|
|
res = magic + salt + "$"
|
|
d = digest
|
|
res += to64((d[0] << 16) | (d[6] << 8) | d[12], 4)
|
|
res += to64((d[1] << 16) | (d[7] << 8) | d[13], 4)
|
|
res += to64((d[2] << 16) | (d[8] << 8) | d[14], 4)
|
|
res += to64((d[3] << 16) | (d[9] << 8) | d[15], 4)
|
|
res += to64((d[4] << 16) | (d[10] << 8) | d[5], 4)
|
|
res += to64(d[11], 2)
|
|
return res
|
|
|
|
|
|
def compute_md5(path: Path) -> str:
|
|
"""Compute MD5 digest of a file in 4MB streaming chunks."""
|
|
digest = hashlib.md5()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def compute_sha256(path: Path) -> str:
|
|
"""Compute SHA256 digest of a file in 4MB streaming chunks."""
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def split_into_chunks(src_path: Path, dest_dir: Path, base_name: str) -> Tuple[str, List[str]]:
|
|
"""
|
|
Split payload into 1 MiB chunks per Creality OTA specification:
|
|
<base_name>.<index:04d>.<prev_md5>
|
|
where index 0000 uses the full file MD5 as prev_md5.
|
|
Returns (full_file_md5, list_of_chunk_md5s).
|
|
"""
|
|
full_md5 = compute_md5(src_path)
|
|
chunk_md5s: List[str] = []
|
|
|
|
with src_path.open("rb") as handle:
|
|
index = 0
|
|
prev_md5 = full_md5
|
|
while True:
|
|
data = handle.read(CHUNK_SIZE)
|
|
if not data:
|
|
break
|
|
chunk_md5 = hashlib.md5(data).hexdigest()
|
|
chunk_name = f"{base_name}.{index:04d}.{prev_md5}"
|
|
chunk_path = dest_dir / chunk_name
|
|
chunk_path.write_bytes(data)
|
|
chunk_md5s.append(chunk_md5)
|
|
prev_md5 = chunk_md5
|
|
index += 1
|
|
|
|
return full_md5, chunk_md5s
|
|
|
|
|
|
def verify_manifest(manifest_path: Path, ximage_path: Path, rootfs_path: Path) -> None:
|
|
"""Verify xImage and rootfs.squashfs match build-manifest.txt if present."""
|
|
if not manifest_path.exists():
|
|
print(f"[*] Manifest {manifest_path} not found, skipping manifest verification.")
|
|
return
|
|
|
|
print(f"[*] Verifying inputs against build manifest: {manifest_path}")
|
|
manifest_data = {}
|
|
for line in manifest_path.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if "=" in line and not line.startswith("#"):
|
|
key, val = line.split("=", 1)
|
|
manifest_data[key.strip()] = val.strip()
|
|
|
|
expected_ximage_size = manifest_data.get("xImage_size")
|
|
expected_ximage_sha = manifest_data.get("xImage_sha256")
|
|
expected_rootfs_size = manifest_data.get("rootfs_squashfs_size")
|
|
expected_rootfs_sha = manifest_data.get("rootfs_squashfs_sha256")
|
|
|
|
if expected_ximage_size and ximage_path.stat().st_size != int(expected_ximage_size):
|
|
raise ValueError(
|
|
f"xImage size mismatch: {ximage_path.stat().st_size} bytes vs expected {expected_ximage_size}"
|
|
)
|
|
if expected_ximage_sha:
|
|
actual_ximage_sha = compute_sha256(ximage_path)
|
|
if actual_ximage_sha != expected_ximage_sha:
|
|
raise ValueError(f"xImage SHA256 mismatch: {actual_ximage_sha} != {expected_ximage_sha}")
|
|
|
|
if expected_rootfs_size and rootfs_path.stat().st_size != int(expected_rootfs_size):
|
|
raise ValueError(
|
|
f"rootfs.squashfs size mismatch: {rootfs_path.stat().st_size} bytes vs expected {expected_rootfs_size}"
|
|
)
|
|
if expected_rootfs_sha:
|
|
actual_rootfs_sha = compute_sha256(rootfs_path)
|
|
if actual_rootfs_sha != expected_rootfs_sha:
|
|
raise ValueError(
|
|
f"rootfs.squashfs SHA256 mismatch: {actual_rootfs_sha} != {expected_rootfs_sha}"
|
|
)
|
|
|
|
print(" [+] Manifest verification passed: exact size & SHA256 match.")
|
|
|
|
|
|
def pack_archive(staging_root: Path, archive_name: str, output_img: Path, password: str) -> None:
|
|
"""Create encrypted 7z archive using 7z CLI (if available) or py7zr."""
|
|
output_img.parent.mkdir(parents=True, exist_ok=True)
|
|
if output_img.exists():
|
|
output_img.unlink()
|
|
|
|
has_7z = shutil.which("7z") is not None
|
|
|
|
if has_7z:
|
|
print("[*] Compressing encrypted 7z archive using system 7z CLI (multi-threaded)...")
|
|
cmd = [
|
|
"7z", "a",
|
|
"-t7z",
|
|
f"-p{password}",
|
|
"-mhe=on",
|
|
"-mx=4",
|
|
str(output_img),
|
|
archive_name,
|
|
]
|
|
res = subprocess.run(cmd, cwd=str(staging_root.parent), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
if res.returncode != 0:
|
|
raise RuntimeError(f"7z command failed with code {res.returncode}:\n{res.stdout}")
|
|
else:
|
|
print("[*] System 7z not found. Compressing using Python py7zr (single-threaded)...")
|
|
try:
|
|
import py7zr
|
|
except ImportError:
|
|
raise RuntimeError("Neither 7z CLI nor python 'py7zr' is available. Install 7zip or pip install py7zr.")
|
|
|
|
with py7zr.SevenZipFile(output_img, mode="w", password=password, header_encryption=True) as zf:
|
|
zf.writeall(staging_root, arcname=archive_name)
|
|
|
|
|
|
def verify_archive(archive_path: Path, password: str) -> None:
|
|
"""Verify archive integrity and password decryption."""
|
|
print(f"[*] Verifying archive integrity: {archive_path}")
|
|
has_7z = shutil.which("7z") is not None
|
|
if has_7z:
|
|
cmd = ["7z", "t", f"-p{password}", str(archive_path)]
|
|
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
if res.returncode != 0:
|
|
raise RuntimeError(f"Archive verification failed:\n{res.stderr or res.stdout}")
|
|
else:
|
|
import py7zr
|
|
with py7zr.SevenZipFile(archive_path, mode="r", password=password) as zf:
|
|
if not zf.test():
|
|
raise RuntimeError("Archive validation failed via py7zr.")
|
|
print(" [+] Archive verification passed: 100% valid encrypted 7z envelope.")
|
|
|
|
|
|
def build_openke_ota_image(
|
|
ximage_path: Path,
|
|
rootfs_path: Path,
|
|
zero_path: Path,
|
|
manifest_path: Path | None,
|
|
output_img: Path,
|
|
version: str,
|
|
password: str,
|
|
target_desc: str = "Creality Display",
|
|
) -> Path:
|
|
"""End-to-end packaging function for OpenKE OTA .img."""
|
|
print("=" * 65)
|
|
print(f" OpenKE Firmware Packager: {target_desc}")
|
|
print("=" * 65)
|
|
|
|
if not ximage_path.exists():
|
|
raise FileNotFoundError(f"xImage not found: {ximage_path}")
|
|
if not rootfs_path.exists():
|
|
raise FileNotFoundError(f"rootfs.squashfs not found: {rootfs_path}")
|
|
if not zero_path.exists():
|
|
raise FileNotFoundError(f"zero.bin (RTOS firmware) not found at {zero_path}.")
|
|
|
|
ximage_size = ximage_path.stat().st_size
|
|
rootfs_size = rootfs_path.stat().st_size
|
|
zero_size = zero_path.stat().st_size
|
|
|
|
print("[*] Partition Budget Preflight:")
|
|
ximage_pct = (ximage_size / KERNEL_PART_MAX_BYTES) * 100
|
|
ximage_free = (KERNEL_PART_MAX_BYTES - ximage_size) / (1024 * 1024)
|
|
print(f" - Kernel: {ximage_size:,} B / {KERNEL_PART_MAX_BYTES:,} B ({ximage_pct:.1f}% used, {ximage_free:.2f} MiB free)")
|
|
if ximage_size > KERNEL_PART_MAX_BYTES:
|
|
raise ValueError(f"xImage ({ximage_size} B) exceeds max partition size ({KERNEL_PART_MAX_BYTES} B)!")
|
|
|
|
rootfs_pct = (rootfs_size / ROOTFS_PART_MAX_BYTES) * 100
|
|
rootfs_free = (ROOTFS_PART_MAX_BYTES - rootfs_size) / (1024 * 1024)
|
|
print(f" - Rootfs: {rootfs_size:,} B / {ROOTFS_PART_MAX_BYTES:,} B ({rootfs_pct:.1f}% used, {rootfs_free:.2f} MiB free)")
|
|
if rootfs_size > ROOTFS_PART_MAX_BYTES:
|
|
raise ValueError(f"rootfs.squashfs ({rootfs_size} B) exceeds max partition size ({ROOTFS_PART_MAX_BYTES} B)!")
|
|
|
|
print(f" - RTOS: {zero_size:,} B / {RTOS_PART_MAX_BYTES:,} B")
|
|
if zero_size > RTOS_PART_MAX_BYTES:
|
|
raise ValueError(f"zero.bin ({zero_size} B) exceeds max partition size ({RTOS_PART_MAX_BYTES} B)!")
|
|
|
|
if manifest_path:
|
|
verify_manifest(manifest_path, ximage_path, rootfs_path)
|
|
|
|
# CRITICAL: Creality's local_ota_update.sh expects:
|
|
# OTA_UNZIP_FILE_NAME=${OTA_FILE_NAME%.img*}
|
|
# ota_site=${OTA_FILE_PATH}/${OTA_UNZIP_FILE_NAME}
|
|
# ota_site_config=$ota_site/ota_config.in
|
|
# Therefore the root directory inside the archive MUST exactly equal output_img.stem!
|
|
archive_name = output_img.name
|
|
if archive_name.endswith(".img"):
|
|
archive_name = archive_name[:-4]
|
|
|
|
with tempfile.TemporaryDirectory(prefix="openke_ota_stage_") as tmpdir:
|
|
stage_dir = Path(tmpdir)
|
|
staging_root = stage_dir / archive_name
|
|
version_dir = staging_root / f"ota_v{version}"
|
|
version_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
print(f"[*] Packaging archive layout: {archive_name}/ota_v{version}/")
|
|
print("[*] Slicing and chunking payloads (1 MiB slices):")
|
|
print(" - Slicing xImage...")
|
|
ximage_md5, ximage_chunk_md5s = split_into_chunks(ximage_path, version_dir, "xImage")
|
|
print(f" Total chunks: {len(ximage_chunk_md5s)}, Full MD5: {ximage_md5}")
|
|
|
|
print(" - Slicing rootfs.squashfs...")
|
|
rootfs_md5, rootfs_chunk_md5s = split_into_chunks(rootfs_path, version_dir, "rootfs.squashfs")
|
|
print(f" Total chunks: {len(rootfs_chunk_md5s)}, Full MD5: {rootfs_md5}")
|
|
|
|
print(" - Slicing zero.bin...")
|
|
zero_md5, zero_chunk_md5s = split_into_chunks(zero_path, version_dir, "zero.bin")
|
|
print(f" Total chunks: {len(zero_chunk_md5s)}, Full MD5: {zero_md5}")
|
|
|
|
print("[*] Generating Creality OTA manifests and MD5 sidecars...")
|
|
(staging_root / "ota_config.in").write_text(f"current_version={version}\n", encoding="utf-8", newline="\n")
|
|
(version_dir / f"ota_v{version}.ok").write_bytes(b"\n")
|
|
|
|
ota_update_lines = [
|
|
f"ota_version={version}",
|
|
"",
|
|
"img_type=kernel",
|
|
"img_name=xImage",
|
|
f"img_size={ximage_size}",
|
|
f"img_md5={ximage_md5}",
|
|
"",
|
|
"img_type=rootfs",
|
|
"img_name=rootfs.squashfs",
|
|
f"img_size={rootfs_size}",
|
|
f"img_md5={rootfs_md5}",
|
|
"",
|
|
"img_type=rtos",
|
|
"img_name=zero.bin",
|
|
f"img_size={zero_size}",
|
|
f"img_md5={zero_md5}",
|
|
"",
|
|
]
|
|
(version_dir / "ota_update.in").write_text("\n".join(ota_update_lines) + "\n", encoding="utf-8", newline="\n")
|
|
|
|
(version_dir / f"ota_md5_xImage.{ximage_md5}").write_text("\n".join(ximage_chunk_md5s) + "\n", encoding="utf-8", newline="\n")
|
|
(version_dir / f"ota_md5_rootfs.squashfs.{rootfs_md5}").write_text("\n".join(rootfs_chunk_md5s) + "\n", encoding="utf-8", newline="\n")
|
|
(version_dir / f"ota_md5_zero.bin.{zero_md5}").write_text("\n".join(zero_chunk_md5s) + "\n", encoding="utf-8", newline="\n")
|
|
|
|
pack_archive(staging_root, archive_name, output_img, password)
|
|
|
|
verify_archive(output_img, password)
|
|
|
|
print("=" * 65)
|
|
print(" SUCCESS: OpenKE OTA Image Created Successfully!")
|
|
print(f" Target Image: {output_img}")
|
|
print(f" Size: {output_img.stat().st_size:,} bytes ({output_img.stat().st_size / (1024*1024):.2f} MiB)")
|
|
print(f" Package Ver: {version}")
|
|
print("=" * 65)
|
|
print(" FLASHING INSTRUCTIONS:")
|
|
print(" 1. Format a USB flash drive as FAT32.")
|
|
print(f" 2. Copy '{output_img.name}' to the root of the USB drive.")
|
|
print(" 3. Insert the USB drive into the printer / Nebula Pad front USB port.")
|
|
print(" 4. On the touchscreen: Settings -> Firmware Update -> Local Update.")
|
|
print(" 5. Select the update and confirm. The device will flash OpenKE to")
|
|
print(" Slot 2, point the bootloader to ota:kernel2, and automatically reboot.")
|
|
print(" 6. First boot will seed /usr/data/openke and launch Klipper & Moonraker.")
|
|
print("=" * 65)
|
|
return output_img
|
|
|
|
|
|
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Package OpenKE build artifacts into stock CrealityOS-compatible USB update (.img) packages."
|
|
)
|
|
parser.add_argument(
|
|
"--target",
|
|
choices=["ke", "nebula", "all"],
|
|
default="ke",
|
|
help="Target hardware profile: 'ke' (Ender-3 V3 KE), 'nebula' (Nebula Pad), or 'all' (build both) (default: ke)",
|
|
)
|
|
parser.add_argument(
|
|
"--artifacts-dir",
|
|
type=Path,
|
|
default=None,
|
|
help="Path to OpenKE build artifacts directory (contains xImage, rootfs.squashfs, and build-manifest.txt)",
|
|
)
|
|
parser.add_argument("--ximage", type=Path, default=None, help="Explicit path to OpenKE xImage")
|
|
parser.add_argument("--rootfs", type=Path, default=None, help="Explicit path to OpenKE rootfs.squashfs")
|
|
parser.add_argument("--manifest", type=Path, default=None, help="Explicit path to build-manifest.txt")
|
|
parser.add_argument(
|
|
"--zero-bin",
|
|
type=Path,
|
|
default=None,
|
|
help="Path to RTOS firmware (zero.bin). Defaults to profile asset.",
|
|
)
|
|
parser.add_argument(
|
|
"--version",
|
|
default=DEFAULT_VERSION,
|
|
help=f"Firmware package version recognized by stock updater (default: {DEFAULT_VERSION})",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
default=None,
|
|
help="Output image path (applicable when packaging a single target)",
|
|
)
|
|
parser.add_argument(
|
|
"--password",
|
|
default=None,
|
|
help="Custom Creality 7z encryption password (defaults to derived per-board key)",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
args = parse_args(argv)
|
|
|
|
artifacts_dir = args.artifacts_dir
|
|
if artifacts_dir is None and (args.ximage is None or args.rootfs is None):
|
|
if DEFAULT_OPENKE_ARTIFACTS.exists():
|
|
artifacts_dir = DEFAULT_OPENKE_ARTIFACTS
|
|
|
|
ximage = args.ximage
|
|
rootfs = args.rootfs
|
|
manifest = args.manifest
|
|
|
|
if artifacts_dir:
|
|
artifacts_dir = artifacts_dir.resolve()
|
|
if ximage is None:
|
|
ximage = artifacts_dir / "xImage"
|
|
if rootfs is None:
|
|
rootfs = artifacts_dir / "rootfs.squashfs"
|
|
if manifest is None and (artifacts_dir / "build-manifest.txt").exists():
|
|
manifest = artifacts_dir / "build-manifest.txt"
|
|
|
|
if ximage is None or rootfs is None:
|
|
print("Error: Must provide --artifacts-dir OR both --ximage and --rootfs", file=sys.stderr)
|
|
return 1
|
|
|
|
ximage = ximage.resolve()
|
|
rootfs = rootfs.resolve()
|
|
if manifest:
|
|
manifest = manifest.resolve()
|
|
|
|
version = args.version
|
|
targets_to_build = ["ke", "nebula"] if args.target == "all" else [args.target]
|
|
|
|
if args.output and len(targets_to_build) > 1:
|
|
print("Error: --output cannot be used with --target all (use default paths).", file=sys.stderr)
|
|
return 1
|
|
|
|
for tgt in targets_to_build:
|
|
profile = TARGET_PROFILES[tgt]
|
|
board_name = profile["board_name"]
|
|
desc = profile["description"]
|
|
|
|
password = args.password
|
|
if password is None:
|
|
password = derive_creality_password(board_name)
|
|
|
|
zero_bin = args.zero_bin
|
|
if zero_bin is None:
|
|
script_dir = Path(__file__).parent.parent
|
|
zero_bin = script_dir / profile["zero_bin"]
|
|
zero_bin = zero_bin.resolve()
|
|
|
|
output = args.output
|
|
if output is None:
|
|
out_filename = profile["output_pattern"].format(version=version)
|
|
output = Path("build") / out_filename
|
|
output = output.resolve()
|
|
|
|
try:
|
|
build_openke_ota_image(
|
|
ximage_path=ximage,
|
|
rootfs_path=rootfs,
|
|
zero_path=zero_bin,
|
|
manifest_path=manifest,
|
|
output_img=output,
|
|
version=version,
|
|
password=password,
|
|
target_desc=desc,
|
|
)
|
|
except Exception as exc:
|
|
print(f"\n[!] ERROR ({tgt}): {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|