lib.state_helpers

  1import shlex
  2from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface, IPv6Network
  3
  4from SRE.lib_sre import NetScheme0
  5from net_config import remount_proc_sys, set_ipv6_forward
  6
  7
  8def set_unbound_server(net_scheme: NetScheme0, machine: str):
  9    """Write a permissive Unbound DNS config and start the service on *machine*."""
 10    set_basic_unbound_server(net_scheme=net_scheme, machine=machine)
 11
 12
 13def set_basic_unbound_server(net_scheme: NetScheme0, machine: str):
 14    """Write /etc/unbound/unbound.conf (listen on 0.0.0.0, allow all) and start unbound on *machine*."""
 15    net_scheme.file(machine=machine, filename='/etc/unbound/unbound.conf', content="""
 16# Unbound configuration file for Debian.
 17#
 18# See the unbound.conf(5) man page.
 19#
 20# See /usr/share/doc/unbound/examples/unbound.conf for a commented
 21# reference config file.
 22#
 23# The following line includes additional configuration files from the
 24# /etc/unbound/unbound.conf.d directory.
 25include-toplevel: "/etc/unbound/unbound.conf.d/*.conf"
 26server:
 27    interface: 0.0.0.0
 28    access-control: 0.0.0.0/0 allow
 29""")
 30    net_scheme.cmd(machine, "systemctl start unbound")
 31
 32
 33def set_nat_gateway(net_scheme: NetScheme0, machine: str):
 34    """Add an iptables MASQUERADE rule on *machine*'s bridged interface.
 35
 36    The machine must have been declared with ``bridged=True``; Kathara appends
 37    the bridged interface as the next ``eth{N}`` after all topology-defined
 38    adapters (i.e. its index equals the highest assigned interface number + 1).
 39    """
 40    m = net_scheme.get_machine(machine)
 41    iface_numbers = [a.interface for a in m.net_adapters.values()]
 42    bridged_iface = (max(iface_numbers) + 1) if iface_numbers else 0
 43    net_scheme.cmd(machine,
 44                   f"sh -c 'iptables -t nat -C POSTROUTING -o eth{bridged_iface} -j MASQUERADE 2>/dev/null"
 45                   f" || iptables -t nat -A POSTROUTING -o eth{bridged_iface} -j MASQUERADE'")
 46
 47
 48def _hosts_line(ip: str, name: str, domain_extension: str, separator: str) -> str:
 49    return f"{ip}{separator}{name}{separator}{name}.{domain_extension}"
 50
 51
 52def _hosts_address(table, container, machine_name: str, index: int, attr: str):
 53    """Address (without prefix) of one machine/network, from the explicit *table*
 54    ({machine: [addresses in topology order]}) when given, else from the data container
 55    attribute *attr*; None when absent."""
 56    if table is not None:
 57        addrs = table.get(machine_name, [])
 58        return str(addrs[index]).split('/')[0] if index < len(addrs) else None
 59    if container is None:
 60        return None
 61    ip_obj = getattr(container, attr, None)
 62    return None if ip_obj is None else str(ip_obj).split('/')[0]
 63
 64
 65def hosts_file_content(net_scheme: NetScheme0, domain_extension: str, included=None, ips=None,
 66                       separator: str = "\t\t", ipv6: bool = False, ips6=None) -> str:
 67    """Return /etc/hosts lines for the given machines.
 68
 69    Args:
 70        net_scheme: the NetScheme0 instance
 71        domain_extension: domain suffix (e.g. 'example.com')
 72        included: list of machine names; defaults to get_visibles_machines()
 73        ips: dict {machine_name: [IPv4Interface|IPv4Address, ...]} one ip per network,
 74             in the same order as host_interfaces_from_topology().
 75             If None, addresses are read from net_scheme.data.ips.*
 76        separator: string placed between fields (default: two tabs)
 77        ipv6: also write an IPv6 line (right after the IPv4 one) for every machine/network
 78              that has one, from *ips6* or from net_scheme.data.ips6.*
 79        ips6: dict {machine_name: [IPv6Interface|IPv6Address, ...]} like *ips* (ipv6=True)
 80    """
 81    if included is None:
 82        included = [m.name for m in net_scheme.get_visibles_machines()]
 83
 84    machine_nets = net_scheme.host_interfaces_from_topology()
 85    lines = []
 86
 87    for machine_name in included:
 88        nets = machine_nets.get(machine_name, [])
 89        single = len(nets) == 1
 90
 91        for i, net_name in enumerate(nets):
 92            name = machine_name if single else f"{machine_name}_{net_name}"
 93            ip = _hosts_address(ips, net_scheme.data.ips, machine_name, i, name)
 94            if ip is not None:
 95                lines.append(_hosts_line(ip, name, domain_extension, separator))
 96            if ipv6:
 97                ip6 = _hosts_address(ips6, getattr(net_scheme.data, 'ips6', None), machine_name, i, name)
 98                if ip6 is not None:
 99                    lines.append(_hosts_line(ip6, name, domain_extension, separator))
100
101    return '\n'.join(lines) + '\n' if lines else ''
102
103
104def create_hosts_file(net_scheme: NetScheme0, domain_extension: str, machine_list=None, included=None, ips=None,
105                      separator: str = "\t\t", ipv6: bool = False, ips6=None):
106    """Write /etc/hosts to each machine in machine_list.
107
108    Each file starts with the standard loopback entries (127.0.0.1 localhost and
109    127.0.1.1 for the machine itself, plus the ::1 / ff02::1 / ff02::2 lines of a
110    Debian host when ipv6=True), followed by the lines produced by
111    hosts_file_content() for the machines in included.
112
113    Args:
114        net_scheme: the NetScheme0 instance
115        domain_extension: domain suffix appended to every hostname (e.g. 'example.com')
116        machine_list: machines that receive the /etc/hosts file; defaults to get_visibles_machines()
117        included: machines whose entries appear in the hosts table; passed through to
118                  hosts_file_content() — defaults to get_visibles_machines() when None
119        ips: dict {machine_name: [IPv4Interface|IPv4Address, ...]} — see hosts_file_content()
120        separator: string placed between fields (default: two tabs)
121        ipv6, ips6: see hosts_file_content()
122    """
123    if machine_list is None:
124        machine_list = included if included is not None else [m.name for m in net_scheme.get_visibles_machines()]
125
126    hosts = hosts_file_content(net_scheme=net_scheme, domain_extension=domain_extension, included=included, ips=ips,
127                               separator=separator, ipv6=ipv6, ips6=ips6)
128
129    for m in machine_list:
130        hosts_start = f"127.0.0.1\t\tlocalhost\n127.0.1.1\t\t{m}\t\t{m}.{domain_extension}\n"
131        if ipv6:
132            hosts_start += ("::1\t\tlocalhost ip6-localhost ip6-loopback\n"
133                            "ff02::1\t\tip6-allnodes\n"
134                            "ff02::2\t\tip6-allrouters\n")
135        net_scheme.file(machine=m, filename='/etc/hosts', content=hosts_start + hosts, permissions=0o0644,
136                        owner="root:root")
137
138
139def change_password(net_scheme: NetScheme0, machine: str, username: str, password: str):
140    """Set *username*'s password on *machine* via chpasswd.
141
142    The password is written to a temporary file (never passed on the command line).
143    """
144    # Write "username:password" to a file so the password never appears in a shell command.
145    net_scheme.file(machine=machine, filename='/tmp/.sre_chpasswd',
146                    content=f'{username}:{password}\n', permissions=0o600)
147    net_scheme.cmd(machine, 'sh -c "chpasswd < /tmp/.sre_chpasswd; rm -f /tmp/.sre_chpasswd"')
148
149
150def create_user(net_scheme: NetScheme0, machine: str, username: str, password: str, uid: int = None, gid: int = None, shell: str = "/bin/bash"):
151    """Create *username* on *machine* (if not already present) and set its password.
152
153    Uses ``useradd -m`` with optional *uid*/*gid* and login *shell*.  The password is written to a
154    temporary file; the username is passed via an environment variable to prevent shell injection.
155    """
156    # Write "username:password" to a file so the password never appears in a shell command.
157    net_scheme.file(machine=machine, filename='/tmp/.sre_chpasswd',
158                    content=f'{username}:{password}\n', permissions=0o600)
159    useradd_opts = f" -s {shlex.quote(shell)}"
160    if uid is not None:
161        useradd_opts += f" -u {int(uid)}"
162    if gid is not None:
163        useradd_opts += f" -g {int(gid)}"
164    # Pass the username via an env var so no user-controlled text appears inside the sh -c string.
165    # "$SRE_USER" is double-quoted in the shell command to prevent word-splitting and glob expansion.
166    net_scheme.cmd(machine,
167                   f"env SRE_USER={shlex.quote(username)} "
168                   f"sh -c 'id \"$SRE_USER\" >/dev/null 2>&1"
169                   f" || useradd{useradd_opts} -m -k /etc/skel \"$SRE_USER\";"
170                   f" chpasswd < /tmp/.sre_chpasswd; rm -f /tmp/.sre_chpasswd'")
171
172
173def setup_simple_tcp_server(net_scheme: NetScheme0, machine: str, port: int, answer: str,
174                            ip: "str | IPv4Interface | IPv4Address | IPv6Interface | IPv6Address" = None,
175                            ipv6: bool = False):
176    """Setup and (re)launch an idempotent TCP server on *machine*.
177
178    The server listens on *port* — bound to *ip* if provided (the network prefix of an
179    ``IPv4Interface`` / ``IPv6Interface`` is stripped; the address decides the family), to
180    ``0.0.0.0`` otherwise, or to ``::`` (dual-stack: IPv4 clients too) with ``ipv6=True`` and
181    no *ip*. On each client connection it sends *answer* (UTF-8) and closes the socket.
182    Calling this function again for the same *port* kills the previous instance before
183    relaunching.
184    """
185    if ip is None:
186        bind_addr = "::" if ipv6 else "0.0.0.0"
187    elif isinstance(ip, (IPv4Interface, IPv6Interface)):
188        bind_addr = str(ip.ip)
189    else:
190        bind_addr = str(ip).split('/')[0]
191    family = "AF_INET6" if ':' in bind_addr else "AF_INET"
192    # a '::' listener also accepts IPv4 clients (mapped addresses) unless IPV6_V6ONLY is set
193    v6only_line = "    s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)\n" if bind_addr == "::" else ""
194
195    script_path = f"/usr/local/sbin/sre_tcp_server_{port}.py"
196    answer_file = f"/var/lib/sre_tcp_server_{port}.answer"
197    log_file = f"/var/log/sre_tcp_server_{port}.log"
198    pid_file = f"/run/sre_tcp_server_{port}.pid"
199
200    net_scheme.file(machine=machine, filename=answer_file, content=answer, permissions=0o644)
201
202    # The script double-forks AND closes the stdio fds inherited from docker exec —
203    # otherwise exec_run keeps streaming and the state op hangs forever. After the
204    # second fork, fds 0/1/2 are reopened on /dev/null (stdin) and the per-port log
205    # file (stdout/stderr), so binding/startup failures land in the log. The daemon
206    # also writes its PID to a per-port file so the launcher can kill the previous
207    # instance without using `pkill -f` (which would match the launcher's own sh -c
208    # argument and kill the shell before the python3 command runs).
209    script_content = (
210        "#!/usr/bin/env python3\n"
211        "import os, socket, traceback\n"
212        "if os.fork() != 0: os._exit(0)\n"
213        "os.setsid()\n"
214        "if os.fork() != 0: os._exit(0)\n"
215        "# detach from docker exec's stdio so exec_run can return\n"
216        "for fd in (0, 1, 2):\n"
217        "    try: os.close(fd)\n"
218        "    except OSError: pass\n"
219        "os.open(os.devnull, os.O_RDONLY)  # fd 0\n"
220        f"_log = os.open({log_file!r}, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)  # fd 1\n"
221        "os.dup2(_log, 2)  # fd 2 = log\n"
222        f"with open({pid_file!r}, 'w') as _pf: _pf.write(str(os.getpid()))\n"
223        "try:\n"
224        f"    with open({answer_file!r}, 'rb') as f:\n"
225        "        answer = f.read()\n"
226        f"    s = socket.socket(socket.{family}, socket.SOCK_STREAM)\n"
227        "    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n"
228        f"{v6only_line}"
229        f"    s.bind(({bind_addr!r}, {int(port)}))\n"
230        "    s.listen(16)\n"
231        "    while True:\n"
232        "        conn, _ = s.accept()\n"
233        "        try:\n"
234        "            conn.sendall(answer)\n"
235        "        finally:\n"
236        "            conn.close()\n"
237        "except Exception:\n"
238        "    traceback.print_exc()\n"
239        "    os._exit(1)\n"
240    )
241    net_scheme.file(machine=machine, filename=script_path, content=script_content, permissions=0o755)
242
243    quoted_script = shlex.quote(script_path)
244    quoted_pidfile = shlex.quote(pid_file)
245    # If a previous instance left a PID file, kill it and give the kernel a moment
246    # to release the port; ignore stale PIDs. Then launch the new daemon. The script
247    # daemonizes itself, so this command returns immediately.
248    net_scheme.cmd(machine,
249                   f"sh -c '[ -f {quoted_pidfile} ] && kill $(cat {quoted_pidfile}) 2>/dev/null; "
250                   f"sleep 0.2; "
251                   f"python3 {quoted_script}'")
252
253
254def _on_off(flag: bool) -> str:
255    return "on" if flag else "off"
256
257
258def _plain_v6(addr) -> str:
259    """IPv6 address without prefix, from a string or an ipaddress object."""
260    if isinstance(addr, IPv6Interface):
261        return str(addr.ip)
262    return str(addr).split('/')[0]
263
264
265def render_radvd_conf(prefixes: dict, *, rdnss=None, dnssl=None, min_rtr_adv_interval: int = 3,
266                      max_rtr_adv_interval: int = 10, adv_autonomous: bool = True, adv_on_link: bool = True,
267                      adv_router_addr: bool = False, adv_managed: bool = False,
268                      adv_other_config: bool = False) -> str:
269    """Text of /etc/radvd.conf announcing *prefixes* (see set_radvd() for the arguments).
270
271    *adv_managed* / *adv_other_config* set the M / O flags of the advertisements
272    (``AdvManagedFlag on;`` / ``AdvOtherConfigFlag on;``: addresses / the other parameters are
273    to be obtained by DHCPv6); the lines are written only when True.
274    """
275    if not prefixes:
276        raise ValueError("set_radvd: no prefix to advertise")
277    blocks = []
278    for iface, plist in prefixes.items():
279        iface_name = f"eth{iface}" if isinstance(iface, int) else str(iface)
280        if isinstance(plist, (str, IPv6Network, IPv6Interface)):
281            plist = [plist]
282        nets = [IPv6Network(str(p), strict=False) for p in plist]
283        if not nets:
284            raise ValueError(f"set_radvd: no prefix for {iface_name}")
285        for net in nets:
286            if adv_autonomous and net.prefixlen != 64:
287                raise ValueError(f"set_radvd: {net} is not a /64, SLAAC (adv_autonomous) needs 64-bit prefixes")
288        lines = [
289            f"interface {iface_name}",
290            "{",
291            "    AdvSendAdvert on;",
292            f"    MinRtrAdvInterval {int(min_rtr_adv_interval)};",
293            f"    MaxRtrAdvInterval {int(max_rtr_adv_interval)};",
294        ]
295        if adv_managed:
296            lines.append("    AdvManagedFlag on;")
297        if adv_other_config:
298            lines.append("    AdvOtherConfigFlag on;")
299        for net in nets:
300            lines += [
301                f"    prefix {net}",
302                "    {",
303                f"        AdvOnLink {_on_off(adv_on_link)};",
304                f"        AdvAutonomous {_on_off(adv_autonomous)};",
305                f"        AdvRouterAddr {_on_off(adv_router_addr)};",
306                "    };",
307            ]
308        if rdnss:
309            lines.append(f"    RDNSS {' '.join(_plain_v6(a) for a in rdnss)} {{ }};")
310        if dnssl:
311            lines.append(f"    DNSSL {' '.join(str(d) for d in dnssl)} {{ }};")
312        lines.append("};")
313        blocks.append('\n'.join(lines))
314    return '\n\n'.join(blocks) + '\n'
315
316
317def set_radvd(net_scheme: NetScheme0, machine: str, prefixes: dict, step: int = 1, *,
318              rdnss=None, dnssl=None, min_rtr_adv_interval: int = 3, max_rtr_adv_interval: int = 10,
319              adv_autonomous: bool = True, adv_on_link: bool = True, adv_router_addr: bool = False,
320              adv_managed: bool = False, adv_other_config: bool = False,
321              enable_forwarding: bool = True) -> str:
322    """Write /etc/radvd.conf on *machine* and (re)start radvd: the machine then announces the
323    given prefixes in router advertisements, so the hosts of those LANs configure themselves
324    with SLAAC (see set_slaac_client()) and learn it as their default router.
325
326    Args:
327        prefixes: ``{interface: [prefix, ...]}`` — the interface as ``'eth1'`` or ``1``, each
328                  prefix as an ``IPv6Network``, an ``IPv6Interface`` (its network is used) or
329                  a string; with *adv_autonomous* every prefix must be a /64 (SLAAC).
330        rdnss: optional list of recursive DNS server addresses announced in the RAs.
331        dnssl: optional list of DNS search domains announced in the RAs.
332        min_rtr_adv_interval, max_rtr_adv_interval: radvd timers in seconds (short by
333                  default so that hosts configure themselves within seconds of the state).
334        adv_autonomous, adv_on_link, adv_router_addr: the prefix flags (radvd.conf(5)).
335        adv_managed, adv_other_config: the M and O flags of the advertisements (the hosts
336                  are to get their addresses / their other parameters from DHCPv6).
337        enable_forwarding: also set net.ipv6.conf.all.forwarding=1 (radvd requires it and a
338                  router advertising a prefix forwards anyway); False to leave it as is.
339
340    Returns the radvd.conf text (render_radvd_conf() gives it without applying it).  Hosts must
341    accept RAs: Kathara starts every machine with forwarding enabled, which makes the kernel
342    ignore RAs unless accept_ra=2 — call set_slaac_client() on them.
343    """
344    content = render_radvd_conf(prefixes, rdnss=rdnss, dnssl=dnssl, min_rtr_adv_interval=min_rtr_adv_interval,
345                                max_rtr_adv_interval=max_rtr_adv_interval, adv_autonomous=adv_autonomous,
346                                adv_on_link=adv_on_link, adv_router_addr=adv_router_addr,
347                                adv_managed=adv_managed, adv_other_config=adv_other_config)
348    net_scheme.file(machine=machine, filename='/etc/radvd.conf', content=content, permissions=0o644, step=step)
349    if enable_forwarding:
350        set_ipv6_forward(net_scheme, machine, True, step=step)
351    net_scheme.cmd(machine, 'systemctl enable radvd', step=step)
352    net_scheme.cmd(machine, 'systemctl restart radvd', step=step)
353    return content
354
355
356def set_slaac_client(net_scheme: NetScheme0, machine: str, interfaces=None, step: int = 1):
357    """Make *machine* accept router advertisements (SLAAC address + default route) on
358    *interfaces* (``'eth0'`` or ``0``; default: every interface of the topology).
359
360    Sets ``accept_ra=2`` and ``autoconf=1`` per interface: Kathara starts the machines of an
361    IPv6 lab with forwarding enabled, and a forwarding host ignores RAs unless accept_ra=2.
362    It keeps working after set_ipv6_forward(net_scheme, machine, False).
363    """
364    if interfaces is None:
365        m = net_scheme.get_machine(machine)
366        interfaces = sorted(a.interface for a in m.net_adapters.values())
367    names = [f"eth{i}" if isinstance(i, int) else str(i) for i in interfaces]
368    remount_proc_sys(net_scheme, machine)
369    for iface in names:
370        net_scheme.cmd(machine, f'sysctl -w net.ipv6.conf.{iface}.accept_ra=2', step=step)
371        net_scheme.cmd(machine, f'sysctl -w net.ipv6.conf.{iface}.autoconf=1', step=step)
def set_unbound_server(net_scheme: SRE.lib_sre.NetScheme0, machine: str):
 9def set_unbound_server(net_scheme: NetScheme0, machine: str):
10    """Write a permissive Unbound DNS config and start the service on *machine*."""
11    set_basic_unbound_server(net_scheme=net_scheme, machine=machine)

Write a permissive Unbound DNS config and start the service on machine.

def set_basic_unbound_server(net_scheme: SRE.lib_sre.NetScheme0, machine: str):
14def set_basic_unbound_server(net_scheme: NetScheme0, machine: str):
15    """Write /etc/unbound/unbound.conf (listen on 0.0.0.0, allow all) and start unbound on *machine*."""
16    net_scheme.file(machine=machine, filename='/etc/unbound/unbound.conf', content="""
17# Unbound configuration file for Debian.
18#
19# See the unbound.conf(5) man page.
20#
21# See /usr/share/doc/unbound/examples/unbound.conf for a commented
22# reference config file.
23#
24# The following line includes additional configuration files from the
25# /etc/unbound/unbound.conf.d directory.
26include-toplevel: "/etc/unbound/unbound.conf.d/*.conf"
27server:
28    interface: 0.0.0.0
29    access-control: 0.0.0.0/0 allow
30""")
31    net_scheme.cmd(machine, "systemctl start unbound")

Write /etc/unbound/unbound.conf (listen on 0.0.0.0, allow all) and start unbound on machine.

def set_nat_gateway(net_scheme: SRE.lib_sre.NetScheme0, machine: str):
34def set_nat_gateway(net_scheme: NetScheme0, machine: str):
35    """Add an iptables MASQUERADE rule on *machine*'s bridged interface.
36
37    The machine must have been declared with ``bridged=True``; Kathara appends
38    the bridged interface as the next ``eth{N}`` after all topology-defined
39    adapters (i.e. its index equals the highest assigned interface number + 1).
40    """
41    m = net_scheme.get_machine(machine)
42    iface_numbers = [a.interface for a in m.net_adapters.values()]
43    bridged_iface = (max(iface_numbers) + 1) if iface_numbers else 0
44    net_scheme.cmd(machine,
45                   f"sh -c 'iptables -t nat -C POSTROUTING -o eth{bridged_iface} -j MASQUERADE 2>/dev/null"
46                   f" || iptables -t nat -A POSTROUTING -o eth{bridged_iface} -j MASQUERADE'")

Add an iptables MASQUERADE rule on machine's bridged interface.

The machine must have been declared with bridged=True; Kathara appends the bridged interface as the next eth{N} after all topology-defined adapters (i.e. its index equals the highest assigned interface number + 1).

def hosts_file_content( net_scheme: SRE.lib_sre.NetScheme0, domain_extension: str, included=None, ips=None, separator: str = '\t\t', ipv6: bool = False, ips6=None) -> str:
 66def hosts_file_content(net_scheme: NetScheme0, domain_extension: str, included=None, ips=None,
 67                       separator: str = "\t\t", ipv6: bool = False, ips6=None) -> str:
 68    """Return /etc/hosts lines for the given machines.
 69
 70    Args:
 71        net_scheme: the NetScheme0 instance
 72        domain_extension: domain suffix (e.g. 'example.com')
 73        included: list of machine names; defaults to get_visibles_machines()
 74        ips: dict {machine_name: [IPv4Interface|IPv4Address, ...]} one ip per network,
 75             in the same order as host_interfaces_from_topology().
 76             If None, addresses are read from net_scheme.data.ips.*
 77        separator: string placed between fields (default: two tabs)
 78        ipv6: also write an IPv6 line (right after the IPv4 one) for every machine/network
 79              that has one, from *ips6* or from net_scheme.data.ips6.*
 80        ips6: dict {machine_name: [IPv6Interface|IPv6Address, ...]} like *ips* (ipv6=True)
 81    """
 82    if included is None:
 83        included = [m.name for m in net_scheme.get_visibles_machines()]
 84
 85    machine_nets = net_scheme.host_interfaces_from_topology()
 86    lines = []
 87
 88    for machine_name in included:
 89        nets = machine_nets.get(machine_name, [])
 90        single = len(nets) == 1
 91
 92        for i, net_name in enumerate(nets):
 93            name = machine_name if single else f"{machine_name}_{net_name}"
 94            ip = _hosts_address(ips, net_scheme.data.ips, machine_name, i, name)
 95            if ip is not None:
 96                lines.append(_hosts_line(ip, name, domain_extension, separator))
 97            if ipv6:
 98                ip6 = _hosts_address(ips6, getattr(net_scheme.data, 'ips6', None), machine_name, i, name)
 99                if ip6 is not None:
100                    lines.append(_hosts_line(ip6, name, domain_extension, separator))
101
102    return '\n'.join(lines) + '\n' if lines else ''

Return /etc/hosts lines for the given machines.

Arguments:
  • net_scheme: the NetScheme0 instance
  • domain_extension: domain suffix (e.g. 'example.com')
  • included: list of machine names; defaults to get_visibles_machines()
  • ips: dict {machine_name: [IPv4Interface|IPv4Address, ...]} one ip per network, in the same order as host_interfaces_from_topology(). If None, addresses are read from net_scheme.data.ips.*
  • separator: string placed between fields (default: two tabs)
  • ipv6: also write an IPv6 line (right after the IPv4 one) for every machine/network that has one, from ips6 or from net_scheme.data.ips6.*
  • ips6: dict {machine_name: [IPv6Interface|IPv6Address, ...]} like ips (ipv6=True)
def create_hosts_file( net_scheme: SRE.lib_sre.NetScheme0, domain_extension: str, machine_list=None, included=None, ips=None, separator: str = '\t\t', ipv6: bool = False, ips6=None):
105def create_hosts_file(net_scheme: NetScheme0, domain_extension: str, machine_list=None, included=None, ips=None,
106                      separator: str = "\t\t", ipv6: bool = False, ips6=None):
107    """Write /etc/hosts to each machine in machine_list.
108
109    Each file starts with the standard loopback entries (127.0.0.1 localhost and
110    127.0.1.1 for the machine itself, plus the ::1 / ff02::1 / ff02::2 lines of a
111    Debian host when ipv6=True), followed by the lines produced by
112    hosts_file_content() for the machines in included.
113
114    Args:
115        net_scheme: the NetScheme0 instance
116        domain_extension: domain suffix appended to every hostname (e.g. 'example.com')
117        machine_list: machines that receive the /etc/hosts file; defaults to get_visibles_machines()
118        included: machines whose entries appear in the hosts table; passed through to
119                  hosts_file_content() — defaults to get_visibles_machines() when None
120        ips: dict {machine_name: [IPv4Interface|IPv4Address, ...]} — see hosts_file_content()
121        separator: string placed between fields (default: two tabs)
122        ipv6, ips6: see hosts_file_content()
123    """
124    if machine_list is None:
125        machine_list = included if included is not None else [m.name for m in net_scheme.get_visibles_machines()]
126
127    hosts = hosts_file_content(net_scheme=net_scheme, domain_extension=domain_extension, included=included, ips=ips,
128                               separator=separator, ipv6=ipv6, ips6=ips6)
129
130    for m in machine_list:
131        hosts_start = f"127.0.0.1\t\tlocalhost\n127.0.1.1\t\t{m}\t\t{m}.{domain_extension}\n"
132        if ipv6:
133            hosts_start += ("::1\t\tlocalhost ip6-localhost ip6-loopback\n"
134                            "ff02::1\t\tip6-allnodes\n"
135                            "ff02::2\t\tip6-allrouters\n")
136        net_scheme.file(machine=m, filename='/etc/hosts', content=hosts_start + hosts, permissions=0o0644,
137                        owner="root:root")

Write /etc/hosts to each machine in machine_list.

Each file starts with the standard loopback entries (127.0.0.1 localhost and 127.0.1.1 for the machine itself, plus the ::1 / ff02::1 / ff02::2 lines of a Debian host when ipv6=True), followed by the lines produced by hosts_file_content() for the machines in included.

Arguments:
  • net_scheme: the NetScheme0 instance
  • domain_extension: domain suffix appended to every hostname (e.g. 'example.com')
  • machine_list: machines that receive the /etc/hosts file; defaults to get_visibles_machines()
  • included: machines whose entries appear in the hosts table; passed through to hosts_file_content() — defaults to get_visibles_machines() when None
  • ips: dict {machine_name: [IPv4Interface|IPv4Address, ...]} — see hosts_file_content()
  • separator: string placed between fields (default: two tabs)
  • ipv6, ips6: see hosts_file_content()
def change_password( net_scheme: SRE.lib_sre.NetScheme0, machine: str, username: str, password: str):
140def change_password(net_scheme: NetScheme0, machine: str, username: str, password: str):
141    """Set *username*'s password on *machine* via chpasswd.
142
143    The password is written to a temporary file (never passed on the command line).
144    """
145    # Write "username:password" to a file so the password never appears in a shell command.
146    net_scheme.file(machine=machine, filename='/tmp/.sre_chpasswd',
147                    content=f'{username}:{password}\n', permissions=0o600)
148    net_scheme.cmd(machine, 'sh -c "chpasswd < /tmp/.sre_chpasswd; rm -f /tmp/.sre_chpasswd"')

Set username's password on machine via chpasswd.

The password is written to a temporary file (never passed on the command line).

def create_user( net_scheme: SRE.lib_sre.NetScheme0, machine: str, username: str, password: str, uid: int = None, gid: int = None, shell: str = '/bin/bash'):
151def create_user(net_scheme: NetScheme0, machine: str, username: str, password: str, uid: int = None, gid: int = None, shell: str = "/bin/bash"):
152    """Create *username* on *machine* (if not already present) and set its password.
153
154    Uses ``useradd -m`` with optional *uid*/*gid* and login *shell*.  The password is written to a
155    temporary file; the username is passed via an environment variable to prevent shell injection.
156    """
157    # Write "username:password" to a file so the password never appears in a shell command.
158    net_scheme.file(machine=machine, filename='/tmp/.sre_chpasswd',
159                    content=f'{username}:{password}\n', permissions=0o600)
160    useradd_opts = f" -s {shlex.quote(shell)}"
161    if uid is not None:
162        useradd_opts += f" -u {int(uid)}"
163    if gid is not None:
164        useradd_opts += f" -g {int(gid)}"
165    # Pass the username via an env var so no user-controlled text appears inside the sh -c string.
166    # "$SRE_USER" is double-quoted in the shell command to prevent word-splitting and glob expansion.
167    net_scheme.cmd(machine,
168                   f"env SRE_USER={shlex.quote(username)} "
169                   f"sh -c 'id \"$SRE_USER\" >/dev/null 2>&1"
170                   f" || useradd{useradd_opts} -m -k /etc/skel \"$SRE_USER\";"
171                   f" chpasswd < /tmp/.sre_chpasswd; rm -f /tmp/.sre_chpasswd'")

Create username on machine (if not already present) and set its password.

Uses useradd -m with optional uid/gid and login shell. The password is written to a temporary file; the username is passed via an environment variable to prevent shell injection.

def setup_simple_tcp_server( net_scheme: SRE.lib_sre.NetScheme0, machine: str, port: int, answer: str, ip: str | ipaddress.IPv4Interface | ipaddress.IPv4Address | ipaddress.IPv6Interface | ipaddress.IPv6Address = None, ipv6: bool = False):
174def setup_simple_tcp_server(net_scheme: NetScheme0, machine: str, port: int, answer: str,
175                            ip: "str | IPv4Interface | IPv4Address | IPv6Interface | IPv6Address" = None,
176                            ipv6: bool = False):
177    """Setup and (re)launch an idempotent TCP server on *machine*.
178
179    The server listens on *port* — bound to *ip* if provided (the network prefix of an
180    ``IPv4Interface`` / ``IPv6Interface`` is stripped; the address decides the family), to
181    ``0.0.0.0`` otherwise, or to ``::`` (dual-stack: IPv4 clients too) with ``ipv6=True`` and
182    no *ip*. On each client connection it sends *answer* (UTF-8) and closes the socket.
183    Calling this function again for the same *port* kills the previous instance before
184    relaunching.
185    """
186    if ip is None:
187        bind_addr = "::" if ipv6 else "0.0.0.0"
188    elif isinstance(ip, (IPv4Interface, IPv6Interface)):
189        bind_addr = str(ip.ip)
190    else:
191        bind_addr = str(ip).split('/')[0]
192    family = "AF_INET6" if ':' in bind_addr else "AF_INET"
193    # a '::' listener also accepts IPv4 clients (mapped addresses) unless IPV6_V6ONLY is set
194    v6only_line = "    s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)\n" if bind_addr == "::" else ""
195
196    script_path = f"/usr/local/sbin/sre_tcp_server_{port}.py"
197    answer_file = f"/var/lib/sre_tcp_server_{port}.answer"
198    log_file = f"/var/log/sre_tcp_server_{port}.log"
199    pid_file = f"/run/sre_tcp_server_{port}.pid"
200
201    net_scheme.file(machine=machine, filename=answer_file, content=answer, permissions=0o644)
202
203    # The script double-forks AND closes the stdio fds inherited from docker exec —
204    # otherwise exec_run keeps streaming and the state op hangs forever. After the
205    # second fork, fds 0/1/2 are reopened on /dev/null (stdin) and the per-port log
206    # file (stdout/stderr), so binding/startup failures land in the log. The daemon
207    # also writes its PID to a per-port file so the launcher can kill the previous
208    # instance without using `pkill -f` (which would match the launcher's own sh -c
209    # argument and kill the shell before the python3 command runs).
210    script_content = (
211        "#!/usr/bin/env python3\n"
212        "import os, socket, traceback\n"
213        "if os.fork() != 0: os._exit(0)\n"
214        "os.setsid()\n"
215        "if os.fork() != 0: os._exit(0)\n"
216        "# detach from docker exec's stdio so exec_run can return\n"
217        "for fd in (0, 1, 2):\n"
218        "    try: os.close(fd)\n"
219        "    except OSError: pass\n"
220        "os.open(os.devnull, os.O_RDONLY)  # fd 0\n"
221        f"_log = os.open({log_file!r}, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)  # fd 1\n"
222        "os.dup2(_log, 2)  # fd 2 = log\n"
223        f"with open({pid_file!r}, 'w') as _pf: _pf.write(str(os.getpid()))\n"
224        "try:\n"
225        f"    with open({answer_file!r}, 'rb') as f:\n"
226        "        answer = f.read()\n"
227        f"    s = socket.socket(socket.{family}, socket.SOCK_STREAM)\n"
228        "    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n"
229        f"{v6only_line}"
230        f"    s.bind(({bind_addr!r}, {int(port)}))\n"
231        "    s.listen(16)\n"
232        "    while True:\n"
233        "        conn, _ = s.accept()\n"
234        "        try:\n"
235        "            conn.sendall(answer)\n"
236        "        finally:\n"
237        "            conn.close()\n"
238        "except Exception:\n"
239        "    traceback.print_exc()\n"
240        "    os._exit(1)\n"
241    )
242    net_scheme.file(machine=machine, filename=script_path, content=script_content, permissions=0o755)
243
244    quoted_script = shlex.quote(script_path)
245    quoted_pidfile = shlex.quote(pid_file)
246    # If a previous instance left a PID file, kill it and give the kernel a moment
247    # to release the port; ignore stale PIDs. Then launch the new daemon. The script
248    # daemonizes itself, so this command returns immediately.
249    net_scheme.cmd(machine,
250                   f"sh -c '[ -f {quoted_pidfile} ] && kill $(cat {quoted_pidfile}) 2>/dev/null; "
251                   f"sleep 0.2; "
252                   f"python3 {quoted_script}'")

Setup and (re)launch an idempotent TCP server on machine.

The server listens on port — bound to ip if provided (the network prefix of an IPv4Interface / IPv6Interface is stripped; the address decides the family), to 0.0.0.0 otherwise, or to :: (dual-stack: IPv4 clients too) with ipv6=True and no ip. On each client connection it sends answer (UTF-8) and closes the socket. Calling this function again for the same port kills the previous instance before relaunching.

def render_radvd_conf( prefixes: dict, *, rdnss=None, dnssl=None, min_rtr_adv_interval: int = 3, max_rtr_adv_interval: int = 10, adv_autonomous: bool = True, adv_on_link: bool = True, adv_router_addr: bool = False, adv_managed: bool = False, adv_other_config: bool = False) -> str:
266def render_radvd_conf(prefixes: dict, *, rdnss=None, dnssl=None, min_rtr_adv_interval: int = 3,
267                      max_rtr_adv_interval: int = 10, adv_autonomous: bool = True, adv_on_link: bool = True,
268                      adv_router_addr: bool = False, adv_managed: bool = False,
269                      adv_other_config: bool = False) -> str:
270    """Text of /etc/radvd.conf announcing *prefixes* (see set_radvd() for the arguments).
271
272    *adv_managed* / *adv_other_config* set the M / O flags of the advertisements
273    (``AdvManagedFlag on;`` / ``AdvOtherConfigFlag on;``: addresses / the other parameters are
274    to be obtained by DHCPv6); the lines are written only when True.
275    """
276    if not prefixes:
277        raise ValueError("set_radvd: no prefix to advertise")
278    blocks = []
279    for iface, plist in prefixes.items():
280        iface_name = f"eth{iface}" if isinstance(iface, int) else str(iface)
281        if isinstance(plist, (str, IPv6Network, IPv6Interface)):
282            plist = [plist]
283        nets = [IPv6Network(str(p), strict=False) for p in plist]
284        if not nets:
285            raise ValueError(f"set_radvd: no prefix for {iface_name}")
286        for net in nets:
287            if adv_autonomous and net.prefixlen != 64:
288                raise ValueError(f"set_radvd: {net} is not a /64, SLAAC (adv_autonomous) needs 64-bit prefixes")
289        lines = [
290            f"interface {iface_name}",
291            "{",
292            "    AdvSendAdvert on;",
293            f"    MinRtrAdvInterval {int(min_rtr_adv_interval)};",
294            f"    MaxRtrAdvInterval {int(max_rtr_adv_interval)};",
295        ]
296        if adv_managed:
297            lines.append("    AdvManagedFlag on;")
298        if adv_other_config:
299            lines.append("    AdvOtherConfigFlag on;")
300        for net in nets:
301            lines += [
302                f"    prefix {net}",
303                "    {",
304                f"        AdvOnLink {_on_off(adv_on_link)};",
305                f"        AdvAutonomous {_on_off(adv_autonomous)};",
306                f"        AdvRouterAddr {_on_off(adv_router_addr)};",
307                "    };",
308            ]
309        if rdnss:
310            lines.append(f"    RDNSS {' '.join(_plain_v6(a) for a in rdnss)} {{ }};")
311        if dnssl:
312            lines.append(f"    DNSSL {' '.join(str(d) for d in dnssl)} {{ }};")
313        lines.append("};")
314        blocks.append('\n'.join(lines))
315    return '\n\n'.join(blocks) + '\n'

Text of /etc/radvd.conf announcing prefixes (see set_radvd() for the arguments).

adv_managed / adv_other_config set the M / O flags of the advertisements (AdvManagedFlag on; / AdvOtherConfigFlag on;: addresses / the other parameters are to be obtained by DHCPv6); the lines are written only when True.

def set_radvd( net_scheme: SRE.lib_sre.NetScheme0, machine: str, prefixes: dict, step: int = 1, *, rdnss=None, dnssl=None, min_rtr_adv_interval: int = 3, max_rtr_adv_interval: int = 10, adv_autonomous: bool = True, adv_on_link: bool = True, adv_router_addr: bool = False, adv_managed: bool = False, adv_other_config: bool = False, enable_forwarding: bool = True) -> str:
318def set_radvd(net_scheme: NetScheme0, machine: str, prefixes: dict, step: int = 1, *,
319              rdnss=None, dnssl=None, min_rtr_adv_interval: int = 3, max_rtr_adv_interval: int = 10,
320              adv_autonomous: bool = True, adv_on_link: bool = True, adv_router_addr: bool = False,
321              adv_managed: bool = False, adv_other_config: bool = False,
322              enable_forwarding: bool = True) -> str:
323    """Write /etc/radvd.conf on *machine* and (re)start radvd: the machine then announces the
324    given prefixes in router advertisements, so the hosts of those LANs configure themselves
325    with SLAAC (see set_slaac_client()) and learn it as their default router.
326
327    Args:
328        prefixes: ``{interface: [prefix, ...]}`` — the interface as ``'eth1'`` or ``1``, each
329                  prefix as an ``IPv6Network``, an ``IPv6Interface`` (its network is used) or
330                  a string; with *adv_autonomous* every prefix must be a /64 (SLAAC).
331        rdnss: optional list of recursive DNS server addresses announced in the RAs.
332        dnssl: optional list of DNS search domains announced in the RAs.
333        min_rtr_adv_interval, max_rtr_adv_interval: radvd timers in seconds (short by
334                  default so that hosts configure themselves within seconds of the state).
335        adv_autonomous, adv_on_link, adv_router_addr: the prefix flags (radvd.conf(5)).
336        adv_managed, adv_other_config: the M and O flags of the advertisements (the hosts
337                  are to get their addresses / their other parameters from DHCPv6).
338        enable_forwarding: also set net.ipv6.conf.all.forwarding=1 (radvd requires it and a
339                  router advertising a prefix forwards anyway); False to leave it as is.
340
341    Returns the radvd.conf text (render_radvd_conf() gives it without applying it).  Hosts must
342    accept RAs: Kathara starts every machine with forwarding enabled, which makes the kernel
343    ignore RAs unless accept_ra=2 — call set_slaac_client() on them.
344    """
345    content = render_radvd_conf(prefixes, rdnss=rdnss, dnssl=dnssl, min_rtr_adv_interval=min_rtr_adv_interval,
346                                max_rtr_adv_interval=max_rtr_adv_interval, adv_autonomous=adv_autonomous,
347                                adv_on_link=adv_on_link, adv_router_addr=adv_router_addr,
348                                adv_managed=adv_managed, adv_other_config=adv_other_config)
349    net_scheme.file(machine=machine, filename='/etc/radvd.conf', content=content, permissions=0o644, step=step)
350    if enable_forwarding:
351        set_ipv6_forward(net_scheme, machine, True, step=step)
352    net_scheme.cmd(machine, 'systemctl enable radvd', step=step)
353    net_scheme.cmd(machine, 'systemctl restart radvd', step=step)
354    return content

Write /etc/radvd.conf on machine and (re)start radvd: the machine then announces the given prefixes in router advertisements, so the hosts of those LANs configure themselves with SLAAC (see set_slaac_client()) and learn it as their default router.

Arguments:
  • prefixes: {interface: [prefix, ...]} — the interface as 'eth1' or 1, each prefix as an IPv6Network, an IPv6Interface (its network is used) or a string; with adv_autonomous every prefix must be a /64 (SLAAC).
  • rdnss: optional list of recursive DNS server addresses announced in the RAs.
  • dnssl: optional list of DNS search domains announced in the RAs.
  • min_rtr_adv_interval, max_rtr_adv_interval: radvd timers in seconds (short by default so that hosts configure themselves within seconds of the state).
  • adv_autonomous, adv_on_link, adv_router_addr: the prefix flags (radvd.conf(5)).
  • adv_managed, adv_other_config: the M and O flags of the advertisements (the hosts are to get their addresses / their other parameters from DHCPv6).
  • enable_forwarding: also set net.ipv6.conf.all.forwarding=1 (radvd requires it and a router advertising a prefix forwards anyway); False to leave it as is.

Returns the radvd.conf text (render_radvd_conf() gives it without applying it). Hosts must accept RAs: Kathara starts every machine with forwarding enabled, which makes the kernel ignore RAs unless accept_ra=2 — call set_slaac_client() on them.

def set_slaac_client( net_scheme: SRE.lib_sre.NetScheme0, machine: str, interfaces=None, step: int = 1):
357def set_slaac_client(net_scheme: NetScheme0, machine: str, interfaces=None, step: int = 1):
358    """Make *machine* accept router advertisements (SLAAC address + default route) on
359    *interfaces* (``'eth0'`` or ``0``; default: every interface of the topology).
360
361    Sets ``accept_ra=2`` and ``autoconf=1`` per interface: Kathara starts the machines of an
362    IPv6 lab with forwarding enabled, and a forwarding host ignores RAs unless accept_ra=2.
363    It keeps working after set_ipv6_forward(net_scheme, machine, False).
364    """
365    if interfaces is None:
366        m = net_scheme.get_machine(machine)
367        interfaces = sorted(a.interface for a in m.net_adapters.values())
368    names = [f"eth{i}" if isinstance(i, int) else str(i) for i in interfaces]
369    remount_proc_sys(net_scheme, machine)
370    for iface in names:
371        net_scheme.cmd(machine, f'sysctl -w net.ipv6.conf.{iface}.accept_ra=2', step=step)
372        net_scheme.cmd(machine, f'sysctl -w net.ipv6.conf.{iface}.autoconf=1', step=step)

Make machine accept router advertisements (SLAAC address + default route) on interfaces ('eth0' or 0; default: every interface of the topology).

Sets accept_ra=2 and autoconf=1 per interface: Kathara starts the machines of an IPv6 lab with forwarding enabled, and a forwarding host ignores RAs unless accept_ra=2. It keeps working after set_ipv6_forward(net_scheme, machine, False).