mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 21:43:05 +09:00
Plan 72 item A.2. FleetDoctorService, FleetRepairService, the FleetDoctorResult model, migration 063 and their tests were all complete and reachable from nothing: no routes, register_jobs() never called, no seeded schedule. The frontend was further along than the backend -- getFleetDoctorReport() and runFleetSweep() have been calling /doctor/fleet and /doctor/fleet/run, two endpoints that did not exist, so the multi-server health story the agent platform advertises 404'd on contact. Nothing in those services was redesigned. The one addition is a read-side aggregator: they could run a sweep and persist rows but had no way to hand back a per-server report, so fleet_report() composes one. It lists every registered server rather than only connected ones, which is what makes an unreachable box a reported state -- last known rows plus connected: false -- instead of a hole in the report. Persisted rows key on check_key while the UI's field-driven renderer keys on key, so the report emits both. Three admin-only routes, matching api/doctor.py's gating exactly. The sweep returns 202 with a job id and never runs on the request thread: it fans out across agents over the network, and the agent gateway registry is single-worker and in-memory. Repair is a thin passthrough to FleetRepairService, which already owns the allowlist, capability gate and audit trail; refusals come back as per-item results inside a 200, so one offline server cannot fail a batch. The batch is bounded at 25 so a single request cannot fan an unbounded number of remote mutations at that single worker. The schedule is daily like the host doctor but starts 30 minutes behind it, so the fleet fan-out contends with neither the local sweep nor the boot-time agent reconnect storm. The frontend gets a Fleet Doctor section rather than losing its dead methods. The per-check article was extracted into one component now shared by the host and fleet lists, so fleet findings render through the same repairable/repair_ref path instead of a parallel implementation; the confirm modal says plainly that a fleet repair acts on a remote server through its agent and is audited. test_jobs' builtin schedule count moves 21 -> 22 for the new schedule. The builtin.* kind count is untouched -- this kind is doctor.fleet.run. Note for anyone reading the suite: tests/test_fleet_doctor_dns_rotation.py is skipped at module level and proves nothing here. Its rotating-DNS-window API did not survive the recovery rebuild; that is pre-existing and unrelated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
840 lines
41 KiB
Python
840 lines
41 KiB
Python
import os
|
|
import sys
|
|
from flask import Flask, send_from_directory, request, jsonify
|
|
from flask_sqlalchemy import SQLAlchemy
|
|
from flask_jwt_extended import JWTManager
|
|
from flask_cors import CORS
|
|
from flask_limiter import Limiter
|
|
from flask_limiter.util import get_remote_address
|
|
from flask_migrate import Migrate
|
|
|
|
from config import config
|
|
|
|
db = SQLAlchemy()
|
|
jwt = JWTManager()
|
|
migrate = Migrate()
|
|
|
|
# PyJWT 2.10+ enforces that 'sub' must be a string.
|
|
# Stringify the identity so integer user IDs work transparently.
|
|
@jwt.user_identity_loader
|
|
def _user_identity(user_id):
|
|
return str(user_id)
|
|
limiter = Limiter(key_func=get_remote_address, default_limits=["100 per minute"])
|
|
# Note: key_func is updated to get_rate_limit_key after app init
|
|
socketio = None
|
|
|
|
# Path to frontend dist folder (relative to backend folder)
|
|
FRONTEND_DIST = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'frontend', 'dist')
|
|
|
|
|
|
def create_app(config_name=None):
|
|
global socketio
|
|
|
|
if config_name is None:
|
|
config_name = os.environ.get('FLASK_ENV', 'development')
|
|
|
|
# Configure Flask to serve static files from frontend dist
|
|
app = Flask(
|
|
__name__,
|
|
static_folder=FRONTEND_DIST,
|
|
static_url_path=''
|
|
)
|
|
app.config.from_object(config[config_name])
|
|
|
|
# Trust the reverse proxy's forwarding headers to derive the real client IP
|
|
# (config-gated; default off). ProxyFix rewrites request.remote_addr from the
|
|
# rightmost TRUSTED_PROXY_HOPS entries of X-Forwarded-For — the hops our own
|
|
# proxies appended — so a client-forged leftmost value is ignored. Applied
|
|
# before the limiter and request handlers so every remote_addr consumer
|
|
# (flask-limiter's get_remote_address, get_client_ip(), audit logs) benefits.
|
|
if app.config.get('TRUST_PROXY_HEADERS'):
|
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
hops = app.config.get('TRUSTED_PROXY_HOPS', 1)
|
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=hops, x_proto=1, x_host=0, x_port=0)
|
|
|
|
# Initialize extensions
|
|
db.init_app(app)
|
|
migrate.init_app(app, db)
|
|
jwt.init_app(app)
|
|
limiter.init_app(app)
|
|
|
|
# SQLite concurrency tuning: the queue-bus consumers, job system and
|
|
# metrics collector share one database file with request handling, and in
|
|
# the default journal mode any writer locks the WHOLE file — readers
|
|
# included — producing sporadic "database is locked" errors under load.
|
|
# WAL lets readers coexist with a writer, a generous busy timeout absorbs
|
|
# the remaining writer-writer contention, and synchronous=NORMAL is the
|
|
# safe pairing for WAL. (journal_mode persists in the database file; the
|
|
# per-connection pragmas re-assert it for fresh files.)
|
|
if app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite') and not app.config.get('TESTING'):
|
|
# (Skipped under TESTING: tests/conftest.py owns connection pragmas
|
|
# for the test process — journal_mode=MEMORY et al. — and two
|
|
# listeners fighting over journal_mode on the same file deadlocks.)
|
|
from sqlalchemy import event
|
|
|
|
with app.app_context():
|
|
@event.listens_for(db.engine, 'connect')
|
|
def _sqlite_tune(dbapi_connection, _record):
|
|
cursor = dbapi_connection.cursor()
|
|
cursor.execute('PRAGMA journal_mode=WAL')
|
|
cursor.execute('PRAGMA busy_timeout=30000')
|
|
cursor.execute('PRAGMA synchronous=NORMAL')
|
|
cursor.close()
|
|
|
|
# Build CORS origins. Start with static config/env, then append the
|
|
# persisted canonical domain from system settings so pointing an A record
|
|
# at the panel works without restarting to edit .env.
|
|
cors_origins = list(app.config['CORS_ORIGINS'])
|
|
try:
|
|
with app.app_context():
|
|
from app.services.settings_service import SettingsService
|
|
from app.utils.domain import canonical_origin
|
|
canonical_domain = SettingsService.get('canonical_domain', '') or ''
|
|
if canonical_domain:
|
|
https_enabled = SettingsService.get('canonical_https_enabled', False) or False
|
|
origin = canonical_origin(canonical_domain, https_enabled)
|
|
if origin not in cors_origins:
|
|
cors_origins.append(origin)
|
|
except Exception:
|
|
# Database may not exist yet during first install / migrations.
|
|
pass
|
|
|
|
CORS(
|
|
app,
|
|
origins=cors_origins,
|
|
supports_credentials=True,
|
|
allow_headers=['Content-Type', 'Authorization', 'X-Requested-With', 'X-API-Key'],
|
|
methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
|
|
# Every panel request carries an Authorization header, which makes it a
|
|
# non-simple cross-origin request: without Access-Control-Max-Age the
|
|
# browser re-runs a preflight OPTIONS every few seconds (Chrome's
|
|
# default cache is 5s), so a cross-origin panel pays two round trips per
|
|
# call forever. One hour is the practical ceiling — Chrome caps the
|
|
# value at 2h, Firefox at 24h, and both take the smaller of the two.
|
|
max_age=3600,
|
|
)
|
|
|
|
# Register security headers middleware
|
|
from app.middleware.security import register_security_headers
|
|
register_security_headers(app)
|
|
|
|
# Demo mode guard — config-gated (default off), blocks mutating API calls
|
|
from app.middleware.demo import init_demo_mode
|
|
init_demo_mode(app)
|
|
|
|
# Register API key authentication middleware
|
|
from app.middleware.api_key_auth import register_api_key_auth
|
|
register_api_key_auth(app)
|
|
|
|
# Register API analytics middleware
|
|
from app.middleware.api_analytics import register_api_analytics
|
|
register_api_analytics(app)
|
|
|
|
# Register fallback audit logging for authenticated mutating API requests
|
|
from app.middleware.audit import register_audit_fallback
|
|
register_audit_fallback(app)
|
|
|
|
# Update rate limiter with custom key function
|
|
from app.middleware.rate_limit import get_rate_limit_key, register_rate_limit_headers
|
|
limiter._key_func = get_rate_limit_key
|
|
register_rate_limit_headers(app)
|
|
|
|
# Initialize SocketIO
|
|
from app.sockets import init_socketio
|
|
socketio = init_socketio(app)
|
|
|
|
# Initialize Agent Gateway
|
|
from app.agent_gateway import init_agent_gateway
|
|
init_agent_gateway(socketio)
|
|
|
|
# Register blueprints - Auth
|
|
from app.api.auth import auth_bp
|
|
app.register_blueprint(auth_bp, url_prefix='/api/v1/auth')
|
|
|
|
# Agent polling fallback transport (REST equivalent of the WS gateway,
|
|
# used when tunnels mangle WebSocket frames).
|
|
from app.api.agent_poll import agent_poll_bp
|
|
app.register_blueprint(agent_poll_bp, url_prefix='/api/v1/agent')
|
|
|
|
# ServerKit-to-ServerKit peering: this panel linked to a master panel
|
|
# (embedded agent mode — no standalone Go agent required).
|
|
from app.api.linked_panel import linked_panel_bp
|
|
app.register_blueprint(linked_panel_bp, url_prefix='/api/v1/linked-panel')
|
|
|
|
# Register blueprints - Core
|
|
from app.api.apps import apps_bp
|
|
from app.api.domains import domains_bp
|
|
from app.api.private_urls import private_urls_bp
|
|
app.register_blueprint(apps_bp, url_prefix='/api/v1/apps')
|
|
# "Services" is the user-facing term for Applications (§1 unification).
|
|
# Mount the same blueprint under /api/v1/services as a true alias so the
|
|
# canonical `apps` routes and any `services` callers resolve identically.
|
|
app.register_blueprint(apps_bp, url_prefix='/api/v1/services', name='services')
|
|
app.register_blueprint(domains_bp, url_prefix='/api/v1/domains')
|
|
app.register_blueprint(private_urls_bp, url_prefix='/api/v1/apps')
|
|
# Per-app managed volumes — same dual mount as apps (/apps + /services alias).
|
|
from app.api.app_volumes import app_volumes_bp
|
|
app.register_blueprint(app_volumes_bp, url_prefix='/api/v1/apps')
|
|
app.register_blueprint(app_volumes_bp, url_prefix='/api/v1/services', name='app_volumes_services')
|
|
|
|
# Register blueprints - System
|
|
from app.api.system import system_bp
|
|
from app.api.processes import processes_bp
|
|
from app.api.logs import logs_bp
|
|
app.register_blueprint(system_bp, url_prefix='/api/v1/system')
|
|
app.register_blueprint(processes_bp, url_prefix='/api/v1/processes')
|
|
app.register_blueprint(logs_bp, url_prefix='/api/v1/logs')
|
|
|
|
# Register blueprints - Infrastructure
|
|
from app.api.nginx import nginx_bp
|
|
from app.api.ssl import ssl_bp
|
|
app.register_blueprint(nginx_bp, url_prefix='/api/v1/nginx')
|
|
app.register_blueprint(ssl_bp, url_prefix='/api/v1/ssl')
|
|
|
|
# Register blueprints - PHP
|
|
from app.api.php import php_bp
|
|
app.register_blueprint(php_bp, url_prefix='/api/v1/php')
|
|
# WordPress is the standalone `serverkit-wordpress` extension (plan 52
|
|
# Phase 5 — it left the tree into its own repo and installs from the
|
|
# registry; the setup wizard offers it from the bundled index entry).
|
|
# Its blueprints (wordpress / wordpress_sites / environment_pipeline,
|
|
# keeping the /api/v1/wordpress[/projects|/pipelines] prefixes per D3,
|
|
# incl. the /pipelines alias) are registered from the extension by the
|
|
# plugin loader when installed. The old `wordpress` module toggle is
|
|
# retired; the plugin status guard is the gate.
|
|
|
|
# Register blueprints - Python
|
|
from app.api.python import python_bp
|
|
app.register_blueprint(python_bp, url_prefix='/api/v1/python')
|
|
|
|
# Register blueprints - Docker
|
|
from app.api.docker import docker_bp
|
|
app.register_blueprint(docker_bp, url_prefix='/api/v1/docker')
|
|
|
|
# Register blueprints - Databases
|
|
from app.api.databases import databases_bp
|
|
app.register_blueprint(databases_bp, url_prefix='/api/v1/databases')
|
|
|
|
# Register blueprints - Database engine catalog (composed from templates)
|
|
from app.api.database_engines import database_engines_bp
|
|
app.register_blueprint(database_engines_bp, url_prefix='/api/v1/databases/engines')
|
|
|
|
# Register blueprints - Managed DB users + Adminer SSO
|
|
from app.api.managed_db_users import managed_db_users_bp
|
|
app.register_blueprint(managed_db_users_bp, url_prefix='/api/v1/managed-databases')
|
|
|
|
# Register blueprints - Curated DB config tuner
|
|
from app.api.db_tuner import db_tuner_bp
|
|
app.register_blueprint(db_tuner_bp, url_prefix='/api/v1/db-tuner')
|
|
|
|
# Register blueprints - Monitoring & Alerts
|
|
from app.api.monitoring import monitoring_bp
|
|
app.register_blueprint(monitoring_bp, url_prefix='/api/v1/monitoring')
|
|
|
|
# Register blueprints - Container status aggregator
|
|
from app.api.container_status import container_status_bp
|
|
app.register_blueprint(container_status_bp, url_prefix='/api/v1/status')
|
|
|
|
# Register blueprints - Build packs (zero-Dockerfile detection)
|
|
from app.api.buildpacks import buildpacks_bp
|
|
app.register_blueprint(buildpacks_bp, url_prefix='/api/v1/buildpacks')
|
|
|
|
# Register blueprints - Deployment config snapshots + diff
|
|
from app.api.snapshots import snapshots_bp
|
|
app.register_blueprint(snapshots_bp, url_prefix='/api/v1/apps')
|
|
|
|
# Register blueprints - Declarative serverkit.yaml manifest
|
|
from app.api.manifests import manifests_bp
|
|
app.register_blueprint(manifests_bp, url_prefix='/api/v1/manifests')
|
|
|
|
# Register blueprints - Projects & Environments hierarchy
|
|
from app.api.projects import projects_bp
|
|
app.register_blueprint(projects_bp, url_prefix='/api/v1/projects')
|
|
from app.api.environments import environments_bp
|
|
app.register_blueprint(environments_bp, url_prefix='/api/v1/environments')
|
|
|
|
# Register blueprints - Polymorphic shared resources (tags + variable groups)
|
|
from app.api.shared_resources import shared_resources_bp
|
|
app.register_blueprint(shared_resources_bp, url_prefix='/api/v1/shared')
|
|
|
|
# Register blueprints - PR preview environments
|
|
from app.api.previews import previews_bp
|
|
app.register_blueprint(previews_bp, url_prefix='/api/v1/apps')
|
|
from app.api.webhooks import webhooks_bp
|
|
app.register_blueprint(webhooks_bp, url_prefix='/api/v1/webhooks')
|
|
|
|
# Register blueprints - Per-server managed proxy stack
|
|
from app.api.proxy import proxy_bp
|
|
app.register_blueprint(proxy_bp, url_prefix='/api/v1/servers')
|
|
|
|
# Register blueprints - Notifications
|
|
from app.api.notifications import notifications_bp
|
|
app.register_blueprint(notifications_bp, url_prefix='/api/v1/notifications')
|
|
|
|
# Register blueprints - Backups
|
|
from app.api.backups import backups_bp
|
|
app.register_blueprint(backups_bp, url_prefix='/api/v1/backups')
|
|
|
|
# Register blueprints - Git Deployment
|
|
from app.api.deploy import deploy_bp
|
|
app.register_blueprint(deploy_bp, url_prefix='/api/v1/deploy')
|
|
|
|
# Register blueprints - Builds & Deployments
|
|
from app.api.builds import builds_bp
|
|
from app.api.deployment_jobs import deployment_jobs_bp
|
|
from app.api.deployments import deployments_bp
|
|
app.register_blueprint(builds_bp, url_prefix='/api/v1/builds')
|
|
app.register_blueprint(deployment_jobs_bp, url_prefix='/api/v1/deployment-jobs')
|
|
# §3 unification: one /api/v1/deployments surface. Federated history/detail
|
|
# live in deployments_bp; the canonical execution records (DeploymentJob)
|
|
# are also mounted here under /deployments/jobs (alias of /deployment-jobs).
|
|
app.register_blueprint(deployments_bp, url_prefix='/api/v1/deployments')
|
|
app.register_blueprint(deployment_jobs_bp, url_prefix='/api/v1/deployments/jobs',
|
|
name='deployment_jobs_unified')
|
|
|
|
# Register blueprints - Templates
|
|
from app.api.templates import templates_bp
|
|
app.register_blueprint(templates_bp, url_prefix='/api/v1/templates')
|
|
|
|
# Register blueprints - File Manager
|
|
from app.api.files import files_bp
|
|
app.register_blueprint(files_bp, url_prefix='/api/v1/files')
|
|
|
|
# FTP Server is an opt-in builtin extension (serverkit-ftp, plan 47) — its
|
|
# blueprint loads from builtin-extensions/serverkit-ftp/ when installed, not
|
|
# from core. A fresh panel that never touches FTP loads none of it.
|
|
|
|
# Register blueprints - Firewall
|
|
from app.api.firewall import firewall_bp
|
|
app.register_blueprint(firewall_bp, url_prefix='/api/v1/firewall')
|
|
|
|
# Register blueprints - Git Server
|
|
from app.api.git import git_bp
|
|
app.register_blueprint(git_bp, url_prefix='/api/v1/git')
|
|
|
|
# Register blueprints - Security (ClamAV, File Integrity, etc.)
|
|
from app.api.security import security_bp
|
|
app.register_blueprint(security_bp, url_prefix='/api/v1/security')
|
|
|
|
# Register blueprints - Secrets manager + inbound webhook gateway
|
|
from app.api.secrets_webhooks import bp as secrets_webhooks_bp
|
|
app.register_blueprint(secrets_webhooks_bp, url_prefix='/api/v1')
|
|
|
|
# Register blueprints - Cron Jobs
|
|
from app.api.cron import cron_bp
|
|
app.register_blueprint(cron_bp, url_prefix='/api/v1/cron')
|
|
|
|
# Email Server is now the serverkit-email builtin extension (Phase 4, #32):
|
|
# its /api/v1/email blueprint + Postfix/Dovecot/DKIM/SpamAssassin/Roundcube
|
|
# services live in builtin-extensions/serverkit-email/ and are registered by
|
|
# the plugin loader when installed. The outbound relay (email_relay_service)
|
|
# and all email models stay core (notifications SMTP + shared Postfix relay).
|
|
|
|
# Register blueprints - Uptime Tracking
|
|
from app.api.uptime import uptime_bp
|
|
app.register_blueprint(uptime_bp, url_prefix='/api/v1/uptime')
|
|
|
|
# Register blueprints - Monitors (synthetic checks + their incidents).
|
|
# Core: watching a site must not depend on the status-page extension.
|
|
from app.api.monitors import monitors_bp
|
|
app.register_blueprint(monitors_bp, url_prefix='/api/v1/monitors')
|
|
|
|
# Register blueprints - Environment Variables
|
|
from app.api.env_vars import env_vars_bp
|
|
app.register_blueprint(env_vars_bp, url_prefix='/api/v1/apps')
|
|
|
|
# Register blueprints - Two-Factor Authentication
|
|
from app.api.two_factor import two_factor_bp
|
|
app.register_blueprint(two_factor_bp, url_prefix='/api/v1/auth/2fa')
|
|
|
|
# Register blueprints - SSO / OAuth
|
|
from app.api.sso import sso_bp
|
|
app.register_blueprint(sso_bp, url_prefix='/api/v1/sso')
|
|
|
|
# Register blueprints - Source provider connections
|
|
from app.api.source_connections import source_connections_bp
|
|
app.register_blueprint(source_connections_bp, url_prefix='/api/v1/source-connections')
|
|
|
|
# Register blueprints - Domain registrar connections (portfolio + expiry)
|
|
from app.api.registrars import registrars_bp
|
|
app.register_blueprint(registrars_bp, url_prefix='/api/v1/registrars')
|
|
|
|
# Register blueprints - Unified connection registry (read-only "all connections")
|
|
from app.api.connections import connections_bp
|
|
app.register_blueprint(connections_bp, url_prefix='/api/v1/connections')
|
|
|
|
# Register blueprints - Database Migrations
|
|
from app.api.migrations import migrations_bp
|
|
app.register_blueprint(migrations_bp, url_prefix='/api/v1/migrations')
|
|
|
|
# Register blueprints - API Enhancements
|
|
from app.api.api_keys import api_keys_bp
|
|
from app.api.api_analytics import api_analytics_bp
|
|
from app.api.event_subscriptions import event_subscriptions_bp
|
|
from app.api.docs import docs_bp
|
|
app.register_blueprint(api_keys_bp, url_prefix='/api/v1/api-keys')
|
|
app.register_blueprint(api_analytics_bp, url_prefix='/api/v1/api-analytics')
|
|
app.register_blueprint(event_subscriptions_bp, url_prefix='/api/v1/event-subscriptions')
|
|
app.register_blueprint(docs_bp, url_prefix='/api/v1/docs')
|
|
|
|
# Register blueprints - Admin (User Management, Settings, Audit Logs)
|
|
from app.api.admin import admin_bp
|
|
app.register_blueprint(admin_bp, url_prefix='/api/v1/admin')
|
|
|
|
# Register blueprints - Invitations
|
|
from app.api.invitations import invitations_bp
|
|
app.register_blueprint(invitations_bp, url_prefix='/api/v1/admin/invitations')
|
|
|
|
# Register blueprints - Historical Metrics
|
|
from app.api.metrics import metrics_bp
|
|
app.register_blueprint(metrics_bp, url_prefix='/api/v1/metrics')
|
|
|
|
# The /api/v1/workflows blueprint (React-Flow Workflow Builder) was retired
|
|
# in plan 45 Phase 4 -- the Automations extension (tramo) replaces it.
|
|
|
|
# Register blueprints - Servers (Multi-server management)
|
|
from app.api.servers import servers_bp
|
|
app.register_blueprint(servers_bp, url_prefix='/api/v1/servers')
|
|
|
|
# Register blueprints - Server Survey (read-only "flights" over a paired agent)
|
|
from app.api.survey import survey_bp
|
|
app.register_blueprint(survey_bp, url_prefix='/api/v1/servers')
|
|
|
|
# Register blueprints - Fleet Monitor (Cross-server monitoring)
|
|
from app.api.fleet_monitor import fleet_monitor_bp
|
|
app.register_blueprint(fleet_monitor_bp, url_prefix='/api/v1/fleet-monitor')
|
|
|
|
# Register blueprints - Fleet (target picker, capability discovery)
|
|
from app.api.fleet import fleet_bp
|
|
app.register_blueprint(fleet_bp, url_prefix='/api/v1/fleet')
|
|
|
|
# Register blueprints - Agent Plugins
|
|
from app.api.agent_plugins import agent_plugins_bp
|
|
app.register_blueprint(agent_plugins_bp, url_prefix='/api/v1/agent-plugins')
|
|
|
|
# Register blueprints - Server Templates
|
|
from app.api.server_templates import server_templates_bp
|
|
app.register_blueprint(server_templates_bp, url_prefix='/api/v1/server-templates')
|
|
|
|
# Register blueprints - Workspaces
|
|
from app.api.workspaces import workspaces_bp
|
|
app.register_blueprint(workspaces_bp, url_prefix='/api/v1/workspaces')
|
|
|
|
# Register blueprints - Advanced SSL
|
|
# §5 unification: one SSL surface. The advanced cert operations (wildcard,
|
|
# SAN, custom upload, profiles, health, expiry alerts) mount under the same
|
|
# /api/v1/ssl prefix as the basic certbot routes (no path collisions). The
|
|
# original /api/v1/ssl/advanced prefix is kept as a deprecated alias.
|
|
from app.api.advanced_ssl import advanced_ssl_bp
|
|
app.register_blueprint(advanced_ssl_bp, url_prefix='/api/v1/ssl/advanced')
|
|
app.register_blueprint(advanced_ssl_bp, url_prefix='/api/v1/ssl', name='advanced_ssl_unified')
|
|
|
|
# Register blueprints - DNS Zones
|
|
from app.api.dns_zones import dns_zones_bp
|
|
app.register_blueprint(dns_zones_bp, url_prefix='/api/v1/dns')
|
|
|
|
# Register blueprints - Reversible DNS cutover (snapshot/cutover/verify/revert
|
|
# a migration's DNS switch; backs the /domains cutover drawer)
|
|
from app.api.dns_cutover import dns_cutover_bp
|
|
app.register_blueprint(dns_cutover_bp, url_prefix='/api/v1/dns-cutover')
|
|
|
|
from app.api.setup_health import setup_health_bp
|
|
app.register_blueprint(setup_health_bp, url_prefix='/api/v1/setup-health')
|
|
|
|
# Register blueprints - Cloudflare operations (zone settings/cache/WAF on top
|
|
# of the existing Cloudflare DNS connection)
|
|
# Cloudflare zone-ops moved into the bundled, default-installed
|
|
# `serverkit-cloudflare-ops` extension (#36). Its blueprint (kept at
|
|
# /api/v1/cloudflare, D9) is registered from the extension by the plugin
|
|
# loader — seeded as a flagship in create_app. DNS records + the Cloudflare
|
|
# connection stay core (they back /domains); the extension borrows the single
|
|
# core CloudflareClient, never a duplicate.
|
|
|
|
# Register blueprints - DNS provider connections. Core (they back the
|
|
# Settings -> Connections DNS tiles and wildcard TLS), but kept at the
|
|
# historical /api/v1/email/dns-providers paths from before the email
|
|
# extraction so existing frontends keep working.
|
|
from app.api.dns_providers import dns_providers_bp
|
|
app.register_blueprint(dns_providers_bp, url_prefix='/api/v1/email')
|
|
|
|
# Register blueprints - Dynamic DNS
|
|
from app.api.ddns import ddns_bp
|
|
app.register_blueprint(ddns_bp, url_prefix='/api/v1/ddns')
|
|
|
|
# Register blueprints - Image update checks
|
|
from app.api.image_updates import image_updates_bp
|
|
app.register_blueprint(image_updates_bp, url_prefix='/api/v1/image-updates')
|
|
|
|
# Register blueprints - Per-application WAF (ModSecurity + OWASP CRS)
|
|
from app.api.waf import waf_bp
|
|
app.register_blueprint(waf_bp, url_prefix='/api/v1/waf')
|
|
|
|
# GPU monitoring lives in the standalone serverkit-gpu extension (own repo,
|
|
# installed from the registry). Its blueprint mounts at /api/v1/gpu when
|
|
# installed; the core app.api.gpu / app.services.gpu_service modules are gone.
|
|
|
|
# Register blueprints - Nginx Advanced
|
|
from app.api.nginx_advanced import nginx_advanced_bp
|
|
app.register_blueprint(nginx_advanced_bp, url_prefix='/api/v1/nginx/advanced')
|
|
|
|
# Status Pages is an opt-in builtin extension (serverkit-status, plan 47) —
|
|
# its blueprint (public + management routes) loads from builtin-extensions/
|
|
# when installed, not from core. The StatusPage/StatusComponent models stay
|
|
# core (G2); the WordPress health-check job reaches the extension's sync
|
|
# helper via get_installed_extension_attr only when installed.
|
|
|
|
# Cloud Provisioning is an opt-in builtin extension (serverkit-cloud-provision,
|
|
# plan 47) — its blueprint loads from builtin-extensions/ when installed, not
|
|
# from core. The CloudProvider/CloudServer models stay core (G2).
|
|
|
|
# Remote Access (WireGuard tunnels) is an opt-in builtin extension
|
|
# (serverkit-remote-access, plan 47) — its blueprint loads from
|
|
# builtin-extensions/ when installed, not from core. The Tunnel/ExposedService
|
|
# models stay core (G2); the agent gateway reaches its reconcile helper via
|
|
# get_installed_extension_attr only when the extension is present.
|
|
|
|
# Register blueprints - Performance
|
|
from app.api.performance import performance_bp
|
|
app.register_blueprint(performance_bp, url_prefix='/api/v1/performance')
|
|
|
|
# Register blueprints - Mobile
|
|
from app.api.mobile import mobile_bp
|
|
app.register_blueprint(mobile_bp, url_prefix='/api/v1/mobile')
|
|
|
|
# Register blueprints - Marketplace
|
|
from app.api.marketplace import marketplace_bp
|
|
app.register_blueprint(marketplace_bp, url_prefix='/api/v1/marketplace')
|
|
|
|
# Register blueprints - Themes (plan 60)
|
|
from app.api.themes import themes_bp
|
|
app.register_blueprint(themes_bp, url_prefix='/api/v1/themes')
|
|
from app.api.views import views_bp
|
|
app.register_blueprint(views_bp, url_prefix='/api/v1/views')
|
|
|
|
from app.api.recycle_bin import recycle_bin_bp
|
|
app.register_blueprint(recycle_bin_bp, url_prefix='/api/v1/recycle-bin')
|
|
# Teach the bin which models it can restore. Import-time registration keeps
|
|
# the API type-agnostic: adding a soft-deletable model touches only this list.
|
|
from app.services import recycle_bin_service
|
|
recycle_bin_service.register_builtin_types()
|
|
|
|
# Register blueprints - Dashboard boards (plan 62, per-user widget grid)
|
|
from app.api.dashboards import dashboards_bp
|
|
app.register_blueprint(dashboards_bp, url_prefix='/api/v1/dashboards')
|
|
|
|
# Register blueprints - Plugins
|
|
from app.api.plugins import plugins_bp
|
|
app.register_blueprint(plugins_bp, url_prefix='/api/v1/plugins')
|
|
|
|
# Register blueprints - Unified entity omnisearch
|
|
from app.api.search import search_bp
|
|
app.register_blueprint(search_bp, url_prefix='/api/v1/search')
|
|
|
|
# Register blueprints - Modules (core-vertical toggles)
|
|
from app.api.modules import modules_bp
|
|
app.register_blueprint(modules_bp, url_prefix='/api/v1/modules')
|
|
|
|
# Register blueprints - Queue Bus
|
|
from app.api.queue_bus import queue_bus_bp
|
|
app.register_blueprint(queue_bus_bp, url_prefix='/api/v1/queue')
|
|
|
|
# Register blueprints - Unified Jobs (work orchestration on the Queue Bus)
|
|
from app.api.jobs import jobs_bp
|
|
app.register_blueprint(jobs_bp, url_prefix='/api/v1/jobs')
|
|
|
|
# Register blueprints - Telemetry / System Event Stream
|
|
from app.api.telemetry import telemetry_bp
|
|
app.register_blueprint(telemetry_bp, url_prefix='/api/v1/telemetry')
|
|
|
|
# §4 unification: one observability namespace. The monitoring / metrics /
|
|
# telemetry / uptime / fleet / status-page read surfaces are re-mounted under
|
|
# /api/v1/observability/<domain> as true aliases (same blueprints, distinct
|
|
# names) so callers have a single front door. The original prefixes remain,
|
|
# and the PUBLIC status page route (/api/v1/status/public/<slug>) is
|
|
# unchanged — its canonical mount is untouched.
|
|
app.register_blueprint(monitoring_bp, url_prefix='/api/v1/observability/monitoring', name='obs_monitoring')
|
|
app.register_blueprint(metrics_bp, url_prefix='/api/v1/observability/metrics', name='obs_metrics')
|
|
app.register_blueprint(telemetry_bp, url_prefix='/api/v1/observability/events', name='obs_events')
|
|
app.register_blueprint(uptime_bp, url_prefix='/api/v1/observability/uptime', name='obs_uptime')
|
|
app.register_blueprint(fleet_monitor_bp, url_prefix='/api/v1/observability/fleet', name='obs_fleet')
|
|
# status-pages observability alias dropped with the serverkit-status
|
|
# extraction (plan 47) — it was unused by the frontend; status pages mount at
|
|
# /api/v1/status from the extension when installed.
|
|
|
|
# Register blueprints - Agent Pairing (RustDesk-style short-code flow)
|
|
from app.api.pairing import pairing_bp
|
|
app.register_blueprint(pairing_bp, url_prefix='/api/v1/pairing')
|
|
|
|
# Register blueprints - AI Assistant (core primitive, powered by Prompture)
|
|
from app.api.ai import ai_bp
|
|
app.register_blueprint(ai_bp, url_prefix='/api/v1/ai')
|
|
|
|
# Register blueprints - Server speed test (Monitoring card)
|
|
from app.api.speed_test import speedtest_bp
|
|
app.register_blueprint(speedtest_bp, url_prefix='/api/v1/speedtest')
|
|
|
|
# Register blueprints - Site imports (panel migration pipeline)
|
|
from app.api.site_imports import site_imports_bp
|
|
app.register_blueprint(site_imports_bp, url_prefix='/api/v1/imports')
|
|
|
|
# Register blueprints - Drift detection + doctor sweep
|
|
from app.api.doctor import doctor_bp
|
|
app.register_blueprint(doctor_bp, url_prefix='/api/v1/doctor')
|
|
|
|
# Register blueprints - Fleet doctor (the same sweep across the agent fleet)
|
|
from app.api.fleet_doctor import fleet_doctor_bp
|
|
app.register_blueprint(fleet_doctor_bp, url_prefix='/api/v1/doctor/fleet')
|
|
|
|
# Register blueprints - Diagnostic support bundle
|
|
from app.api.support_bundle import support_bundle_bp
|
|
app.register_blueprint(support_bundle_bp, url_prefix='/api/v1/support-bundle')
|
|
|
|
# Register blueprints - Per-site bandwidth accounting
|
|
from app.api.bandwidth import bandwidth_bp
|
|
app.register_blueprint(bandwidth_bp, url_prefix='/api/v1/bandwidth')
|
|
|
|
# Register blueprints - .htaccess -> nginx converter (apps-prefixed tool)
|
|
from app.api.htaccess_tools import htaccess_tools_bp
|
|
app.register_blueprint(htaccess_tools_bp, url_prefix='/api/v1/apps')
|
|
|
|
# Register blueprints - Test Sandbox (distro test matrix in Docker)
|
|
from app.api.test_sandbox import test_sandbox_bp
|
|
app.register_blueprint(test_sandbox_bp, url_prefix='/api/v1/test-sandbox')
|
|
|
|
# Handle database migrations (Alembic) — must run before plugin loader
|
|
# since the loader queries the installed_plugins table.
|
|
with app.app_context():
|
|
from app.services.migration_service import MigrationService
|
|
MigrationService.check_and_prepare(app)
|
|
|
|
# Initialize default settings and migrate legacy roles
|
|
from app.services.settings_service import SettingsService
|
|
SettingsService.initialize_defaults()
|
|
SettingsService.migrate_legacy_roles()
|
|
|
|
# Encrypt any legacy plaintext provider secrets at rest (idempotent —
|
|
# DNS-provider api keys and storage credentials predate encryption).
|
|
try:
|
|
from app.services.dns_provider_service import DNSProviderService
|
|
from app.services.storage_provider_service import StorageProviderService
|
|
n_dns = DNSProviderService.encrypt_legacy_secrets()
|
|
n_store = StorageProviderService.encrypt_legacy_secrets()
|
|
# Cloud-provider legacy-secret encryption moved out with the
|
|
# serverkit-cloud-provision extension (plan 47). It was a one-time
|
|
# migration of pre-encryption rows; any panel reaching this version
|
|
# already ran it in an earlier boot (idempotent), and the extension
|
|
# encrypts on write, so there's nothing left for core to do here.
|
|
n_cloud = 0
|
|
n_settings = SettingsService.migrate_legacy_secrets()
|
|
# Migrate DNS zones with an inline Cloudflare token onto the canonical
|
|
# connection store (idempotent), so every zone resolves creds the same way.
|
|
from app.services.dns_zone_service import DNSZoneService
|
|
n_zones = DNSZoneService.link_legacy_zones()
|
|
# Fold a legacy single-row email relay config into the unified
|
|
# EmailProviderConnection table (§6); idempotent, best-effort.
|
|
try:
|
|
from app.services.email_relay_service import EmailRelayService
|
|
EmailRelayService.migrate_legacy_config()
|
|
except Exception as _relay_exc: # never block boot on this
|
|
import logging as _logging
|
|
_logging.getLogger(__name__).warning(
|
|
f'Email relay legacy migration skipped: {_relay_exc}')
|
|
if n_dns or n_store or n_cloud or n_settings or n_zones:
|
|
import logging as _logging
|
|
_logging.getLogger(__name__).info(
|
|
f'Encrypted legacy secrets at rest: {n_dns} DNS provider(s), '
|
|
f'{n_store} storage field(s), {n_cloud} cloud provider(s), '
|
|
f'{n_settings} system setting(s); linked {n_zones} DNS zone(s) '
|
|
f'to a connection')
|
|
except Exception as e:
|
|
import logging as _logging
|
|
_logging.getLogger(__name__).warning(f'Legacy secret encryption skipped: {e}')
|
|
|
|
# Seed bundled flagship extensions (D4) — WordPress ships installed by
|
|
# default on every panel (fresh and upgrade) unless the user uninstalled
|
|
# it. Done BEFORE load_all_plugins so the loader registers the seeded
|
|
# blueprints. In-place: no file copy. Best-effort.
|
|
try:
|
|
from app.services.plugin_service import seed_flagship_extensions
|
|
seed_flagship_extensions()
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(f'Flagship seed: {e}')
|
|
|
|
# Sweep files/rows of retired extensions (e.g. serverkit-workflows)
|
|
# BEFORE the loader, so they are never loaded, never "repaired" back,
|
|
# and never carried forward into the next update's frontend build.
|
|
try:
|
|
from app.services.extension_migration import remove_retired_extensions
|
|
remove_retired_extensions()
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(f'Retired extension sweep: {e}')
|
|
|
|
# Load installed plugins (dynamic blueprints) AFTER migrations,
|
|
# so the installed_plugins table exists.
|
|
try:
|
|
from app.services.plugin_service import load_all_plugins
|
|
load_all_plugins(app)
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(f'Plugin loader: {e}')
|
|
|
|
# One-shot: auto-install builtin extensions that used to be core pages
|
|
# so an upgraded panel doesn't lose the feature (decision D3). Fresh
|
|
# installs see them in the Marketplace instead. Best-effort.
|
|
try:
|
|
from app.services.extension_migration import run_auto_install
|
|
run_auto_install()
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(f'Extension auto-install: {e}')
|
|
|
|
# One-shot: re-acquire the now-extracted backend for converted builtins
|
|
# that first shipped frontend-only (plan 47 Phase 2), so an upgraded panel
|
|
# that installed them frontend-only doesn't lose the API. Best-effort.
|
|
try:
|
|
from app.services.extension_migration import run_backend_acquisition
|
|
run_backend_acquisition()
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(f'Extension backend acquisition: {e}')
|
|
|
|
# Background daemons (metrics collector, queue consumers, analytics
|
|
# flush, the job system, the linked-panel client) only make sense in a
|
|
# long-running server process. When the app is loaded by a Flask CLI
|
|
# one-shot — crucially `flask db upgrade` during an update — they must
|
|
# NOT start: they query the database before migrations have run, and
|
|
# any failure there (a corrupt DB, or pre-migration schema the new code
|
|
# doesn't match yet) aborts the CLI command and sinks the whole update.
|
|
# SERVERKIT_SKIP_BACKGROUND=1 forces the same skip; the updater sets it
|
|
# as an explicit contract when running migrations.
|
|
_cli_one_shot = (
|
|
os.path.basename(sys.argv[0] or '').startswith('flask')
|
|
and (len(sys.argv) < 2 or sys.argv[1] != 'run')
|
|
)
|
|
_skip_background = (
|
|
os.environ.get('SERVERKIT_SKIP_BACKGROUND') == '1' or _cli_one_shot
|
|
)
|
|
if not _skip_background:
|
|
# Start metrics history collection in background
|
|
from app.services.metrics_history_service import MetricsHistoryService
|
|
if not MetricsHistoryService.is_running():
|
|
MetricsHistoryService.start_collection(app)
|
|
|
|
# Start queue-bus webhook consumer
|
|
from app.queue_bus.consumers import start_webhook_consumer
|
|
start_webhook_consumer(app)
|
|
|
|
# Start queue-bus notification consumer (delivers in-app/email/chat)
|
|
from app.notifications.consumer import start_notification_consumer
|
|
start_notification_consumer(app)
|
|
|
|
# Start the API analytics flush thread (a 5s buffer flush — a real-time
|
|
# stream, deliberately NOT modeled as a job).
|
|
from app.middleware.api_analytics import start_analytics_flush_thread
|
|
start_analytics_flush_thread(app)
|
|
|
|
# Start the unified job system: ONE consumer runs every enqueued Job and
|
|
# ONE scheduler ticks all periodic work. This supersedes the former set
|
|
# of per-domain daemon scheduler threads (auto-sync, snapshot-retention,
|
|
# workflow, health-check, wp-update, api-background, pairing-prune,
|
|
# registrar-expiry) — they are now ScheduledJob rows backed by the
|
|
# built-in handlers in app/jobs/builtin_handlers.py.
|
|
from app.jobs import start_job_system
|
|
from app.jobs.builtin_handlers import register_builtin_handlers, seed_builtin_schedules
|
|
register_builtin_handlers()
|
|
# Register event-driven job handlers (deployment installs, workflow runs,
|
|
# scheduled backups).
|
|
from app.services.deployment_job_service import DeploymentJobService
|
|
DeploymentJobService.register_jobs()
|
|
# WorkflowEngine.register_jobs() removed in plan 45 Phase 4 (engine retired).
|
|
from app.services.backup_service import BackupService
|
|
BackupService.register_jobs()
|
|
from app.services.backup_policy_service import BackupPolicyService
|
|
BackupPolicyService.register_jobs()
|
|
from app.services.server_onboarding_service import ServerOnboardingService
|
|
ServerOnboardingService.register_jobs()
|
|
from app.services.preview_service import PreviewService
|
|
PreviewService.register_jobs()
|
|
from app.services.metadata_guard_service import MetadataGuardService
|
|
MetadataGuardService.register_jobs()
|
|
if not app.config.get('TESTING'):
|
|
MetadataGuardService.ensure() # converge the metadata egress rule (no-op when unsupported)
|
|
from app.services.speed_test_service import SpeedTestService
|
|
SpeedTestService.register_jobs()
|
|
from app.services import login_link_service
|
|
login_link_service.register_jobs()
|
|
from app.services.db_admin_sso_service import DbAdminSsoService
|
|
DbAdminSsoService.register_jobs()
|
|
from app.services.site_import_service import SiteImportService
|
|
SiteImportService.register_jobs()
|
|
from app.services.drift_service import DriftService
|
|
DriftService.register_jobs()
|
|
from app.services.doctor_service import DoctorService
|
|
DoctorService.register_jobs()
|
|
from app.services.fleet_doctor_service import FleetDoctorService
|
|
FleetDoctorService.register_jobs()
|
|
from app.services.file_integrity_service import FileIntegrityService
|
|
FileIntegrityService.register_jobs()
|
|
from app.services.malware_scan_service import MalwareScanService
|
|
MalwareScanService.register_jobs()
|
|
from app.services.bandwidth_service import BandwidthService
|
|
BandwidthService.register_jobs()
|
|
start_job_system(app, seed=seed_builtin_schedules)
|
|
|
|
# Resume the embedded agent when this panel is linked to a master
|
|
# ServerKit panel (ServerKit-to-ServerKit peering).
|
|
from app.services.linked_panel_service import LinkedPanelService
|
|
LinkedPanelService.start_client_if_linked(app)
|
|
|
|
# Request body size limit
|
|
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB limit
|
|
|
|
# Reject 2FA pending tokens on non-2FA endpoints
|
|
@app.before_request
|
|
def check_2fa_pending():
|
|
"""Reject 2FA pending tokens on non-2FA endpoints."""
|
|
from flask_jwt_extended import verify_jwt_in_request, get_jwt
|
|
if request.endpoint and request.path.startswith('/api/'):
|
|
# Allow 2FA verification endpoints
|
|
if '/two-factor/verify' in request.path or '/two-factor/verify-backup' in request.path:
|
|
return
|
|
# Allow auth endpoints (login, refresh)
|
|
if '/auth/login' in request.path or '/auth/refresh' in request.path:
|
|
return
|
|
try:
|
|
verify_jwt_in_request()
|
|
claims = get_jwt()
|
|
if claims.get('2fa_pending'):
|
|
return jsonify({'error': '2FA verification required'}), 403
|
|
except Exception:
|
|
pass # Let @jwt_required handle actual auth errors
|
|
|
|
# Serve frontend for root path
|
|
@app.route('/')
|
|
def serve_index():
|
|
index = os.path.join(app.static_folder, 'index.html') if app.static_folder else None
|
|
if index and os.path.isfile(index):
|
|
return send_from_directory(app.static_folder, 'index.html')
|
|
return {'message': 'ServerKit API is running', 'docs': '/api/v1/'}, 200
|
|
|
|
# Catch-all route for SPA - must be after all other routes
|
|
@app.errorhandler(404)
|
|
def not_found(e):
|
|
from flask import request
|
|
if request.path.startswith('/api/'):
|
|
return {'error': 'Not found'}, 404
|
|
# Serve SPA index.html if it exists, otherwise JSON 404
|
|
index = os.path.join(app.static_folder, 'index.html') if app.static_folder else None
|
|
if index and os.path.isfile(index):
|
|
return send_from_directory(app.static_folder, 'index.html')
|
|
return {'error': 'Not found'}, 404
|
|
|
|
return app
|
|
|
|
|
|
def get_socketio():
|
|
"""Get the SocketIO instance."""
|
|
return socketio
|