- Docker Hub / GHCR / GitHub reverse proxy - Web admin panel with link management - Dynamic admin path, user and password config - Rate limiting per link (dual/single auth mode) - Docker and docker-compose deployment support - GitHub Actions workflow for auto-publish to GHCR
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
name: Build and Push to GHCR
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=sha,prefix=,suffix=,format=short
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Binaries
|
||||||
|
mirror-proxy
|
||||||
|
main
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.o
|
||||||
|
*.a
|
||||||
|
|
||||||
|
# Cache & runtime data
|
||||||
|
cache/
|
||||||
|
/cache
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Config (contains sensitive data, auto-generated at runtime)
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# 构建阶段
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum* ./
|
||||||
|
RUN go mod download 2>/dev/null || true
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN go build -ldflags="-s -w" -o mirror-proxy ./cmd/main.go
|
||||||
|
|
||||||
|
# 运行阶段
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 创建必要目录
|
||||||
|
RUN mkdir -p web/static cache
|
||||||
|
|
||||||
|
# 复制编译后的二进制文件和静态资源
|
||||||
|
COPY --from=builder /app/mirror-proxy .
|
||||||
|
COPY --from=builder /app/web/static ./web/static
|
||||||
|
|
||||||
|
# 暴露默认端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
VOLUME ["/app/cache"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["./mirror-proxy"]
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# Mirror Proxy - 镜像代理服务
|
||||||
|
|
||||||
|
Go 开发的远程代理软件,支持加速下载 Docker Hub、GHCR、GitHub 资源。带网页管理后台,可独立分配带鉴权的代理链接。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **Docker Hub 代理** - 加速 Docker 镜像拉取
|
||||||
|
- **GHCR 代理** - 加速 GitHub Container Registry 镜像
|
||||||
|
- **GitHub 代理** - 加速 GitHub 资源、Release 下载
|
||||||
|
- **网页管理后台** - 创建/管理代理链接
|
||||||
|
- **独立鉴权** - 每个链接有独立的 `ID` + `Token`
|
||||||
|
- **速率限制** - 按链接配置请求频率限制
|
||||||
|
- **Basic Auth** - 管理后台带密码保护
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编译
|
||||||
|
go build -o mirror-proxy ./cmd/main.go
|
||||||
|
|
||||||
|
# 运行(默认 :8080)
|
||||||
|
./mirror-proxy
|
||||||
|
|
||||||
|
# 自定义端口
|
||||||
|
LISTEN_ADDR=:9090 ./mirror-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
管理后台: http://localhost:8080/admin/
|
||||||
|
- 用户名: `admin`
|
||||||
|
- 密码: `admin123`
|
||||||
|
|
||||||
|
首次运行自动生成 `config.json`,可修改账号密码。
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
### 使用 Docker 直接运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker build -t mirror-proxy .
|
||||||
|
|
||||||
|
# 运行容器(首次运行会自动创建 config.json)
|
||||||
|
docker run -d \
|
||||||
|
--name mirror-proxy \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-v $(pwd)/config.json:/app/config.json \
|
||||||
|
-v $(pwd)/cache:/app/cache \
|
||||||
|
--restart unless-stopped \
|
||||||
|
mirror-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动服务
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yml` 已包含端口映射和卷挂载:
|
||||||
|
- `./config.json:/app/config.json` — 配置文件持久化
|
||||||
|
- `./cache:/app/cache` — 缓存目录持久化
|
||||||
|
|
||||||
|
### 使用 GHCR 镜像(免构建)
|
||||||
|
|
||||||
|
每次 push 到 `main` 分支或打 `v*` 标签时,会自动构建并推送镜像到 GHCR。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 拉取最新镜像
|
||||||
|
docker pull ghcr.io/${GITHUB_USER}/mirror-proxy:latest
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
docker run -d \
|
||||||
|
--name mirror-proxy \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-v $(pwd)/config.json:/app/config.json \
|
||||||
|
-v $(pwd)/cache:/app/cache \
|
||||||
|
--restart unless-stopped \
|
||||||
|
ghcr.io/${GITHUB_USER}/mirror-proxy:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
> 将 `${GITHUB_USER}` 替换为你的 GitHub 用户名或组织名。
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
### 1. Docker Hub / GHCR(daemon.json 方式)
|
||||||
|
|
||||||
|
创建类型为 `docker` 或 `ghcr` 的链接,获取代理地址:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://yourdomain.com/{linkID}/{token}/
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `/etc/docker/daemon.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry-mirrors": ["https://yourdomain.com/{linkID}/{token}/"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
重启 Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
之后正常使用即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull nginx
|
||||||
|
docker pull ghcr.io/owner/repo:tag
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. GitHub 资源
|
||||||
|
|
||||||
|
创建类型为 `github` 的链接:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -O https://yourdomain.com/{linkID}/{token}/github.com/user/repo/releases/download/v1.0/app.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
代理只做两件事:
|
||||||
|
|
||||||
|
1. **路径鉴权** - 验证 URL 中的 `linkID/token`,通过后去掉前缀
|
||||||
|
2. **原样转发** - 用 `httputil.ReverseProxy` 转发请求到上游(Docker Hub / GHCR / GitHub)
|
||||||
|
|
||||||
|
对于公开镜像,Docker Hub 返回 401 后,Docker 客户端**自己去 auth.docker.io 获取 token**,不需要代理参与。拿到 token 后再次请求代理,代理把 `Authorization` header 原样转发给 Docker Hub 即可。
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → 代理 GET /linkID/token/v2/library/nginx/manifests/latest
|
||||||
|
验证 linkID/token,去掉前缀
|
||||||
|
→ 转发给 registry-1.docker.io
|
||||||
|
|
||||||
|
Client ← 代理 ← Docker Hub 返回 401 + WWW-Authenticate
|
||||||
|
(原样返回,Docker 客户端自己去 auth.docker.io 拿 token)
|
||||||
|
|
||||||
|
Client → 代理 GET /linkID/token/v2/... + Authorization: Bearer xxx
|
||||||
|
验证 linkID/token,去掉前缀
|
||||||
|
→ 带 Authorization 转发给 Docker Hub
|
||||||
|
|
||||||
|
Client ← 代理 ← Docker Hub 返回镜像数据
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
mirror-proxy/
|
||||||
|
├── cmd/
|
||||||
|
│ └── main.go # 入口,路由注册
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # 配置读写(config.json)
|
||||||
|
│ ├── auth/ # linkID/token 鉴权 + 速率限制
|
||||||
|
│ ├── proxy/ # 反向代理(Docker Hub / GHCR / GitHub)
|
||||||
|
│ ├── admin/ # 管理后台 REST API
|
||||||
|
│ └── middleware/ # CORS、Basic Auth、Logger
|
||||||
|
├── web/
|
||||||
|
│ └── static/
|
||||||
|
│ └── index.html # 管理后台页面
|
||||||
|
├── config.json # 配置文件(自动创建)
|
||||||
|
└── go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
`config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"listen_addr": ":8080",
|
||||||
|
"admin_user": "admin",
|
||||||
|
"admin_pass": "your-password",
|
||||||
|
"links": {
|
||||||
|
"xjhdnkcusbnsjc": {
|
||||||
|
"id": "xjhdnkcusbnsjc",
|
||||||
|
"name": "Docker Hub 代理",
|
||||||
|
"token": "a1b2c3d4...",
|
||||||
|
"type": "docker",
|
||||||
|
"enabled": true,
|
||||||
|
"rate_limit": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `listen_addr` | 监听地址,默认 `:8080` |
|
||||||
|
| `admin_user` / `admin_pass` | 管理后台账号密码 |
|
||||||
|
| `links` | 代理链接,每个链接有独立 ID + Token |
|
||||||
|
| `type` | `docker` / `ghcr` / `github` |
|
||||||
|
| `rate_limit` | 每分钟请求数限制,0 为不限 |
|
||||||
|
| `enabled` | 是否启用 |
|
||||||
|
|
||||||
|
## Nginx 反向代理配置(HTTPS)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name yourdomain.com;
|
||||||
|
|
||||||
|
ssl_certificate /path/to/cert.pem;
|
||||||
|
ssl_certificate_key /path/to/key.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
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;
|
||||||
|
|
||||||
|
# Docker 需要大文件上传/下载
|
||||||
|
client_max_body_size 0;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub Actions 自动发布
|
||||||
|
|
||||||
|
项目已配置 GitHub Actions Workflow(`.github/workflows/ghcr.yml`),自动构建多架构镜像并推送到 GHCR。
|
||||||
|
|
||||||
|
**触发条件:**
|
||||||
|
- Push 到 `main` / `master` 分支 → 构建并推送 `latest` 标签
|
||||||
|
- Push `v*` 标签 → 构建并推送语义化版本标签(如 `v1.2.3`、`v1.2`、`v1`)
|
||||||
|
|
||||||
|
**使用方法:**
|
||||||
|
|
||||||
|
1. 确保仓库 **Settings → Actions → General → Workflow permissions** 中勾选 **Read and write permissions**
|
||||||
|
2. 提交代码并推送标签:
|
||||||
|
```bash
|
||||||
|
git tag v1.0.0
|
||||||
|
git push origin v1.0.0
|
||||||
|
```
|
||||||
|
3. 在仓库 **Packages** 页面查看已发布的镜像
|
||||||
|
|
||||||
|
**支持的架构:** `linux/amd64`, `linux/arm64`
|
||||||
|
|
||||||
|
## 防火墙 / 安全建议
|
||||||
|
|
||||||
|
- 外网部署建议用 Nginx + HTTPS
|
||||||
|
- 可配合防火墙限制只有特定 IP 访问管理后台和 `/api/`
|
||||||
|
- 定期更换 token(管理后台支持一键重置)
|
||||||
|
- 每个用户/团队分配独立链接,方便追溯和管控
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"mirror-proxy/internal/admin"
|
||||||
|
"mirror-proxy/internal/auth"
|
||||||
|
"mirror-proxy/internal/config"
|
||||||
|
"mirror-proxy/internal/middleware"
|
||||||
|
"mirror-proxy/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load("config.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rl := auth.NewRateLimiter()
|
||||||
|
adminHandler := admin.NewHandler(cfg)
|
||||||
|
|
||||||
|
dockerProxy := proxy.NewDockerHubProxy()
|
||||||
|
ghcrProxy := proxy.NewGHCRProxy()
|
||||||
|
githubProxy := proxy.NewGitHubProxy()
|
||||||
|
githubRawProxy := proxy.NewGitHubRawProxy()
|
||||||
|
githubAPIProxy := proxy.NewGitHubAPIProxy()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
adminPath := cfg.AdminPath
|
||||||
|
if adminPath == "" {
|
||||||
|
adminPath = "/admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理后台
|
||||||
|
adminAuth := middleware.BasicAuth(cfg.AdminUser, cfg.AdminPass)
|
||||||
|
mux.Handle(adminPath+"/", adminAuth(http.StripPrefix(adminPath, http.FileServer(http.Dir("web/static")))))
|
||||||
|
|
||||||
|
// 管理API
|
||||||
|
mux.HandleFunc("/api/links", adminAuth(http.HandlerFunc(adminHandler.LinksHandler)).ServeHTTP)
|
||||||
|
mux.HandleFunc("/api/links/", adminAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/token") {
|
||||||
|
adminHandler.RegenerateToken(w, r)
|
||||||
|
} else {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPut:
|
||||||
|
adminHandler.UpdateLink(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
adminHandler.DeleteLink(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})).ServeHTTP)
|
||||||
|
mux.Handle("/api/stats", adminAuth(http.HandlerFunc(adminHandler.GetStats)))
|
||||||
|
mux.HandleFunc("/api/config", adminAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
adminHandler.GetConfig(w, r)
|
||||||
|
case http.MethodPut:
|
||||||
|
adminHandler.UpdateConfig(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})).ServeHTTP)
|
||||||
|
|
||||||
|
// GitHub 代理(公开路径,不需要 linkID/token)
|
||||||
|
mux.Handle("/github/", http.StripPrefix("/github", githubProxy))
|
||||||
|
mux.Handle("/raw/", http.StripPrefix("/raw", githubRawProxy))
|
||||||
|
mux.Handle("/api.github.com/", http.StripPrefix("/api.github.com", githubAPIProxy))
|
||||||
|
|
||||||
|
// 鉴权代理:/linkID/token/... → 去掉前缀 → 转发给对应上游
|
||||||
|
authProxy := auth.ProxyAuthMiddleware(rl)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
link, ok := r.Context().Value(auth.LinkContextKey).(*config.Link)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch link.Type {
|
||||||
|
case "docker":
|
||||||
|
dockerProxy.ServeHTTP(w, r)
|
||||||
|
case "ghcr":
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/v2/") {
|
||||||
|
r.URL.Path = "/v2" + r.URL.Path
|
||||||
|
}
|
||||||
|
ghcrProxy.ServeHTTP(w, r)
|
||||||
|
case "github":
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/github/") {
|
||||||
|
r.URL.Path = "/github" + r.URL.Path
|
||||||
|
}
|
||||||
|
githubProxy.ServeHTTP(w, r)
|
||||||
|
default:
|
||||||
|
dockerProxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 根路径处理
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
http.Redirect(w, r, adminPath+"/", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authProxy.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
os.MkdirAll("web/static", 0755)
|
||||||
|
|
||||||
|
handler := middleware.CORS(mux)
|
||||||
|
|
||||||
|
fmt.Printf("Mirror Proxy Server starting on %s\n", cfg.ListenAddr)
|
||||||
|
fmt.Printf("Admin panel: http://localhost%s%s/\n", cfg.ListenAddr, adminPath)
|
||||||
|
fmt.Printf("Admin user: %s\n", cfg.AdminUser)
|
||||||
|
fmt.Printf("Docker Hub: http://localhost%s/{linkID}/{token}/v2/...\n", cfg.ListenAddr)
|
||||||
|
fmt.Printf("GHCR: http://localhost%s/{linkID}/{token}/v2/...\n", cfg.ListenAddr)
|
||||||
|
fmt.Printf("GitHub: http://localhost%s/{linkID}/{token}/github/...\n", cfg.ListenAddr)
|
||||||
|
|
||||||
|
log.Fatal(http.ListenAndServe(cfg.ListenAddr, handler))
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
mirror-proxy:
|
||||||
|
build: .
|
||||||
|
container_name: mirror-proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config.json:/app/config.json
|
||||||
|
- ./cache:/app/cache
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mirror-proxy/internal/auth"
|
||||||
|
"mirror-proxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(cfg *config.Config) *Handler {
|
||||||
|
return &Handler{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLinks 获取所有链接
|
||||||
|
func (h *Handler) GetLinks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
links := h.cfg.ListLinks()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(links)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLink 创建新链接
|
||||||
|
func (h *Handler) CreateLink(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
RateLimit int `json:"rate_limit"`
|
||||||
|
AuthMode string `json:"auth_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Type == "" {
|
||||||
|
req.Type = "docker"
|
||||||
|
}
|
||||||
|
if req.RateLimit <= 0 {
|
||||||
|
req.RateLimit = 100
|
||||||
|
}
|
||||||
|
if req.AuthMode == "" {
|
||||||
|
req.AuthMode = "dual"
|
||||||
|
}
|
||||||
|
|
||||||
|
link := &config.Link{
|
||||||
|
ID: generateID(),
|
||||||
|
Name: req.Name,
|
||||||
|
Token: auth.GenerateToken(),
|
||||||
|
Type: req.Type,
|
||||||
|
AuthMode: req.AuthMode,
|
||||||
|
Enabled: true,
|
||||||
|
RateLimit: req.RateLimit,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.cfg.SetLink(link)
|
||||||
|
if err := h.cfg.Save("config.json"); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLink 更新链接
|
||||||
|
func (h *Handler) UpdateLink(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Path[len("/api/links/"):]
|
||||||
|
|
||||||
|
link, ok := h.cfg.GetLink(id)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Link not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
RateLimit int `json:"rate_limit"`
|
||||||
|
AuthMode string `json:"auth_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
link.Name = req.Name
|
||||||
|
}
|
||||||
|
if req.Type != "" {
|
||||||
|
link.Type = req.Type
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
link.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.RateLimit > 0 {
|
||||||
|
link.RateLimit = req.RateLimit
|
||||||
|
}
|
||||||
|
if req.AuthMode != "" {
|
||||||
|
link.AuthMode = req.AuthMode
|
||||||
|
}
|
||||||
|
|
||||||
|
h.cfg.SetLink(link)
|
||||||
|
if err := h.cfg.Save("config.json"); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLink 删除链接
|
||||||
|
func (h *Handler) DeleteLink(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Path[len("/api/links/"):]
|
||||||
|
|
||||||
|
_, ok := h.cfg.GetLink(id)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Link not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.cfg.DeleteLink(id)
|
||||||
|
if err := h.cfg.Save("config.json"); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateToken 重新生成token
|
||||||
|
func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Path[len("/api/links/"):len(r.URL.Path)-len("/token")]
|
||||||
|
|
||||||
|
link, ok := h.cfg.GetLink(id)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Link not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link.Token = auth.GenerateToken()
|
||||||
|
h.cfg.SetLink(link)
|
||||||
|
if err := h.cfg.Save("config.json"); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"token": link.Token})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats 获取统计信息
|
||||||
|
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
links := h.cfg.ListLinks()
|
||||||
|
stats := make(map[string]interface{})
|
||||||
|
stats["total_links"] = len(links)
|
||||||
|
stats["links"] = links
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig 获取配置
|
||||||
|
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"listen_addr": h.cfg.ListenAddr,
|
||||||
|
"admin_path": h.cfg.AdminPath,
|
||||||
|
"admin_user": h.cfg.AdminUser,
|
||||||
|
"docker_enabled": true,
|
||||||
|
"ghcr_enabled": true,
|
||||||
|
"github_enabled": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConfig 更新系统配置
|
||||||
|
func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ListenAddr string `json:"listen_addr"`
|
||||||
|
AdminPath string `json:"admin_path"`
|
||||||
|
AdminUser string `json:"admin_user"`
|
||||||
|
AdminPass string `json:"admin_pass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restartRequired := false
|
||||||
|
|
||||||
|
if req.ListenAddr != "" {
|
||||||
|
h.cfg.ListenAddr = req.ListenAddr
|
||||||
|
restartRequired = true
|
||||||
|
}
|
||||||
|
if req.AdminPath != "" {
|
||||||
|
if !strings.HasPrefix(req.AdminPath, "/") {
|
||||||
|
req.AdminPath = "/" + req.AdminPath
|
||||||
|
}
|
||||||
|
if h.cfg.AdminPath != req.AdminPath {
|
||||||
|
h.cfg.AdminPath = req.AdminPath
|
||||||
|
restartRequired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.AdminUser != "" {
|
||||||
|
h.cfg.AdminUser = req.AdminUser
|
||||||
|
}
|
||||||
|
if req.AdminPass != "" {
|
||||||
|
h.cfg.AdminPass = req.AdminPass
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.cfg.Save("config.json"); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"listen_addr": h.cfg.ListenAddr,
|
||||||
|
"admin_path": h.cfg.AdminPath,
|
||||||
|
"admin_user": h.cfg.AdminUser,
|
||||||
|
"restart_required": restartRequired,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinksHandler 路由分发
|
||||||
|
func (h *Handler) LinksHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
if r.URL.Path == "/api/links" {
|
||||||
|
h.GetLinks(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case http.MethodPost:
|
||||||
|
if r.URL.Path == "/api/links" {
|
||||||
|
h.CreateLink(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case http.MethodPut:
|
||||||
|
if len(r.URL.Path) > len("/api/links/") {
|
||||||
|
h.UpdateLink(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case http.MethodDelete:
|
||||||
|
if len(r.URL.Path) > len("/api/links/") {
|
||||||
|
h.DeleteLink(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateID() string {
|
||||||
|
// 生成8位随机ID
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
b := make([]byte, 8)
|
||||||
|
for i := range b {
|
||||||
|
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||||
|
b[i] = charset[idx.Int64()]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mirror-proxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RateLimiter struct {
|
||||||
|
visitors map[string]*visitor
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type visitor struct {
|
||||||
|
count int
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRateLimiter() *RateLimiter {
|
||||||
|
rl := &RateLimiter{visitors: make(map[string]*visitor)}
|
||||||
|
go rl.cleanup()
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) cleanup() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
rl.mu.Lock()
|
||||||
|
for ip, v := range rl.visitors {
|
||||||
|
if time.Since(v.lastSeen) > time.Minute {
|
||||||
|
delete(rl.visitors, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) Allow(ip string, limit int) bool {
|
||||||
|
if limit <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
v, exists := rl.visitors[ip]
|
||||||
|
if !exists {
|
||||||
|
rl.visitors[ip] = &visitor{count: 1, lastSeen: time.Now()}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(v.lastSeen) > time.Minute {
|
||||||
|
v.count = 1
|
||||||
|
v.lastSeen = time.Now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.count >= limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
v.count++
|
||||||
|
v.lastSeen = time.Now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateToken() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAuthMode(link *config.Link) string {
|
||||||
|
if link.AuthMode == "" {
|
||||||
|
return "dual"
|
||||||
|
}
|
||||||
|
return link.AuthMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateLinkToken(linkID, token string) (*config.Link, bool) {
|
||||||
|
cfg := config.Get()
|
||||||
|
link, ok := cfg.GetLink(linkID)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !link.Enabled {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if getAuthMode(link) != "dual" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if link.Token != token {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return link, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateLinkTokenSingle(token string) (*config.Link, bool) {
|
||||||
|
cfg := config.Get()
|
||||||
|
link, ok := cfg.GetLinkByToken(token)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !link.Enabled {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if getAuthMode(link) != "single" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return link, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const LinkContextKey contextKey = "link"
|
||||||
|
|
||||||
|
func ProxyAuthMiddleware(rl *RateLimiter) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
parts := strings.SplitN(path, "/", 3)
|
||||||
|
if len(parts) < 1 {
|
||||||
|
http.Error(w, "Unauthorized: missing token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var link *config.Link
|
||||||
|
var ok bool
|
||||||
|
var newPath string
|
||||||
|
|
||||||
|
// 优先尝试 dual 模式 (/{linkID}/{token}/...)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
link, ok = ValidateLinkToken(parts[0], parts[1])
|
||||||
|
if ok {
|
||||||
|
if len(parts) == 2 {
|
||||||
|
newPath = "/"
|
||||||
|
} else {
|
||||||
|
newPath = "/" + parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 single 模式 (/{token}/...)
|
||||||
|
if !ok {
|
||||||
|
link, ok = ValidateLinkTokenSingle(parts[0])
|
||||||
|
if ok {
|
||||||
|
if len(parts) == 1 {
|
||||||
|
newPath = "/"
|
||||||
|
} else {
|
||||||
|
newPath = "/" + parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Unauthorized: invalid link or token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIP := r.RemoteAddr
|
||||||
|
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
|
||||||
|
clientIP = strings.Split(xf, ",")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rl.Allow(clientIP, link.RateLimit) {
|
||||||
|
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.URL.Path = newPath
|
||||||
|
|
||||||
|
// 存储link到context
|
||||||
|
ctx := context.WithValue(r.Context(), LinkContextKey, link)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ListenAddr string `json:"listen_addr"`
|
||||||
|
AdminPath string `json:"admin_path"`
|
||||||
|
AdminUser string `json:"admin_user"`
|
||||||
|
AdminPass string `json:"admin_pass"`
|
||||||
|
DockerHubHost string `json:"docker_hub_host"`
|
||||||
|
GHCRCacheEnabled bool `json:"ghcr_cache_enabled"`
|
||||||
|
CacheDir string `json:"cache_dir"`
|
||||||
|
MaxCacheSize int64 `json:"max_cache_size"`
|
||||||
|
Links map[string]*Link `json:"links"`
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type Link struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Type string `json:"type"` // docker, ghcr, github
|
||||||
|
AuthMode string `json:"auth_mode"` // single, dual
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
RateLimit int `json:"rate_limit"` // requests per minute
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
AccessCount int64 `json:"access_count"`
|
||||||
|
LastAccess int64 `json:"last_access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg *Config
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
cfg = defaultConfig()
|
||||||
|
return cfg, cfg.Save(path)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg = defaultConfig()
|
||||||
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cfg.Links == nil {
|
||||||
|
cfg.Links = make(map[string]*Link)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
AdminPath: "/admin",
|
||||||
|
AdminUser: "admin",
|
||||||
|
AdminPass: "admin123",
|
||||||
|
DockerHubHost: "registry-1.docker.io",
|
||||||
|
GHCRCacheEnabled: true,
|
||||||
|
CacheDir: "./cache",
|
||||||
|
MaxCacheSize: 10 * 1024 * 1024 * 1024, // 10GB
|
||||||
|
Links: make(map[string]*Link),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Save(path string) error {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
data, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetLink(id string) (*Link, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
link, ok := c.Links[id]
|
||||||
|
return link, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetLinkByToken(token string) (*Link, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
for _, link := range c.Links {
|
||||||
|
if link.Token == token {
|
||||||
|
return link, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) SetLink(link *Link) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.Links[link.ID] = link
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) DeleteLink(id string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
delete(c.Links, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) ListLinks() []*Link {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
links := make([]*Link, 0, len(c.Links))
|
||||||
|
for _, l := range c.Links {
|
||||||
|
links = append(links, l)
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
func Get() *Config {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORS 跨域中间件
|
||||||
|
func CORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With")
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type, X-Docker-Token")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger 日志中间件
|
||||||
|
func Logger(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
next.ServeHTTP(wrapped, r)
|
||||||
|
// 简单日志输出
|
||||||
|
_ = start
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriter) WriteHeader(code int) {
|
||||||
|
rw.statusCode = code
|
||||||
|
rw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicAuth 基础认证中间件
|
||||||
|
func BasicAuth(username, password string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok || user != username || pass != password {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="Admin Panel"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripPrefix 安全地移除路径前缀
|
||||||
|
func StripPrefix(prefix string, h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := strings.TrimPrefix(r.URL.Path, prefix)
|
||||||
|
if p == r.URL.Path {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.URL.Path = p
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ProxyRequest struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LinkID string `json:"link_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Duration int64 `json:"duration_ms"`
|
||||||
|
ClientIP string `json:"client_ip"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stats struct {
|
||||||
|
TotalRequests int64 `json:"total_requests"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
LinkStats map[string]*LinkStat `json:"link_stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkStat struct {
|
||||||
|
LinkID string `json:"link_id"`
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
BytesTransferred int64 `json:"bytes_transferred"`
|
||||||
|
LastAccess int64 `json:"last_access"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDockerHubProxy 创建 Docker Hub 反向代理
|
||||||
|
func NewDockerHubProxy() http.Handler {
|
||||||
|
target, _ := url.Parse("https://registry-1.docker.io")
|
||||||
|
|
||||||
|
p := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
p.Director = func(req *http.Request) {
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
req.Host = target.Host
|
||||||
|
req.Header.Set("Host", target.Host)
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Docker-Client/24.0.0")
|
||||||
|
}
|
||||||
|
req.Header.Del("X-Forwarded-For")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `{"errors":[{"code":"PROXY_ERROR","message":"%s"}]}`, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Transport = &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewGHCRProxy 创建 GHCR 反向代理
|
||||||
|
func NewGHCRProxy() http.Handler {
|
||||||
|
target, _ := url.Parse("https://ghcr.io")
|
||||||
|
|
||||||
|
p := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
p.Director = func(req *http.Request) {
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
req.Host = target.Host
|
||||||
|
req.Header.Set("Host", target.Host)
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Docker-Client/24.0.0")
|
||||||
|
}
|
||||||
|
req.Header.Del("X-Forwarded-For")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `{"errors":[{"code":"PROXY_ERROR","message":"%s"}]}`, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Transport = &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewGitHubProxy 创建 GitHub 主站反向代理
|
||||||
|
func NewGitHubProxy() http.Handler {
|
||||||
|
target, _ := url.Parse("https://github.com")
|
||||||
|
|
||||||
|
p := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
p.Director = func(req *http.Request) {
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
req.Host = target.Host
|
||||||
|
req.Header.Set("Host", target.Host)
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "MirrorProxy/1.0")
|
||||||
|
}
|
||||||
|
req.Header.Del("X-Forwarded-For")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, "GitHub proxy error: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Transport = &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitHubRawProxy 创建 GitHub Raw 反向代理
|
||||||
|
func NewGitHubRawProxy() http.Handler {
|
||||||
|
target, _ := url.Parse("https://raw.githubusercontent.com")
|
||||||
|
|
||||||
|
p := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
p.Director = func(req *http.Request) {
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
req.Host = target.Host
|
||||||
|
req.Header.Set("Host", target.Host)
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "MirrorProxy/1.0")
|
||||||
|
}
|
||||||
|
req.Header.Del("X-Forwarded-For")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, "GitHub raw proxy error: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Transport = &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitHubAPIProxy 创建 GitHub API 反向代理
|
||||||
|
func NewGitHubAPIProxy() http.Handler {
|
||||||
|
target, _ := url.Parse("https://api.github.com")
|
||||||
|
|
||||||
|
p := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
p.Director = func(req *http.Request) {
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
req.Host = target.Host
|
||||||
|
req.Header.Set("Host", target.Host)
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "MirrorProxy/1.0")
|
||||||
|
}
|
||||||
|
req.Header.Del("X-Forwarded-For")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `{"message":"GitHub API proxy error: %s"}`, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Transport = &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mirror Proxy - 镜像代理管理后台</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
header {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
padding: 20px 0;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 24px; }
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 25px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.section h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #0056b3; }
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #c82333; }
|
||||||
|
.btn-success {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-success:hover { background: #218838; }
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
tr:hover { background: #f8f9fa; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-success { background: #d4edda; color: #155724; }
|
||||||
|
.badge-danger { background: #f8d7da; color: #721c24; }
|
||||||
|
.badge-info { background: #d1ecf1; color: #0c5460; }
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal.active { display: flex; }
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.form-group input, .form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.form-group input:focus, .form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.token-display {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.url-display {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #007bff;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.empty-state h3 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.copy-btn:hover { background: #f0f0f0; }
|
||||||
|
.actions { display: flex; gap: 5px; }
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 2000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background: #e7f3ff;
|
||||||
|
border: 1px solid #b3d9ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.info-box code {
|
||||||
|
background: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="container" style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<h1>Mirror Proxy - 镜像代理管理后台</h1>
|
||||||
|
<button class="btn btn-primary" onclick="openSettingsModal()">⚙️ 系统设置</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>总链接数</h3>
|
||||||
|
<div class="value" id="totalLinks">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>活跃链接</h3>
|
||||||
|
<div class="value" id="activeLinks">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>服务状态</h3>
|
||||||
|
<div class="value" style="color: #28a745;">运行中</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>链接管理</h2>
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<button class="btn btn-primary" onclick="openModal()">+ 创建新链接</button>
|
||||||
|
</div>
|
||||||
|
<div id="linksTable">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>暂无链接</h3>
|
||||||
|
<p>点击上方按钮创建第一个代理链接</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>使用说明</h2>
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Docker Hub 代理(推荐 daemon.json 方式):</strong></p>
|
||||||
|
<p>1. 创建类型为 <code>docker</code> 的链接</p>
|
||||||
|
<p>2. 编辑 <code>/etc/docker/daemon.json</code>:</p>
|
||||||
|
<pre style="background:#fff;padding:10px;border-radius:4px;margin:8px 0;overflow:auto;">{
|
||||||
|
"registry-mirrors": ["https://yourdomain.com/{linkID}/{token}/"]
|
||||||
|
}</pre>
|
||||||
|
<p>3. 重启 Docker:<code>systemctl restart docker</code></p>
|
||||||
|
<p>4. 之后 <code>docker pull nginx</code> 自动走代理</p>
|
||||||
|
<br>
|
||||||
|
<p><strong>GHCR 代理:</strong></p>
|
||||||
|
<p>1. 创建类型为 <code>ghcr</code> 的链接</p>
|
||||||
|
<p>2. daemon.json 配置:<code>"registry-mirrors": ["https://yourdomain.com/{linkID}/{token}/"]</code></p>
|
||||||
|
<p>3. <code>docker pull ghcr.io/owner/repo</code> 自动走代理</p>
|
||||||
|
<br>
|
||||||
|
<p><strong>GitHub 代理:</strong></p>
|
||||||
|
<p>1. 创建类型为 <code>github</code> 的链接</p>
|
||||||
|
<p>2. <code>curl -O https://yourdomain.com/{linkID}/{token}/github.com/user/repo/releases/...</code></p>
|
||||||
|
<br>
|
||||||
|
<p><strong>鉴权模式说明:</strong></p>
|
||||||
|
<p>• <strong>双重鉴权</strong>:路径为 <code>/{linkID}/{token}/</code>,ID 用于标识,Token 用于验证</p>
|
||||||
|
<p>• <strong>仅 Token</strong>:路径为 <code>/{token}/</code>,更简洁,适合个人使用</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建链接模态框 -->
|
||||||
|
<div class="modal" id="createModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 style="margin-bottom: 20px;">创建新链接</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>链接名称</label>
|
||||||
|
<input type="text" id="linkName" placeholder="例如:Docker Hub 代理">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>代理类型</label>
|
||||||
|
<select id="linkType">
|
||||||
|
<option value="docker">Docker Hub</option>
|
||||||
|
<option value="ghcr">GHCR (GitHub Container Registry)</option>
|
||||||
|
<option value="github">GitHub 资源</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>鉴权模式</label>
|
||||||
|
<select id="linkAuthMode">
|
||||||
|
<option value="dual">双重鉴权 (ID + Token)</option>
|
||||||
|
<option value="single">仅 Token 鉴权</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>速率限制 (请求/分钟)</label>
|
||||||
|
<input type="number" id="linkRateLimit" value="100" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" onclick="closeModal()" style="background: #6c757d; color: white;">取消</button>
|
||||||
|
<button class="btn btn-primary" onclick="createLink()">创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 链接详情模态框 -->
|
||||||
|
<div class="modal" id="detailModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 style="margin-bottom: 20px;">链接详情</h2>
|
||||||
|
<div id="detailContent"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" onclick="closeDetailModal()" style="background: #6c757d; color: white;">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 系统设置模态框 -->
|
||||||
|
<div class="modal" id="settingsModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 style="margin-bottom: 20px;">系统设置</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>监听地址</label>
|
||||||
|
<input type="text" id="cfgListenAddr" placeholder=":8080">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>管理后台路径</label>
|
||||||
|
<input type="text" id="cfgAdminPath" placeholder="/admin">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>管理员用户名</label>
|
||||||
|
<input type="text" id="cfgAdminUser" placeholder="admin">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>管理员密码(留空表示不修改)</label>
|
||||||
|
<input type="password" id="cfgAdminPass" placeholder="不修改请留空">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" onclick="closeSettingsModal()" style="background: #6c757d; color: white;">取消</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveConfig()">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let links = [];
|
||||||
|
let currentConfig = {};
|
||||||
|
|
||||||
|
async function loadLinks() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/links');
|
||||||
|
links = await res.json();
|
||||||
|
renderLinks();
|
||||||
|
updateStats();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载链接失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLinks() {
|
||||||
|
const container = document.getElementById('linksTable');
|
||||||
|
if (links.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>暂无链接</h3>
|
||||||
|
<p>点击上方按钮创建第一个代理链接</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = window.location.host;
|
||||||
|
container.innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>鉴权</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>代理URL</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${links.map(link => {
|
||||||
|
const authMode = link.auth_mode || 'dual';
|
||||||
|
const proxyUrl = authMode === 'single'
|
||||||
|
? `${host}/${link.token}/`
|
||||||
|
: `${host}/${link.id}/${link.token}/`;
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${link.name || '未命名'}</td>
|
||||||
|
<td><code>${link.id}</code></td>
|
||||||
|
<td><span class="badge badge-info">${link.type.toUpperCase()}</span></td>
|
||||||
|
<td><span class="badge badge-info">${authMode === 'single' ? '仅Token' : '双重'}</span></td>
|
||||||
|
<td>
|
||||||
|
${link.enabled
|
||||||
|
? '<span class="badge badge-success">启用</span>'
|
||||||
|
: '<span class="badge badge-danger">禁用</span>'
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="url-display">${proxyUrl}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-sm btn-success" onclick="copyUrl('${proxyUrl}')">复制</button>
|
||||||
|
<button class="btn btn-sm" onclick="toggleLink('${link.id}', ${!link.enabled})" style="background: #6c757d; color: white;">
|
||||||
|
${link.enabled ? '禁用' : '启用'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteLink('${link.id}')">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
document.getElementById('totalLinks').textContent = links.length;
|
||||||
|
document.getElementById('activeLinks').textContent = links.filter(l => l.enabled).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
document.getElementById('createModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('createModal').classList.remove('active');
|
||||||
|
document.getElementById('linkName').value = '';
|
||||||
|
document.getElementById('linkType').value = 'docker';
|
||||||
|
document.getElementById('linkAuthMode').value = 'dual';
|
||||||
|
document.getElementById('linkRateLimit').value = '100';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetailModal() {
|
||||||
|
document.getElementById('detailModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLink() {
|
||||||
|
const name = document.getElementById('linkName').value.trim();
|
||||||
|
const type = document.getElementById('linkType').value;
|
||||||
|
const authMode = document.getElementById('linkAuthMode').value;
|
||||||
|
const rateLimit = parseInt(document.getElementById('linkRateLimit').value) || 100;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showToast('请输入链接名称', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/links', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, type, auth_mode: authMode, rate_limit: rateLimit })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const link = await res.json();
|
||||||
|
showToast('链接创建成功');
|
||||||
|
closeModal();
|
||||||
|
loadLinks();
|
||||||
|
// 显示详情
|
||||||
|
showLinkDetail(link);
|
||||||
|
} else {
|
||||||
|
showToast('创建失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('创建失败: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLinkDetail(link) {
|
||||||
|
const host = window.location.host;
|
||||||
|
const authMode = link.auth_mode || 'dual';
|
||||||
|
const proxyUrl = authMode === 'single'
|
||||||
|
? `${host}/${link.token}/`
|
||||||
|
: `${host}/${link.id}/${link.token}/`;
|
||||||
|
|
||||||
|
let usage = '';
|
||||||
|
if (link.type === 'docker') {
|
||||||
|
usage = `
|
||||||
|
<p><strong> Docker 使用方式:</strong></p>
|
||||||
|
<p>1. 直接拉取:<code>docker pull ${proxyUrl}library/nginx</code></p>
|
||||||
|
<p>2. 配置镜像加速器(daemon.json):</p>
|
||||||
|
<pre style="background:#f5f5f5;padding:10px;border-radius:4px;overflow:auto;">{
|
||||||
|
"registry-mirrors": ["https://${proxyUrl}"]
|
||||||
|
}</pre>
|
||||||
|
`;
|
||||||
|
} else if (link.type === 'ghcr') {
|
||||||
|
usage = `
|
||||||
|
<p><strong> GHCR 使用方式:</strong></p>
|
||||||
|
<p>1. 配置 daemon.json:</p>
|
||||||
|
<pre style="background:#f5f5f5;padding:10px;border-radius:4px;overflow:auto;">{
|
||||||
|
"registry-mirrors": ["https://${proxyUrl}"]
|
||||||
|
}</pre>
|
||||||
|
<p>2. 之后 <code>docker pull ghcr.io/owner/repo:tag</code> 自动走代理</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
usage = `
|
||||||
|
<p><strong> GitHub 使用方式:</strong></p>
|
||||||
|
<p><code>curl https://${proxyUrl}github.com/user/repo/releases/download/...</code></p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('detailContent').innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>链接ID</label>
|
||||||
|
<div class="token-display">${link.id}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>访问Token</label>
|
||||||
|
<div class="token-display">${link.token} <button class="copy-btn" onclick="copyText('${link.token}')">复制</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>代理地址</label>
|
||||||
|
<div class="token-display">${proxyUrl} <button class="copy-btn" onclick="copyText('${proxyUrl}')">复制</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>使用方式</label>
|
||||||
|
<div>${usage}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('detailModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLink(id, enabled) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/links/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(enabled ? '链接已启用' : '链接已禁用');
|
||||||
|
loadLinks();
|
||||||
|
} else {
|
||||||
|
showToast('操作失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('操作失败: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLink(id) {
|
||||||
|
if (!confirm('确定要删除此链接吗?此操作不可恢复。')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/links/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('链接已删除');
|
||||||
|
loadLinks();
|
||||||
|
} else {
|
||||||
|
showToast('删除失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('删除失败: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateToken(id) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/links/${id}/token`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
showToast('Token已重新生成');
|
||||||
|
loadLinks();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('操作失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUrl(url) {
|
||||||
|
copyText(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showToast('已复制到剪贴板');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast';
|
||||||
|
toast.style.background = type === 'error' ? '#dc3545' : '#28a745';
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config');
|
||||||
|
currentConfig = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载配置失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettingsModal() {
|
||||||
|
document.getElementById('cfgListenAddr').value = currentConfig.listen_addr || ':8080';
|
||||||
|
document.getElementById('cfgAdminPath').value = currentConfig.admin_path || '/admin';
|
||||||
|
document.getElementById('cfgAdminUser').value = currentConfig.admin_user || 'admin';
|
||||||
|
document.getElementById('cfgAdminPass').value = '';
|
||||||
|
document.getElementById('settingsModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettingsModal() {
|
||||||
|
document.getElementById('settingsModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
const listenAddr = document.getElementById('cfgListenAddr').value.trim();
|
||||||
|
const adminPath = document.getElementById('cfgAdminPath').value.trim();
|
||||||
|
const adminUser = document.getElementById('cfgAdminUser').value.trim();
|
||||||
|
const adminPass = document.getElementById('cfgAdminPass').value;
|
||||||
|
|
||||||
|
if (!listenAddr || !adminPath || !adminUser) {
|
||||||
|
showToast('请填写完整信息', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = { listen_addr: listenAddr, admin_path: adminPath, admin_user: adminUser };
|
||||||
|
if (adminPass) body.admin_pass = adminPass;
|
||||||
|
|
||||||
|
const res = await fetch('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
currentConfig = data;
|
||||||
|
showToast('保存成功');
|
||||||
|
closeSettingsModal();
|
||||||
|
if (data.restart_required) {
|
||||||
|
setTimeout(() => {
|
||||||
|
alert('配置已保存,由于修改了监听地址或管理后台路径,需要重启服务才能生效。');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('保存失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('保存失败: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击模态框外部关闭
|
||||||
|
document.querySelectorAll('.modal').forEach(modal => {
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
loadConfig();
|
||||||
|
loadLinks();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user