feat: per-peer access enforcement, live peer status, auto IP assignment

Server-side access control:
- firewall_manager.py: per-peer iptables FORWARD rules in WireGuard container;
  virtual IPs on Caddy (172.20.0.21-24) for per-service DROP/ACCEPT targeting
- CoreDNS Corefile regenerated with ACL blocks for blocked services per peer
- POST /api/wireguard/apply-enforcement re-applies rules after WireGuard restart;
  wg0.conf PostUp calls it via curl so rules restore automatically on container start

WireGuard fixes:
- _syncconf uses `wg set peer` instead of `wg syncconf` to avoid resetting ListenPort
- add_peer validates AllowedIPs must be /32 — rejects full/split tunnel CIDRs that
  would route internet or LAN traffic to that peer
- _config_file() checks for linuxserver wg_confs/ subdirectory first

UI:
- Peers page fetches /api/wireguard/peers/statuses for live handshake data;
  status badge now shows real Online/Offline + seconds since last handshake
- IP field removed from Add Peer form (auto-assigned from 10.0.0.0/24)

Tests (246 pass):
- test_firewall_manager.py: 22 tests for ACL generation, iptables rule correctness,
  comment tagging, clear_peer_rules filter logic
- test_peer_wg_integration.py: 10 tests for /32 enforcement, IP auto-assignment,
  syncconf called on add/remove
- test_wireguard_manager.py: updated to reflect correct IPs and /32 requirement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 01:01:07 -04:00
parent 8e41568964
commit 53c7661812
13 changed files with 1457 additions and 378 deletions
+180 -98
View File
@@ -1,98 +1,180 @@
import { useState, useEffect } from 'react';
import { Calendar as CalendarIcon, Users, Clock } from 'lucide-react';
import { calendarAPI } from '../services/api';
function Calendar() {
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchCalendarData();
}, []);
const fetchCalendarData = async () => {
try {
const [usersResponse, statusResponse] = await Promise.all([
calendarAPI.getUsers(),
calendarAPI.getStatus()
]);
setUsers(usersResponse.data);
setStatus(statusResponse.data);
} catch (error) {
console.error('Failed to fetch calendar data:', error);
} finally {
setIsLoading(false);
}
};
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">
<h1 className="text-2xl font-bold text-gray-900">Calendar Services</h1>
<p className="mt-2 text-gray-600">
Manage Radicale CalDAV and CardDAV services
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Radicale:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CalDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CardDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
<div className="card">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Calendar Users</h3>
</div>
<div className="space-y-2">
{users.length > 0 ? (
users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.calendars || 0} calendars</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No calendar users configured</p>
)}
</div>
</div>
</div>
</div>
);
}
export default Calendar;
import { useState, useEffect } from 'react';
import { Calendar as CalendarIcon, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { calendarAPI } from '../services/api';
const CELL_HOST = 'calendar.cell';
const CELL_IP = '172.20.0.21';
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-32 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
function Calendar() {
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchCalendarData();
}, []);
const fetchCalendarData = async () => {
try {
const [usersResponse, statusResponse] = await Promise.all([
calendarAPI.getUsers(),
calendarAPI.getStatus()
]);
setUsers(usersResponse.data);
setStatus(statusResponse.data);
} catch (error) {
console.error('Failed to fetch calendar data:', error);
} finally {
setIsLoading(false);
}
};
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">
<h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1>
<p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Connection Info */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Connect your device</h3>
</div>
<p className="text-xs text-gray-500 mb-3">
Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.)
</p>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server URL" value={`http://${CELL_HOST}`} />
<InfoRow label="CalDAV path" value={`http://${CELL_HOST}/`} />
<InfoRow label="CardDAV path" value={`http://${CELL_HOST}/`} />
<InfoRow label="Port" value="80" />
<InfoRow label="Direct IP" value={CELL_IP} />
<InfoRow label="Protocol" value="HTTP (CalDAV/CardDAV)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN connection. DNS server must be set to <span className="font-mono">172.20.0.3</span>.
</p>
</div>
{/* iOS / Android quick guide */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Quick setup guide</h3>
</div>
<div className="space-y-3 text-sm text-gray-700">
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Settings Calendar Accounts)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Add Account Other Add CalDAV Account</li>
<li>Server: <span className="font-mono">calendar.cell</span></li>
<li>Enter username &amp; password</li>
<li>For contacts: Add CardDAV Account, same server</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android (DAVx⁵ app)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Install DAVx⁵ from Play Store / F-Droid</li>
<li>Login with URL: <span className="font-mono">http://calendar.cell/</span></li>
<li>Select calendars &amp; address books to sync</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Thunderbird</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Calendar New Calendar On the Network</li>
<li>Format: CalDAV, Location: <span className="font-mono">http://calendar.cell/</span></li>
</ol>
</div>
</div>
</div>
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Radicale:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CalDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CardDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
<div className="card">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Calendar Users</h3>
</div>
<div className="space-y-2">
{users.length > 0 ? (
users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.calendars || 0} calendars</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No calendar users configured</p>
)}
</div>
</div>
</div>
</div>
);
}
export default Calendar;
+164 -94
View File
@@ -1,94 +1,164 @@
import { useState, useEffect } from 'react';
import { Mail, Users, Send } from 'lucide-react';
import { emailAPI } from '../services/api';
function Email() {
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchEmailData();
}, []);
const fetchEmailData = async () => {
try {
const [usersResponse, statusResponse] = await Promise.all([
emailAPI.getUsers(),
emailAPI.getStatus()
]);
setUsers(usersResponse.data);
setStatus(statusResponse.data);
} catch (error) {
console.error('Failed to fetch email data:', error);
} finally {
setIsLoading(false);
}
};
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">
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
<p className="mt-2 text-gray-600">
Manage Postfix and Dovecot email services
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Postfix:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Dovecot:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
<div className="card">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Email Users</h3>
</div>
<div className="space-y-2">
{users.length > 0 ? (
users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.domain}</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No email users configured</p>
)}
</div>
</div>
</div>
</div>
);
}
export default Email;
import { useState, useEffect } from 'react';
import { Mail, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { emailAPI } from '../services/api';
const CELL_HOST = 'mail.cell';
const CELL_IP = '172.20.0.23';
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
function Email() {
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchEmailData();
}, []);
const fetchEmailData = async () => {
try {
const [usersResponse, statusResponse] = await Promise.all([
emailAPI.getUsers(),
emailAPI.getStatus()
]);
setUsers(usersResponse.data);
setStatus(statusResponse.data);
} catch (error) {
console.error('Failed to fetch email data:', error);
} finally {
setIsLoading(false);
}
};
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">
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
<p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Incoming mail */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Incoming mail (IMAP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={CELL_HOST} />
<InfoRow label="Port" value="993" />
<InfoRow label="Security" value="SSL/TLS" />
<InfoRow label="Direct IP" value={CELL_IP} />
</div>
</div>
{/* Outgoing mail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Outgoing mail (SMTP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={CELL_HOST} />
<InfoRow label="Port" value="587" />
<InfoRow label="Security" value="STARTTLS" />
<InfoRow label="Auth" value="Username + Password" />
</div>
</div>
{/* Webmail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Webmail</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value="http://mail.cell" />
<InfoRow label="Alt URL" value="http://webmail.cell" />
<InfoRow label="Direct IP" value={`http://${CELL_IP}`} />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN + DNS set to <span className="font-mono">172.20.0.3</span>.
</p>
</div>
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Postfix (SMTP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Dovecot (IMAP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Email Accounts</h3>
</div>
<div className="space-y-2">
{users.length > 0 ? (
users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.domain}</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No email accounts configured</p>
)}
</div>
</div>
</div>
</div>
);
}
export default Email;
+179 -94
View File
@@ -1,94 +1,179 @@
import { useState, useEffect } from 'react';
import { FolderOpen, Users, HardDrive } from 'lucide-react';
import { fileAPI } from '../services/api';
function Files() {
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchFilesData();
}, []);
const fetchFilesData = async () => {
try {
const [usersResponse, statusResponse] = await Promise.all([
fileAPI.getUsers(),
fileAPI.getStatus()
]);
setUsers(usersResponse.data);
setStatus(statusResponse.data);
} catch (error) {
console.error('Failed to fetch files data:', error);
} finally {
setIsLoading(false);
}
};
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">
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="mt-2 text-gray-600">
Manage WebDAV file storage services
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">WebDAV:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Storage:</span>
<span className="text-sm font-medium text-success-600">Available</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
<div className="card">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Storage Users</h3>
</div>
<div className="space-y-2">
{users.length > 0 ? (
users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.storage_used || '0'} MB</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No storage users configured</p>
)}
</div>
</div>
</div>
</div>
);
}
export default Files;
import { useState, useEffect } from 'react';
import { FolderOpen, Users, HardDrive, Wifi, Copy, CheckCheck } from 'lucide-react';
import { fileAPI } from '../services/api';
const FILES_HOST = 'files.cell';
const FILES_IP = '172.20.0.22';
const WEBDAV_HOST = 'webdav.cell';
const WEBDAV_IP = '172.20.0.24';
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
function Files() {
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchFilesData();
}, []);
const fetchFilesData = async () => {
try {
const [usersResponse, statusResponse] = await Promise.all([
fileAPI.getUsers(),
fileAPI.getStatus()
]);
setUsers(usersResponse.data);
setStatus(statusResponse.data);
} catch (error) {
console.error('Failed to fetch files data:', error);
} finally {
setIsLoading(false);
}
};
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">
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File Manager */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Web file manager</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`http://${FILES_HOST}`} />
<InfoRow label="Direct IP" value={`http://${FILES_IP}`} />
<InfoRow label="Port" value="80" />
</div>
<p className="text-xs text-gray-400 mt-3">
Browser-based file manager. Requires VPN.
</p>
</div>
{/* WebDAV */}
<div className="card">
<div className="flex items-center mb-4">
<FolderOpen className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">WebDAV (mount as drive)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`http://${WEBDAV_HOST}`} />
<InfoRow label="Direct IP" value={`http://${WEBDAV_IP}`} />
<InfoRow label="Port" value="80" />
<InfoRow label="Auth" value="Basic (user / password)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Mount in macOS Finder, Windows Explorer, or any WebDAV client.
</p>
</div>
{/* OS quick guide */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Mount as network drive</h3>
</div>
<div className="space-y-3 text-sm">
<div>
<p className="font-medium text-gray-900 mb-1">macOS (Finder)</p>
<p className="text-xs text-gray-600">Go Connect to Server <span className="font-mono">http://webdav.cell</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Windows</p>
<p className="text-xs text-gray-600">Map Network Drive <span className="font-mono">\\webdav.cell\DavWWWRoot</span> or use <span className="font-mono">http://webdav.cell</span> in "Connect to a Web Site"</p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Files app)</p>
<p className="text-xs text-gray-600">Files ... Connect to Server <span className="font-mono">http://webdav.cell</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android</p>
<p className="text-xs text-gray-600">Use <strong>Solid Explorer</strong> or <strong>FX File Explorer</strong> Add cloud WebDAV</p>
</div>
</div>
</div>
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">FileGator:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">WebDAV:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
{users.length > 0 && (
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Storage Users</h3>
</div>
<div className="space-y-2">
{users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.storage_used || '0'} MB</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
export default Files;
+26 -26
View File
@@ -16,7 +16,6 @@ const SERVICES = [
const emptyForm = () => ({
name: '',
ip: '',
description: '',
public_key: '',
persistent_keepalive: 25,
@@ -78,14 +77,20 @@ function Peers() {
const fetchPeers = async () => {
try {
const [regResp, wgResp] = await Promise.all([peerAPI.getPeers(), wireguardAPI.getPeers()]);
const [regResp, statusResp] = await Promise.all([
peerAPI.getPeers(),
wireguardAPI.getPeerStatuses().catch(() => ({ data: {} })),
]);
const regPeers = regResp.data || [];
const wgMap = {};
(wgResp.data || []).forEach(p => { wgMap[p.name] = p; });
const statusMap = statusResp.data || {};
const merged = regPeers.map(p => ({
...p,
name: p.peer || p.name,
wg: wgMap[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);
} catch (err) {
@@ -135,8 +140,6 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
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';
if (!data.ip.trim()) errs.ip = 'IP address is required';
if (!/^\d{1,3}(\.\d{1,3}){3}(\/\d+)?$/.test(data.ip.trim())) errs.ip = 'Invalid IP address';
return errs;
};
@@ -167,11 +170,14 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
service_access: formData.service_access,
peer_access: formData.peer_access,
};
await peerAPI.addPeer(peerData);
const addResult = await peerAPI.addPeer(peerData);
const assignedIp = addResult.data?.ip;
// Server-side AllowedIPs = peer's VPN IP only (/32).
// Full/split tunnel is a CLIENT-side setting (AllowedIPs in the client config).
await wireguardAPI.addPeer({
name: formData.name,
public_key: publicKey,
allowed_ips: formData.internet_access ? FULL_TUNNEL_IPS : SPLIT_TUNNEL_IPS,
allowed_ips: assignedIp ? `${assignedIp}/32` : `${peerData.ip}/32`,
persistent_keepalive: formData.persistent_keepalive,
});
@@ -201,7 +207,6 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip: formData.ip,
description: formData.description,
internet_access: formData.internet_access,
service_access: formData.service_access,
@@ -449,7 +454,12 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
</div>
</td>
<td className="px-6 py-4">
<span className="status-indicator status-online">Online</span>
<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">
@@ -493,18 +503,11 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
{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">VPN IP *</label>
<input value={formData.ip}
onChange={e => { setFormData(f => ({ ...f, ip: e.target.value })); setErrors(e2 => ({ ...e2, ip: undefined })); }}
className={`input ${errors.ip ? 'border-red-500' : ''}`} placeholder="10.0.0.3" />
{errors.ip && <p className="text-xs text-red-600 mt-1">{errors.ip}</p>}
<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>
<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>
<AccessForm data={formData} onChange={updates => setFormData(f => ({ ...f, ...updates }))} />
@@ -587,11 +590,8 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
<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={formData.ip}
onChange={e => { setFormData(f => ({ ...f, ip: e.target.value })); setErrors(e2 => ({ ...e2, ip: undefined })); }}
className={`input ${errors.ip ? 'border-red-500' : ''}`} />
{errors.ip && <p className="text-xs text-red-600 mt-1">{errors.ip}</p>}
<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>
+1
View File
@@ -63,6 +63,7 @@ export const wireguardAPI = {
testConnectivity: (data) => api.post('/api/wireguard/connectivity', data),
updatePeerIP: (data) => api.put('/api/wireguard/peers/ip', data),
getPeerConfig: (data) => api.post('/api/wireguard/peers/config', data),
getPeerStatuses: () => api.get('/api/wireguard/peers/statuses'),
};
// Peer Registry API