docs: update README with desktop requirements, helper builds, and realistic MVP usage notes
Expand README with desktop platform requirements (Windows x86, macOS ARM), helper build commands, gateway utility scripts, and updated local test flow. Add realistic MVP usage section clarifying current platform build status, gateway configuration needs, and admin debug profile behavior with client private key handling.
This commit is contained in:
40
README.md
40
README.md
@@ -41,8 +41,30 @@ This repository contains the initial production-minded MVP scaffold:
|
|||||||
- WireGuard remains the tunnel transport. NexaVPN is the control plane around it.
|
- 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.
|
- Client private keys are generated on-device and are not stored server-side.
|
||||||
- Gateway-side enforcement uses nftables generated from issued policy state.
|
- 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 desktop client is structured so NexaVPN is the only user-facing VPN app.
|
||||||
- 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.
|
- The tunnel layer still uses WireGuard internally, but the intended delivery model is a NexaVPN-bundled tunnel backend, not a separately used WireGuard app.
|
||||||
|
|
||||||
|
## Desktop Requirements
|
||||||
|
|
||||||
|
- Windows x86: package NexaVPN with the bundled Windows x86 tunnel helper
|
||||||
|
- macOS ARM: package NexaVPN with the bundled macOS ARM tunnel helper
|
||||||
|
|
||||||
|
See [client-platforms.md](/mnt/c/Users/neste/Documents/GIT/NexaVPN/docs/client-platforms.md) for the current platform strategy.
|
||||||
|
|
||||||
|
Helper build commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd desktop-client
|
||||||
|
npm run helper:windows-x86
|
||||||
|
npm run helper:macos-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
Gateway utility scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/scripts/generate-gateway-keypair.sh
|
||||||
|
./deploy/scripts/get-admin-token.sh http://localhost admin your-password
|
||||||
|
```
|
||||||
|
|
||||||
## Local Test Flow
|
## Local Test Flow
|
||||||
|
|
||||||
@@ -56,5 +78,15 @@ Then:
|
|||||||
|
|
||||||
1. Visit `http://localhost`
|
1. Visit `http://localhost`
|
||||||
2. Bootstrap the first admin account
|
2. Bootstrap the first admin account
|
||||||
3. Create a user or use the desktop client against `http://localhost`
|
3. Create a standard user in the `Users` page
|
||||||
4. Sign in from the NexaVPN desktop app with that user
|
4. Create a user policy in the `Policies` page
|
||||||
|
5. Enroll a device from the NexaVPN desktop app against `http://localhost`
|
||||||
|
6. Inspect the generated device profile in the `Devices` page
|
||||||
|
|
||||||
|
## Realistic MVP Usage
|
||||||
|
|
||||||
|
The current repository can act as a real WireGuard control plane and issue per-device peer state, but these platform pieces are still at MVP level:
|
||||||
|
|
||||||
|
- the desktop app now targets an embedded NexaVPN tunnel backend model, and the helper source is in-repo, but final platform builds and signing still need to happen per target OS
|
||||||
|
- the gateway helper now applies WireGuard and nftables state in-container, but you still need to provide the gateway private key and correct uplink interface settings
|
||||||
|
- admin debug profiles intentionally use a private-key placeholder because the client private key stays local
|
||||||
|
|||||||
@@ -17,6 +17,31 @@ export type Device = {
|
|||||||
assigned_ip?: string;
|
assigned_ip?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeviceProfile = {
|
||||||
|
device: Device;
|
||||||
|
peer: {
|
||||||
|
assigned_ip: string;
|
||||||
|
dns_servers: string[];
|
||||||
|
allowed_ips: string[];
|
||||||
|
gateway: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
endpoint: string;
|
||||||
|
public_key: string;
|
||||||
|
};
|
||||||
|
profile_revision: number;
|
||||||
|
};
|
||||||
|
profile: {
|
||||||
|
format: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
resources: Array<{
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type Policy = {
|
export type Policy = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -68,8 +93,56 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
users: () => request<User[]>("/admin/users"),
|
users: () => request<User[]>("/admin/users"),
|
||||||
|
createUser: (payload: {
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
}) =>
|
||||||
|
request<User>("/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
devices: () => request<Device[]>("/admin/devices"),
|
devices: () => request<Device[]>("/admin/devices"),
|
||||||
|
deviceProfile: (deviceId: string) => request<DeviceProfile>(`/admin/devices/${deviceId}/profile`),
|
||||||
|
revokeDevice: (deviceId: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/devices/${deviceId}/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({})
|
||||||
|
}),
|
||||||
|
rotateDevice: (deviceId: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/devices/${deviceId}/rotate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({})
|
||||||
|
}),
|
||||||
policies: () => request<Policy[]>("/admin/policies"),
|
policies: () => request<Policy[]>("/admin/policies"),
|
||||||
|
createPolicy: (payload: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
priority: number;
|
||||||
|
effect: string;
|
||||||
|
full_tunnel: boolean;
|
||||||
|
destinations: string[];
|
||||||
|
targets: Array<{ type: string; id: string }>;
|
||||||
|
}) =>
|
||||||
|
request<Policy>("/admin/policies", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
gateways: () => request<Gateway[]>("/admin/gateways"),
|
gateways: () => request<Gateway[]>("/admin/gateways"),
|
||||||
|
updateGateway: (gatewayId: string, payload: {
|
||||||
|
endpoint: string;
|
||||||
|
public_key: string;
|
||||||
|
listen_port: number;
|
||||||
|
vpn_cidr: string;
|
||||||
|
dns_servers: string[];
|
||||||
|
is_active: boolean;
|
||||||
|
}) =>
|
||||||
|
request<Gateway>(`/admin/gateways/${gatewayId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
|
gatewaySync: (gatewayId: string) => request<unknown>(`/admin/gateways/${gatewayId}/sync`),
|
||||||
audit: () => request<AuditEvent[]>("/admin/audit-logs")
|
audit: () => request<AuditEvent[]>("/admin/audit-logs")
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "../../api/client";
|
import { api } from "../../api/client";
|
||||||
import { Page } from "../../components/Page";
|
import { Page } from "../../components/Page";
|
||||||
@@ -13,12 +13,27 @@ const columns = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function DevicesPage() {
|
export function DevicesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["devices"],
|
queryKey: ["devices"],
|
||||||
queryFn: api.devices
|
queryFn: api.devices
|
||||||
});
|
});
|
||||||
|
const profileQuery = useQuery({
|
||||||
|
queryKey: ["device-profile", query.data?.[0]?.id ?? ""],
|
||||||
|
queryFn: () => api.deviceProfile(query.data?.[0]?.id ?? ""),
|
||||||
|
enabled: Boolean(query.data?.[0]?.id)
|
||||||
|
});
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: api.revokeDevice,
|
||||||
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
|
||||||
|
});
|
||||||
|
const rotateMutation = useMutation({
|
||||||
|
mutationFn: api.rotateDevice,
|
||||||
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
|
||||||
|
});
|
||||||
|
|
||||||
const rows = query.data?.map((device) => ({
|
const rows = query.data?.map((device) => ({
|
||||||
|
id: device.id,
|
||||||
name: device.name,
|
name: device.name,
|
||||||
owner: device.user_id ?? "assigned user",
|
owner: device.user_id ?? "assigned user",
|
||||||
platform: device.platform,
|
platform: device.platform,
|
||||||
@@ -34,6 +49,23 @@ export function DevicesPage() {
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
|
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
|
||||||
/>
|
/>
|
||||||
|
{rows.length > 0 ? (
|
||||||
|
<div className="grid two">
|
||||||
|
<div className="card">
|
||||||
|
<h4>Selected device profile</h4>
|
||||||
|
<p className="notice">Admin view shows a debug template. The client private key stays on the device.</p>
|
||||||
|
<pre className="code-block">{profileQuery.data?.profile.content ?? "Loading profile..."}</pre>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h4>Device actions</h4>
|
||||||
|
<p>Target: {rows[0].name}</p>
|
||||||
|
<div className="action-row">
|
||||||
|
<button className="button" onClick={() => rotateMutation.mutate(rows[0].id)}>Rotate profile</button>
|
||||||
|
<button className="ghost-button" onClick={() => revokeMutation.mutate(rows[0].id)}>Revoke device</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "../../api/client";
|
import { api } from "../../api/client";
|
||||||
import { Page } from "../../components/Page";
|
import { Page } from "../../components/Page";
|
||||||
@@ -12,10 +13,31 @@ const columns = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function GatewaysPage() {
|
export function GatewaysPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["gateways"],
|
queryKey: ["gateways"],
|
||||||
queryFn: api.gateways
|
queryFn: api.gateways
|
||||||
});
|
});
|
||||||
|
const syncQuery = useQuery({
|
||||||
|
queryKey: ["gateway-sync", query.data?.[0]?.id ?? ""],
|
||||||
|
queryFn: () => api.gatewaySync(query.data?.[0]?.id ?? ""),
|
||||||
|
enabled: Boolean(query.data?.[0]?.id)
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
endpoint: "",
|
||||||
|
public_key: "",
|
||||||
|
listen_port: 51820,
|
||||||
|
vpn_cidr: "100.96.0.0/24",
|
||||||
|
dns_servers: "10.20.0.53",
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (payload: Parameters<typeof api.updateGateway>[1]) => api.updateGateway(query.data?.[0]?.id ?? "", payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["gateways"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["gateway-sync"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const rows = query.data?.map((gateway) => ({
|
const rows = query.data?.map((gateway) => ({
|
||||||
name: gateway.name,
|
name: gateway.name,
|
||||||
@@ -24,6 +46,32 @@ export function GatewaysPage() {
|
|||||||
status: gateway.is_active ? "active" : "inactive"
|
status: gateway.is_active ? "active" : "inactive"
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.data?.[0]) {
|
||||||
|
const gateway = query.data[0];
|
||||||
|
setForm({
|
||||||
|
endpoint: gateway.endpoint,
|
||||||
|
public_key: gateway.public_key,
|
||||||
|
listen_port: gateway.listen_port,
|
||||||
|
vpn_cidr: gateway.vpn_cidr,
|
||||||
|
dns_servers: gateway.dns_servers.join(", "),
|
||||||
|
is_active: gateway.is_active
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
updateMutation.mutate({
|
||||||
|
endpoint: form.endpoint,
|
||||||
|
public_key: form.public_key,
|
||||||
|
listen_port: Number(form.listen_port),
|
||||||
|
vpn_cidr: form.vpn_cidr,
|
||||||
|
dns_servers: form.dns_servers.split(",").map((value) => value.trim()).filter(Boolean),
|
||||||
|
is_active: form.is_active
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title="Gateways" subtitle="Track WireGuard endpoints and sync state.">
|
<Page title="Gateways" subtitle="Track WireGuard endpoints and sync state.">
|
||||||
{query.isError ? <p className="notice">Unable to load gateways from the API.</p> : null}
|
{query.isError ? <p className="notice">Unable to load gateways from the API.</p> : null}
|
||||||
@@ -32,6 +80,24 @@ export function GatewaysPage() {
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
|
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
|
||||||
/>
|
/>
|
||||||
|
<form className="inline-form" onSubmit={onSubmit}>
|
||||||
|
<input placeholder="endpoint" value={form.endpoint} onChange={(event) => setForm((value) => ({ ...value, endpoint: event.target.value }))} />
|
||||||
|
<input placeholder="public key" value={form.public_key} onChange={(event) => setForm((value) => ({ ...value, public_key: event.target.value }))} />
|
||||||
|
<input placeholder="listen port" type="number" value={form.listen_port} onChange={(event) => setForm((value) => ({ ...value, listen_port: Number(event.target.value) }))} />
|
||||||
|
<input placeholder="vpn cidr" value={form.vpn_cidr} onChange={(event) => setForm((value) => ({ ...value, vpn_cidr: event.target.value }))} />
|
||||||
|
<input placeholder="dns servers" value={form.dns_servers} onChange={(event) => setForm((value) => ({ ...value, dns_servers: event.target.value }))} />
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={form.is_active} onChange={(event) => setForm((value) => ({ ...value, is_active: event.target.checked }))} />
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
<button className="button" type="submit" disabled={updateMutation.isPending}>Update gateway</button>
|
||||||
|
</form>
|
||||||
|
{syncQuery.data ? (
|
||||||
|
<div className="card">
|
||||||
|
<h4>Sync bundle preview</h4>
|
||||||
|
<pre className="code-block">{JSON.stringify(syncQuery.data, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "../../api/client";
|
import { api } from "../../api/client";
|
||||||
import { Page } from "../../components/Page";
|
import { Page } from "../../components/Page";
|
||||||
@@ -12,10 +13,30 @@ const columns = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function PoliciesPage() {
|
export function PoliciesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["policies"],
|
queryKey: ["policies"],
|
||||||
queryFn: api.policies
|
queryFn: api.policies
|
||||||
});
|
});
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: api.users
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
destinations: "",
|
||||||
|
targetUserId: "",
|
||||||
|
fullTunnel: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: api.createPolicy,
|
||||||
|
onSuccess: () => {
|
||||||
|
setForm({ name: "", description: "", destinations: "", targetUserId: "", fullTunnel: false });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["policies"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const rows = query.data?.map((policy) => ({
|
const rows = query.data?.map((policy) => ({
|
||||||
name: policy.name,
|
name: policy.name,
|
||||||
@@ -24,13 +45,55 @@ export function PoliciesPage() {
|
|||||||
mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel"
|
mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel"
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
|
const selectableUsers = useMemo(() => usersQuery.data ?? [], [usersQuery.data]);
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!form.targetUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createMutation.mutate({
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
priority: 100,
|
||||||
|
effect: "allow",
|
||||||
|
full_tunnel: form.fullTunnel,
|
||||||
|
destinations: form.fullTunnel ? ["0.0.0.0/0"] : form.destinations.split(",").map((value) => value.trim()).filter(Boolean),
|
||||||
|
targets: [{ type: "user", id: form.targetUserId }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
title="Policies"
|
title="Policies"
|
||||||
subtitle="Manage per-user and per-device access controls enforced at the gateway."
|
subtitle="Manage per-user and per-device access controls enforced at the gateway."
|
||||||
actions={<button className="button">New policy</button>}
|
actions={<span className="pill">Gateway enforced</span>}
|
||||||
>
|
>
|
||||||
|
<form className="inline-form" onSubmit={onSubmit}>
|
||||||
|
<input placeholder="policy name" value={form.name} onChange={(event) => setForm((value) => ({ ...value, name: event.target.value }))} />
|
||||||
|
<input placeholder="description" value={form.description} onChange={(event) => setForm((value) => ({ ...value, description: event.target.value }))} />
|
||||||
|
<input
|
||||||
|
placeholder="destinations: 172.16.10.0/24,10.0.0.5/32"
|
||||||
|
value={form.destinations}
|
||||||
|
onChange={(event) => setForm((value) => ({ ...value, destinations: event.target.value }))}
|
||||||
|
disabled={form.fullTunnel}
|
||||||
|
/>
|
||||||
|
<select value={form.targetUserId} onChange={(event) => setForm((value) => ({ ...value, targetUserId: event.target.value }))}>
|
||||||
|
<option value="">target user</option>
|
||||||
|
{selectableUsers.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={form.fullTunnel} onChange={(event) => setForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
|
||||||
|
Full tunnel
|
||||||
|
</label>
|
||||||
|
<button className="button" type="submit" disabled={createMutation.isPending}>Create policy</button>
|
||||||
|
</form>
|
||||||
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
|
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
|
||||||
|
{createMutation.isError ? <p className="notice">Unable to create policy.</p> : null}
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "../../api/client";
|
import { api } from "../../api/client";
|
||||||
import { Page } from "../../components/Page";
|
import { Page } from "../../components/Page";
|
||||||
@@ -12,10 +13,26 @@ const columns = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["users"],
|
queryKey: ["users"],
|
||||||
queryFn: api.users
|
queryFn: api.users
|
||||||
});
|
});
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
username: "",
|
||||||
|
display_name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
role: "user"
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: api.createUser,
|
||||||
|
onSuccess: () => {
|
||||||
|
setForm({ username: "", display_name: "", email: "", password: "", role: "user" });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const rows = query.data?.map((user) => ({
|
const rows = query.data?.map((user) => ({
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -24,13 +41,30 @@ export function UsersPage() {
|
|||||||
status: user.is_active ? "active" : "disabled"
|
status: user.is_active ? "active" : "disabled"
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
createMutation.mutate(form);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
title="Users"
|
title="Users"
|
||||||
subtitle="Create, disable, and manage platform identities."
|
subtitle="Create, disable, and manage platform identities."
|
||||||
actions={<button className="button">New user</button>}
|
actions={<span className="pill">Admin managed</span>}
|
||||||
>
|
>
|
||||||
|
<form className="inline-form" onSubmit={onSubmit}>
|
||||||
|
<input placeholder="username" value={form.username} onChange={(event) => setForm((value) => ({ ...value, username: event.target.value }))} />
|
||||||
|
<input placeholder="display name" value={form.display_name} onChange={(event) => setForm((value) => ({ ...value, display_name: event.target.value }))} />
|
||||||
|
<input placeholder="email" value={form.email} onChange={(event) => setForm((value) => ({ ...value, email: event.target.value }))} />
|
||||||
|
<input placeholder="password" type="password" value={form.password} onChange={(event) => setForm((value) => ({ ...value, password: event.target.value }))} />
|
||||||
|
<select value={form.role} onChange={(event) => setForm((value) => ({ ...value, role: event.target.value }))}>
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<button className="button" type="submit" disabled={createMutation.isPending}>Create user</button>
|
||||||
|
</form>
|
||||||
{query.isError ? <p className="notice">Unable to load users from the API.</p> : null}
|
{query.isError ? <p className="notice">Unable to load users from the API.</p> : null}
|
||||||
|
{createMutation.isError ? <p className="notice">Unable to create user.</p> : null}
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
|||||||
@@ -162,6 +162,30 @@ button {
|
|||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form input,
|
||||||
|
.inline-form select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(8, 14, 26, 0.86);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.button,
|
.button,
|
||||||
.pill {
|
.pill {
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -210,6 +234,20 @@ button {
|
|||||||
color: #ffcf9b;
|
color: #ffcf9b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(8, 14, 26, 0.92);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.shell {
|
.shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -224,4 +262,8 @@ button {
|
|||||||
.grid.three {
|
.grid.three {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,11 +96,19 @@ func (s *Service) ListAll(ctx context.Context) ([]Device, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUID) (EnrollmentResponse, error) {
|
func (s *Service) GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUID) (EnrollmentResponse, error) {
|
||||||
return s.repo.GetLatestEnrollmentByUser(ctx, userID)
|
enrollment, err := s.repo.GetLatestEnrollmentByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
return withDebugProfile(enrollment), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error) {
|
func (s *Service) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error) {
|
||||||
return s.repo.GetEnrollmentByDeviceID(ctx, deviceID)
|
enrollment, err := s.repo.GetEnrollmentByDeviceID(ctx, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return EnrollmentResponse{}, err
|
||||||
|
}
|
||||||
|
return withDebugProfile(enrollment), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetConnectionStatus(ctx context.Context, userID uuid.UUID) (ConnectionStatus, error) {
|
func (s *Service) GetConnectionStatus(ctx context.Context, userID uuid.UUID) (ConnectionStatus, error) {
|
||||||
@@ -128,3 +136,19 @@ func (s *Service) Revoke(ctx context.Context, deviceID uuid.UUID) error {
|
|||||||
func (s *Service) Rotate(ctx context.Context, deviceID uuid.UUID) error {
|
func (s *Service) Rotate(ctx context.Context, deviceID uuid.UUID) error {
|
||||||
return s.repo.Rotate(ctx, deviceID)
|
return s.repo.Rotate(ctx, deviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withDebugProfile(enrollment EnrollmentResponse) EnrollmentResponse {
|
||||||
|
enrollment.Profile = ProfileView{
|
||||||
|
Format: "wireguard",
|
||||||
|
Content: profile.BuildWireGuardConfig(profile.BuildInput{
|
||||||
|
PrivateKey: "__CLIENT_PRIVATE_KEY_REQUIRED__",
|
||||||
|
Address: enrollment.Peer.AssignedIP,
|
||||||
|
DNSServers: enrollment.Peer.DNSServers,
|
||||||
|
ServerPublicKey: enrollment.Peer.Gateway.PublicKey,
|
||||||
|
ServerEndpoint: enrollment.Peer.Gateway.Endpoint,
|
||||||
|
AllowedIPs: enrollment.Peer.AllowedIPs,
|
||||||
|
PersistentKeepal: 25,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return enrollment
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -35,3 +36,19 @@ func (h *Handler) SyncBundle(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
apiutil.JSON(w, http.StatusOK, bundle)
|
apiutil.JSON(w, http.StatusOK, bundle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input UpdateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := h.service.Update(r.Context(), chi.URLParam(r, "id"), input)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "gateway_update_failed", "unable to update gateway")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package gateway
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -13,6 +14,7 @@ type Repository interface {
|
|||||||
List(ctx context.Context) ([]Gateway, error)
|
List(ctx context.Context) ([]Gateway, error)
|
||||||
FirstActive(ctx context.Context) (Gateway, error)
|
FirstActive(ctx context.Context) (Gateway, error)
|
||||||
BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error)
|
BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error)
|
||||||
|
Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PGRepository struct {
|
type PGRepository struct {
|
||||||
@@ -66,19 +68,33 @@ func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID)
|
|||||||
bundle.Revision = 1
|
bundle.Revision = 1
|
||||||
|
|
||||||
row := r.db.QueryRow(ctx, `
|
row := r.db.QueryRow(ctx, `
|
||||||
select host(vpn_cidr), listen_port
|
select vpn_cidr::text, listen_port
|
||||||
from gateways
|
from gateways
|
||||||
where id = $1 and deleted_at is null
|
where id = $1 and deleted_at is null
|
||||||
`, gatewayID)
|
`, gatewayID)
|
||||||
if err := row.Scan(&bundle.Interface.Address, &bundle.Interface.ListenPort); err != nil {
|
var vpnCIDR string
|
||||||
|
if err := row.Scan(&vpnCIDR, &bundle.Interface.ListenPort); err != nil {
|
||||||
return wireguard.GatewayBundle{}, err
|
return wireguard.GatewayBundle{}, err
|
||||||
}
|
}
|
||||||
|
interfaceAddress, err := gatewayInterfaceAddress(vpnCIDR)
|
||||||
|
if err != nil {
|
||||||
|
return wireguard.GatewayBundle{}, err
|
||||||
|
}
|
||||||
|
bundle.Interface.Address = interfaceAddress
|
||||||
|
bundle.Interface.NetworkCIDR = vpnCIDR
|
||||||
|
|
||||||
rows, err := r.db.Query(ctx, `
|
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), '{}')
|
select
|
||||||
|
d.id,
|
||||||
|
wp.public_key,
|
||||||
|
set_masklen(wp.assigned_ip, 32)::text,
|
||||||
|
coalesce(array_agg(distinct pd.destination::text) filter (where pd.destination is not null), '{}')
|
||||||
from devices d
|
from devices d
|
||||||
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
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_targets pt on (
|
||||||
|
(pt.target_type = 'device' and pt.target_id = d.id) or
|
||||||
|
(pt.target_type = 'user' and pt.target_id = d.user_id)
|
||||||
|
)
|
||||||
left join policy_destinations pd on pd.policy_id = pt.policy_id
|
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'
|
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
|
group by d.id, wp.public_key, wp.assigned_ip
|
||||||
@@ -100,3 +116,46 @@ func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID)
|
|||||||
|
|
||||||
return bundle, rows.Err()
|
return bundle, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error) {
|
||||||
|
row := r.db.QueryRow(ctx, `
|
||||||
|
update gateways
|
||||||
|
set endpoint = $2,
|
||||||
|
public_key = $3,
|
||||||
|
listen_port = $4,
|
||||||
|
vpn_cidr = $5::cidr,
|
||||||
|
dns_servers = $6::text[],
|
||||||
|
is_active = $7,
|
||||||
|
updated_at = now()
|
||||||
|
where id = $1
|
||||||
|
returning id, name, endpoint, public_key, listen_port, vpn_cidr, dns_servers, is_active
|
||||||
|
`, gatewayID, input.Endpoint, input.PublicKey, input.ListenPort, input.VPNCIDR, input.DNSServers, input.IsActive)
|
||||||
|
|
||||||
|
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 gatewayInterfaceAddress(cidr string) (string, error) {
|
||||||
|
prefix, err := netip.ParsePrefix(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix.Addr().Next().String() + "/" + intToString(prefix.Bits()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func intToString(value int) string {
|
||||||
|
if value == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
var digits [20]byte
|
||||||
|
index := len(digits)
|
||||||
|
for value > 0 {
|
||||||
|
index--
|
||||||
|
digits[index] = byte('0' + value%10)
|
||||||
|
value /= 10
|
||||||
|
}
|
||||||
|
return string(digits[index:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,3 +31,11 @@ func (s *Service) BuildSyncBundle(ctx context.Context, gatewayID string) (wiregu
|
|||||||
}
|
}
|
||||||
return s.repo.BuildSyncBundle(ctx, id)
|
return s.repo.BuildSyncBundle(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Update(ctx context.Context, gatewayID string, input UpdateRequest) (Gateway, error) {
|
||||||
|
id, err := uuid.Parse(gatewayID)
|
||||||
|
if err != nil {
|
||||||
|
return Gateway{}, err
|
||||||
|
}
|
||||||
|
return s.repo.Update(ctx, id, input)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,3 +12,12 @@ type Gateway struct {
|
|||||||
DNSServers []string `json:"dns_servers"`
|
DNSServers []string `json:"dns_servers"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateRequest struct {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
|
|||||||
r.Post("/policies", handlers.Policy.Create)
|
r.Post("/policies", handlers.Policy.Create)
|
||||||
r.Get("/gateways", handlers.Gateway.List)
|
r.Get("/gateways", handlers.Gateway.List)
|
||||||
r.Get("/gateways/{id}/sync", handlers.Gateway.SyncBundle)
|
r.Get("/gateways/{id}/sync", handlers.Gateway.SyncBundle)
|
||||||
|
r.Patch("/gateways/{id}", handlers.Gateway.Update)
|
||||||
r.Get("/audit-logs", handlers.Audit.List)
|
r.Get("/audit-logs", handlers.Audit.List)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type GatewayBundle struct {
|
|||||||
Revision int `json:"revision"`
|
Revision int `json:"revision"`
|
||||||
Interface struct {
|
Interface struct {
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
NetworkCIDR string `json:"network_cidr"`
|
||||||
ListenPort int `json:"listen_port"`
|
ListenPort int `json:"listen_port"`
|
||||||
} `json:"interface"`
|
} `json:"interface"`
|
||||||
Peers []Peer `json:"peers"`
|
Peers []Peer `json:"peers"`
|
||||||
|
|||||||
@@ -12,3 +12,10 @@ DEFAULT_DNS_SERVERS=10.20.0.53
|
|||||||
DEFAULT_VPN_CIDR=100.96.0.0/24
|
DEFAULT_VPN_CIDR=100.96.0.0/24
|
||||||
DEFAULT_GATEWAY_ENDPOINT=vpn.example.com:51820
|
DEFAULT_GATEWAY_ENDPOINT=vpn.example.com:51820
|
||||||
DEFAULT_GATEWAY_PUBLIC_KEY=replace-me
|
DEFAULT_GATEWAY_PUBLIC_KEY=replace-me
|
||||||
|
NEXAVPN_GATEWAY_ID=
|
||||||
|
NEXAVPN_GATEWAY_SYNC_URL=http://backend:8080/api/v1/admin/gateways
|
||||||
|
NEXAVPN_API_TOKEN=
|
||||||
|
NEXAVPN_GATEWAY_PRIVATE_KEY=
|
||||||
|
NEXAVPN_GATEWAY_INTERFACE=wg0
|
||||||
|
NEXAVPN_UPLINK_INTERFACE=eth0
|
||||||
|
NEXAVPN_ENABLE_MASQUERADE=true
|
||||||
|
|||||||
@@ -52,16 +52,28 @@ services:
|
|||||||
- control
|
- control
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
image: alpine:3.21
|
build:
|
||||||
command: ["sh", "/scripts/gateway-entrypoint.sh"]
|
context: .
|
||||||
|
dockerfile: gateway/Dockerfile
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
|
devices:
|
||||||
|
- /dev/net/tun:/dev/net/tun
|
||||||
|
environment:
|
||||||
|
NEXAVPN_GATEWAY_ID: ${NEXAVPN_GATEWAY_ID:-}
|
||||||
|
NEXAVPN_GATEWAY_SYNC_URL: ${NEXAVPN_GATEWAY_SYNC_URL:-http://backend:8080/api/v1/admin/gateways}
|
||||||
|
NEXAVPN_API_TOKEN: ${NEXAVPN_API_TOKEN:-}
|
||||||
|
NEXAVPN_GATEWAY_PRIVATE_KEY: ${NEXAVPN_GATEWAY_PRIVATE_KEY:-}
|
||||||
|
NEXAVPN_GATEWAY_INTERFACE: ${NEXAVPN_GATEWAY_INTERFACE:-wg0}
|
||||||
|
NEXAVPN_UPLINK_INTERFACE: ${NEXAVPN_UPLINK_INTERFACE:-eth0}
|
||||||
|
NEXAVPN_ENABLE_MASQUERADE: ${NEXAVPN_ENABLE_MASQUERADE:-true}
|
||||||
volumes:
|
volumes:
|
||||||
- ./scripts/gateway-entrypoint.sh:/scripts/gateway-entrypoint.sh:ro
|
- ./scripts/gateway-entrypoint.sh:/scripts/gateway-entrypoint.sh:ro
|
||||||
- gateway-state:/var/lib/nexavpn
|
- gateway-state:/var/lib/nexavpn
|
||||||
networks:
|
networks:
|
||||||
- gateway
|
- gateway
|
||||||
|
- control
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|||||||
8
deploy/gateway/Dockerfile
Normal file
8
deploy/gateway/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache bash curl jq wireguard-tools nftables
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY scripts/gateway-entrypoint.sh /scripts/gateway-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["bash", "/scripts/gateway-entrypoint.sh"]
|
||||||
@@ -1,8 +1,104 @@
|
|||||||
#!/bin/sh
|
#!/usr/bin/env bash
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
echo "NexaVPN gateway helper starting"
|
echo "NexaVPN gateway helper starting"
|
||||||
echo "This container is a placeholder for WireGuard + nftables sync logic."
|
mkdir -p /var/lib/nexavpn
|
||||||
echo "Mount generated gateway state into /var/lib/nexavpn and apply rules from there."
|
|
||||||
|
|
||||||
tail -f /dev/null
|
IFACE="${NEXAVPN_GATEWAY_INTERFACE:-wg0}"
|
||||||
|
UPLINK_IFACE="${NEXAVPN_UPLINK_INTERFACE:-eth0}"
|
||||||
|
ENABLE_MASQUERADE="${NEXAVPN_ENABLE_MASQUERADE:-true}"
|
||||||
|
|
||||||
|
if [ -z "${NEXAVPN_GATEWAY_ID:-}" ] || [ -z "${NEXAVPN_API_TOKEN:-}" ] || [ -z "${NEXAVPN_GATEWAY_PRIVATE_KEY:-}" ]; then
|
||||||
|
echo "Gateway sync is not configured yet."
|
||||||
|
echo "Set NEXAVPN_GATEWAY_ID, NEXAVPN_API_TOKEN and NEXAVPN_GATEWAY_PRIVATE_KEY."
|
||||||
|
echo "Gateway apply state will be written to /var/lib/nexavpn when configured."
|
||||||
|
tail -f /dev/null
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SYNC_URL="${NEXAVPN_GATEWAY_SYNC_URL}/${NEXAVPN_GATEWAY_ID}/sync"
|
||||||
|
STATE_JSON="/var/lib/nexavpn/sync-bundle.json"
|
||||||
|
WG_CONF="/etc/wireguard/${IFACE}.conf"
|
||||||
|
WG_GENERATED="/var/lib/nexavpn/${IFACE}.generated.conf"
|
||||||
|
NFT_CONF="/var/lib/nexavpn/nftables.generated.conf"
|
||||||
|
|
||||||
|
mkdir -p /etc/wireguard
|
||||||
|
|
||||||
|
apply_bundle() {
|
||||||
|
echo "Fetching bundle from ${SYNC_URL}"
|
||||||
|
curl -fsSL \
|
||||||
|
-H "Authorization: Bearer ${NEXAVPN_API_TOKEN}" \
|
||||||
|
"${SYNC_URL}" \
|
||||||
|
-o "${STATE_JSON}"
|
||||||
|
|
||||||
|
INTERFACE_ADDRESS=$(jq -r '.interface.address' "${STATE_JSON}")
|
||||||
|
NETWORK_CIDR=$(jq -r '.interface.network_cidr' "${STATE_JSON}")
|
||||||
|
LISTEN_PORT=$(jq -r '.interface.listen_port' "${STATE_JSON}")
|
||||||
|
|
||||||
|
cat > "${WG_GENERATED}" <<EOF
|
||||||
|
[Interface]
|
||||||
|
Address = ${INTERFACE_ADDRESS}
|
||||||
|
ListenPort = ${LISTEN_PORT}
|
||||||
|
PrivateKey = ${NEXAVPN_GATEWAY_PRIVATE_KEY}
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
jq -c '.peers[]?' "${STATE_JSON}" | while read -r peer; do
|
||||||
|
PUBLIC_KEY=$(printf '%s' "${peer}" | jq -r '.public_key')
|
||||||
|
ASSIGNED_IP=$(printf '%s' "${peer}" | jq -r '.assigned_ip')
|
||||||
|
|
||||||
|
cat >> "${WG_GENERATED}" <<EOF
|
||||||
|
[Peer]
|
||||||
|
PublicKey = ${PUBLIC_KEY}
|
||||||
|
AllowedIPs = ${ASSIGNED_IP}
|
||||||
|
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
|
||||||
|
cp "${WG_GENERATED}" "${WG_CONF}"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "flush ruleset"
|
||||||
|
echo "table inet nexavpn {"
|
||||||
|
echo " chain forward {"
|
||||||
|
echo " type filter hook forward priority 0;"
|
||||||
|
echo " policy drop;"
|
||||||
|
echo " ct state established,related accept"
|
||||||
|
echo " iifname \"${IFACE}\" ip saddr ${NETWORK_CIDR} oifname \"${UPLINK_IFACE}\" accept"
|
||||||
|
|
||||||
|
jq -c '.peers[]?' "${STATE_JSON}" | while read -r peer; do
|
||||||
|
ASSIGNED_IP=$(printf '%s' "${peer}" | jq -r '.assigned_ip')
|
||||||
|
printf '%s' "${peer}" | jq -r '.allowed_destinations[]?' | while read -r destination; do
|
||||||
|
echo " iifname \"${IFACE}\" ip saddr ${ASSIGNED_IP} ip daddr ${destination} accept"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " }"
|
||||||
|
if [ "${ENABLE_MASQUERADE}" = "true" ]; then
|
||||||
|
echo " chain postrouting {"
|
||||||
|
echo " type nat hook postrouting priority 100;"
|
||||||
|
echo " oifname \"${UPLINK_IFACE}\" ip saddr ${NETWORK_CIDR} masquerade"
|
||||||
|
echo " }"
|
||||||
|
fi
|
||||||
|
echo "}"
|
||||||
|
} > "${NFT_CONF}"
|
||||||
|
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||||
|
|
||||||
|
nft -f "${NFT_CONF}"
|
||||||
|
|
||||||
|
if ip link show "${IFACE}" >/dev/null 2>&1; then
|
||||||
|
wg syncconf "${IFACE}" <(wg-quick strip "${WG_CONF}")
|
||||||
|
ip link set "${IFACE}" up
|
||||||
|
else
|
||||||
|
wg-quick up "${WG_CONF}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Applied WireGuard config from ${WG_CONF}"
|
||||||
|
echo "Applied nftables config from ${NFT_CONF}"
|
||||||
|
}
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
apply_bundle || echo "Gateway apply failed; retrying in 15 seconds"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|||||||
13
deploy/scripts/generate-gateway-keypair.sh
Normal file
13
deploy/scripts/generate-gateway-keypair.sh
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if ! command -v wg >/dev/null 2>&1; then
|
||||||
|
echo "wg is required to generate gateway keys"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PRIVATE_KEY="$(wg genkey)"
|
||||||
|
PUBLIC_KEY="$(printf '%s' "${PRIVATE_KEY}" | wg pubkey)"
|
||||||
|
|
||||||
|
echo "NEXAVPN_GATEWAY_PRIVATE_KEY=${PRIVATE_KEY}"
|
||||||
|
echo "GATEWAY_PUBLIC_KEY=${PUBLIC_KEY}"
|
||||||
16
deploy/scripts/get-admin-token.sh
Normal file
16
deploy/scripts/get-admin-token.sh
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -lt 3 ]; then
|
||||||
|
echo "usage: get-admin-token.sh <base-url> <username> <password>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_URL="$1"
|
||||||
|
USERNAME="$2"
|
||||||
|
PASSWORD="$3"
|
||||||
|
|
||||||
|
curl -fsSL \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"${USERNAME}\",\"password\":\"${PASSWORD}\"}" \
|
||||||
|
"${BASE_URL%/}/api/v1/auth/login"
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build"
|
"tauri:build": "tauri build",
|
||||||
|
"helper:windows-x86": "bash ./scripts/build-tunnel-helper.sh i686-pc-windows-msvc",
|
||||||
|
"helper:macos-arm64": "bash ./scripts/build-tunnel-helper.sh aarch64-apple-darwin"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.3.0",
|
"@tauri-apps/api": "^2.3.0",
|
||||||
|
|||||||
32
desktop-client/scripts/build-tunnel-helper.sh
Normal file
32
desktop-client/scripts/build-tunnel-helper.sh
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
HELPER_DIR="${ROOT_DIR}/tunnel-helper"
|
||||||
|
BUNDLED_DIR="${ROOT_DIR}/src-tauri/bundled"
|
||||||
|
|
||||||
|
TARGET="${1:-}"
|
||||||
|
if [ -z "${TARGET}" ]; then
|
||||||
|
echo "usage: build-tunnel-helper.sh <cargo-target>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${TARGET}" in
|
||||||
|
i686-pc-windows-msvc)
|
||||||
|
OUTPUT_DIR="${BUNDLED_DIR}/windows-x86"
|
||||||
|
OUTPUT_NAME="nexavpn-tunnel-helper.exe"
|
||||||
|
;;
|
||||||
|
aarch64-apple-darwin)
|
||||||
|
OUTPUT_DIR="${BUNDLED_DIR}/macos-arm64"
|
||||||
|
OUTPUT_NAME="nexavpn-tunnel-helper"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unsupported target: ${TARGET}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "${OUTPUT_DIR}"
|
||||||
|
cargo build --manifest-path "${HELPER_DIR}/Cargo.toml" --release --target "${TARGET}"
|
||||||
|
cp "${HELPER_DIR}/target/${TARGET}/release/${OUTPUT_NAME}" "${OUTPUT_DIR}/${OUTPUT_NAME}"
|
||||||
|
echo "Bundled ${OUTPUT_NAME} into ${OUTPUT_DIR}"
|
||||||
6
desktop-client/src-tauri/bundled/macos-arm64/README.txt
Normal file
6
desktop-client/src-tauri/bundled/macos-arm64/README.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Bundle the macOS ARM NexaVPN tunnel helper here.
|
||||||
|
|
||||||
|
Expected filename:
|
||||||
|
- nexavpn-tunnel-helper
|
||||||
|
|
||||||
|
This helper encapsulates the WireGuard runtime so the end user only interacts with NexaVPN.
|
||||||
6
desktop-client/src-tauri/bundled/windows-x86/README.txt
Normal file
6
desktop-client/src-tauri/bundled/windows-x86/README.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Bundle the Windows x86 NexaVPN tunnel helper here.
|
||||||
|
|
||||||
|
Expected filename:
|
||||||
|
- nexavpn-tunnel-helper.exe
|
||||||
|
|
||||||
|
This helper encapsulates the WireGuard runtime so the end user only interacts with NexaVPN.
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
use std::sync::Mutex;
|
mod tunnel_manager;
|
||||||
|
|
||||||
|
use std::{fs, path::PathBuf, sync::Mutex};
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
||||||
use rand_core::OsRng;
|
use rand_core::OsRng;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::State;
|
use tauri::{AppHandle, Manager, State};
|
||||||
use x25519_dalek::{PublicKey, StaticSecret};
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
|
||||||
|
const PROFILE_NAME: &str = "NexaVPN";
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
session: Mutex<Option<SessionState>>,
|
session: Mutex<Option<SessionState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
struct SessionState {
|
struct SessionState {
|
||||||
access_token: String,
|
access_token: String,
|
||||||
refresh_token: String,
|
refresh_token: String,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
|
profile_path: String,
|
||||||
enrollment: EnrollmentResult,
|
enrollment: EnrollmentResult,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,13 +33,16 @@ struct EnrollmentPayload {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct EnrollmentResult {
|
struct EnrollmentResult {
|
||||||
assigned_ip: String,
|
assigned_ip: String,
|
||||||
resources: Vec<String>,
|
resources: Vec<String>,
|
||||||
profile_revision: u32,
|
profile_revision: u32,
|
||||||
gateway_endpoint: String,
|
gateway_endpoint: String,
|
||||||
|
profile_path: String,
|
||||||
|
last_sync_time: String,
|
||||||
|
tunnel_strategy: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -65,6 +74,7 @@ struct EnrollRequest<'a> {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct EnrollResponse {
|
struct EnrollResponse {
|
||||||
peer: PeerView,
|
peer: PeerView,
|
||||||
|
profile: ProfileView,
|
||||||
resources: Vec<ResourceView>,
|
resources: Vec<ResourceView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +97,17 @@ struct ResourceView {
|
|||||||
value: String,
|
value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ProfileView {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -> Result<EnrollmentResult, String> {
|
async fn enroll_device(
|
||||||
|
app: AppHandle,
|
||||||
|
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() {
|
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());
|
return Err("Server URL, username, and password are required".into());
|
||||||
}
|
}
|
||||||
@@ -117,7 +136,7 @@ async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| format!("Unable to decode login response: {}", err))?;
|
.map_err(|err| format!("Unable to decode login response: {}", err))?;
|
||||||
|
|
||||||
let (private_key, public_key) = generate_keypair();
|
let (_private_key, public_key) = generate_keypair();
|
||||||
let enroll_response = client
|
let enroll_response = client
|
||||||
.post(format!("{}/api/v1/devices/enroll", payload.server_url.trim_end_matches('/')))
|
.post(format!("{}/api/v1/devices/enroll", payload.server_url.trim_end_matches('/')))
|
||||||
.bearer_auth(&login.access_token)
|
.bearer_auth(&login.access_token)
|
||||||
@@ -142,41 +161,52 @@ async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| format!("Unable to decode enrollment response: {}", err))?;
|
.map_err(|err| format!("Unable to decode enrollment response: {}", err))?;
|
||||||
|
|
||||||
|
let profile_path = write_profile(&app, &enroll.profile.content)?;
|
||||||
let result = EnrollmentResult {
|
let result = EnrollmentResult {
|
||||||
assigned_ip: enroll.peer.assigned_ip,
|
assigned_ip: enroll.peer.assigned_ip,
|
||||||
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
|
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
|
||||||
profile_revision: enroll.peer.profile_revision,
|
profile_revision: enroll.peer.profile_revision,
|
||||||
gateway_endpoint: enroll.peer.gateway.endpoint,
|
gateway_endpoint: enroll.peer.gateway.endpoint,
|
||||||
|
profile_path: profile_path.display().to_string(),
|
||||||
|
last_sync_time: "just now".into(),
|
||||||
|
tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
let session_state = SessionState {
|
||||||
*session = Some(SessionState {
|
|
||||||
access_token: login.access_token,
|
access_token: login.access_token,
|
||||||
refresh_token: login.refresh_token,
|
refresh_token: login.refresh_token,
|
||||||
server_url: payload.server_url,
|
server_url: payload.server_url,
|
||||||
|
profile_path: result.profile_path.clone(),
|
||||||
enrollment: result.clone(),
|
enrollment: result.clone(),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
write_session_state(&app, &session_state)?;
|
||||||
|
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
||||||
|
*session = Some(session_state);
|
||||||
|
|
||||||
let _ = private_key;
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn connect_tunnel(state: State<'_, AppState>) -> Result<(), String> {
|
fn load_state(app: AppHandle, state: State<'_, AppState>) -> Result<Option<EnrollmentResult>, String> {
|
||||||
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
let loaded = read_session_state(&app)?;
|
||||||
if session.is_none() {
|
let mut session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||||
return Err("No enrolled profile is available yet".into());
|
*session = loaded.clone();
|
||||||
}
|
Ok(loaded.map(|value| value.enrollment))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn disconnect_tunnel(state: State<'_, AppState>) -> Result<(), String> {
|
fn connect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||||
if session.is_none() {
|
let session = session.as_ref().ok_or_else(|| "No enrolled profile is available yet".to_string())?;
|
||||||
return Err("No active session is available".into());
|
tunnel_manager::connect(&app, std::path::Path::new(&session.profile_path))
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn disconnect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||||
|
let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?;
|
||||||
|
tunnel_manager::disconnect(&app, std::path::Path::new(&session.profile_path))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_keypair() -> (String, String) {
|
fn generate_keypair() -> (String, String) {
|
||||||
@@ -192,13 +222,47 @@ fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> Stri
|
|||||||
format!("nexavpn:{}:{}:{}", server_url, username, public_key)
|
format!("nexavpn:{}:{}:{}", server_url, username, public_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_profile(app: &AppHandle, profile_content: &str) -> Result<PathBuf, String> {
|
||||||
|
let app_dir = ensure_app_dir(app)?;
|
||||||
|
let profile_path = app_dir.join(format!("{}.conf", PROFILE_NAME));
|
||||||
|
fs::write(&profile_path, profile_content).map_err(|err| format!("Unable to store profile: {}", err))?;
|
||||||
|
Ok(profile_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_session_state(app: &AppHandle, session: &SessionState) -> Result<(), String> {
|
||||||
|
let app_dir = ensure_app_dir(app)?;
|
||||||
|
let session_path = app_dir.join("session.json");
|
||||||
|
let json = serde_json::to_vec_pretty(session).map_err(|err| err.to_string())?;
|
||||||
|
fs::write(session_path, json).map_err(|err| format!("Unable to persist session state: {}", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_session_state(app: &AppHandle) -> Result<Option<SessionState>, String> {
|
||||||
|
let app_dir = ensure_app_dir(app)?;
|
||||||
|
let session_path = app_dir.join("session.json");
|
||||||
|
if !session_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let raw = fs::read(session_path).map_err(|err| format!("Unable to read session state: {}", err))?;
|
||||||
|
let value = serde_json::from_slice::<SessionState>(&raw).map_err(|err| format!("Unable to parse session state: {}", err))?;
|
||||||
|
Ok(Some(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_app_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_config_dir()
|
||||||
|
.map_err(|err| format!("Unable to resolve app config dir: {}", err))?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|err| format!("Unable to create app dir: {}", err))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
session: Mutex::new(None),
|
session: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![enroll_device, connect_tunnel, disconnect_tunnel])
|
.invoke_handler(tauri::generate_handler![load_state, enroll_device, connect_tunnel, disconnect_tunnel])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
70
desktop-client/src-tauri/src/tunnel_manager.rs
Normal file
70
desktop-client/src-tauri/src/tunnel_manager.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
pub fn current_tunnel_strategy() -> &'static str {
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
"embedded-wireguard-windows-x86"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"embedded-wireguard-macos-arm"
|
||||||
|
} else {
|
||||||
|
"unsupported"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect(app: &AppHandle, profile_path: &Path) -> Result<(), String> {
|
||||||
|
let backend = bundled_backend(app)?;
|
||||||
|
let status = Command::new(backend)
|
||||||
|
.arg("connect")
|
||||||
|
.arg("--profile")
|
||||||
|
.arg(profile_path)
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("Unable to start embedded tunnel backend: {}", err))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("Embedded tunnel backend connect failed with status {}", status));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disconnect(app: &AppHandle, profile_path: &Path) -> Result<(), String> {
|
||||||
|
let backend = bundled_backend(app)?;
|
||||||
|
let status = Command::new(backend)
|
||||||
|
.arg("disconnect")
|
||||||
|
.arg("--profile")
|
||||||
|
.arg(profile_path)
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("Unable to stop embedded tunnel backend: {}", err))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("Embedded tunnel backend disconnect failed with status {}", status));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bundled_backend(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let resource_dir = app
|
||||||
|
.path()
|
||||||
|
.resource_dir()
|
||||||
|
.map_err(|err| format!("Unable to resolve resource dir: {}", err))?;
|
||||||
|
|
||||||
|
let relative = if cfg!(target_os = "windows") {
|
||||||
|
PathBuf::from("bundled/windows-x86/nexavpn-tunnel-helper.exe")
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
PathBuf::from("bundled/macos-arm64/nexavpn-tunnel-helper")
|
||||||
|
} else {
|
||||||
|
return Err("This NexaVPN client build supports embedded tunnel backends only for Windows x86 and macOS ARM".into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = resource_dir.join(relative);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err("Embedded NexaVPN tunnel backend is not bundled in this build yet.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"icon": []
|
"icon": [],
|
||||||
|
"resources": [
|
||||||
|
"bundled/**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
type EnrollmentState = {
|
type EnrollmentState = {
|
||||||
@@ -6,6 +6,9 @@ type EnrollmentState = {
|
|||||||
resources: string[];
|
resources: string[];
|
||||||
profileRevision: number;
|
profileRevision: number;
|
||||||
gatewayEndpoint: string;
|
gatewayEndpoint: string;
|
||||||
|
profilePath: string;
|
||||||
|
lastSyncTime: string;
|
||||||
|
tunnelStrategy: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -17,6 +20,16 @@ export function App() {
|
|||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [state, setState] = useState<EnrollmentState | null>(null);
|
const [state, setState] = useState<EnrollmentState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void invoke<EnrollmentState | null>("load_state")
|
||||||
|
.then((value) => {
|
||||||
|
if (value) {
|
||||||
|
setState(value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function onSubmit(event: FormEvent) {
|
async function onSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -36,8 +49,13 @@ export function App() {
|
|||||||
|
|
||||||
async function toggleConnection() {
|
async function toggleConnection() {
|
||||||
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
|
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
|
||||||
await invoke(command);
|
try {
|
||||||
setConnected((value) => !value);
|
await invoke(command);
|
||||||
|
setConnected((value) => !value);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Tunnel action failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,7 +109,22 @@ export function App() {
|
|||||||
<span>Profile revision</span>
|
<span>Profile revision</span>
|
||||||
<strong>{state.profileRevision}</strong>
|
<strong>{state.profileRevision}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Last sync</span>
|
||||||
|
<strong>{state.lastSyncTime}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="details">
|
||||||
|
<div>
|
||||||
|
<span>Profile path</span>
|
||||||
|
<strong>{state.profilePath}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Tunnel strategy</span>
|
||||||
|
<strong>{state.tunnelStrategy}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="error">{error}</div> : null}
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Allowed resources</p>
|
<p className="eyebrow">Allowed resources</p>
|
||||||
<ul className="resource-list">
|
<ul className="resource-list">
|
||||||
|
|||||||
7
desktop-client/tunnel-helper/Cargo.toml
Normal file
7
desktop-client/tunnel-helper/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "nexavpn-tunnel-helper"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Bundled tunnel helper for NexaVPN"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
114
desktop-client/tunnel-helper/src/main.rs
Normal file
114
desktop-client/tunnel-helper/src/main.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::{Command, ExitCode},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match run() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{err}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<(), String> {
|
||||||
|
let mut args = env::args().skip(1);
|
||||||
|
let command = args.next().ok_or_else(|| "missing command".to_string())?;
|
||||||
|
let flag = args.next().ok_or_else(|| "missing --profile flag".to_string())?;
|
||||||
|
if flag != "--profile" {
|
||||||
|
return Err("expected --profile flag".into());
|
||||||
|
}
|
||||||
|
let profile = PathBuf::from(args.next().ok_or_else(|| "missing profile path".to_string())?);
|
||||||
|
|
||||||
|
match command.as_str() {
|
||||||
|
"connect" => connect(&profile),
|
||||||
|
"disconnect" => disconnect(&profile),
|
||||||
|
_ => Err("unsupported command".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect(profile: &Path) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let wireguard = find_windows_wireguard()?;
|
||||||
|
let status = Command::new(wireguard)
|
||||||
|
.arg("/installtunnelservice")
|
||||||
|
.arg(profile)
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("WireGuard runtime connect failed with status {status}"));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let command = format!("wg-quick up '{}'", profile.display());
|
||||||
|
let status = Command::new("osascript")
|
||||||
|
.arg("-e")
|
||||||
|
.arg(format!("do shell script \"{}\" with administrator privileges", command))
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("unable to start tunnel: {err}"))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("macOS tunnel connect failed with status {status}"));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err("unsupported platform".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disconnect(profile: &Path) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let wireguard = find_windows_wireguard()?;
|
||||||
|
let tunnel_name = profile
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.ok_or_else(|| "invalid profile filename".to_string())?;
|
||||||
|
let status = Command::new(wireguard)
|
||||||
|
.arg("/uninstalltunnelservice")
|
||||||
|
.arg(tunnel_name)
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("WireGuard runtime disconnect failed with status {status}"));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let command = format!("wg-quick down '{}'", profile.display());
|
||||||
|
let status = Command::new("osascript")
|
||||||
|
.arg("-e")
|
||||||
|
.arg(format!("do shell script \"{}\" with administrator privileges", command))
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("unable to stop tunnel: {err}"))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("macOS tunnel disconnect failed with status {status}"));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err("unsupported platform".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn find_windows_wireguard() -> Result<PathBuf, String> {
|
||||||
|
let candidates = [
|
||||||
|
PathBuf::from(r"C:\Program Files\WireGuard\wireguard.exe"),
|
||||||
|
PathBuf::from(r"C:\Program Files (x86)\WireGuard\wireguard.exe"),
|
||||||
|
];
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|path| path.exists())
|
||||||
|
.ok_or_else(|| "required Windows tunnel runtime is not available".to_string())
|
||||||
|
}
|
||||||
36
docs/client-platforms.md
Normal file
36
docs/client-platforms.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Desktop Platform Strategy
|
||||||
|
|
||||||
|
## Windows x86
|
||||||
|
|
||||||
|
Current MVP integration path:
|
||||||
|
|
||||||
|
- NexaVPN enrolls the device and stores the generated profile locally.
|
||||||
|
- NexaVPN is intended to ship its own bundled Windows x86 tunnel helper.
|
||||||
|
- The end user should interact only with NexaVPN.
|
||||||
|
- The bundled helper encapsulates the WireGuard runtime internally.
|
||||||
|
|
||||||
|
Repository status:
|
||||||
|
|
||||||
|
- the NexaVPN tunnel helper CLI is now included in `desktop-client/tunnel-helper/`
|
||||||
|
- the Windows x86 build can be bundled into `src-tauri/bundled/windows-x86/`
|
||||||
|
|
||||||
|
## macOS ARM
|
||||||
|
|
||||||
|
Current MVP integration path:
|
||||||
|
|
||||||
|
- NexaVPN enrolls the device and stores the generated profile locally.
|
||||||
|
- NexaVPN is intended to ship its own bundled macOS ARM tunnel helper.
|
||||||
|
- The end user should interact only with NexaVPN.
|
||||||
|
- The bundled helper encapsulates the WireGuard runtime internally.
|
||||||
|
|
||||||
|
Repository status:
|
||||||
|
|
||||||
|
- the NexaVPN tunnel helper CLI is now included in `desktop-client/tunnel-helper/`
|
||||||
|
- the macOS ARM build can be bundled into `src-tauri/bundled/macos-arm64/`
|
||||||
|
|
||||||
|
## Security And Limitations
|
||||||
|
|
||||||
|
- Client private keys are generated and stored locally.
|
||||||
|
- Admin debug profile downloads intentionally contain a private-key placeholder.
|
||||||
|
- Desktop secure-secret storage is not yet production-grade keychain integration.
|
||||||
|
- The repository now includes the helper source and bundling paths, but platform builds and signing still need to be performed in the right target environments.
|
||||||
@@ -51,6 +51,47 @@ psql "$DATABASE_URL" -f backend/migrations/000001_init.sql
|
|||||||
psql "$DATABASE_URL" -f backend/seed/001_seed.sql
|
psql "$DATABASE_URL" -f backend/seed/001_seed.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Gateway Helper Flow
|
||||||
|
|
||||||
|
1. Bootstrap an admin and log into the web UI.
|
||||||
|
2. Open the `Gateways` page and note the gateway ID.
|
||||||
|
3. Obtain an admin API token through the login flow.
|
||||||
|
4. Set `NEXAVPN_GATEWAY_ID` and `NEXAVPN_API_TOKEN` in `deploy/.env`.
|
||||||
|
5. Recreate the `gateway` service.
|
||||||
|
|
||||||
|
The helper writes:
|
||||||
|
|
||||||
|
- `/var/lib/nexavpn/sync-bundle.json`
|
||||||
|
- `/var/lib/nexavpn/wg0.generated.conf`
|
||||||
|
- `/var/lib/nexavpn/nftables.generated.conf`
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- the gateway helper fetches the sync bundle every 15 seconds
|
||||||
|
- it renders `/etc/wireguard/<iface>.conf`
|
||||||
|
- it applies nftables rules from generated state
|
||||||
|
- it enables IPv4 forwarding
|
||||||
|
- it brings up or resyncs the WireGuard interface
|
||||||
|
|
||||||
|
Required environment:
|
||||||
|
|
||||||
|
- `NEXAVPN_GATEWAY_ID`
|
||||||
|
- `NEXAVPN_API_TOKEN`
|
||||||
|
- `NEXAVPN_GATEWAY_PRIVATE_KEY`
|
||||||
|
- optional: `NEXAVPN_GATEWAY_INTERFACE`
|
||||||
|
- optional: `NEXAVPN_UPLINK_INTERFACE`
|
||||||
|
- optional: `NEXAVPN_ENABLE_MASQUERADE`
|
||||||
|
|
||||||
|
Helper scripts:
|
||||||
|
|
||||||
|
- `deploy/scripts/generate-gateway-keypair.sh`
|
||||||
|
- `deploy/scripts/get-admin-token.sh`
|
||||||
|
|
||||||
|
Host/runtime note:
|
||||||
|
|
||||||
|
- the gateway container expects `/dev/net/tun`
|
||||||
|
- the host kernel must support WireGuard
|
||||||
|
|
||||||
## Production Notes
|
## Production Notes
|
||||||
|
|
||||||
- Terminate TLS at nginx or another reverse proxy.
|
- Terminate TLS at nginx or another reverse proxy.
|
||||||
|
|||||||
Reference in New Issue
Block a user