mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-31 09:06:52 +09:00
- config: is_insecure_secret() rejects any dev/placeholder key (dev-*, jwt-secret-key*, *change-in-production*, *change-this*, empty), not just the two exact default strings — a variant like `dev-jwt-...` can no longer boot in production. Used in both ProductionConfig.__init__ and init_app. - systemd unit: bind gunicorn to 127.0.0.1 by default so the raw API port is not world-reachable (host nginx already fronts :80/:443). Override with SERVERKIT_BIND_HOST=0.0.0.0 for setups that front it differently. - cli break-glass: mint the token with a string identity; flask-jwt-extended rejects a non-string `sub` at decode time with a 422, which broke every CLI call that authenticated against the panel API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
4.9 KiB
Python
154 lines
4.9 KiB
Python
"""Helpers for CLI commands that talk to the local panel API.
|
|
|
|
Two pieces:
|
|
|
|
* ``mint_breakglass_token()`` — runs in-process (create_app + app_context) and
|
|
mints a short-lived JWT for the first active admin user. Possession of
|
|
root/shell on the box already implies full control of the panel, so this is
|
|
a convenience, not an escalation; every mint is audit-logged.
|
|
* ``ApiClient`` — a tiny requests wrapper pointed at the local panel
|
|
(``http://127.0.0.1:<port>/api/v1``) with a bearer token, short timeouts and
|
|
a helpful message when the panel isn't running.
|
|
"""
|
|
import os
|
|
from datetime import timedelta
|
|
|
|
DEFAULT_PORT = 5000
|
|
DEFAULT_TIMEOUT = 5
|
|
|
|
BREAKGLASS_TTL = timedelta(minutes=10)
|
|
BREAKGLASS_AUDIT_ACTION = 'cli.breakglass'
|
|
|
|
|
|
class CliApiError(Exception):
|
|
"""Raised for any CLI-facing API failure; str(e) is user-presentable."""
|
|
|
|
|
|
def resolve_port():
|
|
"""Panel port: PORT env first (run.py contract), then SERVERKIT_PORT,
|
|
then the config default 5000."""
|
|
for var in ('PORT', 'SERVERKIT_PORT'):
|
|
raw = os.environ.get(var, '').strip()
|
|
if raw:
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
continue
|
|
return DEFAULT_PORT
|
|
|
|
|
|
def base_url(port=None):
|
|
return f"http://127.0.0.1:{port or resolve_port()}/api/v1"
|
|
|
|
|
|
def find_breakglass_admin():
|
|
"""First active admin user, or None. Requires an app context."""
|
|
from app.models import User
|
|
return (
|
|
User.query
|
|
.filter_by(role='admin', is_active=True)
|
|
.order_by(User.id.asc())
|
|
.first()
|
|
)
|
|
|
|
|
|
def mint_breakglass_token(app=None):
|
|
"""Mint a short-lived admin access token for local CLI use.
|
|
|
|
Returns ``(token, user)``. Raises ``CliApiError`` when no active admin
|
|
exists. Creates its own app/app-context unless one is supplied or already
|
|
active.
|
|
"""
|
|
from flask import current_app
|
|
|
|
if app is None:
|
|
try:
|
|
current_app._get_current_object() # noqa: SLF001 - presence probe
|
|
return _mint_in_context()
|
|
except RuntimeError:
|
|
from app import create_app
|
|
app = create_app()
|
|
|
|
with app.app_context():
|
|
return _mint_in_context()
|
|
|
|
|
|
def _mint_in_context():
|
|
from flask_jwt_extended import create_access_token
|
|
|
|
user = find_breakglass_admin()
|
|
if user is None:
|
|
raise CliApiError('No active admin user found — create one with "serverkit create-admin".')
|
|
|
|
# Identity must be a string — flask-jwt-extended rejects a non-string
|
|
# `sub` at decode time with a 422, which otherwise breaks every CLI call
|
|
# that hits the panel API with a break-glass token.
|
|
token = create_access_token(identity=str(user.id), expires_delta=BREAKGLASS_TTL)
|
|
|
|
try:
|
|
from app.services.audit_service import AuditService
|
|
AuditService.log(
|
|
action=BREAKGLASS_AUDIT_ACTION,
|
|
user_id=user.id,
|
|
target_type='user',
|
|
target_id=user.id,
|
|
details={'source': 'cli', 'ttl_minutes': int(BREAKGLASS_TTL.total_seconds() // 60)},
|
|
)
|
|
except Exception: # noqa: BLE001 - auditing must never block break-glass access
|
|
pass
|
|
|
|
return token, user
|
|
|
|
|
|
class ApiClient:
|
|
"""Minimal JSON client for the local panel API."""
|
|
|
|
def __init__(self, token=None, port=None, session=None, timeout=DEFAULT_TIMEOUT):
|
|
import requests
|
|
|
|
self.base = base_url(port)
|
|
self.token = token
|
|
self.timeout = timeout
|
|
self.session = session or requests.Session()
|
|
|
|
def _headers(self):
|
|
headers = {'Accept': 'application/json'}
|
|
if self.token:
|
|
headers['Authorization'] = f'Bearer {self.token}'
|
|
return headers
|
|
|
|
def request(self, method, path, json_body=None):
|
|
"""Issue a request; returns parsed JSON. Raises CliApiError on failure."""
|
|
import requests
|
|
|
|
url = self.base + path
|
|
try:
|
|
resp = self.session.request(
|
|
method, url, json=json_body, headers=self._headers(), timeout=self.timeout
|
|
)
|
|
except requests.exceptions.ConnectionError:
|
|
raise CliApiError(
|
|
'Could not reach the panel API at '
|
|
f'{self.base} — panel not running? Try: systemctl status serverkit'
|
|
)
|
|
except requests.exceptions.Timeout:
|
|
raise CliApiError(f'Panel API timed out after {self.timeout}s ({url}).')
|
|
except requests.exceptions.RequestException as exc:
|
|
raise CliApiError(f'Panel API request failed: {exc}')
|
|
|
|
try:
|
|
data = resp.json()
|
|
except ValueError:
|
|
data = {}
|
|
|
|
if resp.status_code >= 400:
|
|
message = data.get('error') or data.get('message') or f'HTTP {resp.status_code}'
|
|
raise CliApiError(f'{message} ({method} {path})')
|
|
return data
|
|
|
|
def get(self, path):
|
|
return self.request('GET', path)
|
|
|
|
def post(self, path, json_body=None):
|
|
return self.request('POST', path, json_body=json_body)
|