devlogs 2.3.2__tar.gz → 2.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {devlogs-2.3.2/src/devlogs.egg-info → devlogs-2.3.4}/PKG-INFO +1 -1
  2. {devlogs-2.3.2 → devlogs-2.3.4}/pyproject.toml +1 -1
  3. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/_version_static.py +1 -1
  4. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/server.py +24 -21
  5. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/config.py +8 -0
  6. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/web/server.py +4 -2
  7. {devlogs-2.3.2 → devlogs-2.3.4/src/devlogs.egg-info}/PKG-INFO +1 -1
  8. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_collector_server.py +58 -5
  9. {devlogs-2.3.2 → devlogs-2.3.4}/LICENSE +0 -0
  10. {devlogs-2.3.2 → devlogs-2.3.4}/MANIFEST.in +0 -0
  11. {devlogs-2.3.2 → devlogs-2.3.4}/README.md +0 -0
  12. {devlogs-2.3.2 → devlogs-2.3.4}/setup.cfg +0 -0
  13. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/__init__.py +0 -0
  14. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/__main__.py +0 -0
  15. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/build_info.py +0 -0
  16. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/cli.py +0 -0
  17. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/__init__.py +0 -0
  18. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/auth.py +0 -0
  19. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/cli.py +0 -0
  20. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/errors.py +0 -0
  21. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/forwarder.py +0 -0
  22. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/ingestor.py +0 -0
  23. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/loki_plugin.py +0 -0
  24. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/plugins.py +0 -0
  25. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/collector/schema.py +0 -0
  26. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/context.py +0 -0
  27. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/demo.py +0 -0
  28. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/devlogs_client.py +0 -0
  29. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/formatting.py +0 -0
  30. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/handler.py +0 -0
  31. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/jenkins/__init__.py +0 -0
  32. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/jenkins/cli.py +0 -0
  33. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/jenkins/core.py +0 -0
  34. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/levels.py +0 -0
  35. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/loki/__init__.py +0 -0
  36. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/loki/queries.py +0 -0
  37. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/mcp/__init__.py +0 -0
  38. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/mcp/server.py +0 -0
  39. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/opensearch/__init__.py +0 -0
  40. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/opensearch/client.py +0 -0
  41. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/opensearch/indexing.py +0 -0
  42. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/opensearch/mappings.py +0 -0
  43. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/opensearch/queries.py +0 -0
  44. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/proxy/__init__.py +0 -0
  45. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/proxy/server.py +0 -0
  46. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/retention.py +0 -0
  47. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/scrub.py +0 -0
  48. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/time_utils.py +0 -0
  49. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/version.py +0 -0
  50. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/web/__init__.py +0 -0
  51. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/web/static/devlogs.css +0 -0
  52. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/web/static/devlogs.js +0 -0
  53. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/web/static/index.html +0 -0
  54. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs/wrapper.py +0 -0
  55. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs.egg-info/SOURCES.txt +0 -0
  56. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs.egg-info/dependency_links.txt +0 -0
  57. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs.egg-info/entry_points.txt +0 -0
  58. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs.egg-info/requires.txt +0 -0
  59. {devlogs-2.3.2 → devlogs-2.3.4}/src/devlogs.egg-info/top_level.txt +0 -0
  60. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_build_info.py +0 -0
  61. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_cli.py +0 -0
  62. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_collector_auth.py +0 -0
  63. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_collector_config.py +0 -0
  64. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_collector_plugins.py +0 -0
  65. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_collector_schema.py +0 -0
  66. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_config.py +0 -0
  67. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_context.py +0 -0
  68. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_devlogs_client.py +0 -0
  69. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_formatting.py +0 -0
  70. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_handler.py +0 -0
  71. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_indexing.py +0 -0
  72. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_levels.py +0 -0
  73. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_mappings.py +0 -0
  74. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_mcp_server.py +0 -0
  75. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_opensearch_client.py +0 -0
  76. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_opensearch_queries.py +0 -0
  77. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_proxy_server.py +0 -0
  78. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_retention.py +0 -0
  79. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_scrub.py +0 -0
  80. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_time_utils.py +0 -0
  81. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_url_parsing.py +0 -0
  82. {devlogs-2.3.2 → devlogs-2.3.4}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.3.2
3
+ Version: 2.3.4
4
4
  Summary: Developer-focused logging library for Python with OpenSearch integration.
5
5
  Author-email: Dan Driscoll <dan@thedandriscoll.org>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlogs"
7
- version = "2.3.2"
7
+ version = "2.3.4"
8
8
  description = "Developer-focused logging library for Python with OpenSearch integration."
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -1,2 +1,2 @@
1
1
  # AUTO-GENERATED at build time — do not edit or commit
2
- __version__ = "2.3.2"
2
+ __version__ = "2.3.4"
@@ -4,6 +4,7 @@
4
4
  # - Forward mode: proxy to upstream collector
5
5
  # - Ingest mode: write directly to OpenSearch
6
6
 
7
+ import hmac
7
8
  import json
8
9
  import logging
9
10
  import platform
@@ -127,27 +128,28 @@ app.add_middleware(
127
128
  )
128
129
 
129
130
 
130
- def get_client_ip(request: Request) -> str:
131
+ def get_client_ip(request: Request, trusted_proxy_token: str = "") -> str:
131
132
  """Extract client IP from request.
132
133
 
133
134
  Returns a bare IPv4 or IPv6 address string (e.g. "192.168.1.5", "::1").
134
135
  No port, no brackets, no CIDR suffix.
135
136
 
136
- Checks X-Forwarded-For header first (for proxied requests),
137
- then falls back to direct client connection.
137
+ Proxy headers (X-Forwarded-For, X-Real-IP) are only honored when
138
+ DEVLOGS_TRUSTED_PROXY_TOKEN is configured and the request presents
139
+ a matching X-Trusted-Proxy-Token header. This prevents clients from
140
+ spoofing their IP address.
138
141
  """
139
- # Check X-Forwarded-For header (from reverse proxy)
140
- forwarded_for = request.headers.get("X-Forwarded-For")
141
- if forwarded_for:
142
- # Take the first (leftmost) IP in the chain
143
- return forwarded_for.split(",")[0].strip()
144
-
145
- # Check X-Real-IP header (alternative proxy header)
146
- real_ip = request.headers.get("X-Real-IP")
147
- if real_ip:
148
- return real_ip.strip()
149
-
150
- # Fall back to direct client
142
+ if trusted_proxy_token:
143
+ presented = request.headers.get("X-Trusted-Proxy-Token", "")
144
+ if presented and hmac.compare_digest(presented, trusted_proxy_token):
145
+ forwarded_for = request.headers.get("X-Forwarded-For")
146
+ if forwarded_for:
147
+ return forwarded_for.split(",")[0].strip()
148
+
149
+ real_ip = request.headers.get("X-Real-IP")
150
+ if real_ip:
151
+ return real_ip.strip()
152
+
151
153
  if request.client:
152
154
  return request.client.host
153
155
 
@@ -157,7 +159,8 @@ def get_client_ip(request: Request) -> str:
157
159
  @app.exception_handler(CollectorError)
158
160
  async def collector_error_handler(request: Request, exc: CollectorError):
159
161
  """Handle CollectorError exceptions with structured response."""
160
- client_ip = get_client_ip(request)
162
+ cfg = load_config()
163
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
161
164
  logger.warning("%s %d %s: %s", client_ip, exc.status_code, exc.subcode, exc.message)
162
165
  return JSONResponse(
163
166
  status_code=exc.status_code,
@@ -202,7 +205,7 @@ async def ingest_logs(request: Request):
202
205
 
203
206
  # Determine operating mode
204
207
  mode = cfg.get_collector_mode()
205
- client_ip = get_client_ip(request)
208
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
206
209
  logger.info("%s POST / (%d bytes)", client_ip, len(body))
207
210
 
208
211
  if mode == "forward":
@@ -240,7 +243,7 @@ async def _handle_forward_mode(request: Request, cfg, body: bytes) -> Response:
240
243
  timeout=cfg.opensearch_timeout,
241
244
  )
242
245
 
243
- client_ip = get_client_ip(request)
246
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
244
247
 
245
248
  # If upstream returned 2xx, return 202
246
249
  if 200 <= status < 300:
@@ -297,7 +300,7 @@ def _validate_and_enrich_records(request: Request, cfg, body: bytes):
297
300
  raise
298
301
 
299
302
  # Get client info
300
- client_ip = get_client_ip(request)
303
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
301
304
 
302
305
  # Extract token (precedence: Bearer header → X-Devlogs-Token → URL userinfo → ?token=)
303
306
  authorization = request.headers.get("Authorization")
@@ -342,7 +345,7 @@ async def _handle_ingest_mode(request: Request, cfg, body: bytes) -> Response:
342
345
  index_map = parse_forward_index_map_kv(cfg.forward_index_map_kv)
343
346
 
344
347
  result = ingest_records(client, cfg.index, enriched_records, index_map)
345
- client_ip = get_client_ip(request)
348
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
346
349
  logger.info("%s 202 ingested %d record(s)", client_ip, result["ingested"])
347
350
 
348
351
  return Response(
@@ -377,7 +380,7 @@ async def _handle_plugin_mode(request: Request, cfg, body: bytes, plugin) -> Res
377
380
  result = {}
378
381
 
379
382
  ingested = result.get("ingested", len(enriched_records))
380
- client_ip = get_client_ip(request)
383
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
381
384
  logger.info("%s 202 plugin '%s' ingested %d record(s)", client_ip, plugin.name, ingested)
382
385
 
383
386
  return Response(
@@ -49,6 +49,9 @@ _DEVLOGS_CONFIG_KEYS = (
49
49
  "DEVLOGS_TOKEN_MAP_KV",
50
50
  # Legacy: Auth token header name (default: Authorization)
51
51
  "DEVLOGS_AUTH_HEADER",
52
+ # Proxy trust: shared secret that a reverse proxy must present to have
53
+ # X-Forwarded-For / X-Real-IP headers honored
54
+ "DEVLOGS_TRUSTED_PROXY_TOKEN",
52
55
  )
53
56
 
54
57
 
@@ -465,6 +468,11 @@ class DevlogsConfig:
465
468
  # Token-to-identity mapping (KV format)
466
469
  self.token_map_kv = _getenv("DEVLOGS_TOKEN_MAP_KV", "")
467
470
 
471
+ # Trusted proxy token: if set, X-Forwarded-For / X-Real-IP headers are
472
+ # only honored when the request includes this token in X-Trusted-Proxy-Token.
473
+ # If empty, forwarded headers are never trusted.
474
+ self.trusted_proxy_token = _getenv("DEVLOGS_TRUSTED_PROXY_TOKEN", "")
475
+
468
476
  # Forward mode: per-application index routing (KV format)
469
477
  self.forward_index_map_kv = _getenv("DEVLOGS_FORWARD_INDEX_MAP_KV", "")
470
478
 
@@ -78,8 +78,10 @@ def tail(operation_id: Optional[str] = None, area: Optional[str] = None, compone
78
78
 
79
79
  @app.get("/ui/{path:path}")
80
80
  def serve_ui(path: str):
81
- static_dir = os.path.join(os.path.dirname(__file__), "static")
82
- file_path = os.path.join(static_dir, path)
81
+ static_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "static"))
82
+ file_path = os.path.realpath(os.path.join(static_dir, path))
83
+ if not file_path.startswith(static_dir + os.sep) and file_path != static_dir:
84
+ file_path = os.path.join(static_dir, "index.html")
83
85
  if not os.path.isfile(file_path):
84
86
  file_path = os.path.join(static_dir, "index.html")
85
87
  return FileResponse(file_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.3.2
3
+ Version: 2.3.4
4
4
  Summary: Developer-focused logging library for Python with OpenSearch integration.
5
5
  Author-email: Dan Driscoll <dan@thedandriscoll.org>
6
6
  License: MIT License
@@ -166,7 +166,7 @@ class TestIngestEndpoint:
166
166
  # TestClient uses testclient as host
167
167
  assert body["client_ip"] is not None
168
168
 
169
- def test_captures_forwarded_ip(self, client, reset_config, monkeypatch):
169
+ def test_ignores_forwarded_ip_without_proxy_token(self, client, reset_config, monkeypatch):
170
170
  monkeypatch.setenv("DEVLOGS_OPENSEARCH_HOST", "localhost")
171
171
  monkeypatch.setenv("DEVLOGS_INDEX", "test-index")
172
172
 
@@ -184,6 +184,32 @@ class TestIngestEndpoint:
184
184
  headers={"X-Forwarded-For": "192.168.1.100, 10.0.0.1"}
185
185
  )
186
186
 
187
+ assert response.status_code == 202
188
+ body = mock_client.index.call_args.kwargs["body"]
189
+ assert body["client_ip"] != "192.168.1.100"
190
+
191
+ def test_captures_forwarded_ip_with_proxy_token(self, client, reset_config, monkeypatch):
192
+ monkeypatch.setenv("DEVLOGS_OPENSEARCH_HOST", "localhost")
193
+ monkeypatch.setenv("DEVLOGS_INDEX", "test-index")
194
+ monkeypatch.setenv("DEVLOGS_TRUSTED_PROXY_TOKEN", "my-proxy-secret")
195
+
196
+ mock_client = Mock()
197
+ mock_client.index = Mock(return_value={})
198
+
199
+ with patch("devlogs.collector.server.get_opensearch_client", return_value=mock_client):
200
+ response = client.post(
201
+ "/",
202
+ json={
203
+ "application": "test-app",
204
+ "component": "api",
205
+ "timestamp": "2024-01-15T10:30:00Z"
206
+ },
207
+ headers={
208
+ "X-Forwarded-For": "192.168.1.100, 10.0.0.1",
209
+ "X-Trusted-Proxy-Token": "my-proxy-secret",
210
+ }
211
+ )
212
+
187
213
  assert response.status_code == 202
188
214
  body = mock_client.index.call_args.kwargs["body"]
189
215
  assert body["client_ip"] == "192.168.1.100"
@@ -575,17 +601,44 @@ class TestForwardMode:
575
601
  class TestGetClientIp:
576
602
  """Tests for client IP extraction."""
577
603
 
578
- def test_extracts_forwarded_for(self):
604
+ def test_ignores_forwarded_for_without_token(self):
579
605
  mock_request = Mock()
580
606
  mock_request.headers = {"X-Forwarded-For": "192.168.1.1, 10.0.0.1"}
581
607
  mock_request.client = Mock(host="127.0.0.1")
582
- assert get_client_ip(mock_request) == "192.168.1.1"
608
+ assert get_client_ip(mock_request) == "127.0.0.1"
583
609
 
584
- def test_extracts_real_ip(self):
610
+ def test_ignores_real_ip_without_token(self):
585
611
  mock_request = Mock()
586
612
  mock_request.headers = {"X-Real-IP": "192.168.1.1"}
587
613
  mock_request.client = Mock(host="127.0.0.1")
588
- assert get_client_ip(mock_request) == "192.168.1.1"
614
+ assert get_client_ip(mock_request) == "127.0.0.1"
615
+
616
+ def test_extracts_forwarded_for_with_proxy_token(self):
617
+ mock_request = Mock()
618
+ mock_request.headers = {
619
+ "X-Forwarded-For": "192.168.1.1, 10.0.0.1",
620
+ "X-Trusted-Proxy-Token": "secret",
621
+ }
622
+ mock_request.client = Mock(host="127.0.0.1")
623
+ assert get_client_ip(mock_request, "secret") == "192.168.1.1"
624
+
625
+ def test_extracts_real_ip_with_proxy_token(self):
626
+ mock_request = Mock()
627
+ mock_request.headers = {
628
+ "X-Real-IP": "192.168.1.1",
629
+ "X-Trusted-Proxy-Token": "secret",
630
+ }
631
+ mock_request.client = Mock(host="127.0.0.1")
632
+ assert get_client_ip(mock_request, "secret") == "192.168.1.1"
633
+
634
+ def test_ignores_forwarded_for_with_wrong_token(self):
635
+ mock_request = Mock()
636
+ mock_request.headers = {
637
+ "X-Forwarded-For": "192.168.1.1, 10.0.0.1",
638
+ "X-Trusted-Proxy-Token": "wrong",
639
+ }
640
+ mock_request.client = Mock(host="127.0.0.1")
641
+ assert get_client_ip(mock_request, "secret") == "127.0.0.1"
589
642
 
590
643
  def test_falls_back_to_client(self):
591
644
  mock_request = Mock()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes