mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-12 23:12:54 +09:00
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.
**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:
DELETE containers down (VOLUMES KEPT), vhost removed, firewall closed,
cron suspended, row tombstoned. All reversible.
RESTORE vhost rewritten, firewall reopened, cron resumed. Containers left
STOPPED -- "put it back" must not silently start processes or open
ports, the same reasoning that stops domain restore re-issuing certs.
PURGE volumes removed, uploaded source deleted, cron detached.
register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.
A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.
**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.
**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().
Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.
Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.
Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""Diagnostic support bundle: gather panel state into a shareable zip.
|
|
|
|
Everything collected here is meant to leave the box (attached to a bug report),
|
|
so the collectors are shape-over-content by design:
|
|
|
|
* settings are exported as **keys + declared types only** — never values; keys
|
|
in ``SettingsService.SECRET_KEYS`` are additionally flagged as secrets.
|
|
* every piece of free text (logs, job errors, doctor details) passes through
|
|
``_scrub()`` which redacts anything that looks like a token / password /
|
|
secret / key assignment or a bearer header.
|
|
|
|
Encryption: ``pyzipper`` is NOT in requirements.txt and we deliberately add no
|
|
new dependency, so bundles are plain ``zipfile`` zips. When a passphrase is
|
|
requested we record the limitation in the bundle README and advise encrypting
|
|
with the operator's own tooling (e.g. ``gpg -c serverkit-support-*.zip``).
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from datetime import datetime
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LOG_TAIL_LINES = 200
|
|
RECENT_JOB_FAILURES = 20
|
|
|
|
# Candidate panel log files, best-effort — the systemd deployment logs to the
|
|
# journal (no file), so absence is normal.
|
|
LOG_FILE_CANDIDATES = (
|
|
os.environ.get('SERVERKIT_LOG_FILE') or '',
|
|
'/var/log/serverkit/serverkit.log',
|
|
'/var/log/serverkit.log',
|
|
)
|
|
|
|
# key = value / "key": "value" pairs whose key smells secret-ish.
|
|
_SECRETY_KEY = r'[\w.-]*(?:token|secret|passw(?:or)?d|api[_-]?key|private[_-]?key|access[_-]?key|credential|authorization)[\w.-]*'
|
|
_ASSIGNMENT_RE = re.compile(
|
|
r'(?i)(["\']?' + _SECRETY_KEY + r'["\']?\s*[:=]\s*)(["\']?)([^\s"\',;}]+)'
|
|
)
|
|
_BEARER_RE = re.compile(r'(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}')
|
|
# Standalone JWTs (three dot-separated base64url segments).
|
|
_JWT_RE = re.compile(r'\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b')
|
|
|
|
|
|
def _scrub(text):
|
|
"""Redact secret-looking material from free text. Idempotent."""
|
|
if not text:
|
|
return text
|
|
if not isinstance(text, str):
|
|
text = str(text)
|
|
# Order matters: catch "Bearer <token>" / bare JWTs before the assignment
|
|
# pattern, so "Authorization: Bearer x" never leaves the raw token behind.
|
|
text = _JWT_RE.sub('[REDACTED-JWT]', text)
|
|
text = _BEARER_RE.sub(lambda m: f'{m.group(1)} [REDACTED]', text)
|
|
text = _ASSIGNMENT_RE.sub(lambda m: f'{m.group(1)}{m.group(2)}[REDACTED]', text)
|
|
return text
|
|
|
|
|
|
def _safe(section_name, fn, default=None):
|
|
"""Run a collector; a broken collector must never sink the whole bundle."""
|
|
try:
|
|
return fn()
|
|
except Exception as exc: # noqa: BLE001 - diagnostics are best-effort
|
|
logger.warning(f'support bundle: {section_name} collector failed: {exc}')
|
|
return {'error': f'collector failed: {exc.__class__.__name__}'} if default is None else default
|
|
|
|
|
|
def _collect_meta():
|
|
from app.utils.version import get_panel_version, get_install_dir
|
|
return {
|
|
'generated_at': datetime.utcnow().isoformat() + 'Z',
|
|
'panel_version': get_panel_version(),
|
|
'install_dir': get_install_dir(),
|
|
'python_version': sys.version,
|
|
'platform': platform.platform(),
|
|
'os_name': os.name,
|
|
}
|
|
|
|
|
|
def _collect_db():
|
|
from app import db
|
|
info = {'engine': db.engine.url.get_backend_name()}
|
|
try:
|
|
from app.services.migration_service import MigrationService
|
|
status = MigrationService.get_status()
|
|
info['migration_current'] = status.get('current_revision')
|
|
info['migration_head'] = status.get('head_revision')
|
|
info['migration_pending'] = status.get('pending_count')
|
|
except Exception as exc: # noqa: BLE001
|
|
info['migration_error'] = exc.__class__.__name__
|
|
return info
|
|
|
|
|
|
def _collect_counts():
|
|
from app.models import Application, Domain, Server, User
|
|
from app.jobs.models import Job
|
|
return {
|
|
# Live rows only, matching `domains` below — the bundle reports what the
|
|
# install currently runs, not how much is sitting in the recycle bin.
|
|
'applications': Application.query_active().count(),
|
|
'domains': Domain.query_active().count(),
|
|
'servers': Server.query.count(),
|
|
'users': User.query.count(),
|
|
'jobs': Job.query.count(),
|
|
}
|
|
|
|
|
|
def _collect_services():
|
|
if os.name == 'nt':
|
|
return {'skipped': 'service states are Linux-only'}
|
|
from app.services.process_service import ProcessService
|
|
return {'services': ProcessService.get_services_status()}
|
|
|
|
|
|
def _collect_settings_shapes():
|
|
"""Setting keys + declared types ONLY. Values are never exported; secret
|
|
keys are only reported as set/empty."""
|
|
from app.models import SystemSettings
|
|
from app.services.settings_service import SettingsService
|
|
|
|
shapes = []
|
|
for row in SystemSettings.query.order_by(SystemSettings.key.asc()).all():
|
|
is_secret = row.key in SettingsService.SECRET_KEYS
|
|
shapes.append({
|
|
'key': row.key,
|
|
'value_type': 'secret' if is_secret else (row.value_type or 'string'),
|
|
'is_set': bool(row.value),
|
|
})
|
|
return {'settings': shapes}
|
|
|
|
|
|
def _collect_job_failures():
|
|
from app.jobs.models import Job
|
|
rows = (
|
|
Job.query
|
|
.filter_by(status=Job.STATUS_FAILED)
|
|
.order_by(Job.created_at.desc())
|
|
.limit(RECENT_JOB_FAILURES)
|
|
.all()
|
|
)
|
|
failures = []
|
|
for job in rows:
|
|
failures.append({
|
|
'id': job.id,
|
|
'kind': job.kind,
|
|
'attempts': job.attempts,
|
|
'created_at': job.created_at.isoformat() if job.created_at else None,
|
|
'completed_at': job.completed_at.isoformat() if job.completed_at else None,
|
|
'error_message': _scrub(job.error_message or ''),
|
|
})
|
|
return {'recent_failures': failures}
|
|
|
|
|
|
def _collect_restore_confidence():
|
|
"""Per-policy restore-proof summary (plan 23): last run + verify level, last
|
|
drill age/status, cadence, offsite evidence level. Counts and states only —
|
|
no paths, no secrets, matching the bundle's scrub discipline."""
|
|
from datetime import datetime
|
|
from app.models.backup_policy import BackupPolicy
|
|
from app.models.backup_run import BackupRun
|
|
|
|
policies = []
|
|
for policy in BackupPolicy.query.order_by(BackupPolicy.id.asc()).all():
|
|
latest = (BackupRun.query
|
|
.filter_by(policy_id=policy.id, status='success')
|
|
.order_by(BackupRun.started_at.desc()).first())
|
|
offsite = (latest.get_metadata() or {}).get('offsite') if latest else None
|
|
last_drill_age_days = None
|
|
if policy.last_drill_at:
|
|
last_drill_age_days = (datetime.utcnow() - policy.last_drill_at).days
|
|
policies.append({
|
|
'target_type': policy.target_type,
|
|
'target_id': policy.target_id,
|
|
'enabled': policy.enabled,
|
|
'remote_copy': policy.remote_copy,
|
|
'drill_cadence': policy.drill_cadence or 'off',
|
|
'last_run_status': policy.last_status,
|
|
'latest_verify_level': latest.effective_verify_level() if latest else 'none',
|
|
'latest_verify_error': bool(latest and latest.verify_error),
|
|
'last_drill_status': policy.last_drill_status,
|
|
'last_drill_age_days': last_drill_age_days,
|
|
'offsite_evidence': (offsite or {}).get('evidence') if offsite else None,
|
|
'offsite_deep_evidence': (offsite or {}).get('deep_evidence') if offsite else None,
|
|
})
|
|
return {'policies': policies, 'total': len(policies)}
|
|
|
|
|
|
def _collect_doctor():
|
|
"""Last doctor report, if the doctor service (built separately) exposes one."""
|
|
try:
|
|
from app.services import doctor_service
|
|
except Exception: # noqa: BLE001 - module may not exist yet
|
|
return {'skipped': 'doctor service not available'}
|
|
for attr in ('get_last_report', 'last_report'):
|
|
candidate = getattr(doctor_service, attr, None)
|
|
if candidate is None:
|
|
continue
|
|
try:
|
|
report = candidate() if callable(candidate) else candidate
|
|
return {'report': json.loads(_scrub(json.dumps(report, default=str)))}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {'error': f'doctor report unavailable: {exc.__class__.__name__}'}
|
|
return {'skipped': 'doctor service exposes no last report'}
|
|
|
|
|
|
def _collect_log_tail():
|
|
for candidate in LOG_FILE_CANDIDATES:
|
|
if not candidate or not os.path.isfile(candidate):
|
|
continue
|
|
try:
|
|
with open(candidate, 'r', encoding='utf-8', errors='replace') as fh:
|
|
lines = fh.readlines()[-LOG_TAIL_LINES:]
|
|
return f'# source: {candidate}\n' + _scrub(''.join(lines))
|
|
except OSError:
|
|
continue
|
|
return '# no panel log file found (systemd deployments log to the journal)\n'
|
|
|
|
|
|
def build(out_path=None, passphrase=None):
|
|
"""Build the support bundle zip. Returns the absolute path to the zip.
|
|
|
|
Must run inside an app context. ``passphrase`` is accepted for forward
|
|
compatibility but zip encryption is unavailable without pyzipper (not a
|
|
dependency) — the limitation is recorded in the bundle README.
|
|
"""
|
|
stamp = datetime.utcnow().strftime('%Y%m%d-%H%M%S')
|
|
if not out_path:
|
|
out_path = os.path.join(tempfile.gettempdir(), f'serverkit-support-{stamp}.zip')
|
|
out_path = os.path.abspath(out_path)
|
|
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
|
|
|
|
sections = {
|
|
'meta.json': _safe('meta', _collect_meta),
|
|
'db.json': _safe('db', _collect_db),
|
|
'counts.json': _safe('counts', _collect_counts),
|
|
'services.json': _safe('services', _collect_services),
|
|
'settings_shapes.json': _safe('settings_shapes', _collect_settings_shapes),
|
|
'jobs.json': _safe('jobs', _collect_job_failures),
|
|
'doctor.json': _safe('doctor', _collect_doctor),
|
|
'restore_confidence.json': _safe('restore_confidence', _collect_restore_confidence),
|
|
}
|
|
log_tail = _safe('log_tail', _collect_log_tail, default='# log collection failed\n')
|
|
|
|
readme_lines = [
|
|
'ServerKit support bundle',
|
|
f'Generated: {sections["meta.json"].get("generated_at", "unknown")}',
|
|
'',
|
|
'Contents are diagnostic shapes only: setting VALUES are never included,',
|
|
'and all collected text is scrubbed for token/password/secret/key material.',
|
|
]
|
|
if passphrase:
|
|
readme_lines += [
|
|
'',
|
|
'NOTE: a passphrase was requested, but built-in zip encryption requires',
|
|
'pyzipper, which is not a ServerKit dependency. This bundle is NOT',
|
|
'encrypted — encrypt it yourself before sharing, e.g.:',
|
|
' gpg -c ' + os.path.basename(out_path),
|
|
]
|
|
logger.warning('support bundle: passphrase requested but pyzipper is not installed; wrote plain zip')
|
|
|
|
with zipfile.ZipFile(out_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr('README.txt', '\n'.join(readme_lines) + '\n')
|
|
for name, payload in sections.items():
|
|
serialized = json.dumps(payload, indent=2, default=str)
|
|
zf.writestr(name, _scrub(serialized))
|
|
zf.writestr('log_tail.txt', log_tail)
|
|
|
|
return out_path
|