- 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