CYBER-0 initial concept ready
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Combine normalized tool reports into one schema-valid project report."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("inputs", nargs="+", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--schema", type=Path, default=Path("schemas/test-report.schema.json"))
|
||||
args = parser.parse_args()
|
||||
|
||||
reports = [json.loads(path.read_text(encoding="utf-8")) for path in args.inputs]
|
||||
if not reports:
|
||||
parser.error("at least one normalized report is required")
|
||||
|
||||
project = reports[0]["project"]
|
||||
run = reports[0]["run"]
|
||||
results = []
|
||||
raw_artifacts = []
|
||||
for report in reports:
|
||||
if report["project"]["id"] != project["id"]:
|
||||
parser.error("all reports must belong to the same project")
|
||||
tool = report["tool"]
|
||||
for result in report["results"]:
|
||||
combined = dict(result)
|
||||
combined["id"] = f"{tool['id']}:{result['id']}"
|
||||
combined["category"] = f"{tool['name']} | {result.get('category', '')}".rstrip(" |")
|
||||
results.append(combined)
|
||||
raw_artifacts.extend(report.get("raw_artifacts", []))
|
||||
|
||||
counts = dict.fromkeys(("passed", "failed", "errors", "skipped", "review"), 0)
|
||||
for result in results:
|
||||
counter = "errors" if result["status"] == "error" else result["status"]
|
||||
counts[counter] += 1
|
||||
scored = counts["passed"] + counts["failed"]
|
||||
aggregate = {
|
||||
"schema_version": "1.0.0",
|
||||
"run": run,
|
||||
"project": project,
|
||||
"tool": {"id": "combined", "name": "Combined test methodologies", "adapter_version": "1.0.0"},
|
||||
"target": {"id": "project-scope", "type": "test_environment", "groups": []},
|
||||
"summary": {
|
||||
"total": len(results),
|
||||
**counts,
|
||||
"score": round(counts["passed"] / scored * 100, 2) if scored else 0,
|
||||
},
|
||||
"results": results,
|
||||
"raw_artifacts": sorted(set(raw_artifacts)),
|
||||
}
|
||||
|
||||
schema = json.loads(args.schema.read_text(encoding="utf-8"))
|
||||
Draft202012Validator(schema, format_checker=FormatChecker()).validate(aggregate)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(aggregate, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Aggregated {len(reports)} reports with {len(results)} results")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# build-ansible.sh — Build the Ansible Control Node Image
|
||||
#
|
||||
# Produces a Docker image with Ansible + all collections
|
||||
# for Windows, Cisco, VMware, MSSQL, and Linux targets.
|
||||
#
|
||||
# Can be deployed on:
|
||||
# • Docker Swarm / Kubernetes (native)
|
||||
# • Alpine Docker Host on QEMU (docker pull + run)
|
||||
# • Any Linux with Docker
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-ansible.sh # local build
|
||||
# ./scripts/build-ansible.sh --push # build + push to registry
|
||||
# REGISTRY=my-registry ./scripts/build-ansible.sh --push
|
||||
# ───────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
IMAGE_NAME="${IMAGE_NAME:-ansible-node}"
|
||||
DOCKERFILE="${DOCKERFILE:-Dockerfile.ansible}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
TAG="${TAG:-latest}"
|
||||
|
||||
# ── Colour helpers ──────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'; NC='\033[0m'
|
||||
info() { echo -e "${BLUE}[*]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
err() { echo -e "${RED}[✗]${NC} $*"; }
|
||||
|
||||
# ── Build ───────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||
echo -e "${BOLD} Ansible Control Node — Docker Image Builder${NC}"
|
||||
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
FULL_IMAGE="${REGISTRY:+${REGISTRY}/}${IMAGE_NAME}:${TAG}"
|
||||
|
||||
info "Building: ${FULL_IMAGE}"
|
||||
docker build \
|
||||
-t "$FULL_IMAGE" \
|
||||
-f "$PROJECT_DIR/${DOCKERFILE}" \
|
||||
"$PROJECT_DIR"
|
||||
ok "Image built: ${FULL_IMAGE}"
|
||||
|
||||
# ── Size report ─────────────────────────────────────────────
|
||||
echo ""
|
||||
info "Image layers:"
|
||||
docker history "$FULL_IMAGE" --human --no-trunc | head -6
|
||||
echo ""
|
||||
IMAGE_SIZE=$(docker image inspect "$FULL_IMAGE" --format='{{.Size}}' | \
|
||||
awk '{printf "%.0f MB", $1/1024/1024}')
|
||||
ok "Total image size: ${IMAGE_SIZE}"
|
||||
|
||||
# ── Optional: push ──────────────────────────────────────────
|
||||
if [[ "${1:-}" == "--push" ]]; then
|
||||
if [ -z "$REGISTRY" ]; then
|
||||
err "Set REGISTRY env var to push (e.g., REGISTRY=my-registry)"
|
||||
exit 1
|
||||
fi
|
||||
info "Pushing: ${FULL_IMAGE}"
|
||||
docker push "$FULL_IMAGE"
|
||||
ok "Pushed: ${FULL_IMAGE}"
|
||||
fi
|
||||
|
||||
# ── Usage hint ──────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}Usage examples:${NC}"
|
||||
echo ""
|
||||
echo " # Run locally with mounted playbooks:"
|
||||
echo " docker run --rm \\"
|
||||
echo " -v \$(pwd)/playbooks:/ansible/playbooks \\"
|
||||
echo " -v \$(pwd)/inventory.ini:/ansible/inventory/inventory.ini \\"
|
||||
echo " ${FULL_IMAGE} site.yml"
|
||||
echo ""
|
||||
echo " # Pull into Alpine Docker Host:"
|
||||
echo " sshpass -p ansible ssh -p 2222 ansible@localhost \\"
|
||||
echo " docker pull ${FULL_IMAGE}"
|
||||
echo ""
|
||||
echo " # Shell into image:"
|
||||
echo " docker run --rm -it ${FULL_IMAGE} --help"
|
||||
echo ""
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# build-qemu.sh — Dockerfile → Bootable QEMU Disk
|
||||
#
|
||||
# Pipeline:
|
||||
# 1. Build the Docker image (ansible-node)
|
||||
# 2. Export the container rootfs as a tarball
|
||||
# 3. Extract kernel + initramfs for direct -kernel boot
|
||||
# 4. Create an ext4 disk image, label ANSIBLE_ROOT
|
||||
# 5. Populate with rootfs contents
|
||||
# 6. Optionally convert raw → qcow2 (if qemu-img available)
|
||||
#
|
||||
# Output (written to ./output/):
|
||||
# ansible-node.qcow2 (or .raw) — root filesystem disk
|
||||
# vmlinuz-virt — Linux kernel
|
||||
# initramfs-virt — initramfs
|
||||
#
|
||||
# Prerequisites:
|
||||
# docker, sudo, mkfs.ext4, optional: qemu-img
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-qemu.sh # default 2GB
|
||||
# DISK_SIZE_MB=4096 ./scripts/build-qemu.sh # custom size
|
||||
# ───────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
OUTPUT_DIR="$PROJECT_DIR/output"
|
||||
IMAGE_NAME="${IMAGE_NAME:-alpine-docker-host}"
|
||||
DOCKERFILE="${DOCKERFILE:-Dockerfile.alpine-host}"
|
||||
DISK_SIZE_MB="${DISK_SIZE_MB:-2048}"
|
||||
|
||||
# ── Colour helpers ──────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
info() { echo -e "${BLUE}[*]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
err() { echo -e "${RED}[✗]${NC} $*"; }
|
||||
|
||||
# ── Preflight checks ────────────────────────────────────────
|
||||
for cmd in docker sudo mkfs.ext4; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
err "Missing required tool: $cmd"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Banner ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||
echo -e "${BOLD} Ansible Control Node — QEMU Image Builder${NC}"
|
||||
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
info "Image name: ${IMAGE_NAME}"
|
||||
info "Disk size: ${DISK_SIZE_MB}MB"
|
||||
info "Output dir: ${OUTPUT_DIR}"
|
||||
echo ""
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# ── Step 1: Build Docker image ──────────────────────────────
|
||||
info "Step 1/6: Building Docker image '${IMAGE_NAME}' (${DOCKERFILE})..."
|
||||
docker build \
|
||||
-t "$IMAGE_NAME" \
|
||||
-f "$PROJECT_DIR/${DOCKERFILE}" \
|
||||
"$PROJECT_DIR"
|
||||
ok "Docker image built"
|
||||
|
||||
# ── Step 2: Export rootfs ───────────────────────────────────
|
||||
info "Step 2/6: Exporting container rootfs..."
|
||||
ROOTFS_TAR="$PROJECT_DIR/.ansible-node-rootfs.tar"
|
||||
CID=$(docker create "$IMAGE_NAME")
|
||||
docker export "$CID" -o "$ROOTFS_TAR"
|
||||
docker rm "$CID" >/dev/null
|
||||
ROOTFS_SIZE=$(du -sh "$ROOTFS_TAR" | cut -f1)
|
||||
ok "Rootfs exported (${ROOTFS_SIZE})"
|
||||
|
||||
# ── Step 3: Extract kernel + initramfs ──────────────────────
|
||||
info "Step 3/6: Extracting kernel and initramfs..."
|
||||
TMP_BOOT="$(mktemp -d)"
|
||||
tar -xf "$ROOTFS_TAR" -C "$TMP_BOOT" boot/ 2>/dev/null
|
||||
|
||||
# Find kernel/initramfs (handle different naming patterns)
|
||||
KERNEL_SRC=$(find "$TMP_BOOT/boot" -name 'vmlinuz-*' 2>/dev/null | head -1)
|
||||
INITRD_SRC=$(find "$TMP_BOOT/boot" -name 'initramfs-*' 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$KERNEL_SRC" ] || [ -z "$INITRD_SRC" ]; then
|
||||
err "Could not find kernel/initramfs in rootfs."
|
||||
err "Expected files in /boot/ from linux-virt package."
|
||||
ls -la "$TMP_BOOT/boot/" 2>/dev/null || echo "(no /boot directory)"
|
||||
rm -rf "$TMP_BOOT" "$ROOTFS_TAR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KERNEL_NAME=$(basename "$KERNEL_SRC")
|
||||
INITRD_NAME=$(basename "$INITRD_SRC")
|
||||
|
||||
cp "$KERNEL_SRC" "$OUTPUT_DIR/vmlinuz-virt"
|
||||
cp "$INITRD_SRC" "$OUTPUT_DIR/initramfs-virt"
|
||||
rm -rf "$TMP_BOOT"
|
||||
|
||||
ok "Kernel: ${KERNEL_NAME}"
|
||||
ok "Initrd: ${INITRD_NAME}"
|
||||
|
||||
# ── Step 4: Create raw disk image ───────────────────────────
|
||||
info "Step 4/6: Creating disk image (${DISK_SIZE_MB}MB)..."
|
||||
RAW_DISK="$OUTPUT_DIR/ansible-node.raw"
|
||||
dd if=/dev/zero of="$RAW_DISK" bs=1M count="$DISK_SIZE_MB" status=progress 2>/dev/null
|
||||
mkfs.ext4 -q -L ANSIBLE_ROOT "$RAW_DISK"
|
||||
ok "ext4 filesystem created (label: ANSIBLE_ROOT)"
|
||||
|
||||
# ── Step 5: Mount and populate rootfs ───────────────────────
|
||||
info "Step 5/6: Populating root filesystem..."
|
||||
MNT="$(mktemp -d)"
|
||||
sudo mount -o loop "$RAW_DISK" "$MNT"
|
||||
sudo tar -xf "$ROOTFS_TAR" -C "$MNT"
|
||||
sudo umount "$MNT"
|
||||
rmdir "$MNT"
|
||||
ok "Rootfs written to disk"
|
||||
|
||||
# ── Step 6: Convert to qcow2 (optional) ─────────────────────
|
||||
info "Step 6/6: Finalizing..."
|
||||
if command -v qemu-img &>/dev/null; then
|
||||
QCOW2_DISK="$OUTPUT_DIR/ansible-node.qcow2"
|
||||
qemu-img convert -f raw -O qcow2 "$RAW_DISK" "$QCOW2_DISK"
|
||||
rm "$RAW_DISK"
|
||||
ok "Converted to qcow2: ansible-node.qcow2"
|
||||
FINAL_DISK="$QCOW2_DISK"
|
||||
FINAL_FMT="qcow2"
|
||||
else
|
||||
warn "qemu-img not found — keeping raw image"
|
||||
ok "Raw image: ansible-node.raw"
|
||||
FINAL_DISK="$RAW_DISK"
|
||||
FINAL_FMT="raw"
|
||||
fi
|
||||
|
||||
# ── Cleanup ─────────────────────────────────────────────────
|
||||
rm -f "$ROOTFS_TAR"
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN}${BOLD} Build complete!${NC}"
|
||||
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e " Disk: ${BOLD}${FINAL_DISK}${NC} (${FINAL_FMT})"
|
||||
echo -e " Kernel: ${BOLD}${OUTPUT_DIR}/vmlinuz-virt${NC}"
|
||||
echo -e " Initrd: ${BOLD}${OUTPUT_DIR}/initramfs-virt${NC}"
|
||||
echo ""
|
||||
echo -e " Launch: ${BOLD}./scripts/run-qemu.sh${NC}"
|
||||
echo ""
|
||||
ls -lh "$OUTPUT_DIR/"
|
||||
echo ""
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# Entrypoint: web UI by default, ansible-playbook if arguments given
|
||||
#
|
||||
# docker run -p 8080:8080 ansible-node → web UI
|
||||
# docker run ansible-node site.yml -i inventory → ansible-playbook
|
||||
# ───────────────────────────────────────────────────────────
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Starting web UI on http://0.0.0.0:8080"
|
||||
exec python3 /usr/local/bin/container-webui.py
|
||||
else
|
||||
exec ansible-playbook "$@"
|
||||
fi
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate assets.yml and expose its metadata to CI jobs."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REQUIRED_PROJECT_FIELDS = ("id", "name", "environment")
|
||||
|
||||
|
||||
def load_inventory(path: Path) -> dict[str, Any]:
|
||||
with path.open(encoding="utf-8") as inventory_file:
|
||||
inventory = yaml.safe_load(inventory_file)
|
||||
|
||||
if not isinstance(inventory, dict) or not isinstance(inventory.get("all"), dict):
|
||||
raise ValueError("inventory must contain an 'all' mapping")
|
||||
|
||||
project = inventory["all"].get("vars", {}).get("test_project")
|
||||
if not isinstance(project, dict):
|
||||
raise ValueError("all.vars.test_project must be a mapping")
|
||||
|
||||
missing = [field for field in REQUIRED_PROJECT_FIELDS if not project.get(field)]
|
||||
if missing:
|
||||
raise ValueError(f"test_project is missing required values: {', '.join(missing)}")
|
||||
|
||||
children = inventory["all"].get("children", {})
|
||||
if not isinstance(children, dict):
|
||||
raise ValueError("all.children must be a mapping")
|
||||
|
||||
return inventory
|
||||
|
||||
|
||||
def asset_matrix(inventory: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
assets = []
|
||||
seen = set()
|
||||
for group_name, group in inventory["all"].get("children", {}).items():
|
||||
if not isinstance(group, dict):
|
||||
raise ValueError(f"group '{group_name}' must be a mapping")
|
||||
hosts = group.get("hosts", {})
|
||||
if not isinstance(hosts, dict):
|
||||
raise ValueError(f"group '{group_name}'.hosts must be a mapping")
|
||||
for asset_id, host_vars in hosts.items():
|
||||
if asset_id in seen:
|
||||
raise ValueError(f"asset '{asset_id}' is declared more than once")
|
||||
seen.add(asset_id)
|
||||
variables = host_vars or {}
|
||||
if not isinstance(variables, dict):
|
||||
raise ValueError(f"asset '{asset_id}' variables must be a mapping")
|
||||
assets.append(
|
||||
{
|
||||
"id": asset_id,
|
||||
"group": group_name,
|
||||
"address": variables.get("ansible_host", variables.get("target_url", asset_id)),
|
||||
"asset_type": variables.get("asset_type", group_name.rstrip("s")),
|
||||
"test_profiles": variables.get("test_profiles", []),
|
||||
}
|
||||
)
|
||||
return assets
|
||||
|
||||
|
||||
def dotenv_value(value: Any) -> str:
|
||||
return str(value).replace("\n", " ").replace("\r", " ")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("inventory", type=Path)
|
||||
parser.add_argument("--expected-environment")
|
||||
parser.add_argument("--dotenv", type=Path, required=True)
|
||||
parser.add_argument("--matrix", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
inventory = load_inventory(args.inventory)
|
||||
assets = asset_matrix(inventory)
|
||||
except (OSError, ValueError, yaml.YAMLError) as error:
|
||||
parser.error(str(error))
|
||||
|
||||
project = inventory["all"]["vars"]["test_project"]
|
||||
if args.expected_environment and project["environment"] != args.expected_environment:
|
||||
parser.error(
|
||||
"assets.yml environment "
|
||||
f"'{project['environment']}' does not match TARGET_ENVIRONMENT "
|
||||
f"'{args.expected_environment}'"
|
||||
)
|
||||
|
||||
groups = sorted({asset["group"] for asset in assets})
|
||||
dotenv = {
|
||||
"TEST_PROJECT_ID": project["id"],
|
||||
"TEST_PROJECT_NAME": project["name"],
|
||||
"TEST_ENVIRONMENT": project["environment"],
|
||||
"TEST_CUSTOMER": project.get("customer", ""),
|
||||
"TEST_LOCATION": project.get("location", ""),
|
||||
"ASSET_COUNT": len(assets),
|
||||
"ASSET_GROUPS": ",".join(groups),
|
||||
}
|
||||
|
||||
args.dotenv.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.matrix.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.dotenv.write_text(
|
||||
"".join(f"{key}={dotenv_value(value)}\n" for key, value in dotenv.items()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
args.matrix.write_text(json.dumps({"assets": assets}, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Validated {len(assets)} assets for project '{project['name']}'")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a normalized test report as Markdown, HTML, and PDF."""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fpdf import FPDF
|
||||
|
||||
|
||||
def pdf_text(value: object) -> str:
|
||||
return str(value).encode("latin-1", errors="replace").decode("latin-1")
|
||||
|
||||
|
||||
def markdown(report: dict) -> str:
|
||||
project = report["project"]
|
||||
tool = report["tool"]
|
||||
target = report["target"]
|
||||
summary = report["summary"]
|
||||
lines = [
|
||||
f"# {project['name']} Test Report",
|
||||
"",
|
||||
"| Field | Value |",
|
||||
"|---|---|",
|
||||
f"| Project ID | {project['id']} |",
|
||||
f"| Environment | {project['environment']} |",
|
||||
f"| Tool | {tool['name']} |",
|
||||
f"| Target | {target['id']} |",
|
||||
f"| Run | {report['run']['id']} |",
|
||||
f"| Started | {report['run']['started_at']} |",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
"| Total | Passed | Failed | Errors | Skipped | Review | Score |",
|
||||
"|---:|---:|---:|---:|---:|---:|---:|",
|
||||
f"| {summary['total']} | {summary['passed']} | {summary['failed']} | {summary['errors']} | {summary['skipped']} | {summary['review']} | {summary.get('score', 0):.2f}% |",
|
||||
"",
|
||||
"## Results",
|
||||
"",
|
||||
"| Status | Severity | ID | Title |",
|
||||
"|---|---|---|---|",
|
||||
]
|
||||
for result in report["results"]:
|
||||
title = str(result["title"]).replace("|", "\\|")
|
||||
lines.append(f"| {result['status']} | {result['severity']} | {result['id']} | {title} |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def html_document(markdown_text: str, report: dict) -> str:
|
||||
rows = "".join(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(result['status'])}</td>"
|
||||
f"<td>{html.escape(result['severity'])}</td>"
|
||||
f"<td>{html.escape(result['id'])}</td>"
|
||||
f"<td>{html.escape(result['title'])}</td>"
|
||||
"</tr>"
|
||||
for result in report["results"]
|
||||
)
|
||||
summary = report["summary"]
|
||||
return f"""<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>{html.escape(report['project']['name'])} test report</title>
|
||||
<style>body{{font:14px sans-serif;max-width:1100px;margin:40px auto;color:#17202a}}table{{border-collapse:collapse;width:100%}}th,td{{border:1px solid #ccd1d1;padding:8px;text-align:left}}th{{background:#eaecee}}.summary{{display:flex;gap:24px;margin:24px 0}}.summary strong{{font-size:24px;display:block}}</style></head>
|
||||
<body><h1>{html.escape(report['project']['name'])} Test Report</h1>
|
||||
<p>{html.escape(report['tool']['name'])} against {html.escape(report['target']['id'])} at {html.escape(report['run']['started_at'])}</p>
|
||||
<div class="summary"><span><strong>{summary['total']}</strong>Total</span><span><strong>{summary['passed']}</strong>Passed</span><span><strong>{summary['failed']}</strong>Failed</span><span><strong>{summary.get('score', 0):.2f}%</strong>Score</span></div>
|
||||
<table><thead><tr><th>Status</th><th>Severity</th><th>ID</th><th>Title</th></tr></thead><tbody>{rows}</tbody></table>
|
||||
<details><summary>Markdown source</summary><pre>{html.escape(markdown_text)}</pre></details></body></html>"""
|
||||
|
||||
|
||||
def pdf_document(report: dict, output: Path) -> None:
|
||||
pdf = FPDF()
|
||||
pdf.set_auto_page_break(auto=True, margin=15)
|
||||
pdf.add_page()
|
||||
pdf.set_font("Helvetica", "B", 18)
|
||||
pdf.multi_cell(0, 10, pdf_text(f"{report['project']['name']} Test Report"), new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_font("Helvetica", size=10)
|
||||
pdf.multi_cell(
|
||||
0,
|
||||
6,
|
||||
pdf_text(f"Tool: {report['tool']['name']}\nTarget: {report['target']['id']}\nStarted: {report['run']['started_at']}"),
|
||||
new_x="LMARGIN",
|
||||
new_y="NEXT",
|
||||
)
|
||||
summary = report["summary"]
|
||||
pdf.ln(3)
|
||||
pdf.set_font("Helvetica", "B", 12)
|
||||
pdf.cell(0, 8, f"Total {summary['total']} Passed {summary['passed']} Failed {summary['failed']} Score {summary.get('score', 0):.2f}%", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_font("Helvetica", size=9)
|
||||
for result in report["results"]:
|
||||
pdf.multi_cell(
|
||||
0,
|
||||
5,
|
||||
pdf_text(f"[{result['status'].upper()}] [{result['severity']}] {result['id']}: {result['title']}"),
|
||||
new_x="LMARGIN",
|
||||
new_y="NEXT",
|
||||
)
|
||||
pdf.output(output)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
report = json.loads(args.input.read_text(encoding="utf-8"))
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{report['tool']['id']}-{report['target']['id']}"
|
||||
markdown_text = markdown(report)
|
||||
(args.output_dir / f"{stem}.md").write_text(markdown_text, encoding="utf-8")
|
||||
(args.output_dir / f"{stem}.html").write_text(html_document(markdown_text, report), encoding="utf-8")
|
||||
pdf_document(report, args.output_dir / f"{stem}.pdf")
|
||||
print(f"Rendered Markdown, HTML, and PDF reports in {args.output_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# run-qemu.sh — Launch the Ansible Control Node VM
|
||||
#
|
||||
# Boots the minimal Alpine Linux VM with:
|
||||
# • Machine: pc-q35-10.0
|
||||
# • TTY: ttyS0, xterm-256color (serial console)
|
||||
# • Keyboard: PS/2 (atkbd via QEMU default)
|
||||
# • Network: user-mode NAT with port forwards:
|
||||
# localhost:2222 → VM:22 (SSH)
|
||||
# localhost:8080 → VM:8080 (custom services)
|
||||
# • Disk: virtio-blk, read from output/
|
||||
# • Init: OpenRC (no systemd)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/run-qemu.sh # serial console (default)
|
||||
# ./scripts/run-qemu.sh --gui # graphical (GTK) window
|
||||
# ./scripts/run-qemu.sh --vnc :0 # VNC on display :0
|
||||
# ./scripts/run-qemu.sh --debug # verbose kernel boot
|
||||
#
|
||||
# Environment:
|
||||
# QEMU_MEMORY — RAM size (default: 1024M)
|
||||
# QEMU_SMP — CPU count (default: 2)
|
||||
# SSH_PORT — host port for SSH forward (default: 2222)
|
||||
#
|
||||
# Access the VM:
|
||||
# ssh -p 2222 ansible@localhost # password: ansible
|
||||
# ssh -p 2222 root@localhost # password: ansible
|
||||
#
|
||||
# Stop the VM:
|
||||
# Press Ctrl-A then X (in -nographic mode)
|
||||
# Or: sudo shutdown -h now (inside VM)
|
||||
# ───────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-$SCRIPT_DIR/../output}"
|
||||
|
||||
# ── Configuration (env-overridable) ─────────────────────────
|
||||
QEMU_BIN="${QEMU_BIN:-qemu-system-x86_64}"
|
||||
QEMU_MACHINE="${QEMU_MACHINE:-pc-q35-10.0}"
|
||||
QEMU_MEMORY="${QEMU_MEMORY:-1024M}"
|
||||
QEMU_SMP="${QEMU_SMP:-2}"
|
||||
SSH_PORT="${SSH_PORT:-2222}"
|
||||
EXTRA_PORT="${EXTRA_PORT:-8090}"
|
||||
|
||||
# ── Colour helpers ──────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
info() { echo -e "${BLUE}[*]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
|
||||
# ── Find build artifacts ────────────────────────────────────
|
||||
# Prefer qcow2 over raw
|
||||
if [ -f "$OUTPUT_DIR/ansible-node.qcow2" ]; then
|
||||
DISK="$OUTPUT_DIR/ansible-node.qcow2"
|
||||
DISK_FMT="qcow2"
|
||||
elif [ -f "$OUTPUT_DIR/ansible-node.raw" ]; then
|
||||
DISK="$OUTPUT_DIR/ansible-node.raw"
|
||||
DISK_FMT="raw"
|
||||
else
|
||||
echo -e "${RED}[✗]${NC} No disk image found in ${OUTPUT_DIR}"
|
||||
echo " Run ./scripts/build-qemu.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KERNEL="$OUTPUT_DIR/vmlinuz-virt"
|
||||
INITRD="$OUTPUT_DIR/initramfs-virt"
|
||||
|
||||
for f in "$DISK" "$KERNEL" "$INITRD"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo -e "${RED}[✗]${NC} Missing: $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────
|
||||
DISPLAY_MODE="nographic"
|
||||
KERNEL_APPEND="root=/dev/vda console=ttyS0 TERM=xterm-256color quiet modules=virtio_blk,ext4 rootflags=rw"
|
||||
EXTRA_QEMU_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gui|-g)
|
||||
DISPLAY_MODE="gtk"
|
||||
;;
|
||||
--vnc)
|
||||
DISPLAY_MODE="vnc"
|
||||
VNC_DISPLAY="${2:-:0}"
|
||||
shift
|
||||
;;
|
||||
--debug)
|
||||
# Remove 'quiet', add verbose/delay for debugging
|
||||
KERNEL_APPEND="${KERNEL_APPEND// quiet/} debug_init rootdelay=3"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--gui|--vnc :N|--debug] [extra qemu args...]"
|
||||
echo ""
|
||||
echo "Modes:"
|
||||
echo " (default) Serial console (-nographic), Ctrl-A X to exit"
|
||||
echo " --gui Graphical GTK window with keyboard support"
|
||||
echo " --vnc :N VNC server on display N"
|
||||
echo " --debug Verbose kernel boot messages"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " QEMU_MEMORY=1024M RAM size"
|
||||
echo " QEMU_SMP=2 CPU count"
|
||||
echo " SSH_PORT=2222 Host SSH port"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
EXTRA_QEMU_ARGS+=("$1")
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# ── Display mode flags ──────────────────────────────────────
|
||||
case "$DISPLAY_MODE" in
|
||||
nographic)
|
||||
DISPLAY_FLAG="-nographic"
|
||||
;;
|
||||
gtk)
|
||||
DISPLAY_FLAG="-display gtk"
|
||||
# QEMU's GTK display includes keyboard support via PS/2 emulation
|
||||
# which covers the CONFIG_KEYBOARD_ATKBD kernel requirement
|
||||
;;
|
||||
vnc)
|
||||
DISPLAY_FLAG="-vnc ${VNC_DISPLAY} -vga virtio"
|
||||
KERNEL_APPEND="$KERNEL_APPEND video=1024x768"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Launch ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||
echo -e "${BOLD} Ansible Control Node${NC}"
|
||||
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e " Machine: ${GREEN}${QEMU_MACHINE}${NC}"
|
||||
echo -e " Memory: ${GREEN}${QEMU_MEMORY}${NC}"
|
||||
echo -e " CPUs: ${GREEN}${QEMU_SMP}${NC}"
|
||||
echo -e " Disk: ${GREEN}${DISK_FMT}:${DISK}${NC}"
|
||||
echo -e " Display: ${GREEN}${DISPLAY_MODE}${NC}"
|
||||
echo -e " Kernel: ${KERNEL_APPEND}"
|
||||
echo ""
|
||||
echo -e " SSH: ${YELLOW}ssh -p ${SSH_PORT} ansible@localhost${NC}"
|
||||
echo -e " Password: ${YELLOW}ansible${NC}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Ctrl-A X${NC} to quit (serial mode)"
|
||||
echo -e " ${BOLD}Ctrl-C${NC} to force-quit (any mode)"
|
||||
echo ""
|
||||
|
||||
# Build netdev string, skipping extra port if already in use
|
||||
NETDEV_FORWARDS="hostfwd=tcp::${SSH_PORT}-:22"
|
||||
if ! ss -tlnp 2>/dev/null | grep -q ":${EXTRA_PORT} "; then
|
||||
NETDEV_FORWARDS="${NETDEV_FORWARDS},hostfwd=tcp::${EXTRA_PORT}-:8080"
|
||||
else
|
||||
warn "Port ${EXTRA_PORT} already in use — skipping extra forward"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
exec "$QEMU_BIN" \
|
||||
-machine "$QEMU_MACHINE" \
|
||||
-m "$QEMU_MEMORY" \
|
||||
-smp "$QEMU_SMP" \
|
||||
-enable-kvm \
|
||||
-kernel "$KERNEL" \
|
||||
-initrd "$INITRD" \
|
||||
-append "$KERNEL_APPEND" \
|
||||
-drive "file=$DISK,if=virtio,format=$DISK_FMT" \
|
||||
-netdev "user,id=net0,${NETDEV_FORWARDS}" \
|
||||
-device virtio-net,netdev=net0 \
|
||||
$DISPLAY_FLAG \
|
||||
"${EXTRA_QEMU_ARGS[@]}"
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# tty-menu.sh — Serial Console Menu for the Alpine Docker Host
|
||||
#
|
||||
# Launched by agetty on ttyS0. The user interacts directly
|
||||
# with the Ansible container from the serial console.
|
||||
#
|
||||
# Options:
|
||||
# 1-4 Run playbooks (docker run ansible-node)
|
||||
# 5 Download reports as tar.gz
|
||||
# 6 View latest report summary
|
||||
# 7 Shell into Ansible container
|
||||
# 8 Shell on Docker host
|
||||
# 0 Shutdown VM
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
PLAYBOOKS_DIR="/ansible/playbooks"
|
||||
REPORTS_DIR="/ansible/reports"
|
||||
INVENTORY="/ansible/inventory/inventory.ini"
|
||||
IMAGE="ansible-node"
|
||||
WEB_PORT="8080"
|
||||
|
||||
# ── Colour helpers ────────────────────────────────────────
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
clear
|
||||
|
||||
while true; do
|
||||
# Determine IP for web UI hint
|
||||
IP=$(ip -4 addr show scope global 2>/dev/null | \
|
||||
grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)
|
||||
[ -z "$IP" ] && IP="(no network)"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}${BOLD}║ IEC 62443-3-3 SL2 Compliance Validator ║${NC}"
|
||||
echo -e "${GREEN}${BOLD}╠══════════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} Web UI: ${CYAN}http://${IP}:${WEB_PORT}${GREEN} ${NC}${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}╠══════════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}1)${NC} Run all tests (site.yml) ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}2)${NC} Run FR1 — Auth & Identification ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}3)${NC} Run FR2 — Use Control ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}4)${NC} Run FR5 — Restricted Data Flow ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}5)${NC} Create reports archive (tar.gz) ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}6)${NC} View latest report summary ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}7)${NC} Shell — Ansible container ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}8)${NC} Shell — Docker host ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}0)${NC} Shutdown VM ${GREEN}${BOLD}║${NC}"
|
||||
echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
printf " Choice [0-8]: "
|
||||
read -r CHOICE
|
||||
|
||||
docker_run() {
|
||||
local pb="$1"; shift
|
||||
echo ""
|
||||
echo -e "${YELLOW}Running: ${pb}${NC}"
|
||||
echo "═══════════════════════════════════════════"
|
||||
docker run --rm -it \
|
||||
-v "${PLAYBOOKS_DIR}:/ansible/playbooks:ro" \
|
||||
-v "${REPORTS_DIR}:/ansible/reports" \
|
||||
-v "$(dirname "${INVENTORY}"):/ansible/inventory:ro" \
|
||||
"${IMAGE}" \
|
||||
"/ansible/playbooks/${pb}" \
|
||||
-i /ansible/inventory/inventory.ini "$@"
|
||||
echo ""
|
||||
echo -e "${GREEN}Done. Reports: ${REPORTS_DIR}${NC}"
|
||||
echo "Press Enter to continue..."
|
||||
read -r _
|
||||
}
|
||||
|
||||
case "$CHOICE" in
|
||||
1) docker_run "site.yml" ;;
|
||||
2) docker_run "suites/fr1_auth.yml" ;;
|
||||
3) docker_run "suites/fr2_use_control.yml" ;;
|
||||
4) docker_run "suites/fr5_data_flow.yml" ;;
|
||||
5)
|
||||
echo ""
|
||||
echo -e "${YELLOW}Available reports:${NC}"
|
||||
ls -lh "${REPORTS_DIR}"/*.json 2>/dev/null || echo " No reports yet."
|
||||
echo ""
|
||||
printf " Enter filename (or Enter for all as tar.gz): "
|
||||
read -r FN
|
||||
if [ -n "$FN" ]; then
|
||||
OUT="/tmp/${FN}"
|
||||
cp "${REPORTS_DIR}/${FN}" "$OUT" 2>/dev/null && \
|
||||
echo -e " Written: ${GREEN}${OUT}${NC}" || \
|
||||
echo -e "${RED} Not found: ${FN}${NC}"
|
||||
else
|
||||
OUTF="/tmp/reports-$(date +%Y%m%d-%H%M).tar.gz"
|
||||
cd "${REPORTS_DIR}" && tar czf "$OUTF" *.json 2>/dev/null && \
|
||||
echo -e " Created: ${GREEN}${OUTF}${NC} ($(du -sh "$OUTF" | cut -f1))"
|
||||
fi
|
||||
echo " Transfer via: scp ansible@<host>:/tmp/reports-*.tar.gz ."
|
||||
echo ""
|
||||
echo "Press Enter to continue..."
|
||||
read -r _
|
||||
;;
|
||||
6)
|
||||
LATEST=$(ls -t "${REPORTS_DIR}"/*.json 2>/dev/null | head -1)
|
||||
if [ -z "$LATEST" ]; then
|
||||
echo -e "${RED} No reports yet.${NC}"
|
||||
else
|
||||
echo ""
|
||||
echo -e "${YELLOW}Latest: $(basename "$LATEST")${NC}"
|
||||
echo "═══════════════════════════════════════════"
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$LATEST') as f:
|
||||
r = json.load(f)
|
||||
s = r['summary']
|
||||
print(f'Total: {s[\"total\"]} | Passed: {s[\"passed\"]} | Failed: {s[\"failed\"]}')
|
||||
print(f'Compliance rate: {s[\"passed\"]/s[\"total\"]*100:.1f}%')
|
||||
print()
|
||||
for t in r['results']:
|
||||
icon = '\u2705' if t['passed'] == True else ('\u274c' if t['passed'] == False else '\U0001f50d')
|
||||
print(f' {icon} [{t[\"test_id\"]}] {t[\"description\"]}')
|
||||
print()
|
||||
if r.get('failures'):
|
||||
print('Failures:')
|
||||
for f in r['failures']:
|
||||
print(f' \u274c {f[\"test_id\"]}: {f[\"description\"]}')
|
||||
print(f' Expected: {f[\"expected\"]}')
|
||||
print(f' Actual: {f[\"actual\"]}')
|
||||
print(f' Fix: {f[\"remediation\"]}')
|
||||
" 2>/dev/null || echo " Error reading report"
|
||||
fi
|
||||
echo ""
|
||||
echo "Press Enter to continue..."
|
||||
read -r _
|
||||
;;
|
||||
7)
|
||||
echo ""
|
||||
echo -e "${YELLOW}Ansible container shell (type 'exit' to return)${NC}"
|
||||
echo "═══════════════════════════════════════════"
|
||||
docker run --rm -it \
|
||||
-v "${PLAYBOOKS_DIR}:/ansible/playbooks:ro" \
|
||||
-v "${REPORTS_DIR}:/ansible/reports" \
|
||||
-v "$(dirname "${INVENTORY}"):/ansible/inventory:ro" \
|
||||
"${IMAGE}" /bin/bash 2>/dev/null || \
|
||||
docker run --rm -it \
|
||||
-v "${PLAYBOOKS_DIR}:/ansible/playbooks:ro" \
|
||||
-v "${REPORTS_DIR}:/ansible/reports" \
|
||||
-v "$(dirname "${INVENTORY}"):/ansible/inventory:ro" \
|
||||
"${IMAGE}" /bin/sh
|
||||
;;
|
||||
8)
|
||||
echo ""
|
||||
echo -e "${YELLOW}Host shell (type 'exit' to return to menu)${NC}"
|
||||
echo "═══════════════════════════════════════════"
|
||||
/bin/bash 2>/dev/null || /bin/sh
|
||||
;;
|
||||
0)
|
||||
echo ""
|
||||
echo -e "${RED}Shutting down...${NC}"
|
||||
sudo poweroff 2>/dev/null || poweroff
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice${NC}"
|
||||
sleep 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
Reference in New Issue
Block a user