Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,10 @@
|
|||||||
|
.git/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
**/.DS_Store
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.cursor/
|
||||||
|
agent-transcripts/
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Gitea Actions CI 模板(Go + Vue)
|
||||||
|
|
||||||
|
复制整个 `.gitea/` 目录到新仓库即可启用 CI:**可选 go test** + **Docker 构建/推送**(Go 编译与 Vue 构建在 Dockerfile 内完成)。
|
||||||
|
|
||||||
|
## 项目约定
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `go.mod` | 仓库根目录 |
|
||||||
|
| `Dockerfile` | 仓库根目录,多阶段构建(含前端 `web/`) |
|
||||||
|
| `web/` | 可选;由 Dockerfile 内 `npm run build` 处理,CI 不再单独构建 |
|
||||||
|
|
||||||
|
## Dockerfile 要求
|
||||||
|
|
||||||
|
工作流的 **Build image** 步骤在仓库根目录(或 Variable `DOCKERFILE` 指定路径)执行 `docker buildx build`,**不会**在 CI 里单独装 Node / 跑 `npm`。因此 Dockerfile 必须能独立完成构建与(运行时)启动。
|
||||||
|
|
||||||
|
### 基本要求
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|----|------|
|
||||||
|
| 位置 | 默认仓库根目录 `Dockerfile`;其他路径用 Variable `DOCKERFILE` |
|
||||||
|
| 构建上下文 | 仓库根目录 `.`(整个仓库会被 `COPY` 进镜像) |
|
||||||
|
| Go 版本 | `go.mod` 里 `go` 行与 `golang` 基础镜像大版本一致 |
|
||||||
|
| 多架构 | `go build` 须使用 `GOARCH="$TARGETARCH"`(buildx 注入);推荐 `CGO_ENABLED=0` |
|
||||||
|
| Go 模块代理 | **必须**在镜像内显式设置 `GOPROXY` / `GOSUMDB`(见下);容器内不会自动读取 `go.env` |
|
||||||
|
| 基础镜像加速 | 支持构建参数 `IMAGE_PREFIX`(CI 默认 `docker.1panel.live/library/`) |
|
||||||
|
| 前端(可选) | 存在 `web/` 时在 Dockerfile 内完成 `npm ci` + `npm run build` |
|
||||||
|
|
||||||
|
### CI 自动传入的构建参数
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `IMAGE_PREFIX` | `docker.1panel.live/library/` | 基础镜像前缀;`hub` 表示 Docker Hub |
|
||||||
|
| `GOPROXY` | `https://goproxy.cn,direct` | 与 workflow / Variable 一致 |
|
||||||
|
| `GOSUMDB` | `sum.golang.google.cn` | checksum 数据库 |
|
||||||
|
|
||||||
|
buildx 还会注入 `TARGETARCH`、`BUILDPLATFORM` 等,无需在 workflow 里写。
|
||||||
|
|
||||||
|
### 推荐:Go + Vue 多阶段模板
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
ARG IMAGE_PREFIX=
|
||||||
|
|
||||||
|
# 1) 前端(无 web/ 时可删整个 stage,并去掉 go-builder 里 COPY dist)
|
||||||
|
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder
|
||||||
|
WORKDIR /src/web
|
||||||
|
COPY web/package.json web/package-lock.json web/.npmrc ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 2) Go 编译
|
||||||
|
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG GOPROXY=https://goproxy.cn,direct
|
||||||
|
ARG GOSUMDB=sum.golang.google.cn
|
||||||
|
ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||||
|
|
||||||
|
COPY cmd/ ./cmd/
|
||||||
|
COPY internal/ ./internal/
|
||||||
|
# 若静态资源嵌入 Go:COPY --from=web-builder /src/web/dist/ ./path/to/static/
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" \
|
||||||
|
go build -ldflags="-w -s" -o /app ./cmd/yourapp
|
||||||
|
|
||||||
|
# 3) 运行镜像
|
||||||
|
FROM ${IMAGE_PREFIX}alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
COPY --from=go-builder /app /usr/local/bin/yourapp
|
||||||
|
ENTRYPOINT ["/usr/local/bin/yourapp"]
|
||||||
|
```
|
||||||
|
|
||||||
|
按项目调整:`./cmd/yourapp`、静态资源路径、运行用户、`EXPOSE` / `VOLUME` 等。
|
||||||
|
|
||||||
|
### 仅 Go(无前端)
|
||||||
|
|
||||||
|
删除 `web-builder` stage;`go-builder` 中不要 `COPY` 前端产物;其余 `GOPROXY` / `TARGETARCH` / `IMAGE_PREFIX` 要求相同。
|
||||||
|
|
||||||
|
### 前端 npm 源(国内)
|
||||||
|
|
||||||
|
在 `web/.npmrc` 配置 registry,并在 Dockerfile 里与 `package.json` 一并 `COPY`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地验证(与 CI 一致)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
--build-arg IMAGE_PREFIX=docker.1panel.live/library/ \
|
||||||
|
--build-arg GOPROXY=https://goproxy.cn,direct \
|
||||||
|
-f Dockerfile -t myapp:test .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见 Dockerfile 构建错误
|
||||||
|
|
||||||
|
| 报错 | 原因 | 处理 |
|
||||||
|
|------|------|------|
|
||||||
|
| `proxy.golang.org` timeout | 镜像内未设 `ENV GOPROXY` | go-builder 阶段加 `ARG`/`ENV GOPROXY` |
|
||||||
|
| `node` / Vite 版本不符 | 基础镜像 Node 过旧 | 使用 `node:20-alpine` 及以上 |
|
||||||
|
| 某架构 build 失败 | 未使用 `TARGETARCH` | `GOARCH="$TARGETARCH"` |
|
||||||
|
| 拉基础镜像 timeout | Hub 不可达 | `--build-arg IMAGE_PREFIX=docker.1panel.live/library/` 或 1Panel 配镜像加速 |
|
||||||
|
|
||||||
|
## 一次性配置
|
||||||
|
|
||||||
|
1. **Runner**:部署 act_runner,标签含 `ubuntu-latest`,并挂载 `docker.sock`(见 [act-runner/README.md](act-runner/README.md))。
|
||||||
|
2. **Secret**:仓库 Settings → Actions → Secrets,添加 `REGISTRY_TOKEN`(PAT,`write:package` 权限)。
|
||||||
|
3. **Variable(推荐)**:若 runner 与 Gitea 同机、或 `gitea.server_url` 为内网地址,必须设置 `REGISTRY=你的公网域名`(如 `git.example.com`,**不要**填 `172.17.0.1:13827`)。
|
||||||
|
4. **Gitea Registry**:服务端 `[packages] ENABLED = true`,`ROOT_URL` 正确;穿透场景建议 `PUBLIC_URL_DETECTION = never`(Gitea 1.26+)。
|
||||||
|
|
||||||
|
`REGISTRY` 未设置时,会从 `GITEA_ROOT_URL` 或 `gitea.server_url` 推断;均为内网地址时 workflow 会提前失败并提示。
|
||||||
|
|
||||||
|
## 可选 Variables
|
||||||
|
|
||||||
|
仓库 Settings → Actions → Variables(留空则用默认值):
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `REGISTRY` | 见下方推断顺序 | **公网** Registry 主机名,如 `git.example.com`(勿用内网 IP:13827) |
|
||||||
|
| `GITEA_ROOT_URL` | (空) | 当 `gitea.server_url` 为内网时,可设 `https://git.example.com/` |
|
||||||
|
| `IMAGE_NAME` | 仓库名小写 | 镜像名,非 owner/repo 全路径 |
|
||||||
|
| `DOCKERFILE` | `Dockerfile` | Dockerfile 路径 |
|
||||||
|
| `DOCKER_PLATFORMS` | `linux/amd64,linux/arm64` | push 时 buildx 平台 |
|
||||||
|
| `GO_TEST_SCOPE` | `./...` | `go test` 包路径 |
|
||||||
|
| `RUN_GO_TEST` | (空,即运行) | 设 `false` 跳过 go test,仅 Docker 构建 |
|
||||||
|
| `DOCKER_IMAGE_PREFIX` | `1panel` | 基础镜像前缀;可选 `hub` / `daocloud` |
|
||||||
|
| `GOPROXY` | `https://goproxy.cn,direct` | Go 模块代理 |
|
||||||
|
| `GOSUMDB` | `sum.golang.google.cn` | Go checksum 数据库 |
|
||||||
|
|
||||||
|
## 触发与镜像 tag
|
||||||
|
|
||||||
|
- **pull_request**:go test(可关)+ 单架构 `docker build` 验证(不推送)
|
||||||
|
- **push main/master**:go test + 多架构构建并推送 `:latest`、`:sha-xxxxxxx`
|
||||||
|
- **push tag v\***:额外推送 `:v1.2.3` 等
|
||||||
|
|
||||||
|
示例(仓库 `rose_cat707/Prism`,Gitea 在 `git.example.com`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
git.example.com/rose_cat707/prism:latest
|
||||||
|
git.example.com/rose_cat707/prism:sha-35b3b48
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.gitea/
|
||||||
|
├── README.md # 本文件
|
||||||
|
├── workflows/
|
||||||
|
│ └── ci.yml # 主工作流
|
||||||
|
└── act-runner/ # runner 部署参考(可选)
|
||||||
|
├── README.md
|
||||||
|
├── config.yaml
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地验证 Registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
host=$(echo "https://你的-gitea-地址/" | sed -e 's|^https://||' -e 's|/.*||')
|
||||||
|
curl -s -D - "https://${host}/v2/" -o /dev/null | grep -i www-authenticate
|
||||||
|
docker pull "${host}/owner/image:latest" # 公开 Registry 无需 login
|
||||||
|
```
|
||||||
|
|
||||||
|
推送镜像(CI)仍需仓库 Secret `REGISTRY_TOKEN`(`write:package`)。仅拉取公开包不需要登录。
|
||||||
|
|
||||||
|
`realm` 应指向公网 Gitea 域名,而非 `127.0.0.1`。
|
||||||
|
|
||||||
|
## 常见 Registry 错误
|
||||||
|
|
||||||
|
| 报错 | 原因 | 处理 |
|
||||||
|
|------|------|------|
|
||||||
|
| `Get "https://172.17.0.1:13827/v2/"` HTTP/HTTPS | Variable `REGISTRY` 或 `gitea.server_url` 为内网地址 | 设 `REGISTRY=git.example.com` |
|
||||||
|
| token 指向 `127.0.0.1` | Gitea `realm` 配置错误 | `PUBLIC_URL_DETECTION=never` + 正确 `ROOT_URL` |
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
GITEA_INSTANCE_URL=https://git.example.com
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN=从仓库_Settings_Actions_Runners_复制
|
||||||
|
GITEA_RUNNER_NAME=go-vue-ci-runner
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# act_runner 配置参考
|
||||||
|
|
||||||
|
工作流 `runs-on: ubuntu-latest`;`docker` job 额外使用 `catthehacker/ubuntu:act-22.04` 容器(含 Docker CLI)。
|
||||||
|
|
||||||
|
## 构建镜像必须:挂载 Docker Socket
|
||||||
|
|
||||||
|
`docker` job 需要访问宿主机 Docker 引擎。在 **runner 所在机器** 的 `config.yaml` 中配置:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
container:
|
||||||
|
options: -v /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
valid_volumes:
|
||||||
|
- /var/run/docker.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐 job 镜像(含 Node + Docker CLI):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runner:
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
|
||||||
|
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
|
||||||
|
```
|
||||||
|
|
||||||
|
修改后 **重启 runner**(例如 `docker restart <runner容器>` 或重启 Gitea Runner 服务)。
|
||||||
|
|
||||||
|
## 1Panel 宿主机:配置 Docker 镜像加速(推荐)
|
||||||
|
|
||||||
|
Runner 通过 `docker.sock` 使用宿主机 Docker。在 **1Panel** 中配置加速器后,普通 `docker pull` 会走加速;**buildx 多架构构建**仍建议配合 workflow 内的 `IMAGE_PREFIX`(默认 `docker.1panel.live`)。
|
||||||
|
|
||||||
|
1. 登录 1Panel → **容器** → **配置**
|
||||||
|
2. **镜像加速地址** 填入:
|
||||||
|
```text
|
||||||
|
https://docker.1panel.live
|
||||||
|
```
|
||||||
|
3. 保存并 **重启 Docker**
|
||||||
|
|
||||||
|
验证(在 runner 宿主机):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker info | grep -A5 'Registry Mirrors'
|
||||||
|
docker pull alpine:3.20
|
||||||
|
```
|
||||||
|
|
||||||
|
等价 `daemon.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry-mirrors": ["https://docker.1panel.live"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CI 工作流默认 `IMAGE_PREFIX=docker.1panel.live/library/`(buildkit 拉基础镜像)。若 1Panel 加速不可用,可在仓库 Variables 设 `DOCKER_IMAGE_PREFIX=daocloud` 或 `hub`。
|
||||||
|
|
||||||
|
参考:[1Panel 容器配置文档](https://1panel.cn/docs/user_manual/containers/setting)
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
在 runner 宿主机执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker info
|
||||||
|
ls -l /var/run/docker.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
## 从零部署 runner(可选)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd .gitea/act-runner
|
||||||
|
cp .env.example .env # 填入 Registration Token
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见错误
|
||||||
|
|
||||||
|
| 报错 | 原因 | 处理 |
|
||||||
|
|------|------|------|
|
||||||
|
| `docker: command not found` | job 容器无 Docker CLI | 工作流已指定 act 镜像;或 runner 改用 catthehacker/ubuntu |
|
||||||
|
| `Cannot connect to Docker daemon` | 未挂载 docker.sock | 按上文修改 config.yaml 并重启 runner |
|
||||||
|
| `node not in PATH` | job 镜像无 Node | 标签映射改用 catthehacker/ubuntu:act-22.04 |
|
||||||
|
| `registry-1.docker.io` i/o timeout | Docker Hub 不可达 | 1Panel 配 `docker.1panel.live`;Variable `DOCKER_IMAGE_PREFIX=1panel` |
|
||||||
|
| `http: server gave HTTP response to HTTPS client` 且 token 指向 `127.0.0.1` | **Gitea Registry 配置/反代错误** | 见下文「Registry 登录失败」 |
|
||||||
|
|
||||||
|
## Registry 登录失败(127.0.0.1 / HTTP vs HTTPS)
|
||||||
|
|
||||||
|
若 `docker login git.rc707blog.top` 报错类似:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Get "https://127.0.0.1:xxxxx/v2/token?...": http: server gave HTTP response to HTTPS client
|
||||||
|
```
|
||||||
|
|
||||||
|
说明 Gitea 把 **Docker 认证 token 地址** 配成了本机内网地址,CI runner 访问不到。需在 **Gitea 服务器** 修复,而非改 workflow。
|
||||||
|
|
||||||
|
### 1. 检查 `app.ini`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[server]
|
||||||
|
ROOT_URL = https://git.example.com/
|
||||||
|
LOCAL_ROOT_URL = http://127.0.0.1:3000/
|
||||||
|
; 内网穿透 / 错误 Host 时(Gitea 1.26+):
|
||||||
|
; PUBLIC_URL_DETECTION = never
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
ENABLED = true
|
||||||
|
```
|
||||||
|
|
||||||
|
`ROOT_URL` 必须与浏览器访问 Gitea 的 **HTTPS 外网地址** 完全一致(含末尾 `/`)。
|
||||||
|
|
||||||
|
### 2. 反向代理必须转发 `/v2` 并带上头
|
||||||
|
|
||||||
|
Container Registry 固定使用根路径 `/v2`。Nginx 示例:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
client_max_body_size 0;
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
关键:`X-Forwarded-Proto: https` 和正确的 `Host`(与 Gitea `ROOT_URL` 域名一致)。
|
||||||
|
|
||||||
|
### 3. 在 runner 宿主机验证(修复后)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GITEA_HOST=git.example.com # 改成你的 Gitea 域名
|
||||||
|
curl -s -D - "https://${GITEA_HOST}/v2/" -o /dev/null | grep -i www-authenticate
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login "${GITEA_HOST}" -u 你的用户名 --password-stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
应返回 `401 Unauthorized`(正常,表示 registry 可达)且 `docker login` 显示 **Login Succeeded**。
|
||||||
|
|
||||||
|
参考:[Gitea 反向代理文档](https://docs.gitea.com/administration/reverse-proxies)
|
||||||
|
|
||||||
|
## Gitea Runner v0.6.x(个人 runner)
|
||||||
|
|
||||||
|
1. 找到 runner 的配置文件或环境(安装目录 / docker compose)
|
||||||
|
2. 确保 runner 进程能访问宿主机 `/var/run/docker.sock`
|
||||||
|
3. Runners 页标签含 `ubuntu-latest` 且状态 **空闲/在线**
|
||||||
|
|
||||||
|
若使用 Gitea 网页注册的个人 runner(docker 模式),通常需在 runner 启动参数或 `config.yaml` 里加入 socket 挂载,具体路径取决于你的安装方式。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# act_runner 配置(可选参考部署)
|
||||||
|
# 工作流 runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
file: .runner
|
||||||
|
capacity: 2
|
||||||
|
timeout: 3h
|
||||||
|
insecure: false
|
||||||
|
fetch_timeout: 5s
|
||||||
|
fetch_interval: 2s
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
|
||||||
|
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
container:
|
||||||
|
network: bridge
|
||||||
|
privileged: false
|
||||||
|
options: -v /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
valid_volumes:
|
||||||
|
- /var/run/docker.sock
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Gitea act_runner — 可选参考部署(标签 default,与工作流一致)
|
||||||
|
#
|
||||||
|
# 若已在 Gitea 注册个人/仓库 runner 且标签为 default,无需使用本目录。
|
||||||
|
|
||||||
|
services:
|
||||||
|
act-runner:
|
||||||
|
image: docker.io/gitea/act_runner:0.2.12
|
||||||
|
container_name: prism-act-runner
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CONFIG_FILE: /config.yaml
|
||||||
|
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.rc707blog.top}
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
|
||||||
|
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-prism-ci-runner}
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/config.yaml:ro
|
||||||
|
- act-runner-data:/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
working_dir: /data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
act-runner-data:
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# 通用 Go + Vue 项目 CI(Gitea Actions)
|
||||||
|
#
|
||||||
|
# 单 job:可选 go test + Docker 多架构构建/推送
|
||||||
|
# 约定:go.mod、Dockerfile(详见 .gitea/README.md「Dockerfile 要求」);前端(可选)在 web/
|
||||||
|
#
|
||||||
|
# 前置:act_runner(ubuntu-latest + docker.sock)、Secret REGISTRY_TOKEN
|
||||||
|
# Variables:REGISTRY、IMAGE_NAME、DOCKER_IMAGE_PREFIX、RUN_GO_TEST 等
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
GITEA_ROOT_URL: ${{ vars.GITEA_ROOT_URL }}
|
||||||
|
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
|
||||||
|
DOCKERFILE: ${{ vars.DOCKERFILE }}
|
||||||
|
DOCKER_PLATFORMS: ${{ vars.DOCKER_PLATFORMS }}
|
||||||
|
GO_TEST_SCOPE: ${{ vars.GO_TEST_SCOPE }}
|
||||||
|
RUN_GO_TEST: ${{ vars.RUN_GO_TEST }}
|
||||||
|
DOCKER_IMAGE_PREFIX: ${{ vars.DOCKER_IMAGE_PREFIX }}
|
||||||
|
GOPROXY: ${{ vars.GOPROXY }}
|
||||||
|
GOSUMDB: ${{ vars.GOSUMDB }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Go tests
|
||||||
|
if: vars.RUN_GO_TEST != 'false'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||||||
|
export GOSUMDB="${GOSUMDB:-sum.golang.google.cn}"
|
||||||
|
|
||||||
|
GO_VERSION=$(grep '^go ' go.mod | awk '{print $2}')
|
||||||
|
ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
|
||||||
|
TAR="go${GO_VERSION}.linux-${ARCH}.tar.gz"
|
||||||
|
|
||||||
|
if ! command -v go >/dev/null 2>&1 || ! go version | grep -q "go${GO_VERSION} "; then
|
||||||
|
downloaded=0
|
||||||
|
for url in \
|
||||||
|
"https://mirrors.aliyun.com/golang/${TAR}" \
|
||||||
|
"https://golang.google.cn/dl/${TAR}" \
|
||||||
|
"https://go.dev/dl/${TAR}"; do
|
||||||
|
echo "trying ${url}"
|
||||||
|
if curl -fsSL --connect-timeout 20 --retry 3 --retry-delay 5 --max-time 600 \
|
||||||
|
"$url" -o /tmp/go.tgz; then
|
||||||
|
downloaded=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$downloaded" -eq 1 ] || { echo "failed to download Go ${GO_VERSION}" >&2; exit 1; }
|
||||||
|
rm -rf /usr/local/go
|
||||||
|
tar -C /usr/local -xzf /tmp/go.tgz
|
||||||
|
export PATH="/usr/local/go/bin:${PATH}"
|
||||||
|
fi
|
||||||
|
go version
|
||||||
|
go test "${GO_TEST_SCOPE:-./...}"
|
||||||
|
|
||||||
|
- name: Setup Docker
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io
|
||||||
|
fi
|
||||||
|
if [ ! -S /var/run/docker.sock ]; then
|
||||||
|
echo "ERROR: /var/run/docker.sock 未挂载到 job 容器。" >&2
|
||||||
|
echo "请在 act_runner config.yaml 中配置:" >&2
|
||||||
|
echo " container.options: -v /var/run/docker.sock:/var/run/docker.sock" >&2
|
||||||
|
echo " container.valid_volumes: [/var/run/docker.sock]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker info
|
||||||
|
|
||||||
|
- name: Resolve registry
|
||||||
|
id: reg
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
url_to_host() {
|
||||||
|
echo "$1" | sed -e 's|^https://||' -e 's|^http://||' -e 's|/.*||'
|
||||||
|
}
|
||||||
|
|
||||||
|
is_internal_host() {
|
||||||
|
local host="${1%%:*}"
|
||||||
|
case "$host" in
|
||||||
|
localhost|127.*|10.*|192.168.*) return 0 ;;
|
||||||
|
172.*)
|
||||||
|
local second
|
||||||
|
second=$(echo "$host" | cut -d. -f2)
|
||||||
|
[ "$second" -ge 16 ] && [ "$second" -le 31 ]
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
registry="${REGISTRY:-}"
|
||||||
|
if [ -z "$registry" ] && [ -n "${GITEA_ROOT_URL:-}" ]; then
|
||||||
|
registry=$(url_to_host "${GITEA_ROOT_URL}")
|
||||||
|
fi
|
||||||
|
if [ -z "$registry" ]; then
|
||||||
|
registry=$(url_to_host "${GITEA_SERVER_URL}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_internal_host "$registry"; then
|
||||||
|
echo "ERROR: Registry 主机 '${registry}' 是内网地址。" >&2
|
||||||
|
echo "请设置 Variable REGISTRY=公网 Gitea 域名(如 git.example.com)。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "registry host: ${registry}"
|
||||||
|
|
||||||
|
auth_header=""
|
||||||
|
if auth_header=$(curl -fsS -D - "https://${registry}/v2/" -o /dev/null 2>&1 | grep -i '^www-authenticate:'); then
|
||||||
|
if echo "$auth_header" | grep -qiE '127\.0\.0\.1|172\.(1[6-9]|2[0-9]|3[01])\.|localhost|:13827'; then
|
||||||
|
echo "ERROR: Registry token realm 指向内网地址:${auth_header}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "host=${registry}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | \
|
||||||
|
docker login "${{ steps.reg.outputs.host }}" -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
|
GITEA_REF: ${{ gitea.ref }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
|
||||||
|
GITEA_EVENT_NAME: ${{ gitea.event_name }}
|
||||||
|
REGISTRY_HOST: ${{ steps.reg.outputs.host }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
dockerfile="${DOCKERFILE:-Dockerfile}"
|
||||||
|
|
||||||
|
if [ "${DOCKER_IMAGE_PREFIX:-}" = "hub" ] || [ "${DOCKER_IMAGE_PREFIX:-}" = "docker.io" ]; then
|
||||||
|
image_prefix=""
|
||||||
|
elif [ -z "${DOCKER_IMAGE_PREFIX:-}" ] || [ "${DOCKER_IMAGE_PREFIX}" = "1panel" ]; then
|
||||||
|
image_prefix="docker.1panel.live/library/"
|
||||||
|
elif [ "${DOCKER_IMAGE_PREFIX}" = "daocloud" ]; then
|
||||||
|
image_prefix="docker.m.daocloud.io/library/"
|
||||||
|
else
|
||||||
|
image_prefix="${DOCKER_IMAGE_PREFIX}"
|
||||||
|
[[ "${image_prefix}" == */ ]] || image_prefix="${image_prefix}/"
|
||||||
|
fi
|
||||||
|
echo "using IMAGE_PREFIX=${image_prefix:-<docker.io>}"
|
||||||
|
build_args=(
|
||||||
|
--build-arg "IMAGE_PREFIX=${image_prefix}"
|
||||||
|
--build-arg "GOPROXY=${GOPROXY:-https://goproxy.cn,direct}"
|
||||||
|
--build-arg "GOSUMDB=${GOSUMDB:-sum.golang.google.cn}"
|
||||||
|
)
|
||||||
|
|
||||||
|
builder_id="gitea-buildx-${GITEA_REPOSITORY//\//-}"
|
||||||
|
|
||||||
|
if [ "${GITEA_EVENT_NAME}" = "push" ]; then
|
||||||
|
install_binfmt() {
|
||||||
|
for img in \
|
||||||
|
"docker.1panel.live/tonistiigi/binfmt:latest" \
|
||||||
|
"docker.m.daocloud.io/tonistiigi/binfmt:latest" \
|
||||||
|
"tonistiigi/binfmt:latest"; do
|
||||||
|
echo "trying binfmt image: ${img}"
|
||||||
|
if docker run --privileged --rm "${img}" --install all; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
install_binfmt || true
|
||||||
|
|
||||||
|
registry="${REGISTRY_HOST}"
|
||||||
|
image_name="${IMAGE_NAME:-$(echo "${GITEA_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')}"
|
||||||
|
platforms="${DOCKER_PLATFORMS:-linux/amd64,linux/arm64}"
|
||||||
|
image="${registry}/${GITEA_REPOSITORY_OWNER}/${image_name}"
|
||||||
|
tags="${image}:sha-${GITEA_SHA:0:7}"
|
||||||
|
case "${GITEA_REF}" in
|
||||||
|
refs/heads/main|refs/heads/master)
|
||||||
|
tags="${tags},${image}:latest"
|
||||||
|
;;
|
||||||
|
refs/tags/v*)
|
||||||
|
tags="${tags},${image}:${GITEA_REF#refs/tags/}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
|
tag_args=()
|
||||||
|
IFS=',' read -r -a tag_list <<< "$tags"
|
||||||
|
for t in "${tag_list[@]}"; do
|
||||||
|
tag_args+=(-t "$t")
|
||||||
|
done
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--platform "${platforms}" \
|
||||||
|
-f "${dockerfile}" \
|
||||||
|
"${build_args[@]}" \
|
||||||
|
"${tag_args[@]}" \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
else
|
||||||
|
# PR:仅单架构构建验证 Dockerfile,不推送
|
||||||
|
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
-f "${dockerfile}" \
|
||||||
|
"${build_args[@]}" \
|
||||||
|
--load \
|
||||||
|
.
|
||||||
|
fi
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# --- Go ---
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
*.coverprofile
|
||||||
|
coverage.html
|
||||||
|
coverage.out
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
dist/
|
||||||
|
vendor/
|
||||||
|
/prism
|
||||||
|
|
||||||
|
# --- Runtime data (docker-compose ./data, local -data) ---
|
||||||
|
data/
|
||||||
|
|
||||||
|
# --- Secrets ---
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# --- Frontend (see also web/.gitignore) ---
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|
||||||
|
# --- IDE & OS ---
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.cursor/
|
||||||
|
|
||||||
|
# --- Gitea act-runner (local deploy) ---
|
||||||
|
.gitea/act-runner/.env
|
||||||
|
.gitea/act-runner/.runner
|
||||||
|
|
||||||
|
# --- Logs ---
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 贡献指南
|
||||||
|
|
||||||
|
感谢关注 Prism!本仓库托管于 [git.rc707blog.top](https://git.rc707blog.top/rose_cat707/Prism)。
|
||||||
|
|
||||||
|
## 开发环境
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
- Go 1.25+(见 `go.mod`)
|
||||||
|
- Node.js 20+(仅前端开发/构建)
|
||||||
|
|
||||||
|
国内网络可使用仓库内已配置的镜像:
|
||||||
|
|
||||||
|
- Go:`go.env` → `GOPROXY=https://goproxy.cn,direct`
|
||||||
|
- npm:`web/.npmrc` → `registry.npmmirror.com`
|
||||||
|
|
||||||
|
海外用户可将 `GOPROXY` 改为 `https://proxy.golang.org,direct`,npm 使用默认 registry 即可。
|
||||||
|
|
||||||
|
### 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 终端 1:后端
|
||||||
|
make run
|
||||||
|
|
||||||
|
# 终端 2:前端(Vite 代理 /api → :8080)
|
||||||
|
make dev-web
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 http://localhost:5173 ,默认密码 `admin`。
|
||||||
|
|
||||||
|
### 测试与构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # 运行 Go 单元测试
|
||||||
|
make build # 构建含前端的单二进制 dist/prism
|
||||||
|
```
|
||||||
|
|
||||||
|
## 提交规范
|
||||||
|
|
||||||
|
- 一个 PR 聚焦一个主题,避免混合无关重构
|
||||||
|
- 涉及 API 变更时同步更新 `internal/api/openapi/spec.yaml`
|
||||||
|
- 涉及 UI 变更请参考 `UI-DESIGN-SYSTEM.md`
|
||||||
|
- 确保 `go test ./...` 通过
|
||||||
|
|
||||||
|
## CI / 镜像发布
|
||||||
|
|
||||||
|
仓库使用 **Gitea Actions**(`.gitea/workflows/ci.yml`):
|
||||||
|
|
||||||
|
| 触发 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| Pull Request | 仅运行 `go test ./...` |
|
||||||
|
| 推送到 `main` / `master` | 测试 + 构建并推送 `latest` 与 `sha-xxxxxxx` 镜像 |
|
||||||
|
| 推送 tag `v*` | 测试 + 额外推送版本 tag 镜像 |
|
||||||
|
|
||||||
|
镜像发布到 Gitea Container Registry:
|
||||||
|
|
||||||
|
```
|
||||||
|
git.rc707blog.top/{owner}/prism:{tag}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 维护者:配置 CI Secret
|
||||||
|
|
||||||
|
在仓库 **Settings → Secrets → Actions** 添加:
|
||||||
|
|
||||||
|
| Secret | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `REGISTRY_TOKEN` | 具有 `write:package` 权限的 Personal Access Token |
|
||||||
|
|
||||||
|
> **注意:** Secret 名称不能以 `GITEA_` 或 `GITHUB_` 开头(Gitea 保留前缀),因此使用 `REGISTRY_TOKEN` 而非 `GITEA_TOKEN`。
|
||||||
|
|
||||||
|
> Gitea Actions 的 job token 暂不支持直接推送软件包,需使用 PAT。见 [Gitea 文档](https://docs.gitea.com/usage/actions/comparison#package-repository-authorization)。
|
||||||
|
|
||||||
|
### Runner 配置
|
||||||
|
|
||||||
|
CI 使用 **`runs-on: ubuntu-latest`**(须与 Runners 页「**标签**」列一致)。
|
||||||
|
|
||||||
|
1. **Settings → Actions → Runners** 确认 runner **在线**,标签含 `ubuntu-latest`
|
||||||
|
2. 标签映射的 job 镜像须含 **Node.js**(否则 `actions/checkout` 会报 `node not in PATH`)
|
||||||
|
3. **构建镜像**:runner 须挂载 `/var/run/docker.sock`(见 [.gitea/act-runner/README.md](.gitea/act-runner/README.md))
|
||||||
|
4. CI 使用 shell 安装 Go(阿里云 / golang.google.cn 镜像),避免 `setup-go` 从 GitHub 下载超时
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/prism/ 进程入口
|
||||||
|
internal/api/ HTTP 层、OpenAPI、嵌入静态资源
|
||||||
|
internal/store/ SQLite 与迁移
|
||||||
|
internal/service/ 业务编排
|
||||||
|
internal/configbuilder/ Mihomo YAML 合成
|
||||||
|
web/ Vue 3 管理台
|
||||||
|
.gitea/workflows/ CI 工作流
|
||||||
|
```
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
提交代码即表示你同意在 [MIT License](LICENSE) 下授权你的贡献。嵌入的 Mihomo 内核遵循其自身许可,见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Prism 代理网关(Go 控制面 + 嵌入 Mihomo + Vue 管理台)
|
||||||
|
#
|
||||||
|
# 构建:
|
||||||
|
# docker build -t prism:latest .
|
||||||
|
#
|
||||||
|
# 运行:
|
||||||
|
# docker run -d --name prism \
|
||||||
|
# -p 8080:8080 -p 7890:7890 -p 7891:7891 \
|
||||||
|
# -v prism-data:/app/data \
|
||||||
|
# prism:latest
|
||||||
|
#
|
||||||
|
# 生产镜像由 Gitea Actions 构建,见 README「部署」
|
||||||
|
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# 基础镜像默认 Docker Hub;CI 默认 1Panel 加速(docker.1panel.live),本地可传:
|
||||||
|
# docker build --build-arg IMAGE_PREFIX=docker.1panel.live/library/ -t prism:latest .
|
||||||
|
ARG IMAGE_PREFIX=
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder
|
||||||
|
|
||||||
|
WORKDIR /src/web
|
||||||
|
COPY web/package.json web/package-lock.json web/.npmrc ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG GOPROXY=https://goproxy.cn,direct
|
||||||
|
ARG GOSUMDB=sum.golang.google.cn
|
||||||
|
ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum go.env ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
COPY cmd/ ./cmd/
|
||||||
|
COPY internal/ ./internal/
|
||||||
|
|
||||||
|
RUN rm -rf internal/api/static/* && mkdir -p internal/api/static
|
||||||
|
COPY --from=web-builder /src/web/dist/ ./internal/api/static/
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" \
|
||||||
|
go build -ldflags="-w -s" -o /prism ./cmd/prism
|
||||||
|
|
||||||
|
FROM ${IMAGE_PREFIX}alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata \
|
||||||
|
&& addgroup -S prism \
|
||||||
|
&& adduser -S prism -G prism
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=go-builder /prism /usr/local/bin/prism
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data/geo \
|
||||||
|
&& chown -R prism:prism /app
|
||||||
|
|
||||||
|
USER prism
|
||||||
|
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
EXPOSE 8080 7890 7891
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/prism"]
|
||||||
|
CMD ["-data", "/app/data"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Prism Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embedded components
|
||||||
|
|
||||||
|
This project statically links [Mihomo](https://github.com/MetaCubeX/mihomo)
|
||||||
|
(github.com/metacubex/mihomo) as the proxy data plane. Mihomo is dual-licensed
|
||||||
|
under **GPL-3.0 OR MIT** (recipient's choice). See the Mihomo repository and
|
||||||
|
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) for details.
|
||||||
|
|
||||||
|
Prism control-plane code (`cmd/`, `internal/`, `web/`) is MIT-licensed as above.
|
||||||
|
When you distribute Prism binaries or container images, you must comply with
|
||||||
|
the license terms of Mihomo and all bundled dependencies.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.PHONY: dev dev-web build build-web run test clean
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@echo "Run backend and frontend in separate terminals:"
|
||||||
|
@echo " make run"
|
||||||
|
@echo " make dev-web"
|
||||||
|
|
||||||
|
dev-web:
|
||||||
|
cd web && npm run dev
|
||||||
|
|
||||||
|
build-web:
|
||||||
|
cd web && npm run build
|
||||||
|
rm -rf internal/api/static/*
|
||||||
|
cp -r web/dist/* internal/api/static/
|
||||||
|
|
||||||
|
build: build-web
|
||||||
|
go build -o dist/prism ./cmd/prism
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run ./cmd/prism
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf dist web/dist internal/api/static/*
|
||||||
|
@echo '<!DOCTYPE html><html><body>Prism</body></html>' > internal/api/static/index.html
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# Prism
|
||||||
|
|
||||||
|
HTTP / SOCKS5 代理网关,带 Web 管理台。控制面自研(Go + SQLite),数据面嵌入 [Mihomo](https://github.com/MetaCubeX/mihomo)。
|
||||||
|
|
||||||
|
**源码仓库:** [git.rc707blog.top/rose_cat707/Prism](https://git.rc707blog.top/rose_cat707/Prism)
|
||||||
|
|
||||||
|
管理台支持**简体中文、繁体中文、英文**三种界面语言,可在「系统设置 → 显示语言」中随时切换。
|
||||||
|
|
||||||
|
## 界面预览
|
||||||
|
|
||||||
|
以下为 Web 管理台主要页面(点击可查看原图)。
|
||||||
|
|
||||||
|
| 登录 | 监控大盘 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
| 默认密码 `admin`,首次登录后请修改 | 实时速率、连接趋势与流量分布 |
|
||||||
|
|
||||||
|
| 订阅管理 | 代理节点 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
| Clash YAML / 节点链接,自动识别与定时拉取 | 延迟测速、国家标签、导入导出 |
|
||||||
|
|
||||||
|
| 出站管理 | 路由规则 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
| 默认出站、策略组与国家 url-test 池 | 手动规则、合成预览与命中测试 |
|
||||||
|
|
||||||
|
| 请求日志 | 运行日志 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
| 代理请求明细与分页检索 | 内核与系统运行日志 |
|
||||||
|
|
||||||
|
| 系统设置 |
|
||||||
|
|:---:|
|
||||||
|
|  |
|
||||||
|
| 端口、认证、Geo 数据、API Key、界面语言 |
|
||||||
|
|
||||||
|
## 功能概览
|
||||||
|
|
||||||
|
| 模块 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 订阅管理 | Clash YAML、节点链接、自动识别;定时拉取与热重载 |
|
||||||
|
| 代理节点 | 订阅/手动节点、国家标签、延迟测速、导入导出 |
|
||||||
|
| 出站管理 | 默认出站、策略组切换、国家 url-test 池 |
|
||||||
|
| 路由规则 | 手动高优先级规则、合成预览、命中测试、导入导出 |
|
||||||
|
| 监控大盘 | 实时速率、域名/节点流量、连接趋势 |
|
||||||
|
| 日志 | 请求日志、运行日志、审计日志(分页,单页最多 500 条) |
|
||||||
|
| 系统设置 | 端口、认证、Geo 数据、API Key、界面语言 |
|
||||||
|
| OpenAPI | `/api/docs` 交互文档,`/api/v1/openapi.json` |
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
客户端 → Mihomo 入站 (HTTP/SOCKS5) → 规则引擎 → 出站节点
|
||||||
|
↑
|
||||||
|
Prism 控制面 (配置合成 / REST API / Vue UI)
|
||||||
|
↓
|
||||||
|
SQLite + 内存缓存
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始(开发)
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
- Go 1.25+(见 `go.mod`)
|
||||||
|
- Node.js 20+(仅开发/构建前端)
|
||||||
|
|
||||||
|
依赖镜像(已写入 `go.env`、`web/.npmrc`,国内网络友好):
|
||||||
|
|
||||||
|
- Go:`GOPROXY=https://goproxy.cn,direct`
|
||||||
|
- npm:`registry=https://registry.npmmirror.com`
|
||||||
|
|
||||||
|
海外用户可将 `GOPROXY` 改为 `https://proxy.golang.org,direct`,npm 使用默认 registry 即可。
|
||||||
|
|
||||||
|
### 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 终端 1:后端
|
||||||
|
make run
|
||||||
|
|
||||||
|
# 终端 2:前端(Vite 代理 /api → :8080)
|
||||||
|
make dev-web
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 http://localhost:5173 ,默认密码 `admin`(**首次登录后请立即修改**)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # 运行单元测试
|
||||||
|
make build # 构建 dist/prism
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
镜像由 Gitea Actions 自动构建并发布到 Container Registry,**无需自行编译**。直接拉取运行即可。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.rc707blog.top/rose_cat707/prism:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tag | 说明 |
|
||||||
|
|-----|------|
|
||||||
|
| `latest` | `main` / `master` 分支最新构建 |
|
||||||
|
| `sha-xxxxxxx` | 对应 commit 短 SHA |
|
||||||
|
| `v1.2.3` | 推送 Git tag `v1.2.3` 时发布 |
|
||||||
|
|
||||||
|
### docker run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.rc707blog.top/rose_cat707/prism:latest
|
||||||
|
|
||||||
|
docker run -d --name prism \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-p 7890:7890 \
|
||||||
|
-p 7891:7891 \
|
||||||
|
-v prism-data:/app/data \
|
||||||
|
git.rc707blog.top/rose_cat707/prism:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
仓库根目录 `docker-compose.yml` 已指向上述镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
数据持久化在 `./data`,升级时 `docker compose pull && docker compose up -d` 即可保留数据卷。
|
||||||
|
|
||||||
|
**端口(默认)**
|
||||||
|
|
||||||
|
| 服务 | 端口 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 管理 API / Web UI | 8080 | 浏览器访问管理台 |
|
||||||
|
| HTTP 代理 | 7890 | 客户端 HTTP 代理 |
|
||||||
|
| SOCKS5 代理 | 7891 | 客户端 SOCKS5 代理 |
|
||||||
|
|
||||||
|
代理端口可在管理台「系统设置」修改并保存后重载内核。
|
||||||
|
|
||||||
|
若通过域名 + HTTPS 访问管理台,可在前面加 Nginx/Caddy 反代到 `8080`。**勿将代理端口 7890/7891 无防护暴露公网**;建议 VPN、IP 白名单或强密码 + 入站认证。
|
||||||
|
|
||||||
|
## 数据目录
|
||||||
|
|
||||||
|
`-data` 指定数据目录(默认 `./data`):
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── prism.db # SQLite:订阅、节点、规则、设置、日志
|
||||||
|
└── geo/ # Country.mmdb、geoip.dat、geosite.dat
|
||||||
|
```
|
||||||
|
|
||||||
|
首次使用含 `GEOIP`/`GEOSITE` 规则时,可在「系统设置 → Geo 数据」一键下载,或手动放入 `data/geo/`。
|
||||||
|
|
||||||
|
## 使用攻略(简要)
|
||||||
|
|
||||||
|
### 1. 首次登录
|
||||||
|
|
||||||
|
1. 打开 `http://<主机>:8080`
|
||||||
|
2. 默认密码 `admin`,登录后进入「系统设置」修改密码
|
||||||
|
3. 按需切换「显示语言」
|
||||||
|
|
||||||
|
### 2. 添加订阅
|
||||||
|
|
||||||
|
1. 「订阅管理」→「添加订阅」
|
||||||
|
2. 类型选 **自动识别**(兼容 Clash YAML、Base64 链接、短链)
|
||||||
|
3. 保存后自动拉取节点并重载内核
|
||||||
|
|
||||||
|
### 3. 配置出站
|
||||||
|
|
||||||
|
1. 「出站管理」设置**默认出站**(未命中规则时生效)
|
||||||
|
2. 在策略组中切换当前节点;国家池为自动测速选最快
|
||||||
|
|
||||||
|
### 4. 配置规则
|
||||||
|
|
||||||
|
1. 「路由规则」添加手动规则(优先级高于订阅规则)
|
||||||
|
2. 可将域名指向 `country:HK` 等国家池,或 `DIRECT` / `PROXY`
|
||||||
|
3. 使用「规则命中测试」验证 URL 走向
|
||||||
|
|
||||||
|
### 5. 客户端接入
|
||||||
|
|
||||||
|
| 类型 | 地址 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| HTTP 代理 | `<服务器IP>:7890` | 无认证(可在设置中启用) |
|
||||||
|
| SOCKS5 | `<服务器IP>:7891` | 同上 |
|
||||||
|
|
||||||
|
本机 `127.0.0.1` 访问代理可免认证(若已配置入站认证)。
|
||||||
|
|
||||||
|
### 6. API 与自动化
|
||||||
|
|
||||||
|
- 交互文档:`http://<主机>:8080/api/docs`
|
||||||
|
- 登录获取 Token:`POST /api/v1/auth/login`
|
||||||
|
- 或在「系统设置」申请 **API Key**,请求头:
|
||||||
|
- `Authorization: Bearer <key>` 或
|
||||||
|
- `X-API-Key: <key>`
|
||||||
|
- 修改配置后:`POST /api/v1/system/reload` 热重载内核
|
||||||
|
|
||||||
|
### 7. 日常运维
|
||||||
|
|
||||||
|
| 操作 | 位置 / API |
|
||||||
|
|------|------------|
|
||||||
|
| 查看流量与连接 | 监控大盘 |
|
||||||
|
| 节点测速 | 代理节点 → 全部测速 |
|
||||||
|
| 查看请求明细 | 请求日志 |
|
||||||
|
| 更新 Geo | 系统设置 → Geo 数据 |
|
||||||
|
| 重载配置 | 系统设置 → 重载内核 |
|
||||||
|
|
||||||
|
## 订阅类型
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `auto` | 自动识别(推荐) |
|
||||||
|
| `clash` | Clash / Mihomo YAML |
|
||||||
|
| `links` | Base64 或纯文本节点链接 |
|
||||||
|
|
||||||
|
## API 前缀
|
||||||
|
|
||||||
|
REST API:`/api/v1`
|
||||||
|
认证:`Authorization: Bearer <token>` 或 API Key。
|
||||||
|
|
||||||
|
主要端点:`/dashboard`、`/subscriptions`、`/proxies`、`/rules`、`/settings`、`/traffic-logs`、`/logs`、`/system/reload`。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/prism/ # 进程入口
|
||||||
|
internal/
|
||||||
|
api/ # HTTP 层(按域拆分 handlers_*.go)
|
||||||
|
settings/ # 设置键名、展示/提交规范化、语言
|
||||||
|
store/ # SQLite 与仓储
|
||||||
|
service/ # 编排与热重载
|
||||||
|
configbuilder/ # Mihomo YAML 合成
|
||||||
|
subscription/ # 订阅拉取解析
|
||||||
|
web/ # Vue 3 管理台
|
||||||
|
src/i18n/ # 多语言资源
|
||||||
|
src/views/ # 页面
|
||||||
|
src/styles/ # 设计系统 theme.css
|
||||||
|
docs/
|
||||||
|
image/ # README 界面截图
|
||||||
|
UI-DESIGN-SYSTEM.md
|
||||||
|
.gitea/workflows/ # Gitea Actions CI
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI / 镜像发布
|
||||||
|
|
||||||
|
推送 `main` / `master` 或 tag `v*` 时,Gitea Actions 构建多架构镜像并推送到 Registry。工作流与 Dockerfile 约定见 [.gitea/README.md](.gitea/README.md);维护者配置见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||||
|
|
||||||
|
## 贡献与安全
|
||||||
|
|
||||||
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — 开发环境、CI 配置、提交规范
|
||||||
|
- [SECURITY.md](SECURITY.md) — 漏洞报告与部署安全建议
|
||||||
|
- [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) — 第三方组件与许可
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
Prism 控制面代码采用 [MIT License](LICENSE)。
|
||||||
|
|
||||||
|
嵌入的 [Mihomo](https://github.com/MetaCubeX/mihomo) 内核采用 **GPL-3.0 OR MIT** 双重许可。分发二进制或容器镜像时,请保留相关版权声明并遵守 Mihomo 及全部依赖的许可条款。详见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 安全策略
|
||||||
|
|
||||||
|
## 支持的版本
|
||||||
|
|
||||||
|
| 版本 | 支持状态 |
|
||||||
|
|------|----------|
|
||||||
|
| 最新 `main` / `master` 分支 | ✅ 接受安全报告 |
|
||||||
|
| 最新发布的容器镜像 tag | ✅ 接受安全报告 |
|
||||||
|
| 更早版本 | ❌ 不保证修复 |
|
||||||
|
|
||||||
|
## 报告漏洞
|
||||||
|
|
||||||
|
**请勿在公开 Issue 中披露可被利用的安全漏洞。**
|
||||||
|
|
||||||
|
请通过以下方式私下联系维护者:
|
||||||
|
|
||||||
|
- Gitea Issues 的「私有安全报告」功能(若实例已启用)
|
||||||
|
- 或仓库维护者提供的私密联系渠道
|
||||||
|
|
||||||
|
报告请尽量包含:
|
||||||
|
|
||||||
|
1. 漏洞类型与影响范围
|
||||||
|
2. 复现步骤或 PoC
|
||||||
|
3. 受影响版本 / 提交
|
||||||
|
4. 建议修复思路(如有)
|
||||||
|
|
||||||
|
我们会在确认后尽快回复,并在修复发布后公开致谢(除非你希望匿名)。
|
||||||
|
|
||||||
|
## 威胁模型说明
|
||||||
|
|
||||||
|
Prism 是**自托管代理网关**,默认设计面向受信任的内网或个人 VPS,而非多租户公网 SaaS。
|
||||||
|
|
||||||
|
部署时请注意:
|
||||||
|
|
||||||
|
| 风险 | 说明 | 建议 |
|
||||||
|
|------|------|------|
|
||||||
|
| 默认管理密码 | 首次安装默认 `admin` | 登录后立即修改 |
|
||||||
|
| 管理 API 暴露 | 8080 端口可控制全部配置 | 勿对公网裸奔;使用 VPN / IP 白名单 / 反向代理 + HTTPS |
|
||||||
|
| 代理端口暴露 | 7890/7891 可被滥用为开放代理 | 启用入站认证;限制来源 IP |
|
||||||
|
| Mihomo 内部 API | 9090 仅应监听 127.0.0.1 | 勿映射到公网 |
|
||||||
|
| CORS `*` | 便于开发,生产需配合鉴权 | 管理台不要与不可信站点共享 Cookie 场景 |
|
||||||
|
| API Key / Token | 等同管理员权限 | 定期轮换,勿提交到版本库 |
|
||||||
|
| 订阅 URL | 可能含敏感节点信息 | 妥善保护数据库与备份 |
|
||||||
|
|
||||||
|
## 依赖安全
|
||||||
|
|
||||||
|
- Go 依赖见 `go.mod` / `go.sum`
|
||||||
|
- 前端依赖见 `web/package-lock.json`
|
||||||
|
- 嵌入内核 [Mihomo](https://github.com/MetaCubeX/mihomo) 的安全更新需跟随上游版本升级
|
||||||
|
|
||||||
|
## 安全更新
|
||||||
|
|
||||||
|
安全修复将通过:
|
||||||
|
|
||||||
|
1. 向 `main` / `master` 提交补丁
|
||||||
|
2. 推送新容器镜像(`latest` 与对应版本 tag)
|
||||||
|
3. 在 Release / Issue 中简要说明(不含 exploit 细节)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 第三方组件与许可声明
|
||||||
|
|
||||||
|
Prism 在 MIT 许可下发布(见 [LICENSE](LICENSE))。以下列出主要直接依赖及嵌入组件。**完整依赖树**见 `go.mod`、`go.sum` 与 `web/package-lock.json`。
|
||||||
|
|
||||||
|
## 嵌入内核
|
||||||
|
|
||||||
|
| 组件 | 用途 | 许可 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Mihomo](https://github.com/MetaCubeX/mihomo) | HTTP/SOCKS5 代理数据面(Go 静态链接嵌入) | GPL-3.0 **或** MIT(双重许可,由使用者选择) |
|
||||||
|
|
||||||
|
### Mihomo 许可说明
|
||||||
|
|
||||||
|
Mihomo 以 **GPL-3.0 / MIT 双许可** 发布。分发包含 Mihomo 的 Prism 二进制或容器镜像时:
|
||||||
|
|
||||||
|
1. **保留** Mihomo 及所有依赖的版权声明与许可全文
|
||||||
|
2. 若选择 GPL-3.0 路径,需按 GPL-3.0 要求提供对应源码获取方式
|
||||||
|
3. 若选择 MIT 路径(针对 Mihomo 部分),需保留 Mihomo 的 MIT 版权声明
|
||||||
|
|
||||||
|
Prism **自研控制面代码**(`cmd/`、`internal/`、`web/`)采用 MIT 许可,与 Mihomo 通过 Go 包导入方式组合为单一可执行文件。
|
||||||
|
|
||||||
|
## Go 直接依赖
|
||||||
|
|
||||||
|
| 组件 | 用途 | 许可 |
|
||||||
|
|------|------|------|
|
||||||
|
| [gin-gonic/gin](https://github.com/gin-gonic/gin) | HTTP 框架 | MIT |
|
||||||
|
| [pressly/goose](https://github.com/pressly/goose) | 数据库迁移 | MIT |
|
||||||
|
| [robfig/cron](https://github.com/robfig/cron) | 定时任务 | MIT |
|
||||||
|
| [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) | bcrypt 等 | BSD-3-Clause |
|
||||||
|
| [gopkg.in/yaml.v3](https://github.com/go-yaml/yaml) | YAML 解析 | Apache-2.0 / MIT |
|
||||||
|
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | 纯 Go SQLite 驱动 | BSD-3-Clause |
|
||||||
|
|
||||||
|
Mihomo 还引入大量间接依赖(Tailscale、gVisor、quic-go 等),其许可信息见 `vendor/`(若本地生成)或各模块仓库。
|
||||||
|
|
||||||
|
## 前端直接依赖
|
||||||
|
|
||||||
|
| 组件 | 用途 | 许可 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Vue 3](https://vuejs.org/) | UI 框架 | MIT |
|
||||||
|
| [Element Plus](https://element-plus.org/) | 组件库 | MIT |
|
||||||
|
| [vue-i18n](https://vue-i18n.intlify.dev/) | 国际化 | MIT |
|
||||||
|
| [ECharts](https://echarts.apache.org/) | 图表 | Apache-2.0 |
|
||||||
|
| [axios](https://axios-http.com/) | HTTP 客户端 | MIT |
|
||||||
|
| [Vite](https://vitejs.dev/) | 构建工具 | MIT |
|
||||||
|
|
||||||
|
## 生成 vendor 目录(可选)
|
||||||
|
|
||||||
|
如需离线/可复现构建,可在本地执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod vendor
|
||||||
|
```
|
||||||
|
|
||||||
|
`vendor/` 已加入 `.gitignore`,默认由 CI 与 Docker 构建在线拉取依赖。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prism/proxy/internal/geodata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyGeoSettingsOnStart(dataDir string, settings map[string]string) {
|
||||||
|
geodata.InitHomeDir(dataDir)
|
||||||
|
geodata.ApplyMihomoURLs(geodata.ConfigFromSettings(settings))
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/api"
|
||||||
|
"github.com/prism/proxy/internal/cache"
|
||||||
|
"github.com/prism/proxy/internal/configbuilder"
|
||||||
|
"github.com/prism/proxy/internal/core"
|
||||||
|
"github.com/prism/proxy/internal/geodata"
|
||||||
|
"github.com/prism/proxy/internal/health"
|
||||||
|
applog "github.com/prism/proxy/internal/log"
|
||||||
|
"github.com/prism/proxy/internal/metrics"
|
||||||
|
"github.com/prism/proxy/internal/mihomoapi"
|
||||||
|
"github.com/prism/proxy/internal/service"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
"github.com/prism/proxy/internal/subscription"
|
||||||
|
"github.com/prism/proxy/internal/trafficlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dataDir := flag.String("data", "data", "data directory")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
log.Printf("Prism starting, data dir: %s", *dataDir)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(*dataDir, 0o755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(*dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
log.Printf("database ready")
|
||||||
|
|
||||||
|
if n, err := st.BackfillNodeCountries(context.Background()); err != nil {
|
||||||
|
log.Printf("country backfill: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("country tags backfilled for %d nodes", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
core.InitDataDir(*dataDir)
|
||||||
|
|
||||||
|
c := cache.New()
|
||||||
|
engine := core.NewEngine()
|
||||||
|
builder := configbuilder.New(st, *dataDir)
|
||||||
|
logCollector := applog.NewCollector(st, 5000, true)
|
||||||
|
subSvc := subscription.NewService(st)
|
||||||
|
|
||||||
|
app := &service.App{
|
||||||
|
Store: st,
|
||||||
|
Cache: c,
|
||||||
|
Engine: engine,
|
||||||
|
Builder: builder,
|
||||||
|
Subscription: subSvc,
|
||||||
|
Logs: logCollector,
|
||||||
|
DataDir: *dataDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
apiBase, secret, _ := app.MihomoAPIBase(ctx)
|
||||||
|
hc := health.NewChecker(st, c, apiBase, secret)
|
||||||
|
mc := metrics.NewCollector(st, c, apiBase, secret)
|
||||||
|
trafficCollector := trafficlog.NewCollector(st, 5000)
|
||||||
|
trafficCollector.Start(ctx, mihomoapi.New(apiBase, secret))
|
||||||
|
|
||||||
|
settings, _ := st.GetSettings(ctx)
|
||||||
|
applyGeoSettingsOnStart(*dataDir, settings)
|
||||||
|
if settings["geo_auto_download"] != "false" {
|
||||||
|
go func() {
|
||||||
|
log.Printf("checking geo data in background...")
|
||||||
|
cfg := geodata.ConfigFromSettings(settings)
|
||||||
|
if updated, err := geodata.Ensure(*dataDir, cfg, false); err != nil {
|
||||||
|
log.Printf("geo data: %v", err)
|
||||||
|
logCollector.Add("warn", "prism", "geo data: "+err.Error())
|
||||||
|
} else if len(updated) > 0 {
|
||||||
|
log.Printf("geo data downloaded: %v", updated)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
logCollector.Add("info", "prism", "Prism started")
|
||||||
|
log.Printf("loading proxy engine...")
|
||||||
|
|
||||||
|
if err := app.ReloadEngine(ctx); err != nil {
|
||||||
|
log.Printf("initial engine reload: %v (will retry after config)", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("proxy engine ready")
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
warmCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = mc.Refresh(warmCtx)
|
||||||
|
app.RefreshEngineStatusAsync()
|
||||||
|
}()
|
||||||
|
|
||||||
|
srv := api.NewServer(app, hc, mc, subSvc, logCollector, trafficCollector)
|
||||||
|
router := srv.Router()
|
||||||
|
|
||||||
|
apiPort := settings["api_port"]
|
||||||
|
if apiPort == "" {
|
||||||
|
apiPort = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: ":" + apiPort,
|
||||||
|
Handler: router,
|
||||||
|
}
|
||||||
|
|
||||||
|
cr := cron.New()
|
||||||
|
_, _ = cr.AddFunc("@every 1m", func() {
|
||||||
|
_, _ = mc.Refresh(context.Background())
|
||||||
|
})
|
||||||
|
_, _ = cr.AddFunc("@every 5m", func() {
|
||||||
|
settings, _ := st.GetSettings(context.Background())
|
||||||
|
sec, _ := strconv.Atoi(settings["health_interval_sec"])
|
||||||
|
if sec <= 0 {
|
||||||
|
sec = 300
|
||||||
|
}
|
||||||
|
// health checker runs on its own schedule approximation
|
||||||
|
_ = hc.TestAll(context.Background(), nil)
|
||||||
|
})
|
||||||
|
_, _ = cr.AddFunc("@every 10m", func() {
|
||||||
|
syncSubscriptions(app, subSvc)
|
||||||
|
})
|
||||||
|
cr.Start()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("Prism API listening on :%s", apiPort)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sig
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = httpServer.Shutdown(shutdownCtx)
|
||||||
|
cr.Stop()
|
||||||
|
engine.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncSubscriptions(app *service.App, subSvc *subscription.Service) {
|
||||||
|
ctx := context.Background()
|
||||||
|
settings, err := app.Store.GetSettings(ctx)
|
||||||
|
if err != nil || settings["sub_sync_enabled"] != "true" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subs, err := app.Store.ListSubscriptions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, sub := range subs {
|
||||||
|
if !sub.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sub.LastFetchAt != nil && now.Sub(*sub.LastFetchAt) < time.Duration(sub.IntervalSec)*time.Second {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := subSvc.FetchAndStore(ctx, sub.ID); err != nil {
|
||||||
|
app.Logs.Add("error", "subscription", fmt.Sprintf("sync %s failed: %v", sub.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = app.ReloadEngine(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Prism 一键部署示例
|
||||||
|
# 镜像由 Gitea Actions 构建并发布到 Container Registry
|
||||||
|
#
|
||||||
|
# docker compose pull && docker compose up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
prism:
|
||||||
|
image: git.rc707blog.top/rose_cat707/prism:latest
|
||||||
|
container_name: prism
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # 管理 API / Web UI
|
||||||
|
- "7890:7890" # HTTP 代理
|
||||||
|
- "7891:7891" # SOCKS5 代理
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 240 KiB |
@@ -0,0 +1,179 @@
|
|||||||
|
module github.com/prism/proxy
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/metacubex/mihomo v1.19.27
|
||||||
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.52.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/RyuaNerin/go-krypto v1.3.0 // indirect
|
||||||
|
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect
|
||||||
|
github.com/ajg/form v1.5.1 // indirect
|
||||||
|
github.com/akutz/memconn v0.1.0 // indirect
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
|
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||||
|
github.com/bodgit/windows v1.0.1 // indirect
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
|
github.com/coreos/go-iptables v0.8.0 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||||
|
github.com/dunglas/httpsfv v1.0.2 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/enfein/mieru/v3 v3.33.0 // indirect
|
||||||
|
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect
|
||||||
|
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 // indirect
|
||||||
|
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect
|
||||||
|
github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gaukas/godicttls v0.0.4 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
|
||||||
|
github.com/gofrs/uuid/v5 v5.4.0 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
|
github.com/google/btree v1.1.3 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/huin/goupnp v1.3.0 // indirect
|
||||||
|
github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 // indirect
|
||||||
|
github.com/josharian/native v1.1.0 // indirect
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/klauspost/reedsolomon v1.12.3 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||||
|
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||||
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
|
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 // indirect
|
||||||
|
github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d // indirect
|
||||||
|
github.com/metacubex/ascon v0.1.0 // indirect
|
||||||
|
github.com/metacubex/bart v0.26.0 // indirect
|
||||||
|
github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b // indirect
|
||||||
|
github.com/metacubex/blake3 v0.1.0 // indirect
|
||||||
|
github.com/metacubex/chacha v0.1.5 // indirect
|
||||||
|
github.com/metacubex/chi v0.1.0 // indirect
|
||||||
|
github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a // indirect
|
||||||
|
github.com/metacubex/cpu v0.1.1 // indirect
|
||||||
|
github.com/metacubex/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/metacubex/fswatch v0.1.1 // indirect
|
||||||
|
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect
|
||||||
|
github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8 // indirect
|
||||||
|
github.com/metacubex/hkdf v0.1.0 // indirect
|
||||||
|
github.com/metacubex/hpke v0.1.0 // indirect
|
||||||
|
github.com/metacubex/http v0.1.6 // indirect
|
||||||
|
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df // indirect
|
||||||
|
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 // indirect
|
||||||
|
github.com/metacubex/mhurl v0.1.0 // indirect
|
||||||
|
github.com/metacubex/mlkem v0.1.0 // indirect
|
||||||
|
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb // indirect
|
||||||
|
github.com/metacubex/qpack v0.6.0 // indirect
|
||||||
|
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace // indirect
|
||||||
|
github.com/metacubex/randv2 v0.2.0 // indirect
|
||||||
|
github.com/metacubex/restls-client-go v0.1.7 // indirect
|
||||||
|
github.com/metacubex/sevenzip v1.6.4 // indirect
|
||||||
|
github.com/metacubex/sing v0.5.7 // indirect
|
||||||
|
github.com/metacubex/sing-mux v0.3.9 // indirect
|
||||||
|
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 // indirect
|
||||||
|
github.com/metacubex/sing-shadowsocks v0.2.12 // indirect
|
||||||
|
github.com/metacubex/sing-shadowsocks2 v0.2.7 // indirect
|
||||||
|
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc // indirect
|
||||||
|
github.com/metacubex/sing-tun v0.4.20 // indirect
|
||||||
|
github.com/metacubex/sing-vmess v0.2.5 // indirect
|
||||||
|
github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c // indirect
|
||||||
|
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 // indirect
|
||||||
|
github.com/metacubex/ssh v0.1.0 // indirect
|
||||||
|
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7 // indirect
|
||||||
|
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af // indirect
|
||||||
|
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 // indirect
|
||||||
|
github.com/metacubex/tls v0.1.6 // indirect
|
||||||
|
github.com/metacubex/utls v1.8.4 // indirect
|
||||||
|
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f // indirect
|
||||||
|
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 // indirect
|
||||||
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
|
github.com/miekg/dns v1.1.63 // indirect
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/mroth/weightedrand/v2 v2.1.0 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect
|
||||||
|
github.com/openacid/low v0.1.21 // indirect
|
||||||
|
github.com/oschwald/maxminddb-golang v1.12.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||||
|
github.com/pires/go-proxyproto v0.8.0 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/safchain/ethtool v0.3.0 // indirect
|
||||||
|
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
|
||||||
|
github.com/samber/lo v1.53.0 // indirect
|
||||||
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
|
github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b // indirect
|
||||||
|
github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c // indirect
|
||||||
|
github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
|
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||||
|
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||||
|
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
|
||||||
|
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 // indirect
|
||||||
|
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
|
golang.org/x/mod v0.36.0 // indirect
|
||||||
|
golang.org/x/net v0.55.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.24.0 // indirect
|
||||||
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/term v0.44.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
golang.org/x/time v0.10.0 // indirect
|
||||||
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
github.com/RyuaNerin/go-krypto v1.3.0 h1:smavTzSMAx8iuVlGb4pEwl9MD2qicqMzuXR2QWp2/Pg=
|
||||||
|
github.com/RyuaNerin/go-krypto v1.3.0/go.mod h1:9R9TU936laAIqAmjcHo/LsaXYOZlymudOAxjaBf62UM=
|
||||||
|
github.com/RyuaNerin/testingutil v0.1.0 h1:IYT6JL57RV3U2ml3dLHZsVtPOP6yNK7WUVdzzlpNrss=
|
||||||
|
github.com/RyuaNerin/testingutil v0.1.0/go.mod h1:yTqj6Ta/ycHMPJHRyO12Mz3VrvTloWOsy23WOZH19AA=
|
||||||
|
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 h1:cDVUiFo+npB0ZASqnw4q90ylaVAbnYyx0JYqK4YcGok=
|
||||||
|
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk=
|
||||||
|
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||||
|
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||||
|
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||||
|
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
|
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||||
|
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||||
|
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||||
|
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cilium/ebpf v0.12.3 h1:8ht6F9MquybnY97at+VDZb3eQQr8ev79RueWeVaEcG4=
|
||||||
|
github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc=
|
||||||
|
github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
|
||||||
|
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc=
|
||||||
|
github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
||||||
|
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
|
||||||
|
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
|
||||||
|
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=
|
||||||
|
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391/go.mod h1:K2R7GhgxrlJzHw2qiPWsCZXf/kXEJN9PLnQK73Ll0po=
|
||||||
|
github.com/ericlagergren/saferand v0.0.0-20220206064634-960a4dd2bc5c h1:RUzBDdZ+e/HEe2Nh8lYsduiPAZygUfVXJn0Ncj5sHMg=
|
||||||
|
github.com/ericlagergren/saferand v0.0.0-20220206064634-960a4dd2bc5c/go.mod h1:ETASDWf/FmEb6Ysrtd1QhjNedUU/ZQxBCRLh60bQ/UI=
|
||||||
|
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 h1:tlDMEdcPRQKBEz5nGDMvswiajqh7k8ogWRlhRwKy5mY=
|
||||||
|
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1/go.mod h1:4RfsapbGx2j/vU5xC/5/9qB3kn9Awp1YDiEnN43QrJ4=
|
||||||
|
github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 h1:fuGucgPk5dN6wzfnxl3D0D3rVLw4v2SbBT9jb4VnxzA=
|
||||||
|
github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010/go.mod h1:JtBcj7sBuTTRupn7c2bFspMDIObMJsVK8TeUvpShPok=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
|
||||||
|
github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
||||||
|
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
|
||||||
|
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
||||||
|
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
|
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||||
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/tink/go v1.6.1 h1:t7JHqO8Ath2w2ig5vjwQYJzhGEZymedQc90lQXUBa4I=
|
||||||
|
github.com/google/tink/go v1.6.1/go.mod h1:IGW53kTgag+st5yPhKKwJ6u2l+SSp5/v9XF7spovjlY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||||
|
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||||
|
github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 h1:q3OEI9RaN/wwcx+qgGo6ZaoJkCiDYe/gjDLfq7lQQF4=
|
||||||
|
github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905/go.mod h1:VvGYjkZoJyKqlmT1yzakUs4mfKMNB0XdODP0+rdml6k=
|
||||||
|
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||||
|
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||||
|
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/klauspost/reedsolomon v1.12.3 h1:tzUznbfc3OFwJaTebv/QdhnFf2Xvb7gZ24XaHLBPmdc=
|
||||||
|
github.com/klauspost/reedsolomon v1.12.3/go.mod h1:3K5rXwABAvzGeR01r6pWZieUALXO/Tq7bFKGIb4m4WI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||||
|
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
|
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||||
|
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||||
|
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||||
|
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||||
|
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48=
|
||||||
|
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c=
|
||||||
|
github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d h1:vAJ0ZT4aO803F1uw2roIA9yH7Sxzox34tVVyye1bz6c=
|
||||||
|
github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY=
|
||||||
|
github.com/metacubex/ascon v0.1.0 h1:6ZWxmXYszT1XXtwkf6nxfFhc/OTtQ9R3Vyj1jN32lGM=
|
||||||
|
github.com/metacubex/ascon v0.1.0/go.mod h1:eV5oim4cVPPdEL8/EYaTZ0iIKARH9pnhAK/fcT5Kacc=
|
||||||
|
github.com/metacubex/bart v0.26.0 h1:d/bBTvVatfVWGfQbiDpYKI1bXUJgjaabB2KpK1Tnk6w=
|
||||||
|
github.com/metacubex/bart v0.26.0/go.mod h1:DCcyfP4MC+Zy7sLK7XeGuMw+P5K9mIRsYOBgiE8icsI=
|
||||||
|
github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b h1:j7dadXD8I2KTmMt8jg1JcaP1ANL3JEObJPdANKcSYPY=
|
||||||
|
github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b/go.mod h1:+WmP0VJZDkDszvpa83HzfUp6QzARl/IKkMorH4+nODw=
|
||||||
|
github.com/metacubex/blake3 v0.1.0 h1:KGnjh/56REO7U+cgZA8dnBhxdP7jByrG7hTP+bu6cqY=
|
||||||
|
github.com/metacubex/blake3 v0.1.0/go.mod h1:CCkLdzFrqf7xmxCdhQFvJsRRV2mwOLDoSPg6vUTB9Uk=
|
||||||
|
github.com/metacubex/chacha v0.1.5 h1:fKWMb/5c7ZrY8Uoqi79PPFxl+qwR7X/q0OrsAubyX2M=
|
||||||
|
github.com/metacubex/chacha v0.1.5/go.mod h1:Djn9bPZxLTXbJFSeyo0/qzEzQI+gUSSzttuzZM75GH8=
|
||||||
|
github.com/metacubex/chi v0.1.0 h1:rjNDyDj50nRpicG43CNkIw4ssiCbmDL8d7wJXKlUCsg=
|
||||||
|
github.com/metacubex/chi v0.1.0/go.mod h1:zM5u5oMQt8b2DjvDHvzadKrP6B2ztmasL1YHRMbVV+g=
|
||||||
|
github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a h1:Ph5UfTWDsGruZ+v95Df1ycTflQFmpZBFg2LUvj2kx/M=
|
||||||
|
github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a/go.mod h1:xYC8Ik7/rN6no+vTRuWMEziGwm3brA0wNM/zZP9qhOQ=
|
||||||
|
github.com/metacubex/cpu v0.1.1 h1:rRV5HGmeuGzjiKI3hYbL0dCd0qGwM7VUtk4ICXD06mI=
|
||||||
|
github.com/metacubex/cpu v0.1.1/go.mod h1:09VEt4dSRLR+bOA8l4w4NDuzGZ8n5dkMv7e8axgEeTU=
|
||||||
|
github.com/metacubex/edwards25519 v1.2.0 h1:pIQZLBsjQgg3Nl/c86YYFEUAbL5qQRnPq4LrgIw0KK4=
|
||||||
|
github.com/metacubex/edwards25519 v1.2.0/go.mod h1:NCQF3J/Ki7382FJuokwsywEIIEI/gro/3smyXgQJsx0=
|
||||||
|
github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQuxhU=
|
||||||
|
github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI=
|
||||||
|
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI=
|
||||||
|
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759/go.mod h1:UHOv2xu+RIgLwpXca7TLrXleEd4oR3sPatW6IF8wU88=
|
||||||
|
github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8 h1:hUL81H0Ic/XIDkvtn9M1pmfDdfid7JzYQToY4Ps1TvQ=
|
||||||
|
github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8/go.mod h1:8LpS0IJW1VmWzUm3ylb0e2SK5QDm5lO/2qwWLZgRpBU=
|
||||||
|
github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA=
|
||||||
|
github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4=
|
||||||
|
github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ=
|
||||||
|
github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U=
|
||||||
|
github.com/metacubex/http v0.1.6 h1:xvXuvXMCMxCWMF5nEJF4yiKvXL+p2atWMzs37e80m1I=
|
||||||
|
github.com/metacubex/http v0.1.6/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg=
|
||||||
|
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df h1:S0vBzqjXok24VopstOgPd1JdgglW9tXehrqvwpQWbQ8=
|
||||||
|
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg=
|
||||||
|
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC35MGHB+FBr8TuIf3BUFn2EQ1VIamsI=
|
||||||
|
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604/go.mod h1:lpmN3m269b3V5jFCWtffqBLS4U3QQoIid9ugtO+OhVc=
|
||||||
|
github.com/metacubex/mhurl v0.1.0 h1:ZdW4Zxe3j3uJ89gNytOazHu6kbHn5owutN/VfXOI8GE=
|
||||||
|
github.com/metacubex/mhurl v0.1.0/go.mod h1:2qpQImCbXoUs6GwJrjuEXKelPyoimsIXr07eNKZdS00=
|
||||||
|
github.com/metacubex/mihomo v1.19.27 h1:Yw0sep/yXiC5B/doFAqtLVt9D+NrwHyukcsoa+dU8C4=
|
||||||
|
github.com/metacubex/mihomo v1.19.27/go.mod h1:OPVY7SkbwJ24yBVBYGCLNRhK4bjIuXiKYyJKa9opv4Y=
|
||||||
|
github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I=
|
||||||
|
github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ=
|
||||||
|
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb h1:wk6mHYPURSUvWcUv72gNP79oiylFsscBSDPJ6ieV6Iw=
|
||||||
|
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb/go.mod h1:73ZrCfhdkW4F2E2GAlta3km/S2RHhFNogCMtWZV2anQ=
|
||||||
|
github.com/metacubex/qpack v0.6.0 h1:YqClGIMOpiRYLjV1qOs483Od08MdPgRnHjt90FuaAKw=
|
||||||
|
github.com/metacubex/qpack v0.6.0/go.mod h1:lKGSi7Xk94IMvHGOmxS9eIei3bvIqpOAImEBsaOwTkA=
|
||||||
|
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace h1:KXacx7dp1GYVMgxezwXRt5BMsEbvAYuA6rPFUmdAvcQ=
|
||||||
|
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace/go.mod h1:2YEQEvFrZ5V76oynMBDTlN+4fdnSHCa2uNJxv3cm1HU=
|
||||||
|
github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs=
|
||||||
|
github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY=
|
||||||
|
github.com/metacubex/restls-client-go v0.1.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58dEe7hcN5H15k=
|
||||||
|
github.com/metacubex/restls-client-go v0.1.7/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g=
|
||||||
|
github.com/metacubex/sevenzip v1.6.4 h1:OIL+DeOeSAbKNsjqxcYUMiarRmX6Kaxakb0GT7E9Oik=
|
||||||
|
github.com/metacubex/sevenzip v1.6.4/go.mod h1:FP3X9bzFKj9wPxifGN9B3w2fIEicMjzKYIGIhnu+1pw=
|
||||||
|
github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM=
|
||||||
|
github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
|
||||||
|
github.com/metacubex/sing-mux v0.3.9 h1:/aoBD2+sK2qsXDlNDe3hkR0GZuFDtwIZhOeGUx9W0Yk=
|
||||||
|
github.com/metacubex/sing-mux v0.3.9/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc=
|
||||||
|
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 h1:PnMby5+kZXTl/CFDHfxMbMTaSRD+uMKMsrDYVQyAmX8=
|
||||||
|
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
|
||||||
|
github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE=
|
||||||
|
github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
|
||||||
|
github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A=
|
||||||
|
github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
|
||||||
|
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc h1:8wLoFfYQ88iGPL+krQ5tJsI8IAmkFjKpQL2q+y3pvss=
|
||||||
|
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E=
|
||||||
|
github.com/metacubex/sing-tun v0.4.20 h1:xdupzizRoZKyDzP0l68WAx5Sk4ooiuT1GiWsiJyOGPw=
|
||||||
|
github.com/metacubex/sing-tun v0.4.20/go.mod h1:g4I/JNplDBhXLF+aQWgFbhNeJPSXQOWS9HvLeNvkgeA=
|
||||||
|
github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE=
|
||||||
|
github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q=
|
||||||
|
github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c h1:tH9FuQW357zp2xAGzkoZTGpNGMVmEFZov0iV5M2S5ew=
|
||||||
|
github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c/go.mod h1:eQZDJTx+IH3k4mXqaOJ3VJ9h9ZqOl60F7TLi5wAU51Q=
|
||||||
|
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2BhiAPgbJygiWhesPlfGmF+9Vw6ARdk=
|
||||||
|
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg=
|
||||||
|
github.com/metacubex/ssh v0.1.0 h1:iGfr99qk/eMHzUnQ/0bTxXT8+8SWqLSHBWDHoAhngzw=
|
||||||
|
github.com/metacubex/ssh v0.1.0/go.mod h1:NUtl0d+/f2cG9ECEpMM8iCVOpmggQlC13oLeDUONDlU=
|
||||||
|
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7 h1:LoJR4NMyNKHeEJoeGDtcsao7sV0NRkzMeV5H/0J0MIE=
|
||||||
|
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7/go.mod h1:MAo3HhE7968rIwmDvYTYE8xCsV4x+hLnkChdXeP3X4c=
|
||||||
|
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af h1:c60IbBMUq2h1M2m7+grMJJmBmrObxL8SwvNtm6Ozbwk=
|
||||||
|
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af/go.mod h1:i3zLKytWkOnyT1i9OmiLevWvrN5J5HE1+yjE7UYNfcQ=
|
||||||
|
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 h1:H6TnfM12tOoTizYE/qBHH3nEuibIelmHI+BVSxVJr8o=
|
||||||
|
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw=
|
||||||
|
github.com/metacubex/tls v0.1.6 h1:t2ubLneYa4ceyIC++54a57BLqZFA/QYUrhdjLk2GPwo=
|
||||||
|
github.com/metacubex/tls v0.1.6/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
|
||||||
|
github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg=
|
||||||
|
github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
|
||||||
|
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk=
|
||||||
|
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4=
|
||||||
|
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E=
|
||||||
|
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49/go.mod h1:MBeEa9IVBphH7vc3LNtW6ZujVXFizotPo3OEiHQ+TNU=
|
||||||
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
|
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
|
||||||
|
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mroth/weightedrand/v2 v2.1.0 h1:o1ascnB1CIVzsqlfArQQjeMy1U0NcIbBO5rfd5E/OeU=
|
||||||
|
github.com/mroth/weightedrand/v2 v2.1.0/go.mod h1:f2faGsfOGOwc1p94wzHKKZyTpcJUW7OJ/9U4yfiNAOU=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 h1:1102pQc2SEPp5+xrS26wEaeb26sZy6k9/ZXlZN+eXE4=
|
||||||
|
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7/go.mod h1:UqoUn6cHESlliMhOnKLWr+CBH+e3bazUPvFj1XZwAjs=
|
||||||
|
github.com/openacid/errors v0.8.1/go.mod h1:GUQEJJOJE3W9skHm8E8Y4phdl2LLEN8iD7c5gcGgdx0=
|
||||||
|
github.com/openacid/low v0.1.21 h1:Tr2GNu4N/+rGRYdOsEHOE89cxUIaDViZbVmKz29uKGo=
|
||||||
|
github.com/openacid/low v0.1.21/go.mod h1:q+MsKI6Pz2xsCkzV4BLj7NR5M4EX0sGz5AqotpZDVh0=
|
||||||
|
github.com/openacid/must v0.1.3/go.mod h1:luPiXCuJlEo3UUFQngVQokV0MPGryeYvtCbQPs3U1+I=
|
||||||
|
github.com/openacid/testkeys v0.1.6/go.mod h1:MfA7cACzBpbiwekivj8StqX0WIRmqlMsci1c37CA3Do=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
|
github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0=
|
||||||
|
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||||
|
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
|
||||||
|
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
|
||||||
|
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
|
||||||
|
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
|
||||||
|
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
|
||||||
|
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||||
|
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
|
github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b h1:rXHg9GrUEtWZhEkrykicdND3VPjlVbYiLdX9J7gimS8=
|
||||||
|
github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b/go.mod h1:X7qrxNQViEaAN9LNZOPl9PfvQtp3V3c7LTo0dvGi0fM=
|
||||||
|
github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c h1:DjKMC30y6yjG3IxDaeAj3PCoRr+IsO+bzyT+Se2m2Hk=
|
||||||
|
github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c/go.mod h1:NV/a66PhhWYVmUMaotlXJ8fIEFB98u+c8l/CQIEFLrU=
|
||||||
|
github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e h1:ur8uMsPIFG3i4Gi093BQITvwH9znsz2VUZmnmwHvpIo=
|
||||||
|
github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e/go.mod h1:+e5fBW3bpPyo+3uLo513gIUblc03egGjMM0+5GKbzK8=
|
||||||
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
|
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
|
||||||
|
github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
|
||||||
|
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||||
|
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
|
||||||
|
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
|
||||||
|
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
|
||||||
|
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
|
||||||
|
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
|
||||||
|
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
|
||||||
|
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||||
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
|
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM=
|
||||||
|
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 h1:UNrDfkQqiEYzdMlNsVvBYOAJWZjdktqFE9tQh5BT2+4=
|
||||||
|
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7/go.mod h1:E+rxHvJG9H6PUdzq9NRG6csuLN3XUx98BfGOVWNYnXs=
|
||||||
|
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiVfzQttTuxanPIT43FtkkCFypIod8LHo=
|
||||||
|
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||||
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||||
|
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
|
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||||
|
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
software.sslmate.com/src/go-pkcs12 v0.2.1 h1:tbT1jjaeFOF230tzOIRJ6U5S1jNqpsSyNjzDd58H3J8=
|
||||||
|
software.sslmate.com/src/go-pkcs12 v0.2.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func issueToken(secret string, ttl time.Duration) string {
|
||||||
|
exp := time.Now().Add(ttl).Unix()
|
||||||
|
return fmt.Sprintf("%d.%s", exp, signToken(secret, exp))
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyToken(secret, token string) bool {
|
||||||
|
parts := strings.SplitN(token, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
exp, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil || time.Now().Unix() > exp {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expected := signToken(secret, exp)
|
||||||
|
return hmac.Equal([]byte(parts[1]), []byte(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
func signToken(secret string, exp int64) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write([]byte(strconv.FormatInt(exp, 10)))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueAndVerifyToken(t *testing.T) {
|
||||||
|
secret := "test-secret"
|
||||||
|
token := issueToken(secret, time.Hour)
|
||||||
|
if !verifyToken(secret, token) {
|
||||||
|
t.Fatal("expected valid token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyTokenWrongSecret(t *testing.T) {
|
||||||
|
token := issueToken("a", time.Hour)
|
||||||
|
if verifyToken("b", token) {
|
||||||
|
t.Fatal("expected invalid token for wrong secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyTokenExpired(t *testing.T) {
|
||||||
|
secret := "test-secret"
|
||||||
|
exp := time.Now().Add(-time.Hour).Unix()
|
||||||
|
token := fmtToken(secret, exp)
|
||||||
|
if verifyToken(secret, token) {
|
||||||
|
t.Fatal("expected expired token to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtToken(secret string, exp int64) string {
|
||||||
|
return fmt.Sprintf("%d.%s", exp, signToken(secret, exp))
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func clientIP(c *gin.Context) string {
|
||||||
|
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
if xri := strings.TrimSpace(c.GetHeader("X-Real-IP")); xri != "" {
|
||||||
|
return xri
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return c.Request.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/auth"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/country"
|
||||||
|
"github.com/prism/proxy/internal/health"
|
||||||
|
"github.com/prism/proxy/internal/metrics"
|
||||||
|
applog "github.com/prism/proxy/internal/log"
|
||||||
|
"github.com/prism/proxy/internal/mihomoapi"
|
||||||
|
"github.com/prism/proxy/internal/service"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
"github.com/prism/proxy/internal/subscription"
|
||||||
|
"github.com/prism/proxy/internal/trafficlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
app *service.App
|
||||||
|
health *health.Checker
|
||||||
|
metrics *metrics.Collector
|
||||||
|
subscription *subscription.Service
|
||||||
|
logs *applog.Collector
|
||||||
|
traffic *trafficlog.Collector
|
||||||
|
loginLimiter *auth.LoginLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(app *service.App, hc *health.Checker, mc *metrics.Collector, sub *subscription.Service, logs *applog.Collector, traffic *trafficlog.Collector) *Server {
|
||||||
|
return &Server{
|
||||||
|
app: app,
|
||||||
|
health: hc,
|
||||||
|
metrics: mc,
|
||||||
|
subscription: sub,
|
||||||
|
logs: logs,
|
||||||
|
traffic: traffic,
|
||||||
|
loginLimiter: auth.NewLoginLimiter(app.Store),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) syncMihomoClient(ctx context.Context) {
|
||||||
|
base, secret, err := s.app.MihomoAPIBase(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.health.UpdateAPIBase(base, secret)
|
||||||
|
s.metrics.UpdateAPIBase(base, secret)
|
||||||
|
if s.traffic != nil {
|
||||||
|
s.traffic.UpdateClient(mihomoapi.New(base, secret))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Router() *gin.Engine {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery(), s.corsMiddleware())
|
||||||
|
|
||||||
|
r.GET("/api/v1/openapi.json", s.serveOpenAPIJSON)
|
||||||
|
r.GET("/api/v1/openapi.yaml", s.serveOpenAPIYAML)
|
||||||
|
r.GET("/api/docs", s.serveOpenAPIDocs)
|
||||||
|
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
api.POST("/auth/login", s.login)
|
||||||
|
api.GET("/auth/status", s.authStatus)
|
||||||
|
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(s.authMiddleware())
|
||||||
|
{
|
||||||
|
protected.GET("/dashboard", s.dashboard)
|
||||||
|
protected.GET("/connections", s.connections)
|
||||||
|
|
||||||
|
protected.GET("/subscriptions", s.listSubscriptions)
|
||||||
|
protected.POST("/subscriptions", s.createSubscription)
|
||||||
|
protected.PUT("/subscriptions/:id", s.updateSubscription)
|
||||||
|
protected.DELETE("/subscriptions/:id", s.deleteSubscription)
|
||||||
|
protected.POST("/subscriptions/:id/refresh", s.refreshSubscription)
|
||||||
|
|
||||||
|
protected.GET("/proxies", s.listProxies)
|
||||||
|
protected.POST("/proxies", s.createProxy)
|
||||||
|
protected.PUT("/proxies/:id", s.updateProxy)
|
||||||
|
protected.DELETE("/proxies/:id", s.deleteProxy)
|
||||||
|
protected.POST("/proxies/:id/test", s.testProxy)
|
||||||
|
protected.POST("/proxies/test-all", s.testAllProxies)
|
||||||
|
protected.POST("/proxies/rebuild-countries", s.rebuildCountries)
|
||||||
|
protected.GET("/proxies/export", s.exportProxies)
|
||||||
|
protected.POST("/proxies/import", s.importProxies)
|
||||||
|
protected.GET("/countries", s.listCountries)
|
||||||
|
|
||||||
|
protected.GET("/outbound", s.outboundOverview)
|
||||||
|
protected.PUT("/outbound/groups/:name", s.selectOutboundGroup)
|
||||||
|
protected.PUT("/outbound/default-policy", s.setDefaultPolicy)
|
||||||
|
|
||||||
|
protected.GET("/traffic-logs", s.listTrafficLogs)
|
||||||
|
|
||||||
|
protected.GET("/rules", s.listRules)
|
||||||
|
protected.POST("/rules", s.createRule)
|
||||||
|
protected.PUT("/rules/:id", s.updateRule)
|
||||||
|
protected.DELETE("/rules/:id", s.deleteRule)
|
||||||
|
protected.GET("/rules/merged", s.mergedRules)
|
||||||
|
protected.POST("/rules/test", s.testRule)
|
||||||
|
protected.GET("/rules/export", s.exportRules)
|
||||||
|
protected.POST("/rules/import", s.importRules)
|
||||||
|
|
||||||
|
protected.GET("/settings", s.getSettings)
|
||||||
|
protected.PUT("/settings", s.putSettings)
|
||||||
|
|
||||||
|
protected.GET("/api-keys", s.listAPIKeys)
|
||||||
|
protected.POST("/api-keys", s.createAPIKey)
|
||||||
|
protected.DELETE("/api-keys/:id", s.deleteAPIKey)
|
||||||
|
|
||||||
|
protected.GET("/logs", s.listLogs)
|
||||||
|
protected.GET("/audit-logs", s.listAuditLogs)
|
||||||
|
|
||||||
|
protected.GET("/system/status", s.systemStatus)
|
||||||
|
protected.POST("/system/reload", s.systemReload)
|
||||||
|
protected.GET("/system/geo", s.geoStatus)
|
||||||
|
protected.POST("/system/geo/update", s.geoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.NoRoute(s.serveSPA)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) corsMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key")
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dashboard(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
s.metrics.RefreshAsync(10 * time.Second)
|
||||||
|
s.app.RefreshEngineStatusAsync()
|
||||||
|
history, _ := s.app.Store.ListTrafficSnapshots(ctx, 60)
|
||||||
|
conns := s.metrics.CachedConnections()
|
||||||
|
policyGroups, _ := s.app.PolicyGroupRuntimes(ctx)
|
||||||
|
names, _ := s.app.ProxyDisplayNames(ctx)
|
||||||
|
liveDomainsAll, liveNodesAll := metrics.AggregateConnections(conns, policyGroups)
|
||||||
|
liveDomainsAll = metrics.ApplyDomainLabels(liveDomainsAll, names)
|
||||||
|
liveNodesAll = metrics.ApplyTrafficLabels(liveNodesAll, names)
|
||||||
|
|
||||||
|
liveDomainLimit, liveDomainOffset := parseLimitOffset(c.DefaultQuery("live_domain_limit", "50"), c.DefaultQuery("live_domain_offset", "0"))
|
||||||
|
liveNodeLimit, liveNodeOffset := parseLimitOffset(c.DefaultQuery("live_node_limit", "50"), c.DefaultQuery("live_node_offset", "0"))
|
||||||
|
periodDomainLimit, periodDomainOffset := parseLimitOffset(c.DefaultQuery("period_domain_limit", "50"), c.DefaultQuery("period_domain_offset", "0"))
|
||||||
|
periodNodeLimit, periodNodeOffset := parseLimitOffset(c.DefaultQuery("period_node_limit", "50"), c.DefaultQuery("period_node_offset", "0"))
|
||||||
|
|
||||||
|
liveDomains, liveDomainTotal := paginateSlice(liveDomainsAll, liveDomainLimit, liveDomainOffset)
|
||||||
|
liveNodes, liveNodeTotal := paginateSlice(liveNodesAll, liveNodeLimit, liveNodeOffset)
|
||||||
|
|
||||||
|
hostNodeTotals, _ := s.app.Store.AggregateTrafficByHostNode(ctx, 24, periodDomainLimit, periodDomainOffset)
|
||||||
|
nodeTotals, _ := s.app.Store.AggregateTrafficByNode(ctx, 24, periodNodeLimit, periodNodeOffset)
|
||||||
|
periodDomainTotal, _ := s.app.Store.CountAggregateTrafficByHostNode(ctx, 24)
|
||||||
|
periodNodeTotal, _ := s.app.Store.CountAggregateTrafficByNode(ctx, 24)
|
||||||
|
periodDomains := metrics.TrafficHostNodeAggToItems(hostNodeTotals, names)
|
||||||
|
periodNodes := metrics.TrafficAggToItems(nodeTotals, names)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"snapshot": s.metrics.Snapshot(ctx),
|
||||||
|
"history": history,
|
||||||
|
"connections": conns,
|
||||||
|
"engine_running": s.app.EngineStatusCached(),
|
||||||
|
"live_domains": liveDomains,
|
||||||
|
"live_nodes": liveNodes,
|
||||||
|
"live_domain_total": liveDomainTotal,
|
||||||
|
"live_node_total": liveNodeTotal,
|
||||||
|
"period_domains": periodDomains,
|
||||||
|
"period_nodes": periodNodes,
|
||||||
|
"period_domain_total": periodDomainTotal,
|
||||||
|
"period_node_total": periodNodeTotal,
|
||||||
|
"period_hours": 24,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) connections(c *gin.Context) {
|
||||||
|
conns, err := s.metrics.GetConnectionsCached(8 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"connections": conns})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listSubscriptions(c *gin.Context) {
|
||||||
|
items, err := s.app.Store.ListSubscriptions(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createSubscription(c *gin.Context) {
|
||||||
|
var sub store.Subscription
|
||||||
|
if err := c.ShouldBindJSON(&sub); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sub.Type == "" {
|
||||||
|
sub.Type = "auto"
|
||||||
|
}
|
||||||
|
if sub.IntervalSec <= 0 {
|
||||||
|
sub.IntervalSec = 3600
|
||||||
|
}
|
||||||
|
sub.Enabled = true
|
||||||
|
if err := s.app.Store.CreateSubscription(c.Request.Context(), &sub); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.Store.AddAuditLog(c.Request.Context(), "create", "subscription", sub.Name)
|
||||||
|
go func(id int64) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = s.subscription.FetchAndStore(ctx, id)
|
||||||
|
_ = s.app.ReloadEngine(ctx)
|
||||||
|
}(sub.ID)
|
||||||
|
c.JSON(http.StatusCreated, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateSubscription(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
sub, err := s.app.Store.GetSubscription(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(sub); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub.ID = id
|
||||||
|
if err := s.app.Store.UpdateSubscription(c.Request.Context(), sub); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteSubscription(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err := s.app.Store.DeleteSubscription(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.ReloadEngine(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) refreshSubscription(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err := s.subscription.FetchAndStore(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.app.ReloadEngine(c.Request.Context()); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "warning": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listProxies(c *gin.Context) {
|
||||||
|
source := c.Query("source")
|
||||||
|
country := c.Query("country")
|
||||||
|
var subID *int64
|
||||||
|
if v := c.Query("subscription_id"); v != "" {
|
||||||
|
id, _ := strconv.ParseInt(v, 10, 64)
|
||||||
|
subID = &id
|
||||||
|
}
|
||||||
|
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
|
||||||
|
items, err := s.app.Store.ListProxyNodesPage(c.Request.Context(), source, subID, country, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := s.app.Store.CountProxyNodes(c.Request.Context(), source, subID, country)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
type proxyReq struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
RawConfig string `json:"raw_config"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createProxy(c *gin.Context) {
|
||||||
|
var req proxyReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := store.ProxyNode{
|
||||||
|
Name: req.Name,
|
||||||
|
RuntimeName: store.RuntimeName("manual", 0, req.Name),
|
||||||
|
Type: req.Type,
|
||||||
|
RawConfig: req.RawConfig,
|
||||||
|
Source: "manual",
|
||||||
|
Country: country.Detect(req.Name),
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
n.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if err := s.app.Store.CreateProxyNode(c.Request.Context(), &n); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.ReloadEngine(c.Request.Context())
|
||||||
|
c.JSON(http.StatusCreated, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateProxy(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
n, err := s.app.Store.GetProxyNode(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req proxyReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name != "" {
|
||||||
|
n.Name = req.Name
|
||||||
|
n.Country = country.Detect(req.Name)
|
||||||
|
}
|
||||||
|
if req.Type != "" {
|
||||||
|
n.Type = req.Type
|
||||||
|
}
|
||||||
|
if req.RawConfig != "" {
|
||||||
|
n.RawConfig = req.RawConfig
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
n.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if err := s.app.Store.UpdateProxyNode(c.Request.Context(), n); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.ReloadEngine(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteProxy(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
n, err := s.app.Store.GetProxyNode(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n.Source != "manual" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "only manual proxies can be deleted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.app.Store.DeleteProxyNode(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.ReloadEngine(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) testProxy(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
latency, err := s.health.TestNode(c.Request.Context(), id, s.app.ReloadEngine)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"latency_ms": -1, "alive": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"latency_ms": latency, "alive": latency >= 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) testAllProxies(c *gin.Context) {
|
||||||
|
if err := s.health.TestAll(c.Request.Context(), s.app.ReloadEngine); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listRules(c *gin.Context) {
|
||||||
|
source := c.DefaultQuery("source", "manual")
|
||||||
|
items, err := s.app.Store.ListRules(c.Request.Context(), source)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createRule(c *gin.Context) {
|
||||||
|
var r store.Rule
|
||||||
|
if err := c.ShouldBindJSON(&r); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Source = "manual"
|
||||||
|
r.Enabled = true
|
||||||
|
if r.Priority <= 0 {
|
||||||
|
p, _ := s.app.Store.NextManualRulePriority(c.Request.Context())
|
||||||
|
r.Priority = p
|
||||||
|
}
|
||||||
|
if err := s.app.Store.CreateRule(c.Request.Context(), &r); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.ReloadEngine(c.Request.Context())
|
||||||
|
c.JSON(http.StatusCreated, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateRule(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
r, err := s.app.Store.GetRule(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(r); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.ID = id
|
||||||
|
if err := s.app.Store.UpdateRule(c.Request.Context(), r); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.ReloadEngine(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteRule(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err := s.app.Store.DeleteRule(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.ReloadEngine(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listLogs(c *gin.Context) {
|
||||||
|
level := c.Query("level")
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
|
||||||
|
items, total, err := s.app.Store.ListRequestLogs(c.Request.Context(), level, keyword, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listAuditLogs(c *gin.Context) {
|
||||||
|
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
|
||||||
|
items, err := s.app.Store.ListAuditLogs(c.Request.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := s.app.Store.CountAuditLogs(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) systemStatus(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"engine_running": s.app.EngineReachable(c.Request.Context()),
|
||||||
|
"has_config": len(s.app.Cache.GetLastYAML()) > 0,
|
||||||
|
"last_reload_error": s.app.LastReloadError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) systemReload(c *gin.Context) {
|
||||||
|
if err := s.app.ReloadEngine(c.Request.Context()); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type createAPIKeyReq struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listAPIKeys(c *gin.Context) {
|
||||||
|
items, err := s.app.Store.ListAPIKeys(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createAPIKey(c *gin.Context) {
|
||||||
|
var req createAPIKeyReq
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := auth.GenerateAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := s.app.Store.CreateAPIKey(
|
||||||
|
c.Request.Context(),
|
||||||
|
name,
|
||||||
|
auth.APIKeyDisplayPrefix(raw),
|
||||||
|
auth.HashAPIKey(raw),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.Store.AddAuditLog(c.Request.Context(), "create", "api_key", name)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": item.ID,
|
||||||
|
"name": item.Name,
|
||||||
|
"key": raw,
|
||||||
|
"key_prefix": item.KeyPrefix,
|
||||||
|
"created_at": item.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteAPIKey(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err := s.app.Store.DeleteAPIKey(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.Store.AddAuditLog(c.Request.Context(), "delete", "api_key", strconv.FormatInt(id, 10))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeRequest(c *gin.Context, settings map[string]string) bool {
|
||||||
|
if settings["auth_enabled"] != "true" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if key := strings.TrimSpace(c.GetHeader("X-API-Key")); key != "" {
|
||||||
|
if ok, _ := s.app.Store.VerifyAPIKey(ctx, auth.HashAPIKey(key)); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header := c.GetHeader("Authorization")
|
||||||
|
if !strings.HasPrefix(header, "Bearer ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if auth.IsAPIKeyFormat(token) {
|
||||||
|
ok, _ := s.app.Store.VerifyAPIKey(ctx, auth.HashAPIKey(token))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
return verifyToken(settings["admin_password"], token)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/prism/proxy/internal/auth"
|
||||||
|
"github.com/prism/proxy/internal/service"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthorizeRequestWithAPIKey(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := st.SetSettings(ctx, map[string]string{
|
||||||
|
"auth_enabled": "true",
|
||||||
|
"admin_password": "secret",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := auth.GenerateAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateAPIKey(ctx, "test", auth.APIKeyDisplayPrefix(raw), auth.HashAPIKey(raw)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{app: &service.App{Store: st}}
|
||||||
|
settings, _ := st.GetSettings(ctx)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest("GET", "/api/v1/dashboard", nil)
|
||||||
|
c.Request.Header.Set("X-API-Key", raw)
|
||||||
|
if !s.authorizeRequest(c, settings) {
|
||||||
|
t.Fatal("expected api key to authorize")
|
||||||
|
}
|
||||||
|
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
c2, _ := gin.CreateTestContext(w2)
|
||||||
|
c2.Request = httptest.NewRequest("GET", "/api/v1/dashboard", nil)
|
||||||
|
c2.Request.Header.Set("Authorization", "Bearer "+raw)
|
||||||
|
if !s.authorizeRequest(c2, settings) {
|
||||||
|
t.Fatal("expected bearer api key to authorize")
|
||||||
|
}
|
||||||
|
|
||||||
|
w3 := httptest.NewRecorder()
|
||||||
|
c3, _ := gin.CreateTestContext(w3)
|
||||||
|
c3.Request = httptest.NewRequest("GET", "/api/v1/dashboard", nil)
|
||||||
|
c3.Request.Header.Set("Authorization", "Bearer prism_invalid")
|
||||||
|
if s.authorizeRequest(c3, settings) {
|
||||||
|
t.Fatal("expected invalid key to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginReq struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
settings, err := s.app.Store.GetSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.authorizeRequest(c, settings) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authStatus(c *gin.Context) {
|
||||||
|
settings, err := s.app.Store.GetSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"enabled": settings["auth_enabled"] == "true"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) login(c *gin.Context) {
|
||||||
|
var req loginReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
settings, err := s.app.Store.GetSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := clientIP(c)
|
||||||
|
cfg := auth.ParseLoginConfig(settings)
|
||||||
|
check, err := s.loginLimiter.Check(ctx, ip, cfg)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !check.Allowed {
|
||||||
|
writeLoginLocked(c, check.RetryAfter)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := settings["admin_password"]
|
||||||
|
if !checkPassword(req.Password, hash) {
|
||||||
|
time.Sleep(loginFailureDelay())
|
||||||
|
result, err := s.loginLimiter.RecordFailure(ctx, ip, cfg)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Locked {
|
||||||
|
writeLoginLocked(c, result.RetryAfter)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"error": "invalid password",
|
||||||
|
"remaining": result.Remaining,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.loginLimiter.RecordSuccess(ctx, ip); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := issueToken(hash, 24*time.Hour)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLoginLocked(c *gin.Context, retryAfter time.Duration) {
|
||||||
|
secs := int(retryAfter.Seconds())
|
||||||
|
if secs < 1 {
|
||||||
|
secs = 1
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||||
|
"error": "too many login attempts",
|
||||||
|
"retry_after": secs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginFailureDelay() time.Duration {
|
||||||
|
return 300*time.Millisecond + time.Duration(rand.Intn(200))*time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPassword(password, stored string) bool {
|
||||||
|
if strings.HasPrefix(stored, "$2a$") {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
return password == stored
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) listCountries(c *gin.Context) {
|
||||||
|
items, err := s.app.Store.ListCountryStats(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) rebuildCountries(c *gin.Context) {
|
||||||
|
n, err := s.app.Store.RebuildAllNodeCountries(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.ReloadEngine(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, gin.H{"updated": n})
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/geodata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) geoStatus(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
settings, err := s.app.Store.GetSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := geodata.ConfigFromSettings(settings)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ready": geodata.Ready(s.app.DataDir),
|
||||||
|
"auto_download": settings["geo_auto_download"] != "false",
|
||||||
|
"files": geodata.Status(s.app.DataDir, cfg),
|
||||||
|
"default_urls": geodata.DefaultConfig(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type geoUpdateReq struct {
|
||||||
|
Force bool `json:"force"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) geoUpdate(c *gin.Context) {
|
||||||
|
var req geoUpdateReq
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
settings, err := s.app.Store.GetSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := geodata.ConfigFromSettings(settings)
|
||||||
|
geodata.ApplyMihomoURLs(cfg)
|
||||||
|
|
||||||
|
updated, err := geodata.Ensure(s.app.DataDir, cfg, req.Force)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.app.Store.AddAuditLog(ctx, "update", "geo", "updated: "+joinNames(updated))
|
||||||
|
s.logs.Add("info", "prism", "geo data updated")
|
||||||
|
_ = s.app.ReloadEngine(ctx)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"updated": updated,
|
||||||
|
"ready": geodata.Ready(s.app.DataDir),
|
||||||
|
"files": geodata.Status(s.app.DataDir, cfg),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinNames(names []string) string {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return "none (already present)"
|
||||||
|
}
|
||||||
|
out := names[0]
|
||||||
|
for i := 1; i < len(names); i++ {
|
||||||
|
out += ", " + names[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func geoDataReady(dataDir string) bool {
|
||||||
|
return geodata.Ready(dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGeoSettings(dataDir string, settings map[string]string) {
|
||||||
|
geodata.InitHomeDir(dataDir)
|
||||||
|
geodata.ApplyMihomoURLs(geodata.ConfigFromSettings(settings))
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) outboundOverview(c *gin.Context) {
|
||||||
|
data, err := s.app.OutboundOverview(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type selectOutboundReq struct {
|
||||||
|
Proxy string `json:"proxy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) selectOutboundGroup(c *gin.Context) {
|
||||||
|
name := c.Param("name")
|
||||||
|
var req selectOutboundReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.Proxy == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "proxy required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.app.SelectOutboundGroup(c.Request.Context(), name, req.Proxy); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
type defaultPolicyReq struct {
|
||||||
|
Policy string `json:"policy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setDefaultPolicy(c *gin.Context) {
|
||||||
|
var req defaultPolicyReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.Policy == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "policy required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.app.SetDefaultPolicy(c.Request.Context(), req.Policy); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.syncMihomoClient(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/proxyio"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) exportProxies(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
items, err := s.app.Store.ListProxyNodes(ctx, "manual", nil)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format := strings.ToLower(c.DefaultQuery("format", "json"))
|
||||||
|
filename := "prism-proxies"
|
||||||
|
switch format {
|
||||||
|
case "text", "txt", "links":
|
||||||
|
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="`+filename+`.txt"`)
|
||||||
|
c.String(http.StatusOK, proxyio.ExportLinks(items))
|
||||||
|
default:
|
||||||
|
data, err := proxyio.ExportJSON(items)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="`+filename+`.json"`)
|
||||||
|
c.Data(http.StatusOK, "application/json; charset=utf-8", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type importProxiesReq struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Nodes []proxyio.ExportNode `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) importProxies(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(c.DefaultQuery("mode", "")))
|
||||||
|
|
||||||
|
var nodes []store.ProxyNode
|
||||||
|
var parseErrors []string
|
||||||
|
|
||||||
|
contentType := c.GetHeader("Content-Type")
|
||||||
|
if strings.Contains(contentType, "application/json") {
|
||||||
|
var req importProxiesReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = strings.ToLower(strings.TrimSpace(req.Mode))
|
||||||
|
}
|
||||||
|
if len(req.Nodes) > 0 {
|
||||||
|
var valErrors []string
|
||||||
|
nodes, valErrors = proxyio.ToStoreNodes(req.Nodes)
|
||||||
|
parseErrors = append(parseErrors, valErrors...)
|
||||||
|
} else if strings.TrimSpace(req.Text) != "" {
|
||||||
|
nodes, parseErrors = proxyio.ParseText(req.Text)
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "nodes or text required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = "append"
|
||||||
|
}
|
||||||
|
nodes, parseErrors = proxyio.ParseText(string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "" {
|
||||||
|
mode = "append"
|
||||||
|
}
|
||||||
|
if mode != "append" && mode != "replace" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be append or replace"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid proxies", "errors": parseErrors})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "append" {
|
||||||
|
existing, _ := s.app.Store.ListProxyNodes(ctx, "manual", nil)
|
||||||
|
proxyio.AssignUniqueNames(nodes, proxyio.ReservedNames(existing))
|
||||||
|
if err := s.app.Store.AppendManualProxies(ctx, nodes); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
proxyio.AssignUniqueNames(nodes, nil)
|
||||||
|
if err := s.app.Store.ReplaceManualProxies(ctx, nodes); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.app.Store.AddAuditLog(ctx, "import", "proxies", mode+": "+strconv.Itoa(len(nodes)))
|
||||||
|
_ = s.app.ReloadEngine(ctx)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"imported": len(nodes),
|
||||||
|
"skipped": len(parseErrors),
|
||||||
|
"errors": parseErrors,
|
||||||
|
"mode": mode,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/rulematch"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testRuleReq struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mergedRules(c *gin.Context) {
|
||||||
|
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
|
||||||
|
items, err := s.app.Store.ListMergedRulesPage(c.Request.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := s.app.Store.CountMergedRules(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, _ := s.app.Store.GetSettings(c.Request.Context())
|
||||||
|
policy := settings["default_policy"]
|
||||||
|
if policy == "" {
|
||||||
|
policy = "DIRECT"
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"default_policy": policy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) testRule(c *gin.Context) {
|
||||||
|
var req testRuleReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.URL) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "url required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
rules, err := s.app.Store.ListRules(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, _ := s.app.Store.GetSettings(ctx)
|
||||||
|
policy := settings["default_policy"]
|
||||||
|
geoReady := geoDataReady(s.app.DataDir)
|
||||||
|
result := rulematch.Match(rules, policy, req.URL, geoReady)
|
||||||
|
if result.SkippedGeo > 0 {
|
||||||
|
result.Note = appendNote(result.Note, "已跳过 "+strconv.Itoa(result.SkippedGeo)+" 条 Geo 规则(本地测试不支持 GEOIP/GEOSITE)")
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendNote(existing, extra string) string {
|
||||||
|
if existing == "" {
|
||||||
|
return extra
|
||||||
|
}
|
||||||
|
return existing + ";" + extra
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/ruleio"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) exportRules(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
items, err := s.app.Store.ListRules(ctx, "manual")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format := strings.ToLower(c.DefaultQuery("format", "json"))
|
||||||
|
filename := "prism-rules"
|
||||||
|
switch format {
|
||||||
|
case "text", "txt", "clash":
|
||||||
|
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="`+filename+`.txt"`)
|
||||||
|
c.String(http.StatusOK, ruleio.ExportText(items))
|
||||||
|
default:
|
||||||
|
data, err := ruleio.ExportJSON(items)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="`+filename+`.json"`)
|
||||||
|
c.Data(http.StatusOK, "application/json; charset=utf-8", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type importRulesReq struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Rules []ruleio.ExportRule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) importRules(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(c.DefaultQuery("mode", "")))
|
||||||
|
|
||||||
|
var parsed []ruleio.ExportRule
|
||||||
|
var parseErrors []string
|
||||||
|
|
||||||
|
contentType := c.GetHeader("Content-Type")
|
||||||
|
if strings.Contains(contentType, "application/json") {
|
||||||
|
var req importRulesReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = strings.ToLower(strings.TrimSpace(req.Mode))
|
||||||
|
}
|
||||||
|
if len(req.Rules) > 0 {
|
||||||
|
parsed = req.Rules
|
||||||
|
} else if strings.TrimSpace(req.Text) != "" {
|
||||||
|
parsed, parseErrors = parseImportText(req.Text)
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "rules or text required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = "append"
|
||||||
|
}
|
||||||
|
parsed, parseErrors = parseImportText(string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "" {
|
||||||
|
mode = "append"
|
||||||
|
}
|
||||||
|
if mode != "append" && mode != "replace" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be append or replace"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(parsed) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rules", "errors": parseErrors})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, valErrors := ruleio.ToStoreRules(parsed)
|
||||||
|
allErrors := append(parseErrors, valErrors...)
|
||||||
|
if len(rules) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rules", "errors": allErrors})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "append" {
|
||||||
|
start, _ := s.app.Store.NextManualRulePriority(ctx)
|
||||||
|
ruleio.AssignPriorities(rules, start)
|
||||||
|
if err := s.app.Store.AppendManualRules(ctx, rules); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ruleio.AssignPriorities(rules, 100)
|
||||||
|
if err := s.app.Store.ReplaceManualRules(ctx, rules); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.app.Store.AddAuditLog(ctx, "import", "rules", mode+": "+strconv.Itoa(len(rules)))
|
||||||
|
_ = s.app.ReloadEngine(ctx)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"imported": len(rules),
|
||||||
|
"skipped": len(allErrors),
|
||||||
|
"errors": allErrors,
|
||||||
|
"mode": mode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportText(text string) ([]ruleio.ExportRule, []string) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(text, "{") || strings.HasPrefix(text, "[") {
|
||||||
|
rules, err := ruleio.ParseJSON([]byte(text))
|
||||||
|
if err == nil && len(rules) > 0 {
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ruleio.ParseText(text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
appsettings "github.com/prism/proxy/internal/settings"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) getSettings(c *gin.Context) {
|
||||||
|
settings, err := s.app.Store.GetSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
safe := make(map[string]string)
|
||||||
|
for k, v := range settings {
|
||||||
|
if k == appsettings.KeyAdminPassword || k == appsettings.KeyProxyAuthPass {
|
||||||
|
safe[k] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
safe[k] = v
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, appsettings.Display(safe))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) putSettings(c *gin.Context) {
|
||||||
|
var req map[string]string
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pw, ok := req[appsettings.KeyAdminPassword]; ok && pw != "" {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req[appsettings.KeyAdminPassword] = string(hash)
|
||||||
|
} else {
|
||||||
|
delete(req, appsettings.KeyAdminPassword)
|
||||||
|
}
|
||||||
|
if err := normalizeProxyAuthSettings(c.Request.Context(), s.app.Store, req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appsettings.NormalizeSubmit(req)
|
||||||
|
if err := s.app.Store.SetSettings(c.Request.Context(), req); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allSettings, _ := s.app.Store.GetSettings(c.Request.Context())
|
||||||
|
applyGeoSettings(s.app.DataDir, allSettings)
|
||||||
|
base, secret, _ := s.app.MihomoAPIBase(c.Request.Context())
|
||||||
|
s.health.UpdateAPIBase(base, secret)
|
||||||
|
s.metrics.UpdateAPIBase(base, secret)
|
||||||
|
s.syncMihomoClient(c.Request.Context())
|
||||||
|
_ = s.app.ReloadEngine(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProxyAuthSettings(ctx context.Context, st *store.Store, req map[string]string) error {
|
||||||
|
user, hasUser := req["proxy_auth_user"]
|
||||||
|
pass, hasPass := req["proxy_auth_pass"]
|
||||||
|
if !hasUser && !hasPass {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
user = strings.TrimSpace(user)
|
||||||
|
if user == "" {
|
||||||
|
req["proxy_auth_user"] = ""
|
||||||
|
req["proxy_auth_pass"] = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.Contains(user, ":") {
|
||||||
|
return fmt.Errorf("代理用户名不能包含冒号")
|
||||||
|
}
|
||||||
|
req["proxy_auth_user"] = user
|
||||||
|
if !hasPass || pass == "" {
|
||||||
|
current, err := st.GetSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing := current["proxy_auth_pass"]; existing != "" {
|
||||||
|
delete(req, "proxy_auth_pass")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("请填写代理密码")
|
||||||
|
}
|
||||||
|
req["proxy_auth_pass"] = pass
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) listTrafficLogs(c *gin.Context) {
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
|
||||||
|
items, total, err := s.app.Store.ListProxyRequestLogs(c.Request.Context(), keyword, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed openapi/spec.yaml
|
||||||
|
var openAPISpecYAML []byte
|
||||||
|
|
||||||
|
//go:embed openapi/docs.html
|
||||||
|
var openAPIDocsHTML []byte
|
||||||
|
|
||||||
|
var (
|
||||||
|
openAPIJSONOnce sync.Once
|
||||||
|
openAPIJSON []byte
|
||||||
|
openAPIJSONErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadOpenAPIJSON() {
|
||||||
|
var doc any
|
||||||
|
if err := yaml.Unmarshal(openAPISpecYAML, &doc); err != nil {
|
||||||
|
openAPIJSONErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openAPIJSON, openAPIJSONErr = json.Marshal(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveOpenAPIJSON(c *gin.Context) {
|
||||||
|
openAPIJSONOnce.Do(loadOpenAPIJSON)
|
||||||
|
if openAPIJSONErr != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": openAPIJSONErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=300")
|
||||||
|
c.Data(http.StatusOK, "application/json; charset=utf-8", openAPIJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveOpenAPIYAML(c *gin.Context) {
|
||||||
|
c.Header("Cache-Control", "public, max-age=300")
|
||||||
|
c.Data(http.StatusOK, "application/yaml; charset=utf-8", openAPISpecYAML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveOpenAPIDocs(c *gin.Context) {
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", openAPIDocsHTML)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Prism API 文档</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||||
|
<style>
|
||||||
|
html { box-sizing: border-box; overflow-y: scroll; }
|
||||||
|
*, *:before, *:after { box-sizing: inherit; }
|
||||||
|
body { margin: 0; background: #fafafa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function () {
|
||||||
|
SwaggerUIBundle({
|
||||||
|
url: '/api/v1/openapi.json',
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
persistAuthorization: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Prism Management API
|
||||||
|
description: |
|
||||||
|
Prism 代理网关管理接口(默认端口 8080)。
|
||||||
|
|
||||||
|
除 `auth/login`、`auth/status` 与 OpenAPI 文档外,启用登录后需在请求头携带以下任一凭证:
|
||||||
|
- `Authorization: Bearer <token>`(登录接口返回的短期 token)
|
||||||
|
- `Authorization: Bearer <api_key>` 或 `X-API-Key: <api_key>`(在系统设置中申请的 API Key)
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: Prism
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
description: 管理 API v1
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Auth
|
||||||
|
- name: Dashboard
|
||||||
|
- name: Subscriptions
|
||||||
|
- name: Proxies
|
||||||
|
- name: Countries
|
||||||
|
- name: Outbound
|
||||||
|
- name: Rules
|
||||||
|
- name: TrafficLogs
|
||||||
|
- name: Settings
|
||||||
|
- name: System
|
||||||
|
- name: Logs
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/auth/login:
|
||||||
|
post:
|
||||||
|
tags: [Auth]
|
||||||
|
summary: 登录
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [password]
|
||||||
|
properties:
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 登录成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'429':
|
||||||
|
description: 登录尝试过多
|
||||||
|
|
||||||
|
/auth/status:
|
||||||
|
get:
|
||||||
|
tags: [Auth]
|
||||||
|
summary: 是否启用登录认证
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
/api-keys:
|
||||||
|
get:
|
||||||
|
tags: [Auth]
|
||||||
|
summary: API Key 列表
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
post:
|
||||||
|
tags: [Auth]
|
||||||
|
summary: 申请 API Key
|
||||||
|
description: 完整 Key 仅在创建响应中返回一次,请妥善保存
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
|
||||||
|
/api-keys/{id}:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/IdPath'
|
||||||
|
delete:
|
||||||
|
tags: [Auth]
|
||||||
|
summary: 吊销 API Key
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/dashboard:
|
||||||
|
get:
|
||||||
|
tags: [Dashboard]
|
||||||
|
summary: 监控大盘
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: live_domain_limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 500
|
||||||
|
- name: live_domain_offset
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
- name: live_node_limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 500
|
||||||
|
- name: live_node_offset
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
- name: period_domain_limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 500
|
||||||
|
- name: period_domain_offset
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
- name: period_node_limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 500
|
||||||
|
- name: period_node_offset
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/connections:
|
||||||
|
get:
|
||||||
|
tags: [Dashboard]
|
||||||
|
summary: 当前活跃连接
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/subscriptions:
|
||||||
|
get:
|
||||||
|
tags: [Subscriptions]
|
||||||
|
summary: 订阅列表
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
post:
|
||||||
|
tags: [Subscriptions]
|
||||||
|
summary: 创建订阅
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Subscription'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
|
||||||
|
/subscriptions/{id}:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/IdPath'
|
||||||
|
put:
|
||||||
|
tags: [Subscriptions]
|
||||||
|
summary: 更新订阅
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Subscription'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
delete:
|
||||||
|
tags: [Subscriptions]
|
||||||
|
summary: 删除订阅
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/subscriptions/{id}/refresh:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/IdPath'
|
||||||
|
post:
|
||||||
|
tags: [Subscriptions]
|
||||||
|
summary: 刷新订阅
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/proxies:
|
||||||
|
get:
|
||||||
|
tags: [Proxies]
|
||||||
|
summary: 节点列表
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: source
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [manual, subscription]
|
||||||
|
- name: subscription_id
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: country
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 500
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
post:
|
||||||
|
tags: [Proxies]
|
||||||
|
summary: 添加手动节点
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
raw_config:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
|
||||||
|
/proxies/export:
|
||||||
|
get:
|
||||||
|
tags: [Proxies]
|
||||||
|
summary: 导出手动节点
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: format
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [json, links, text, txt]
|
||||||
|
default: json
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 文件下载
|
||||||
|
|
||||||
|
/proxies/import:
|
||||||
|
post:
|
||||||
|
tags: [Proxies]
|
||||||
|
summary: 导入手动节点
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [append, replace]
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
nodes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/proxies/{id}:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/IdPath'
|
||||||
|
put:
|
||||||
|
tags: [Proxies]
|
||||||
|
summary: 更新节点
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
delete:
|
||||||
|
tags: [Proxies]
|
||||||
|
summary: 删除手动节点
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/proxies/{id}/test:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/IdPath'
|
||||||
|
post:
|
||||||
|
tags: [Proxies]
|
||||||
|
summary: 单节点测速
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/proxies/test-all:
|
||||||
|
post:
|
||||||
|
tags: [Proxies]
|
||||||
|
summary: 全部节点测速
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/proxies/rebuild-countries:
|
||||||
|
post:
|
||||||
|
tags: [Proxies]
|
||||||
|
summary: 重新识别节点国家
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/countries:
|
||||||
|
get:
|
||||||
|
tags: [Countries]
|
||||||
|
summary: 国家统计
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/outbound:
|
||||||
|
get:
|
||||||
|
tags: [Outbound]
|
||||||
|
summary: 出站概览
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/outbound/groups/{name}:
|
||||||
|
parameters:
|
||||||
|
- name: name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
put:
|
||||||
|
tags: [Outbound]
|
||||||
|
summary: 切换策略组节点
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [proxy]
|
||||||
|
properties:
|
||||||
|
proxy:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/outbound/default-policy:
|
||||||
|
put:
|
||||||
|
tags: [Outbound]
|
||||||
|
summary: 设置默认出站
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [policy]
|
||||||
|
properties:
|
||||||
|
policy:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/traffic-logs:
|
||||||
|
get:
|
||||||
|
tags: [TrafficLogs]
|
||||||
|
summary: 请求日志
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: keyword
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/rules:
|
||||||
|
get:
|
||||||
|
tags: [Rules]
|
||||||
|
summary: 规则列表
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: source
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
post:
|
||||||
|
tags: [Rules]
|
||||||
|
summary: 添加手动规则
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
|
||||||
|
/rules/export:
|
||||||
|
get:
|
||||||
|
tags: [Rules]
|
||||||
|
summary: 导出手动规则
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: format
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [json, text, txt, clash]
|
||||||
|
default: json
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 文件下载
|
||||||
|
|
||||||
|
/rules/import:
|
||||||
|
post:
|
||||||
|
tags: [Rules]
|
||||||
|
summary: 导入手动规则
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [append, replace]
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/rules/merged:
|
||||||
|
get:
|
||||||
|
tags: [Rules]
|
||||||
|
summary: 合成规则预览
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/rules/test:
|
||||||
|
post:
|
||||||
|
tags: [Rules]
|
||||||
|
summary: 规则命中测试
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [url]
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/rules/{id}:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/IdPath'
|
||||||
|
put:
|
||||||
|
tags: [Rules]
|
||||||
|
summary: 更新规则
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
delete:
|
||||||
|
tags: [Rules]
|
||||||
|
summary: 删除手动规则
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/settings:
|
||||||
|
get:
|
||||||
|
tags: [Settings]
|
||||||
|
summary: 获取系统设置
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
put:
|
||||||
|
tags: [Settings]
|
||||||
|
summary: 更新系统设置
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/logs:
|
||||||
|
get:
|
||||||
|
tags: [Logs]
|
||||||
|
summary: 运行日志
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: level
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/audit-logs:
|
||||||
|
get:
|
||||||
|
tags: [Logs]
|
||||||
|
summary: 审计日志
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/system/status:
|
||||||
|
get:
|
||||||
|
tags: [System]
|
||||||
|
summary: 系统状态
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/system/reload:
|
||||||
|
post:
|
||||||
|
tags: [System]
|
||||||
|
summary: 重载内核
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/system/geo:
|
||||||
|
get:
|
||||||
|
tags: [System]
|
||||||
|
summary: Geo 数据状态
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/system/geo/update:
|
||||||
|
post:
|
||||||
|
tags: [System]
|
||||||
|
summary: 更新 Geo 数据
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
force:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: 登录 token 或 API Key(prism_ 开头)
|
||||||
|
apiKeyAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-API-Key
|
||||||
|
description: 系统设置中申请的 API Key
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
IdPath:
|
||||||
|
name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
Subscription:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: auto / clash / links
|
||||||
|
interval_sec:
|
||||||
|
type: integer
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
responses:
|
||||||
|
Unauthorized:
|
||||||
|
description: 未授权
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServeOpenAPIJSON(t *testing.T) {
|
||||||
|
s := &Server{}
|
||||||
|
r := s.Router()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||||
|
t.Fatalf("content-type: %s", ct)
|
||||||
|
}
|
||||||
|
var doc map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if doc["openapi"] != "3.0.3" {
|
||||||
|
t.Fatalf("unexpected openapi version: %v", doc["openapi"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeOpenAPIDocs(t *testing.T) {
|
||||||
|
s := &Server{}
|
||||||
|
r := s.Router()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/docs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "swagger-ui") {
|
||||||
|
t.Fatal("expected swagger ui html")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPageLimit = 50
|
||||||
|
maxPageLimit = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseLimitOffset(limitStr, offsetStr string) (limit, offset int) {
|
||||||
|
limit, _ = strconv.Atoi(limitStr)
|
||||||
|
offset, _ = strconv.Atoi(offsetStr)
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultPageLimit
|
||||||
|
}
|
||||||
|
if limit > maxPageLimit {
|
||||||
|
limit = maxPageLimit
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
return limit, offset
|
||||||
|
}
|
||||||
|
|
||||||
|
func paginateSlice[T any](items []T, limit, offset int) ([]T, int) {
|
||||||
|
total := len(items)
|
||||||
|
if offset >= total {
|
||||||
|
return nil, total
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
return items[offset:end], total
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
func (s *Server) serveSPA(c *gin.Context) {
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "static fs error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(sub, path)
|
||||||
|
if err != nil {
|
||||||
|
data, err = fs.ReadFile(sub, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentType := "text/html; charset=utf-8"
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, ".js"):
|
||||||
|
contentType = "application/javascript"
|
||||||
|
case strings.HasSuffix(path, ".css"):
|
||||||
|
contentType = "text/css"
|
||||||
|
case strings.HasSuffix(path, ".svg"):
|
||||||
|
contentType = "image/svg+xml"
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, contentType, data)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{Kt as e,R as t,Ut as n,V as r,b as i,d as a,m as o,p as s}from"./client-CiT_nDm8.js";var c={class:`page-header`},l={class:`page-title`},u={key:0,class:`page-desc`},d={key:0,class:`page-actions`},f=i({__name:`PageHeader`,props:{title:{},description:{}},setup(n){return(i,f)=>(t(),o(`div`,c,[a(`div`,null,[a(`h1`,l,e(n.title),1),n.description?(t(),o(`p`,u,e(n.description),1)):s(``,!0)]),i.$slots.actions?(t(),o(`div`,d,[r(i.$slots,`actions`)])):s(``,!0)]))}}),p={class:`app-card`},m={key:0,class:`app-card__header`},h={class:`app-card__title`},g=i({__name:`AppCard`,props:{title:{},padded:{type:Boolean}},setup(i){return(c,l)=>(t(),o(`div`,p,[i.title||c.$slots.header?(t(),o(`div`,m,[r(c.$slots,`header`,{},()=>[a(`h3`,h,e(i.title),1)])])):s(``,!0),a(`div`,{class:n(i.padded?`app-card__body--padded`:``)},[r(c.$slots,`default`)],2)]))}});export{f as n,g as t};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{H as e,R as t,_ as n,b as r,d as i,et as a,m as o,ut as s,v as c,y as l}from"./client-CiT_nDm8.js";import{i as u,n as d,o as f,t as p}from"./index-B4K9RppJ.js";var m={class:`login-page`},h={class:`login-wrap`},g={class:`login-card`},_=r({__name:`LoginView`,setup(r){let _=d(),v=p(),y=s(``),b=s(!1);async function x(){b.value=!0;try{await v.login(y.value),_.push(`/`)}catch{u.error(`密码错误`)}finally{b.value=!1}}return(r,s)=>{let u=e(`el-input`),d=e(`el-form-item`),p=e(`el-button`),_=e(`el-form`);return t(),o(`div`,m,[i(`div`,h,[s[4]||=n(`<div class="login-brand-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path><path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path><path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path></svg></div><h1 class="page-title">Prism</h1><p class="page-desc">HTTP / SOCKS5 代理网关管理台</p>`,3),i(`div`,g,[s[2]||=i(`h3`,{class:`app-card__title`,style:{"margin-bottom":`24px`}},`登录`,-1),l(_,{size:`large`,onSubmit:f(x,[`prevent`])},{default:a(()=>[l(d,{label:`密码`},{default:a(()=>[l(u,{modelValue:y.value,"onUpdate:modelValue":s[0]||=e=>y.value=e,type:`password`,"show-password":``,placeholder:`默认 admin`},null,8,[`modelValue`])]),_:1}),l(p,{type:`primary`,"native-type":`submit`,loading:b.value,style:{width:`100%`}},{default:a(()=>[...s[1]||=[c(` 登录 `,-1)]]),_:1},8,[`loading`])]),_:1}),s[3]||=i(`p`,{class:`form-hint`,style:{"margin-top":`16px`,"text-align":`center`}},` 首次使用默认密码为 admin,请在设置中修改 `,-1)])])])}}});export{_ as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,H as t,R as n,U as r,b as i,d as a,et as o,f as s,m as c,p as l,t as u,tt as d,ut as f,v as p,y as m}from"./client-CiT_nDm8.js";import{a as h}from"./index-B4K9RppJ.js";import{n as g,t as _}from"./AppCard-ClnBgZ-s.js";var v={class:`page`},y={class:`tabs-bar`},b={key:0,class:`card-toolbar`},x={key:2,class:`card-pager`},S=50,C=i({__name:`LogsView`,setup(i){let C=f([]),w=f([]),T=f(0),E=f(!1),D=f(``),O=f(``),k=f(1),A=f(`runtime`);async function j(){E.value=!0;try{if(A.value===`audit`){let{data:e}=await u.get(`/audit-logs`);w.value=e.items||[];return}let{data:e}=await u.get(`/logs`,{params:{level:D.value,keyword:O.value,limit:S,offset:(k.value-1)*S}});C.value=e.items||[],T.value=e.total||0}finally{E.value=!1}}return e(j),(e,i)=>{let u=t(`el-tab-pane`),f=t(`el-tabs`),M=t(`el-option`),N=t(`el-select`),P=t(`el-input`),F=t(`el-button`),I=t(`el-table-column`),L=t(`el-table`),R=t(`el-pagination`),z=r(`loading`);return n(),c(`div`,v,[m(g,{title:`运行日志`,description:`Prism 与代理内核运行日志`}),m(_,null,{default:o(()=>[a(`div`,y,[m(f,{modelValue:A.value,"onUpdate:modelValue":i[0]||=e=>A.value=e,onTabChange:j},{default:o(()=>[m(u,{label:`运行日志`,name:`runtime`}),m(u,{label:`审计日志`,name:`audit`})]),_:1},8,[`modelValue`])]),A.value===`runtime`?(n(),c(`div`,b,[m(N,{modelValue:D.value,"onUpdate:modelValue":i[1]||=e=>D.value=e,placeholder:`级别`,clearable:``,style:{width:`120px`},onChange:j},{default:o(()=>[m(M,{label:`info`,value:`info`}),m(M,{label:`warn`,value:`warn`}),m(M,{label:`error`,value:`error`})]),_:1},8,[`modelValue`]),m(P,{modelValue:O.value,"onUpdate:modelValue":i[2]||=e=>O.value=e,placeholder:`搜索关键词`,clearable:``,style:{width:`200px`},onKeyup:h(j,[`enter`])},null,8,[`modelValue`]),m(F,{onClick:j},{default:o(()=>[...i[4]||=[p(`刷新`,-1)]]),_:1})])):l(``,!0),A.value===`runtime`?d((n(),s(L,{key:1,data:C.value,size:`small`},{default:o(()=>[m(I,{prop:`created_at`,label:`时间`,width:`170`}),m(I,{prop:`level`,label:`级别`,width:`80`}),m(I,{prop:`source`,label:`来源`,width:`100`}),m(I,{prop:`message`,label:`消息`,"min-width":`300`,"show-overflow-tooltip":``})]),_:1},8,[`data`])),[[z,E.value]]):l(``,!0),A.value===`runtime`?(n(),c(`div`,x,[m(R,{"current-page":k.value,"onUpdate:currentPage":i[3]||=e=>k.value=e,"page-size":S,total:T.value,layout:`total, prev, pager, next`,onCurrentChange:j},null,8,[`current-page`,`total`])])):d((n(),s(L,{key:3,data:w.value,size:`small`},{default:o(()=>[m(I,{prop:`created_at`,label:`时间`,width:`170`}),m(I,{prop:`action`,label:`操作`,width:`100`}),m(I,{prop:`resource`,label:`资源`,width:`120`}),m(I,{prop:`detail`,label:`详情`,"min-width":`200`,"show-overflow-tooltip":``})]),_:1},8,[`data`])),[[z,E.value]])]),_:1})])}}});export{C as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,H as t,Kt as n,R as r,U as i,Ut as a,b as o,d as s,et as c,f as l,m as u,t as d,tt as f,u as p,ut as m,v as h,y as g}from"./client-CiT_nDm8.js";import{i as _}from"./index-B4K9RppJ.js";import{n as v,t as y}from"./AppCard-ClnBgZ-s.js";var b={class:`page`},x={class:`tabs-bar`},S=o({__name:`ProxiesView`,setup(o){let S=m([]),C=m(!1),w=m(`all`),T=m(!1),E=m(!1),D=m({name:``,type:`socks5`,server:``,port:1080,username:``,password:``}),O=p(()=>w.value===`manual`?S.value.filter(e=>e.source===`manual`):w.value===`subscription`?S.value.filter(e=>e.source===`subscription`):S.value);function k(e){return e<0?`latency-dead`:e<200?`latency-good`:e<500?`latency-warn`:`latency-bad`}async function A(){C.value=!0;try{let{data:e}=await d.get(`/proxies`);S.value=e.items||[]}finally{C.value=!1}}async function j(e){let{data:t}=await d.post(`/proxies/${e.id}/test`);e.latency_ms=t.latency_ms,e.alive=t.alive}async function M(){T.value=!0;try{await d.post(`/proxies/test-all`),_.success(`测速完成`),A()}finally{T.value=!1}}function N(){D.value={name:``,type:`socks5`,server:``,port:1080,username:``,password:``},E.value=!0}async function P(){let e={type:D.value.type,server:D.value.server,port:D.value.port,...D.value.username?{username:D.value.username,password:D.value.password}:{}};await d.post(`/proxies`,{name:D.value.name,type:D.value.type,raw_config:JSON.stringify(e)}),_.success(`节点已添加`),E.value=!1,A()}return e(A),(e,o)=>{let d=t(`el-button`),p=t(`el-tab-pane`),m=t(`el-tabs`),_=t(`el-table-column`),S=t(`el-tag`),A=t(`el-table`),F=t(`el-input`),I=t(`el-form-item`),L=t(`el-option`),R=t(`el-select`),z=t(`el-input-number`),B=t(`el-form`),V=t(`el-dialog`),H=i(`loading`);return r(),u(`div`,b,[g(v,{title:`代理节点`,description:`查看节点状态与延迟,支持手动添加节点`},{actions:c(()=>[g(d,{onClick:N},{default:c(()=>[...o[9]||=[h(`添加节点`,-1)]]),_:1}),g(d,{loading:T.value,onClick:M},{default:c(()=>[...o[10]||=[h(`全部测速`,-1)]]),_:1},8,[`loading`])]),_:1}),g(y,null,{default:c(()=>[s(`div`,x,[g(m,{modelValue:w.value,"onUpdate:modelValue":o[0]||=e=>w.value=e},{default:c(()=>[g(p,{label:`全部`,name:`all`}),g(p,{label:`订阅`,name:`subscription`}),g(p,{label:`手动`,name:`manual`})]),_:1},8,[`modelValue`])]),f((r(),l(A,{data:O.value,size:`small`},{default:c(()=>[g(_,{prop:`name`,label:`名称`,"min-width":`140`}),g(_,{prop:`type`,label:`类型`,width:`90`}),g(_,{prop:`source`,label:`来源`,width:`90`}),g(_,{label:`延迟`,width:`100`},{default:c(({row:e})=>[s(`span`,{class:a(k(e.latency_ms))},n(e.latency_ms>=0?`${e.latency_ms} ms`:`未测`),3)]),_:1}),g(_,{label:`状态`,width:`80`},{default:c(({row:e})=>[g(S,{type:e.alive?`success`:`info`,size:`small`},{default:c(()=>[h(n(e.alive?`可用`:`未知`),1)]),_:2},1032,[`type`])]),_:1}),g(_,{label:`操作`,width:`80`,fixed:`right`},{default:c(({row:e})=>[g(d,{size:`small`,link:``,onClick:t=>j(e)},{default:c(()=>[...o[11]||=[h(`测速`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[H,C.value]])]),_:1}),g(V,{modelValue:E.value,"onUpdate:modelValue":o[8]||=e=>E.value=e,title:`添加手动节点`,width:`520px`,"destroy-on-close":``},{footer:c(()=>[g(d,{onClick:o[7]||=e=>E.value=!1},{default:c(()=>[...o[12]||=[h(`取消`,-1)]]),_:1}),g(d,{type:`primary`,onClick:P},{default:c(()=>[...o[13]||=[h(`确认`,-1)]]),_:1})]),default:c(()=>[g(B,{"label-width":`100px`},{default:c(()=>[g(I,{label:`名称`},{default:c(()=>[g(F,{modelValue:D.value.name,"onUpdate:modelValue":o[1]||=e=>D.value.name=e},null,8,[`modelValue`])]),_:1}),g(I,{label:`类型`},{default:c(()=>[g(R,{modelValue:D.value.type,"onUpdate:modelValue":o[2]||=e=>D.value.type=e,style:{width:`100%`}},{default:c(()=>[g(L,{label:`SOCKS5`,value:`socks5`}),g(L,{label:`HTTP`,value:`http`})]),_:1},8,[`modelValue`])]),_:1}),g(I,{label:`服务器`},{default:c(()=>[g(F,{modelValue:D.value.server,"onUpdate:modelValue":o[3]||=e=>D.value.server=e},null,8,[`modelValue`])]),_:1}),g(I,{label:`端口`},{default:c(()=>[g(z,{modelValue:D.value.port,"onUpdate:modelValue":o[4]||=e=>D.value.port=e,min:1,max:65535},null,8,[`modelValue`])]),_:1}),g(I,{label:`用户名`},{default:c(()=>[g(F,{modelValue:D.value.username,"onUpdate:modelValue":o[5]||=e=>D.value.username=e},null,8,[`modelValue`])]),_:1}),g(I,{label:`密码`},{default:c(()=>[g(F,{modelValue:D.value.password,"onUpdate:modelValue":o[6]||=e=>D.value.password=e,type:`password`,"show-password":``},null,8,[`modelValue`])]),_:1})]),_:1})]),_:1},8,[`modelValue`])])}}});export{S as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,H as t,R as n,U as r,b as i,d as a,et as o,f as s,m as c,t as l,tt as u,ut as d,v as f,y as p}from"./client-CiT_nDm8.js";import{i as m,r as h}from"./index-B4K9RppJ.js";import{n as g,t as _}from"./AppCard-ClnBgZ-s.js";var v={class:`page`},y={class:`content-grid content-grid--2`},b=i({__name:`RulesView`,setup(i){let b=d([]),x=d([]),S=d(!1),C=d(!1),w=d({rule_type:`DOMAIN-SUFFIX`,payload:``,target:`PROXY`,priority:100});async function T(){S.value=!0;try{let[e,t]=await Promise.all([l.get(`/rules?source=manual`),l.get(`/rules/merged`)]);b.value=e.data.items||[],x.value=t.data.items||[]}finally{S.value=!1}}function E(){w.value={rule_type:`DOMAIN-SUFFIX`,payload:``,target:`PROXY`,priority:100},C.value=!0}async function D(){await l.post(`/rules`,w.value),m.success(`规则已添加`),C.value=!1,T()}async function O(e){await h.confirm(`确定删除该规则?`,`确认`),await l.delete(`/rules/${e.id}`),T()}return e(T),(e,i)=>{let l=t(`el-button`),d=t(`el-table-column`),m=t(`el-table`),h=t(`el-option`),T=t(`el-select`),k=t(`el-form-item`),A=t(`el-input`),j=t(`el-input-number`),M=t(`el-form`),N=t(`el-dialog`),P=r(`loading`);return n(),c(`div`,v,[p(g,{title:`路由规则`,description:`手动规则优先级高于订阅规则,自上而下首条命中`},{actions:o(()=>[p(l,{type:`primary`,onClick:E},{default:o(()=>[...i[6]||=[f(`添加规则`,-1)]]),_:1})]),_:1}),a(`div`,y,[p(_,{title:`手动规则(高优先级)`},{default:o(()=>[u((n(),s(m,{data:b.value,size:`small`},{default:o(()=>[p(d,{prop:`priority`,label:`优先级`,width:`80`}),p(d,{prop:`rule_type`,label:`类型`,width:`120`}),p(d,{prop:`payload`,label:`匹配`,"min-width":`120`}),p(d,{prop:`target`,label:`目标`,width:`100`}),p(d,{label:`操作`,width:`80`,fixed:`right`},{default:o(({row:e})=>[p(l,{size:`small`,type:`danger`,link:``,onClick:t=>O(e)},{default:o(()=>[...i[7]||=[f(`删除`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[P,S.value]])]),_:1}),p(_,{title:`合成后规则预览`},{default:o(()=>[p(m,{data:x.value,size:`small`,"max-height":`400`},{default:o(()=>[p(d,{prop:`priority`,label:`序`,width:`70`}),p(d,{prop:`rule`,label:`规则`,"min-width":`200`}),p(d,{prop:`source`,label:`来源`,width:`90`})]),_:1},8,[`data`])]),_:1})]),p(N,{modelValue:C.value,"onUpdate:modelValue":i[5]||=e=>C.value=e,title:`添加规则`,width:`520px`,"destroy-on-close":``},{footer:o(()=>[p(l,{onClick:i[4]||=e=>C.value=!1},{default:o(()=>[...i[9]||=[f(`取消`,-1)]]),_:1}),p(l,{type:`primary`,onClick:D},{default:o(()=>[...i[10]||=[f(`确认`,-1)]]),_:1})]),default:o(()=>[p(M,{"label-width":`100px`},{default:o(()=>[p(k,{label:`类型`},{default:o(()=>[p(T,{modelValue:w.value.rule_type,"onUpdate:modelValue":i[0]||=e=>w.value.rule_type=e,style:{width:`100%`}},{default:o(()=>[p(h,{label:`DOMAIN`,value:`DOMAIN`}),p(h,{label:`DOMAIN-SUFFIX`,value:`DOMAIN-SUFFIX`}),p(h,{label:`DOMAIN-KEYWORD`,value:`DOMAIN-KEYWORD`}),p(h,{label:`IP-CIDR`,value:`IP-CIDR`}),p(h,{label:`GEOIP`,value:`GEOIP`})]),_:1},8,[`modelValue`])]),_:1}),p(k,{label:`匹配值`},{default:o(()=>[p(A,{modelValue:w.value.payload,"onUpdate:modelValue":i[1]||=e=>w.value.payload=e},null,8,[`modelValue`])]),_:1}),p(k,{label:`目标`},{default:o(()=>[p(A,{modelValue:w.value.target,"onUpdate:modelValue":i[2]||=e=>w.value.target=e,placeholder:`PROXY / DIRECT / REJECT`},null,8,[`modelValue`])]),_:1}),p(k,{label:`优先级`},{default:o(()=>[p(j,{modelValue:w.value.priority,"onUpdate:modelValue":i[3]||=e=>w.value.priority=e,min:1,max:9999},null,8,[`modelValue`]),i[8]||=a(`p`,{class:`form-hint`},`数值越小优先级越高`,-1)]),_:1})]),_:1})]),_:1},8,[`modelValue`])])}}});export{b as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,H as t,R as n,U as r,b as i,d as a,et as o,f as s,m as c,t as l,tt as u,ut as d,v as f,y as p}from"./client-CiT_nDm8.js";import{i as m}from"./index-B4K9RppJ.js";import{n as h,t as g}from"./AppCard-ClnBgZ-s.js";var _={class:`page`},v=i({__name:`SettingsView`,setup(i){let v=d({}),y=d(!1),b=d(!1);async function x(){y.value=!0;try{let{data:e}=await l.get(`/settings`);v.value=e}finally{y.value=!1}}async function S(){b.value=!0;try{await l.put(`/settings`,v.value),m.success(`设置已保存`)}finally{b.value=!1}}async function C(){await l.post(`/system/reload`),m.success(`内核已重载`)}return e(x),(e,i)=>{let l=t(`el-button`),d=t(`el-input`),m=t(`el-form-item`),x=t(`el-option`),w=t(`el-select`),T=t(`el-switch`),E=t(`el-form`),D=r(`loading`);return n(),c(`div`,_,[p(h,{title:`系统设置`,description:`代理端口、调度间隔与安全配置`},{actions:o(()=>[p(l,{onClick:C},{default:o(()=>[...i[10]||=[f(`重载内核`,-1)]]),_:1}),p(l,{type:`primary`,loading:b.value,onClick:S},{default:o(()=>[...i[11]||=[f(`保存`,-1)]]),_:1},8,[`loading`])]),_:1}),u((n(),s(g,{title:`入站与 API`,padded:``},{default:o(()=>[p(E,{"label-width":`120px`},{default:o(()=>[i[14]||=a(`div`,{class:`section-title`},`代理入站`,-1),p(m,{label:`HTTP 端口`},{default:o(()=>[p(d,{modelValue:v.value.http_port,"onUpdate:modelValue":i[0]||=e=>v.value.http_port=e},null,8,[`modelValue`])]),_:1}),p(m,{label:`SOCKS5 端口`},{default:o(()=>[p(d,{modelValue:v.value.socks_port,"onUpdate:modelValue":i[1]||=e=>v.value.socks_port=e},null,8,[`modelValue`])]),_:1}),i[15]||=a(`div`,{class:`section-title`},`管理 API`,-1),p(m,{label:`API 端口`},{default:o(()=>[p(d,{modelValue:v.value.api_port,"onUpdate:modelValue":i[2]||=e=>v.value.api_port=e},null,8,[`modelValue`]),i[12]||=a(`p`,{class:`form-hint`},`修改后需重启进程生效`,-1)]),_:1}),p(m,{label:`Mihomo API`},{default:o(()=>[p(d,{modelValue:v.value.mihomo_api_port,"onUpdate:modelValue":i[3]||=e=>v.value.mihomo_api_port=e},null,8,[`modelValue`])]),_:1}),p(m,{label:`API Secret`},{default:o(()=>[p(d,{modelValue:v.value.api_secret,"onUpdate:modelValue":i[4]||=e=>v.value.api_secret=e,placeholder:`Clash API 密钥`},null,8,[`modelValue`])]),_:1}),i[16]||=a(`div`,{class:`section-title`},`调度`,-1),p(m,{label:`健康检查间隔`},{default:o(()=>[p(d,{modelValue:v.value.health_interval_sec,"onUpdate:modelValue":i[5]||=e=>v.value.health_interval_sec=e},null,8,[`modelValue`]),i[13]||=a(`p`,{class:`form-hint`},`秒`,-1)]),_:1}),p(m,{label:`默认策略`},{default:o(()=>[p(w,{modelValue:v.value.default_policy,"onUpdate:modelValue":i[6]||=e=>v.value.default_policy=e,style:{width:`100%`}},{default:o(()=>[p(x,{label:`DIRECT`,value:`DIRECT`}),p(x,{label:`PROXY`,value:`PROXY`}),p(x,{label:`REJECT`,value:`REJECT`})]),_:1},8,[`modelValue`])]),_:1}),p(m,{label:`日志级别`},{default:o(()=>[p(w,{modelValue:v.value.log_level,"onUpdate:modelValue":i[7]||=e=>v.value.log_level=e,style:{width:`100%`}},{default:o(()=>[p(x,{label:`info`,value:`info`}),p(x,{label:`warning`,value:`warning`}),p(x,{label:`error`,value:`error`}),p(x,{label:`debug`,value:`debug`})]),_:1},8,[`modelValue`])]),_:1}),i[17]||=a(`div`,{class:`section-title`},`安全`,-1),p(m,{label:`启用登录`},{default:o(()=>[p(T,{modelValue:v.value.auth_enabled,"onUpdate:modelValue":i[8]||=e=>v.value.auth_enabled=e,"active-value":`true`,"inactive-value":`false`},null,8,[`modelValue`])]),_:1}),p(m,{label:`新密码`},{default:o(()=>[p(d,{modelValue:v.value.admin_password,"onUpdate:modelValue":i[9]||=e=>v.value.admin_password=e,type:`password`,"show-password":``,placeholder:`留空则不修改`},null,8,[`modelValue`])]),_:1})]),_:1})]),_:1})),[[D,y.value]])])}}});export{v as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,H as t,Kt as n,R as r,U as i,b as a,d as o,et as s,f as c,m as l,t as u,tt as d,ut as f,v as p,y as m}from"./client-CiT_nDm8.js";import{i as h,r as g}from"./index-B4K9RppJ.js";import{n as _,t as v}from"./AppCard-ClnBgZ-s.js";var y={class:`page`},b=a({__name:`SubscriptionsView`,setup(a){let b=f([]),x=f(!1),S=f(!1),C=f({name:``,url:``,type:`clash`,interval_sec:3600});async function w(){x.value=!0;try{let{data:e}=await u.get(`/subscriptions`);b.value=e.items||[]}finally{x.value=!1}}function T(){C.value={name:``,url:``,type:`clash`,interval_sec:3600},S.value=!0}async function E(){await u.post(`/subscriptions`,C.value),h.success(`已添加订阅`),S.value=!1,w()}async function D(e){await u.post(`/subscriptions/${e.id}/refresh`),h.success(`刷新成功`),w()}async function O(e){await g.confirm(`确定删除订阅「${e.name}」?`,`确认`),await u.delete(`/subscriptions/${e.id}`),h.success(`已删除`),w()}return e(w),(e,a)=>{let u=t(`el-button`),f=t(`el-table-column`),h=t(`el-tag`),g=t(`el-table`),w=t(`el-input`),k=t(`el-form-item`),A=t(`el-option`),j=t(`el-select`),M=t(`el-input-number`),N=t(`el-form`),P=t(`el-dialog`),F=i(`loading`);return r(),l(`div`,y,[m(_,{title:`订阅管理`,description:`添加并定时更新 Clash 或节点链接订阅`},{actions:s(()=>[m(u,{type:`primary`,onClick:T},{default:s(()=>[...a[6]||=[p(`添加订阅`,-1)]]),_:1})]),_:1}),m(v,null,{default:s(()=>[d((r(),c(g,{data:b.value,size:`small`},{default:s(()=>[m(f,{prop:`name`,label:`名称`,"min-width":`120`}),m(f,{prop:`type`,label:`类型`,width:`80`}),m(f,{prop:`url`,label:`URL`,"min-width":`200`,"show-overflow-tooltip":``}),m(f,{prop:`node_count`,label:`节点数`,width:`80`}),m(f,{label:`状态`,width:`100`},{default:s(({row:e})=>[m(h,{type:e.last_error?`danger`:`success`,size:`small`},{default:s(()=>[p(n(e.last_error?`异常`:`正常`),1)]),_:2},1032,[`type`])]),_:1}),m(f,{prop:`last_fetch_at`,label:`上次更新`,width:`160`}),m(f,{label:`操作`,width:`160`,fixed:`right`},{default:s(({row:e})=>[m(u,{size:`small`,link:``,onClick:t=>D(e)},{default:s(()=>[...a[7]||=[p(`刷新`,-1)]]),_:1},8,[`onClick`]),m(u,{size:`small`,type:`danger`,link:``,onClick:t=>O(e)},{default:s(()=>[...a[8]||=[p(`删除`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[F,x.value]])]),_:1}),m(P,{modelValue:S.value,"onUpdate:modelValue":a[5]||=e=>S.value=e,title:`添加订阅`,width:`520px`,"destroy-on-close":``},{footer:s(()=>[m(u,{onClick:a[4]||=e=>S.value=!1},{default:s(()=>[...a[10]||=[p(`取消`,-1)]]),_:1}),m(u,{type:`primary`,onClick:E},{default:s(()=>[...a[11]||=[p(`确认`,-1)]]),_:1})]),default:s(()=>[m(N,{"label-width":`100px`},{default:s(()=>[m(k,{label:`名称`},{default:s(()=>[m(w,{modelValue:C.value.name,"onUpdate:modelValue":a[0]||=e=>C.value.name=e},null,8,[`modelValue`])]),_:1}),m(k,{label:`URL`},{default:s(()=>[m(w,{modelValue:C.value.url,"onUpdate:modelValue":a[1]||=e=>C.value.url=e},null,8,[`modelValue`])]),_:1}),m(k,{label:`类型`},{default:s(()=>[m(j,{modelValue:C.value.type,"onUpdate:modelValue":a[2]||=e=>C.value.type=e,style:{width:`100%`}},{default:s(()=>[m(A,{label:`Clash YAML`,value:`clash`}),m(A,{label:`节点链接`,value:`links`})]),_:1},8,[`modelValue`])]),_:1}),m(k,{label:`更新间隔`},{default:s(()=>[m(M,{modelValue:C.value.interval_sec,"onUpdate:modelValue":a[3]||=e=>C.value.interval_sec=e,min:300,step:300},null,8,[`modelValue`]),a[9]||=o(`p`,{class:`form-hint`},`秒,最小 300`,-1)]),_:1})]),_:1})]),_:1},8,[`modelValue`])])}}});export{b as default};
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>web</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-B4K9RppJ.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/client-CiT_nDm8.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-CxbpOrcM.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const APIKeyPrefix = "prism_"
|
||||||
|
|
||||||
|
func GenerateAPIKey() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return APIKeyPrefix + hex.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashAPIKey(key string) string {
|
||||||
|
sum := sha256.Sum256([]byte(key))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func APIKeyDisplayPrefix(key string) string {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if len(key) <= 12 {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return key[:12] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAPIKeyFormat(key string) bool {
|
||||||
|
return strings.HasPrefix(strings.TrimSpace(key), APIKeyPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAPIKeyFormat(key string) error {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if !strings.HasPrefix(key, APIKeyPrefix) {
|
||||||
|
return fmt.Errorf("invalid api key format")
|
||||||
|
}
|
||||||
|
raw := strings.TrimPrefix(key, APIKeyPrefix)
|
||||||
|
if len(raw) != 32 {
|
||||||
|
return fmt.Errorf("invalid api key length")
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(raw); err != nil {
|
||||||
|
return fmt.Errorf("invalid api key encoding")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGenerateAndHashAPIKey(t *testing.T) {
|
||||||
|
key, err := GenerateAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ValidateAPIKeyFormat(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if HashAPIKey(key) == "" {
|
||||||
|
t.Fatal("empty hash")
|
||||||
|
}
|
||||||
|
if HashAPIKey(key) != HashAPIKey(key) {
|
||||||
|
t.Fatal("hash not stable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMaxAttempts = 5
|
||||||
|
defaultLockMinutes = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginConfig struct {
|
||||||
|
MaxAttempts int
|
||||||
|
Window time.Duration
|
||||||
|
LockDuration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResult struct {
|
||||||
|
Allowed bool
|
||||||
|
Locked bool
|
||||||
|
Remaining int
|
||||||
|
RetryAfter time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginLimiter struct {
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoginLimiter(st *store.Store) *LoginLimiter {
|
||||||
|
return &LoginLimiter{store: st}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseLoginConfig(settings map[string]string) LoginConfig {
|
||||||
|
maxAttempts := parsePositiveInt(settings["login_max_attempts"], defaultMaxAttempts)
|
||||||
|
lockMin := parsePositiveInt(settings["login_lock_minutes"], defaultLockMinutes)
|
||||||
|
lock := time.Duration(lockMin) * time.Minute
|
||||||
|
return LoginConfig{
|
||||||
|
MaxAttempts: maxAttempts,
|
||||||
|
Window: lock,
|
||||||
|
LockDuration: lock,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginLimiter) Check(ctx context.Context, ip string, cfg LoginConfig) (LoginResult, error) {
|
||||||
|
attempt, err := l.store.GetLoginAttempt(ctx, ip)
|
||||||
|
if err != nil {
|
||||||
|
return LoginResult{}, err
|
||||||
|
}
|
||||||
|
if attempt == nil {
|
||||||
|
return LoginResult{Allowed: true, Remaining: cfg.MaxAttempts}, nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if attempt.LockedUntil != nil && now.Before(*attempt.LockedUntil) {
|
||||||
|
return LoginResult{
|
||||||
|
Allowed: false,
|
||||||
|
Locked: true,
|
||||||
|
RetryAfter: attempt.LockedUntil.Sub(now),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
remaining := cfg.MaxAttempts - attempt.FailCount
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
if attempt.FailCount > 0 && now.Sub(attempt.WindowStart) > cfg.Window {
|
||||||
|
remaining = cfg.MaxAttempts
|
||||||
|
}
|
||||||
|
return LoginResult{Allowed: true, Remaining: remaining}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginLimiter) RecordFailure(ctx context.Context, ip string, cfg LoginConfig) (LoginResult, error) {
|
||||||
|
now := time.Now()
|
||||||
|
attempt, err := l.store.GetLoginAttempt(ctx, ip)
|
||||||
|
if err != nil {
|
||||||
|
return LoginResult{}, err
|
||||||
|
}
|
||||||
|
if attempt != nil && attempt.LockedUntil != nil && now.Before(*attempt.LockedUntil) {
|
||||||
|
return LoginResult{
|
||||||
|
Allowed: false,
|
||||||
|
Locked: true,
|
||||||
|
RetryAfter: attempt.LockedUntil.Sub(now),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
failCount := 1
|
||||||
|
windowStart := now
|
||||||
|
if attempt != nil {
|
||||||
|
if attempt.LockedUntil != nil || now.Sub(attempt.WindowStart) <= cfg.Window {
|
||||||
|
if now.Sub(attempt.WindowStart) <= cfg.Window {
|
||||||
|
failCount = attempt.FailCount + 1
|
||||||
|
windowStart = attempt.WindowStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lockedUntil *time.Time
|
||||||
|
locked := false
|
||||||
|
if failCount >= cfg.MaxAttempts {
|
||||||
|
t := now.Add(cfg.LockDuration)
|
||||||
|
lockedUntil = &t
|
||||||
|
locked = true
|
||||||
|
_ = l.store.AddAuditLog(ctx, "login_lock", "auth", fmt.Sprintf("ip=%s locked %v", ip, cfg.LockDuration))
|
||||||
|
}
|
||||||
|
if err := l.store.SetLoginAttempt(ctx, ip, store.LoginAttempt{
|
||||||
|
FailCount: failCount,
|
||||||
|
WindowStart: windowStart,
|
||||||
|
LockedUntil: lockedUntil,
|
||||||
|
}); err != nil {
|
||||||
|
return LoginResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := cfg.MaxAttempts - failCount
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
res := LoginResult{Allowed: !locked, Remaining: remaining, Locked: locked}
|
||||||
|
if locked && lockedUntil != nil {
|
||||||
|
res.RetryAfter = lockedUntil.Sub(now)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginLimiter) RecordSuccess(ctx context.Context, ip string) error {
|
||||||
|
return l.store.ClearLoginAttempt(ctx, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePositiveInt(raw string, fallback int) int {
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginLimiterLockAndClear(t *testing.T) {
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
limiter := NewLoginLimiter(st)
|
||||||
|
cfg := LoginConfig{MaxAttempts: 3, Window: time.Minute, LockDuration: 2 * time.Minute}
|
||||||
|
ip := "203.0.113.1"
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
res, err := limiter.RecordFailure(ctx, ip, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Locked {
|
||||||
|
t.Fatalf("unexpected lock at attempt %d", i+1)
|
||||||
|
}
|
||||||
|
if res.Remaining != cfg.MaxAttempts-i-1 {
|
||||||
|
t.Fatalf("remaining=%d want %d", res.Remaining, cfg.MaxAttempts-i-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := limiter.RecordFailure(ctx, ip, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Locked || res.RetryAfter <= 0 {
|
||||||
|
t.Fatalf("expected lock, got %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
check, err := limiter.Check(ctx, ip, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if check.Allowed {
|
||||||
|
t.Fatal("expected blocked while locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := limiter.RecordSuccess(ctx, ip); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check, err = limiter.Check(ctx, ip, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !check.Allowed || check.Remaining != cfg.MaxAttempts {
|
||||||
|
t.Fatalf("expected reset, got %+v", check)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DashboardSnapshot struct {
|
||||||
|
UploadRate int64 `json:"upload_rate"`
|
||||||
|
DownloadRate int64 `json:"download_rate"`
|
||||||
|
Connections int `json:"connections"`
|
||||||
|
TotalNodes int `json:"total_nodes"`
|
||||||
|
AliveNodes int `json:"alive_nodes"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastYAML []byte
|
||||||
|
dashboard DashboardSnapshot
|
||||||
|
proxyLatency map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Cache {
|
||||||
|
return &Cache{
|
||||||
|
proxyLatency: make(map[string]int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) SetLastYAML(yaml []byte) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.lastYAML = append([]byte(nil), yaml...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) GetLastYAML() []byte {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if c.lastYAML == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]byte(nil), c.lastYAML...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) SetDashboard(s DashboardSnapshot) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.dashboard = s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) GetDashboard() DashboardSnapshot {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.dashboard
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) SetProxyLatency(name string, ms int) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.proxyLatency[name] = ms
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) GetProxyLatency(name string) (int, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
v, ok := c.proxyLatency[name]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package configbuilder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/country"
|
||||||
|
"github.com/prism/proxy/internal/geodata"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Builder struct {
|
||||||
|
store *store.Store
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(s *store.Store, dataDir string) *Builder {
|
||||||
|
return &Builder{store: s, dataDir: dataDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Builder) Build(ctx context.Context) ([]byte, error) {
|
||||||
|
return b.build(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildMinimal loads nodes/groups with MATCH-only rules when full config fails.
|
||||||
|
func (b *Builder) BuildMinimal(ctx context.Context) ([]byte, error) {
|
||||||
|
return b.build(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Builder) build(ctx context.Context, minimalRules bool) ([]byte, error) {
|
||||||
|
settings, err := b.store.GetSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpPort := settings["http_port"]
|
||||||
|
socksPort := settings["socks_port"]
|
||||||
|
logLevel := settings["log_level"]
|
||||||
|
if logLevel == "" {
|
||||||
|
logLevel = "info"
|
||||||
|
}
|
||||||
|
defaultPolicy := settings["default_policy"]
|
||||||
|
if defaultPolicy == "" {
|
||||||
|
defaultPolicy = "DIRECT"
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, err := b.store.ListProxyNodes(ctx, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
groups, err := b.store.ListProxyGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mergedRules, err := b.store.ListMergedRules(ctx, defaultPolicy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if minimalRules {
|
||||||
|
mergedRules = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := map[string]any{
|
||||||
|
"port": atoi(httpPort, 7890),
|
||||||
|
"socks-port": atoi(socksPort, 7891),
|
||||||
|
"allow-lan": true,
|
||||||
|
"mode": "rule",
|
||||||
|
"log-level": logLevel,
|
||||||
|
"proxies": []any{},
|
||||||
|
"proxy-groups": []any{
|
||||||
|
map[string]any{
|
||||||
|
"name": "PROXY",
|
||||||
|
"type": "select",
|
||||||
|
"proxies": []string{"DIRECT"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"rules": []string{"MATCH," + defaultPolicy},
|
||||||
|
}
|
||||||
|
applyInboundAuth(cfg, settings)
|
||||||
|
|
||||||
|
var proxies []any
|
||||||
|
proxyNames := []string{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if !n.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var proxy map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(n.RawConfig), &proxy); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proxy["name"] = n.RuntimeName
|
||||||
|
proxies = append(proxies, proxy)
|
||||||
|
proxyNames = append(proxyNames, n.RuntimeName)
|
||||||
|
}
|
||||||
|
cfg["proxies"] = proxies
|
||||||
|
|
||||||
|
var proxyGroups []any
|
||||||
|
hasGroups := false
|
||||||
|
for _, g := range groups {
|
||||||
|
if !g.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasGroups = true
|
||||||
|
var members []string
|
||||||
|
_ = json.Unmarshal([]byte(g.Proxies), &members)
|
||||||
|
remapped := remapMembers(members, nodes, groups)
|
||||||
|
proxyGroups = append(proxyGroups, buildProxyGroup(g.RuntimeName, g.Type, remapped))
|
||||||
|
}
|
||||||
|
if !hasGroups && len(proxyNames) > 0 {
|
||||||
|
proxyGroups = []any{
|
||||||
|
map[string]any{
|
||||||
|
"name": "PROXY",
|
||||||
|
"type": "select",
|
||||||
|
"proxies": append(proxyNames, "DIRECT"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if len(proxyGroups) > 0 {
|
||||||
|
cfg["proxy-groups"] = proxyGroups
|
||||||
|
} else {
|
||||||
|
cfg["proxy-groups"] = []any{
|
||||||
|
map[string]any{
|
||||||
|
"name": "PROXY",
|
||||||
|
"type": "select",
|
||||||
|
"proxies": []string{"DIRECT"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(proxyGroups) > 0 {
|
||||||
|
cfg["proxy-groups"] = proxyGroups
|
||||||
|
}
|
||||||
|
proxyGroups = appendCountryProxyGroups(proxyGroups, nodes)
|
||||||
|
cfg["proxy-groups"] = proxyGroups
|
||||||
|
|
||||||
|
nameMap := buildNameMap(nodes, groups)
|
||||||
|
countryNameMap, countryValid := countryMaps(nodes)
|
||||||
|
for k, v := range countryNameMap {
|
||||||
|
nameMap[k] = v
|
||||||
|
}
|
||||||
|
valid := buildValidTargets(nodes, groups)
|
||||||
|
for k := range countryValid {
|
||||||
|
valid[k] = true
|
||||||
|
}
|
||||||
|
defaultPolicy = resolvePolicy(defaultPolicy, nameMap, valid, groups)
|
||||||
|
cfg["rules"] = sanitizeRules(mergedRules, nameMap, valid, defaultPolicy, geodata.Ready(b.dataDir))
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNameMap(nodes []store.ProxyNode, groups []store.ProxyGroup) map[string]string {
|
||||||
|
nameMap := make(map[string]string)
|
||||||
|
for _, n := range nodes {
|
||||||
|
nameMap[n.Name] = n.RuntimeName
|
||||||
|
nameMap[n.RuntimeName] = n.RuntimeName
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
nameMap[g.Name] = g.RuntimeName
|
||||||
|
nameMap[g.RuntimeName] = g.RuntimeName
|
||||||
|
}
|
||||||
|
return nameMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func remapMembers(members []string, nodes []store.ProxyNode, groups []store.ProxyGroup) []string {
|
||||||
|
nameMap := buildNameMap(nodes, groups)
|
||||||
|
out := make([]string, 0, len(members))
|
||||||
|
for _, name := range members {
|
||||||
|
if name == "DIRECT" || name == "REJECT" {
|
||||||
|
out = append(out, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rn, ok := nameMap[name]; ok {
|
||||||
|
out = append(out, rn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func remapRuleTarget(rule string, nameMap map[string]string) string {
|
||||||
|
parts := strings.Split(rule, ",")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
target := parts[len(parts)-1]
|
||||||
|
if code, ok := country.ParseTargetAlias(target); ok {
|
||||||
|
parts[len(parts)-1] = country.GroupRuntime(code)
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
if mapped, ok := nameMap[target]; ok {
|
||||||
|
parts[len(parts)-1] = mapped
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
func remapProxyNames(names []string, nodes []store.ProxyNode) []string {
|
||||||
|
return remapMembers(names, nodes, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProxyGroup(name, gtype string, members []string) map[string]any {
|
||||||
|
group := map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"type": gtype,
|
||||||
|
"proxies": appendGroupMembers(gtype, members),
|
||||||
|
}
|
||||||
|
switch gtype {
|
||||||
|
case "url-test", "fallback":
|
||||||
|
group["url"] = "http://www.gstatic.com/generate_204"
|
||||||
|
group["interval"] = 300
|
||||||
|
}
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendGroupMembers(gtype string, members []string) []string {
|
||||||
|
switch strings.ToLower(gtype) {
|
||||||
|
case "url-test", "fallback":
|
||||||
|
return members
|
||||||
|
default:
|
||||||
|
return append(append([]string{}, members...), "DIRECT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildValidTargets(nodes []store.ProxyNode, groups []store.ProxyGroup) map[string]bool {
|
||||||
|
valid := map[string]bool{
|
||||||
|
"DIRECT": true,
|
||||||
|
"REJECT": true,
|
||||||
|
}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Enabled {
|
||||||
|
valid[n.RuntimeName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.Enabled {
|
||||||
|
valid[g.RuntimeName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePolicy(policy string, nameMap map[string]string, valid map[string]bool, groups []store.ProxyGroup) string {
|
||||||
|
if code, ok := country.ParseTargetAlias(policy); ok {
|
||||||
|
rn := country.GroupRuntime(code)
|
||||||
|
if valid[rn] {
|
||||||
|
return rn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mapped, ok := nameMap[policy]; ok && valid[mapped] {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
if valid[policy] {
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
if policy == "PROXY" || policy == "GLOBAL" {
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.Enabled && valid[g.RuntimeName] {
|
||||||
|
return g.RuntimeName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "DIRECT"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeRules(merged []store.MergedRule, nameMap map[string]string, valid map[string]bool, defaultPolicy string, geoReady bool) []string {
|
||||||
|
var rules []string
|
||||||
|
hasMatch := false
|
||||||
|
for _, r := range merged {
|
||||||
|
line := remapRuleTarget(r.Rule, nameMap)
|
||||||
|
if !geoReady && needsGeoData(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "MATCH,") {
|
||||||
|
line = "MATCH," + defaultPolicy
|
||||||
|
hasMatch = true
|
||||||
|
}
|
||||||
|
target := ruleTarget(line)
|
||||||
|
if target == "" || !valid[target] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rules = append(rules, line)
|
||||||
|
}
|
||||||
|
if !hasMatch {
|
||||||
|
rules = append(rules, "MATCH,"+defaultPolicy)
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsGeoData(rule string) bool {
|
||||||
|
upper := strings.ToUpper(rule)
|
||||||
|
if strings.HasPrefix(upper, "GEOIP,") || strings.HasPrefix(upper, "GEOSITE,") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(rule), "geoip:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleTarget(line string) string {
|
||||||
|
parts := strings.Split(line, ",")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyInboundAuth(cfg map[string]any, settings map[string]string) {
|
||||||
|
user := strings.TrimSpace(settings["proxy_auth_user"])
|
||||||
|
pass := settings["proxy_auth_pass"]
|
||||||
|
if user == "" || pass == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg["authentication"] = []string{user + ":" + pass}
|
||||||
|
cfg["skip-auth-prefixes"] = []string{"127.0.0.1/8", "::1/128"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoi(s string, def int) int {
|
||||||
|
var v int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(s), "%d", &v); err != nil || v <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCountryProxyGroups(groups []any, nodes []store.ProxyNode) []any {
|
||||||
|
byCountry := map[string][]string{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if !n.Enabled || n.Country == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byCountry[n.Country] = append(byCountry[n.Country], n.RuntimeName)
|
||||||
|
}
|
||||||
|
codes := make([]string, 0, len(byCountry))
|
||||||
|
for code := range byCountry {
|
||||||
|
codes = append(codes, code)
|
||||||
|
}
|
||||||
|
sort.Strings(codes)
|
||||||
|
for _, code := range codes {
|
||||||
|
members := byCountry[code]
|
||||||
|
if len(members) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups = append(groups, buildProxyGroup(country.GroupRuntime(code), "url-test", members))
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func countryMaps(nodes []store.ProxyNode) (map[string]string, map[string]bool) {
|
||||||
|
nameMap := map[string]string{}
|
||||||
|
valid := map[string]bool{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if !n.Enabled || n.Country == "" || seen[n.Country] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[n.Country] = true
|
||||||
|
rn := country.GroupRuntime(n.Country)
|
||||||
|
nameMap[country.TargetAlias(n.Country)] = rn
|
||||||
|
nameMap[country.Name(n.Country)] = rn
|
||||||
|
nameMap[rn] = rn
|
||||||
|
valid[rn] = true
|
||||||
|
}
|
||||||
|
return nameMap, valid
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package configbuilder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/metacubex/mihomo/hub/executor"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeRulesSkipsUnknownTarget(t *testing.T) {
|
||||||
|
nameMap := map[string]string{
|
||||||
|
"节点选择": "grp1_auto",
|
||||||
|
"grp1_auto": "grp1_auto",
|
||||||
|
}
|
||||||
|
valid := map[string]bool{
|
||||||
|
"DIRECT": true,
|
||||||
|
"REJECT": true,
|
||||||
|
"grp1_auto": true,
|
||||||
|
}
|
||||||
|
merged := []store.MergedRule{
|
||||||
|
{Rule: "DOMAIN,google.com,不存在组"},
|
||||||
|
{Rule: "DOMAIN,example.com,节点选择"},
|
||||||
|
{Rule: "MATCH,DIRECT", Source: "system"},
|
||||||
|
}
|
||||||
|
rules := sanitizeRules(merged, nameMap, valid, "DIRECT", true)
|
||||||
|
if len(rules) != 2 {
|
||||||
|
t.Fatalf("expected 2 rules, got %d: %v", len(rules), rules)
|
||||||
|
}
|
||||||
|
if rules[0] != "DOMAIN,example.com,grp1_auto" {
|
||||||
|
t.Fatalf("unexpected rule: %s", rules[0])
|
||||||
|
}
|
||||||
|
if rules[1] != "MATCH,DIRECT" {
|
||||||
|
t.Fatalf("unexpected match: %s", rules[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemapCountryTarget(t *testing.T) {
|
||||||
|
nameMap := map[string]string{"country:HK": "country_HK"}
|
||||||
|
line := remapRuleTarget("DOMAIN-SUFFIX,google.com,country:HK", nameMap)
|
||||||
|
if line != "DOMAIN-SUFFIX,google.com,country_HK" {
|
||||||
|
t.Fatalf("unexpected: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyInboundAuth(t *testing.T) {
|
||||||
|
cfg := map[string]any{}
|
||||||
|
applyInboundAuth(cfg, map[string]string{})
|
||||||
|
if _, ok := cfg["authentication"]; ok {
|
||||||
|
t.Fatal("expected no authentication when empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
applyInboundAuth(cfg, map[string]string{
|
||||||
|
"proxy_auth_user": "proxy",
|
||||||
|
"proxy_auth_pass": "secret",
|
||||||
|
})
|
||||||
|
auth, ok := cfg["authentication"].([]string)
|
||||||
|
if !ok || len(auth) != 1 || auth[0] != "proxy:secret" {
|
||||||
|
t.Fatalf("unexpected authentication: %#v", cfg["authentication"])
|
||||||
|
}
|
||||||
|
prefixes, ok := cfg["skip-auth-prefixes"].([]string)
|
||||||
|
if !ok || len(prefixes) != 2 {
|
||||||
|
t.Fatalf("unexpected skip-auth-prefixes: %#v", cfg["skip-auth-prefixes"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltConfigValidatesWithSanitizedRules(t *testing.T) {
|
||||||
|
yaml := []byte(`port: 7890
|
||||||
|
socks-port: 7891
|
||||||
|
mode: rule
|
||||||
|
log-level: info
|
||||||
|
proxies:
|
||||||
|
- name: sub1_node
|
||||||
|
type: ss
|
||||||
|
server: 1.2.3.4
|
||||||
|
port: 8388
|
||||||
|
cipher: aes-256-gcm
|
||||||
|
password: test
|
||||||
|
proxy-groups:
|
||||||
|
- name: grp1_auto
|
||||||
|
type: select
|
||||||
|
proxies: [sub1_node, DIRECT]
|
||||||
|
rules:
|
||||||
|
- DOMAIN,example.com,grp1_auto
|
||||||
|
- MATCH,grp1_auto
|
||||||
|
`)
|
||||||
|
if _, err := executor.ParseWithBytes(yaml); err != nil {
|
||||||
|
t.Fatalf("config should validate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prism/proxy/internal/geodata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitDataDir points Mihomo geodata/cache paths at dataDir/geo.
|
||||||
|
func InitDataDir(dataDir string) string {
|
||||||
|
return geodata.InitHomeDir(dataDir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/metacubex/mihomo/hub"
|
||||||
|
"github.com/metacubex/mihomo/hub/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
apiAddr string
|
||||||
|
secret string
|
||||||
|
lastGood []byte
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEngine() *Engine {
|
||||||
|
return &Engine{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Configure(apiAddr, secret string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.apiAddr = apiAddr
|
||||||
|
e.secret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Reload(yaml []byte) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
if _, err := executor.ParseWithBytes(yaml); err != nil {
|
||||||
|
return fmt.Errorf("config validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []hub.Option{
|
||||||
|
hub.WithExternalController(e.apiAddr),
|
||||||
|
}
|
||||||
|
if e.secret != "" {
|
||||||
|
opts = append(opts, hub.WithSecret(e.secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hub.Parse(yaml, opts...); err != nil {
|
||||||
|
return fmt.Errorf("mihomo reload failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.lastGood = append([]byte(nil), yaml...)
|
||||||
|
e.running = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Shutdown() {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
executor.Shutdown()
|
||||||
|
e.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) LastGoodConfig() []byte {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.lastGood == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]byte(nil), e.lastGood...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Running() bool {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.running
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package country
|
||||||
|
|
||||||
|
// catalog lists ISO 3166-1 alpha-2 codes with Chinese names and common proxy-node keywords.
|
||||||
|
// Keywords are matched longest-first; 2-letter codes require word boundaries.
|
||||||
|
var catalog = []entry{
|
||||||
|
// Greater China
|
||||||
|
{Code: "HK", Name: "香港", Keywords: []string{"香港", "hong kong", "hongkong", "hk", "hkg"}},
|
||||||
|
{Code: "TW", Name: "台湾", Keywords: []string{"台湾", "台北", "台中", "高雄", "taiwan", "taipei", "tw", "twn"}},
|
||||||
|
{Code: "MO", Name: "澳门", Keywords: []string{"澳门", "macau", "macao", "mo", "mac"}},
|
||||||
|
{Code: "CN", Name: "中国", Keywords: []string{"中国", "内地", "china", "beijing", "shanghai", "cn", "chn"}},
|
||||||
|
|
||||||
|
// East & Southeast Asia
|
||||||
|
{Code: "JP", Name: "日本", Keywords: []string{"日本", "东京", "大阪", "东京都", "japan", "tokyo", "osaka", "jp", "jpn"}},
|
||||||
|
{Code: "KR", Name: "韩国", Keywords: []string{"韩国", "首尔", "south korea", "korea", "seoul", "kr", "kor"}},
|
||||||
|
{Code: "KP", Name: "朝鲜", Keywords: []string{"朝鲜", "north korea", "pyongyang", "kp", "prk"}},
|
||||||
|
{Code: "SG", Name: "新加坡", Keywords: []string{"新加坡", "singapore", "sg", "sgp"}},
|
||||||
|
{Code: "MY", Name: "马来西亚", Keywords: []string{"马来西亚", "吉隆坡", "malaysia", "kuala lumpur", "my", "mys"}},
|
||||||
|
{Code: "TH", Name: "泰国", Keywords: []string{"泰国", "曼谷", "thailand", "bangkok", "th", "tha"}},
|
||||||
|
{Code: "VN", Name: "越南", Keywords: []string{"越南", "胡志明", "河内", "vietnam", "hanoi", "saigon", "vn", "vnm"}},
|
||||||
|
{Code: "PH", Name: "菲律宾", Keywords: []string{"菲律宾", "马尼拉", "philippines", "manila", "ph", "phl"}},
|
||||||
|
{Code: "ID", Name: "印尼", Keywords: []string{"印尼", "印度尼西亚", "indonesia", "jakarta", "id", "idn"}},
|
||||||
|
{Code: "MM", Name: "缅甸", Keywords: []string{"缅甸", "myanmar", "burma", "yangon", "mm", "mmr"}},
|
||||||
|
{Code: "KH", Name: "柬埔寨", Keywords: []string{"柬埔寨", "cambodia", "phnom penh", "kh", "khm"}},
|
||||||
|
{Code: "LA", Name: "老挝", Keywords: []string{"老挝", "laos", "vientiane", "la", "lao"}},
|
||||||
|
{Code: "BN", Name: "文莱", Keywords: []string{"文莱", "brunei", "bn", "brn"}},
|
||||||
|
{Code: "TL", Name: "东帝汶", Keywords: []string{"东帝汶", "timor", "dili", "tl", "tls"}},
|
||||||
|
|
||||||
|
// South Asia
|
||||||
|
{Code: "IN", Name: "印度", Keywords: []string{"印度", "india", "mumbai", "delhi", "bangalore", "in", "ind"}},
|
||||||
|
{Code: "PK", Name: "巴基斯坦", Keywords: []string{"巴基斯坦", "pakistan", "karachi", "lahore", "pk", "pak"}},
|
||||||
|
{Code: "BD", Name: "孟加拉国", Keywords: []string{"孟加拉", "bangladesh", "dhaka", "bd", "bgd"}},
|
||||||
|
{Code: "LK", Name: "斯里兰卡", Keywords: []string{"斯里兰卡", "sri lanka", "colombo", "lk", "lka"}},
|
||||||
|
{Code: "NP", Name: "尼泊尔", Keywords: []string{"尼泊尔", "nepal", "kathmandu", "np", "npl"}},
|
||||||
|
{Code: "BT", Name: "不丹", Keywords: []string{"不丹", "bhutan", "thimphu", "bt", "btn"}},
|
||||||
|
{Code: "MV", Name: "马尔代夫", Keywords: []string{"马尔代夫", "maldives", "male", "mv", "mdv"}},
|
||||||
|
{Code: "AF", Name: "阿富汗", Keywords: []string{"阿富汗", "afghanistan", "kabul", "af", "afg"}},
|
||||||
|
|
||||||
|
// Central & West Asia
|
||||||
|
{Code: "KZ", Name: "哈萨克斯坦", Keywords: []string{"哈萨克斯坦", "kazakhstan", "almaty", "astana", "kz", "kaz"}},
|
||||||
|
{Code: "UZ", Name: "乌兹别克斯坦", Keywords: []string{"乌兹别克斯坦", "uzbekistan", "tashkent", "uz", "uzb"}},
|
||||||
|
{Code: "TM", Name: "土库曼斯坦", Keywords: []string{"土库曼斯坦", "turkmenistan", "ashgabat", "tm", "tkm"}},
|
||||||
|
{Code: "KG", Name: "吉尔吉斯斯坦", Keywords: []string{"吉尔吉斯斯坦", "kyrgyzstan", "bishkek", "kg", "kgz"}},
|
||||||
|
{Code: "TJ", Name: "塔吉克斯坦", Keywords: []string{"塔吉克斯坦", "tajikistan", "dushanbe", "tj", "tjk"}},
|
||||||
|
{Code: "MN", Name: "蒙古", Keywords: []string{"蒙古", "mongolia", "ulaanbaatar", "mn", "mng"}},
|
||||||
|
{Code: "IR", Name: "伊朗", Keywords: []string{"伊朗", "iran", "tehran", "ir", "irn"}},
|
||||||
|
{Code: "IQ", Name: "伊拉克", Keywords: []string{"伊拉克", "iraq", "baghdad", "iq", "irq"}},
|
||||||
|
{Code: "SA", Name: "沙特阿拉伯", Keywords: []string{"沙特", "沙特阿拉伯", "saudi arabia", "riyadh", "jeddah", "sa", "sau"}},
|
||||||
|
{Code: "AE", Name: "阿联酋", Keywords: []string{"阿联酋", "迪拜", "uae", "dubai", "abu dhabi", "ae", "are"}},
|
||||||
|
{Code: "QA", Name: "卡塔尔", Keywords: []string{"卡塔尔", "qatar", "doha", "qa", "qat"}},
|
||||||
|
{Code: "KW", Name: "科威特", Keywords: []string{"科威特", "kuwait", "kw", "kwt"}},
|
||||||
|
{Code: "BH", Name: "巴林", Keywords: []string{"巴林", "bahrain", "manama", "bh", "bhr"}},
|
||||||
|
{Code: "OM", Name: "阿曼", Keywords: []string{"阿曼", "oman", "muscat", "om", "omn"}},
|
||||||
|
{Code: "YE", Name: "也门", Keywords: []string{"也门", "yemen", "sanaa", "ye", "yem"}},
|
||||||
|
{Code: "JO", Name: "约旦", Keywords: []string{"约旦", "jordan", "amman", "jo", "jor"}},
|
||||||
|
{Code: "LB", Name: "黎巴嫩", Keywords: []string{"黎巴嫩", "lebanon", "beirut", "lb", "lbn"}},
|
||||||
|
{Code: "SY", Name: "叙利亚", Keywords: []string{"叙利亚", "syria", "damascus", "sy", "syr"}},
|
||||||
|
{Code: "IL", Name: "以色列", Keywords: []string{"以色列", "israel", "tel aviv", "jerusalem", "il", "isr"}},
|
||||||
|
{Code: "PS", Name: "巴勒斯坦", Keywords: []string{"巴勒斯坦", "palestine", "gaza", "ps", "pse"}},
|
||||||
|
{Code: "TR", Name: "土耳其", Keywords: []string{"土耳其", "turkey", "istanbul", "ankara", "tr", "tur"}},
|
||||||
|
{Code: "CY", Name: "塞浦路斯", Keywords: []string{"塞浦路斯", "cyprus", "nicosia", "cy", "cyp"}},
|
||||||
|
{Code: "AM", Name: "亚美尼亚", Keywords: []string{"亚美尼亚", "armenia", "yerevan", "am", "arm"}},
|
||||||
|
{Code: "AZ", Name: "阿塞拜疆", Keywords: []string{"阿塞拜疆", "azerbaijan", "baku", "az", "aze"}},
|
||||||
|
{Code: "GE", Name: "格鲁吉亚", Keywords: []string{"格鲁吉亚", "georgia", "tbilisi", "ge", "geo"}},
|
||||||
|
|
||||||
|
// Europe — Western
|
||||||
|
{Code: "GB", Name: "英国", Keywords: []string{"英国", "伦敦", "united kingdom", "britain", "london", "uk", "gb", "gbr"}},
|
||||||
|
{Code: "IE", Name: "爱尔兰", Keywords: []string{"爱尔兰", "ireland", "dublin", "ie", "irl"}},
|
||||||
|
{Code: "FR", Name: "法国", Keywords: []string{"法国", "巴黎", "france", "paris", "fr", "fra"}},
|
||||||
|
{Code: "DE", Name: "德国", Keywords: []string{"德国", "法兰克福", "germany", "frankfurt", "berlin", "munich", "de", "deu"}},
|
||||||
|
{Code: "NL", Name: "荷兰", Keywords: []string{"荷兰", "阿姆斯特丹", "netherlands", "amsterdam", "nl", "nld"}},
|
||||||
|
{Code: "BE", Name: "比利时", Keywords: []string{"比利时", "belgium", "brussels", "be", "bel"}},
|
||||||
|
{Code: "LU", Name: "卢森堡", Keywords: []string{"卢森堡", "luxembourg", "lu", "lux"}},
|
||||||
|
{Code: "CH", Name: "瑞士", Keywords: []string{"瑞士", "switzerland", "zurich", "ch", "che"}},
|
||||||
|
{Code: "AT", Name: "奥地利", Keywords: []string{"奥地利", "austria", "vienna", "at", "aut"}},
|
||||||
|
{Code: "LI", Name: "列支敦士登", Keywords: []string{"列支敦士登", "liechtenstein", "li", "lie"}},
|
||||||
|
{Code: "MC", Name: "摩纳哥", Keywords: []string{"摩纳哥", "monaco", "mc", "mco"}},
|
||||||
|
{Code: "AD", Name: "安道尔", Keywords: []string{"安道尔", "andorra", "ad", "and"}},
|
||||||
|
{Code: "ES", Name: "西班牙", Keywords: []string{"西班牙", "spain", "madrid", "barcelona", "es", "esp"}},
|
||||||
|
{Code: "PT", Name: "葡萄牙", Keywords: []string{"葡萄牙", "portugal", "lisbon", "pt", "prt"}},
|
||||||
|
{Code: "IT", Name: "意大利", Keywords: []string{"意大利", "italy", "milan", "rome", "it", "ita"}},
|
||||||
|
{Code: "MT", Name: "马耳他", Keywords: []string{"马耳他", "malta", "valletta", "mt", "mlt"}},
|
||||||
|
{Code: "SM", Name: "圣马力诺", Keywords: []string{"圣马力诺", "san marino", "sm", "smr"}},
|
||||||
|
{Code: "VA", Name: "梵蒂冈", Keywords: []string{"梵蒂冈", "vatican", "va", "vat"}},
|
||||||
|
|
||||||
|
// Europe — Northern
|
||||||
|
{Code: "IS", Name: "冰岛", Keywords: []string{"冰岛", "iceland", "reykjavik", "is", "isl"}},
|
||||||
|
{Code: "NO", Name: "挪威", Keywords: []string{"挪威", "norway", "oslo", "no", "nor"}},
|
||||||
|
{Code: "SE", Name: "瑞典", Keywords: []string{"瑞典", "sweden", "stockholm", "se", "swe"}},
|
||||||
|
{Code: "FI", Name: "芬兰", Keywords: []string{"芬兰", "finland", "helsinki", "fi", "fin"}},
|
||||||
|
{Code: "DK", Name: "丹麦", Keywords: []string{"丹麦", "denmark", "copenhagen", "dk", "dnk"}},
|
||||||
|
{Code: "EE", Name: "爱沙尼亚", Keywords: []string{"爱沙尼亚", "estonia", "tallinn", "ee", "est"}},
|
||||||
|
{Code: "LV", Name: "拉脱维亚", Keywords: []string{"拉脱维亚", "latvia", "riga", "lv", "lva"}},
|
||||||
|
{Code: "LT", Name: "立陶宛", Keywords: []string{"立陶宛", "lithuania", "vilnius", "lt", "ltu"}},
|
||||||
|
|
||||||
|
// Europe — Eastern
|
||||||
|
{Code: "RU", Name: "俄罗斯", Keywords: []string{"俄罗斯", "russia", "moscow", "saint petersburg", "ru", "rus"}},
|
||||||
|
{Code: "UA", Name: "乌克兰", Keywords: []string{"乌克兰", "ukraine", "kyiv", "kiev", "ua", "ukr"}},
|
||||||
|
{Code: "BY", Name: "白俄罗斯", Keywords: []string{"白俄罗斯", "belarus", "minsk", "by", "blr"}},
|
||||||
|
{Code: "PL", Name: "波兰", Keywords: []string{"波兰", "poland", "warsaw", "pl", "pol"}},
|
||||||
|
{Code: "CZ", Name: "捷克", Keywords: []string{"捷克", "czech", "prague", "cz", "cze"}},
|
||||||
|
{Code: "SK", Name: "斯洛伐克", Keywords: []string{"斯洛伐克", "slovakia", "bratislava", "sk", "svk"}},
|
||||||
|
{Code: "HU", Name: "匈牙利", Keywords: []string{"匈牙利", "hungary", "budapest", "hu", "hun"}},
|
||||||
|
{Code: "RO", Name: "罗马尼亚", Keywords: []string{"罗马尼亚", "romania", "bucharest", "ro", "rou"}},
|
||||||
|
{Code: "BG", Name: "保加利亚", Keywords: []string{"保加利亚", "bulgaria", "sofia", "bg", "bgr"}},
|
||||||
|
{Code: "MD", Name: "摩尔多瓦", Keywords: []string{"摩尔多瓦", "moldova", "chisinau", "md", "mda"}},
|
||||||
|
{Code: "RS", Name: "塞尔维亚", Keywords: []string{"塞尔维亚", "serbia", "belgrade", "rs", "srb"}},
|
||||||
|
{Code: "ME", Name: "黑山", Keywords: []string{"黑山", "montenegro", "podgorica", "me", "mne"}},
|
||||||
|
{Code: "BA", Name: "波黑", Keywords: []string{"波黑", "bosnia", "sarajevo", "ba", "bih"}},
|
||||||
|
{Code: "HR", Name: "克罗地亚", Keywords: []string{"克罗地亚", "croatia", "zagreb", "hr", "hrv"}},
|
||||||
|
{Code: "SI", Name: "斯洛文尼亚", Keywords: []string{"斯洛文尼亚", "slovenia", "ljubljana", "si", "svn"}},
|
||||||
|
{Code: "MK", Name: "北马其顿", Keywords: []string{"北马其顿", "macedonia", "skopje", "mk", "mkd"}},
|
||||||
|
{Code: "AL", Name: "阿尔巴尼亚", Keywords: []string{"阿尔巴尼亚", "albania", "tirana", "al", "alb"}},
|
||||||
|
{Code: "XK", Name: "科索沃", Keywords: []string{"科索沃", "kosovo", "pristina", "xk", "xxk"}},
|
||||||
|
|
||||||
|
// Europe — Southern (Greece etc.)
|
||||||
|
{Code: "GR", Name: "希腊", Keywords: []string{"希腊", "greece", "athens", "gr", "grc"}},
|
||||||
|
|
||||||
|
// North America
|
||||||
|
{Code: "US", Name: "美国", Keywords: []string{"美国", "洛杉矶", "纽约", "西雅图", "硅谷", "芝加哥", "达拉斯", "迈阿密", "united states", "america", "usa", "us", "lax", "nyc", "sfo", "dfw", "mia"}},
|
||||||
|
{Code: "CA", Name: "加拿大", Keywords: []string{"加拿大", "canada", "toronto", "vancouver", "montreal", "ca", "can"}},
|
||||||
|
{Code: "MX", Name: "墨西哥", Keywords: []string{"墨西哥", "mexico", "mexico city", "guadalajara", "mx", "mex"}},
|
||||||
|
{Code: "GT", Name: "危地马拉", Keywords: []string{"危地马拉", "guatemala", "gt", "gtm"}},
|
||||||
|
{Code: "BZ", Name: "伯利兹", Keywords: []string{"伯利兹", "belize", "bz", "blz"}},
|
||||||
|
{Code: "HN", Name: "洪都拉斯", Keywords: []string{"洪都拉斯", "honduras", "hn", "hnd"}},
|
||||||
|
{Code: "SV", Name: "萨尔瓦多", Keywords: []string{"萨尔瓦多", "el salvador", "sv", "slv"}},
|
||||||
|
{Code: "NI", Name: "尼加拉瓜", Keywords: []string{"尼加拉瓜", "nicaragua", "ni", "nic"}},
|
||||||
|
{Code: "CR", Name: "哥斯达黎加", Keywords: []string{"哥斯达黎加", "costa rica", "cr", "cri"}},
|
||||||
|
{Code: "PA", Name: "巴拿马", Keywords: []string{"巴拿马", "panama", "pa", "pan"}},
|
||||||
|
{Code: "CU", Name: "古巴", Keywords: []string{"古巴", "cuba", "havana", "cu", "cub"}},
|
||||||
|
{Code: "JM", Name: "牙买加", Keywords: []string{"牙买加", "jamaica", "kingston", "jm", "jam"}},
|
||||||
|
{Code: "HT", Name: "海地", Keywords: []string{"海地", "haiti", "ht", "hti"}},
|
||||||
|
{Code: "DO", Name: "多米尼加", Keywords: []string{"多米尼加", "dominican republic", "santo domingo", "do", "dom"}},
|
||||||
|
{Code: "TT", Name: "特立尼达和多巴哥", Keywords: []string{"特立尼达", "trinidad", "tobago", "tt", "tto"}},
|
||||||
|
{Code: "BS", Name: "巴哈马", Keywords: []string{"巴哈马", "bahamas", "nassau", "bs", "bhs"}},
|
||||||
|
{Code: "BB", Name: "巴巴多斯", Keywords: []string{"巴巴多斯", "barbados", "bb", "brb"}},
|
||||||
|
{Code: "PR", Name: "波多黎各", Keywords: []string{"波多黎各", "puerto rico", "pr", "pri"}},
|
||||||
|
|
||||||
|
// South America
|
||||||
|
{Code: "BR", Name: "巴西", Keywords: []string{"巴西", "brazil", "sao paulo", "rio", "br", "bra"}},
|
||||||
|
{Code: "AR", Name: "阿根廷", Keywords: []string{"阿根廷", "argentina", "buenos aires", "ar", "arg"}},
|
||||||
|
{Code: "CL", Name: "智利", Keywords: []string{"智利", "chile", "santiago", "cl", "chl"}},
|
||||||
|
{Code: "CO", Name: "哥伦比亚", Keywords: []string{"哥伦比亚", "colombia", "bogota", "co", "col"}},
|
||||||
|
{Code: "PE", Name: "秘鲁", Keywords: []string{"秘鲁", "peru", "lima", "pe", "per"}},
|
||||||
|
{Code: "VE", Name: "委内瑞拉", Keywords: []string{"委内瑞拉", "venezuela", "caracas", "ve", "ven"}},
|
||||||
|
{Code: "EC", Name: "厄瓜多尔", Keywords: []string{"厄瓜多尔", "ecuador", "quito", "ec", "ecu"}},
|
||||||
|
{Code: "BO", Name: "玻利维亚", Keywords: []string{"玻利维亚", "bolivia", "bo", "bol"}},
|
||||||
|
{Code: "PY", Name: "巴拉圭", Keywords: []string{"巴拉圭", "paraguay", "asuncion", "py", "pry"}},
|
||||||
|
{Code: "UY", Name: "乌拉圭", Keywords: []string{"乌拉圭", "uruguay", "montevideo", "uy", "ury"}},
|
||||||
|
{Code: "GY", Name: "圭亚那", Keywords: []string{"圭亚那", "guyana", "georgetown", "gy", "guy"}},
|
||||||
|
{Code: "SR", Name: "苏里南", Keywords: []string{"苏里南", "suriname", "sr", "sur"}},
|
||||||
|
{Code: "GF", Name: "法属圭亚那", Keywords: []string{"法属圭亚那", "french guiana", "gf", "guf"}},
|
||||||
|
|
||||||
|
// Oceania
|
||||||
|
{Code: "AU", Name: "澳大利亚", Keywords: []string{"澳大利亚", "澳洲", "悉尼", "墨尔本", "australia", "sydney", "melbourne", "au", "aus"}},
|
||||||
|
{Code: "NZ", Name: "新西兰", Keywords: []string{"新西兰", "new zealand", "auckland", "wellington", "nz", "nzl"}},
|
||||||
|
{Code: "FJ", Name: "斐济", Keywords: []string{"斐济", "fiji", "suva", "fj", "fji"}},
|
||||||
|
{Code: "PG", Name: "巴布亚新几内亚", Keywords: []string{"巴布亚新几内亚", "papua new guinea", "port moresby", "pg", "png"}},
|
||||||
|
{Code: "NC", Name: "新喀里多尼亚", Keywords: []string{"新喀里多尼亚", "new caledonia", "nc", "ncl"}},
|
||||||
|
{Code: "PF", Name: "法属波利尼西亚", Keywords: []string{"法属波利尼西亚", "french polynesia", "tahiti", "pf", "pyf"}},
|
||||||
|
{Code: "GU", Name: "关岛", Keywords: []string{"关岛", "guam", "gu", "gum"}},
|
||||||
|
{Code: "AS", Name: "美属萨摩亚", Keywords: []string{"美属萨摩亚", "american samoa", "as", "asm"}},
|
||||||
|
{Code: "WS", Name: "萨摩亚", Keywords: []string{"萨摩亚", "samoa", "apia", "ws", "wsm"}},
|
||||||
|
{Code: "TO", Name: "汤加", Keywords: []string{"汤加", "tonga", "to", "ton"}},
|
||||||
|
{Code: "VU", Name: "瓦努阿图", Keywords: []string{"瓦努阿图", "vanuatu", "vu", "vut"}},
|
||||||
|
{Code: "SB", Name: "所罗门群岛", Keywords: []string{"所罗门群岛", "solomon islands", "sb", "slb"}},
|
||||||
|
{Code: "KI", Name: "基里巴斯", Keywords: []string{"基里巴斯", "kiribati", "ki", "kir"}},
|
||||||
|
{Code: "MH", Name: "马绍尔群岛", Keywords: []string{"马绍尔群岛", "marshall islands", "mh", "mhl"}},
|
||||||
|
{Code: "FM", Name: "密克罗尼西亚", Keywords: []string{"密克罗尼西亚", "micronesia", "fm", "fsm"}},
|
||||||
|
{Code: "PW", Name: "帕劳", Keywords: []string{"帕劳", "palau", "pw", "plw"}},
|
||||||
|
{Code: "NR", Name: "瑙鲁", Keywords: []string{"瑙鲁", "nauru", "nr", "nru"}},
|
||||||
|
{Code: "TV", Name: "图瓦卢", Keywords: []string{"图瓦卢", "tuvalu", "tv", "tuv"}},
|
||||||
|
|
||||||
|
// North Africa
|
||||||
|
{Code: "EG", Name: "埃及", Keywords: []string{"埃及", "egypt", "cairo", "eg", "egy"}},
|
||||||
|
{Code: "LY", Name: "利比亚", Keywords: []string{"利比亚", "libya", "tripoli", "ly", "lby"}},
|
||||||
|
{Code: "TN", Name: "突尼斯", Keywords: []string{"突尼斯", "tunisia", "tunis", "tn", "tun"}},
|
||||||
|
{Code: "DZ", Name: "阿尔及利亚", Keywords: []string{"阿尔及利亚", "algeria", "algiers", "dz", "dza"}},
|
||||||
|
{Code: "MA", Name: "摩洛哥", Keywords: []string{"摩洛哥", "morocco", "casablanca", "rabat", "ma", "mar"}},
|
||||||
|
{Code: "SD", Name: "苏丹", Keywords: []string{"苏丹", "sudan", "khartoum", "sd", "sdn"}},
|
||||||
|
|
||||||
|
// West Africa
|
||||||
|
{Code: "NG", Name: "尼日利亚", Keywords: []string{"尼日利亚", "nigeria", "lagos", "abuja", "ng", "nga"}},
|
||||||
|
{Code: "GH", Name: "加纳", Keywords: []string{"加纳", "ghana", "accra", "gh", "gha"}},
|
||||||
|
{Code: "CI", Name: "科特迪瓦", Keywords: []string{"科特迪瓦", "ivory coast", "cote d'ivoire", "abidjan", "ci", "civ"}},
|
||||||
|
{Code: "SN", Name: "塞内加尔", Keywords: []string{"塞内加尔", "senegal", "dakar", "sn", "sen"}},
|
||||||
|
{Code: "ML", Name: "马里", Keywords: []string{"马里", "mali", "bamako", "ml", "mli"}},
|
||||||
|
{Code: "BF", Name: "布基纳法索", Keywords: []string{"布基纳法索", "burkina faso", "bf", "bfa"}},
|
||||||
|
{Code: "NE", Name: "尼日尔", Keywords: []string{"尼日尔", "niger", "niamey", "ne", "ner"}},
|
||||||
|
{Code: "GN", Name: "几内亚", Keywords: []string{"几内亚", "guinea", "conakry", "gn", "gin"}},
|
||||||
|
{Code: "GW", Name: "几内亚比绍", Keywords: []string{"几内亚比绍", "guinea bissau", "gw", "gnb"}},
|
||||||
|
{Code: "SL", Name: "塞拉利昂", Keywords: []string{"塞拉利昂", "sierra leone", "freetown", "sl", "sle"}},
|
||||||
|
{Code: "LR", Name: "利比里亚", Keywords: []string{"利比里亚", "liberia", "monrovia", "lr", "lbr"}},
|
||||||
|
{Code: "TG", Name: "多哥", Keywords: []string{"多哥", "togo", "lome", "tg", "tgo"}},
|
||||||
|
{Code: "BJ", Name: "贝宁", Keywords: []string{"贝宁", "benin", "porto novo", "bj", "ben"}},
|
||||||
|
{Code: "GM", Name: "冈比亚", Keywords: []string{"冈比亚", "gambia", "banjul", "gm", "gmb"}},
|
||||||
|
{Code: "CV", Name: "佛得角", Keywords: []string{"佛得角", "cape verde", "cv", "cpv"}},
|
||||||
|
{Code: "MR", Name: "毛里塔尼亚", Keywords: []string{"毛里塔尼亚", "mauritania", "mr", "mrt"}},
|
||||||
|
|
||||||
|
// Central Africa
|
||||||
|
{Code: "CM", Name: "喀麦隆", Keywords: []string{"喀麦隆", "cameroon", "yaounde", "cm", "cmr"}},
|
||||||
|
{Code: "CF", Name: "中非", Keywords: []string{"中非", "central african", "bangui", "cf", "caf"}},
|
||||||
|
{Code: "TD", Name: "乍得", Keywords: []string{"乍得", "chad", "ndjamena", "td", "tcd"}},
|
||||||
|
{Code: "CG", Name: "刚果布", Keywords: []string{"刚果布", "republic of congo", "brazzaville", "cg", "cog"}},
|
||||||
|
{Code: "CD", Name: "刚果金", Keywords: []string{"刚果金", "democratic republic of congo", "kinshasa", "cd", "cod"}},
|
||||||
|
{Code: "GA", Name: "加蓬", Keywords: []string{"加蓬", "gabon", "libreville", "ga", "gab"}},
|
||||||
|
{Code: "GQ", Name: "赤道几内亚", Keywords: []string{"赤道几内亚", "equatorial guinea", "gq", "gnq"}},
|
||||||
|
{Code: "ST", Name: "圣多美和普林西比", Keywords: []string{"圣多美", "sao tome", "st", "stp"}},
|
||||||
|
|
||||||
|
// East Africa
|
||||||
|
{Code: "ET", Name: "埃塞俄比亚", Keywords: []string{"埃塞俄比亚", "ethiopia", "addis ababa", "et", "eth"}},
|
||||||
|
{Code: "KE", Name: "肯尼亚", Keywords: []string{"肯尼亚", "kenya", "nairobi", "ke", "ken"}},
|
||||||
|
{Code: "TZ", Name: "坦桑尼亚", Keywords: []string{"坦桑尼亚", "tanzania", "dar es salaam", "tz", "tza"}},
|
||||||
|
{Code: "UG", Name: "乌干达", Keywords: []string{"乌干达", "uganda", "kampala", "ug", "uga"}},
|
||||||
|
{Code: "RW", Name: "卢旺达", Keywords: []string{"卢旺达", "rwanda", "kigali", "rw", "rwa"}},
|
||||||
|
{Code: "BI", Name: "布隆迪", Keywords: []string{"布隆迪", "burundi", "bujumbura", "bi", "bdi"}},
|
||||||
|
{Code: "SO", Name: "索马里", Keywords: []string{"索马里", "somalia", "mogadishu", "so", "som"}},
|
||||||
|
{Code: "DJ", Name: "吉布提", Keywords: []string{"吉布提", "djibouti", "dj", "dji"}},
|
||||||
|
{Code: "ER", Name: "厄立特里亚", Keywords: []string{"厄立特里亚", "eritrea", "asmara", "er", "eri"}},
|
||||||
|
{Code: "SS", Name: "南苏丹", Keywords: []string{"南苏丹", "south sudan", "juba", "ss", "ssd"}},
|
||||||
|
{Code: "SC", Name: "塞舌尔", Keywords: []string{"塞舌尔", "seychelles", "sc", "syc"}},
|
||||||
|
{Code: "MU", Name: "毛里求斯", Keywords: []string{"毛里求斯", "mauritius", "mu", "mus"}},
|
||||||
|
{Code: "KM", Name: "科摩罗", Keywords: []string{"科摩罗", "comoros", "km", "com"}},
|
||||||
|
{Code: "MG", Name: "马达加斯加", Keywords: []string{"马达加斯加", "madagascar", "antananarivo", "mg", "mdg"}},
|
||||||
|
{Code: "MW", Name: "马拉维", Keywords: []string{"马拉维", "malawi", "lilongwe", "mw", "mwi"}},
|
||||||
|
{Code: "ZM", Name: "赞比亚", Keywords: []string{"赞比亚", "zambia", "lusaka", "zm", "zmb"}},
|
||||||
|
{Code: "ZW", Name: "津巴布韦", Keywords: []string{"津巴布韦", "zimbabwe", "harare", "zw", "zwe"}},
|
||||||
|
{Code: "MZ", Name: "莫桑比克", Keywords: []string{"莫桑比克", "mozambique", "maputo", "mz", "moz"}},
|
||||||
|
{Code: "AO", Name: "安哥拉", Keywords: []string{"安哥拉", "angola", "luanda", "ao", "ago"}},
|
||||||
|
{Code: "NA", Name: "纳米比亚", Keywords: []string{"纳米比亚", "namibia", "windhoek", "na", "nam"}},
|
||||||
|
{Code: "BW", Name: "博茨瓦纳", Keywords: []string{"博茨瓦纳", "botswana", "gaborone", "bw", "bwa"}},
|
||||||
|
{Code: "LS", Name: "莱索托", Keywords: []string{"莱索托", "lesotho", "ls", "lso"}},
|
||||||
|
{Code: "SZ", Name: "斯威士兰", Keywords: []string{"斯威士兰", "eswatini", "swaziland", "sz", "swz"}},
|
||||||
|
|
||||||
|
// Southern Africa
|
||||||
|
{Code: "ZA", Name: "南非", Keywords: []string{"南非", "south africa", "johannesburg", "cape town", "za", "zaf"}},
|
||||||
|
|
||||||
|
// Caribbean & Atlantic territories
|
||||||
|
{Code: "BM", Name: "百慕大", Keywords: []string{"百慕大", "bermuda", "bm", "bmu"}},
|
||||||
|
{Code: "KY", Name: "开曼群岛", Keywords: []string{"开曼群岛", "cayman", "ky", "cym"}},
|
||||||
|
{Code: "VG", Name: "英属维尔京群岛", Keywords: []string{"英属维尔京", "british virgin", "vg", "vgb"}},
|
||||||
|
{Code: "VI", Name: "美属维尔京群岛", Keywords: []string{"美属维尔京", "us virgin", "vi", "vir"}},
|
||||||
|
{Code: "AW", Name: "阿鲁巴", Keywords: []string{"阿鲁巴", "aruba", "aw", "abw"}},
|
||||||
|
{Code: "CW", Name: "库拉索", Keywords: []string{"库拉索", "curacao", "cw", "cuw"}},
|
||||||
|
{Code: "AG", Name: "安提瓜和巴布达", Keywords: []string{"安提瓜", "antigua", "ag", "atg"}},
|
||||||
|
{Code: "DM", Name: "多米尼克", Keywords: []string{"多米尼克", "dominica", "dm", "dma"}},
|
||||||
|
{Code: "GD", Name: "格林纳达", Keywords: []string{"格林纳达", "grenada", "gd", "grd"}},
|
||||||
|
{Code: "KN", Name: "圣基茨和尼维斯", Keywords: []string{"圣基茨", "saint kitts", "kn", "kna"}},
|
||||||
|
{Code: "LC", Name: "圣卢西亚", Keywords: []string{"圣卢西亚", "saint lucia", "lc", "lca"}},
|
||||||
|
{Code: "VC", Name: "圣文森特", Keywords: []string{"圣文森特", "saint vincent", "vc", "vct"}},
|
||||||
|
|
||||||
|
// European territories & microstates
|
||||||
|
{Code: "GI", Name: "直布罗陀", Keywords: []string{"直布罗陀", "gibraltar", "gi", "gib"}},
|
||||||
|
{Code: "FO", Name: "法罗群岛", Keywords: []string{"法罗群岛", "faroe", "fo", "fro"}},
|
||||||
|
{Code: "GL", Name: "格陵兰", Keywords: []string{"格陵兰", "greenland", "gl", "grl"}},
|
||||||
|
{Code: "JE", Name: "泽西岛", Keywords: []string{"泽西岛", "jersey", "je", "jey"}},
|
||||||
|
{Code: "GG", Name: "根西岛", Keywords: []string{"根西岛", "guernsey", "gg", "ggy"}},
|
||||||
|
{Code: "IM", Name: "马恩岛", Keywords: []string{"马恩岛", "isle of man", "im", "imn"}},
|
||||||
|
{Code: "RE", Name: "留尼汪", Keywords: []string{"留尼汪", "reunion", "re", "reu"}},
|
||||||
|
{Code: "GP", Name: "瓜德罗普", Keywords: []string{"瓜德罗普", "guadeloupe", "gp", "glp"}},
|
||||||
|
{Code: "MQ", Name: "马提尼克", Keywords: []string{"马提尼克", "martinique", "mq", "mtq"}},
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package country
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Keywords []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sortedKeywords []keywordMatch
|
||||||
|
nameByCode map[string]string
|
||||||
|
)
|
||||||
|
|
||||||
|
type keywordMatch struct {
|
||||||
|
keyword string
|
||||||
|
code string
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
nameByCode = make(map[string]string, len(catalog))
|
||||||
|
for _, e := range catalog {
|
||||||
|
nameByCode[e.Code] = e.Name
|
||||||
|
for _, kw := range e.Keywords {
|
||||||
|
sortedKeywords = append(sortedKeywords, keywordMatch{keyword: strings.ToLower(kw), code: e.Code})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(sortedKeywords, func(i, j int) bool {
|
||||||
|
return len(sortedKeywords[i].keyword) > len(sortedKeywords[j].keyword)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect returns ISO-like country code from node name, or empty string.
|
||||||
|
func Detect(name string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if lower == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
normalized := strings.NewReplacer(
|
||||||
|
"_", " ", "-", " ", "|", " ", "/", " ", "[", " ", "]", " ",
|
||||||
|
"(", " ", ")", " ", "【", " ", "】", " ", "(", " ", ")", " ",
|
||||||
|
).Replace(lower)
|
||||||
|
padded := " " + normalized + " "
|
||||||
|
for _, m := range sortedKeywords {
|
||||||
|
kw := m.keyword
|
||||||
|
if len(kw) <= 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if keywordMatches(padded, kw) {
|
||||||
|
return m.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tok := range strings.Fields(normalized) {
|
||||||
|
tok = strings.TrimSpace(tok)
|
||||||
|
if tok == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(tok) == 2 {
|
||||||
|
if _, ok := nameByCode[strings.ToUpper(tok)]; ok {
|
||||||
|
return strings.ToUpper(tok)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(tok) > 2 {
|
||||||
|
prefix := strings.ToUpper(tok[:2])
|
||||||
|
if _, ok := nameByCode[prefix]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
suffix := tok[2:]
|
||||||
|
if suffix == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allDigits := true
|
||||||
|
for _, r := range suffix {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
allDigits = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allDigits {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns Chinese display name for a country code.
|
||||||
|
func Name(code string) string {
|
||||||
|
if n, ok := nameByCode[strings.ToUpper(code)]; ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupRuntime returns Mihomo proxy-group name for a country pool.
|
||||||
|
func GroupRuntime(code string) string {
|
||||||
|
return "country_" + strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetAlias returns rule target alias stored in DB, e.g. country:HK.
|
||||||
|
func TargetAlias(code string) string {
|
||||||
|
return "country:" + strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTargetAlias parses country:XX to code, ok false if not a country target.
|
||||||
|
func ParseTargetAlias(target string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(strings.ToLower(target), "country:") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
code := strings.TrimSpace(target[len("country:"):])
|
||||||
|
if code == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.ToUpper(code), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns catalog entries for UI.
|
||||||
|
func All() []struct{ Code, Name string } {
|
||||||
|
out := make([]struct{ Code, Name string }, 0, len(catalog))
|
||||||
|
for _, e := range catalog {
|
||||||
|
out = append(out, struct{ Code, Name string }{Code: e.Code, Name: e.Name})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func keywordMatches(text, kw string) bool {
|
||||||
|
if !strings.Contains(text, kw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if containsHan(kw) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
for {
|
||||||
|
idx := strings.Index(text[start:], kw)
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pos := start + idx
|
||||||
|
before := pos == 0 || !isASCIIAlphaNum(text[pos-1])
|
||||||
|
afterEnd := pos + len(kw)
|
||||||
|
after := afterEnd >= len(text) || !isASCIIAlphaNum(text[afterEnd])
|
||||||
|
if before && after {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
start = pos + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIAlphaNum(b byte) bool {
|
||||||
|
return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsHan(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= 0x4e00 && r <= 0x9fff {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package country
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDetectCountry(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"香港 01 | IEPL": "HK",
|
||||||
|
"🇺🇸美国洛杉矶": "US",
|
||||||
|
"日本东京专线": "JP",
|
||||||
|
"Singapore-01": "SG",
|
||||||
|
"KR首尔": "KR",
|
||||||
|
"台湾节点": "TW",
|
||||||
|
"DE法兰克福": "DE",
|
||||||
|
"HK 01": "HK",
|
||||||
|
"墨西哥城 01": "MX",
|
||||||
|
"Mexico City": "MX",
|
||||||
|
"南非 约翰内斯堡": "ZA",
|
||||||
|
"South Africa JNB": "ZA",
|
||||||
|
"新西兰 奥克兰": "NZ",
|
||||||
|
"New Zealand Auckland": "NZ",
|
||||||
|
"尼日利亚 拉各斯": "NG",
|
||||||
|
"Nigeria Lagos": "NG",
|
||||||
|
"冰岛 雷克雅未克": "IS",
|
||||||
|
"Iceland Reykjavik": "IS",
|
||||||
|
"random node": "",
|
||||||
|
}
|
||||||
|
for name, want := range cases {
|
||||||
|
if got := Detect(name); got != want {
|
||||||
|
t.Fatalf("%q: want %q got %q", name, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetAlias(t *testing.T) {
|
||||||
|
code, ok := ParseTargetAlias("country:hk")
|
||||||
|
if !ok || code != "HK" {
|
||||||
|
t.Fatalf("parse failed: %v %q", ok, code)
|
||||||
|
}
|
||||||
|
if GroupRuntime("HK") != "country_HK" {
|
||||||
|
t.Fatal("group runtime name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogCoverage(t *testing.T) {
|
||||||
|
if len(catalog) < 150 {
|
||||||
|
t.Fatalf("expected comprehensive catalog, got %d entries", len(catalog))
|
||||||
|
}
|
||||||
|
for _, e := range catalog {
|
||||||
|
if e.Code == "" || e.Name == "" || len(e.Keywords) == 0 {
|
||||||
|
t.Fatalf("invalid entry: %+v", e)
|
||||||
|
}
|
||||||
|
if Name(e.Code) != e.Name {
|
||||||
|
t.Fatalf("name mismatch for %s", e.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package geodata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/metacubex/mihomo/component/geodata"
|
||||||
|
C "github.com/metacubex/mihomo/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultBase = "https://ghfast.top/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
CountryMmdbURL string
|
||||||
|
GeoIPURL string
|
||||||
|
GeoSiteURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileSpec struct {
|
||||||
|
Name string
|
||||||
|
URL func(Config) string
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileSpecs = []FileSpec{
|
||||||
|
{Name: "Country.mmdb", URL: func(c Config) string { return c.CountryMmdbURL }},
|
||||||
|
{Name: "geoip.dat", URL: func(c Config) string { return c.GeoIPURL }},
|
||||||
|
{Name: "geosite.dat", URL: func(c Config) string { return c.GeoSiteURL }},
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
CountryMmdbURL: DefaultBase + "/Country.mmdb",
|
||||||
|
GeoIPURL: DefaultBase + "/geoip.dat",
|
||||||
|
GeoSiteURL: DefaultBase + "/geosite.dat",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigFromSettings(settings map[string]string) Config {
|
||||||
|
def := DefaultConfig()
|
||||||
|
return Config{
|
||||||
|
CountryMmdbURL: pickURL(settings["geo_country_mmdb_url"], def.CountryMmdbURL),
|
||||||
|
GeoIPURL: pickURL(settings["geo_geoip_url"], def.GeoIPURL),
|
||||||
|
GeoSiteURL: pickURL(settings["geo_geosite_url"], def.GeoSiteURL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickURL(value, fallback string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitHomeDir(dataDir string) string {
|
||||||
|
geoDir := filepath.Join(dataDir, "geo")
|
||||||
|
_ = os.MkdirAll(geoDir, 0o755)
|
||||||
|
C.SetHomeDir(geoDir)
|
||||||
|
return geoDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyMihomoURLs(cfg Config) {
|
||||||
|
geodata.SetMmdbUrl(cfg.CountryMmdbURL)
|
||||||
|
geodata.SetGeoIpUrl(cfg.GeoIPURL)
|
||||||
|
geodata.SetGeoSiteUrl(cfg.GeoSiteURL)
|
||||||
|
geodata.SetASNUrl(DefaultBase + "/GeoLite2-ASN.mmdb")
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Status(dataDir string, cfg Config) []FileStatus {
|
||||||
|
geoDir := filepath.Join(dataDir, "geo")
|
||||||
|
out := make([]FileStatus, 0, len(fileSpecs))
|
||||||
|
for _, spec := range fileSpecs {
|
||||||
|
dest := filepath.Join(geoDir, spec.Name)
|
||||||
|
item := FileStatus{Name: spec.Name, URL: spec.URL(cfg)}
|
||||||
|
if st, err := os.Stat(dest); err == nil && st.Size() > 0 {
|
||||||
|
item.Exists = true
|
||||||
|
item.Size = st.Size()
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func Ready(dataDir string) bool {
|
||||||
|
geoDir := filepath.Join(dataDir, "geo")
|
||||||
|
for _, spec := range fileSpecs {
|
||||||
|
st, err := os.Stat(filepath.Join(geoDir, spec.Name))
|
||||||
|
if err != nil || st.Size() == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure downloads missing geo files. When force is true, all files are re-downloaded.
|
||||||
|
func Ensure(dataDir string, cfg Config, force bool) ([]string, error) {
|
||||||
|
geoDir := InitHomeDir(dataDir)
|
||||||
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
var updated []string
|
||||||
|
for _, spec := range fileSpecs {
|
||||||
|
dest := filepath.Join(geoDir, spec.Name)
|
||||||
|
if !force {
|
||||||
|
if st, err := os.Stat(dest); err == nil && st.Size() > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url := spec.URL(cfg)
|
||||||
|
if err := downloadFile(client, url, dest); err != nil {
|
||||||
|
return updated, fmt.Errorf("download %s: %w (place files manually in %s)", spec.Name, err, geoDir)
|
||||||
|
}
|
||||||
|
updated = append(updated, spec.Name)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(client *http.Client, url, dest string) error {
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
tmp := dest + ".tmp"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
return os.Rename(tmp, dest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package geodata
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestConfigFromSettingsUsesDefaults(t *testing.T) {
|
||||||
|
cfg := ConfigFromSettings(map[string]string{})
|
||||||
|
def := DefaultConfig()
|
||||||
|
if cfg.CountryMmdbURL != def.CountryMmdbURL || cfg.GeoIPURL != def.GeoIPURL || cfg.GeoSiteURL != def.GeoSiteURL {
|
||||||
|
t.Fatalf("expected defaults, got %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigFromSettingsOverrides(t *testing.T) {
|
||||||
|
cfg := ConfigFromSettings(map[string]string{
|
||||||
|
"geo_geoip_url": "https://example.com/geoip.dat",
|
||||||
|
})
|
||||||
|
if cfg.GeoIPURL != "https://example.com/geoip.dat" {
|
||||||
|
t.Fatalf("got %q", cfg.GeoIPURL)
|
||||||
|
}
|
||||||
|
if cfg.GeoSiteURL != DefaultConfig().GeoSiteURL {
|
||||||
|
t.Fatalf("expected default geosite url")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/cache"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReloadFunc func(ctx context.Context) error
|
||||||
|
|
||||||
|
type Checker struct {
|
||||||
|
store *store.Store
|
||||||
|
cache *cache.Cache
|
||||||
|
apiBase string
|
||||||
|
secret string
|
||||||
|
client *http.Client
|
||||||
|
testing atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChecker(s *store.Store, c *cache.Cache, apiBase, secret string) *Checker {
|
||||||
|
return &Checker{
|
||||||
|
store: s,
|
||||||
|
cache: c,
|
||||||
|
apiBase: stringsTrimRight(apiBase, "/"),
|
||||||
|
secret: secret,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringsTrimRight(s, cut string) string {
|
||||||
|
for len(s) > 0 && stringsHasSuffix(s, cut) {
|
||||||
|
s = s[:len(s)-len(cut)]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringsHasSuffix(s, suffix string) bool {
|
||||||
|
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Checker) TestNode(ctx context.Context, nodeID int64, reload ReloadFunc) (int, error) {
|
||||||
|
node, err := h.store.GetProxyNode(ctx, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
latency, err := h.testRuntimeName(node.RuntimeName)
|
||||||
|
if isNotFound(err) && reload != nil {
|
||||||
|
if reloadErr := reload(ctx); reloadErr == nil {
|
||||||
|
latency, err = h.testRuntimeName(node.RuntimeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isNotFound(err) {
|
||||||
|
err = fmt.Errorf("proxy %q not loaded in engine; reload engine in settings", node.RuntimeName)
|
||||||
|
}
|
||||||
|
alive := err == nil && latency >= 0
|
||||||
|
lat := latency
|
||||||
|
if !alive {
|
||||||
|
lat = -1
|
||||||
|
}
|
||||||
|
_ = h.store.UpdateProxyNodeHealth(ctx, nodeID, lat, alive)
|
||||||
|
h.cache.SetProxyLatency(node.RuntimeName, lat)
|
||||||
|
return lat, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Checker) TestAll(ctx context.Context, _ ReloadFunc) error {
|
||||||
|
if !h.testing.CompareAndSwap(false, true) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer h.testing.Store(false)
|
||||||
|
nodes, err := h.store.ListProxyNodes(context.Background(), "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if !n.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = h.TestNode(context.Background(), n.ID, nil)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Checker) ProxyLoaded(name string) bool {
|
||||||
|
data, err := h.getJSON("/proxies")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
proxies, ok := data["proxies"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok = proxies[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Checker) testRuntimeName(name string) (int, error) {
|
||||||
|
testURL := fmt.Sprintf("%s/proxies/%s/delay?timeout=5000&url=%s", h.apiBase, url.PathEscape(name), url.QueryEscape("http://www.gstatic.com/generate_204"))
|
||||||
|
req, err := http.NewRequest(http.MethodGet, testURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
if h.secret != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+h.secret)
|
||||||
|
}
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return -1, fmt.Errorf("delay test failed: %s", string(body))
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Delay int `json:"delay"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return result.Delay, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Checker) getJSON(path string) (map[string]any, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, h.apiBase+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if h.secret != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+h.secret)
|
||||||
|
}
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNotFound(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), "Resource not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Checker) UpdateAPIBase(apiBase, secret string) {
|
||||||
|
h.apiBase = stringsTrimRight(apiBase, "/")
|
||||||
|
h.secret = secret
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package applog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Collector struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
ring []Entry
|
||||||
|
cap int
|
||||||
|
store *store.Store
|
||||||
|
persist bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCollector(s *store.Store, capacity int, persist bool) *Collector {
|
||||||
|
return &Collector{
|
||||||
|
ring: make([]Entry, 0, capacity),
|
||||||
|
cap: capacity,
|
||||||
|
store: s,
|
||||||
|
persist: persist,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Add(level, source, message string) {
|
||||||
|
e := Entry{
|
||||||
|
Level: level,
|
||||||
|
Source: source,
|
||||||
|
Message: message,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
if len(c.ring) >= c.cap {
|
||||||
|
c.ring = c.ring[1:]
|
||||||
|
}
|
||||||
|
c.ring = append(c.ring, e)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.persist && c.store != nil {
|
||||||
|
_ = c.store.AddRequestLog(context.Background(), level, source, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) List(limit int) []Entry {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if limit <= 0 || limit > len(c.ring) {
|
||||||
|
limit = len(c.ring)
|
||||||
|
}
|
||||||
|
start := len(c.ring) - limit
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
out := make([]Entry, limit)
|
||||||
|
copy(out, c.ring[start:])
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prism/proxy/internal/mihomoapi"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrafficItem struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Node string `json:"node,omitempty"`
|
||||||
|
NodeKey string `json:"node_key,omitempty"`
|
||||||
|
Upload int64 `json:"upload"`
|
||||||
|
Download int64 `json:"download"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Connections int `json:"connections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func AggregateConnections(conns []map[string]any, policyGroups map[string]bool) (domains, nodes []TrafficItem) {
|
||||||
|
domainMap := map[string]*TrafficItem{}
|
||||||
|
nodeMap := map[string]*TrafficItem{}
|
||||||
|
|
||||||
|
for _, conn := range conns {
|
||||||
|
host := connHost(conn)
|
||||||
|
nodeKey := connLeafNode(conn, policyGroups)
|
||||||
|
up := toInt64(conn["upload"])
|
||||||
|
down := toInt64(conn["download"])
|
||||||
|
|
||||||
|
if host != "" {
|
||||||
|
key := host + "\x00" + nodeKey
|
||||||
|
acc(domainMap, key, up, down)
|
||||||
|
item := domainMap[key]
|
||||||
|
item.Label = host
|
||||||
|
item.NodeKey = nodeKey
|
||||||
|
}
|
||||||
|
if nodeKey != "" {
|
||||||
|
acc(nodeMap, nodeKey, up, down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortTrafficItems(domainMap), sortTrafficItems(nodeMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acc(m map[string]*TrafficItem, key string, up, down int64) {
|
||||||
|
item, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
item = &TrafficItem{Key: key, Label: key}
|
||||||
|
m[key] = item
|
||||||
|
}
|
||||||
|
item.Upload += up
|
||||||
|
item.Download += down
|
||||||
|
item.Total += up + down
|
||||||
|
item.Connections++
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortTrafficItems(m map[string]*TrafficItem) []TrafficItem {
|
||||||
|
out := make([]TrafficItem, 0, len(m))
|
||||||
|
for _, v := range m {
|
||||||
|
out = append(out, *v)
|
||||||
|
}
|
||||||
|
for i := 0; i < len(out); i++ {
|
||||||
|
for j := i + 1; j < len(out); j++ {
|
||||||
|
if out[j].Total > out[i].Total {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func connHost(m map[string]any) string {
|
||||||
|
meta, _ := m["metadata"].(map[string]any)
|
||||||
|
if meta == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if h, ok := meta["host"].(string); ok && h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if h, ok := meta["sniffHost"].(string); ok && h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if h, ok := meta["remoteDestination"].(string); ok && h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if ip, ok := meta["destinationIP"].(string); ok && ip != "" {
|
||||||
|
port, _ := meta["destinationPort"].(string)
|
||||||
|
if port != "" {
|
||||||
|
return ip + ":" + port
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func connLeafNode(m map[string]any, policyGroups map[string]bool) string {
|
||||||
|
return mihomoapi.ResolveLeafOutbound(mihomoapi.ChainStringsFromAny(m["chains"]), policyGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInt64(v any) int64 {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(n)
|
||||||
|
case int64:
|
||||||
|
return n
|
||||||
|
case int:
|
||||||
|
return int64(n)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyTrafficLabels(items []TrafficItem, names map[string]string) []TrafficItem {
|
||||||
|
out := make([]TrafficItem, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
out[i] = item
|
||||||
|
if label, ok := names[item.Key]; ok && label != "" {
|
||||||
|
out[i].Label = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyDomainLabels(items []TrafficItem, names map[string]string) []TrafficItem {
|
||||||
|
out := make([]TrafficItem, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
out[i] = item
|
||||||
|
if label, ok := names[item.NodeKey]; ok && label != "" {
|
||||||
|
out[i].Node = label
|
||||||
|
} else if item.NodeKey != "" {
|
||||||
|
out[i].Node = item.NodeKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TrafficAggToItems(aggs []store.TrafficAgg, names map[string]string) []TrafficItem {
|
||||||
|
out := make([]TrafficItem, 0, len(aggs))
|
||||||
|
for _, a := range aggs {
|
||||||
|
label := a.Key
|
||||||
|
if n, ok := names[a.Key]; ok && n != "" {
|
||||||
|
label = n
|
||||||
|
}
|
||||||
|
out = append(out, TrafficItem{
|
||||||
|
Key: a.Key,
|
||||||
|
Label: label,
|
||||||
|
Upload: a.Upload,
|
||||||
|
Download: a.Download,
|
||||||
|
Total: a.Upload + a.Download,
|
||||||
|
Connections: a.Connections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TrafficHostNodeAggToItems(aggs []store.TrafficHostNodeAgg, names map[string]string) []TrafficItem {
|
||||||
|
out := make([]TrafficItem, 0, len(aggs))
|
||||||
|
for _, a := range aggs {
|
||||||
|
nodeLabel := a.NodeKey
|
||||||
|
if n, ok := names[a.NodeKey]; ok && n != "" {
|
||||||
|
nodeLabel = n
|
||||||
|
}
|
||||||
|
out = append(out, TrafficItem{
|
||||||
|
Key: a.Host + "\x00" + a.NodeKey,
|
||||||
|
Label: a.Host,
|
||||||
|
Node: nodeLabel,
|
||||||
|
NodeKey: a.NodeKey,
|
||||||
|
Upload: a.Upload,
|
||||||
|
Download: a.Download,
|
||||||
|
Total: a.Upload + a.Download,
|
||||||
|
Connections: a.Connections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAggregateConnections(t *testing.T) {
|
||||||
|
groups := map[string]bool{
|
||||||
|
"PROXY": true,
|
||||||
|
"country_HK": true,
|
||||||
|
}
|
||||||
|
conns := []map[string]any{
|
||||||
|
{
|
||||||
|
"upload": float64(100),
|
||||||
|
"download": float64(200),
|
||||||
|
"metadata": map[string]any{"host": "www.google.com"},
|
||||||
|
"chains": []any{"PROXY", "node_us_1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"upload": float64(50),
|
||||||
|
"download": float64(150),
|
||||||
|
"metadata": map[string]any{"host": "www.google.com"},
|
||||||
|
"chains": []any{"country_HK", "node_us_1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"upload": float64(10),
|
||||||
|
"download": float64(20),
|
||||||
|
"metadata": map[string]any{"host": "api.github.com"},
|
||||||
|
"chains": []any{"DIRECT"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
domains, nodes := AggregateConnections(conns, groups)
|
||||||
|
if len(domains) != 2 {
|
||||||
|
t.Fatalf("domains: want 2 got %d", len(domains))
|
||||||
|
}
|
||||||
|
if domains[0].Label != "www.google.com" || domains[0].NodeKey != "node_us_1" || domains[0].Total != 500 {
|
||||||
|
t.Fatalf("google row: %+v", domains[0])
|
||||||
|
}
|
||||||
|
if domains[1].Label != "api.github.com" || domains[1].NodeKey != "DIRECT" {
|
||||||
|
t.Fatalf("github row: %+v", domains[1])
|
||||||
|
}
|
||||||
|
if len(nodes) != 2 {
|
||||||
|
t.Fatalf("nodes: want 2 got %d: %+v", len(nodes), nodes)
|
||||||
|
}
|
||||||
|
if nodes[0].Key != "node_us_1" {
|
||||||
|
t.Fatalf("top node: %+v", nodes[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/cache"
|
||||||
|
"github.com/prism/proxy/internal/mihomoapi"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Collector struct {
|
||||||
|
store *store.Store
|
||||||
|
cache *cache.Cache
|
||||||
|
apiBase string
|
||||||
|
secret string
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
connCache []map[string]any
|
||||||
|
connCacheAt time.Time
|
||||||
|
refreshing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCollector(s *store.Store, c *cache.Cache, apiBase, secret string) *Collector {
|
||||||
|
return &Collector{
|
||||||
|
store: s,
|
||||||
|
cache: c,
|
||||||
|
apiBase: trimRight(apiBase, "/"),
|
||||||
|
secret: secret,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimRight(s, cut string) string {
|
||||||
|
for len(s) > 0 && len(s) >= len(cut) && s[len(s)-len(cut):] == cut {
|
||||||
|
s = s[:len(s)-len(cut)]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns cached dashboard data and never blocks on Mihomo.
|
||||||
|
func (m *Collector) Snapshot(ctx context.Context) cache.DashboardSnapshot {
|
||||||
|
snap := m.cache.GetDashboard()
|
||||||
|
total, alive, _ := m.store.CountEnabledProxies(ctx)
|
||||||
|
snap.TotalNodes = total
|
||||||
|
snap.AliveNodes = alive
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Collector) RefreshIfStale(ctx context.Context, maxAge time.Duration) cache.DashboardSnapshot {
|
||||||
|
snap := m.cache.GetDashboard()
|
||||||
|
if !snap.UpdatedAt.IsZero() && time.Since(snap.UpdatedAt) < maxAge {
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
refreshed, err := m.Refresh(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
return refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Collector) RefreshAsync(maxAge time.Duration) {
|
||||||
|
snap := m.cache.GetDashboard()
|
||||||
|
if !snap.UpdatedAt.IsZero() && time.Since(snap.UpdatedAt) < maxAge {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.refreshing {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.refreshing = true
|
||||||
|
m.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.refreshing = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = m.Refresh(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Collector) Refresh(ctx context.Context) (cache.DashboardSnapshot, error) {
|
||||||
|
var snap cache.DashboardSnapshot
|
||||||
|
snap.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
var (
|
||||||
|
traffic TrafficRatesResult
|
||||||
|
conns map[string]any
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
rates, err := mihomoapi.New(m.apiBase, m.secret).TrafficRates(ctx)
|
||||||
|
if err == nil {
|
||||||
|
traffic = TrafficRatesResult{Up: rates.Up, Down: rates.Down, OK: true}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
conns, _ = m.getJSON("/connections")
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if traffic.OK {
|
||||||
|
snap.UploadRate = traffic.Up
|
||||||
|
snap.DownloadRate = traffic.Down
|
||||||
|
}
|
||||||
|
|
||||||
|
if conns != nil {
|
||||||
|
if arr, ok := conns["connections"].([]any); ok {
|
||||||
|
snap.Connections = len(arr)
|
||||||
|
m.setConnCache(arr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total, alive, _ := m.store.CountEnabledProxies(ctx)
|
||||||
|
snap.TotalNodes = total
|
||||||
|
snap.AliveNodes = alive
|
||||||
|
|
||||||
|
m.cache.SetDashboard(snap)
|
||||||
|
_ = m.store.AddTrafficSnapshot(ctx, snap.UploadRate, snap.DownloadRate, int64(snap.Connections))
|
||||||
|
return snap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Collector) setConnCache(arr []any) {
|
||||||
|
out := make([]map[string]any, 0, len(arr))
|
||||||
|
for _, item := range arr {
|
||||||
|
if m, ok := item.(map[string]any); ok {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.connCache = out
|
||||||
|
m.connCacheAt = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Collector) CachedConnections() []map[string]any {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return copyConnSlice(m.connCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Collector) GetConnections() ([]map[string]any, error) {
|
||||||
|
return m.GetConnectionsCached(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Collector) GetConnectionsCached(maxAge time.Duration) ([]map[string]any, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if maxAge > 0 && m.connCache != nil && time.Since(m.connCacheAt) < maxAge {
|
||||||
|
out := copyConnSlice(m.connCache)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.RefreshAsync(maxAge)
|
||||||
|
if cached := m.CachedConnections(); len(cached) > 0 {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := m.getJSON("/connections")
|
||||||
|
if err != nil {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.connCache != nil {
|
||||||
|
return copyConnSlice(m.connCache), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr, ok := data["connections"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
m.setConnCache(arr)
|
||||||
|
return m.CachedConnections(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyConnSlice(in []map[string]any) []map[string]any {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, len(in))
|
||||||
|
copy(out, in)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrafficRatesResult struct {
|
||||||
|
Up int64
|
||||||
|
Down int64
|
||||||
|
OK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Collector) UpdateAPIBase(apiBase, secret string) {
|
||||||
|
m.apiBase = trimRight(apiBase, "/")
|
||||||
|
m.secret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Collector) getJSON(path string) (map[string]any, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, m.apiBase+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m.secret != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+m.secret)
|
||||||
|
}
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package mihomoapi
|
||||||
|
|
||||||
|
var leafOutbounds = map[string]bool{
|
||||||
|
"DIRECT": true,
|
||||||
|
"REJECT": true,
|
||||||
|
"REJECT-DROP": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveLeafOutbound returns the actual proxy node from a Mihomo chains path.
|
||||||
|
// Policy groups are appended after the leaf dial, so the last chain entry is often a group name.
|
||||||
|
func ResolveLeafOutbound(chains []string, policyGroups map[string]bool) string {
|
||||||
|
if len(chains) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := len(chains) - 1; i >= 0; i-- {
|
||||||
|
name := chains[i]
|
||||||
|
if leafOutbounds[name] {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if policyGroups != nil && policyGroups[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return chains[len(chains)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChainStringsFromAny(raw any) []string {
|
||||||
|
arr, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(arr))
|
||||||
|
for _, item := range arr {
|
||||||
|
if s, ok := item.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package mihomoapi
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolveLeafOutbound(t *testing.T) {
|
||||||
|
groups := map[string]bool{
|
||||||
|
"PROXY": true,
|
||||||
|
"country_HK": true,
|
||||||
|
"自动选择": true,
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
chains []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"PROXY", "sub1_hk_node"}, "sub1_hk_node"},
|
||||||
|
{[]string{"country_HK", "sub1_us_node"}, "sub1_us_node"},
|
||||||
|
{[]string{"PROXY", "自动选择", "sub1_jp_node"}, "sub1_jp_node"},
|
||||||
|
{[]string{"DIRECT"}, "DIRECT"},
|
||||||
|
{[]string{"country_HK"}, "country_HK"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := ResolveLeafOutbound(tc.chains, groups)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("chains=%v want %q got %q", tc.chains, tc.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package mihomoapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
base string
|
||||||
|
secret string
|
||||||
|
http *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
proxyCache map[string]ProxyState
|
||||||
|
proxyCacheAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyState struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Now string `json:"now"`
|
||||||
|
All []string `json:"all"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(base, secret string) *Client {
|
||||||
|
return &Client{
|
||||||
|
base: stringsTrimRight(base, "/"),
|
||||||
|
secret: secret,
|
||||||
|
http: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListProxies() (map[string]ProxyState, error) {
|
||||||
|
return c.ListProxiesCached(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListProxiesCached(ttl time.Duration) (map[string]ProxyState, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if ttl > 0 && c.proxyCache != nil && time.Since(c.proxyCacheAt) < ttl {
|
||||||
|
out := copyProxyStates(c.proxyCache)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Proxies map[string]ProxyState `json:"proxies"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON("/proxies", &out); err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.proxyCache != nil {
|
||||||
|
return copyProxyStates(c.proxyCache), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out.Proxies == nil {
|
||||||
|
out.Proxies = map[string]ProxyState{}
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.proxyCache = out.Proxies
|
||||||
|
c.proxyCacheAt = time.Now()
|
||||||
|
c.mu.Unlock()
|
||||||
|
return copyProxyStates(out.Proxies), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Reachable() bool {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, c.base+"/version", nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.secret != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.secret)
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode < 400
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyProxyStates(in map[string]ProxyState) map[string]ProxyState {
|
||||||
|
out := make(map[string]ProxyState, len(in))
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SelectProxy(groupName, proxyName string) error {
|
||||||
|
body, _ := json.Marshal(map[string]string{"name": proxyName})
|
||||||
|
return c.putJSON("/proxies/"+url.PathEscape(groupName), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getJSON(path string, dest any) error {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, c.base+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.secret != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.secret)
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("mihomo api %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) putJSON(path string, body []byte) error {
|
||||||
|
req, err := http.NewRequest(http.MethodPut, c.base+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.secret != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.secret)
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("mihomo api %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringsTrimRight(s, cut string) string {
|
||||||
|
for len(s) > 0 && strings.HasSuffix(s, cut) {
|
||||||
|
s = s[:len(s)-len(cut)]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsPolicyGroupType(t string) bool {
|
||||||
|
switch t {
|
||||||
|
case "Selector", "URLTest", "Fallback", "LoadBalance", "Relay":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package mihomoapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConnectionSnapshot struct {
|
||||||
|
Connections []Connection `json:"connections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Connection struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Metadata ConnectionMeta `json:"metadata"`
|
||||||
|
Upload int64 `json:"upload"`
|
||||||
|
Download int64 `json:"download"`
|
||||||
|
Start time.Time `json:"start"`
|
||||||
|
Chains []string `json:"chains"`
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
RulePayload string `json:"rulePayload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionMeta struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
RemoteDestination string `json:"remoteDestination"`
|
||||||
|
DestinationIP string `json:"destinationIP"`
|
||||||
|
DestinationPort string `json:"destinationPort"`
|
||||||
|
SniffHost string `json:"sniffHost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ConnectionSnapshot() (*ConnectionSnapshot, error) {
|
||||||
|
var snap ConnectionSnapshot
|
||||||
|
if err := c.getJSON("/connections", &snap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &snap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConnectionSnapshot decodes Mihomo /connections JSON.
|
||||||
|
func ParseConnectionSnapshot(data []byte) (*ConnectionSnapshot, error) {
|
||||||
|
var snap ConnectionSnapshot
|
||||||
|
if err := json.Unmarshal(data, &snap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &snap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conn *Connection) RequestHost() string {
|
||||||
|
if conn.Metadata.Host != "" {
|
||||||
|
return conn.Metadata.Host
|
||||||
|
}
|
||||||
|
if conn.Metadata.SniffHost != "" {
|
||||||
|
return conn.Metadata.SniffHost
|
||||||
|
}
|
||||||
|
if conn.Metadata.RemoteDestination != "" {
|
||||||
|
return conn.Metadata.RemoteDestination
|
||||||
|
}
|
||||||
|
if conn.Metadata.DestinationIP != "" {
|
||||||
|
return fmt.Sprintf("%s:%s", conn.Metadata.DestinationIP, conn.Metadata.DestinationPort)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conn *Connection) RuleLine() string {
|
||||||
|
if conn.Rule == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if conn.RulePayload == "" {
|
||||||
|
return conn.Rule
|
||||||
|
}
|
||||||
|
return conn.Rule + "," + conn.RulePayload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conn *Connection) OutboundNode() string {
|
||||||
|
return ResolveLeafOutbound(conn.Chains, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conn *Connection) LeafOutbound(policyGroups map[string]bool) string {
|
||||||
|
return ResolveLeafOutbound(conn.Chains, policyGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conn *Connection) ChainString() string {
|
||||||
|
if len(conn.Chains) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(conn.Chains)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conn *Connection) IsSuccess() bool {
|
||||||
|
for _, ch := range conn.Chains {
|
||||||
|
if ch == "REJECT" || ch == "REJECT-DROP" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conn.Upload+conn.Download > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package mihomoapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseConnectionSnapshot(t *testing.T) {
|
||||||
|
raw := []byte(`{
|
||||||
|
"downloadTotal": 200,
|
||||||
|
"uploadTotal": 100,
|
||||||
|
"connections": [{
|
||||||
|
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
|
||||||
|
"metadata": {
|
||||||
|
"network": "tcp",
|
||||||
|
"type": "HTTP",
|
||||||
|
"sourceIP": "127.0.0.1",
|
||||||
|
"destinationIP": "142.250.185.78",
|
||||||
|
"destinationPort": "443",
|
||||||
|
"host": "www.google.com"
|
||||||
|
},
|
||||||
|
"upload": 100,
|
||||||
|
"download": 200,
|
||||||
|
"start": "2026-06-16T10:00:00Z",
|
||||||
|
"chains": ["DIRECT"],
|
||||||
|
"rule": "DOMAIN",
|
||||||
|
"rulePayload": "google.com"
|
||||||
|
}],
|
||||||
|
"memory": 0
|
||||||
|
}`)
|
||||||
|
|
||||||
|
snap, err := ParseConnectionSnapshot(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(snap.Connections) != 1 {
|
||||||
|
t.Fatalf("expected 1 connection, got %d", len(snap.Connections))
|
||||||
|
}
|
||||||
|
conn := snap.Connections[0]
|
||||||
|
if conn.ID == "" {
|
||||||
|
t.Fatal("expected connection id")
|
||||||
|
}
|
||||||
|
if conn.Metadata.DestinationPort != "443" {
|
||||||
|
t.Fatalf("destination port: %q", conn.Metadata.DestinationPort)
|
||||||
|
}
|
||||||
|
if conn.RequestHost() != "www.google.com" {
|
||||||
|
t.Fatalf("host: %q", conn.RequestHost())
|
||||||
|
}
|
||||||
|
if conn.OutboundNode() != "DIRECT" {
|
||||||
|
t.Fatalf("outbound: %q", conn.OutboundNode())
|
||||||
|
}
|
||||||
|
if conn.Start.IsZero() {
|
||||||
|
t.Fatal("expected start time")
|
||||||
|
}
|
||||||
|
if conn.Start.UTC() != time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) {
|
||||||
|
t.Fatalf("unexpected start: %v", conn.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package mihomoapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrafficRates struct {
|
||||||
|
Up int64 `json:"up"`
|
||||||
|
Down int64 `json:"down"`
|
||||||
|
UpTotal int64 `json:"upTotal"`
|
||||||
|
DownTotal int64 `json:"downTotal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrafficRates reads the first frame from Mihomo's streaming /traffic endpoint.
|
||||||
|
func (c *Client) TrafficRates(ctx context.Context) (TrafficRates, error) {
|
||||||
|
var zero TrafficRates
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/traffic", nil)
|
||||||
|
if err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
if c.secret != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.secret)
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return zero, fmt.Errorf("mihomo api %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var rates TrafficRates
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&rates); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
return rates, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package mihomoapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTrafficRatesReadsFirstFrame(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
_ = json.NewEncoder(w).Encode(TrafficRates{Up: 100, Down: 200})
|
||||||
|
flusher.Flush()
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
_ = json.NewEncoder(w).Encode(TrafficRates{Up: 300, Down: 400})
|
||||||
|
flusher.Flush()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := New(srv.URL, "")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rates, err := client.TrafficRates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TrafficRates: %v", err)
|
||||||
|
}
|
||||||
|
if rates.Up != 100 || rates.Down != 200 {
|
||||||
|
t.Fatalf("got %+v", rates)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package proxyio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/country"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
"github.com/prism/proxy/internal/subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ExportVersion = 1
|
||||||
|
|
||||||
|
type ExportDoc struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Nodes []ExportNode `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportNode struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
RawConfig json.RawMessage `json:"raw_config"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeEnabled(n ExportNode) bool {
|
||||||
|
if n.Enabled == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *n.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToExportNodes(nodes []store.ProxyNode) []ExportNode {
|
||||||
|
out := make([]ExportNode, 0, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
enabled := n.Enabled
|
||||||
|
raw := json.RawMessage(n.RawConfig)
|
||||||
|
if !json.Valid(raw) {
|
||||||
|
raw = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
out = append(out, ExportNode{
|
||||||
|
Name: n.Name,
|
||||||
|
Type: n.Type,
|
||||||
|
RawConfig: raw,
|
||||||
|
Country: n.Country,
|
||||||
|
Enabled: &enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportJSON(nodes []store.ProxyNode) ([]byte, error) {
|
||||||
|
doc := ExportDoc{Version: ExportVersion, Nodes: ToExportNodes(nodes)}
|
||||||
|
return json.MarshalIndent(doc, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportLinks(nodes []store.ProxyNode) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, n := range nodes {
|
||||||
|
if !n.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
link, err := EncodeLink(n)
|
||||||
|
if err != nil {
|
||||||
|
b.WriteString("# ")
|
||||||
|
b.WriteString(n.Name)
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(err.Error())
|
||||||
|
b.WriteByte('\n')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(link)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseJSON(data []byte) ([]ExportNode, error) {
|
||||||
|
var doc ExportDoc
|
||||||
|
if err := json.Unmarshal(data, &doc); err == nil && (len(doc.Nodes) > 0 || doc.Version > 0) {
|
||||||
|
return doc.Nodes, nil
|
||||||
|
}
|
||||||
|
var nodes []ExportNode
|
||||||
|
if err := json.Unmarshal(data, &nodes); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||||
|
}
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseText(text string) ([]store.ProxyNode, []string) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(text, "{") || strings.HasPrefix(text, "[") {
|
||||||
|
items, err := ParseJSON([]byte(text))
|
||||||
|
if err == nil && len(items) > 0 {
|
||||||
|
nodes, errs := ToStoreNodes(items)
|
||||||
|
return nodes, errs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subscription.ParseLinkLines(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToStoreNodes(items []ExportNode) ([]store.ProxyNode, []string) {
|
||||||
|
out := make([]store.ProxyNode, 0, len(items))
|
||||||
|
var errs []string
|
||||||
|
for i, item := range items {
|
||||||
|
name := strings.TrimSpace(item.Name)
|
||||||
|
typ := strings.TrimSpace(strings.ToLower(item.Type))
|
||||||
|
raw := normalizeRawConfig(item.RawConfig)
|
||||||
|
if name == "" {
|
||||||
|
errs = append(errs, fmt.Sprintf("节点 %d: 缺少名称", i+1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if typ == "" {
|
||||||
|
errs = append(errs, fmt.Sprintf("节点 %d: 缺少类型", i+1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if raw == "" || raw == "{}" {
|
||||||
|
errs = append(errs, fmt.Sprintf("节点 %d: 缺少配置", i+1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := strings.TrimSpace(item.Country)
|
||||||
|
if code == "" {
|
||||||
|
code = country.Detect(name)
|
||||||
|
}
|
||||||
|
out = append(out, store.ProxyNode{
|
||||||
|
Name: name,
|
||||||
|
RuntimeName: store.RuntimeName("manual", 0, name),
|
||||||
|
Type: typ,
|
||||||
|
RawConfig: raw,
|
||||||
|
Source: "manual",
|
||||||
|
Country: code,
|
||||||
|
Enabled: nodeEnabled(item),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRawConfig(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if raw[0] == '{' || raw[0] == '[' {
|
||||||
|
if !json.Valid(raw) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err == nil {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssignUniqueNames(nodes []store.ProxyNode, reserved map[string]bool) {
|
||||||
|
if reserved == nil {
|
||||||
|
reserved = map[string]bool{}
|
||||||
|
}
|
||||||
|
for i := range nodes {
|
||||||
|
name := strings.TrimSpace(nodes[i].Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "node"
|
||||||
|
}
|
||||||
|
base := name
|
||||||
|
candidate := base
|
||||||
|
for n := 2; reserved[candidate]; n++ {
|
||||||
|
candidate = fmt.Sprintf("%s_%d", base, n)
|
||||||
|
}
|
||||||
|
reserved[candidate] = true
|
||||||
|
nodes[i].Name = candidate
|
||||||
|
nodes[i].RuntimeName = store.RuntimeName("manual", 0, candidate)
|
||||||
|
if nodes[i].Country == "" {
|
||||||
|
nodes[i].Country = country.Detect(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReservedNames(nodes []store.ProxyNode) map[string]bool {
|
||||||
|
m := make(map[string]bool, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
m[n.Name] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeLink(n store.ProxyNode) (string, error) {
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(n.RawConfig), &raw); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fragment := url.QueryEscape(n.Name)
|
||||||
|
typ := strings.ToLower(n.Type)
|
||||||
|
switch typ {
|
||||||
|
case "socks5":
|
||||||
|
return encodeSocks5(raw, fragment)
|
||||||
|
case "http", "https":
|
||||||
|
return encodeHTTP(raw, fragment, typ == "https")
|
||||||
|
case "trojan":
|
||||||
|
return encodeTrojan(raw, fragment)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("类型 %s 暂不支持导出为分享链接", n.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeSocks5(raw map[string]any, fragment string) (string, error) {
|
||||||
|
server, _ := raw["server"].(string)
|
||||||
|
port := intFromAny(raw["port"])
|
||||||
|
if server == "" || port <= 0 {
|
||||||
|
return "", fmt.Errorf("socks5 配置不完整")
|
||||||
|
}
|
||||||
|
host := fmt.Sprintf("%s:%d", server, port)
|
||||||
|
user, _ := raw["username"].(string)
|
||||||
|
pass, _ := raw["password"].(string)
|
||||||
|
if user != "" {
|
||||||
|
return fmt.Sprintf("socks5://%s:%s@%s#%s", url.QueryEscape(user), url.QueryEscape(pass), host, fragment), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("socks5://%s#%s", host, fragment), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeHTTP(raw map[string]any, fragment string, tls bool) (string, error) {
|
||||||
|
server, _ := raw["server"].(string)
|
||||||
|
port := intFromAny(raw["port"])
|
||||||
|
if server == "" || port <= 0 {
|
||||||
|
return "", fmt.Errorf("http 配置不完整")
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if tls {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
host := fmt.Sprintf("%s:%d", server, port)
|
||||||
|
user, _ := raw["username"].(string)
|
||||||
|
pass, _ := raw["password"].(string)
|
||||||
|
if user != "" {
|
||||||
|
return fmt.Sprintf("%s://%s:%s@%s#%s", scheme, url.QueryEscape(user), url.QueryEscape(pass), host, fragment), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s#%s", scheme, host, fragment), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeTrojan(raw map[string]any, fragment string) (string, error) {
|
||||||
|
server, _ := raw["server"].(string)
|
||||||
|
port := intFromAny(raw["port"])
|
||||||
|
pass, _ := raw["password"].(string)
|
||||||
|
if server == "" || port <= 0 || pass == "" {
|
||||||
|
return "", fmt.Errorf("trojan 配置不完整")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("trojan://%s@%s:%d#%s", url.QueryEscape(pass), server, port, fragment), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func intFromAny(v any) int {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case int64:
|
||||||
|
return int(n)
|
||||||
|
case json.Number:
|
||||||
|
i, _ := n.Int64()
|
||||||
|
return int(i)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package proxyio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExportLinksSocks5(t *testing.T) {
|
||||||
|
text := ExportLinks([]store.ProxyNode{{
|
||||||
|
Name: "HK-1",
|
||||||
|
Type: "socks5",
|
||||||
|
RawConfig: `{"type":"socks5","server":"1.2.3.4","port":1080,"username":"u","password":"p"}`,
|
||||||
|
Enabled: true,
|
||||||
|
}})
|
||||||
|
if !strings.Contains(text, "socks5://u:p@1.2.3.4:1080#HK-1") {
|
||||||
|
t.Fatalf("got %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJSONDoc(t *testing.T) {
|
||||||
|
nodes, err := ParseJSON([]byte(`{"version":1,"nodes":[{"name":"n1","type":"socks5","raw_config":{"server":"1.1.1.1","port":1080}}]}`))
|
||||||
|
if err != nil || len(nodes) != 1 {
|
||||||
|
t.Fatalf("nodes=%+v err=%v", nodes, err)
|
||||||
|
}
|
||||||
|
stored, errs := ToStoreNodes(nodes)
|
||||||
|
if len(errs) != 0 || len(stored) != 1 || stored[0].Name != "n1" {
|
||||||
|
t.Fatalf("stored=%+v errs=%v", stored, errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignUniqueNames(t *testing.T) {
|
||||||
|
nodes := []store.ProxyNode{{Name: "a"}, {Name: "a"}}
|
||||||
|
reserved := map[string]bool{"a": true}
|
||||||
|
AssignUniqueNames(nodes, reserved)
|
||||||
|
if nodes[0].Name != "a_2" || nodes[1].Name != "a_3" {
|
||||||
|
t.Fatalf("names: %s, %s", nodes[0].Name, nodes[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package ruleio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ExportVersion = 1
|
||||||
|
|
||||||
|
type ExportDoc struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Rules []ExportRule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportRule struct {
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
RuleType string `json:"rule_type"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportResult struct {
|
||||||
|
Imported int `json:"imported"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleEnabled(r ExportRule) bool {
|
||||||
|
if r.Enabled == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *r.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToExportRules(rules []store.Rule) []ExportRule {
|
||||||
|
out := make([]ExportRule, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
enabled := r.Enabled
|
||||||
|
out = append(out, ExportRule{
|
||||||
|
Priority: r.Priority,
|
||||||
|
RuleType: r.RuleType,
|
||||||
|
Payload: r.Payload,
|
||||||
|
Target: r.Target,
|
||||||
|
Enabled: &enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportJSON(rules []store.Rule) ([]byte, error) {
|
||||||
|
doc := ExportDoc{Version: ExportVersion, Rules: ToExportRules(rules)}
|
||||||
|
return json.MarshalIndent(doc, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportText(rules []store.Rule) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range rules {
|
||||||
|
if !r.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(formatRuleLine(r))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRuleLine(r store.Rule) string {
|
||||||
|
if r.Payload == "" {
|
||||||
|
return fmt.Sprintf("%s,%s", r.RuleType, r.Target)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s,%s,%s", r.RuleType, r.Payload, r.Target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseJSON(data []byte) ([]ExportRule, error) {
|
||||||
|
var doc ExportDoc
|
||||||
|
if err := json.Unmarshal(data, &doc); err == nil && (len(doc.Rules) > 0 || doc.Version > 0) {
|
||||||
|
return doc.Rules, nil
|
||||||
|
}
|
||||||
|
var rules []ExportRule
|
||||||
|
if err := json.Unmarshal(data, &rules); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseText(text string) ([]ExportRule, []string) {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
var out []ExportRule
|
||||||
|
var errs []string
|
||||||
|
for i, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, err := ParseLine(line)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("第 %d 行: %v", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseLine(line string) (ExportRule, error) {
|
||||||
|
parts := strings.Split(line, ",")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ExportRule{}, fmt.Errorf("格式无效 %q", line)
|
||||||
|
}
|
||||||
|
ruleType := strings.TrimSpace(strings.ToUpper(parts[0]))
|
||||||
|
if ruleType == "MATCH" {
|
||||||
|
return ExportRule{}, fmt.Errorf("跳过 MATCH 规则")
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
if target == "" {
|
||||||
|
return ExportRule{}, fmt.Errorf("缺少出站目标")
|
||||||
|
}
|
||||||
|
payload := ""
|
||||||
|
if len(parts) > 2 {
|
||||||
|
payload = strings.Join(parts[1:len(parts)-1], ",")
|
||||||
|
}
|
||||||
|
if needsPayload(ruleType) && payload == "" {
|
||||||
|
return ExportRule{}, fmt.Errorf("%s 需要匹配值", ruleType)
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
return ExportRule{
|
||||||
|
RuleType: ruleType,
|
||||||
|
Payload: payload,
|
||||||
|
Target: target,
|
||||||
|
Enabled: &enabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsPayload(ruleType string) bool {
|
||||||
|
switch ruleType {
|
||||||
|
case "DIRECT", "REJECT", "REJECT-DROP", "MATCH":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToStoreRules(rules []ExportRule) ([]store.Rule, []string) {
|
||||||
|
out := make([]store.Rule, 0, len(rules))
|
||||||
|
var errs []string
|
||||||
|
for i, r := range rules {
|
||||||
|
ruleType := strings.TrimSpace(strings.ToUpper(r.RuleType))
|
||||||
|
target := strings.TrimSpace(r.Target)
|
||||||
|
if ruleType == "" || ruleType == "MATCH" {
|
||||||
|
errs = append(errs, fmt.Sprintf("规则 %d: 类型无效", i+1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
errs = append(errs, fmt.Sprintf("规则 %d: 缺少出站目标", i+1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := strings.TrimSpace(r.Payload)
|
||||||
|
if needsPayload(ruleType) && payload == "" {
|
||||||
|
errs = append(errs, fmt.Sprintf("规则 %d: %s 需要匹配值", i+1, ruleType))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, store.Rule{
|
||||||
|
Priority: r.Priority,
|
||||||
|
RuleType: ruleType,
|
||||||
|
Payload: payload,
|
||||||
|
Target: target,
|
||||||
|
Source: "manual",
|
||||||
|
Enabled: ruleEnabled(r),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssignPriorities(rules []store.Rule, start int) {
|
||||||
|
if start <= 0 {
|
||||||
|
start = 100
|
||||||
|
}
|
||||||
|
step := 10
|
||||||
|
for i := range rules {
|
||||||
|
if rules[i].Priority <= 0 {
|
||||||
|
rules[i].Priority = start
|
||||||
|
start += step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package ruleio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLine(t *testing.T) {
|
||||||
|
r, err := ParseLine("DOMAIN-SUFFIX,google.com,country:HK")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.RuleType != "DOMAIN-SUFFIX" || r.Payload != "google.com" || r.Target != "country:HK" {
|
||||||
|
t.Fatalf("unexpected %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLineSkipsMatch(t *testing.T) {
|
||||||
|
_, err := ParseLine("MATCH,DIRECT")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseText(t *testing.T) {
|
||||||
|
rules, errs := ParseText("# comment\nDOMAIN,google.com,DIRECT\n\nDOMAIN-SUFFIX,foo.com,PROXY")
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatal(errs)
|
||||||
|
}
|
||||||
|
if len(rules) != 2 {
|
||||||
|
t.Fatalf("got %d rules", len(rules))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJSONDoc(t *testing.T) {
|
||||||
|
rules, err := ParseJSON([]byte(`{"version":1,"rules":[{"rule_type":"DOMAIN","payload":"a.com","target":"DIRECT"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rules) != 1 || rules[0].Target != "DIRECT" {
|
||||||
|
t.Fatalf("unexpected %+v", rules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportText(t *testing.T) {
|
||||||
|
text := ExportText([]store.Rule{{
|
||||||
|
RuleType: "DOMAIN-SUFFIX",
|
||||||
|
Payload: "x.com",
|
||||||
|
Target: "PROXY",
|
||||||
|
Enabled: true,
|
||||||
|
}})
|
||||||
|
if !strings.Contains(text, "DOMAIN-SUFFIX,x.com,PROXY") {
|
||||||
|
t.Fatalf("got %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToStoreRulesDefaultsEnabled(t *testing.T) {
|
||||||
|
rules, errs := ToStoreRules([]ExportRule{{RuleType: "DOMAIN", Payload: "a.com", Target: "DIRECT"}})
|
||||||
|
if len(errs) != 0 || len(rules) != 1 || !rules[0].Enabled {
|
||||||
|
t.Fatalf("rules=%+v errs=%v", rules, errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package rulematch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/country"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Matched bool `json:"matched"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port string `json:"port,omitempty"`
|
||||||
|
RuleLine string `json:"rule_line"`
|
||||||
|
RuleType string `json:"rule_type"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
TargetLabel string `json:"target_label"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
SkippedGeo int `json:"skipped_geo"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseHost(raw string) (host, port string) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, "://") {
|
||||||
|
raw = "http://" + raw
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimSpace(raw), ""
|
||||||
|
}
|
||||||
|
host = u.Hostname()
|
||||||
|
port = u.Port()
|
||||||
|
if host == "" {
|
||||||
|
host = strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
return strings.ToLower(host), port
|
||||||
|
}
|
||||||
|
|
||||||
|
func Match(rules []store.Rule, defaultPolicy, rawURL string, geoReady bool) Result {
|
||||||
|
host, port := ParseHost(rawURL)
|
||||||
|
if host == "" {
|
||||||
|
return Result{Note: "无法解析 URL 或域名"}
|
||||||
|
}
|
||||||
|
|
||||||
|
skippedGeo := 0
|
||||||
|
for _, r := range rules {
|
||||||
|
if !r.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ruleType := strings.ToUpper(strings.TrimSpace(r.RuleType))
|
||||||
|
if ruleType == "MATCH" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isGeoRule(ruleType) {
|
||||||
|
if !geoReady {
|
||||||
|
skippedGeo++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Geo rules need Mihomo geodata engine; skip in offline matcher.
|
||||||
|
skippedGeo++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matchesRule(ruleType, r.Payload, host, port) {
|
||||||
|
line := formatLine(r)
|
||||||
|
return Result{
|
||||||
|
Matched: true,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
RuleLine: line,
|
||||||
|
RuleType: ruleType,
|
||||||
|
Payload: r.Payload,
|
||||||
|
Target: r.Target,
|
||||||
|
TargetLabel: targetLabel(r.Target),
|
||||||
|
Source: r.Source,
|
||||||
|
Priority: r.Priority,
|
||||||
|
SkippedGeo: skippedGeo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := defaultPolicy
|
||||||
|
if policy == "" {
|
||||||
|
policy = "DIRECT"
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
Matched: true,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
RuleLine: "MATCH," + policy,
|
||||||
|
RuleType: "MATCH",
|
||||||
|
Payload: "",
|
||||||
|
Target: policy,
|
||||||
|
TargetLabel: targetLabel(policy),
|
||||||
|
Source: "system",
|
||||||
|
Priority: 999999,
|
||||||
|
SkippedGeo: skippedGeo,
|
||||||
|
Note: "未命中任何前置规则,使用默认出站",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGeoRule(ruleType string) bool {
|
||||||
|
switch ruleType {
|
||||||
|
case "GEOIP", "GEOSITE":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesRule(ruleType, payload, host, port string) bool {
|
||||||
|
payload = strings.TrimSpace(payload)
|
||||||
|
switch ruleType {
|
||||||
|
case "DOMAIN":
|
||||||
|
return host == strings.ToLower(payload)
|
||||||
|
case "DOMAIN-SUFFIX":
|
||||||
|
suffix := strings.ToLower(payload)
|
||||||
|
return host == suffix || strings.HasSuffix(host, "."+suffix)
|
||||||
|
case "DOMAIN-KEYWORD":
|
||||||
|
return strings.Contains(host, strings.ToLower(payload))
|
||||||
|
case "IP-CIDR", "IP-CIDR6":
|
||||||
|
return matchCIDR(host, payload)
|
||||||
|
case "DST-PORT":
|
||||||
|
return port != "" && port == strings.TrimSpace(payload)
|
||||||
|
case "SRC-PORT":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchCIDR(host, cidr string) bool {
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, network, err := net.ParseCIDR(strings.TrimSpace(cidr))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return network.Contains(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatLine(r store.Rule) string {
|
||||||
|
if r.Payload == "" {
|
||||||
|
return r.RuleType + "," + r.Target
|
||||||
|
}
|
||||||
|
return r.RuleType + "," + r.Payload + "," + r.Target
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetLabel(target string) string {
|
||||||
|
target = strings.TrimSpace(target)
|
||||||
|
if code, ok := country.ParseTargetAlias(target); ok {
|
||||||
|
return country.Name(code) + "(国家池)"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(target, "country_") {
|
||||||
|
code := strings.TrimPrefix(target, "country_")
|
||||||
|
return country.Name(code) + "(国家池)"
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package rulematch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchDomainSuffix(t *testing.T) {
|
||||||
|
rules := []store.Rule{
|
||||||
|
{Priority: 10, RuleType: "DOMAIN-SUFFIX", Payload: "google.com", Target: "PROXY", Source: "manual", Enabled: true},
|
||||||
|
{Priority: 20, RuleType: "DOMAIN", Payload: "exact.com", Target: "DIRECT", Source: "manual", Enabled: true},
|
||||||
|
}
|
||||||
|
res := Match(rules, "DIRECT", "https://www.google.com/search", true)
|
||||||
|
if !res.Matched || res.RuleType != "DOMAIN-SUFFIX" || res.Target != "PROXY" {
|
||||||
|
t.Fatalf("unexpected: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchFallback(t *testing.T) {
|
||||||
|
rules := []store.Rule{
|
||||||
|
{Priority: 10, RuleType: "DOMAIN", Payload: "other.com", Target: "PROXY", Source: "manual", Enabled: true},
|
||||||
|
}
|
||||||
|
res := Match(rules, "country:HK", "https://example.com", true)
|
||||||
|
if res.RuleType != "MATCH" || res.Target != "country:HK" {
|
||||||
|
t.Fatalf("unexpected: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHost(t *testing.T) {
|
||||||
|
host, port := ParseHost("https://foo.com:8443/path")
|
||||||
|
if host != "foo.com" || port != "8443" {
|
||||||
|
t.Fatalf("got %q %q", host, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/cache"
|
||||||
|
"github.com/prism/proxy/internal/configbuilder"
|
||||||
|
"github.com/prism/proxy/internal/core"
|
||||||
|
"github.com/prism/proxy/internal/geodata"
|
||||||
|
applog "github.com/prism/proxy/internal/log"
|
||||||
|
"github.com/prism/proxy/internal/mihomoapi"
|
||||||
|
"github.com/prism/proxy/internal/store"
|
||||||
|
"github.com/prism/proxy/internal/subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
Store *store.Store
|
||||||
|
Cache *cache.Cache
|
||||||
|
Engine *core.Engine
|
||||||
|
Builder *configbuilder.Builder
|
||||||
|
Subscription *subscription.Service
|
||||||
|
Logs *applog.Collector
|
||||||
|
DataDir string
|
||||||
|
LastReloadError string
|
||||||
|
|
||||||
|
engineMu sync.RWMutex
|
||||||
|
engineOK bool
|
||||||
|
engineAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ReloadEngine(ctx context.Context) error {
|
||||||
|
geodata.InitHomeDir(a.DataDir)
|
||||||
|
settings, err := a.Store.GetSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
geodata.ApplyMihomoURLs(geodata.ConfigFromSettings(settings))
|
||||||
|
|
||||||
|
yaml, err := a.Builder.Build(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiPort := settings["mihomo_api_port"]
|
||||||
|
if apiPort == "" {
|
||||||
|
apiPort = "9090"
|
||||||
|
}
|
||||||
|
secret := settings["api_secret"]
|
||||||
|
a.Engine.Configure("127.0.0.1:"+apiPort, secret)
|
||||||
|
|
||||||
|
if err := a.applyConfig(ctx, yaml); err != nil {
|
||||||
|
a.Logs.Add("warn", "prism", "full config failed, trying minimal: "+err.Error())
|
||||||
|
minimal, mErr := a.Builder.BuildMinimal(ctx)
|
||||||
|
if mErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mErr := a.applyConfig(ctx, minimal); mErr != nil {
|
||||||
|
a.LastReloadError = err.Error()
|
||||||
|
if last := a.Cache.GetLastYAML(); last != nil {
|
||||||
|
if fallbackErr := a.Engine.Reload(last); fallbackErr == nil {
|
||||||
|
a.Logs.Add("warn", "prism", "kept previous engine config after reload failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.LastReloadError = "rules simplified: " + err.Error()
|
||||||
|
a.Logs.Add("warn", "prism", a.LastReloadError)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) applyConfig(ctx context.Context, yaml []byte) error {
|
||||||
|
if err := a.Engine.Reload(yaml); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.LastReloadError = ""
|
||||||
|
a.Cache.SetLastYAML(yaml)
|
||||||
|
a.Logs.Add("info", "prism", "engine reloaded successfully")
|
||||||
|
a.engineMu.Lock()
|
||||||
|
a.engineOK = true
|
||||||
|
a.engineAt = time.Now()
|
||||||
|
a.engineMu.Unlock()
|
||||||
|
_ = a.Store.AddAuditLog(ctx, "reload", "engine", "config applied")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) MihomoAPIBase(ctx context.Context) (string, string, error) {
|
||||||
|
settings, err := a.Store.GetSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
port := settings["mihomo_api_port"]
|
||||||
|
if port == "" {
|
||||||
|
port = "9090"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("http://127.0.0.1:%s", port), settings["api_secret"], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) EngineReachable(ctx context.Context) bool {
|
||||||
|
if a.Engine.Running() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
a.engineMu.RLock()
|
||||||
|
if time.Since(a.engineAt) < 15*time.Second {
|
||||||
|
ok := a.engineOK
|
||||||
|
a.engineMu.RUnlock()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
a.engineMu.RUnlock()
|
||||||
|
|
||||||
|
ok := a.probeEngine(ctx)
|
||||||
|
a.engineMu.Lock()
|
||||||
|
a.engineOK = ok
|
||||||
|
a.engineAt = time.Now()
|
||||||
|
a.engineMu.Unlock()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineStatusCached returns last-known engine reachability without blocking on Mihomo.
|
||||||
|
func (a *App) EngineStatusCached() bool {
|
||||||
|
if a.Engine.Running() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
a.engineMu.RLock()
|
||||||
|
defer a.engineMu.RUnlock()
|
||||||
|
return a.engineOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RefreshEngineStatusAsync() {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ok := a.probeEngine(ctx)
|
||||||
|
a.engineMu.Lock()
|
||||||
|
a.engineOK = ok
|
||||||
|
a.engineAt = time.Now()
|
||||||
|
a.engineMu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) probeEngine(ctx context.Context) bool {
|
||||||
|
base, secret, err := a.MihomoAPIBase(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mihomoapi.New(base, secret).Reachable()
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prism/proxy/internal/country"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) ProxyDisplayNames(ctx context.Context) (map[string]string, error) {
|
||||||
|
nodes, err := a.Store.ListProxyNodes(ctx, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
groups, err := a.Store.ListProxyGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := map[string]string{
|
||||||
|
"DIRECT": "DIRECT",
|
||||||
|
"REJECT": "REJECT",
|
||||||
|
}
|
||||||
|
for _, n := range nodes {
|
||||||
|
m[n.RuntimeName] = n.Name
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
m[g.RuntimeName] = g.Name
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Country == "" || seen[n.Country] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[n.Country] = true
|
||||||
|
rn := country.GroupRuntime(n.Country)
|
||||||
|
m[rn] = country.Name(n.Country) + "(国家池)"
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) PolicyGroupRuntimes(ctx context.Context) (map[string]bool, error) {
|
||||||
|
groups, err := a.Store.ListProxyGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]bool{
|
||||||
|
"GLOBAL": true,
|
||||||
|
"PROXY": true,
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.Enabled {
|
||||||
|
out[g.RuntimeName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DisplayName(names map[string]string, key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if name, ok := names[key]; ok && name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(key, "country_") {
|
||||||
|
code := strings.TrimPrefix(key, "country_")
|
||||||
|
return country.Name(code) + "(国家池)"
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||