diff --git a/README.md b/README.md index 60655c3..985b801 100644 --- a/README.md +++ b/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. - 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. +- The desktop client is structured so NexaVPN is the only user-facing VPN app. +- 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 @@ -56,5 +78,15 @@ 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 +3. Create a standard user in the `Users` page +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 diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index 3518114..0161e7e 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -17,6 +17,31 @@ export type Device = { 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 = { id: string; name: string; @@ -68,8 +93,56 @@ async function request(path: string, init?: RequestInit): Promise { export const api = { users: () => request("/admin/users"), + createUser: (payload: { + username: string; + display_name: string; + email: string; + password: string; + role: string; + }) => + request("/admin/users", { + method: "POST", + body: JSON.stringify(payload) + }), devices: () => request("/admin/devices"), + deviceProfile: (deviceId: string) => request(`/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("/admin/policies"), + createPolicy: (payload: { + name: string; + description: string; + priority: number; + effect: string; + full_tunnel: boolean; + destinations: string[]; + targets: Array<{ type: string; id: string }>; + }) => + request("/admin/policies", { + method: "POST", + body: JSON.stringify(payload) + }), gateways: () => request("/admin/gateways"), + updateGateway: (gatewayId: string, payload: { + endpoint: string; + public_key: string; + listen_port: number; + vpn_cidr: string; + dns_servers: string[]; + is_active: boolean; + }) => + request(`/admin/gateways/${gatewayId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }), + gatewaySync: (gatewayId: string) => request(`/admin/gateways/${gatewayId}/sync`), audit: () => request("/admin/audit-logs") }; diff --git a/admin-web/src/features/devices/DevicesPage.tsx b/admin-web/src/features/devices/DevicesPage.tsx index 7181b2c..54044a8 100644 --- a/admin-web/src/features/devices/DevicesPage.tsx +++ b/admin-web/src/features/devices/DevicesPage.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../../api/client"; import { Page } from "../../components/Page"; @@ -13,12 +13,27 @@ const columns = [ ]; export function DevicesPage() { + const queryClient = useQueryClient(); const query = useQuery({ queryKey: ["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) => ({ + id: device.id, name: device.name, owner: device.user_id ?? "assigned user", platform: device.platform, @@ -34,6 +49,23 @@ export function DevicesPage() { rows={rows} renderCell={(row, column) => {row[column.key as keyof (typeof rows)[number]]}} /> + {rows.length > 0 ? ( +
+
+

Selected device profile

+

Admin view shows a debug template. The client private key stays on the device.

+
{profileQuery.data?.profile.content ?? "Loading profile..."}
+
+
+

Device actions

+

Target: {rows[0].name}

+
+ + +
+
+
+ ) : null} ); } diff --git a/admin-web/src/features/gateways/GatewaysPage.tsx b/admin-web/src/features/gateways/GatewaysPage.tsx index 6ace7d6..8039e0b 100644 --- a/admin-web/src/features/gateways/GatewaysPage.tsx +++ b/admin-web/src/features/gateways/GatewaysPage.tsx @@ -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 { Page } from "../../components/Page"; @@ -12,10 +13,31 @@ const columns = [ ]; export function GatewaysPage() { + const queryClient = useQueryClient(); const query = useQuery({ queryKey: ["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[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) => ({ name: gateway.name, @@ -24,6 +46,32 @@ export function GatewaysPage() { 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 ( {query.isError ?

Unable to load gateways from the API.

: null} @@ -32,6 +80,24 @@ export function GatewaysPage() { rows={rows} renderCell={(row, column) => {row[column.key as keyof (typeof rows)[number]]}} /> +
+ setForm((value) => ({ ...value, endpoint: event.target.value }))} /> + setForm((value) => ({ ...value, public_key: event.target.value }))} /> + setForm((value) => ({ ...value, listen_port: Number(event.target.value) }))} /> + setForm((value) => ({ ...value, vpn_cidr: event.target.value }))} /> + setForm((value) => ({ ...value, dns_servers: event.target.value }))} /> + + +
+ {syncQuery.data ? ( +
+

Sync bundle preview

+
{JSON.stringify(syncQuery.data, null, 2)}
+
+ ) : null}
); } diff --git a/admin-web/src/features/policies/PoliciesPage.tsx b/admin-web/src/features/policies/PoliciesPage.tsx index 05b8b49..a75c5d9 100644 --- a/admin-web/src/features/policies/PoliciesPage.tsx +++ b/admin-web/src/features/policies/PoliciesPage.tsx @@ -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 { Page } from "../../components/Page"; @@ -12,10 +13,30 @@ const columns = [ ]; export function PoliciesPage() { + const queryClient = useQueryClient(); const query = useQuery({ queryKey: ["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) => ({ name: policy.name, @@ -24,13 +45,55 @@ export function PoliciesPage() { 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 ( New policy} + actions={Gateway enforced} > +
+ setForm((value) => ({ ...value, name: event.target.value }))} /> + setForm((value) => ({ ...value, description: event.target.value }))} /> + setForm((value) => ({ ...value, destinations: event.target.value }))} + disabled={form.fullTunnel} + /> + + + +
{query.isError ?

Unable to load policies from the API.

: null} + {createMutation.isError ?

Unable to create policy.

: null} { + setForm({ username: "", display_name: "", email: "", password: "", role: "user" }); + void queryClient.invalidateQueries({ queryKey: ["users"] }); + } + }); const rows = query.data?.map((user) => ({ username: user.username, @@ -24,13 +41,30 @@ export function UsersPage() { status: user.is_active ? "active" : "disabled" })) ?? []; + function onSubmit(event: FormEvent) { + event.preventDefault(); + createMutation.mutate(form); + } + return ( New user} + actions={Admin managed} > +
+ setForm((value) => ({ ...value, username: event.target.value }))} /> + setForm((value) => ({ ...value, display_name: event.target.value }))} /> + setForm((value) => ({ ...value, email: event.target.value }))} /> + setForm((value) => ({ ...value, password: event.target.value }))} /> + + + {query.isError ?

Unable to load users from the API.

: null} + {createMutation.isError ?

Unable to create user.

: null}
0 { + index-- + digits[index] = byte('0' + value%10) + value /= 10 + } + return string(digits[index:]) +} diff --git a/backend/internal/gateway/service.go b/backend/internal/gateway/service.go index 7fc782c..709a1a5 100644 --- a/backend/internal/gateway/service.go +++ b/backend/internal/gateway/service.go @@ -31,3 +31,11 @@ func (s *Service) BuildSyncBundle(ctx context.Context, gatewayID string) (wiregu } 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) +} diff --git a/backend/internal/gateway/types.go b/backend/internal/gateway/types.go index 8d02539..eac2825 100644 --- a/backend/internal/gateway/types.go +++ b/backend/internal/gateway/types.go @@ -12,3 +12,12 @@ type Gateway struct { DNSServers []string `json:"dns_servers"` 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"` +} diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go index 7e8c91e..a7620c5 100644 --- a/backend/internal/httpserver/router.go +++ b/backend/internal/httpserver/router.go @@ -59,6 +59,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler { r.Post("/policies", handlers.Policy.Create) r.Get("/gateways", handlers.Gateway.List) r.Get("/gateways/{id}/sync", handlers.Gateway.SyncBundle) + r.Patch("/gateways/{id}", handlers.Gateway.Update) r.Get("/audit-logs", handlers.Audit.List) }) }) diff --git a/backend/internal/wireguard/types.go b/backend/internal/wireguard/types.go index 9cf65ca..b4c208b 100644 --- a/backend/internal/wireguard/types.go +++ b/backend/internal/wireguard/types.go @@ -12,6 +12,7 @@ type GatewayBundle struct { Revision int `json:"revision"` Interface struct { Address string `json:"address"` + NetworkCIDR string `json:"network_cidr"` ListenPort int `json:"listen_port"` } `json:"interface"` Peers []Peer `json:"peers"` diff --git a/deploy/.env.example b/deploy/.env.example index 8abb5b2..a5e6c35 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -12,3 +12,10 @@ 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 +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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4a8db80..0e23d7d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -52,16 +52,28 @@ services: - control gateway: - image: alpine:3.21 - command: ["sh", "/scripts/gateway-entrypoint.sh"] + build: + context: . + dockerfile: gateway/Dockerfile cap_add: - NET_ADMIN - 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: - ./scripts/gateway-entrypoint.sh:/scripts/gateway-entrypoint.sh:ro - gateway-state:/var/lib/nexavpn networks: - gateway + - control volumes: postgres-data: diff --git a/deploy/gateway/Dockerfile b/deploy/gateway/Dockerfile new file mode 100644 index 0000000..c44b7df --- /dev/null +++ b/deploy/gateway/Dockerfile @@ -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"] diff --git a/deploy/scripts/gateway-entrypoint.sh b/deploy/scripts/gateway-entrypoint.sh index f261339..6f08bae 100644 --- a/deploy/scripts/gateway-entrypoint.sh +++ b/deploy/scripts/gateway-entrypoint.sh @@ -1,8 +1,104 @@ -#!/bin/sh +#!/usr/bin/env bash 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." +mkdir -p /var/lib/nexavpn -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}" <> "${WG_GENERATED}" < "${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 diff --git a/deploy/scripts/generate-gateway-keypair.sh b/deploy/scripts/generate-gateway-keypair.sh new file mode 100644 index 0000000..85b1272 --- /dev/null +++ b/deploy/scripts/generate-gateway-keypair.sh @@ -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}" diff --git a/deploy/scripts/get-admin-token.sh b/deploy/scripts/get-admin-token.sh new file mode 100644 index 0000000..3c7caea --- /dev/null +++ b/deploy/scripts/get-admin-token.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -eu + +if [ "$#" -lt 3 ]; then + echo "usage: get-admin-token.sh " + 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" diff --git a/desktop-client/package.json b/desktop-client/package.json index 8e97498..88076a3 100644 --- a/desktop-client/package.json +++ b/desktop-client/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "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": { "@tauri-apps/api": "^2.3.0", diff --git a/desktop-client/scripts/build-tunnel-helper.sh b/desktop-client/scripts/build-tunnel-helper.sh new file mode 100644 index 0000000..1cd0258 --- /dev/null +++ b/desktop-client/scripts/build-tunnel-helper.sh @@ -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 " + 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}" diff --git a/desktop-client/src-tauri/bundled/macos-arm64/README.txt b/desktop-client/src-tauri/bundled/macos-arm64/README.txt new file mode 100644 index 0000000..80a1a52 --- /dev/null +++ b/desktop-client/src-tauri/bundled/macos-arm64/README.txt @@ -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. diff --git a/desktop-client/src-tauri/bundled/windows-x86/README.txt b/desktop-client/src-tauri/bundled/windows-x86/README.txt new file mode 100644 index 0000000..330f318 --- /dev/null +++ b/desktop-client/src-tauri/bundled/windows-x86/README.txt @@ -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. diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index aab4556..5bdd16b 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -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 rand_core::OsRng; use reqwest::Client; use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, Manager, State}; use x25519_dalek::{PublicKey, StaticSecret}; +const PROFILE_NAME: &str = "NexaVPN"; + struct AppState { session: Mutex>, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] struct SessionState { access_token: String, refresh_token: String, server_url: String, + profile_path: String, enrollment: EnrollmentResult, } @@ -27,13 +33,16 @@ struct EnrollmentPayload { password: String, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct EnrollmentResult { assigned_ip: String, resources: Vec, profile_revision: u32, gateway_endpoint: String, + profile_path: String, + last_sync_time: String, + tunnel_strategy: String, } #[derive(Debug, Serialize)] @@ -65,6 +74,7 @@ struct EnrollRequest<'a> { #[serde(rename_all = "camelCase")] struct EnrollResponse { peer: PeerView, + profile: ProfileView, resources: Vec, } @@ -87,8 +97,17 @@ struct ResourceView { value: String, } +#[derive(Debug, Deserialize)] +struct ProfileView { + content: String, +} + #[tauri::command] -async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -> Result { +async fn enroll_device( + app: AppHandle, + payload: EnrollmentPayload, + state: State<'_, AppState>, +) -> Result { 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()); } @@ -117,7 +136,7 @@ async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) - .await .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 .post(format!("{}/api/v1/devices/enroll", payload.server_url.trim_end_matches('/'))) .bearer_auth(&login.access_token) @@ -142,41 +161,52 @@ async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) - .await .map_err(|err| format!("Unable to decode enrollment response: {}", err))?; + let profile_path = write_profile(&app, &enroll.profile.content)?; 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, + 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())?; - *session = Some(SessionState { + let session_state = SessionState { access_token: login.access_token, refresh_token: login.refresh_token, server_url: payload.server_url, + profile_path: result.profile_path.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) } #[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(()) +fn load_state(app: AppHandle, state: State<'_, AppState>) -> Result, String> { + let loaded = read_session_state(&app)?; + let mut session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; + *session = loaded.clone(); + Ok(loaded.map(|value| value.enrollment)) } #[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())?; - if session.is_none() { - return Err("No active session is available".into()); - } - Ok(()) + let session = session.as_ref().ok_or_else(|| "No enrolled profile is available yet".to_string())?; + tunnel_manager::connect(&app, std::path::Path::new(&session.profile_path)) +} + +#[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) { @@ -192,13 +222,47 @@ fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> Stri format!("nexavpn:{}:{}:{}", server_url, username, public_key) } +fn write_profile(app: &AppHandle, profile_content: &str) -> Result { + 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, 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::(&raw).map_err(|err| format!("Unable to parse session state: {}", err))?; + Ok(Some(value)) +} + +fn ensure_app_dir(app: &AppHandle) -> Result { + 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)] pub fn run() { tauri::Builder::default() .manage(AppState { 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!()) .expect("error while running tauri application"); } diff --git a/desktop-client/src-tauri/src/tunnel_manager.rs b/desktop-client/src-tauri/src/tunnel_manager.rs new file mode 100644 index 0000000..6073c05 --- /dev/null +++ b/desktop-client/src-tauri/src/tunnel_manager.rs @@ -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 { + 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) +} diff --git a/desktop-client/src-tauri/tauri.conf.json b/desktop-client/src-tauri/tauri.conf.json index 3f2635e..5faf365 100644 --- a/desktop-client/src-tauri/tauri.conf.json +++ b/desktop-client/src-tauri/tauri.conf.json @@ -25,6 +25,9 @@ "bundle": { "active": true, "targets": "all", - "icon": [] + "icon": [], + "resources": [ + "bundled/**/*" + ] } } diff --git a/desktop-client/src/App.tsx b/desktop-client/src/App.tsx index 4049257..351dbaa 100644 --- a/desktop-client/src/App.tsx +++ b/desktop-client/src/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; type EnrollmentState = { @@ -6,6 +6,9 @@ type EnrollmentState = { resources: string[]; profileRevision: number; gatewayEndpoint: string; + profilePath: string; + lastSyncTime: string; + tunnelStrategy: string; }; export function App() { @@ -17,6 +20,16 @@ export function App() { const [connected, setConnected] = useState(false); const [state, setState] = useState(null); + useEffect(() => { + void invoke("load_state") + .then((value) => { + if (value) { + setState(value); + } + }) + .catch(() => undefined); + }, []); + async function onSubmit(event: FormEvent) { event.preventDefault(); setLoading(true); @@ -36,8 +49,13 @@ export function App() { async function toggleConnection() { const command = connected ? "disconnect_tunnel" : "connect_tunnel"; - await invoke(command); - setConnected((value) => !value); + try { + await invoke(command); + setConnected((value) => !value); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Tunnel action failed"); + } } return ( @@ -91,7 +109,22 @@ export function App() { Profile revision {state.profileRevision} +
+ Last sync + {state.lastSyncTime} +
+
+
+ Profile path + {state.profilePath} +
+
+ Tunnel strategy + {state.tunnelStrategy} +
+
+ {error ?
{error}
: null}

Allowed resources

    diff --git a/desktop-client/tunnel-helper/Cargo.toml b/desktop-client/tunnel-helper/Cargo.toml new file mode 100644 index 0000000..b8c7e3f --- /dev/null +++ b/desktop-client/tunnel-helper/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "nexavpn-tunnel-helper" +version = "0.1.0" +edition = "2021" +description = "Bundled tunnel helper for NexaVPN" + +[dependencies] diff --git a/desktop-client/tunnel-helper/src/main.rs b/desktop-client/tunnel-helper/src/main.rs new file mode 100644 index 0000000..318e215 --- /dev/null +++ b/desktop-client/tunnel-helper/src/main.rs @@ -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 { + 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()) +} diff --git a/docs/client-platforms.md b/docs/client-platforms.md new file mode 100644 index 0000000..e1df0e6 --- /dev/null +++ b/docs/client-platforms.md @@ -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. diff --git a/docs/deployment.md b/docs/deployment.md index 2cf62c7..edc51ec 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -51,6 +51,47 @@ psql "$DATABASE_URL" -f backend/migrations/000001_init.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/.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 - Terminate TLS at nginx or another reverse proxy.