Files
GuofangandClaude Sonnet 5.5 78f6fd424b Read port forwards from per-VM forwards.conf files
A QEMU config file section cannot hold more than one hostfwd entry, so
the forwards move out of the ini files into qemu/<vm>/forwards.conf, one
"LABEL HOST_PORT GUEST_PORT" rule per line. The new vmforwards.py parses
them, skips rules whose host port is taken and builds the -netdev
argument, which every launcher now passes instead of relying on a
netdev section in the ini or on -nic.

The ini files move to qemu/<vm>/config.ini and qemu/<vm>/<os>-<arch>.ini.
The *_SSH_PORT / *_RDP_PORT variables and --rdp-port / --ssh-port still
replace the host port of the SSH and RDP rules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 14:55:30 +08:00

97 lines
3.5 KiB
Python

"""Host port forwards for the QEMU user-mode network, read from a .forwards file.
Each line of the file is "LABEL HOST_PORT GUEST_PORT"; "#" starts a comment.
A QEMU config file section cannot hold more than one hostfwd entry, so the
launchers build the -netdev argument from these rules instead.
"""
import socket
import sys
from collections import namedtuple
Forward = namedtuple("Forward", "label host_port guest_port")
def _die(message):
print(f"Error: {message}", file=sys.stderr)
sys.exit(1)
def _port(text, where):
if not (text.isascii() and text.isdigit() and 1 <= int(text) <= 65535):
_die(f"{where}: not a port number from 1 to 65535: {text}")
return int(text)
def port_in_use(port):
"""True if a host TCP port cannot be bound, as QEMU does for a hostfwd rule."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("", int(port)))
except OSError:
return True
return False
def load_forwards(path, host_port_overrides=None):
"""Read the rules in a .forwards file.
host_port_overrides maps a label to a host port that replaces the one in
the file, e.g. {"SSH": "2200"}.
"""
if not path.is_file():
_die(f"port forward file was not found: {path}")
forwards = []
for number, line in enumerate(path.read_text().splitlines(), 1):
fields = line.partition("#")[0].split()
if not fields:
continue
where = f"{path}:{number}"
if len(fields) != 3:
_die(f"{where}: expected LABEL HOST_PORT GUEST_PORT: {line.strip()}")
label, host_port, guest_port = fields
if any(forward.label == label for forward in forwards):
_die(f"{where}: duplicate label: {label}")
forwards.append(Forward(label, _port(host_port, where), _port(guest_port, where)))
for label, host_port in (host_port_overrides or {}).items():
if not any(forward.label == label for forward in forwards):
_die(f"{path} has no {label} rule to override the port of")
forwards = [forward._replace(host_port=_port(str(host_port), f"{label} port"))
if forward.label == label else forward for forward in forwards]
seen = set()
for forward in forwards:
if forward.host_port in seen:
_die(f"{path}: host port {forward.host_port} is used by more than one rule")
seen.add(forward.host_port)
return forwards
def usable_forwards(forwards):
"""Drop (with a warning) the rules whose host port is taken.
QEMU refuses to start when a forwarded host port is taken.
"""
usable = []
for forward in forwards:
if port_in_use(forward.host_port):
print(f"Warning: host port {forward.host_port} is already in use; "
f"{forward.label} will not be forwarded to the guest.", file=sys.stderr)
else:
usable.append(forward)
return usable
def netdev_argument(forwards, netdev_id="net0"):
"""The value of -netdev for a user-mode network with the given rules."""
return ",".join([f"user,id={netdev_id}", *(
f"hostfwd=tcp::{forward.host_port}-:{forward.guest_port}" for forward in forwards)])
def print_forwards(forwards):
for forward in forwards:
print(f"{forward.label}: localhost:{forward.host_port} -> guest port "
f"{forward.guest_port}", flush=True)
if forward.guest_port == 22:
print(f" ssh -p {forward.host_port} guofang@localhost", flush=True)