runspec-linux-core 0.3.4__tar.gz → 0.4.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.
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/CHANGELOG.md +48 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/PKG-INFO +1 -1
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/pyproject.toml +1 -1
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/__init__.py +45 -1
- runspec_linux_core-0.4.0/runspec_linux_core/become.py +97 -0
- runspec_linux_core-0.4.0/runspec_linux_core/investigate.py +217 -0
- runspec_linux_core-0.4.0/runspec_linux_core/logs.py +99 -0
- runspec_linux_core-0.4.0/runspec_linux_core/network.py +276 -0
- runspec_linux_core-0.4.0/runspec_linux_core/perf.py +228 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/services.py +15 -11
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/venvs.py +29 -38
- runspec_linux_core-0.4.0/tests/test_become.py +86 -0
- runspec_linux_core-0.4.0/tests/test_investigate.py +86 -0
- runspec_linux_core-0.4.0/tests/test_logs_services_runas.py +102 -0
- runspec_linux_core-0.4.0/tests/test_network.py +59 -0
- runspec_linux_core-0.4.0/tests/test_perf.py +69 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_venvs.py +45 -24
- runspec_linux_core-0.3.4/runspec_linux_core/logs.py +0 -57
- runspec_linux_core-0.3.4/runspec_linux_core/network.py +0 -130
- runspec_linux_core-0.3.4/tests/test_network.py +0 -21
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/.gitignore +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/_paths.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/commands.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/containers.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/errors.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/files.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/nc.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/packages.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/power.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/security.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/sudoers.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/runspec_linux_core/system.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/__init__.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_commands.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_nc_send.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_packages.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_paths.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_power.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_preconditions.py +0 -0
- {runspec_linux_core-0.3.4 → runspec_linux_core-0.4.0}/tests/test_sudoers.py +0 -0
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# runspec-linux-core Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0] — 2026-08-01
|
|
4
|
+
|
|
5
|
+
New shared privilege-escalation module `runspec_linux_core.become` — the single
|
|
6
|
+
source of truth for runspec-linux's per-invocation `run_as` escalation
|
|
7
|
+
(mechanism B). It exposes `build_become(argv, run_as, method)` (`sudo -n -u` /
|
|
8
|
+
`su -c`), `validate_user`, `check_become_error` (the passwordless-sudo hint),
|
|
9
|
+
`escalation_supported`, `current_user`, and `BecomeError` (a `CommandError`).
|
|
10
|
+
`venvs.py` now delegates to it instead of its own private `_become`/`_check`
|
|
11
|
+
copy — byte-identical command output, no behaviour change beyond invalid
|
|
12
|
+
run-as/method now raising `BecomeError` (a `CommandError`) rather than a bare
|
|
13
|
+
`ValueError`. This module also backs the new `--run-as` / `--sudo` mutually
|
|
14
|
+
exclusive args on the privileged `runspec-linux` runnables.
|
|
15
|
+
|
|
16
|
+
New system-admin helpers for corporate app-support and performance work:
|
|
17
|
+
|
|
18
|
+
- `perf.py` — `get_sysctl` / `set_sysctl` (read/tune kernel params, optional
|
|
19
|
+
`/etc/sysctl.d` persist), `cpu_info` (governor/frequency/topology),
|
|
20
|
+
`pressure` (PSI `/proc/pressure/*`).
|
|
21
|
+
- `investigate.py` — `service_detail` (a unit's ExecStart/env/paths/state),
|
|
22
|
+
`open_ports` (listening port → pid → process), `owning_package` (which
|
|
23
|
+
package owns a path, dpkg/rpm), `failed_units` (`systemctl --failed`).
|
|
24
|
+
- `network.py` additions — `trace_route`, `route_table`, `socket_stats`,
|
|
25
|
+
`dns_config`.
|
|
26
|
+
|
|
27
|
+
The privileged helpers (`set_sysctl` persist, `service_detail`, `open_ports`,
|
|
28
|
+
and the log/service helpers) take `run_as` / `become_method` params and wrap
|
|
29
|
+
their subprocesses with `build_become`, so a caller can run them as root or a
|
|
30
|
+
specific service account.
|
|
31
|
+
|
|
32
|
+
## [0.3.5] — 2026-07-08
|
|
33
|
+
|
|
34
|
+
Apply the same umask-neutralising treatment to `install_into_venv` that `0.3.4`
|
|
35
|
+
added to `create_venv`. After the `pip install`, the venv tree is now
|
|
36
|
+
normalised with `chmod -R u=rwX,go=rX <venv>` so pip's new directories are
|
|
37
|
+
`755` regardless of the caller's umask.
|
|
38
|
+
|
|
39
|
+
The trigger was a `sudo pip install --upgrade runspec-linux` under a host
|
|
40
|
+
where sudo/PAM had switched to a `027` login umask: pip's new
|
|
41
|
+
`site-packages/runspec_linux/` and `site-packages/runspec_linux_core/`
|
|
42
|
+
directories landed at `750`, and `runspec local` (walking `.dist-info` via
|
|
43
|
+
`importlib.metadata`) stopped seeing the packs for any user but the owner.
|
|
44
|
+
The equivalent bug for the venv root was fixed at build time in `0.3.4`; this
|
|
45
|
+
release closes the same gap at upgrade time.
|
|
46
|
+
|
|
47
|
+
If `<venv>/logs` exists (created by `create_venv`), its `2775` setgid mode is
|
|
48
|
+
restored after the wide chmod — the `test -d` guard skips the restore for a
|
|
49
|
+
venv built some other way. When `run_as` is set the new steps escalate too.
|
|
50
|
+
|
|
3
51
|
## [0.3.4] — 2026-07-07
|
|
4
52
|
|
|
5
53
|
Fix `create_venv` producing an unreadable `750` venv directory when the caller's
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runspec-linux-core"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
requires-python = ">=3.10"
|
|
9
9
|
description = "Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)"
|
|
10
10
|
dependencies = []
|
|
@@ -9,6 +9,14 @@ Each function does the work and returns plain data; failures raise (see
|
|
|
9
9
|
``runspec_linux_core.errors``).
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
from runspec_linux_core.become import (
|
|
13
|
+
BecomeError,
|
|
14
|
+
build_become,
|
|
15
|
+
check_become_error,
|
|
16
|
+
current_user,
|
|
17
|
+
escalation_supported,
|
|
18
|
+
validate_user,
|
|
19
|
+
)
|
|
12
20
|
from runspec_linux_core.commands import which
|
|
13
21
|
from runspec_linux_core.containers import (
|
|
14
22
|
container_logs,
|
|
@@ -17,9 +25,23 @@ from runspec_linux_core.containers import (
|
|
|
17
25
|
)
|
|
18
26
|
from runspec_linux_core.errors import CommandError, LinuxCoreError, ToolNotFoundError
|
|
19
27
|
from runspec_linux_core.files import backup_files, find_large_files
|
|
28
|
+
from runspec_linux_core.investigate import (
|
|
29
|
+
failed_units,
|
|
30
|
+
open_ports,
|
|
31
|
+
owning_package,
|
|
32
|
+
service_detail,
|
|
33
|
+
)
|
|
20
34
|
from runspec_linux_core.logs import journalctl, search_log, tail_log
|
|
21
35
|
from runspec_linux_core.nc import nc_send
|
|
22
|
-
from runspec_linux_core.network import
|
|
36
|
+
from runspec_linux_core.network import (
|
|
37
|
+
check_port,
|
|
38
|
+
dns_config,
|
|
39
|
+
ping_host,
|
|
40
|
+
route_table,
|
|
41
|
+
show_connections,
|
|
42
|
+
socket_stats,
|
|
43
|
+
trace_route,
|
|
44
|
+
)
|
|
23
45
|
from runspec_linux_core.packages import (
|
|
24
46
|
detect_manager,
|
|
25
47
|
install_package,
|
|
@@ -28,6 +50,7 @@ from runspec_linux_core.packages import (
|
|
|
28
50
|
remove_package,
|
|
29
51
|
upgrade_packages,
|
|
30
52
|
)
|
|
53
|
+
from runspec_linux_core.perf import cpu_info, get_sysctl, pressure, set_sysctl
|
|
31
54
|
from runspec_linux_core.power import reboot_host
|
|
32
55
|
from runspec_linux_core.security import last_logins, who
|
|
33
56
|
from runspec_linux_core.services import check_service, list_services, restart_service
|
|
@@ -40,6 +63,13 @@ __all__ = [
|
|
|
40
63
|
"LinuxCoreError",
|
|
41
64
|
"ToolNotFoundError",
|
|
42
65
|
"CommandError",
|
|
66
|
+
# become (privilege escalation)
|
|
67
|
+
"BecomeError",
|
|
68
|
+
"build_become",
|
|
69
|
+
"check_become_error",
|
|
70
|
+
"current_user",
|
|
71
|
+
"escalation_supported",
|
|
72
|
+
"validate_user",
|
|
43
73
|
# system
|
|
44
74
|
"system_info",
|
|
45
75
|
"disk_usage",
|
|
@@ -57,6 +87,20 @@ __all__ = [
|
|
|
57
87
|
"ping_host",
|
|
58
88
|
"check_port",
|
|
59
89
|
"show_connections",
|
|
90
|
+
"trace_route",
|
|
91
|
+
"route_table",
|
|
92
|
+
"socket_stats",
|
|
93
|
+
"dns_config",
|
|
94
|
+
# perf tuning & analysis
|
|
95
|
+
"get_sysctl",
|
|
96
|
+
"set_sysctl",
|
|
97
|
+
"cpu_info",
|
|
98
|
+
"pressure",
|
|
99
|
+
# app discovery & investigation
|
|
100
|
+
"service_detail",
|
|
101
|
+
"open_ports",
|
|
102
|
+
"owning_package",
|
|
103
|
+
"failed_units",
|
|
60
104
|
# files
|
|
61
105
|
"find_large_files",
|
|
62
106
|
"backup_files",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Privilege-escalation command builder — run a command as another OS user via
|
|
2
|
+
``sudo -n -u <user>`` / ``su <user> -c``.
|
|
3
|
+
|
|
4
|
+
This is the single source of truth for runspec-linux's per-invocation ``run_as``
|
|
5
|
+
escalation (mechanism B): the venv runnables and the ``--run-as`` / ``--sudo``
|
|
6
|
+
args on the privileged runnables all build their become command here. It is a
|
|
7
|
+
sibling of ``runspec_fs_core.become`` (kept a separate copy because the ``*-core``
|
|
8
|
+
packages stay dependency-free and must not cross-import); the canonical user
|
|
9
|
+
regex here allows a dot (``[a-z0-9_.-]``), a superset of the fs-core form, so it
|
|
10
|
+
matches ``sudoers._USER_RE`` and every name runspec-linux already accepts.
|
|
11
|
+
|
|
12
|
+
Pure and stdlib-only: this *builds and validates* the become command; executing
|
|
13
|
+
it (and interpreting a passwordless-sudo failure) stays with the caller — see
|
|
14
|
+
``_check`` in ``venvs.py`` and ``check_become_error`` below.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
|
|
23
|
+
from runspec_linux_core.errors import CommandError
|
|
24
|
+
|
|
25
|
+
# A POSIX-ish user/group name, validated before it is interpolated into a become
|
|
26
|
+
# command (or a chgrp) so a crafted value can't inject extra tokens — everything
|
|
27
|
+
# still goes to argv, never a shell, but this keeps the error early and clear.
|
|
28
|
+
# Allows a dot, matching sudoers._USER_RE and the historical venvs._NAME_RE.
|
|
29
|
+
_USER_RE = re.compile(r"^[a-z_][a-z0-9_.-]*\$?$")
|
|
30
|
+
|
|
31
|
+
BECOME_METHODS = ("sudo", "su")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BecomeError(CommandError):
|
|
35
|
+
"""An invalid run-as user, or an unsupported / unavailable become method."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_user(name: str) -> str:
|
|
39
|
+
"""Raise :class:`BecomeError` unless ``name`` is a well-formed OS user name;
|
|
40
|
+
return it unchanged on success (so it can be used inline)."""
|
|
41
|
+
if not name or not _USER_RE.match(name):
|
|
42
|
+
raise BecomeError(f"invalid run-as user: {name!r}")
|
|
43
|
+
return name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_become(argv: list[str], run_as: str | None, method: str = "sudo") -> list[str]:
|
|
47
|
+
"""Wrap ``argv`` so it runs as ``run_as``. Returns ``argv`` unchanged when
|
|
48
|
+
``run_as`` is falsy (no escalation requested).
|
|
49
|
+
|
|
50
|
+
- ``sudo`` → ``sudo -n -u <user> <argv...>`` (``-n`` never prompts, so a
|
|
51
|
+
missing passwordless-sudo rule fails fast instead of hanging on a
|
|
52
|
+
TTY-less SSH/agent channel).
|
|
53
|
+
- ``su`` → ``su <user> -c '<argv...>'`` (su has no ``-n``; it only works
|
|
54
|
+
without a password when the caller is already root).
|
|
55
|
+
"""
|
|
56
|
+
if not run_as:
|
|
57
|
+
return argv
|
|
58
|
+
validate_user(run_as)
|
|
59
|
+
if method not in BECOME_METHODS:
|
|
60
|
+
raise CommandError(f"invalid become-method: {method!r} (expected sudo or su)")
|
|
61
|
+
if method == "su":
|
|
62
|
+
return ["su", run_as, "-c", shlex.join(argv)]
|
|
63
|
+
return ["sudo", "-n", "-u", run_as, *argv]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_become_error(what: str, stderr: str, run_as: str | None) -> None:
|
|
67
|
+
"""Raise :class:`CommandError` for a failed escalated subprocess, adding a
|
|
68
|
+
passwordless-sudo hint on the classic "password/terminal required" cases.
|
|
69
|
+
|
|
70
|
+
``what`` labels the operation; ``stderr`` is the subprocess's captured
|
|
71
|
+
stderr; ``run_as`` is the target user (``None`` when no escalation was in
|
|
72
|
+
play — then the hint is skipped). Shared by the venv helpers and the new
|
|
73
|
+
``--run-as`` / ``--sudo`` runnables so the message is identical everywhere.
|
|
74
|
+
"""
|
|
75
|
+
err = stderr.strip()
|
|
76
|
+
low = err.lower()
|
|
77
|
+
if run_as and ("password is required" in low or "a terminal is required" in low):
|
|
78
|
+
raise CommandError(f"{what}: passwordless sudo is not configured for run-as {run_as!r} ({err})")
|
|
79
|
+
raise CommandError(err or f"{what} failed")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def escalation_supported() -> bool:
|
|
83
|
+
"""True on a POSIX host where ``sudo``/``su`` escalation makes sense."""
|
|
84
|
+
return hasattr(os, "geteuid")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def current_user() -> str:
|
|
88
|
+
"""The effective OS user name — used to decide whether escalation is needed
|
|
89
|
+
(running as the user you already are is a no-op)."""
|
|
90
|
+
try:
|
|
91
|
+
import pwd
|
|
92
|
+
|
|
93
|
+
return pwd.getpwuid(os.geteuid()).pw_name
|
|
94
|
+
except (ImportError, KeyError, AttributeError):
|
|
95
|
+
import getpass
|
|
96
|
+
|
|
97
|
+
return getpass.getuser()
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Application discovery & investigation helpers — answer "where does this
|
|
2
|
+
bespoke app live, what runs it, what's it listening on, and which package owns
|
|
3
|
+
this file" for corporate application support.
|
|
4
|
+
|
|
5
|
+
- ``service_detail`` — a systemd unit's ExecStart / working dir / user / env
|
|
6
|
+
files / active state (from ``systemctl show``).
|
|
7
|
+
- ``open_ports`` — listening sockets mapped to pid → process (from ``ss -tlnp``);
|
|
8
|
+
the full pid map needs privilege, so it takes ``run_as`` / ``become_method``.
|
|
9
|
+
- ``owning_package`` — which package owns a path (dpkg / rpm).
|
|
10
|
+
- ``failed_units`` — systemd units in the failed state.
|
|
11
|
+
|
|
12
|
+
Parsers are separated from the subprocess I/O so they unit-test without the
|
|
13
|
+
real tools.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
|
|
22
|
+
from runspec_linux_core.become import build_become, check_become_error
|
|
23
|
+
from runspec_linux_core.errors import CommandError, ToolNotFoundError
|
|
24
|
+
|
|
25
|
+
# systemctl show properties worth surfacing for "where does this app live".
|
|
26
|
+
_SERVICE_PROPS = [
|
|
27
|
+
"Id",
|
|
28
|
+
"Description",
|
|
29
|
+
"LoadState",
|
|
30
|
+
"ActiveState",
|
|
31
|
+
"SubState",
|
|
32
|
+
"UnitFileState",
|
|
33
|
+
"FragmentPath",
|
|
34
|
+
"ExecStart",
|
|
35
|
+
"WorkingDirectory",
|
|
36
|
+
"User",
|
|
37
|
+
"Group",
|
|
38
|
+
"EnvironmentFiles",
|
|
39
|
+
"MainPID",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_show(text: str) -> dict[str, str]:
|
|
44
|
+
"""Parse ``systemctl show`` ``key=value`` output into a dict."""
|
|
45
|
+
props: dict[str, str] = {}
|
|
46
|
+
for line in text.splitlines():
|
|
47
|
+
if "=" in line:
|
|
48
|
+
k, v = line.split("=", 1)
|
|
49
|
+
props[k] = v
|
|
50
|
+
return props
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _exec_start_path(exec_start: str) -> str:
|
|
54
|
+
"""Pull the binary path out of a systemd ExecStart property value.
|
|
55
|
+
|
|
56
|
+
ExecStart is rendered as ``{ path=/usr/bin/foo ; argv[]=... }`` by
|
|
57
|
+
``systemctl show``; fall back to the first token otherwise.
|
|
58
|
+
"""
|
|
59
|
+
m = re.search(r"path=(\S+)", exec_start)
|
|
60
|
+
if m:
|
|
61
|
+
return m.group(1)
|
|
62
|
+
return exec_start.split(";", 1)[0].strip().split(" ", 1)[0]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def service_detail(unit: str, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
|
|
66
|
+
"""Return where a systemd unit lives: ExecStart binary, working dir, user,
|
|
67
|
+
env files, unit-file path, and active state.
|
|
68
|
+
|
|
69
|
+
Some properties on protected units need privilege, so ``run_as`` /
|
|
70
|
+
``become_method`` escalate the ``systemctl show``. Raises ToolNotFoundError if
|
|
71
|
+
systemctl is absent.
|
|
72
|
+
"""
|
|
73
|
+
if not shutil.which("systemctl"):
|
|
74
|
+
raise ToolNotFoundError("systemctl not available — not a systemd system")
|
|
75
|
+
|
|
76
|
+
cmd = ["systemctl", "show", unit, "--property=" + ",".join(_SERVICE_PROPS)]
|
|
77
|
+
result = subprocess.run(build_become(cmd, run_as, become_method), capture_output=True, text=True)
|
|
78
|
+
if result.returncode != 0:
|
|
79
|
+
check_become_error("service detail", result.stderr, run_as)
|
|
80
|
+
|
|
81
|
+
props = _parse_show(result.stdout)
|
|
82
|
+
exec_start = props.get("ExecStart", "")
|
|
83
|
+
return {
|
|
84
|
+
"unit": props.get("Id", unit),
|
|
85
|
+
"description": props.get("Description", ""),
|
|
86
|
+
"load_state": props.get("LoadState", ""),
|
|
87
|
+
"active_state": props.get("ActiveState", ""),
|
|
88
|
+
"sub_state": props.get("SubState", ""),
|
|
89
|
+
"unit_file_state": props.get("UnitFileState", ""),
|
|
90
|
+
"fragment_path": props.get("FragmentPath", ""),
|
|
91
|
+
"exec_start": exec_start,
|
|
92
|
+
"exec_start_path": _exec_start_path(exec_start) if exec_start else "",
|
|
93
|
+
"working_directory": props.get("WorkingDirectory", ""),
|
|
94
|
+
"user": props.get("User", ""),
|
|
95
|
+
"group": props.get("Group", ""),
|
|
96
|
+
"environment_files": props.get("EnvironmentFiles", ""),
|
|
97
|
+
"main_pid": props.get("MainPID", ""),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_PEER_WILDCARD_RE = re.compile(r"^[\d.a-fA-F:\[\]*]+:\*$")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_ss_listening(text: str) -> list[dict]:
|
|
105
|
+
"""Parse ``ss -tlnp`` / ``-ulnp`` / ``-tulnp`` output into listening-socket
|
|
106
|
+
rows with port → pid/process.
|
|
107
|
+
|
|
108
|
+
Column layout shifts with the flags (``-tu`` prepends a Netid column that
|
|
109
|
+
``-t`` alone omits), so the local address is anchored on the wildcard peer
|
|
110
|
+
field (``*:*`` / ``0.0.0.0:*`` / ``[::]:*``) rather than a fixed index.
|
|
111
|
+
"""
|
|
112
|
+
rows: list[dict] = []
|
|
113
|
+
lines = text.strip().splitlines()
|
|
114
|
+
for line in lines[1:] if lines else []: # skip header
|
|
115
|
+
parts = line.split()
|
|
116
|
+
if len(parts) < 4:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
peer_idx = next((i for i, p in enumerate(parts) if _PEER_WILDCARD_RE.match(p)), None)
|
|
120
|
+
if peer_idx is None or peer_idx == 0:
|
|
121
|
+
continue
|
|
122
|
+
local = parts[peer_idx - 1]
|
|
123
|
+
port = local.rsplit(":", 1)[-1]
|
|
124
|
+
# A leading Netid column (tcp/udp) is present with -tu but not -t alone.
|
|
125
|
+
proto = parts[0] if parts[0] in ("tcp", "udp", "tcp6", "udp6") else ""
|
|
126
|
+
|
|
127
|
+
procs: list[dict] = []
|
|
128
|
+
m = re.search(r"users:\(\((.*)\)\)", line)
|
|
129
|
+
if m:
|
|
130
|
+
for pm in re.finditer(r'"([^"]+)",pid=(\d+)', m.group(1)):
|
|
131
|
+
procs.append({"process": pm.group(1), "pid": int(pm.group(2))})
|
|
132
|
+
rows.append({"proto": proto, "local": local, "port": port, "processes": procs})
|
|
133
|
+
return rows
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def open_ports(proto: str = "tcp", *, run_as: str | None = None, become_method: str = "sudo") -> dict:
|
|
137
|
+
"""Return listening sockets mapped to the owning process (port → pid → name).
|
|
138
|
+
|
|
139
|
+
The full pid/process map needs privilege (``ss`` only shows other users'
|
|
140
|
+
processes as root), so ``run_as`` / ``become_method`` escalate the probe.
|
|
141
|
+
``proto`` is ``tcp`` / ``udp`` / ``both``. Raises ToolNotFoundError if ``ss``
|
|
142
|
+
is absent.
|
|
143
|
+
"""
|
|
144
|
+
if not shutil.which("ss"):
|
|
145
|
+
raise ToolNotFoundError("ss not available (iproute2)")
|
|
146
|
+
|
|
147
|
+
flags = {"tcp": "-tlnp", "udp": "-ulnp", "both": "-tulnp"}.get(proto)
|
|
148
|
+
if flags is None:
|
|
149
|
+
raise ValueError(f"invalid proto: {proto!r} (expected tcp, udp, or both)")
|
|
150
|
+
|
|
151
|
+
result = subprocess.run(build_become(["ss", flags], run_as, become_method), capture_output=True, text=True)
|
|
152
|
+
if result.returncode != 0:
|
|
153
|
+
check_become_error("open ports", result.stderr, run_as)
|
|
154
|
+
|
|
155
|
+
rows = _parse_ss_listening(result.stdout)
|
|
156
|
+
out = {"proto": proto, "count": len(rows), "listeners": rows}
|
|
157
|
+
if run_as:
|
|
158
|
+
out["run_as"] = run_as
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def owning_package(path: str) -> dict:
|
|
163
|
+
"""Return which installed package owns ``path`` (dpkg on Debian/Ubuntu, rpm on
|
|
164
|
+
RHEL/SUSE). Raises ToolNotFoundError if neither dpkg nor rpm is present;
|
|
165
|
+
returns ``{owned: False}`` when the file belongs to no package.
|
|
166
|
+
"""
|
|
167
|
+
if shutil.which("dpkg"):
|
|
168
|
+
result = subprocess.run(["dpkg", "-S", path], capture_output=True, text=True)
|
|
169
|
+
if result.returncode == 0 and ":" in result.stdout:
|
|
170
|
+
pkg = result.stdout.split(":", 1)[0].strip()
|
|
171
|
+
return {"path": path, "owned": True, "package": pkg, "manager": "dpkg"}
|
|
172
|
+
return {"path": path, "owned": False, "manager": "dpkg"}
|
|
173
|
+
if shutil.which("rpm"):
|
|
174
|
+
result = subprocess.run(["rpm", "-qf", path], capture_output=True, text=True)
|
|
175
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
176
|
+
return {"path": path, "owned": True, "package": result.stdout.strip(), "manager": "rpm"}
|
|
177
|
+
return {"path": path, "owned": False, "manager": "rpm"}
|
|
178
|
+
raise ToolNotFoundError("neither dpkg nor rpm found — cannot resolve package ownership")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_failed_units(text: str) -> list[dict]:
|
|
182
|
+
"""Parse ``systemctl --failed --no-legend`` output into unit rows."""
|
|
183
|
+
rows: list[dict] = []
|
|
184
|
+
for line in text.strip().splitlines():
|
|
185
|
+
# A leading bullet ("● ") may appear; strip non-unit prefix tokens.
|
|
186
|
+
parts = line.replace("●", " ").split(None, 4)
|
|
187
|
+
if len(parts) < 4:
|
|
188
|
+
continue
|
|
189
|
+
rows.append(
|
|
190
|
+
{
|
|
191
|
+
"unit": parts[0],
|
|
192
|
+
"load": parts[1],
|
|
193
|
+
"active": parts[2],
|
|
194
|
+
"sub": parts[3],
|
|
195
|
+
"description": parts[4] if len(parts) > 4 else "",
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
return rows
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def failed_units() -> dict:
|
|
202
|
+
"""Return systemd units currently in the failed state (``systemctl --failed``).
|
|
203
|
+
|
|
204
|
+
Raises ToolNotFoundError if systemctl is absent, or CommandError on failure.
|
|
205
|
+
"""
|
|
206
|
+
if not shutil.which("systemctl"):
|
|
207
|
+
raise ToolNotFoundError("systemctl not available — not a systemd system")
|
|
208
|
+
|
|
209
|
+
result = subprocess.run(
|
|
210
|
+
["systemctl", "--failed", "--type=service", "--no-legend", "--no-pager", "--plain"],
|
|
211
|
+
capture_output=True,
|
|
212
|
+
text=True,
|
|
213
|
+
)
|
|
214
|
+
if result.returncode != 0:
|
|
215
|
+
raise CommandError(result.stderr.strip() or "systemctl --failed failed")
|
|
216
|
+
rows = _parse_failed_units(result.stdout)
|
|
217
|
+
return {"count": len(rows), "units": rows}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Log helpers: tail a file, search a file, read journald units.
|
|
2
|
+
|
|
3
|
+
Corporate application logs are often root-owned (or owned by a service
|
|
4
|
+
account), so each helper takes an optional ``run_as`` user (with a
|
|
5
|
+
``become_method``). When set, the read is performed by a subprocess escalated
|
|
6
|
+
via ``sudo -n -u`` / ``su`` (:func:`runspec_linux_core.become.build_become`) — a
|
|
7
|
+
faithful escalation of the same read, bounded by that user's OS permissions.
|
|
8
|
+
With no ``run_as`` the read stays in-process (unchanged).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
from runspec_linux_core.become import build_become, check_become_error
|
|
16
|
+
from runspec_linux_core.errors import ToolNotFoundError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def tail_log(file_path: str, lines: int = 50, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
|
|
20
|
+
"""Return the last ``lines`` lines of a text file.
|
|
21
|
+
|
|
22
|
+
With ``run_as`` set the read is escalated via ``tail -n`` under sudo/su (so a
|
|
23
|
+
root-owned log is readable); otherwise it reads in-process. Propagates OSError
|
|
24
|
+
if an in-process read fails, or CommandError if the escalated read fails.
|
|
25
|
+
"""
|
|
26
|
+
if run_as:
|
|
27
|
+
argv = build_become(["tail", "-n", str(lines), file_path], run_as, become_method)
|
|
28
|
+
result = subprocess.run(argv, capture_output=True, text=True)
|
|
29
|
+
if result.returncode != 0:
|
|
30
|
+
check_become_error("tail log", result.stderr, run_as)
|
|
31
|
+
tail = result.stdout.splitlines()
|
|
32
|
+
return {"file": file_path, "lines": tail, "count": len(tail), "run_as": run_as}
|
|
33
|
+
|
|
34
|
+
with open(file_path, errors="replace") as f:
|
|
35
|
+
all_lines = f.readlines()
|
|
36
|
+
tail = [line.rstrip("\n") for line in all_lines[-lines:]]
|
|
37
|
+
return {"file": file_path, "lines": tail, "count": len(tail)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def search_log(file_path: str, pattern: str, limit: int = 100, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
|
|
41
|
+
"""Return up to ``limit`` (most recent) lines of a file matching ``pattern``.
|
|
42
|
+
|
|
43
|
+
The match is always Python ``re`` (case-insensitive). With ``run_as`` set the
|
|
44
|
+
file is read via ``cat`` under sudo/su (so a root-owned log is searchable) and
|
|
45
|
+
the same regex is applied; otherwise it streams the file in-process. Propagates
|
|
46
|
+
OSError / re.error in-process, or CommandError if the escalated read fails.
|
|
47
|
+
"""
|
|
48
|
+
regex = re.compile(pattern, re.IGNORECASE)
|
|
49
|
+
matches: list[str] = []
|
|
50
|
+
|
|
51
|
+
if run_as:
|
|
52
|
+
argv = build_become(["cat", file_path], run_as, become_method)
|
|
53
|
+
result = subprocess.run(argv, capture_output=True, text=True)
|
|
54
|
+
if result.returncode != 0:
|
|
55
|
+
check_become_error("read log", result.stderr, run_as)
|
|
56
|
+
for line in result.stdout.splitlines():
|
|
57
|
+
if regex.search(line):
|
|
58
|
+
matches.append(line)
|
|
59
|
+
else:
|
|
60
|
+
with open(file_path, errors="replace") as f:
|
|
61
|
+
for line in f:
|
|
62
|
+
if regex.search(line):
|
|
63
|
+
matches.append(line.rstrip("\n"))
|
|
64
|
+
|
|
65
|
+
trimmed = matches[-limit:] if len(matches) > limit else matches
|
|
66
|
+
result_dict = {
|
|
67
|
+
"file": file_path,
|
|
68
|
+
"pattern": pattern,
|
|
69
|
+
"matches": trimmed,
|
|
70
|
+
"count": len(trimmed),
|
|
71
|
+
"total_matches": len(matches),
|
|
72
|
+
}
|
|
73
|
+
if run_as:
|
|
74
|
+
result_dict["run_as"] = run_as
|
|
75
|
+
return result_dict
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def journalctl(unit: str, lines: int = 50, since: str | None = None, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
|
|
79
|
+
"""Return recent journald log lines for a systemd unit.
|
|
80
|
+
|
|
81
|
+
With ``run_as`` set the query is escalated via sudo/su (so protected units
|
|
82
|
+
outside the ``systemd-journal`` group are readable). Raises ToolNotFoundError
|
|
83
|
+
if journalctl is not present, or CommandError if the query exits non-zero.
|
|
84
|
+
"""
|
|
85
|
+
if not shutil.which("journalctl"):
|
|
86
|
+
raise ToolNotFoundError("journalctl not available — not a systemd system")
|
|
87
|
+
|
|
88
|
+
cmd = ["journalctl", f"-u{unit}", f"-n{lines}", "--no-pager", "--output=short"]
|
|
89
|
+
if since:
|
|
90
|
+
cmd += [f"--since={since}"]
|
|
91
|
+
|
|
92
|
+
result = subprocess.run(build_become(cmd, run_as, become_method), capture_output=True, text=True)
|
|
93
|
+
if result.returncode != 0:
|
|
94
|
+
check_become_error("journalctl", result.stderr, run_as)
|
|
95
|
+
output_lines = result.stdout.strip().splitlines()
|
|
96
|
+
out = {"unit": unit, "lines": output_lines, "count": len(output_lines)}
|
|
97
|
+
if run_as:
|
|
98
|
+
out["run_as"] = run_as
|
|
99
|
+
return out
|