feat: Prism HTTP/SOCKS5 代理网关及管理台
Go 控制面(SQLite + REST API)嵌入 Mihomo 数据面,Vue 3 多语言 Web 管理台。 含订阅/节点/规则/出站/监控/日志、OpenAPI、API Key、Gitea Actions CI 与开源文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git/
|
||||
dist/
|
||||
data/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
**/.DS_Store
|
||||
*.md
|
||||
!README.md
|
||||
.cursor/
|
||||
agent-transcripts/
|
||||
@@ -0,0 +1,87 @@
|
||||
# Prism CI:单元测试 + 多架构镜像发布到 Gitea Container Registry
|
||||
#
|
||||
# 前置条件(仓库 Settings → Secrets → Actions):
|
||||
# REGISTRY_TOKEN 具有 write:package 权限的 Personal Access Token
|
||||
#
|
||||
# 镜像命名:{REGISTRY}/{owner}/prism:{tag}
|
||||
# 默认 REGISTRY=git.rc707blog.top(Gitea 实例域名,Container Registry 与代码仓库同域)
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
REGISTRY: git.rc707blog.top
|
||||
IMAGE_NAME: prism
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Resolve image tags
|
||||
id: meta
|
||||
run: |
|
||||
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*)
|
||||
tag="${GITEA_REF#refs/tags/}"
|
||||
tags="${tags},${image}:${tag}"
|
||||
;;
|
||||
esac
|
||||
echo "tags=${tags}" >> "${GITHUB_OUTPUT}"
|
||||
env:
|
||||
REGISTRY: ${{ env.REGISTRY }}
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
GITEA_REF: ${{ gitea.ref }}
|
||||
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# data
|
||||
data/
|
||||
dist/
|
||||
vendor/
|
||||
|
||||
# node
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
# gitignore template for Vue.js projects
|
||||
#
|
||||
# Recommended template: Node.gitignore
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
docs/_book
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
test/
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢关注 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
|
||||
make docker-build # 本地 Docker 镜像
|
||||
```
|
||||
|
||||
## 提交规范
|
||||
|
||||
- 一个 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 要求
|
||||
|
||||
- 已注册 act_runner,且挂载 Docker socket(或 DinD)
|
||||
- `runs-on: ubuntu-latest` 标签可用
|
||||
- 支持 `linux/amd64` + `linux/arm64` 多架构构建(需 QEMU)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
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)。
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# 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
|
||||
#
|
||||
# 多架构构建并推送:
|
||||
# ./scripts/docker-build-push-multiarch.sh
|
||||
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# 基础镜像使用 docker.1ms.run 公开镜像加速
|
||||
FROM --platform=$BUILDPLATFORM docker.1ms.run/library/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 docker.1ms.run/library/golang:1.25-alpine AS go-builder
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
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 docker.1ms.run/library/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,33 @@
|
||||
.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
|
||||
|
||||
docker-build:
|
||||
docker build -t prism:latest .
|
||||
|
||||
docker-push:
|
||||
./scripts/docker-build-push-multiarch.sh
|
||||
|
||||
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,295 @@
|
||||
# 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)
|
||||
|
||||
管理台支持**简体中文、繁体中文、英文**三种界面语言,可在「系统设置 → 显示语言」中随时切换。
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| 订阅管理 | 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
|
||||
```
|
||||
|
||||
## 部署方案
|
||||
|
||||
### 方案一:二进制部署(推荐 VPS / 裸机)
|
||||
|
||||
适合已有 Linux 服务器、希望资源占用最小的场景。
|
||||
|
||||
```bash
|
||||
# 1. 构建(含前端静态资源嵌入)
|
||||
make build
|
||||
|
||||
# 2. 准备数据目录
|
||||
mkdir -p /opt/prism/data
|
||||
|
||||
# 3. 前台运行(调试)
|
||||
./dist/prism -data /opt/prism/data
|
||||
|
||||
# 4. 生产建议使用 systemd(示例)
|
||||
```
|
||||
|
||||
`/etc/systemd/system/prism.service` 示例:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Prism Proxy Gateway
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=prism
|
||||
WorkingDirectory=/opt/prism
|
||||
ExecStart=/opt/prism/prism -data /opt/prism/data
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**端口映射(默认)**
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| 管理 API / Web UI | 8080 | 浏览器访问管理台 |
|
||||
| HTTP 代理 | 7890 | 客户端 HTTP 代理 |
|
||||
| SOCKS5 代理 | 7891 | 客户端 SOCKS5 代理 |
|
||||
| Mihomo 内部 API | 9090 | 仅 127.0.0.1,勿对外暴露 |
|
||||
|
||||
代理端口可在管理台「系统设置」修改并保存后重载内核;**管理 API 端口**修改后需重启进程。
|
||||
|
||||
**反向代理(可选)**
|
||||
|
||||
若希望通过域名 + HTTPS 访问管理台,可在前面加 Nginx/Caddy,将 `/` 与 `/api` 反代到 `127.0.0.1:8080`。务必启用访问控制(VPN、IP 白名单或强密码 + 关闭公网入站代理端口)。
|
||||
|
||||
### 方案二:Docker 单容器
|
||||
|
||||
适合 NAS、面板一键部署、多架构(amd64/arm64)。
|
||||
|
||||
镜像由 Gitea Actions 自动构建,发布到 Gitea Container Registry:
|
||||
|
||||
```
|
||||
git.rc707blog.top/rose_cat707/prism:{tag}
|
||||
```
|
||||
|
||||
| Tag | 说明 |
|
||||
|-----|------|
|
||||
| `latest` | `main` / `master` 分支最新构建 |
|
||||
| `sha-xxxxxxx` | 对应 commit 短 SHA |
|
||||
| `v1.2.3` | 推送 Git tag `v1.2.3` 时发布 |
|
||||
|
||||
```bash
|
||||
# 本地构建
|
||||
make docker-build
|
||||
|
||||
# 拉取 CI 构建镜像
|
||||
docker login git.rc707blog.top
|
||||
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
|
||||
```
|
||||
|
||||
**本地多架构构建并推送**
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `REGISTRY` | `git.rc707blog.top` | Gitea 实例域名(Container Registry 与代码仓库同域) |
|
||||
| `OWNER` | `rose_cat707` | 镜像 owner |
|
||||
| `IMAGE` | `prism` | 镜像名 |
|
||||
| `TAG` | `latest` | 标签 |
|
||||
| `PLATFORM` | `linux/amd64,linux/arm64` | 目标架构 |
|
||||
|
||||
```bash
|
||||
make docker-push
|
||||
# 或
|
||||
REGISTRY=git.rc707blog.top OWNER=rose_cat707 TAG=v1.0.0 ./scripts/docker-build-push-multiarch.sh
|
||||
```
|
||||
|
||||
### 方案三:Docker Compose
|
||||
|
||||
仓库已包含 `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
docker login git.rc707blog.top
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
数据持久化在 `./data`,升级镜像时保留卷即可。
|
||||
|
||||
## 数据目录
|
||||
|
||||
`-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
|
||||
.gitea/workflows/ # Gitea Actions CI
|
||||
```
|
||||
|
||||
## CI / 自动发布
|
||||
|
||||
推送代码到 `main` / `master` 或 tag `v*` 时,Gitea Actions 会:
|
||||
|
||||
1. 运行 `go test ./...`
|
||||
2. 构建 `linux/amd64` + `linux/arm64` 镜像
|
||||
3. 推送到 `git.rc707blog.top/rose_cat707/prism`
|
||||
|
||||
维护者需在仓库 Secrets 中配置 `REGISTRY_TOKEN`(`write:package` 权限)。详见 [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)。
|
||||
+57
@@ -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 构建在线拉取依赖。
|
||||
+2013
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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};
|
||||
File diff suppressed because one or more lines are too long
@@ -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};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
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)
|
||||
}
|
||||
}
|
||||
Vendored
+68
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prism/proxy/internal/country"
|
||||
"github.com/prism/proxy/internal/mihomoapi"
|
||||
"github.com/prism/proxy/internal/store"
|
||||
)
|
||||
|
||||
type OutboundMember struct {
|
||||
RuntimeName string `json:"runtime_name"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type OutboundGroup struct {
|
||||
RuntimeName string `json:"runtime_name"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Now string `json:"now"`
|
||||
NowName string `json:"now_name"`
|
||||
Selectable bool `json:"selectable"`
|
||||
IsCountry bool `json:"is_country"`
|
||||
Members []OutboundMember `json:"members"`
|
||||
}
|
||||
|
||||
type OutboundOverview struct {
|
||||
DefaultPolicy string `json:"default_policy"`
|
||||
Groups []OutboundGroup `json:"groups"`
|
||||
}
|
||||
|
||||
func (a *App) OutboundOverview(ctx context.Context) (OutboundOverview, error) {
|
||||
settings, err := a.Store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return OutboundOverview{}, err
|
||||
}
|
||||
defaultPolicy := settings["default_policy"]
|
||||
if defaultPolicy == "" {
|
||||
defaultPolicy = "DIRECT"
|
||||
}
|
||||
|
||||
base, secret, err := a.MihomoAPIBase(ctx)
|
||||
if err != nil {
|
||||
return OutboundOverview{}, err
|
||||
}
|
||||
live, err := mihomoapi.New(base, secret).ListProxiesCached(8 * time.Second)
|
||||
if err != nil {
|
||||
return OutboundOverview{}, fmt.Errorf("mihomo proxies: %w", err)
|
||||
}
|
||||
|
||||
nodes, err := a.Store.ListProxyNodes(ctx, "", nil)
|
||||
if err != nil {
|
||||
return OutboundOverview{}, err
|
||||
}
|
||||
groups, err := a.Store.ListProxyGroups(ctx)
|
||||
if err != nil {
|
||||
return OutboundOverview{}, err
|
||||
}
|
||||
display := buildDisplayMap(nodes, groups)
|
||||
|
||||
var outGroups []OutboundGroup
|
||||
seen := map[string]bool{}
|
||||
for _, g := range groups {
|
||||
if !g.Enabled {
|
||||
continue
|
||||
}
|
||||
state, ok := live[g.RuntimeName]
|
||||
if !ok || !mihomoapi.IsPolicyGroupType(state.Type) {
|
||||
continue
|
||||
}
|
||||
seen[g.RuntimeName] = true
|
||||
outGroups = append(outGroups, buildOutboundGroup(g.RuntimeName, g.Name, g.Source, state, display))
|
||||
}
|
||||
for name, state := range live {
|
||||
if seen[name] || !mihomoapi.IsPolicyGroupType(state.Type) {
|
||||
continue
|
||||
}
|
||||
outGroups = append(outGroups, buildOutboundGroup(name, display[name], "runtime", state, display))
|
||||
}
|
||||
|
||||
return OutboundOverview{
|
||||
DefaultPolicy: defaultPolicy,
|
||||
Groups: outGroups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) SelectOutboundGroup(ctx context.Context, groupRuntime, proxyRuntime string) error {
|
||||
base, secret, err := a.MihomoAPIBase(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mihomoapi.New(base, secret).SelectProxy(groupRuntime, proxyRuntime); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = a.Store.AddAuditLog(ctx, "select", "outbound", groupRuntime+" -> "+proxyRuntime)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SetDefaultPolicy(ctx context.Context, policy string) error {
|
||||
if err := a.Store.SetSettings(ctx, map[string]string{"default_policy": policy}); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.ReloadEngine(ctx)
|
||||
}
|
||||
|
||||
func buildOutboundGroup(runtimeName, name, source string, state mihomoapi.ProxyState, display map[string]string) OutboundGroup {
|
||||
if name == "" {
|
||||
name = runtimeName
|
||||
}
|
||||
autoOnly := state.Type == "URLTest" || state.Type == "Fallback"
|
||||
members := make([]OutboundMember, 0, len(state.All))
|
||||
for _, m := range state.All {
|
||||
if autoOnly && (m == "DIRECT" || m == "REJECT" || m == "REJECT-DROP") {
|
||||
continue
|
||||
}
|
||||
if m == "DIRECT" || m == "REJECT" || m == "REJECT-DROP" {
|
||||
members = append(members, OutboundMember{RuntimeName: m, Name: m})
|
||||
continue
|
||||
}
|
||||
members = append(members, OutboundMember{
|
||||
RuntimeName: m,
|
||||
Name: displayName(display, m),
|
||||
})
|
||||
}
|
||||
return OutboundGroup{
|
||||
RuntimeName: runtimeName,
|
||||
Name: name,
|
||||
Type: state.Type,
|
||||
Source: source,
|
||||
Now: state.Now,
|
||||
NowName: displayName(display, state.Now),
|
||||
Selectable: state.Type == "Selector",
|
||||
IsCountry: strings.HasPrefix(runtimeName, "country_"),
|
||||
Members: members,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDisplayMap(nodes []store.ProxyNode, groups []store.ProxyGroup) map[string]string {
|
||||
m := map[string]string{
|
||||
"DIRECT": "DIRECT",
|
||||
"REJECT": "REJECT",
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
m[n.RuntimeName] = n.Name
|
||||
if n.Country != "" && !seen[n.Country] {
|
||||
seen[n.Country] = true
|
||||
m[country.GroupRuntime(n.Country)] = country.Name(n.Country) + "(国家池)"
|
||||
}
|
||||
}
|
||||
for _, g := range groups {
|
||||
m[g.RuntimeName] = g.Name
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func displayName(display map[string]string, runtime string) string {
|
||||
if name, ok := display[runtime]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prism/proxy/internal/geodata"
|
||||
)
|
||||
|
||||
// Display merges stored settings with effective defaults for the settings UI.
|
||||
func Display(raw map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(raw)+8)
|
||||
for k, v := range raw {
|
||||
out[k] = v
|
||||
}
|
||||
applyGeoDisplayDefaults(out)
|
||||
applyScalarDisplayDefaults(out)
|
||||
applyLocaleDisplayDefaults(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizeSubmit clears values that match built-in defaults so empty means default.
|
||||
func NormalizeSubmit(req map[string]string) {
|
||||
normalizeGeoSubmit(req)
|
||||
normalizeLocaleSubmit(req)
|
||||
}
|
||||
|
||||
func applyGeoDisplayDefaults(out map[string]string) {
|
||||
def := geodata.DefaultConfig()
|
||||
if strings.TrimSpace(out["geo_country_mmdb_url"]) == "" {
|
||||
out["geo_country_mmdb_url"] = def.CountryMmdbURL
|
||||
}
|
||||
if strings.TrimSpace(out["geo_geoip_url"]) == "" {
|
||||
out["geo_geoip_url"] = def.GeoIPURL
|
||||
}
|
||||
if strings.TrimSpace(out["geo_geosite_url"]) == "" {
|
||||
out["geo_geosite_url"] = def.GeoSiteURL
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGeoSubmit(req map[string]string) {
|
||||
def := geodata.DefaultConfig()
|
||||
if strings.TrimSpace(req["geo_country_mmdb_url"]) == def.CountryMmdbURL {
|
||||
req["geo_country_mmdb_url"] = ""
|
||||
}
|
||||
if strings.TrimSpace(req["geo_geoip_url"]) == def.GeoIPURL {
|
||||
req["geo_geoip_url"] = ""
|
||||
}
|
||||
if strings.TrimSpace(req["geo_geosite_url"]) == def.GeoSiteURL {
|
||||
req["geo_geosite_url"] = ""
|
||||
}
|
||||
}
|
||||
|
||||
func applyScalarDisplayDefaults(out map[string]string) {
|
||||
if strings.TrimSpace(out["login_max_attempts"]) == "" {
|
||||
out["login_max_attempts"] = strconv.Itoa(5)
|
||||
}
|
||||
if strings.TrimSpace(out["login_lock_minutes"]) == "" {
|
||||
out["login_lock_minutes"] = strconv.Itoa(15)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDisplayFillsGeoDefaults(t *testing.T) {
|
||||
out := Display(map[string]string{
|
||||
"geo_country_mmdb_url": "",
|
||||
"geo_geoip_url": "",
|
||||
"geo_geosite_url": "",
|
||||
})
|
||||
if out["geo_country_mmdb_url"] == "" || out["geo_geoip_url"] == "" || out["geo_geosite_url"] == "" {
|
||||
t.Fatalf("expected geo defaults, got %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSubmitClearsDefaultGeoURLs(t *testing.T) {
|
||||
out := Display(map[string]string{})
|
||||
req := map[string]string{
|
||||
"geo_country_mmdb_url": out["geo_country_mmdb_url"],
|
||||
"geo_geoip_url": out["geo_geoip_url"],
|
||||
"geo_geosite_url": out["geo_geosite_url"],
|
||||
}
|
||||
NormalizeSubmit(req)
|
||||
if req["geo_country_mmdb_url"] != "" || req["geo_geoip_url"] != "" || req["geo_geosite_url"] != "" {
|
||||
t.Fatalf("expected empty submit, got %+v", req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package settings
|
||||
|
||||
// Well-known settings keys stored in SQLite.
|
||||
const (
|
||||
KeyHTTPPort = "http_port"
|
||||
KeySocksPort = "socks_port"
|
||||
KeyAPIPort = "api_port"
|
||||
KeyMihomoAPIPort = "mihomo_api_port"
|
||||
KeyAPISecret = "api_secret"
|
||||
KeyAdminPassword = "admin_password"
|
||||
KeyAuthEnabled = "auth_enabled"
|
||||
KeyLogLevel = "log_level"
|
||||
KeyHealthIntervalSec = "health_interval_sec"
|
||||
KeySubSyncEnabled = "sub_sync_enabled"
|
||||
KeyDefaultPolicy = "default_policy"
|
||||
KeyGeoAutoDownload = "geo_auto_download"
|
||||
KeyLoginMaxAttempts = "login_max_attempts"
|
||||
KeyLoginLockMinutes = "login_lock_minutes"
|
||||
KeyProxyAuthUser = "proxy_auth_user"
|
||||
KeyProxyAuthPass = "proxy_auth_pass"
|
||||
KeyGeoCountryMmdbURL = "geo_country_mmdb_url"
|
||||
KeyGeoGeoIPURL = "geo_geoip_url"
|
||||
KeyGeoGeoSiteURL = "geo_geosite_url"
|
||||
KeyUILocale = "ui_locale"
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package settings
|
||||
|
||||
import "strings"
|
||||
|
||||
var validUILocales = map[string]bool{
|
||||
"zh-CN": true,
|
||||
"zh-TW": true,
|
||||
"en": true,
|
||||
}
|
||||
|
||||
func NormalizeUILocale(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if validUILocales[v] {
|
||||
return v
|
||||
}
|
||||
return "zh-CN"
|
||||
}
|
||||
|
||||
func applyLocaleDisplayDefaults(out map[string]string) {
|
||||
if strings.TrimSpace(out[KeyUILocale]) == "" {
|
||||
out[KeyUILocale] = "zh-CN"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLocaleSubmit(req map[string]string) {
|
||||
if v, ok := req[KeyUILocale]; ok {
|
||||
req[KeyUILocale] = NormalizeUILocale(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKey, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, key_prefix, enabled, last_used_at, created_at
|
||||
FROM api_keys
|
||||
ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAPIKeys(rows)
|
||||
}
|
||||
|
||||
func (s *Store) CreateAPIKey(ctx context.Context, name, keyPrefix, keyHash string) (*APIKey, error) {
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO api_keys(name, key_prefix, key_hash, enabled)
|
||||
VALUES(?,?,?,1)`, name, keyPrefix, keyHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetAPIKey(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) GetAPIKey(ctx context.Context, id int64) (*APIKey, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, key_prefix, enabled, last_used_at, created_at
|
||||
FROM api_keys WHERE id=?`, id)
|
||||
return scanAPIKey(row)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAPIKey(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM api_keys WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) VerifyAPIKey(ctx context.Context, keyHash string) (bool, error) {
|
||||
var id int64
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id FROM api_keys WHERE key_hash=? AND enabled=1`, keyHash).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at=datetime('now') WHERE id=?`, id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func scanAPIKeys(rows *sql.Rows) ([]APIKey, error) {
|
||||
var out []APIKey
|
||||
for rows.Next() {
|
||||
item, err := scanAPIKeyFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanAPIKey(row *sql.Row) (*APIKey, error) {
|
||||
var item APIKey
|
||||
var enabled int
|
||||
var lastUsed, created sql.NullString
|
||||
if err := row.Scan(&item.ID, &item.Name, &item.KeyPrefix, &enabled, &lastUsed, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Enabled = enabled == 1
|
||||
if lastUsed.Valid {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", lastUsed.String); err == nil {
|
||||
item.LastUsedAt = &t
|
||||
}
|
||||
}
|
||||
if created.Valid {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil {
|
||||
item.CreatedAt = t
|
||||
}
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanAPIKeyFromRows(rows *sql.Rows) (*APIKey, error) {
|
||||
var item APIKey
|
||||
var enabled int
|
||||
var lastUsed, created sql.NullString
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.KeyPrefix, &enabled, &lastUsed, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Enabled = enabled == 1
|
||||
if lastUsed.Valid {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", lastUsed.String); err == nil {
|
||||
item.LastUsedAt = &t
|
||||
}
|
||||
}
|
||||
if created.Valid {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil {
|
||||
item.CreatedAt = t
|
||||
}
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/prism/proxy/internal/country"
|
||||
)
|
||||
|
||||
type CountryStat struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (s *Store) ListCountryStats(ctx context.Context) ([]CountryStat, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT country, COUNT(*) FROM proxy_nodes
|
||||
WHERE enabled=1 AND country != ''
|
||||
GROUP BY country ORDER BY COUNT(*) DESC, country`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CountryStat
|
||||
for rows.Next() {
|
||||
var st CountryStat
|
||||
if err := rows.Scan(&st.Code, &st.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.Name = country.Name(st.Code)
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) BackfillNodeCountries(ctx context.Context) (int, error) {
|
||||
nodes, err := s.ListProxyNodes(ctx, "", nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
updated := 0
|
||||
for _, n := range nodes {
|
||||
code := n.Country
|
||||
if code == "" {
|
||||
code = country.Detect(n.Name)
|
||||
}
|
||||
if code == "" || code == n.Country {
|
||||
continue
|
||||
}
|
||||
if err := s.UpdateProxyNodeCountry(ctx, n.ID, code); err != nil {
|
||||
return updated, err
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateProxyNodeCountry(ctx context.Context, id int64, code string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE proxy_nodes SET country=?, updated_at=datetime('now') WHERE id=?`, code, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RebuildAllNodeCountries(ctx context.Context) (int, error) {
|
||||
nodes, err := s.ListProxyNodes(ctx, "", nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
updated := 0
|
||||
for _, n := range nodes {
|
||||
code := country.Detect(n.Name)
|
||||
if code == n.Country {
|
||||
continue
|
||||
}
|
||||
if err := s.UpdateProxyNodeCountry(ctx, n.ID, code); err != nil {
|
||||
return updated, err
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoginAttempt struct {
|
||||
FailCount int
|
||||
WindowStart time.Time
|
||||
LockedUntil *time.Time
|
||||
}
|
||||
|
||||
func (s *Store) GetLoginAttempt(ctx context.Context, ip string) (*LoginAttempt, error) {
|
||||
var failCount int
|
||||
var windowStart string
|
||||
var lockedUntil sql.NullString
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT fail_count, window_start, locked_until FROM login_attempts WHERE ip = ?`, ip,
|
||||
).Scan(&failCount, &windowStart, &lockedUntil)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &LoginAttempt{
|
||||
FailCount: failCount,
|
||||
WindowStart: parseTime(windowStart),
|
||||
}
|
||||
if lockedUntil.Valid && lockedUntil.String != "" {
|
||||
t := parseTime(lockedUntil.String)
|
||||
out.LockedUntil = &t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetLoginAttempt(ctx context.Context, ip string, attempt LoginAttempt) error {
|
||||
var locked string
|
||||
if attempt.LockedUntil != nil {
|
||||
locked = attempt.LockedUntil.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO login_attempts(ip, fail_count, window_start, locked_until)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(ip) DO UPDATE SET
|
||||
fail_count = excluded.fail_count,
|
||||
window_start = excluded.window_start,
|
||||
locked_until = excluded.locked_until
|
||||
`, ip, attempt.FailCount, attempt.WindowStart.Format("2006-01-02 15:04:05"), nullIfEmpty(locked))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClearLoginAttempt(ctx context.Context, ip string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM login_attempts WHERE ip = ?`, ip)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'clash',
|
||||
interval_sec INTEGER NOT NULL DEFAULT 3600,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_fetch_at TEXT,
|
||||
last_error TEXT,
|
||||
node_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proxy_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
runtime_name TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL,
|
||||
raw_config TEXT NOT NULL DEFAULT '{}',
|
||||
source TEXT NOT NULL DEFAULT 'subscription',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
latency_ms INTEGER NOT NULL DEFAULT -1,
|
||||
alive INTEGER NOT NULL DEFAULT 0,
|
||||
checked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
rule_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
subscription_id INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proxy_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
runtime_name TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL DEFAULT 'select',
|
||||
proxies TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'subscription',
|
||||
subscription_id INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS request_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
source TEXT NOT NULL DEFAULT 'prism',
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_rate INTEGER NOT NULL DEFAULT 0,
|
||||
download_rate INTEGER NOT NULL DEFAULT 0,
|
||||
connections INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||
('http_port', '7890'),
|
||||
('socks_port', '7891'),
|
||||
('api_port', '8080'),
|
||||
('mihomo_api_port', '9090'),
|
||||
('api_secret', ''),
|
||||
('admin_password', 'admin'),
|
||||
('auth_enabled', 'true'),
|
||||
('log_level', 'info'),
|
||||
('health_interval_sec', '300'),
|
||||
('sub_sync_enabled', 'true'),
|
||||
('default_policy', 'DIRECT'),
|
||||
('geo_auto_download', 'true');
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS traffic_snapshots;
|
||||
DROP TABLE IF EXISTS request_logs;
|
||||
DROP TABLE IF EXISTS audit_logs;
|
||||
DROP TABLE IF EXISTS settings;
|
||||
DROP TABLE IF EXISTS proxy_groups;
|
||||
DROP TABLE IF EXISTS rules;
|
||||
DROP TABLE IF EXISTS proxy_nodes;
|
||||
DROP TABLE IF EXISTS subscriptions;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE proxy_nodes ADD COLUMN country TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE proxy_nodes DROP COLUMN country;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
network TEXT NOT NULL DEFAULT '',
|
||||
rule_type TEXT NOT NULL DEFAULT '',
|
||||
rule_payload TEXT NOT NULL DEFAULT '',
|
||||
rule_line TEXT NOT NULL DEFAULT '',
|
||||
outbound_node TEXT NOT NULL DEFAULT '',
|
||||
outbound_chain TEXT NOT NULL DEFAULT '',
|
||||
upload INTEGER NOT NULL DEFAULT 0,
|
||||
download INTEGER NOT NULL DEFAULT 0,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_proxy_request_logs_created ON proxy_request_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_proxy_request_logs_host ON proxy_request_logs(host);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS proxy_request_logs;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
CREATE INDEX IF NOT EXISTS idx_proxy_request_logs_node ON proxy_request_logs(outbound_node);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_proxy_request_logs_node;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
ip TEXT PRIMARY KEY,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
window_start TEXT NOT NULL,
|
||||
locked_until TEXT
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||
('login_max_attempts', '5'),
|
||||
('login_lock_minutes', '15');
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS login_attempts;
|
||||
DELETE FROM settings WHERE key IN ('login_max_attempts', 'login_lock_minutes');
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||
('proxy_auth_user', ''),
|
||||
('proxy_auth_pass', '');
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM settings WHERE key IN ('proxy_auth_user', 'proxy_auth_pass');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user