diff --git a/web/network_core.py b/web/network_core.py index 4515c14..1f27bd8 100644 --- a/web/network_core.py +++ b/web/network_core.py @@ -41,6 +41,76 @@ 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 '')) @@ -147,12 +217,37 @@ def load_port_forwards() -> list[dict[str, Any]]: data = json.loads(PORT_FORWARDS_FILE.read_text()) except Exception: data = [] - return data if isinstance(data, list) else [] + 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() - PORT_FORWARDS_FILE.write_text(json.dumps(items, ensure_ascii=False, indent=2)) + 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]: @@ -196,7 +291,7 @@ def tcp_connect_check(host: str, port: int, timeout: float = 2.0) -> dict[str, A return {'ok': False, 'message': str(exc)} -def add_port_forward(vm_name: str, guest_ip: str, external_port: int, guest_port: int, protocol: str, note: str = '') -> dict[str, Any]: +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': @@ -206,27 +301,38 @@ def add_port_forward(vm_name: str, guest_ip: str, external_port: int, guest_port guest_ip = resolved_ip if not valid_ip(guest_ip): raise NetworkError('Некорректный внутренний IP VM') - if not valid_port(external_port) or not valid_port(guest_port): - raise NetworkError('Порт должен быть от 1 до 65535') + + 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 int(item['external_port']) == int(external_port) and item['protocol'] == protocol: - raise NetworkError(f'Внешний порт {external_port}/{protocol} уже занят') + 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': int(external_port), - 'guest_port': int(guest_port), + '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 forward + return normalize_forward(forward) def delete_port_forward(forward_id: str) -> None: @@ -243,7 +349,9 @@ def render_nft_rules() -> str: ' type nat hook prerouting priority dstnat; policy accept;', ] for item in load_port_forwards(): - lines.append(f" iifname \"{ext}\" {item['protocol']} dport {int(item['external_port'])} dnat to {item['guest_ip']}:{int(item['guest_port'])}") + 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 {', @@ -268,17 +376,20 @@ def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]: status = run_cmd(['ufw', 'status'], timeout=8) if 'Status: active' not in status['stdout']: return results - for item in items: + 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', str(int(item['guest_port'])), + 'port', guest_port, 'proto', item['protocol'], ] results.append(run_cmd(cmd, timeout=15)) - results.append(run_cmd(['ufw', 'allow', f"{int(item['external_port'])}/{item['protocol']}"], timeout=15)) + results.append(run_cmd(['ufw', 'allow', f"{external_port}/{item['protocol']}"], timeout=15)) run_cmd(['ufw', 'reload'], timeout=20) return results @@ -303,14 +414,16 @@ def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]] results: list[dict[str, Any]] = [] if not run_cmd(['sh', '-lc', 'command -v iptables >/dev/null 2>&1'], timeout=5)['ok']: return results - for item in items: + for raw_item in items: + item = normalize_forward(raw_item) proto = item['protocol'] guest_ip = item['guest_ip'] - external_port = str(int(item['external_port'])) - guest_port = str(int(item['guest_port'])) - results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '--dport', guest_port, '-j', 'ACCEPT'])) + 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, '--dport', external_port, '-j', 'DNAT', '--to-destination', f'{guest_ip}:{guest_port}'])) + 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 @@ -336,6 +449,16 @@ def apply_port_forwards() -> dict[str, Any]: } +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') @@ -347,7 +470,7 @@ def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, pr ext = external_interface() vm_ip = resolve_vm_ip(vm_name) forwards = load_port_forwards() - matching_forward = next((item for item in forwards if item.get('vm_name') == vm_name and int(item.get('external_port', 0)) == int(external_port) and int(item.get('guest_port', 0)) == int(guest_port) and item.get('protocol') == protocol), None) + 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) @@ -369,10 +492,14 @@ def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, pr 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_rule = bool(vm_ip and expected_dnat in nft_text) + 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_forward = bool(vm_ip and f'-d {vm_ip}/32' in ipt_forward_text and f'--dport {int(guest_port)}' in ipt_forward_text) - ufw_has_route = bool(vm_ip and vm_ip in ufw_text and str(int(guest_port)) in ufw_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']}, @@ -381,7 +508,7 @@ def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, pr {'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 'VM IP not found'}, + {'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'},