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:
+36
-12
@@ -358,8 +358,8 @@ def get_config():
|
||||
try:
|
||||
service_configs = config_manager.get_all_configs()
|
||||
config = {
|
||||
'cell_name': os.environ.get('CELL_NAME', 'personal-internet-cell'),
|
||||
'domain': os.environ.get('CELL_DOMAIN', 'cell.local'),
|
||||
'cell_name': os.environ.get('CELL_NAME', 'mycell'),
|
||||
'domain': os.environ.get('CELL_DOMAIN', 'cell'),
|
||||
'ip_range': os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'),
|
||||
'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'])
|
||||
def get_peer_status():
|
||||
"""Get WireGuard peer status."""
|
||||
"""Get live WireGuard status for a single peer."""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if data is None or 'public_key' not in data:
|
||||
return jsonify({"error": "Missing public key"}), 400
|
||||
|
||||
public_key = data['public_key']
|
||||
data = request.get_json(silent=True) or {}
|
||||
public_key = data.get('public_key', '')
|
||||
if not public_key:
|
||||
return jsonify({"error": "Missing public_key"}), 400
|
||||
status = wireguard_manager.get_peer_status(public_key)
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting peer status: {e}")
|
||||
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'])
|
||||
def setup_network():
|
||||
"""Setup network configuration for internet access."""
|
||||
@@ -917,17 +926,23 @@ def get_server_config():
|
||||
def refresh_external_ip():
|
||||
try:
|
||||
ip = wireguard_manager.get_external_ip(force_refresh=True)
|
||||
port_open = wireguard_manager.check_port_open()
|
||||
return jsonify({
|
||||
'external_ip': ip,
|
||||
'port': wireguard_manager.DEFAULT_PORT if hasattr(wireguard_manager, 'DEFAULT_PORT') else 51820,
|
||||
'port_open': port_open,
|
||||
'endpoint': f'{ip}:{51820}' if ip else None,
|
||||
'port': 51820,
|
||||
'endpoint': f'{ip}:51820' if ip else None,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing external IP: {e}")
|
||||
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
|
||||
@app.route('/api/peers', methods=['GET'])
|
||||
def get_peers():
|
||||
@@ -1369,6 +1384,15 @@ def get_routing_status():
|
||||
logger.error(f"Error getting routing status: {e}")
|
||||
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'])
|
||||
def 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}")
|
||||
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]:
|
||||
"""Load DNS records from zone file"""
|
||||
zone_file = os.path.join(self.dns_zones_dir, f'{zone}.zone')
|
||||
@@ -131,12 +145,17 @@ class NetworkManager(BaseServiceManager):
|
||||
lines = f.readlines()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line and not line.startswith(';') and not line.startswith('$'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 5:
|
||||
record_type = parts[3]
|
||||
if record_type in ('A', 'CNAME'):
|
||||
line = line.strip().split(';')[0].strip() # strip inline comments
|
||||
if not line or line.startswith('$'):
|
||||
continue
|
||||
parts = line.split()
|
||||
# Support both: name IN type value (4 parts)
|
||||
# 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({
|
||||
'name': parts[0],
|
||||
'ttl': parts[1],
|
||||
|
||||
+94
-24
@@ -24,9 +24,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SERVER_ADDRESS = '172.20.0.1/16'
|
||||
SERVER_NETWORK = '172.20.0.0/16'
|
||||
PEER_DNS = '172.20.0.2'
|
||||
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):
|
||||
"""Manages WireGuard VPN configuration and peers"""
|
||||
@@ -216,19 +224,23 @@ class WireGuardManager(BaseServiceManager):
|
||||
|
||||
def get_peer_config(self, peer_name: str, peer_ip: str,
|
||||
peer_private_key: str,
|
||||
server_endpoint: str = '<SERVER_IP>') -> str:
|
||||
"""Generate a WireGuard client config string."""
|
||||
server_endpoint: str = '<SERVER_IP>',
|
||||
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()
|
||||
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 (
|
||||
f'[Interface]\n'
|
||||
f'PrivateKey = {peer_private_key}\n'
|
||||
f'Address = {peer_ip if "/" in peer_ip else f"{peer_ip}/32"}\n'
|
||||
f'DNS = {PEER_DNS}\n'
|
||||
f'Address = {addr}\n'
|
||||
f'DNS = {peer_dns}\n'
|
||||
f'\n'
|
||||
f'[Peer]\n'
|
||||
f'PublicKey = {server_keys["public_key"]}\n'
|
||||
f'AllowedIPs = {SERVER_NETWORK}\n'
|
||||
f'Endpoint = {server_endpoint if ":" in server_endpoint else f"{server_endpoint}:{DEFAULT_PORT}"}\n'
|
||||
f'AllowedIPs = {allowed_ips}\n'
|
||||
f'Endpoint = {endpoint}\n'
|
||||
f'PersistentKeepalive = 25\n'
|
||||
)
|
||||
|
||||
@@ -277,27 +289,31 @@ class WireGuardManager(BaseServiceManager):
|
||||
def check_port_open(self, port: int = DEFAULT_PORT) -> bool:
|
||||
"""Check if the WireGuard UDP port is reachable from outside."""
|
||||
external_ip = self.get_external_ip()
|
||||
if not external_ip or _requests is None:
|
||||
if not external_ip:
|
||||
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:
|
||||
resp = _requests.get(
|
||||
f'https://portchecker.co/api/v1/query',
|
||||
params={'host': external_ip, 'port': port},
|
||||
timeout=8,
|
||||
)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
return bool(data.get('isOpen') or data.get('open'))
|
||||
statuses = self.get_all_peer_statuses()
|
||||
for st in statuses.values():
|
||||
if st.get('online'):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: try TCP (won't work for UDP WireGuard, but gives a network clue)
|
||||
try:
|
||||
sock = socket.create_connection((external_ip, port), timeout=3)
|
||||
sock.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
# Try UDP port check APIs that support UDP
|
||||
if _requests is not None:
|
||||
for url, params in [
|
||||
('https://portchecker.io/api/query', {'host': external_ip, 'port': port, 'type': 'udp'}),
|
||||
('https://api.ipquery.io/portcheck', {'ip': external_ip, 'port': port, 'protocol': 'udp'}),
|
||||
]:
|
||||
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]:
|
||||
"""Return server public key, external IP, endpoint, and port status."""
|
||||
@@ -309,8 +325,62 @@ class WireGuardManager(BaseServiceManager):
|
||||
'external_ip': external_ip,
|
||||
'endpoint': endpoint,
|
||||
'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 ─────────────────────────────────────────────────
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user