Initial commit: abyssVpn project

- Tauri desktop app (apps/desktop)
- Python Django API service (services/api)
- Deployment scripts and docs
This commit is contained in:
arthur_chen
2026-05-15 09:06:10 +08:00
commit f69efb67ad
146 changed files with 18039 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+17
View File
@@ -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",)
+7
View File
@@ -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",
),
),
]
+1
View File
@@ -0,0 +1 @@
+35
View File
@@ -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)
+5
View File
@@ -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)
+1
View File
@@ -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("/"))
+68
View File
@@ -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,
}
+190
View File
@@ -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)
+12
View File
@@ -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),
]
+142
View File
@@ -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})