CYBER-0 initial concept ready

This commit is contained in:
Ole Valente
2026-09-22 00:00:45 +02:00
parent 29eecd4f70
commit edb6cde069
70 changed files with 1976 additions and 3140 deletions
+66
View File
@@ -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())
-87
View File
@@ -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 ""
-155
View File
@@ -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 ""
-13
View File
@@ -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
+114
View File
@@ -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())
+118
View File
@@ -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())
-176
View File
@@ -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[@]}"
-174
View File
@@ -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