mirror of
https://github.com/eduardogsilva/wireguard_webadmin.git
synced 2026-02-19 03:06:17 +00:00
Add API v2 for managing WireGuard peers: create, update, and delete functionality
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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
445
api_v2/views_api.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user