create 1.2.2.3 beta

This commit is contained in:
MacRimi
2026-07-05 16:50:06 +02:00
parent 9f03164258
commit 7fc7125c71
18 changed files with 1581 additions and 300 deletions

View File

@@ -237,6 +237,22 @@ def _list_target_disks() -> list[str]:
return fresh
def _is_disk_usb(disk_name: str) -> bool:
"""True if the disk sits behind a USB bus, checked via the resolved
sysfs device path. USB-NVMe bridges (ASMedia, JMicron, Realtek) and
plain USB-HDDs both report `/sys/block/<disk>/removable = 0`, so the
older removable-flag heuristic missed them and the temperature
poller never tried the snt* driver variants that are the only way
to reach the NVMe controller behind those bridges."""
try:
base = disk_name[5:] if disk_name.startswith('/dev/') else disk_name
real = os.path.realpath(f'/sys/block/{base}')
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
for seg in real.split('/'))
except Exception:
return False
def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]:
"""Build the smartctl invocation for a given probe key.
@@ -354,7 +370,15 @@ def _read_temperature(disk_name: str) -> Optional[float]:
# Cached probe stopped working — fall through and re-detect.
# Slow path: try every probe and remember the first one that works.
for probe in ("auto", "nvme", "ata", "sat"):
# For USB-attached disks we prepend the three snt* driver variants —
# USB-NVMe bridges (ASMedia / JMicron / Realtek) don't answer the
# plain probes with real SMART; only snt* passes through to the NVMe
# controller so temperature actually comes back. Non-USB disks skip
# them, so this adds zero overhead on internal drives.
probes: tuple[str, ...] = ("auto", "nvme", "ata", "sat")
if _is_disk_usb(disk_name):
probes = ("sntasmedia", "sntjmicron", "sntrealtek") + probes
for probe in probes:
if probe == cached_probe:
continue # already tried above
temp = _handle(_try_probe(disk_name, probe))

View File

@@ -2658,6 +2658,29 @@ def is_disk_removable(disk_name):
return False
def is_disk_usb(disk_name):
"""Return True if the disk is attached via USB, using the sysfs device
path. Reliable for USB-attached HDDs and USB-NVMe bridges (which
both report `/sys/block/<disk>/removable = 0` even though they ARE
USB) — the previous heuristic based on the removable flag missed
them entirely, so snt* pass-through was never attempted and the
bridge's own identity + missing temperature/hours were cached.
Uses `os.path.realpath` (no subprocess) so it's cheap enough to be
called on every SMART probe.
"""
try:
real = os.path.realpath(f'/sys/block/{disk_name}')
# sysfs path segment like `usb1`, `usb2`, ... always precedes a
# USB-attached block device. Match on the segment prefix rather
# than a substring so a directory happening to contain "usb" in
# its literal name can't false-positive.
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
for seg in real.split('/'))
except Exception:
return False
def _is_system_mount(mountpoint):
"""Check if mountpoint is a critical system path (matching bash scripts logic)."""
system_mounts = ('/', '/boot', '/boot/efi', '/efi', '/usr', '/var', '/etc',
@@ -3676,12 +3699,15 @@ def _get_smart_data_uncached(disk_name):
# passes through to the actual NVMe controller and returns real
# model, serial, temperature and health.
#
# For removable disks we prepend the three snt* variants so the
# cascade tries them FIRST — otherwise the plain variant "succeeds"
# (>50 chars of bridge chatter), the probe cache locks it in, and
# temperature is never seen. For non-removable disks the cascade
# is unchanged (zero regression risk on internal SATA/NVMe).
if is_disk_removable(disk_name):
# For USB-attached disks we prepend the three snt* variants so
# the cascade tries them FIRST — otherwise the plain variant
# "succeeds" (>50 chars of bridge chatter), the probe cache locks
# it in, and temperature is never seen. USB detection is by sysfs
# path (`is_disk_usb`) rather than the `removable` flag: USB-NVMe
# bridges and USB-HDDs both report `removable=0` even though they
# ARE USB, so the older `is_disk_removable` check missed them.
# For internal SATA/NVMe the cascade is unchanged (zero regression).
if is_disk_usb(disk_name) or is_disk_removable(disk_name):
all_commands = [
['smartctl', '-a', '-j', '-d', 'sntasmedia', f'/dev/{disk_name}'],
['smartctl', '-a', '-j', '-d', 'sntjmicron', f'/dev/{disk_name}'],
@@ -10027,45 +10053,40 @@ def api_network_summary():
bridge_interfaces = []
for interface_name, stats in net_if_stats.items():
# Skip loopback and special interfaces
if interface_name in ['lo', 'docker0'] or interface_name.startswith(('veth', 'tap', 'fw')):
# Delegate classification to the shared helper so this endpoint
# matches the full /api/network path. The inline prefix-based
# check that lived here missed operator-renamed NICs (e.g.
# `nic0` via systemd .link rules) and reported 0 physical
# interfaces even when the host clearly had one — surfaces as
# "Network Interfaces: N/A" on the System Overview card.
iface_type = get_interface_type(interface_name)
if iface_type not in ('physical', 'bridge'):
continue
is_up = stats.isup
# Classify interface type
if interface_name.startswith(('enp', 'eth', 'eno', 'ens', 'wlan', 'wlp')):
addresses = []
if interface_name in net_if_addrs:
for addr in net_if_addrs[interface_name]:
if addr.family == socket.AF_INET:
addresses.append({'ip': addr.address, 'netmask': addr.netmask})
if iface_type == 'physical':
physical_total += 1
if is_up:
physical_active += 1
# Get IP addresses
addresses = []
if interface_name in net_if_addrs:
for addr in net_if_addrs[interface_name]:
if addr.family == socket.AF_INET:
addresses.append({'ip': addr.address, 'netmask': addr.netmask})
physical_interfaces.append({
'name': interface_name,
'status': 'up' if is_up else 'down',
'addresses': addresses
'status': 'up',
'addresses': addresses,
})
elif interface_name.startswith(('vmbr', 'br')):
else: # bridge
bridge_total += 1
if is_up:
bridge_active += 1
# Get IP addresses
addresses = []
if interface_name in net_if_addrs:
for addr in net_if_addrs[interface_name]:
if addr.family == socket.AF_INET:
addresses.append({'ip': addr.address, 'netmask': addr.netmask})
bridge_interfaces.append({
'name': interface_name,
'status': 'up' if is_up else 'down',
'addresses': addresses
'status': 'up',
'addresses': addresses,
})
return jsonify({
@@ -13803,6 +13824,151 @@ def api_pbs_recovery_status():
})
# ─── Restore progress state (written by apply_cluster_postboot.sh) ──
# The postboot script maintains /var/lib/proxmenux/restore-state.json
# with a `running|complete|failed` status plus milestone counters,
# per-component results, sanity warnings and a rollback delta. The
# Backups tab polls status() to render a live card while the restore
# is running and a summary once it's done; dismiss() flips the
# `acknowledged` flag so the card collapses; history() lists archived
# runs; log() returns a filtered tail of the postboot log.
_RESTORE_STATE_FILE = '/var/lib/proxmenux/restore-state.json'
_RESTORE_HISTORY_DIR = '/var/lib/proxmenux/restore-history'
_RESTORE_LOG_DIR = '/var/log/proxmenux'
def _read_restore_state(path):
try:
with open(path, 'r') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
@app.route('/api/host-backups/restore/status', methods=['GET'])
@require_auth
def api_host_backups_restore_status():
"""Current restore-state.json content — empty response means no
restore has ever run on this host (or the state dir is missing)."""
state = _read_restore_state(_RESTORE_STATE_FILE)
if state is None:
return jsonify({'state': None})
return jsonify({'state': state})
@app.route('/api/host-backups/restore/dismiss', methods=['POST'])
@require_auth
def api_host_backups_restore_dismiss():
"""Flip acknowledged=true on the current state file so the Backups
tab card collapses. The state file itself is preserved until the
next restore overwrites it — the operator can still open the
history modal to review this run's details later."""
state = _read_restore_state(_RESTORE_STATE_FILE)
if state is None:
return jsonify({'error': 'no restore state to dismiss'}), 404
state['acknowledged'] = True
try:
tmp = _RESTORE_STATE_FILE + '.tmp'
with open(tmp, 'w') as f:
json.dump(state, f)
os.replace(tmp, _RESTORE_STATE_FILE)
except OSError as e:
return jsonify({'error': f'cannot persist state: {e}'}), 500
return jsonify({'status': 'ok'})
@app.route('/api/host-backups/restore/history', methods=['GET'])
@require_auth
def api_host_backups_restore_history():
"""List archived restore runs (newest first). Each entry carries
just the summary metadata; the detail modal fetches the full
payload on demand via ?file=<name>."""
file_arg = request.args.get('file')
if file_arg:
# Reject anything that could climb out of the history dir.
# Filenames are always <YYYYmmdd_HHMMSS>-<complete|failed>.json.
if '/' in file_arg or '..' in file_arg or not file_arg.endswith('.json'):
return jsonify({'error': 'invalid file name'}), 400
full = os.path.join(_RESTORE_HISTORY_DIR, file_arg)
payload = _read_restore_state(full)
if payload is None:
return jsonify({'error': 'not found'}), 404
return jsonify({'state': payload})
entries = []
try:
for name in os.listdir(_RESTORE_HISTORY_DIR):
if not name.endswith('.json'):
continue
full = os.path.join(_RESTORE_HISTORY_DIR, name)
try:
mtime = os.stat(full).st_mtime
except OSError:
continue
payload = _read_restore_state(full) or {}
entries.append({
'file': name,
'mtime': mtime,
'status': payload.get('status', 'unknown'),
'started_at': payload.get('started_at'),
'finished_at': payload.get('finished_at'),
'duration': (payload.get('summary') or {}).get('duration'),
})
except FileNotFoundError:
pass
entries.sort(key=lambda e: e['mtime'], reverse=True)
return jsonify({'entries': entries})
@app.route('/api/host-backups/restore/log', methods=['GET'])
@require_auth
def api_host_backups_restore_log():
"""Return a tail of the postboot log — optionally filtered to just
error/warning lines. Path is taken from the state file's log_path
(or from ?path=… for a history entry) and validated to live under
/var/log/proxmenux so an operator can't read arbitrary files."""
path = request.args.get('path')
filter_mode = request.args.get('filter', 'all') # all | issues
try:
tail_lines = min(int(request.args.get('tail', '400')), 4000)
except ValueError:
tail_lines = 400
if not path:
state = _read_restore_state(_RESTORE_STATE_FILE) or {}
path = state.get('log_path')
if not path:
return jsonify({'lines': [], 'path': None})
# Resolve to guard against traversal; the log MUST live inside
# /var/log/proxmenux for the endpoint to expose it.
real = os.path.realpath(path)
if not real.startswith(_RESTORE_LOG_DIR + os.sep) and real != _RESTORE_LOG_DIR:
return jsonify({'error': 'log path outside allowed directory'}), 400
if not os.path.isfile(real):
return jsonify({'lines': [], 'path': path}), 200
try:
with open(real, 'r', errors='replace') as f:
# Simple tail: read the whole file (postboot logs are
# small — a few hundred KB at worst) and slice.
data = f.readlines()
except OSError as e:
return jsonify({'error': f'cannot read log: {e}'}), 500
if filter_mode == 'issues':
needle = re.compile(r'(error|warning|failed|✗|✘|traceback)', re.IGNORECASE)
data = [ln for ln in data if needle.search(ln)]
return jsonify({
'path': path,
'total_lines': len(data),
'lines': [ln.rstrip('\n') for ln in data[-tail_lines:]],
'filter': filter_mode,
})
@app.route('/api/host-backups/pbs-encryption/import', methods=['POST'])
@require_auth
def api_pbs_encryption_import():

View File

@@ -83,6 +83,22 @@ def _is_disk_removable(disk_name: str) -> bool:
except Exception:
return False
def _is_disk_usb(disk_name: str) -> bool:
"""True if the disk sits behind a USB bus. Reads the resolved sysfs
device path — reliable for USB-NVMe bridges and USB-attached HDDs
that report `removable=0` even though they ARE USB (so the older
`_is_disk_removable` heuristic skipped snt* driver probes and left
NVMe-behind-a-bridge disks with the bridge's own chatter cached
forever)."""
try:
base = _disk_base_for_sysfs(disk_name)
real = os.path.realpath(f'/sys/block/{base}')
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
for seg in real.split('/'))
except Exception:
return False
class HealthMonitor:
"""
Monitors system health across multiple components with minimal impact.
@@ -2431,13 +2447,16 @@ class HealthMonitor:
try:
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
# Removable disks may be USB-NVMe bridges: try the snt* driver
# variants first so identity reflects the drive (Samsung 990 PRO)
# rather than the enclosure (ASMT 2462 NVME). If all snt* fail,
# fall through to the plain call — that's still correct for
# USB-SATA sticks and non-USB devices.
# USB-attached disks may sit behind an NVMe bridge: try the
# snt* driver variants first so identity reflects the drive
# (Samsung 990 PRO) rather than the enclosure (ASMT 2462 NVME).
# If all snt* fail, fall through to the plain call — that's
# still correct for USB-SATA sticks and non-USB devices.
# USB detection is by sysfs path (`_is_disk_usb`) rather than
# the `removable` flag, since USB-NVMe and USB-HDD both report
# `removable=0` even though they ARE USB.
attempts = []
if _is_disk_removable(disk_name):
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
for drv in _USB_NVME_DRIVERS:
attempts.append(['smartctl', '-i', '-j', '-d', drv, dev_path])
attempts.append(['smartctl', '-i', '-j', dev_path])
@@ -2495,12 +2514,14 @@ class HealthMonitor:
# sleep — issue #232. The "UNKNOWN" branch below correctly
# keeps the previous cached result alive on exit code 2.
#
# Removable disks may be USB-NVMe bridges: try snt* drivers
# first so health reflects the actual NVMe controller. A
# bridge that fakes "PASSED" while the drive behind it is
# USB-attached disks may sit behind an NVMe bridge: try snt*
# drivers first so health reflects the actual NVMe controller.
# A bridge that fakes "PASSED" while the drive behind it is
# failing is exactly the false-negative we want to avoid.
# USB detection uses the sysfs path so USB-NVMe bridges (which
# report removable=0) are caught too.
attempts = []
if _is_disk_removable(disk_name):
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
for drv in _USB_NVME_DRIVERS:
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', '-d', drv, dev_path])
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', dev_path])