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)
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.
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.
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).
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)
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()
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).
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.
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.
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.
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'or1, each prefix as anIPv6Network, anIPv6Interface(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.
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).