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
24 changes: 21 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,34 @@ jobs:
repository: devluigi06/ipmideck
readme-filepath: ./README.md

# ---- Draft GitHub Release (D-10) — user presses Publish ----
# ---- Draft GitHub Release (D-10) — body from CHANGELOG.md, user presses Publish ----
release-draft:
if: ${{ github.event_name == 'push' }}
needs: [guard, tests]
runs-on: ubuntu-latest
permissions:
contents: write # required to create the release
steps:
- uses: actions/checkout@v6
- name: Slice the CHANGELOG section for this tag into the release body
run: |
VER="${GITHUB_REF_NAME#v}" # v2.0.1 -> 2.0.1
# Emit the lines under `## [VER]` up to (not incl.) the next version header or the
# link-reference block. index()==1 is an exact prefix match, so the [ and . in the
# version string stay literal (no regex escaping needed).
awk -v hdr="## [$VER]" '
index($0, hdr) == 1 { f = 1; next }
/^## \[/ { f = 0 }
/^\[.*\]: http/ { f = 0 }
f
' CHANGELOG.md > release-notes.md
if [ ! -s release-notes.md ]; then
echo "::error::CHANGELOG.md has no section for $VER — promote [Unreleased] before tagging."
exit 1
fi
echo "---- release body ----"; cat release-notes.md
- uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: true
draft: true # option B: body pre-filled from CHANGELOG, maintainer Publishes
body_path: release-notes.md
# tag_name defaults to the pushed tag (github.ref_name)
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Changelog

All notable changes to IPMIDeck are recorded here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

At release time the release workflow slices the `## [<version>]` section out of this file and uses
it as the GitHub Release body. Before tagging a version, promote the relevant `[Unreleased]` items
into a new dated `## [<version>] - YYYY-MM-DD` section.

## [Unreleased]

## [2.0.1] - 2026-07-25

### Fixed

- Session expiry is now honored. `IPMIDECK_AUTH_SESSION_EXPIRY` (and the `auth.session_expiry`
config key) now set the session token and cookie lifetime; previously the setting had no effect
and the lifetime was always 24 hours.
- FanPilot status no longer reports "active" for monitoring-only vendors (HPE, Lenovo, and unknown
BMCs). Their fans stay under the BMC's own control, and the dashboard now shows that instead of a
false "FanPilot active" state.

## [2.0.0] - 2026-07-13

Complete rewrite. v1 was a single-page app that pushed fan commands at one Dell PowerEdge; v2 is a
self-hosted IPMI platform — a Python/FastAPI backend serving a React dashboard, talking to any
number of BMCs over ipmitool. Everything runs locally: SQLite on disk, no cloud, no telemetry.

### Added

- Multi-server dashboard with live sensors (temperature, fan RPM, voltage, power) over a WebSocket,
history charts, and a drag-and-drop widget grid.
- FanPilot: a backend fan-curve engine with hysteresis, a non-negotiable safety override at the
critical threshold, and fail-safe handling when a BMC becomes unreachable.
- Power control (on, soft off, hard off, reset, cycle) with an audit log and per-server energy-cost
tracking.
- Hardware event log (SEL) and FRU inventory, both browsable, searchable, and exportable to CSV/JSON.
- 12 languages, dark and light themes, optional local authentication, HTTPS with self-signed
certificates, and one-click backup/restore.
- Ships as a multi-arch Docker image (`devluigi06/ipmideck`) and the `ipmideck` package on PyPI.

### Notes

- Fan control is vendor-specific: Dell is tested on real hardware; Supermicro and IBM are
experimental; HPE, Lenovo, and unknown BMCs are monitoring-only (full sensors, power, SEL, and
FRU, but no fan writes).

[Unreleased]: https://github.com/ipmideck/IPMIDeck/compare/v2.0.1...HEAD
[2.0.1]: https://github.com/ipmideck/IPMIDeck/compare/v2.0.0...v2.0.1
[2.0.0]: https://github.com/ipmideck/IPMIDeck/compare/84df472...v2.0.0
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/License-Apache%202.0-D22128?logo=apache&logoColor=white"></a>
<a href="https://hub.docker.com/r/devluigi06/ipmideck"><img alt="Docker Hub pulls" src="https://img.shields.io/docker/pulls/devluigi06/ipmideck?logo=docker&logoColor=white&label=Docker%20pulls&color=2496ED"></a>
<a href="https://pypi.org/project/ipmideck/"><img alt="PyPI version" src="https://img.shields.io/pypi/v/ipmideck?logo=pypi&logoColor=white&label=PyPI&color=FFD43B"></a>
<img alt="Supported BMCs: Dell, Supermicro, IBM, HPE, Lenovo" src="https://img.shields.io/badge/BMC-Dell%20%7C%20Supermicro%20%7C%20IBM%20%7C%20HPE%20%7C%20Lenovo-06B6D4">
<img alt="Supported BMCs: Dell, Supermicro, IBM, HPE, Lenovo, Generic" src="https://img.shields.io/badge/BMC-Dell%20%7C%20Supermicro%20%7C%20IBM%20%7C%20HPE%20%7C%20Lenovo%20%7C%20Generic-06B6D4">
</p>

<p align="center"><strong>Documentation:</strong> <a href="https://docs.ipmideck.com">docs.ipmideck.com</a></p>
Expand Down Expand Up @@ -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 `<data_dir>/encryption.key` — deliberately **outside** the database, so a stolen DB
alone decrypts nothing (back the key file up separately)
Expand Down
14 changes: 9 additions & 5 deletions backend/api/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,25 @@
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
this single helper so the secure flag is set consistently. Detection uses
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",
)

Expand Down Expand Up @@ -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}


Expand All @@ -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}


Expand All @@ -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}


Expand Down
9 changes: 7 additions & 2 deletions backend/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion backend/core/branding.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# stable public release): tag == dist == METADATA == this literal, zero per-surface normalization
# surprises. pyproject derives the wheel version from THIS via attr: (D-05).
# Bump this + tag the same commit to cut a release (firing the tag is a USER action, D-21).
_VERSION_FALLBACK = "2.0.0"
_VERSION_FALLBACK = "2.0.1"

# Runtime resolution (D-02): an installed dist (pip/Docker) reports what was ACTUALLY shipped;
# a raw source checkout (`python -m backend.main`) falls back to the literal. The dist name
Expand Down
19 changes: 19 additions & 0 deletions backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
13 changes: 12 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions backend/modules/fanpilot/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}
Expand All @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions scripts/check-wheel.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
"""Assert the built wheel ships static/** + per-module *.sql + version 2.0.0 (SC-4).
"""Assert the built wheel ships static/** + per-module *.sql + the branding version (SC-4).

Run `python -m build` first (writes dist/, gitignored). Then `python scripts/check-wheel.py`.

The expected version is read from backend/core/branding.py (_VERSION_FALLBACK), the single source
of truth, so a version bump needs no edit here.

Gitignore note: dist/ + ipmideck.egg-info/ are gitignored build artifacts — never `git add` them.
"""
from __future__ import annotations

import glob
import pathlib
import re
import sys
import zipfile

_branding = pathlib.Path(__file__).resolve().parent.parent / "backend" / "core" / "branding.py"
_match = re.search(r'_VERSION_FALLBACK\s*=\s*"([^"]+)"', _branding.read_text(encoding="utf-8"))
if not _match:
sys.exit("could not read _VERSION_FALLBACK from backend/core/branding.py")
VERSION = _match.group(1)

whls = sorted(glob.glob("dist/ipmideck-*.whl"))
if not whls:
sys.exit("no wheel in dist/ — run `python -m build` first")
z = zipfile.ZipFile(whls[-1])
n = z.namelist()
assert any(p.startswith("backend/static/") for p in n), "no SPA (backend/static/) in wheel"
assert any(p.endswith(".sql") and "/migrations/" in p for p in n), "no *.sql migrations in wheel"
assert any(p == "ipmideck-2.0.0.dist-info/METADATA" for p in n), "version drift (expected 2.0.0)"
assert any(
p == f"ipmideck-{VERSION}.dist-info/METADATA" for p in n
), f"version drift (expected {VERSION})"
print(f"wheel OK: {len(n)} entries")
10 changes: 7 additions & 3 deletions scripts/smoke-docker.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
$ErrorActionPreference = "Stop"
$img = "ipmideck:smoke"; $name = "ipmideck-smoke"; $vol = "ipmideck-smoke-data"
$rev = (git rev-parse HEAD).Trim()
# Version from the single source of truth (backend/core/branding.py) — no hardcoded literal, so a
# bump needs no edit here. Same _VERSION_FALLBACK the wheel build and release.yml guard read.
$ver = ([regex]::Match((Get-Content -Raw backend/core/branding.py), '_VERSION_FALLBACK\s*=\s*"([^"]+)"')).Groups[1].Value
if (-not $ver) { throw "could not read _VERSION_FALLBACK from backend/core/branding.py" }

# Host port for the smoke: prefer 3000, but fall back to a free ephemeral port if it is already
# in use (e.g. another dev server holds 3000). The container always serves on its internal 3000,
Expand All @@ -14,7 +18,7 @@ if (Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyCon
}

# 1. build from clean checkout with the same build-args CI's metadata-action would pass
docker build --build-arg VERSION=2.0.0 --build-arg REVISION=$rev -t $img .
docker build --build-arg VERSION=$ver --build-arg REVISION=$rev -t $img .

# 2. run demo mode, throwaway volume, PORT MAPPING (host networking is a no-op on Windows)
# best-effort pre-clean of any leftover from a prior run — tolerate "not found" on a clean first
Expand All @@ -33,9 +37,9 @@ foreach ($i in 1..30) {
}
if (-not $ok) { throw "health never 200" }

# 4. version consistency: /api/health reports 2.0.0 (wheel via importlib.metadata)
# 4. version consistency: /api/health reports the branding version (wheel via importlib.metadata)
$health = Invoke-RestMethod "http://localhost:$port/api/health"
if ($health.version -ne "2.0.0") { throw "health version $($health.version) != 2.0.0" }
if ($health.version -ne $ver) { throw "health version $($health.version) != $ver" }

# 5. SPA served (index references hashed assets/)
$root = (Invoke-WebRequest "http://localhost:$port/" -UseBasicParsing).Content
Expand Down
Loading
Loading