Files
ServerKit/backend/app/services/support_bundle_service.py
T
Juan DenisandClaude Opus 5 5f8a3ef204 feat(recycle-bin): applications are soft-deletable, and a delete stops keeping data hostage
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>
2026-08-12 20:26:04 -04:00

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