559 строки
25 KiB
Python
559 строки
25 KiB
Python
import json
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
CONFIG_DIR = Path('/var/lib/virtuality/config')
|
|
NETWORK_DIR = Path('/var/lib/virtuality/network')
|
|
NFT_DIR = Path('/etc/virtuality/nftables')
|
|
PORT_FORWARDS_FILE = NETWORK_DIR / 'port_forwards.json'
|
|
NFT_FILE = NFT_DIR / 'virtuality.nft'
|
|
NETWORK_NAME = 'virtuality-nat'
|
|
NAT_BRIDGE = 'virbr100'
|
|
NAT_SUBNET = '192.168.100.0/24'
|
|
NAT_GATEWAY = '192.168.100.1'
|
|
DHCP_START = '192.168.100.50'
|
|
DHCP_END = '192.168.100.200'
|
|
|
|
|
|
class NetworkError(Exception):
|
|
pass
|
|
|
|
|
|
def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
|
return {'ok': result.returncode == 0, 'code': result.returncode, 'stdout': result.stdout.strip(), 'stderr': result.stderr.strip(), 'cmd': ' '.join(cmd)}
|
|
except Exception as exc:
|
|
return {'ok': False, 'code': -1, 'stdout': '', 'stderr': str(exc), 'cmd': ' '.join(cmd)}
|
|
|
|
|
|
def ensure_dirs() -> None:
|
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
NETWORK_DIR.mkdir(parents=True, exist_ok=True)
|
|
NFT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def valid_port(value: int) -> bool:
|
|
return 1 <= int(value) <= 65535
|
|
|
|
|
|
def valid_port_range(start: int, end: int) -> bool:
|
|
return valid_port(start) and valid_port(end) and int(start) <= int(end)
|
|
|
|
|
|
def parse_port_range(value: Any, field_label: str = 'Порт') -> tuple[int, int]:
|
|
raw = str(value or '').strip().replace(' ', '')
|
|
if not raw:
|
|
raise NetworkError(f'{field_label} не указан')
|
|
match = re.fullmatch(r'(\d{1,5})(?:[-:](\d{1,5}))?', raw)
|
|
if not match:
|
|
raise NetworkError(f'{field_label} должен быть числом или диапазоном, например 80 или 10000-20000')
|
|
start = int(match.group(1))
|
|
end = int(match.group(2) or match.group(1))
|
|
if not valid_port_range(start, end):
|
|
raise NetworkError(f'{field_label} должен быть в диапазоне 1-65535, начало не больше конца')
|
|
return start, end
|
|
|
|
|
|
def port_range_size(start: int, end: int) -> int:
|
|
return int(end) - int(start) + 1
|
|
|
|
|
|
def nft_port_value(start: int, end: int) -> str:
|
|
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
|
|
|
|
|
|
def iptables_port_value(start: int, end: int) -> str:
|
|
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
|
|
|
|
|
|
def port_label(start: int, end: int) -> str:
|
|
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
|
|
|
|
|
|
def normalize_forward(item: dict[str, Any]) -> dict[str, Any]:
|
|
normalized = dict(item)
|
|
|
|
external_start = normalized.get('external_port_start', normalized.get('external_port'))
|
|
external_end = normalized.get('external_port_end', normalized.get('external_port'))
|
|
guest_start = normalized.get('guest_port_start', normalized.get('guest_port'))
|
|
guest_end = normalized.get('guest_port_end', normalized.get('guest_port'))
|
|
|
|
try:
|
|
external_start_i, external_end_i = int(external_start), int(external_end)
|
|
guest_start_i, guest_end_i = int(guest_start), int(guest_end)
|
|
except (TypeError, ValueError):
|
|
external_start_i, external_end_i = parse_port_range(normalized.get('external_port', ''), 'Внешний порт')
|
|
guest_start_i, guest_end_i = parse_port_range(normalized.get('guest_port', ''), 'Внутренний порт')
|
|
|
|
normalized['external_port_start'] = external_start_i
|
|
normalized['external_port_end'] = external_end_i
|
|
normalized['guest_port_start'] = guest_start_i
|
|
normalized['guest_port_end'] = guest_end_i
|
|
|
|
# Backward compatibility for old templates and diagnostics.
|
|
normalized['external_port'] = external_start_i
|
|
normalized['guest_port'] = guest_start_i
|
|
|
|
normalized['external_port_label'] = port_label(external_start_i, external_end_i)
|
|
normalized['guest_port_label'] = port_label(guest_start_i, guest_end_i)
|
|
normalized['mapping_label'] = f"{normalized['external_port_label']} → {normalized['guest_port_label']}"
|
|
normalized['is_range'] = external_start_i != external_end_i or guest_start_i != guest_end_i
|
|
normalized['range_size'] = max(port_range_size(external_start_i, external_end_i), port_range_size(guest_start_i, guest_end_i))
|
|
return normalized
|
|
|
|
|
|
def ranges_overlap(a_start: int, a_end: int, b_start: int, b_end: int) -> bool:
|
|
return int(a_start) <= int(b_end) and int(b_start) <= int(a_end)
|
|
|
|
|
|
def valid_ip(value: str) -> bool:
|
|
return bool(re.fullmatch(r'(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}', value or ''))
|
|
|
|
|
|
def valid_proto(value: str) -> bool:
|
|
return value in {'tcp', 'udp'}
|
|
|
|
|
|
def external_interface() -> str:
|
|
result = run_cmd(['ip', 'route', 'show', 'default'], timeout=5)
|
|
if not result['ok']:
|
|
return 'eth0'
|
|
match = re.search(r'\bdev\s+([^\s]+)', result['stdout'])
|
|
return match.group(1) if match else 'eth0'
|
|
|
|
|
|
def ip_forward_state() -> str:
|
|
path = Path('/proc/sys/net/ipv4/ip_forward')
|
|
if not path.exists():
|
|
return 'unknown'
|
|
return 'enabled' if path.read_text().strip() == '1' else 'disabled'
|
|
|
|
|
|
def nat_network_xml() -> str:
|
|
return f"""<network>
|
|
<name>{NETWORK_NAME}</name>
|
|
<forward mode='nat'/>
|
|
<bridge name='{NAT_BRIDGE}' stp='on' delay='0'/>
|
|
<ip address='{NAT_GATEWAY}' netmask='255.255.255.0'>
|
|
<dhcp>
|
|
<range start='{DHCP_START}' end='{DHCP_END}'/>
|
|
</dhcp>
|
|
</ip>
|
|
</network>
|
|
"""
|
|
|
|
|
|
def libvirt_network_info() -> dict[str, Any]:
|
|
info = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8)
|
|
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)
|
|
return {
|
|
'name': NETWORK_NAME,
|
|
'bridge': NAT_BRIDGE,
|
|
'subnet': NAT_SUBNET,
|
|
'gateway': NAT_GATEWAY,
|
|
'dhcp': f'{DHCP_START} - {DHCP_END}',
|
|
'exists': info['ok'],
|
|
'info': info['stdout'] if info['ok'] else info['stderr'],
|
|
'leases': leases['stdout'] if leases['ok'] else leases['stderr'],
|
|
}
|
|
|
|
|
|
def list_libvirt_networks() -> list[dict[str, str]]:
|
|
result = run_cmd(['virsh', 'net-list', '--all'], timeout=8)
|
|
rows = []
|
|
if not result['ok']:
|
|
return rows
|
|
for line in result['stdout'].splitlines()[2:]:
|
|
parts = line.split()
|
|
if len(parts) >= 3:
|
|
rows.append({'name': parts[0], 'state': parts[1], 'autostart': parts[2]})
|
|
return rows
|
|
|
|
|
|
def create_nat_network() -> dict[str, Any]:
|
|
ensure_dirs()
|
|
Path('/tmp/virtuality-nat.xml').write_text(nat_network_xml())
|
|
existing = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8)
|
|
if not existing['ok']:
|
|
defined = run_cmd(['virsh', 'net-define', '/tmp/virtuality-nat.xml'], timeout=15)
|
|
if not defined['ok']:
|
|
raise NetworkError(defined['stderr'] or defined['stdout'] or 'Не удалось создать libvirt NAT-сеть')
|
|
started = run_cmd(['virsh', 'net-start', NETWORK_NAME], timeout=15)
|
|
if not started['ok'] and 'already active' not in (started['stderr'] + started['stdout']).lower():
|
|
raise NetworkError(started['stderr'] or started['stdout'] or 'Не удалось запустить libvirt NAT-сеть')
|
|
autostart = run_cmd(['virsh', 'net-autostart', NETWORK_NAME], timeout=15)
|
|
if not autostart['ok']:
|
|
raise NetworkError(autostart['stderr'] or autostart['stdout'] or 'Не удалось включить autostart для NAT-сети')
|
|
enable_ip_forward()
|
|
disable_rp_filter()
|
|
return libvirt_network_info()
|
|
|
|
|
|
def enable_ip_forward() -> None:
|
|
Path('/etc/sysctl.d/99-virtuality-forward.conf').write_text('net.ipv4.ip_forward=1\n')
|
|
run_cmd(['sysctl', '-p', '/etc/sysctl.d/99-virtuality-forward.conf'], timeout=10)
|
|
|
|
|
|
def disable_rp_filter() -> None:
|
|
ext = external_interface()
|
|
Path('/etc/sysctl.d/98-virtuality-rpfilter.conf').write_text(
|
|
'net.ipv4.conf.all.rp_filter=0\n'
|
|
f'net.ipv4.conf.{ext}.rp_filter=0\n'
|
|
f'net.ipv4.conf.{NAT_BRIDGE}.rp_filter=0\n'
|
|
)
|
|
run_cmd(['sysctl', '-p', '/etc/sysctl.d/98-virtuality-rpfilter.conf'], timeout=10)
|
|
|
|
|
|
def load_port_forwards() -> list[dict[str, Any]]:
|
|
ensure_dirs()
|
|
if not PORT_FORWARDS_FILE.exists():
|
|
PORT_FORWARDS_FILE.write_text('[]')
|
|
try:
|
|
data = json.loads(PORT_FORWARDS_FILE.read_text())
|
|
except Exception:
|
|
data = []
|
|
if not isinstance(data, list):
|
|
return []
|
|
|
|
items: list[dict[str, Any]] = []
|
|
for item in data:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
try:
|
|
items.append(normalize_forward(item))
|
|
except NetworkError:
|
|
continue
|
|
return items
|
|
|
|
|
|
def save_port_forwards(items: list[dict[str, Any]]) -> None:
|
|
ensure_dirs()
|
|
cleaned: list[dict[str, Any]] = []
|
|
for item in items:
|
|
normalized = normalize_forward(item)
|
|
cleaned.append({
|
|
'id': normalized.get('id') or str(uuid.uuid4()),
|
|
'vm_name': normalized['vm_name'],
|
|
'guest_ip': normalized['guest_ip'],
|
|
'external_port_start': int(normalized['external_port_start']),
|
|
'external_port_end': int(normalized['external_port_end']),
|
|
'guest_port_start': int(normalized['guest_port_start']),
|
|
'guest_port_end': int(normalized['guest_port_end']),
|
|
'protocol': normalized['protocol'],
|
|
'note': str(normalized.get('note', '')).strip()[:120],
|
|
})
|
|
PORT_FORWARDS_FILE.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2))
|
|
|
|
|
|
def vm_mac_addresses(vm_name: str) -> list[str]:
|
|
result = run_cmd(['virsh', 'domiflist', vm_name], timeout=8)
|
|
if not result['ok']:
|
|
return []
|
|
macs: list[str] = []
|
|
for line in result['stdout'].splitlines():
|
|
match = re.search(r'([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}', line)
|
|
if match:
|
|
macs.append(match.group(0).lower())
|
|
return macs
|
|
|
|
|
|
def resolve_vm_ip(vm_name: str) -> str | None:
|
|
result = run_cmd(['virsh', 'domifaddr', vm_name], timeout=8)
|
|
if result['ok']:
|
|
match = re.search(r'\b(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/\d+', result['stdout'])
|
|
if match:
|
|
return match.group(1)
|
|
|
|
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)
|
|
if not leases['ok']:
|
|
return None
|
|
macs = set(vm_mac_addresses(vm_name))
|
|
for line in leases['stdout'].splitlines():
|
|
low = line.lower()
|
|
if macs and not any(mac in low for mac in macs):
|
|
continue
|
|
match = re.search(r'\b(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/\d+', line)
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
def tcp_connect_check(host: str, port: int, timeout: float = 2.0) -> dict[str, Any]:
|
|
try:
|
|
with socket.create_connection((host, int(port)), timeout=timeout):
|
|
return {'ok': True, 'message': f'{host}:{port} reachable'}
|
|
except Exception as exc:
|
|
return {'ok': False, 'message': str(exc)}
|
|
|
|
|
|
def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port: Any, protocol: str, note: str = '') -> dict[str, Any]:
|
|
if not vm_name or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name):
|
|
raise NetworkError('Некорректное имя VM')
|
|
if guest_ip == 'auto':
|
|
resolved_ip = resolve_vm_ip(vm_name)
|
|
if not resolved_ip:
|
|
raise NetworkError('Не удалось автоматически определить IP VM. Запусти VM, дождись DHCP, установи qemu-guest-agent или проверь DHCP leases на странице сети.')
|
|
guest_ip = resolved_ip
|
|
if not valid_ip(guest_ip):
|
|
raise NetworkError('Некорректный внутренний IP VM')
|
|
|
|
external_start, external_end = parse_port_range(external_port, 'Внешний порт')
|
|
guest_start, guest_end = parse_port_range(guest_port, 'Внутренний порт')
|
|
external_size = port_range_size(external_start, external_end)
|
|
guest_size = port_range_size(guest_start, guest_end)
|
|
if external_size != guest_size:
|
|
raise NetworkError('Диапазоны внешних и внутренних портов должны быть одинаковой длины. Например: 10000-20000 → 10000-20000.')
|
|
if not valid_proto(protocol):
|
|
raise NetworkError('Протокол должен быть tcp или udp')
|
|
|
|
items = load_port_forwards()
|
|
for item in items:
|
|
if item['protocol'] != protocol:
|
|
continue
|
|
if ranges_overlap(external_start, external_end, int(item['external_port_start']), int(item['external_port_end'])):
|
|
raise NetworkError(f"Внешний порт/диапазон пересекается с {item['external_port_label']}/{protocol}")
|
|
|
|
forward = {
|
|
'id': str(uuid.uuid4()),
|
|
'vm_name': vm_name,
|
|
'guest_ip': guest_ip,
|
|
'external_port_start': external_start,
|
|
'external_port_end': external_end,
|
|
'guest_port_start': guest_start,
|
|
'guest_port_end': guest_end,
|
|
'protocol': protocol,
|
|
'note': note.strip()[:120],
|
|
}
|
|
items.append(forward)
|
|
save_port_forwards(items)
|
|
apply_port_forwards()
|
|
return normalize_forward(forward)
|
|
|
|
|
|
def delete_port_forward(forward_id: str) -> None:
|
|
items = [item for item in load_port_forwards() if item.get('id') != forward_id]
|
|
save_port_forwards(items)
|
|
apply_port_forwards()
|
|
|
|
|
|
def render_nft_rules() -> str:
|
|
ext = external_interface()
|
|
lines = [
|
|
'table ip virtuality {',
|
|
' chain prerouting {',
|
|
' type nat hook prerouting priority dstnat; policy accept;',
|
|
]
|
|
for item in load_port_forwards():
|
|
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
|
|
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
|
|
lines.append(f" iifname \"{ext}\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
|
|
lines += [
|
|
' }',
|
|
' chain postrouting {',
|
|
' type nat hook postrouting priority srcnat; policy accept;',
|
|
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
|
|
' }',
|
|
' chain forward {',
|
|
' type filter hook forward priority filter; policy accept;',
|
|
f' ip saddr {NAT_SUBNET} accept',
|
|
f' ip daddr {NAT_SUBNET} accept',
|
|
' }',
|
|
'}',
|
|
]
|
|
return '\n'.join(lines) + '\n'
|
|
|
|
|
|
def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
ext = external_interface()
|
|
results: list[dict[str, Any]] = []
|
|
if not run_cmd(['sh', '-lc', 'command -v ufw >/dev/null 2>&1'], timeout=5)['ok']:
|
|
return results
|
|
status = run_cmd(['ufw', 'status'], timeout=8)
|
|
if 'Status: active' not in status['stdout']:
|
|
return results
|
|
for raw_item in items:
|
|
item = normalize_forward(raw_item)
|
|
guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end'])
|
|
external_port = iptables_port_value(item['external_port_start'], item['external_port_end'])
|
|
cmd = [
|
|
'ufw', 'route', 'allow',
|
|
'in', 'on', ext,
|
|
'out', 'on', NAT_BRIDGE,
|
|
'to', item['guest_ip'],
|
|
'port', guest_port,
|
|
'proto', item['protocol'],
|
|
]
|
|
results.append(run_cmd(cmd, timeout=15))
|
|
results.append(run_cmd(['ufw', 'allow', f"{external_port}/{item['protocol']}"], timeout=15))
|
|
run_cmd(['ufw', 'reload'], timeout=20)
|
|
return results
|
|
|
|
|
|
def iptables_rule_exists(cmd: list[str]) -> bool:
|
|
check_cmd = cmd.copy()
|
|
if '-I' in check_cmd:
|
|
check_cmd[check_cmd.index('-I')] = '-C'
|
|
if check_cmd[check_cmd.index('-C') + 2].isdigit():
|
|
del check_cmd[check_cmd.index('-C') + 2]
|
|
return run_cmd(check_cmd, timeout=8)['ok']
|
|
|
|
|
|
def ensure_iptables_rule(cmd: list[str]) -> dict[str, Any]:
|
|
if iptables_rule_exists(cmd):
|
|
return {'ok': True, 'code': 0, 'stdout': 'exists', 'stderr': '', 'cmd': ' '.join(cmd)}
|
|
return run_cmd(cmd, timeout=10)
|
|
|
|
|
|
def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
ext = external_interface()
|
|
results: list[dict[str, Any]] = []
|
|
if not run_cmd(['sh', '-lc', 'command -v iptables >/dev/null 2>&1'], timeout=5)['ok']:
|
|
return results
|
|
for raw_item in items:
|
|
item = normalize_forward(raw_item)
|
|
proto = item['protocol']
|
|
guest_ip = item['guest_ip']
|
|
external_port = iptables_port_value(item['external_port_start'], item['external_port_end'])
|
|
guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end'])
|
|
guest_to = f"{guest_ip}:{iptables_port_value(item['guest_port_start'], item['guest_port_end'])}"
|
|
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
|
|
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
|
|
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
|
|
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, '-j', 'MASQUERADE']))
|
|
return results
|
|
|
|
|
|
def apply_port_forwards() -> dict[str, Any]:
|
|
ensure_dirs()
|
|
enable_ip_forward()
|
|
disable_rp_filter()
|
|
items = load_port_forwards()
|
|
NFT_FILE.write_text(render_nft_rules())
|
|
run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
|
|
result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
|
|
if not result['ok']:
|
|
raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила')
|
|
ufw_results = apply_ufw_route_rules(items)
|
|
iptables_results = apply_iptables_fallback(items)
|
|
return {
|
|
'ok': True,
|
|
'file': str(NFT_FILE),
|
|
'rules': render_nft_rules(),
|
|
'ufw': ufw_results,
|
|
'iptables': iptables_results,
|
|
}
|
|
|
|
|
|
def find_matching_forward(forwards: list[dict[str, Any]], vm_name: str, external_port: int, guest_port: int, protocol: str) -> dict[str, Any] | None:
|
|
for raw_item in forwards:
|
|
item = normalize_forward(raw_item)
|
|
if item.get('vm_name') != vm_name or item.get('protocol') != protocol:
|
|
continue
|
|
if int(item['external_port_start']) <= int(external_port) <= int(item['external_port_end']) and int(item['guest_port_start']) <= int(guest_port) <= int(item['guest_port_end']):
|
|
return item
|
|
return None
|
|
|
|
|
|
def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, protocol: str = 'tcp') -> dict[str, Any]:
|
|
if not vm_name or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name):
|
|
raise NetworkError('Некорректное имя VM')
|
|
if not valid_port(external_port) or not valid_port(guest_port):
|
|
raise NetworkError('Порт должен быть от 1 до 65535')
|
|
if not valid_proto(protocol):
|
|
raise NetworkError('Протокол должен быть tcp или udp')
|
|
|
|
ext = external_interface()
|
|
vm_ip = resolve_vm_ip(vm_name)
|
|
forwards = load_port_forwards()
|
|
matching_forward = find_matching_forward(forwards, vm_name, int(external_port), int(guest_port), protocol)
|
|
|
|
nft_rules = run_cmd(['nft', 'list', 'ruleset'], timeout=12)
|
|
ipt_forward = run_cmd(['iptables', '-S', 'FORWARD'], timeout=8)
|
|
ipt_nat = run_cmd(['iptables', '-t', 'nat', '-S'], timeout=8)
|
|
ufw_status = run_cmd(['ufw', 'status', 'numbered'], timeout=10)
|
|
domiflist = run_cmd(['virsh', 'domiflist', vm_name], timeout=8)
|
|
domifaddr = run_cmd(['virsh', 'domifaddr', vm_name], timeout=8)
|
|
net_info = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8)
|
|
|
|
vm_port_check = {'ok': False, 'message': 'VM IP not found'}
|
|
if vm_ip and protocol == 'tcp':
|
|
vm_port_check = tcp_connect_check(vm_ip, int(guest_port))
|
|
elif vm_ip and protocol == 'udp':
|
|
vm_port_check = {'ok': True, 'message': 'UDP cannot be reliably checked with TCP connect; rules only'}
|
|
|
|
nft_text = nft_rules['stdout']
|
|
ipt_forward_text = ipt_forward['stdout']
|
|
ipt_nat_text = ipt_nat['stdout']
|
|
ufw_text = ufw_status['stdout']
|
|
|
|
expected_dnat = f'dport {int(external_port)} dnat to {vm_ip}:{int(guest_port)}' if vm_ip else ''
|
|
nft_has_single_rule = bool(vm_ip and expected_dnat in nft_text)
|
|
nft_has_range_rule = bool(matching_forward and vm_ip and matching_forward['external_port_label'] in nft_text and f"{vm_ip}:{matching_forward['guest_port_label']}" in nft_text)
|
|
nft_has_rule = nft_has_single_rule or nft_has_range_rule
|
|
iptables_has_prerouting = bool(vm_ip and f'--dport {int(external_port)} -j DNAT --to-destination {vm_ip}:{int(guest_port)}' in ipt_nat_text)
|
|
iptables_has_range_prerouting = bool(matching_forward and vm_ip and matching_forward['external_port_label'].replace('-', ':') in ipt_nat_text and f"{vm_ip}:{matching_forward['guest_port_label'].replace('-', ':')}" in ipt_nat_text)
|
|
iptables_has_prerouting = iptables_has_prerouting or iptables_has_range_prerouting
|
|
iptables_has_forward = bool(vm_ip and f'-d {vm_ip}/32' in ipt_forward_text and (f'--dport {int(guest_port)}' in ipt_forward_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ipt_forward_text)))
|
|
ufw_has_route = bool(vm_ip and vm_ip in ufw_text and (str(int(guest_port)) in ufw_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ufw_text)))
|
|
|
|
checks = [
|
|
{'name': 'VM exists/interface', 'ok': domiflist['ok'], 'detail': domiflist['stdout'] or domiflist['stderr']},
|
|
{'name': 'VM IP resolved', 'ok': bool(vm_ip), 'detail': vm_ip or 'not found'},
|
|
{'name': 'virtuality-nat active', 'ok': net_info['ok'] and 'Active: yes' in net_info['stdout'], 'detail': net_info['stdout'] or net_info['stderr']},
|
|
{'name': 'ip_forward enabled', 'ok': ip_forward_state() == 'enabled', 'detail': ip_forward_state()},
|
|
{'name': 'port forward config', 'ok': bool(matching_forward), 'detail': json.dumps(matching_forward, ensure_ascii=False) if matching_forward else 'not found'},
|
|
{'name': f'VM service {vm_ip}:{guest_port}', 'ok': vm_port_check['ok'], 'detail': vm_port_check['message']},
|
|
{'name': 'nft DNAT rule', 'ok': nft_has_rule, 'detail': expected_dnat or (matching_forward['mapping_label'] if matching_forward else 'VM IP not found')},
|
|
{'name': 'iptables DNAT fallback', 'ok': iptables_has_prerouting, 'detail': 'present' if iptables_has_prerouting else 'not found'},
|
|
{'name': 'iptables FORWARD fallback', 'ok': iptables_has_forward, 'detail': 'present' if iptables_has_forward else 'not found'},
|
|
{'name': 'UFW route allow', 'ok': ufw_has_route or 'Status: inactive' in ufw_text, 'detail': 'present/inactive' if ufw_has_route or 'Status: inactive' in ufw_text else 'not found'},
|
|
]
|
|
|
|
if not vm_ip:
|
|
verdict = 'VM не получила IP. Запусти VM, дождись DHCP или проверь virtuality-nat.'
|
|
elif not vm_port_check['ok']:
|
|
verdict = f'VM найдена, но сервис внутри VM не отвечает на {vm_ip}:{guest_port}.'
|
|
elif not nft_has_rule and not iptables_has_prerouting:
|
|
verdict = 'Сервис VM отвечает, но DNAT-правило не найдено. Нажми «Применить правила».'
|
|
elif not iptables_has_forward and not ufw_has_route:
|
|
verdict = 'DNAT есть, но FORWARD/route-правила выглядят неполными. Нажми «Применить правила».'
|
|
else:
|
|
verdict = 'Локальная цепочка Virtuality выглядит готовой. Если снаружи не открывается — проверь входящий трафик tcpdump на внешнем интерфейсе и firewall провайдера.'
|
|
|
|
return {
|
|
'vm_name': vm_name,
|
|
'vm_ip': vm_ip,
|
|
'external_interface': ext,
|
|
'external_port': int(external_port),
|
|
'guest_port': int(guest_port),
|
|
'protocol': protocol,
|
|
'verdict': verdict,
|
|
'checks': checks,
|
|
'commands': {
|
|
'watch_external': f"sudo tcpdump -ni {ext} '{protocol} port {int(external_port)}'",
|
|
'watch_internal': f"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'",
|
|
},
|
|
'raw': {
|
|
'domifaddr': domifaddr['stdout'] or domifaddr['stderr'],
|
|
'ufw': ufw_text,
|
|
'iptables_forward': ipt_forward_text,
|
|
'iptables_nat': ipt_nat_text,
|
|
},
|
|
}
|
|
|
|
|
|
def network_context() -> dict[str, Any]:
|
|
return {
|
|
'nat': libvirt_network_info(),
|
|
'networks': list_libvirt_networks(),
|
|
'forwards': load_port_forwards(),
|
|
'external_interface': external_interface(),
|
|
'ip_forward': ip_forward_state(),
|
|
'nft_rules': render_nft_rules(),
|
|
}
|