linuxfabrik-lib 4.4.0__tar.gz → 5.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/PKG-INFO +1 -1
  2. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/base.py +8 -1
  3. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/db_mysql.py +8 -6
  4. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/disk.py +9 -2
  5. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/distro.py +16 -11
  6. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/dmidecode.py +2 -2
  7. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/endoflifedate.py +22 -16
  8. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/lftest.py +24 -13
  9. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/linuxfabrik_lib.egg-info/PKG-INFO +1 -1
  10. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/nextcloud.py +7 -5
  11. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/pyproject.toml +1 -1
  12. linuxfabrik_lib-5.0.0/shell.py +204 -0
  13. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/ssh.py +74 -62
  14. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/url.py +3 -4
  15. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/version.py +18 -11
  16. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/winrm.py +0 -0
  17. linuxfabrik_lib-4.4.0/shell.py +0 -235
  18. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/LICENSE +0 -0
  19. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/README.md +0 -0
  20. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/__init__.py +0 -0
  21. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/args.py +0 -0
  22. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/cache.py +0 -0
  23. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/db_sqlite.py +0 -0
  24. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/feedparser.py +0 -0
  25. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/globals.py +0 -0
  26. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/grassfish.py +0 -0
  27. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/huawei.py +0 -0
  28. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/human.py +0 -0
  29. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/icinga.py +0 -0
  30. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/infomaniak.py +0 -0
  31. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/jitsi.py +0 -0
  32. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/keycloak.py +0 -0
  33. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/librenms.py +0 -0
  34. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/linuxfabrik_lib.egg-info/SOURCES.txt +0 -0
  35. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/linuxfabrik_lib.egg-info/dependency_links.txt +0 -0
  36. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/linuxfabrik_lib.egg-info/requires.txt +0 -0
  37. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/linuxfabrik_lib.egg-info/top_level.txt +0 -0
  38. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/net.py +0 -0
  39. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/nodebb.py +0 -0
  40. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/powershell.py +0 -0
  41. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/psutil.py +0 -0
  42. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/qts.py +0 -0
  43. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/redfish.py +0 -0
  44. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/rocket.py +0 -0
  45. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/setup.cfg +0 -0
  46. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/smb.py +0 -0
  47. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/time.py +0 -0
  48. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/txt.py +0 -0
  49. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/uptimerobot.py +0 -0
  50. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/veeam.py +0 -0
  51. {linuxfabrik_lib-4.4.0 → linuxfabrik_lib-5.0.0}/wildfly.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linuxfabrik-lib
3
- Version: 4.4.0
3
+ Version: 5.0.0
4
4
  Summary: Python libraries used in various Linuxfabrik projects, including the 'Linuxfabrik Monitoring Plugins' project.
5
5
  Author-email: "Linuxfabrik GmbH, Zurich, Switzerland" <info@linuxfabrik.ch>
6
6
  License: This is free and unencumbered software released into the public domain.
@@ -11,7 +11,7 @@
11
11
  """Provides very common every-day functions."""
12
12
 
13
13
  __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
14
- __version__ = '2026051002'
14
+ __version__ = '2026061201'
15
15
 
16
16
  import html
17
17
  import numbers
@@ -119,6 +119,9 @@ def cu(msg=None):
119
119
  tb = format_exc() if has_traceback else None
120
120
 
121
121
  if msg is not None:
122
+ # Normalize line endings to LF (see oao); error output may also carry
123
+ # CRLF, for example when a Windows command prints its error to stdout.
124
+ msg = msg.replace('\r\n', '\n').replace('\r', '\n')
122
125
  msg = (
123
126
  txt.sanitize_sensitive_data(msg).strip().replace('<', "'").replace('>', "'")
124
127
  )
@@ -628,6 +631,10 @@ def oao(msg, state=STATE_OK, perfdata='', always_ok=False):
628
631
  (and exits with code 2)
629
632
 
630
633
  """
634
+ # Normalize line endings to LF. Output captured on Windows (or read from a
635
+ # file or HTTP response) can carry CRLF or stray CR, which a web UI showing
636
+ # the output with `white-space: pre-wrap` would render as an extra line break.
637
+ msg = msg.replace('\r\n', '\n').replace('\r', '\n')
631
638
  msg = html.escape(
632
639
  txt.sanitize_sensitive_data(msg.strip()),
633
640
  quote=False,
@@ -20,7 +20,7 @@ from .globals import STATE_UNKNOWN
20
20
  warnings.filterwarnings('ignore', category=UserWarning, module='pymysql')
21
21
 
22
22
  __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
23
- __version__ = '2026060201'
23
+ __version__ = '2026061201'
24
24
 
25
25
  try:
26
26
  import pymysql.cursors
@@ -302,7 +302,9 @@ def _align_session_collation(conn):
302
302
  # Whitelist identifiers to keep the formatted `SET NAMES` safe from
303
303
  # any caller-controlled value (the row comes from a system view, but
304
304
  # defence in depth doesn't cost anything).
305
- if not (re.match(r'^[A-Za-z0-9_]+$', cs) and re.match(r'^[A-Za-z0-9_]+$', coll)):
305
+ if not (
306
+ re.match(r'^[A-Za-z0-9_]+$', cs) and re.match(r'^[A-Za-z0-9_]+$', coll)
307
+ ):
306
308
  return
307
309
  with conn.cursor() as cursor:
308
310
  cursor.execute(f'set names {cs} collate {coll}')
@@ -541,10 +543,10 @@ def get_server_info(banner=None):
541
543
  from . import shell
542
544
 
543
545
  for command in (
544
- 'mysqld --version',
545
- 'mariadbd --version',
546
- 'mariadb --version',
547
- 'mysql --version',
546
+ ['mysqld', '--version'],
547
+ ['mariadbd', '--version'],
548
+ ['mariadb', '--version'],
549
+ ['mysql', '--version'],
548
550
  ):
549
551
  success, result = shell.shell_exec(command)
550
552
  if not success:
@@ -13,7 +13,7 @@ partitions, grepping a file, etc.
13
13
  """
14
14
 
15
15
  __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
16
- __version__ = '2026060801'
16
+ __version__ = '2026061201'
17
17
 
18
18
  import csv
19
19
  import os
@@ -747,7 +747,14 @@ def udevadm(device, _property):
747
747
  >>> udevadm('/dev/linuxfabrik', 'DEVNAME')
748
748
  ''
749
749
  """
750
- success, result = shell.shell_exec(f'udevadm info --query=property --name={device}')
750
+ # Only query real device nodes under /dev. Resolving the path first defends
751
+ # against a crafted `device` that points elsewhere or smuggles arguments.
752
+ device = os.path.realpath(device)
753
+ if not device.startswith('/dev/'):
754
+ return ''
755
+ success, result = shell.shell_exec(
756
+ ['udevadm', 'info', '--query=property', f'--name={device}']
757
+ )
751
758
  if not success:
752
759
  return ''
753
760
  stdout, _, _ = result
@@ -17,15 +17,13 @@ Source Code is taken, converted, shortened and modified from:
17
17
  """
18
18
 
19
19
  __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
20
- __version__ = '2025061001'
20
+ __version__ = '2026061201'
21
21
 
22
22
 
23
23
  import os
24
24
  import platform
25
25
  import re
26
26
 
27
- from . import shell
28
-
29
27
  # --- Static mappings ---
30
28
 
31
29
  OSDIST_LIST = (
@@ -430,15 +428,22 @@ def get_distribution_facts():
430
428
  distro = facts.get('distribution_file_variety', 'distribution')
431
429
  facts['os_family'] = _map_os_family(distro) # returns 'RedHat', for example
432
430
 
433
- # hardcoded command, no user input; shell=True needed to source the file
434
- cmd = '. /etc/os-release && echo "$NAME $VERSION"'
435
- success, result = shell.shell_exec(cmd, shell=True) # nosec B604
436
- if not success:
431
+ # read NAME and VERSION from /etc/os-release
432
+ values = {}
433
+ try:
434
+ with open('/etc/os-release', encoding='utf-8') as os_release:
435
+ for line in os_release:
436
+ line = line.strip()
437
+ if not line or line.startswith('#') or '=' not in line:
438
+ continue
439
+ key, value = line.split('=', 1)
440
+ values[key] = value.strip().strip('"').strip("'")
441
+ except OSError:
437
442
  return facts
438
443
 
439
- stdout, _, _ = result
440
- facts['os_info'] = (
441
- stdout.strip()
442
- ) # returns 'Fedora Linux 41 (Workstation Edition)', for example
444
+ # returns 'Fedora Linux 41 (Workstation Edition)', for example
445
+ facts['os_info'] = ' '.join(
446
+ part for part in (values.get('NAME'), values.get('VERSION')) if part
447
+ )
443
448
 
444
449
  return facts
@@ -14,7 +14,7 @@ Copied and refactored from py-dmidecode (https://github.com/zaibon/py-dmidecode)
14
14
  """
15
15
 
16
16
  __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
17
- __version__ = '2026060201'
17
+ __version__ = '2026061201'
18
18
 
19
19
  import re
20
20
 
@@ -511,7 +511,7 @@ def get_data():
511
511
  ...
512
512
  }
513
513
  """
514
- success, result = shell.shell_exec('sudo dmidecode')
514
+ success, result = shell.shell_exec(['sudo', 'dmidecode'])
515
515
  if not success:
516
516
  return False
517
517
 
@@ -15,11 +15,11 @@ Internet."""
15
15
  # pylint: disable=C0302
16
16
 
17
17
  __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
18
- __version__ = '2026060201' # adapt this if something in this script changed fundamentally
18
+ __version__ = '2026061001' # adapt this if something in this script changed fundamentally
19
19
 
20
20
  ENDOFLIFE_DATE = {
21
21
 
22
- 'https://endoflife.date/api/apache.json': [
22
+ 'https://endoflife.date/api/apache-http-server.json': [
23
23
  { 'cycle': '2.4',
24
24
  'eol': False,
25
25
  'latest': '2.4.67',
@@ -503,22 +503,22 @@ ENDOFLIFE_DATE = {
503
503
  'https://endoflife.date/api/gitlab.json': [
504
504
  { 'cycle': '19.0',
505
505
  'eol': '2026-08-20',
506
- 'latest': '19.0.1',
507
- 'latestReleaseDate': '2026-05-26',
506
+ 'latest': '19.0.2',
507
+ 'latestReleaseDate': '2026-06-10',
508
508
  'lts': False,
509
509
  'releaseDate': '2026-05-21',
510
510
  'support': '2026-06-18'},
511
511
  { 'cycle': '18.11',
512
512
  'eol': '2026-07-16',
513
- 'latest': '18.11.4',
514
- 'latestReleaseDate': '2026-05-26',
513
+ 'latest': '18.11.5',
514
+ 'latestReleaseDate': '2026-06-10',
515
515
  'lts': False,
516
516
  'releaseDate': '2026-04-16',
517
517
  'support': '2026-05-21'},
518
518
  { 'cycle': '18.10',
519
519
  'eol': '2026-06-18',
520
- 'latest': '18.10.7',
521
- 'latestReleaseDate': '2026-05-26',
520
+ 'latest': '18.10.8',
521
+ 'latestReleaseDate': '2026-06-10',
522
522
  'lts': False,
523
523
  'releaseDate': '2026-03-19',
524
524
  'support': '2026-04-16'},
@@ -956,7 +956,7 @@ ENDOFLIFE_DATE = {
956
956
  { 'cycle': '12.4',
957
957
  'eol': '2027-05-24',
958
958
  'latest': '12.4.4',
959
- 'latestReleaseDate': '2026-06-04',
959
+ 'latestReleaseDate': '2026-06-09',
960
960
  'lts': False,
961
961
  'releaseDate': '2026-02-24',
962
962
  'support': '2026-04-17'},
@@ -1927,8 +1927,8 @@ ENDOFLIFE_DATE = {
1927
1927
  'https://endoflife.date/api/matomo.json': [
1928
1928
  { 'cycle': '5',
1929
1929
  'eol': False,
1930
- 'latest': '5.11.0',
1931
- 'latestReleaseDate': '2026-06-09',
1930
+ 'latest': '5.11.1',
1931
+ 'latestReleaseDate': '2026-06-12',
1932
1932
  'lts': False,
1933
1933
  'releaseDate': '2023-12-18',
1934
1934
  'support': True},
@@ -2297,6 +2297,12 @@ ENDOFLIFE_DATE = {
2297
2297
 
2298
2298
 
2299
2299
  'https://endoflife.date/api/nextcloud.json': [
2300
+ { 'cycle': '34',
2301
+ 'eol': '2027-06-30',
2302
+ 'latest': '34.0.0',
2303
+ 'latestReleaseDate': '2026-06-08',
2304
+ 'lts': False,
2305
+ 'releaseDate': '2026-06-09'},
2300
2306
  { 'cycle': '33',
2301
2307
  'eol': '2027-02-28',
2302
2308
  'latest': '33.0.5',
@@ -2944,16 +2950,16 @@ ENDOFLIFE_DATE = {
2944
2950
  'https://endoflife.date/api/python.json': [
2945
2951
  { 'cycle': '3.14',
2946
2952
  'eol': '2030-10-31',
2947
- 'latest': '3.14.5',
2948
- 'latestReleaseDate': '2026-05-10',
2953
+ 'latest': '3.14.6',
2954
+ 'latestReleaseDate': '2026-06-10',
2949
2955
  'lts': False,
2950
2956
  'pep': 'PEP-0745',
2951
2957
  'releaseDate': '2025-10-07',
2952
2958
  'support': '2027-10-01'},
2953
2959
  { 'cycle': '3.13',
2954
2960
  'eol': '2029-10-31',
2955
- 'latest': '3.13.13',
2956
- 'latestReleaseDate': '2026-04-07',
2961
+ 'latest': '3.13.14',
2962
+ 'latestReleaseDate': '2026-06-10',
2957
2963
  'lts': False,
2958
2964
  'pep': 'PEP-0719',
2959
2965
  'releaseDate': '2024-10-07',
@@ -3273,7 +3279,7 @@ ENDOFLIFE_DATE = {
3273
3279
  ],
3274
3280
 
3275
3281
 
3276
- 'https://endoflife.date/api/rocketchat.json': [
3282
+ 'https://endoflife.date/api/rocket-chat.json': [
3277
3283
  { 'cycle': '8.4',
3278
3284
  'eol': '2026-10-31',
3279
3285
  'latest': '8.4.3',
@@ -13,12 +13,13 @@
13
13
  import contextlib
14
14
  import os
15
15
  import re
16
+ import shlex
16
17
  import tempfile
17
18
 
18
19
  from . import base, disk, shell
19
20
 
20
21
  __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
21
- __version__ = '2026060202'
22
+ __version__ = '2026061201'
22
23
 
23
24
 
24
25
  def run(test_instance, plugin, testcase):
@@ -73,8 +74,10 @@ def run(test_instance, plugin, testcase):
73
74
  ... with self.subTest(id=t['id']):
74
75
  ... lib.lftest.run(self, self.check, t)
75
76
  """
77
+ # params come from the trusted testcase definition; tokenize them the way a
78
+ # shell would so quoted multi-word values stay intact, then build the argv.
76
79
  params = testcase.get('params', '')
77
- cmd = f'{plugin} {params} --test={testcase["test"]}'.strip()
80
+ cmd = [plugin, *shlex.split(params), f'--test={testcase["test"]}']
78
81
  stdout, stderr, retc = base.coe(shell.shell_exec(cmd))
79
82
 
80
83
  test_instance.assertEqual(
@@ -133,7 +136,6 @@ def attach_tests(test_class, tests, plugin_attr='check'):
133
136
  ### Example
134
137
  >>> class TestCheck(unittest.TestCase):
135
138
  ... check = '../my-plugin'
136
- ...
137
139
  >>> attach_tests(TestCheck, TESTS)
138
140
  >>>
139
141
  >>> if __name__ == '__main__':
@@ -157,6 +159,7 @@ def attach_tests(test_class, tests, plugin_attr='check'):
157
159
  def _make(captured_testcase):
158
160
  def _method(self):
159
161
  run(self, getattr(self, plugin_attr), captured_testcase)
162
+
160
163
  return _method
161
164
 
162
165
  setattr(test_class, method_name, _make(testcase))
@@ -222,6 +225,7 @@ def attach_each(test_class, items, action, id_func=str):
222
225
  def _make(captured_item):
223
226
  def _method(self):
224
227
  action(self, captured_item)
228
+
225
229
  return _method
226
230
 
227
231
  setattr(test_class, method_name, _make(item))
@@ -339,8 +343,7 @@ def run_container(
339
343
  from testcontainers.core.wait_strategies import LogMessageWaitStrategy
340
344
  except ImportError as e:
341
345
  raise RuntimeError(
342
- 'testcontainers is not installed; run '
343
- "`pip install testcontainers`"
346
+ 'testcontainers is not installed; run `pip install testcontainers`'
344
347
  ) from e
345
348
 
346
349
  c = DockerContainer(image)
@@ -456,12 +459,15 @@ def _run_mysql_compatible_resolved(image_ref, command, seed):
456
459
  # in favour of `mariadb`; sclorg c10s ships both; MySQL
457
460
  # ships `mysql` only. Prefer `mariadb`, fall back to
458
461
  # `mysql` so all flavours work.
459
- container.exec([
460
- 'sh', '-c',
461
- 'if command -v mariadb >/dev/null 2>&1; then CLIENT=mariadb; '
462
- 'else CLIENT=mysql; fi; '
463
- f'"$CLIENT" -utest -ptest test -e "{seed}"',
464
- ])
462
+ container.exec(
463
+ [
464
+ 'sh',
465
+ '-c',
466
+ 'if command -v mariadb >/dev/null 2>&1; then CLIENT=mariadb; '
467
+ 'else CLIENT=mysql; fi; '
468
+ f'"$CLIENT" -utest -ptest test -e "{seed}"',
469
+ ]
470
+ )
465
471
 
466
472
  host = container.get_container_host_ip()
467
473
  port = container.get_exposed_port(3306)
@@ -574,7 +580,8 @@ def run_mysql_compatible_from_containerfile(
574
580
  ... ) as (container, defaults_file):
575
581
  ... result = subprocess.run(
576
582
  ... ['python3', '../mysql-traffic', f'--defaults-file={defaults_file}'],
577
- ... capture_output=True, text=True,
583
+ ... capture_output=True,
584
+ ... text=True,
578
585
  ... )
579
586
  """
580
587
  try:
@@ -592,6 +599,8 @@ def run_mysql_compatible_from_containerfile(
592
599
  dockerfile_name = os.path.basename(abspath)
593
600
  tag = f'lfmp-mysql-{dockerfile_name}'.lower().replace('_', '-')
594
601
 
602
+ # No parenthesized context managers: they are Python 3.10+ syntax and break
603
+ # parsing on RHEL 8's default Python 3.6.
595
604
  with (
596
605
  DockerImage(
597
606
  path=build_dir,
@@ -600,7 +609,9 @@ def run_mysql_compatible_from_containerfile(
600
609
  clean_up=False,
601
610
  ) as image,
602
611
  _run_mysql_compatible_resolved(
603
- str(image.tag), command, seed,
612
+ str(image.tag),
613
+ command,
614
+ seed,
604
615
  ) as result,
605
616
  ):
606
617
  yield result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linuxfabrik-lib
3
- Version: 4.4.0
3
+ Version: 5.0.0
4
4
  Summary: Python libraries used in various Linuxfabrik projects, including the 'Linuxfabrik Monitoring Plugins' project.
5
5
  Author-email: "Linuxfabrik GmbH, Zurich, Switzerland" <info@linuxfabrik.ch>
6
6
  License: This is free and unencumbered software released into the public domain.
@@ -11,10 +11,11 @@
11
11
  """This library collects some Nextcloud related functions."""
12
12
 
13
13
  __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
14
- __version__ = '2026041401'
14
+ __version__ = '2026061201'
15
15
 
16
16
  import json
17
17
  import os
18
+ import shlex
18
19
  import shutil
19
20
 
20
21
  from . import disk, shell
@@ -80,16 +81,17 @@ def run_occ(path, cmd, _format='json'):
80
81
  # get the owner of config.php
81
82
  user = disk.get_owner(os.path.join(path, 'config/config.php'))
82
83
  occ = os.path.join(path, 'occ')
83
- # When running a command as a UID, many shells require
84
- # that the `#` be escaped with a backslash (`\`).
85
- sudo_cmd = f'sudo -u \\#{user} {php} {occ} {cmd}'
84
+ # Run occ as the numeric UID of the config.php owner. `sudo -u '#<uid>'` selects
85
+ # the user by UID; the `#` only needs escaping for a shell, which we do not use.
86
+ sudo_cmd = ['sudo', '-u', f'#{user}', php, occ, *shlex.split(cmd)]
86
87
 
87
88
  success, result = shell.shell_exec(sudo_cmd)
88
89
  stdout, stderr, rc = result
89
90
 
90
91
  # Prefer the return code to decide success/failure, not stderr presence
91
92
  if not success or rc != 0:
92
- return False, f'Error running `{sudo_cmd}`: rc={rc}\n{stderr or stdout}'
93
+ cmd_display = ' '.join(sudo_cmd)
94
+ return False, f'Error running `{cmd_display}`: rc={rc}\n{stderr or stdout}'
93
95
 
94
96
  # If we expect JSON, try to parse it; otherwise return text
95
97
  if str(_format).lower() == 'json':
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "linuxfabrik-lib"
9
- version = "4.4.0"
9
+ version = "5.0.0"
10
10
  description = "Python libraries used in various Linuxfabrik projects, including the 'Linuxfabrik Monitoring Plugins' project."
11
11
  readme = "README.md"
12
12
  authors = [
@@ -0,0 +1,204 @@
1
+ #! /usr/bin/env python3
2
+ # -*- coding: utf-8; py-indent-offset: 4 -*-
3
+ #
4
+ # Author: Linuxfabrik GmbH, Zurich, Switzerland
5
+ # Contact: info (at) linuxfabrik (dot) ch
6
+ # https://www.linuxfabrik.ch/
7
+ # License: The Unlicense, see LICENSE file.
8
+
9
+ # https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.rst
10
+
11
+ """Communicates with the Shell on Linux and Windows."""
12
+
13
+ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
14
+ __version__ = '2026061202'
15
+
16
+
17
+ import os
18
+ import shutil
19
+ import subprocess # nosec B404 - this library is the subprocess helper
20
+
21
+ from . import txt
22
+
23
+ RETC_SSHPASS = {
24
+ 1: 'Invalid command line argument',
25
+ 2: 'Conflicting arguments given',
26
+ 3: 'General runtime error',
27
+ 4: 'Unrecognized response from ssh (parse error)',
28
+ 5: 'Invalid/incorrect password',
29
+ 6: 'Host public key is unknown. sshpass exits without confirming the new key.',
30
+ 7: 'IP public key changed. sshpass exits without confirming the new key.',
31
+ }
32
+
33
+
34
+ def shell_exec(cmd, env=None, stdin='', cwd=None, timeout=None, lc_all='C'):
35
+ """
36
+ Execute a command in a subprocess, given as a list of arguments (argv).
37
+
38
+ The command is always run with `shell=False`, so no shell is involved: arguments are passed
39
+ verbatim to the executable and are never interpreted for pipes (`|`), redirection, globbing,
40
+ variable expansion or any other shell metacharacter. This makes the helper safe to call with
41
+ untrusted argument values: a value like `|reboot|` or `; rm -rf /` ends up as one literal
42
+ argument, not as a command.
43
+
44
+ Because there is no shell, `cmd` must be a list (argv). Passing a string raises `TypeError`.
45
+ Build the command as a list, for example `['df', '--human-readable', mountpoint]`, where
46
+ `mountpoint` may be untrusted. A genuine pipeline (`a | b`) has to be expressed in code by
47
+ running the stages and connecting them explicitly, or by post-processing the first command's
48
+ output; it can no longer be expressed as a shell string.
49
+
50
+ ### Parameters
51
+ - **cmd** (`list`):
52
+ The command to execute, as a list of arguments (argv), e.g. `['ls', '-l', '/tmp']`.
53
+ The first element is the program, the rest are its arguments.
54
+ - **env** (`dict`, optional):
55
+ A dictionary of environment variables to merge with the current OS environment.
56
+ Defaults to the current environment.
57
+ - **stdin** (`str`, optional):
58
+ A string to pass as standard input to the command. Defaults to an empty string.
59
+ - **cwd** (`str`, optional):
60
+ Working directory in which to execute the command. Defaults to None (current directory).
61
+ - **timeout** (`int` or `float`, optional):
62
+ Maximum time (in seconds) to allow the command to run. If exceeded, the process is
63
+ terminated. Defaults to None (no timeout).
64
+ - **lc_all** (`str`, optional):
65
+ Value to set for the `LC_ALL` environment variable, forcing command output locale.
66
+ Defaults to `'C'` (POSIX "C" locale, i.e., English).
67
+
68
+ ### Returns
69
+ - **tuple**:
70
+ - On success:
71
+ `(True, (stdout, stderr, return_code))`
72
+ - **stdout** (`str`): Standard output of the command (decoded to text).
73
+ - **stderr** (`str`): Standard error of the command (decoded to text).
74
+ - **return_code** (`int`): Exit status of the command.
75
+ - On failure:
76
+ `(False, error_message)` — a string describing the error.
77
+
78
+ ### Notes
79
+ - The environment is merged with `env` and always includes `LC_ALL=<lc_all>`, forcing output
80
+ to the specified locale.
81
+ - Exceptions such as `OSError`, `ValueError`, or other execution errors during process
82
+ creation are caught and reported as `(False, <error message>)`.
83
+ - If the process exceeds the specified `timeout`, it is killed, and the function returns
84
+ `(False, "Timeout after <timeout> seconds.")`.
85
+ """
86
+ if not isinstance(cmd, (list, tuple)):
87
+ raise TypeError(
88
+ 'shell_exec() requires cmd as a list of arguments '
89
+ f'(for example ["df", "-h"]), got {type(cmd).__name__}.'
90
+ )
91
+
92
+ env = {**os.environ.copy(), **(env or {})}
93
+ env['LC_ALL'] = lc_all
94
+
95
+ try:
96
+ p = subprocess.Popen( # nosec B603 - shell=False, cmd is an argv list, no shell interpretation
97
+ cmd,
98
+ stdin=subprocess.PIPE,
99
+ stdout=subprocess.PIPE,
100
+ stderr=subprocess.PIPE,
101
+ env=env,
102
+ shell=False,
103
+ cwd=cwd,
104
+ )
105
+ except (OSError, ValueError, Exception) as e:
106
+ return False, f'Error "{e}" while calling command "{cmd}"'
107
+
108
+ try:
109
+ stdout, stderr = p.communicate(
110
+ input=txt.to_bytes(stdin) if stdin else None,
111
+ timeout=timeout,
112
+ )
113
+ except subprocess.TimeoutExpired:
114
+ p.kill()
115
+ p.communicate()
116
+ return False, f'Timeout after {timeout} seconds.'
117
+
118
+ # Decode the captured bytes. On Windows, console programs (query, schtasks,
119
+ # w32tm, ...) write their piped output in the OEM / console output code page
120
+ # (e.g. cp437, cp850), not UTF-8 and not the ANSI code page, so a username
121
+ # like "müller" would otherwise be mangled (Linuxfabrik/monitoring-plugins#681).
122
+ if os.name == 'nt':
123
+ encoding, errors = _windows_output_encoding(), 'replace'
124
+ else:
125
+ encoding, errors = 'utf-8', 'surrogateescape'
126
+ return True, (
127
+ txt.to_text(stdout, encoding=encoding, errors=errors),
128
+ txt.to_text(stderr, encoding=encoding, errors=errors),
129
+ p.returncode,
130
+ )
131
+
132
+
133
+ def _windows_output_encoding():
134
+ """
135
+ Best-effort code page name for decoding a Windows subprocess's piped output.
136
+
137
+ Console programs emit their pipe output in the OEM / console output code page, not UTF-8 and
138
+ not the ANSI code page (`chcp 65001` has no effect on a pipe; see PEP 528). Prefer the console
139
+ output code page, fall back to the OEM code page when the process has no console (for example
140
+ when run headless by a monitoring agent), and fall back to UTF-8 if neither is available.
141
+
142
+ ### Returns
143
+ - **str**: A Python codec name such as `'cp437'`, or `'utf-8'` as a last resort.
144
+ """
145
+ try:
146
+ import ctypes
147
+
148
+ kernel32 = ctypes.windll.kernel32
149
+ code_page = kernel32.GetConsoleOutputCP() or kernel32.GetOEMCP()
150
+ if code_page:
151
+ return f'cp{code_page}'
152
+ except (OSError, AttributeError, ValueError):
153
+ pass
154
+ return 'utf-8'
155
+
156
+
157
+ def safe_cli_value(value, name='value'):
158
+ """
159
+ Reject a value that a called program could misinterpret as an option.
160
+
161
+ Building a command as an argument list (argv) and running it with `shell_exec()` prevents
162
+ shell injection, but it does not stop a value that starts with `-` from being picked up as an
163
+ *option* by the program being run, for example an ssh destination `-oProxyCommand=...` (remote
164
+ code execution) or a `ping` target `-f` (flood). Use this for values that reach a command as a
165
+ positional argument or as a command target, where option-style values have no legitimate
166
+ meaning. Values that are bound to an explicit option (`--name=<value>` or `-H <value>`) do not
167
+ need this guard.
168
+
169
+ ### Parameters
170
+ - **value** (`any`): The value to check. Non-string values pass through unchanged.
171
+ - **name** (`str`, optional): Human-readable name used in the error message. Defaults to
172
+ `'value'`.
173
+
174
+ ### Returns
175
+ - **tuple**: `(True, value)` if the value is safe, else `(False, error_message)`. The shape
176
+ is suitable for `lib.base.coe()`.
177
+
178
+ ### Example
179
+ >>> host = lib.base.coe(lib.shell.safe_cli_value(args.HOSTNAME, '--hostname'))
180
+ """
181
+ if isinstance(value, str) and value.startswith('-'):
182
+ return False, f'Refusing {name} that starts with "-": {value}'
183
+ return True, value
184
+
185
+
186
+ def which(name):
187
+ """
188
+ Locate an executable in the system PATH, like the `which` command.
189
+
190
+ Thin wrapper around `shutil.which()` so callers do not need to import it
191
+ directly and the lookup stays consistent across consumers.
192
+
193
+ ### Parameters
194
+ - **name** (`str`): Program name to look for (e.g. `lynis`).
195
+
196
+ ### Returns
197
+ - **str or None**: The absolute path to the executable, or `None` if it is
198
+ not found in PATH.
199
+
200
+ ### Example
201
+ >>> which('sh')
202
+ '/usr/bin/sh'
203
+ """
204
+ return shutil.which(name)