servchart-agent 0.2.0__tar.gz → 0.3.1__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.
@@ -23,3 +23,6 @@ local/
23
23
  .DS_Store
24
24
  .idea/
25
25
  .vscode/
26
+
27
+ # others
28
+ helper.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: servchart-agent
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -1,4 +1,4 @@
1
1
  """ServChart agent: reads Docker state on one host and reports it to a ServChart hub."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
4
4
  SCHEMA_VERSION = 1
@@ -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 push(snapshot: dict, hub: str, token: str, ca_file: str | None = None, insecure: bool = False) -> None:
29
- url = hub.rstrip("/") + "/api/v1/report"
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 url.startswith("https://"):
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
- with urllib.request.urlopen(req, timeout=20, context=ctx) as resp:
48
- if resp.status >= 300:
49
- raise RuntimeError(f"hub answered HTTP {resp.status}")
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
- containers = []
166
+
167
+ inspected = []
149
168
  for summary in client.containers():
150
169
  try:
151
- containers.append(container_record(client.inspect_container(summary["Id"]), hide_paths))
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
- return {
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
- "hostname": hostname or os.environ.get("SERVCHART_HOSTNAME") or info.get("Name"),
161
- "os": info.get("OperatingSystem"),
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 get(self, path: str):
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 json.loads(body or b"null")
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&timestamps=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