Commit c1be8f26 authored by Vitaly Lipatov's avatar Vitaly Lipatov

router: add Swagger UI for route-web-api

- Add OpenAPI 3.0 spec (OPENAPI_SPEC) describing all API endpoints - Add /swagger page with Swagger UI loaded from CDN (unpkg.com) - Add /api/openapi.json endpoint serving the spec - Add "API docs" link on the main page - Use ThreadingHTTPServer for concurrent requests - Document Swagger UI in route-web-api.md Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 1e73e6e8
# route-web-api — веб-интерфейс управления маршрутами
Веб-сервис для добавления доменов/IP на обход (egw) или напрямую (dgw)
на шлюзе igw.etersoft.ru. Доступен только из офисной сети.
**URL:** http://igw.etersoft.ru/
## Принцип работы
- Веб-сервис работает под пользователем `routeweb`, управляет двумя list-файлами
- `route-update.sh` подхватывает изменения автоматически по таймеру (каждые 5 мин)
через MD5 hash detection — API его НЕ вызывает
- Добавление домена в один список автоматически удаляет его из другого
## Swagger UI
Интерактивная документация API доступна по адресу:
**URL:** http://igw.etersoft.ru/swagger
Swagger UI загружается с CDN (`unpkg.com/swagger-ui-dist@5`), OpenAPI-спецификация
встроена в `route-web-api.py` (константа `OPENAPI_SPEC`) и отдаётся по `/api/openapi.json`.
Никаких внешних зависимостей на сервере не требуется.
## API
### GET /
HTML-страница с веб-интерфейсом. Содержит ссылку на Swagger UI.
### GET /swagger
Swagger UI — интерактивная страница для тестирования API.
### GET /api/openapi.json
OpenAPI 3.0 спецификация всех endpoint'ов (JSON).
### GET /api/list
Текущие записи в управляемых списках.
**Ответ:**
```json
{"bypass": ["example.com", "1.2.3.4"], "direct": ["site.ru"]}
```
### POST /api/add
Добавить домен/IP. Если запись есть в другом списке — автоматически перемещается.
**Тело запроса:**
```json
{"domain": "example.com", "mode": "bypass"}
```
- `domain` — домен, IP, подсеть или URL (из URL извлекается домен)
- `mode``bypass` (через egw) или `direct` (через dgw)
**Ответ:** `{"ok": true, "domain": "example.com", "mode": "bypass"}`
**Ошибки:**
- `400` — невалидный домен/IP или превышен лимит (500 записей)
- `409` — уже есть в этом списке
### POST /api/remove
Удалить домен/IP из списка.
**Тело запроса:**
```json
{"domain": "example.com", "mode": "bypass"}
```
**Ответ:** `{"ok": true, "domain": "example.com", "mode": "bypass"}`
**Ошибки:**
- `404` — не найден в указанном списке
### POST /api/move
Переместить между списками.
**Тело запроса:**
```json
{"domain": "example.com", "from": "bypass", "to": "direct"}
```
**Ответ:** `{"ok": true, "domain": "example.com", "from": "bypass", "to": "direct"}`
### GET /api/active
Действующие правила из всех list-файлов route-update.sh.
Генерируется route-update.sh в конце каждого цикла.
**Ответ:**
```json
{
"updated": 1740430545,
"groups": {
"egw": {"ai": ["openai.com", "claude.ai"], "blocked": ["..."]},
"dgw": {"dgw": ["site.ru"]},
"zapret": {"antifilter-ipresolve": {"count": 154198}}
}
}
```
Списки > 200 записей усекаются до `{"count": N}`.
Поле `updated` — unix timestamp последнего обновления.
## Файлы
### В репозитории (router/)
- `route-web-api.py` — Python-скрипт API+UI
- `route-web-api.service` — systemd unit
- `route-web-api.md` — этот файл
### На igw (/home/routeweb/route-web-api/)
- `route-web-api.py` — копия скрипта
- `web-bypass.list` — домены через egw (owner=routeweb)
- `web-direct.list` — домены через dgw (owner=routeweb)
- `all-routes.json` — генерируется route-update.sh (owner=root, 644)
### Симлинки (создаёт root)
```
routes.d/egw/web-bypass.list → /home/routeweb/route-web-api/web-bypass.list
routes.d/dgw/web-direct.list → /home/routeweb/route-web-api/web-direct.list
routes6.d/egw/web-bypass.list → /home/routeweb/route-web-api/web-bypass.list
routes6.d/dgw/web-direct.list → /home/routeweb/route-web-api/web-direct.list
```
## Безопасность
- Сервис под пользователем `routeweb` (без root)
- `AmbientCapabilities=CAP_NET_BIND_SERVICE` для порта 80
- iptables: порт 80 только для 91.232.225.0/24 и 192.168.0.0/16
- Валидация доменов через regex, лимит 500 записей
- Атомарная запись файлов (tmpfile + rename)
## Деплой
```bash
# 1. Создать пользователя
useradd -m -s /usr/sbin/nologin routeweb
# 2. Каталог и файлы
mkdir /home/routeweb/route-web-api
cp route-web-api.py /home/routeweb/route-web-api/
touch /home/routeweb/route-web-api/web-bypass.list
touch /home/routeweb/route-web-api/web-direct.list
chown -R routeweb:routeweb /home/routeweb/route-web-api/
# 3. Симлинки
ln -s /home/routeweb/route-web-api/web-bypass.list routes.d/egw/web-bypass.list
ln -s /home/routeweb/route-web-api/web-direct.list routes.d/dgw/web-direct.list
ln -s /home/routeweb/route-web-api/web-bypass.list routes6.d/egw/web-bypass.list
ln -s /home/routeweb/route-web-api/web-direct.list routes6.d/dgw/web-direct.list
# 4. iptables
iptables -A INPUT -p tcp -s 91.232.225.0/24 --dport 80 -j ACCEPT
iptables -A INPUT -p tcp -s 192.168.0.0/16 --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j DROP
iptables-save > /etc/sysconfig/iptables
# 5. Systemd
cp route-web-api.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now route-web-api
# 6. Первая генерация all-routes.json
./route-update.sh
```
...@@ -44,6 +44,163 @@ IPV4_RE = re.compile( ...@@ -44,6 +44,163 @@ IPV4_RE = re.compile(
) )
IPV6_RE = re.compile(r"^[0-9a-fA-F:]+(?:/\d{1,3})?$") IPV6_RE = re.compile(r"^[0-9a-fA-F:]+(?:/\d{1,3})?$")
OPENAPI_SPEC = {
"openapi": "3.0.3",
"info": {
"title": "Route Management API — igw",
"description": "Управление списками маршрутизации bypass/direct на igw.etersoft.ru",
"version": "1.0.0",
},
"paths": {
"/api/list": {
"get": {
"summary": "Получить все записи",
"description": "Возвращает содержимое обоих списков (bypass и direct).",
"responses": {
"200": {
"description": "Списки записей",
"content": {"application/json": {"schema": {
"type": "object",
"properties": {
"bypass": {"type": "array", "items": {"type": "string"}},
"direct": {"type": "array", "items": {"type": "string"}},
},
}}},
}
},
}
},
"/api/add": {
"post": {
"summary": "Добавить запись",
"description": "Добавляет домен/IP в указанный список. Если запись есть в другом списке — перемещает.",
"requestBody": {"required": True, "content": {"application/json": {"schema": {
"type": "object",
"required": ["domain", "mode"],
"properties": {
"domain": {"type": "string", "description": "Домен, URL или IP-адрес", "example": "example.com"},
"mode": {"type": "string", "enum": ["bypass", "direct"], "description": "Целевой список"},
},
}}}},
"responses": {
"200": {"description": "Успешно добавлено", "content": {"application/json": {"schema": {
"type": "object",
"properties": {
"ok": {"type": "boolean"},
"domain": {"type": "string"},
"mode": {"type": "string"},
},
}}}},
"400": {"description": "Некорректный запрос"},
"409": {"description": "Запись уже существует в этом списке"},
},
}
},
"/api/remove": {
"post": {
"summary": "Удалить запись",
"description": "Удаляет домен/IP из указанного списка.",
"requestBody": {"required": True, "content": {"application/json": {"schema": {
"type": "object",
"required": ["domain", "mode"],
"properties": {
"domain": {"type": "string", "description": "Домен или IP-адрес", "example": "example.com"},
"mode": {"type": "string", "enum": ["bypass", "direct"], "description": "Из какого списка удалить"},
},
}}}},
"responses": {
"200": {"description": "Успешно удалено", "content": {"application/json": {"schema": {
"type": "object",
"properties": {
"ok": {"type": "boolean"},
"domain": {"type": "string"},
"mode": {"type": "string"},
},
}}}},
"400": {"description": "Некорректный запрос"},
"404": {"description": "Запись не найдена в списке"},
},
}
},
"/api/move": {
"post": {
"summary": "Переместить запись",
"description": "Перемещает домен/IP из одного списка в другой.",
"requestBody": {"required": True, "content": {"application/json": {"schema": {
"type": "object",
"required": ["domain", "from", "to"],
"properties": {
"domain": {"type": "string", "description": "Домен или IP-адрес", "example": "example.com"},
"from": {"type": "string", "enum": ["bypass", "direct"], "description": "Исходный список"},
"to": {"type": "string", "enum": ["bypass", "direct"], "description": "Целевой список"},
},
}}}},
"responses": {
"200": {"description": "Успешно перемещено", "content": {"application/json": {"schema": {
"type": "object",
"properties": {
"ok": {"type": "boolean"},
"domain": {"type": "string"},
"from": {"type": "string"},
"to": {"type": "string"},
},
}}}},
"400": {"description": "Некорректный запрос"},
"404": {"description": "Запись не найдена в исходном списке"},
"409": {"description": "Запись уже существует в целевом списке"},
},
}
},
"/api/active": {
"get": {
"summary": "Действующие правила",
"description": "Возвращает все активные маршруты из all-routes.json (формируется route-update.sh).",
"responses": {
"200": {
"description": "Группы маршрутов",
"content": {"application/json": {"schema": {
"type": "object",
"properties": {
"updated": {"type": "integer", "description": "Unix timestamp последнего обновления"},
"groups": {"type": "object", "description": "Группы маршрутов по именам"},
},
}}},
}
},
}
},
},
}
SWAGGER_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API docs — igw route management</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
html { box-sizing: border-box; }
body { margin: 0; background: #fafafa; }
.topbar { display: none !important; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "/api/openapi.json",
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: "BaseLayout",
});
</script>
</body>
</html>
"""
HTML_PAGE = """\ HTML_PAGE = """\
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
...@@ -125,7 +282,7 @@ HTML_PAGE = """\ ...@@ -125,7 +282,7 @@ HTML_PAGE = """\
<div id="list-direct"></div> <div id="list-direct"></div>
</div> </div>
</div> </div>
<p class="note">Изменения применятся в течение 5 минут</p> <p class="note">Изменения применятся в течение 5 минут · <a href="/swagger">API docs</a></p>
<div class="active-section"> <div class="active-section">
<h2>Действующие правила <span id="active-updated" class="subtitle"></span></h2> <h2>Действующие правила <span id="active-updated" class="subtitle"></span></h2>
<div id="active-routes"></div> <div id="active-routes"></div>
...@@ -300,7 +457,6 @@ $('domain').addEventListener('keydown', e => { ...@@ -300,7 +457,6 @@ $('domain').addEventListener('keydown', e => {
}); });
refresh(); refresh();
refreshActive();
</script> </script>
</body> </body>
</html> </html>
...@@ -409,6 +565,17 @@ class RouteHandler(http.server.BaseHTTPRequestHandler): ...@@ -409,6 +565,17 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
elif path == "/swagger":
body = SWAGGER_HTML.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
elif path == "/api/openapi.json":
self.send_json(OPENAPI_SPEC)
elif path == "/api/list": elif path == "/api/list":
data = {} data = {}
for mode, fpath in LIST_FILES.items(): for mode, fpath in LIST_FILES.items():
...@@ -553,7 +720,7 @@ def main(): ...@@ -553,7 +720,7 @@ def main():
if not os.path.exists(fpath): if not os.path.exists(fpath):
write_list(fpath, []) write_list(fpath, [])
server = http.server.HTTPServer((LISTEN_HOST, LISTEN_PORT), RouteHandler) server = http.server.ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), RouteHandler)
print("Listening on %s:%d" % (LISTEN_HOST, LISTEN_PORT), file=sys.stderr) print("Listening on %s:%d" % (LISTEN_HOST, LISTEN_PORT), file=sys.stderr)
try: try:
server.serve_forever() server.serve_forever()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment