feat: Prism HTTP/SOCKS5 代理网关及管理台
CI / docker (push) Failing after 10m34s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-23 15:24:45 +08:00
commit 99c30839e8
162 changed files with 20031 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git/
dist/
data/
web/node_modules/
web/dist/
**/.DS_Store
*.md
!README.md
.cursor/
agent-transcripts/
+81
View File
@@ -0,0 +1,81 @@
# Gitea Actions CI 模板(Go + Vue
复制整个 `.gitea/` 目录到新仓库即可启用 CI:**可选 go test** + **Docker 构建/推送**Go 编译与 Vue 构建在 Dockerfile 内完成)。
## 项目约定
| 路径 | 说明 |
|------|------|
| `go.mod` | 仓库根目录 |
| `Dockerfile` | 仓库根目录,多阶段构建(含前端 `web/` |
| `web/` | 可选;由 Dockerfile 内 `npm run build` 处理,CI 不再单独构建 |
## 一次性配置
1. **Runner**:部署 act_runner,标签含 `ubuntu-latest`,并挂载 `docker.sock`(见 [act-runner/README.md](act-runner/README.md))。
2. **Secret**:仓库 Settings → Actions → Secrets,添加 `REGISTRY_TOKEN`PAT`write:package` 权限)。
3. **Variable(推荐)**:若 runner 与 Gitea 同机、或 `gitea.server_url` 为内网地址,必须设置 `REGISTRY=你的公网域名`(如 `git.example.com`**不要**填 `172.17.0.1:13827`)。
4. **Gitea Registry**:服务端 `[packages] ENABLED = true``ROOT_URL` 正确;穿透场景建议 `PUBLIC_URL_DETECTION = never`Gitea 1.26+)。
`REGISTRY` 未设置时,会从 `GITEA_ROOT_URL``gitea.server_url` 推断;均为内网地址时 workflow 会提前失败并提示。
## 可选 Variables
仓库 Settings → Actions → Variables(留空则用默认值):
| 变量 | 默认 | 说明 |
|------|------|------|
| `REGISTRY` | 见下方推断顺序 | **公网** Registry 主机名,如 `git.example.com`(勿用内网 IP:13827 |
| `GITEA_ROOT_URL` | (空) | 当 `gitea.server_url` 为内网时,可设 `https://git.example.com/` |
| `IMAGE_NAME` | 仓库名小写 | 镜像名,非 owner/repo 全路径 |
| `DOCKERFILE` | `Dockerfile` | Dockerfile 路径 |
| `DOCKER_PLATFORMS` | `linux/amd64,linux/arm64` | push 时 buildx 平台 |
| `GO_TEST_SCOPE` | `./...` | `go test` 包路径 |
| `RUN_GO_TEST` | (空,即运行) | 设 `false` 跳过 go test,仅 Docker 构建 |
| `DOCKER_IMAGE_PREFIX` | `1panel` | 基础镜像前缀;可选 `hub` / `daocloud` |
| `GOPROXY` | `https://goproxy.cn,direct` | Go 模块代理 |
| `GOSUMDB` | `sum.golang.google.cn` | Go checksum 数据库 |
## 触发与镜像 tag
- **pull_request**go test(可关)+ 单架构 `docker build` 验证(不推送)
- **push main/master**go test + 多架构构建并推送 `:latest``:sha-xxxxxxx`
- **push tag v\***:额外推送 `:v1.2.3`
示例(仓库 `rose_cat707/Prism`Gitea 在 `git.example.com`):
```text
git.example.com/rose_cat707/prism:latest
git.example.com/rose_cat707/prism:sha-35b3b48
```
## 目录结构
```text
.gitea/
├── README.md # 本文件
├── workflows/
│ └── ci.yml # 主工作流
└── act-runner/ # runner 部署参考(可选)
├── README.md
├── config.yaml
├── docker-compose.yml
└── .env.example
```
## 本地验证 Registry
```bash
host=$(echo "https://你的-gitea-地址/" | sed -e 's|^https://||' -e 's|/.*||')
curl -s -D - "https://${host}/v2/" -o /dev/null | grep -i www-authenticate
echo "$REGISTRY_TOKEN" | docker login "$host" -u 你的用户名 --password-stdin
```
`realm` 应指向公网 Gitea 域名,而非 `127.0.0.1`
## 常见 Registry 错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `Get "https://172.17.0.1:13827/v2/"` HTTP/HTTPS | Variable `REGISTRY``gitea.server_url` 为内网地址 | 设 `REGISTRY=git.example.com` |
| token 指向 `127.0.0.1` | Gitea `realm` 配置错误 | `PUBLIC_URL_DETECTION=never` + 正确 `ROOT_URL` |
+3
View File
@@ -0,0 +1,3 @@
GITEA_INSTANCE_URL=https://git.example.com
GITEA_RUNNER_REGISTRATION_TOKEN=从仓库_Settings_Actions_Runners_复制
GITEA_RUNNER_NAME=go-vue-ci-runner
+144
View File
@@ -0,0 +1,144 @@
# act_runner 配置参考
工作流 `runs-on: ubuntu-latest``docker` job 额外使用 `catthehacker/ubuntu:act-22.04` 容器(含 Docker CLI)。
## 构建镜像必须:挂载 Docker Socket
`docker` job 需要访问宿主机 Docker 引擎。在 **runner 所在机器**`config.yaml` 中配置:
```yaml
container:
options: -v /var/run/docker.sock:/var/run/docker.sock
valid_volumes:
- /var/run/docker.sock
```
推荐 job 镜像(含 Node + Docker CLI):
```yaml
runner:
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
```
修改后 **重启 runner**(例如 `docker restart <runner容器>` 或重启 Gitea Runner 服务)。
## 1Panel 宿主机:配置 Docker 镜像加速(推荐)
Runner 通过 `docker.sock` 使用宿主机 Docker。在 **1Panel** 中配置加速器后,普通 `docker pull` 会走加速;**buildx 多架构构建**仍建议配合 workflow 内的 `IMAGE_PREFIX`(默认 `docker.1panel.live`)。
1. 登录 1Panel → **容器****配置**
2. **镜像加速地址** 填入:
```text
https://docker.1panel.live
```
3. 保存并 **重启 Docker**
验证(在 runner 宿主机):
```bash
docker info | grep -A5 'Registry Mirrors'
docker pull alpine:3.20
```
等价 `daemon.json`
```json
{
"registry-mirrors": ["https://docker.1panel.live"]
}
```
CI 工作流默认 `IMAGE_PREFIX=docker.1panel.live/library/`buildkit 拉基础镜像)。若 1Panel 加速不可用,可在仓库 Variables 设 `DOCKER_IMAGE_PREFIX=daocloud` 或 `hub`。
参考:[1Panel 容器配置文档](https://1panel.cn/docs/user_manual/containers/setting)
## 验证
在 runner 宿主机执行:
```bash
docker info
ls -l /var/run/docker.sock
```
## 从零部署 runner(可选)
```bash
cd .gitea/act-runner
cp .env.example .env # 填入 Registration Token
docker compose up -d
```
## 常见错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `docker: command not found` | job 容器无 Docker CLI | 工作流已指定 act 镜像;或 runner 改用 catthehacker/ubuntu |
| `Cannot connect to Docker daemon` | 未挂载 docker.sock | 按上文修改 config.yaml 并重启 runner |
| `node not in PATH` | job 镜像无 Node | 标签映射改用 catthehacker/ubuntu:act-22.04 |
| `registry-1.docker.io` i/o timeout | Docker Hub 不可达 | 1Panel 配 `docker.1panel.live`Variable `DOCKER_IMAGE_PREFIX=1panel` |
| `http: server gave HTTP response to HTTPS client` 且 token 指向 `127.0.0.1` | **Gitea Registry 配置/反代错误** | 见下文「Registry 登录失败」 |
## Registry 登录失败(127.0.0.1 / HTTP vs HTTPS
若 `docker login git.rc707blog.top` 报错类似:
```text
Get "https://127.0.0.1:xxxxx/v2/token?...": http: server gave HTTP response to HTTPS client
```
说明 Gitea 把 **Docker 认证 token 地址** 配成了本机内网地址,CI runner 访问不到。需在 **Gitea 服务器** 修复,而非改 workflow。
### 1. 检查 `app.ini`
```ini
[server]
ROOT_URL = https://git.example.com/
LOCAL_ROOT_URL = http://127.0.0.1:3000/
; 内网穿透 / 错误 Host 时(Gitea 1.26+):
; PUBLIC_URL_DETECTION = never
[packages]
ENABLED = true
```
`ROOT_URL` 必须与浏览器访问 Gitea 的 **HTTPS 外网地址** 完全一致(含末尾 `/`)。
### 2. 反向代理必须转发 `/v2` 并带上头
Container Registry 固定使用根路径 `/v2`。Nginx 示例:
```nginx
location / {
client_max_body_size 0;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
关键:`X-Forwarded-Proto: https` 和正确的 `Host`(与 Gitea `ROOT_URL` 域名一致)。
### 3. 在 runner 宿主机验证(修复后)
```bash
GITEA_HOST=git.example.com # 改成你的 Gitea 域名
curl -s -D - "https://${GITEA_HOST}/v2/" -o /dev/null | grep -i www-authenticate
echo "$REGISTRY_TOKEN" | docker login "${GITEA_HOST}" -u 你的用户名 --password-stdin
```
应返回 `401 Unauthorized`(正常,表示 registry 可达)且 `docker login` 显示 **Login Succeeded**。
参考:[Gitea 反向代理文档](https://docs.gitea.com/administration/reverse-proxies)
## Gitea Runner v0.6.x(个人 runner
1. 找到 runner 的配置文件或环境(安装目录 / docker compose
2. 确保 runner 进程能访问宿主机 `/var/run/docker.sock`
3. Runners 页标签含 `ubuntu-latest` 且状态 **空闲/在线**
若使用 Gitea 网页注册的个人 runnerdocker 模式),通常需在 runner 启动参数或 `config.yaml` 里加入 socket 挂载,具体路径取决于你的安装方式。
+26
View File
@@ -0,0 +1,26 @@
# act_runner 配置(可选参考部署)
# 工作流 runs-on: ubuntu-latest
log:
level: info
runner:
file: .runner
capacity: 2
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
cache:
enabled: false
container:
network: bridge
privileged: false
options: -v /var/run/docker.sock:/var/run/docker.sock
valid_volumes:
- /var/run/docker.sock
+22
View File
@@ -0,0 +1,22 @@
# Gitea act_runner — 可选参考部署(标签 default,与工作流一致)
#
# 若已在 Gitea 注册个人/仓库 runner 且标签为 default,无需使用本目录。
services:
act-runner:
image: docker.io/gitea/act_runner:0.2.12
container_name: prism-act-runner
restart: unless-stopped
environment:
CONFIG_FILE: /config.yaml
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.rc707blog.top}
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-prism-ci-runner}
volumes:
- ./config.yaml:/config.yaml:ro
- act-runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
working_dir: /data
volumes:
act-runner-data:
+232
View File
@@ -0,0 +1,232 @@
# 通用 Go + Vue 项目 CIGitea Actions
#
# 单 job:可选 go test + Docker 多架构构建/推送
# 约定:仓库根目录 go.mod、Dockerfile;前端(可选)在 web/
#
# 前置:act_runnerubuntu-latest + docker.sock)、Secret REGISTRY_TOKEN
# VariablesREGISTRY、IMAGE_NAME、DOCKER_IMAGE_PREFIX、RUN_GO_TEST 等
name: CI
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
env:
REGISTRY: ${{ vars.REGISTRY }}
GITEA_ROOT_URL: ${{ vars.GITEA_ROOT_URL }}
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
DOCKERFILE: ${{ vars.DOCKERFILE }}
DOCKER_PLATFORMS: ${{ vars.DOCKER_PLATFORMS }}
GO_TEST_SCOPE: ${{ vars.GO_TEST_SCOPE }}
RUN_GO_TEST: ${{ vars.RUN_GO_TEST }}
DOCKER_IMAGE_PREFIX: ${{ vars.DOCKER_IMAGE_PREFIX }}
GOPROXY: ${{ vars.GOPROXY }}
GOSUMDB: ${{ vars.GOSUMDB }}
jobs:
docker:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run Go tests
if: vars.RUN_GO_TEST != 'false'
shell: bash
run: |
set -euo pipefail
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export GOSUMDB="${GOSUMDB:-sum.golang.google.cn}"
GO_VERSION=$(grep '^go ' go.mod | awk '{print $2}')
ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
TAR="go${GO_VERSION}.linux-${ARCH}.tar.gz"
if ! command -v go >/dev/null 2>&1 || ! go version | grep -q "go${GO_VERSION} "; then
downloaded=0
for url in \
"https://mirrors.aliyun.com/golang/${TAR}" \
"https://golang.google.cn/dl/${TAR}" \
"https://go.dev/dl/${TAR}"; do
echo "trying ${url}"
if curl -fsSL --connect-timeout 20 --retry 3 --retry-delay 5 --max-time 600 \
"$url" -o /tmp/go.tgz; then
downloaded=1
break
fi
done
[ "$downloaded" -eq 1 ] || { echo "failed to download Go ${GO_VERSION}" >&2; exit 1; }
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tgz
export PATH="/usr/local/go/bin:${PATH}"
fi
go version
go test "${GO_TEST_SCOPE:-./...}"
- name: Setup Docker
run: |
set -eux
if ! command -v docker >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io
fi
if [ ! -S /var/run/docker.sock ]; then
echo "ERROR: /var/run/docker.sock 未挂载到 job 容器。" >&2
echo "请在 act_runner config.yaml 中配置:" >&2
echo " container.options: -v /var/run/docker.sock:/var/run/docker.sock" >&2
echo " container.valid_volumes: [/var/run/docker.sock]" >&2
exit 1
fi
docker info
- name: Resolve registry
id: reg
if: gitea.event_name == 'push'
shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
run: |
set -euo pipefail
url_to_host() {
echo "$1" | sed -e 's|^https://||' -e 's|^http://||' -e 's|/.*||'
}
is_internal_host() {
local host="${1%%:*}"
case "$host" in
localhost|127.*|10.*|192.168.*) return 0 ;;
172.*)
local second
second=$(echo "$host" | cut -d. -f2)
[ "$second" -ge 16 ] && [ "$second" -le 31 ]
return
;;
*) return 1 ;;
esac
}
registry="${REGISTRY:-}"
if [ -z "$registry" ] && [ -n "${GITEA_ROOT_URL:-}" ]; then
registry=$(url_to_host "${GITEA_ROOT_URL}")
fi
if [ -z "$registry" ]; then
registry=$(url_to_host "${GITEA_SERVER_URL}")
fi
if is_internal_host "$registry"; then
echo "ERROR: Registry 主机 '${registry}' 是内网地址。" >&2
echo "请设置 Variable REGISTRY=公网 Gitea 域名(如 git.example.com)。" >&2
exit 1
fi
echo "registry host: ${registry}"
auth_header=""
if auth_header=$(curl -fsS -D - "https://${registry}/v2/" -o /dev/null 2>&1 | grep -i '^www-authenticate:'); then
if echo "$auth_header" | grep -qiE '127\.0\.0\.1|172\.(1[6-9]|2[0-9]|3[01])\.|localhost|:13827'; then
echo "ERROR: Registry token realm 指向内网地址:${auth_header}" >&2
exit 1
fi
fi
echo "host=${registry}" >> "${GITHUB_OUTPUT}"
- name: Log in to Container Registry
if: gitea.event_name == 'push'
shell: bash
run: |
set -euo pipefail
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login "${{ steps.reg.outputs.host }}" -u "${{ gitea.actor }}" --password-stdin
- name: Build image
shell: bash
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_REF: ${{ gitea.ref }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
GITEA_EVENT_NAME: ${{ gitea.event_name }}
REGISTRY_HOST: ${{ steps.reg.outputs.host }}
run: |
set -euo pipefail
dockerfile="${DOCKERFILE:-Dockerfile}"
if [ "${DOCKER_IMAGE_PREFIX:-}" = "hub" ] || [ "${DOCKER_IMAGE_PREFIX:-}" = "docker.io" ]; then
image_prefix=""
elif [ -z "${DOCKER_IMAGE_PREFIX:-}" ] || [ "${DOCKER_IMAGE_PREFIX}" = "1panel" ]; then
image_prefix="docker.1panel.live/library/"
elif [ "${DOCKER_IMAGE_PREFIX}" = "daocloud" ]; then
image_prefix="docker.m.daocloud.io/library/"
else
image_prefix="${DOCKER_IMAGE_PREFIX}"
[[ "${image_prefix}" == */ ]] || image_prefix="${image_prefix}/"
fi
echo "using IMAGE_PREFIX=${image_prefix:-<docker.io>}"
build_args=(--build-arg "IMAGE_PREFIX=${image_prefix}")
builder_id="gitea-buildx-${GITEA_REPOSITORY//\//-}"
if [ "${GITEA_EVENT_NAME}" = "push" ]; then
install_binfmt() {
for img in \
"docker.1panel.live/tonistiigi/binfmt:latest" \
"docker.m.daocloud.io/tonistiigi/binfmt:latest" \
"tonistiigi/binfmt:latest"; do
echo "trying binfmt image: ${img}"
if docker run --privileged --rm "${img}" --install all; then
return 0
fi
done
return 1
}
install_binfmt || true
registry="${REGISTRY_HOST}"
image_name="${IMAGE_NAME:-$(echo "${GITEA_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')}"
platforms="${DOCKER_PLATFORMS:-linux/amd64,linux/arm64}"
image="${registry}/${GITEA_REPOSITORY_OWNER}/${image_name}"
tags="${image}:sha-${GITEA_SHA:0:7}"
case "${GITEA_REF}" in
refs/heads/main|refs/heads/master)
tags="${tags},${image}:latest"
;;
refs/tags/v*)
tags="${tags},${image}:${GITEA_REF#refs/tags/}"
;;
esac
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
docker buildx inspect --bootstrap
tag_args=()
IFS=',' read -r -a tag_list <<< "$tags"
for t in "${tag_list[@]}"; do
tag_args+=(-t "$t")
done
docker buildx build \
--platform "${platforms}" \
-f "${dockerfile}" \
"${build_args[@]}" \
"${tag_args[@]}" \
--push \
.
else
# PR:仅单架构构建验证 Dockerfile,不推送
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
docker buildx inspect --bootstrap
docker buildx build \
--platform linux/amd64 \
-f "${dockerfile}" \
"${build_args[@]}" \
--load \
.
fi
+45
View File
@@ -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/
+97
View File
@@ -0,0 +1,97 @@
# 贡献指南
感谢关注 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 配置
CI 使用 **`runs-on: ubuntu-latest`**(须与 Runners 页「**标签**」列一致)。
1. **Settings → Actions → Runners** 确认 runner **在线**,标签含 `ubuntu-latest`
2. 标签映射的 job 镜像须含 **Node.js**(否则 `actions/checkout` 会报 `node not in PATH`
3. **构建镜像**runner 须挂载 `/var/run/docker.sock`(见 [.gitea/act-runner/README.md](.gitea/act-runner/README.md)
4. CI 使用 shell 安装 Go(阿里云 / golang.google.cn 镜像),避免 `setup-go` 从 GitHub 下载超时
## 目录结构
```
cmd/prism/ 进程入口
internal/api/ HTTP 层、OpenAPI、嵌入静态资源
internal/store/ SQLite 与迁移
internal/service/ 业务编排
internal/configbuilder/ Mihomo YAML 合成
web/ Vue 3 管理台
.gitea/workflows/ CI 工作流
```
## 许可
提交代码即表示你同意在 [MIT License](LICENSE) 下授权你的贡献。嵌入的 Mihomo 内核遵循其自身许可,见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。
+69
View File
@@ -0,0 +1,69 @@
# 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 HubCI 默认 1Panel 加速(docker.1panel.live),本地可传:
# docker build --build-arg IMAGE_PREFIX=docker.1panel.live/library/ -t prism:latest .
ARG IMAGE_PREFIX=
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json web/.npmrc ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum go.env ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
RUN rm -rf internal/api/static/* && mkdir -p internal/api/static
COPY --from=web-builder /src/web/dist/ ./internal/api/static/
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" \
go build -ldflags="-w -s" -o /prism ./cmd/prism
FROM ${IMAGE_PREFIX}alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S prism \
&& adduser -S prism -G prism
WORKDIR /app
COPY --from=go-builder /prism /usr/local/bin/prism
RUN mkdir -p /app/data/geo \
&& chown -R prism:prism /app
USER prism
VOLUME ["/app/data"]
EXPOSE 8080 7890 7891
ENTRYPOINT ["/usr/local/bin/prism"]
CMD ["-data", "/app/data"]
+34
View File
@@ -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.
+33
View File
@@ -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
+295
View File
@@ -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`
维护者需:① 在线 runner(标签含 `ubuntu-latest`);② Secret `REGISTRY_TOKEN`。详见 [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
View File
@@ -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 细节)
+53
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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))
}
+183
View File
@@ -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)
}
+16
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
GOPROXY=https://goproxy.cn,direct
GOSUMDB=sum.golang.google.cn
+179
View File
@@ -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
)
+443
View File
@@ -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=
+35
View File
@@ -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))
}
+35
View File
@@ -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))
}
+23
View File
@@ -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
}
+498
View File
@@ -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})
}
+96
View File
@@ -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)
}
+66
View File
@@ -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")
}
}
+116
View File
@@ -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
}
+26
View File
@@ -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})
}
+79
View File
@@ -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))
}
+52
View File
@@ -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})
}
+124
View File
@@ -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,
})
}
+70
View File
@@ -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
}
+142
View File
@@ -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)
}
+97
View File
@@ -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
}
+18
View File
@@ -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})
}
+51
View File
@@ -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)
}
+30
View File
@@ -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>
+753
View File
@@ -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 Keyprism_ 开头)
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'
+48
View File
@@ -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")
}
}
+35
View File
@@ -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
}
+47
View File
@@ -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

+24
View File
@@ -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

+15
View File
@@ -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>
+51
View File
@@ -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
}
+19
View File
@@ -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")
}
}
+137
View File
@@ -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
}
+63
View File
@@ -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)
}
}
+68
View File
@@ -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
}
+367
View File
@@ -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
}
+89
View File
@@ -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)
}
}
+10
View File
@@ -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)
}
+74
View File
@@ -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
}
+261
View File
@@ -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"}},
}
+166
View File
@@ -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
}
+56
View File
@@ -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)
}
}
}
+149
View File
@@ -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)
}
+23
View File
@@ -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")
}
}
+170
View File
@@ -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
}
+67
View File
@@ -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
}
+173
View File
@@ -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
}
+46
View File
@@ -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])
}
}
+237
View File
@@ -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
}
+40
View File
@@ -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
}
+27
View File
@@ -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)
}
}
}
+163
View File
@@ -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
}
}
+99
View File
@@ -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
}
+58
View File
@@ -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)
}
}
+42
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+269
View File
@@ -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
}
}
+40
View File
@@ -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)
}
}
+189
View File
@@ -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
}
}
}
+64
View File
@@ -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)
}
}
+168
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+150
View File
@@ -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()
}
+70
View File
@@ -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
}
+166
View File
@@ -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
}
+61
View File
@@ -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)
}
}
+27
View File
@@ -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)
}
}
+25
View File
@@ -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"
)
+29
View File
@@ -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)
}
}
+110
View File
@@ -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
}
+80
View File
@@ -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
}
+65
View File
@@ -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
}
+112
View File
@@ -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;

Some files were not shown because too many files have changed in this diff Show More