Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ replacing a module has to be a decision with visible consequences.

## Unreleased

- **`/processes` no longer returns command lines, and wrong keys no longer cost PBKDF2.**
`/processes` listed each host process's command line, which can carry a password or
a key; it now returns the same metadata as `/runtime` (pid, name, user, CPU, memory).
Every admin request ran 200,000 rounds of PBKDF2, so anyone who could reach the port
could tie up the service with wrong keys; after the first match (at startup, from the
configured key) a constant-time SHA-256 compare decides. Journal and package output
loses terminal control codes, and a command that cannot run reports only its kind of
error.

## 0.4.0

- **Remove Docker daemon and host-root authority from the shipped container.**
Expand Down
4 changes: 3 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ SystemGate is intentionally narrow. It exists so a local dashboard or agent harn

- **Read-only API**: there are no write, exec, restart, package-install, file-edit, or Docker mutation endpoints. Package and log collection shell out to a fixed, non-injectable command set; no caller input reaches a shell.
- **Admin-key auth**: every endpoint except `/health` requires `X-SystemGate-Key`.
- **PBKDF2 key storage**: the first configured `SYSTEMGATE_ADMIN_KEY` is stored as a PBKDF2 hash under `data/admin-key.pbkdf2`; the raw key is not stored by SystemGate.
- **PBKDF2 key storage**: the first configured `SYSTEMGATE_ADMIN_KEY` is stored as a PBKDF2 hash under `data/admin-key.pbkdf2`; the raw key is not stored by SystemGate. Once a key has matched (at startup, from the configured key), later requests compare a SHA-256 of the presented key in constant time instead of running PBKDF2 again, so a caller sending wrong keys cannot tie up the service with slow hashing. A new key file is checked in full again.
- **Server-side proxy friendly**: a dashboard can call SystemGate from its backend so the browser never receives the SystemGate key.
- **Loopback binding**: compose publishes the API on `127.0.0.1:8040` only.
- **Private Docker network**: the standalone compose file uses `systemgate_net` for local service-to-service traffic.
Expand All @@ -13,6 +13,8 @@ SystemGate is intentionally narrow. It exists so a local dashboard or agent harn
- **Container-scoped disk figures**: the host root is not visible to SystemGate. `/vitals` reports the disk path and scope so callers do not mistake container filesystem capacity for host capacity.
- **Says which machine it measured**: `/vitals` reports `source.scope` as `host` or `container`, with the procfs and disk paths actually in use - read back from psutil, not from the configuration, so it reports where the numbers came from rather than where they were asked to come from. Host figures require `SYSTEMGATE_PROCFS_PATH=/host/proc`, the host root mount and `uts: host`, all set by the bundled compose file. psutil honours no environment variable for procfs redirection, so SystemGate assigns `psutil.PROCFS_PATH` itself at import; setting an environment variable alone silently does nothing.
- **Bounded outputs**: process, log, package, backup, and container responses are capped so host telemetry cannot become an unbounded data leak.
- **No command lines**: `/processes`, `/services` and `/runtime` never collect a process's command line or environment, which often carry passwords and keys.
- **Clean text**: log and package output loses terminal control codes, and a command that cannot run is reported by its kind of error, not its message.
- **No secret logging**: endpoints return system telemetry only; admin keys and upstream service secrets are not returned in responses.
- **Telemetry only**: SystemGate can observe process lists, packages, journal errors, vitals, and backup timestamps, but it cannot change them. Container telemetry requires a future least-authority collector contract; it is not inferred from daemon access.

Expand Down
16 changes: 15 additions & 1 deletion systemgate/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,29 @@ def hash_key(key: str, salt: bytes | None = None) -> str:
return f"{base64.b64encode(salt).decode()}${base64.b64encode(digest).decode()}"


# Stored hash -> SHA-256 of the key that matched it. PBKDF2 is slow on purpose, and paying it on
# every request let any caller that reaches the port burn CPU with wrong keys. Once a key has
# matched, a fast constant-time compare decides; a changed key file is a new entry.
_matched: dict[str, bytes] = {}


def verify_key(key: str | None, encoded: str) -> bool:
if not key or not encoded:
return False
fast = hashlib.sha256(key.encode("utf-8")).digest()
if encoded in _matched:
return hmac.compare_digest(fast, _matched[encoded])
try:
salt_b64, digest_b64 = encoded.split("$", 1)
salt = base64.b64decode(salt_b64.encode())
expected = base64.b64decode(digest_b64.encode())
except Exception:
return False
actual = hashlib.pbkdf2_hmac("sha256", key.encode("utf-8"), salt, PBKDF2_ROUNDS)
return hmac.compare_digest(actual, expected)
if not hmac.compare_digest(actual, expected):
return False
if len(_matched) >= 8:
_matched.clear()
_matched[encoded] = fast
return True

25 changes: 18 additions & 7 deletions systemgate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import platform
import re
import subprocess
import time
from contextlib import asynccontextmanager
Expand Down Expand Up @@ -38,8 +39,13 @@ def procfs_path() -> str:
return str(getattr(psutil, "PROCFS_PATH", "/proc"))


# Terminal control codes: C0 except tab and newline, DEL and C1. Journal lines carry whatever any
# host process printed, and an escape sequence must not reach a terminal-style view intact.
_CONTROL = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f]")


def _bounded(value: str, limit: int = 4000) -> str:
return value[-limit:]
return _CONTROL.sub("", value)[-limit:]


def _secret_path(settings: Settings) -> Path:
Expand Down Expand Up @@ -69,6 +75,9 @@ def require_admin(request: Request, x_systemgate_key: str | None = Header(None,
async def lifespan(app: FastAPI):
settings = get_settings()
_ensure_key_hash(settings)
if settings.admin_key:
# One slow check now, so a wrong key never costs a slow check later (see verify_key).
verify_key(settings.admin_key, _secret_path(settings).read_text(encoding="utf-8").strip())
app.state.settings = settings
app.state.package_cache = {"at": 0.0, "value": None}
yield
Expand Down Expand Up @@ -243,15 +252,17 @@ def containers():
}


# Metadata only, like /runtime: a command line can carry a password or a key.
_PROCESS_FIELDS = ["pid", "name", "username", "cpu_percent", "memory_percent"]


@app.get("/processes", dependencies=[Depends(require_admin)])
def processes(limit: int = 20):
limit = max(1, min(limit, 100))
rows = []
for proc in psutil.process_iter(["pid", "name", "username", "cpu_percent", "memory_percent", "cmdline"]):
for proc in psutil.process_iter(_PROCESS_FIELDS):
try:
item = proc.info
item["cmdline"] = " ".join(item.get("cmdline") or [])[:300]
rows.append(item)
rows.append({field: proc.info.get(field) for field in _PROCESS_FIELDS})
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
rows.sort(key=lambda item: (float(item.get("cpu_percent") or 0), float(item.get("memory_percent") or 0)), reverse=True)
Expand All @@ -266,7 +277,7 @@ def _journal_errors() -> str:
result = subprocess.run(command, check=False, capture_output=True, text=True, timeout=8)
chunks.append(result.stdout or result.stderr)
except Exception as exc:
chunks.append(f"{command[0]} unavailable: {exc}")
chunks.append(f"{command[0]} unavailable: {type(exc).__name__}")
return _bounded("\n".join(chunks), 12000)


Expand All @@ -280,7 +291,7 @@ def _run_summary(command: list[str]) -> dict[str, Any]:
result = subprocess.run(command, check=False, capture_output=True, text=True, timeout=12)
return {"ok": result.returncode == 0, "output": _bounded(result.stdout or result.stderr, 2000)}
except Exception as exc:
return {"ok": False, "output": str(exc)}
return {"ok": False, "output": type(exc).__name__}


@app.get("/packages", dependencies=[Depends(require_admin)])
Expand Down
49 changes: 49 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

import pytest

from systemgate import auth


@pytest.fixture
def slow(monkeypatch):
"""Every PBKDF2 run, with a fresh cache of matched keys."""
monkeypatch.setattr(auth, "_matched", {})
calls = []
real = auth.hashlib.pbkdf2_hmac
monkeypatch.setattr(auth.hashlib, "pbkdf2_hmac", lambda *args: calls.append(args) or real(*args))
return calls


def test_a_matched_key_is_not_hashed_again(slow):
encoded = auth.hash_key("right-key-1234")
slow.clear()

assert auth.verify_key("right-key-1234", encoded)
assert len(slow) == 1
assert auth.verify_key("right-key-1234", encoded)
assert not auth.verify_key("wrong-key-1234", encoded)
assert not auth.verify_key("right-key-12345", encoded)
assert len(slow) == 1


def test_a_wrong_key_before_any_match_still_runs_the_full_check(slow):
encoded = auth.hash_key("right-key-1234")
slow.clear()

assert not auth.verify_key("wrong-key-1234", encoded)
assert not auth.verify_key("wrong-key-1234", encoded)
assert len(slow) == 2


def test_a_new_key_file_is_checked_in_full(slow):
first = auth.hash_key("right-key-1234")
assert auth.verify_key("right-key-1234", first)
rotated = auth.hash_key("other-key-5678")
slow.clear()

assert not auth.verify_key("right-key-1234", rotated)
assert auth.verify_key("other-key-5678", rotated)
assert len(slow) == 2
assert auth.verify_key("other-key-5678", rotated)
assert len(slow) == 2
58 changes: 58 additions & 0 deletions tests/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,61 @@ def test_processes_logs_packages_and_backups_are_bounded(tmp_path, monkeypatch):
assert backup_state["latest"] is None
assert backup_state["rejected"][0]["name"] == "20260101T000000Z"
assert backup_state["rejected"][0]["status"] == "incomplete"


def test_processes_never_return_command_lines(tmp_path, monkeypatch):
import systemgate.main as main
asked = []
info = {"pid": 7, "name": "app", "username": "me", "cpu_percent": 0.0, "memory_percent": 0.0,
"cmdline": ["app", "--password", "hunter2"], "environ": {"TOKEN": "hunter2"}}

def process_iter(fields):
asked.extend(fields)
return [Mock(info=info)]

monkeypatch.setattr(main.psutil, "process_iter", process_iter)

with make_client(tmp_path, monkeypatch) as client:
response = client.get("/processes", headers=headers())

assert response.json()["results"] == [
{"pid": 7, "name": "app", "username": "me", "cpu_percent": 0.0, "memory_percent": 0.0}
]
assert "cmdline" not in asked
assert "hunter2" not in response.text


def test_host_output_loses_terminal_control_codes(monkeypatch):
import systemgate.main as main

assert main._bounded("\x1b[31mred\x07\n\tok\x9b\x7f") == "[31mred\n\tok"
assert main._bounded("ab\x1bcd", 3) == "bcd"
output = Mock(stdout="\x1b]0;title\x07disk full\n", stderr="")
monkeypatch.setattr(main.subprocess, "run", lambda *args, **kwargs: output)
assert main._journal_errors() == "]0;titledisk full\n"
assert main._run_summary(["pip"])["output"] == "]0;titledisk full\n"


def test_a_failed_command_reports_only_the_kind_of_error(monkeypatch):
import systemgate.main as main

def run(*args, **kwargs):
raise FileNotFoundError(2, "No such file or directory", "/srv/private/journalctl")

monkeypatch.setattr(main.subprocess, "run", run)

assert main._run_summary(["apt"]) == {"ok": False, "output": "FileNotFoundError"}
assert main._journal_errors() == "journalctl unavailable: FileNotFoundError"


def test_startup_primes_the_key_check_so_wrong_keys_stay_cheap(tmp_path, monkeypatch):
from systemgate import auth
monkeypatch.setattr(auth, "_matched", {})
with make_client(tmp_path, monkeypatch) as client:
slow = []
real = auth.hashlib.pbkdf2_hmac
monkeypatch.setattr(auth.hashlib, "pbkdf2_hmac", lambda *args: slow.append(args) or real(*args))
assert client.get("/vitals", headers={"X-SystemGate-Key": "wrong-key-0000"}).status_code == 401
assert client.get("/processes", headers=headers()).status_code == 200

assert slow == []
Loading