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:
+180
-98
@@ -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 & 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 & 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 & 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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user