diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a27649f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/.build/ +/PegasusMonitor.xcodeproj/ diff --git a/.idea/PegasusMonitor.iml b/.idea/PegasusMonitor.iml index 6102194..d4b4a87 100644 --- a/.idea/PegasusMonitor.iml +++ b/.idea/PegasusMonitor.iml @@ -1,7 +1,24 @@ - + + + + + + + + + + + + + + + + + + diff --git a/PegasusMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/PegasusMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index 13f095b..9b566b9 100644 Binary files a/PegasusMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png and b/PegasusMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/PegasusMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json b/PegasusMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json index 5d6d8e7..a5365a2 100644 --- a/PegasusMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/PegasusMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -11,4 +11,4 @@ "author": "xcode", "version": 1 } -} \ No newline at end of file +} diff --git a/Scripts/release.sh.20261021-142305.bak b/Scripts/release.sh.20261021-142305.bak deleted file mode 100755 index ab0e7f7..0000000 --- a/Scripts/release.sh.20261021-142305.bak +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/zsh -set -euo pipefail - -SCRIPT_DIR="${0:A:h}" -ROOT="${SCRIPT_DIR:h}" -PROJECT="$ROOT/PegasusMonitor.xcodeproj" -SCHEME="PegasusMonitor" -DERIVED="$ROOT/.build/ReleaseDerivedData" -APP="$DERIVED/Build/Products/Release/PegasusMonitor.app" -DIST="$ROOT/dist" - -[[ -d "$PROJECT" ]] || { print -u2 "Missing Xcode project: $PROJECT"; exit 1; } -for cmd in xcodebuild codesign hdiutil; do - command -v "$cmd" >/dev/null || { print -u2 "Missing command: $cmd"; exit 1; } -done - -# Choose a Developer ID Application identity unless explicitly provided. -IDENTITY="${SIGNING_IDENTITY:-}" -if [[ -z "$IDENTITY" ]]; then - IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null | awk '/Developer ID Application:/ {print $2; exit}')" -fi -if [[ -z "$IDENTITY" && "${ALLOW_UNSIGNED:-0}" != 1 ]]; then - print -u2 'No Developer ID Application identity found. Set SIGNING_IDENTITY or use ALLOW_UNSIGNED=1 for a local test package.' - exit 1 -fi - -print "Building $SCHEME (Release)..." -xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration Release \ - -derivedDataPath "$DERIVED" -destination 'platform=macOS' \ - CODE_SIGNING_ALLOWED=NO build -[[ -d "$APP" ]] || { print -u2 "Missing built application: $APP"; exit 1; } - -VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" -BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP/Contents/Info.plist")" -NAME="PegasusMonitor-${VERSION}-${BUILD}" -mkdir -p "$DIST" -DMG="$DIST/$NAME.dmg" - -if [[ -n "$IDENTITY" ]]; then - print "Signing application with: $IDENTITY" - codesign --force --deep --options runtime --timestamp --sign "$IDENTITY" "$APP" - codesign --verify --deep --strict --verbose=2 "$APP" -else - print 'WARNING: creating unsigned local-test release; distribution outside this Mac is not recommended.' -fi - -STAGE="$(mktemp -d)" -trap 'rm -rf "$STAGE"' EXIT -cp -R "$APP" "$STAGE/PegasusMonitor.app" -ln -s /Applications "$STAGE/Applications" -rm -f "$DMG" -hdiutil create -volname "Pegasus Monitor" -srcfolder "$STAGE" -format UDZO -ov "$DMG" >/dev/null - -# Optional Apple notary profile, previously saved with `xcrun notarytool store-credentials`. -if [[ -n "${NOTARY_PROFILE:-}" ]]; then - [[ -n "$IDENTITY" ]] || { print -u2 'Notarization requires a signed app.'; exit 1; } - print "Submitting for notarization with profile: $NOTARY_PROFILE" - xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" -fi - -print "Release: $DMG" -print "Version: $VERSION ($BUILD)" diff --git a/Scripts/release.sh.20261021-142423.bak b/Scripts/release.sh.20261021-142423.bak deleted file mode 100755 index 7b6c413..0000000 --- a/Scripts/release.sh.20261021-142423.bak +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/zsh -set -euo pipefail - -SCRIPT_DIR="${0:A:h}" -ROOT="${SCRIPT_DIR:h}" -PROJECT="$ROOT/PegasusMonitor.xcodeproj" -SCHEME="PegasusMonitor" -DERIVED="$ROOT/.build/ReleaseDerivedData" -APP="$DERIVED/Build/Products/Release/PegasusMonitor.app" -DIST="$ROOT/dist" - -[[ -d "$PROJECT" ]] || { print -u2 "Missing Xcode project: $PROJECT"; exit 1; } -for cmd in xcodebuild codesign hdiutil; do - command -v "$cmd" >/dev/null || { print -u2 "Missing command: $cmd"; exit 1; } -done - -# Choose a Developer ID Application identity unless explicitly provided. -IDENTITY="${SIGNING_IDENTITY:-}" -if [[ -z "$IDENTITY" ]]; then - IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null | awk '/Developer ID Application:/ {print $2; exit}')" -fi -if [[ -z "$IDENTITY" && "${ALLOW_UNSIGNED:-0}" != 1 ]]; then - print -u2 'No Developer ID Application identity found. Set SIGNING_IDENTITY or use ALLOW_UNSIGNED=1 for a local test package.' - exit 1 -fi - -print "Building $SCHEME (Release)..." -xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration Release \ - -derivedDataPath "$DERIVED" -destination 'platform=macOS' \ - CODE_SIGNING_ALLOWED=NO build -[[ -d "$APP" ]] || { print -u2 "Missing built application: $APP"; exit 1; } - -VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" -BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP/Contents/Info.plist")" -NAME="PegasusMonitor-${VERSION}-${BUILD}" -mkdir -p "$DIST" -DMG="$DIST/$NAME.dmg" - -if [[ -n "$IDENTITY" ]]; then - print "Signing application with: $IDENTITY" - codesign --force --deep --options runtime --timestamp --sign "$IDENTITY" "$APP" - codesign --verify --deep --strict --verbose=2 "$APP" -else - print 'WARNING: creating unsigned local-test release; distribution outside this Mac is not recommended.' -fi - -STAGE="$(mktemp -d)" -trap 'rm -rf "$STAGE"' EXIT -cp -R "$APP" "$STAGE/PegasusMonitor.app" -ln -s /Applications "$STAGE/Applications" -rm -f "$DMG" -hdiutil create -volname "Pegasus Monitor" -srcfolder "$STAGE" -format UDZO -ov "$DMG" >/dev/null - -# Assign the application icon to the DMG file in Finder. -ICON="$APP/Contents/Resources/AppIcon.icns" - -if [[ -f "$ICON" ]]; then - /usr/bin/swift - "$ICON" "$DMG" <<'SWIFT' -import AppKit -import Foundation - -let iconPath = CommandLine.arguments[1] -let dmgPath = CommandLine.arguments[2] - -guard let image = NSImage(contentsOfFile: iconPath) else { - fatalError("Unable to load DMG icon") -} - -guard NSWorkspace.shared.setIcon( - image, - forFile: dmgPath, - options: [] -) else { - fatalError("Unable to assign DMG icon") -} - -print("Custom DMG icon applied.") -SWIFT -fi - -# Optional Apple notary profile, previously saved with `xcrun notarytool store-credentials`. -if [[ -n "${NOTARY_PROFILE:-}" ]]; then - [[ -n "$IDENTITY" ]] || { print -u2 'Notarization requires a signed app.'; exit 1; } - print "Submitting for notarization with profile: $NOTARY_PROFILE" - xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" -fi - -print "Release: $DMG" -print "Version: $VERSION ($BUILD)" diff --git a/server/pegasus-health.py b/server/pegasus-health.py new file mode 100644 index 0000000..94cac88 --- /dev/null +++ b/server/pegasus-health.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Read-only post-reboot audit for Pegasus (Ubuntu). Python 3 stdlib only.""" +import datetime +import os +import shutil +import socket +import subprocess +import sys +from pathlib import Path + +OK = 0 +WARN = 0 +FAIL = 0 + +def run(*args, timeout=12): + try: + p = subprocess.run(args, text=True, capture_output=True, timeout=timeout) + return p.returncode, (p.stdout + p.stderr).strip() + except (OSError, subprocess.TimeoutExpired) as exc: + return 124, str(exc) + +def check(label, success, detail='', severity='fail'): + global OK, WARN, FAIL + if success: + OK += 1 + tag = 'OK ' + elif severity == 'warn': + WARN += 1 + tag = 'WARN' + else: + FAIL += 1 + tag = 'FAIL' + print(f'[{tag}] {label}' + (f' — {detail}' if detail else '')) + +def section(label): + print(f'\n=== {label} ===') + +def active(unit, required=True): + rc, state = run('systemctl', 'is-active', unit) + check(unit, rc == 0 and state == 'active', state.splitlines()[0] if state else 'unknown', + 'fail' if required else 'warn') + +def port(label, port, host='127.0.0.1', required=False): + try: + with socket.create_connection((host, port), timeout=2): + check(label, True, f'{host}:{port} listening') + except OSError as exc: + check(label, False, f'{host}:{port} not reachable ({exc})', 'fail' if required else 'warn') + +def web(label, url, required=False): + if not shutil.which('curl'): + check(label, False, 'curl not installed', 'warn') + return + rc, value = run('curl', '-L', '-sS', '-o', '/dev/null', '--connect-timeout', '4', '--max-time', '12', + '-w', '%{http_code}', url, timeout=15) + code = value[-3:] if len(value) >= 3 else value + # 401/403 may be expected for protected applications. + success = rc == 0 and code.isdigit() and 200 <= int(code) < 400 + check(label, success, f'HTTP {code}' if rc == 0 else value[:120], + 'fail' if required else 'warn') + +now = datetime.datetime.now().astimezone() +print('PEGASUS — POST-REBOOT SYSTEM HEALTH') +print('Time:', now.isoformat(timespec='seconds')) +print('Host:', socket.gethostname(), '| User:', os.environ.get('USER', '?')) +if os.geteuid() != 0: + print('NOTE: Run with sudo for full Docker, kernel journal and SMART visibility.') + +section('BOOT AND SYSTEM') +rc, value = run('uptime', '-p') +print('Uptime:', value) +rc, value = run('systemctl', '--failed', '--no-legend', '--plain', '--no-pager') +failed = [line for line in value.splitlines() if '.service' in line or '.mount' in line or '.socket' in line] +check('Failed systemd units', rc == 0 and not failed, '; '.join(failed[:8]) or 'none') +rc, value = run('journalctl', '-b', '-p', 'err', '--no-pager', '-n', '15') +if rc == 0 and value and 'No entries' not in value: + print('Recent boot errors (may include recovered/transient errors):') + for line in value.splitlines()[-15:]: + print(' ', line[:200]) + +section('STORAGE') +rc, value = run('findmnt', '-rn', '-o', 'SOURCE,FSTYPE', '--target', '/backup') +backed = rc == 0 and value and value.split()[0] != '/dev/mapper/ubuntu--vg-ubuntu--lv' +# Strict mountpoint test avoids interpreting root filesystem as a backup mount. +rc2, _ = run('mountpoint', '-q', '/backup') +backed = backed and rc2 == 0 +check('/backup mounted', backed, value if backed else 'backup unavailable — DO NOT RUN BACKUPS') +if backed: + rc, uuid = run('findmnt', '-n', '-o', 'UUID', '--target', '/backup') + expected = '008c9f5d-5274-4d27-9ebe-ba8a6986f5ad' + check('Backup filesystem UUID', rc == 0 and uuid.strip() == expected, + f'found {uuid.strip() or "unknown"}; expected {expected}') +for path in ('/', '/backup', '/mnt/timemachine'): + if path == '/backup' and not backed: + continue + if path == '/mnt/timemachine': + rc, _ = run('mountpoint', '-q', path) + if rc: + check('Time Machine mount', False, f'{path} not mounted (if expected)', 'warn') + continue + try: + usage = shutil.disk_usage(path) + used = 100 * usage.used / usage.total + check(f'Space {path}', used < 90, f'{used:.1f}% used, {usage.free / 2**30:.1f} GiB free', + 'warn' if used < 95 else 'fail') + except OSError as exc: + check(f'Space {path}', False, str(exc)) +for folder in ('/backup/borg', '/backup/vms', '/backup/sebrant'): + if backed: + check(folder, Path(folder).is_dir(), 'directory present' if Path(folder).is_dir() else 'missing', 'warn') + +section('CORE SERVICES') +for unit in ('nginx', 'mysql', 'postgresql', 'redis-server', 'docker', 'containerd', + 'gitea', 'cloudflared', 'cron', 'libvirtd'): + active(unit) +for unit in ('php8.3-fpm', 'php8.4-fpm', 'netdata', 'cockpit.socket', + 'openvpn', 'strongswan-starter', 'olilo-l2tp', 'gitea-mcp', + 'pegasus-backup.timer', 'pegasus-backup.service'): + rc, output = run('systemctl', 'show', '-p', 'LoadState', '--value', unit) + if rc == 0 and output.strip() not in ('not-found', ''): + if unit.endswith('.service') and ('backup' in unit): + rc2, state = run('systemctl', 'is-active', unit) + check(unit, state in ('active', 'inactive'), + f'{state} (oneshot jobs can normally be inactive)', 'warn') + else: + active(unit, required=False) +rc, value = run('nginx', '-t') +check('Nginx configuration', rc == 0, value.splitlines()[-1][:120] if value else 'no result') + +section('DATABASE AND SERVICE PORTS') +for label, portno, required in ( + ('Nginx HTTP', 80, True), ('Nginx HTTPS', 443, True), + ('MySQL', 3306, True), ('Redis', 6379, True), + ('PostgreSQL', 5432, False), ('Gitea HTTP', 3000, False), + ('Cockpit', 9090, False), ('Netdata', 19999, False), + ('Domino NRPC', 1352, False), ('Mail SMTP', 25, False), + ('Mail submission', 587, False), ('Mail IMAPS', 993, False), +): + port(label, portno, required=required) + +section('DOCKER CONTAINERS') +if shutil.which('docker'): + rc, value = run('docker', 'ps', '-a', '--format', '{{.Names}}|{{.State}}|{{.Status}}', timeout=25) + if rc != 0: + check('Docker query', False, value[:150]) + else: + lines = [line for line in value.splitlines() if '|' in line] + for line in sorted(lines): + name, state, status = line.split('|', 2) + # Some stacks are intentionally stopped; report, do not declare them failed. + expected = {'sonarqube', 'sonarqube-db', 'zap', 'mailpit', 'mailserver', + 'komodo-core', 'komodo-periphery', 'komodo-ferretdb', + 'komodo-postgres', 'registry'} + required = name in expected + healthy = state == 'running' and '(unhealthy)' not in status + check(f'Container {name}', healthy, status, 'fail' if required else 'warn') + check('Docker containers discovered', bool(lines), f'{len(lines)} present') +else: + check('Docker CLI', False, 'not installed') + +section('VIRTUAL MACHINES') +if shutil.which('virsh'): + rc, value = run('virsh', 'list', '--all', '--name') + names = [n.strip() for n in value.splitlines() if n.strip()] if rc == 0 else [] + if 'domino651' in names: + rc, state = run('virsh', 'domstate', 'domino651') + check('domino651 VM', rc == 0 and state.strip() == 'running', state) + else: + check('domino651 VM', False, 'not found via default virsh connection', 'warn') + rc, value = run('virsh', 'dominfo', 'domino651') + if rc == 0: + autostart = next((x for x in value.splitlines() if x.startswith('Autostart:')), '') + check('domino651 autostart', 'enable' in autostart.lower(), autostart.strip(), 'warn') + +section('NETWORK AND VPN') +for device in ('br0', 'ppp0', 'tun0'): + rc, value = run('ip', '-br', 'addr', 'show', device) + check(f'Interface {device}', rc == 0 and ' UP ' in f' {value} ', value[:120], + 'fail' if device in ('br0', 'ppp0') else 'warn') +rc, value = run('ip', 'route', 'show', 'default') +check('Default route', rc == 0 and bool(value), value.replace('\n', '; ')[:150]) +rc, value = run('getent', 'ahostsv4', 'cloudflare.com') +check('DNS resolution', rc == 0 and bool(value), value.splitlines()[0][:100] if value else 'failed') +for label, host, service_port in (('OpenVPN UDP', '0.0.0.0', 1194), ('IKEv2 UDP', '0.0.0.0', 500), + ('IPsec NAT-T UDP', '0.0.0.0', 4500)): + rc, value = run('ss', '-H', '-lun') + present = rc == 0 and any(f':{service_port} ' in line for line in value.splitlines()) + check(label, present, f'UDP {service_port} listening' if present else 'not listening', 'warn') + +section('PUBLIC ENDPOINTS (EXTERNAL NETWORK / DNS DEPENDENT)') +for label, url in ( + ('absdev.net', 'https://absdev.net'), ('git.absdev.net', 'https://git.absdev.net'), + ('notes.absdev.net', 'https://notes.absdev.net'), + ('cloud.absdev.net', 'https://cloud.absdev.net')): + web(label, url, required=False) + +section('BACKUP JOBS') +for unit in ('pegasus-backup.service', 'pegasus-backup.timer'): + rc, value = run('systemctl', 'show', unit, '-p', 'LoadState', '-p', 'ActiveState', + '-p', 'Result', '-p', 'UnitFileState') + if rc == 0 and 'LoadState=not-found' not in value: + print(unit + ': ' + value.replace('\n', ' | ')) +rc, value = run('systemctl', 'list-timers', '--all', '--no-pager', '--plain') +if rc == 0: + for line in value.splitlines(): + if any(word in line.lower() for word in ('backup', 'borg', 'certbot')): + print(line[:200]) + +print('\n' + '=' * 58) +print(f'SUMMARY: {OK} OK | {WARN} WARN | {FAIL} FAIL') +print('Warnings include intentionally stopped or optional services; review them.') +print('This checks availability, not complete application behavior or backup integrity.') +sys.exit(2 if FAIL else 1 if WARN else 0)