From 93f05f13c1963bcc844e54c2d39e42acf91d4a6b Mon Sep 17 00:00:00 2001 From: wangqifan Date: Fri, 17 Oct 2025 17:17:14 +0800 Subject: [PATCH] Initial commit with project setup and basic structure. --- .gitignore | 4 + API.md | 185 +++++++++++++++++ EIP.postman_collection.json | 391 ++++++++++++++++++++++++++++++++++++ app/.env | 17 ++ app/__init__.py | 2 + app/__main__.py | 7 + app/config.py | 22 ++ app/eip_client.py | 98 +++++++++ app/main.py | 15 ++ app/redis_store.py | 49 +++++ app/rotation_service.py | 77 +++++++ app/routers/proxy.py | 27 +++ requirements.txt | 8 + 13 files changed, 902 insertions(+) create mode 100644 .gitignore create mode 100644 API.md create mode 100644 EIP.postman_collection.json create mode 100644 app/.env create mode 100644 app/__init__.py create mode 100644 app/__main__.py create mode 100644 app/config.py create mode 100644 app/eip_client.py create mode 100644 app/main.py create mode 100644 app/redis_store.py create mode 100644 app/rotation_service.py create mode 100644 app/routers/proxy.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b3af98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +app/__pycache__/__init__.*.pyc +app/__pycache__/eip_client.*.pyc +app/__pycache__/*.pyc +app/routers/__pycache__/*.pyc diff --git a/API.md b/API.md new file mode 100644 index 0000000..23affb9 --- /dev/null +++ b/API.md @@ -0,0 +1,185 @@ +## EIP API 文档 + +**Base URL**: `https://smart.jdbox.xyz:58001` + +### 鉴权 +- **名称**: API 鉴权 +- **方法**: POST +- **路径**: `/client/auth` +- **请求头**: + - `Content-Type: application/json` +- **请求体**: +```json +{ + "username": "<你的用户名>", + "password": "<你的密码>" +} +``` +- **说明**: 成功后返回 Token,请在后续请求中以 `X-Token` 头部携带。 + +--- + +### 代理链路节点 - 获取城市列表 +- **名称**: 获取代理链路节点 +- **方法**: GET +- **路径**: `/edge/city` +- **请求头**: + - `X-Token: <你的Token>` + - `Accept: application/json` +- **说明**: 返回可用城市(或区域)节点信息列表。 + +--- + +### 代理链路节点 - 按 cityhash 查询设备 +- **名称**: cityhash获取代理链路节点信息 +- **方法**: POST +- **路径**: `/edge/device` +- **请求头**: + - `X-Token: <你的Token>` + - `Content-Type: application/json` +- **请求体**: +```json +{ + "geo": "", + "offset": 0, + "num": 200 +} +``` +- **参数说明**: + - `geo`: 城市哈希(cityhash) + - `offset`: 起始偏移 + - `num`: 返回数量上限 + +--- + +### 代理链路节点 - 指定城市、数量查询 +- **名称**: 查询指定城市、指定数量代理信息 +- **方法**: POST +- **路径**: `/edge/device` +- **请求头**: + - `X-Token: <你的Token>` + - `Content-Type: application/json` +- **请求体**: +```json +{ + "geo": "", + "offset": 0, + "num": 5 +} +``` +- **说明**: 与上一个接口一致,按 `num` 控制返回的设备数量。 + +--- + +### 网关 - 获取账户下已授权网关 +- **名称**: 获取账户下已授权EIP网关信息 +- **方法**: GET +- **路径**: `/gateway/list` +- **请求头**: + - `X-Token: <你的Token>` + - `Accept: application/json` + +--- + +### 网关 - 获取网关配置 +- **名称**: 获取网关配置 +- **方法**: POST +- **路径**: `/gateway/config/get` +- **请求头**: + - `X-Token: <你的Token>` + - `Content-Type: application/json` + - `Accept: application/json` +- **请求体**: +```json +{ + "macaddr": "<网关MAC地址>" +} +``` + +--- + +### 网关 - 设置网关代理链路 +- **名称**: 配置网关代理链路 +- **方法**: POST +- **路径**: `/gateway/config/set` +- **请求头**: + - `X-Token: <你的Token>` + - `Content-Type: application/json` + - `Accept: application/json` +- **请求体示例**: +```json +{ + "macaddr": "<网关MAC地址>", + "config": { + "id": 1, + "rules": [ + { + "table": 1, + "enable": true, + "edge": ["<设备ID1>"] , + "network": ["<内网IP>"] , + "cityhash": "" + } + ] + } +} +``` +- **字段说明**: + - `id`: 配置版本或规则集标识 + - `rules[*].table`: 规则表编号 + - `rules[*].enable`: 是否启用 + - `rules[*].edge`: 目标边缘设备ID列表 + - `rules[*].network`: 需要走代理的内网网段或IP列表 + - `rules[*].cityhash`: 目标城市哈希 + +--- + +### 网关 - 清空网关配置链路 +- **名称**: 清空网关配置的链路 +- **方法**: POST +- **路径**: `/gateway/config/set` +- **请求头**: + - `X-Token: <你的Token>` + - `Content-Type: application/json` +- **请求体示例**: +```json +{ + "macaddr": "<网关MAC地址>", + "config": { + "id": 1002, + "rules": [ + { + "table": 1, + "enable": false, + "edge": ["", "", "", ""], + "network": [], + "cityhash": "" + } + ] + } +} +``` +- **说明**: 通过禁用规则并清空 `edge/network/cityhash` 实现清空配置。 + +--- + +### 网关 - 获取运行状态 +- **名称**: 获取网关运行状态 +- **方法**: POST +- **路径**: `/gateway/status` +- **请求头**: + - `X-Token: <你的Token>` + - `Content-Type: application/json` +- **请求体**: +```json +{ "macaddr": "<网关MAC地址>" } +``` + +--- + +### 通用说明 +- **认证**: 除 `/client/auth` 外,其余接口均需在请求头添加 `X-Token`。 +- **内容类型**: JSON 请求统一使用 `Content-Type: application/json`。 +- **响应**: 均返回 JSON;具体字段以实际接口返回为准。 + + diff --git a/EIP.postman_collection.json b/EIP.postman_collection.json new file mode 100644 index 0000000..903f032 --- /dev/null +++ b/EIP.postman_collection.json @@ -0,0 +1,391 @@ +{ + "info": { + "_postman_id": "2544013c-a940-46e2-a945-8f8318944008", + "name": "EIP", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "49242368", + "_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-2544013c-a940-46e2-a945-8f8318944008?action=share&source=collection_link&creator=49242368" + }, + "item": [ + { + "name": "API 鉴权", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"username\": \"UID000000151\",\r\n \"password\": \"Hnz3HuWCqEX9vdzpBodY304T6CEJJJ\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://smart.jdbox.xyz:58001/client/auth", + "protocol": "https", + "host": [ + "smart", + "jdbox", + "xyz" + ], + "port": "58001", + "path": [ + "client", + "auth" + ] + } + }, + "response": [] + }, + { + "name": "获取代理链路节点", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "X-Token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqZGJveCIsImV4cCI6MTc2MTAxMTc0MywianRpIjoiY2VhZmVlOTgtMzY5ZC00OTUzLThiZTYtMDVhYjA3NGNmMTZmIiwiaWF0IjoxNzYwNDA2OTQzLCJpc3MiOiJVSUQwMDAwMDAxNTEiLCJuYmYiOjE3NjA0MDY5NDMsInN1YiI6ImpkYm94LXNtYXJ0LWxpbmsifQ._LpBTBRAzOPm8Y62-jR7gCtsgSc-tMoxt06wj0rv7b8", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://smart.jdbox.xyz:58001/edge/city", + "protocol": "https", + "host": [ + "smart", + "jdbox", + "xyz" + ], + "port": "58001", + "path": [ + "edge", + "city" + ] + } + }, + "response": [] + }, + { + "name": "cityhash获取代理链路节点信息", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqZGJveCIsImV4cCI6MTc2MTAxMTc0MywianRpIjoiY2VhZmVlOTgtMzY5ZC00OTUzLThiZTYtMDVhYjA3NGNmMTZmIiwiaWF0IjoxNzYwNDA2OTQzLCJpc3MiOiJVSUQwMDAwMDAxNTEiLCJuYmYiOjE3NjA0MDY5NDMsInN1YiI6ImpkYm94LXNtYXJ0LWxpbmsifQ._LpBTBRAzOPm8Y62-jR7gCtsgSc-tMoxt06wj0rv7b8", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"geo\": \"093326b72938f2b2d636ad1a392aaab4978e61694aebaf7a88cb9fb85727165c\",\r\n \"offset\": 0,\r\n \"num\": 200\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://smart.jdbox.xyz:58001/edge/device", + "protocol": "https", + "host": [ + "smart", + "jdbox", + "xyz" + ], + "port": "58001", + "path": [ + "edge", + "device" + ] + } + }, + "response": [] + }, + { + "name": "查询指定城市、指定数量代理信息", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqZGJveCIsImV4cCI6MTc2MTAxMTc0MywianRpIjoiY2VhZmVlOTgtMzY5ZC00OTUzLThiZTYtMDVhYjA3NGNmMTZmIiwiaWF0IjoxNzYwNDA2OTQzLCJpc3MiOiJVSUQwMDAwMDAxNTEiLCJuYmYiOjE3NjA0MDY5NDMsInN1YiI6ImpkYm94LXNtYXJ0LWxpbmsifQ._LpBTBRAzOPm8Y62-jR7gCtsgSc-tMoxt06wj0rv7b8", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"geo\": \"807deb1e7e56a8926342fc7a2b8dd0f4cfad0218fc14dbd2834613340bf87db1\",\r\n \"offset\": 0,\r\n \"num\": 5\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://smart.jdbox.xyz:58001/edge/device", + "protocol": "https", + "host": [ + "smart", + "jdbox", + "xyz" + ], + "port": "58001", + "path": [ + "edge", + "device" + ] + } + }, + "response": [] + }, + { + "name": "获取账户下已授权EIP网关信息", + "request": { + "method": "GET", + "header": [ + { + "key": "X-Token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqZGJveCIsImV4cCI6MTc2MTAxMTc0MywianRpIjoiY2VhZmVlOTgtMzY5ZC00OTUzLThiZTYtMDVhYjA3NGNmMTZmIiwiaWF0IjoxNzYwNDA2OTQzLCJpc3MiOiJVSUQwMDAwMDAxNTEiLCJuYmYiOjE3NjA0MDY5NDMsInN1YiI6ImpkYm94LXNtYXJ0LWxpbmsifQ._LpBTBRAzOPm8Y62-jR7gCtsgSc-tMoxt06wj0rv7b8", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "https://smart.jdbox.xyz:58001/gateway/list", + "protocol": "https", + "host": [ + "smart", + "jdbox", + "xyz" + ], + "port": "58001", + "path": [ + "gateway", + "list" + ] + } + }, + "response": [] + }, + { + "name": "获取网关配置", + "request": { + "method": "POST", + "header": [ + { + "key": "X-Token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqZGJveCIsImV4cCI6MTc2MTAxMTc0MywianRpIjoiY2VhZmVlOTgtMzY5ZC00OTUzLThiZTYtMDVhYjA3NGNmMTZmIiwiaWF0IjoxNzYwNDA2OTQzLCJpc3MiOiJVSUQwMDAwMDAxNTEiLCJuYmYiOjE3NjA0MDY5NDMsInN1YiI6ImpkYm94LXNtYXJ0LWxpbmsifQ._LpBTBRAzOPm8Y62-jR7gCtsgSc-tMoxt06wj0rv7b8", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"macaddr\": \"00163E1A2F0E\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://smart.jdbox.xyz:58001/gateway/config/get", + "protocol": "https", + "host": [ + "smart", + "jdbox", + "xyz" + ], + "port": "58001", + "path": [ + "gateway", + "config", + "get" + ] + } + }, + "response": [] + }, + { + "name": "配置网关代理链路", + "request": { + "method": "POST", + "header": [ + { + "key": "X-Token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqZGJveCIsImV4cCI6MTc2MTAxMTc0MywianRpIjoiY2VhZmVlOTgtMzY5ZC00OTUzLThiZTYtMDVhYjA3NGNmMTZmIiwiaWF0IjoxNzYwNDA2OTQzLCJpc3MiOiJVSUQwMDAwMDAxNTEiLCJuYmYiOjE3NjA0MDY5NDMsInN1YiI6ImpkYm94LXNtYXJ0LWxpbmsifQ._LpBTBRAzOPm8Y62-jR7gCtsgSc-tMoxt06wj0rv7b8", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"macaddr\": \"00163E1A2F0E\",\r\n \"config\": {\r\n \"id\": 1,\r\n \"rules\": [\r\n { \"table\": 1, \"enable\": true, \"edge\": [\"DCD87C557EC3\"], \"network\": [\"172.30.168.2\"], \"cityhash\": \"20c5485e25a2f55681fa38f3598b34a6876e60c3188e4b5174042e4e390a1808\" }\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://smart.jdbox.xyz:58001/gateway/config/set", + "protocol": "https", + "host": [ + "smart", + "jdbox", + "xyz" + ], + "port": "58001", + "path": [ + "gateway", + "config", + "set" + ] + } + }, + "response": [] + }, + { + "name": "清空网关配置的链路", + "request": { + "method": "POST", + "header": [ + { + "key": "X-Token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqZGJveCIsImV4cCI6MTc2MTAxMTc0MywianRpIjoiY2VhZmVlOTgtMzY5ZC00OTUzLThiZTYtMDVhYjA3NGNmMTZmIiwiaWF0IjoxNzYwNDA2OTQzLCJpc3MiOiJVSUQwMDAwMDAxNTEiLCJuYmYiOjE3NjA0MDY5NDMsInN1YiI6ImpkYm94LXNtYXJ0LWxpbmsifQ._LpBTBRAzOPm8Y62-jR7gCtsgSc-tMoxt06wj0rv7b8", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"macaddr\": \"00163E1A2F0E\",\r\n \"config\": {\r\n \"id\": 1002,\r\n \"rules\": [\r\n {\r\n \"table\": 1,\r\n \"enable\": false,\r\n \"edge\": [\"\", \"\", \"\", \"\"],\r\n \"network\": [],\r\n \"cityhash\": \"\"\r\n }\r\n ]\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://smart.jdbox.xyz:58001/gateway/config/set", + "protocol": "https", + "host": [ + "smart", + "jdbox", + "xyz" + ], + "port": "58001", + "path": [ + "gateway", + "config", + "set" + ] + } + }, + "response": [] + }, + { + "name": "获取网关运行状态", + "request": { + "method": "POST", + "header": [ + { + "key": "X-Token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqZGJveCIsImV4cCI6MTc2MTAxMTc0MywianRpIjoiY2VhZmVlOTgtMzY5ZC00OTUzLThiZTYtMDVhYjA3NGNmMTZmIiwiaWF0IjoxNzYwNDA2OTQzLCJpc3MiOiJVSUQwMDAwMDAxNTEiLCJuYmYiOjE3NjA0MDY5NDMsInN1YiI6ImpkYm94LXNtYXJ0LWxpbmsifQ._LpBTBRAzOPm8Y62-jR7gCtsgSc-tMoxt06wj0rv7b8", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"macaddr\": \"00163E1A2F0E\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://smart.jdbox.xyz:58001/gateway/status", + "protocol": "https", + "host": [ + "smart", + "jdbox", + "xyz" + ], + "port": "58001", + "path": [ + "gateway", + "status" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/app/.env b/app/.env new file mode 100644 index 0000000..6ae0d6f --- /dev/null +++ b/app/.env @@ -0,0 +1,17 @@ +# EIP 鉴权(来自你的 Postman 集合) +EIP_USERNAME=UID000000151 +EIP_PASSWORD=Hnz3HuWCqEX9vdzpBodY304T6CEJJJ + +# EIP 基础配置 +EIP_BASE_URL=https://smart.jdbox.xyz:58001 +EIP_GATEWAY_MAC=00163E1A2F0E + +# 轮换默认参数(如不指定,请在请求体传递 cityhash) +EIP_DEFAULT_CITYHASH= +EIP_DEFAULT_NUM=10 + +# Redis 连接 +REDIS_URL=redis://localhost:6379/0 + +# 日志级别 +LOG_LEVEL=INFO \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..9ded7e2 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +# Make app a package + diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..7449075 --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,7 @@ +import uvicorn + + +if __name__ == "__main__": + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) + + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9919b0f --- /dev/null +++ b/app/config.py @@ -0,0 +1,22 @@ +import os +from pydantic import BaseModel +from dotenv import load_dotenv + + +load_dotenv() + + +class Settings(BaseModel): + eip_base_url: str = os.getenv("EIP_BASE_URL", "https://smart.jdbox.xyz:58001") + eip_username: str = os.getenv("EIP_USERNAME", "") + eip_password: str = os.getenv("EIP_PASSWORD", "") + eip_gateway_mac: str = os.getenv("EIP_GATEWAY_MAC", "") + eip_default_cityhash: str = os.getenv("EIP_DEFAULT_CITYHASH", "") + eip_default_num: int = int(os.getenv("EIP_DEFAULT_NUM", "10")) + redis_url: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") + log_level: str = os.getenv("LOG_LEVEL", "INFO") + + +settings = Settings() + + diff --git a/app/eip_client.py b/app/eip_client.py new file mode 100644 index 0000000..6ea5a99 --- /dev/null +++ b/app/eip_client.py @@ -0,0 +1,98 @@ +import time +from typing import Any, Dict, List, Optional + +import httpx +from pydantic import BaseModel +from tenacity import retry, stop_after_attempt, wait_exponential + +from .config import settings + + +class AuthResponse(BaseModel): + token: str + exp: Optional[int] = None # epoch seconds + + +class EipClient: + def __init__(self, base_url: str, username: str, password: str) -> None: + self.base_url = base_url.rstrip("/") + self.username = username + self.password = password + self._token: Optional[str] = None + self._token_exp: Optional[int] = None + self._client = httpx.Client(timeout=15.0) + + def _is_token_valid(self) -> bool: + if not self._token or not self._token_exp: + return False + # refresh 60s earlier + return time.time() < (self._token_exp - 60) + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=2)) + def authenticate(self) -> AuthResponse: + url = f"{self.base_url}/client/auth" + resp = self._client.post(url, json={"username": self.username, "password": self.password}) + resp.raise_for_status() + data = resp.json() + # 尝试从响应中提取 exp;如果没有,设置 1 小时后过期 + exp = data.get("exp") or int(time.time()) + 3600 + token = data.get("token") or data.get("X-Token") or data.get("access_token") + if not token: + # 兼容后端直接返回字符串 token 的情况 + if isinstance(data, str) and data: + token = data + else: + raise ValueError("Auth response missing token") + self._token = token + self._token_exp = exp + return AuthResponse(token=token, exp=exp) + + def _get_headers(self) -> Dict[str, str]: + if not self._is_token_valid(): + self.authenticate() + assert self._token + return {"X-Token": self._token, "Accept": "application/json"} + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=2)) + def list_cities(self) -> List[Dict[str, Any]]: + url = f"{self.base_url}/edge/city" + resp = self._client.get(url, headers=self._get_headers()) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, list) else data.get("data", []) + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=2)) + def list_devices(self, geo: str, offset: int, num: int) -> List[Dict[str, Any]]: + url = f"{self.base_url}/edge/device" + payload = {"geo": geo, "offset": offset, "num": num} + resp = self._client.post(url, headers=self._get_headers(), json=payload) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, list) else data.get("data", []) + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=2)) + def gateway_config_get(self, macaddr: str) -> Dict[str, Any]: + url = f"{self.base_url}/gateway/config/get" + resp = self._client.post(url, headers=self._get_headers(), json={"macaddr": macaddr}) + resp.raise_for_status() + return resp.json() + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=2)) + def gateway_config_set(self, macaddr: str, config: Dict[str, Any]) -> Dict[str, Any]: + url = f"{self.base_url}/gateway/config/set" + payload = {"macaddr": macaddr, "config": config} + resp = self._client.post(url, headers=self._get_headers(), json=payload) + resp.raise_for_status() + return resp.json() + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=2)) + def gateway_status(self, macaddr: str) -> Dict[str, Any]: + url = f"{self.base_url}/gateway/status" + resp = self._client.post(url, headers=self._get_headers(), json={"macaddr": macaddr}) + resp.raise_for_status() + return resp.json() + + +client_singleton = EipClient(settings.eip_base_url, settings.eip_username, settings.eip_password) + + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..c871fba --- /dev/null +++ b/app/main.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI +from .config import settings +from .routers.proxy import router as proxy_router + +app = FastAPI(title="EIP Rotation Service") + + +@app.get("/health") +def health_check(): + return {"status": "ok"} + + +app.include_router(proxy_router, prefix="/proxy", tags=["proxy"]) + + diff --git a/app/redis_store.py b/app/redis_store.py new file mode 100644 index 0000000..fe0faae --- /dev/null +++ b/app/redis_store.py @@ -0,0 +1,49 @@ +import datetime +from typing import Iterable, Optional + +import redis + +from .config import settings + + +class RedisStore: + def __init__(self, url: str) -> None: + self._r = redis.Redis.from_url(url, decode_responses=True) + + @staticmethod + def _today_key(prefix: str) -> str: + today = datetime.datetime.utcnow().strftime("%Y%m%d") + return f"eip:{prefix}:{today}" + + def add_used_ip_today(self, ip: str) -> None: + key = self._today_key("used") + self._r.sadd(key, ip) + # 设置 2 天过期,跨时区冗余 + self._r.expire(key, 172800) + + def is_ip_used_today(self, ip: str) -> bool: + key = self._today_key("used") + return bool(self._r.sismember(key, ip)) + + def get_used_count_today(self) -> int: + key = self._today_key("used") + return int(self._r.scard(key)) + + def set_current(self, ip: str, edge_id: Optional[str]) -> None: + self._r.hset("eip:current", mapping={"ip": ip, "edge": edge_id or ""}) + + def get_current(self) -> dict: + data = self._r.hgetall("eip:current") + return data or {} + + def cache_candidates(self, items: Iterable[str]) -> None: + key = self._today_key("candidates") + if items: + self._r.delete(key) + self._r.rpush(key, *list(items)) + self._r.expire(key, 172800) + + +store_singleton = RedisStore(settings.redis_url) + + diff --git a/app/rotation_service.py b/app/rotation_service.py new file mode 100644 index 0000000..53c2cce --- /dev/null +++ b/app/rotation_service.py @@ -0,0 +1,77 @@ +from typing import Any, Dict, List, Optional, Tuple + +from .config import settings +from .eip_client import client_singleton as eip +from .redis_store import store_singleton as kv + + +def _extract_device_ip_and_id(device: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]: + # 由于未知后端返回结构,尽可能容错地提取 + ip = ( + device.get("ip") + or device.get("public_ip") + or device.get("eip") + or device.get("addr") + or device.get("address") + ) + edge_id = device.get("id") or device.get("edge") or device.get("mac") or device.get("device_id") + # 若 edge 是数组或对象,尝试取可打印的第一项 + if isinstance(edge_id, list) and edge_id: + edge_id = edge_id[0] + if isinstance(edge_id, dict): + edge_id = edge_id.get("id") or edge_id.get("mac") or edge_id.get("edge") + return (str(ip) if ip else None, str(edge_id) if edge_id else None) + + +def select_unused_ip(devices: List[Dict[str, Any]]) -> Tuple[Optional[str], Optional[str]]: + for d in devices: + ip, edge_id = _extract_device_ip_and_id(d) + if not ip: + # 没有可识别的 IP,跳过 + continue + if not kv.is_ip_used_today(ip): + return ip, edge_id + return None, None + + +def apply_gateway_route(edge_id: Optional[str], ip: str) -> Dict[str, Any]: + # 将选中的 edge 配置到网关规则中 + rule = { + "table": 1, + "enable": True, + "edge": [edge_id] if edge_id else [], + "network": [], + "cityhash": settings.eip_default_cityhash or "", + } + config = {"id": 1, "rules": [rule]} + return eip.gateway_config_set(settings.eip_gateway_mac, config) + + +def rotate(cityhash: Optional[str] = None, num: Optional[int] = None) -> Dict[str, Any]: + geo = cityhash or settings.eip_default_cityhash + if not geo: + raise ValueError("cityhash 不能为空,请在请求中或环境变量中提供") + n = int(num or settings.eip_default_num or 10) + devices = eip.list_devices(geo=geo, offset=0, num=n) + ip, edge_id = select_unused_ip(devices) + if not ip: + return {"changed": False, "reason": "没有可用且今天未使用的 IP"} + + _ = apply_gateway_route(edge_id=edge_id, ip=ip) + kv.add_used_ip_today(ip) + kv.set_current(ip=ip, edge_id=edge_id) + status = eip.gateway_status(settings.eip_gateway_mac) + return {"changed": True, "ip": ip, "edge": edge_id, "status": status} + + +def status() -> Dict[str, Any]: + cur = kv.get_current() + used_count = kv.get_used_count_today() + gw = {} + try: + gw = eip.gateway_status(settings.eip_gateway_mac) + except Exception: + pass + return {"current": cur, "used_today": used_count, "gateway": gw} + + diff --git a/app/routers/proxy.py b/app/routers/proxy.py new file mode 100644 index 0000000..f9fa391 --- /dev/null +++ b/app/routers/proxy.py @@ -0,0 +1,27 @@ +from typing import Optional + +from fastapi import APIRouter +from pydantic import BaseModel + +from ..rotation_service import rotate as rotate_impl, status as status_impl + + +router = APIRouter() + + +class RotateRequest(BaseModel): + cityhash: Optional[str] = None + num: Optional[int] = None + + +@router.post("/rotate") +def rotate(req: RotateRequest): + result = rotate_impl(cityhash=req.cityhash, num=req.num) + return result + + +@router.get("/status") +def get_status(): + return status_impl() + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6b873b8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +pydantic==2.9.2 +python-dotenv==1.0.1 +redis==5.0.8 +tenacity==9.0.0 +