8e41568964
Peer creation/edit form now configures: - Tunnel mode: full (0.0.0.0/0) or split (PIC only) - Per-service access toggles (calendar, files, mail, webdav) - Peer-to-peer communication toggle - Optional calendar account creation - Access capability badges in peer list Bug fixes: - DNS in client configs was 8.8.8.8 / 172.20.0.2 — now 172.20.0.3 (CoreDNS) This was why .cell domains didn't resolve on connected VPN peers - get_peer_config API uses stored internet_access to set AllowedIPs - New PUT /api/peers/<name> endpoint with config_changed detection - POST /api/peers/<name>/clear-reinstall clears reinstall flag after download - Routing page reads real host routes via /proc/1/net/route (pid: host) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
772 lines
33 KiB
React
772 lines
33 KiB
React
import { useState, useEffect } from 'react';
|
|
import { Shield, Key, Users, Activity, Wifi, Download, Copy, RefreshCw, Play, Pause, AlertCircle, Eye, Globe, CheckCircle, XCircle } from 'lucide-react';
|
|
import { wireguardAPI, peerAPI } from '../services/api';
|
|
import QRCode from 'qrcode';
|
|
|
|
function WireGuard() {
|
|
const [status, setStatus] = useState(null);
|
|
const [serverConfig, setServerConfig] = useState(null);
|
|
const [isRefreshingIp, setIsRefreshingIp] = useState(false);
|
|
const [peers, setPeers] = useState([]);
|
|
const [totalPeers, setTotalPeers] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [selectedPeer, setSelectedPeer] = useState(null);
|
|
const [showPeerConfig, setShowPeerConfig] = useState(false);
|
|
const [peerConfig, setPeerConfig] = useState('');
|
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
|
const [peerStatuses, setPeerStatuses] = useState({});
|
|
const [tunnelMode, setTunnelMode] = useState('full'); // 'split' or 'full'
|
|
|
|
useEffect(() => {
|
|
fetchWireGuardData();
|
|
}, []);
|
|
|
|
const refreshExternalIp = async () => {
|
|
setIsRefreshingIp(true);
|
|
try {
|
|
// Refresh IP first (fast)
|
|
const ipResp = await fetch('/api/wireguard/refresh-ip', { method: 'POST' });
|
|
const ipData = await ipResp.json();
|
|
setServerConfig(prev => ({ ...prev, ...ipData, port_open: 'checking' }));
|
|
// Then check port (slow — external call)
|
|
const portResp = await fetch('/api/wireguard/check-port', { method: 'POST' });
|
|
const portData = await portResp.json();
|
|
setServerConfig(prev => ({ ...prev, port_open: portData.port_open }));
|
|
} catch (e) {
|
|
console.error('Failed to refresh IP:', e);
|
|
} finally {
|
|
setIsRefreshingIp(false);
|
|
}
|
|
};
|
|
|
|
const fetchWireGuardData = async () => {
|
|
try {
|
|
const [statusResponse, peersResponse, wireguardResponse, serverConfigResponse] = await Promise.all([
|
|
wireguardAPI.getStatus(),
|
|
peerAPI.getPeers(),
|
|
wireguardAPI.getPeers(),
|
|
fetch('/api/wireguard/server-config').then(r => r.json()).catch(() => null),
|
|
]);
|
|
|
|
setStatus(statusResponse.data);
|
|
if (serverConfigResponse) setServerConfig(serverConfigResponse);
|
|
|
|
// Merge peer registry data with WireGuard data (same as Peers page)
|
|
const peersData = peersResponse.data || [];
|
|
const wireguardPeers = wireguardResponse.data || [];
|
|
|
|
// Create a map of WireGuard peers by name for quick lookup
|
|
const wireguardMap = {};
|
|
wireguardPeers.forEach(peer => {
|
|
wireguardMap[peer.name] = peer;
|
|
});
|
|
|
|
// Merge the data
|
|
const mergedPeers = peersData.map(peer => ({
|
|
...peer,
|
|
...wireguardMap[peer.peer || peer.name],
|
|
name: peer.peer || peer.name,
|
|
status: 'Online', // For now, assume all peers are online
|
|
type: 'WireGuard',
|
|
// Preserve important fields that might be overwritten
|
|
private_key: peer.private_key,
|
|
server_public_key: peer.server_public_key,
|
|
server_endpoint: peer.server_endpoint,
|
|
allowed_ips: peer.allowed_ips || wireguardMap[peer.peer || peer.name]?.AllowedIPs || '0.0.0.0/0',
|
|
persistent_keepalive: peer.persistent_keepalive || wireguardMap[peer.peer || peer.name]?.PersistentKeepalive || 25
|
|
}));
|
|
|
|
// Load all peer statuses in one call (keyed by public_key)
|
|
let liveStatuses = {};
|
|
try {
|
|
const stResp = await fetch('/api/wireguard/peers/statuses');
|
|
if (stResp.ok) liveStatuses = await stResp.json();
|
|
} catch (_) {}
|
|
|
|
// Normalize snake_case API fields to camelCase for UI
|
|
const normalizeStatus = (st) => ({
|
|
online: st.online ?? null,
|
|
lastHandshake: st.last_handshake || st.lastHandshake || null,
|
|
lastHandshakeSecondsAgo: st.last_handshake_seconds_ago ?? null,
|
|
transferRx: st.transfer_rx ?? st.transferRx ?? 0,
|
|
transferTx: st.transfer_tx ?? st.transferTx ?? 0,
|
|
endpoint: st.endpoint || null,
|
|
});
|
|
|
|
// Build name→status map and annotate peers
|
|
const statusMap = {};
|
|
const annotated = mergedPeers.map(peer => {
|
|
const raw = liveStatuses[peer.public_key] || { online: null };
|
|
const st = normalizeStatus(raw);
|
|
statusMap[peer.name] = st;
|
|
return { ...peer, _liveStatus: st };
|
|
});
|
|
setPeerStatuses(statusMap);
|
|
setTotalPeers(annotated.length);
|
|
|
|
// Show all peers; live ones bubble up via status indicator
|
|
setPeers(annotated);
|
|
} catch (error) {
|
|
console.error('Failed to fetch WireGuard data:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const refreshData = async () => {
|
|
setIsRefreshing(true);
|
|
await fetchWireGuardData();
|
|
};
|
|
|
|
const handleViewPeerConfig = async (peer, mode = tunnelMode) => {
|
|
setSelectedPeer(peer);
|
|
try {
|
|
const sc = await getServerConfig();
|
|
const peerWithServerConfig = { ...peer, server_public_key: sc.public_key, server_endpoint: sc.endpoint };
|
|
const config = generateWireGuardConfig(peerWithServerConfig, mode);
|
|
setPeerConfig(config);
|
|
|
|
// Generate QR code for the config
|
|
try {
|
|
const qrDataUrl = await generateQRCode(config);
|
|
setQrCodeDataUrl(qrDataUrl);
|
|
} catch (qrError) {
|
|
console.error('Failed to generate QR code:', qrError);
|
|
setQrCodeDataUrl('');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to get peer config:', error);
|
|
setPeerConfig('Failed to load configuration');
|
|
setQrCodeDataUrl('');
|
|
}
|
|
setShowPeerConfig(true);
|
|
};
|
|
|
|
const copyToClipboard = async (text) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
alert('Configuration copied to clipboard!');
|
|
} catch (error) {
|
|
console.error('Failed to copy to clipboard:', error);
|
|
alert('Failed to copy to clipboard. Please copy manually.');
|
|
}
|
|
};
|
|
|
|
const downloadConfig = (peerName, config) => {
|
|
const blob = new Blob([config], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${peerName}.conf`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const getServerConfig = async () => {
|
|
if (serverConfig?.public_key) return serverConfig;
|
|
try {
|
|
const response = await fetch('/api/wireguard/server-config');
|
|
if (response.ok) {
|
|
const config = await response.json();
|
|
setServerConfig(config);
|
|
return config;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not get server config:', error);
|
|
}
|
|
return { public_key: '', endpoint: '<SERVER_IP>:51820' };
|
|
};
|
|
|
|
const CELL_DNS = '172.20.0.3';
|
|
const SPLIT_TUNNEL_IPS = '10.0.0.0/24, 172.20.0.0/16';
|
|
const FULL_TUNNEL_IPS = '0.0.0.0/0, ::/0';
|
|
|
|
const generateWireGuardConfig = (peer, mode = tunnelMode) => {
|
|
const serverPublicKey = peer.server_public_key || "SERVER_PUBLIC_KEY_PLACEHOLDER";
|
|
const serverEndpoint = peer.server_endpoint || "YOUR_SERVER_IP:51820";
|
|
const privateKey = peer.private_key || 'YOUR_PRIVATE_KEY_HERE';
|
|
const peerAddress = peer.ip?.includes('/') ? peer.ip : `${peer.ip}/32`;
|
|
const allowedIPs = mode === 'full' ? FULL_TUNNEL_IPS : SPLIT_TUNNEL_IPS;
|
|
|
|
return `[Interface]
|
|
PrivateKey = ${privateKey}
|
|
Address = ${peerAddress}
|
|
DNS = ${CELL_DNS}
|
|
|
|
[Peer]
|
|
PublicKey = ${serverPublicKey}
|
|
Endpoint = ${serverEndpoint}
|
|
AllowedIPs = ${allowedIPs}
|
|
PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|
};
|
|
|
|
const generateQRCode = async (text) => {
|
|
try {
|
|
const qrDataUrl = await QRCode.toDataURL(text, {
|
|
width: 256,
|
|
margin: 2,
|
|
color: {
|
|
dark: '#000000',
|
|
light: '#FFFFFF'
|
|
},
|
|
errorCorrectionLevel: 'M'
|
|
});
|
|
return qrDataUrl;
|
|
} catch (error) {
|
|
console.error('QR Code generation error:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const formatBytes = (bytes) => {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const getPeerStatus = async (peer) => {
|
|
try {
|
|
// Get real peer status from the API
|
|
const response = await fetch('http://localhost:3000/api/wireguard/peers/status', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
public_key: peer.public_key
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const status = await response.json();
|
|
return {
|
|
online: status.online,
|
|
lastHandshake: status.latest_handshake,
|
|
transferRx: status.transfer_rx || 0,
|
|
transferTx: status.transfer_tx || 0
|
|
};
|
|
} else {
|
|
console.error('Failed to get peer status:', response.status);
|
|
return {
|
|
online: null,
|
|
lastHandshake: null,
|
|
transferRx: 0,
|
|
transferTx: 0
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting peer status:', error);
|
|
return {
|
|
online: null,
|
|
lastHandshake: null,
|
|
transferRx: 0,
|
|
transferTx: 0
|
|
};
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-8">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">WireGuard VPN</h1>
|
|
<p className="mt-2 text-gray-600">
|
|
Manage WireGuard VPN configuration and monitor active peers
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={refreshData}
|
|
disabled={isRefreshing}
|
|
className="btn btn-secondary flex items-center"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Overview */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
<div className="card">
|
|
<div className="flex items-center">
|
|
<div className="p-2 bg-primary-100 rounded-lg">
|
|
<Shield className="h-6 w-6 text-primary-600" />
|
|
</div>
|
|
<div className="ml-4">
|
|
<p className="text-sm font-medium text-gray-500">Service Status</p>
|
|
<p className={`text-lg font-semibold ${status?.running ? 'text-green-600' : 'text-red-600'}`}>
|
|
{status?.running ? 'Online' : 'Offline'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="flex items-center">
|
|
<div className="p-2 bg-blue-100 rounded-lg">
|
|
<Users className="h-6 w-6 text-blue-600" />
|
|
</div>
|
|
<div className="ml-4">
|
|
<p className="text-sm font-medium text-gray-500">Total Peers</p>
|
|
<p className="text-lg font-semibold text-gray-900">{totalPeers}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="flex items-center">
|
|
<div className={`p-2 rounded-lg ${peers.some(p => p._liveStatus?.online) ? 'bg-green-100' : 'bg-gray-100'}`}>
|
|
<Activity className={`h-6 w-6 ${peers.some(p => p._liveStatus?.online) ? 'text-green-600' : 'text-gray-400'}`} />
|
|
</div>
|
|
<div className="ml-4">
|
|
<p className="text-sm font-medium text-gray-500">Live Connections</p>
|
|
<p className={`text-lg font-semibold ${peers.some(p => p._liveStatus?.online) ? 'text-green-600' : 'text-gray-900'}`}>
|
|
{peers.filter(p => p._liveStatus?.online).length} / {totalPeers}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="flex items-center">
|
|
<div className="p-2 bg-purple-100 rounded-lg">
|
|
<Wifi className="h-6 w-6 text-purple-600" />
|
|
</div>
|
|
<div className="ml-4">
|
|
<p className="text-sm font-medium text-gray-500">Interface</p>
|
|
<p className="text-lg font-semibold text-gray-900">{status?.interface || 'wg0'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* External IP & Port Status */}
|
|
<div className="card mb-8">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center">
|
|
<Globe className="h-5 w-5 text-gray-500 mr-2" />
|
|
<h2 className="text-lg font-semibold text-gray-900">Server Endpoint</h2>
|
|
</div>
|
|
<button
|
|
onClick={refreshExternalIp}
|
|
disabled={isRefreshingIp}
|
|
className="btn btn-secondary flex items-center text-sm"
|
|
>
|
|
<RefreshCw className={`h-3 w-3 mr-1 ${isRefreshingIp ? 'animate-spin' : ''}`} />
|
|
Refresh IP
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<p className="text-sm text-gray-500">External IP</p>
|
|
<p className="font-mono font-semibold text-gray-900">
|
|
{serverConfig?.external_ip || <span className="text-yellow-600">Detecting…</span>}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500">WireGuard Endpoint</p>
|
|
<p className="font-mono font-semibold text-gray-900">
|
|
{serverConfig?.endpoint || `<SERVER_IP>:${serverConfig?.port || 51820}`}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500">UDP Port {serverConfig?.port || 51820}</p>
|
|
{serverConfig ? (
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
serverConfig.port_open === true ? 'bg-green-100 text-green-800' :
|
|
serverConfig.port_open === false ? 'bg-red-100 text-red-800' :
|
|
'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
|
serverConfig.port_open === true ? 'bg-green-400' :
|
|
serverConfig.port_open === false ? 'bg-red-400' : 'bg-gray-400'
|
|
}`} />
|
|
{serverConfig.port_open === true ? 'Open' :
|
|
serverConfig.port_open === false ? 'Blocked' :
|
|
serverConfig.port_open === 'checking' ? 'Checking…' :
|
|
'Click Refresh IP to check'}
|
|
</span>
|
|
) : '—'}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500 mb-1">Server Public Key</p>
|
|
<p className="font-mono text-xs text-gray-700 break-all">
|
|
{serverConfig?.public_key || '—'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{serverConfig && !serverConfig.external_ip && (
|
|
<div className="mt-3 flex items-center text-yellow-700 bg-yellow-50 rounded p-2 text-sm">
|
|
<AlertCircle className="h-4 w-4 mr-2 flex-shrink-0" />
|
|
External IP could not be detected. Check internet connectivity, then click Refresh IP.
|
|
</div>
|
|
)}
|
|
{serverConfig && serverConfig.port_open === false && (
|
|
<div className="mt-3 flex items-center text-red-700 bg-red-50 rounded p-2 text-sm">
|
|
<AlertCircle className="h-4 w-4 mr-2 flex-shrink-0" />
|
|
UDP port {serverConfig.port || 51820} appears closed. Check your router/firewall and forward this port to this machine.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Traffic Stats */}
|
|
{status?.total_traffic && (
|
|
<div className="card mb-8">
|
|
<div className="flex items-center mb-4">
|
|
<Activity className="h-6 w-6 text-primary-500 mr-2" />
|
|
<h3 className="text-lg font-medium text-gray-900">Traffic Statistics</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<p className="text-sm text-gray-500 mb-1">Data Received</p>
|
|
<p className="text-2xl font-bold text-green-600">
|
|
{formatBytes(status.total_traffic.bytes_received || 0)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500 mb-1">Data Sent</p>
|
|
<p className="text-2xl font-bold text-blue-600">
|
|
{formatBytes(status.total_traffic.bytes_sent || 0)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Peers Table */}
|
|
<div className="card">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center">
|
|
<Users className="h-6 w-6 text-primary-500 mr-2" />
|
|
<h3 className="text-lg font-medium text-gray-900">Live Connected Peers</h3>
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{peers.length} peer{peers.length !== 1 ? 's' : ''} currently connected
|
|
</div>
|
|
</div>
|
|
|
|
{peers.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<Wifi className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No active connections</h3>
|
|
<p className="text-gray-500 mb-4">No peers are currently connected to the WireGuard VPN</p>
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-gray-400">Configured peers will appear here when they connect</p>
|
|
<button
|
|
onClick={() => window.location.href = '/peers'}
|
|
className="btn btn-secondary"
|
|
>
|
|
Manage Peers
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Peer
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
IP Address
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Last Handshake
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Traffic
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{peers.map((peer, index) => {
|
|
const peerStatus = peerStatuses[peer.name] || { online: null, lastHandshake: null, transferRx: 0, transferTx: 0, endpoint: null };
|
|
return (
|
|
<tr key={index} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0 h-8 w-8">
|
|
<div className="h-8 w-8 rounded-full bg-primary-100 flex items-center justify-center">
|
|
<Shield className="h-4 w-4 text-primary-600" />
|
|
</div>
|
|
</div>
|
|
<div className="ml-4">
|
|
<div className="text-sm font-medium text-gray-900">{peer.name}</div>
|
|
{peer.description && (
|
|
<div className="text-sm text-gray-500">{peer.description}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{peer.ip || peer.AllowedIPs || 'N/A'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
peerStatus.online === true
|
|
? 'bg-green-100 text-green-800'
|
|
: peerStatus.online === false
|
|
? 'bg-red-100 text-red-800'
|
|
: 'bg-gray-100 text-gray-800'
|
|
}`}>
|
|
<div className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
|
peerStatus.online === true ? 'bg-green-400' :
|
|
peerStatus.online === false ? 'bg-red-400' : 'bg-gray-400'
|
|
}`} />
|
|
{peerStatus.online === true ? 'Online' :
|
|
peerStatus.online === false ? 'Offline' : 'Unknown'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{peerStatus.lastHandshake
|
|
? new Date(peerStatus.lastHandshake).toLocaleString()
|
|
: 'Unknown'
|
|
}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center text-xs">
|
|
<span className="text-green-600 mr-1">↓</span>
|
|
{peerStatus.transferRx > 0 ? formatBytes(peerStatus.transferRx) : 'No data'}
|
|
</div>
|
|
<div className="flex items-center text-xs">
|
|
<span className="text-blue-600 mr-1">↑</span>
|
|
{peerStatus.transferTx > 0 ? formatBytes(peerStatus.transferTx) : 'No data'}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={() => handleViewPeerConfig(peer)}
|
|
className="text-primary-600 hover:text-primary-900"
|
|
title="View Configuration"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleViewPeerConfig(peer)}
|
|
className="text-green-600 hover:text-green-900"
|
|
title="Show QR Code"
|
|
>
|
|
<div className="h-4 w-4 border-2 border-current rounded-sm flex items-center justify-center">
|
|
<div className="w-2 h-2 bg-current rounded-sm"></div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Peer Configuration Modal */}
|
|
{showPeerConfig && selectedPeer && (
|
|
<div
|
|
className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
|
|
onClick={(e) => {
|
|
// Close modal when clicking on backdrop
|
|
if (e.target === e.currentTarget) {
|
|
setShowPeerConfig(false);
|
|
}
|
|
}}
|
|
>
|
|
<div className="relative top-10 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white">
|
|
<div className="mt-3">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center">
|
|
<Shield className="h-6 w-6 text-primary-500 mr-2" />
|
|
<h3 className="text-lg font-medium text-gray-900">
|
|
{selectedPeer.name} Configuration
|
|
</h3>
|
|
</div>
|
|
<div className="flex items-center space-x-3">
|
|
<div className="flex items-center bg-gray-100 rounded-lg p-1 text-xs">
|
|
<button
|
|
onClick={() => { setTunnelMode('split'); handleViewPeerConfig(selectedPeer, 'split'); }}
|
|
className={`px-2 py-1 rounded ${tunnelMode === 'split' ? 'bg-white shadow text-primary-700 font-medium' : 'text-gray-500'}`}
|
|
>
|
|
Split tunnel
|
|
</button>
|
|
<button
|
|
onClick={() => { setTunnelMode('full'); handleViewPeerConfig(selectedPeer, 'full'); }}
|
|
className={`px-2 py-1 rounded ${tunnelMode === 'full' ? 'bg-white shadow text-primary-700 font-medium' : 'text-gray-500'}`}
|
|
>
|
|
Full tunnel
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowPeerConfig(false)}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mb-3">
|
|
{tunnelMode === 'split'
|
|
? 'Split tunnel: only cell services (10.0.0.0/24, 172.20.0.0/16) route through VPN — local network & internet traffic stay direct.'
|
|
: 'Full tunnel: all traffic (internet + local) routes through VPN server.'}
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Peer Name
|
|
</label>
|
|
<p className="text-sm text-gray-900 bg-gray-50 p-2 rounded">{selectedPeer.name}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
IP Address
|
|
</label>
|
|
<p className="text-sm text-gray-900 bg-gray-50 p-2 rounded">{selectedPeer.ip || selectedPeer.AllowedIPs}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Public Key
|
|
</label>
|
|
<p className="text-xs text-gray-900 bg-gray-50 p-2 rounded break-all">{selectedPeer.public_key || selectedPeer.PublicKey}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Allowed IPs
|
|
</label>
|
|
<p className="text-sm text-gray-900 bg-gray-50 p-2 rounded">{selectedPeer.allowed_ips || selectedPeer.AllowedIPs}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
WireGuard Configuration
|
|
</label>
|
|
<div className="relative">
|
|
<textarea
|
|
value={peerConfig}
|
|
readOnly
|
|
className="w-full h-64 p-3 border border-gray-300 rounded-md font-mono text-sm bg-gray-50"
|
|
placeholder="Loading configuration..."
|
|
/>
|
|
<div className="absolute top-2 right-2 flex space-x-2">
|
|
<button
|
|
onClick={() => copyToClipboard(peerConfig)}
|
|
className="btn btn-secondary btn-sm flex items-center"
|
|
title="Copy to clipboard"
|
|
>
|
|
<Copy className="h-4 w-4 mr-1" />
|
|
Copy
|
|
</button>
|
|
<button
|
|
onClick={() => downloadConfig(selectedPeer.name, peerConfig)}
|
|
className="btn btn-secondary btn-sm flex items-center"
|
|
title="Download config file"
|
|
>
|
|
<Download className="h-4 w-4 mr-1" />
|
|
Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
Use this configuration on your mobile device or computer to connect to this WireGuard network.
|
|
</p>
|
|
</div>
|
|
|
|
{/* QR Code Section */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center">
|
|
<div className="h-5 w-5 mr-2 border-2 border-gray-400 rounded-sm flex items-center justify-center">
|
|
<div className="w-2 h-2 bg-gray-400 rounded-sm"></div>
|
|
</div>
|
|
QR Code
|
|
</label>
|
|
<div className="text-center">
|
|
{qrCodeDataUrl ? (
|
|
<div className="space-y-4">
|
|
<div className="inline-block p-4 bg-white border-2 border-gray-200 rounded-lg">
|
|
<img
|
|
src={qrCodeDataUrl}
|
|
alt="WireGuard QR Code"
|
|
className="w-48 h-48 mx-auto"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-gray-600">
|
|
Scan this QR code with your WireGuard app to connect automatically
|
|
</p>
|
|
<div className="flex justify-center space-x-2">
|
|
<button
|
|
onClick={() => {
|
|
const link = document.createElement('a');
|
|
link.href = qrCodeDataUrl;
|
|
link.download = `${selectedPeer.name}-qrcode.png`;
|
|
link.click();
|
|
}}
|
|
className="btn btn-sm btn-secondary flex items-center"
|
|
title="Download QR Code"
|
|
>
|
|
<Download className="h-4 w-4 mr-1" />
|
|
Download QR
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="p-8 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
|
|
<div className="h-12 w-12 border-2 border-gray-400 rounded-sm flex items-center justify-center mx-auto mb-2">
|
|
<div className="w-6 h-6 bg-gray-400 rounded-sm"></div>
|
|
</div>
|
|
<p className="text-sm text-gray-500">
|
|
QR Code will appear here
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end space-x-3 mt-6">
|
|
<button
|
|
onClick={() => setShowPeerConfig(false)}
|
|
className="btn btn-secondary"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default WireGuard; |