fix(#2422): harden NixOS packaged runtime
This commit is contained in:
1 parent
1f505aad88
commit
70514549c4
7 files changed
+152
-32
No files matched your search
@@ -419,7 +419,7 @@ Service example:
|
||||
```nix
|
||||
services.hermes-webui = {
|
||||
enable = true;
|
||||
host = "0.0.0.0";
|
||||
host = "127.0.0.1";
|
||||
port = 8787;
|
||||
stateDir = "/var/lib/hermes-webui";
|
||||
agent.dir = "/var/lib/hermes/hermes-agent";
|
||||
@@ -427,9 +427,11 @@ services.hermes-webui = {
|
||||
};
|
||||
```
|
||||
|
||||
The module defaults to `0.0.0.0` for a network-reachable system service. Pair that with auth, for example `HERMES_WEBUI_PASSWORD` via `environmentFiles`, or override `host = "127.0.0.1"` when you want loopback-only access.
|
||||
The module defaults to `127.0.0.1`. Set `host = "0.0.0.0"` and `openFirewall = true` only when you want direct network access, and pair that with auth, for example `HERMES_WEBUI_PASSWORD` via `environmentFiles`.
|
||||
|
||||
You can also set `agent.package` instead of `agent.dir` when you are using a compatible Hermes Agent package layout. The module derives `HERMES_WEBUI_AGENT_DIR` from `<pkg>/share/hermes-agent` and sets `HERMES_WEBUI_PYTHON` from the package's `passthru.hermesVenv` interpreter so bootstrap can use agent dependencies without creating a local `.venv`.
|
||||
You can also set `agent.package` instead of `agent.dir` when you are using a compatible Hermes Agent package layout. When the package exposes `passthru.hermesVenv`, the module derives `HERMES_WEBUI_AGENT_DIR` from that venv's site-packages path and sets `HERMES_WEBUI_PYTHON` from the same venv interpreter so bootstrap can use agent dependencies without creating a local `.venv`. If the package does not expose that venv metadata, set `agent.dir` and `agent.python` explicitly.
|
||||
|
||||
When WebUI reads shared Hermes Agent state, run the service as a user that can already read that state. For a co-located Hermes Agent service, set `user` and `group` to the agent service account; the module only creates the default `hermes-webui` account and never changes ownership of an existing `hermesHome`.
|
||||
|
||||
The module maps directly onto existing WebUI environment variables, including:
|
||||
`HERMES_WEBUI_HOST`, `HERMES_WEBUI_PORT`, `HERMES_WEBUI_STATE_DIR`, `HERMES_HOME`, `HERMES_WEBUI_AGENT_DIR`, and `HERMES_WEBUI_PYTHON`.
|
||||
|
||||
@@ -270,6 +270,13 @@ def ensure_python_has_webui_deps(python_exe: str, agent_dir: Path | None = None)
|
||||
if _python_can_run_webui_and_agent(str(candidate), agent_dir):
|
||||
return str(candidate)
|
||||
|
||||
if (os.getenv("HERMES_WEBUI_DISABLE_LOCAL_VENV") or "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
raise RuntimeError(
|
||||
"Python environment cannot import both WebUI dependencies and Hermes Agent, "
|
||||
"and local .venv creation is disabled for this packaged launch. Set "
|
||||
"HERMES_WEBUI_PYTHON to an interpreter with Hermes Agent dependencies."
|
||||
)
|
||||
|
||||
venv_dir = REPO_ROOT / ".venv"
|
||||
venv_python = venv_dir / (
|
||||
"Scripts/python.exe" if platform.system() == "Windows" else "bin/python"
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
services.hermes-webui = {
|
||||
enable = true;
|
||||
package = package;
|
||||
host = "0.0.0.0";
|
||||
host = "127.0.0.1";
|
||||
port = 8787;
|
||||
stateDir = "/var/lib/hermes-webui";
|
||||
agent.dir = "/var/lib/hermes-agent";
|
||||
@@ -47,12 +47,25 @@
|
||||
module-env-mapping = pkgs.runCommand "hermes-webui-nixos-module-${system}" {
|
||||
nativeBuildInputs = [ pkgs.coreutils ];
|
||||
} ''
|
||||
grep -q 'HERMES_WEBUI_HOST=0.0.0.0' ${envProbe}
|
||||
grep -q 'HERMES_WEBUI_HOST=127.0.0.1' ${envProbe}
|
||||
grep -q 'HERMES_WEBUI_PORT=8787' ${envProbe}
|
||||
grep -q 'HERMES_WEBUI_STATE_DIR=/var/lib/hermes-webui' ${envProbe}
|
||||
grep -q 'HERMES_WEBUI_AGENT_DIR=/var/lib/hermes-agent' ${envProbe}
|
||||
touch "$out"
|
||||
'';
|
||||
runtime-layout = pkgs.runCommand "hermes-webui-runtime-layout-${system}" {
|
||||
nativeBuildInputs = [ pkgs.coreutils ];
|
||||
} ''
|
||||
test -f ${package}/hermes-webui/bootstrap.py
|
||||
test -f ${package}/hermes-webui/server.py
|
||||
test -d ${package}/hermes-webui/api
|
||||
test -d ${package}/hermes-webui/static
|
||||
cd ${package}/hermes-webui
|
||||
${package}/bin/hermes-webui --help >/dev/null
|
||||
${package}/bin/hermes-webui --help 2>&1 | grep -q -- '--foreground'
|
||||
PYTHONPATH=${package}/hermes-webui ${pkgs.python3.withPackages (ps: with ps; [ pyyaml cryptography ])}/bin/python3 -c 'import api.config, server; print("runtime imports ok")'
|
||||
touch "$out"
|
||||
'';
|
||||
}
|
||||
else
|
||||
{ };
|
||||
|
||||
+45
-25
@@ -15,6 +15,9 @@ let
|
||||
"HERMES_WEBUI_PYTHON"
|
||||
];
|
||||
|
||||
defaultUser = "hermes-webui";
|
||||
defaultGroup = "hermes-webui";
|
||||
|
||||
protectedEnvironmentFileCheck = pkgs.writeShellScript "hermes-webui-protected-envfile-check" ''
|
||||
set -eu
|
||||
for env_file in "$@"; do
|
||||
@@ -47,8 +50,16 @@ let
|
||||
inferredAgentDir =
|
||||
if cfg.agent.package == null then
|
||||
null
|
||||
else if (cfg.agent.package ? passthru) && (cfg.agent.package.passthru ? hermesVenv) then
|
||||
"${cfg.agent.package.passthru.hermesVenv}/lib/python3.12/site-packages"
|
||||
else
|
||||
"${cfg.agent.package}/share/hermes-agent";
|
||||
null;
|
||||
|
||||
configuredAgentPython =
|
||||
if cfg.agent.python != null then
|
||||
cfg.agent.python
|
||||
else
|
||||
inferredAgentPython;
|
||||
|
||||
mappedEnvironment = (lib.mapAttrsToList
|
||||
(name: value: "${name}=${value}")
|
||||
@@ -66,27 +77,18 @@ let
|
||||
// lib.optionalAttrs (cfg.agent.dir == null && inferredAgentDir != null) {
|
||||
HERMES_WEBUI_AGENT_DIR = inferredAgentDir;
|
||||
}
|
||||
// lib.optionalAttrs (cfg.agent.dir == null && inferredAgentPython != null) {
|
||||
HERMES_WEBUI_PYTHON = inferredAgentPython;
|
||||
// lib.optionalAttrs (configuredAgentPython != null) {
|
||||
HERMES_WEBUI_PYTHON = configuredAgentPython;
|
||||
}
|
||||
// lib.filterAttrs
|
||||
(name: _: !(lib.elem name protectedEnvironment))
|
||||
cfg.extraEnvironment));
|
||||
|
||||
needsWritableStateDir = cfg.stateDir != defaultStateDir;
|
||||
writableServiceDirs =
|
||||
lib.optionals needsWritableStateDir [ cfg.stateDir ]
|
||||
++ lib.optionals (cfg.hermesHome != null) [ cfg.hermesHome ];
|
||||
|
||||
userspaceDirs = lib.optionals (cfg.hermesHome != null) [
|
||||
"d ${cfg.hermesHome} 2770 ${cfg.user} ${cfg.group} - -"
|
||||
writableServiceDirs = lib.optionals needsWritableStateDir [ cfg.stateDir ];
|
||||
tmpfilesRules = lib.optionals needsWritableStateDir [
|
||||
"d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
tmpfilesRules =
|
||||
(lib.optionals needsWritableStateDir [
|
||||
"d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -"
|
||||
])
|
||||
++ userspaceDirs;
|
||||
in
|
||||
{
|
||||
options.services.hermes-webui = {
|
||||
@@ -101,19 +103,19 @@ in
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "hermes-webui";
|
||||
default = defaultUser;
|
||||
description = "User that runs the Hermes WebUI service.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "hermes-webui";
|
||||
default = defaultGroup;
|
||||
description = "Group that runs the Hermes WebUI service.";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "0.0.0.0";
|
||||
default = "127.0.0.1";
|
||||
description = "Value for HERMES_WEBUI_HOST.";
|
||||
};
|
||||
|
||||
@@ -123,6 +125,12 @@ in
|
||||
description = "Value for HERMES_WEBUI_PORT.";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Open the configured TCP port in the NixOS firewall.";
|
||||
};
|
||||
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.strMatching "^/.+";
|
||||
default = defaultStateDir;
|
||||
@@ -140,7 +148,7 @@ in
|
||||
package = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
default = null;
|
||||
description = "Package to derive HERMES_WEBUI_AGENT_DIR from when `agent.dir` is unset.";
|
||||
description = "Package to derive HERMES_WEBUI_AGENT_DIR and HERMES_WEBUI_PYTHON from when it exposes passthru.hermesVenv.";
|
||||
};
|
||||
|
||||
dir = lib.mkOption {
|
||||
@@ -148,6 +156,12 @@ in
|
||||
default = null;
|
||||
description = "Explicit path for HERMES_WEBUI_AGENT_DIR.";
|
||||
};
|
||||
|
||||
python = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.strMatching "^/.+");
|
||||
default = null;
|
||||
description = "Explicit path for HERMES_WEBUI_PYTHON when the service must run with agent dependencies from a separately managed environment.";
|
||||
};
|
||||
};
|
||||
|
||||
environmentFiles = lib.mkOption {
|
||||
@@ -187,13 +201,19 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = { };
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
createHome = true;
|
||||
home = cfg.stateDir;
|
||||
users.groups = lib.mkIf (cfg.group == defaultGroup) {
|
||||
${cfg.group} = { };
|
||||
};
|
||||
|
||||
users.users = lib.mkIf (cfg.user == defaultUser) {
|
||||
${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
createHome = true;
|
||||
home = cfg.stateDir;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = tmpfilesRules;
|
||||
|
||||
+3
-2
@@ -27,10 +27,11 @@ pkgs.stdenv.mkDerivation {
|
||||
cp "${./../server.py}" "$out/${runtimeDir}/server.py"
|
||||
cp "${./../mcp_server.py}" "$out/${runtimeDir}/mcp_server.py"
|
||||
cp "${./../requirements.txt}" "$out/${runtimeDir}/requirements.txt"
|
||||
cp -r "${./../api}" "$out/${runtimeDir}/"
|
||||
cp -r "${./../static}" "$out/${runtimeDir}/"
|
||||
cp -r "${./../api}" "$out/${runtimeDir}/api"
|
||||
cp -r "${./../static}" "$out/${runtimeDir}/static"
|
||||
|
||||
makeWrapper ${pythonEnv}/bin/python3 "$out/bin/hermes-webui" \
|
||||
--set HERMES_WEBUI_DISABLE_LOCAL_VENV 1 \
|
||||
--add-flags "$out/${runtimeDir}/bootstrap.py --foreground --no-browser --skip-agent-install"
|
||||
|
||||
runHook postInstall
|
||||
|
||||
@@ -72,6 +72,24 @@ def test_ensure_python_fails_loudly_when_no_interpreter_can_import_agent(monkeyp
|
||||
raise AssertionError("expected RuntimeError")
|
||||
|
||||
|
||||
def test_packaged_launch_disables_repo_local_venv_creation(monkeypatch, tmp_path):
|
||||
local_python = tmp_path / "webui" / ".venv" / "bin" / "python"
|
||||
monkeypatch.setattr(bootstrap, "REPO_ROOT", tmp_path)
|
||||
monkeypatch.setenv("HERMES_WEBUI_DISABLE_LOCAL_VENV", "1")
|
||||
monkeypatch.setattr(bootstrap, "_python_can_run_webui_and_agent", lambda *a, **k: False)
|
||||
|
||||
with patch.object(bootstrap.venv, "EnvBuilder") as mock_builder:
|
||||
try:
|
||||
bootstrap.ensure_python_has_webui_deps(str(local_python), tmp_path / "agent")
|
||||
except RuntimeError as exc:
|
||||
assert "local .venv creation is disabled" in str(exc)
|
||||
assert "HERMES_WEBUI_PYTHON" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected RuntimeError")
|
||||
|
||||
mock_builder.assert_not_called()
|
||||
|
||||
|
||||
def test_local_venv_is_created_with_symlinks(monkeypatch, tmp_path):
|
||||
"""Regression: mise/asdf macOS Pythons need symlinks=True to avoid SIGABRT.
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FLAKE_NIX = (ROOT / "flake.nix").read_text(encoding="utf-8")
|
||||
PACKAGES_NIX = (ROOT / "nix" / "packages.nix").read_text(encoding="utf-8")
|
||||
MODULE_NIX = (ROOT / "nix" / "nixosModules.nix").read_text(encoding="utf-8")
|
||||
README = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_nix_package_installs_runtime_directories_with_stable_names():
|
||||
assert 'cp -r "${./../api}" "$out/${runtimeDir}/api"' in PACKAGES_NIX
|
||||
assert 'cp -r "${./../static}" "$out/${runtimeDir}/static"' in PACKAGES_NIX
|
||||
|
||||
|
||||
def test_flake_check_covers_runtime_layout_and_wrapper_help():
|
||||
assert "runtime-layout" in FLAKE_NIX
|
||||
assert "test -d ${package}/hermes-webui/api" in FLAKE_NIX
|
||||
assert "test -d ${package}/hermes-webui/static" in FLAKE_NIX
|
||||
assert "${package}/bin/hermes-webui --help >/dev/null" in FLAKE_NIX
|
||||
assert "import api.config, server" in FLAKE_NIX
|
||||
|
||||
|
||||
def test_nix_package_disables_store_local_venv_fallback():
|
||||
assert "--set HERMES_WEBUI_DISABLE_LOCAL_VENV 1" in PACKAGES_NIX
|
||||
|
||||
|
||||
def test_nixos_module_decouples_agent_dir_from_python_inference():
|
||||
assert "agent.python" in MODULE_NIX
|
||||
assert "configuredAgentPython" in MODULE_NIX
|
||||
assert "configuredAgentPython != null" in MODULE_NIX
|
||||
assert "cfg.agent.dir == null && configuredAgentPython" not in MODULE_NIX
|
||||
|
||||
|
||||
def test_nixos_module_uses_agent_venv_site_packages_when_available():
|
||||
assert "${cfg.agent.package.passthru.hermesVenv}/lib/python3.12/site-packages" in MODULE_NIX
|
||||
assert "${cfg.agent.package}/share/hermes-agent" not in MODULE_NIX
|
||||
|
||||
|
||||
def test_nixos_module_does_not_chown_existing_hermes_home():
|
||||
assert "d ${cfg.hermesHome}" not in MODULE_NIX
|
||||
|
||||
|
||||
def test_nixos_module_only_creates_default_service_identity():
|
||||
assert "cfg.group == defaultGroup" in MODULE_NIX
|
||||
assert "cfg.user == defaultUser" in MODULE_NIX
|
||||
assert "users.groups.${cfg.group}" not in MODULE_NIX
|
||||
assert "users.users.${cfg.user}" not in MODULE_NIX
|
||||
|
||||
|
||||
def test_nixos_module_defaults_to_loopback_with_explicit_firewall_opt_in():
|
||||
nix_section = README.split("### Nix flake and NixOS module", 1)[1].split(
|
||||
"### Remote access", 1
|
||||
)[0]
|
||||
assert 'default = "127.0.0.1";' in MODULE_NIX
|
||||
assert "openFirewall" in MODULE_NIX
|
||||
assert "networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];" in MODULE_NIX
|
||||
assert 'host = "127.0.0.1";' in nix_section
|
||||
assert 'Set `host = "0.0.0.0"` and `openFirewall = true`' in nix_section
|
||||
Reference in new issue
Block a user