This commit is contained in:
Mark Gregory
2026-10-21 14:34:16 +01:00
parent ba66bab7bc
commit f6d5505380
7 changed files with 235 additions and 157 deletions
+3
View File
@@ -0,0 +1,3 @@
/dist/
/.build/
/PegasusMonitor.xcodeproj/
+18 -1
View File
@@ -1,7 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.build/DerivedData/Build/Intermediates.noindex/EagerLinkingTBDs" />
<excludeFolder url="file://$MODULE_DIR$/.build/DerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Debug/PegasusMonitor.build/assetcatalog_output" />
<excludeFolder url="file://$MODULE_DIR$/.build/DerivedData/Build/Intermediates.noindex/PrecompiledHeaders" />
<excludeFolder url="file://$MODULE_DIR$/.build/DerivedData/Build/Products/Debug/PegasusMonitor.app/Contents/Resources" />
<excludeFolder url="file://$MODULE_DIR$/.build/DerivedData/Logs/Launch" />
<excludeFolder url="file://$MODULE_DIR$/.build/DerivedData/Logs/Localization" />
<excludeFolder url="file://$MODULE_DIR$/.build/DerivedData/Logs/Package" />
<excludeFolder url="file://$MODULE_DIR$/.build/DerivedData/Logs/Test" />
<excludeFolder url="file://$MODULE_DIR$/.build/ReleaseDerivedData/Build/Intermediates.noindex/EagerLinkingTBDs" />
<excludeFolder url="file://$MODULE_DIR$/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/assetcatalog_output/unthinned" />
<excludeFolder url="file://$MODULE_DIR$/.build/ReleaseDerivedData/Build/Intermediates.noindex/PrecompiledHeaders" />
<excludeFolder url="file://$MODULE_DIR$/.build/ReleaseDerivedData/Logs/Launch" />
<excludeFolder url="file://$MODULE_DIR$/.build/ReleaseDerivedData/Logs/Localization" />
<excludeFolder url="file://$MODULE_DIR$/.build/ReleaseDerivedData/Logs/Package" />
<excludeFolder url="file://$MODULE_DIR$/.build/ReleaseDerivedData/Logs/Test" />
<excludeFolder url="file://$MODULE_DIR$/PegasusMonitor.xcodeproj/project.xcworkspace/xcshareddata" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -11,4 +11,4 @@
"author": "xcode",
"version": 1
}
}
}
-64
View File
@@ -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)"
-91
View File
@@ -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)"
+213
View File
@@ -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)