Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9a067dd
ci: log the silent install and dump service-setup logs on any outcome
StaticHumStudio Jul 31, 2026
d958f54
fix: flatten the application tree ACL with icacls instead of a per-fi…
StaticHumStudio Jul 31, 2026
1d3a739
fix: fail closed when icacls reports per-file ACL failures
StaticHumStudio Jul 31, 2026
b664f66
fix: fail fast and log the reason when installed service setup fails
StaticHumStudio Jul 31, 2026
30f0733
fix: rebuild the Windows PowerShell module path before the installer …
StaticHumStudio Jul 31, 2026
a7ebee4
fix: generate Windows TLS certificates with bundled Python, not host …
StaticHumStudio Jul 31, 2026
57621dc
fix: keep Users read access when update.bat re-hardens the portable tree
StaticHumStudio Jul 31, 2026
533abea
fix: make certificate generation import and fail loudly under embedde…
StaticHumStudio Jul 31, 2026
28e377c
docs: stop claiming the installer ships PawnIO and fix release-note s…
StaticHumStudio Aug 2, 2026
1f0c3a2
fix: stop emitting empty native command arguments and report failed i…
StaticHumStudio Aug 2, 2026
6f478a9
fix: validate security config in the server lifespan, not only under …
StaticHumStudio Aug 2, 2026
81be3a8
fix: stop the installed-state ACL check from flagging read-only grants
StaticHumStudio Aug 3, 2026
890ccf2
fix: cache the bearer token file between requests with mtime invalida…
StaticHumStudio Aug 3, 2026
b3af4fc
fix: assert the installer and harness ACL masks cannot drift apart
StaticHumStudio Aug 3, 2026
dc4d8e7
fix: guard the bearer token cache against same-tick mtime collisions
StaticHumStudio Aug 3, 2026
8e1857a
fix: key the bearer token cache on file identity, not pathname
StaticHumStudio Aug 3, 2026
e91e728
fix: pin get-pip to an immutable commit URL and reject floating hosts
StaticHumStudio Aug 3, 2026
61f95d4
fix: mark config.json as a dpkg conffile so upgrades keep operator edits
StaticHumStudio Aug 3, 2026
889b19d
fix: make a failed post-install exit nonzero instead of reporting suc…
StaticHumStudio Aug 3, 2026
dc14059
fix: check the secret paths for any access, not just write access
StaticHumStudio Aug 3, 2026
8469ac3
fix: harden the application tree ACL in every locale and close the re…
StaticHumStudio Aug 3, 2026
2632267
fix: bring update.bat up to the installer's ACL and module path handling
StaticHumStudio Aug 3, 2026
a223413
fix: source every build download URL from the asset manifest
StaticHumStudio Aug 3, 2026
2e184e5
fix: create the TLS private key owner-only instead of at the umask
StaticHumStudio Aug 3, 2026
73e026f
fix: stop four source-level tests from passing against a broken imple…
StaticHumStudio Aug 3, 2026
309edb0
fix: convert update.bat line comments to PowerShell block comments
StaticHumStudio Aug 4, 2026
d200805
fix: refuse to harden a volume root in both ACL routines
StaticHumStudio Aug 4, 2026
a99119d
fix: silence the Stop preference around the certificate stderr capture
StaticHumStudio Aug 5, 2026
5d511a1
docs: add the v2 security hardening story to the README
StaticHumStudio Aug 5, 2026
46c3e19
fix: stop recursive ACL hardening from following reparse points
StaticHumStudio Aug 5, 2026
a5373a4
fix: fail provisioning on a planted reparse point instead of skipping it
StaticHumStudio Aug 5, 2026
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
18 changes: 17 additions & 1 deletion .github/workflows/build-windows-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,29 @@ jobs:

- name: Install HumWatch with default firewall task
shell: pwsh
timeout-minutes: 20
run: |
$installer = Get-ChildItem "${{ runner.temp }}\humwatch-installer" -Filter "HumWatch-Setup-v*.exe" | Select-Object -First 1
if (-not $installer) { throw "Downloaded HumWatch installer artifact is missing" }
$argumentLine = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /DIR="C:\HumWatch" /TASKS="firewallrule"'
$installLog = Join-Path $env:RUNNER_TEMP "humwatch-install.log"
"HUMWATCH_INSTALL_LOG=$installLog" >> $env:GITHUB_ENV
$argumentLine = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /DIR="C:\HumWatch" /TASKS="firewallrule" /LOG="' + $installLog + '"'
$process = Start-Process -FilePath $installer.FullName -ArgumentList $argumentLine -Wait -PassThru
if ($process.ExitCode -ne 0) { throw "HumWatch installer failed with exit code $($process.ExitCode)" }

- name: Dump installer and service setup logs
if: always()
shell: pwsh
run: |
foreach ($log in @($env:HUMWATCH_INSTALL_LOG, "$env:ProgramData\HumWatch\logs\service-setup.log")) {
if ($log -and (Test-Path -LiteralPath $log)) {
Write-Host "===== $log ====="
Get-Content -LiteralPath $log
} else {
Write-Host "Log not found: $log"
}
}

- name: Verify installed service and firewall state
shell: pwsh
env:
Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ A self-hosted, local-first hardware monitoring system for Windows and Linux PCs.
2. Run the installer — click **Yes** when Windows asks for admin access
3. Follow the prompts (default install path `C:\HumWatch` is fine)

The installer handles everything: bundled Python 3.12, all dependencies, LibreHardwareMonitor (v0.9.6 + PawnIO driver), Windows service registration, firewall rule, and auto-start on boot.
The installer handles everything except one optional driver: bundled Python 3.12, all dependencies, LibreHardwareMonitor v0.9.6, Windows service registration, firewall rule, and auto-start on boot. For temperatures, fans, voltages, and GPU metrics, also install the PawnIO driver once: `winget install PawnIO.PawnIO`. Without it HumWatch runs in psutil-only mode (CPU load, memory, disk, network, battery).

Once installed, open `https://localhost:9100` in your browser. The first visit
may require certificate trust setup... trust the HumWatch certificate or its
Expand All @@ -70,7 +70,7 @@ Running `python -m agent.main` directly skips that provisioning, so it requires

Without LibreHardwareMonitor, HumWatch runs in psutil-only mode — you get CPU load, memory, disk, network, and battery basics. With LHM, you also get temperatures, voltages, GPU metrics, fan speeds, and more.

> **Note:** LHM v0.9.5+ requires the [PawnIO](https://github.com/PawnIO/PawnIO) driver (replaces the deprecated WinRing0 driver). The `download-lhm.ps1` script installs it automatically via `winget`. To install manually: `winget install PawnIO.PawnIO`
> **Note:** LHM v0.9.5+ requires the [PawnIO](https://github.com/PawnIO/PawnIO) driver (replaces the deprecated WinRing0 driver). The download-lhm.ps1 script installs it via winget on development machines. The Windows installer does not install drivers, so run the winget command once on installed machines that need full sensors.

### Install as a Windows Service (from source)

Expand Down Expand Up @@ -118,6 +118,30 @@ sudo systemctl enable --now humwatch
Check status with `systemctl status humwatch` and logs with
`journalctl -u humwatch -f`.

## Security Hardening

HumWatch v2 treats an install as a security boundary, not a file copy. The
highlights, all on by default:

- **Bearer token auth and TLS everywhere.** Every listener requires HTTPS and
every protected endpoint requires a bearer token. Install-time provisioning
generates the certificate and token rather than leaving them as homework.
- **Locked-down install ACLs on Windows.** The runtime data under
`C:\ProgramData\HumWatch` (token, TLS material, database, logs) is stripped
to SYSTEM and Administrators only. The application tree grants ordinary
users read and execute, nothing more. Both routines resolve their target
first and refuse to harden a volume root, so a scripted `/DIR="C:\"` aborts
instead of rewriting ACLs across the whole drive.
- **Owner-only private keys.** The TLS private key is created with a
restricted mode from the first byte, never at the default umask.
- **A pinned supply chain.** Everything the Windows build downloads comes
from a single asset manifest with pinned URLs and SHA-256 digests, verified
before use. No floating URLs, no unverified fetches.
- **Honest exit codes.** A failed Windows install now exits nonzero instead
of reporting success, so silent and scripted installs can trust the result.
- **Upgrades keep your config.** On Debian, `config.json` is a dpkg conffile,
so package upgrades preserve operator edits instead of overwriting them.

## Dashboard

The dashboard sidebar contains these pages:
Expand Down
4 changes: 4 additions & 0 deletions agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ async def lifespan(app: FastAPI):
],
)

# Refuse to serve an unsafe transport/auth combination no matter how the
# ASGI app was launched (python -m, uvicorn CLI, or a direct import).
validate_security_config(config)

scheme = "https" if config.resolved_tls_certfile and config.resolved_tls_keyfile else "http"
logger.info("HumWatch v%s starting on port %d", __version__, config.port)

Expand Down
63 changes: 58 additions & 5 deletions agent/security/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import os
import secrets
import time
from collections import Counter
from math import log2
from pathlib import Path

from fastapi import HTTPException, Request

Expand All @@ -17,6 +19,61 @@
MAX_TOKEN_CHARACTER_RATIO = 0.75
MAX_REPEATED_CYCLE_LENGTH = 16

# Cache key is real file identity (device, inode), not the pathname. Value is
# (mtime_ns, size, token). Keying on identity means a rename-into-place
# rotation (the standard admin pattern) is detected immediately on any
# filesystem, regardless of timestamps, because it always produces a new
# inode: the (dev, ino) lookup simply misses.
_TOKEN_CACHE: dict[tuple[int, int], tuple[int, int, str]] = {}

# A cached entry is only trusted once its mtime is at least this far in the
# past. Rename-into-place rotation (the standard admin pattern) is already
# caught by the (dev, ino) identity check above, regardless of this window,
# since it always produces a new inode. This window exists only for the one
# case identity cannot see: an in-place, same-length, timestamp-preserving
# overwrite of the SAME inode (no rename, size unchanged, mtime unchanged or
# landing on the same truncated tick) on a filesystem with coarse mtime
# resolution (FAT-family's 2-second resolution is the reference case; 3
# seconds gives it margin). That residual is accepted, not eliminated: an
# attacker able to overwrite the token file in place while preserving its
# size and timestamp already has write access to the token file itself,
# which is an independent compromise no caching strategy for reading that
# same file can defend against.
_MTIME_GUARD_WINDOW_NS = 3_000_000_000


def _now_ns() -> int:
return time.time_ns()


def _read_token_file(token_file: Path) -> str:
stat = token_file.stat()
identity = (stat.st_dev, stat.st_ino)
trust_cache = (_now_ns() - stat.st_mtime_ns) >= _MTIME_GUARD_WINDOW_NS
cached = _TOKEN_CACHE.get(identity)
if (
trust_cache
and cached is not None
and cached[0] == stat.st_mtime_ns
and cached[1] == stat.st_size
):
return cached[2]

token = token_file.read_bytes().decode("utf-8")
if token.endswith("\r\n"):
token = token[:-2]
elif token.endswith("\n"):
token = token[:-1]

if trust_cache:
_TOKEN_CACHE[identity] = (stat.st_mtime_ns, stat.st_size, token)
else:
# Recent mtimes are never safe to cache: a follow-up rewrite could
# still land on the same truncated tick, so keep re-reading until
# the file's mtime is safely in the past.
_TOKEN_CACHE.pop(identity, None)
return token


def _has_repeated_cycle(token: str) -> bool:
"""Reject short repeating token cycles that meet simple entropy metrics."""
Expand Down Expand Up @@ -60,11 +117,7 @@ def load_auth_token(config: HumWatchConfig) -> str:
token_file = config.resolved_auth_token_file
if token_file is not None:
try:
token = token_file.read_bytes().decode("utf-8")
if token.endswith("\r\n"):
token = token[:-2]
elif token.endswith("\n"):
token = token[:-1]
token = _read_token_file(token_file)
except OSError as exc:
raise SecurityConfigurationError("Auth token file could not be read") from exc
elif os.environ.get("HUMWATCH_AUTH_TOKEN") is not None:
Expand Down
51 changes: 45 additions & 6 deletions installer/HumWatch.iss
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ Source: "{#StageDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion isreadme
Source: "service-setup.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\scripts\provision-security.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\scripts\certificate_identities.py"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\scripts\generate_certificate.py"; DestDir: "{app}\tools"; Flags: ignoreversion

[Icons]
Name: "{group}\Open HumWatch Dashboard"; Filename: "{sys}\cmd.exe"; Parameters: "/c start {#MyDashboardURL}"; IconFilename: "{app}\static\img\icon.ico"; Comment: "Open the HumWatch dashboard in your browser"
Expand All @@ -131,6 +132,24 @@ Type: filesandordirs; Name: "{app}\__pycache__"
; User can manually delete {app} after uninstall if they want a clean removal

[Code]
// ── Failed-install reporting ───────────────────────────────────────────────
// By ssPostInstall, Inno has already copied every file and written the
// uninstall key, so an exception raised from there is caught, logged, and
// Setup still deinitializes with exit code 0. CI run 30760330669 recorded
// exactly that: a service setup that exited 1, a runtime error dialog, and a
// Setup process that reported success to its caller. Inno only rolls back and
// returns a fatal exit code for failures during the file-copy phase.
//
// DeinitializeSetup runs last, after Setup has finished its own cleanup, which
// is late enough for ExitProcess to decide the real process exit code. The
// install itself is already committed at that point, so the operator has to
// uninstall before retrying. Both failure messages say so.
procedure ExitProcess(uExitCode: Integer);
external 'ExitProcess@kernel32.dll stdcall';

var
HumWatchSetupFailed: Boolean;

// ── Service control ────────────────────────────────────────────────────────
// Stop the HumWatch service before file copy so files aren't locked.
// Pascal post-install code reinstalls and starts the service after copy.
Expand Down Expand Up @@ -170,11 +189,12 @@ begin
Result := ResultCode = 0;
end;

procedure RunHumWatchPostInstall();
function RunHumWatchPostInstall(): Boolean;
var
InstallParameters: string;
FirewallParameters: string;
begin
Result := True;
InstallParameters :=
'-ExecutionPolicy Bypass -NonInteractive -File "' +
ExpandConstant('{app}\tools\service-setup.ps1') +
Expand All @@ -189,8 +209,16 @@ begin
InstallParameters := InstallParameters + ' -FullSensorMode';

if not ExecuteHumWatchSetup(InstallParameters) then begin
MsgBox('HumWatch service setup failed. Installation cannot continue.', mbError, MB_OK);
Abort;
// Abort alone let a silent (/VERYSILENT) install finish reporting exit
// code 0 even though the service was never configured -- any caller,
// CI or a user's script, was told a broken install succeeded. So did the
// RaiseException that replaced it. The failure is reported to the caller
// through HumWatchSetupFailed and DeinitializeSetup instead. Interactive
// users still get the suppressible dialog.
SuppressibleMsgBox('HumWatch service setup failed. Installation cannot continue. Uninstall HumWatch from Apps and Features before retrying the installer.', mbError, MB_OK, IDOK);
Log('HumWatch service setup failed. Setup will report a nonzero exit code.');
Result := False;
Exit;
end;

if WizardIsTaskSelected('firewallpublic') then begin
Expand All @@ -200,8 +228,10 @@ begin
'" -Action firewall -AppDir "' + ExpandConstant('{app}') +
'" -FirewallPort 9100 -FirewallProfiles Domain,Private,Public -AllowPublic';
if not ExecuteHumWatchSetup(FirewallParameters) then begin
MsgBox('HumWatch Public firewall setup failed. Installation cannot continue.', mbError, MB_OK);
Abort;
SuppressibleMsgBox('HumWatch Public firewall setup failed. Installation cannot continue. Uninstall HumWatch from Apps and Features before retrying the installer.', mbError, MB_OK, IDOK);
Log('HumWatch Public firewall setup failed. Setup will report a nonzero exit code.');
Result := False;
Exit;
end;
end;
end;
Expand All @@ -211,7 +241,16 @@ begin
if CurStep = ssInstall then begin
StopHumWatchService();
end else if CurStep = ssPostInstall then begin
RunHumWatchPostInstall();
if not RunHumWatchPostInstall() then
HumWatchSetupFailed := True;
end;
end;

procedure DeinitializeSetup();
begin
if HumWatchSetupFailed then begin
Log('HumWatch post-install failed. Exiting Setup with code 1.');
ExitProcess(1);
end;
end;

Expand Down
Loading
Loading