server-management-lib 0.2.0__py3-none-any.whl
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.
- server_management_lib/__init__.py +124 -0
- server_management_lib/audit.py +80 -0
- server_management_lib/base.py +55 -0
- server_management_lib/config.py +385 -0
- server_management_lib/executor.py +53 -0
- server_management_lib/http_clients.py +636 -0
- server_management_lib/local_executor.py +200 -0
- server_management_lib/operations.py +33 -0
- server_management_lib/output.py +61 -0
- server_management_lib/py.typed +0 -0
- server_management_lib/rate_limit.py +118 -0
- server_management_lib/redaction.py +191 -0
- server_management_lib/responses.py +126 -0
- server_management_lib/security.py +1552 -0
- server_management_lib/ssh_manager.py +447 -0
- server_management_lib/transport.py +128 -0
- server_management_lib/tunnel.py +380 -0
- server_management_lib-0.2.0.dist-info/METADATA +46 -0
- server_management_lib-0.2.0.dist-info/RECORD +21 -0
- server_management_lib-0.2.0.dist-info/WHEEL +5 -0
- server_management_lib-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Clients for InfluxDB and Prometheus
|
|
3
|
+
|
|
4
|
+
Provides read-only query clients for both time-series databases
|
|
5
|
+
with built-in security validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from time import monotonic
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
import sqlparse
|
|
16
|
+
from sqlparse import tokens as sql_tokens
|
|
17
|
+
|
|
18
|
+
from .audit import AuditLogger
|
|
19
|
+
from .output import DEFAULT_MAX_OUTPUT_BYTES, sanitize_output
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Defense-in-depth: whitelist the InfluxDB endpoints so even if
|
|
24
|
+
# the path construction changes, requests to admin/write endpoints are blocked.
|
|
25
|
+
INFLUXDB_ALLOWED_ENDPOINTS: frozenset[str] = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"/api/v3/query_sql",
|
|
28
|
+
"/api/v3/query_influxql",
|
|
29
|
+
"/health",
|
|
30
|
+
"/metrics",
|
|
31
|
+
"/ping",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InfluxDBClient:
|
|
37
|
+
"""Client for InfluxDB v3 HTTP API (read-only)."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
host: str = "localhost",
|
|
42
|
+
port: int = 8181,
|
|
43
|
+
use_https: bool = False,
|
|
44
|
+
database: str | None = None,
|
|
45
|
+
token: str | None = None,
|
|
46
|
+
query_limit: int = 1000,
|
|
47
|
+
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
48
|
+
timeout: float = 30,
|
|
49
|
+
audit: AuditLogger | None = None,
|
|
50
|
+
):
|
|
51
|
+
self.host = host
|
|
52
|
+
self.port = port
|
|
53
|
+
self.use_https = use_https
|
|
54
|
+
self.database = database
|
|
55
|
+
self.token = token
|
|
56
|
+
self.query_limit = min(query_limit, 10000)
|
|
57
|
+
self.max_output_bytes = max_output_bytes
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
self.audit = audit
|
|
60
|
+
self.scheme = "https" if use_https else "http"
|
|
61
|
+
self._session: aiohttp.ClientSession | None = None
|
|
62
|
+
|
|
63
|
+
def _get_session(self) -> aiohttp.ClientSession:
|
|
64
|
+
"""Return the client's shared HTTP session, creating it on demand."""
|
|
65
|
+
if self._session is None:
|
|
66
|
+
self._session = aiohttp.ClientSession()
|
|
67
|
+
return self._session
|
|
68
|
+
|
|
69
|
+
async def close(self) -> None:
|
|
70
|
+
"""Close the shared HTTP session (idempotent)."""
|
|
71
|
+
session = self._session
|
|
72
|
+
self._session = None
|
|
73
|
+
if session is not None:
|
|
74
|
+
await session.close()
|
|
75
|
+
|
|
76
|
+
def _log_audit(
|
|
77
|
+
self,
|
|
78
|
+
query: str,
|
|
79
|
+
database: str | None,
|
|
80
|
+
status: str | int,
|
|
81
|
+
started: float,
|
|
82
|
+
*,
|
|
83
|
+
error: str | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Record query metadata without storing the query text.
|
|
86
|
+
|
|
87
|
+
SQL and PromQL may contain literal values that the model should not
|
|
88
|
+
persist in a local log. Length plus SHA-256 keeps queries correlatable
|
|
89
|
+
without copying their contents.
|
|
90
|
+
"""
|
|
91
|
+
if self.audit is None:
|
|
92
|
+
return
|
|
93
|
+
fields: dict[str, object] = {
|
|
94
|
+
"database": database,
|
|
95
|
+
"status": status,
|
|
96
|
+
"duration_ms": round((monotonic() - started) * 1000, 1),
|
|
97
|
+
"query_length": len(query),
|
|
98
|
+
"query_sha256": hashlib.sha256(query.encode("utf-8")).hexdigest(),
|
|
99
|
+
}
|
|
100
|
+
if error is not None:
|
|
101
|
+
fields["error"] = error
|
|
102
|
+
self.audit.log("influxdb_query", **fields)
|
|
103
|
+
|
|
104
|
+
async def query(self, sql_query: str, database: str | None = None) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Execute a SELECT query against the InfluxDB v3 HTTP API.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
sql_query: SQL query (must start with SELECT)
|
|
110
|
+
database: Database name (overrides constructor default)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Query results in JSON format
|
|
114
|
+
"""
|
|
115
|
+
started = monotonic()
|
|
116
|
+
db = database or self.database
|
|
117
|
+
if not db:
|
|
118
|
+
self._log_audit(sql_query, db, "no_database", started)
|
|
119
|
+
return (
|
|
120
|
+
"❌ No database specified. Provide 'database' parameter or set "
|
|
121
|
+
"'influxdb.database' in config.yaml."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
path = "/api/v3/query_sql"
|
|
125
|
+
url = f"{self.scheme}://{self.host}:{self.port}{path}"
|
|
126
|
+
|
|
127
|
+
# InfluxDB v3 has no server-side row limit parameter; enforce it in SQL.
|
|
128
|
+
sql_query = _ensure_limit(sql_query, self.query_limit)
|
|
129
|
+
|
|
130
|
+
if path not in INFLUXDB_ALLOWED_ENDPOINTS:
|
|
131
|
+
return f"❌ Blocked: endpoint '{path}' is not in the allowed whitelist"
|
|
132
|
+
|
|
133
|
+
params = {
|
|
134
|
+
"db": db,
|
|
135
|
+
"q": sql_query,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
139
|
+
if self.token:
|
|
140
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
session = self._get_session()
|
|
144
|
+
async with session.post(
|
|
145
|
+
url,
|
|
146
|
+
json=params,
|
|
147
|
+
headers=headers,
|
|
148
|
+
timeout=self.timeout,
|
|
149
|
+
) as response:
|
|
150
|
+
if response.status == 200:
|
|
151
|
+
try:
|
|
152
|
+
data = await response.json()
|
|
153
|
+
except (aiohttp.ContentTypeError, ValueError):
|
|
154
|
+
body = await response.text()
|
|
155
|
+
if _looks_like_html(response, body):
|
|
156
|
+
self._log_audit(
|
|
157
|
+
sql_query,
|
|
158
|
+
db,
|
|
159
|
+
response.status,
|
|
160
|
+
started,
|
|
161
|
+
error="html_response",
|
|
162
|
+
)
|
|
163
|
+
return _html_response_message(
|
|
164
|
+
"InfluxDB", url, response.status, body
|
|
165
|
+
)
|
|
166
|
+
self._log_audit(
|
|
167
|
+
sql_query,
|
|
168
|
+
db,
|
|
169
|
+
response.status,
|
|
170
|
+
started,
|
|
171
|
+
error="json_decode_error",
|
|
172
|
+
)
|
|
173
|
+
return _json_decode_message(
|
|
174
|
+
"InfluxDB", url, response.status, body
|
|
175
|
+
)
|
|
176
|
+
formatted = json.dumps(data, indent=2)
|
|
177
|
+
formatted = sanitize_output(
|
|
178
|
+
formatted, self.max_output_bytes, label="InfluxDB result"
|
|
179
|
+
)
|
|
180
|
+
self._log_audit(sql_query, db, response.status, started)
|
|
181
|
+
return f"✅ Query successful\n\n{formatted}"
|
|
182
|
+
else:
|
|
183
|
+
body = await response.text()
|
|
184
|
+
if _looks_like_html(response, body):
|
|
185
|
+
self._log_audit(
|
|
186
|
+
sql_query,
|
|
187
|
+
db,
|
|
188
|
+
response.status,
|
|
189
|
+
started,
|
|
190
|
+
error="html_response",
|
|
191
|
+
)
|
|
192
|
+
return _html_response_message(
|
|
193
|
+
"InfluxDB", url, response.status, body
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
error_body = sanitize_output(
|
|
197
|
+
body,
|
|
198
|
+
self.max_output_bytes,
|
|
199
|
+
label="InfluxDB error",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
hint = ""
|
|
203
|
+
if _should_show_scan_hint(sql_query, error_body):
|
|
204
|
+
hint = (
|
|
205
|
+
"💡 Tip: InfluxDB 3 Core has a file-scan limit. "
|
|
206
|
+
"Queries without a `WHERE time` clause may fail "
|
|
207
|
+
"with HTTP 500. Add a time range like:\n"
|
|
208
|
+
" WHERE time > now() - INTERVAL '1 hour'\n\n"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
self._log_audit(sql_query, db, response.status, started)
|
|
212
|
+
return (
|
|
213
|
+
f"{hint}"
|
|
214
|
+
f"❌ InfluxDB query failed (HTTP {response.status})\n"
|
|
215
|
+
f"{error_body}"
|
|
216
|
+
)
|
|
217
|
+
except aiohttp.ClientError as e:
|
|
218
|
+
self._log_audit(
|
|
219
|
+
sql_query, db, "connection_error", started, error=type(e).__name__
|
|
220
|
+
)
|
|
221
|
+
return f"❌ Connection error: {e}"
|
|
222
|
+
except TimeoutError:
|
|
223
|
+
self._log_audit(sql_query, db, "timeout", started)
|
|
224
|
+
return f"❌ Query timed out after {self.timeout} seconds"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class PrometheusClient:
|
|
228
|
+
"""Client for Prometheus HTTP API (read-only)."""
|
|
229
|
+
|
|
230
|
+
def __init__(
|
|
231
|
+
self,
|
|
232
|
+
host: str = "localhost",
|
|
233
|
+
port: int = 9090,
|
|
234
|
+
use_https: bool = False,
|
|
235
|
+
token: str | None = None,
|
|
236
|
+
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
237
|
+
timeout: float = 30,
|
|
238
|
+
audit: AuditLogger | None = None,
|
|
239
|
+
):
|
|
240
|
+
self.host = host
|
|
241
|
+
self.port = port
|
|
242
|
+
self.use_https = use_https
|
|
243
|
+
self.token = token
|
|
244
|
+
self.max_output_bytes = max_output_bytes
|
|
245
|
+
self.timeout = timeout
|
|
246
|
+
self.audit = audit
|
|
247
|
+
self.scheme = "https" if use_https else "http"
|
|
248
|
+
self._session: aiohttp.ClientSession | None = None
|
|
249
|
+
|
|
250
|
+
def _get_session(self) -> aiohttp.ClientSession:
|
|
251
|
+
"""Return the client's shared HTTP session, creating it on demand."""
|
|
252
|
+
if self._session is None:
|
|
253
|
+
self._session = aiohttp.ClientSession()
|
|
254
|
+
return self._session
|
|
255
|
+
|
|
256
|
+
async def close(self) -> None:
|
|
257
|
+
"""Close the shared HTTP session (idempotent)."""
|
|
258
|
+
session = self._session
|
|
259
|
+
self._session = None
|
|
260
|
+
if session is not None:
|
|
261
|
+
await session.close()
|
|
262
|
+
|
|
263
|
+
def _log_audit(
|
|
264
|
+
self,
|
|
265
|
+
query: str,
|
|
266
|
+
status: str | int,
|
|
267
|
+
started: float,
|
|
268
|
+
*,
|
|
269
|
+
error: str | None = None,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Record query metadata without storing the PromQL text."""
|
|
272
|
+
if self.audit is None:
|
|
273
|
+
return
|
|
274
|
+
fields: dict[str, object] = {
|
|
275
|
+
"status": status,
|
|
276
|
+
"duration_ms": round((monotonic() - started) * 1000, 1),
|
|
277
|
+
"query_length": len(query),
|
|
278
|
+
"query_sha256": hashlib.sha256(query.encode("utf-8")).hexdigest(),
|
|
279
|
+
}
|
|
280
|
+
if error is not None:
|
|
281
|
+
fields["error"] = error
|
|
282
|
+
self.audit.log("prometheus_query", **fields)
|
|
283
|
+
|
|
284
|
+
async def query(self, promql: str, time: str | None = None) -> str:
|
|
285
|
+
"""
|
|
286
|
+
Execute an instant query against the Prometheus HTTP API.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
promql: PromQL expression
|
|
290
|
+
time: Optional RFC3339 timestamp or Unix timestamp (defaults to now)
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Query results in JSON format
|
|
294
|
+
"""
|
|
295
|
+
started = monotonic()
|
|
296
|
+
url = f"{self.scheme}://{self.host}:{self.port}/api/v1/query"
|
|
297
|
+
|
|
298
|
+
params: dict[str, str] = {"query": promql}
|
|
299
|
+
if time:
|
|
300
|
+
params["time"] = time
|
|
301
|
+
|
|
302
|
+
headers: dict[str, str] = {}
|
|
303
|
+
if self.token:
|
|
304
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
session = self._get_session()
|
|
308
|
+
async with session.get(
|
|
309
|
+
url,
|
|
310
|
+
params=params,
|
|
311
|
+
headers=headers,
|
|
312
|
+
timeout=self.timeout,
|
|
313
|
+
) as response:
|
|
314
|
+
if response.status == 200:
|
|
315
|
+
try:
|
|
316
|
+
data = await response.json()
|
|
317
|
+
except (aiohttp.ContentTypeError, ValueError):
|
|
318
|
+
body = await response.text()
|
|
319
|
+
if _looks_like_html(response, body):
|
|
320
|
+
self._log_audit(
|
|
321
|
+
promql, response.status, started, error="html_response"
|
|
322
|
+
)
|
|
323
|
+
return _html_response_message(
|
|
324
|
+
"Prometheus", url, response.status, body
|
|
325
|
+
)
|
|
326
|
+
self._log_audit(
|
|
327
|
+
promql,
|
|
328
|
+
response.status,
|
|
329
|
+
started,
|
|
330
|
+
error="json_decode_error",
|
|
331
|
+
)
|
|
332
|
+
return _json_decode_message(
|
|
333
|
+
"Prometheus", url, response.status, body
|
|
334
|
+
)
|
|
335
|
+
if data.get("status") == "success":
|
|
336
|
+
formatted = json.dumps(data["data"], indent=2)
|
|
337
|
+
formatted = sanitize_output(
|
|
338
|
+
formatted,
|
|
339
|
+
self.max_output_bytes,
|
|
340
|
+
label="Prometheus result",
|
|
341
|
+
)
|
|
342
|
+
self._log_audit(promql, response.status, started)
|
|
343
|
+
return f"✅ Query successful\n\n{formatted}"
|
|
344
|
+
else:
|
|
345
|
+
error_type = data.get("errorType", "unknown")
|
|
346
|
+
error_msg = data.get("error", "Unknown error")
|
|
347
|
+
self._log_audit(promql, f"promql_error:{error_type}", started)
|
|
348
|
+
return (
|
|
349
|
+
f"❌ PromQL query error\n"
|
|
350
|
+
f"Type: {error_type}\n"
|
|
351
|
+
f"Error: {error_msg}"
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
body = await response.text()
|
|
355
|
+
if _looks_like_html(response, body):
|
|
356
|
+
self._log_audit(
|
|
357
|
+
promql, response.status, started, error="html_response"
|
|
358
|
+
)
|
|
359
|
+
return _html_response_message(
|
|
360
|
+
"Prometheus", url, response.status, body
|
|
361
|
+
)
|
|
362
|
+
error_body = sanitize_output(
|
|
363
|
+
body,
|
|
364
|
+
self.max_output_bytes,
|
|
365
|
+
label="Prometheus error",
|
|
366
|
+
)
|
|
367
|
+
self._log_audit(promql, response.status, started)
|
|
368
|
+
return (
|
|
369
|
+
f"❌ Prometheus query failed (HTTP {response.status})\n"
|
|
370
|
+
f"{error_body}"
|
|
371
|
+
)
|
|
372
|
+
except aiohttp.ClientError as e:
|
|
373
|
+
self._log_audit(promql, "connection_error", started, error=type(e).__name__)
|
|
374
|
+
return f"❌ Connection error: {e}"
|
|
375
|
+
except TimeoutError:
|
|
376
|
+
self._log_audit(promql, "timeout", started)
|
|
377
|
+
return f"❌ Query timed out after {self.timeout} seconds"
|
|
378
|
+
|
|
379
|
+
def _log_targets(
|
|
380
|
+
self,
|
|
381
|
+
status: str | int,
|
|
382
|
+
started: float,
|
|
383
|
+
*,
|
|
384
|
+
error: str | None = None,
|
|
385
|
+
) -> None:
|
|
386
|
+
"""Record a targets request without storing response bodies."""
|
|
387
|
+
if self.audit is None:
|
|
388
|
+
return
|
|
389
|
+
fields: dict[str, object] = {
|
|
390
|
+
"status": status,
|
|
391
|
+
"duration_ms": round((monotonic() - started) * 1000, 1),
|
|
392
|
+
}
|
|
393
|
+
if error is not None:
|
|
394
|
+
fields["error"] = error
|
|
395
|
+
self.audit.log("prometheus_targets", **fields)
|
|
396
|
+
|
|
397
|
+
async def get_targets(self) -> str:
|
|
398
|
+
"""Get scrape targets from Prometheus."""
|
|
399
|
+
started = monotonic()
|
|
400
|
+
url = f"{self.scheme}://{self.host}:{self.port}/api/v1/targets"
|
|
401
|
+
|
|
402
|
+
headers: dict[str, str] = {}
|
|
403
|
+
if self.token:
|
|
404
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
session = self._get_session()
|
|
408
|
+
async with session.get(
|
|
409
|
+
url, headers=headers, timeout=self.timeout
|
|
410
|
+
) as response:
|
|
411
|
+
if response.status == 200:
|
|
412
|
+
try:
|
|
413
|
+
data = await response.json()
|
|
414
|
+
except (aiohttp.ContentTypeError, ValueError):
|
|
415
|
+
body = await response.text()
|
|
416
|
+
if _looks_like_html(response, body):
|
|
417
|
+
self._log_targets(
|
|
418
|
+
response.status, started, error="html_response"
|
|
419
|
+
)
|
|
420
|
+
return _html_response_message(
|
|
421
|
+
"Prometheus", url, response.status, body
|
|
422
|
+
)
|
|
423
|
+
self._log_targets(
|
|
424
|
+
response.status, started, error="json_decode_error"
|
|
425
|
+
)
|
|
426
|
+
return _json_decode_message(
|
|
427
|
+
"Prometheus", url, response.status, body
|
|
428
|
+
)
|
|
429
|
+
if data.get("status") == "success":
|
|
430
|
+
targets = data.get("data", {}).get("activeTargets", [])
|
|
431
|
+
if not targets:
|
|
432
|
+
self._log_targets(response.status, started)
|
|
433
|
+
return "No active targets configured in Prometheus."
|
|
434
|
+
|
|
435
|
+
lines = [f"✅ Found {len(targets)} active targets:\n"]
|
|
436
|
+
for target in targets:
|
|
437
|
+
labels = target.get("labels", {})
|
|
438
|
+
job = labels.get("job", "unknown")
|
|
439
|
+
instance = labels.get("instance", "unknown")
|
|
440
|
+
health = target.get("health", "unknown")
|
|
441
|
+
last_error = target.get("lastError", "")
|
|
442
|
+
lines.append(
|
|
443
|
+
f" • {job}/{instance}: {health}"
|
|
444
|
+
+ (f" ({last_error})" if last_error else "")
|
|
445
|
+
)
|
|
446
|
+
self._log_targets(response.status, started)
|
|
447
|
+
return "\n".join(lines)
|
|
448
|
+
|
|
449
|
+
self._log_targets("unexpected_response", started)
|
|
450
|
+
return f"❌ Unexpected response: {json.dumps(data, indent=2)}"
|
|
451
|
+
|
|
452
|
+
body = await response.text()
|
|
453
|
+
if _looks_like_html(response, body):
|
|
454
|
+
self._log_targets(response.status, started, error="html_response")
|
|
455
|
+
return _html_response_message(
|
|
456
|
+
"Prometheus", url, response.status, body
|
|
457
|
+
)
|
|
458
|
+
error_body = sanitize_output(
|
|
459
|
+
body,
|
|
460
|
+
self.max_output_bytes,
|
|
461
|
+
label="Prometheus error",
|
|
462
|
+
)
|
|
463
|
+
self._log_targets(response.status, started)
|
|
464
|
+
return (
|
|
465
|
+
f"❌ Failed to get targets (HTTP {response.status})\n{error_body}"
|
|
466
|
+
)
|
|
467
|
+
except aiohttp.ClientError as e:
|
|
468
|
+
self._log_targets("connection_error", started, error=type(e).__name__)
|
|
469
|
+
return f"❌ Connection error: {e}"
|
|
470
|
+
except TimeoutError:
|
|
471
|
+
self._log_targets("timeout", started)
|
|
472
|
+
return f"❌ Request timed out after {self.timeout} seconds"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# An error page is diagnostic context, not a query result: keep the excerpt
|
|
476
|
+
# short so a themed proxy page cannot flood the model context.
|
|
477
|
+
_HTML_EXCERPT_BYTES = 400
|
|
478
|
+
|
|
479
|
+
_HTML_START_PATTERN = re.compile(
|
|
480
|
+
r"^\s*<(?:!doctype\s+html|html|head|body)\b", re.IGNORECASE
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
_SCAN_LIMIT_PATTERN = re.compile(r"(?:file[ -]scan|scan[ -]limit)", re.IGNORECASE)
|
|
484
|
+
|
|
485
|
+
_TIME_PREDICATE_PATTERN = re.compile(r"\btime\b", re.IGNORECASE)
|
|
486
|
+
|
|
487
|
+
_WHERE_END_KEYWORDS = frozenset(
|
|
488
|
+
{"GROUP BY", "ORDER BY", "LIMIT", "HAVING", "UNION", "WINDOW", "OFFSET"}
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _content_type(response: object) -> str:
|
|
493
|
+
"""Return the declared content type when the response exposes one."""
|
|
494
|
+
value = getattr(response, "content_type", None)
|
|
495
|
+
return value if isinstance(value, str) else ""
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _looks_like_html(response: object, body: str) -> bool:
|
|
499
|
+
"""Detect a proxy/gateway HTML page rather than an API response."""
|
|
500
|
+
if "html" in _content_type(response).lower():
|
|
501
|
+
return True
|
|
502
|
+
return bool(_HTML_START_PATTERN.match(body[:512]))
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _excerpt(body: str, label: str) -> str:
|
|
506
|
+
"""Collapse whitespace and cap a body to a short, readable excerpt."""
|
|
507
|
+
collapsed = " ".join(body.split())
|
|
508
|
+
return sanitize_output(collapsed, _HTML_EXCERPT_BYTES, label=label)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _html_response_message(service: str, url: str, status: int, body: str) -> str:
|
|
512
|
+
"""Build the diagnostic for an HTML page served instead of the API."""
|
|
513
|
+
excerpt = _excerpt(body, "HTML page excerpt")
|
|
514
|
+
return (
|
|
515
|
+
f"❌ Received an HTML page from {url} (HTTP {status}), "
|
|
516
|
+
f"not a {service} response.\n"
|
|
517
|
+
"This is usually a reverse proxy / gateway error, not a query error.\n"
|
|
518
|
+
"Check that the upstream is reachable and that the vhost disables\n"
|
|
519
|
+
"`proxy_intercept_errors` for /api paths.\n"
|
|
520
|
+
f"\nHTML excerpt: {excerpt}"
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _json_decode_message(service: str, url: str, status: int, body: str) -> str:
|
|
525
|
+
"""Build the diagnostic for a body that is not decodable JSON."""
|
|
526
|
+
excerpt = _excerpt(body, f"{service} response excerpt")
|
|
527
|
+
return (
|
|
528
|
+
f"❌ {service} returned a {status} response from {url} that is not "
|
|
529
|
+
"valid JSON.\n"
|
|
530
|
+
"This is usually a reverse proxy, captcha or login page instead of the "
|
|
531
|
+
f"{service} API.\n"
|
|
532
|
+
f"\nResponse excerpt: {excerpt}"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _has_time_predicate(sql_query: str) -> bool:
|
|
537
|
+
"""Return True when the WHERE clause filters on a ``time`` column.
|
|
538
|
+
|
|
539
|
+
``SELECT time ...`` is not a time filter, so the hint must not be
|
|
540
|
+
suppressed for it. The check walks the flattened token stream, ignores
|
|
541
|
+
string literals and stops at the end of the WHERE clause.
|
|
542
|
+
"""
|
|
543
|
+
try:
|
|
544
|
+
statements = sqlparse.parse(sql_query)
|
|
545
|
+
except Exception: # pragma: no cover - sqlparse is defensive
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
for statement in statements:
|
|
549
|
+
in_where = False
|
|
550
|
+
for token in statement.flatten():
|
|
551
|
+
if token.ttype in sql_tokens.Keyword:
|
|
552
|
+
keyword = " ".join(token.value.upper().split())
|
|
553
|
+
if keyword == "WHERE":
|
|
554
|
+
in_where = True
|
|
555
|
+
continue
|
|
556
|
+
if keyword in _WHERE_END_KEYWORDS:
|
|
557
|
+
in_where = False
|
|
558
|
+
continue
|
|
559
|
+
if not in_where or token.is_whitespace:
|
|
560
|
+
continue
|
|
561
|
+
if token.ttype in sql_tokens.String:
|
|
562
|
+
continue
|
|
563
|
+
if _TIME_PREDICATE_PATTERN.search(token.value):
|
|
564
|
+
return True
|
|
565
|
+
return False
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _should_show_scan_hint(sql_query: str, error_body: str) -> bool:
|
|
569
|
+
"""Only hint at the file-scan limit when it can be the actual cause."""
|
|
570
|
+
if _SCAN_LIMIT_PATTERN.search(error_body):
|
|
571
|
+
return True
|
|
572
|
+
return not _has_time_predicate(sql_query)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _ensure_limit(sql_query: str, limit: int) -> str:
|
|
576
|
+
"""Bound the result set of a SELECT query.
|
|
577
|
+
|
|
578
|
+
A top-level ``LIMIT <n>`` is clamped to ``limit``; a ``LIMIT`` inside a
|
|
579
|
+
string literal, an identifier or a nested subquery is ignored. When the
|
|
580
|
+
outermost query has no numeric LIMIT clause, one is appended. Comments are
|
|
581
|
+
rejected by :meth:`SecurityValidator.validate_influxdb_query` before the
|
|
582
|
+
query reaches this helper.
|
|
583
|
+
"""
|
|
584
|
+
query = sql_query.strip().rstrip(";").rstrip()
|
|
585
|
+
maximum = int(limit)
|
|
586
|
+
|
|
587
|
+
try:
|
|
588
|
+
statements = sqlparse.parse(query)
|
|
589
|
+
except Exception: # pragma: no cover - sqlparse is defensive
|
|
590
|
+
return f"{query}\nLIMIT {maximum}"
|
|
591
|
+
|
|
592
|
+
if len(statements) != 1:
|
|
593
|
+
return f"{query}\nLIMIT {maximum}"
|
|
594
|
+
|
|
595
|
+
statement = statements[0]
|
|
596
|
+
tokens = statement.tokens
|
|
597
|
+
for index, token in enumerate(tokens):
|
|
598
|
+
if token.ttype not in sql_tokens.Keyword or token.value.upper() != "LIMIT":
|
|
599
|
+
continue
|
|
600
|
+
|
|
601
|
+
next_index = index + 1
|
|
602
|
+
while next_index < len(tokens) and (
|
|
603
|
+
tokens[next_index].is_whitespace
|
|
604
|
+
or tokens[next_index].ttype in sql_tokens.Comment
|
|
605
|
+
):
|
|
606
|
+
next_index += 1
|
|
607
|
+
|
|
608
|
+
if next_index >= len(tokens):
|
|
609
|
+
continue
|
|
610
|
+
value_token = tokens[next_index]
|
|
611
|
+
if value_token.ttype not in sql_tokens.Number.Integer:
|
|
612
|
+
continue
|
|
613
|
+
|
|
614
|
+
if int(value_token.value) > maximum:
|
|
615
|
+
value_token.value = str(maximum)
|
|
616
|
+
return str(statement).strip()
|
|
617
|
+
|
|
618
|
+
return f"{query}\nLIMIT {maximum}"
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def parse_duration(value: str | int | float, default: float = 30) -> float:
|
|
622
|
+
"""Parse ``30``, ``"30s"``, ``"1m"`` or ``"500ms"`` into seconds."""
|
|
623
|
+
if isinstance(value, int | float):
|
|
624
|
+
return float(value)
|
|
625
|
+
text = str(value).strip().lower()
|
|
626
|
+
units = {"ms": 0.001, "s": 1.0, "m": 60.0, "h": 3600.0}
|
|
627
|
+
for suffix, factor in units.items():
|
|
628
|
+
if text.endswith(suffix):
|
|
629
|
+
try:
|
|
630
|
+
return float(text[: -len(suffix)]) * factor
|
|
631
|
+
except ValueError:
|
|
632
|
+
return default
|
|
633
|
+
try:
|
|
634
|
+
return float(text)
|
|
635
|
+
except ValueError:
|
|
636
|
+
return default
|