diff --git a/src/fgai/cli.py b/src/fgai/cli.py index 14f1fbb..f1ad866 100644 --- a/src/fgai/cli.py +++ b/src/fgai/cli.py @@ -204,12 +204,13 @@ def run_monitor(args: argparse.Namespace) -> int: llm_model=args.model, llm_timeout=args.llm_timeout, baseline_path=args.baseline_db, + config_path=args.config_file, ) return 0 def run_dashboard(args: argparse.Namespace) -> int: - serve_dashboard(args.host, args.port, args.status_file, image_dir=args.image_dir) + serve_dashboard(args.host, args.port, args.status_file, image_dir=args.image_dir, config_file=args.config_file) return 0 @@ -287,6 +288,7 @@ def build_parser() -> argparse.ArgumentParser: monitor.add_argument("--interval", type=int, default=10, help="Seconds between analysis runs") monitor.add_argument("--anomaly-limit", type=int, default=20, help="Maximum anomaly findings to include") monitor.add_argument("--baseline-db", default="state/fgai-baseline.sqlite3", help="SQLite database for historical behavior baselines") + monitor.add_argument("--config-file", default="state/fgai-config.json", help="Runtime configuration written by dashboard") monitor.add_argument("--llm", action="store_true", help="Generate cached Ollama analyst note for dashboard") monitor.add_argument("--llm-interval", type=int, default=300, help="Seconds between Ollama dashboard assessments") monitor.add_argument("--model", default=None, help="Ollama model name") @@ -298,6 +300,7 @@ def build_parser() -> argparse.ArgumentParser: dashboard.add_argument("--port", type=int, default=8088, help="Dashboard TCP port") dashboard.add_argument("--status-file", default="state/fgai-status.json", help="Status JSON produced by monitor") dashboard.add_argument("--image-dir", default="images", help="Directory containing dashboard images") + dashboard.add_argument("--config-file", default="state/fgai-config.json", help="Local runtime configuration JSON") dashboard.set_defaults(func=run_dashboard) return parser diff --git a/src/fgai/config.py b/src/fgai/config.py new file mode 100644 index 0000000..9ad798a --- /dev/null +++ b/src/fgai/config.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + + +DEFAULT_CONFIG: dict[str, object] = { + "log_source": "local_syslog", + "graylog_mcp_url": "", + "graylog_stream": "", + "llm_enabled": False, + "llm_model": "", + "threat_intel_enabled": False, +} + +EDITABLE_FIELDS = set(DEFAULT_CONFIG) | {"graylog_mcp_token"} + + +class ConfigStore: + def __init__(self, path: str) -> None: + self.path = Path(path) + + def read(self) -> dict[str, object]: + try: + stored = json.loads(self.path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + stored = {} + return {**DEFAULT_CONFIG, **{key: value for key, value in stored.items() if key in EDITABLE_FIELDS}} + + def public(self) -> dict[str, object]: + config = self.read() + config["graylog_mcp_token_configured"] = bool(config.pop("graylog_mcp_token", "")) + return config + + def update(self, values: dict[str, object]) -> dict[str, object]: + current = self.read() + for key, value in values.items(): + if key not in EDITABLE_FIELDS: + continue + if key == "graylog_mcp_token" and value == "": + continue + if key in {"llm_enabled", "threat_intel_enabled"}: + current[key] = bool(value) + elif key == "log_source" and value in {"local_syslog", "graylog_mcp"}: + current[key] = value + elif isinstance(value, str): + current[key] = value.strip() + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(".tmp") + temporary.write_text(json.dumps(current, indent=2, sort_keys=True), encoding="utf-8") + os.chmod(temporary, 0o600) + temporary.replace(self.path) + return self.public() diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index 6cabbda..0bbb4a4 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -4,6 +4,8 @@ import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from .config import ConfigStore + HTML = """ @@ -56,10 +58,11 @@ HTML = """

Live Status

Waiting for monitor data.
- +

AI Assessment

LLM assessment disabled.

Anomalies

Recommendations

Block Candidates

Threat Intelligence

Policy Findings

Diagnostics

+

Runtime Configuration

@@ -167,15 +194,19 @@ setInterval(refresh, 5000); """ -def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str | None = None) -> None: +def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str | None = None, config_file: str = "state/fgai-config.json") -> None: status_path = Path(status_file) image_root = Path(image_dir) if image_dir else None + config_store = ConfigStore(config_file) class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: if self.path == "/": self._send(200, "text/html; charset=utf-8", HTML.encode("utf-8")) return + if self.path == "/api/config": + self._send(200, "application/json", json.dumps(config_store.public()).encode("utf-8")) + return if self.path == "/api/status": if status_path.exists(): body = status_path.read_bytes() @@ -191,6 +222,24 @@ def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str | return self._send(404, "text/plain; charset=utf-8", b"not found") + def do_POST(self) -> None: + if self.path != "/api/config" or not self._is_loopback_client(): + self._send(403, "application/json", b'{"error":"configuration is local-only"}') + return + try: + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(min(length, 32_768)).decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("configuration must be an object") + body = json.dumps(config_store.update(payload)).encode("utf-8") + except (ValueError, json.JSONDecodeError) as exc: + self._send(400, "application/json", json.dumps({"error": str(exc)}).encode("utf-8")) + return + self._send(200, "application/json", body) + + def _is_loopback_client(self) -> bool: + return self.client_address[0] in {"127.0.0.1", "::1"} + def log_message(self, format: str, *args: object) -> None: return diff --git a/src/fgai/monitor.py b/src/fgai/monitor.py index c103960..dfea679 100644 --- a/src/fgai/monitor.py +++ b/src/fgai/monitor.py @@ -6,6 +6,7 @@ from pathlib import Path from .anomaly import anomaly_summary, detect_source_anomalies from .baseline import BaselineStore +from .config import ConfigStore from .llm import ollama_dashboard_assessment from .logs import local_in_failures, read_events, summarize_events, top_field_values from .mitigation import parse_allowlist, suggest_block_candidates @@ -22,12 +23,17 @@ def build_status( min_block_score: int = 7, anomaly_limit: int = 20, baseline_path: str | None = None, + config_path: str | None = None, ) -> dict[str, object]: events = read_events(log_path) if Path(log_path).exists() else [] baseline = BaselineStore(baseline_path) if baseline_path else None profiles = baseline.profiles({event.src_ip for event in events if event.src_ip}) if baseline else {} anomalies = detect_source_anomalies(events, limit=anomaly_limit, baselines=profiles) baseline_events = baseline.ingest(events) if baseline else 0 + config_store = ConfigStore(config_path) if config_path else None + config_exists = bool(config_store and config_store.path.exists()) + runtime_values = config_store.read() if config_exists and config_store else {} + runtime_config = config_store.public() if config_store else {} intel_ips = sorted( { ip @@ -36,8 +42,9 @@ def build_status( if is_public_ip(ip) } ) - reputation = enrich_ips(intel_ips, limit=25) - threat_intel_status = ThreatIntelClient().status() + threat_enabled = bool(runtime_values.get("threat_intel_enabled")) if runtime_values else None + reputation = enrich_ips(intel_ips, limit=25, enabled=threat_enabled) + threat_intel_status = ThreatIntelClient(enabled=threat_enabled).status() recommendations = build_recommendations(events, anomalies, reputation) block_candidates = suggest_block_candidates( events, @@ -62,6 +69,7 @@ def build_status( "anomaly_summary": anomaly_summary(anomalies), "baseline": {"enabled": bool(baseline), "sources_ready": len(profiles), "new_events_recorded": baseline_events}, "capabilities": {"threat_intel": threat_intel_status}, + "configuration": runtime_config, "diagnostics": { "top_source_ips": top_field_values(events, "srcip", limit=10), "top_destination_ips": top_field_values(events, "dstip", limit=10), @@ -145,17 +153,24 @@ def monitor_loop( llm_model: str | None = None, llm_timeout: int | None = None, baseline_path: str | None = None, + config_path: str | None = None, ) -> None: print(f"Monitoring {log_path}") print(f"Writing status to {output}") last_llm_at = 0 last_llm_text: str | None = None while True: - status = build_status(log_path, policy_path=policy_path, anomaly_limit=anomaly_limit, baseline_path=baseline_path) - if llm: + runtime = ConfigStore(config_path).read() if config_path and Path(config_path).exists() else {} + effective_llm = bool(runtime.get("llm_enabled")) if runtime else llm + effective_model = str(runtime.get("llm_model") or llm_model or "") + status = build_status( + log_path, policy_path=policy_path, anomaly_limit=anomaly_limit, + baseline_path=baseline_path, config_path=config_path, + ) + if effective_llm: now = int(time.time()) if now - last_llm_at >= llm_interval: - add_llm_assessment(status, previous=last_llm_text, model=llm_model, timeout=llm_timeout) + add_llm_assessment(status, previous=last_llm_text, model=effective_model or None, timeout=llm_timeout) assessment = status.get("llm_assessment", {}) if isinstance(assessment, dict): last_llm_text = str(assessment.get("text", "") or last_llm_text or "") diff --git a/src/fgai/threat_intel.py b/src/fgai/threat_intel.py index df45e90..8327164 100644 --- a/src/fgai/threat_intel.py +++ b/src/fgai/threat_intel.py @@ -19,8 +19,8 @@ def is_public_ip(value: str | None) -> bool: class ThreatIntelClient: - def __init__(self, *, cache_file: str = "state/threat-intel-cache.json", ttl_seconds: int = 86400) -> None: - self.enabled = os.getenv("FGAI_THREAT_INTEL", "").lower() in {"1", "true", "yes", "on"} + def __init__(self, *, cache_file: str = "state/threat-intel-cache.json", ttl_seconds: int = 86400, enabled: bool | None = None) -> None: + self.enabled = os.getenv("FGAI_THREAT_INTEL", "").lower() in {"1", "true", "yes", "on"} if enabled is None else enabled self.abuseipdb_key = os.getenv("ABUSEIPDB_API_KEY") self.virustotal_key = os.getenv("VIRUSTOTAL_API_KEY") self.provider = os.getenv("FGAI_THREAT_INTEL_PROVIDER", "auto").lower() @@ -144,8 +144,10 @@ class ThreatIntelClient: } -def enrich_ips(ips: list[str], *, cache_file: str = "state/threat-intel-cache.json", limit: int = 25) -> dict[str, dict[str, object]]: - client = ThreatIntelClient(cache_file=cache_file) +def enrich_ips( + ips: list[str], *, cache_file: str = "state/threat-intel-cache.json", limit: int = 25, enabled: bool | None = None +) -> dict[str, dict[str, object]]: + client = ThreatIntelClient(cache_file=cache_file, enabled=enabled) enriched: dict[str, dict[str, object]] = {} for ip in ips[:limit]: enriched[ip] = client.lookup_ip(ip) diff --git a/start.sh b/start.sh index ab641f3..a17b6f3 100755 --- a/start.sh +++ b/start.sh @@ -11,6 +11,7 @@ LOG_ROTATE_COUNT="${FGAI_LOG_ROTATE_COUNT:-14}" LISTENER_LOG="${FGAI_LISTENER_LOG:-$ROOT_DIR/logs/fgai-listener.log}" POLICY_FILE="${FGAI_POLICY_FILE:-$ROOT_DIR/exports/policies.json}" STATE_FILE="${FGAI_STATE_FILE:-$ROOT_DIR/state/fgai-status.json}" +CONFIG_FILE="${FGAI_CONFIG_FILE:-$ROOT_DIR/state/fgai-config.json}" BASELINE_DB="${FGAI_BASELINE_DB:-$ROOT_DIR/state/fgai-baseline.sqlite3}" MONITOR_INTERVAL="${FGAI_MONITOR_INTERVAL:-10}" LLM_ENABLED="${FGAI_LLM:-0}" @@ -119,7 +120,7 @@ start_monitor() { return 0 fi - monitor_args=(monitor --logs "$LOG_FILE" --output "$STATE_FILE" --interval "$MONITOR_INTERVAL" --baseline-db "$BASELINE_DB") + monitor_args=(monitor --logs "$LOG_FILE" --output "$STATE_FILE" --interval "$MONITOR_INTERVAL" --baseline-db "$BASELINE_DB" --config-file "$CONFIG_FILE") if [ -f "$POLICY_FILE" ]; then monitor_args+=(--policies "$POLICY_FILE") fi @@ -149,6 +150,7 @@ start_dashboard() { --host "$DASHBOARD_HOST" \ --port "$DASHBOARD_PORT" \ --status-file "$STATE_FILE" \ + --config-file "$CONFIG_FILE" \ --image-dir "$ROOT_DIR/images" > "$DASHBOARD_LOG" 2>&1 & printf '%s\n' "$!" > "$DASHBOARD_PID_FILE" printf 'Started fgAI dashboard, pid %s\n' "$(cat "$DASHBOARD_PID_FILE")" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d4c9641 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,20 @@ +import tempfile +import unittest +from pathlib import Path + +from fgai.config import ConfigStore + + +class ConfigTests(unittest.TestCase): + def test_public_config_hides_token_and_preserves_blank_update(self): + with tempfile.TemporaryDirectory() as directory: + store = ConfigStore(str(Path(directory) / "config.json")) + store.update({"log_source": "graylog_mcp", "graylog_mcp_token": "secret"}) + public = store.update({"graylog_mcp_token": ""}) + self.assertEqual(public["log_source"], "graylog_mcp") + self.assertTrue(public["graylog_mcp_token_configured"]) + self.assertNotIn("graylog_mcp_token", public) + + +if __name__ == "__main__": + unittest.main()