runspec-linux 0.1.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-0.1.0/.gitignore +55 -0
- runspec_linux-0.1.0/CHANGELOG.md +25 -0
- runspec_linux-0.1.0/PKG-INFO +10 -0
- runspec_linux-0.1.0/pyproject.toml +55 -0
- runspec_linux-0.1.0/runspec_linux/__init__.py +3 -0
- runspec_linux-0.1.0/runspec_linux/containers.py +117 -0
- runspec_linux-0.1.0/runspec_linux/files.py +72 -0
- runspec_linux-0.1.0/runspec_linux/logs.py +81 -0
- runspec_linux-0.1.0/runspec_linux/nc_command.py +65 -0
- runspec_linux-0.1.0/runspec_linux/network.py +159 -0
- runspec_linux-0.1.0/runspec_linux/runspec.toml +328 -0
- runspec_linux-0.1.0/runspec_linux/security.py +70 -0
- runspec_linux-0.1.0/runspec_linux/services.py +119 -0
- runspec_linux-0.1.0/runspec_linux/system_info.py +140 -0
- runspec_linux-0.1.0/tests/__init__.py +0 -0
- runspec_linux-0.1.0/tests/test_nc_send.py +84 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
.env
|
|
16
|
+
pip-wheel-metadata/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
htmlcov/
|
|
21
|
+
.coverage
|
|
22
|
+
coverage.xml
|
|
23
|
+
*.cover
|
|
24
|
+
|
|
25
|
+
# Node
|
|
26
|
+
node_modules/
|
|
27
|
+
dist/
|
|
28
|
+
*.js.map
|
|
29
|
+
.npm
|
|
30
|
+
|
|
31
|
+
# Go
|
|
32
|
+
*.exe
|
|
33
|
+
*.test
|
|
34
|
+
*.out
|
|
35
|
+
vendor/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.iml
|
|
41
|
+
*.iws
|
|
42
|
+
*.ipr
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
|
|
46
|
+
# Docs
|
|
47
|
+
site/
|
|
48
|
+
|
|
49
|
+
# Misc
|
|
50
|
+
*.log
|
|
51
|
+
*.tmp
|
|
52
|
+
|
|
53
|
+
# External reference repos (cloned locally, not committed)
|
|
54
|
+
chainlit-docs/
|
|
55
|
+
.chainlit/
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# runspec-linux Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] — 2026-05-27
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
21 runnables covering Linux system administration:
|
|
8
|
+
|
|
9
|
+
**System monitoring** — `system-info`, `disk-usage`, `check-memory`, `list-processes`
|
|
10
|
+
|
|
11
|
+
**Services** — `check-service`, `list-services`, `restart-service`
|
|
12
|
+
|
|
13
|
+
**Logs** — `tail-log`, `search-log`, `journalctl`
|
|
14
|
+
|
|
15
|
+
**Network** — `ping-host`, `check-port`, `show-connections`
|
|
16
|
+
|
|
17
|
+
**Files** — `find-large-files`, `backup-files`
|
|
18
|
+
|
|
19
|
+
**Security** — `last-logins`, `who`
|
|
20
|
+
|
|
21
|
+
**Containers** — `list-containers`, `container-logs`, `restart-container`
|
|
22
|
+
|
|
23
|
+
**TCP interfaces** — `nc-command` (also exports `nc_send()` as a public Python API for wrapper runnables)
|
|
24
|
+
|
|
25
|
+
All read-only runnables are `autonomy = "autonomous"`. State-changing runnables (`restart-service`, `backup-files`, `restart-container`) are `autonomy = "confirm"`. Missing tools (`docker`, `systemctl`, `journalctl`) return a structured JSON error rather than a stack trace.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec-linux
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Linux system admin runnables for runspec
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: runspec>=0.17.0
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runspec-linux"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
description = "Linux system admin runnables for runspec"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"runspec>=0.17.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
system-info = "runspec_linux.system_info:main_system_info"
|
|
16
|
+
disk-usage = "runspec_linux.system_info:main_disk_usage"
|
|
17
|
+
check-memory = "runspec_linux.system_info:main_check_memory"
|
|
18
|
+
list-processes = "runspec_linux.system_info:main_list_processes"
|
|
19
|
+
check-service = "runspec_linux.services:main_check_service"
|
|
20
|
+
list-services = "runspec_linux.services:main_list_services"
|
|
21
|
+
restart-service = "runspec_linux.services:main_restart_service"
|
|
22
|
+
tail-log = "runspec_linux.logs:main_tail_log"
|
|
23
|
+
search-log = "runspec_linux.logs:main_search_log"
|
|
24
|
+
journalctl = "runspec_linux.logs:main_journalctl"
|
|
25
|
+
ping-host = "runspec_linux.network:main_ping_host"
|
|
26
|
+
check-port = "runspec_linux.network:main_check_port"
|
|
27
|
+
show-connections = "runspec_linux.network:main_show_connections"
|
|
28
|
+
find-large-files = "runspec_linux.files:main_find_large_files"
|
|
29
|
+
backup-files = "runspec_linux.files:main_backup_files"
|
|
30
|
+
last-logins = "runspec_linux.security:main_last_logins"
|
|
31
|
+
who = "runspec_linux.security:main_who"
|
|
32
|
+
list-containers = "runspec_linux.containers:main_list_containers"
|
|
33
|
+
container-logs = "runspec_linux.containers:main_container_logs"
|
|
34
|
+
restart-container = "runspec_linux.containers:main_restart_container"
|
|
35
|
+
nc-command = "runspec_linux.nc_command:main"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"ruff",
|
|
40
|
+
"mypy",
|
|
41
|
+
"pytest>=8.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
|
|
47
|
+
[tool.mypy]
|
|
48
|
+
python_version = "3.11"
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 200
|
|
52
|
+
target-version = "py311"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import runspec as rs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _docker_available() -> bool:
|
|
10
|
+
return shutil.which("docker") is not None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main_list_containers() -> None:
|
|
14
|
+
spec = rs.parse("list-containers")
|
|
15
|
+
include_all = bool(spec.all)
|
|
16
|
+
|
|
17
|
+
if not _docker_available():
|
|
18
|
+
print(json.dumps({"error": "docker not installed or not in PATH"}))
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
cmd = [
|
|
23
|
+
"docker",
|
|
24
|
+
"ps",
|
|
25
|
+
"--format",
|
|
26
|
+
"{{.ID}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Status}}\t{{.Names}}",
|
|
27
|
+
]
|
|
28
|
+
if include_all:
|
|
29
|
+
cmd.append("--all")
|
|
30
|
+
|
|
31
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
32
|
+
if result.returncode != 0:
|
|
33
|
+
print(json.dumps({"error": result.stderr.strip()}))
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
rows = []
|
|
37
|
+
for line in result.stdout.strip().splitlines():
|
|
38
|
+
parts = line.split("\t")
|
|
39
|
+
if len(parts) < 6:
|
|
40
|
+
continue
|
|
41
|
+
rows.append(
|
|
42
|
+
{
|
|
43
|
+
"id": parts[0],
|
|
44
|
+
"image": parts[1],
|
|
45
|
+
"command": parts[2].strip('"'),
|
|
46
|
+
"created": parts[3],
|
|
47
|
+
"status": parts[4],
|
|
48
|
+
"name": parts[5],
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
print(json.dumps(rows))
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(json.dumps({"error": str(e)}))
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main_container_logs() -> None:
|
|
58
|
+
spec = rs.parse("container-logs")
|
|
59
|
+
container = str(spec.container)
|
|
60
|
+
lines = int(spec.lines)
|
|
61
|
+
|
|
62
|
+
if not _docker_available():
|
|
63
|
+
print(json.dumps({"error": "docker not installed or not in PATH"}))
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["docker", "logs", "--tail", str(lines), container],
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
)
|
|
72
|
+
# docker logs writes to stderr by default
|
|
73
|
+
output = result.stderr if result.stderr else result.stdout
|
|
74
|
+
output_lines = output.strip().splitlines()
|
|
75
|
+
print(
|
|
76
|
+
json.dumps(
|
|
77
|
+
{
|
|
78
|
+
"container": container,
|
|
79
|
+
"lines": output_lines,
|
|
80
|
+
"count": len(output_lines),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
print(json.dumps({"error": str(e)}))
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main_restart_container() -> None:
|
|
90
|
+
spec = rs.parse("restart-container")
|
|
91
|
+
container = str(spec.container)
|
|
92
|
+
|
|
93
|
+
if not _docker_available():
|
|
94
|
+
print(json.dumps({"error": "docker not installed or not in PATH"}))
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = subprocess.run(
|
|
99
|
+
["docker", "restart", container],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
)
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
print(
|
|
105
|
+
json.dumps(
|
|
106
|
+
{
|
|
107
|
+
"container": container,
|
|
108
|
+
"restarted": False,
|
|
109
|
+
"error": result.stderr.strip(),
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
print(json.dumps({"container": container, "restarted": True}))
|
|
115
|
+
except Exception as e:
|
|
116
|
+
print(json.dumps({"error": str(e)}))
|
|
117
|
+
sys.exit(1)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tarfile
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import runspec as rs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main_find_large_files() -> None:
|
|
12
|
+
spec = rs.parse("find-large-files")
|
|
13
|
+
search_path = str(spec.path)
|
|
14
|
+
min_mb = float(spec.min_mb)
|
|
15
|
+
limit = int(spec.limit)
|
|
16
|
+
|
|
17
|
+
min_bytes = int(min_mb * 1_048_576)
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
results: list[dict] = []
|
|
21
|
+
for dirpath, _dirs, filenames in os.walk(search_path):
|
|
22
|
+
for name in filenames:
|
|
23
|
+
fpath = os.path.join(dirpath, name)
|
|
24
|
+
try:
|
|
25
|
+
stat = os.stat(fpath, follow_symlinks=False)
|
|
26
|
+
if stat.st_size >= min_bytes:
|
|
27
|
+
results.append(
|
|
28
|
+
{
|
|
29
|
+
"path": fpath,
|
|
30
|
+
"size_mb": round(stat.st_size / 1_048_576, 2),
|
|
31
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
except OSError:
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
results.sort(key=lambda r: r["size_mb"], reverse=True)
|
|
38
|
+
print(json.dumps(results[:limit]))
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(json.dumps({"error": str(e)}))
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main_backup_files() -> None:
|
|
45
|
+
spec = rs.parse("backup-files")
|
|
46
|
+
source = str(spec.source)
|
|
47
|
+
destination = str(spec.destination)
|
|
48
|
+
|
|
49
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
50
|
+
source_name = Path(source).name or "backup"
|
|
51
|
+
archive_name = f"{source_name}_{timestamp}.tar.gz"
|
|
52
|
+
archive_path = os.path.join(destination, archive_name)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
os.makedirs(destination, exist_ok=True)
|
|
56
|
+
with tarfile.open(archive_path, "w:gz") as tar:
|
|
57
|
+
tar.add(source, arcname=source_name)
|
|
58
|
+
|
|
59
|
+
size_bytes = os.path.getsize(archive_path)
|
|
60
|
+
print(
|
|
61
|
+
json.dumps(
|
|
62
|
+
{
|
|
63
|
+
"source": source,
|
|
64
|
+
"destination": archive_path,
|
|
65
|
+
"size_bytes": size_bytes,
|
|
66
|
+
"size_mb": round(size_bytes / 1_048_576, 2),
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
print(json.dumps({"error": str(e)}))
|
|
72
|
+
sys.exit(1)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import runspec as rs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main_tail_log() -> None:
|
|
11
|
+
spec = rs.parse("tail-log")
|
|
12
|
+
file_path = str(spec.file)
|
|
13
|
+
lines = int(spec.lines)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
with open(file_path, errors="replace") as f:
|
|
17
|
+
all_lines = f.readlines()
|
|
18
|
+
tail = [line.rstrip("\n") for line in all_lines[-lines:]]
|
|
19
|
+
print(json.dumps({"file": file_path, "lines": tail, "count": len(tail)}))
|
|
20
|
+
except OSError as e:
|
|
21
|
+
print(json.dumps({"error": str(e), "file": file_path}))
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main_search_log() -> None:
|
|
26
|
+
spec = rs.parse("search-log")
|
|
27
|
+
file_path = str(spec.file)
|
|
28
|
+
pattern = str(spec.pattern)
|
|
29
|
+
limit = int(spec.limit)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
regex = re.compile(pattern, re.IGNORECASE)
|
|
33
|
+
matches: list[str] = []
|
|
34
|
+
with open(file_path, errors="replace") as f:
|
|
35
|
+
for line in f:
|
|
36
|
+
if regex.search(line):
|
|
37
|
+
matches.append(line.rstrip("\n"))
|
|
38
|
+
trimmed = matches[-limit:] if len(matches) > limit else matches
|
|
39
|
+
print(
|
|
40
|
+
json.dumps(
|
|
41
|
+
{
|
|
42
|
+
"file": file_path,
|
|
43
|
+
"pattern": pattern,
|
|
44
|
+
"matches": trimmed,
|
|
45
|
+
"count": len(trimmed),
|
|
46
|
+
"total_matches": len(matches),
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
except OSError as e:
|
|
51
|
+
print(json.dumps({"error": str(e), "file": file_path}))
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
except re.error as e:
|
|
54
|
+
print(json.dumps({"error": f"Invalid regex: {e}", "pattern": pattern}))
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main_journalctl() -> None:
|
|
59
|
+
spec = rs.parse("journalctl")
|
|
60
|
+
unit = str(spec.unit)
|
|
61
|
+
lines = int(spec.lines)
|
|
62
|
+
since = str(spec.since) if spec.since is not None else None
|
|
63
|
+
|
|
64
|
+
if not shutil.which("journalctl"):
|
|
65
|
+
print(json.dumps({"error": "journalctl not available — not a systemd system"}))
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
cmd = ["journalctl", f"-u{unit}", f"-n{lines}", "--no-pager", "--output=short"]
|
|
70
|
+
if since:
|
|
71
|
+
cmd += [f"--since={since}"]
|
|
72
|
+
|
|
73
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
74
|
+
output_lines = result.stdout.strip().splitlines()
|
|
75
|
+
print(json.dumps({"unit": unit, "lines": output_lines, "count": len(output_lines)}))
|
|
76
|
+
except subprocess.CalledProcessError as e:
|
|
77
|
+
print(json.dumps({"error": e.stderr.strip(), "unit": unit}))
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
print(json.dumps({"error": str(e)}))
|
|
81
|
+
sys.exit(1)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import socket
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import runspec as rs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def nc_send(
|
|
10
|
+
host: str,
|
|
11
|
+
port: int,
|
|
12
|
+
command: str,
|
|
13
|
+
wait: float = 0.3,
|
|
14
|
+
read_timeout: float = 0.1,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Send a command over a plain TCP socket and return the response.
|
|
17
|
+
|
|
18
|
+
Appends a newline to command if not present, waits `wait` seconds for the
|
|
19
|
+
server to respond, then reads in chunks until the socket goes quiet.
|
|
20
|
+
"""
|
|
21
|
+
with socket.create_connection((host, port), timeout=10) as sock:
|
|
22
|
+
payload = command if command.endswith("\n") else command + "\n"
|
|
23
|
+
sock.sendall(payload.encode())
|
|
24
|
+
|
|
25
|
+
time.sleep(wait)
|
|
26
|
+
|
|
27
|
+
sock.settimeout(read_timeout)
|
|
28
|
+
chunks: list[bytes] = []
|
|
29
|
+
while True:
|
|
30
|
+
try:
|
|
31
|
+
chunk = sock.recv(4096)
|
|
32
|
+
if not chunk:
|
|
33
|
+
break
|
|
34
|
+
chunks.append(chunk)
|
|
35
|
+
except TimeoutError:
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
return b"".join(chunks).decode(errors="replace")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> None:
|
|
42
|
+
spec = rs.parse("nc-command")
|
|
43
|
+
host = str(spec.host)
|
|
44
|
+
port = int(spec.port)
|
|
45
|
+
command = str(spec.command)
|
|
46
|
+
wait = float(spec.wait)
|
|
47
|
+
read_timeout = float(spec.read_timeout)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
response = nc_send(host, port, command, wait=wait, read_timeout=read_timeout)
|
|
51
|
+
lines = response.splitlines()
|
|
52
|
+
print(
|
|
53
|
+
json.dumps(
|
|
54
|
+
{
|
|
55
|
+
"host": host,
|
|
56
|
+
"port": port,
|
|
57
|
+
"command": command,
|
|
58
|
+
"stdout": response,
|
|
59
|
+
"lines": lines,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
except OSError as e:
|
|
64
|
+
print(json.dumps({"error": str(e), "host": host, "port": port}))
|
|
65
|
+
sys.exit(1)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import socket
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import runspec as rs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main_ping_host() -> None:
|
|
13
|
+
spec = rs.parse("ping-host")
|
|
14
|
+
host = str(spec.host)
|
|
15
|
+
count = int(spec.count)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
["ping", "-c", str(count), host],
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
)
|
|
23
|
+
output = result.stdout + result.stderr
|
|
24
|
+
|
|
25
|
+
sent = received = 0
|
|
26
|
+
m = re.search(r"(\d+) packets transmitted,\s*(\d+) received", output)
|
|
27
|
+
if m:
|
|
28
|
+
sent = int(m.group(1))
|
|
29
|
+
received = int(m.group(2))
|
|
30
|
+
|
|
31
|
+
loss_pct = 0.0
|
|
32
|
+
m2 = re.search(r"([\d.]+)% packet loss", output)
|
|
33
|
+
if m2:
|
|
34
|
+
loss_pct = float(m2.group(1))
|
|
35
|
+
|
|
36
|
+
print(
|
|
37
|
+
json.dumps(
|
|
38
|
+
{
|
|
39
|
+
"host": host,
|
|
40
|
+
"reachable": received > 0,
|
|
41
|
+
"packets_sent": sent,
|
|
42
|
+
"packets_received": received,
|
|
43
|
+
"loss_pct": loss_pct,
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(json.dumps({"error": str(e), "host": host}))
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main_check_port() -> None:
|
|
53
|
+
spec = rs.parse("check-port")
|
|
54
|
+
host = str(spec.host)
|
|
55
|
+
port = int(spec.port)
|
|
56
|
+
timeout = float(spec.timeout)
|
|
57
|
+
|
|
58
|
+
start = time.monotonic()
|
|
59
|
+
try:
|
|
60
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
61
|
+
elapsed_ms = round((time.monotonic() - start) * 1000, 1)
|
|
62
|
+
print(
|
|
63
|
+
json.dumps(
|
|
64
|
+
{
|
|
65
|
+
"host": host,
|
|
66
|
+
"port": port,
|
|
67
|
+
"open": True,
|
|
68
|
+
"response_ms": elapsed_ms,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
except (OSError, TimeoutError):
|
|
73
|
+
elapsed_ms = round((time.monotonic() - start) * 1000, 1)
|
|
74
|
+
print(json.dumps({"host": host, "port": port, "open": False, "response_ms": elapsed_ms}))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main_show_connections() -> None:
|
|
78
|
+
spec = rs.parse("show-connections")
|
|
79
|
+
state_filter = str(spec.state)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
if shutil.which("ss"):
|
|
83
|
+
cmd = ["ss", "-tupn"]
|
|
84
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
85
|
+
rows = _parse_ss(result.stdout, state_filter)
|
|
86
|
+
elif shutil.which("netstat"):
|
|
87
|
+
cmd = ["netstat", "-tupn"]
|
|
88
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
89
|
+
rows = _parse_netstat(result.stdout, state_filter)
|
|
90
|
+
else:
|
|
91
|
+
print(json.dumps({"error": "Neither ss nor netstat found in PATH"}))
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
print(json.dumps(rows))
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(json.dumps({"error": str(e)}))
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_ss(output: str, state_filter: str) -> list[dict]:
|
|
101
|
+
rows = []
|
|
102
|
+
for line in output.strip().splitlines()[1:]:
|
|
103
|
+
parts = line.split()
|
|
104
|
+
if len(parts) < 5:
|
|
105
|
+
continue
|
|
106
|
+
proto, local, peer = parts[0], parts[3], parts[4]
|
|
107
|
+
state = parts[5] if len(parts) > 5 and not parts[5].startswith("users:") else ""
|
|
108
|
+
process = ""
|
|
109
|
+
for p in parts:
|
|
110
|
+
if p.startswith("users:"):
|
|
111
|
+
m = re.search(r'"([^"]+)"', p)
|
|
112
|
+
if m:
|
|
113
|
+
process = m.group(1)
|
|
114
|
+
|
|
115
|
+
row_state = state.lower() if state else ("listen" if peer == "*:*" else "established")
|
|
116
|
+
if state_filter == "established" and "estab" not in row_state:
|
|
117
|
+
continue
|
|
118
|
+
if state_filter == "listening" and "listen" not in row_state:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
rows.append(
|
|
122
|
+
{
|
|
123
|
+
"proto": proto,
|
|
124
|
+
"local": local,
|
|
125
|
+
"peer": peer,
|
|
126
|
+
"state": state,
|
|
127
|
+
"process": process,
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
return rows
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_netstat(output: str, state_filter: str) -> list[dict]:
|
|
134
|
+
rows = []
|
|
135
|
+
for line in output.strip().splitlines():
|
|
136
|
+
parts = line.split()
|
|
137
|
+
if not parts or parts[0] not in ("tcp", "tcp6", "udp", "udp6"):
|
|
138
|
+
continue
|
|
139
|
+
if len(parts) < 6:
|
|
140
|
+
continue
|
|
141
|
+
proto, local, foreign = parts[0], parts[3], parts[4]
|
|
142
|
+
state = parts[5] if len(parts) > 5 else ""
|
|
143
|
+
process = parts[-1] if len(parts) > 6 else ""
|
|
144
|
+
|
|
145
|
+
if state_filter == "established" and state.upper() != "ESTABLISHED":
|
|
146
|
+
continue
|
|
147
|
+
if state_filter == "listening" and state.upper() != "LISTEN":
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
rows.append(
|
|
151
|
+
{
|
|
152
|
+
"proto": proto,
|
|
153
|
+
"local": local,
|
|
154
|
+
"peer": foreign,
|
|
155
|
+
"state": state,
|
|
156
|
+
"process": process,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
return rows
|