- 特征检测(规则/签名)与行为检测(24h 统计)单进程运行 - 融合名单按首次发现时间倒序,展示位置/归属(GeoIP + ASN) - 钉钉融合推送(加签、24h 节流、去重) - 页面状态灯、CSV 导出、Docker 镜像构建文件
215 lines
7.7 KiB
Python
215 lines
7.7 KiB
Python
"""Minimal HTTP layer for the Graylog REST API.
|
|
|
|
Deliberately built on urllib so the tool has no third-party dependencies and
|
|
can be copied onto old jump hosts. Handles the three things Graylog's API
|
|
demands that plain urllib does not do for you:
|
|
|
|
* HTTP Basic auth (API token used as username with the literal password
|
|
``token``, or a real username/password pair),
|
|
* the ``X-Requested-By`` header required by Graylog's CSRF protection on all
|
|
non-GET requests,
|
|
* bounded retries with backoff for 429/5xx and transient connection errors.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import random
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
USER_AGENT = "graylog-ops/0.1 (read-only recon)"
|
|
RETRY_STATUS = frozenset((429, 500, 502, 503, 504))
|
|
|
|
|
|
class GraylogError(Exception):
|
|
"""Any failure talking to the Graylog API."""
|
|
|
|
def __init__(self, message, status=None, body=None, path=None, method=None):
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.status = status
|
|
self.body = body
|
|
self.path = path
|
|
self.method = method
|
|
|
|
def __str__(self):
|
|
parts = []
|
|
if self.status is not None:
|
|
parts.append("HTTP %s" % self.status)
|
|
if self.method and self.path:
|
|
parts.append("%s %s" % (self.method, self.path))
|
|
parts.append(self.message)
|
|
return " | ".join(parts)
|
|
|
|
|
|
def _basic_auth(username, password):
|
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
|
|
|
|
|
def _error_detail(body):
|
|
"""Pull a human readable message out of a Graylog/ES error body."""
|
|
if not body:
|
|
return ""
|
|
try:
|
|
data = json.loads(body)
|
|
except (TypeError, ValueError):
|
|
return body.strip()[:300]
|
|
if isinstance(data, dict):
|
|
for key in ("message", "error", "description"):
|
|
value = data.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
if isinstance(data.get("error"), dict):
|
|
reason = data["error"].get("reason")
|
|
if isinstance(reason, str):
|
|
return reason
|
|
return body.strip()[:300]
|
|
|
|
|
|
class HttpClient:
|
|
def __init__(
|
|
self,
|
|
base_url,
|
|
username=None,
|
|
password=None,
|
|
timeout=30.0,
|
|
retries=2,
|
|
insecure=False,
|
|
verbose=False,
|
|
):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.timeout = float(timeout)
|
|
self.retries = max(0, int(retries))
|
|
self.verbose = verbose
|
|
self.auth = None
|
|
if username is not None:
|
|
self.auth = _basic_auth(username, "" if password is None else password)
|
|
|
|
handlers = []
|
|
if insecure:
|
|
context = ssl.create_default_context()
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE
|
|
handlers.append(urllib.request.HTTPSHandler(context=context))
|
|
# build_opener keeps the default ProxyHandler, so HTTP(S)_PROXY env vars
|
|
# keep working for environments that need a proxy to reach Graylog.
|
|
self.opener = urllib.request.build_opener(*handlers)
|
|
|
|
# -- url helpers -----------------------------------------------------
|
|
def url(self, path, params=None):
|
|
if not path.startswith("/"):
|
|
path = "/" + path
|
|
target = self.base_url + path
|
|
if params:
|
|
clean = {}
|
|
for key, value in params.items():
|
|
if value is None:
|
|
continue
|
|
if isinstance(value, bool):
|
|
value = "true" if value else "false"
|
|
clean[key] = value
|
|
if clean:
|
|
sep = "&" if "?" in target else "?"
|
|
target += sep + urllib.parse.urlencode(clean, doseq=True)
|
|
return target
|
|
|
|
# -- request core ----------------------------------------------------
|
|
def request(self, method, path, params=None, body=None, expect_json=True):
|
|
method = method.upper()
|
|
url = self.url(path, params)
|
|
payload = None
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"Accept-Encoding": "identity",
|
|
"User-Agent": USER_AGENT,
|
|
# Graylog rejects state changing requests without this header.
|
|
"X-Requested-By": "graylog-ops",
|
|
}
|
|
if body is not None:
|
|
payload = json.dumps(body).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
if self.auth:
|
|
headers["Authorization"] = self.auth
|
|
|
|
started = time.time()
|
|
last_error = None
|
|
for attempt in range(self.retries + 1):
|
|
request = urllib.request.Request(
|
|
url, data=payload, headers=headers, method=method
|
|
)
|
|
try:
|
|
with self.opener.open(request, timeout=self.timeout) as response:
|
|
status = response.getcode()
|
|
raw = response.read()
|
|
self._debug(method, url, status, started)
|
|
if not raw.strip():
|
|
return None
|
|
text = raw.decode("utf-8", "replace")
|
|
if not expect_json:
|
|
return text
|
|
try:
|
|
return json.loads(text)
|
|
except ValueError:
|
|
raise GraylogError(
|
|
"response was not JSON",
|
|
status=status,
|
|
body=text[:500],
|
|
path=path,
|
|
method=method,
|
|
)
|
|
except urllib.error.HTTPError as exc:
|
|
text = exc.read().decode("utf-8", "replace")
|
|
detail = _error_detail(text)
|
|
self._debug(method, url, exc.code, started)
|
|
last_error = GraylogError(
|
|
detail or ("request rejected" if exc.code != 401 else "authentication failed"),
|
|
status=exc.code,
|
|
body=text[:2000],
|
|
path=path,
|
|
method=method,
|
|
)
|
|
if exc.code in RETRY_STATUS and attempt < self.retries:
|
|
self._sleep(attempt, exc.headers.get("Retry-After"))
|
|
continue
|
|
raise last_error
|
|
except (urllib.error.URLError, socket.timeout, ConnectionError, OSError) as exc:
|
|
reason = getattr(exc, "reason", exc)
|
|
self._debug(method, url, "ERR", started)
|
|
last_error = GraylogError(
|
|
"connection failed: %s" % reason,
|
|
path=path,
|
|
method=method,
|
|
)
|
|
if attempt < self.retries:
|
|
self._sleep(attempt, None)
|
|
continue
|
|
raise last_error
|
|
raise last_error # pragma: no cover - loop always raises or returns
|
|
|
|
# -- internals -------------------------------------------------------
|
|
def _sleep(self, attempt, retry_after):
|
|
delay = (1.5 ** attempt) + random.uniform(0, 0.3)
|
|
try:
|
|
if retry_after is not None:
|
|
delay = max(delay, float(retry_after))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
time.sleep(min(delay, 30.0))
|
|
|
|
def _debug(self, method, url, status, started):
|
|
if not self.verbose:
|
|
return
|
|
elapsed = int((time.time() - started) * 1000)
|
|
sys.stderr.write("[http] %s %s -> %s (%dms)\n" % (method, url, status, elapsed))
|
|
sys.stderr.flush()
|
|
|
|
# -- convenience -----------------------------------------------------
|
|
def get(self, path, params=None):
|
|
return self.request("GET", path, params=params)
|