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.
+
@@ -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()