servchart-agent 0.2.0__tar.gz → 0.3.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.
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/PKG-INFO +3 -2
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/README.md +2 -1
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/servchart_agent/__init__.py +1 -1
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/servchart_agent/__main__.py +29 -18
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/servchart_agent/collect.py +81 -14
- servchart_agent-0.3.0/servchart_agent/commands.py +67 -0
- servchart_agent-0.3.0/servchart_agent/connections.py +164 -0
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/servchart_agent/docker_api.py +13 -3
- servchart_agent-0.3.0/servchart_agent/logs.py +87 -0
- servchart_agent-0.3.0/tests/test_connections.py +132 -0
- servchart_agent-0.3.0/tests/test_logs.py +110 -0
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/.gitignore +0 -0
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/LICENSE +0 -0
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/pyproject.toml +0 -0
- {servchart_agent-0.2.0 → servchart_agent-0.3.0}/tests/test_collect.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: servchart-agent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: ServChart agent: reports a host's Docker containers, networks and Compose stacks to a ServChart hub.
|
|
5
5
|
Project-URL: Homepage, https://github.com/opticodev/servchart
|
|
6
6
|
Project-URL: Issues, https://github.com/opticodev/servchart/issues
|
|
@@ -29,8 +29,9 @@ It reads this host's Docker state (GET requests only), drops anything sensitive,
|
|
|
29
29
|
**With Docker** (recommended):
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
docker run -d --name servchart-agent --restart unless-stopped \
|
|
32
|
+
docker run -d --name servchart-agent --restart unless-stopped --pid host \
|
|
33
33
|
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
|
34
|
+
-e SERVCHART_OBSERVE_CONNECTIONS=1 \
|
|
34
35
|
-e SERVCHART_HUB=https://servchart.example.com \
|
|
35
36
|
-e SERVCHART_TOKEN=sc_... \
|
|
36
37
|
opticodev/servchart-agent:latest
|
|
@@ -9,8 +9,9 @@ It reads this host's Docker state (GET requests only), drops anything sensitive,
|
|
|
9
9
|
**With Docker** (recommended):
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
docker run -d --name servchart-agent --restart unless-stopped \
|
|
12
|
+
docker run -d --name servchart-agent --restart unless-stopped --pid host \
|
|
13
13
|
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
|
14
|
+
-e SERVCHART_OBSERVE_CONNECTIONS=1 \
|
|
14
15
|
-e SERVCHART_HUB=https://servchart.example.com \
|
|
15
16
|
-e SERVCHART_TOKEN=sc_... \
|
|
16
17
|
opticodev/servchart-agent:latest
|
|
@@ -18,35 +18,44 @@ import time
|
|
|
18
18
|
import urllib.error
|
|
19
19
|
import urllib.request
|
|
20
20
|
|
|
21
|
-
from . import __version__
|
|
21
|
+
from . import __version__, commands
|
|
22
22
|
from .collect import collect
|
|
23
23
|
from .docker_api import DockerClient, DockerError
|
|
24
24
|
|
|
25
25
|
log = logging.getLogger("servchart.agent")
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
data = json.dumps(snapshot).encode()
|
|
31
|
-
req = urllib.request.Request(
|
|
32
|
-
url,
|
|
33
|
-
data=data,
|
|
34
|
-
method="POST",
|
|
35
|
-
headers={
|
|
36
|
-
"Content-Type": "application/json",
|
|
37
|
-
"Authorization": f"Bearer {token}",
|
|
38
|
-
"User-Agent": f"servchart-agent/{__version__}",
|
|
39
|
-
},
|
|
40
|
-
)
|
|
28
|
+
def hub_request(hub: str, token: str, ca_file: str | None = None, insecure: bool = False):
|
|
29
|
+
"""Returns request(method, path, body) -> parsed JSON, bound to this hub and token."""
|
|
41
30
|
ctx = None
|
|
42
|
-
if
|
|
31
|
+
if hub.startswith("https://"):
|
|
43
32
|
ctx = ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()
|
|
44
33
|
if insecure:
|
|
45
34
|
ctx.check_hostname = False
|
|
46
35
|
ctx.verify_mode = ssl.CERT_NONE
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
36
|
+
|
|
37
|
+
def request(method: str, path: str, body):
|
|
38
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
39
|
+
req = urllib.request.Request(
|
|
40
|
+
hub.rstrip("/") + path,
|
|
41
|
+
data=data,
|
|
42
|
+
method=method,
|
|
43
|
+
headers={
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
"Accept": "application/json",
|
|
46
|
+
"Authorization": f"Bearer {token}",
|
|
47
|
+
"User-Agent": f"servchart-agent/{__version__}",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
with urllib.request.urlopen(req, timeout=20, context=ctx) as resp:
|
|
51
|
+
raw = resp.read()
|
|
52
|
+
return json.loads(raw) if raw else None
|
|
53
|
+
|
|
54
|
+
return request
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def push(snapshot: dict, hub: str, token: str, ca_file: str | None = None, insecure: bool = False) -> None:
|
|
58
|
+
hub_request(hub, token, ca_file, insecure)("POST", "/api/v1/report", snapshot)
|
|
50
59
|
|
|
51
60
|
|
|
52
61
|
def parse_args(argv=None):
|
|
@@ -83,6 +92,8 @@ def main(argv=None) -> int:
|
|
|
83
92
|
return 2
|
|
84
93
|
|
|
85
94
|
log.info("servchart-agent %s reporting to %s every %ss", __version__, args.hub, args.interval)
|
|
95
|
+
if not args.once:
|
|
96
|
+
commands.start(client, hub_request(args.hub, args.token, args.ca_file, args.insecure))
|
|
86
97
|
backoff = 0
|
|
87
98
|
while True:
|
|
88
99
|
try:
|
|
@@ -15,6 +15,7 @@ import re
|
|
|
15
15
|
from datetime import datetime, timezone
|
|
16
16
|
|
|
17
17
|
from . import SCHEMA_VERSION, __version__
|
|
18
|
+
from . import connections as conn
|
|
18
19
|
from .docker_api import DockerClient
|
|
19
20
|
|
|
20
21
|
SECRET_KEY_RE = re.compile(r"(pass|secret|token|key|auth|credential|cookie|private)", re.IGNORECASE)
|
|
@@ -97,9 +98,20 @@ def compose_info(labels: dict, hide_paths: bool = False) -> dict:
|
|
|
97
98
|
"service": labels.get("com.docker.compose.service"),
|
|
98
99
|
"working_dir": None if hide_paths else labels.get("com.docker.compose.project.working_dir"),
|
|
99
100
|
"config_files": [] if hide_paths else [f for f in config_files.split(",") if f],
|
|
101
|
+
"depends_on": parse_depends_on(labels.get("com.docker.compose.depends_on")),
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
|
|
105
|
+
def parse_depends_on(value: str | None) -> list[str]:
|
|
106
|
+
"""Compose (v2.17+) stores depends_on as "db:service_healthy:false,redis:service_started:false"."""
|
|
107
|
+
services = []
|
|
108
|
+
for item in (value or "").split(","):
|
|
109
|
+
name = item.split(":", 1)[0].strip()
|
|
110
|
+
if name and name not in services:
|
|
111
|
+
services.append(name)
|
|
112
|
+
return services
|
|
113
|
+
|
|
114
|
+
|
|
103
115
|
def container_record(inspect: dict, hide_paths: bool = False) -> dict:
|
|
104
116
|
"""Convert a `docker inspect` document into a sanitized ServChart record.
|
|
105
117
|
|
|
@@ -119,6 +131,7 @@ def container_record(inspect: dict, hide_paths: bool = False) -> dict:
|
|
|
119
131
|
"health": health,
|
|
120
132
|
"started_at": state.get("StartedAt"),
|
|
121
133
|
"restart_policy": (host_config.get("RestartPolicy") or {}).get("Name") or None,
|
|
134
|
+
"restart_count": inspect.get("RestartCount") or 0,
|
|
122
135
|
"network_mode": host_config.get("NetworkMode"),
|
|
123
136
|
"compose": compose_info(labels, hide_paths),
|
|
124
137
|
"labels": sanitize_labels(labels),
|
|
@@ -140,31 +153,85 @@ def network_record(net: dict) -> dict:
|
|
|
140
153
|
}
|
|
141
154
|
|
|
142
155
|
|
|
156
|
+
def observe_enabled() -> bool:
|
|
157
|
+
return os.environ.get("SERVCHART_OBSERVE_CONNECTIONS", "0").lower() in ("1", "true", "yes", "on")
|
|
158
|
+
|
|
159
|
+
|
|
143
160
|
def collect(client: DockerClient | None = None, hostname: str | None = None) -> dict:
|
|
144
161
|
client = client or DockerClient()
|
|
145
162
|
hide_paths = os.environ.get("SERVCHART_HIDE_PATHS", "0") == "1"
|
|
163
|
+
include_public = os.environ.get("SERVCHART_OBSERVE_PUBLIC", "0") == "1"
|
|
146
164
|
info = client.info()
|
|
147
165
|
version = client.version()
|
|
148
|
-
|
|
166
|
+
|
|
167
|
+
inspected = []
|
|
149
168
|
for summary in client.containers():
|
|
150
169
|
try:
|
|
151
|
-
|
|
170
|
+
inspected.append(client.inspect_container(summary["Id"]))
|
|
152
171
|
except Exception: # container vanished between list and inspect
|
|
153
172
|
continue
|
|
173
|
+
containers = [container_record(i, hide_paths) for i in inspected]
|
|
154
174
|
networks = [network_record(n) for n in client.networks()]
|
|
155
|
-
|
|
175
|
+
|
|
176
|
+
host = {
|
|
177
|
+
"hostname": hostname or os.environ.get("SERVCHART_HOSTNAME") or info.get("Name"),
|
|
178
|
+
"os": info.get("OperatingSystem"),
|
|
179
|
+
"kernel": info.get("KernelVersion"),
|
|
180
|
+
"arch": info.get("Architecture"),
|
|
181
|
+
"cpus": info.get("NCPU"),
|
|
182
|
+
"memory_bytes": info.get("MemTotal"),
|
|
183
|
+
"docker_version": version.get("Version"),
|
|
184
|
+
"observe": "off",
|
|
185
|
+
# on-demand log viewing, opt-in per host (SERVCHART_ALLOW_LOGS=1)
|
|
186
|
+
"logs": "on" if os.environ.get("SERVCHART_ALLOW_LOGS", "0").lower() in ("1", "true", "yes", "on") else "off",
|
|
187
|
+
}
|
|
188
|
+
snapshot = {
|
|
156
189
|
"schema": SCHEMA_VERSION,
|
|
157
190
|
"agent_version": __version__,
|
|
158
191
|
"collected_at": _now(),
|
|
159
|
-
"host":
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"kernel": info.get("KernelVersion"),
|
|
163
|
-
"arch": info.get("Architecture"),
|
|
164
|
-
"cpus": info.get("NCPU"),
|
|
165
|
-
"memory_bytes": info.get("MemTotal"),
|
|
166
|
-
"docker_version": version.get("Version"),
|
|
167
|
-
},
|
|
168
|
-
"containers": sorted(containers, key=lambda c: c["name"]),
|
|
169
|
-
"networks": sorted(networks, key=lambda n: n["name"] or ""),
|
|
192
|
+
"host": host,
|
|
193
|
+
"containers": containers,
|
|
194
|
+
"networks": networks,
|
|
170
195
|
}
|
|
196
|
+
|
|
197
|
+
# The host's own addresses let the hub resolve other hosts' connections to this one.
|
|
198
|
+
# Readable only when we see the host's /proc (native, or --pid host); else SERVCHART_HOST_IPS.
|
|
199
|
+
subnets = [s for n in networks for s in n.get("subnets") or []]
|
|
200
|
+
host["ips"] = conn.host_ips(subnets) if conn.observe_available() else conn.host_ips_from_env()
|
|
201
|
+
|
|
202
|
+
if observe_enabled():
|
|
203
|
+
if conn.observe_available():
|
|
204
|
+
observe(snapshot, inspected, include_public)
|
|
205
|
+
else:
|
|
206
|
+
host["observe"] = "unavailable" # needs --pid host when running in a container
|
|
207
|
+
|
|
208
|
+
snapshot["containers"] = sorted(containers, key=lambda c: c["name"])
|
|
209
|
+
snapshot["networks"] = sorted(networks, key=lambda n: n["name"] or "")
|
|
210
|
+
return snapshot
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def observe(snapshot: dict, inspected: list[dict], include_public: bool = False) -> None:
|
|
214
|
+
"""Add observed outbound connections to each container and to the host itself."""
|
|
215
|
+
host = snapshot["host"]
|
|
216
|
+
container_ips = {n["ip"] for c in snapshot["containers"] for n in c["networks"] if n.get("ip")}
|
|
217
|
+
by_name = {c["name"]: c for c in snapshot["containers"]}
|
|
218
|
+
read = 0
|
|
219
|
+
for doc in inspected:
|
|
220
|
+
rec = by_name.get((doc.get("Name") or "").lstrip("/"))
|
|
221
|
+
state = doc.get("State") or {}
|
|
222
|
+
mode = (doc.get("HostConfig") or {}).get("NetworkMode") or ""
|
|
223
|
+
# host-mode containers share the host's table (reported below); container:X shares X's.
|
|
224
|
+
if not rec or not state.get("Running") or mode == "host" or mode.startswith("container:"):
|
|
225
|
+
continue
|
|
226
|
+
rows = conn.read_netns(state.get("Pid") or 0)
|
|
227
|
+
if rows is None:
|
|
228
|
+
continue
|
|
229
|
+
read += 1
|
|
230
|
+
own_ips = {n["ip"] for n in rec["networks"] if n.get("ip")}
|
|
231
|
+
rec["connections"] = conn.outbound(rows, exclude_ips=own_ips, include_public=include_public)
|
|
232
|
+
rec["listening"] = conn.listening_ports(rows)
|
|
233
|
+
host_rows = conn.read_netns(1)
|
|
234
|
+
if host_rows is not None:
|
|
235
|
+
# skip host -> own containers: that's Docker's port forwarding, not an application talking
|
|
236
|
+
snapshot["host_connections"] = conn.outbound(host_rows, exclude_ips=container_ips | set(host.get("ips") or []), include_public=include_public)
|
|
237
|
+
host["observe"] = "on" if (read or host_rows is not None) else "unavailable"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Hub -> agent requests (currently: "show me this container's logs").
|
|
2
|
+
|
|
3
|
+
The agent never accepts inbound connections. Instead, when logs are allowed
|
|
4
|
+
(SERVCHART_ALLOW_LOGS=1), a background thread asks the hub every few seconds whether an
|
|
5
|
+
admin is waiting for something, does it, and posts the answer back. Each request is
|
|
6
|
+
re-checked here: only known request types, only containers that exist on this host.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from . import logs as logs_mod
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("servchart.agent.commands")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def logs_allowed() -> bool:
|
|
22
|
+
return os.environ.get("SERVCHART_ALLOW_LOGS", "0").lower() in ("1", "true", "yes", "on")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def handle(client, command: dict) -> dict:
|
|
26
|
+
"""Run one command; returns the result payload (never raises)."""
|
|
27
|
+
kind = command.get("type")
|
|
28
|
+
try:
|
|
29
|
+
if kind == "logs":
|
|
30
|
+
if not logs_allowed():
|
|
31
|
+
return {"ok": False, "error": "Logs are disabled on this host (SERVCHART_ALLOW_LOGS=0)."}
|
|
32
|
+
name = str(command.get("container") or "")
|
|
33
|
+
known = {(c.get("Names") or [""])[0].lstrip("/") for c in client.containers()}
|
|
34
|
+
if name not in known:
|
|
35
|
+
return {"ok": False, "error": f"No container named {name!r} on this host."}
|
|
36
|
+
since = command.get("since")
|
|
37
|
+
return {"ok": True, "result": logs_mod.fetch(client, name, tail=command.get("tail") or 200, since=int(since) if since else None)}
|
|
38
|
+
return {"ok": False, "error": f"Unknown request type {kind!r}."}
|
|
39
|
+
except Exception as exc: # report the failure to the admin instead of dying
|
|
40
|
+
log.warning("command %s failed: %s", command.get("id"), exc)
|
|
41
|
+
return {"ok": False, "error": str(exc)[:500]}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def loop(client, request, interval: float = 3.0, stop: threading.Event | None = None) -> None:
|
|
45
|
+
"""Poll the hub for commands until `stop` is set. `request(method, path, body)` does the HTTP."""
|
|
46
|
+
stop = stop or threading.Event()
|
|
47
|
+
backoff = 0.0
|
|
48
|
+
while not stop.is_set():
|
|
49
|
+
try:
|
|
50
|
+
pending = (request("GET", "/api/v1/agent/commands", None) or {}).get("commands") or []
|
|
51
|
+
for cmd in pending:
|
|
52
|
+
request("POST", f"/api/v1/agent/commands/{cmd['id']}/result", handle(client, cmd))
|
|
53
|
+
backoff = 0.0
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
backoff = min(max(backoff * 2, interval * 2), 60.0)
|
|
56
|
+
log.debug("command poll failed: %s", exc)
|
|
57
|
+
stop.wait(backoff or interval)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def start(client, request) -> threading.Thread | None:
|
|
61
|
+
if not logs_allowed():
|
|
62
|
+
return None
|
|
63
|
+
interval = float(os.environ.get("SERVCHART_COMMAND_POLL", "3"))
|
|
64
|
+
t = threading.Thread(target=loop, args=(client, request, interval), name="servchart-commands", daemon=True)
|
|
65
|
+
t.start()
|
|
66
|
+
log.info("log viewing enabled: checking the hub for requests every %ss", interval)
|
|
67
|
+
return t
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Observed network connections ("who talks to whom"), read from the kernel's TCP tables.
|
|
2
|
+
|
|
3
|
+
Every network namespace has its own /proc/<pid>/net/tcp{,6} - the same data `ss -tn`
|
|
4
|
+
shows. Reading another container's table needs the host's /proc, so the agent must run
|
|
5
|
+
with `--pid host` (or as a plain process on the host). This is opt-in:
|
|
6
|
+
SERVCHART_OBSERVE_CONNECTIONS=1.
|
|
7
|
+
|
|
8
|
+
What is reported, per container and for the host itself:
|
|
9
|
+
* outbound ESTABLISHED connections only (inbound ones are the other side's outbound),
|
|
10
|
+
aggregated to (destination IP, destination port, count) - no source ports, no payloads;
|
|
11
|
+
* private destinations only (RFC 1918, CGNAT/Tailscale 100.64/10, ULA fc00::/7) unless
|
|
12
|
+
SERVCHART_OBSERVE_PUBLIC=1, so the internet hosts your apps call never leave the host.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ipaddress
|
|
18
|
+
import os
|
|
19
|
+
import socket
|
|
20
|
+
import struct
|
|
21
|
+
from collections import Counter
|
|
22
|
+
|
|
23
|
+
PROC = os.environ.get("SERVCHART_PROC", "/proc")
|
|
24
|
+
TCP_ESTABLISHED = "01"
|
|
25
|
+
TCP_LISTEN = "0A"
|
|
26
|
+
CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
|
27
|
+
MAX_PER_SOURCE = 200
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _hex_to_ip(h: str) -> str:
|
|
31
|
+
if len(h) == 8: # IPv4: one 32-bit word in host (little-endian) order
|
|
32
|
+
return socket.inet_ntop(socket.AF_INET, struct.pack("<I", int(h, 16)))
|
|
33
|
+
# IPv6: four 32-bit words, each in host order
|
|
34
|
+
raw = b"".join(struct.pack("<I", int(h[i : i + 8], 16)) for i in range(0, 32, 8))
|
|
35
|
+
addr = ipaddress.IPv6Address(raw)
|
|
36
|
+
return str(addr.ipv4_mapped) if addr.ipv4_mapped else str(addr)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_tcp_table(text: str) -> list[tuple[str, int, str, int, str]]:
|
|
40
|
+
"""Parse /proc/net/tcp or tcp6 into (local_ip, local_port, remote_ip, remote_port, state)."""
|
|
41
|
+
rows = []
|
|
42
|
+
for line in text.splitlines()[1:]:
|
|
43
|
+
parts = line.split()
|
|
44
|
+
if len(parts) < 4:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
lip, lport = parts[1].split(":")
|
|
48
|
+
rip, rport = parts[2].split(":")
|
|
49
|
+
rows.append((_hex_to_ip(lip), int(lport, 16), _hex_to_ip(rip), int(rport, 16), parts[3].upper()))
|
|
50
|
+
except (ValueError, struct.error):
|
|
51
|
+
continue
|
|
52
|
+
return rows
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_netns(pid: int | str) -> list[tuple[str, int, str, int, str]] | None:
|
|
56
|
+
"""TCP rows of the network namespace that process `pid` lives in, or None if unreadable."""
|
|
57
|
+
rows, ok = [], False
|
|
58
|
+
for name in ("tcp", "tcp6"):
|
|
59
|
+
try:
|
|
60
|
+
with open(f"{PROC}/{pid}/net/{name}") as fh:
|
|
61
|
+
rows.extend(parse_tcp_table(fh.read()))
|
|
62
|
+
ok = True
|
|
63
|
+
except OSError:
|
|
64
|
+
continue
|
|
65
|
+
return rows if ok else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_reportable(ip: str, include_public: bool = False) -> bool:
|
|
69
|
+
try:
|
|
70
|
+
addr = ipaddress.ip_address(ip)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return False
|
|
73
|
+
if addr.is_loopback or addr.is_unspecified or addr.is_link_local or addr.is_multicast:
|
|
74
|
+
return False
|
|
75
|
+
if include_public:
|
|
76
|
+
return True
|
|
77
|
+
return addr.is_private or (addr.version == 4 and addr in CGNAT)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def outbound(rows, exclude_ips=(), include_public: bool = False) -> list[dict]:
|
|
81
|
+
"""Aggregate outbound established connections: [{"ip", "port", "count"}].
|
|
82
|
+
|
|
83
|
+
A connection is outbound when its local port isn't one this namespace listens on.
|
|
84
|
+
"""
|
|
85
|
+
listening = {lport for _, lport, _, _, st in rows if st == TCP_LISTEN}
|
|
86
|
+
exclude = set(exclude_ips)
|
|
87
|
+
counts: Counter = Counter()
|
|
88
|
+
for _lip, lport, rip, rport, st in rows:
|
|
89
|
+
if st != TCP_ESTABLISHED or lport in listening:
|
|
90
|
+
continue
|
|
91
|
+
if rip in exclude or not is_reportable(rip, include_public):
|
|
92
|
+
continue
|
|
93
|
+
counts[(rip, rport)] += 1
|
|
94
|
+
out = [{"ip": ip, "port": port, "count": n} for (ip, port), n in counts.items()]
|
|
95
|
+
out.sort(key=lambda c: (-c["count"], c["ip"], c["port"]))
|
|
96
|
+
return out[:MAX_PER_SOURCE]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def listening_ports(rows) -> list[int]:
|
|
100
|
+
return sorted({lport for lip, lport, _, _, st in rows if st == TCP_LISTEN and not lip.startswith("127.")})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def host_ips(docker_subnets=()) -> list[str]:
|
|
104
|
+
"""Addresses of the host itself (from the host's routing trie), minus Docker bridge gateways.
|
|
105
|
+
|
|
106
|
+
Docker bridge gateways (172.17.0.1, ...) exist on every host, so they would make
|
|
107
|
+
cross-host matching ambiguous; anything inside a Docker network subnet is dropped.
|
|
108
|
+
Extra addresses can be added with SERVCHART_HOST_IPS=ip1,ip2 (e.g. a public IP).
|
|
109
|
+
"""
|
|
110
|
+
found = set()
|
|
111
|
+
try:
|
|
112
|
+
with open(f"{PROC}/1/net/fib_trie") as fh:
|
|
113
|
+
last = None
|
|
114
|
+
for line in fh:
|
|
115
|
+
s = line.strip()
|
|
116
|
+
if s.startswith("|--"):
|
|
117
|
+
last = s[3:].strip()
|
|
118
|
+
elif "/32 host LOCAL" in s and last:
|
|
119
|
+
found.add(last)
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
nets = []
|
|
123
|
+
for sn in docker_subnets:
|
|
124
|
+
try:
|
|
125
|
+
nets.append(ipaddress.ip_network(sn, strict=False))
|
|
126
|
+
except ValueError:
|
|
127
|
+
continue
|
|
128
|
+
keep = set()
|
|
129
|
+
for ip in found:
|
|
130
|
+
try:
|
|
131
|
+
addr = ipaddress.ip_address(ip)
|
|
132
|
+
except ValueError:
|
|
133
|
+
continue
|
|
134
|
+
if addr.is_loopback or any(addr in n for n in nets if n.version == addr.version):
|
|
135
|
+
continue
|
|
136
|
+
keep.add(ip)
|
|
137
|
+
keep.update(host_ips_from_env())
|
|
138
|
+
return sorted(keep)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def host_ips_from_env() -> list[str]:
|
|
142
|
+
return sorted({ip.strip() for ip in (os.environ.get("SERVCHART_HOST_IPS") or "").split(",") if ip.strip()})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
INIT_NAMES = {"systemd", "init", "runit", "openrc-init", "s6-svscan", "launchd"}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _in_container() -> bool:
|
|
149
|
+
return os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def observe_available() -> bool:
|
|
153
|
+
"""True when this process sees the host's processes: run natively, or in a container with --pid host.
|
|
154
|
+
|
|
155
|
+
Without --pid host, PID 1 is the container's own entrypoint, and /proc/1/net would be
|
|
156
|
+
our own namespace - so it must never be mistaken for the host's.
|
|
157
|
+
"""
|
|
158
|
+
if not _in_container():
|
|
159
|
+
return True
|
|
160
|
+
try:
|
|
161
|
+
with open(f"{PROC}/1/comm") as fh:
|
|
162
|
+
return fh.read().strip() in INIT_NAMES
|
|
163
|
+
except OSError:
|
|
164
|
+
return False
|
|
@@ -52,19 +52,23 @@ class DockerClient:
|
|
|
52
52
|
host, port = target
|
|
53
53
|
return http.client.HTTPConnection(host, port, timeout=self.timeout)
|
|
54
54
|
|
|
55
|
-
def
|
|
55
|
+
def get_raw(self, path: str, limit: int | None = None) -> bytes:
|
|
56
|
+
"""GET returning raw bytes (read at most `limit` bytes)."""
|
|
56
57
|
conn = self._connection()
|
|
57
58
|
try:
|
|
58
59
|
conn.request("GET", path, headers={"Accept": "application/json"})
|
|
59
60
|
resp = conn.getresponse()
|
|
60
|
-
body = resp.read()
|
|
61
|
+
body = resp.read(limit) if limit else resp.read()
|
|
61
62
|
except OSError as exc:
|
|
62
63
|
raise DockerError(f"Cannot reach Docker at {self.docker_host}: {exc}") from exc
|
|
63
64
|
finally:
|
|
64
65
|
conn.close()
|
|
65
66
|
if resp.status >= 400:
|
|
66
67
|
raise DockerError(f"Docker API GET {path} -> HTTP {resp.status}: {body[:200]!r}")
|
|
67
|
-
return
|
|
68
|
+
return body
|
|
69
|
+
|
|
70
|
+
def get(self, path: str):
|
|
71
|
+
return json.loads(self.get_raw(path) or b"null")
|
|
68
72
|
|
|
69
73
|
# Convenience wrappers -------------------------------------------------
|
|
70
74
|
def info(self):
|
|
@@ -81,3 +85,9 @@ class DockerClient:
|
|
|
81
85
|
|
|
82
86
|
def networks(self):
|
|
83
87
|
return self.get("/networks")
|
|
88
|
+
|
|
89
|
+
def logs(self, container: str, tail: int = 200, since: int | None = None, limit: int = 2 * 1024 * 1024) -> bytes:
|
|
90
|
+
q = f"stdout=1&stderr=1×tamps=1&tail={int(tail)}"
|
|
91
|
+
if since:
|
|
92
|
+
q += f"&since={int(since)}"
|
|
93
|
+
return self.get_raw(f"/containers/{quote(container)}/logs?{q}", limit=limit)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""On-demand container logs, for the hub's log viewer.
|
|
2
|
+
|
|
3
|
+
Opt-in per host with SERVCHART_ALLOW_LOGS=1. Logs are read only when an admin asks for
|
|
4
|
+
them in the hub, are capped (lines and bytes), and pass through a redaction step that
|
|
5
|
+
masks the most common secret patterns before leaving the host. Redaction is best effort -
|
|
6
|
+
that's why the feature is off by default.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import struct
|
|
14
|
+
|
|
15
|
+
MAX_LINES = 2000
|
|
16
|
+
MAX_BYTES = 1024 * 1024 # what we send to the hub
|
|
17
|
+
READ_LIMIT = 4 * 1024 * 1024 # what we read from Docker
|
|
18
|
+
|
|
19
|
+
# (pattern, replacement) - replacements keep the key so lines stay readable
|
|
20
|
+
REDACT_RULES = [
|
|
21
|
+
# key=value / key: value where the key looks secret
|
|
22
|
+
(re.compile(r"(?i)\b([\w.-]*(?:pass(?:word|wd)?|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)[\w.-]*[\"']?\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;&\"']+)"),
|
|
23
|
+
r"\1[redacted]"),
|
|
24
|
+
# Authorization headers
|
|
25
|
+
(re.compile(r"(?i)\b(authorization\s*[:=]\s*(?:bearer|basic|token)\s+)[A-Za-z0-9._~+/=-]+"), r"\1[redacted]"),
|
|
26
|
+
# credentials in URLs: scheme://user:pass@host
|
|
27
|
+
(re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s:/@]+:)[^\s@/]+@"), r"\1[redacted]@"),
|
|
28
|
+
# JSON Web Tokens
|
|
29
|
+
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "[redacted-jwt]"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def redaction_enabled() -> bool:
|
|
34
|
+
return os.environ.get("SERVCHART_LOGS_REDACT", "1").lower() not in ("0", "false", "no", "off")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def redact(line: str) -> str:
|
|
38
|
+
for pattern, replacement in REDACT_RULES:
|
|
39
|
+
line = pattern.sub(replacement, line)
|
|
40
|
+
return line
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def demux(raw: bytes, tty: bool) -> list[tuple[str, str]]:
|
|
44
|
+
"""Split Docker's log stream into (stream, line).
|
|
45
|
+
|
|
46
|
+
Without a TTY, Docker multiplexes stdout/stderr in frames:
|
|
47
|
+
[stream(1) 0 0 0 size(4, big-endian)] payload. With a TTY it's one raw stream.
|
|
48
|
+
"""
|
|
49
|
+
if tty:
|
|
50
|
+
return [("out", ln) for ln in raw.decode("utf-8", "replace").splitlines()]
|
|
51
|
+
out, i = [], 0
|
|
52
|
+
buffers = {1: "", 2: ""}
|
|
53
|
+
while i + 8 <= len(raw):
|
|
54
|
+
stream, size = raw[i], struct.unpack(">I", raw[i + 4 : i + 8])[0]
|
|
55
|
+
chunk = raw[i + 8 : i + 8 + size].decode("utf-8", "replace")
|
|
56
|
+
i += 8 + size
|
|
57
|
+
key = 2 if stream == 2 else 1
|
|
58
|
+
text = buffers[key] + chunk
|
|
59
|
+
*lines, rest = text.split("\n")
|
|
60
|
+
buffers[key] = rest
|
|
61
|
+
out.extend(("err" if key == 2 else "out", ln) for ln in lines)
|
|
62
|
+
for key, rest in buffers.items():
|
|
63
|
+
if rest:
|
|
64
|
+
out.append(("err" if key == 2 else "out", rest))
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fetch(client, container: str, tail: int = 200, since: int | None = None) -> dict:
|
|
69
|
+
"""Read, split, cap and redact a container's recent logs."""
|
|
70
|
+
tail = max(1, min(int(tail or 200), MAX_LINES))
|
|
71
|
+
info = client.inspect_container(container)
|
|
72
|
+
tty = bool((info.get("Config") or {}).get("Tty"))
|
|
73
|
+
raw = client.logs(info.get("Id") or container, tail=tail, since=since, limit=READ_LIMIT)
|
|
74
|
+
redact_on = redaction_enabled()
|
|
75
|
+
lines, size, truncated = [], 0, len(raw) >= READ_LIMIT
|
|
76
|
+
for stream, text in demux(raw, tty)[-tail:]:
|
|
77
|
+
ts, _, msg = text.partition(" ")
|
|
78
|
+
if not ts[:4].isdigit():
|
|
79
|
+
ts, msg = "", text # line without a timestamp prefix
|
|
80
|
+
if redact_on:
|
|
81
|
+
msg = redact(msg)
|
|
82
|
+
size += len(msg) + 40
|
|
83
|
+
if size > MAX_BYTES:
|
|
84
|
+
truncated = True
|
|
85
|
+
break
|
|
86
|
+
lines.append({"t": ts, "s": stream, "m": msg})
|
|
87
|
+
return {"lines": lines, "truncated": truncated, "redacted": redact_on, "tty": tty}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
9
|
+
|
|
10
|
+
from servchart_agent import collect as collect_mod # noqa: E402
|
|
11
|
+
from servchart_agent import connections as conn # noqa: E402
|
|
12
|
+
from servchart_agent.collect import collect, parse_depends_on # noqa: E402
|
|
13
|
+
|
|
14
|
+
HEADER = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"
|
|
15
|
+
# 10.18.0.2 is the container. Hex addresses are little-endian 32-bit words, ports big-endian.
|
|
16
|
+
TCP = HEADER + "\n".join(
|
|
17
|
+
[
|
|
18
|
+
" 0: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1", # LISTEN :80
|
|
19
|
+
" 1: 0200120A:9C40 0500120A:0CEA 01 00000000:00000000 00:00000000 00000000 0 0 2", # -> 10.18.0.5:3306
|
|
20
|
+
" 2: 0200120A:9C44 0500120A:0CEA 01 00000000:00000000 00:00000000 00000000 0 0 3", # -> same again
|
|
21
|
+
" 3: 0200120A:0050 0900120A:A000 01 00000000:00000000 00:00000000 00000000 0 0 4", # inbound to :80
|
|
22
|
+
" 4: 0200120A:9C41 08080808:01BB 01 00000000:00000000 00:00000000 00000000 0 0 5", # -> 8.8.8.8:443 (public)
|
|
23
|
+
" 5: 0100007F:9C42 0100007F:1F90 01 00000000:00000000 00:00000000 00000000 0 0 6", # loopback
|
|
24
|
+
" 6: 0200120A:9C43 0600120A:18EB 06 00000000:00000000 00:00000000 00000000 0 0 7", # TIME_WAIT
|
|
25
|
+
]
|
|
26
|
+
)
|
|
27
|
+
# ::ffff:10.18.0.2 -> ::ffff:192.168.0.28:8484 established
|
|
28
|
+
TCP6 = HEADER + " 0: 0000000000000000FFFF00000200120A:9C50 0000000000000000FFFF00001C00A8C0:2124 01 0 0 0 0 0 0 8\n"
|
|
29
|
+
FIB_TRIE = """Main:
|
|
30
|
+
+-- 0.0.0.0/0 3 0 5
|
|
31
|
+
|-- 192.168.0.28
|
|
32
|
+
/32 host LOCAL
|
|
33
|
+
|-- 172.17.0.1
|
|
34
|
+
/32 host LOCAL
|
|
35
|
+
|-- 127.0.0.1
|
|
36
|
+
/32 host LOCAL
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ParseTests(unittest.TestCase):
|
|
41
|
+
def test_parse_ipv4_and_mapped_ipv6(self):
|
|
42
|
+
rows = conn.parse_tcp_table(TCP) + conn.parse_tcp_table(TCP6)
|
|
43
|
+
self.assertIn(("10.18.0.2", 40000, "10.18.0.5", 3306, "01"), rows)
|
|
44
|
+
self.assertIn(("10.18.0.2", 40016, "192.168.0.28", 8484, "01"), rows)
|
|
45
|
+
|
|
46
|
+
def test_outbound_only_private_aggregated(self):
|
|
47
|
+
rows = conn.parse_tcp_table(TCP) + conn.parse_tcp_table(TCP6)
|
|
48
|
+
out = conn.outbound(rows, exclude_ips={"10.18.0.2"})
|
|
49
|
+
self.assertEqual(out, [{"ip": "10.18.0.5", "port": 3306, "count": 2}, {"ip": "192.168.0.28", "port": 8484, "count": 1}])
|
|
50
|
+
|
|
51
|
+
def test_public_opt_in(self):
|
|
52
|
+
rows = conn.parse_tcp_table(TCP)
|
|
53
|
+
self.assertIn({"ip": "8.8.8.8", "port": 443, "count": 1}, conn.outbound(rows, include_public=True))
|
|
54
|
+
|
|
55
|
+
def test_listening(self):
|
|
56
|
+
self.assertEqual(conn.listening_ports(conn.parse_tcp_table(TCP)), [80])
|
|
57
|
+
|
|
58
|
+
def test_cgnat_counts_as_private(self):
|
|
59
|
+
self.assertTrue(conn.is_reportable("100.101.1.2"))
|
|
60
|
+
self.assertFalse(conn.is_reportable("1.1.1.1"))
|
|
61
|
+
|
|
62
|
+
def test_depends_on_label(self):
|
|
63
|
+
self.assertEqual(parse_depends_on("db:service_healthy:false,redis:service_started:false,db:x:y"), ["db", "redis"])
|
|
64
|
+
self.assertEqual(parse_depends_on(None), [])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ObserveTests(unittest.TestCase):
|
|
68
|
+
"""End to end: a fake /proc with one container namespace (pid 4242) and the host (pid 1)."""
|
|
69
|
+
|
|
70
|
+
def setUp(self):
|
|
71
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
72
|
+
for pid, files in {"4242": {"tcp": TCP, "tcp6": TCP6}, "1": {"tcp": HEADER, "fib_trie": FIB_TRIE}}.items():
|
|
73
|
+
os.makedirs(f"{self.tmp.name}/{pid}/net")
|
|
74
|
+
for name, text in files.items():
|
|
75
|
+
with open(f"{self.tmp.name}/{pid}/net/{name}", "w") as fh:
|
|
76
|
+
fh.write(text)
|
|
77
|
+
self.patches = [
|
|
78
|
+
mock.patch.object(conn, "PROC", self.tmp.name),
|
|
79
|
+
mock.patch.object(conn, "observe_available", return_value=True),
|
|
80
|
+
mock.patch.dict(os.environ, {"SERVCHART_OBSERVE_CONNECTIONS": "1"}),
|
|
81
|
+
]
|
|
82
|
+
for p in self.patches:
|
|
83
|
+
p.start()
|
|
84
|
+
|
|
85
|
+
def tearDown(self):
|
|
86
|
+
for p in self.patches:
|
|
87
|
+
p.stop()
|
|
88
|
+
self.tmp.cleanup()
|
|
89
|
+
|
|
90
|
+
def test_snapshot_has_connections_and_host_ips(self):
|
|
91
|
+
class Client:
|
|
92
|
+
def info(self):
|
|
93
|
+
return {"Name": "docker-host"}
|
|
94
|
+
|
|
95
|
+
def version(self):
|
|
96
|
+
return {"Version": "29"}
|
|
97
|
+
|
|
98
|
+
def containers(self):
|
|
99
|
+
return [{"Id": "a"}]
|
|
100
|
+
|
|
101
|
+
def inspect_container(self, cid):
|
|
102
|
+
return {
|
|
103
|
+
"Id": "a" * 12,
|
|
104
|
+
"Name": "/web",
|
|
105
|
+
"Config": {"Image": "nginx", "Env": ["SECRET=x"], "Labels": {}},
|
|
106
|
+
"State": {"Status": "running", "Running": True, "Pid": 4242},
|
|
107
|
+
"HostConfig": {"NetworkMode": "app_default"},
|
|
108
|
+
"NetworkSettings": {"Networks": {"app_default": {"IPAddress": "10.18.0.2"}}},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def networks(self):
|
|
112
|
+
return [{"Name": "app_default", "IPAM": {"Config": [{"Subnet": "10.18.0.0/16"}]}},
|
|
113
|
+
{"Name": "bridge", "IPAM": {"Config": [{"Subnet": "172.17.0.0/16"}]}}]
|
|
114
|
+
|
|
115
|
+
snap = collect(Client())
|
|
116
|
+
web = snap["containers"][0]
|
|
117
|
+
self.assertEqual(snap["host"]["observe"], "on")
|
|
118
|
+
self.assertEqual(snap["host"]["ips"], ["192.168.0.28"]) # docker gateway + loopback dropped
|
|
119
|
+
self.assertEqual([(c["ip"], c["port"]) for c in web["connections"]], [("10.18.0.5", 3306), ("192.168.0.28", 8484)])
|
|
120
|
+
self.assertEqual(web["listening"], [80])
|
|
121
|
+
self.assertEqual(snap["host_connections"], [])
|
|
122
|
+
self.assertNotIn("SECRET", json.dumps(snap))
|
|
123
|
+
|
|
124
|
+
def test_unavailable_without_pid_host(self):
|
|
125
|
+
with mock.patch.object(conn, "observe_available", return_value=False):
|
|
126
|
+
snap = collect_mod.collect(type("C", (), {
|
|
127
|
+
"info": lambda s: {"Name": "h"}, "version": lambda s: {}, "containers": lambda s: [], "networks": lambda s: []})())
|
|
128
|
+
self.assertEqual(snap["host"]["observe"], "unavailable")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
unittest.main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import struct
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
9
|
+
|
|
10
|
+
from servchart_agent import commands # noqa: E402
|
|
11
|
+
from servchart_agent import logs as logs_mod # noqa: E402
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def frame(stream: int, text: str) -> bytes:
|
|
15
|
+
data = text.encode()
|
|
16
|
+
return bytes([stream, 0, 0, 0]) + struct.pack(">I", len(data)) + data
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
RAW = (
|
|
20
|
+
frame(1, "2026-10-19T10:00:00.000000001Z starting web\n")
|
|
21
|
+
+ frame(2, "2026-10-19T10:00:01.000000001Z WARN DB_PASSWORD=hunter2 rejected\n2026-10-19T10:00:02.0Z sec")
|
|
22
|
+
+ frame(2, "ond line split across frames\n")
|
|
23
|
+
+ frame(1, "2026-10-19T10:00:03.0Z GET /api 200\n")
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FakeClient:
|
|
28
|
+
def __init__(self, raw=RAW, tty=False):
|
|
29
|
+
self.raw, self.tty, self.calls = raw, tty, []
|
|
30
|
+
|
|
31
|
+
def containers(self):
|
|
32
|
+
return [{"Names": ["/web"]}, {"Names": ["/db"]}]
|
|
33
|
+
|
|
34
|
+
def inspect_container(self, name):
|
|
35
|
+
return {"Id": "abc123", "Config": {"Tty": self.tty}}
|
|
36
|
+
|
|
37
|
+
def logs(self, cid, tail=200, since=None, limit=None):
|
|
38
|
+
self.calls.append((cid, tail, since))
|
|
39
|
+
return self.raw
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DemuxTests(unittest.TestCase):
|
|
43
|
+
def test_multiplexed_stream(self):
|
|
44
|
+
lines = logs_mod.demux(RAW, tty=False)
|
|
45
|
+
self.assertEqual([s for s, _ in lines], ["out", "err", "err", "out"])
|
|
46
|
+
self.assertTrue(lines[2][1].endswith("second line split across frames"))
|
|
47
|
+
|
|
48
|
+
def test_tty_stream(self):
|
|
49
|
+
lines = logs_mod.demux(b"2026-10-19T10:00:00Z hello\n2026-10-19T10:00:01Z world\n", tty=True)
|
|
50
|
+
self.assertEqual([m for _, m in lines], ["2026-10-19T10:00:00Z hello", "2026-10-19T10:00:01Z world"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FetchTests(unittest.TestCase):
|
|
54
|
+
def test_fetch_splits_timestamp_and_redacts(self):
|
|
55
|
+
out = logs_mod.fetch(FakeClient(), "web", tail=50)
|
|
56
|
+
self.assertEqual(out["lines"][0], {"t": "2026-10-19T10:00:00.000000001Z", "s": "out", "m": "starting web"})
|
|
57
|
+
self.assertEqual(out["lines"][1]["m"], "WARN DB_PASSWORD=[redacted] rejected")
|
|
58
|
+
self.assertTrue(out["redacted"])
|
|
59
|
+
|
|
60
|
+
def test_redaction_can_be_turned_off(self):
|
|
61
|
+
with mock.patch.dict(os.environ, {"SERVCHART_LOGS_REDACT": "0"}):
|
|
62
|
+
out = logs_mod.fetch(FakeClient(), "web")
|
|
63
|
+
self.assertIn("hunter2", out["lines"][1]["m"])
|
|
64
|
+
|
|
65
|
+
def test_tail_is_capped(self):
|
|
66
|
+
client = FakeClient()
|
|
67
|
+
logs_mod.fetch(client, "web", tail=999999)
|
|
68
|
+
self.assertEqual(client.calls[0][1], logs_mod.MAX_LINES)
|
|
69
|
+
|
|
70
|
+
def test_byte_cap(self):
|
|
71
|
+
big = b"".join(frame(1, f"2026-10-19T10:00:00Z {'x' * 900}\n") for _ in range(2000))
|
|
72
|
+
out = logs_mod.fetch(FakeClient(raw=big), "web", tail=2000)
|
|
73
|
+
self.assertTrue(out["truncated"])
|
|
74
|
+
self.assertLess(sum(len(l["m"]) for l in out["lines"]), logs_mod.MAX_BYTES)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CommandTests(unittest.TestCase):
|
|
78
|
+
def test_disabled_by_default(self):
|
|
79
|
+
with mock.patch.dict(os.environ, {}, clear=False):
|
|
80
|
+
os.environ.pop("SERVCHART_ALLOW_LOGS", None)
|
|
81
|
+
self.assertFalse(commands.handle(FakeClient(), {"type": "logs", "container": "web"})["ok"])
|
|
82
|
+
self.assertIsNone(commands.start(FakeClient(), lambda *a: None))
|
|
83
|
+
|
|
84
|
+
def test_only_existing_containers_and_known_types(self):
|
|
85
|
+
with mock.patch.dict(os.environ, {"SERVCHART_ALLOW_LOGS": "1"}):
|
|
86
|
+
self.assertIn("No container", commands.handle(FakeClient(), {"type": "logs", "container": "../etc"})["error"])
|
|
87
|
+
self.assertIn("Unknown request", commands.handle(FakeClient(), {"type": "exec", "container": "web"})["error"])
|
|
88
|
+
ok = commands.handle(FakeClient(), {"type": "logs", "container": "web", "tail": 10})
|
|
89
|
+
self.assertTrue(ok["ok"])
|
|
90
|
+
self.assertEqual(len(ok["result"]["lines"]), 4)
|
|
91
|
+
|
|
92
|
+
def test_loop_polls_and_posts_results(self):
|
|
93
|
+
stop = threading.Event()
|
|
94
|
+
posted = []
|
|
95
|
+
|
|
96
|
+
def request(method, path, body):
|
|
97
|
+
if method == "GET":
|
|
98
|
+
return {"commands": [{"id": "r1", "type": "logs", "container": "web", "tail": 5}]}
|
|
99
|
+
posted.append((path, body))
|
|
100
|
+
stop.set()
|
|
101
|
+
return {"ok": True}
|
|
102
|
+
|
|
103
|
+
with mock.patch.dict(os.environ, {"SERVCHART_ALLOW_LOGS": "1"}):
|
|
104
|
+
commands.loop(FakeClient(), request, interval=0.01, stop=stop)
|
|
105
|
+
self.assertEqual(posted[0][0], "/api/v1/agent/commands/r1/result")
|
|
106
|
+
self.assertTrue(posted[0][1]["ok"])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|