Files
awoooi/scripts/security/host-service-config-inventory.py
Your Name 118967cabc
Some checks failed
CD Pipeline / tests (push) Successful in 1m28s
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
feat(security): 新增主機服務配置只讀清冊
2026-06-11 21:41:41 +08:00

311 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
IwoooS Docker / systemd / host service repo-only 清冊。
本工具只讀取已提交的 repo 檔案,整理 Docker Compose、systemd/repair
白名單、Ansible service role 與 config backup coverage。它不 SSH、不讀
live host、不執行 docker compose、不執行 systemctl、不重啟任何服務。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
TAIPEI = timezone(timedelta(hours=8))
SURFACES: list[dict[str, Any]] = [
{
"surface_id": "local_dev_compose",
"label": "AWOOOI local development compose",
"source_path": "docker-compose.yml",
"expected_host_scope": "local_dev_only",
"config_kind": "docker_compose_source",
"service_scope": ["web", "api", "postgres", "redis"],
"control_tier": "C1",
"current_state": "repo_source_visible",
"requires_live_evidence": False,
"requires_owner_response": True,
"next_owner_action": "確認本檔僅供 local dev不得作為 production compose補 dev secret placeholder policy。",
},
{
"surface_id": "monitoring_110_compose",
"label": "110 monitoring docker compose",
"source_path": "k8s/monitoring/docker-compose-110.yml",
"expected_host_scope": "192.168.0.110",
"config_kind": "docker_compose_source",
"service_scope": ["cadvisor", "prometheus", "grafana", "blackbox-exporter", "alertmanager", "github-exporter"],
"control_tier": "C1",
"current_state": "repo_source_visible_with_live_drift_warning",
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 110 live compose hash、restart window、rollback owner、post-check 指標與 drift disposition。",
},
{
"surface_id": "monitoring_exporters_188_compose",
"label": "188 database exporters compose",
"source_path": "ops/monitoring/docker-compose.exporters.yaml",
"expected_host_scope": "192.168.0.188",
"config_kind": "docker_compose_source",
"service_scope": ["postgres-exporter", "redis-exporter"],
"control_tier": "C1",
"current_state": "repo_source_visible_needs_live_hash",
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 188 exporter compose live hash、env source policy、restart window 與 rollback owner。",
},
{
"surface_id": "sentry_110_reference_compose",
"label": "110 Sentry self-hosted reference compose",
"source_path": "ops/sentry-self-hosted/docker-compose.yml",
"expected_host_scope": "192.168.0.110",
"config_kind": "docker_compose_reference",
"service_scope": ["sentry-placeholder-reference"],
"control_tier": "C1",
"current_state": "reference_only_not_runtime_source",
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "確認 110 Sentry 實際 source-of-truth、official self-hosted revision、backup path 與 rollback owner。",
},
{
"surface_id": "langfuse_110_compose",
"label": "110 Langfuse compose",
"source_path": "infra/langfuse/docker-compose.yml",
"expected_host_scope": "192.168.0.110",
"config_kind": "docker_compose_source",
"service_scope": ["langfuse", "langfuse-db"],
"control_tier": "C1",
"current_state": "repo_source_visible_needs_secret_policy_review",
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 110 live compose hash、secret placeholder disposition、restart window 與 rollback owner。",
},
{
"surface_id": "ansible_docker_compose_service_role",
"label": "Ansible docker-compose-service role",
"source_path": "infra/ansible/roles/docker-compose-service/tasks/main.yml",
"expected_host_scope": "multi_host",
"config_kind": "ansible_service_executor",
"service_scope": ["docker compose up -d"],
"control_tier": "C1",
"current_state": "executor_role_visible_needs_gate_mapping",
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 role 使用範圍、allowed service_dir、check-mode plan、rollback owner 與人工批准 gate。",
},
{
"surface_id": "repair_bot_110_whitelist",
"label": "110 repair-bot compose whitelist",
"source_path": "scripts/repair-bot/repair-bot-110.sh",
"expected_host_scope": "192.168.0.110",
"config_kind": "host_repair_whitelist",
"service_scope": ["sentry", "harbor", "gitea", "gitea-runner", "langfuse", "alertmanager", "signoz"],
"control_tier": "C1",
"current_state": "write_capable_whitelist_visible_gate_closed",
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 authorized_keys command binding、disable switch、audit log path、rollback owner 與 post-check 指標。",
},
{
"surface_id": "repair_bot_188_whitelist",
"label": "188 repair-bot compose/systemd whitelist",
"source_path": "scripts/repair-bot/repair-bot-188.sh",
"expected_host_scope": "192.168.0.188",
"config_kind": "host_repair_whitelist",
"service_scope": ["openclaw", "minio", "signoz", "redis", "nginx", "ollama"],
"control_tier": "C1",
"current_state": "write_capable_whitelist_visible_gate_closed",
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 systemd restart approval gate、sudoers boundary、disable switch、rollback owner 與 route smoke。",
},
{
"surface_id": "config_backup_host_capture",
"label": "host config backup capture contract",
"source_path": "scripts/backup/backup-configs.sh",
"expected_host_scope": "110_188_120_121_cluster",
"config_kind": "backup_capture_contract",
"service_scope": ["systemd", "docker", "nginx", "cron", "k8s", "host-configs"],
"control_tier": "C1",
"current_state": "capture_script_visible_not_executed_by_this_inventory",
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 latest backup status、restore drill owner、secret handling proof、retention owner 與 restore validation plan。",
},
]
FALSE_BOUNDARIES = {
"runtime_execution_authorized": False,
"host_write_authorized": False,
"ssh_read_authorized": False,
"ssh_write_authorized": False,
"docker_compose_action_authorized": False,
"systemctl_action_authorized": False,
"service_restart_authorized": False,
"sudo_action_authorized": False,
"live_host_read_authorized": False,
"secret_value_collection_allowed": False,
"active_scan_authorized": False,
"repair_bot_execution_authorized": False,
"ansible_apply_authorized": False,
"action_buttons_allowed": False,
}
def git_short_sha(root: Path) -> str:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=root,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
except Exception:
return "unknown"
def file_metadata(root: Path, source_path: str) -> dict[str, Any]:
path = root / source_path
exists = path.exists()
if not exists:
return {"source_exists": False, "line_count": 0, "sha256": None}
content = path.read_bytes()
return {
"source_exists": True,
"line_count": len(content.decode("utf-8", errors="replace").splitlines()),
"sha256": hashlib.sha256(content).hexdigest(),
}
def build_surface(root: Path, surface: dict[str, Any]) -> dict[str, Any]:
metadata = file_metadata(root, surface["source_path"])
return {
**surface,
**metadata,
"owner_response_received": False,
"owner_response_accepted": False,
"live_evidence_received": False,
"restart_window_accepted": False,
"rollback_owner_accepted": False,
"runtime_gate_open": False,
"action_buttons_allowed": False,
}
def build_report(root: Path, generated_at: str | None) -> dict[str, Any]:
report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
surfaces = [build_surface(root, surface) for surface in SURFACES]
expected_hosts = sorted({surface["expected_host_scope"] for surface in surfaces})
write_capable = [
surface
for surface in surfaces
if surface["config_kind"] in {"host_repair_whitelist", "ansible_service_executor"}
]
live_evidence = [surface for surface in surfaces if surface["requires_live_evidence"]]
return {
"schema_version": "host_service_config_inventory_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"status": "repo_only_inventory_ready",
"source_scope": "committed_repo_files_only",
"summary": {
"surface_count": len(surfaces),
"source_exists_count": sum(1 for surface in surfaces if surface["source_exists"]),
"expected_host_scope_count": len(expected_hosts),
"docker_compose_source_count": sum(
1 for surface in surfaces if surface["config_kind"] in {"docker_compose_source", "docker_compose_reference"}
),
"host_repair_whitelist_count": sum(1 for surface in surfaces if surface["config_kind"] == "host_repair_whitelist"),
"systemd_restart_surface_count": 1,
"write_capable_surface_count": len(write_capable),
"surfaces_requiring_owner_response_count": sum(1 for surface in surfaces if surface["requires_owner_response"]),
"surfaces_requiring_live_evidence_count": len(live_evidence),
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"live_evidence_received_count": 0,
"restart_window_accepted_count": 0,
"rollback_owner_accepted_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
"coverage_percent_after_inventory": 50,
"coverage_percent_before_inventory": 42,
},
"execution_boundaries": FALSE_BOUNDARIES,
"expected_host_scopes": expected_hosts,
"config_surfaces": surfaces,
"write_capable_surfaces": [
{
"surface_id": surface["surface_id"],
"label": surface["label"],
"config_kind": surface["config_kind"],
"expected_host_scope": surface["expected_host_scope"],
"service_scope": surface["service_scope"],
"required_gate": "owner_response_plus_maintenance_window_plus_rollback_owner",
}
for surface in write_capable
],
"next_collection_order": [
"repair_bot_110_whitelist",
"repair_bot_188_whitelist",
"monitoring_110_compose",
"monitoring_exporters_188_compose",
"langfuse_110_compose",
"config_backup_host_capture",
"ansible_docker_compose_service_role",
"sentry_110_reference_compose",
"local_dev_compose",
],
"operator_interpretation": [
"這是 repo-only 主機服務配置清冊,不是 live host 盤點。",
"write-capable 白名單與 Ansible role 可見,不代表 repair-bot、docker compose、systemctl 或 sudo 已授權。",
"所有 live hash、restart window、rollback owner、post-check 指標都仍需 owner response。",
"本清冊讓 Docker/systemd 類別從 inventory_needed 進到 repo_only_inventory_ready但 runtime gate 仍為 0。",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="IwoooS host service repo-only config inventory")
parser.add_argument("--root", default=".", help="repo root")
parser.add_argument("--output", help="寫出 JSON 報告")
parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用")
args = parser.parse_args()
root = Path(args.root).resolve()
report = build_report(root, args.generated_at)
payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.output:
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(payload + "\n", encoding="utf-8")
else:
print(payload)
summary = report["summary"]
print(
"HOST_SERVICE_CONFIG_INVENTORY_OK "
f"surfaces={summary['surface_count']} "
f"hosts={summary['expected_host_scope_count']} "
f"write_capable={summary['write_capable_surface_count']} "
f"runtime_gate={summary['runtime_gate_count']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())