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
+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
}