Skip to content

Commit 397205f

Browse files
committed
feat: add ssh module and net/disk/shell helpers
* ssh.py: new module to run commands and copy files over SSH (build_options, target, run, scp, rsync) * net.py: cidr_to_hosts() and get_subnet_hosts() enumerate the usable IPv4/IPv6 hosts of a network or interface, with a configurable size cap * disk.py: copy_file(), copy_dir(), rm_dir(), make_temp_dir(), mkdir() * shell.py: which() * add unit tests for the new helpers
1 parent e03d53d commit 397205f

10 files changed

Lines changed: 805 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 16 deletions
Large diffs are not rendered by default.

disk.py

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
"""
1414

1515
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
16-
__version__ = '2026060201'
16+
__version__ = '2026060601'
1717

1818
import csv
1919
import os
2020
import re
21+
import shutil
2122
import tempfile
2223

2324
from . import shell
@@ -55,6 +56,65 @@ def bd2dmd(device):
5556
return mapped_device if os.path.islink(mapped_device) else ''
5657

5758

59+
def copy_dir(src, dst):
60+
"""
61+
Recursively copy a directory tree.
62+
63+
Wraps `shutil.copytree()` and reports the outcome in the same
64+
`(success, error)` style as the other disk helpers, so callers do not have to
65+
handle exceptions themselves.
66+
67+
### Parameters
68+
- **src** (`str`): Source directory.
69+
- **dst** (`str`): Destination directory (must not exist yet).
70+
71+
### Returns
72+
- **tuple**:
73+
- tuple[0] (**bool**): True if the copy succeeded, otherwise False.
74+
- tuple[1] (**None or str**): None on success, otherwise an error message.
75+
76+
### Example
77+
>>> copy_dir('/usr/share/lynis', '/tmp/lynis')
78+
(True, None)
79+
"""
80+
try:
81+
shutil.copytree(src, dst)
82+
return True, None
83+
except (OSError, shutil.Error) as e:
84+
return False, f'Error copying directory {src} to {dst}: {e}'
85+
except Exception as e:
86+
return False, f'Unknown error copying directory {src} to {dst}: {e}'
87+
88+
89+
def copy_file(src, dst):
90+
"""
91+
Copy a single file, preserving its metadata.
92+
93+
Wraps `shutil.copy2()` and reports the outcome in the same `(success, error)`
94+
style as the other disk helpers.
95+
96+
### Parameters
97+
- **src** (`str`): Source file.
98+
- **dst** (`str`): Destination file or directory.
99+
100+
### Returns
101+
- **tuple**:
102+
- tuple[0] (**bool**): True if the copy succeeded, otherwise False.
103+
- tuple[1] (**None or str**): None on success, otherwise an error message.
104+
105+
### Example
106+
>>> copy_file('/etc/lynis/default.prf', '/tmp/lynis/default.prf')
107+
(True, None)
108+
"""
109+
try:
110+
shutil.copy2(src, dst)
111+
return True, None
112+
except OSError as e:
113+
return False, f'OS error "{e.strerror}" while copying {src} to {dst}'
114+
except Exception as e:
115+
return False, f'Unknown error copying {src} to {dst}: {e}'
116+
117+
58118
def dir_exists(path):
59119
"""
60120
Check if a directory exists at the given path.
@@ -375,6 +435,67 @@ def grep_file(filename, pattern):
375435
return True, ''
376436

377437

438+
def make_temp_dir(prefix=''):
439+
"""
440+
Create a unique temporary directory and return its path.
441+
442+
Wraps `tempfile.mkdtemp()` and reports the outcome in the same
443+
`(success, result)` style as the other disk helpers.
444+
445+
### Parameters
446+
- **prefix** (`str`, optional): Prefix for the directory name. Defaults to ''.
447+
448+
### Returns
449+
- **tuple**:
450+
- tuple[0] (**bool**): True on success, otherwise False.
451+
- tuple[1] (**str**): The created directory path on success, otherwise an
452+
error message.
453+
454+
### Example
455+
>>> make_temp_dir(prefix='myapp-')
456+
(True, '/tmp/myapp-abcd1234')
457+
"""
458+
try:
459+
return True, tempfile.mkdtemp(prefix=prefix)
460+
except OSError as e:
461+
return False, f'OS error "{e.strerror}" while creating a temporary directory'
462+
except Exception as e:
463+
return False, f'Unknown error creating a temporary directory: {e}'
464+
465+
466+
def mkdir(path, mode=0o755, exist_ok=True):
467+
"""
468+
Create a directory, including any missing parent directories.
469+
470+
Wraps `os.makedirs()` and reports the outcome in the same `(success, error)`
471+
style as the other disk helpers, so callers do not have to handle exceptions
472+
themselves.
473+
474+
### Parameters
475+
- **path** (`str`): Directory path to create.
476+
- **mode** (`int`, optional): Permission bits for newly created directories.
477+
Defaults to `0o755`.
478+
- **exist_ok** (`bool`, optional): If `True`, an already existing directory is
479+
not an error. Defaults to `True`.
480+
481+
### Returns
482+
- **tuple**:
483+
- tuple[0] (**bool**): True if the directory exists afterwards, else False.
484+
- tuple[1] (**None or str**): None on success, otherwise an error message.
485+
486+
### Example
487+
>>> mkdir('/tmp/example/sub')
488+
(True, None)
489+
"""
490+
try:
491+
os.makedirs(path, mode=mode, exist_ok=exist_ok)
492+
return True, None
493+
except OSError as e:
494+
return False, f'OS error "{e.strerror}" while creating {path}'
495+
except Exception as e:
496+
return False, f'Unknown error creating {path}: {e}'
497+
498+
378499
def read_csv(
379500
filename,
380501
delimiter=',',
@@ -517,6 +638,34 @@ def read_file(filename):
517638
return False, f'Unknown error opening or reading {filename}: {e}'
518639

519640

641+
def rm_dir(path):
642+
"""
643+
Recursively delete a directory tree.
644+
645+
Wraps `shutil.rmtree()` and reports the outcome in the same `(success, error)`
646+
style as `rm_file()`.
647+
648+
### Parameters
649+
- **path** (`str`): Directory tree to delete.
650+
651+
### Returns
652+
- **tuple**:
653+
- tuple[0] (**bool**): True if deletion succeeded, otherwise False.
654+
- tuple[1] (**None or str**): None on success, otherwise an error message.
655+
656+
### Example
657+
>>> rm_dir('/tmp/lynis')
658+
(True, None)
659+
"""
660+
try:
661+
shutil.rmtree(path)
662+
return True, None
663+
except OSError as e:
664+
return False, f'OS error "{e.strerror}" while deleting {path}'
665+
except Exception as e:
666+
return False, f'Unknown error deleting {path}: {e}'
667+
668+
520669
def rm_file(filename):
521670
"""
522671
Delete or remove a file.

net.py

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
"""Provides network related functions and variables."""
1313

1414
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
15-
__version__ = '2026050901'
15+
__version__ = '2026060601'
1616

17+
import ipaddress
1718
import random
1819
import re
1920
import socket
@@ -128,7 +129,11 @@
128129

129130

130131
def _socket_fetch(
131-
open_socket_func, connect_args, payload=None, dialog=None, timeout=3,
132+
open_socket_func,
133+
connect_args,
134+
payload=None,
135+
dialog=None,
136+
timeout=3,
132137
socket_name='socket',
133138
):
134139
"""
@@ -192,7 +197,10 @@ def _socket_fetch(
192197
try:
193198
s.sendall(send_bytes)
194199
except Exception as e:
195-
return False, f'Could not send payload on {socket_name}: {e}'
200+
return (
201+
False,
202+
f'Could not send payload on {socket_name}: {e}',
203+
)
196204

197205
if expect is None:
198206
results.append('')
@@ -293,7 +301,8 @@ def fetch(host, port, msg=None, dialog=None, timeout=3, ipv6=False, tls=False):
293301
### Example
294302
>>> success, response = fetch('example.com', 80)
295303
>>> ok, [hello, vars_block, _] = fetch(
296-
... '127.0.0.1', 3493,
304+
... '127.0.0.1',
305+
... 3493,
297306
... dialog=[
298307
... (None, r'^OK\\b|^ERR\\b'),
299308
... (b'LIST VAR myups\\n', r'END LIST VAR myups'),
@@ -548,6 +557,110 @@ def get_public_ip(services, insecure=False, no_proxy=False, timeout=2):
548557
return False, None
549558

550559

560+
def cidr_to_hosts(cidr, max_hosts=65536):
561+
"""
562+
Return the usable host IP addresses of a network in CIDR notation.
563+
564+
Enumerates the usable host addresses of an IPv4 or IPv6 network given as a
565+
CIDR string (e.g. `10.1.1.0/24` or `2001:db8::/120`). The network and
566+
broadcast address are excluded. Host bits set in the input are tolerated
567+
(`strict=False`).
568+
569+
### Parameters
570+
- **cidr** (`str`): Network in CIDR notation, e.g. `10.1.1.0/24`.
571+
- **max_hosts** (`int`, optional): Refuse to enumerate networks with more
572+
addresses than this, which protects against accidentally expanding a large
573+
range such as an IPv6 `/64` (2^64 addresses). Set to `None` to disable the
574+
limit. Defaults to `65536` (an IPv4 `/16`).
575+
576+
### Returns
577+
- **tuple** (`bool`, `list` or `str`):
578+
- `True` and the list of host IP address strings (IPv4 or IPv6) on success.
579+
- `False` and an error message on failure.
580+
581+
### Example
582+
>>> cidr_to_hosts('10.1.1.0/30')
583+
(True, ['10.1.1.1', '10.1.1.2'])
584+
>>> cidr_to_hosts('2001:db8::/126')
585+
(True, ['2001:db8::1', '2001:db8::2', '2001:db8::3'])
586+
"""
587+
try:
588+
network = ipaddress.ip_network(cidr, strict=False)
589+
except ValueError as e:
590+
return (False, f'Invalid network "{cidr}": {e}')
591+
if max_hosts is not None and network.num_addresses > max_hosts:
592+
return (
593+
False,
594+
f'Network "{cidr}" is too large to enumerate '
595+
f'({network.num_addresses} addresses, limit {max_hosts}).',
596+
)
597+
return (True, [str(host) for host in network.hosts()])
598+
599+
600+
def get_subnet_hosts(interface=None, max_hosts=65536):
601+
"""
602+
Return the usable host IP addresses of an interface's subnet.
603+
604+
Resolves the address and netmask of the given interface (or the default
605+
interface, the one carrying the default route, if none is given), derives the
606+
subnet and enumerates its usable host addresses (network and broadcast
607+
address excluded). IPv4 is preferred; if an interface has no IPv4 address its
608+
IPv6 subnet is used. Note that an IPv6 `/64` exceeds `max_hosts` and is
609+
therefore reported as too large rather than enumerated.
610+
611+
### Parameters
612+
- **interface** (`str`, optional): Network interface name (e.g. `eth0`). If
613+
`None`, the default interface is used.
614+
- **max_hosts** (`int`, optional): Passed through to `cidr_to_hosts()` to cap
615+
enumeration. Defaults to `65536`.
616+
617+
### Returns
618+
- **tuple** (`bool`, `list` or `str`):
619+
- `True` and the list of host IP address strings on success.
620+
- `False` and an error message on failure.
621+
622+
### Notes
623+
- Requires the `netifaces` library.
624+
625+
### Example
626+
>>> get_subnet_hosts('eth0')
627+
(True, ['192.168.1.1', '192.168.1.2', ..., '192.168.1.254'])
628+
"""
629+
if not HAVE_NETIFACES:
630+
return (False, 'Python module "netifaces" is not installed.')
631+
632+
if interface is None:
633+
netinfo = get_netinfo()
634+
if not netinfo or not netinfo.get('address'):
635+
return (False, 'Unable to determine the default interface subnet.')
636+
return cidr_to_hosts(
637+
f'{netinfo["address"]}/{netinfo["mask_cidr"]}', max_hosts=max_hosts
638+
)
639+
640+
try:
641+
iface_addrs = netifaces.ifaddresses(interface)
642+
except ValueError:
643+
return (False, f'No such interface "{interface}".')
644+
645+
# IPv4 first, then IPv6. netifaces stores the IPv6 netmask as
646+
# "ffff:ffff:ffff:ffff::/64" and may suffix the address with a "%scope".
647+
for family in (netifaces.AF_INET, netifaces.AF_INET6):
648+
entries = iface_addrs.get(family)
649+
if not entries:
650+
continue
651+
address = (entries[0].get('addr') or '').split('%')[0]
652+
netmask = entries[0].get('netmask') or ''
653+
if not address:
654+
continue
655+
if family == netifaces.AF_INET:
656+
cidr = ip_to_cidr(netmask)
657+
else:
658+
cidr = netmask.split('/')[-1] if '/' in netmask else '128'
659+
return cidr_to_hosts(f'{address}/{cidr}', max_hosts=max_hosts)
660+
661+
return (False, f'No IPv4 or IPv6 address found on interface "{interface}".')
662+
663+
551664
def ip_to_cidr(ip):
552665
"""
553666
Convert an IPv4 netmask to CIDR notation.

shell.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111
"""Communicates with the Shell on Linux and Windows."""
1212

1313
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
14-
__version__ = '2026041201'
14+
__version__ = '2026060601'
1515

1616

1717
import os
1818
import re
1919
import shlex
20+
import shutil
2021
import subprocess # nosec B404 - this library is the subprocess helper
2122

2223
from . import txt
@@ -211,3 +212,24 @@ def shell_exec(
211212
return False, f'Timeout after {timeout} seconds.'
212213

213214
return True, (txt.to_text(stdout), txt.to_text(stderr), p.returncode)
215+
216+
217+
def which(name):
218+
"""
219+
Locate an executable in the system PATH, like the `which` command.
220+
221+
Thin wrapper around `shutil.which()` so callers do not need to import it
222+
directly and the lookup stays consistent across consumers.
223+
224+
### Parameters
225+
- **name** (`str`): Program name to look for (e.g. `lynis`).
226+
227+
### Returns
228+
- **str or None**: The absolute path to the executable, or `None` if it is
229+
not found in PATH.
230+
231+
### Example
232+
>>> which('sh')
233+
'/usr/bin/sh'
234+
"""
235+
return shutil.which(name)

0 commit comments

Comments
 (0)