Files
ServerKit/backend/app/api/pairing.py
T
Juan Denis e5a90a3085 Add short-code agent pairing flow
Implements a RustDesk-style short-code pairing feature across agent, backend, frontend and docs.

Key changes:
- Agent CLI: adds `serverkit-agent pair` command (agent/cmd/agent/pair.go) that generates/loads an Ed25519 keypair, enrolls with the panel, shows a rotating pair code, and saves credentials on claim. Adds passphrase marker handling and interactive/headless modes.
- Agent internals: new pairing package with client and keypair (agent/internal/pairing/*.go). Keypair files are encrypted using new exported config EncryptBytes/DecryptBytes utilities and MachineID() helper in agent/internal/config/config.go. go.mod/go.sum updated (Go version and x/term, x/sys bumps).
- Backend API: new pairing blueprint exposing endpoints for enroll, poll, code refresh/freeze, lookup and claim (backend/app/api/pairing.py). Adds PendingAgent model (backend/app/models/pending_agent.py) to track enrollments and their lifecycle.
- Backend service: pairing_service (backend/app/services/pairing_service.py) implements enrollment, rotation, long-polling, claim handling, credential minting and one-shot delivery. Adds a background pairing pruner thread to remove expired pending enrollments (registered in backend/app/__init__.py).
- Frontend: add API client support and page changes to integrate pairing flow (frontend/src/services/api/pairing.js, modifications to Servers.jsx and api index).
- Docs: adds pairing.md explaining the pairing flow.

Why: provide a secure, user-friendly mechanism for operators to claim new agents via short rotating codes and a passphrase, simplifying server onboarding while protecting credentials and preventing brute-force attempts.
2026-04-29 03:08:21 -04:00

183 lines
5.6 KiB
Python

"""
Pairing API — short-code RustDesk-style agent pairing.
Endpoints:
POST /api/v1/pairing/enroll (no auth, IP rate-limited)
POST /api/v1/pairing/code/refresh (enrollment auth)
POST /api/v1/pairing/code/freeze (enrollment auth)
GET /api/v1/pairing/poll (enrollment auth, long-poll)
POST /api/v1/pairing/claim (operator JWT)
GET /api/v1/pairing/lookup (operator JWT — pre-flight code check)
"""
import time
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from app import limiter
from app.services import pairing_service
from app.services.pairing_service import (
PairingError,
InvalidEnrollmentError,
InvalidPairCredentialsError,
LockoutError,
AlreadyClaimedError,
ExpiredEnrollmentError,
)
pairing_bp = Blueprint('pairing', __name__)
def _enrollment_creds():
"""Read enrollment_id + enrollment_secret from headers or JSON body."""
enrollment_id = request.headers.get('X-Enrollment-Id')
enrollment_secret = request.headers.get('X-Enrollment-Secret')
if not enrollment_id and request.is_json:
body = request.get_json(silent=True) or {}
enrollment_id = body.get('enrollment_id')
enrollment_secret = body.get('enrollment_secret')
return enrollment_id, enrollment_secret
def _error_response(exc: PairingError):
return jsonify({'error': str(exc)}), getattr(exc, 'status_code', 400)
# ==================== Enroll ====================
@pairing_bp.route('/enroll', methods=['POST'])
@limiter.limit("10 per minute")
def enroll():
data = request.get_json(silent=True) or {}
pubkey = data.get('pubkey')
passphrase = data.get('passphrase')
machine_id = data.get('machine_id')
system_info = data.get('system_info') or {}
if not pubkey or not passphrase:
return jsonify({'error': 'pubkey and passphrase are required'}), 400
try:
result = pairing_service.enroll(
pubkey_hex=pubkey,
passphrase=passphrase,
machine_id=machine_id,
system_info=system_info,
)
except PairingError as exc:
return _error_response(exc)
return jsonify(result), 201
# ==================== Code refresh / freeze ====================
@pairing_bp.route('/code/refresh', methods=['POST'])
@limiter.limit("30 per minute")
def code_refresh():
enrollment_id, enrollment_secret = _enrollment_creds()
body = request.get_json(silent=True) or {}
force = bool(body.get('force', False))
try:
result = pairing_service.rotate_code(enrollment_id, enrollment_secret, force=force)
except PairingError as exc:
return _error_response(exc)
return jsonify(result), 200
@pairing_bp.route('/code/freeze', methods=['POST'])
@limiter.limit("30 per minute")
def code_freeze():
enrollment_id, enrollment_secret = _enrollment_creds()
body = request.get_json(silent=True) or {}
frozen = bool(body.get('frozen', True))
try:
result = pairing_service.set_freeze(enrollment_id, enrollment_secret, frozen=frozen)
except PairingError as exc:
return _error_response(exc)
return jsonify(result), 200
# ==================== Long-poll ====================
@pairing_bp.route('/poll', methods=['GET', 'POST'])
@limiter.limit("120 per minute")
def poll():
"""
Long-poll for claim status. Up to ~25 seconds of waiting per request.
Agent should reconnect immediately after each response.
"""
enrollment_id, enrollment_secret = _enrollment_creds()
if not enrollment_id or not enrollment_secret:
return jsonify({'error': 'Missing enrollment credentials'}), 401
deadline = time.time() + 25.0 # leave headroom under typical proxy timeouts
poll_interval = 1.0
while True:
try:
result = pairing_service.poll(enrollment_id, enrollment_secret)
except PairingError as exc:
return _error_response(exc)
if result.get('status') == 'claimed':
return jsonify(result), 200
if time.time() >= deadline:
return jsonify(result), 200 # status: pending
time.sleep(poll_interval)
# ==================== Operator claim ====================
@pairing_bp.route('/lookup', methods=['POST'])
@jwt_required()
@limiter.limit("20 per minute")
def lookup():
"""Pre-flight: confirm a code exists (without revealing details)."""
data = request.get_json(silent=True) or {}
code = data.get('code', '')
pending = pairing_service.find_by_code(code)
if not pending:
return jsonify({'found': False}), 404
return jsonify({
'found': True,
'pubkey_fpr': pending.pubkey_fpr,
'system_info': pending.system_info or {},
'machine_id': pending.machine_id,
}), 200
@pairing_bp.route('/claim', methods=['POST'])
@jwt_required()
@limiter.limit("5 per 10 minute")
def claim():
user_id = int(get_jwt_identity())
data = request.get_json(silent=True) or {}
code = data.get('code')
passphrase = data.get('passphrase')
name = data.get('name')
group_id = data.get('group_id')
if not code or not passphrase:
return jsonify({'error': 'code and passphrase are required'}), 400
try:
result = pairing_service.claim(
operator_user_id=user_id,
code=code,
passphrase=passphrase,
name=name,
group_id=group_id,
ip_address=request.remote_addr,
)
except PairingError as exc:
return _error_response(exc)
return jsonify(result), 201