Files
ServerKit/backend/app/api/servers.py

3025 lines
101 KiB
Python

"""
Server Management API
Endpoints for managing remote servers and their agents.
"""
import os
import re
import hashlib
import hmac
import requests
from datetime import datetime, timedelta
from flask import Blueprint, request, jsonify, Response, current_app, redirect
from flask_jwt_extended import jwt_required, get_jwt_identity
from sqlalchemy.orm import joinedload
from app import db, limiter
from app.services.server_metrics_service import latest_metrics_by_server
from app.api._query import apply_query, QueryParseError
from app.models import User
from app.models.server import Server, ServerGroup, ServerMetrics, ServerCommand, AgentSession, AgentVersion, AgentRollout
from app.services.agent_registry import agent_registry
from app.services.remote_command_dispatcher import dispatch_agent_command
from app.services.agent_fleet_service import fleet_service
from app.services.discovery_service import discovery_service
from app.services import connection_string as connection_string_codec
from app.middleware.rbac import admin_required, developer_required, get_current_user
from app.exceptions import ValidationError
# Default token lifetime when the caller doesn't specify one. 7 days is
# the sweet spot from the design discussion: long enough that "I'll set
# this up later tonight" survives, short enough that an abandoned string
# doesn't linger forever as a usable bearer credential.
_DEFAULT_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60
# Sentinel placeholder name used while a server row exists but no agent
# has registered against it yet. The register endpoint replaces this
# with the agent's reported hostname; the prefix check there is the
# reason the format here must stay stable.
_PLACEHOLDER_NAME_PREFIX = "Pending pairing ("
def _placeholder_name(server_id: str) -> str:
"""Generate the placeholder name shown in the panel until pairing
completes. Includes a short id suffix so multiple unpaired rows are
distinguishable."""
return f"{_PLACEHOLDER_NAME_PREFIX}{server_id[:8]})"
def _is_placeholder_name(name) -> bool:
"""True if the given name is the placeholder we issued at create
time. Used by the register endpoint to decide whether overwriting
with the agent's hostname is safe (a user-chosen name shouldn't be
clobbered on re-pair)."""
return isinstance(name, str) and name.startswith(_PLACEHOLDER_NAME_PREFIX)
def _resolve_token_expiry(expires_in):
"""Convert an ``expires_in`` request field to a concrete datetime.
- missing / None → default 7 days
- positive int seconds → that many seconds from now
- -1 → "never" (100 years out — DB-safe sentinel)
"""
if expires_in is None:
seconds = _DEFAULT_TOKEN_TTL_SECONDS
elif expires_in == -1:
return datetime.utcnow() + timedelta(days=365 * 100)
else:
try:
seconds = int(expires_in)
except (TypeError, ValueError):
seconds = _DEFAULT_TOKEN_TTL_SECONDS
if seconds <= 0:
seconds = _DEFAULT_TOKEN_TTL_SECONDS
return datetime.utcnow() + timedelta(seconds=seconds)
servers_bp = Blueprint('servers', __name__)
def _get_external_base_url():
"""Return the public origin agents should use when this app sits behind a proxy."""
public_url = current_app.config.get('PUBLIC_URL') or os.environ.get('SERVERKIT_PUBLIC_URL')
if public_url:
return public_url.rstrip('/')
forwarded_proto = request.headers.get('X-Forwarded-Proto', request.scheme)
forwarded_host = request.headers.get('X-Forwarded-Host', request.host)
scheme = forwarded_proto.split(',')[0].strip() or request.scheme
host = forwarded_host.split(',')[0].strip() or request.host
return f"{scheme}://{host}".rstrip('/')
def _get_external_websocket_url():
base_url = _get_external_base_url()
if base_url.startswith('https://'):
return f"wss://{base_url[len('https://'):]}/agent"
if base_url.startswith('http://'):
return f"ws://{base_url[len('http://'):]}/agent"
return f"{base_url}/agent"
# ==================== Permission Profiles ====================
PERMISSION_PROFILES = {
'docker_readonly': {
'name': 'Docker Read-Only',
'description': 'View containers, images, and metrics',
'permissions': [
'docker:container:read',
'docker:image:read',
'docker:compose:read',
'docker:volume:read',
'docker:network:read',
'system:metrics:read',
# Fleet doctor / survey reads (plan 28 #7): let the panel probe
# host health without granting any mutating verb.
'doctor:read',
'systemd:read',
'survey:read',
]
},
'docker_manager': {
'name': 'Docker Manager',
'description': 'Full Docker management and metrics',
'permissions': [
'docker:container:*',
'docker:image:*',
'docker:compose:*',
'docker:volume:*',
'docker:network:*',
'system:metrics:read',
'system:logs:read',
'doctor:read',
'systemd:read',
'survey:read',
]
},
'deployment_runner': {
'name': 'Deployment Runner',
'description': 'Deploy and operate ServerKit-managed Docker Compose apps',
'permissions': [
'docker:container:*',
'docker:image:*',
'docker:compose:*',
'docker:volume:*',
'docker:network:*',
'file:read',
'file:write',
'file:list',
'system:metrics:read',
'system:logs:read',
'doctor:read',
'systemd:read',
'survey:read',
]
},
'full_access': {
'name': 'Full Access',
'description': 'All permissions including system commands',
'permissions': ['*']
}
}
# ==================== Server Groups ====================
@servers_bp.route('/groups', methods=['GET'])
@jwt_required()
def list_groups():
"""List all server groups"""
groups = ServerGroup.query.all()
return jsonify([g.to_dict() for g in groups])
@servers_bp.route('/groups', methods=['POST'])
@developer_required
def create_group():
"""Create a new server group"""
data = request.get_json()
if not data.get('name'):
return jsonify({'error': 'Name is required'}), 400
group = ServerGroup(
name=data['name'],
description=data.get('description'),
color=data.get('color', '#6366f1'),
icon=data.get('icon', 'server'),
parent_id=data.get('parent_id')
)
db.session.add(group)
db.session.commit()
return jsonify(group.to_dict()), 201
@servers_bp.route('/groups/<group_id>', methods=['GET'])
@jwt_required()
def get_group(group_id):
"""Get a server group by ID"""
group = ServerGroup.query.get(group_id)
if not group:
return jsonify({'error': 'Group not found'}), 404
return jsonify(group.to_dict(include_servers=True))
@servers_bp.route('/groups/<group_id>', methods=['PUT'])
@developer_required
def update_group(group_id):
"""Update a server group"""
group = ServerGroup.query.get(group_id)
if not group:
return jsonify({'error': 'Group not found'}), 404
data = request.get_json()
if 'name' in data:
group.name = data['name']
if 'description' in data:
group.description = data['description']
if 'color' in data:
group.color = data['color']
if 'icon' in data:
group.icon = data['icon']
if 'parent_id' in data:
group.parent_id = data['parent_id']
db.session.commit()
return jsonify(group.to_dict())
@servers_bp.route('/groups/<group_id>', methods=['DELETE'])
@admin_required
def delete_group(group_id):
"""Delete a server group"""
group = ServerGroup.query.get(group_id)
if not group:
return jsonify({'error': 'Group not found'}), 404
# Move servers to no group
for server in group.servers:
server.group_id = None
db.session.delete(group)
db.session.commit()
return jsonify({'message': 'Group deleted'})
# ==================== Servers ====================
@servers_bp.route('', methods=['GET'])
@jwt_required()
def list_servers():
"""List all servers"""
# Query parameters
group_id = request.args.get('group_id')
status = request.args.get('status')
tag = request.args.get('tag')
# Workspace-aware scoping (#33). Servers are global today, so with no workspace
# context this stays unfiltered; with a workspace context it filters to it.
from app.models import User
from app.services.workspace_service import WorkspaceService
user = get_current_user()
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
query = WorkspaceService.scope_query(Server.query, Server, user,
workspace_id=ws_id, owner_attr=None)
if group_id:
query = query.filter_by(group_id=group_id)
if status:
query = query.filter_by(status=status)
if tag:
query = query.filter(Server.tags.contains([tag]))
# $select / $filter / $orderby / $skip / $top, all optional. No paging meta
# is emitted here: this route answers with a bare JSON array, and wrapping
# it in an envelope would break every existing caller.
try:
narrowed = apply_query(query, Server, request, select_extra=Server.DERIVED_FIELDS)
except QueryParseError as exc:
return jsonify({'error': str(exc)}), 400
query = narrowed.query
fields = narrowed.fields
# `group_name` is a relationship load per row; hoist it when it is wanted.
if fields is None or 'group_name' in fields:
query = query.options(joinedload(Server.group))
# An explicit $orderby wins; otherwise keep the by-name default the list UI
# has always relied on.
servers = (query.all() if request.args.get('$orderby')
else query.order_by(Server.name).all())
# Keep the list's insertion-order contract; monitoring uses sample time.
metrics_by_server = latest_metrics_by_server(
(server.id for server in servers), order_by='id',
)
result = []
for server in servers:
server_dict = server.to_dict(fields=fields)
server_dict['is_connected'] = agent_registry.is_agent_connected(server.id)
metrics = metrics_by_server.get(server.id)
if metrics:
server_dict['metrics'] = metrics.to_dict()
result.append(server_dict)
return jsonify(result)
@servers_bp.route('', methods=['POST'])
@developer_required
def create_server():
"""
Create a new server slot and generate a connection string.
The user no longer types a server name here — the agent's hostname
becomes the name on first register. We just allocate a row, mint a
single-use registration token, and bundle URL+token+expiry into one
pasteable string.
Body fields (all optional):
expires_in: token TTL in seconds. -1 = never, missing = 7 days.
description, group_id, tags, permissions, permission_profile,
allowed_ips: passed through unchanged.
"""
data = request.get_json() or {}
# Through the one identity door: @developer_required admits API-key
# callers, for whom reading the JWT directly raises.
creator = get_current_user()
user_id = creator.id if creator else None
# Generate registration token
registration_token = Server.generate_registration_token()
# Get permissions from profile or custom list. Default is `['*']`
# (full access) — ServerKit is single-tenant by default and the
# previous default of `[]` left every new server 403'ing on every
# action. Users who want a locked-down ACL pass an explicit list
# or `permission_profile`.
permissions = data.get('permissions')
profile = data.get('permission_profile')
if profile and profile in PERMISSION_PROFILES:
permissions = PERMISSION_PROFILES[profile]['permissions']
elif permissions is None:
permissions = ['*']
expires_at = _resolve_token_expiry(data.get('expires_in'))
# Stamp the workspace (#33): the requested one (membership-checked) or the default.
from app.services.workspace_service import WorkspaceService
user = creator
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
# Role reconciliation (#33): a workspace 'viewer' member has read-only access to
# the active workspace and may not create resources in it (checked only when a
# workspace context is explicitly active, so no-context behavior is unchanged).
if ws_id is not None and not WorkspaceService.can_write_in_workspace(user, ws_id):
return jsonify({'error': 'You have read-only access to this workspace'}), 403
if ws_id is None:
ws_id = WorkspaceService.ensure_default_workspace().id
# Enforce workspace server quota.
server_count = Server.query.filter_by(workspace_id=ws_id).count()
quota_error = WorkspaceService.check_quota(ws_id, server_count, 'max_servers')
if quota_error:
return jsonify({'error': quota_error}), 403
server = Server(
# Temporary name — replaced with the agent's hostname when the
# agent calls /register. _is_placeholder_name() in the register
# endpoint detects this and overwrites it; user-chosen names are
# left alone.
name=_PLACEHOLDER_NAME_PREFIX + "...)",
description=data.get('description'),
group_id=data.get('group_id'),
tags=data.get('tags', []),
permissions=permissions,
allowed_ips=data.get('allowed_ips', []),
registered_by=user_id,
workspace_id=ws_id,
registration_token_expires=expires_at,
)
server.set_registration_token(registration_token)
db.session.add(server)
db.session.commit()
# Now that the row has its UUID, give it a placeholder with a short
# id suffix so multiple unpaired rows are distinguishable in the UI.
server.name = _placeholder_name(server.id)
db.session.commit()
panel_url = _get_external_base_url()
conn_string = connection_string_codec.encode(
url=panel_url,
token=registration_token,
expires_at=expires_at,
)
result = server.to_dict()
result['registration_token'] = registration_token
result['registration_expires'] = server.registration_token_expires.isoformat()
result['connection_string'] = conn_string
result['panel_url'] = panel_url
return jsonify(result), 201
@servers_bp.route('/<server_id>/workspace', methods=['PUT'])
@developer_required
def set_server_workspace(server_id):
"""Reassign a server to a workspace (#33). Developer+; the target must be a
workspace the caller can access (member or admin). A null/'default' target
moves it back to the default workspace."""
from app.models import User
from app.models.workspace import Workspace
from app.services.workspace_service import WorkspaceService
user = get_current_user()
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
target = (request.get_json() or {}).get('workspace_id')
if target in (None, '', 'default'):
ws_id = WorkspaceService.ensure_default_workspace().id
else:
ws = Workspace.query.get(target)
if not ws:
return jsonify({'error': 'Workspace not found'}), 404
if not user.is_admin and WorkspaceService.get_user_role(ws.id, user.id) is None:
return jsonify({'error': 'Not a member of the target workspace'}), 403
# Role reconciliation (#33): a 'viewer' member can't move resources into it.
if not WorkspaceService.can_write_in_workspace(user, ws.id):
return jsonify({'error': 'You have read-only access to the target workspace'}), 403
# Enforce workspace server quota when moving a server into a workspace.
if server.workspace_id != ws.id:
server_count = Server.query.filter_by(workspace_id=ws.id).count()
quota_error = WorkspaceService.check_quota(ws.id, server_count, 'max_servers')
if quota_error:
return jsonify({'error': quota_error}), 403
ws_id = ws.id
server.workspace_id = ws_id
db.session.commit()
return jsonify({'message': 'Workspace updated', 'server': server.to_dict()}), 200
@servers_bp.route('/<server_id>', methods=['GET'])
@jwt_required()
def get_server(server_id):
"""Get a server by ID"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
result = server.to_dict(include_metrics=True)
# Surface the live transport so the UI can disable / banner features
# that don't work when the agent is on the polling fallback (live
# logs, real-time metrics fan-out, terminal). Possible values:
# "ws" — long-lived Socket.IO connection, all features work
# "poll" — REST polling fallback, streams unavailable
# None — agent not connected
agent = agent_registry.get_agent(server.id)
# Self-healing inconsistency check: if the Server row claims
# "online" but there's no in-memory agent (panel was restarted, or
# the agent process died without a clean disconnect), the status
# is lying. Flip it to "offline" so the UI doesn't show the user
# an "online" pill while the Docker tab errors with "Agent not
# connected." Persist the correction so subsequent reads agree.
if not agent and server.status == 'online':
server.status = 'offline'
try:
db.session.commit()
result['status'] = 'offline'
except Exception:
db.session.rollback()
result['is_connected'] = agent is not None
result['transport'] = agent.transport if agent else None
# Capability map the agent advertised on connect. When the agent is
# connected, prefer the live in-memory copy; when offline, fall back
# to the snapshot persisted by update_capabilities so the Overview
# tab can still render the last-known state instead of an empty
# screen. capabilities_stale + capabilities_at let the UI badge the
# data as cached.
if agent:
result['capabilities'] = dict(agent.capabilities)
result['runtimes'] = dict(agent.runtimes)
result['runtime_managers'] = dict(getattr(agent, 'runtime_managers', {}) or {})
result['allowed_paths'] = list(getattr(agent, 'allowed_paths', []) or [])
result['sudo'] = getattr(agent, 'sudo', '') or (server.cached_sudo or '')
result['systemd_json'] = bool(getattr(agent, 'systemd_json', server.cached_systemd_json or False))
result['platform'] = agent.platform
result['distro'] = agent.distro
result['distro_version'] = agent.distro_version
result['capabilities_stale'] = False
result['capabilities_at'] = server.capabilities_at.isoformat() + 'Z' if server.capabilities_at else None
else:
result['capabilities'] = dict(server.cached_capabilities or {})
result['runtimes'] = dict(server.cached_runtimes or {})
result['runtime_managers'] = dict(server.cached_runtime_managers or {})
result['allowed_paths'] = list(server.cached_allowed_paths or [])
result['sudo'] = server.cached_sudo or ''
result['systemd_json'] = bool(server.cached_systemd_json)
result['capabilities_stale'] = bool(server.capabilities_at)
result['capabilities_at'] = server.capabilities_at.isoformat() + 'Z' if server.capabilities_at else None
return jsonify(result)
@servers_bp.route('/<server_id>/timeline', methods=['GET'])
@developer_required
def get_server_timeline(server_id):
"""Merge the server's accessible audit and checkpoint history."""
allowed = {'types', 'before', 'limit'}
unknown = set(request.args) - allowed
if unknown:
raise ValidationError(
'Unknown query parameters',
details={'fields': sorted(unknown)},
)
for key in allowed:
if len(request.args.getlist(key)) > 1:
raise ValidationError(
f'{key} must be provided at most once',
details={'field': key},
)
from app.services.server_timeline_service import ServerTimelineService
page = ServerTimelineService.get_timeline(
get_current_user(),
server_id,
types=request.args.get('types'),
before=request.args.get('before'),
limit=request.args.get('limit'),
)
return jsonify({
'events': page.events,
'next_cursor': page.next_cursor,
}), 200
@servers_bp.route('/<server_id>', methods=['PUT'])
@developer_required
def update_server(server_id):
"""Update a server"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
data = request.get_json()
if 'name' in data:
server.name = data['name']
if 'description' in data:
server.description = data['description']
if 'group_id' in data:
server.group_id = data['group_id']
if 'tags' in data:
server.tags = data['tags']
if 'permissions' in data:
server.permissions = data['permissions']
if 'allowed_ips' in data:
server.allowed_ips = data['allowed_ips']
# Handle permission profile
if 'permission_profile' in data:
profile = data['permission_profile']
if profile in PERMISSION_PROFILES:
server.permissions = PERMISSION_PROFILES[profile]['permissions']
db.session.commit()
return jsonify(server.to_dict())
@servers_bp.route('/<server_id>', methods=['DELETE'])
@admin_required
def delete_server(server_id):
"""Delete a server"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
# Disconnect agent if connected
if agent_registry.is_agent_connected(server_id):
# TODO: Send disconnect command to agent
pass
db.session.delete(server)
db.session.commit()
return jsonify({'message': 'Server deleted'})
# ==================== Onboarding State Machine ====================
@servers_bp.route('/<server_id>/onboarding/start', methods=['POST'])
@developer_required
def start_server_onboarding(server_id):
"""Begin the onboarding lifecycle for a server (pending -> validating ...)."""
from app.services.server_onboarding_service import ServerOnboardingService
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
try:
status = ServerOnboardingService.start(server_id)
except ValueError as exc:
return jsonify({'error': str(exc)}), 404
return jsonify(status)
@servers_bp.route('/<server_id>/onboarding/retry', methods=['POST'])
@developer_required
def retry_server_onboarding(server_id):
"""Clear a failed onboarding and resume from validation."""
from app.services.server_onboarding_service import ServerOnboardingService
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
try:
status = ServerOnboardingService.retry(server_id)
except ValueError as exc:
return jsonify({'error': str(exc)}), 404
return jsonify(status)
@servers_bp.route('/<server_id>/onboarding/status', methods=['GET'])
@jwt_required()
def get_server_onboarding_status(server_id):
"""Return the onboarding state + ordered progress log for a server."""
from app.services.server_onboarding_service import ServerOnboardingService
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
try:
status = ServerOnboardingService.get_status(server_id)
except ValueError as exc:
return jsonify({'error': str(exc)}), 404
return jsonify(status)
# ==================== Registration ====================
@servers_bp.route('/<server_id>/regenerate-token', methods=['POST'])
@developer_required
def regenerate_token(server_id):
"""Regenerate the registration token for a server and return a fresh
connection string.
Used both for first-pair (panel just created the row) and re-pair
(the agent was uninstalled / reinstalled and lost its credentials).
Body is optional; only ``expires_in`` is read (same semantics as
create_server).
"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
data = request.get_json(silent=True) or {}
expires_at = _resolve_token_expiry(data.get('expires_in'))
registration_token = Server.generate_registration_token()
server.set_registration_token(registration_token)
server.registration_token_expires = expires_at
server.status = 'pending'
server.agent_id = None
db.session.commit()
panel_url = _get_external_base_url()
conn_string = connection_string_codec.encode(
url=panel_url,
token=registration_token,
expires_at=expires_at,
)
return jsonify({
'registration_token': registration_token,
'registration_expires': server.registration_token_expires.isoformat(),
'connection_string': conn_string,
'panel_url': panel_url,
})
@servers_bp.route('/register', methods=['POST'])
@limiter.limit("5 per minute")
def register_agent():
"""
Agent registration endpoint.
Called by agents during initial setup.
Expected data:
{
"token": "sk_reg_xxx",
"name": "server-name",
"system_info": {...},
"agent_version": "1.0.0"
}
"""
data = request.get_json()
token = data.get('token')
if not token:
return jsonify({'error': 'Registration token required'}), 400
# Find server by token (need to check all servers)
server = None
for s in Server.query.filter(Server.registration_token_hash.isnot(None)).all():
if s.verify_registration_token(token):
server = s
break
if not server:
return jsonify({'error': 'Invalid or expired registration token'}), 401
# Generate API credentials
api_key, api_secret = Server.generate_api_credentials()
# Update server with agent info
server.agent_id = data.get('agent_id') or str(__import__('uuid').uuid4())
server.set_api_key(api_key)
server.set_api_secret_encrypted(api_secret) # Store encrypted secret for signature verification
server.status = 'connecting'
server.registered_at = datetime.utcnow()
# Clear registration token (single use)
server.registration_token_hash = None
server.registration_token_expires = None
# Update system info if provided
system_info = data.get('system_info', {})
if system_info:
server.hostname = system_info.get('hostname', server.hostname)
server.os_type = system_info.get('os', server.os_type)
server.os_version = system_info.get('platform_version', server.os_version)
server.platform = system_info.get('platform', server.platform)
server.architecture = system_info.get('architecture', server.architecture)
server.cpu_cores = system_info.get('cpu_cores', server.cpu_cores)
server.total_memory = system_info.get('total_memory', server.total_memory)
server.total_disk = system_info.get('total_disk', server.total_disk)
server.agent_version = data.get('agent_version')
# Replace the panel-issued placeholder name with whatever the agent
# reports — preferring the OS hostname over the agent's optional
# top-level "name" field. Once a user has renamed the server in the
# UI, the placeholder check fails and we leave their name alone, so
# re-pair after reinstall doesn't clobber renames.
if _is_placeholder_name(server.name):
reported_name = (system_info.get('hostname') if system_info else None) or data.get('name')
if reported_name:
server.name = reported_name
db.session.commit()
# Security note: api_secret is returned once during registration so the agent
# can store it. The server-side copy is stored encrypted. The registration token
# is already cleared above (single-use), preventing re-registration.
return jsonify({
'agent_id': server.agent_id,
'name': server.name,
'api_key': api_key,
'api_secret': api_secret,
'websocket_url': _get_external_websocket_url(),
'server_id': server.id
})
# ==================== Server Status ====================
@servers_bp.route('/<server_id>/status', methods=['GET'])
@jwt_required()
def get_server_status(server_id):
"""Get current server status and live metrics"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
is_connected = agent_registry.is_agent_connected(server_id)
result = {
'id': server.id,
'name': server.name,
'status': 'online' if is_connected else server.status,
'is_connected': is_connected,
'last_seen': server.last_seen.isoformat() if server.last_seen else None,
'last_error': server.last_error,
}
# Get latest metrics
latest_metrics = server.metrics.order_by(ServerMetrics.timestamp.desc()).first()
if latest_metrics:
result['metrics'] = latest_metrics.to_dict()
return jsonify(result)
@servers_bp.route('/<server_id>/ping', methods=['POST'])
@developer_required
def ping_server(server_id):
"""Force a ping/health check on a server"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
if not agent_registry.is_agent_connected(server_id):
return jsonify({
'success': False,
'error': 'Agent not connected'
})
# Send system:metrics command to get fresh data
result = dispatch_agent_command(
server_id=server_id,
action='system:metrics',
timeout=10.0
)
return jsonify(result)
# ==================== Metrics ====================
@servers_bp.route('/<server_id>/metrics', methods=['GET'])
@jwt_required()
def get_server_metrics(server_id):
"""Get historical metrics for a server"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
# Query parameters
from_time = request.args.get('from')
to_time = request.args.get('to')
limit = request.args.get('limit', 100, type=int)
query = ServerMetrics.query.filter_by(server_id=server_id)
if from_time:
try:
from_dt = datetime.fromisoformat(from_time.replace('Z', '+00:00'))
query = query.filter(ServerMetrics.timestamp >= from_dt)
except:
pass
if to_time:
try:
to_dt = datetime.fromisoformat(to_time.replace('Z', '+00:00'))
query = query.filter(ServerMetrics.timestamp <= to_dt)
except:
pass
metrics = query.order_by(ServerMetrics.timestamp.desc()).limit(limit).all()
return jsonify([m.to_dict() for m in reversed(metrics)])
@servers_bp.route('/metrics/compare', methods=['GET'])
@jwt_required()
def compare_server_metrics():
"""Compare metrics across multiple servers"""
server_ids = request.args.get('ids', '').split(',')
metric = request.args.get('metric', 'cpu_percent')
limit = request.args.get('limit', 50, type=int)
if not server_ids or server_ids == ['']:
return jsonify({'error': 'Server IDs required'}), 400
result = {}
for server_id in server_ids:
server = Server.query.get(server_id)
if not server:
continue
metrics = ServerMetrics.query.filter_by(server_id=server_id)\
.order_by(ServerMetrics.timestamp.desc())\
.limit(limit)\
.all()
result[server_id] = {
'name': server.name,
'data': [
{
'timestamp': m.timestamp.isoformat(),
'value': getattr(m, metric, None)
}
for m in reversed(metrics)
]
}
return jsonify(result)
# ==================== Server Overview ====================
@servers_bp.route('/overview', methods=['GET'])
@jwt_required()
def get_servers_overview():
"""Get overview of all servers health"""
servers = Server.query.options(joinedload(Server.group)).all()
metrics_by_server = latest_metrics_by_server(server.id for server in servers)
connected_ids = set(agent_registry.get_connected_servers())
total = len(servers)
online = len(connected_ids)
offline = total - online
total_containers = 0
total_running = 0
servers_data = []
for server in servers:
is_online = server.id in connected_ids
# Get latest metrics
latest = metrics_by_server.get(server.id)
server_summary = {
'id': server.id,
'name': server.name,
'status': 'online' if is_online else server.status,
'group_id': server.group_id,
'group_name': server.group.name if server.group else None,
'tags': server.tags or [],
}
if latest:
server_summary['cpu_percent'] = latest.cpu_percent
server_summary['memory_percent'] = latest.memory_percent
server_summary['disk_percent'] = latest.disk_percent
server_summary['container_count'] = latest.container_count
server_summary['container_running'] = latest.container_running
if latest.container_count:
total_containers += latest.container_count
if latest.container_running:
total_running += latest.container_running
servers_data.append(server_summary)
return jsonify({
'summary': {
'total': total,
'online': online,
'offline': offline,
'total_containers': total_containers,
'running_containers': total_running,
},
'servers': servers_data
})
# ==================== Command History ====================
@servers_bp.route('/<server_id>/commands', methods=['GET'])
@jwt_required()
def get_command_history(server_id):
"""Get command history for a server"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
limit = request.args.get('limit', 50, type=int)
commands = ServerCommand.query.filter_by(server_id=server_id)\
.order_by(ServerCommand.created_at.desc())\
.limit(limit)\
.all()
return jsonify([c.to_dict() for c in commands])
# ==================== Permission Profiles ====================
@servers_bp.route('/permission-profiles', methods=['GET'])
@jwt_required()
def get_permission_profiles():
"""Get available permission profiles"""
return jsonify(PERMISSION_PROFILES)
# ==================== Security Features ====================
from app.utils.ip_utils import is_ip_allowed, validate_ip_pattern
from app.models.security_alert import SecurityAlert
from app.services.anomaly_detection_service import anomaly_detection_service
@servers_bp.route('/<server_id>/allowed-ips', methods=['GET'])
@jwt_required()
def get_allowed_ips(server_id):
"""Get allowed IPs for a server"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
return jsonify({
'allowed_ips': server.allowed_ips or [],
'is_enforced': bool(server.allowed_ips and len(server.allowed_ips) > 0)
})
@servers_bp.route('/<server_id>/allowed-ips', methods=['PUT'])
@developer_required
def update_allowed_ips(server_id):
"""
Update allowed IPs for a server.
Body: { "allowed_ips": ["192.168.1.0/24", "10.0.0.5"] }
Supports:
- Single IP: "192.168.1.100"
- CIDR: "192.168.1.0/24"
- Wildcards: "192.168.1.*"
"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
data = request.get_json()
allowed_ips = data.get('allowed_ips', [])
# Validate each IP pattern
errors = []
for ip_pattern in allowed_ips:
is_valid, error = validate_ip_pattern(ip_pattern)
if not is_valid:
errors.append(f"Invalid pattern '{ip_pattern}': {error}")
if errors:
return jsonify({'error': 'Invalid IP patterns', 'details': errors}), 400
server.allowed_ips = allowed_ips
db.session.commit()
return jsonify({
'allowed_ips': server.allowed_ips,
'is_enforced': bool(server.allowed_ips and len(server.allowed_ips) > 0)
})
@servers_bp.route('/<server_id>/connection-info', methods=['GET'])
@jwt_required()
def get_connection_info(server_id):
"""Get connection info for a server (current IP, connected status, etc.)"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
is_connected = agent_registry.is_agent_connected(server_id)
agent = agent_registry.get_agent(server_id) if is_connected else None
# Get active session from database
active_session = AgentSession.query.filter_by(
server_id=server_id,
is_active=True
).first()
return jsonify({
'connected': is_connected,
'ip_address': agent.ip_address if agent else (active_session.ip_address if active_session else None),
'connected_since': active_session.connected_at.isoformat() if active_session else None,
'agent_version': agent.agent_version if agent else server.agent_version,
'last_heartbeat': agent.last_heartbeat.isoformat() if agent else None
})
@servers_bp.route('/<server_id>/rotate-api-key', methods=['POST'])
@admin_required
def rotate_api_key(server_id):
"""
Initiate API key rotation for a server.
This generates new credentials and sends them to the connected agent.
The agent must acknowledge the update within 5 minutes.
"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
if not agent_registry.is_agent_connected(server_id):
return jsonify({
'error': 'Agent must be connected to rotate API key',
'code': 'AGENT_OFFLINE'
}), 400
# Check if there's already a pending rotation
if server.api_key_rotation_id and server.api_key_rotation_expires:
if datetime.utcnow() < server.api_key_rotation_expires:
return jsonify({
'error': 'API key rotation already in progress',
'rotation_id': server.api_key_rotation_id,
'expires': server.api_key_rotation_expires.isoformat()
}), 409
# Start rotation
new_api_key, new_api_secret, rotation_id = server.start_key_rotation()
db.session.commit()
# Send credential update to agent. The agent verifies an HMAC over
# the new credentials computed with its *current* secret before
# applying the rotation, so a session-level WS auth bypass alone
# can't be used to silently rotate fleet credentials to attacker-
# controlled values. The agent expects:
# hex(HMAC-SHA256("rotation_id:agent_id:new_api_key:new_api_secret",
# current_api_secret))
current_secret = server.get_api_secret() or ''
if not current_secret:
# Agents paired before the api_secret_encrypted column existed
# can't be verified. Better to fail the rotation than silently
# ship an unsigned credential update — operators can re-pair to
# establish a verifiable secret on disk.
return jsonify({
'error': 'Server has no recoverable secret on file; re-pair the agent before rotating.',
'code': 'NO_CURRENT_SECRET'
}), 409
payload = f'{rotation_id}:{server.agent_id}:{new_api_key}:{new_api_secret}'
sig = hmac.new(
current_secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256,
).hexdigest()
agent = agent_registry.get_agent(server_id)
if agent and agent_registry._socketio:
agent_registry._socketio.emit(
'credential_update',
{
'type': 'credential_update',
'rotation_id': rotation_id,
'api_key': new_api_key,
'api_secret': new_api_secret,
'hmac_sig': sig,
},
room=agent.socket_id,
namespace='/agent'
)
return jsonify({
'success': True,
'rotation_id': rotation_id,
'message': 'Credential update sent to agent. Waiting for acknowledgment.',
'expires': server.api_key_rotation_expires.isoformat()
})
@servers_bp.route('/<server_id>/security/alerts', methods=['GET'])
@jwt_required()
def get_server_security_alerts(server_id):
"""Get security alerts for a specific server"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
status = request.args.get('status')
severity = request.args.get('severity')
limit = request.args.get('limit', 100, type=int)
alerts = anomaly_detection_service.get_alerts(
server_id=server_id,
status=status,
severity=severity,
limit=limit
)
return jsonify([a.to_dict() for a in alerts])
@servers_bp.route('/security/alerts', methods=['GET'])
@jwt_required()
def get_all_security_alerts():
"""Get security alerts for all servers"""
status = request.args.get('status')
severity = request.args.get('severity')
alert_type = request.args.get('type')
limit = request.args.get('limit', 100, type=int)
alerts = anomaly_detection_service.get_alerts(
status=status,
severity=severity,
alert_type=alert_type,
limit=limit
)
return jsonify([a.to_dict() for a in alerts])
@servers_bp.route('/security/alerts/counts', methods=['GET'])
@jwt_required()
def get_security_alert_counts():
"""Get counts of security alerts by status and severity"""
server_id = request.args.get('server_id')
counts = anomaly_detection_service.get_alert_counts(server_id=server_id)
return jsonify(counts)
@servers_bp.route('/security/alerts/<alert_id>/acknowledge', methods=['POST'])
@developer_required
def acknowledge_alert(alert_id):
"""Acknowledge a security alert"""
alert = SecurityAlert.query.get(alert_id)
if not alert:
return jsonify({'error': 'Alert not found'}), 404
user_id = get_jwt_identity()
alert.acknowledge(user_id=user_id)
return jsonify(alert.to_dict())
@servers_bp.route('/security/alerts/<alert_id>/resolve', methods=['POST'])
@developer_required
def resolve_alert(alert_id):
"""Resolve a security alert"""
alert = SecurityAlert.query.get(alert_id)
if not alert:
return jsonify({'error': 'Alert not found'}), 404
user_id = get_jwt_identity()
alert.resolve(user_id=user_id)
return jsonify(alert.to_dict())
# ==================== Remote Docker Operations ====================
from app.services.remote_docker_service import RemoteDockerService
from app.services.server_metrics_service import ServerMetricsService
from app.services.terminal_service import TerminalService
@servers_bp.route('/available', methods=['GET'])
@jwt_required()
def get_available_servers():
"""Get list of servers available for Docker operations"""
servers = RemoteDockerService.get_available_servers()
return jsonify(servers)
@servers_bp.route('/<server_id>/docker/containers', methods=['GET'])
@jwt_required()
def list_remote_containers(server_id):
"""List containers on a remote server"""
all_containers = request.args.get('all', 'false').lower() == 'true'
user_id = get_jwt_identity()
result = RemoteDockerService.list_containers(server_id, all=all_containers, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500 if result.get('code') != 'AGENT_OFFLINE' else 503
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/containers/<container_id>', methods=['GET'])
@jwt_required()
def inspect_remote_container(server_id, container_id):
"""Inspect a container on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.inspect_container(server_id, container_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/docker/containers/<container_id>/start', methods=['POST'])
@developer_required
def start_remote_container(server_id, container_id):
"""Start a container on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.start_container(server_id, container_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify({'message': 'Container started'})
@servers_bp.route('/<server_id>/docker/containers/<container_id>/stop', methods=['POST'])
@developer_required
def stop_remote_container(server_id, container_id):
"""Stop a container on a remote server"""
user_id = get_jwt_identity()
data = request.get_json() or {}
timeout = data.get('timeout')
result = RemoteDockerService.stop_container(server_id, container_id, timeout=timeout, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify({'message': 'Container stopped'})
@servers_bp.route('/<server_id>/docker/containers/<container_id>/restart', methods=['POST'])
@developer_required
def restart_remote_container(server_id, container_id):
"""Restart a container on a remote server"""
user_id = get_jwt_identity()
data = request.get_json() or {}
timeout = data.get('timeout')
result = RemoteDockerService.restart_container(server_id, container_id, timeout=timeout, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify({'message': 'Container restarted'})
@servers_bp.route('/<server_id>/docker/containers/<container_id>', methods=['DELETE'])
@developer_required
def remove_remote_container(server_id, container_id):
"""Remove a container on a remote server"""
user_id = get_jwt_identity()
force = request.args.get('force', 'false').lower() == 'true'
remove_volumes = request.args.get('v', 'false').lower() == 'true'
result = RemoteDockerService.remove_container(
server_id, container_id,
force=force, remove_volumes=remove_volumes,
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 500
return jsonify({'message': 'Container removed'})
@servers_bp.route('/<server_id>/docker/containers/<container_id>/stats', methods=['GET'])
@jwt_required()
def get_remote_container_stats(server_id, container_id):
"""Get container stats from a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.get_container_stats(server_id, container_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/docker/containers/<container_id>/logs', methods=['GET'])
@jwt_required()
def get_remote_container_logs(server_id, container_id):
"""
Get container logs from a remote server.
Query params:
tail: Number of lines (default 100, 'all' for all lines)
since: Show logs since timestamp
timestamps: Include timestamps (default true)
"""
user_id = get_jwt_identity()
tail = request.args.get('tail', '100')
since = request.args.get('since')
timestamps = request.args.get('timestamps', 'true').lower() == 'true'
result = RemoteDockerService.get_container_logs(
server_id, container_id,
tail=tail, since=since, timestamps=timestamps,
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/docker/images', methods=['GET'])
@jwt_required()
def list_remote_images(server_id):
"""List images on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.list_images(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/images/pull', methods=['POST'])
@developer_required
def pull_remote_image(server_id):
"""Pull an image on a remote server"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get('image'):
return jsonify({'error': 'Image name required'}), 400
result = RemoteDockerService.pull_image(server_id, data['image'], user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result)
@servers_bp.route('/<server_id>/docker/images/<image_id>', methods=['DELETE'])
@developer_required
def remove_remote_image(server_id, image_id):
"""Remove an image on a remote server"""
user_id = get_jwt_identity()
force = request.args.get('force', 'false').lower() == 'true'
result = RemoteDockerService.remove_image(server_id, image_id, force=force, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify({'message': 'Image removed'})
@servers_bp.route('/<server_id>/docker/volumes', methods=['GET'])
@jwt_required()
def list_remote_volumes(server_id):
"""List volumes on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.list_volumes(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/volumes/<volume_name>', methods=['DELETE'])
@developer_required
def remove_remote_volume(server_id, volume_name):
"""Remove a volume on a remote server"""
user_id = get_jwt_identity()
force = request.args.get('force', 'false').lower() == 'true'
result = RemoteDockerService.remove_volume(server_id, volume_name, force=force, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify({'message': 'Volume removed'})
@servers_bp.route('/<server_id>/docker/networks', methods=['GET'])
@jwt_required()
def list_remote_networks(server_id):
"""List networks on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.list_networks(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/networks/<network_id>', methods=['DELETE'])
@developer_required
def remove_remote_network(server_id, network_id):
"""Remove a network on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.remove_network(server_id, network_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify({'message': 'Network removed'})
@servers_bp.route('/<server_id>/system/metrics', methods=['GET'])
@jwt_required()
def get_remote_system_metrics(server_id):
"""Get system metrics from a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.get_system_metrics(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/system/info', methods=['GET'])
@jwt_required()
def get_remote_system_info(server_id):
"""Get system info from a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.get_system_info(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data'))
# ==================== Remote Docker Compose Operations ====================
@servers_bp.route('/<server_id>/docker/compose/projects', methods=['GET'])
@jwt_required()
def list_remote_compose_projects(server_id):
"""List compose projects on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.compose_list(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), 500 if result.get('code') != 'AGENT_OFFLINE' else 503
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/compose/ps', methods=['POST'])
@developer_required
def remote_compose_ps(server_id):
"""List containers for a compose project"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get('project_path'):
return jsonify({'error': 'project_path is required'}), 400
result = RemoteDockerService.compose_ps(
server_id,
data['project_path'],
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/compose/up', methods=['POST'])
@developer_required
def remote_compose_up(server_id):
"""Start a compose project"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get('project_path'):
return jsonify({'error': 'project_path is required'}), 400
result = RemoteDockerService.compose_up(
server_id,
data['project_path'],
detach=data.get('detach', True),
build=data.get('build', False),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result)
@servers_bp.route('/<server_id>/docker/compose/down', methods=['POST'])
@developer_required
def remote_compose_down(server_id):
"""Stop a compose project"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get('project_path'):
return jsonify({'error': 'project_path is required'}), 400
result = RemoteDockerService.compose_down(
server_id,
data['project_path'],
volumes=data.get('volumes', False),
remove_orphans=data.get('remove_orphans', True),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result)
@servers_bp.route('/<server_id>/docker/compose/logs', methods=['POST'])
@developer_required
def remote_compose_logs(server_id):
"""Get logs from a compose project"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get('project_path'):
return jsonify({'error': 'project_path is required'}), 400
result = RemoteDockerService.compose_logs(
server_id,
data['project_path'],
service=data.get('service'),
tail=data.get('tail', 100),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/docker/compose/restart', methods=['POST'])
@developer_required
def remote_compose_restart(server_id):
"""Restart a compose project or specific service"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get('project_path'):
return jsonify({'error': 'project_path is required'}), 400
result = RemoteDockerService.compose_restart(
server_id,
data['project_path'],
service=data.get('service'),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result)
@servers_bp.route('/<server_id>/docker/compose/pull', methods=['POST'])
@developer_required
def remote_compose_pull(server_id):
"""Pull images for a compose project"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get('project_path'):
return jsonify({'error': 'project_path is required'}), 400
result = RemoteDockerService.compose_pull(
server_id,
data['project_path'],
service=data.get('service'),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 500
return jsonify(result)
# ==================== Historical Metrics ====================
@servers_bp.route('/<server_id>/metrics/history', methods=['GET'])
@jwt_required()
def get_server_metrics_history(server_id):
"""Get historical metrics for a server.
Query params:
period: '1h', '6h', '24h', '7d', '30d' (default: '24h')
"""
period = request.args.get('period', '24h')
result = ServerMetricsService.get_server_history(server_id, period)
return jsonify(result)
@servers_bp.route('/<server_id>/metrics/aggregated', methods=['GET'])
@jwt_required()
def get_server_metrics_aggregated(server_id):
"""Get aggregated metrics for a server.
Query params:
period: '24h', '7d', '30d' (default: '24h')
aggregation: 'hourly', 'daily' (default: 'hourly')
"""
period = request.args.get('period', '24h')
aggregation = request.args.get('aggregation', 'hourly')
result = ServerMetricsService.get_aggregated_metrics(server_id, period, aggregation)
return jsonify(result)
@servers_bp.route('/metrics/retention', methods=['GET'])
@developer_required
def get_metrics_retention_stats():
"""Get metrics retention statistics."""
result = ServerMetricsService.get_retention_stats()
return jsonify(result)
@servers_bp.route('/metrics/cleanup', methods=['POST'])
@developer_required
def trigger_metrics_cleanup():
"""Trigger cleanup of old metrics data."""
result = ServerMetricsService.cleanup_old_metrics()
return jsonify({
'success': True,
'deleted': result
})
# ==================== Remote Terminal ====================
@servers_bp.route('/<server_id>/terminal', methods=['POST'])
@developer_required
def create_terminal_session(server_id):
"""Create a new terminal session on a remote server.
Body:
cols: Terminal width (default: 80)
rows: Terminal height (default: 24)
"""
user_id = get_jwt_identity()
data = request.get_json() or {}
cols = data.get('cols', 80)
rows = data.get('rows', 24)
result = TerminalService.create_session(
server_id=server_id,
user_id=user_id,
cols=cols,
rows=rows
)
if not result.get('success'):
return jsonify(result), 500 if result.get('code') != 'AGENT_OFFLINE' else 503
return jsonify(result)
@servers_bp.route('/terminal/<session_id>/input', methods=['POST'])
@developer_required
def terminal_input(session_id):
"""Send input to a terminal session.
Body:
data: Base64-encoded input data
"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get('data'):
return jsonify({'error': 'data is required'}), 400
result = TerminalService.send_input(
session_id=session_id,
data=data['data'],
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 400
return jsonify(result)
@servers_bp.route('/terminal/<session_id>/resize', methods=['POST'])
@developer_required
def terminal_resize(session_id):
"""Resize a terminal session.
Body:
cols: New terminal width
rows: New terminal height
"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get('cols') or not data.get('rows'):
return jsonify({'error': 'cols and rows are required'}), 400
result = TerminalService.resize_session(
session_id=session_id,
cols=data['cols'],
rows=data['rows'],
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 400
return jsonify(result)
@servers_bp.route('/terminal/<session_id>', methods=['DELETE'])
@developer_required
def close_terminal_session(session_id):
"""Close a terminal session."""
user_id = get_jwt_identity()
result = TerminalService.close_session(
session_id=session_id,
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 400
return jsonify(result)
@servers_bp.route('/terminal/sessions', methods=['GET'])
@jwt_required()
def list_terminal_sessions():
"""List all terminal sessions for the current user."""
user_id = get_jwt_identity()
sessions = TerminalService.get_user_sessions(user_id)
return jsonify({
'sessions': sessions,
'count': len(sessions)
})
# ==================== Installation Scripts ====================
def _get_scripts_dir():
"""Resolve the directory holding the fleet-agent installer scripts.
Every shipped layout has to land on a real directory here:
* git checkout / release tarball -> ``<tree root>/scripts``
* Docker image -> ``/app/scripts``, which exists only
because the Dockerfile explicitly copies the two installers there.
``SERVERKIT_SCRIPTS_DIR`` overrides the guess for layouts that split the
backend away from the rest of the tree.
"""
override = os.environ.get('SERVERKIT_SCRIPTS_DIR')
if override:
return override
# Go up from backend/app/api to backend, then to scripts
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
return os.path.join(base_dir, 'scripts')
def _load_installer(filename):
"""Read an agent installer off disk and stamp this panel's coordinates in.
Returns ``(content, None)`` on success or ``(None, error_response)`` when the
file is absent, so the two endpoints below cannot drift apart.
"""
script_path = os.path.join(_get_scripts_dir(), filename)
if not os.path.exists(script_path):
# A missing installer is a packaging fault on THIS panel, never a bad
# request. The old 404 asserted "no such URL", which is exactly what sent
# issue #101's reporter hunting for a wrong path -- they filed it as
# "incorrect paths in setup" -- when the file had simply never been
# shipped into the image. 503 says "this server is broken, not your URL",
# and the log line names the path actually searched so an operator can
# confirm it in one look instead of guessing.
current_app.logger.error(
'Agent installer %s is missing from this deployment (searched %s). '
'Fleet enrollment via /api/v1/servers/%s cannot work until it is '
'restored; see docs/ARCHITECTURE.md.',
filename, script_path, filename
)
return None, (jsonify({
'error': f'{filename} is not present on this ServerKit panel',
'detail': (
'The panel serves this installer from its own filesystem and the '
'file is missing, so this is a packaging/deployment problem with '
'this panel rather than a wrong URL. The panel log records the '
'exact path searched.'
),
'searched_path': script_path,
}), 503)
with open(script_path, 'r', encoding='utf-8') as f:
content = f.read()
# Replace placeholders with actual values
content = content.replace('https://your-serverkit.com', _get_external_base_url())
content = content.replace('jhd3197/serverkit-agent', AGENT_GITHUB_REPO)
return content, None
@servers_bp.route('/install.sh', methods=['GET'])
def get_install_script_linux():
"""
Get the Linux installation script.
This endpoint serves the bash installation script for installing
the ServerKit agent on Linux systems.
Usage (download-then-run, not `curl | bash`: a pipeline reports bash's exit
status, so a failed download would exit 0 having installed nothing):
curl -fsSL https://your-server/api/v1/servers/install.sh \\
-o /tmp/serverkit-agent-install.sh \\
&& sudo bash /tmp/serverkit-agent-install.sh \\
--token "YOUR_TOKEN" --server "https://your-server"
"""
content, error = _load_installer('install.sh')
if error is not None:
return error
# Inject the agent version the panel already resolved so enrollment does
# not depend on the installer rediscovering it via the GitHub API. Best
# effort: on failure the placeholder stays empty and the script falls
# back to its own GitHub discovery.
release = _get_latest_agent_release()
version = (release or {}).get('version') or ''
if version and all(c.isalnum() or c in '._-' for c in version):
content = content.replace(
'SERVERKIT_AGENT_VERSION=""',
f'SERVERKIT_AGENT_VERSION="{version}"',
1
)
return Response(
content,
mimetype='text/x-shellscript',
headers={
'Content-Disposition': 'inline; filename="install.sh"',
'Cache-Control': 'no-cache'
}
)
@servers_bp.route('/install.ps1', methods=['GET'])
def get_install_script_windows():
"""
Get the Windows installation script.
This endpoint serves the PowerShell installation script for installing
the ServerKit agent on Windows systems.
Usage:
irm https://your-server/api/v1/servers/install.ps1 | iex; \\
Install-ServerKitAgent -Token "YOUR_TOKEN" -Server "https://your-server"
"""
content, error = _load_installer('install.ps1')
if error is not None:
return error
return Response(
content,
mimetype='text/plain',
headers={
'Content-Disposition': 'inline; filename="install.ps1"',
'Cache-Control': 'no-cache'
}
)
@servers_bp.route('/install-instructions/<server_id>', methods=['GET'])
@jwt_required()
def get_install_instructions(server_id):
"""
Get installation instructions for a specific server.
Returns installation commands with the correct API endpoint. Registration
tokens are only shown when they are generated, so the UI supplies the token.
"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
# Check if server has a valid registration token
if not server.registration_token_hash:
return jsonify({
'error': 'No registration token available',
'message': 'Generate a new token using the regenerate-token endpoint'
}), 400
if server.registration_token_expires and server.registration_token_expires < datetime.utcnow():
return jsonify({
'error': 'Registration token expired',
'message': 'Generate a new token using the regenerate-token endpoint'
}), 400
# Get base URL
base_url = _get_external_base_url()
api_url = f"{base_url}/api/v1/servers"
return jsonify({
'linux': {
# Download-then-run rather than `curl … | sudo bash`: in a pipeline
# the shell reports bash's exit status, so a failed download exits 0
# and installs nothing without saying so (issue #101).
'one_liner': (
f'curl -fsSL {api_url}/install.sh -o /tmp/serverkit-agent-install.sh '
f'&& sudo bash /tmp/serverkit-agent-install.sh '
f'--token "YOUR_TOKEN" --server "{base_url}"'
),
'manual': [
f'# Download the script',
f'curl -fsSL {api_url}/install.sh -o install.sh',
f'chmod +x install.sh',
f'',
f'# Run installation',
f'sudo ./install.sh --token "YOUR_TOKEN" --server "{base_url}"'
]
},
'windows': {
'one_liner': f'irm {api_url}/install.ps1 | iex; Install-ServerKitAgent -Token "YOUR_TOKEN" -Server "{base_url}"',
'manual': [
f'# Download the script (run in PowerShell as Administrator)',
f'Invoke-WebRequest -Uri "{api_url}/install.ps1" -OutFile install.ps1',
f'',
f'# Run installation',
f'.\\install.ps1 -Token "YOUR_TOKEN" -Server "{base_url}"'
]
},
'note': 'Replace YOUR_TOKEN with the registration token shown in the UI'
})
# ==================== Agent Updates ====================
# Cache for GitHub releases to avoid rate limiting
_releases_cache = {
'data': None,
'expires': None
}
# The agent is built and released from its own repo, tagged plain `vX.Y.Z`. It
# used to ship from this monorepo under `agent-v*` tags; when it moved out,
# nothing here followed it, so this lookup searched a repo that has no agent
# releases at all -- /agent/version answered 503 for every agent polling for an
# update, and the served installer downloaded from a 404. Point both at the
# repo that actually publishes the binaries.
AGENT_GITHUB_REPO = os.environ.get('SERVERKIT_AGENT_GITHUB_REPO', 'jhd3197/serverkit-agent')
# Matches vX.Y.Z but not the malformed release tagged just "v" that sits in the
# agent repo's release list -- that one parses to an empty version and builds
# download URLs like .../releases/download/v/serverkit-agent--linux-amd64.tar.gz.
_AGENT_TAG_RE = re.compile(r'^v(\d[^\s]*)$')
def _get_latest_agent_release():
"""Fetch latest agent release from GitHub with caching"""
now = datetime.utcnow()
# Check cache
if _releases_cache['data'] and _releases_cache['expires'] and _releases_cache['expires'] > now:
return _releases_cache['data']
try:
# Every release in the agent repo is an agent release, but ask for 100
# anyway so a run of pre-release tags cannot hide the newest stable one.
response = requests.get(
f'https://api.github.com/repos/{AGENT_GITHUB_REPO}/releases',
headers={'Accept': 'application/vnd.github.v3+json'},
params={'per_page': 100},
timeout=10
)
response.raise_for_status()
releases = response.json()
# Find latest agent release
for release in releases:
match = _AGENT_TAG_RE.match(release.get('tag_name', ''))
if match:
version = match.group(1)
# Build assets map
assets = {}
for asset in release.get('assets', []):
name = asset['name']
if 'linux-amd64' in name:
assets['linux-amd64'] = asset['browser_download_url']
elif 'linux-arm64' in name:
assets['linux-arm64'] = asset['browser_download_url']
elif 'windows-amd64' in name:
assets['windows-amd64'] = asset['browser_download_url']
elif name == 'checksums.txt':
assets['checksums'] = asset['browser_download_url']
result = {
'version': version,
'tag': release['tag_name'],
'published_at': release['published_at'],
'release_url': release['html_url'],
'assets': assets,
'body': release.get('body', '')
}
# Cache for 5 minutes
_releases_cache['data'] = result
_releases_cache['expires'] = now + timedelta(minutes=5)
return result
return None
except Exception as e:
current_app.logger.error(f"Failed to fetch GitHub releases: {e}")
# Return cached data if available, even if expired
if _releases_cache['data']:
return _releases_cache['data']
return None
@servers_bp.route('/agent/version', methods=['GET'])
def get_agent_version():
"""
Get the latest agent version information.
This endpoint is called by agents to check for updates.
Returns version info and download URLs for all platforms.
"""
release = _get_latest_agent_release()
if not release:
return jsonify({
'error': 'Unable to fetch version information',
'message': 'GitHub API may be unavailable'
}), 503
# Get base URL for local downloads (fallback)
base_url = request.url_root.rstrip('/')
return jsonify({
'version': release['version'],
'published_at': release['published_at'],
'release_notes_url': release['release_url'],
'downloads': {
'linux-amd64': release['assets'].get('linux-amd64'),
'linux-arm64': release['assets'].get('linux-arm64'),
'windows-amd64': release['assets'].get('windows-amd64'),
},
'checksums_url': release['assets'].get('checksums'),
'update_available_message': f"A new version of ServerKit Agent is available: v{release['version']}"
})
@servers_bp.route('/agent/version/check', methods=['POST'])
def check_agent_version():
"""
Check if an agent needs to be updated.
Called by agents with their current version to check if an update is needed.
Request body:
{
"current_version": "1.0.0",
"os": "linux",
"arch": "amd64"
}
"""
data = request.get_json() or {}
current_version = data.get('current_version', '0.0.0')
agent_os = data.get('os', 'linux')
agent_arch = data.get('arch', 'amd64')
release = _get_latest_agent_release()
if not release:
return jsonify({
'update_available': False,
'error': 'Unable to check for updates'
})
latest_version = release['version']
# Compare versions (simple string comparison works for semver)
update_available = _compare_versions(current_version, latest_version) < 0
platform_key = f"{agent_os}-{agent_arch}"
download_url = release['assets'].get(platform_key)
return jsonify({
'update_available': update_available,
'current_version': current_version,
'latest_version': latest_version,
'download_url': download_url,
'checksums_url': release['assets'].get('checksums'),
'release_notes_url': release['release_url'],
'published_at': release['published_at']
})
def _compare_versions(v1, v2):
"""
Compare two semantic versions.
Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
"""
def parse_version(v):
# Remove leading 'v' if present
v = v.lstrip('v')
# Split by dots and convert to integers
parts = []
for part in v.split('.'):
# Handle pre-release versions like 1.0.0-beta.1
if '-' in part:
num, _ = part.split('-', 1)
parts.append(int(num) if num.isdigit() else 0)
else:
parts.append(int(part) if part.isdigit() else 0)
# Pad to at least 3 parts
while len(parts) < 3:
parts.append(0)
return parts
try:
p1 = parse_version(v1)
p2 = parse_version(v2)
for a, b in zip(p1, p2):
if a < b:
return -1
elif a > b:
return 1
return 0
except:
return 0
@servers_bp.route('/agent/download/<os_name>/<arch>', methods=['GET'])
def download_agent(os_name, arch):
"""
Redirect to the appropriate agent download.
This endpoint redirects to the GitHub release asset for the
requested OS and architecture.
"""
# Validate inputs
valid_os = ['linux', 'windows', 'darwin']
valid_arch = ['amd64', 'arm64']
if os_name not in valid_os:
return jsonify({'error': f'Invalid OS. Valid options: {valid_os}'}), 400
if arch not in valid_arch:
return jsonify({'error': f'Invalid architecture. Valid options: {valid_arch}'}), 400
release = _get_latest_agent_release()
if not release:
return jsonify({'error': 'Unable to fetch release information'}), 503
platform_key = f"{os_name}-{arch}"
download_url = release['assets'].get(platform_key)
if not download_url:
return jsonify({
'error': f'No release available for {os_name}-{arch}',
'available': list(release['assets'].keys())
}), 404
# Redirect to GitHub release
return redirect(download_url, code=302)
@servers_bp.route('/agent/checksums', methods=['GET'])
def get_agent_checksums():
"""
Get SHA256 checksums for agent binaries.
Returns the checksums.txt content from the latest release.
"""
release = _get_latest_agent_release()
if not release or not release['assets'].get('checksums'):
return jsonify({'error': 'Checksums not available'}), 404
try:
response = requests.get(release['assets']['checksums'], timeout=10)
response.raise_for_status()
return Response(
response.text,
mimetype='text/plain',
headers={
'Content-Disposition': 'inline; filename="checksums.txt"',
'X-Agent-Version': release['version']
}
)
except Exception as e:
current_app.logger.error(f"Failed to fetch checksums: {e}")
return jsonify({'error': 'Failed to fetch checksums'}), 503
@servers_bp.route('/<server_id>/agent/update', methods=['POST'])
@developer_required
def trigger_agent_update(server_id):
"""
Trigger an agent update on a specific server.
Sends a command to the agent to check for and install updates.
Developer role required — this replaces the agent binary and restarts the
service across the fleet, so it must not be reachable by read-only viewers
(every other state-changing server route is already @developer_required).
"""
server = Server.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
if not agent_registry.is_agent_connected(server_id):
return jsonify({
'success': False,
'error': 'Agent not connected'
}), 503
# Get latest version info
release = _get_latest_agent_release()
if not release:
return jsonify({
'success': False,
'error': 'Unable to fetch latest version'
}), 503
# Send update command to agent
result = dispatch_agent_command(
server_id=server_id,
action='agent:update',
params={
'version': release['version'],
'force': request.get_json().get('force', False) if request.get_json() else False
},
timeout=60.0 # Updates may take a while
)
return jsonify(result)
# ==================== Agent Fleet Management ====================
@servers_bp.route('/fleet/health', methods=['GET'])
@admin_required
def get_fleet_health():
"""Get aggregated health metrics for the agent fleet"""
return jsonify(fleet_service.get_fleet_health())
@servers_bp.route('/fleet/versions', methods=['GET'])
@admin_required
def list_agent_versions():
"""List all available agent versions"""
versions = AgentVersion.query.order_by(AgentVersion.version.desc()).all()
return jsonify([v.to_dict() for v in versions])
@servers_bp.route('/fleet/versions', methods=['POST'])
@admin_required
def add_agent_version():
"""Add a new available agent version"""
data = request.get_json()
if not data.get('version'):
return jsonify({'error': 'Version is required'}), 400
version = AgentVersion(
version=data['version'],
channel=data.get('channel', 'stable'),
min_panel_version=data.get('min_panel_version'),
max_panel_version=data.get('max_panel_version'),
release_notes=data.get('release_notes'),
assets=data.get('assets', {}),
published_at=datetime.fromisoformat(data['published_at']) if data.get('published_at') else datetime.utcnow()
)
db.session.add(version)
db.session.commit()
return jsonify(version.to_dict()), 201
@servers_bp.route('/fleet/upgrade', methods=['POST'])
@admin_required
def upgrade_fleet():
"""Trigger upgrade for selected servers or entire fleet"""
data = request.get_json()
server_ids = data.get('server_ids', [])
version_id = data.get('version_id')
user_id = get_jwt_identity()
if not server_ids:
# If no IDs provided, upgrade all online servers
servers = Server.query.filter_by(status='online').all()
server_ids = [s.id for s in servers]
if not server_ids:
return jsonify({'success': True, 'message': 'No online servers to upgrade'})
result = fleet_service.upgrade_servers(server_ids, version_id, user_id)
return jsonify(result)
@servers_bp.route('/fleet/rollout', methods=['POST'])
@admin_required
def start_staged_rollout():
"""Start a staged rollout"""
data = request.get_json()
group_id = data.get('group_id')
version_id = data.get('version_id')
batch_size = data.get('batch_size', 5)
delay_minutes = data.get('delay_minutes', 10)
strategy = data.get('strategy', 'staged')
server_ids = data.get('server_ids')
user_id = get_jwt_identity()
if not version_id:
return jsonify({'error': 'version_id is required'}), 400
result = fleet_service.staged_rollout(
group_id, version_id, batch_size, delay_minutes,
strategy, user_id, server_ids
)
return jsonify(result)
@servers_bp.route('/fleet/rollouts', methods=['GET'])
@admin_required
def list_rollouts():
"""List rollout history"""
status = request.args.get('status')
limit = request.args.get('limit', 20, type=int)
return jsonify(fleet_service.get_rollouts(status, limit))
@servers_bp.route('/fleet/rollouts/<rollout_id>', methods=['GET'])
@admin_required
def get_rollout(rollout_id):
"""Get a specific rollout"""
rollout = fleet_service.get_rollout(rollout_id)
if not rollout:
return jsonify({'error': 'Rollout not found'}), 404
return jsonify(rollout)
@servers_bp.route('/fleet/rollouts/<rollout_id>/cancel', methods=['POST'])
@admin_required
def cancel_rollout(rollout_id):
"""Cancel an active rollout"""
success = fleet_service.cancel_rollout(rollout_id)
if not success:
return jsonify({'error': 'Cannot cancel rollout (not running or not found)'}), 400
return jsonify({'success': True, 'message': 'Rollout cancelled'})
@servers_bp.route('/fleet/discovery', methods=['POST'])
@admin_required
def start_discovery_scan():
"""Start a network scan for new agents"""
duration = request.args.get('duration', 10, type=int)
agents = discovery_service.start_scan(duration)
return jsonify(agents)
@servers_bp.route('/fleet/discovery', methods=['GET'])
@admin_required
def get_discovered_agents():
"""Get results of last discovery scan"""
return jsonify(discovery_service.get_discovered_agents())
@servers_bp.route('/fleet/approve/<server_id>', methods=['POST'])
@admin_required
def approve_agent_registration(server_id):
"""Approve a pending agent registration"""
user_id = get_jwt_identity()
success = fleet_service.approve_registration(server_id, user_id)
if not success:
return jsonify({'error': 'Failed to approve registration'}), 400
return jsonify({'success': True, 'message': 'Registration approved'})
@servers_bp.route('/fleet/reject/<server_id>', methods=['POST'])
@admin_required
def reject_agent_registration(server_id):
"""Reject a pending agent registration"""
success = fleet_service.reject_registration(server_id)
if not success:
return jsonify({'error': 'Failed to reject registration'}), 400
return jsonify({'success': True, 'message': 'Registration rejected'})
@servers_bp.route('/fleet/commands/queued', methods=['GET'])
@admin_required
def get_queued_commands():
"""Get all pending queued commands"""
server_id = request.args.get('server_id')
commands = fleet_service.get_queued_commands(server_id)
return jsonify(commands)
@servers_bp.route('/fleet/commands/<command_id>/retry', methods=['POST'])
@admin_required
def retry_command(command_id):
"""Retry a failed command"""
result = fleet_service.retry_command(command_id)
if not result.get('success'):
return jsonify(result), 400
return jsonify(result)
@servers_bp.route('/fleet/diagnostics/<server_id>', methods=['GET'])
@admin_required
def get_server_diagnostics(server_id):
"""Get detailed connection diagnostics for a server"""
diagnostics = fleet_service.get_server_diagnostics(server_id)
if 'error' in diagnostics:
return jsonify(diagnostics), 404
return jsonify(diagnostics)
# ==================== Remote Cron Operations ====================
#
# Endpoints under /servers/<id>/cron/ proxy to the agent's cron:*
# command handlers. They mirror the panel-local /api/v1/cron/* surface
# (CronService) but target a remote host. The agent owns validation and
# crontab IO; this layer is just transport.
from app.services.remote_cron_service import RemoteCronService
def _agent_result(result, ok_status=200, missing_status=503):
"""Translate an agent_registry.send_command result into an HTTP
response. AGENT_OFFLINE → 503; other failures → 500; success → ok."""
if not result.get('success'):
code = 500
if result.get('code') == 'AGENT_OFFLINE':
code = missing_status
elif result.get('code') == 'PERMISSION_DENIED':
code = 403
return jsonify(result), code
# Unwrap the agent's data payload so callers see the same shape as
# the local CronService responses.
data = result.get('data')
if isinstance(data, dict):
return jsonify(data), ok_status
return jsonify({'data': data, 'success': True}), ok_status
@servers_bp.route('/<server_id>/cron/status', methods=['GET'])
@jwt_required()
def remote_cron_status(server_id):
user_id = get_jwt_identity()
result = RemoteCronService.status(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cron/jobs', methods=['GET'])
@jwt_required()
def remote_cron_list(server_id):
user_id = get_jwt_identity()
result = RemoteCronService.list_jobs(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cron/jobs', methods=['POST'])
@developer_required
def remote_cron_add(server_id):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
schedule = (data.get('schedule') or '').strip()
command = (data.get('command') or '').strip()
if not schedule:
return jsonify({'error': 'schedule is required'}), 400
if not command:
return jsonify({'error': 'command is required'}), 400
result = RemoteCronService.add_job(
server_id, schedule, command,
name=data.get('name'),
description=data.get('description'),
user_id=user_id,
)
return _agent_result(result, ok_status=201)
@servers_bp.route('/<server_id>/cron/jobs/<job_id>', methods=['DELETE'])
@developer_required
def remote_cron_remove(server_id, job_id):
user_id = get_jwt_identity()
result = RemoteCronService.remove_job(server_id, job_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cron/jobs/<job_id>/toggle', methods=['POST'])
@developer_required
def remote_cron_toggle(server_id, job_id):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
enabled = bool(data.get('enabled', True))
result = RemoteCronService.toggle_job(server_id, job_id, enabled, user_id=user_id)
return _agent_result(result)
# ==================== Remote Cloudflared Operations ====================
#
# Auth: the user runs `cloudflared tunnel login` once per server.
# That writes ~/.cloudflared/cert.pem (or /etc/cloudflared/cert.pem
# when run as root). The panel never sees a Cloudflare API token —
# every cloudflared:* action just shells out to the binary, which
# uses the cert for auth.
#
# /status surfaces both "binary installed" and "cert present" so the
# UI can show a "log in first" prompt before letting users hit the
# CRUD actions and getting confusing errors.
from app.services.remote_cloudflared_service import RemoteCloudflaredService
@servers_bp.route('/<server_id>/cloudflared/status', methods=['GET'])
@jwt_required()
def remote_cloudflared_status(server_id):
user_id = get_jwt_identity()
result = RemoteCloudflaredService.status(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cloudflared/login', methods=['POST'])
@developer_required
def remote_cloudflared_login(server_id):
"""Start `cloudflared tunnel login` on the agent. Returns
{job_id, channel}; the panel subscribes to the corresponding
Socket.IO room and renders the auth_url the agent surfaces."""
user_id = get_jwt_identity()
result = RemoteCloudflaredService.login(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cloudflared/tunnels', methods=['GET'])
@jwt_required()
def remote_cloudflared_list(server_id):
user_id = get_jwt_identity()
result = RemoteCloudflaredService.list_tunnels(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cloudflared/tunnels', methods=['POST'])
@developer_required
def remote_cloudflared_create(server_id):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'name is required'}), 400
result = RemoteCloudflaredService.create_tunnel(server_id, name, user_id=user_id)
return _agent_result(result, ok_status=201)
@servers_bp.route('/<server_id>/cloudflared/tunnels/<tunnel_ref>/route', methods=['POST'])
@developer_required
def remote_cloudflared_route(server_id, tunnel_ref):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
hostname = (data.get('hostname') or '').strip()
if not hostname:
return jsonify({'error': 'hostname is required'}), 400
result = RemoteCloudflaredService.route_tunnel(
server_id, tunnel_ref, hostname, user_id=user_id,
)
return _agent_result(result)
@servers_bp.route('/<server_id>/cloudflared/tunnels/<tunnel_ref>', methods=['DELETE'])
@developer_required
def remote_cloudflared_delete(server_id, tunnel_ref):
user_id = get_jwt_identity()
result = RemoteCloudflaredService.delete_tunnel(server_id, tunnel_ref, user_id=user_id)
return _agent_result(result)
# ==================== Remote Capability Refresh ====================
#
# Lets the panel ask the agent to re-run its capability probe on
# demand — useful after the user installs a runtime, sudoers entry,
# or new package manager and wants new tabs to light up without an
# agent service restart. The agent's response is the freshly merged
# capabilities map; the agent additionally pushes via the persistent
# Capabilities message so all panel listeners stay in sync.
@servers_bp.route('/<server_id>/refresh-capabilities', methods=['POST'])
@developer_required
def remote_refresh_capabilities(server_id):
user_id = get_jwt_identity()
result = dispatch_agent_command(
server_id=server_id, action='agent:recapabilities',
params={}, user_id=user_id, timeout=20.0,
)
return _agent_result(result)
# ==================== Remote Packages ====================
#
# Exposes the agent's packages:* surface for the Packages tab.
# Long-running operations (install/upgrade) return {job_id, channel};
# the frontend subscribes to Socket.IO room
# server_<id>_<channel> for live progress events.
from app.services.remote_packages_service import RemotePackagesService
@servers_bp.route('/<server_id>/packages', methods=['GET'])
@jwt_required()
def remote_packages_list(server_id):
user_id = get_jwt_identity()
result = RemotePackagesService.list_installed(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/search', methods=['GET'])
@jwt_required()
def remote_packages_search(server_id):
user_id = get_jwt_identity()
query = (request.args.get('q') or '').strip()
if not query:
return jsonify({'error': 'q is required'}), 400
try:
limit = int(request.args.get('limit', 100))
except (TypeError, ValueError):
limit = 100
result = RemotePackagesService.search(server_id, query, limit=limit, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/info/<name>', methods=['GET'])
@jwt_required()
def remote_packages_info(server_id, name):
user_id = get_jwt_identity()
result = RemotePackagesService.info(server_id, name, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/update-cache', methods=['POST'])
@developer_required
def remote_packages_update_cache(server_id):
user_id = get_jwt_identity()
result = RemotePackagesService.update_cache(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/install', methods=['POST'])
@developer_required
def remote_packages_install(server_id):
"""Streaming install. Body: {names: ['nginx', 'redis-server']}.
Returns {job_id, channel} immediately; the panel subscribes to the
matching Socket.IO room for live install output."""
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
names = data.get('names') or []
if isinstance(names, str):
names = [names]
if not names:
return jsonify({'error': 'names is required'}), 400
result = RemotePackagesService.install_async(server_id, names, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/remove', methods=['POST'])
@developer_required
def remote_packages_remove(server_id):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'name is required'}), 400
result = RemotePackagesService.remove(server_id, name, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/upgrade', methods=['POST'])
@developer_required
def remote_packages_upgrade(server_id):
"""Streaming upgrade. Body: {all: bool, names?: [...]}. Returns
{job_id, channel}."""
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
names = data.get('names') or []
all_pkgs = bool(data.get('all', False))
if not all_pkgs and not names:
return jsonify({'error': 'either all=true or names=[...] required'}), 400
result = RemotePackagesService.upgrade(
server_id,
names=names if names else None,
all_packages=all_pkgs,
user_id=user_id,
)
return _agent_result(result)
# ==================== Remote Services (systemd) ====================
from app.services.remote_systemd_service import RemoteSystemdService
@servers_bp.route('/<server_id>/services', methods=['GET'])
@jwt_required()
def remote_services_list(server_id):
user_id = get_jwt_identity()
state = request.args.get('state')
type_ = request.args.get('type', 'service')
result = RemoteSystemdService.list_units(server_id, state=state, type_=type_, user_id=user_id)
return _agent_result(result)
# Static-suffix routes must come before the generic <unit>/<action>
# route so Werkzeug picks the right matcher in registration order.
@servers_bp.route('/<server_id>/services/daemon-reload', methods=['POST'])
@developer_required
def remote_services_daemon_reload(server_id):
user_id = get_jwt_identity()
result = RemoteSystemdService.daemon_reload(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/services/<unit>/logs', methods=['GET'])
@jwt_required()
def remote_services_logs(server_id, unit):
user_id = get_jwt_identity()
try:
lines = int(request.args.get('lines', 200))
except (TypeError, ValueError):
lines = 200
result = RemoteSystemdService.logs(server_id, unit, lines=lines, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/services/<unit>', methods=['GET'])
@jwt_required()
def remote_services_status(server_id, unit):
user_id = get_jwt_identity()
result = RemoteSystemdService.status(server_id, unit, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/services/<unit>/<action>', methods=['POST'])
@developer_required
def remote_services_control(server_id, unit, action):
user_id = get_jwt_identity()
result = RemoteSystemdService.control(server_id, unit, action, user_id=user_id)
return _agent_result(result)
# ==================== Remote Runtimes (pyenv) ====================
#
# Manages Python versions via pyenv (Linux) and pyenv-win (Windows).
# Bootstrap and install return {job_id, channel} for streaming
# progress; everything else is a synchronous round-trip.
from app.services.remote_runtimes_service import RemoteRuntimesService
@servers_bp.route('/<server_id>/runtimes', methods=['GET'])
@jwt_required()
def remote_runtimes_list(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.list_state(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/pyenv/bootstrap', methods=['POST'])
@developer_required
def remote_runtimes_pyenv_bootstrap(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.pyenv_bootstrap(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python', methods=['GET'])
@jwt_required()
def remote_runtimes_python_installed(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.python_installed(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/available', methods=['GET'])
@jwt_required()
def remote_runtimes_python_available(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.python_available(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/current', methods=['GET'])
@jwt_required()
def remote_runtimes_python_current(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.python_current(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/install', methods=['POST'])
@developer_required
def remote_runtimes_python_install(server_id):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
version = (data.get('version') or '').strip()
if not version:
return jsonify({'error': 'version is required'}), 400
result = RemoteRuntimesService.python_install(server_id, version, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/uninstall', methods=['POST'])
@developer_required
def remote_runtimes_python_uninstall(server_id):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
version = (data.get('version') or '').strip()
if not version:
return jsonify({'error': 'version is required'}), 400
result = RemoteRuntimesService.python_uninstall(server_id, version, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/global', methods=['POST'])
@developer_required
def remote_runtimes_python_set_global(server_id):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
version = (data.get('version') or '').strip()
if not version:
return jsonify({'error': 'version is required'}), 400
result = RemoteRuntimesService.python_set_global(server_id, version, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/local', methods=['POST'])
@developer_required
def remote_runtimes_python_set_local(server_id):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
version = (data.get('version') or '').strip()
dir_path = (data.get('dir') or '').strip()
if not version or not dir_path:
return jsonify({'error': 'version and dir are required'}), 400
result = RemoteRuntimesService.python_set_local(server_id, version, dir_path, user_id=user_id)
return _agent_result(result)
# ==================== Remote File Operations ====================
#
# Endpoints under /servers/<id>/files/ proxy to the agent's file:*
# command handlers. Phase 3b ships browse/read/write only — the broader
# verb set (delete, mkdir, rename, copy, chmod, search, disk_usage)
# requires new agent handlers and lands in a follow-up. The agent owns
# allowed_paths enforcement; the panel just transports.
from app.services.remote_file_service import RemoteFileService
@servers_bp.route('/<server_id>/files/allowed-paths', methods=['GET'])
@jwt_required()
def remote_file_allowed_paths(server_id):
"""Return the file roots the agent advertised on connect. Lets the
file manager seed the browse picker without guessing or hardcoding."""
paths = RemoteFileService.get_allowed_paths(server_id)
return jsonify({'allowed_paths': paths})
@servers_bp.route('/<server_id>/files/browse', methods=['GET'])
@jwt_required()
def remote_file_browse(server_id):
user_id = get_jwt_identity()
path = request.args.get('path')
if not path:
return jsonify({'error': 'path is required'}), 400
result = RemoteFileService.list_directory(server_id, path, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/files/read', methods=['GET'])
@jwt_required()
def remote_file_read(server_id):
user_id = get_jwt_identity()
path = request.args.get('path')
if not path:
return jsonify({'error': 'path is required'}), 400
result = RemoteFileService.read_file(server_id, path, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/files/write', methods=['POST'])
@developer_required
def remote_file_write(server_id):
user_id = get_jwt_identity()
data = request.get_json(silent=True) or {}
path = (data.get('path') or '').strip()
content = data.get('content')
if not path:
return jsonify({'error': 'path is required'}), 400
if content is None:
return jsonify({'error': 'content is required'}), 400
result = RemoteFileService.write_file(server_id, path, content, user_id=user_id)
return _agent_result(result)