From efa8a7ebb5f56ea3188caed593c77e49e543d60f Mon Sep 17 00:00:00 2001
From: dev-luigi <70869541+dev-luigi@users.noreply.github.com>
Date: Fri, 24 Jul 2026 21:00:25 +0200
Subject: [PATCH 1/7] feat(260724-sx0): make session expiry configurable
end-to-end
- add parse_duration_seconds() pure parser to config.py (24h/90m/1d/45s/bare
seconds; invalid or non-positive -> 24h default, never raises)
- AuthManager.session_expiry_seconds feeds create_session_token exp; keep the
SESSION_EXPIRY_SECONDS constant as the 24h fallback default
- _set_session_cookie takes max_age; login/setup/configure pass the configured
expiry so the cookie Max-Age matches the token exp (no more hardcoded 86400)
- lifespan wires config.auth.session_expiry -> auth.session_expiry_seconds
- tests/unit/test_session_expiry.py: parser + token-exp + end-to-end cookie proof
---
backend/api/auth_routes.py | 14 ++--
backend/core/auth.py | 9 ++-
backend/core/config.py | 19 ++++++
backend/main.py | 13 +++-
tests/unit/test_session_expiry.py | 109 ++++++++++++++++++++++++++++++
5 files changed, 156 insertions(+), 8 deletions(-)
create mode 100644 tests/unit/test_session_expiry.py
diff --git a/backend/api/auth_routes.py b/backend/api/auth_routes.py
index dacbdf4..d33cc0f 100644
--- a/backend/api/auth_routes.py
+++ b/backend/api/auth_routes.py
@@ -11,7 +11,7 @@
router = APIRouter()
-def _set_session_cookie(response: Response, request: Request, token: str) -> None:
+def _set_session_cookie(response: Response, request: Request, token: str, max_age: int) -> None:
"""Issue the session cookie, setting secure=True when the request arrived over HTTPS.
Decision R (04-W4-03): all three cookie issuers (login / setup / configure) route through
@@ -19,13 +19,17 @@ def _set_session_cookie(response: Response, request: Request, token: str) -> Non
request.url.scheme == "https" — true when uvicorn terminates TLS (config.server.https on)
or a TLS-terminating reverse proxy forwards the scheme. On plain HTTP it stays False so
LAN-only HTTP deployments keep working.
+
+ SX0-A: ``max_age`` is the configured session lifetime in seconds
+ (auth.session_expiry_seconds), so the cookie Max-Age matches the token exp instead of a
+ hardcoded 24h.
"""
response.set_cookie(
key="session",
value=token,
httponly=True,
samesite="lax",
- max_age=86400,
+ max_age=max_age,
secure=request.url.scheme == "https",
)
@@ -122,7 +126,7 @@ async def login(body: LoginRequest, request: Request, response: Response, lang:
# 3. Success: clear any prior failure counter, issue session.
await auth.reset_failures(body.username)
token = auth.create_session_token(body.username)
- _set_session_cookie(response, request, token)
+ _set_session_cookie(response, request, token, auth.session_expiry_seconds)
return {"success": True, "username": body.username}
@@ -139,7 +143,7 @@ async def setup(body: SetupRequest, request: Request, response: Response, lang:
return {"success": False, "error": t("user_already_exists", lang)}
await auth.create_user(body.username, body.password)
token = auth.create_session_token(body.username)
- _set_session_cookie(response, request, token)
+ _set_session_cookie(response, request, token, auth.session_expiry_seconds)
return {"success": True, "username": body.username}
@@ -161,7 +165,7 @@ async def configure_auth(body: ConfigureRequest, request: Request, response: Res
return {"success": False, "error": str(e)}
await auth.set_auth_enabled(True)
token = auth.create_session_token(body.username)
- _set_session_cookie(response, request, token)
+ _set_session_cookie(response, request, token, auth.session_expiry_seconds)
return {"success": True, "username": body.username}
diff --git a/backend/core/auth.py b/backend/core/auth.py
index 73d2de1..6e3eb33 100644
--- a/backend/core/auth.py
+++ b/backend/core/auth.py
@@ -21,12 +21,17 @@
logger = logging.getLogger("ipmideck.auth")
-SESSION_EXPIRY_SECONDS = 86400 # 24h default
+SESSION_EXPIRY_SECONDS = 86400 # 24h — fallback default when config supplies no/invalid value
class AuthManager:
def __init__(self, db: Database):
self.db = db
+ # SX0-A: session-token / cookie lifetime in seconds. Defaults to the 24h fallback
+ # constant; main.py lifespan overwrites it from config.auth.session_expiry
+ # (IPMIDECK_AUTH_SESSION_EXPIRY) via parse_duration_seconds. Kept == the module
+ # constant by default so the auth_manager fixture (no config) stays at 24h.
+ self.session_expiry_seconds: int = SESSION_EXPIRY_SECONDS
# Session-token HMAC signing secret (kept in memory after initialize()).
# Persisted separately under app_config['session_secret'] — distinct from the
# at-rest credential encryption key, which lives in data/encryption.key.
@@ -398,7 +403,7 @@ def create_session_token(self, username: str) -> str:
payload = {
"sub": username,
"iat": int(time.time()),
- "exp": int(time.time()) + SESSION_EXPIRY_SECONDS,
+ "exp": int(time.time()) + self.session_expiry_seconds,
}
data = json.dumps(payload, separators=(",", ":"))
sig = hmac.new(self._secret.encode(), data.encode(), hashlib.sha256).hexdigest()
diff --git a/backend/core/config.py b/backend/core/config.py
index 4242b57..20978bc 100644
--- a/backend/core/config.py
+++ b/backend/core/config.py
@@ -3,11 +3,30 @@
from __future__ import annotations
import os
+import re
from dataclasses import dataclass, field
from pathlib import Path
import yaml
+_DURATION_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
+_DURATION_RE = re.compile(r"^(\d+)([smhd]?)$")
+
+
+def parse_duration_seconds(value: str | int | None, default: int = 86400) -> int:
+ """Parse a duration like '24h', '90m', '1d', '45s', or a bare integer (seconds) into
+ seconds. Invalid / non-positive input returns ``default`` (never raises)."""
+ if value is None or isinstance(value, bool):
+ # bool is an int subclass — reject it explicitly so True/False can't slip through.
+ return default
+ if isinstance(value, int):
+ return value if value > 0 else default
+ match = _DURATION_RE.match(value.strip().lower())
+ if not match:
+ return default
+ seconds = int(match.group(1)) * _DURATION_UNITS[match.group(2) or "s"]
+ return seconds if seconds > 0 else default
+
def _data_dir() -> Path:
return Path(os.environ.get("IPMIDECK_DATA_DIR", "/data" if os.name != "nt" else "./data"))
diff --git a/backend/main.py b/backend/main.py
index d695d14..89dd1d8 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -21,7 +21,13 @@
from backend.core.auth import AuthManager, require_auth
from backend.core.branding import APP_NAME, VERSION, credits_line, render_banner_safe
-from backend.core.config import AppConfig, load_config, save_default_config, update_server_yaml
+from backend.core.config import (
+ AppConfig,
+ load_config,
+ parse_duration_seconds,
+ save_default_config,
+ update_server_yaml,
+)
from backend.core.logging_util import suppress_noisy_loggers
from backend.core.database import Database
from backend.core.modules import ModuleLoader
@@ -246,6 +252,11 @@ async def lifespan(app: FastAPI):
# Initialize auth
auth = AuthManager(db)
await auth.initialize()
+ # SX0-A: thread the configured session lifetime (config.auth.session_expiry /
+ # IPMIDECK_AUTH_SESSION_EXPIRY) into BOTH the token exp and the cookie max_age. This is
+ # the single parse point; invalid values fall back to the 24h default without raising.
+ # Only consulted when auth is enabled — no change to the is_auth_enabled() gating.
+ auth.session_expiry_seconds = parse_duration_seconds(config.auth.session_expiry)
# 08-04 (D-16): in demo mode, seed one synthetic server per canonical vendor so the
# per-vendor journeys (tier badges, monitoring-only warnings, loop-skip, argv routing)
diff --git a/tests/unit/test_session_expiry.py b/tests/unit/test_session_expiry.py
new file mode 100644
index 0000000..7dfb6fb
--- /dev/null
+++ b/tests/unit/test_session_expiry.py
@@ -0,0 +1,109 @@
+"""Session-expiry configurability tests (SX0-A).
+
+Proves the three legs of `IPMIDECK_AUTH_SESSION_EXPIRY` / `auth.session_expiry`:
+ 1. parse_duration_seconds is a pure, never-raising string/int -> seconds parser with a 24h
+ fallback for anything invalid (unit tests, no I/O).
+ 2. AuthManager mints a token whose exp - iat == its session_expiry_seconds (unit, no config).
+ 3. End-to-end: a non-default env value drives the issued cookie Max-Age through the real app
+ lifespan (config re-load + wiring), using the conftest env-before-import pattern.
+
+asyncio_mode="auto" (pyproject) => async tests need NO decorator.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+
+import pytest
+from fastapi.testclient import TestClient
+
+from backend.core.config import parse_duration_seconds
+
+
+# === 1. pure parser ===
+
+
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ ("24h", 86400),
+ ("90m", 5400),
+ ("1h", 3600),
+ ("1d", 86400),
+ ("45s", 45),
+ ("3600", 3600), # bare integer string = seconds
+ (3600, 3600), # bare integer
+ (" 1H ", 3600), # whitespace + case-insensitive
+ ("2D", 172800),
+ ],
+)
+def test_parse_duration_valid(value, expected):
+ assert parse_duration_seconds(value, default=86400) == expected
+
+
+@pytest.mark.parametrize(
+ "value",
+ ["", "abc", "-5", "0", "12x", None, "1.5h", "h", " ", "0s", "0h", True, False],
+)
+def test_parse_duration_invalid_returns_default(value):
+ """Invalid / non-positive input falls back to the default and never raises."""
+ assert parse_duration_seconds(value, default=86400) == 86400
+
+
+def test_parse_duration_respects_custom_default():
+ assert parse_duration_seconds("nonsense", default=1234) == 1234
+
+
+# === 2. token exp reflects the instance session_expiry_seconds ===
+
+
+def _decode_payload(token: str) -> dict:
+ """Decode the base64url-encoded JSON payload half of a session token."""
+ b64 = token.rsplit(".", 1)[0]
+ raw = base64.urlsafe_b64decode(b64 + "=" * (-len(b64) % 4)).decode()
+ return json.loads(raw)
+
+
+async def test_token_exp_matches_session_expiry_seconds(auth_manager):
+ am, _db = auth_manager
+ am.session_expiry_seconds = 3600
+ payload = _decode_payload(am.create_session_token("alice"))
+ assert payload["exp"] - payload["iat"] == 3600
+
+
+async def test_token_exp_default_is_24h(auth_manager):
+ """A fresh AuthManager (no config wiring) keeps the 24h fallback default."""
+ am, _db = auth_manager
+ payload = _decode_payload(am.create_session_token("alice"))
+ assert payload["exp"] - payload["iat"] == 86400
+
+
+# === 3. end-to-end: env value -> cookie Max-Age ===
+
+
+def test_configured_expiry_drives_cookie_max_age(tmp_path, monkeypatch):
+ """IPMIDECK_AUTH_SESSION_EXPIRY="1h" (auth ON, fresh temp DB) yields Max-Age=3600 on the
+ session cookie AND a token whose exp - iat == 3600.
+
+ Env is set BEFORE importing backend.main (conftest Pitfall 3): the lifespan re-runs
+ load_config() so the override + SX0-A wiring apply. Throwaway synthetic creds only.
+ """
+ monkeypatch.setenv("IPMIDECK_DATA_DIR", str(tmp_path))
+ monkeypatch.setenv("IPMIDECK_DEMO", "true")
+ monkeypatch.setenv("IPMIDECK_DATA_DB_PATH", str(tmp_path / "ipmideck.db"))
+ monkeypatch.setenv("IPMIDECK_AUTH_SESSION_EXPIRY", "1h")
+
+ from backend.main import app # import AFTER env is set
+
+ with TestClient(app) as c:
+ # Auth defaults ON in the fresh temp DB, so /setup issues the first session cookie.
+ r = c.post("/api/auth/setup", json={"username": "admin", "password": "correcthorse"})
+ assert r.status_code == 200, r.text
+ assert r.json()["success"] is True
+ set_cookie = r.headers["set-cookie"]
+ assert "Max-Age=3600" in set_cookie, set_cookie
+ # The token payload exp window matches too (not just the cookie attribute).
+ token = c.cookies["session"]
+ payload = _decode_payload(token)
+ assert payload["exp"] - payload["iat"] == 3600
From 43fc84aaaaa7b76620f48194f039ec0f032b22d1 Mon Sep 17 00:00:00 2001
From: dev-luigi <70869541+dev-luigi@users.noreply.github.com>
Date: Fri, 24 Jul 2026 21:02:31 +0200
Subject: [PATCH 2/7] fix(260724-sx0): FanPilot /status no longer false-active
for monitoring-only vendors
- get_fanpilot_status guards the cold-start 'auto'->'fanpilot' fallback behind
is_fan_capable(vendor); HPE/Lenovo/generic (no IPMI fan control) now report
the truthful 'auto' instead of a false 'fanpilot active'
- SELECT vendor; default NULL/empty vendor to 'dell' to match /mode Decision G
- fan-capable vendors (dell/supermicro/ibm) keep the prior 'fanpilot' behavior
- integration tests: HPE reports 'auto', dell still reports 'fanpilot'
---
backend/modules/fanpilot/routes.py | 9 ++++--
tests/integration/test_api_routes.py | 46 ++++++++++++++++++++++++++++
2 files changed, 53 insertions(+), 2 deletions(-)
diff --git a/backend/modules/fanpilot/routes.py b/backend/modules/fanpilot/routes.py
index e6214b5..599ebe6 100644
--- a/backend/modules/fanpilot/routes.py
+++ b/backend/modules/fanpilot/routes.py
@@ -8,6 +8,7 @@
from pydantic import BaseModel, Field
from backend.core.i18n import get_lang, t
+from backend.core.ipmi_service import is_fan_capable
from backend.modules import get_ctx
from backend.modules.fanpilot.tasks import get_last_state, set_last_state, wake_loop
from backend.modules.sensors.tasks import wake_loop as wake_sensor_loop
@@ -152,7 +153,8 @@ async def delete_profile(profile_id: int, lang: str = Depends(get_lang)):
async def get_fanpilot_status(server_id: str, lang: str = Depends(get_lang)):
ctx = get_ctx() # Fresh lookup — live ctx (Decision J)
server = await ctx.db.fetchone(
- "SELECT fanpilot_enabled, fanpilot_profile_id FROM servers WHERE id = ?", (server_id,)
+ "SELECT fanpilot_enabled, fanpilot_profile_id, vendor FROM servers WHERE id = ?",
+ (server_id,),
)
if not server:
return {"success": False, "error": t("server_not_found", lang)}
@@ -168,7 +170,10 @@ async def get_fanpilot_status(server_id: str, lang: str = Depends(get_lang)):
# FanPilot is enabled, trust the DB (the loop will refresh `speed_pct` shortly).
cached = get_last_state(server_id)
mode = cached["mode"]
- if mode == "auto" and server["fanpilot_enabled"]:
+ # SX0-B: only report "fanpilot" for fan-capable vendors. A monitoring-only vendor (HPE,
+ # Lenovo, generic) never has FanPilot actively driving fans, so reporting "fanpilot" would
+ # be a false-active. Default a NULL/empty vendor to "dell" to match /mode's Decision G.
+ if mode == "auto" and server["fanpilot_enabled"] and is_fan_capable(server["vendor"] or "dell"):
mode = "fanpilot"
return {
diff --git a/tests/integration/test_api_routes.py b/tests/integration/test_api_routes.py
index d0be82f..0fac976 100644
--- a/tests/integration/test_api_routes.py
+++ b/tests/integration/test_api_routes.py
@@ -667,3 +667,49 @@ def test_demo_dell_write_echoes_argv_to_command_log(client):
assert resp.json()["success"] is True
detail = _latest_log_text(client, server_id)
assert "0x30 0x30 0x02 0xff" in detail, detail
+
+
+# --- SX0-B: /status must not report a false 'fanpilot active' for monitoring-only vendors ----
+
+
+def _enable_fanpilot_in_db(server_id: str) -> None:
+ """Flip servers.fanpilot_enabled=1 directly on the live lifespan DB (mirrors conftest)."""
+ loop = asyncio.get_event_loop()
+ loop.run_until_complete(
+ bm.db.execute("UPDATE servers SET fanpilot_enabled=1 WHERE id=?", (server_id,))
+ )
+ loop.run_until_complete(bm.db.commit())
+
+
+def test_status_monitoring_only_vendor_reports_auto_not_fanpilot(client):
+ """HPE (is_fan_capable False) + fanpilot_enabled + cached 'auto' -> /status mode == 'auto'.
+
+ Without the SX0-B guard the cold-start fallback would overwrite 'auto' -> 'fanpilot' even
+ though HPE has no IPMI fan control, so /status would falsely claim FanPilot is driving fans.
+ """
+ from backend.modules.fanpilot.tasks import set_last_state
+
+ server_id = _create_server(client, "hpe", "192.0.2.80")
+ _enable_fanpilot_in_db(server_id)
+ set_last_state(server_id, "auto") # cache at its default 'auto'
+
+ resp = client.get(f"/api/modules/fanpilot/{server_id}/status")
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["enabled"] is True
+ assert body["mode"] == "auto", "monitoring-only vendor must NOT report a false 'fanpilot active'"
+
+
+def test_status_fan_capable_vendor_still_reports_fanpilot(client):
+ """Companion: a fan-capable vendor (dell) with the same setup still resolves 'fanpilot'."""
+ from backend.modules.fanpilot.tasks import set_last_state
+
+ server_id = _create_server(client, "dell", "192.0.2.81")
+ _enable_fanpilot_in_db(server_id)
+ set_last_state(server_id, "auto")
+
+ resp = client.get(f"/api/modules/fanpilot/{server_id}/status")
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["enabled"] is True
+ assert body["mode"] == "fanpilot", "fan-capable vendor keeps the cold-start 'fanpilot' fallback"
From b3d73bafe1afb3362fc277a0267b6ec0da9d5793 Mon Sep 17 00:00:00 2001
From: dev-luigi <70869541+dev-luigi@users.noreply.github.com>
Date: Fri, 24 Jul 2026 21:03:30 +0200
Subject: [PATCH 3/7] test(260724-sx0): make test_branding.py bump-agnostic (no
pinned 2.0.0 literal)
- test_fallback_is_pep440_canonical asserts the PEP 440 X.Y.Z shape via regex
instead of the pinned '2.0.0' literal, so a future _VERSION_FALLBACK bump stays
committable through the pre-commit hook + CI gate
- test_version_fallback_when_uninstalled proves the PackageNotFoundError branch
was actually taken (took_fallback flag) so the assert is no longer vacuous;
keeps resolved == _VERSION_FALLBACK and adds an X.Y.Z shape check
- add 'import re'; leave the bump-agnostic '2.0.0-alpha.1' source-grep untouched
---
tests/unit/test_branding.py | 21 ++++++++++++++-------
1 file changed, 14 insertions(+), 7 deletions(-)
diff --git a/tests/unit/test_branding.py b/tests/unit/test_branding.py
index 79fa677..9978590 100644
--- a/tests/unit/test_branding.py
+++ b/tests/unit/test_branding.py
@@ -11,6 +11,7 @@
from __future__ import annotations
+import re
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
@@ -71,11 +72,12 @@ def test_health_version_literal_absent_from_source():
def test_fallback_is_pep440_canonical():
- """The single edited literal is the PEP 440 canonical form (stable "2.0.0").
- Pure string assert — `packaging` is intentionally NOT imported (not a declared runtime dep).
- Canonical form keeps tag == dist == METADATA == metadata-action semver with zero per-surface
- normalization (D-03)."""
- assert branding._VERSION_FALLBACK == "2.0.0"
+ """The fallback literal is a PEP 440 canonical X.Y.Z release version (shape, not a pinned
+ literal — so a future _VERSION_FALLBACK bump stays committable through the pre-commit hook
+ + CI gate). Pure regex assert — `packaging` is intentionally NOT imported (not a declared
+ runtime dep). Canonical X.Y.Z keeps tag == dist == METADATA == metadata-action semver with
+ zero per-surface normalization (D-03)."""
+ assert re.fullmatch(r"\d+\.\d+\.\d+", branding._VERSION_FALLBACK)
def test_version_fallback_when_uninstalled(monkeypatch):
@@ -85,12 +87,17 @@ def test_version_fallback_when_uninstalled(monkeypatch):
def _raise(_name):
raise PackageNotFoundError(_name)
monkeypatch.setattr("backend.core.branding.version", _raise)
- # Re-run the same resolution the module performs at import time:
+ # Re-run the same resolution the module performs at import time. Prove the fallback branch
+ # was ACTUALLY taken (else the assert is vacuous — resolved IS _VERSION_FALLBACK there).
+ took_fallback = False
try:
resolved = branding.version("ipmideck")
except PackageNotFoundError:
+ took_fallback = True
resolved = branding._VERSION_FALLBACK
- assert resolved == branding._VERSION_FALLBACK == "2.0.0"
+ assert took_fallback, "resolver should hit the PackageNotFoundError fallback path"
+ assert resolved == branding._VERSION_FALLBACK
+ assert re.fullmatch(r"\d+\.\d+\.\d+", resolved)
def test_version_matches_installed_dist():
From 1a410b575e4cd3682eeb887f217fd652e1942396 Mon Sep 17 00:00:00 2001
From: dev-luigi <70869541+dev-luigi@users.noreply.github.com>
Date: Fri, 24 Jul 2026 21:04:13 +0200
Subject: [PATCH 4/7] docs(260724-sx0): README BMC badge + configurable-expiry
wording match shipped code
- Security section names the IPMIDECK_AUTH_SESSION_EXPIRY / auth.session_expiry
knob and the 24h default (the 'configurable expiry' claim is true after SX0-A)
- header BMC badge adds Generic (alt text + shields.io src label) so it agrees
with the support matrix's sixth monitoring-only row
---
README.md | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 0b4df7a..7bd7a81 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
Documentation: docs.ipmideck.com
@@ -267,7 +267,9 @@ ipmideck/ ## Security - Local authentication with bcrypt password hashing -- Opaque session tokens, HMAC-SHA256 signed with a per-install secret, with configurable expiry +- Opaque session tokens, HMAC-SHA256 signed with a per-install secret, with configurable + expiry (`IPMIDECK_AUTH_SESSION_EXPIRY` / the `auth.session_expiry` config key — e.g. `24h`, + `90m`, `1h`; default `24h`) - BMC credentials encrypted at rest with AES-256-CBC. The 32-byte key is randomly generated and stored in `