fix: WireGuard routing, DNS, service access, and UI improvements
- Fix CoreDNS not loading .cell zones (wrong Corefile path, now uses -conf flag) - Fix WireGuard server address conflict (172.20.0.1/16 overlapped with Docker network; changed to 10.0.0.1/24 to eliminate duplicate routes) - Add SERVERMODE=true and sysctls to WireGuard docker-compose for server mode - Fix DNS zone file parser to handle 4-field records (name IN type value) - Add get_dns_records() to NetworkManager; mount data/dns into API container - Fix peer config endpoint: look up IP/key from registry, use real endpoint - Add bulk peer statuses endpoint keyed by public_key - Normalize snake_case API fields to camelCase in WireGuard UI - Add port check endpoint (checks via live handshake, not unreliable TCP probe) - Add Caddy virtual hosts for ui/calendar/files/mail .cell domains (HTTP only) - Fix cell config domain default from cell.local to cell - Fix Routing Network Config tab (was calling hardcoded localhost:3000) - Fix DNS records display (record.value not record.ip) - Move service access guide to top of Dashboard with login hints - Add /api/routing/setup endpoint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What This Project Is
|
||||||
|
|
||||||
|
**Personal Internet Cell (PIC)** — a self-hosted digital infrastructure platform. It manages DNS, DHCP, NTP, WireGuard VPN, email, calendar/contacts (CalDAV), file storage (WebDAV), reverse proxy (Caddy), a certificate authority, and container orchestration, all from a single API + React UI.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full stack
|
||||||
|
make start # docker-compose up -d
|
||||||
|
make stop # docker-compose down
|
||||||
|
make restart # docker-compose restart
|
||||||
|
make status # docker status + API health
|
||||||
|
make logs # docker-compose logs -f
|
||||||
|
make build # rebuild api image
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
make test # pytest tests/ api/tests/
|
||||||
|
make test-coverage # pytest with coverage HTML report
|
||||||
|
make test-api # pytest tests/test_api_endpoints.py
|
||||||
|
pytest tests/test_<module>.py # single test file
|
||||||
|
|
||||||
|
# Local dev (no Docker)
|
||||||
|
pip install -r api/requirements.txt
|
||||||
|
python api/app.py # Flask API on :3000
|
||||||
|
|
||||||
|
cd webui && npm install && npm run dev # React UI on :5173 (proxies API to :3000)
|
||||||
|
|
||||||
|
# WireGuard
|
||||||
|
make show-routes
|
||||||
|
make add-peer PEER_NAME=foo PEER_IP=10.0.0.5 PEER_KEY=<pubkey>
|
||||||
|
make list-peers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend (`api/`)
|
||||||
|
|
||||||
|
All service managers inherit `BaseServiceManager` (`api/base_service_manager.py`). This enforces a consistent interface: `get_status()`, `get_config()`, `update_config()`, `validate_config()`, `test_connectivity()`, `get_logs()`, `restart_service()`. When adding or modifying a service manager, follow this pattern.
|
||||||
|
|
||||||
|
The `ServiceBus` (`api/service_bus.py`) is a pub/sub event system used for inter-service communication. Services publish events (e.g., `SERVICE_STARTED`, `CONFIG_CHANGED`, `PEER_CONNECTED`) and subscribe to events from dependencies. Dependency graph is declared in the bus — e.g., `wireguard` depends on `network`; `email` depends on `network` and `vault`.
|
||||||
|
|
||||||
|
`ConfigManager` (`api/config_manager.py`) is the single source of truth. Config lives in `/app/config/cell_config.json` (mapped from `config/api/`). All managers read/write through ConfigManager, which validates against per-service schemas and maintains automatic backups.
|
||||||
|
|
||||||
|
`LogManager` (`api/log_manager.py`) provides structured JSON logging with rotation (5 MB / 5 backups per service). Use it instead of `print()` or raw `logging`.
|
||||||
|
|
||||||
|
`app.py` (2000+ lines) contains all Flask REST endpoints, organized by service. It runs a background health-monitoring thread.
|
||||||
|
|
||||||
|
Service managers:
|
||||||
|
- `network_manager.py` — DNS (CoreDNS), DHCP (dnsmasq), NTP (chrony)
|
||||||
|
- `wireguard_manager.py` — VPN peer lifecycle, QR codes
|
||||||
|
- `peer_registry.py` — peer registration/lookup
|
||||||
|
- `routing_manager.py` — NAT, firewall rules, VPN gateway
|
||||||
|
- `vault_manager.py` — internal certificate authority
|
||||||
|
- `email_manager.py` — Postfix + Dovecot
|
||||||
|
- `calendar_manager.py` — Radicale CalDAV/CardDAV
|
||||||
|
- `file_manager.py` — WebDAV storage
|
||||||
|
- `container_manager.py` — Docker SDK wrappers
|
||||||
|
- `cell_manager.py` — top-level orchestration
|
||||||
|
|
||||||
|
### Frontend (`webui/`)
|
||||||
|
|
||||||
|
React 18 + Vite + Tailwind CSS. All API calls go through `src/services/api.js` (Axios). Vite dev server proxies `/api` to `localhost:3000`. Pages in `src/pages/`, shared components in `src/components/`.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
`docker-compose.yml` defines 13 services on a custom bridge network `cell-network` (172.20.0.0/16). Cell IPs default to 10.0.0.0/24. Key ports: 53 (DNS), 80/443 (Caddy), 3000 (API), 5173/8081 (WebUI), 51820/udp (WireGuard), 25/587/993 (mail), 5232 (CalDAV), 8080 (WebDAV).
|
||||||
|
|
||||||
|
Config files for each service live under `config/<service>/`. Persistent data is under `data/` (git-ignored). WireGuard configs are also git-ignored.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live in `tests/` (28 files). Use mocking (`pytest-mock`) for external system calls. Integration tests in `test_integration.py` require Docker services running.
|
||||||
+36
-12
@@ -358,8 +358,8 @@ def get_config():
|
|||||||
try:
|
try:
|
||||||
service_configs = config_manager.get_all_configs()
|
service_configs = config_manager.get_all_configs()
|
||||||
config = {
|
config = {
|
||||||
'cell_name': os.environ.get('CELL_NAME', 'personal-internet-cell'),
|
'cell_name': os.environ.get('CELL_NAME', 'mycell'),
|
||||||
'domain': os.environ.get('CELL_DOMAIN', 'cell.local'),
|
'domain': os.environ.get('CELL_DOMAIN', 'cell'),
|
||||||
'ip_range': os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'),
|
'ip_range': os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'),
|
||||||
'wireguard_port': int(os.environ.get('WG_PORT', '51820')),
|
'wireguard_port': int(os.environ.get('WG_PORT', '51820')),
|
||||||
}
|
}
|
||||||
@@ -836,19 +836,28 @@ def update_peer_ip():
|
|||||||
|
|
||||||
@app.route('/api/wireguard/peers/status', methods=['POST'])
|
@app.route('/api/wireguard/peers/status', methods=['POST'])
|
||||||
def get_peer_status():
|
def get_peer_status():
|
||||||
"""Get WireGuard peer status."""
|
"""Get live WireGuard status for a single peer."""
|
||||||
try:
|
try:
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True) or {}
|
||||||
if data is None or 'public_key' not in data:
|
public_key = data.get('public_key', '')
|
||||||
return jsonify({"error": "Missing public key"}), 400
|
if not public_key:
|
||||||
|
return jsonify({"error": "Missing public_key"}), 400
|
||||||
public_key = data['public_key']
|
|
||||||
status = wireguard_manager.get_peer_status(public_key)
|
status = wireguard_manager.get_peer_status(public_key)
|
||||||
return jsonify(status)
|
return jsonify(status)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting peer status: {e}")
|
logger.error(f"Error getting peer status: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/wireguard/peers/statuses', methods=['GET'])
|
||||||
|
def get_all_peer_statuses():
|
||||||
|
"""Get live WireGuard status for all peers (keyed by public_key)."""
|
||||||
|
try:
|
||||||
|
statuses = wireguard_manager.get_all_peer_statuses()
|
||||||
|
return jsonify(statuses)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting peer statuses: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/wireguard/network/setup', methods=['POST'])
|
@app.route('/api/wireguard/network/setup', methods=['POST'])
|
||||||
def setup_network():
|
def setup_network():
|
||||||
"""Setup network configuration for internet access."""
|
"""Setup network configuration for internet access."""
|
||||||
@@ -917,17 +926,23 @@ def get_server_config():
|
|||||||
def refresh_external_ip():
|
def refresh_external_ip():
|
||||||
try:
|
try:
|
||||||
ip = wireguard_manager.get_external_ip(force_refresh=True)
|
ip = wireguard_manager.get_external_ip(force_refresh=True)
|
||||||
port_open = wireguard_manager.check_port_open()
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'external_ip': ip,
|
'external_ip': ip,
|
||||||
'port': wireguard_manager.DEFAULT_PORT if hasattr(wireguard_manager, 'DEFAULT_PORT') else 51820,
|
'port': 51820,
|
||||||
'port_open': port_open,
|
'endpoint': f'{ip}:51820' if ip else None,
|
||||||
'endpoint': f'{ip}:{51820}' if ip else None,
|
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error refreshing external IP: {e}")
|
logger.error(f"Error refreshing external IP: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/wireguard/check-port', methods=['POST'])
|
||||||
|
def check_wireguard_port():
|
||||||
|
try:
|
||||||
|
port_open = wireguard_manager.check_port_open()
|
||||||
|
return jsonify({'port_open': port_open, 'port': 51820})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
# Peer Registry API
|
# Peer Registry API
|
||||||
@app.route('/api/peers', methods=['GET'])
|
@app.route('/api/peers', methods=['GET'])
|
||||||
def get_peers():
|
def get_peers():
|
||||||
@@ -1369,6 +1384,15 @@ def get_routing_status():
|
|||||||
logger.error(f"Error getting routing status: {e}")
|
logger.error(f"Error getting routing status: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/routing/setup', methods=['POST'])
|
||||||
|
def setup_routing():
|
||||||
|
"""Apply/verify routing setup (WireGuard handles NAT via PostUp rules)."""
|
||||||
|
try:
|
||||||
|
status = routing_manager.get_status()
|
||||||
|
return jsonify({'success': True, 'message': 'Routing managed by WireGuard PostUp rules', **status})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/routing/nat', methods=['POST'])
|
@app.route('/api/routing/nat', methods=['POST'])
|
||||||
def add_nat_rule():
|
def add_nat_rule():
|
||||||
"""Add NAT rule.
|
"""Add NAT rule.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+25
-6
@@ -118,6 +118,20 @@ class NetworkManager(BaseServiceManager):
|
|||||||
logger.error(f"Failed to remove DNS record: {e}")
|
logger.error(f"Failed to remove DNS record: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_dns_records(self, zone: str = 'cell') -> List[Dict]:
|
||||||
|
"""Get all DNS records across all zones"""
|
||||||
|
all_records = []
|
||||||
|
try:
|
||||||
|
for fname in os.listdir(self.dns_zones_dir):
|
||||||
|
if fname.endswith('.zone'):
|
||||||
|
z = fname[:-5]
|
||||||
|
for rec in self._load_dns_records(z):
|
||||||
|
rec['zone'] = z
|
||||||
|
all_records.append(rec)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list DNS records: {e}")
|
||||||
|
return all_records
|
||||||
|
|
||||||
def _load_dns_records(self, zone: str) -> List[Dict]:
|
def _load_dns_records(self, zone: str) -> List[Dict]:
|
||||||
"""Load DNS records from zone file"""
|
"""Load DNS records from zone file"""
|
||||||
zone_file = os.path.join(self.dns_zones_dir, f'{zone}.zone')
|
zone_file = os.path.join(self.dns_zones_dir, f'{zone}.zone')
|
||||||
@@ -131,12 +145,17 @@ class NetworkManager(BaseServiceManager):
|
|||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
line = line.strip()
|
line = line.strip().split(';')[0].strip() # strip inline comments
|
||||||
if line and not line.startswith(';') and not line.startswith('$'):
|
if not line or line.startswith('$'):
|
||||||
parts = line.split()
|
continue
|
||||||
if len(parts) >= 5:
|
parts = line.split()
|
||||||
record_type = parts[3]
|
# Support both: name IN type value (4 parts)
|
||||||
if record_type in ('A', 'CNAME'):
|
# and name TTL IN type value (5 parts)
|
||||||
|
if len(parts) == 4 and parts[1] in ('IN',) and parts[2] in ('A', 'CNAME', 'MX', 'TXT'):
|
||||||
|
records.append({'name': parts[0], 'ttl': '300', 'type': parts[2], 'value': parts[3]})
|
||||||
|
elif len(parts) >= 5:
|
||||||
|
record_type = parts[3]
|
||||||
|
if record_type in ('A', 'CNAME'):
|
||||||
records.append({
|
records.append({
|
||||||
'name': parts[0],
|
'name': parts[0],
|
||||||
'ttl': parts[1],
|
'ttl': parts[1],
|
||||||
|
|||||||
+94
-24
@@ -24,9 +24,17 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
SERVER_ADDRESS = '172.20.0.1/16'
|
SERVER_ADDRESS = '172.20.0.1/16'
|
||||||
SERVER_NETWORK = '172.20.0.0/16'
|
SERVER_NETWORK = '172.20.0.0/16'
|
||||||
PEER_DNS = '172.20.0.2'
|
|
||||||
DEFAULT_PORT = 51820
|
DEFAULT_PORT = 51820
|
||||||
|
|
||||||
|
def _resolve_peer_dns() -> str:
|
||||||
|
"""Resolve cell-dns container IP at runtime; fall back to known default."""
|
||||||
|
for hostname in ('cell-dns',):
|
||||||
|
try:
|
||||||
|
return socket.gethostbyname(hostname)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return '172.20.0.2'
|
||||||
|
|
||||||
|
|
||||||
class WireGuardManager(BaseServiceManager):
|
class WireGuardManager(BaseServiceManager):
|
||||||
"""Manages WireGuard VPN configuration and peers"""
|
"""Manages WireGuard VPN configuration and peers"""
|
||||||
@@ -216,19 +224,23 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
|
|
||||||
def get_peer_config(self, peer_name: str, peer_ip: str,
|
def get_peer_config(self, peer_name: str, peer_ip: str,
|
||||||
peer_private_key: str,
|
peer_private_key: str,
|
||||||
server_endpoint: str = '<SERVER_IP>') -> str:
|
server_endpoint: str = '<SERVER_IP>',
|
||||||
"""Generate a WireGuard client config string."""
|
allowed_ips: str = '0.0.0.0/0, ::/0') -> str:
|
||||||
|
"""Generate a WireGuard client config string (full-tunnel by default)."""
|
||||||
server_keys = self.get_keys()
|
server_keys = self.get_keys()
|
||||||
|
peer_dns = _resolve_peer_dns()
|
||||||
|
endpoint = server_endpoint if ':' in server_endpoint else f'{server_endpoint}:{DEFAULT_PORT}'
|
||||||
|
addr = peer_ip if '/' in peer_ip else f'{peer_ip}/32'
|
||||||
return (
|
return (
|
||||||
f'[Interface]\n'
|
f'[Interface]\n'
|
||||||
f'PrivateKey = {peer_private_key}\n'
|
f'PrivateKey = {peer_private_key}\n'
|
||||||
f'Address = {peer_ip if "/" in peer_ip else f"{peer_ip}/32"}\n'
|
f'Address = {addr}\n'
|
||||||
f'DNS = {PEER_DNS}\n'
|
f'DNS = {peer_dns}\n'
|
||||||
f'\n'
|
f'\n'
|
||||||
f'[Peer]\n'
|
f'[Peer]\n'
|
||||||
f'PublicKey = {server_keys["public_key"]}\n'
|
f'PublicKey = {server_keys["public_key"]}\n'
|
||||||
f'AllowedIPs = {SERVER_NETWORK}\n'
|
f'AllowedIPs = {allowed_ips}\n'
|
||||||
f'Endpoint = {server_endpoint if ":" in server_endpoint else f"{server_endpoint}:{DEFAULT_PORT}"}\n'
|
f'Endpoint = {endpoint}\n'
|
||||||
f'PersistentKeepalive = 25\n'
|
f'PersistentKeepalive = 25\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -277,27 +289,31 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
def check_port_open(self, port: int = DEFAULT_PORT) -> bool:
|
def check_port_open(self, port: int = DEFAULT_PORT) -> bool:
|
||||||
"""Check if the WireGuard UDP port is reachable from outside."""
|
"""Check if the WireGuard UDP port is reachable from outside."""
|
||||||
external_ip = self.get_external_ip()
|
external_ip = self.get_external_ip()
|
||||||
if not external_ip or _requests is None:
|
if not external_ip:
|
||||||
return False
|
return False
|
||||||
# Use an external UDP port-check service
|
# Check via WireGuard itself: if any peer has a recent handshake the port is open
|
||||||
try:
|
try:
|
||||||
resp = _requests.get(
|
statuses = self.get_all_peer_statuses()
|
||||||
f'https://portchecker.co/api/v1/query',
|
for st in statuses.values():
|
||||||
params={'host': external_ip, 'port': port},
|
if st.get('online'):
|
||||||
timeout=8,
|
return True
|
||||||
)
|
|
||||||
if resp.ok:
|
|
||||||
data = resp.json()
|
|
||||||
return bool(data.get('isOpen') or data.get('open'))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Fallback: try TCP (won't work for UDP WireGuard, but gives a network clue)
|
# Try UDP port check APIs that support UDP
|
||||||
try:
|
if _requests is not None:
|
||||||
sock = socket.create_connection((external_ip, port), timeout=3)
|
for url, params in [
|
||||||
sock.close()
|
('https://portchecker.io/api/query', {'host': external_ip, 'port': port, 'type': 'udp'}),
|
||||||
return True
|
('https://api.ipquery.io/portcheck', {'ip': external_ip, 'port': port, 'protocol': 'udp'}),
|
||||||
except Exception:
|
]:
|
||||||
return False
|
try:
|
||||||
|
resp = _requests.get(url, params=params, timeout=6)
|
||||||
|
if resp.ok:
|
||||||
|
d = resp.json()
|
||||||
|
if d.get('open') or d.get('isOpen') or d.get('status') == 'open':
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
def get_server_config(self) -> Dict[str, Any]:
|
def get_server_config(self) -> Dict[str, Any]:
|
||||||
"""Return server public key, external IP, endpoint, and port status."""
|
"""Return server public key, external IP, endpoint, and port status."""
|
||||||
@@ -309,8 +325,62 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
'external_ip': external_ip,
|
'external_ip': external_ip,
|
||||||
'endpoint': endpoint,
|
'endpoint': endpoint,
|
||||||
'port': DEFAULT_PORT,
|
'port': DEFAULT_PORT,
|
||||||
|
'port_open': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_peer_status(self, public_key: str) -> Dict[str, Any]:
|
||||||
|
"""Return live handshake + transfer stats for a peer from `wg show`."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['docker', 'exec', 'cell-wireguard', 'wg', 'show', 'wg0', 'dump'],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split('\t')
|
||||||
|
# peer lines: pubkey psk endpoint allowed_ips handshake rx tx keepalive
|
||||||
|
if len(parts) >= 8 and parts[0] == public_key:
|
||||||
|
handshake_ts = int(parts[4]) if parts[4].isdigit() else 0
|
||||||
|
now = int(time.time())
|
||||||
|
age = now - handshake_ts if handshake_ts else None
|
||||||
|
return {
|
||||||
|
'online': age is not None and age < 90,
|
||||||
|
'last_handshake': datetime.utcfromtimestamp(handshake_ts).isoformat() if handshake_ts else None,
|
||||||
|
'last_handshake_seconds_ago': age,
|
||||||
|
'endpoint': parts[2] if parts[2] != '(none)' else None,
|
||||||
|
'transfer_rx': int(parts[5]) if parts[5].isdigit() else 0,
|
||||||
|
'transfer_tx': int(parts[6]) if parts[6].isdigit() else 0,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f'get_peer_status failed: {e}')
|
||||||
|
return {'online': None, 'last_handshake': None, 'transfer_rx': 0, 'transfer_tx': 0}
|
||||||
|
|
||||||
|
def get_all_peer_statuses(self) -> Dict[str, Any]:
|
||||||
|
"""Return {public_key: status_dict} for all known peers from wg show."""
|
||||||
|
statuses: Dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['docker', 'exec', 'cell-wireguard', 'wg', 'show', 'wg0', 'dump'],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
now = int(time.time())
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split('\t')
|
||||||
|
if len(parts) >= 8:
|
||||||
|
pub = parts[0]
|
||||||
|
handshake_ts = int(parts[4]) if parts[4].isdigit() else 0
|
||||||
|
age = now - handshake_ts if handshake_ts else None
|
||||||
|
statuses[pub] = {
|
||||||
|
'online': age is not None and age < 90,
|
||||||
|
'last_handshake': datetime.utcfromtimestamp(handshake_ts).isoformat() if handshake_ts else None,
|
||||||
|
'last_handshake_seconds_ago': age,
|
||||||
|
'endpoint': parts[2] if parts[2] != '(none)' else None,
|
||||||
|
'transfer_rx': int(parts[5]) if parts[5].isdigit() else 0,
|
||||||
|
'transfer_tx': int(parts[6]) if parts[6].isdigit() else 0,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f'get_all_peer_statuses failed: {e}')
|
||||||
|
return statuses
|
||||||
|
|
||||||
# ── Status & connectivity ─────────────────────────────────────────────────
|
# ── Status & connectivity ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> Dict[str, Any]:
|
||||||
|
|||||||
+30
-73
@@ -1,92 +1,49 @@
|
|||||||
# Personal Internet Cell - Caddy Configuration
|
|
||||||
# This serves as the main reverse proxy and TLS termination point
|
|
||||||
|
|
||||||
# Global settings
|
|
||||||
{
|
{
|
||||||
# Auto-generate certificates for .cell domains
|
auto_https off
|
||||||
auto_https disable_redirects
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main cell domain - replace 'mycell' with your cell name
|
# Main cell domain
|
||||||
mycell.cell {
|
http://mycell.cell {
|
||||||
# TLS with internal CA
|
|
||||||
tls internal
|
|
||||||
|
|
||||||
# API endpoints
|
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy cell-api:3000
|
reverse_proxy cell-api:3000
|
||||||
}
|
}
|
||||||
|
handle /calendar* {
|
||||||
# Web UI
|
|
||||||
handle / {
|
|
||||||
reverse_proxy cell-webui:80
|
|
||||||
}
|
|
||||||
|
|
||||||
# Email web interface
|
|
||||||
handle /mail {
|
|
||||||
reverse_proxy cell-mail:80
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calendar and contacts
|
|
||||||
handle /calendar {
|
|
||||||
reverse_proxy cell-radicale:5232
|
reverse_proxy cell-radicale:5232
|
||||||
}
|
}
|
||||||
|
handle /files* {
|
||||||
# File storage
|
|
||||||
handle /files {
|
|
||||||
reverse_proxy cell-webdav:80
|
|
||||||
}
|
|
||||||
|
|
||||||
# DNS management interface
|
|
||||||
handle /dns {
|
|
||||||
reverse_proxy cell-dns:8080
|
|
||||||
}
|
|
||||||
|
|
||||||
# RainLoop Webmail
|
|
||||||
handle_path /webmail/* {
|
|
||||||
reverse_proxy cell-rainloop:8888
|
|
||||||
}
|
|
||||||
|
|
||||||
# FileGator File Browser
|
|
||||||
handle /files-ui* {
|
|
||||||
reverse_proxy cell-filegator:8080
|
reverse_proxy cell-filegator:8080
|
||||||
}
|
}
|
||||||
|
handle /webmail* {
|
||||||
|
reverse_proxy cell-rainloop:8888
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy cell-webui:80
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Peer cell domains (will be dynamically added)
|
# Service aliases
|
||||||
# Example: bob.cell {
|
http://ui.cell {
|
||||||
# reverse_proxy cell-wireguard:51820
|
reverse_proxy cell-webui:80
|
||||||
# }
|
}
|
||||||
|
|
||||||
# Local development
|
http://calendar.cell {
|
||||||
localhost {
|
reverse_proxy cell-radicale:5232
|
||||||
# API endpoints
|
}
|
||||||
|
|
||||||
|
http://files.cell {
|
||||||
|
reverse_proxy cell-filegator:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
http://mail.cell {
|
||||||
|
reverse_proxy cell-rainloop:8888
|
||||||
|
}
|
||||||
|
|
||||||
|
# Catch-all for direct IP and localhost access
|
||||||
|
:80 {
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy cell-api:3000
|
reverse_proxy cell-api:3000
|
||||||
}
|
}
|
||||||
|
handle {
|
||||||
# Web UI
|
|
||||||
handle / {
|
|
||||||
reverse_proxy cell-webui:80
|
reverse_proxy cell-webui:80
|
||||||
}
|
}
|
||||||
|
|
||||||
# Email web interface
|
|
||||||
handle /mail {
|
|
||||||
reverse_proxy cell-mail:80
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calendar and contacts
|
|
||||||
handle /calendar {
|
|
||||||
reverse_proxy cell-radicale:5232
|
|
||||||
}
|
|
||||||
|
|
||||||
# File storage
|
|
||||||
handle /files {
|
|
||||||
reverse_proxy cell-webdav:80
|
|
||||||
}
|
|
||||||
|
|
||||||
# DNS management interface
|
|
||||||
handle /dns {
|
|
||||||
reverse_proxy cell-dns:8080
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -1,42 +1,16 @@
|
|||||||
# Personal Internet Cell - CoreDNS Configuration
|
|
||||||
# Handles .cell TLD resolution and peer discovery
|
|
||||||
|
|
||||||
. {
|
. {
|
||||||
# Forward all non-.cell domains to upstream DNS
|
|
||||||
forward . 8.8.8.8 1.1.1.1
|
forward . 8.8.8.8 1.1.1.1
|
||||||
|
|
||||||
# Cache responses
|
|
||||||
cache
|
cache
|
||||||
|
|
||||||
# Log queries
|
|
||||||
log
|
log
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
health
|
health
|
||||||
}
|
}
|
||||||
|
|
||||||
# .cell TLD zone
|
|
||||||
cell {
|
cell {
|
||||||
# File-based zone for static records
|
|
||||||
file /data/cell.zone
|
file /data/cell.zone
|
||||||
|
|
||||||
# Dynamic peer records (will be managed by API)
|
|
||||||
reload
|
|
||||||
|
|
||||||
# Allow zone transfers
|
|
||||||
transfer {
|
|
||||||
to *
|
|
||||||
}
|
|
||||||
|
|
||||||
# Log queries
|
|
||||||
log
|
log
|
||||||
}
|
}
|
||||||
|
|
||||||
# Local network zone
|
|
||||||
local.cell {
|
local.cell {
|
||||||
# File-based zone for local services
|
|
||||||
file /data/local.zone
|
file /data/local.zone
|
||||||
|
|
||||||
# Log queries
|
|
||||||
log
|
log
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
OVERRIDE_HOSTNAME=mail.cell.local
|
||||||
|
POSTMASTER_ADDRESS=admin@cell.local
|
||||||
|
LOG_LEVEL=warn
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
dns:
|
dns:
|
||||||
image: coredns/coredns:latest
|
image: coredns/coredns:latest
|
||||||
container_name: cell-dns
|
container_name: cell-dns
|
||||||
|
command: ["-conf", "/etc/coredns/Corefile"]
|
||||||
ports:
|
ports:
|
||||||
- "53:53/udp"
|
- "53:53/udp"
|
||||||
- "53:53/tcp"
|
- "53:53/tcp"
|
||||||
@@ -112,6 +113,10 @@ services:
|
|||||||
wireguard:
|
wireguard:
|
||||||
image: linuxserver/wireguard:latest
|
image: linuxserver/wireguard:latest
|
||||||
container_name: cell-wireguard
|
container_name: cell-wireguard
|
||||||
|
environment:
|
||||||
|
- SERVERMODE=true
|
||||||
|
- PUID=911
|
||||||
|
- PGID=911
|
||||||
ports:
|
ports:
|
||||||
- "51820:51820/udp"
|
- "51820:51820/udp"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -123,6 +128,9 @@ services:
|
|||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.conf.all.src_valid_mark=1
|
||||||
|
- net.ipv4.ip_forward=1
|
||||||
|
|
||||||
# CLI API Server
|
# CLI API Server
|
||||||
api:
|
api:
|
||||||
@@ -132,6 +140,7 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/api:/app/data
|
- ./data/api:/app/data
|
||||||
|
- ./data/dns:/app/data/dns
|
||||||
- ./config/api:/app/config
|
- ./config/api:/app/config
|
||||||
- ./config/wireguard:/app/config/wireguard
|
- ./config/wireguard:/app/config/wireguard
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cellAPI, servicesAPI } from '../services/api';
|
import { cellAPI, servicesAPI } from '../services/api';
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
{ name: 'Cell Home', url: 'http://mycell.cell', desc: 'Main UI — no login needed' },
|
||||||
|
{ name: 'Calendar', url: 'http://calendar.cell', desc: 'Login: your WireGuard username' },
|
||||||
|
{ name: 'Files', url: 'http://files.cell', desc: 'Login: admin / admin123' },
|
||||||
|
{ name: 'Webmail', url: 'http://mail.cell', desc: 'Login: admin@rainloop.net / 12345' },
|
||||||
|
];
|
||||||
|
|
||||||
function Dashboard({ isOnline }) {
|
function Dashboard({ isOnline }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [cellStatus, setCellStatus] = useState(null);
|
const [cellStatus, setCellStatus] = useState(null);
|
||||||
@@ -203,11 +210,29 @@ function Dashboard({ isOnline }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-1 text-gray-600">Personal Internet Cell — connect via WireGuard to access services</p>
|
||||||
Overview of your Personal Internet Cell status and services
|
</div>
|
||||||
</p>
|
|
||||||
|
{/* Access Services — shown first, no scroll needed */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Services (connect via WireGuard first)</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{SERVICES.map(svc => (
|
||||||
|
<a
|
||||||
|
key={svc.url}
|
||||||
|
href={svc.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="card hover:shadow-md transition-shadow group border border-gray-100"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold text-primary-700 group-hover:text-primary-900">{svc.name}</p>
|
||||||
|
<p className="font-mono text-xs text-gray-400 mt-0.5 truncate">{svc.url}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{svc.desc}</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cell Status */}
|
{/* Cell Status */}
|
||||||
|
|||||||
@@ -58,8 +58,11 @@ function NetworkServices() {
|
|||||||
{dnsRecords.length > 0 ? (
|
{dnsRecords.length > 0 ? (
|
||||||
dnsRecords.map((record, index) => (
|
dnsRecords.map((record, index) => (
|
||||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||||
<span className="text-sm font-medium">{record.name}</span>
|
<div>
|
||||||
<span className="text-sm text-gray-500">{record.ip}</span>
|
<span className="text-sm font-medium">{record.name}</span>
|
||||||
|
<span className="text-xs text-gray-400 ml-1">.{record.zone}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-gray-600">{record.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+33
-109
@@ -95,7 +95,7 @@ function Routing() {
|
|||||||
setNetworkLoading(true);
|
setNetworkLoading(true);
|
||||||
setNetworkError(null);
|
setNetworkError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:3000/api/wireguard/network/status');
|
const response = await fetch('/api/routing/status');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setNetworkStatus(data);
|
setNetworkStatus(data);
|
||||||
@@ -114,11 +114,9 @@ function Routing() {
|
|||||||
setIsSettingUp(true);
|
setIsSettingUp(true);
|
||||||
setNetworkError(null);
|
setNetworkError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:3000/api/wireguard/network/setup', {
|
const response = await fetch('/api/routing/setup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -404,125 +402,51 @@ function Routing() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{networkError && (
|
|
||||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<p className="text-red-800">{networkError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{networkLoading ? (
|
{networkLoading ? (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
</div>
|
</div>
|
||||||
) : networkStatus ? (
|
) : networkStatus ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{/* Network Status Cards */}
|
{/* Status cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
{[
|
||||||
<div className="flex items-center">
|
{ label: 'Routing', value: networkStatus.status === 'online' ? 'Online' : 'Offline', ok: networkStatus.running },
|
||||||
<div className={`w-3 h-3 rounded-full mr-3 ${networkStatus.ip_forwarding ? 'bg-green-400' : 'bg-red-400'}`}></div>
|
{ label: 'NAT Rules', value: networkStatus.nat_rules_count ?? 0, ok: true },
|
||||||
<div>
|
{ label: 'Firewall Rules', value: networkStatus.firewall_rules_count ?? 0, ok: true },
|
||||||
<p className="text-sm font-medium text-gray-900">IP Forwarding</p>
|
{ label: 'Peer Routes', value: networkStatus.peer_routes_count ?? 0, ok: true },
|
||||||
<p className="text-xs text-gray-500">{networkStatus.ip_forwarding ? 'Enabled' : 'Disabled'}</p>
|
].map(item => (
|
||||||
</div>
|
<div key={item.label} className="bg-white p-4 rounded-lg border border-gray-200">
|
||||||
|
<p className="text-xs text-gray-500">{item.label}</p>
|
||||||
|
<p className={`text-lg font-semibold mt-1 ${item.ok ? 'text-gray-900' : 'text-red-600'}`}>{item.value}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={`w-3 h-3 rounded-full mr-3 ${networkStatus.interface_status ? 'bg-green-400' : 'bg-red-400'}`}></div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-900">WireGuard Interface</p>
|
|
||||||
<p className="text-xs text-gray-500">{networkStatus.interface_status ? 'Up' : 'Down'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={`w-3 h-3 rounded-full mr-3 ${networkStatus.nat_rules ? 'bg-green-400' : 'bg-red-400'}`}></div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-900">NAT Rules</p>
|
|
||||||
<p className="text-xs text-gray-500">{networkStatus.nat_rules ? 'Configured' : 'Missing'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={`w-3 h-3 rounded-full mr-3 ${networkStatus.forwarding_rules ? 'bg-green-400' : 'bg-red-400'}`}></div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-900">Forwarding Rules</p>
|
|
||||||
<p className="text-xs text-gray-500">{networkStatus.forwarding_rules ? 'Configured' : 'Missing'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Configuration Details */}
|
{/* Routing table */}
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
{networkStatus.routing_status?.routing_table?.length > 0 && (
|
||||||
<h4 className="text-md font-medium text-gray-900 mb-3">Configuration Details</h4>
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<div className="space-y-2 text-sm">
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Active Routes</h4>
|
||||||
<div className="flex justify-between">
|
<div className="space-y-1 font-mono text-xs text-gray-600">
|
||||||
<span className="text-gray-600">Last Updated:</span>
|
{networkStatus.routing_status.routing_table.map((r, i) => (
|
||||||
<span className="text-gray-900">{new Date(networkStatus.timestamp).toLocaleString()}</span>
|
<div key={i} className="flex gap-4">
|
||||||
</div>
|
<span className="text-gray-900 w-40 truncate">{r.parsed?.destination || r.route}</span>
|
||||||
<div className="flex justify-between">
|
<span className="text-gray-500">via {r.parsed?.dev || '—'}</span>
|
||||||
<span className="text-gray-600">IP Forwarding:</span>
|
{r.parsed?.via && <span className="text-gray-400">{r.parsed.via}</span>}
|
||||||
<span className={`font-medium ${networkStatus.ip_forwarding ? 'text-green-600' : 'text-red-600'}`}>
|
</div>
|
||||||
{networkStatus.ip_forwarding ? 'Enabled' : 'Disabled'}
|
))}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">WireGuard Interface:</span>
|
|
||||||
<span className={`font-medium ${networkStatus.interface_status ? 'text-green-600' : 'text-red-600'}`}>
|
|
||||||
{networkStatus.interface_status ? 'Up (wg0)' : 'Down'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">NAT Translation:</span>
|
|
||||||
<span className={`font-medium ${networkStatus.nat_rules ? 'text-green-600' : 'text-red-600'}`}>
|
|
||||||
{networkStatus.nat_rules ? 'Active' : 'Not Configured'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Traffic Forwarding:</span>
|
|
||||||
<span className={`font-medium ${networkStatus.forwarding_rules ? 'text-green-600' : 'text-red-600'}`}>
|
|
||||||
{networkStatus.forwarding_rules ? 'Allowed' : 'Blocked'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Quick Actions */}
|
<div className="flex gap-2">
|
||||||
<div className="bg-blue-50 p-4 rounded-lg">
|
<button className="btn btn-secondary text-sm" onClick={fetchNetworkStatus}>Refresh</button>
|
||||||
<h4 className="text-md font-medium text-blue-900 mb-2">Quick Actions</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline"
|
|
||||||
onClick={fetchNetworkStatus}
|
|
||||||
>
|
|
||||||
Refresh Status
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-primary"
|
|
||||||
onClick={setupNetworkConfiguration}
|
|
||||||
disabled={isSettingUp}
|
|
||||||
>
|
|
||||||
{isSettingUp ? 'Setting up...' : 'Setup Network'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-gray-500">Failed to load network status</p>
|
<p className="text-gray-500">Could not load network status</p>
|
||||||
<button
|
<button className="btn btn-primary mt-2" onClick={fetchNetworkStatus}>Retry</button>
|
||||||
className="btn btn-primary mt-2"
|
|
||||||
onClick={fetchNetworkStatus}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,9 +24,14 @@ function WireGuard() {
|
|||||||
const refreshExternalIp = async () => {
|
const refreshExternalIp = async () => {
|
||||||
setIsRefreshingIp(true);
|
setIsRefreshingIp(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/wireguard/refresh-ip', { method: 'POST' });
|
// Refresh IP first (fast)
|
||||||
const data = await response.json();
|
const ipResp = await fetch('/api/wireguard/refresh-ip', { method: 'POST' });
|
||||||
setServerConfig(prev => ({ ...prev, ...data }));
|
const ipData = await ipResp.json();
|
||||||
|
setServerConfig(prev => ({ ...prev, ...ipData, port_open: 'checking' }));
|
||||||
|
// Then check port (slow — external call)
|
||||||
|
const portResp = await fetch('/api/wireguard/check-port', { method: 'POST' });
|
||||||
|
const portData = await portResp.json();
|
||||||
|
setServerConfig(prev => ({ ...prev, port_open: portData.port_open }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to refresh IP:', e);
|
console.error('Failed to refresh IP:', e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,36 +76,36 @@ function WireGuard() {
|
|||||||
persistent_keepalive: peer.persistent_keepalive || wireguardMap[peer.peer || peer.name]?.PersistentKeepalive || 25
|
persistent_keepalive: peer.persistent_keepalive || wireguardMap[peer.peer || peer.name]?.PersistentKeepalive || 25
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Load peer statuses first
|
// Load all peer statuses in one call (keyed by public_key)
|
||||||
const statusPromises = mergedPeers.map(async (peer) => {
|
let liveStatuses = {};
|
||||||
if (peer.public_key) {
|
try {
|
||||||
const status = await getPeerStatus(peer);
|
const stResp = await fetch('/api/wireguard/peers/statuses');
|
||||||
return { peerId: peer.name, status };
|
if (stResp.ok) liveStatuses = await stResp.json();
|
||||||
}
|
} catch (_) {}
|
||||||
return { peerId: peer.name, status: { online: null, lastHandshake: null, transferRx: 0, transferTx: 0 } };
|
|
||||||
|
// Normalize snake_case API fields to camelCase for UI
|
||||||
|
const normalizeStatus = (st) => ({
|
||||||
|
online: st.online ?? null,
|
||||||
|
lastHandshake: st.last_handshake || st.lastHandshake || null,
|
||||||
|
lastHandshakeSecondsAgo: st.last_handshake_seconds_ago ?? null,
|
||||||
|
transferRx: st.transfer_rx ?? st.transferRx ?? 0,
|
||||||
|
transferTx: st.transfer_tx ?? st.transferTx ?? 0,
|
||||||
|
endpoint: st.endpoint || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusResults = await Promise.all(statusPromises);
|
// Build name→status map and annotate peers
|
||||||
const statusMap = {};
|
const statusMap = {};
|
||||||
statusResults.forEach(({ peerId, status }) => {
|
const annotated = mergedPeers.map(peer => {
|
||||||
statusMap[peerId] = status;
|
const raw = liveStatuses[peer.public_key] || { online: null };
|
||||||
|
const st = normalizeStatus(raw);
|
||||||
|
statusMap[peer.name] = st;
|
||||||
|
return { ...peer, _liveStatus: st };
|
||||||
});
|
});
|
||||||
setPeerStatuses(statusMap);
|
setPeerStatuses(statusMap);
|
||||||
|
setTotalPeers(annotated.length);
|
||||||
|
|
||||||
// Set total peers count
|
// Show all peers; live ones bubble up via status indicator
|
||||||
setTotalPeers(mergedPeers.length);
|
setPeers(annotated);
|
||||||
|
|
||||||
// Filter to only show live connected peers
|
|
||||||
const livePeers = mergedPeers.filter(peer => {
|
|
||||||
const peerStatus = statusMap[peer.name];
|
|
||||||
return peerStatus && (
|
|
||||||
peerStatus.online === true ||
|
|
||||||
(peerStatus.lastHandshake && peerStatus.lastHandshake !== null) ||
|
|
||||||
(peerStatus.transferRx > 0 || peerStatus.transferTx > 0)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
setPeers(livePeers);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch WireGuard data:', error);
|
console.error('Failed to fetch WireGuard data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -339,13 +344,13 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="p-2 bg-green-100 rounded-lg">
|
<div className={`p-2 rounded-lg ${peers.some(p => p._liveStatus?.online) ? 'bg-green-100' : 'bg-gray-100'}`}>
|
||||||
<Activity className="h-6 w-6 text-green-600" />
|
<Activity className={`h-6 w-6 ${peers.some(p => p._liveStatus?.online) ? 'text-green-600' : 'text-gray-400'}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-gray-500">Live Connections</p>
|
<p className="text-sm font-medium text-gray-500">Live Connections</p>
|
||||||
<p className="text-lg font-semibold text-gray-900">
|
<p className={`text-lg font-semibold ${peers.some(p => p._liveStatus?.online) ? 'text-green-600' : 'text-gray-900'}`}>
|
||||||
{peers.length}
|
{peers.filter(p => p._liveStatus?.online).length} / {totalPeers}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,7 +385,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|||||||
Refresh IP
|
Refresh IP
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">External IP</p>
|
<p className="text-sm text-gray-500">External IP</p>
|
||||||
<p className="font-mono font-semibold text-gray-900">
|
<p className="font-mono font-semibold text-gray-900">
|
||||||
@@ -393,6 +398,25 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|||||||
{serverConfig?.endpoint || `<SERVER_IP>:${serverConfig?.port || 51820}`}
|
{serverConfig?.endpoint || `<SERVER_IP>:${serverConfig?.port || 51820}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">UDP Port {serverConfig?.port || 51820}</p>
|
||||||
|
{serverConfig ? (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
serverConfig.port_open === true ? 'bg-green-100 text-green-800' :
|
||||||
|
serverConfig.port_open === false ? 'bg-red-100 text-red-800' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
||||||
|
serverConfig.port_open === true ? 'bg-green-400' :
|
||||||
|
serverConfig.port_open === false ? 'bg-red-400' : 'bg-gray-400'
|
||||||
|
}`} />
|
||||||
|
{serverConfig.port_open === true ? 'Open' :
|
||||||
|
serverConfig.port_open === false ? 'Blocked' :
|
||||||
|
serverConfig.port_open === 'checking' ? 'Checking…' :
|
||||||
|
'Click Refresh IP to check'}
|
||||||
|
</span>
|
||||||
|
) : '—'}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mb-1">Server Public Key</p>
|
<p className="text-sm text-gray-500 mb-1">Server Public Key</p>
|
||||||
<p className="font-mono text-xs text-gray-700 break-all">
|
<p className="font-mono text-xs text-gray-700 break-all">
|
||||||
@@ -406,6 +430,12 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|||||||
External IP could not be detected. Check internet connectivity, then click Refresh IP.
|
External IP could not be detected. Check internet connectivity, then click Refresh IP.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{serverConfig && serverConfig.port_open === false && (
|
||||||
|
<div className="mt-3 flex items-center text-red-700 bg-red-50 rounded p-2 text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||||
|
UDP port {serverConfig.port || 51820} appears closed. Check your router/firewall and forward this port to this machine.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Traffic Stats */}
|
{/* Traffic Stats */}
|
||||||
@@ -486,7 +516,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{peers.map((peer, index) => {
|
{peers.map((peer, index) => {
|
||||||
const peerStatus = peerStatuses[peer.name] || { online: null, lastHandshake: null, transferRx: 0, transferTx: 0 };
|
const peerStatus = peerStatuses[peer.name] || { online: null, lastHandshake: null, transferRx: 0, transferTx: 0, endpoint: null };
|
||||||
return (
|
return (
|
||||||
<tr key={index} className="hover:bg-gray-50">
|
<tr key={index} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
|||||||
Reference in New Issue
Block a user