chore: initial project scaffold with admin web, backend, desktop client, and deployment setup
Add monorepo structure for NexaVPN WireGuard control plane including: - .gitignore for node_modules, build artifacts, and environment files - README with project overview, monorepo layout, and quick start guide - Admin web UI with React, Vite, TypeScript, and nginx reverse proxy - API client with type definitions for users, devices, policies, gateways, and audit logs - Admin pages for dashboard, users, devices, policies, g
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
coverage/
|
||||||
|
tmp/
|
||||||
|
*.log
|
||||||
|
desktop-client/src-tauri/target/
|
||||||
|
backend/bin/
|
||||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# NexaVPN
|
||||||
|
|
||||||
|
NexaVPN is a production-oriented, self-hosted WireGuard control plane for remote access.
|
||||||
|
It combines:
|
||||||
|
|
||||||
|
- A Go backend and PostgreSQL control plane
|
||||||
|
- A React admin console
|
||||||
|
- A Tauri desktop client for Windows and macOS
|
||||||
|
- WireGuard gateway and firewall policy enforcement
|
||||||
|
- Docker Compose deployment assets
|
||||||
|
|
||||||
|
## Monorepo Layout
|
||||||
|
|
||||||
|
- `docs/` architecture, schema, API, and deployment design
|
||||||
|
- `backend/` Go API, migrations, seeds, and domain services
|
||||||
|
- `admin-web/` React + Vite admin UI
|
||||||
|
- `desktop-client/` Tauri desktop client
|
||||||
|
- `deploy/` Docker Compose, reverse proxy, and gateway assets
|
||||||
|
|
||||||
|
## Phase Status
|
||||||
|
|
||||||
|
This repository contains the initial production-minded MVP scaffold:
|
||||||
|
|
||||||
|
- Phase 1: architecture, schema, API, enrollment, provisioning, gateway design
|
||||||
|
- Phase 2: backend scaffold, migrations, auth, CRUD, audit, profile generation
|
||||||
|
- Phase 3: admin UI scaffold and core pages
|
||||||
|
- Phase 4: desktop client scaffold, enrollment flow, profile provisioning abstraction
|
||||||
|
- Phase 5: deployment assets, bootstrap scripts, and hardening notes
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Copy `deploy/.env.example` to `deploy/.env`.
|
||||||
|
2. Review `docs/architecture.md` and `docs/deployment.md`.
|
||||||
|
3. Start the stack with Docker Compose from `deploy/`.
|
||||||
|
4. Open `http://localhost`.
|
||||||
|
5. On the admin login screen, choose the bootstrap flow if this is a fresh install.
|
||||||
|
6. Create the initial admin, then sign in.
|
||||||
|
|
||||||
|
## Important MVP Notes
|
||||||
|
|
||||||
|
- WireGuard remains the tunnel transport. NexaVPN is the control plane around it.
|
||||||
|
- Client private keys are generated on-device and are not stored server-side.
|
||||||
|
- Gateway-side enforcement uses nftables generated from issued policy state.
|
||||||
|
- The Tauri client is structured around embedded tunnel management. Native system WireGuard import can be added as an optional integration later.
|
||||||
|
- The current desktop client now performs real backend login and enrollment calls, but secure OS key storage and native tunnel activation are still the next hardening step.
|
||||||
|
|
||||||
|
## Local Test Flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
1. Visit `http://localhost`
|
||||||
|
2. Bootstrap the first admin account
|
||||||
|
3. Create a user or use the desktop client against `http://localhost`
|
||||||
|
4. Sign in from the NexaVPN desktop app with that user
|
||||||
10
admin-web/Dockerfile
Normal file
10
admin-web/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
12
admin-web/index.html
Normal file
12
admin-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NexaVPN Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
admin-web/nginx.conf
Normal file
18
admin-web/nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
admin-web/package.json
Normal file
24
admin-web/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "nexavpn-admin-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.66.8",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.30.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.20",
|
||||||
|
"@types/react-dom": "^18.3.6",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
admin-web/src/api/client.ts
Normal file
75
admin-web/src/api/client.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
role: string;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Device = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
user_id?: string;
|
||||||
|
platform: string;
|
||||||
|
status: string;
|
||||||
|
assigned_ip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Policy = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
priority: number;
|
||||||
|
effect: string;
|
||||||
|
full_tunnel: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
destinations?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Gateway = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
endpoint: string;
|
||||||
|
public_key: string;
|
||||||
|
listen_port: number;
|
||||||
|
vpn_cidr: string;
|
||||||
|
dns_servers: string[];
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditEvent = {
|
||||||
|
id: string;
|
||||||
|
event_type: string;
|
||||||
|
entity_type: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const token = localStorage.getItem("nexavpn_admin_token");
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(init?.headers ?? {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
users: () => request<User[]>("/admin/users"),
|
||||||
|
devices: () => request<Device[]>("/admin/devices"),
|
||||||
|
policies: () => request<Policy[]>("/admin/policies"),
|
||||||
|
gateways: () => request<Gateway[]>("/admin/gateways"),
|
||||||
|
audit: () => request<AuditEvent[]>("/admin/audit-logs")
|
||||||
|
};
|
||||||
40
admin-web/src/app/App.tsx
Normal file
40
admin-web/src/app/App.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
import { AuditPage } from "../features/audit/AuditPage";
|
||||||
|
import { LoginPage } from "../features/auth/LoginPage";
|
||||||
|
import { DashboardPage } from "../features/dashboard/DashboardPage";
|
||||||
|
import { DevicesPage } from "../features/devices/DevicesPage";
|
||||||
|
import { GatewaysPage } from "../features/gateways/GatewaysPage";
|
||||||
|
import { PoliciesPage } from "../features/policies/PoliciesPage";
|
||||||
|
import { SettingsPage } from "../features/settings/SettingsPage";
|
||||||
|
import { UsersPage } from "../features/users/UsersPage";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [token, setToken] = useState(() => localStorage.getItem("nexavpn_admin_token") ?? "");
|
||||||
|
const authenticated = useMemo(() => token.length > 0, [token]);
|
||||||
|
|
||||||
|
function handleAuthenticated(accessToken: string) {
|
||||||
|
localStorage.setItem("nexavpn_admin_token", accessToken);
|
||||||
|
setToken(accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={authenticated ? <Navigate to="/" replace /> : <LoginPage onAuthenticated={handleAuthenticated} />}
|
||||||
|
/>
|
||||||
|
<Route element={authenticated ? <Layout /> : <Navigate to="/login" replace />}>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="/devices" element={<DevicesPage />} />
|
||||||
|
<Route path="/policies" element={<PoliciesPage />} />
|
||||||
|
<Route path="/gateways" element={<GatewaysPage />} />
|
||||||
|
<Route path="/audit" element={<AuditPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
admin-web/src/components/Card.tsx
Normal file
5
admin-web/src/components/Card.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export function Card({ children }: PropsWithChildren) {
|
||||||
|
return <div className="card">{children}</div>;
|
||||||
|
}
|
||||||
45
admin-web/src/components/Layout.tsx
Normal file
45
admin-web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
["Dashboard", "/"],
|
||||||
|
["Users", "/users"],
|
||||||
|
["Devices", "/devices"],
|
||||||
|
["Policies", "/policies"],
|
||||||
|
["Gateways", "/gateways"],
|
||||||
|
["Audit", "/audit"],
|
||||||
|
["Settings", "/settings"]
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">NexaVPN</p>
|
||||||
|
<h1>Control Plane</h1>
|
||||||
|
</div>
|
||||||
|
<nav className="nav">
|
||||||
|
{items.map(([label, path]) => (
|
||||||
|
<NavLink
|
||||||
|
key={path}
|
||||||
|
to={path}
|
||||||
|
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main className="content">
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Enterprise WireGuard</p>
|
||||||
|
<h2>Self-hosted VPN management</h2>
|
||||||
|
</div>
|
||||||
|
<div className="pill">Secure by design</div>
|
||||||
|
</header>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
admin-web/src/components/Page.tsx
Normal file
22
admin-web/src/components/Page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
|
|
||||||
|
type PageProps = PropsWithChildren<{
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function Page({ title, subtitle, actions, children }: PageProps) {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
admin-web/src/components/Table.tsx
Normal file
37
admin-web/src/components/Table.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
|
|
||||||
|
type Column = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TableProps<T> = {
|
||||||
|
columns: Column[];
|
||||||
|
rows: T[];
|
||||||
|
renderCell: (row: T, column: Column) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Table<T>({ columns, rows, renderCell }: PropsWithChildren<TableProps<T>>) {
|
||||||
|
return (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column.key}>{column.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column.key}>{renderCell(row, column)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
admin-web/src/features/audit/AuditPage.tsx
Normal file
37
admin-web/src/features/audit/AuditPage.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api } from "../../api/client";
|
||||||
|
import { Page } from "../../components/Page";
|
||||||
|
import { Table } from "../../components/Table";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: "event", label: "Event" },
|
||||||
|
{ key: "entity", label: "Entity" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "time", label: "Time" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AuditPage() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["audit"],
|
||||||
|
queryFn: api.audit
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = query.data?.map((event) => ({
|
||||||
|
event: event.event_type,
|
||||||
|
entity: event.entity_type,
|
||||||
|
status: event.status,
|
||||||
|
time: event.created_at
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Audit Logs" subtitle="Review authentication, provisioning, and admin activity.">
|
||||||
|
{query.isError ? <p className="notice">Unable to load audit events from the API.</p> : null}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
admin-web/src/features/auth/LoginPage.tsx
Normal file
96
admin-web/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
type LoginPageProps = {
|
||||||
|
onAuthenticated: (accessToken: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
|
export function LoginPage({ onAuthenticated }: LoginPageProps) {
|
||||||
|
const [mode, setMode] = useState<"login" | "bootstrap">("login");
|
||||||
|
const [username, setUsername] = useState("admin");
|
||||||
|
const [displayName, setDisplayName] = useState("NexaVPN Admin");
|
||||||
|
const [password, setPassword] = useState("admin123!");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === "bootstrap") {
|
||||||
|
const bootstrap = await fetch(`${API_BASE}/auth/bootstrap`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
display_name: displayName,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bootstrap.ok && bootstrap.status !== 409) {
|
||||||
|
throw new Error(`Bootstrap failed: ${bootstrap.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!login.ok) {
|
||||||
|
throw new Error("Login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await login.json()) as { access_token: string };
|
||||||
|
onAuthenticated(payload.access_token);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Authentication failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-shell">
|
||||||
|
<form className="auth-card" onSubmit={onSubmit}>
|
||||||
|
<p className="eyebrow">NexaVPN Admin</p>
|
||||||
|
<h2>{mode === "login" ? "Sign in" : "Create initial admin"}</h2>
|
||||||
|
<p className="auth-copy">
|
||||||
|
{mode === "login"
|
||||||
|
? "Use your NexaVPN admin credentials."
|
||||||
|
: "Bootstrap the first admin account for a fresh deployment."}
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
{mode === "bootstrap" ? (
|
||||||
|
<label>
|
||||||
|
Display name
|
||||||
|
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
{error ? <p className="notice">{error}</p> : null}
|
||||||
|
<button className="button" disabled={loading} type="submit">
|
||||||
|
{loading ? "Please wait..." : mode === "login" ? "Sign in" : "Bootstrap and sign in"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode((current) => (current === "login" ? "bootstrap" : "login"))}
|
||||||
|
>
|
||||||
|
{mode === "login" ? "Need initial setup?" : "Already have an admin?"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
admin-web/src/features/dashboard/DashboardPage.tsx
Normal file
36
admin-web/src/features/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Card } from "../../components/Card";
|
||||||
|
import { Page } from "../../components/Page";
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<Page title="Dashboard" subtitle="Operational visibility across users, devices, gateways, and policy activity.">
|
||||||
|
<div className="grid three">
|
||||||
|
<Card>
|
||||||
|
<p className="metric-label">Active users</p>
|
||||||
|
<strong className="metric-value">248</strong>
|
||||||
|
<span>12 enrolled this week</span>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<p className="metric-label">Connected devices</p>
|
||||||
|
<strong className="metric-value">181</strong>
|
||||||
|
<span>7 pending rotation</span>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<p className="metric-label">Gateway health</p>
|
||||||
|
<strong className="metric-value">Healthy</strong>
|
||||||
|
<span>Last sync 36s ago</span>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="grid two">
|
||||||
|
<Card>
|
||||||
|
<h4>Recent enrollments</h4>
|
||||||
|
<p>New devices are issued profiles automatically after successful sign-in.</p>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<h4>Policy posture</h4>
|
||||||
|
<p>Gateway enforcement is generated from effective allow-lists backed by nftables.</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
admin-web/src/features/devices/DevicesPage.tsx
Normal file
39
admin-web/src/features/devices/DevicesPage.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api } from "../../api/client";
|
||||||
|
import { Page } from "../../components/Page";
|
||||||
|
import { Table } from "../../components/Table";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: "name", label: "Device" },
|
||||||
|
{ key: "owner", label: "Owner" },
|
||||||
|
{ key: "platform", label: "Platform" },
|
||||||
|
{ key: "ip", label: "VPN IP" },
|
||||||
|
{ key: "status", label: "Status" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DevicesPage() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["devices"],
|
||||||
|
queryFn: api.devices
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = query.data?.map((device) => ({
|
||||||
|
name: device.name,
|
||||||
|
owner: device.user_id ?? "assigned user",
|
||||||
|
platform: device.platform,
|
||||||
|
ip: device.assigned_ip ?? "-",
|
||||||
|
status: device.status
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Devices" subtitle="Inspect enrolled clients, assignments, and profile lifecycle.">
|
||||||
|
{query.isError ? <p className="notice">Unable to load devices from the API.</p> : null}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
admin-web/src/features/gateways/GatewaysPage.tsx
Normal file
37
admin-web/src/features/gateways/GatewaysPage.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api } from "../../api/client";
|
||||||
|
import { Page } from "../../components/Page";
|
||||||
|
import { Table } from "../../components/Table";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: "name", label: "Gateway" },
|
||||||
|
{ key: "endpoint", label: "Endpoint" },
|
||||||
|
{ key: "cidr", label: "VPN CIDR" },
|
||||||
|
{ key: "status", label: "Status" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GatewaysPage() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["gateways"],
|
||||||
|
queryFn: api.gateways
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = query.data?.map((gateway) => ({
|
||||||
|
name: gateway.name,
|
||||||
|
endpoint: gateway.endpoint,
|
||||||
|
cidr: gateway.vpn_cidr,
|
||||||
|
status: gateway.is_active ? "active" : "inactive"
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Gateways" subtitle="Track WireGuard endpoints and sync state.">
|
||||||
|
{query.isError ? <p className="notice">Unable to load gateways from the API.</p> : null}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
admin-web/src/features/policies/PoliciesPage.tsx
Normal file
41
admin-web/src/features/policies/PoliciesPage.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api } from "../../api/client";
|
||||||
|
import { Page } from "../../components/Page";
|
||||||
|
import { Table } from "../../components/Table";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: "name", label: "Policy" },
|
||||||
|
{ key: "targets", label: "Targets" },
|
||||||
|
{ key: "destinations", label: "Destinations" },
|
||||||
|
{ key: "mode", label: "Mode" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PoliciesPage() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["policies"],
|
||||||
|
queryFn: api.policies
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = query.data?.map((policy) => ({
|
||||||
|
name: policy.name,
|
||||||
|
targets: "assigned targets",
|
||||||
|
destinations: policy.destinations?.join(", ") ?? (policy.full_tunnel ? "0.0.0.0/0" : "-"),
|
||||||
|
mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel"
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
title="Policies"
|
||||||
|
subtitle="Manage per-user and per-device access controls enforced at the gateway."
|
||||||
|
actions={<button className="button">New policy</button>}
|
||||||
|
>
|
||||||
|
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
admin-web/src/features/settings/SettingsPage.tsx
Normal file
21
admin-web/src/features/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Card } from "../../components/Card";
|
||||||
|
import { Page } from "../../components/Page";
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<Page title="Settings" subtitle="Default DNS, token lifetime, and deployment configuration surfaces.">
|
||||||
|
<div className="grid two">
|
||||||
|
<Card>
|
||||||
|
<h4>Auth</h4>
|
||||||
|
<p>Access token TTL: 15 minutes</p>
|
||||||
|
<p>Refresh token TTL: 30 days</p>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<h4>VPN</h4>
|
||||||
|
<p>Default DNS: 10.20.0.53</p>
|
||||||
|
<p>Address pool: 100.96.0.0/24</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
admin-web/src/features/users/UsersPage.tsx
Normal file
41
admin-web/src/features/users/UsersPage.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api } from "../../api/client";
|
||||||
|
import { Page } from "../../components/Page";
|
||||||
|
import { Table } from "../../components/Table";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: "username", label: "Username" },
|
||||||
|
{ key: "name", label: "Display name" },
|
||||||
|
{ key: "role", label: "Role" },
|
||||||
|
{ key: "status", label: "Status" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: api.users
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = query.data?.map((user) => ({
|
||||||
|
username: user.username,
|
||||||
|
name: user.display_name,
|
||||||
|
role: user.role,
|
||||||
|
status: user.is_active ? "active" : "disabled"
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
title="Users"
|
||||||
|
subtitle="Create, disable, and manage platform identities."
|
||||||
|
actions={<button className="button">New user</button>}
|
||||||
|
>
|
||||||
|
{query.isError ? <p className="notice">Unable to load users from the API.</p> : null}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
admin-web/src/main.tsx
Normal file
19
admin-web/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import { App } from "./app/App";
|
||||||
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
const client = new QueryClient();
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
227
admin-web/src/styles/global.css
Normal file
227
admin-web/src/styles/global.css
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #0c1322;
|
||||||
|
--bg-soft: #111b30;
|
||||||
|
--panel: rgba(17, 27, 48, 0.78);
|
||||||
|
--panel-strong: #14203a;
|
||||||
|
--text: #eff4ff;
|
||||||
|
--muted: #9fb2d4;
|
||||||
|
--line: rgba(177, 197, 229, 0.15);
|
||||||
|
--accent: #74e0b8;
|
||||||
|
--accent-strong: #1fb67a;
|
||||||
|
--shadow: 0 18px 48px rgba(3, 8, 20, 0.35);
|
||||||
|
font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(116, 224, 184, 0.17), transparent 28%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(98, 144, 255, 0.16), transparent 24%),
|
||||||
|
linear-gradient(180deg, #08101d 0%, #0d1728 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 28px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card input {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(8, 14, 26, 0.86);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 28px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: rgba(7, 12, 22, 0.76);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(116, 224, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.two {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.table-wrap {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 22px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 14px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.pill {
|
||||||
|
border: 0;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||||
|
color: #04141a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label,
|
||||||
|
.page-header p,
|
||||||
|
.card p {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
display: block;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
margin: 0;
|
||||||
|
color: #ffcf9b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.two,
|
||||||
|
.grid.three {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
admin-web/tsconfig.app.json
Normal file
18
admin-web/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
6
admin-web/tsconfig.json
Normal file
6
admin-web/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
10
admin-web/vite.config.ts
Normal file
10
admin-web/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true
|
||||||
|
}
|
||||||
|
});
|
||||||
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/nexavpn-api ./cmd/api
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /out/nexavpn-api /usr/local/bin/nexavpn-api
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY seed ./seed
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/usr/local/bin/nexavpn-api"]
|
||||||
48
backend/cmd/api/main.go
Normal file
48
backend/cmd/api/main.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/app"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
application, err := app.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize app: %v", err)
|
||||||
|
}
|
||||||
|
defer application.Close()
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.HTTPAddress,
|
||||||
|
Handler: application.Router,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("nexavpn backend listening on %s", cfg.HTTPAddress)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("http server failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-stop
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("server shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/go.mod
Normal file
11
backend/go.mod
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module github.com/nexavpn/nexavpn/backend
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2
|
||||||
|
golang.org/x/crypto v0.36.0
|
||||||
|
)
|
||||||
26
backend/internal/apiutil/respond.go
Normal file
26
backend/internal/apiutil/respond.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package apiutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func JSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
resp := ErrorResponse{}
|
||||||
|
resp.Error.Code = code
|
||||||
|
resp.Error.Message = message
|
||||||
|
JSON(w, status, resp)
|
||||||
|
}
|
||||||
62
backend/internal/app/app.go
Normal file
62
backend/internal/app/app.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/audit"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/auth"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/config"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/db"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/device"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/gateway"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/httpserver"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/ipam"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/policy"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
DB *pgxpool.Pool
|
||||||
|
Router http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.Config) (*App, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
authRepo := auth.NewPGRepository(pool)
|
||||||
|
authService := auth.NewService(authRepo, cfg.JWTSecret, cfg.JWTIssuer, cfg.AccessTokenTTL, cfg.RefreshTokenTTL)
|
||||||
|
|
||||||
|
userService := user.NewService(user.NewPGRepository(pool))
|
||||||
|
policyService := policy.NewService(policy.NewPGRepository(pool))
|
||||||
|
gatewayService := gateway.NewService(gateway.NewPGRepository(pool))
|
||||||
|
deviceService := device.NewService(device.NewPGRepository(pool), policyService, gatewayService, ipam.NewService())
|
||||||
|
auditService := audit.NewService(audit.NewPGRepository(pool))
|
||||||
|
|
||||||
|
router := httpserver.NewRouter(cfg.JWTSecret, httpserver.Handlers{
|
||||||
|
Auth: auth.NewHandler(authService, auditService),
|
||||||
|
User: user.NewHandler(userService, auditService),
|
||||||
|
Device: device.NewHandler(deviceService, auditService),
|
||||||
|
Policy: policy.NewHandler(policyService, auditService),
|
||||||
|
Gateway: gateway.NewHandler(gatewayService),
|
||||||
|
Audit: audit.NewHandler(auditService),
|
||||||
|
})
|
||||||
|
|
||||||
|
return &App{
|
||||||
|
DB: pool,
|
||||||
|
Router: router,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Close() {
|
||||||
|
if a.DB != nil {
|
||||||
|
a.DB.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/internal/audit/handler.go
Normal file
25
backend/internal/audit/handler.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.service.List(r.Context(), 100)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "audit_list_failed", "unable to list audit logs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
70
backend/internal/audit/repository.go
Normal file
70
backend/internal/audit/repository.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PGRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
|
||||||
|
return &PGRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Write(ctx context.Context, entry Entry) error {
|
||||||
|
const query = `
|
||||||
|
insert into audit_logs (id, actor_user_id, entity_type, entity_id, event_type, status, message, metadata)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.db.Exec(
|
||||||
|
ctx,
|
||||||
|
query,
|
||||||
|
uuid.New(),
|
||||||
|
entry.ActorUserID,
|
||||||
|
entry.EntityType,
|
||||||
|
entry.EntityID,
|
||||||
|
entry.EventType,
|
||||||
|
entry.Status,
|
||||||
|
entry.Message,
|
||||||
|
MarshalMetadata(entry.Metadata),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) List(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select id, event_type, entity_type, status, message, created_at
|
||||||
|
from audit_logs
|
||||||
|
order by created_at desc
|
||||||
|
limit $1
|
||||||
|
`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []map[string]any
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
var eventType, entityType, status, message string
|
||||||
|
var createdAt any
|
||||||
|
if err := rows.Scan(&id, &eventType, &entityType, &status, &message, &createdAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"event_type": eventType,
|
||||||
|
"entity_type": entityType,
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"created_at": createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
47
backend/internal/audit/service.go
Normal file
47
backend/internal/audit/service.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
ActorUserID *uuid.UUID
|
||||||
|
EntityType string
|
||||||
|
EntityID *uuid.UUID
|
||||||
|
EventType string
|
||||||
|
Status string
|
||||||
|
Message string
|
||||||
|
Metadata map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
Write(ctx context.Context, entry Entry) error
|
||||||
|
List(ctx context.Context, limit int) ([]map[string]any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo Repository) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Record(ctx context.Context, entry Entry) error {
|
||||||
|
if entry.Metadata == nil {
|
||||||
|
entry.Metadata = map[string]any{}
|
||||||
|
}
|
||||||
|
return s.repo.Write(ctx, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||||
|
return s.repo.List(ctx, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarshalMetadata(metadata map[string]any) []byte {
|
||||||
|
raw, _ := json.Marshal(metadata)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
137
backend/internal/auth/handler.go
Normal file
137
backend/internal/auth/handler.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/audit"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service, auditService *audit.Service) *Handler {
|
||||||
|
return &Handler{service: service, audit: auditService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.service.Login(r.Context(), input.Username, input.Password, r.RemoteAddr, r.UserAgent())
|
||||||
|
if err != nil {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
EventType: "auth.login.failed",
|
||||||
|
EntityType: "user",
|
||||||
|
Status: "failed",
|
||||||
|
Message: "user login failed",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"username": input.Username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &response.User.ID,
|
||||||
|
EventType: "auth.login",
|
||||||
|
EntityType: "user",
|
||||||
|
EntityID: &response.User.ID,
|
||||||
|
Status: "success",
|
||||||
|
Message: "user login succeeded",
|
||||||
|
})
|
||||||
|
apiutil.JSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input BootstrapRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Username == "" || input.Password == "" {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "validation_error", "username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.DisplayName == "" {
|
||||||
|
input.DisplayName = input.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.service.BootstrapAdmin(r.Context(), input.Username, input.DisplayName, input.Password)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusConflict, "bootstrap_failed", "initial admin already exists or could not be created")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &user.ID,
|
||||||
|
EntityType: "user",
|
||||||
|
EntityID: &user.ID,
|
||||||
|
EventType: "system.bootstrap_admin",
|
||||||
|
Status: "success",
|
||||||
|
Message: "initial admin account created",
|
||||||
|
})
|
||||||
|
apiutil.JSON(w, http.StatusCreated, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input RefreshRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.service.Refresh(r.Context(), input.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "invalid_refresh_token", "unable to refresh session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input RefreshRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.Logout(r.Context(), input.RefreshToken); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "logout_failed", "unable to revoke session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EventType: "auth.logout",
|
||||||
|
EntityType: "session",
|
||||||
|
Status: "success",
|
||||||
|
Message: "session logout succeeded",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := httpserver.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"id": claims.UserID,
|
||||||
|
"username": claims.Username,
|
||||||
|
"role": claims.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
11
backend/internal/auth/hash.go
Normal file
11
backend/internal/auth/hash.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
)
|
||||||
|
|
||||||
|
func base64Hash(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
40
backend/internal/auth/password.go
Normal file
40
backend/internal/auth/password.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
||||||
|
return fmt.Sprintf("argon2id$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyPassword(hashValue, password string) bool {
|
||||||
|
parts := strings.Split(hashValue, "$")
|
||||||
|
if len(parts) != 3 || parts[0] != "argon2id" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expected, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
||||||
|
return subtle.ConstantTimeCompare(expected, actual) == 1
|
||||||
|
}
|
||||||
105
backend/internal/auth/repository.go
Normal file
105
backend/internal/auth/repository.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PGRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
|
||||||
|
return &PGRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) FindUserByUsername(ctx context.Context, username string) (UserRecord, error) {
|
||||||
|
const query = `
|
||||||
|
select u.id, u.username, u.display_name, r.name, u.password_hash, u.is_active
|
||||||
|
from users u
|
||||||
|
join roles r on r.id = u.role_id
|
||||||
|
where u.username = $1 and u.deleted_at is null
|
||||||
|
`
|
||||||
|
|
||||||
|
row := r.db.QueryRow(ctx, query, username)
|
||||||
|
record := UserRecord{}
|
||||||
|
if err := row.Scan(&record.ID, &record.Username, &record.DisplayName, &record.Role, &record.PasswordHash, &record.IsActive); err != nil {
|
||||||
|
return UserRecord{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error) {
|
||||||
|
const query = `
|
||||||
|
insert into sessions (id, user_id, ip_address, user_agent, expires_at)
|
||||||
|
values ($1, $2, nullif($3, '')::inet, $4, $5)
|
||||||
|
`
|
||||||
|
|
||||||
|
id := uuid.New()
|
||||||
|
_, err := r.db.Exec(ctx, query, id, userID, ipAddress, userAgent, expiresAt)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) StoreRefreshToken(ctx context.Context, sessionID uuid.UUID, userID uuid.UUID, tokenHash string, expiresAt time.Time) error {
|
||||||
|
const query = `
|
||||||
|
insert into refresh_tokens (id, session_id, user_id, token_hash, expires_at)
|
||||||
|
values ($1, $2, $3, $4, $5)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.db.Exec(ctx, query, uuid.New(), sessionID, userID, tokenHash, expiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) FindRefreshToken(ctx context.Context, tokenHash string) (UserRecord, uuid.UUID, error) {
|
||||||
|
const query = `
|
||||||
|
select u.id, u.username, u.display_name, roles.name, u.password_hash, u.is_active, rt.session_id
|
||||||
|
from refresh_tokens rt
|
||||||
|
join users u on u.id = rt.user_id
|
||||||
|
join roles on roles.id = u.role_id
|
||||||
|
where rt.token_hash = $1 and rt.revoked_at is null and rt.expires_at > now()
|
||||||
|
`
|
||||||
|
|
||||||
|
record := UserRecord{}
|
||||||
|
var sessionID uuid.UUID
|
||||||
|
row := r.db.QueryRow(ctx, query, tokenHash)
|
||||||
|
if err := row.Scan(&record.ID, &record.Username, &record.DisplayName, &record.Role, &record.PasswordHash, &record.IsActive, &sessionID); err != nil {
|
||||||
|
return UserRecord{}, uuid.Nil, err
|
||||||
|
}
|
||||||
|
if !record.IsActive {
|
||||||
|
return UserRecord{}, uuid.Nil, errors.New("user inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
return record, sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
||||||
|
const query = `update refresh_tokens set revoked_at = now() where token_hash = $1 and revoked_at is null`
|
||||||
|
_, err := r.db.Exec(ctx, query, tokenHash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) HasUsers(ctx context.Context) (bool, error) {
|
||||||
|
var count int
|
||||||
|
if err := r.db.QueryRow(ctx, `select count(*) from users where deleted_at is null`).Scan(&count); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) CreateBootstrapAdmin(ctx context.Context, username, displayName, passwordHash string) (UserRecord, error) {
|
||||||
|
const query = `
|
||||||
|
insert into users (id, role_id, username, display_name, password_hash, is_active)
|
||||||
|
values ($1, (select id from roles where name = 'admin'), $2, $3, $4, true)
|
||||||
|
returning id, username, display_name, password_hash, is_active
|
||||||
|
`
|
||||||
|
|
||||||
|
record := UserRecord{Role: "admin"}
|
||||||
|
err := r.db.QueryRow(ctx, query, uuid.New(), username, displayName, passwordHash).
|
||||||
|
Scan(&record.ID, &record.Username, &record.DisplayName, &record.PasswordHash, &record.IsActive)
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
155
backend/internal/auth/service.go
Normal file
155
backend/internal/auth/service.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
|
||||||
|
type UserRecord struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
Role string
|
||||||
|
PasswordHash string
|
||||||
|
IsActive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
FindUserByUsername(ctx context.Context, username string) (UserRecord, error)
|
||||||
|
CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error)
|
||||||
|
StoreRefreshToken(ctx context.Context, sessionID uuid.UUID, userID uuid.UUID, tokenHash string, expiresAt time.Time) error
|
||||||
|
FindRefreshToken(ctx context.Context, tokenHash string) (UserRecord, uuid.UUID, error)
|
||||||
|
RevokeRefreshToken(ctx context.Context, tokenHash string) error
|
||||||
|
HasUsers(ctx context.Context) (bool, error)
|
||||||
|
CreateBootstrapAdmin(ctx context.Context, username, displayName, passwordHash string) (UserRecord, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo Repository
|
||||||
|
jwtSecret string
|
||||||
|
jwtIssuer string
|
||||||
|
accessTokenTTL time.Duration
|
||||||
|
refreshTokenTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo Repository, jwtSecret, jwtIssuer string, accessTokenTTL, refreshTokenTTL time.Duration) *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
jwtSecret: jwtSecret,
|
||||||
|
jwtIssuer: jwtIssuer,
|
||||||
|
accessTokenTTL: accessTokenTTL,
|
||||||
|
refreshTokenTTL: refreshTokenTTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Login(ctx context.Context, username, password, ipAddress, userAgent string) (LoginResponse, error) {
|
||||||
|
record, err := s.repo.FindUserByUsername(ctx, username)
|
||||||
|
if err != nil || !record.IsActive || !VerifyPassword(record.PasswordHash, password) {
|
||||||
|
return LoginResponse{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := s.repo.CreateSession(ctx, record.ID, time.Now().Add(s.refreshTokenTTL), ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
return LoginResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
plainRefresh, hashedRefresh, err := NewRefreshToken()
|
||||||
|
if err != nil {
|
||||||
|
return LoginResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.StoreRefreshToken(ctx, sessionID, record.ID, hashedRefresh, time.Now().Add(s.refreshTokenTTL)); err != nil {
|
||||||
|
return LoginResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, Claims{
|
||||||
|
UserID: record.ID,
|
||||||
|
Username: record.Username,
|
||||||
|
Role: record.Role,
|
||||||
|
Session: sessionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return LoginResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoginResponse{
|
||||||
|
AccessToken: access,
|
||||||
|
RefreshToken: plainRefresh,
|
||||||
|
ExpiresIn: int64(s.accessTokenTTL.Seconds()),
|
||||||
|
User: UserView{
|
||||||
|
ID: record.ID,
|
||||||
|
Username: record.Username,
|
||||||
|
DisplayName: record.DisplayName,
|
||||||
|
Role: record.Role,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Refresh(ctx context.Context, refreshToken string) (LoginResponse, error) {
|
||||||
|
record, sessionID, err := s.repo.FindRefreshToken(ctx, hashToken(refreshToken))
|
||||||
|
if err != nil {
|
||||||
|
return LoginResponse{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, Claims{
|
||||||
|
UserID: record.ID,
|
||||||
|
Username: record.Username,
|
||||||
|
Role: record.Role,
|
||||||
|
Session: sessionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return LoginResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoginResponse{
|
||||||
|
AccessToken: access,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresIn: int64(s.accessTokenTTL.Seconds()),
|
||||||
|
User: UserView{
|
||||||
|
ID: record.ID,
|
||||||
|
Username: record.Username,
|
||||||
|
DisplayName: record.DisplayName,
|
||||||
|
Role: record.Role,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Logout(ctx context.Context, refreshToken string) error {
|
||||||
|
return s.repo.RevokeRefreshToken(ctx, hashToken(refreshToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BootstrapAdmin(ctx context.Context, username, displayName, password string) (UserView, error) {
|
||||||
|
hasUsers, err := s.repo.HasUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return UserView{}, err
|
||||||
|
}
|
||||||
|
if hasUsers {
|
||||||
|
return UserView{}, errors.New("bootstrap already completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return UserView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := s.repo.CreateBootstrapAdmin(ctx, username, displayName, passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
return UserView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserView{
|
||||||
|
ID: record.ID,
|
||||||
|
Username: record.Username,
|
||||||
|
DisplayName: record.DisplayName,
|
||||||
|
Role: record.Role,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(plain string) string {
|
||||||
|
return base64Hash(plain)
|
||||||
|
}
|
||||||
77
backend/internal/auth/token.go
Normal file
77
backend/internal/auth/token.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRefreshToken() (plain string, hashed string, err error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(raw); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
plain = base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
sum := sha256.Sum256([]byte(plain))
|
||||||
|
hashed = base64.RawURLEncoding.EncodeToString(sum[:])
|
||||||
|
return plain, hashed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SignAccessToken(secret, issuer string, ttl time.Duration, claims Claims) (string, error) {
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"iss": issuer,
|
||||||
|
"sub": claims.UserID.String(),
|
||||||
|
"username": claims.Username,
|
||||||
|
"role": claims.Role,
|
||||||
|
"session_id": claims.Session.String(),
|
||||||
|
"exp": time.Now().Add(ttl).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return token.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseAccessToken(secret string, tokenString string) (Claims, error) {
|
||||||
|
claims := Claims{}
|
||||||
|
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return claims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mapClaims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return claims, jwt.ErrTokenMalformed
|
||||||
|
}
|
||||||
|
|
||||||
|
subject, ok := mapClaims["sub"].(string)
|
||||||
|
if !ok {
|
||||||
|
return claims, jwt.ErrTokenMalformed
|
||||||
|
}
|
||||||
|
sessionValue, ok := mapClaims["session_id"].(string)
|
||||||
|
if !ok {
|
||||||
|
return claims, jwt.ErrTokenMalformed
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := uuid.Parse(subject)
|
||||||
|
if err != nil {
|
||||||
|
return claims, err
|
||||||
|
}
|
||||||
|
sessionID, err := uuid.Parse(sessionValue)
|
||||||
|
if err != nil {
|
||||||
|
return claims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims.UserID = userID
|
||||||
|
claims.Session = sessionID
|
||||||
|
claims.Username, _ = mapClaims["username"].(string)
|
||||||
|
claims.Role, _ = mapClaims["role"].(string)
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
39
backend/internal/auth/types.go
Normal file
39
backend/internal/auth/types.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Session uuid.UUID `json:"session_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BootstrapRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
User UserView `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserView struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
82
backend/internal/config/config.go
Normal file
82
backend/internal/config/config.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AppName string
|
||||||
|
Environment string
|
||||||
|
HTTPAddress string
|
||||||
|
DatabaseURL string
|
||||||
|
JWTIssuer string
|
||||||
|
JWTSecret string
|
||||||
|
AccessTokenTTL time.Duration
|
||||||
|
RefreshTokenTTL time.Duration
|
||||||
|
DefaultGatewayID string
|
||||||
|
DefaultDNS []string
|
||||||
|
DefaultVPNCIDR string
|
||||||
|
DefaultGatewayHost string
|
||||||
|
DefaultGatewayPubKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
AppName: getenv("APP_NAME", "NexaVPN"),
|
||||||
|
Environment: getenv("APP_ENV", "development"),
|
||||||
|
HTTPAddress: getenv("HTTP_ADDRESS", ":8080"),
|
||||||
|
DatabaseURL: getenv("DATABASE_URL", "postgres://nexavpn:nexavpn@localhost:5432/nexavpn?sslmode=disable"),
|
||||||
|
JWTIssuer: getenv("JWT_ISSUER", "nexavpn"),
|
||||||
|
JWTSecret: getenv("JWT_SECRET", "change-me-in-production"),
|
||||||
|
AccessTokenTTL: time.Duration(getenvInt("ACCESS_TOKEN_TTL_SECONDS", 900)) * time.Second,
|
||||||
|
RefreshTokenTTL: time.Duration(getenvInt("REFRESH_TOKEN_TTL_SECONDS", 2592000)) * time.Second,
|
||||||
|
DefaultGatewayID: getenv("DEFAULT_GATEWAY_ID", ""),
|
||||||
|
DefaultDNS: splitCSV(getenv("DEFAULT_DNS_SERVERS", "10.20.0.53")),
|
||||||
|
DefaultVPNCIDR: getenv("DEFAULT_VPN_CIDR", "100.96.0.0/24"),
|
||||||
|
DefaultGatewayHost: getenv("DEFAULT_GATEWAY_ENDPOINT", "vpn.example.com:51820"),
|
||||||
|
DefaultGatewayPubKey: getenv("DEFAULT_GATEWAY_PUBLIC_KEY", "replace-me"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvInt(key string, fallback int) int {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(value string) []string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []string
|
||||||
|
start := 0
|
||||||
|
for i := range value {
|
||||||
|
if value[i] == ',' {
|
||||||
|
if start < i {
|
||||||
|
items = append(items, value[start:i])
|
||||||
|
}
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < len(value) {
|
||||||
|
items = append(items, value[start:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
11
backend/internal/db/db.go
Normal file
11
backend/internal/db/db.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||||
|
return pgxpool.New(ctx, databaseURL)
|
||||||
|
}
|
||||||
176
backend/internal/device/handler.go
Normal file
176
backend/internal/device/handler.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/audit"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service, auditService *audit.Service) *Handler {
|
||||||
|
return &Handler{service: service, audit: auditService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Enroll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input EnrollRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := httpserver.MustUserID(r.Context())
|
||||||
|
if !ok {
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.service.Enroll(r.Context(), userID, input, "__CLIENT_GENERATED_PRIVATE_KEY__")
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "device_enroll_failed", "unable to enroll device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &userID,
|
||||||
|
EntityType: "device",
|
||||||
|
EntityID: &response.Device.ID,
|
||||||
|
EventType: "device.enrolled",
|
||||||
|
Status: "success",
|
||||||
|
Message: "device enrolled and profile issued",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"platform": response.Device.Platform,
|
||||||
|
"assigned_ip": response.Peer.AssignedIP,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
apiutil.JSON(w, http.StatusCreated, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListOwn(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := httpserver.MustUserID(r.Context())
|
||||||
|
if !ok {
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := h.service.ListByUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "devices_list_failed", "unable to list devices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
devices, err := h.service.ListAll(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "devices_list_failed", "unable to list devices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ConnectionStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := httpserver.MustUserID(r.Context())
|
||||||
|
if !ok {
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := h.service.GetConnectionStatus(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "connection_status_failed", "unable to fetch connection status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetOwnProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := httpserver.MustUserID(r.Context())
|
||||||
|
if !ok {
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.service.GetLatestEnrollmentByUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusNotFound, "profile_not_found", "no active profile found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetProfileByDeviceID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
deviceID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_device_id", "invalid device id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.service.GetEnrollmentByDeviceID(r.Context(), deviceID)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusNotFound, "profile_not_found", "device profile not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
deviceID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_device_id", "invalid device id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.Revoke(r.Context(), deviceID); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "device_revoke_failed", "unable to revoke device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EntityType: "device",
|
||||||
|
EntityID: &deviceID,
|
||||||
|
EventType: "admin.device.revoked",
|
||||||
|
Status: "success",
|
||||||
|
Message: "admin revoked device",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Rotate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
deviceID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_device_id", "invalid device id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.Rotate(r.Context(), deviceID); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "device_rotate_failed", "unable to rotate device profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EntityType: "device",
|
||||||
|
EntityID: &deviceID,
|
||||||
|
EventType: "admin.device.rotated",
|
||||||
|
Status: "success",
|
||||||
|
Message: "admin rotated device profile",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
265
backend/internal/device/repository.go
Normal file
265
backend/internal/device/repository.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
Enroll(ctx context.Context, userID uuid.UUID, gatewayID uuid.UUID, input EnrollRequest, assignedIP string, dnsServers []string, allowedIPs []string) (EnrollmentResponse, error)
|
||||||
|
ListByUser(ctx context.Context, userID uuid.UUID) ([]Device, error)
|
||||||
|
ListAll(ctx context.Context) ([]Device, error)
|
||||||
|
GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUID) (EnrollmentResponse, error)
|
||||||
|
GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error)
|
||||||
|
Revoke(ctx context.Context, deviceID uuid.UUID) error
|
||||||
|
Rotate(ctx context.Context, deviceID uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type PGRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
|
||||||
|
return &PGRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Enroll(ctx context.Context, userID uuid.UUID, gatewayID uuid.UUID, input EnrollRequest, assignedIP string, dnsServers []string, allowedIPs []string) (EnrollmentResponse, error) {
|
||||||
|
tx, err := r.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
deviceID := uuid.New()
|
||||||
|
peerID := uuid.New()
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into devices (
|
||||||
|
id, user_id, gateway_id, name, platform, os_version, app_version, device_fingerprint, public_key, status, approved_at
|
||||||
|
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', now())
|
||||||
|
`, deviceID, userID, gatewayID, input.Name, input.Platform, input.OSVersion, input.AppVersion, input.DeviceFingerprint, input.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into wireguard_peers (
|
||||||
|
id, device_id, gateway_id, public_key, assigned_ip, allowed_ips, dns_servers, last_profile_issued_at
|
||||||
|
) values ($1, $2, $3, $4, $5::inet, $6::cidr[], $7::text[], now())
|
||||||
|
`, peerID, deviceID, gatewayID, input.PublicKey, assignedIP, allowedIPs, dnsServers)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into ip_allocations (id, gateway_id, device_id, address, status)
|
||||||
|
values ($1, $2, $3, $4::inet, 'allocated')
|
||||||
|
`, uuid.New(), gatewayID, deviceID, assignedIP)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return EnrollmentResponse{
|
||||||
|
Device: Device{
|
||||||
|
ID: deviceID,
|
||||||
|
UserID: userID,
|
||||||
|
GatewayID: gatewayID,
|
||||||
|
Name: input.Name,
|
||||||
|
Platform: input.Platform,
|
||||||
|
Status: "active",
|
||||||
|
AssignedIP: assignedIP,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUID) (EnrollmentResponse, error) {
|
||||||
|
row := r.db.QueryRow(ctx, `
|
||||||
|
select
|
||||||
|
d.id,
|
||||||
|
d.user_id,
|
||||||
|
d.gateway_id,
|
||||||
|
d.name,
|
||||||
|
d.platform,
|
||||||
|
d.status,
|
||||||
|
host(wp.assigned_ip),
|
||||||
|
wp.profile_revision,
|
||||||
|
wp.last_profile_issued_at,
|
||||||
|
g.name,
|
||||||
|
g.endpoint,
|
||||||
|
g.public_key,
|
||||||
|
wp.dns_servers,
|
||||||
|
wp.allowed_ips
|
||||||
|
from devices d
|
||||||
|
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
||||||
|
join gateways g on g.id = wp.gateway_id
|
||||||
|
where d.user_id = $1 and d.deleted_at is null
|
||||||
|
order by d.created_at desc
|
||||||
|
limit 1
|
||||||
|
`, userID)
|
||||||
|
return scanEnrollmentRow(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error) {
|
||||||
|
row := r.db.QueryRow(ctx, `
|
||||||
|
select
|
||||||
|
d.id,
|
||||||
|
d.user_id,
|
||||||
|
d.gateway_id,
|
||||||
|
d.name,
|
||||||
|
d.platform,
|
||||||
|
d.status,
|
||||||
|
host(wp.assigned_ip),
|
||||||
|
wp.profile_revision,
|
||||||
|
wp.last_profile_issued_at,
|
||||||
|
g.name,
|
||||||
|
g.endpoint,
|
||||||
|
g.public_key,
|
||||||
|
wp.dns_servers,
|
||||||
|
wp.allowed_ips
|
||||||
|
from devices d
|
||||||
|
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
||||||
|
join gateways g on g.id = wp.gateway_id
|
||||||
|
where d.id = $1 and d.deleted_at is null
|
||||||
|
`, deviceID)
|
||||||
|
return scanEnrollmentRow(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Device, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select d.id, d.user_id, d.gateway_id, d.name, d.platform, d.status, coalesce(host(wp.assigned_ip), '')
|
||||||
|
from devices d
|
||||||
|
left join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
||||||
|
where d.user_id = $1 and d.deleted_at is null
|
||||||
|
order by d.created_at desc
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []Device
|
||||||
|
for rows.Next() {
|
||||||
|
var item Device
|
||||||
|
if err := rows.Scan(&item.ID, &item.UserID, &item.GatewayID, &item.Name, &item.Platform, &item.Status, &item.AssignedIP); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) ListAll(ctx context.Context) ([]Device, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select d.id, d.user_id, d.gateway_id, d.name, d.platform, d.status, coalesce(host(wp.assigned_ip), '')
|
||||||
|
from devices d
|
||||||
|
left join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
||||||
|
where d.deleted_at is null
|
||||||
|
order by d.created_at desc
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []Device
|
||||||
|
for rows.Next() {
|
||||||
|
var item Device
|
||||||
|
if err := rows.Scan(&item.ID, &item.UserID, &item.GatewayID, &item.Name, &item.Platform, &item.Status, &item.AssignedIP); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Revoke(ctx context.Context, deviceID uuid.UUID) error {
|
||||||
|
tx, err := r.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `update devices set status = 'revoked', revoked_at = now(), updated_at = now() where id = $1`, deviceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `update wireguard_peers set deleted_at = now(), updated_at = now() where device_id = $1 and deleted_at is null`, deviceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `update ip_allocations set status = 'released', released_at = now(), updated_at = now() where device_id = $1 and status = 'allocated'`, deviceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Rotate(ctx context.Context, deviceID uuid.UUID) error {
|
||||||
|
_, err := r.db.Exec(ctx, `
|
||||||
|
update wireguard_peers
|
||||||
|
set profile_revision = profile_revision + 1, last_profile_issued_at = now(), updated_at = now()
|
||||||
|
where device_id = $1 and deleted_at is null
|
||||||
|
`, deviceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type enrollmentRowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanEnrollmentRow(row enrollmentRowScanner) (EnrollmentResponse, error) {
|
||||||
|
var response EnrollmentResponse
|
||||||
|
var profileRevision int
|
||||||
|
var lastIssuedAt *time.Time
|
||||||
|
var gatewayName string
|
||||||
|
var gatewayEndpoint string
|
||||||
|
var gatewayPublicKey string
|
||||||
|
var dnsServers []string
|
||||||
|
var allowedIPs []string
|
||||||
|
|
||||||
|
if err := row.Scan(
|
||||||
|
&response.Device.ID,
|
||||||
|
&response.Device.UserID,
|
||||||
|
&response.Device.GatewayID,
|
||||||
|
&response.Device.Name,
|
||||||
|
&response.Device.Platform,
|
||||||
|
&response.Device.Status,
|
||||||
|
&response.Device.AssignedIP,
|
||||||
|
&profileRevision,
|
||||||
|
&lastIssuedAt,
|
||||||
|
&gatewayName,
|
||||||
|
&gatewayEndpoint,
|
||||||
|
&gatewayPublicKey,
|
||||||
|
&dnsServers,
|
||||||
|
&allowedIPs,
|
||||||
|
); err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Peer = PeerView{
|
||||||
|
AssignedIP: response.Device.AssignedIP,
|
||||||
|
DNSServers: dnsServers,
|
||||||
|
AllowedIPs: allowedIPs,
|
||||||
|
Gateway: GatewayView{
|
||||||
|
ID: response.Device.GatewayID,
|
||||||
|
Name: gatewayName,
|
||||||
|
Endpoint: gatewayEndpoint,
|
||||||
|
PublicKey: gatewayPublicKey,
|
||||||
|
},
|
||||||
|
ProfileRevision: profileRevision,
|
||||||
|
}
|
||||||
|
for _, destination := range allowedIPs {
|
||||||
|
response.Resources = append(response.Resources, Resource{
|
||||||
|
Type: "cidr",
|
||||||
|
Value: destination,
|
||||||
|
Label: destination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = lastIssuedAt
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
130
backend/internal/device/service.go
Normal file
130
backend/internal/device/service.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/gateway"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/ipam"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/policy"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo Repository
|
||||||
|
policyService *policy.Service
|
||||||
|
gatewayService *gateway.Service
|
||||||
|
ipamService *ipam.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo Repository, policyService *policy.Service, gatewayService *gateway.Service, ipamService *ipam.Service) *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
policyService: policyService,
|
||||||
|
gatewayService: gatewayService,
|
||||||
|
ipamService: ipamService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Enroll(ctx context.Context, userID uuid.UUID, input EnrollRequest, privateKeyPlaceholder string) (EnrollmentResponse, error) {
|
||||||
|
selectedGateway, err := s.gatewayService.SelectActive(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
assignedIP, err := s.ipamService.Allocate(selectedGateway.VPNCIDR, 10)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
enrollment, err := s.repo.Enroll(ctx, userID, selectedGateway.ID, input, assignedIP, selectedGateway.DNSServers, nil)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
destinations, err := s.policyService.ResolveDestinations(ctx, userID, &enrollment.Device.ID)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
if len(destinations) == 0 {
|
||||||
|
destinations = []string{"172.16.10.0/24"}
|
||||||
|
}
|
||||||
|
|
||||||
|
enrollment.Peer = PeerView{
|
||||||
|
AssignedIP: assignedIP,
|
||||||
|
DNSServers: selectedGateway.DNSServers,
|
||||||
|
AllowedIPs: destinations,
|
||||||
|
Gateway: GatewayView{
|
||||||
|
ID: selectedGateway.ID,
|
||||||
|
Name: selectedGateway.Name,
|
||||||
|
Endpoint: selectedGateway.Endpoint,
|
||||||
|
PublicKey: selectedGateway.PublicKey,
|
||||||
|
},
|
||||||
|
ProfileRevision: 1,
|
||||||
|
}
|
||||||
|
for _, destination := range destinations {
|
||||||
|
enrollment.Resources = append(enrollment.Resources, Resource{
|
||||||
|
Type: "cidr",
|
||||||
|
Value: destination,
|
||||||
|
Label: destination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
enrollment.Profile = ProfileView{
|
||||||
|
Format: "wireguard",
|
||||||
|
Content: profile.BuildWireGuardConfig(profile.BuildInput{
|
||||||
|
PrivateKey: privateKeyPlaceholder,
|
||||||
|
Address: assignedIP,
|
||||||
|
DNSServers: selectedGateway.DNSServers,
|
||||||
|
ServerPublicKey: selectedGateway.PublicKey,
|
||||||
|
ServerEndpoint: selectedGateway.Endpoint,
|
||||||
|
AllowedIPs: destinations,
|
||||||
|
PersistentKeepal: 25,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return enrollment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListByUser(ctx context.Context, userID uuid.UUID) ([]Device, error) {
|
||||||
|
return s.repo.ListByUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListAll(ctx context.Context) ([]Device, error) {
|
||||||
|
return s.repo.ListAll(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUID) (EnrollmentResponse, error) {
|
||||||
|
return s.repo.GetLatestEnrollmentByUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error) {
|
||||||
|
return s.repo.GetEnrollmentByDeviceID(ctx, deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetConnectionStatus(ctx context.Context, userID uuid.UUID) (ConnectionStatus, error) {
|
||||||
|
enrollment, err := s.repo.GetLatestEnrollmentByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return ConnectionStatus{
|
||||||
|
Status: "disconnected",
|
||||||
|
Resources: []Resource{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSync := "just now"
|
||||||
|
return ConnectionStatus{
|
||||||
|
Status: "provisioned",
|
||||||
|
AssignedIP: enrollment.Peer.AssignedIP,
|
||||||
|
LastSyncTime: &lastSync,
|
||||||
|
Resources: enrollment.Resources,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Revoke(ctx context.Context, deviceID uuid.UUID) error {
|
||||||
|
return s.repo.Revoke(ctx, deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Rotate(ctx context.Context, deviceID uuid.UUID) error {
|
||||||
|
return s.repo.Rotate(ctx, deviceID)
|
||||||
|
}
|
||||||
62
backend/internal/device/types.go
Normal file
62
backend/internal/device/types.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
type EnrollRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
OSVersion string `json:"os_version"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
DeviceFingerprint string `json:"device_fingerprint"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id,omitempty"`
|
||||||
|
GatewayID uuid.UUID `json:"gateway_id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AssignedIP string `json:"assigned_ip,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionStatus struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
AssignedIP string `json:"assigned_ip"`
|
||||||
|
LastSyncTime *string `json:"last_sync_time"`
|
||||||
|
Resources []Resource `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resource struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnrollmentResponse struct {
|
||||||
|
Device Device `json:"device"`
|
||||||
|
Peer PeerView `json:"peer"`
|
||||||
|
Profile ProfileView `json:"profile"`
|
||||||
|
Resources []Resource `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerView struct {
|
||||||
|
AssignedIP string `json:"assigned_ip"`
|
||||||
|
DNSServers []string `json:"dns_servers"`
|
||||||
|
AllowedIPs []string `json:"allowed_ips"`
|
||||||
|
Gateway GatewayView `json:"gateway"`
|
||||||
|
ProfileRevision int `json:"profile_revision"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatewayView struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileView struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
37
backend/internal/gateway/handler.go
Normal file
37
backend/internal/gateway/handler.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.service.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "gateways_list_failed", "unable to list gateways")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SyncBundle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bundle, err := h.service.BuildSyncBundle(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "gateway_sync_failed", "unable to build sync bundle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, bundle)
|
||||||
|
}
|
||||||
102
backend/internal/gateway/repository.go
Normal file
102
backend/internal/gateway/repository.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/wireguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
List(ctx context.Context) ([]Gateway, error)
|
||||||
|
FirstActive(ctx context.Context) (Gateway, error)
|
||||||
|
BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PGRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
|
||||||
|
return &PGRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) List(ctx context.Context) ([]Gateway, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select id, name, endpoint, public_key, listen_port, vpn_cidr, dns_servers, is_active
|
||||||
|
from gateways
|
||||||
|
where deleted_at is null
|
||||||
|
order by created_at desc
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []Gateway
|
||||||
|
for rows.Next() {
|
||||||
|
var item Gateway
|
||||||
|
if err := rows.Scan(&item.ID, &item.Name, &item.Endpoint, &item.PublicKey, &item.ListenPort, &item.VPNCIDR, &item.DNSServers, &item.IsActive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) FirstActive(ctx context.Context) (Gateway, error) {
|
||||||
|
row := r.db.QueryRow(ctx, `
|
||||||
|
select id, name, endpoint, public_key, listen_port, vpn_cidr, dns_servers, is_active
|
||||||
|
from gateways
|
||||||
|
where deleted_at is null and is_active = true
|
||||||
|
order by created_at asc
|
||||||
|
limit 1
|
||||||
|
`)
|
||||||
|
|
||||||
|
var item Gateway
|
||||||
|
err := row.Scan(&item.ID, &item.Name, &item.Endpoint, &item.PublicKey, &item.ListenPort, &item.VPNCIDR, &item.DNSServers, &item.IsActive)
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error) {
|
||||||
|
var bundle wireguard.GatewayBundle
|
||||||
|
bundle.GatewayID = gatewayID.String()
|
||||||
|
bundle.Revision = 1
|
||||||
|
|
||||||
|
row := r.db.QueryRow(ctx, `
|
||||||
|
select host(vpn_cidr), listen_port
|
||||||
|
from gateways
|
||||||
|
where id = $1 and deleted_at is null
|
||||||
|
`, gatewayID)
|
||||||
|
if err := row.Scan(&bundle.Interface.Address, &bundle.Interface.ListenPort); err != nil {
|
||||||
|
return wireguard.GatewayBundle{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select d.id, wp.public_key, host(wp.assigned_ip), coalesce(array_agg(pd.destination::text) filter (where pd.destination is not null), '{}')
|
||||||
|
from devices d
|
||||||
|
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
||||||
|
left join policy_targets pt on pt.target_id = d.id and pt.target_type = 'device'
|
||||||
|
left join policy_destinations pd on pd.policy_id = pt.policy_id
|
||||||
|
where d.gateway_id = $1 and d.deleted_at is null and d.status = 'active'
|
||||||
|
group by d.id, wp.public_key, wp.assigned_ip
|
||||||
|
`, gatewayID)
|
||||||
|
if err != nil {
|
||||||
|
return wireguard.GatewayBundle{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var peer wireguard.Peer
|
||||||
|
var deviceID uuid.UUID
|
||||||
|
if err := rows.Scan(&deviceID, &peer.PublicKey, &peer.AssignedIP, &peer.AllowedDestinations); err != nil {
|
||||||
|
return wireguard.GatewayBundle{}, err
|
||||||
|
}
|
||||||
|
peer.DeviceID = deviceID.String()
|
||||||
|
bundle.Peers = append(bundle.Peers, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle, rows.Err()
|
||||||
|
}
|
||||||
33
backend/internal/gateway/service.go
Normal file
33
backend/internal/gateway/service.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/wireguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo Repository) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context) ([]Gateway, error) {
|
||||||
|
return s.repo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SelectActive(ctx context.Context) (Gateway, error) {
|
||||||
|
return s.repo.FirstActive(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BuildSyncBundle(ctx context.Context, gatewayID string) (wireguard.GatewayBundle, error) {
|
||||||
|
id, err := uuid.Parse(gatewayID)
|
||||||
|
if err != nil {
|
||||||
|
return wireguard.GatewayBundle{}, err
|
||||||
|
}
|
||||||
|
return s.repo.BuildSyncBundle(ctx, id)
|
||||||
|
}
|
||||||
14
backend/internal/gateway/types.go
Normal file
14
backend/internal/gateway/types.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
type Gateway struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
ListenPort int `json:"listen_port"`
|
||||||
|
VPNCIDR string `json:"vpn_cidr"`
|
||||||
|
DNSServers []string `json:"dns_servers"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
66
backend/internal/httpserver/middleware.go
Normal file
66
backend/internal/httpserver/middleware.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const claimsContextKey contextKey = "claims"
|
||||||
|
|
||||||
|
func BaseMiddleware(next http.Handler) http.Handler {
|
||||||
|
return middleware.RealIP(middleware.RequestID(middleware.Logger(next)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
header := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(header, "Bearer ") {
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := auth.ParseAccessToken(jwtSecret, strings.TrimPrefix(header, "Bearer "))
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "invalid access token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminOnly(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := ClaimsFromContext(r.Context())
|
||||||
|
if !ok || claims.Role != "admin" {
|
||||||
|
apiutil.Error(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClaimsFromContext(ctx context.Context) (auth.Claims, bool) {
|
||||||
|
claims, ok := ctx.Value(claimsContextKey).(auth.Claims)
|
||||||
|
return claims, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustUserID(ctx context.Context) (uuid.UUID, bool) {
|
||||||
|
claims, ok := ClaimsFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
return claims.UserID, true
|
||||||
|
}
|
||||||
68
backend/internal/httpserver/router.go
Normal file
68
backend/internal/httpserver/router.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/auth"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/audit"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/device"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/gateway"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/policy"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handlers struct {
|
||||||
|
Auth *auth.Handler
|
||||||
|
User *user.Handler
|
||||||
|
Device *device.Handler
|
||||||
|
Policy *policy.Handler
|
||||||
|
Gateway *gateway.Handler
|
||||||
|
Audit *audit.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(BaseMiddleware)
|
||||||
|
|
||||||
|
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
apiutil.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Post("/auth/bootstrap", handlers.Auth.Bootstrap)
|
||||||
|
r.Post("/auth/login", handlers.Auth.Login)
|
||||||
|
r.Post("/auth/refresh", handlers.Auth.Refresh)
|
||||||
|
r.Post("/auth/logout", handlers.Auth.Logout)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(AuthMiddleware(jwtSecret))
|
||||||
|
r.Get("/auth/me", handlers.Auth.Me)
|
||||||
|
r.Post("/devices/enroll", handlers.Device.Enroll)
|
||||||
|
r.Get("/me/devices", handlers.Device.ListOwn)
|
||||||
|
r.Get("/me/profile", handlers.Device.GetOwnProfile)
|
||||||
|
r.Get("/connection/status", handlers.Device.ConnectionStatus)
|
||||||
|
|
||||||
|
r.Route("/admin", func(r chi.Router) {
|
||||||
|
r.Use(AdminOnly)
|
||||||
|
r.Get("/users", handlers.User.List)
|
||||||
|
r.Post("/users", handlers.User.Create)
|
||||||
|
r.Post("/users/{id}/disable", handlers.User.Disable)
|
||||||
|
r.Post("/users/{id}/enable", handlers.User.Enable)
|
||||||
|
r.Get("/devices", handlers.Device.ListAll)
|
||||||
|
r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID)
|
||||||
|
r.Post("/devices/{id}/revoke", handlers.Device.Revoke)
|
||||||
|
r.Post("/devices/{id}/rotate", handlers.Device.Rotate)
|
||||||
|
r.Get("/policies", handlers.Policy.List)
|
||||||
|
r.Post("/policies", handlers.Policy.Create)
|
||||||
|
r.Get("/gateways", handlers.Gateway.List)
|
||||||
|
r.Get("/gateways/{id}/sync", handlers.Gateway.SyncBundle)
|
||||||
|
r.Get("/audit-logs", handlers.Audit.List)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
26
backend/internal/ipam/service.go
Normal file
26
backend/internal/ipam/service.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package ipam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct{}
|
||||||
|
|
||||||
|
func NewService() *Service {
|
||||||
|
return &Service{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Allocate(cidr string, offset int) (string, error) {
|
||||||
|
prefix, err := netip.ParsePrefix(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
address := prefix.Addr().Next()
|
||||||
|
for i := 1; i < offset; i++ {
|
||||||
|
address = address.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s/32", address.String()), nil
|
||||||
|
}
|
||||||
62
backend/internal/policy/handler.go
Normal file
62
backend/internal/policy/handler.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/audit"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service, auditService *audit.Service) *Handler {
|
||||||
|
return &Handler{service: service, audit: auditService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.service.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "policies_list_failed", "unable to list policies")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input CreateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := httpserver.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := h.service.Create(r.Context(), claims.UserID, input)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "policy_create_failed", "unable to create policy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EntityType: "policy",
|
||||||
|
EntityID: &item.ID,
|
||||||
|
EventType: "admin.policy.created",
|
||||||
|
Status: "success",
|
||||||
|
Message: "admin created policy",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"name": item.Name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
apiutil.JSON(w, http.StatusCreated, item)
|
||||||
|
}
|
||||||
143
backend/internal/policy/repository.go
Normal file
143
backend/internal/policy/repository.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
List(ctx context.Context) ([]Policy, error)
|
||||||
|
Create(ctx context.Context, input CreateRequest, createdBy uuid.UUID) (Policy, error)
|
||||||
|
ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PGRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
|
||||||
|
return &PGRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) List(ctx context.Context) ([]Policy, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
p.description,
|
||||||
|
p.priority,
|
||||||
|
p.effect,
|
||||||
|
p.full_tunnel,
|
||||||
|
p.is_active,
|
||||||
|
coalesce(array_agg(pd.destination::text) filter (where pd.destination is not null), '{}')
|
||||||
|
from policies p
|
||||||
|
left join policy_destinations pd on pd.policy_id = p.id
|
||||||
|
where p.deleted_at is null
|
||||||
|
group by p.id, p.name, p.description, p.priority, p.effect, p.full_tunnel, p.is_active, p.created_at
|
||||||
|
order by p.priority asc, p.created_at desc
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []Policy
|
||||||
|
for rows.Next() {
|
||||||
|
var item Policy
|
||||||
|
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.Priority, &item.Effect, &item.FullTunnel, &item.IsActive, &item.Destinations); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Create(ctx context.Context, input CreateRequest, createdBy uuid.UUID) (Policy, error) {
|
||||||
|
tx, err := r.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Policy{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
policyID := uuid.New()
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
insert into policies (id, name, description, priority, effect, full_tunnel, created_by)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, policyID, input.Name, input.Description, input.Priority, input.Effect, input.FullTunnel, createdBy)
|
||||||
|
if err != nil {
|
||||||
|
return Policy{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, destination := range input.Destinations {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
insert into policy_destinations (id, policy_id, destination)
|
||||||
|
values ($1, $2, $3::cidr)
|
||||||
|
`, uuid.New(), policyID, destination); err != nil {
|
||||||
|
return Policy{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range input.Targets {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
insert into policy_targets (id, policy_id, target_type, target_id)
|
||||||
|
values ($1, $2, $3, $4)
|
||||||
|
`, uuid.New(), policyID, target.Type, target.ID); err != nil {
|
||||||
|
return Policy{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return Policy{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
inputPolicy := Policy{
|
||||||
|
ID: policyID,
|
||||||
|
Name: input.Name,
|
||||||
|
Description: input.Description,
|
||||||
|
Priority: input.Priority,
|
||||||
|
Effect: input.Effect,
|
||||||
|
FullTunnel: input.FullTunnel,
|
||||||
|
IsActive: true,
|
||||||
|
Destinations: input.Destinations,
|
||||||
|
Targets: input.Targets,
|
||||||
|
}
|
||||||
|
return inputPolicy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) {
|
||||||
|
query := `
|
||||||
|
select distinct pd.destination::text
|
||||||
|
from policies p
|
||||||
|
join policy_destinations pd on pd.policy_id = p.id
|
||||||
|
join policy_targets pt on pt.policy_id = p.id
|
||||||
|
where p.deleted_at is null
|
||||||
|
and p.is_active = true
|
||||||
|
and p.effect = 'allow'
|
||||||
|
and (
|
||||||
|
(pt.target_type = 'user' and pt.target_id = $1)
|
||||||
|
`
|
||||||
|
args := []any{userID}
|
||||||
|
if deviceID != nil {
|
||||||
|
query += ` or (pt.target_type = 'device' and pt.target_id = $2)`
|
||||||
|
args = append(args, *deviceID)
|
||||||
|
}
|
||||||
|
query += `)`
|
||||||
|
|
||||||
|
rows, err := r.db.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var destinations []string
|
||||||
|
for rows.Next() {
|
||||||
|
var value string
|
||||||
|
if err := rows.Scan(&value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
destinations = append(destinations, value)
|
||||||
|
}
|
||||||
|
return destinations, rows.Err()
|
||||||
|
}
|
||||||
33
backend/internal/policy/service.go
Normal file
33
backend/internal/policy/service.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo Repository) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context) ([]Policy, error) {
|
||||||
|
return s.repo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, actorID uuid.UUID, input CreateRequest) (Policy, error) {
|
||||||
|
if input.Priority == 0 {
|
||||||
|
input.Priority = 100
|
||||||
|
}
|
||||||
|
if input.Effect == "" {
|
||||||
|
input.Effect = "allow"
|
||||||
|
}
|
||||||
|
return s.repo.Create(ctx, input, actorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) {
|
||||||
|
return s.repo.ResolveDestinations(ctx, userID, deviceID)
|
||||||
|
}
|
||||||
30
backend/internal/policy/types.go
Normal file
30
backend/internal/policy/types.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
type Target struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Policy struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Effect string `json:"effect"`
|
||||||
|
FullTunnel bool `json:"full_tunnel"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
Destinations []string `json:"destinations"`
|
||||||
|
Targets []Target `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Effect string `json:"effect"`
|
||||||
|
FullTunnel bool `json:"full_tunnel"`
|
||||||
|
Destinations []string `json:"destinations"`
|
||||||
|
Targets []Target `json:"targets"`
|
||||||
|
}
|
||||||
33
backend/internal/profile/builder.go
Normal file
33
backend/internal/profile/builder.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BuildInput struct {
|
||||||
|
PrivateKey string
|
||||||
|
Address string
|
||||||
|
DNSServers []string
|
||||||
|
ServerPublicKey string
|
||||||
|
ServerEndpoint string
|
||||||
|
AllowedIPs []string
|
||||||
|
PersistentKeepal int
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildWireGuardConfig(input BuildInput) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("[Interface]\n")
|
||||||
|
b.WriteString(fmt.Sprintf("PrivateKey = %s\n", input.PrivateKey))
|
||||||
|
b.WriteString(fmt.Sprintf("Address = %s\n", input.Address))
|
||||||
|
if len(input.DNSServers) > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("DNS = %s\n", strings.Join(input.DNSServers, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n[Peer]\n")
|
||||||
|
b.WriteString(fmt.Sprintf("PublicKey = %s\n", input.ServerPublicKey))
|
||||||
|
b.WriteString(fmt.Sprintf("Endpoint = %s\n", input.ServerEndpoint))
|
||||||
|
b.WriteString(fmt.Sprintf("AllowedIPs = %s\n", strings.Join(input.AllowedIPs, ", ")))
|
||||||
|
b.WriteString(fmt.Sprintf("PersistentKeepalive = %d\n", input.PersistentKeepal))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
110
backend/internal/user/handler.go
Normal file
110
backend/internal/user/handler.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/audit"
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service, auditService *audit.Service) *Handler {
|
||||||
|
return &Handler{service: service, audit: auditService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := h.service.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "users_list_failed", "unable to list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input CreateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Role == "" {
|
||||||
|
input.Role = "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := h.service.Create(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "user_create_failed", "unable to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EntityType: "user",
|
||||||
|
EntityID: &created.ID,
|
||||||
|
EventType: "admin.user.created",
|
||||||
|
Status: "success",
|
||||||
|
Message: "admin created user",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"username": created.Username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
apiutil.JSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Disable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_user_id", "invalid user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.SetActive(r.Context(), targetID.String(), false); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "user_disable_failed", "unable to disable user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EntityType: "user",
|
||||||
|
EntityID: &targetID,
|
||||||
|
EventType: "admin.user.disabled",
|
||||||
|
Status: "success",
|
||||||
|
Message: "admin disabled user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Enable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_user_id", "invalid user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.SetActive(r.Context(), targetID.String(), true); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "user_enable_failed", "unable to enable user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EntityType: "user",
|
||||||
|
EntityID: &targetID,
|
||||||
|
EventType: "admin.user.enabled",
|
||||||
|
Status: "success",
|
||||||
|
Message: "admin enabled user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
64
backend/internal/user/repository.go
Normal file
64
backend/internal/user/repository.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
List(ctx context.Context) ([]User, error)
|
||||||
|
Create(ctx context.Context, input CreateRequest, passwordHash string) (User, error)
|
||||||
|
SetActive(ctx context.Context, userID uuid.UUID, active bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type PGRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
|
||||||
|
return &PGRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) List(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select u.id, r.id, r.name, u.username, u.display_name, coalesce(u.email, ''), u.is_active
|
||||||
|
from users u
|
||||||
|
join roles r on r.id = u.role_id
|
||||||
|
where u.deleted_at is null
|
||||||
|
order by u.created_at desc
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []User
|
||||||
|
for rows.Next() {
|
||||||
|
var item User
|
||||||
|
if err := rows.Scan(&item.ID, &item.RoleID, &item.RoleName, &item.Username, &item.DisplayName, &item.Email, &item.IsActive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, item)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Create(ctx context.Context, input CreateRequest, passwordHash string) (User, error) {
|
||||||
|
const query = `
|
||||||
|
insert into users (id, role_id, username, display_name, email, password_hash)
|
||||||
|
values ($1, (select id from roles where name = $2), $3, $4, nullif($5, ''), $6)
|
||||||
|
returning id, username, display_name, coalesce(email, ''), is_active
|
||||||
|
`
|
||||||
|
|
||||||
|
item := User{RoleName: input.Role}
|
||||||
|
err := r.db.QueryRow(ctx, query, uuid.New(), input.Role, input.Username, input.DisplayName, input.Email, passwordHash).
|
||||||
|
Scan(&item.ID, &item.Username, &item.DisplayName, &item.Email, &item.IsActive)
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) SetActive(ctx context.Context, userID uuid.UUID, active bool) error {
|
||||||
|
_, err := r.db.Exec(ctx, `update users set is_active = $2, updated_at = now() where id = $1`, userID, active)
|
||||||
|
return err
|
||||||
|
}
|
||||||
37
backend/internal/user/service.go
Normal file
37
backend/internal/user/service.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/nexavpn/nexavpn/backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo Repository) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context) ([]User, error) {
|
||||||
|
return s.repo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, input CreateRequest) (User, error) {
|
||||||
|
passwordHash, err := auth.HashPassword(input.Password)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return s.repo.Create(ctx, input, passwordHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetActive(ctx context.Context, userID string, active bool) error {
|
||||||
|
id, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.SetActive(ctx, id, active)
|
||||||
|
}
|
||||||
27
backend/internal/user/types.go
Normal file
27
backend/internal/user/types.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
RoleID uuid.UUID `json:"role_id,omitempty"`
|
||||||
|
RoleName string `json:"role"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateRequest struct {
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
18
backend/internal/wireguard/types.go
Normal file
18
backend/internal/wireguard/types.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
type Peer struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
AssignedIP string `json:"assigned_ip"`
|
||||||
|
AllowedDestinations []string `json:"allowed_destinations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatewayBundle struct {
|
||||||
|
GatewayID string `json:"gateway_id"`
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Interface struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
ListenPort int `json:"listen_port"`
|
||||||
|
} `json:"interface"`
|
||||||
|
Peers []Peer `json:"peers"`
|
||||||
|
}
|
||||||
183
backend/migrations/000001_init.sql
Normal file
183
backend/migrations/000001_init.sql
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
create extension if not exists pgcrypto;
|
||||||
|
create extension if not exists citext;
|
||||||
|
|
||||||
|
create table if not exists roles (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text unique not null,
|
||||||
|
description text not null default '',
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists users (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
role_id uuid not null references roles(id),
|
||||||
|
username citext unique not null,
|
||||||
|
display_name text not null,
|
||||||
|
email citext unique,
|
||||||
|
password_hash text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
last_login_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists sessions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references users(id),
|
||||||
|
ip_address inet,
|
||||||
|
user_agent text,
|
||||||
|
last_seen_at timestamptz not null default now(),
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
revoked_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists refresh_tokens (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
session_id uuid not null references sessions(id),
|
||||||
|
user_id uuid not null references users(id),
|
||||||
|
token_hash text not null,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
revoked_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists gateways (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text unique not null,
|
||||||
|
endpoint text not null,
|
||||||
|
public_key text not null,
|
||||||
|
listen_port integer not null,
|
||||||
|
vpn_cidr cidr not null,
|
||||||
|
dns_servers text[] not null default '{}',
|
||||||
|
is_active boolean not null default true,
|
||||||
|
last_sync_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists devices (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references users(id),
|
||||||
|
gateway_id uuid references gateways(id),
|
||||||
|
name text not null,
|
||||||
|
platform text not null,
|
||||||
|
os_version text not null default '',
|
||||||
|
app_version text not null default '',
|
||||||
|
device_fingerprint text not null,
|
||||||
|
public_key text not null,
|
||||||
|
status text not null default 'active',
|
||||||
|
last_seen_at timestamptz,
|
||||||
|
last_connected_at timestamptz,
|
||||||
|
approved_at timestamptz,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists idx_devices_user_fingerprint on devices(user_id, device_fingerprint) where deleted_at is null;
|
||||||
|
|
||||||
|
create table if not exists wireguard_peers (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
device_id uuid not null references devices(id),
|
||||||
|
gateway_id uuid not null references gateways(id),
|
||||||
|
public_key text unique not null,
|
||||||
|
assigned_ip inet not null,
|
||||||
|
preshared_key_ciphertext text,
|
||||||
|
allowed_ips cidr[] not null default '{}',
|
||||||
|
dns_servers text[] not null default '{}',
|
||||||
|
profile_revision integer not null default 1,
|
||||||
|
last_profile_issued_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists ip_allocations (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
gateway_id uuid not null references gateways(id),
|
||||||
|
device_id uuid references devices(id),
|
||||||
|
address inet not null,
|
||||||
|
status text not null default 'allocated',
|
||||||
|
allocated_at timestamptz not null default now(),
|
||||||
|
released_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists idx_ip_allocations_gateway_address on ip_allocations(gateway_id, address);
|
||||||
|
create unique index if not exists idx_ip_allocations_device_active on ip_allocations(device_id) where status = 'allocated';
|
||||||
|
|
||||||
|
create table if not exists policies (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text unique not null,
|
||||||
|
description text not null default '',
|
||||||
|
priority integer not null default 100,
|
||||||
|
effect text not null default 'allow',
|
||||||
|
is_active boolean not null default true,
|
||||||
|
full_tunnel boolean not null default false,
|
||||||
|
created_by uuid references users(id),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists policy_targets (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
policy_id uuid not null references policies(id),
|
||||||
|
target_type text not null,
|
||||||
|
target_id uuid not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists policy_destinations (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
policy_id uuid not null references policies(id),
|
||||||
|
destination cidr not null,
|
||||||
|
description text not null default '',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists groups (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text unique not null,
|
||||||
|
description text not null default '',
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists group_memberships (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
group_id uuid not null references groups(id),
|
||||||
|
user_id uuid not null references users(id),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists audit_logs (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
actor_user_id uuid references users(id),
|
||||||
|
actor_device_id uuid references devices(id),
|
||||||
|
event_type text not null,
|
||||||
|
entity_type text not null,
|
||||||
|
entity_id uuid,
|
||||||
|
status text not null,
|
||||||
|
ip_address inet,
|
||||||
|
message text not null,
|
||||||
|
metadata jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists settings (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
category text not null,
|
||||||
|
key text not null,
|
||||||
|
value jsonb not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique(category, key)
|
||||||
|
);
|
||||||
24
backend/seed/001_seed.sql
Normal file
24
backend/seed/001_seed.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
insert into roles (name, description)
|
||||||
|
values
|
||||||
|
('admin', 'NexaVPN administrator'),
|
||||||
|
('user', 'Standard VPN user')
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
insert into settings (category, key, value)
|
||||||
|
values
|
||||||
|
('vpn', 'default_dns_servers', '["10.20.0.53"]'::jsonb),
|
||||||
|
('auth', 'access_token_ttl_seconds', '900'::jsonb),
|
||||||
|
('auth', 'refresh_token_ttl_seconds', '2592000'::jsonb)
|
||||||
|
on conflict (category, key) do nothing;
|
||||||
|
|
||||||
|
insert into gateways (name, endpoint, public_key, listen_port, vpn_cidr, dns_servers, is_active)
|
||||||
|
values (
|
||||||
|
'primary-gateway',
|
||||||
|
'vpn.example.com:51820',
|
||||||
|
'replace-me-with-gateway-public-key',
|
||||||
|
51820,
|
||||||
|
'100.96.0.0/24',
|
||||||
|
array['10.20.0.53'],
|
||||||
|
true
|
||||||
|
)
|
||||||
|
on conflict (name) do nothing;
|
||||||
14
deploy/.env.example
Normal file
14
deploy/.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
POSTGRES_DB=nexavpn
|
||||||
|
POSTGRES_USER=nexavpn
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
DATABASE_URL=postgres://nexavpn:change-me@postgres:5432/nexavpn?sslmode=disable
|
||||||
|
HTTP_ADDRESS=:8080
|
||||||
|
APP_ENV=production
|
||||||
|
JWT_SECRET=replace-with-a-long-random-secret
|
||||||
|
JWT_ISSUER=nexavpn
|
||||||
|
ACCESS_TOKEN_TTL_SECONDS=900
|
||||||
|
REFRESH_TOKEN_TTL_SECONDS=2592000
|
||||||
|
DEFAULT_DNS_SERVERS=10.20.0.53
|
||||||
|
DEFAULT_VPN_CIDR=100.96.0.0/24
|
||||||
|
DEFAULT_GATEWAY_ENDPOINT=vpn.example.com:51820
|
||||||
|
DEFAULT_GATEWAY_PUBLIC_KEY=replace-me
|
||||||
72
deploy/docker-compose.yml
Normal file
72
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
- ../backend/migrations/000001_init.sql:/docker-entrypoint-initdb.d/010_init.sql:ro
|
||||||
|
- ../backend/seed/001_seed.sql:/docker-entrypoint-initdb.d/020_seed.sql:ro
|
||||||
|
networks:
|
||||||
|
- control
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
networks:
|
||||||
|
- control
|
||||||
|
- gateway
|
||||||
|
|
||||||
|
admin-web:
|
||||||
|
build:
|
||||||
|
context: ../admin-web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
networks:
|
||||||
|
- control
|
||||||
|
|
||||||
|
reverse-proxy:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- admin-web
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/reverse-proxy.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
networks:
|
||||||
|
- control
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
image: alpine:3.21
|
||||||
|
command: ["sh", "/scripts/gateway-entrypoint.sh"]
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- SYS_MODULE
|
||||||
|
volumes:
|
||||||
|
- ./scripts/gateway-entrypoint.sh:/scripts/gateway-entrypoint.sh:ro
|
||||||
|
- gateway-state:/var/lib/nexavpn
|
||||||
|
networks:
|
||||||
|
- gateway
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
gateway-state:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
control:
|
||||||
|
gateway:
|
||||||
10
deploy/nginx/admin.conf
Normal file
10
deploy/nginx/admin.conf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
deploy/nginx/reverse-proxy.conf
Normal file
18
deploy/nginx/reverse-proxy.conf
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://admin-web:80;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
deploy/scripts/bootstrap-admin.sh
Normal file
9
deploy/scripts/bootstrap-admin.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
Bootstrap flow:
|
||||||
|
1. Run backend migrations from backend/migrations.
|
||||||
|
2. Seed roles and default settings from backend/seed/001_seed.sql.
|
||||||
|
3. Insert the first admin user with an Argon2id password hash generated by the backend auth package or a one-off helper.
|
||||||
|
EOF
|
||||||
8
deploy/scripts/gateway-entrypoint.sh
Normal file
8
deploy/scripts/gateway-entrypoint.sh
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
echo "NexaVPN gateway helper starting"
|
||||||
|
echo "This container is a placeholder for WireGuard + nftables sync logic."
|
||||||
|
echo "Mount generated gateway state into /var/lib/nexavpn and apply rules from there."
|
||||||
|
|
||||||
|
tail -f /dev/null
|
||||||
12
desktop-client/index.html
Normal file
12
desktop-client/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NexaVPN</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
desktop-client/package.json
Normal file
25
desktop-client/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "nexavpn-desktop-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"tauri:dev": "tauri dev",
|
||||||
|
"tauri:build": "tauri build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.3.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.3.1",
|
||||||
|
"@types/react": "^18.3.20",
|
||||||
|
"@types/react-dom": "^18.3.6",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
desktop-client/src-tauri/Cargo.toml
Normal file
22
desktop-client/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "nexavpn-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "NexaVPN desktop client"
|
||||||
|
authors = ["NexaVPN"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "nexavpn_desktop"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0.2" }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
tauri = { version = "2.3.1", features = [] }
|
||||||
|
x25519-dalek = "2.0"
|
||||||
3
desktop-client/src-tauri/build.rs
Normal file
3
desktop-client/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
204
desktop-client/src-tauri/src/lib.rs
Normal file
204
desktop-client/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
session: Mutex<Option<SessionState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SessionState {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: String,
|
||||||
|
server_url: String,
|
||||||
|
enrollment: EnrollmentResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EnrollmentPayload {
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EnrollmentResult {
|
||||||
|
assigned_ip: String,
|
||||||
|
resources: Vec<String>,
|
||||||
|
profile_revision: u32,
|
||||||
|
gateway_endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LoginRequest<'a> {
|
||||||
|
username: &'a str,
|
||||||
|
password: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LoginResponse {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EnrollRequest<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
platform: &'a str,
|
||||||
|
os_version: &'a str,
|
||||||
|
app_version: &'a str,
|
||||||
|
device_fingerprint: String,
|
||||||
|
public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EnrollResponse {
|
||||||
|
peer: PeerView,
|
||||||
|
resources: Vec<ResourceView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PeerView {
|
||||||
|
assigned_ip: String,
|
||||||
|
gateway: GatewayView,
|
||||||
|
profile_revision: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GatewayView {
|
||||||
|
endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResourceView {
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -> Result<EnrollmentResult, String> {
|
||||||
|
if payload.server_url.trim().is_empty() || payload.username.trim().is_empty() || payload.password.trim().is_empty() {
|
||||||
|
return Err("Server URL, username, and password are required".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.use_rustls_tls()
|
||||||
|
.build()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let login_response = client
|
||||||
|
.post(format!("{}/api/v1/auth/login", payload.server_url.trim_end_matches('/')))
|
||||||
|
.json(&LoginRequest {
|
||||||
|
username: &payload.username,
|
||||||
|
password: &payload.password,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Login failed: {}", err))?;
|
||||||
|
|
||||||
|
if !login_response.status().is_success() {
|
||||||
|
return Err(format!("Login failed with status {}", login_response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let login = login_response
|
||||||
|
.json::<LoginResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Unable to decode login response: {}", err))?;
|
||||||
|
|
||||||
|
let (private_key, public_key) = generate_keypair();
|
||||||
|
let enroll_response = client
|
||||||
|
.post(format!("{}/api/v1/devices/enroll", payload.server_url.trim_end_matches('/')))
|
||||||
|
.bearer_auth(&login.access_token)
|
||||||
|
.json(&EnrollRequest {
|
||||||
|
name: "NexaVPN Desktop",
|
||||||
|
platform: if cfg!(target_os = "macos") { "macos" } else { "windows" },
|
||||||
|
os_version: std::env::consts::OS,
|
||||||
|
app_version: env!("CARGO_PKG_VERSION"),
|
||||||
|
device_fingerprint: build_fingerprint(&payload.server_url, &payload.username, &public_key),
|
||||||
|
public_key,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Enrollment failed: {}", err))?;
|
||||||
|
|
||||||
|
if !enroll_response.status().is_success() {
|
||||||
|
return Err(format!("Enrollment failed with status {}", enroll_response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let enroll = enroll_response
|
||||||
|
.json::<EnrollResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Unable to decode enrollment response: {}", err))?;
|
||||||
|
|
||||||
|
let result = EnrollmentResult {
|
||||||
|
assigned_ip: enroll.peer.assigned_ip,
|
||||||
|
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
|
||||||
|
profile_revision: enroll.peer.profile_revision,
|
||||||
|
gateway_endpoint: enroll.peer.gateway.endpoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
||||||
|
*session = Some(SessionState {
|
||||||
|
access_token: login.access_token,
|
||||||
|
refresh_token: login.refresh_token,
|
||||||
|
server_url: payload.server_url,
|
||||||
|
enrollment: result.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = private_key;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn connect_tunnel(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||||
|
if session.is_none() {
|
||||||
|
return Err("No enrolled profile is available yet".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn disconnect_tunnel(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||||
|
if session.is_none() {
|
||||||
|
return Err("No active session is available".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_keypair() -> (String, String) {
|
||||||
|
let private = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let public = PublicKey::from(&private);
|
||||||
|
(
|
||||||
|
STANDARD_NO_PAD.encode(private.to_bytes()),
|
||||||
|
STANDARD_NO_PAD.encode(public.to_bytes()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> String {
|
||||||
|
format!("nexavpn:{}:{}:{}", server_url, username, public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(AppState {
|
||||||
|
session: Mutex::new(None),
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![enroll_device, connect_tunnel, disconnect_tunnel])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
3
desktop-client/src-tauri/src/main.rs
Normal file
3
desktop-client/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
nexavpn_desktop::run();
|
||||||
|
}
|
||||||
30
desktop-client/src-tauri/tauri.conf.json
Normal file
30
desktop-client/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "NexaVPN",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.nexavpn.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devUrl": "http://localhost:4173",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "NexaVPN",
|
||||||
|
"width": 1120,
|
||||||
|
"height": 760,
|
||||||
|
"resizable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": []
|
||||||
|
}
|
||||||
|
}
|
||||||
107
desktop-client/src/App.tsx
Normal file
107
desktop-client/src/App.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
type EnrollmentState = {
|
||||||
|
assignedIp: string;
|
||||||
|
resources: string[];
|
||||||
|
profileRevision: number;
|
||||||
|
gatewayEndpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [serverUrl, setServerUrl] = useState("http://localhost");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [state, setState] = useState<EnrollmentState | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<EnrollmentState>("enroll_device", {
|
||||||
|
payload: { serverUrl, username, password }
|
||||||
|
});
|
||||||
|
setState(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Enrollment failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleConnection() {
|
||||||
|
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
|
||||||
|
await invoke(command);
|
||||||
|
setConnected((value) => !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="client-shell">
|
||||||
|
<div className="hero">
|
||||||
|
<p className="eyebrow">NexaVPN</p>
|
||||||
|
<h1>Private access without manual WireGuard setup.</h1>
|
||||||
|
<p className="lede">
|
||||||
|
Enter your VPN address, username, and password. NexaVPN provisions and stores the profile for you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!state ? (
|
||||||
|
<form className="panel" onSubmit={onSubmit}>
|
||||||
|
<label>
|
||||||
|
VPN server address
|
||||||
|
<input value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
{error ? <div className="error">{error}</div> : null}
|
||||||
|
<button disabled={loading} type="submit">
|
||||||
|
{loading ? "Provisioning..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="panel status">
|
||||||
|
<div className="status-row">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Connection</p>
|
||||||
|
<h2>{connected ? "Connected" : "Disconnected"}</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleConnection}>{connected ? "Disconnect" : "Connect"}</button>
|
||||||
|
</div>
|
||||||
|
<div className="details">
|
||||||
|
<div>
|
||||||
|
<span>Assigned VPN IP</span>
|
||||||
|
<strong>{state.assignedIp}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Gateway</span>
|
||||||
|
<strong>{state.gatewayEndpoint}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Profile revision</span>
|
||||||
|
<strong>{state.profileRevision}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Allowed resources</p>
|
||||||
|
<ul className="resource-list">
|
||||||
|
{state.resources.map((resource) => (
|
||||||
|
<li key={resource}>{resource}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
desktop-client/src/main.tsx
Normal file
11
desktop-client/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
111
desktop-client/src/styles.css
Normal file
111
desktop-client/src/styles.css
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
:root {
|
||||||
|
font-family: "Segoe UI", "SF Pro Text", sans-serif;
|
||||||
|
color: #f5f7fb;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(79, 208, 164, 0.18), transparent 25%),
|
||||||
|
linear-gradient(180deg, #08111f 0%, #0d1727 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.panel {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: #74e0b8;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
color: #a9b8d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(12, 22, 38, 0.84);
|
||||||
|
border: 1px solid rgba(167, 185, 219, 0.14);
|
||||||
|
box-shadow: 0 24px 60px rgba(2, 8, 18, 0.36);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: #c2cfe5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border: 1px solid rgba(167, 185, 219, 0.16);
|
||||||
|
background: rgba(7, 14, 27, 0.85);
|
||||||
|
color: #f5f7fb;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 13px 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #74e0b8 0%, #1fb67a 100%);
|
||||||
|
color: #04141a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ffb7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row,
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details div {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details span {
|
||||||
|
color: #9db0cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
16
desktop-client/tsconfig.app.json
Normal file
16
desktop-client/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
6
desktop-client/tsconfig.json
Normal file
6
desktop-client/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
12
desktop-client/vite.config.ts
Normal file
12
desktop-client/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 4173,
|
||||||
|
strictPort: true,
|
||||||
|
host: true
|
||||||
|
}
|
||||||
|
});
|
||||||
223
docs/api.md
Normal file
223
docs/api.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# REST API Contract
|
||||||
|
|
||||||
|
Base path: `/api/v1`
|
||||||
|
|
||||||
|
All responses are JSON.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### `POST /auth/login`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "alice",
|
||||||
|
"password": "secret-password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "jwt",
|
||||||
|
"refresh_token": "opaque-token",
|
||||||
|
"expires_in": 900,
|
||||||
|
"user": {
|
||||||
|
"id": "uuid",
|
||||||
|
"username": "alice",
|
||||||
|
"display_name": "Alice",
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /auth/refresh`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"refresh_token": "opaque-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /auth/logout`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"refresh_token": "opaque-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /auth/me`
|
||||||
|
|
||||||
|
Returns the authenticated user and role.
|
||||||
|
|
||||||
|
## User Self-Service
|
||||||
|
|
||||||
|
### `GET /me/devices`
|
||||||
|
|
||||||
|
Returns the current user devices.
|
||||||
|
|
||||||
|
### `GET /me/profile`
|
||||||
|
|
||||||
|
Returns profile metadata for the current active device when the client needs to resync.
|
||||||
|
|
||||||
|
## Device Enrollment and Provisioning
|
||||||
|
|
||||||
|
### `POST /devices/enroll`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Alice MacBook Pro",
|
||||||
|
"platform": "macos",
|
||||||
|
"os_version": "14.4",
|
||||||
|
"app_version": "0.1.0",
|
||||||
|
"device_fingerprint": "sha256:...",
|
||||||
|
"public_key": "base64-wireguard-public-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device": {
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Alice MacBook Pro",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"peer": {
|
||||||
|
"assigned_ip": "100.96.0.10/32",
|
||||||
|
"dns_servers": ["10.20.0.53"],
|
||||||
|
"allowed_ips": ["172.16.10.0/24"],
|
||||||
|
"gateway": {
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "primary-gateway",
|
||||||
|
"endpoint": "vpn.example.com:51820",
|
||||||
|
"public_key": "gateway-public-key"
|
||||||
|
},
|
||||||
|
"profile_revision": 1
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"format": "wireguard",
|
||||||
|
"content": "[Interface]\n..."
|
||||||
|
},
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"type": "cidr",
|
||||||
|
"value": "172.16.10.0/24",
|
||||||
|
"label": "Private subnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /devices/{deviceId}/profile`
|
||||||
|
|
||||||
|
Admin-only debug endpoint for rendered config retrieval.
|
||||||
|
|
||||||
|
### `POST /devices/{deviceId}/rotate`
|
||||||
|
|
||||||
|
Rotates the device profile revision and forces reprovisioning.
|
||||||
|
|
||||||
|
### `POST /devices/{deviceId}/revoke`
|
||||||
|
|
||||||
|
Revokes the device and removes it from future gateway sync output.
|
||||||
|
|
||||||
|
### `POST /devices/{deviceId}/heartbeat`
|
||||||
|
|
||||||
|
Optional client status sync for last-seen and runtime metadata.
|
||||||
|
|
||||||
|
## Connection Metadata
|
||||||
|
|
||||||
|
### `GET /connection/status`
|
||||||
|
|
||||||
|
Returns assigned IP, latest sync time, and effective allowed resources for the current authenticated device session.
|
||||||
|
|
||||||
|
## Admin: Users
|
||||||
|
|
||||||
|
### `GET /admin/users`
|
||||||
|
### `POST /admin/users`
|
||||||
|
### `GET /admin/users/{id}`
|
||||||
|
### `PATCH /admin/users/{id}`
|
||||||
|
### `POST /admin/users/{id}/disable`
|
||||||
|
### `POST /admin/users/{id}/enable`
|
||||||
|
### `POST /admin/users/{id}/password`
|
||||||
|
|
||||||
|
## Admin: Devices
|
||||||
|
|
||||||
|
### `GET /admin/devices`
|
||||||
|
### `GET /admin/devices/{id}`
|
||||||
|
### `PATCH /admin/devices/{id}`
|
||||||
|
### `POST /admin/devices/{id}/revoke`
|
||||||
|
### `POST /admin/devices/{id}/rotate`
|
||||||
|
|
||||||
|
## Admin: Policies
|
||||||
|
|
||||||
|
### `GET /admin/policies`
|
||||||
|
### `POST /admin/policies`
|
||||||
|
### `GET /admin/policies/{id}`
|
||||||
|
### `PATCH /admin/policies/{id}`
|
||||||
|
### `DELETE /admin/policies/{id}`
|
||||||
|
|
||||||
|
Policy create request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Finance subnet access",
|
||||||
|
"description": "Access for finance team",
|
||||||
|
"priority": 100,
|
||||||
|
"effect": "allow",
|
||||||
|
"full_tunnel": false,
|
||||||
|
"destinations": [
|
||||||
|
"172.16.20.0/24",
|
||||||
|
"172.16.21.10/32"
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"id": "uuid"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin: Gateways
|
||||||
|
|
||||||
|
### `GET /admin/gateways`
|
||||||
|
### `POST /admin/gateways`
|
||||||
|
### `GET /admin/gateways/{id}`
|
||||||
|
### `PATCH /admin/gateways/{id}`
|
||||||
|
### `GET /admin/gateways/{id}/sync`
|
||||||
|
|
||||||
|
The sync endpoint returns the peer and firewall bundle consumed by the gateway helper.
|
||||||
|
|
||||||
|
## Admin: Audit
|
||||||
|
|
||||||
|
### `GET /admin/audit-logs`
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- `event_type`
|
||||||
|
- `entity_type`
|
||||||
|
- `status`
|
||||||
|
- `page`
|
||||||
|
- `page_size`
|
||||||
|
|
||||||
|
## Error Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "validation_error",
|
||||||
|
"message": "public_key is required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
180
docs/architecture.md
Normal file
180
docs/architecture.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# NexaVPN Architecture
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
NexaVPN is a self-hosted remote access platform that uses WireGuard for transport and a centralized control plane for identity, device enrollment, provisioning, and policy enforcement.
|
||||||
|
|
||||||
|
The platform is split into four major planes:
|
||||||
|
|
||||||
|
1. Control plane
|
||||||
|
- Go REST API
|
||||||
|
- PostgreSQL
|
||||||
|
- JWT auth and refresh sessions
|
||||||
|
- policy engine
|
||||||
|
- audit logging
|
||||||
|
- WireGuard profile builder
|
||||||
|
- gateway state publisher
|
||||||
|
2. Management plane
|
||||||
|
- React admin console
|
||||||
|
- user, device, policy, gateway, and audit workflows
|
||||||
|
3. Endpoint plane
|
||||||
|
- Tauri desktop client for Windows and macOS
|
||||||
|
- local secure token/config storage
|
||||||
|
- on-device WireGuard keypair generation
|
||||||
|
- embedded tunnel lifecycle management
|
||||||
|
4. Data plane
|
||||||
|
- Linux WireGuard gateway
|
||||||
|
- nftables policy enforcement
|
||||||
|
- routed access to protected resources
|
||||||
|
|
||||||
|
## Logical Components
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `auth`
|
||||||
|
- username/password login
|
||||||
|
- access and refresh token issuance
|
||||||
|
- session tracking
|
||||||
|
- `user`
|
||||||
|
- user CRUD
|
||||||
|
- role assignment
|
||||||
|
- account enable/disable
|
||||||
|
- `device`
|
||||||
|
- device registration
|
||||||
|
- enrollment lifecycle
|
||||||
|
- device revocation
|
||||||
|
- device profile rotation
|
||||||
|
- `policy`
|
||||||
|
- user and device policy resolution
|
||||||
|
- group-aware target model
|
||||||
|
- allow-list centric MVP with deny precedence reserved in schema
|
||||||
|
- `gateway`
|
||||||
|
- gateway inventory
|
||||||
|
- endpoint metadata
|
||||||
|
- peer sync bundle generation
|
||||||
|
- firewall rule translation output
|
||||||
|
- `profile`
|
||||||
|
- WireGuard config assembly
|
||||||
|
- connect metadata response
|
||||||
|
- `audit`
|
||||||
|
- immutable security and admin events
|
||||||
|
- `ipam`
|
||||||
|
- VPN address pool allocation
|
||||||
|
- uniqueness and lifecycle tracking
|
||||||
|
|
||||||
|
### Admin UI
|
||||||
|
|
||||||
|
- Dashboard
|
||||||
|
- counts, enrollment trend, latest audit events
|
||||||
|
- Users
|
||||||
|
- create, edit, disable, password reset
|
||||||
|
- Devices
|
||||||
|
- list, revoke, rotate, inspect assigned profile metadata
|
||||||
|
- Policies
|
||||||
|
- create CIDR-based allow rules and attach them to users, devices, or groups
|
||||||
|
- Gateways
|
||||||
|
- endpoint configuration, sync status, address pool view
|
||||||
|
- Audit
|
||||||
|
- searchable event history
|
||||||
|
- Settings
|
||||||
|
- DNS defaults, token lifetimes, bootstrap settings
|
||||||
|
|
||||||
|
### Desktop Client
|
||||||
|
|
||||||
|
- onboarding
|
||||||
|
- server URL
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
- enrollment
|
||||||
|
- machine fingerprint generation
|
||||||
|
- WireGuard keypair generation on-device
|
||||||
|
- device registration
|
||||||
|
- profile provisioning
|
||||||
|
- runtime
|
||||||
|
- secure local storage
|
||||||
|
- connect/disconnect
|
||||||
|
- status display
|
||||||
|
- last sync time
|
||||||
|
- allowed resources display
|
||||||
|
- diagnostics
|
||||||
|
- logs
|
||||||
|
- TLS trust warning surface
|
||||||
|
- profile refresh retry
|
||||||
|
|
||||||
|
### Gateway
|
||||||
|
|
||||||
|
- WireGuard interface
|
||||||
|
- issued peers synced from control plane
|
||||||
|
- nftables chain generated from effective device policies
|
||||||
|
- route advertisement limited to assigned resources or full tunnel mode
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- Access tokens are short-lived JWTs.
|
||||||
|
- Refresh tokens are opaque, hashed in the database, and bound to a session.
|
||||||
|
- Admin and standard-user authorization is role-based.
|
||||||
|
|
||||||
|
### Device Trust
|
||||||
|
|
||||||
|
- A device is represented as a durable record linked to a user.
|
||||||
|
- Clients generate their own WireGuard keypairs.
|
||||||
|
- Only the public key is stored server-side.
|
||||||
|
- Device rotation invalidates the old peer and issues a fresh profile revision.
|
||||||
|
|
||||||
|
### Policy Model
|
||||||
|
|
||||||
|
- Effective access is the union of active allow policies targeted at:
|
||||||
|
- the user
|
||||||
|
- the device
|
||||||
|
- any future groups
|
||||||
|
- Device-specific policies can narrow or extend user-level access.
|
||||||
|
- Gateway enforcement is authoritative; the client display is informational.
|
||||||
|
|
||||||
|
### WireGuard Provisioning
|
||||||
|
|
||||||
|
- The backend returns structured peer metadata and a rendered profile payload.
|
||||||
|
- The desktop client stores the private key and profile locally.
|
||||||
|
- The MVP uses an embedded tunnel-management abstraction rather than depending on the standalone WireGuard desktop app.
|
||||||
|
|
||||||
|
### Expandability
|
||||||
|
|
||||||
|
The schema and package layout reserve room for:
|
||||||
|
|
||||||
|
- MFA
|
||||||
|
- OIDC and SSO
|
||||||
|
- approval-based enrollment
|
||||||
|
- group and role expansion
|
||||||
|
- multiple gateways
|
||||||
|
- route and posture-aware policies
|
||||||
|
- richer sync agents at the gateway edge
|
||||||
|
|
||||||
|
## Request Flow Summary
|
||||||
|
|
||||||
|
### Login and Enrollment
|
||||||
|
|
||||||
|
1. User enters server URL, username, and password in the desktop app.
|
||||||
|
2. Client authenticates against `/api/v1/auth/login`.
|
||||||
|
3. Client generates a WireGuard keypair and device fingerprint locally.
|
||||||
|
4. Client registers with `/api/v1/devices/enroll`.
|
||||||
|
5. Backend resolves policy, allocates IP, selects a gateway, stores the peer, and returns profile metadata.
|
||||||
|
6. Client stores tokens and WireGuard config securely.
|
||||||
|
7. Client uses the embedded tunnel manager to create the local profile and expose one-click connect/disconnect.
|
||||||
|
|
||||||
|
### Policy Update
|
||||||
|
|
||||||
|
1. Admin changes a policy in the web UI.
|
||||||
|
2. Backend stores the update and writes an audit event.
|
||||||
|
3. Gateway sync state is recalculated.
|
||||||
|
4. Gateway rule bundle is regenerated for affected peers.
|
||||||
|
5. The client sees refreshed allowed resources on next sync.
|
||||||
|
|
||||||
|
## Security Posture
|
||||||
|
|
||||||
|
- Passwords use Argon2id.
|
||||||
|
- Refresh tokens are stored hashed.
|
||||||
|
- Device private keys stay local.
|
||||||
|
- Audit logs capture auth, enrollment, policy, and admin actions.
|
||||||
|
- TLS is assumed in production behind a reverse proxy.
|
||||||
|
- Gateway firewalling enforces allowed destinations independently from the client.
|
||||||
60
docs/deployment.md
Normal file
60
docs/deployment.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Deployment Layout
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- `postgres`
|
||||||
|
- primary relational database
|
||||||
|
- `backend`
|
||||||
|
- Go API and migration runner
|
||||||
|
- `admin-web`
|
||||||
|
- static React admin UI served by nginx
|
||||||
|
- `gateway`
|
||||||
|
- WireGuard plus nftables helper container or host-managed service
|
||||||
|
- `reverse-proxy`
|
||||||
|
- TLS termination and routing
|
||||||
|
|
||||||
|
## Docker Compose Networks
|
||||||
|
|
||||||
|
- `control`
|
||||||
|
- backend, postgres, admin-web, reverse-proxy
|
||||||
|
- `gateway`
|
||||||
|
- backend and gateway helper communication
|
||||||
|
|
||||||
|
## Volume Layout
|
||||||
|
|
||||||
|
- postgres data volume
|
||||||
|
- backend local state volume for dev logs if needed
|
||||||
|
- gateway config volume for rendered peer sync
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
1. Start PostgreSQL.
|
||||||
|
2. Run migrations.
|
||||||
|
3. Start the backend.
|
||||||
|
4. Seed roles, settings, and the initial admin user.
|
||||||
|
5. Start the admin UI and reverse proxy.
|
||||||
|
6. Register the first gateway.
|
||||||
|
|
||||||
|
## Example Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d postgres
|
||||||
|
docker compose up -d backend admin-web reverse-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
For SQL bootstrap during early MVP testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f backend/migrations/000001_init.sql
|
||||||
|
psql "$DATABASE_URL" -f backend/seed/001_seed.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Notes
|
||||||
|
|
||||||
|
- Terminate TLS at nginx or another reverse proxy.
|
||||||
|
- Restrict backend and database exposure to private networks.
|
||||||
|
- Run the gateway with the privileges required for WireGuard and nftables.
|
||||||
|
- Replace example secrets before deployment.
|
||||||
|
- Use an external secret manager when available.
|
||||||
76
docs/enrollment.md
Normal file
76
docs/enrollment.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Device Enrollment And Auto-Provisioning
|
||||||
|
|
||||||
|
## Enrollment Flow
|
||||||
|
|
||||||
|
1. The user launches the desktop client.
|
||||||
|
2. The client shows a minimal form with:
|
||||||
|
- server URL
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
3. The client validates the server URL and performs a TLS reachability check.
|
||||||
|
4. The user submits credentials.
|
||||||
|
5. The client calls `POST /api/v1/auth/login`.
|
||||||
|
6. On success, the client securely stores:
|
||||||
|
- access token
|
||||||
|
- refresh token
|
||||||
|
- remembered server URL
|
||||||
|
7. The client generates a WireGuard keypair locally.
|
||||||
|
8. The client computes a stable device fingerprint.
|
||||||
|
9. The client calls `POST /api/v1/devices/enroll`.
|
||||||
|
10. The backend:
|
||||||
|
- creates or updates the device record
|
||||||
|
- selects an active gateway
|
||||||
|
- allocates a VPN IP
|
||||||
|
- resolves effective policy destinations
|
||||||
|
- stores the peer
|
||||||
|
- renders the WireGuard profile
|
||||||
|
- emits audit events
|
||||||
|
11. The client stores the profile locally in secure application storage.
|
||||||
|
12. The client registers the profile with the local tunnel manager.
|
||||||
|
13. The post-login view shows:
|
||||||
|
- connection status
|
||||||
|
- assigned VPN IP
|
||||||
|
- allowed resources
|
||||||
|
- connect/disconnect button
|
||||||
|
- last sync time
|
||||||
|
|
||||||
|
## Private Key Handling
|
||||||
|
|
||||||
|
- The WireGuard private key is generated on-device.
|
||||||
|
- It is never sent to the backend.
|
||||||
|
- The backend stores only the public key and provisioning metadata.
|
||||||
|
- The desktop app keeps the private key inside the OS-secured storage boundary where possible.
|
||||||
|
|
||||||
|
## Auto-Profile Provisioning Design
|
||||||
|
|
||||||
|
The provisioning response returns both structured metadata and a rendered WireGuard config. The client uses the rendered config for immediate compatibility and the structured metadata for UI status.
|
||||||
|
|
||||||
|
### Preferred MVP Strategy
|
||||||
|
|
||||||
|
Use an embedded tunnel manager inside the Tauri app:
|
||||||
|
|
||||||
|
- Windows
|
||||||
|
- manage the tunnel using a Rust bridge and local service abstraction
|
||||||
|
- support future integration with WireGuardNT or the official service
|
||||||
|
- macOS
|
||||||
|
- manage the tunnel through a privileged helper or Network Extension bridge in a later hardening phase
|
||||||
|
- MVP keeps the app abstraction stable while platform backends can evolve
|
||||||
|
|
||||||
|
This approach keeps the user flow consistent even if the platform-specific implementation differs.
|
||||||
|
|
||||||
|
### Fallback Strategy
|
||||||
|
|
||||||
|
If native embedded control is not ready on a platform:
|
||||||
|
|
||||||
|
- the app still auto-creates the profile locally
|
||||||
|
- the app exposes a one-click import handoff to the system WireGuard client
|
||||||
|
- this fallback is clearly labeled as temporary in documentation, not as the intended end state
|
||||||
|
|
||||||
|
## Reprovisioning
|
||||||
|
|
||||||
|
- Profile rotation increments `profile_revision`.
|
||||||
|
- On next sync or forced refresh, the client fetches a new profile.
|
||||||
|
- Revoked devices lose access because:
|
||||||
|
- the gateway peer is removed
|
||||||
|
- tokens can be invalidated
|
||||||
|
- the client marks the local profile unusable
|
||||||
84
docs/folder-structure.md
Normal file
84
docs/folder-structure.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Folder Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
NexaVPN/
|
||||||
|
├── README.md
|
||||||
|
├── docs/
|
||||||
|
│ ├── api.md
|
||||||
|
│ ├── architecture.md
|
||||||
|
│ ├── deployment.md
|
||||||
|
│ ├── enrollment.md
|
||||||
|
│ ├── folder-structure.md
|
||||||
|
│ ├── gateway.md
|
||||||
|
│ └── schema.md
|
||||||
|
├── backend/
|
||||||
|
│ ├── cmd/api/
|
||||||
|
│ │ └── main.go
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── apiutil/
|
||||||
|
│ │ ├── app/
|
||||||
|
│ │ ├── audit/
|
||||||
|
│ │ ├── auth/
|
||||||
|
│ │ ├── config/
|
||||||
|
│ │ ├── db/
|
||||||
|
│ │ ├── device/
|
||||||
|
│ │ ├── gateway/
|
||||||
|
│ │ ├── httpserver/
|
||||||
|
│ │ ├── ipam/
|
||||||
|
│ │ ├── policy/
|
||||||
|
│ │ ├── profile/
|
||||||
|
│ │ ├── user/
|
||||||
|
│ │ └── wireguard/
|
||||||
|
│ ├── migrations/
|
||||||
|
│ │ └── 000001_init.sql
|
||||||
|
│ ├── seed/
|
||||||
|
│ │ └── 001_seed.sql
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── go.mod
|
||||||
|
├── admin-web/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ ├── app/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── features/
|
||||||
|
│ │ │ ├── audit/
|
||||||
|
│ │ │ ├── dashboard/
|
||||||
|
│ │ │ ├── devices/
|
||||||
|
│ │ │ ├── gateways/
|
||||||
|
│ │ │ ├── policies/
|
||||||
|
│ │ │ ├── settings/
|
||||||
|
│ │ │ └── users/
|
||||||
|
│ │ └── styles/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── tsconfig.app.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ └── vite.config.ts
|
||||||
|
├── desktop-client/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.tsx
|
||||||
|
│ │ ├── main.tsx
|
||||||
|
│ │ └── styles.css
|
||||||
|
│ ├── src-tauri/
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── lib.rs
|
||||||
|
│ │ │ └── main.rs
|
||||||
|
│ │ ├── build.rs
|
||||||
|
│ │ ├── Cargo.toml
|
||||||
|
│ │ └── tauri.conf.json
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── tsconfig.app.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ └── vite.config.ts
|
||||||
|
└── deploy/
|
||||||
|
├── .env.example
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── nginx/
|
||||||
|
│ ├── admin.conf
|
||||||
|
│ └── reverse-proxy.conf
|
||||||
|
└── scripts/
|
||||||
|
├── bootstrap-admin.sh
|
||||||
|
└── gateway-entrypoint.sh
|
||||||
|
```
|
||||||
69
docs/gateway.md
Normal file
69
docs/gateway.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Gateway Enforcement Strategy
|
||||||
|
|
||||||
|
## WireGuard And Firewall Roles
|
||||||
|
|
||||||
|
- WireGuard authenticates peers and provides encrypted transport.
|
||||||
|
- nftables enforces which protected destinations a peer may reach.
|
||||||
|
- NexaVPN control plane translates policy into gateway-side rules.
|
||||||
|
|
||||||
|
## Gateway Sync Bundle
|
||||||
|
|
||||||
|
Each gateway receives a generated sync bundle that contains:
|
||||||
|
|
||||||
|
- interface settings
|
||||||
|
- peer list
|
||||||
|
- peer allowed source address
|
||||||
|
- destination policy matrix
|
||||||
|
- DNS metadata
|
||||||
|
- revision metadata
|
||||||
|
|
||||||
|
Example bundle shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gateway_id": "uuid",
|
||||||
|
"revision": 12,
|
||||||
|
"interface": {
|
||||||
|
"address": "100.96.0.1/24",
|
||||||
|
"listen_port": 51820
|
||||||
|
},
|
||||||
|
"peers": [
|
||||||
|
{
|
||||||
|
"device_id": "uuid",
|
||||||
|
"public_key": "peer-key",
|
||||||
|
"assigned_ip": "100.96.0.10/32",
|
||||||
|
"allowed_destinations": [
|
||||||
|
"172.16.10.0/24"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## nftables Model
|
||||||
|
|
||||||
|
Recommended model:
|
||||||
|
|
||||||
|
1. Accept WireGuard interface input.
|
||||||
|
2. Map peer source VPN IP to allowed destination CIDRs.
|
||||||
|
3. Drop traffic from VPN clients to destinations outside their effective allow list.
|
||||||
|
4. Permit full tunnel peers through explicit default-route policy.
|
||||||
|
|
||||||
|
High-level chain logic:
|
||||||
|
|
||||||
|
- traffic enters from `wg0`
|
||||||
|
- source address identifies the device
|
||||||
|
- destination is matched against generated sets
|
||||||
|
- allowed traffic is accepted
|
||||||
|
- unmatched traffic is dropped and optionally logged
|
||||||
|
|
||||||
|
## Enforcement Details
|
||||||
|
|
||||||
|
- Each device receives a unique VPN IP, which makes firewall mapping deterministic.
|
||||||
|
- The generated firewall rules are derived from the effective policy union.
|
||||||
|
- Device revocation removes both the WireGuard peer and its nftables set members.
|
||||||
|
- Full-tunnel policy expands to `0.0.0.0/0` and `::/0` when enabled in later IPv6 support.
|
||||||
|
|
||||||
|
## Multi-Gateway Readiness
|
||||||
|
|
||||||
|
The backend stores policies independently from the gateway implementation. Each gateway receives only the peers assigned to it, which keeps multi-gateway expansion straightforward later.
|
||||||
204
docs/schema.md
Normal file
204
docs/schema.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# PostgreSQL Schema
|
||||||
|
|
||||||
|
## Core Tables
|
||||||
|
|
||||||
|
### `roles`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `name text unique not null`
|
||||||
|
- `description text not null default ''`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `updated_at timestamptz not null default now()`
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `role_id uuid not null references roles(id)`
|
||||||
|
- `username citext unique not null`
|
||||||
|
- `display_name text not null`
|
||||||
|
- `email citext unique`
|
||||||
|
- `password_hash text not null`
|
||||||
|
- `is_active boolean not null default true`
|
||||||
|
- `last_login_at timestamptz`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `updated_at timestamptz not null default now()`
|
||||||
|
- `deleted_at timestamptz`
|
||||||
|
|
||||||
|
### `sessions`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `user_id uuid not null references users(id)`
|
||||||
|
- `ip_address inet`
|
||||||
|
- `user_agent text`
|
||||||
|
- `last_seen_at timestamptz not null default now()`
|
||||||
|
- `expires_at timestamptz not null`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `revoked_at timestamptz`
|
||||||
|
|
||||||
|
### `refresh_tokens`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `session_id uuid not null references sessions(id)`
|
||||||
|
- `user_id uuid not null references users(id)`
|
||||||
|
- `token_hash text not null`
|
||||||
|
- `expires_at timestamptz not null`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `revoked_at timestamptz`
|
||||||
|
|
||||||
|
### `gateways`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `name text unique not null`
|
||||||
|
- `endpoint text not null`
|
||||||
|
- `public_key text not null`
|
||||||
|
- `listen_port integer not null`
|
||||||
|
- `vpn_cidr cidr not null`
|
||||||
|
- `dns_servers text[] not null default '{}'`
|
||||||
|
- `is_active boolean not null default true`
|
||||||
|
- `last_sync_at timestamptz`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `updated_at timestamptz not null default now()`
|
||||||
|
- `deleted_at timestamptz`
|
||||||
|
|
||||||
|
### `devices`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `user_id uuid not null references users(id)`
|
||||||
|
- `gateway_id uuid references gateways(id)`
|
||||||
|
- `name text not null`
|
||||||
|
- `platform text not null`
|
||||||
|
- `os_version text not null default ''`
|
||||||
|
- `app_version text not null default ''`
|
||||||
|
- `device_fingerprint text not null`
|
||||||
|
- `public_key text not null`
|
||||||
|
- `status text not null default 'active'`
|
||||||
|
- `last_seen_at timestamptz`
|
||||||
|
- `last_connected_at timestamptz`
|
||||||
|
- `approved_at timestamptz`
|
||||||
|
- `revoked_at timestamptz`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `updated_at timestamptz not null default now()`
|
||||||
|
- `deleted_at timestamptz`
|
||||||
|
|
||||||
|
Unique index:
|
||||||
|
|
||||||
|
- `(user_id, device_fingerprint)` where `deleted_at is null`
|
||||||
|
|
||||||
|
### `wireguard_peers`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `device_id uuid not null references devices(id)`
|
||||||
|
- `gateway_id uuid not null references gateways(id)`
|
||||||
|
- `public_key text unique not null`
|
||||||
|
- `assigned_ip inet not null`
|
||||||
|
- `preshared_key_ciphertext text`
|
||||||
|
- `allowed_ips cidr[] not null default '{}'`
|
||||||
|
- `dns_servers text[] not null default '{}'`
|
||||||
|
- `profile_revision integer not null default 1`
|
||||||
|
- `last_profile_issued_at timestamptz`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `updated_at timestamptz not null default now()`
|
||||||
|
- `deleted_at timestamptz`
|
||||||
|
|
||||||
|
### `ip_allocations`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `gateway_id uuid not null references gateways(id)`
|
||||||
|
- `device_id uuid references devices(id)`
|
||||||
|
- `address inet not null`
|
||||||
|
- `status text not null default 'allocated'`
|
||||||
|
- `allocated_at timestamptz not null default now()`
|
||||||
|
- `released_at timestamptz`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `updated_at timestamptz not null default now()`
|
||||||
|
|
||||||
|
Unique indexes:
|
||||||
|
|
||||||
|
- `(gateway_id, address)`
|
||||||
|
- `(device_id)` where `status = 'allocated'`
|
||||||
|
|
||||||
|
### `policies`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `name text unique not null`
|
||||||
|
- `description text not null default ''`
|
||||||
|
- `priority integer not null default 100`
|
||||||
|
- `effect text not null default 'allow'`
|
||||||
|
- `is_active boolean not null default true`
|
||||||
|
- `full_tunnel boolean not null default false`
|
||||||
|
- `created_by uuid references users(id)`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `updated_at timestamptz not null default now()`
|
||||||
|
- `deleted_at timestamptz`
|
||||||
|
|
||||||
|
### `policy_targets`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `policy_id uuid not null references policies(id)`
|
||||||
|
- `target_type text not null`
|
||||||
|
- `target_id uuid not null`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
|
||||||
|
Target types:
|
||||||
|
|
||||||
|
- `user`
|
||||||
|
- `device`
|
||||||
|
- `group`
|
||||||
|
|
||||||
|
### `policy_destinations`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `policy_id uuid not null references policies(id)`
|
||||||
|
- `destination cidr not null`
|
||||||
|
- `description text not null default ''`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
|
||||||
|
### `groups`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `name text unique not null`
|
||||||
|
- `description text not null default ''`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `updated_at timestamptz not null default now()`
|
||||||
|
- `deleted_at timestamptz`
|
||||||
|
|
||||||
|
### `group_memberships`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `group_id uuid not null references groups(id)`
|
||||||
|
- `user_id uuid not null references users(id)`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
|
||||||
|
### `audit_logs`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `actor_user_id uuid references users(id)`
|
||||||
|
- `actor_device_id uuid references devices(id)`
|
||||||
|
- `event_type text not null`
|
||||||
|
- `entity_type text not null`
|
||||||
|
- `entity_id uuid`
|
||||||
|
- `status text not null`
|
||||||
|
- `ip_address inet`
|
||||||
|
- `message text not null`
|
||||||
|
- `metadata jsonb not null default '{}'::jsonb`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
|
||||||
|
### `settings`
|
||||||
|
|
||||||
|
- `id uuid primary key`
|
||||||
|
- `category text not null`
|
||||||
|
- `key text not null`
|
||||||
|
- `value jsonb not null`
|
||||||
|
- `created_at timestamptz not null default now()`
|
||||||
|
- `updated_at timestamptz not null default now()`
|
||||||
|
|
||||||
|
Unique index:
|
||||||
|
|
||||||
|
- `(category, key)`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- UUIDs are generated with `gen_random_uuid()`.
|
||||||
|
- `citext` is used for case-insensitive usernames and emails.
|
||||||
|
- Soft deletes are enabled where historical traceability matters.
|
||||||
|
- Group tables are included now so policy resolution can grow without a destructive migration later.
|
||||||
Reference in New Issue
Block a user