Add port range support for NAT forwards
Этот коммит содержится в:
+151
-24
@@ -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'},
|
||||
|
||||
Ссылка в новой задаче
Block a user