Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.7.76
1.7.78
51 changes: 51 additions & 0 deletions backend/app/api/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from app.models import User
from app.services.system_service import SystemService
from app.services.resource_tier_service import ResourceTierService
from app.services import install_profile_service
from app.services.site_domain_service import SiteDomainService
from app.utils.domain import is_valid_canonical_domain
from app.utils.version import get_panel_version, get_install_dir
Expand Down Expand Up @@ -283,6 +284,56 @@ def get_resource_tier():
return jsonify(tier_info), 200


@system_bp.route('/capacity', methods=['GET'])
@jwt_required()
def get_capacity():
"""
Server capacity: live headroom, install profile, and probed capabilities.

This is the endpoint the setup wizard and the dashboard should read.
/resource-tier remains for callers that only want the tier label; this one
additionally answers "what did we install" and "can this box host apps".
"""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)

if not user or user.role != 'admin':
return jsonify({'error': 'Admin access required'}), 403

force_refresh = request.args.get('refresh', 'false').lower() == 'true'
tier_info = ResourceTierService.get_tier_info(force_refresh=force_refresh)
profile_info = install_profile_service.get_profile_info(force_refresh=force_refresh)

return jsonify({
**tier_info,
**profile_info,
'recommended_profile': install_profile_service.recommend_profile(
tier_info['specs']
),
}), 200


@system_bp.route('/capacity/profile', methods=['PUT'])
@jwt_required()
def set_capacity_profile():
"""Record an operator's profile change made after install."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)

if not user or user.role != 'admin':
return jsonify({'error': 'Admin access required'}), 403

data = request.get_json() or {}
profile = data.get('profile')

try:
install_profile_service.set_profile(profile, user_id=user.id)
except ValueError as e:
return jsonify({'error': str(e)}), 400

return jsonify(install_profile_service.get_profile_info()), 200


@system_bp.route('/time', methods=['GET'])
@jwt_required()
def get_server_time():
Expand Down
17 changes: 17 additions & 0 deletions backend/app/jobs/builtin_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,22 @@ def run_job_retention():
return None


def run_security_feed_check():
"""Daily security-advisory feed check.

Pulls the normalized GHSA feed from serverkit.ai and notifies admins when
the running panel version falls inside a published advisory's affected
range, plus one-time post-fix reminders (e.g. key rotation) after an
upgrade crosses a fix boundary. Fails silent on outbound errors."""
from app.services.security_feed_service import check_security_feed

try:
return check_security_feed()
except Exception as e:
logger.debug(f'Security feed check skipped: {e}')
return None


def run_extension_update_check():
"""Daily registry check for installed-extension updates (#50).

Expand Down Expand Up @@ -350,6 +366,7 @@ def run_extension_update_check():
('builtin.registrar_expiry', run_registrar_expiry, 'registrar-expiry', 86400, 300),
('builtin.backup_scheduler', run_backup_scheduler, 'backup-scheduler', 30, 30),
('builtin.extension_updates', run_extension_update_check, 'extension-updates', 86400, 600),
('builtin.security_feed', run_security_feed_check, 'security-feed', 86400, 600),
('builtin.job_retention', run_job_retention, 'job-retention', 21600, 1500),
]

Expand Down
43 changes: 42 additions & 1 deletion backend/app/services/doctor_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,57 @@ def _drift_checks(cls):
r.get('detail') or 'Check could not run.'))
return checks

@classmethod
def _expected_services(cls):
"""
Core services this install is actually supposed to be running.

The minimal install profile ships without Docker on purpose, so probing
for it there would report a permanent, unfixable failure on a perfectly
healthy box. Docker is only dropped when it is genuinely both
unexpected *and* absent — an operator who installed it later, or a
standard/full install that has lost it (real drift), still gets probed.
"""
from app.services import install_profile_service as ips

services = []
for name in CORE_SERVICES:
if name != 'docker':
services.append(name)
continue
try:
unexpected = ips.get_profile() == ips.PROFILE_MINIMAL
absent = not ips.get_capabilities().get('docker')
except Exception: # noqa: BLE001
# Never let profile resolution suppress a real health check.
services.append(name)
continue
if not (unexpected and absent):
services.append(name)
return services

@classmethod
def _skipped_service_checks(cls, probed):
"""An 'ok' row explaining each core service deliberately not probed."""
return [
_check(f'service.{name}', f'{name} service', 'ok',
'Not installed — this install uses the Minimal profile. '
'Add it from Settings to enable app hosting.')
for name in CORE_SERVICES if name not in probed
]

@classmethod
def _service_checks(cls):
checks = []
probed = cls._expected_services()
if not sys.platform.startswith('linux'):
for name in CORE_SERVICES:
checks.append(_check(f'service.{name}', f'{name} service', 'warn',
'unsupported on this host'))
return checks
checks.extend(cls._skipped_service_checks(probed))
from app.utils.system import ServiceControl
for name in CORE_SERVICES:
for name in probed:
try:
active = ServiceControl.is_active(name)
except Exception as e: # noqa: BLE001
Expand Down
27 changes: 25 additions & 2 deletions backend/app/services/file_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ class FileService:
# Allowed root directories for browsing (security)
ALLOWED_ROOTS = ['/home', '/var/www', '/opt', '/srv', '/var/log', paths.SERVERKIT_DIR]

# Panel-internal directories that must NEVER be reachable through the file
# manager, for any role. The backend .env (JWT_SECRET_KEY, encryption key,
# DB creds), the SQLite instance dir and the generated deploy config all
# live here; on the documented install.sh layout the install dir sits under
# the allowed root /opt, so without this exclusion any user with files.read
# (including the default viewer role) could read the panel's own secrets
# and forge admin sessions (GHSA-rm3m-9mvw-68fh).
# __file__ = <install>/backend/app/services/file_service.py — two levels up
# is the backend dir, three is the install root. Getting this wrong by one
# level silently un-protects <install>/.env and frontend/dist.
_BACKEND_DIR = os.path.realpath(
os.path.join(os.path.dirname(__file__), '..', '..'))
_INSTALL_DIR = os.path.realpath(os.path.join(_BACKEND_DIR, '..'))
PROTECTED_ROOTS = [
_INSTALL_DIR,
_BACKEND_DIR, # covers flat layouts (e.g. code at /app, install=/ inert)
os.path.realpath(paths.SERVERKIT_CONFIG_DIR),
]

# File extensions that can be edited in browser
EDITABLE_EXTENSIONS = {
'.txt', '.md', '.json', '.xml', '.yml', '.yaml', '.ini', '.conf', '.cfg',
Expand All @@ -49,10 +68,14 @@ class FileService:

@classmethod
def is_path_allowed(cls, path: str) -> bool:
"""Check if path is within allowed directories."""
"""Check if path is within allowed directories and not panel-internal."""
try:
real_path = os.path.realpath(path)
return any(real_path.startswith(root) for root in cls.ALLOWED_ROOTS)
if any(real_path == root or real_path.startswith(root + os.sep)
for root in cls.PROTECTED_ROOTS):
return False
return any(real_path == root or real_path.startswith(root + os.sep)
for root in cls.ALLOWED_ROOTS)
except (ValueError, OSError):
return False

Expand Down
8 changes: 8 additions & 0 deletions backend/app/services/fleet_doctor_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@

# Core systemd units the fleet doctor probes on every agent (mirrors the
# panel-host doctor's CORE_SERVICES).
#
# Deliberately NOT profile-aware, unlike DoctorService._expected_services():
# an install profile describes the *panel* host, and these units live on remote
# agent boxes that were never provisioned by this panel's installer. Telling a
# Dockerless agent apart from a stopped-Docker agent needs the agent to report
# "unit not installed" rather than just active=false, which is a change in the
# serverkit-agent repo — panel↔agent protocol changes are not atomic with this
# one. Until then a Dockerless fleet member reports a Docker failure.
DOCTOR_UNITS = ('nginx', 'docker')

# Capability an agent advertises when it implements the batched doctor:probe
Expand Down
Loading