Add API v2 for managing WireGuard peers: create, update, and delete functionality

This commit is contained in:
Eduardo Silva
2026-02-11 15:29:52 -03:00
parent a4945b3c2b
commit dc7fee2de8
3 changed files with 451 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ from api.views import api_instance_info, api_peer_invite, api_peer_list, cron_ch
wireguard_status, cron_refresh_wireguard_status_cache, cron_calculate_peer_schedules, cron_peer_scheduler
urlpatterns = [
path('v2/', include('api_v2.urls_api')),
path('cluster/', include('cluster.urls_api')),
path('routerfleet_get_user_token/', routerfleet_get_user_token, name='routerfleet_get_user_token'),
path('wireguard_status/', wireguard_status, name='api_wireguard_status'),

View File

@@ -1,3 +1,8 @@
from django.urls import path
from .views_api import api_v2_manage_peer
urlpatterns = [
path('manage_peer/', api_v2_manage_peer, name='api_v2_manage_peer'),
]

445
api_v2/views_api.py Normal file
View File

@@ -0,0 +1,445 @@
import ipaddress
import json
from functools import wraps
from typing import List, Optional, Tuple
from django.db import transaction
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from routing_templates.models import RoutingTemplate
from wireguard.models import Peer, PeerAllowedIP, WireGuardInstance
from wireguard_peer.functions import func_create_new_peer
from wireguard_tools.functions import func_reload_wireguard_interface
from wireguard_tools.views import export_wireguard_configuration
from .models import ApiKey
def api_doc(*, summary: str, auth: str, params: list, returns: list, examples: Optional[dict] = None):
def decorator(view_func):
view_func.api_doc = {
"summary": summary,
"auth": auth,
"params": params,
"returns": returns,
"examples": examples or {},
}
@wraps(view_func)
def wrapper(*args, **kwargs):
return view_func(*args, **kwargs)
wrapper.__dict__.update(getattr(view_func, "__dict__", {}))
wrapper.api_doc = view_func.api_doc
return wrapper
return decorator
def validate_api_key(request, wireguard_instance: WireGuardInstance):
"""
Validates the API key and checks whether it can manage the given instance.
Rule:
- If ApiKey.allowed_instances is empty => key can manage any instance.
- Otherwise, wireguard_instance must be included in ApiKey.allowed_instances.
"""
token = request.headers.get("token")
if not token:
return None, "Missing API token."
try:
api_key = ApiKey.objects.get(token=token, enabled=True)
except ApiKey.DoesNotExist:
return None, "Invalid API key."
if api_key.allowed_instances.exists():
if not api_key.allowed_instances.filter(uuid=wireguard_instance.uuid).exists():
return None, "This API key is not allowed to manage the requested instance."
return api_key, ""
def _parse_ipv4_cidrs(value) -> Tuple[Optional[List[Tuple[str, int]]], Optional[str]]:
"""
Parses a list of CIDR strings into [(allowed_ip, netmask), ...].
Example:
["10.0.0.0/24"] => [("10.0.0.0", 24)]
"""
if value is None:
return None, None
if not isinstance(value, list):
return None, "Invalid payload: networks must be a list of CIDR strings."
pairs: List[Tuple[str, int]] = []
for item in value:
if not isinstance(item, str) or not item.strip():
return None, "Invalid payload: each network must be a non-empty string."
try:
network = ipaddress.ip_network(item.strip(), strict=False)
except Exception:
return None, f"Invalid network: {item}"
if network.version != 4:
return None, f"Only IPv4 networks are supported: {item}"
pairs.append((str(network.network_address), int(network.prefixlen)))
# De-duplicate while preserving order
seen = set()
unique: List[Tuple[str, int]] = []
for pair in pairs:
if pair not in seen:
seen.add(pair)
unique.append(pair)
return unique, None
def _sync_allowed_ips(peer: Peer, desired_pairs: Optional[List[Tuple[str, int]]], *, config_file: str) -> None:
"""
Sync PeerAllowedIP rows for a peer/config_file, only for priority >= 1.
- Adds missing (allowed_ip, netmask)
- Removes extra (allowed_ip, netmask)
- Never touches priority=0 entries (peer main address)
"""
if desired_pairs is None:
return
current_qs = PeerAllowedIP.objects.filter(peer=peer, config_file=config_file, priority__gte=1)
current_pairs = set(current_qs.values_list("allowed_ip", "netmask"))
desired_set = set(desired_pairs)
pairs_to_remove = current_pairs - desired_set
pairs_to_add = desired_set - current_pairs
for allowed_ip, netmask in pairs_to_remove:
PeerAllowedIP.objects.filter(
peer=peer,
config_file=config_file,
priority__gte=1,
allowed_ip=allowed_ip,
netmask=netmask,
).delete()
for allowed_ip, netmask in pairs_to_add:
PeerAllowedIP.objects.create(
peer=peer,
config_file=config_file,
priority=1,
allowed_ip=allowed_ip,
netmask=int(netmask),
)
def _apply_reload_or_pending_changes(*, wireguard_instance: WireGuardInstance, skip_reload: bool) -> Tuple[bool, str]:
"""
Applies changes after create/update/delete.
If skip_reload=True:
- sets pending_changes=True
Else:
- exports WireGuard configuration
- reloads the interface
"""
if skip_reload:
wireguard_instance.pending_changes = True
wireguard_instance.save(update_fields=["pending_changes", "updated"])
return True, "Changes saved. Reload skipped (pending_changes set to True)."
export_wireguard_configuration(wireguard_instance)
success, message = func_reload_wireguard_interface(wireguard_instance)
return bool(success), str(message or "")
def _get_wireguard_instance(instance_name: str) -> Optional[WireGuardInstance]:
return
@api_doc(
summary="Create / Update / Delete a WireGuard peer (and optionally reload the interface)",
auth="Header token: <ApiKey.token>",
params=[
{"name": "instance", "in": "json", "type": "string", "required": True, "example": "wg0",
"description": "Target instance name in the format wg{instance_id} (e.g. wg0, wg1)."},
{"name": "skip_reload", "in": "json", "type": "boolean", "required": False, "example": True,
"description": "If true, does not reload the interface and only sets wireguard_instance.pending_changes=True."},
{"name": "peer_uuid", "in": "json", "type": "string", "required": False,
"description": "Peer UUID used to select the peer for update/delete."},
{"name": "peer_public_key", "in": "json", "type": "string", "required": False,
"description": "Peer public key used to select the peer for update/delete."},
{"name": "routing_template_uuid", "in": "json", "type": "string", "required": False,
"description": "Routing template UUID (optional). Must belong to the same WireGuard instance."},
{"name": "announced_networks", "in": "json", "type": "list[string]", "required": False,
"example": ["10.10.0.0/24"], "description": "Server announced networks (priority>=1). Will be synced."},
{"name": "client_routes", "in": "json", "type": "list[string]", "required": False,
"example": ["192.168.1.0/24"],
"description": "Client routes (priority>=1). Will be synced. Not allowed when allow_peer_custom_routes=True."},
{"name": "public_key", "in": "json", "type": "string", "required": False,
"description": "Peer public key (create/update)."},
{"name": "pre_shared_key", "in": "json", "type": "string", "required": False,
"description": "Peer pre-shared key (create/update)."},
{"name": "private_key", "in": "json", "type": "string", "required": False,
"description": "Peer private key (create/update). Optional."},
{"name": "persistent_keepalive", "in": "json", "type": "integer", "required": False,
"description": "Persistent keepalive (create/update)."},
{"name": "suspended", "in": "json", "type": "boolean", "required": False,
"description": "Suspend/unsuspend a peer (update)."},
{"name": "suspend_reason", "in": "json", "type": "string", "required": False,
"description": "Suspend reason (update). Can be cleared by sending null/empty string."},
],
returns=[
{"status": 200, "body": {"status": "success", "message": "Peer updated successfully.", "peer_uuid": "...", "public_key": "...", "reload": {"success": True, "message": "..."}}},
{"status": 201, "body": {"status": "success", "message": "Peer created successfully.", "peer_uuid": "...", "public_key": "...", "reload": {"success": True, "message": "..."}}},
{"status": 400, "body": {"status": "error", "error_message": "Invalid payload: ..."}},
{"status": 403, "body": {"status": "error", "error_message": "Invalid API key."}},
{"status": 405, "body": {"status": "error", "error_message": "Method not allowed."}},
],
examples={
"create_skip_reload": {
"method": "POST",
"json": {
"instance": "wg0",
"name": "John",
"routing_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"announced_networks": ["10.10.0.0/24"],
"skip_reload": True
}
},
"update_with_reload": {
"method": "PUT",
"json": {
"instance": "wg0",
"peer_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"suspended": True,
"suspend_reason": "Maintenance window",
"skip_reload": False
}
}
}
)
@csrf_exempt
def api_v2_manage_peer(request):
if request.method not in ("POST", "PUT", "DELETE"):
return JsonResponse({"status": "error", "error_message": "Method not allowed."}, status=405)
try:
payload = json.loads(request.body.decode("utf-8")) if request.body else {}
except Exception:
return JsonResponse({"status": "error", "error_message": "Invalid JSON body."}, status=400)
try:
wireguard_instance = WireGuardInstance.objects.get(instance_id=int(payload.get("instance",).replace("wg", "")))
except:
wireguard_instance = None
if not wireguard_instance:
return JsonResponse({"status": "error", "error_message": "Invalid or missing WireGuard instance."}, status=400)
api_key, api_error = validate_api_key(request, wireguard_instance)
if not api_key:
return JsonResponse({"status": "error", "error_message": api_error}, status=403)
skip_reload = bool(payload.get("skip_reload", False))
# Routing template (optional) - must belong to the same instance
routing_template_uuid = payload.get("routing_template_uuid")
routing_template = None
if routing_template_uuid:
routing_template = RoutingTemplate.objects.filter(uuid=routing_template_uuid).first()
if not routing_template:
return JsonResponse({"status": "error", "error_message": "Invalid routing_template_uuid."}, status=400)
if routing_template.wireguard_instance_id != wireguard_instance.uuid:
return JsonResponse(
{"status": "error", "error_message": "routing_template_uuid does not belong to the requested instance."},
status=400
)
# Parse networks (only if provided)
announced_pairs, announced_error = _parse_ipv4_cidrs(payload.get("announced_networks"))
if announced_error:
return JsonResponse({"status": "error", "error_message": announced_error}, status=400)
client_route_pairs, client_route_error = _parse_ipv4_cidrs(payload.get("client_routes"))
if client_route_error:
return JsonResponse({"status": "error", "error_message": client_route_error}, status=400)
with transaction.atomic():
# CREATE
if request.method == "POST":
peer_name = payload.get("name", "") or ""
peer_public_key = payload.get("public_key") or None
peer_pre_shared_key = payload.get("pre_shared_key") or None
peer_private_key = payload.get("private_key") or None # optional
peer_persistent_keepalive = payload.get("persistent_keepalive")
if peer_persistent_keepalive is not None:
try:
peer_persistent_keepalive = int(peer_persistent_keepalive)
except Exception:
return JsonResponse({"status": "error", "error_message": "Invalid persistent_keepalive."}, status=400)
peer_allowed_ip = payload.get("allowed_ip") or None
peer_allowed_ip_netmask = payload.get("allowed_ip_netmask")
if peer_allowed_ip_netmask is not None:
try:
peer_allowed_ip_netmask = int(peer_allowed_ip_netmask)
except Exception:
return JsonResponse({"status": "error", "error_message": "Invalid allowed_ip_netmask."}, status=400)
create_overrides = {"name": peer_name}
if peer_public_key:
create_overrides["public_key"] = peer_public_key
if peer_pre_shared_key:
create_overrides["pre_shared_key"] = peer_pre_shared_key
if peer_private_key:
create_overrides["private_key"] = peer_private_key
if peer_persistent_keepalive is not None:
create_overrides["persistent_keepalive"] = peer_persistent_keepalive
if peer_allowed_ip:
create_overrides["allowed_ip"] = str(peer_allowed_ip).strip()
if peer_allowed_ip_netmask is not None:
create_overrides["allowed_ip_netmask"] = peer_allowed_ip_netmask
if routing_template is not None:
create_overrides["default_routing_template"] = routing_template
created_peer, create_message = func_create_new_peer(wireguard_instance=wireguard_instance, overrides=create_overrides)
if not created_peer:
return JsonResponse({"status": "error", "error_message": create_message or "Error creating peer."}, status=400)
# Enforce allow_peer_custom_routes rule:
# If template allows peer custom routes, client_routes must NOT be provided.
if routing_template is not None and routing_template.allow_peer_custom_routes:
if client_route_pairs is not None and len(client_route_pairs) > 0:
return JsonResponse(
{"status": "error", "error_message": "client_routes is not allowed when routing_template.allow_peer_custom_routes is enabled."},
status=400
)
_sync_allowed_ips(created_peer, announced_pairs, config_file="server")
_sync_allowed_ips(created_peer, client_route_pairs, config_file="client")
reload_success, reload_message = _apply_reload_or_pending_changes(
wireguard_instance=created_peer.wireguard_instance,
skip_reload=skip_reload
)
return JsonResponse(
{
"status": "success",
"message": create_message or "Peer created successfully.",
"peer_uuid": str(created_peer.uuid),
"public_key": created_peer.public_key,
"reload": {"success": reload_success, "message": reload_message},
},
status=201
)
# UPDATE / DELETE: locate peer by uuid or public_key (explicit variable names)
selector_peer_uuid = payload.get("peer_uuid")
selector_peer_public_key = payload.get("peer_public_key")
peer_for_action = None
if selector_peer_uuid:
peer_for_action = Peer.objects.filter(uuid=selector_peer_uuid, wireguard_instance=wireguard_instance).first()
if not peer_for_action:
return JsonResponse({"status": "error", "error_message": "Peer not found for the provided peer_uuid in this instance."}, status=400)
elif selector_peer_public_key:
peer_for_action = Peer.objects.filter(public_key=selector_peer_public_key, wireguard_instance=wireguard_instance).first()
if not peer_for_action:
return JsonResponse({"status": "error", "error_message": "Peer not found for the provided peer_public_key in this instance."}, status=400)
else:
return JsonResponse({"status": "error", "error_message": "Missing peer selector (peer_uuid or peer_public_key)."}, status=400)
# Determine effective routing template for allow_peer_custom_routes validation
effective_routing_template = routing_template if routing_template is not None else peer_for_action.routing_template
if effective_routing_template is not None and effective_routing_template.allow_peer_custom_routes:
if client_route_pairs is not None and len(client_route_pairs) > 0:
return JsonResponse(
{"status": "error", "error_message": "client_routes is not allowed when routing_template.allow_peer_custom_routes is enabled."},
status=400
)
# UPDATE
if request.method == "PUT":
new_public_key = payload.get("public_key")
new_pre_shared_key = payload.get("pre_shared_key")
new_private_key = payload.get("private_key") # optional
new_persistent_keepalive = payload.get("persistent_keepalive")
new_suspended = payload.get("suspended")
new_suspend_reason = payload.get("suspend_reason") if "suspend_reason" in payload else None
if new_public_key:
peer_for_action.public_key = new_public_key
if new_pre_shared_key:
peer_for_action.pre_shared_key = new_pre_shared_key
if new_private_key:
peer_for_action.private_key = new_private_key
if new_persistent_keepalive is not None:
try:
peer_for_action.persistent_keepalive = int(new_persistent_keepalive)
except Exception:
return JsonResponse({"status": "error", "error_message": "Invalid persistent_keepalive."}, status=400)
if routing_template is not None:
peer_for_action.routing_template = routing_template
if new_suspended is not None:
peer_for_action.suspended = bool(new_suspended)
if "suspend_reason" in payload:
peer_for_action.suspend_reason = new_suspend_reason
peer_for_action.save()
_sync_allowed_ips(peer_for_action, announced_pairs, config_file="server")
_sync_allowed_ips(peer_for_action, client_route_pairs, config_file="client")
reload_success, reload_message = _apply_reload_or_pending_changes(
wireguard_instance=peer_for_action.wireguard_instance,
skip_reload=skip_reload
)
return JsonResponse(
{
"status": "success",
"message": "Peer updated successfully.",
"peer_uuid": str(peer_for_action.uuid),
"public_key": peer_for_action.public_key,
"reload": {"success": reload_success, "message": reload_message},
},
status=200
)
# DELETE
deleted_uuid = str(peer_for_action.uuid)
peer_for_action.delete()
reload_success, reload_message = _apply_reload_or_pending_changes(
wireguard_instance=wireguard_instance,
skip_reload=skip_reload
)
return JsonResponse(
{
"status": "success",
"message": "Peer deleted successfully.",
"peer_uuid": deleted_uuid,
"reload": {"success": reload_success, "message": reload_message},
},
status=200
)