Skip to content

Commit 0d04b4e

Browse files
committed
feat(hardware): ipmitool install route + node Hardware panel (#609 phase 1 step 3-4)
Step 3 — one-click ipmitool install: * POST /api/clusters/<cid>/hardware/ipmitool/install (admin.settings) mirrors the proven StarWind installer but with a FULLY STATIC script (no user-controlled shell input), gated: cluster-access -> proxmox-only -> consent-required (the install mutates the node, so it must not run before the warning is acknowledged) -> per-node name validation -> run_per_node fan-out (cap 8, idempotent) -> audited. Step 4 — frontend: * Self-contained HardwareMonitoringPanel: consent fetch, mandatory compliance-warning modal (renders the server-versioned warning text, required ack checkbox, generic require_delay_seconds countdown ready for the Redfish phase), ipmitool install button when missing, sensor/power/FRU/SEL rollup with health badge. * Wired as a 'Hardware' tab in BOTH node UIs (corporate CorporateNodeDetailView + default NodeModal) for parity. 21 i18n keys across all 7 languages. Hardening from the adversarial review (3 confirmed findings, all fixed): * MEDIUM — config-restore could enable hardware monitoring WITHOUT the versioned acknowledgement audit record (non-repudiation bypass): restore now drops the hardware_monitoring key and preserves the live consent state, so consent only ever moves through the audit-logged set_hw_monitoring_consent path (settings.py). * LOW — a non-int stored/posted ack_version raised int() -> 500 on every gate; add _hw_int() so the gate fails CLOSED (disabled) instead of crashing. * NIT — a crafted {"nodes":[123]}/[{...}] blew up _reject_bad_node's regex / set() -> 500; validate the raw list + reject non-strings centrally -> clean 400. Tests: +9 integration cases (install gates admin/consent/proxmox/bad-node + the 3 hardening regressions). Frontend build clean (Babel). Full suite 307 passed.
1 parent 7516573 commit 0d04b4e

6 files changed

Lines changed: 674 additions & 20 deletions

File tree

pegaprox/api/nodes.py

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,10 @@ def get_node_network_api(cluster_id, node):
133133
def _reject_bad_node(node):
134134
"""Return (None, None) if node looks valid, (response, 400) otherwise.
135135
Used by the SSH-fronted node endpoints below as a defense-in-depth gate
136-
even though the SSH commands themselves don't interpolate `node`."""
137-
if not node or not _NODE_NAME_RE.match(node):
136+
even though the SSH commands themselves don't interpolate `node`.
137+
Non-strings (e.g. a crafted JSON list `{"nodes":[123]}`) are rejected as
138+
invalid rather than blowing up the regex with a TypeError -> clean 400."""
139+
if not isinstance(node, str) or not _NODE_NAME_RE.match(node):
138140
return jsonify({'error': 'Invalid node name'}), 400
139141
return None, None
140142

@@ -229,13 +231,23 @@ def get_node_temperature_history_api(cluster_id, node):
229231
# the audit log — so "I never enabled that" cannot hold ("dann heißt es nicht 'Ich
230232
# hab es nie bekommen'"). The read route refuses to serve until consent is on.
231233

234+
def _hw_int(v):
235+
"""Coerce a stored/posted ack_version to int, degrading to 0 on any bad value
236+
(e.g. a corrupt/crafted settings blob with ack_version='x' or [1]) so the gate
237+
fails CLOSED — returns disabled — rather than raising a 500 on every read."""
238+
try:
239+
return int(v)
240+
except (TypeError, ValueError):
241+
return 0
242+
243+
232244
def _hw_consent_state():
233245
"""(enabled, ack_record) for in-band hardware monitoring from server settings.
234246
enabled == acknowledged AND the stored ack is at/above the current warning ver."""
235247
from pegaprox.api.helpers import load_server_settings
236248
from pegaprox.core import bmc
237249
ack = (load_server_settings() or {}).get('hardware_monitoring') or {}
238-
enabled = bool(ack.get('enabled')) and int(ack.get('ack_version') or 0) >= bmc.HW_CONSENT_VERSION
250+
enabled = bool(ack.get('enabled')) and _hw_int(ack.get('ack_version')) >= bmc.HW_CONSENT_VERSION
239251
return enabled, ack
240252

241253

@@ -282,7 +294,7 @@ def set_hw_monitoring_consent():
282294
'In-band hardware monitoring disabled')
283295
return jsonify({'enabled': False})
284296

285-
if data.get('acknowledge') is True and int(data.get('ack_version') or 0) == bmc.HW_CONSENT_VERSION:
297+
if data.get('acknowledge') is True and _hw_int(data.get('ack_version')) == bmc.HW_CONSENT_VERSION:
286298
rec = {
287299
'enabled': True,
288300
'acknowledged_by': usr,
@@ -329,6 +341,119 @@ def get_node_hardware_api(cluster_id, node):
329341
return jsonify(result)
330342

331343

344+
# ipmitool one-click install (#609 step 3). Fully static script — no user-controlled
345+
# input reaches the shell (unlike the StarWind installer there is no repo/key URL), so
346+
# no SSRF/url-allowlist is needed. Read-only in-band IPMI (local KCS, no BMC creds).
347+
# Gated behind the SAME compliance acknowledgement as the read path: the install
348+
# MUTATES the node (package + IPMI kernel modules) so it must not run before the
349+
# warning is acknowledged. admin.settings + audited + idempotent + bounded-parallel.
350+
IPMITOOL_INSTALL_SCRIPT = """#!/usr/bin/env bash
351+
# PegaProx (#609) — install ipmitool for in-band hardware monitoring on this node.
352+
set -uo pipefail
353+
if command -v ipmitool >/dev/null 2>&1; then
354+
ver="$(ipmitool -V 2>/dev/null | head -1 || echo ipmitool)"
355+
echo "PP_OK already_installed $ver"; exit 0
356+
fi
357+
if ! command -v apt-get >/dev/null 2>&1; then echo 'PP_ERR no-apt-get'; exit 3; fi
358+
apt-get update -o Acquire::Retries=2 >/tmp/pp_ipmitool_apt.log 2>&1 || true
359+
if ! DEBIAN_FRONTEND=noninteractive apt-get install -y ipmitool >>/tmp/pp_ipmitool_apt.log 2>&1; then
360+
echo 'PP_ERR apt-install-failed'; exit 5
361+
fi
362+
command -v ipmitool >/dev/null 2>&1 || { echo 'PP_ERR not-installed-after-apt'; exit 6; }
363+
ver="$(ipmitool -V 2>/dev/null | head -1 || echo ipmitool)"
364+
echo "PP_OK installed $ver"
365+
"""
366+
367+
368+
@bp.route('/api/clusters/<cluster_id>/hardware/ipmitool/install', methods=['POST'])
369+
@require_auth(perms=['admin.settings']) # root apt-install on nodes -> admin-only, like starlvm
370+
def install_ipmitool_api(cluster_id):
371+
"""Install ipmitool on cluster node(s) over SSH for in-band hardware monitoring.
372+
373+
Body (optional): {nodes: [names]} — defaults to all cluster nodes. Idempotent
374+
per node (skips if ipmitool present). Requires the hardware-monitoring feature
375+
to be enabled (compliance acknowledged) first — the install changes the node.
376+
"""
377+
ok, err = check_cluster_access(cluster_id)
378+
if not ok: return err
379+
if cluster_id not in cluster_managers:
380+
return jsonify({'error': 'Cluster not found'}), 404
381+
mgr = cluster_managers[cluster_id]
382+
if getattr(mgr, 'cluster_type', 'proxmox') != 'proxmox':
383+
return jsonify({'error': 'ipmitool install is Proxmox-only'}), 400
384+
385+
# consent gate — do not mutate a node before the warning is acknowledged
386+
from pegaprox.core import bmc
387+
enabled, _ack = _hw_consent_state()
388+
if not enabled:
389+
return jsonify({'error': 'Hardware monitoring is not enabled',
390+
'code': 'CONSENT_REQUIRED',
391+
'current_version': bmc.HW_CONSENT_VERSION}), 403
392+
393+
raw_nodes = (request.get_json(silent=True) or {}).get('nodes') or []
394+
if not isinstance(raw_nodes, list):
395+
return jsonify({'error': 'nodes must be a list of node names'}), 400
396+
# validate each entry BEFORE set() — a dict/list entry would raise in set()
397+
for n in raw_nodes:
398+
bad, code = _reject_bad_node(n)
399+
if bad is not None:
400+
return bad, code
401+
only = set(raw_nodes)
402+
nodes = _cluster_node_names(mgr)
403+
if only:
404+
nodes = [n for n in nodes if n in only]
405+
if not nodes:
406+
return jsonify({'error': 'No nodes found in cluster'}), 404
407+
408+
def _install_one(node):
409+
node_ip = mgr._get_node_ip(node)
410+
if not node_ip:
411+
return {'node': node, 'success': False, 'error': 'no SSH-reachable IP'}
412+
ssh = None
413+
try:
414+
for attempt in range(3):
415+
ssh = mgr._ssh_connect(node_ip)
416+
if ssh:
417+
break
418+
if attempt < 2:
419+
time.sleep(1.5)
420+
if not ssh:
421+
return {'node': node, 'success': False, 'error': 'SSH connect failed after 3 tries'}
422+
_ssh_write_file(ssh, '/tmp/pegaprox-ipmitool-install.sh', IPMITOOL_INSTALL_SCRIPT, 0o755)
423+
out, _e = _ssh_run_checked(ssh, 'bash /tmp/pegaprox-ipmitool-install.sh', timeout=180)
424+
try:
425+
ssh.exec_command('rm -f /tmp/pegaprox-ipmitool-install.sh')
426+
except Exception:
427+
pass
428+
last = (out or '').strip().splitlines()[-1] if (out or '').strip() else ''
429+
if 'PP_OK already_installed' in out:
430+
return {'node': node, 'success': True, 'already_installed': True, 'detail': last}
431+
if 'PP_OK installed' in out:
432+
return {'node': node, 'success': True, 'already_installed': False, 'detail': last}
433+
return {'node': node, 'success': False, 'error': last or 'unexpected installer output'}
434+
except Exception as e:
435+
return {'node': node, 'success': False, 'error': safe_error(e, 'install failed')}
436+
finally:
437+
if ssh:
438+
try: ssh.close()
439+
except Exception: pass
440+
441+
from pegaprox.utils.concurrent import run_per_node
442+
res_map = run_per_node({n: _install_one for n in nodes}, max_concurrent=8, timeout=200)
443+
results = [res_map.get(n) or {'node': n, 'success': False, 'error': 'timed out'} for n in nodes]
444+
installed = sum(1 for r in results if r.get('success'))
445+
usr = getattr(request, 'session', {}).get('user', 'system')
446+
log_audit(usr, 'hardware_monitoring.ipmitool_install',
447+
f"ipmitool install: {installed}/{len(nodes)} node(s)",
448+
cluster=mgr.config.name)
449+
return jsonify({
450+
'success': installed == len(nodes),
451+
'installed': installed,
452+
'total': len(nodes),
453+
'results': results,
454+
})
455+
456+
332457
@bp.route('/api/clusters/<cluster_id>/nodes/<node>/network/<iface>', methods=['PUT'])
333458
@require_auth(perms=['node.network'])
334459
def update_node_network_api(cluster_id, node, iface):

pegaprox/api/settings.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2427,14 +2427,23 @@ def restore_config():
24272427
try:
24282428
if not dry_run:
24292429
current = load_server_settings()
2430+
# #609: hardware-monitoring consent is a versioned, audit-logged
2431+
# toggle that must ONLY move through set_hw_monitoring_consent (which
2432+
# records the acknowledgement — "dann heißt es nicht 'Ich hab es nie
2433+
# bekommen'"). A generic config-restore must neither enable nor disable
2434+
# it, so we drop the backup's value and preserve the live consent state.
2435+
incoming_ss = {k: v for k, v in (data['server_settings'] or {}).items()
2436+
if k != 'hardware_monitoring'}
24302437
if mode == 'merge':
24312438
# Only update non-empty values
2432-
for key, value in data['server_settings'].items():
2439+
for key, value in incoming_ss.items():
24332440
if value not in [None, '', []]:
24342441
current[key] = value
24352442
save_server_settings(current)
24362443
else:
2437-
save_server_settings(data['server_settings'])
2444+
if (current or {}).get('hardware_monitoring') is not None:
2445+
incoming_ss['hardware_monitoring'] = current['hardware_monitoring']
2446+
save_server_settings(incoming_ss)
24382447
results['restored']['server_settings'] = True
24392448
except Exception as e:
24402449
results['errors'].append(f"Server settings: {str(e)}")

tests/test_integration_hardware.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,94 @@ def test_missing_cluster_manager_is_404_after_consent(api, seed, monkeypatch):
205205
resp = api.as_user(admin).get(HW_ROUTE)
206206
assert resp.status_code == 404, resp.get_data(as_text=True)
207207
assert 'not found' in resp.get_json()['error'].lower()
208+
209+
210+
# ===========================================================================
211+
# ipmitool INSTALL (step 3) — gates fire before the SSH fan-out. The actual
212+
# per-node apt install needs real hardware (like starlvm), so only the gates
213+
# are asserted here: admin-only, proxmox-only, and consent-required.
214+
# ===========================================================================
215+
216+
INSTALL_ROUTE = f'/api/clusters/{CID}/hardware/ipmitool/install'
217+
218+
219+
def test_install_requires_admin_403(api, seed):
220+
user = seed.user('joe', role='user', tenant_id='default') # node.view, not admin.settings
221+
api.set_manager(CID, _mgr(api))
222+
resp = api.as_user(user).post(INSTALL_ROUTE, json={'nodes': [NODE]})
223+
assert resp.status_code == 403, resp.get_data(as_text=True)
224+
225+
226+
def test_install_without_consent_is_403(api, seed):
227+
# admin, proxmox cluster, but feature not enabled -> CONSENT_REQUIRED before fan-out.
228+
admin = seed.user('root', role='admin', tenant_id='default')
229+
api.set_manager(CID, _mgr(api))
230+
resp = api.as_user(admin).post(INSTALL_ROUTE, json={'nodes': [NODE]})
231+
assert resp.status_code == 403, resp.get_data(as_text=True)
232+
assert resp.get_json()['code'] == 'CONSENT_REQUIRED'
233+
234+
235+
def test_install_non_proxmox_is_400(api, seed):
236+
admin = seed.user('root', role='admin', tenant_id='default')
237+
api.set_manager(CID, _mgr(api, cluster_type='vmware'))
238+
resp = api.as_user(admin).post(INSTALL_ROUTE, json={'nodes': [NODE]})
239+
assert resp.status_code == 400, resp.get_data(as_text=True)
240+
assert 'proxmox' in resp.get_json()['error'].lower()
241+
242+
243+
def test_install_bad_node_name_is_400(api, seed, monkeypatch):
244+
_consent_on(api, seed, monkeypatch)
245+
admin = seed.user('root', role='admin', tenant_id='default')
246+
api.set_manager(CID, _mgr(api))
247+
resp = api.as_user(admin).post(INSTALL_ROUTE, json={'nodes': ['bad;rm -rf']})
248+
assert resp.status_code == 400, resp.get_data(as_text=True)
249+
250+
251+
# ===========================================================================
252+
# Hardening regressions from the adversarial review (input hygiene / fail-closed)
253+
# ===========================================================================
254+
255+
def test_install_non_string_node_is_400_not_500(api, seed, monkeypatch):
256+
# crafted {"nodes":[123]} used to blow up _reject_bad_node's regex -> 500.
257+
_consent_on(api, seed, monkeypatch)
258+
admin = seed.user('root', role='admin', tenant_id='default')
259+
api.set_manager(CID, _mgr(api))
260+
resp = api.as_user(admin).post(INSTALL_ROUTE, json={'nodes': [123]})
261+
assert resp.status_code == 400, resp.get_data(as_text=True)
262+
263+
264+
def test_install_unhashable_node_entry_is_400_not_500(api, seed, monkeypatch):
265+
# a dict entry used to raise in set() before the loop even ran.
266+
_consent_on(api, seed, monkeypatch)
267+
admin = seed.user('root', role='admin', tenant_id='default')
268+
api.set_manager(CID, _mgr(api))
269+
resp = api.as_user(admin).post(INSTALL_ROUTE, json={'nodes': [{'x': 1}]})
270+
assert resp.status_code == 400, resp.get_data(as_text=True)
271+
272+
273+
def test_install_nodes_not_a_list_is_400(api, seed, monkeypatch):
274+
_consent_on(api, seed, monkeypatch)
275+
admin = seed.user('root', role='admin', tenant_id='default')
276+
api.set_manager(CID, _mgr(api))
277+
resp = api.as_user(admin).post(INSTALL_ROUTE, json={'nodes': 'pve1'})
278+
assert resp.status_code == 400, resp.get_data(as_text=True)
279+
280+
281+
def test_enable_non_int_ack_version_is_400_not_500(api, seed):
282+
# POST {acknowledge:true, ack_version:'x'} used to raise int('x') -> 500.
283+
admin = seed.user('root', role='admin', tenant_id='default')
284+
resp = api.as_user(admin).post(CONSENT_ROUTE, json={'acknowledge': True, 'ack_version': 'x'})
285+
assert resp.status_code == 400, resp.get_data(as_text=True)
286+
assert resp.get_json()['code'] == 'ACK_REQUIRED'
287+
288+
289+
def test_corrupt_stored_ack_version_fails_closed(api, seed):
290+
# a corrupt/crafted settings blob (e.g. via config-restore) with a non-int
291+
# ack_version must NOT 500 the gate — it fails closed to CONSENT_REQUIRED.
292+
from pegaprox.api.helpers import save_server_settings
293+
save_server_settings({'hardware_monitoring': {'enabled': True, 'ack_version': 'abc'}})
294+
admin = seed.user('root', role='admin', tenant_id='default')
295+
api.set_manager(CID, _mgr(api, _get_node_ip='10.0.0.9', _ssh_run_command_output=SAMPLE))
296+
resp = api.as_user(admin).get(HW_ROUTE)
297+
assert resp.status_code == 403, resp.get_data(as_text=True)
298+
assert resp.get_json()['code'] == 'CONSENT_REQUIRED'

0 commit comments

Comments
 (0)