f1666ba19c
DNAT rules applied via docker exec are lost whenever wg-easy reloads the WireGuard interface (PostDown flushes the nat table then PostUp only re-adds static rules). Fix: embed DNS (port 53) and service (port 80) DNAT rules directly in wg0.conf PostUp/PostDown so they reapply on every interface restart. ensure_postup_dnat() patches existing configs on startup. get_server_config() now returns the WG server IP (e.g. 10.0.0.1) for dns_ip instead of the cell-dns container IP (172.20.0.3). This makes the value consistent with what get_peer_config() writes into the .conf file, and fixes the stale hint text in Peers.jsx and WireGuard.jsx. UI: fallback dns_ip changed from 172.20.0.3 to 10.0.0.1; split-tunnel fallback drops the 172.20.0.0/16 stale range. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
817 lines
38 KiB
React
817 lines
38 KiB
React
import { useState, useEffect } from 'react';
|
|
import { Plus, Trash2, Edit, Eye, Shield, Copy, Download, Key, AlertTriangle, CheckCircle, Globe, Lock, Users, Server } from 'lucide-react';
|
|
import { peerRegistryAPI, wireguardAPI, cellLinkAPI, getCsrfToken } from '../services/api';
|
|
import { useConfig } from '../contexts/ConfigContext';
|
|
import QRCode from 'qrcode';
|
|
|
|
const FULL_TUNNEL_IPS = '0.0.0.0/0, ::/0';
|
|
|
|
const emptyForm = () => ({
|
|
name: '',
|
|
description: '',
|
|
public_key: '',
|
|
persistent_keepalive: 25,
|
|
internet_access: true,
|
|
service_access: ['calendar', 'files', 'mail', 'webdav'],
|
|
peer_access: true,
|
|
create_calendar: false,
|
|
password: '',
|
|
});
|
|
|
|
const generatePassword = () => {
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
|
|
const arr = new Uint8Array(14);
|
|
crypto.getRandomValues(arr);
|
|
return Array.from(arr).map(b => chars[b % chars.length]).join('');
|
|
};
|
|
|
|
function AccessBadge({ icon: Icon, label, active }) {
|
|
return (
|
|
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium mr-1 ${
|
|
active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
|
|
}`}>
|
|
<Icon className="h-3 w-3 mr-0.5" />
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Toggle({ checked, onChange, label, description }) {
|
|
return (
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<div className="relative mt-0.5">
|
|
<input type="checkbox" className="sr-only" checked={checked} onChange={e => onChange(e.target.checked)} />
|
|
<div className={`w-10 h-6 rounded-full transition-colors ${checked ? 'bg-primary-600' : 'bg-gray-300'}`} />
|
|
<div className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${checked ? 'translate-x-4' : ''}`} />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">{label}</div>
|
|
{description && <div className="text-xs text-gray-500 mt-0.5">{description}</div>}
|
|
</div>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function Peers() {
|
|
const { domain = 'cell' } = useConfig();
|
|
const SERVICES = [
|
|
{ key: 'calendar', label: 'Calendar', domain: `calendar.${domain}` },
|
|
{ key: 'files', label: 'Files', domain: `files.${domain}` },
|
|
{ key: 'mail', label: 'Webmail', domain: `mail.${domain}` },
|
|
{ key: 'webdav', label: 'WebDAV', domain: `webdav.${domain}` },
|
|
];
|
|
|
|
const [peers, setPeers] = useState([]);
|
|
const [connectedCells, setConnectedCells] = useState([]);
|
|
const [serverConf, setServerConf] = useState(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [showViewModal, setShowViewModal] = useState(false);
|
|
const [selectedPeer, setSelectedPeer] = useState(null);
|
|
const [formData, setFormData] = useState(emptyForm());
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const [peerConfig, setPeerConfig] = useState('');
|
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
|
const [isGeneratingKeys, setIsGeneratingKeys] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [errors, setErrors] = useState({});
|
|
const [toast, setToast] = useState(null);
|
|
|
|
useEffect(() => {
|
|
fetchPeers();
|
|
cellLinkAPI.listConnections().then(r => setConnectedCells(r.data || [])).catch(() => {});
|
|
}, []);
|
|
|
|
const showToast = (msg, type = 'success') => {
|
|
setToast({ msg, type });
|
|
setTimeout(() => setToast(null), 4000);
|
|
};
|
|
|
|
const fetchPeers = async () => {
|
|
try {
|
|
const [regResp, statusResp, scResp] = await Promise.all([
|
|
peerRegistryAPI.getPeers(),
|
|
wireguardAPI.getPeerStatuses().catch(() => ({ data: {} })),
|
|
fetch('/api/wireguard/server-config', { credentials: 'include' }).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
]);
|
|
const regPeers = regResp.data || [];
|
|
const statusMap = statusResp.data || {};
|
|
const merged = regPeers.map(p => ({
|
|
...p,
|
|
name: p.peer || p.name,
|
|
online: statusMap[p.public_key]?.online ?? false,
|
|
last_handshake: statusMap[p.public_key]?.last_handshake ?? null,
|
|
last_handshake_seconds_ago: statusMap[p.public_key]?.last_handshake_seconds_ago ?? null,
|
|
transfer_rx: statusMap[p.public_key]?.transfer_rx ?? 0,
|
|
transfer_tx: statusMap[p.public_key]?.transfer_tx ?? 0,
|
|
}));
|
|
setPeers(merged);
|
|
if (scResp) setServerConf(scResp);
|
|
} catch (err) {
|
|
console.error('Failed to fetch peers:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const getServerConfig = async () => {
|
|
if (serverConf) return serverConf;
|
|
try {
|
|
const r = await fetch('/api/wireguard/server-config', { credentials: 'include' });
|
|
if (r.ok) {
|
|
const sc = await r.json();
|
|
setServerConf(sc);
|
|
return sc;
|
|
}
|
|
} catch {}
|
|
return { public_key: 'SERVER_PUBLIC_KEY_PLACEHOLDER', endpoint: 'YOUR_SERVER_IP:51820' };
|
|
};
|
|
|
|
const generateConfig = (peer, sc) => {
|
|
const privateKey = peer.private_key || 'YOUR_PRIVATE_KEY_HERE';
|
|
const serverPubKey = peer.server_public_key || sc?.public_key || 'SERVER_PUBLIC_KEY_PLACEHOLDER';
|
|
const endpoint = peer.server_endpoint || sc?.endpoint || 'YOUR_SERVER_IP:51820';
|
|
const address = peer.ip?.includes('/') ? peer.ip : `${peer.ip}/32`;
|
|
const splitTunnelIPs = sc?.split_tunnel_ips || `10.0.0.0/24`;
|
|
const allowedIPs = peer.internet_access !== false ? FULL_TUNNEL_IPS : splitTunnelIPs;
|
|
const dnsIp = sc?.dns_ip || '10.0.0.1';
|
|
return `[Interface]
|
|
PrivateKey = ${privateKey}
|
|
Address = ${address}
|
|
DNS = ${dnsIp}
|
|
|
|
[Peer]
|
|
PublicKey = ${serverPubKey}
|
|
Endpoint = ${endpoint}
|
|
AllowedIPs = ${allowedIPs}
|
|
PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|
};
|
|
|
|
const generateKeys = async () => {
|
|
if (!formData.name) { setErrors(e => ({ ...e, name: 'Enter a name first' })); return; }
|
|
setIsGeneratingKeys(true);
|
|
try {
|
|
const r = await wireguardAPI.generatePeerKeys({ peer_name: formData.name });
|
|
setFormData(f => ({ ...f, public_key: r.data.public_key, _private_key: r.data.private_key }));
|
|
} catch { showToast('Failed to generate keys', 'error'); }
|
|
finally { setIsGeneratingKeys(false); }
|
|
};
|
|
|
|
const validate = (data) => {
|
|
const errs = {};
|
|
if (!data.name.trim()) errs.name = 'Name is required';
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(data.name)) errs.name = 'Only letters, numbers, - and _ allowed';
|
|
return errs;
|
|
};
|
|
|
|
const handleAddPeer = async (e) => {
|
|
e.preventDefault();
|
|
const errs = validate(formData);
|
|
if (!formData.password || formData.password.length < 10) errs.password = 'Password must be at least 10 characters';
|
|
if (Object.keys(errs).length) { setErrors(errs); return; }
|
|
setIsSubmitting(true);
|
|
try {
|
|
let publicKey = formData.public_key;
|
|
let privateKey = formData._private_key || null;
|
|
if (!publicKey) {
|
|
const kr = await wireguardAPI.generatePeerKeys({ peer_name: formData.name });
|
|
publicKey = kr.data.public_key;
|
|
privateKey = kr.data.private_key;
|
|
}
|
|
const serverConf = await getServerConfig();
|
|
const peerData = {
|
|
name: formData.name,
|
|
ip: formData.ip,
|
|
public_key: publicKey,
|
|
private_key: privateKey,
|
|
server_public_key: serverConf.public_key,
|
|
server_endpoint: serverConf.endpoint,
|
|
persistent_keepalive: formData.persistent_keepalive,
|
|
description: formData.description,
|
|
internet_access: formData.internet_access,
|
|
service_access: formData.service_access,
|
|
peer_access: formData.peer_access,
|
|
password: formData.password,
|
|
};
|
|
const addResult = await peerRegistryAPI.addPeer(peerData);
|
|
|
|
if (formData.create_calendar) {
|
|
try {
|
|
await fetch(`/api/calendar/create-user-collection?user=${formData.name}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'X-CSRF-Token': getCsrfToken() || '' },
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
const provisioned = addResult.data?.provisioned;
|
|
const createdName = formData.name;
|
|
const provisionedList = provisioned
|
|
? Object.entries(provisioned).filter(([, v]) => v).map(([k]) => k).join(', ')
|
|
: '';
|
|
setShowAddModal(false);
|
|
setFormData(emptyForm());
|
|
setErrors({});
|
|
fetchPeers();
|
|
showToast(
|
|
`Peer "${createdName}" created.` + (provisionedList ? ` Accounts: ${provisionedList}.` : ''),
|
|
'success'
|
|
);
|
|
} catch (err) {
|
|
showToast(err?.response?.data?.error || 'Failed to add peer', 'error');
|
|
} finally { setIsSubmitting(false); }
|
|
};
|
|
|
|
const handleUpdatePeer = async (e) => {
|
|
e.preventDefault();
|
|
const errs = validate(formData);
|
|
if (Object.keys(errs).length) { setErrors(errs); return; }
|
|
setIsSubmitting(true);
|
|
try {
|
|
const r = await fetch(`/api/peers/${selectedPeer.name}`, {
|
|
method: 'PUT',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() || '' },
|
|
body: JSON.stringify({
|
|
description: formData.description,
|
|
internet_access: formData.internet_access,
|
|
service_access: formData.service_access,
|
|
peer_access: formData.peer_access,
|
|
persistent_keepalive: formData.persistent_keepalive,
|
|
}),
|
|
});
|
|
const result = await r.json();
|
|
|
|
// Server-side AllowedIPs for the peer must stay as /32 (host route only)
|
|
const existingIp = selectedPeer.ip?.includes('/') ? selectedPeer.ip : `${selectedPeer.ip}/32`;
|
|
await wireguardAPI.addPeer({
|
|
name: selectedPeer.name,
|
|
public_key: formData.public_key || selectedPeer.public_key,
|
|
allowed_ips: existingIp,
|
|
persistent_keepalive: formData.persistent_keepalive,
|
|
});
|
|
|
|
// Route-via is a separate endpoint (triggers WG + iptables changes)
|
|
const oldRouteVia = selectedPeer.route_via || null;
|
|
const newRouteVia = formData.route_via || null;
|
|
if (oldRouteVia !== newRouteVia) {
|
|
await peerRegistryAPI.setRouteVia(selectedPeer.name, newRouteVia);
|
|
}
|
|
|
|
setShowEditModal(false);
|
|
setSelectedPeer(null);
|
|
fetchPeers();
|
|
if (result.config_changed) {
|
|
showToast(`Peer updated. Config changed — ask them to reinstall the tunnel.`, 'warning');
|
|
} else {
|
|
showToast('Peer updated successfully.');
|
|
}
|
|
} catch (err) {
|
|
showToast('Failed to update peer', 'error');
|
|
} finally { setIsSubmitting(false); }
|
|
};
|
|
|
|
const handleRemovePeer = async (peerName) => {
|
|
if (!window.confirm(`Remove peer "${peerName}"?`)) return;
|
|
try {
|
|
await peerRegistryAPI.removePeer(peerName);
|
|
fetchPeers();
|
|
showToast(`Peer "${peerName}" removed.`);
|
|
} catch { showToast('Failed to remove peer', 'error'); }
|
|
};
|
|
|
|
const handleViewPeer = async (peer) => {
|
|
setSelectedPeer(peer);
|
|
setQrCodeDataUrl('');
|
|
setPeerConfig('');
|
|
setShowViewModal(true);
|
|
try {
|
|
const serverConf = await getServerConfig();
|
|
let config;
|
|
try {
|
|
const r = await wireguardAPI.getPeerConfig({ name: peer.name });
|
|
config = r.data.config;
|
|
} catch {}
|
|
if (!config || config.includes('PLACEHOLDER')) {
|
|
config = generateConfig({ ...peer, server_public_key: serverConf.public_key, server_endpoint: serverConf.endpoint }, serverConf);
|
|
}
|
|
setPeerConfig(config);
|
|
const qr = await QRCode.toDataURL(config, { width: 256, margin: 2, errorCorrectionLevel: 'M' });
|
|
setQrCodeDataUrl(qr);
|
|
} catch (err) {
|
|
console.error('Failed to load peer config:', err);
|
|
}
|
|
};
|
|
|
|
const handleConfigDownloaded = async (peerName) => {
|
|
try {
|
|
await fetch(`/api/peers/${peerName}/clear-reinstall`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'X-CSRF-Token': getCsrfToken() || '' },
|
|
});
|
|
setPeers(ps => ps.map(p => p.name === peerName ? { ...p, config_needs_reinstall: false } : p));
|
|
} catch {}
|
|
};
|
|
|
|
const handleEditPeer = (peer) => {
|
|
setSelectedPeer(peer);
|
|
setFormData({
|
|
name: peer.name,
|
|
ip: peer.ip || '',
|
|
description: peer.description || '',
|
|
public_key: peer.public_key || '',
|
|
persistent_keepalive: peer.persistent_keepalive || 25,
|
|
internet_access: peer.internet_access !== false,
|
|
service_access: peer.service_access || ['calendar', 'files', 'mail', 'webdav'],
|
|
peer_access: peer.peer_access !== false,
|
|
create_calendar: false,
|
|
route_via: peer.route_via || null,
|
|
});
|
|
setErrors({});
|
|
setShowAdvanced(false);
|
|
setShowEditModal(true);
|
|
};
|
|
|
|
const toggleService = (key) => {
|
|
setFormData(f => ({
|
|
...f,
|
|
service_access: f.service_access.includes(key)
|
|
? f.service_access.filter(s => s !== key)
|
|
: [...f.service_access, key],
|
|
}));
|
|
};
|
|
|
|
const copyToClipboard = async (text) => {
|
|
try { await navigator.clipboard.writeText(text); showToast('Copied to clipboard!'); }
|
|
catch { showToast('Copy failed', 'error'); }
|
|
};
|
|
|
|
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);
|
|
if (selectedPeer) handleConfigDownloaded(selectedPeer.name);
|
|
};
|
|
|
|
const AccessForm = ({ data, onChange }) => (
|
|
<div className="space-y-5 pt-4 border-t border-gray-200">
|
|
<div>
|
|
<div className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
|
|
<Globe className="h-4 w-4" /> Internet Access
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="button"
|
|
onClick={() => onChange({ internet_access: true })}
|
|
className={`flex-1 px-3 py-2 rounded border text-sm font-medium transition-colors ${data.internet_access ? 'bg-primary-600 text-white border-primary-600' : 'bg-white text-gray-600 border-gray-300 hover:border-primary-400'}`}>
|
|
<Globe className="h-4 w-4 inline mr-1" />Full tunnel
|
|
<div className="text-xs font-normal mt-0.5 opacity-75">All traffic via PIC server</div>
|
|
</button>
|
|
<button type="button"
|
|
onClick={() => onChange({ internet_access: false })}
|
|
className={`flex-1 px-3 py-2 rounded border text-sm font-medium transition-colors ${!data.internet_access ? 'bg-primary-600 text-white border-primary-600' : 'bg-white text-gray-600 border-gray-300 hover:border-primary-400'}`}>
|
|
<Lock className="h-4 w-4 inline mr-1" />Split tunnel
|
|
<div className="text-xs font-normal mt-0.5 opacity-75">Only PIC services via VPN</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
|
|
<Server className="h-4 w-4" /> Service Access
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{SERVICES.map(svc => (
|
|
<label key={svc.key} className={`flex items-center gap-2 p-2.5 rounded border cursor-pointer transition-colors ${
|
|
data.service_access?.includes(svc.key) ? 'bg-green-50 border-green-300' : 'bg-gray-50 border-gray-200'
|
|
}`}>
|
|
<input type="checkbox" className="rounded"
|
|
checked={data.service_access?.includes(svc.key) ?? true}
|
|
onChange={() => onChange({ service_access: data.service_access?.includes(svc.key)
|
|
? data.service_access.filter(s => s !== svc.key)
|
|
: [...(data.service_access || []), svc.key]
|
|
})} />
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">{svc.label}</div>
|
|
<div className="text-xs text-gray-500">{svc.domain}</div>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
|
|
<Users className="h-4 w-4" /> Peer Communication
|
|
</div>
|
|
<Toggle
|
|
checked={data.peer_access !== false}
|
|
onChange={v => onChange({ peer_access: v })}
|
|
label="Allow peer-to-peer traffic"
|
|
description={`This peer can communicate with other VPN peers (${serverConf?.vpn_network || 'VPN subnet'})`}
|
|
/>
|
|
</div>
|
|
|
|
{connectedCells.length > 0 && (
|
|
<div>
|
|
<div className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
|
<Globe className="h-4 w-4" /> Internet Exit
|
|
</div>
|
|
<select
|
|
value={data.route_via || ''}
|
|
onChange={e => onChange({ route_via: e.target.value || null })}
|
|
className="input"
|
|
>
|
|
<option value="">Direct (this cell's connection)</option>
|
|
{connectedCells.map(cell => (
|
|
<option key={cell.cell_name} value={cell.cell_name}>
|
|
Via {cell.cell_name}
|
|
{cell.remote_exit_offered ? ' ✓ offers internet' : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
Route this peer's internet traffic through a connected cell.
|
|
{data.route_via && !connectedCells.find(c => c.cell_name === data.route_via)?.remote_exit_offered && (
|
|
<span className="text-yellow-600 ml-1">The selected cell hasn't offered their internet yet.</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
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>
|
|
{/* Toast */}
|
|
{toast && (
|
|
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${
|
|
toast.type === 'error' ? 'bg-red-600 text-white' :
|
|
toast.type === 'warning' ? 'bg-yellow-500 text-white' : 'bg-green-600 text-white'
|
|
}`}>
|
|
{toast.type === 'error' ? <AlertTriangle className="h-4 w-4" /> : <CheckCircle className="h-4 w-4" />}
|
|
{toast.msg}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mb-8 flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Peers</h1>
|
|
<p className="mt-1 text-gray-600">Manage VPN peer connections and access policies</p>
|
|
</div>
|
|
<button onClick={() => { setFormData(emptyForm()); setErrors({}); setShowAdvanced(false); setShowAddModal(true); }}
|
|
className="btn btn-primary flex items-center">
|
|
<Plus className="h-4 w-4 mr-2" />Add Peer
|
|
</button>
|
|
</div>
|
|
|
|
{/* Peers Table */}
|
|
<div className="card">
|
|
<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</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Access</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">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{peers.length === 0 ? (
|
|
<tr><td colSpan="5" className="px-6 py-8 text-center text-gray-500">No peers configured.</td></tr>
|
|
) : peers.map(peer => (
|
|
<tr key={peer.name} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-4 w-4 text-primary-500 flex-shrink-0" />
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900 flex items-center gap-1.5">
|
|
{peer.name}
|
|
{peer.config_needs_reinstall && (
|
|
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
|
<AlertTriangle className="h-3 w-3" /> Reinstall tunnel
|
|
</span>
|
|
)}
|
|
</div>
|
|
{peer.description && <div className="text-xs text-gray-500">{peer.description}</div>}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-900 font-mono">{peer.ip}</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex flex-wrap gap-0.5">
|
|
<AccessBadge icon={Globe} label="Internet" active={peer.internet_access !== false} />
|
|
{SERVICES.map(s => (
|
|
<AccessBadge key={s.key} icon={Server} label={s.label} active={(peer.service_access || SERVICES.map(s=>s.key)).includes(s.key)} />
|
|
))}
|
|
<AccessBadge icon={Users} label="Peers" active={peer.peer_access !== false} />
|
|
{peer.route_via && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 mr-1">
|
|
<Globe className="h-3 w-3 mr-0.5" />via {peer.route_via}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className={`status-indicator ${peer.online ? 'status-online' : 'status-offline'}`}>
|
|
{peer.online ? 'Online' : 'Offline'}
|
|
</span>
|
|
{peer.last_handshake_seconds_ago != null && (
|
|
<div className="text-xs text-gray-400 mt-0.5">{peer.last_handshake_seconds_ago}s ago</div>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex space-x-2">
|
|
<button onClick={() => handleViewPeer(peer)} className="text-primary-600 hover:text-primary-900" title="View Config & QR">
|
|
<Eye className="h-4 w-4" />
|
|
</button>
|
|
<button onClick={() => handleEditPeer(peer)} className="text-primary-600 hover:text-primary-900" title="Edit Peer">
|
|
<Edit className="h-4 w-4" />
|
|
</button>
|
|
<button onClick={() => handleRemovePeer(peer.name)} className="text-red-600 hover:text-red-900" title="Remove Peer">
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add Peer Modal */}
|
|
{showAddModal && (
|
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
|
|
onClick={e => { if (e.target === e.currentTarget) setShowAddModal(false); }}>
|
|
<div className="relative top-10 mx-auto p-6 border w-full max-w-lg shadow-lg rounded-md bg-white mb-10">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-6 w-6 text-primary-500" />
|
|
<h3 className="text-lg font-medium text-gray-900">Add New Peer</h3>
|
|
</div>
|
|
<button onClick={() => setShowAddModal(false)} className="text-gray-400 hover:text-gray-600">✕</button>
|
|
</div>
|
|
<form onSubmit={handleAddPeer} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
|
<input value={formData.name}
|
|
onChange={e => { setFormData(f => ({ ...f, name: e.target.value })); setErrors(e2 => ({ ...e2, name: undefined })); }}
|
|
className={`input ${errors.name ? 'border-red-500' : ''}`} placeholder="mobile-phone" />
|
|
{errors.name && <p className="text-xs text-red-600 mt-1">{errors.name}</p>}
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
<input value={formData.description} onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
|
|
className="input" placeholder="My laptop" />
|
|
</div>
|
|
</div>
|
|
|
|
<AccessForm data={formData} onChange={updates => setFormData(f => ({ ...f, ...updates }))} />
|
|
|
|
{/* Account Creation */}
|
|
<div className="pt-3 border-t border-gray-200">
|
|
<div className="text-sm font-semibold text-gray-700 mb-2">Account Setup</div>
|
|
<div className="mb-3">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Dashboard Password *</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="password"
|
|
value={formData.password}
|
|
onChange={e => { setFormData(f => ({ ...f, password: e.target.value })); setErrors(e2 => ({ ...e2, password: undefined })); }}
|
|
className={`input flex-1 ${errors.password ? 'border-red-500' : ''}`}
|
|
placeholder="Min 10 characters"
|
|
autoComplete="new-password"
|
|
/>
|
|
<button type="button"
|
|
onClick={() => setFormData(f => ({ ...f, password: generatePassword() }))}
|
|
className="btn btn-secondary text-xs whitespace-nowrap">
|
|
Generate
|
|
</button>
|
|
</div>
|
|
{errors.password && <p className="text-xs text-red-600 mt-1">{errors.password}</p>}
|
|
</div>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={formData.create_calendar}
|
|
onChange={e => setFormData(f => ({ ...f, create_calendar: e.target.checked }))} className="rounded" />
|
|
<span className="text-sm text-gray-700">Create calendar collection for this peer</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Advanced */}
|
|
<div className="pt-2 border-t border-gray-200">
|
|
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)}
|
|
className="text-sm text-primary-600 hover:text-primary-800 flex items-center gap-1">
|
|
<Key className="h-4 w-4" />
|
|
{showAdvanced ? 'Hide' : 'Show'} Advanced Options
|
|
</button>
|
|
{showAdvanced && (
|
|
<div className="mt-3 space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Public Key</label>
|
|
<div className="flex gap-2">
|
|
<textarea value={formData.public_key}
|
|
onChange={e => setFormData(f => ({ ...f, public_key: e.target.value }))}
|
|
className="input flex-1" rows="2" placeholder="Leave empty to auto-generate" />
|
|
<button type="button" onClick={generateKeys} disabled={isGeneratingKeys}
|
|
className="btn btn-secondary text-xs self-start mt-0.5">
|
|
{isGeneratingKeys ? '…' : 'Generate'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Keepalive (s)</label>
|
|
<input type="number" value={formData.persistent_keepalive} min="0" max="65535"
|
|
onChange={e => setFormData(f => ({ ...f, persistent_keepalive: parseInt(e.target.value) }))}
|
|
className="input w-32" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<button type="button" onClick={() => setShowAddModal(false)} className="btn btn-secondary">Cancel</button>
|
|
<button type="submit" disabled={isSubmitting} className="btn btn-primary flex items-center gap-2">
|
|
<Plus className="h-4 w-4" />{isSubmitting ? 'Adding…' : 'Add Peer'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Peer Modal */}
|
|
{showEditModal && selectedPeer && (
|
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
|
|
onClick={e => { if (e.target === e.currentTarget) { setShowEditModal(false); setSelectedPeer(null); } }}>
|
|
<div className="relative top-10 mx-auto p-6 border w-full max-w-lg shadow-lg rounded-md bg-white mb-10">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-6 w-6 text-primary-500" />
|
|
<h3 className="text-lg font-medium text-gray-900">Edit Peer — {selectedPeer.name}</h3>
|
|
</div>
|
|
<button onClick={() => { setShowEditModal(false); setSelectedPeer(null); }} className="text-gray-400 hover:text-gray-600">✕</button>
|
|
</div>
|
|
|
|
{selectedPeer.config_needs_reinstall && (
|
|
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md flex items-start gap-2 text-sm text-yellow-800">
|
|
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
|
<div>Config changed since last tunnel install. After saving, download the updated config and reinstall on the device.</div>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleUpdatePeer} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
|
<input value={formData.name} className="input bg-gray-50" disabled />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">VPN IP</label>
|
|
<input value={selectedPeer?.ip || ''} className="input bg-gray-50 font-mono" disabled />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
<input value={formData.description} onChange={e => setFormData(f => ({ ...f, description: e.target.value }))} className="input" />
|
|
</div>
|
|
|
|
<AccessForm data={formData} onChange={updates => setFormData(f => ({ ...f, ...updates }))} />
|
|
|
|
<div>
|
|
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)}
|
|
className="text-sm text-primary-600 hover:text-primary-800 flex items-center gap-1 mt-2">
|
|
<Key className="h-4 w-4" />
|
|
{showAdvanced ? 'Hide' : 'Show'} Advanced Options
|
|
</button>
|
|
{showAdvanced && (
|
|
<div className="mt-3">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Keepalive (s)</label>
|
|
<input type="number" value={formData.persistent_keepalive} min="0" max="65535"
|
|
onChange={e => setFormData(f => ({ ...f, persistent_keepalive: parseInt(e.target.value) }))}
|
|
className="input w-32" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<button type="button" onClick={() => { setShowEditModal(false); setSelectedPeer(null); }} className="btn btn-secondary">Cancel</button>
|
|
<button type="submit" disabled={isSubmitting} className="btn btn-primary flex items-center gap-2">
|
|
<Edit className="h-4 w-4" />{isSubmitting ? 'Saving…' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* View Config Modal */}
|
|
{showViewModal && selectedPeer && (
|
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
|
|
onClick={e => { if (e.target === e.currentTarget) setShowViewModal(false); }}>
|
|
<div className="relative top-10 mx-auto p-6 border w-full max-w-4xl shadow-lg rounded-md bg-white mb-10">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-6 w-6 text-primary-500" />
|
|
<h3 className="text-lg font-medium text-gray-900">{selectedPeer.name} — Tunnel Config</h3>
|
|
</div>
|
|
<button onClick={() => setShowViewModal(false)} className="text-gray-400 hover:text-gray-600">✕</button>
|
|
</div>
|
|
|
|
{selectedPeer.config_needs_reinstall && (
|
|
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md flex items-start gap-2 text-sm text-yellow-800">
|
|
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<strong>Config changed.</strong> Download the updated config below and reinstall the WireGuard tunnel on the device. The old config will no longer work correctly.
|
|
</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 File</label>
|
|
{peerConfig ? (
|
|
<div className="relative">
|
|
<textarea value={peerConfig} readOnly
|
|
className="w-full h-64 p-3 border border-gray-300 rounded-md font-mono text-xs bg-gray-50" />
|
|
<div className="flex gap-2 mt-2">
|
|
<button onClick={() => copyToClipboard(peerConfig)}
|
|
className="btn btn-secondary btn-sm flex items-center gap-1">
|
|
<Copy className="h-4 w-4" /> Copy
|
|
</button>
|
|
<button onClick={() => downloadConfig(selectedPeer.name, peerConfig)}
|
|
className="btn btn-primary btn-sm flex items-center gap-1">
|
|
<Download className="h-4 w-4" /> Download .conf
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-64 bg-gray-50 rounded-md flex items-center justify-center text-gray-400 text-sm">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600 mr-2"></div>
|
|
Loading…
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
DNS is set to <code className="bg-gray-100 px-1 rounded">{serverConf?.dns_ip || '10.0.0.1'}</code> (PIC DNS) — required to resolve <code className="bg-gray-100 px-1 rounded">.{domain}</code> domains.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">QR Code — Scan with WireGuard app</label>
|
|
<div className="text-center">
|
|
{qrCodeDataUrl ? (
|
|
<div className="space-y-3">
|
|
<div className="inline-block p-3 bg-white border-2 border-gray-200 rounded-lg">
|
|
<img src={qrCodeDataUrl} alt="WireGuard QR Code" className="w-48 h-48 mx-auto" />
|
|
</div>
|
|
<button onClick={() => { const a = document.createElement('a'); a.href = qrCodeDataUrl; a.download = `${selectedPeer.name}-qr.png`; a.click(); handleConfigDownloaded(selectedPeer.name); }}
|
|
className="btn btn-secondary btn-sm flex items-center gap-1 mx-auto">
|
|
<Download className="h-4 w-4" /> Download QR
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="h-56 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center text-gray-400 text-sm">
|
|
{peerConfig ? 'Generating QR…' : 'Loading…'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
|
|
<div className="text-xs text-gray-500">
|
|
Tunnel: <span className="font-medium">{selectedPeer.internet_access !== false ? 'Full' : 'Split'}</span>
|
|
{' · '}Services: <span className="font-medium">{(selectedPeer.service_access || SERVICES.map(s=>s.key)).join(', ')}</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => { setShowViewModal(false); handleEditPeer(selectedPeer); }} className="btn btn-secondary flex items-center gap-1">
|
|
<Edit className="h-4 w-4" /> Edit
|
|
</button>
|
|
<button onClick={() => setShowViewModal(false)} className="btn btn-secondary">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Peers;
|