mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""The panel half of fleet policies.
|
|
|
|
Two jobs, and neither of them is deciding anything. ServerKit Cloud holds the
|
|
policy; this panel reports what is true and, when asked with a consent the
|
|
operator granted, changes it.
|
|
|
|
**Facts.** `build_facts()` assembles one document from services that already
|
|
exist — the firewall, fail2ban, the 2FA policy, the panel updater, backup
|
|
policies, the storage destination — and it advertises a `capabilities` list
|
|
naming only the bundles it could actually read. That list is the whole of the
|
|
capability gate: a rule Cloud cannot get facts for reads "not checkable here"
|
|
rather than "failing", which is FLEET_CONTRACT rule 2 and the difference
|
|
between a useful compliance page and a wall of red on servers that are fine.
|
|
|
|
**Repairs.** Four handlers, each calling the panel's own path — the same code
|
|
an operator pressing the button in this panel would run. A repair this install
|
|
cannot perform says so in a sentence rather than failing obscurely; Cloud shows
|
|
that sentence next to the finding.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from app.services.connect_commands import handler
|
|
from app.services.connect_format import iso_datetime as _iso
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FACTS_VERSION = 1
|
|
|
|
|
|
# ==================== facts ====================
|
|
|
|
|
|
def build_facts(app=None) -> dict:
|
|
"""One document describing this server. Never raises: a probe that fails
|
|
leaves its bundle out of `capabilities`, which is the honest answer."""
|
|
facts = {
|
|
"facts_version": FACTS_VERSION,
|
|
"reported_at": datetime.now(timezone.utc).isoformat(),
|
|
"capabilities": [],
|
|
}
|
|
|
|
security = _security_facts()
|
|
if security:
|
|
facts["security"] = security
|
|
facts["capabilities"].append("security")
|
|
|
|
updates = _update_facts()
|
|
if updates:
|
|
facts["updates"] = updates
|
|
facts["capabilities"].append("updates")
|
|
|
|
backups = _backup_facts(app)
|
|
if backups is not None:
|
|
facts["backups"] = backups
|
|
facts["capabilities"].append("backups")
|
|
|
|
return facts
|
|
|
|
|
|
def _security_facts() -> dict:
|
|
out = {}
|
|
try:
|
|
from app.services.firewall_service import FirewallService
|
|
status = FirewallService.get_status() or {}
|
|
out["firewall_enabled"] = bool(status.get("any_active"))
|
|
except Exception:
|
|
logger.debug("Connect policy: no firewall status", exc_info=True)
|
|
try:
|
|
from app.services.firewall_service import FirewallService
|
|
ports = FirewallService.ssh_ports()
|
|
if ports:
|
|
# One number is what the rule compares; the lowest configured port
|
|
# is the one an untargeted scan finds first.
|
|
out["ssh_port"] = int(min(ports))
|
|
except Exception:
|
|
logger.debug("Connect policy: no ssh port", exc_info=True)
|
|
try:
|
|
from app.services.fail2ban_jail_service import Fail2banJailService
|
|
status = Fail2banJailService.get_fail2ban_status() or {}
|
|
out["fail2ban_running"] = bool(status.get("installed")
|
|
and status.get("service_running"))
|
|
except Exception:
|
|
logger.debug("Connect policy: no fail2ban status", exc_info=True)
|
|
try:
|
|
from app.services.security_policy_service import SecurityPolicyService
|
|
out["panel_2fa_required"] = bool(SecurityPolicyService.require_2fa_enabled())
|
|
except Exception:
|
|
logger.debug("Connect policy: no 2FA policy", exc_info=True)
|
|
return out
|
|
|
|
|
|
def _update_facts() -> dict:
|
|
out = {}
|
|
try:
|
|
from app.services import panel_update_service
|
|
out["panel_version"] = panel_update_service.get_status().get("version")
|
|
except Exception:
|
|
logger.debug("Connect policy: no panel version", exc_info=True)
|
|
# Pending operating-system security updates are not probed by this panel
|
|
# yet, so `packages` is deliberately absent from capabilities and Cloud
|
|
# reads that rule as "not checkable here" rather than "no updates pending".
|
|
return out
|
|
|
|
|
|
def _backup_facts(app=None) -> dict | None:
|
|
"""Backups need an app context to read policy rows; the destination does
|
|
not. A panel with no backup engine returns None and is not judged on it."""
|
|
out = {}
|
|
try:
|
|
from app.services.storage_provider_service import StorageProviderService
|
|
config = StorageProviderService.get_config()
|
|
out["destination_configured"] = (config.get("provider") or "local") != "local"
|
|
except Exception:
|
|
logger.debug("Connect policy: no storage config", exc_info=True)
|
|
|
|
rows = _backup_policy_rows(app)
|
|
if rows is None and not out:
|
|
return None
|
|
if rows is not None:
|
|
newest_success = newest_verify = None
|
|
for row in rows:
|
|
if row.get("last_status") == "success" and row.get("last_run_at"):
|
|
at = row["last_run_at"]
|
|
newest_success = at if newest_success is None or at > newest_success else newest_success
|
|
if row.get("last_drill_at"):
|
|
at = row["last_drill_at"]
|
|
newest_verify = at if newest_verify is None or at > newest_verify else newest_verify
|
|
# The keys are always present when policies could be read, so Cloud can
|
|
# tell "never succeeded" apart from "did not report".
|
|
out["last_success_at"] = _iso(newest_success)
|
|
out["last_verified_at"] = _iso(newest_verify)
|
|
return out
|
|
|
|
|
|
def _backup_policy_rows(app=None):
|
|
try:
|
|
from app.models.backup_policy import BackupPolicy
|
|
except Exception:
|
|
return None
|
|
|
|
def collect():
|
|
return [{"last_status": p.last_status, "last_run_at": p.last_run_at,
|
|
"last_drill_at": p.last_drill_at} for p in BackupPolicy.query.all()]
|
|
|
|
try:
|
|
if app is not None:
|
|
with app.app_context():
|
|
return collect()
|
|
return collect()
|
|
except Exception:
|
|
logger.debug("Connect policy: could not read backup policies", exc_info=True)
|
|
return None
|
|
|
|
|
|
def facts_frame(stream_id: str, facts: dict) -> dict:
|
|
return {"s": stream_id, "t": "open", "k": "policy", "p": {"facts": facts}}
|
|
|
|
|
|
# ==================== commands ====================
|
|
|
|
|
|
@handler('policy.report')
|
|
def report(args: dict, app=None) -> dict:
|
|
""""Check now": build a document and push it up the policy stream."""
|
|
facts = build_facts(app)
|
|
client = _relay_client()
|
|
if client is None or not client.publish_policy(facts):
|
|
return {'ok': False, 'summary': 'The panel is not connected to ServerKit Cloud right '
|
|
'now, so the check could not be sent. It goes out on '
|
|
'the next connection.'}
|
|
return {'ok': True, 'summary': f'Reported {len(facts.get("capabilities") or [])} fact '
|
|
f'bundles.'}
|
|
|
|
|
|
@handler('security.firewall.enable')
|
|
def enable_firewall(args: dict, app=None) -> dict:
|
|
"""Turn the host firewall on, through the panel's own path — including its
|
|
refusal to lock SSH out, which is exactly the check we want here."""
|
|
try:
|
|
from app.services.firewall_service import FirewallService
|
|
except Exception as exc:
|
|
return {'ok': False, 'summary': f'This install has no firewall management ({exc}).'}
|
|
result = FirewallService.enable() or {}
|
|
if result.get('success') is False or result.get('error'):
|
|
return {'ok': False, 'summary': result.get('error') or 'The firewall did not come up.'}
|
|
return {'ok': True, 'summary': 'The host firewall is on.'}
|
|
|
|
|
|
@handler('security.2fa.require')
|
|
def require_2fa(args: dict, app=None) -> dict:
|
|
"""Turn on the panel's require-2FA policy. Its grace window starts now, so
|
|
nobody is locked out by a policy that arrived while they were asleep."""
|
|
try:
|
|
from app.services.security_policy_service import SecurityPolicyService
|
|
except Exception as exc:
|
|
return {'ok': False, 'summary': f'This install has no 2FA policy ({exc}).'}
|
|
|
|
def go():
|
|
SecurityPolicyService.set_require_2fa(True)
|
|
if app is not None:
|
|
with app.app_context():
|
|
go()
|
|
else:
|
|
go()
|
|
return {'ok': True, 'summary': 'The panel now requires two-factor authentication, with its '
|
|
'usual grace window for people who have not set it up yet.'}
|
|
|
|
|
|
@handler('security.fail2ban.enable')
|
|
def enable_fail2ban(args: dict, app=None) -> dict:
|
|
"""Start fail2ban if this host manages its own services."""
|
|
try:
|
|
from app.services.fail2ban_jail_service import Fail2banJailService
|
|
status = Fail2banJailService.get_fail2ban_status() or {}
|
|
except Exception as exc:
|
|
return {'ok': False, 'summary': f'This install cannot manage fail2ban ({exc}).'}
|
|
if not status.get('installed'):
|
|
return {'ok': False, 'summary': 'fail2ban is not installed on this host. Install it and '
|
|
'ServerKit Cloud will see it on the next check.'}
|
|
started = _systemctl('start', 'fail2ban')
|
|
if started is not True:
|
|
return {'ok': False, 'summary': f'fail2ban would not start: {started}'}
|
|
return {'ok': True, 'summary': 'fail2ban is running.'}
|
|
|
|
|
|
@handler('backup.verify')
|
|
def verify_backup(args: dict, app=None) -> dict:
|
|
try:
|
|
from app.services.backup_offsite_service import BackupOffsiteService
|
|
except Exception as exc:
|
|
return {'ok': False, 'summary': f'This install has no offsite verification ({exc}).'}
|
|
|
|
def go():
|
|
return BackupOffsiteService.run_offsite_verify()
|
|
result = (go() if app is None else _in_app(app, go)) or {}
|
|
if result.get('success') is False or result.get('error'):
|
|
return {'ok': False, 'summary': result.get('error') or 'The verification did not pass.'}
|
|
return {'ok': True, 'summary': 'The most recent backup was read back from its destination.'}
|
|
|
|
|
|
@handler('packages.security_upgrade')
|
|
def security_upgrade(args: dict, app=None) -> dict:
|
|
"""Not implemented on the panel yet, and it says so rather than pretending.
|
|
|
|
Cloud shows this sentence next to the finding, which is the truth: the rule
|
|
that produced it evaluates `unsupported` anyway, because `_update_facts`
|
|
does not advertise the `packages` bundle.
|
|
"""
|
|
return {'ok': False,
|
|
'summary': 'This version of ServerKit does not install operating-system security '
|
|
'updates from ServerKit Cloud. Run them on the host, or update ServerKit '
|
|
'and try again.'}
|
|
|
|
|
|
def _in_app(app, fn):
|
|
with app.app_context():
|
|
return fn()
|
|
|
|
|
|
def _systemctl(action: str, unit: str):
|
|
"""True, or the reason it did not work. Never raises.
|
|
|
|
run_privileged() adds sudo when the panel is not root, which is the
|
|
difference between a repair that works off a packaged install and one
|
|
that reports a permission error the operator cannot act on.
|
|
"""
|
|
from app.utils.system import is_command_available, run_privileged
|
|
if not is_command_available('systemctl'):
|
|
return 'this host does not use systemd'
|
|
try:
|
|
out = run_privileged(['systemctl', action, unit], timeout=60)
|
|
except Exception as exc:
|
|
return str(exc)
|
|
if out.returncode == 0:
|
|
return True
|
|
return (out.stderr or out.stdout or f'systemctl {action} {unit} exited {out.returncode}').strip()
|
|
|
|
|
|
def _relay_client():
|
|
try:
|
|
from app.services import connect_client
|
|
return connect_client.get_client()
|
|
except Exception:
|
|
return None
|