feat: init mirror-proxy project
Build and Push to GHCR / build-and-push (push) Has been cancelled

- 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:
Agent
2026-05-24 15:27:45 +08:00
commit 9223e35164
16 changed files with 2063 additions and 0 deletions
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+252
View File
@@ -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
View File
@@ -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))
}
+12
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module mirror-proxy
go 1.26.3
+274
View File
@@ -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)
}
+182
View File
@@ -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))
})
}
}
+125
View File
@@ -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
}
+74
View File
@@ -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)
})
}
+28
View File
@@ -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"`
}
+41
View File
@@ -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
}
+41
View File
@@ -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
}
+105
View File
@@ -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
}
+682
View File
@@ -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>