Initial commit with project setup and basic structure.

This commit is contained in:
wangqifan 2025-10-17 17:17:14 +08:00
parent 2f6c0eaa38
commit 93f05f13c1
13 changed files with 902 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
app/__pycache__/__init__.*.pyc
app/__pycache__/eip_client.*.pyc
app/__pycache__/*.pyc
app/routers/__pycache__/*.pyc

185
API.md Normal file
View File

@ -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": "<cityhash>",
"offset": 0,
"num": 200
}
```
- **参数说明**:
- `geo`: 城市哈希cityhash
- `offset`: 起始偏移
- `num`: 返回数量上限
---
### 代理链路节点 - 指定城市、数量查询
- **名称**: 查询指定城市、指定数量代理信息
- **方法**: POST
- **路径**: `/edge/device`
- **请求头**:
- `X-Token: <你的Token>`
- `Content-Type: application/json`
- **请求体**:
```json
{
"geo": "<cityhash>",
"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": "<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具体字段以实际接口返回为准。

391
EIP.postman_collection.json Normal file
View File

@ -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": []
}
]
}

17
app/.env Normal file
View File

@ -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

2
app/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# Make app a package

7
app/__main__.py Normal file
View File

@ -0,0 +1,7 @@
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

22
app/config.py Normal file
View File

@ -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()

98
app/eip_client.py Normal file
View File

@ -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)

15
app/main.py Normal file
View File

@ -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"])

49
app/redis_store.py Normal file
View File

@ -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)

77
app/rotation_service.py Normal file
View File

@ -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}

27
app/routers/proxy.py Normal file
View File

@ -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()

8
requirements.txt Normal file
View File

@ -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