Build and Push to GHCR / build-and-push (push) Has been cancelled
- Add `allowed_ips` to Link model with IPv4/IPv6 and CIDR support - Validate client IP in proxy auth middleware against link whitelist - Extract client IP from X-Forwarded-For / X-Real-Ip headers - Fix copy button for non-HTTPS contexts via execCommand fallback - Allow editing existing links (name, type, auth mode, rate limit, IPs) - Add dedicated IP whitelist modal for quick editing via [HAPI](https://hapi.run) Co-Authored-By: HAPI <[email protected]>
471 lines
11 KiB
Go
471 lines
11 KiB
Go
package admin
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"math/big"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"mirror-proxy/internal/auth"
|
|
"mirror-proxy/internal/config"
|
|
)
|
|
|
|
func normalizeIPs(ips []string) []string {
|
|
var out []string
|
|
for _, ip := range ips {
|
|
ip = strings.TrimSpace(ip)
|
|
if ip != "" {
|
|
out = append(out, ip)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type Handler struct {
|
|
cfg *config.Config
|
|
onRestart func()
|
|
sessions *SessionStore
|
|
}
|
|
|
|
func NewHandler(cfg *config.Config) *Handler {
|
|
return &Handler{
|
|
cfg: cfg,
|
|
sessions: newSessionStore(),
|
|
}
|
|
}
|
|
|
|
func (h *Handler) SetOnRestart(fn func()) {
|
|
h.onRestart = fn
|
|
}
|
|
|
|
// ---------- Session Management ----------
|
|
|
|
type Session struct {
|
|
Token string
|
|
Username string
|
|
Expires time.Time
|
|
}
|
|
|
|
type SessionStore struct {
|
|
mu sync.RWMutex
|
|
sessions map[string]*Session
|
|
}
|
|
|
|
func newSessionStore() *SessionStore {
|
|
s := &SessionStore{sessions: make(map[string]*Session)}
|
|
go s.cleanupLoop()
|
|
return s
|
|
}
|
|
|
|
func (s *SessionStore) cleanupLoop() {
|
|
ticker := time.NewTicker(time.Minute)
|
|
for range ticker.C {
|
|
s.mu.Lock()
|
|
for token, sess := range s.sessions {
|
|
if time.Now().After(sess.Expires) {
|
|
delete(s.sessions, token)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func (s *SessionStore) Create(username string, timeoutHours int) *Session {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
token := hex.EncodeToString(b)
|
|
|
|
if timeoutHours <= 0 {
|
|
timeoutHours = 24
|
|
}
|
|
|
|
sess := &Session{
|
|
Token: token,
|
|
Username: username,
|
|
Expires: time.Now().Add(time.Duration(timeoutHours) * time.Hour),
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.sessions[token] = sess
|
|
s.mu.Unlock()
|
|
|
|
return sess
|
|
}
|
|
|
|
func (s *SessionStore) Get(token string) (*Session, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
sess, ok := s.sessions[token]
|
|
if !ok || time.Now().After(sess.Expires) {
|
|
return nil, false
|
|
}
|
|
return sess, true
|
|
}
|
|
|
|
func (s *SessionStore) Delete(token string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.sessions, token)
|
|
}
|
|
|
|
func (h *Handler) SessionStore() *SessionStore {
|
|
return h.sessions
|
|
}
|
|
|
|
// ---------- Auth Handlers ----------
|
|
|
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"})
|
|
return
|
|
}
|
|
|
|
if req.Username != h.cfg.AdminUser || req.Password != h.cfg.AdminPass {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
|
|
sess := h.sessions.Create(req.Username, h.cfg.SessionTimeout)
|
|
|
|
maxAge := h.cfg.SessionTimeout * 3600
|
|
if maxAge <= 0 {
|
|
maxAge = 86400
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: sess.Token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: maxAge,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"username": sess.Username})
|
|
}
|
|
|
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err == nil {
|
|
h.sessions.Delete(cookie.Value)
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "not authenticated"})
|
|
return
|
|
}
|
|
|
|
sess, ok := h.sessions.Get(cookie.Value)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "session expired"})
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"username": sess.Username})
|
|
}
|
|
|
|
// ---------- Link Handlers ----------
|
|
|
|
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)
|
|
}
|
|
|
|
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"`
|
|
AllowedIPs []string `json:"allowed_ips"`
|
|
}
|
|
|
|
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,
|
|
AllowedIPs: normalizeIPs(req.AllowedIPs),
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
|
|
h.cfg.SetLink(link)
|
|
if err := h.cfg.Save(config.GetConfigFilePath()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(link)
|
|
}
|
|
|
|
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"`
|
|
AllowedIPs []string `json:"allowed_ips,omitempty"`
|
|
}
|
|
|
|
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
|
|
}
|
|
if req.AllowedIPs != nil {
|
|
link.AllowedIPs = normalizeIPs(req.AllowedIPs)
|
|
}
|
|
|
|
h.cfg.SetLink(link)
|
|
if err := h.cfg.Save(config.GetConfigFilePath()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(link)
|
|
}
|
|
|
|
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.GetConfigFilePath()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
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.GetConfigFilePath()); 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})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"admin_path": h.cfg.AdminPath,
|
|
"admin_user": h.cfg.AdminUser,
|
|
"session_timeout": h.cfg.SessionTimeout,
|
|
"docker_enabled": true,
|
|
"ghcr_enabled": true,
|
|
"github_enabled": true,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
AdminPath string `json:"admin_path"`
|
|
AdminUser string `json:"admin_user"`
|
|
AdminPass string `json:"admin_pass"`
|
|
SessionTimeout int `json:"session_timeout"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
restartRequired := false
|
|
|
|
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 req.SessionTimeout > 0 {
|
|
h.cfg.SessionTimeout = req.SessionTimeout
|
|
}
|
|
|
|
if err := h.cfg.Save(config.GetConfigFilePath()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if restartRequired && h.onRestart != nil {
|
|
go h.onRestart()
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"admin_path": h.cfg.AdminPath,
|
|
"admin_user": h.cfg.AdminUser,
|
|
"session_timeout": h.cfg.SessionTimeout,
|
|
"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)
|
|
}
|
|
|
|
// AuthHandler 认证路由分发
|
|
func (h *Handler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
if r.URL.Path == "/api/auth/login" {
|
|
h.Login(w, r)
|
|
return
|
|
}
|
|
if r.URL.Path == "/api/auth/logout" {
|
|
h.Logout(w, r)
|
|
return
|
|
}
|
|
case http.MethodGet:
|
|
if r.URL.Path == "/api/auth/me" {
|
|
h.GetMe(w, r)
|
|
return
|
|
}
|
|
}
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
func generateID() string {
|
|
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)
|
|
}
|