From 61eadfcb0da5a8157e38994c3183e87fb2865df2 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Mon, 25 May 2026 16:45:01 +0300 Subject: [PATCH] first commit --- .README.md.swp | Bin 0 -> 16384 bytes .drone.yml | 23 +++++ Dockerfile | 12 +++ README.md | 222 +++++++++++++++++++++++++++++++++++++++++++++++ network_tools.py | 171 ++++++++++++++++++++++++++++++++++++ 5 files changed, 428 insertions(+) create mode 100644 .README.md.swp create mode 100644 .drone.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 network_tools.py diff --git a/.README.md.swp b/.README.md.swp new file mode 100644 index 0000000000000000000000000000000000000000..76ec65c71190cce0ba9b10993ee9d1849b59080e GIT binary patch literal 16384 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 Kg3q7&)c*l~iunit literal 0 HcmV?d00001 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..4543785 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,23 @@ +kind: pipeline +type: docker +name: build-and-push + +steps: + - name: build-image + image: plugins/docker + settings: + registry: git.sedletskiy.ru + repo: git.sedletskiy.ru/kosechok/testmintools + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + username: + from_secret: REGISTRY_USER + password: + from_secret: REGISTRY_PASSWORD + +trigger: + branch: + - master + event: + - push \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6be9526 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.10-slim + +RUN python -m pip install --upgrade pip +RUN pip install Flask + +# Установка curl, nano, ping и traceroute +RUN apt-get update && apt-get install -y curl nano iputils-ping traceroute +RUN mkdir /app +COPY ./network_tools.py /app +WORKDIR /app + +CMD ["python", "network_tools.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..08e62b5 --- /dev/null +++ b/README.md @@ -0,0 +1,222 @@ +# 🛠️ testmontools + +Лёгкий HTTP-сервис для сетевой диагностики. Оборачивает `ping`, `traceroute` и `curl` в REST API — удобно для мониторинга, дебага и автоматизации. + +--- + +## 📋 Содержание + +- [Требования](#требования) +- [Запуск](#запуск) +- [API Reference](#api-reference) + - [POST /testmontools/ping](#post-testmontoolsping) + - [POST /testmontools/traceroute](#post-testmontoolstraceroute) + - [POST /testmontools/curl](#post-testmontoolscurl) + - [POST /testmontools/all](#post-testmontoolsall) +- [Формат входных данных](#формат-входных-данных) +- [Примеры](#примеры) + +--- + +## Требования + +- Python 3.8+ +- Flask +- Утилиты `ping`, `traceroute`, `curl` в системе + +```bash +pip install flask +``` + +--- + +## Запуск + +```bash +python app.py +``` + +Сервер стартует на `http://0.0.0.0:7088`. + +--- + +## API Reference + +Все эндпоинты принимают `Content-Type: application/json` и возвращают JSON. + +### Формат входных данных + +В теле каждого запроса передаётся одно из полей: + +| Поле | Тип | Описание | +|--------|--------|--------------------------------------------| +| `url` | string | Полный URL (`https://example.com/path`) | +| `host` | string | Хост или IP (`example.com`, `8.8.8.8`) | + +Если передан URL, хост извлекается автоматически там, где нужен именно он (ping, traceroute). + +--- + +### POST /testmontools/ping + +Запускает `ping -c 4` до указанного хоста. + +**Запрос** +```json +{ "host": "example.com" } +``` + +**Ответ** +```json +{ + "input": "example.com", + "resolved_host": "example.com", + "stdout": "PING example.com (93.184.216.34) ...", + "stderr": "", + "returncode": 0 +} +``` + +--- + +### POST /testmontools/traceroute + +Запускает `traceroute` до указанного хоста. + +**Запрос** +```json +{ "host": "8.8.8.8" } +``` + +**Ответ** +```json +{ + "input": "8.8.8.8", + "resolved_host": "8.8.8.8", + "stdout": "traceroute to 8.8.8.8 (8.8.8.8), 30 hops max ...", + "stderr": "", + "returncode": 0 +} +``` + +--- + +### POST /testmontools/curl + +Выполняет `curl` к указанному URL с подробными тайминговыми метриками. + +**Запрос** +```json +{ "url": "https://example.com" } +``` + +Дополнительные параметры: + +| Поле | Тип | Описание | +|-----------|---------|-----------------------------------------------| +| `headers` | boolean | Если `true` — запрашивает только заголовки (`-I`) | + +**Ответ** +```json +{ + "input": "https://example.com", + "url": "https://example.com", + "timing": { + "dns_lookup": 0.012, + "tcp_connect": 0.034, + "tls_handshake":0.098, + "pre_transfer": 0.099, + "first_byte": 0.215, + "total": 0.216, + "size_bytes": 1256, + "speed_bps": 5814, + "http_code": 200 + }, + "trace": "* Connected to example.com ...", + "body": "...", + "returncode": 0 +} +``` + +**Тайминговые поля** + +| Поле | Описание | +|-----------------|---------------------------------------| +| `dns_lookup` | Время разрешения DNS (сек) | +| `tcp_connect` | Время установки TCP-соединения (сек) | +| `tls_handshake` | Время TLS-хендшейка (сек) | +| `pre_transfer` | Время до начала передачи (сек) | +| `first_byte` | Время до первого байта ответа (сек) | +| `total` | Общее время запроса (сек) | +| `size_bytes` | Размер скачанного тела (байт) | +| `speed_bps` | Скорость загрузки (байт/сек) | +| `http_code` | HTTP-код ответа | + +--- + +### POST /testmontools/all + +Запускает `ping`, `traceroute` и `curl` **параллельно** и возвращает сводный результат. + +**Запрос** +```json +{ "url": "https://example.com" } +``` + +**Ответ** +```json +{ + "input": "https://example.com", + "ping": { ... }, + "traceroute": { ... }, + "curl": { ... } +} +``` + +Структура каждого вложенного объекта совпадает с ответами соответствующих отдельных эндпоинтов. + +--- + +## Примеры + +**curl из терминала** + +```bash +# Ping +curl -s -X POST http://localhost:7088/testmontools/ping \ + -H 'Content-Type: application/json' \ + -d '{"host": "google.com"}' | jq + +# Только заголовки +curl -s -X POST http://localhost:7088/testmontools/curl \ + -H 'Content-Type: application/json' \ + -d '{"url": "https://google.com", "headers": true}' | jq + +# Полная диагностика +curl -s -X POST http://localhost:7088/testmontools/all \ + -H 'Content-Type: application/json' \ + -d '{"url": "https://google.com"}' | jq +``` + +**Python** + +```python +import requests + +r = requests.post( + "http://localhost:7088/testmontools/all", + json={"url": "https://example.com"} +) +data = r.json() +print(data["curl"]["timing"]["first_byte"]) +``` + +--- + +## Коды ответов + +| HTTP-код | Описание | +|----------|-------------------------------------| +| `200` | Успешно, результат в теле ответа | +| `400` | Не передан обязательный параметр | + diff --git a/network_tools.py b/network_tools.py new file mode 100644 index 0000000..3dc5afc --- /dev/null +++ b/network_tools.py @@ -0,0 +1,171 @@ +from flask import Flask, request, jsonify +from urllib.parse import urlparse +from concurrent.futures import ThreadPoolExecutor +import subprocess +import re + +app = Flask(__name__) + +CURL_TIMING_FORMAT = ( + "dns_lookup=%(time_namelookup)s\n" + "tcp_connect=%(time_connect)s\n" + "tls_handshake=%(time_appconnect)s\n" + "pre_transfer=%(time_pretransfer)s\n" + "first_byte=%(time_starttransfer)s\n" + "total=%(time_total)s\n" + "size_bytes=%(size_download)s\n" + "speed_bps=%(speed_download)s\n" + "http_code=%(http_code)s\n" +).replace('%', '%') + +CURL_NOISE = re.compile(r'[\{\}] \[\d+ bytes data\]|\[[\d\s]+ bytes data\]|\r') + + +def extract_host(value: str) -> str: + if value.startswith(('http://', 'https://')): + return urlparse(value).hostname or value + return value + + +def parse_input(data: dict): + raw = data.get('url') or data.get('host') + if not raw: + return None, None + return raw, extract_host(raw) + + +def parse_curl_timing(text: str) -> dict: + timing = {} + for line in text.splitlines(): + if '=' in line: + key, _, val = line.partition('=') + try: + timing[key.strip()] = float(val.strip()) + except ValueError: + timing[key.strip()] = val.strip() + return timing + + +def clean_curl_output(text: str) -> str: + return '\n'.join( + line for line in text.splitlines() + if not CURL_NOISE.search(line) + ) + + +def run_ping(host: str) -> dict: + result = subprocess.run( + ['ping', '-c', '4', host], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + return { + 'resolved_host': host, + 'stdout': result.stdout, + 'stderr': result.stderr, + 'returncode': result.returncode + } + + +def run_traceroute(host: str) -> dict: + result = subprocess.run( + ['traceroute', host], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + return { + 'resolved_host': host, + 'stdout': result.stdout, + 'stderr': result.stderr, + 'returncode': result.returncode + } + + + +def run_curl(url: str, headers_only: bool = False) -> dict: + args = [ + 'curl', '-sS', '-v', + '-w', '\n===TIMING===\n' + CURL_TIMING_FORMAT, + '--max-time', '30', + ] + + if headers_only: + args.append('-I') + + args.append(url) + + result = subprocess.run( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # Разделяем тело и timing + parts = result.stdout.split('===TIMING===') + + body = parts[0].strip() + timing_text = parts[1] if len(parts) > 1 else "" + + return { + 'url': url, + 'timing': parse_curl_timing(timing_text), + 'trace': clean_curl_output(result.stderr), + 'returncode': result.returncode, + 'body': body + } + + +# ── Endpoints ──────────────────────────────────────────────────────────────── + +@app.route('/testmontools/ping', methods=['POST']) +def ping(): + data = request.get_json() + raw, host = parse_input(data or {}) + if not host: + return jsonify({'error': 'Field "url" or "host" is required'}), 400 + return jsonify({'input': raw, **run_ping(host)}) + + +@app.route('/testmontools/traceroute', methods=['POST']) +def traceroute(): + data = request.get_json() + raw, host = parse_input(data or {}) + if not host: + return jsonify({'error': 'Field "url" or "host" is required'}), 400 + return jsonify({'input': raw, **run_traceroute(host)}) + + +@app.route('/testmontools/curl', methods=['POST']) +def curl(): + data = request.get_json() + raw, _ = parse_input(data or {}) + if not raw: + return jsonify({'error': 'Field "url" or "host" is required'}), 400 + url = raw if raw.startswith(('http://', 'https://')) else f'https://{raw}' + return jsonify({'input': raw, **run_curl(url, headers_only=bool(data.get('headers')))}) + + +@app.route('/testmontools/all', methods=['POST']) +def check_all(): + data = request.get_json() + raw, host = parse_input(data or {}) + if not raw or not host: + return jsonify({'error': 'Field "url" or "host" is required'}), 400 + + url = raw if raw.startswith(('http://', 'https://')) else f'https://{raw}' + + # Запускаем ping и traceroute параллельно, curl — отдельно + with ThreadPoolExecutor(max_workers=3) as pool: + f_ping = pool.submit(run_ping, host) + f_traceroute = pool.submit(run_traceroute, host) + f_curl = pool.submit(run_curl, url) + + return jsonify({ + 'input': raw, + 'curl': f_curl.result(), + 'ping': f_ping.result(), + 'traceroute': f_traceroute.result(), + }) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=7088, debug=False)