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
+25 -1
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))
+198 -32
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():
+31 -10
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])