Initial commit: abyssVpn project
- Tauri desktop app (apps/desktop) - Python Django API service (services/api) - Deployment scripts and docs
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Device, VpnLog
|
||||
|
||||
|
||||
@admin.register(Device)
|
||||
class DeviceAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "user", "device_name", "status", "netmaker_node_id", "created_at")
|
||||
search_fields = ("device_name", "device_fingerprint", "netmaker_node_id")
|
||||
list_filter = ("status",)
|
||||
|
||||
|
||||
@admin.register(VpnLog)
|
||||
class VpnLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "user", "device", "action", "created_at")
|
||||
list_filter = ("action",)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class VpnConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "vpn"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Device",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("device_name", models.CharField(max_length=128)),
|
||||
("device_fingerprint", models.CharField(blank=True, max_length=256)),
|
||||
("netmaker_node_id", models.CharField(blank=True, max_length=128)),
|
||||
("status", models.CharField(default="active", max_length=16)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="devices",
|
||||
to="accounts.user",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="VpnLog",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("action", models.CharField(max_length=64)),
|
||||
("detail", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"device",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="vpn.device",
|
||||
),
|
||||
),
|
||||
("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="accounts.user")),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("vpn", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="device",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user", "device_fingerprint"),
|
||||
name="unique_user_device_fingerprint",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from django.db import models
|
||||
|
||||
from accounts.models import User
|
||||
|
||||
|
||||
class Device(models.Model):
|
||||
STATUS_ACTIVE = "active"
|
||||
STATUS_REVOKED = "revoked"
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="devices")
|
||||
device_name = models.CharField(max_length=128)
|
||||
device_fingerprint = models.CharField(max_length=256, blank=True)
|
||||
netmaker_node_id = models.CharField(max_length=128, blank=True)
|
||||
status = models.CharField(max_length=16, default=STATUS_ACTIVE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user_id}:{self.device_name}"
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "device_fingerprint"],
|
||||
name="unique_user_device_fingerprint",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class VpnLog(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
device = models.ForeignKey(Device, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
action = models.CharField(max_length=64)
|
||||
detail = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -0,0 +1,5 @@
|
||||
from .services.netmaker_client import NetmakerClient
|
||||
|
||||
|
||||
def create_one_time_join_token(device_name: str) -> str:
|
||||
return NetmakerClient().create_one_time_join_token(device_name)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from accounts.models import User
|
||||
|
||||
from vpn.models import Device, VpnLog
|
||||
from vpn.services.netmaker_client import NetmakerClient
|
||||
|
||||
|
||||
def issue_connect_token(user: User, device_name: str, fingerprint: str) -> str:
|
||||
device, _created = Device.objects.get_or_create(
|
||||
user=user,
|
||||
device_fingerprint=fingerprint,
|
||||
defaults={"device_name": device_name},
|
||||
)
|
||||
if device.status == Device.STATUS_REVOKED:
|
||||
raise PermissionError("device_revoked")
|
||||
|
||||
token = NetmakerClient().create_one_time_join_token(device.device_name)
|
||||
VpnLog.objects.create(user=user, device=device, action="connect_token_issued")
|
||||
return token
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import re
|
||||
from urllib.parse import quote, urljoin
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class NetmakerConfigError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class NetmakerApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class NetmakerClient:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.NETMAKER_BASE_URL.rstrip("/")
|
||||
self.api_token = settings.NETMAKER_API_TOKEN
|
||||
self.network_id = settings.NETMAKER_NETWORK_ID
|
||||
|
||||
def create_one_time_join_token(self, device_name: str) -> str:
|
||||
self._validate_config()
|
||||
key_name = self._build_unique_key_name(device_name)
|
||||
payload = {
|
||||
"name": key_name,
|
||||
"tags": [key_name[:32]],
|
||||
# Netmaker v1.5 uses numeric key types:
|
||||
# 1 = time-bound, 2 = uses-based, 3 = unlimited.
|
||||
"type": 2,
|
||||
"uses_remaining": 1,
|
||||
"unlimited": False,
|
||||
"expiration": 0,
|
||||
"networks": [self.network_id],
|
||||
"groups": settings.NETMAKER_ENROLLMENT_TAGS,
|
||||
"auto_egress": False,
|
||||
"auto_assign_gw": False,
|
||||
}
|
||||
response = requests.post(
|
||||
self._api_url("/api/v1/enrollment-keys"),
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise NetmakerApiError(f"netmaker_error:{response.status_code}:{response.text}")
|
||||
|
||||
body = response.json()
|
||||
token = body.get("token") or body.get("data", {}).get("token")
|
||||
if not token:
|
||||
raise NetmakerApiError("netmaker_missing_token")
|
||||
return token
|
||||
|
||||
def delete_node(self, node_id: str) -> None:
|
||||
self._validate_config()
|
||||
normalized = (node_id or "").strip()
|
||||
if not normalized:
|
||||
return
|
||||
|
||||
encoded_node_id = quote(normalized, safe="")
|
||||
encoded_network_id = quote(self.network_id, safe="")
|
||||
paths = [
|
||||
f"/api/v1/hosts/{encoded_node_id}",
|
||||
f"/api/v1/nodes/{encoded_network_id}/{encoded_node_id}",
|
||||
]
|
||||
last_error = ""
|
||||
for index, path in enumerate(paths):
|
||||
response = requests.delete(
|
||||
self._api_url(path),
|
||||
headers={"Authorization": f"Bearer {self.api_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in (200, 202, 204):
|
||||
return
|
||||
if response.status_code == 404:
|
||||
last_error = f"netmaker_error:{response.status_code}:{response.text}"
|
||||
if index < len(paths) - 1:
|
||||
continue
|
||||
return
|
||||
last_error = f"netmaker_error:{response.status_code}:{response.text}"
|
||||
if response.status_code in (400, 405):
|
||||
continue
|
||||
raise NetmakerApiError(last_error)
|
||||
|
||||
raise NetmakerApiError(last_error or "netmaker_delete_node_failed")
|
||||
|
||||
def _build_unique_key_name(self, device_name: str) -> str:
|
||||
normalized = re.sub(r"[^a-z0-9-]+", "-", device_name.lower()).strip("-")
|
||||
if not normalized:
|
||||
normalized = "device"
|
||||
return f"homevpn-{normalized[:24]}-{uuid4().hex[:12]}"
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
missing = [
|
||||
name
|
||||
for name, value in {
|
||||
"NETMAKER_BASE_URL": self.base_url,
|
||||
"NETMAKER_API_TOKEN": self.api_token,
|
||||
"NETMAKER_NETWORK_ID": self.network_id,
|
||||
}.items()
|
||||
if not value
|
||||
]
|
||||
if missing:
|
||||
raise NetmakerConfigError("missing_config:" + ",".join(missing))
|
||||
|
||||
def _api_url(self, path: str) -> str:
|
||||
return urljoin(f"{self.base_url}/", path.lstrip("/"))
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from accounts.models import User
|
||||
from vpn.models import Device, VpnLog
|
||||
from vpn.services.netmaker_client import NetmakerApiError, NetmakerClient, NetmakerConfigError
|
||||
|
||||
|
||||
def revoke_user_vpn_access(
|
||||
user: User,
|
||||
*,
|
||||
reason: str,
|
||||
source: str = "system",
|
||||
event: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
disabled = False
|
||||
if user.status != User.STATUS_DISABLED:
|
||||
user.status = User.STATUS_DISABLED
|
||||
user.save(update_fields=["status", "updated_at"])
|
||||
disabled = True
|
||||
|
||||
client = NetmakerClient()
|
||||
revoked_count = 0
|
||||
netmaker_errors: list[dict[str, str]] = []
|
||||
devices = list(Device.objects.filter(user=user))
|
||||
|
||||
for device in devices:
|
||||
node_id = (device.netmaker_node_id or "").strip()
|
||||
if node_id:
|
||||
try:
|
||||
client.delete_node(node_id)
|
||||
except (NetmakerConfigError, NetmakerApiError) as error:
|
||||
netmaker_errors.append({"device_id": str(device.id), "error": str(error)})
|
||||
|
||||
if device.status != Device.STATUS_REVOKED:
|
||||
device.status = Device.STATUS_REVOKED
|
||||
device.save(update_fields=["status", "updated_at"])
|
||||
revoked_count += 1
|
||||
|
||||
VpnLog.objects.create(
|
||||
user=user,
|
||||
device=device,
|
||||
action="device_revoked",
|
||||
detail={
|
||||
"reason": reason,
|
||||
"source": source,
|
||||
"netmaker_node_id": node_id,
|
||||
},
|
||||
)
|
||||
|
||||
VpnLog.objects.create(
|
||||
user=user,
|
||||
action="user_disabled",
|
||||
detail={
|
||||
"reason": reason,
|
||||
"source": source,
|
||||
"disabled": disabled,
|
||||
"revoked_devices": revoked_count,
|
||||
"netmaker_errors": netmaker_errors,
|
||||
"event": event or {},
|
||||
},
|
||||
)
|
||||
return {
|
||||
"disabled": disabled,
|
||||
"revoked_devices": revoked_count,
|
||||
"netmaker_errors": netmaker_errors,
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import User
|
||||
from accounts.services.jwt_service import issue_jwt
|
||||
from vpn.models import Device, VpnLog
|
||||
from vpn.services.netmaker_client import NetmakerApiError, NetmakerClient, NetmakerConfigError
|
||||
|
||||
|
||||
class NetmakerClientTests(TestCase):
|
||||
@override_settings(
|
||||
NETMAKER_BASE_URL="https://netmaker.example.com",
|
||||
NETMAKER_API_TOKEN="secret",
|
||||
NETMAKER_NETWORK_ID="office",
|
||||
NETMAKER_ENROLLMENT_TAGS=["homevpn", "desktop", "vpn"],
|
||||
NETMAKER_ENROLLMENT_TTL_SECONDS=600,
|
||||
)
|
||||
@patch("vpn.services.netmaker_client.uuid4")
|
||||
@patch("vpn.services.netmaker_client.requests.post")
|
||||
def test_create_one_time_join_token(self, post, uuid4):
|
||||
uuid4.return_value.hex = "abcdef1234567890"
|
||||
response = Mock()
|
||||
response.status_code = 200
|
||||
response.json.return_value = {"token": "join-token"}
|
||||
post.return_value = response
|
||||
|
||||
token = NetmakerClient().create_one_time_join_token("pc-01")
|
||||
|
||||
self.assertEqual(token, "join-token")
|
||||
url = post.call_args.args[0]
|
||||
payload = post.call_args.kwargs["json"]
|
||||
headers = post.call_args.kwargs["headers"]
|
||||
self.assertEqual(url, "https://netmaker.example.com/api/v1/enrollment-keys")
|
||||
self.assertEqual(payload["name"], "homevpn-pc-01-abcdef123456")
|
||||
self.assertEqual(payload["tags"], ["homevpn-pc-01-abcdef123456"[:32]])
|
||||
self.assertEqual(payload["uses_remaining"], 1)
|
||||
self.assertEqual(payload["networks"], ["office"])
|
||||
self.assertEqual(headers["Authorization"], "Bearer secret")
|
||||
|
||||
@override_settings(
|
||||
NETMAKER_BASE_URL="https://netmaker.example.com",
|
||||
NETMAKER_API_TOKEN="secret",
|
||||
NETMAKER_NETWORK_ID="office",
|
||||
NETMAKER_ENROLLMENT_TAGS=["homevpn", "desktop", "vpn"],
|
||||
NETMAKER_ENROLLMENT_TTL_SECONDS=600,
|
||||
)
|
||||
@patch("vpn.services.netmaker_client.uuid4")
|
||||
@patch("vpn.services.netmaker_client.requests.post")
|
||||
def test_create_one_time_join_token_uses_unique_key_name(self, post, uuid4):
|
||||
first = Mock()
|
||||
first.hex = "111111111111aaaaaaaa"
|
||||
second = Mock()
|
||||
second.hex = "222222222222bbbbbbbb"
|
||||
uuid4.side_effect = [first, second]
|
||||
response = Mock()
|
||||
response.status_code = 200
|
||||
response.json.return_value = {"token": "join-token"}
|
||||
post.return_value = response
|
||||
|
||||
NetmakerClient().create_one_time_join_token("pc-01")
|
||||
NetmakerClient().create_one_time_join_token("pc-01")
|
||||
|
||||
first_payload = post.call_args_list[0].kwargs["json"]
|
||||
second_payload = post.call_args_list[1].kwargs["json"]
|
||||
self.assertNotEqual(first_payload["name"], second_payload["name"])
|
||||
|
||||
@override_settings(NETMAKER_BASE_URL="", NETMAKER_API_TOKEN="", NETMAKER_NETWORK_ID="")
|
||||
def test_missing_config_raises(self):
|
||||
with self.assertRaises(NetmakerConfigError):
|
||||
NetmakerClient().create_one_time_join_token("pc-01")
|
||||
|
||||
@override_settings(
|
||||
NETMAKER_BASE_URL="https://netmaker.example.com",
|
||||
NETMAKER_API_TOKEN="secret",
|
||||
NETMAKER_NETWORK_ID="office",
|
||||
NETMAKER_ENROLLMENT_TAGS=["homevpn", "desktop", "vpn"],
|
||||
NETMAKER_ENROLLMENT_TTL_SECONDS=600,
|
||||
)
|
||||
@patch("vpn.services.netmaker_client.requests.post")
|
||||
def test_api_error_raises(self, post):
|
||||
response = Mock()
|
||||
response.status_code = 500
|
||||
response.text = "server error"
|
||||
post.return_value = response
|
||||
|
||||
with self.assertRaises(NetmakerApiError):
|
||||
NetmakerClient().create_one_time_join_token("pc-01")
|
||||
|
||||
@override_settings(
|
||||
NETMAKER_BASE_URL="https://netmaker.example.com",
|
||||
NETMAKER_API_TOKEN="secret",
|
||||
NETMAKER_NETWORK_ID="office",
|
||||
)
|
||||
@patch("vpn.services.netmaker_client.requests.delete")
|
||||
def test_delete_node_uses_host_endpoint(self, delete):
|
||||
response = Mock()
|
||||
response.status_code = 204
|
||||
response.text = ""
|
||||
delete.return_value = response
|
||||
|
||||
NetmakerClient().delete_node("host-01")
|
||||
|
||||
self.assertEqual(delete.call_args.args[0], "https://netmaker.example.com/api/v1/hosts/host-01")
|
||||
self.assertEqual(delete.call_args.kwargs["headers"]["Authorization"], "Bearer secret")
|
||||
|
||||
@override_settings(
|
||||
NETMAKER_BASE_URL="https://netmaker.example.com",
|
||||
NETMAKER_API_TOKEN="secret",
|
||||
NETMAKER_NETWORK_ID="office",
|
||||
)
|
||||
@patch("vpn.services.netmaker_client.requests.delete")
|
||||
def test_delete_node_falls_back_to_network_node_endpoint(self, delete):
|
||||
first = Mock()
|
||||
first.status_code = 405
|
||||
first.text = "method not allowed"
|
||||
second = Mock()
|
||||
second.status_code = 204
|
||||
second.text = ""
|
||||
delete.side_effect = [first, second]
|
||||
|
||||
NetmakerClient().delete_node("node-01")
|
||||
|
||||
urls = [call.args[0] for call in delete.call_args_list]
|
||||
self.assertEqual(urls[0], "https://netmaker.example.com/api/v1/hosts/node-01")
|
||||
self.assertEqual(urls[1], "https://netmaker.example.com/api/v1/nodes/office/node-01")
|
||||
|
||||
|
||||
@override_settings(JWT_SECRET="test-secret-key-with-at-least-32-bytes")
|
||||
class VpnApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(feishu_open_id="open-id", email="u@example.com", name="User")
|
||||
self.jwt = issue_jwt(self.user.id)
|
||||
self.device = Device.objects.create(
|
||||
user=self.user,
|
||||
device_name="pc-01",
|
||||
device_fingerprint="fingerprint-01",
|
||||
)
|
||||
|
||||
def test_status_returns_devices_and_logs(self):
|
||||
VpnLog.objects.create(user=self.user, device=self.device, action="connect_token_issued")
|
||||
|
||||
response = self.client.get("/vpn/status", HTTP_AUTHORIZATION=f"Bearer {self.jwt}")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["user"]["name"], "User")
|
||||
self.assertEqual(data["user"]["email"], "u@example.com")
|
||||
self.assertEqual(len(data["devices"]), 1)
|
||||
self.assertEqual(data["devices"][0]["device_name"], "pc-01")
|
||||
self.assertEqual(len(data["logs"]), 1)
|
||||
|
||||
def test_register_node_stores_netmaker_identifier(self):
|
||||
response = self.client.post(
|
||||
"/vpn/register-node",
|
||||
data='{"device_name": "pc-01", "device_fingerprint": "fingerprint-01", "netmaker_node_id": "host-01"}',
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.jwt}",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.device.refresh_from_db()
|
||||
self.assertEqual(self.device.netmaker_node_id, "host-01")
|
||||
self.assertTrue(VpnLog.objects.filter(user=self.user, action="netmaker_node_registered").exists())
|
||||
|
||||
def test_revoke_device_marks_device_revoked(self):
|
||||
response = self.client.post(
|
||||
"/vpn/revoke-device",
|
||||
data='{"device_id": %d}' % self.device.id,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.jwt}",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.device.refresh_from_db()
|
||||
self.assertEqual(self.device.status, Device.STATUS_REVOKED)
|
||||
self.assertTrue(VpnLog.objects.filter(user=self.user, action="device_revoked").exists())
|
||||
|
||||
def test_disconnect_log_records_action(self):
|
||||
response = self.client.post(
|
||||
"/vpn/disconnect-log",
|
||||
data='{"device_fingerprint": "fingerprint-01"}',
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.jwt}",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
log = VpnLog.objects.get(user=self.user, action="disconnect")
|
||||
self.assertEqual(log.device_id, self.device.id)
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("connect-token", views.connect_token),
|
||||
path("disconnect-log", views.disconnect_log),
|
||||
path("register-node", views.register_node),
|
||||
path("status", views.status),
|
||||
path("revoke-device", views.revoke_device),
|
||||
]
|
||||
@@ -0,0 +1,142 @@
|
||||
import json
|
||||
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from common.auth import get_current_user
|
||||
from .models import Device, VpnLog
|
||||
from .services.device_service import issue_connect_token
|
||||
from .services.netmaker_client import NetmakerApiError, NetmakerClient, NetmakerConfigError
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def connect_token(request: HttpRequest) -> JsonResponse:
|
||||
user = get_current_user(request)
|
||||
if user is None:
|
||||
return JsonResponse({"error": "unauthorized"}, status=401)
|
||||
|
||||
body = json.loads(request.body or b"{}")
|
||||
device_name = body.get("device_name") or "unknown-device"
|
||||
fingerprint = body.get("device_fingerprint") or ""
|
||||
try:
|
||||
token = issue_connect_token(user, device_name, fingerprint)
|
||||
except PermissionError as error:
|
||||
return JsonResponse({"error": str(error)}, status=403)
|
||||
except NetmakerConfigError as error:
|
||||
return JsonResponse({"error": str(error)}, status=503)
|
||||
except NetmakerApiError as error:
|
||||
return JsonResponse({"error": str(error)}, status=502)
|
||||
return JsonResponse({"token": token})
|
||||
|
||||
|
||||
@require_GET
|
||||
def status(request: HttpRequest) -> JsonResponse:
|
||||
user = get_current_user(request)
|
||||
if user is None:
|
||||
return JsonResponse({"error": "unauthorized"}, status=401)
|
||||
devices = list(user.devices.values("id", "device_name", "status", "netmaker_node_id", "updated_at"))
|
||||
logs = list(
|
||||
VpnLog.objects.filter(user=user)
|
||||
.select_related("device")
|
||||
.order_by("-created_at")
|
||||
.values("id", "device_id", "action", "detail", "created_at")[:20]
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
},
|
||||
"devices": devices,
|
||||
"logs": logs,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def register_node(request: HttpRequest) -> JsonResponse:
|
||||
user = get_current_user(request)
|
||||
if user is None:
|
||||
return JsonResponse({"error": "unauthorized"}, status=401)
|
||||
|
||||
body = json.loads(request.body or b"{}")
|
||||
fingerprint = body.get("device_fingerprint") or ""
|
||||
node_id = body.get("netmaker_node_id") or body.get("host_id") or body.get("node_id") or ""
|
||||
device_name = body.get("device_name") or "unknown-device"
|
||||
if not fingerprint:
|
||||
return JsonResponse({"error": "missing_device_fingerprint"}, status=400)
|
||||
if not node_id:
|
||||
return JsonResponse({"error": "missing_netmaker_node_id"}, status=400)
|
||||
|
||||
device, _created = Device.objects.get_or_create(
|
||||
user=user,
|
||||
device_fingerprint=fingerprint,
|
||||
defaults={"device_name": device_name},
|
||||
)
|
||||
if device.status == Device.STATUS_REVOKED:
|
||||
return JsonResponse({"error": "device_revoked"}, status=403)
|
||||
|
||||
changed_fields = []
|
||||
if device.device_name != device_name:
|
||||
device.device_name = device_name
|
||||
changed_fields.append("device_name")
|
||||
if device.netmaker_node_id != node_id:
|
||||
device.netmaker_node_id = node_id
|
||||
changed_fields.append("netmaker_node_id")
|
||||
if changed_fields:
|
||||
changed_fields.append("updated_at")
|
||||
device.save(update_fields=changed_fields)
|
||||
VpnLog.objects.create(
|
||||
user=user,
|
||||
device=device,
|
||||
action="netmaker_node_registered",
|
||||
detail={"netmaker_node_id": node_id},
|
||||
)
|
||||
|
||||
return JsonResponse({"status": "ok", "device_id": device.id})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def disconnect_log(request: HttpRequest) -> JsonResponse:
|
||||
user = get_current_user(request)
|
||||
if user is None:
|
||||
return JsonResponse({"error": "unauthorized"}, status=401)
|
||||
body = json.loads(request.body or b"{}")
|
||||
fingerprint = body.get("device_fingerprint") or ""
|
||||
device = Device.objects.filter(user=user, device_fingerprint=fingerprint).first()
|
||||
VpnLog.objects.create(user=user, device=device, action="disconnect", detail={"device_fingerprint": fingerprint})
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def revoke_device(request: HttpRequest) -> JsonResponse:
|
||||
user = get_current_user(request)
|
||||
if user is None:
|
||||
return JsonResponse({"error": "unauthorized"}, status=401)
|
||||
body = json.loads(request.body or b"{}")
|
||||
device = Device.objects.filter(user=user, id=body.get("device_id")).first()
|
||||
if device is None:
|
||||
return JsonResponse({"error": "device_not_found"}, status=404)
|
||||
|
||||
netmaker_error = ""
|
||||
if device.netmaker_node_id:
|
||||
try:
|
||||
NetmakerClient().delete_node(device.netmaker_node_id)
|
||||
except (NetmakerConfigError, NetmakerApiError) as error:
|
||||
netmaker_error = str(error)
|
||||
|
||||
device.status = Device.STATUS_REVOKED
|
||||
device.save(update_fields=["status", "updated_at"])
|
||||
VpnLog.objects.create(
|
||||
user=user,
|
||||
device=device,
|
||||
action="device_revoked",
|
||||
detail={"device_id": device.id, "netmaker_error": netmaker_error},
|
||||
)
|
||||
return JsonResponse({"status": "ok", "netmaker_error": netmaker_error})
|
||||
Reference in New Issue
Block a user