Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ COPY --from=builder /app/ccnexus-server /app/ccnexus-server
ENV CCNEXUS_DATA_DIR=/data
ENV CCNEXUS_PORT=3000
ENV CCNEXUS_DB_PATH=/data/ccnexus.db
ENV CCNEXUS_BASIC_AUTH_ENABLED=true
ENV CCNEXUS_BASIC_AUTH_USERNAME=admin

# Expose HTTP API port
EXPOSE 3000
Expand Down
42 changes: 42 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"os"
Expand Down Expand Up @@ -40,6 +42,21 @@ func main() {
os.Exit(1)
}

if cfg.BasicAuthEnabled && cfg.BasicAuthPassword == "" {
randomPassword := generateRandomPassword(16)
cfg.BasicAuthPassword = randomPassword
logger.Info("======================================")
logger.Info(" Basic Auth 密码已随机生成")
logger.Info(" 用户名: %s", cfg.BasicAuthUsername)
logger.Info(" 密码: %s", randomPassword)
logger.Info(" 请妥善保存,密码不会再次显示")
logger.Info("======================================")
adapter := storage.NewConfigStorageAdapter(sqliteStorage)
_ = cfg.SaveToStorage(adapter)
} else if cfg.BasicAuthEnabled {
logger.Info("Basic Auth 已启用,用户名: %s", cfg.BasicAuthUsername)
}

applyEnvOverrides(cfg)
setLogLevels(cfg.GetLogLevel())

Expand Down Expand Up @@ -142,6 +159,19 @@ func applyEnvOverrides(cfg *config.Config) {
logger.Warn("Invalid CCNEXUS_LOG_LEVEL value %q: %v", levelStr, err)
}
}

if authEnabled := os.Getenv("CCNEXUS_BASIC_AUTH_ENABLED"); authEnabled != "" {
enabled := authEnabled == "1" || authEnabled == "true"
cfg.BasicAuthEnabled = enabled
}

if username := os.Getenv("CCNEXUS_BASIC_AUTH_USERNAME"); username != "" {
cfg.BasicAuthUsername = username
}

if password := os.Getenv("CCNEXUS_BASIC_AUTH_PASSWORD"); password != "" {
cfg.BasicAuthPassword = password
}
}

func setLogLevels(level int) {
Expand All @@ -151,3 +181,15 @@ func setLogLevels(level int) {
logger.GetLogger().SetMinLevel(logger.LogLevel(level))
logger.GetLogger().SetConsoleLevel(logger.LogLevel(level))
}

func generateRandomPassword(length int) string {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
fallback := make([]byte, length)
for i := range fallback {
fallback[i] = byte(i*7%26 + 'a')
}
return string(fallback)
}
return hex.EncodeToString(bytes)[:length]
}
80 changes: 79 additions & 1 deletion cmd/server/webui/api/config.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
package api

import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"

"github.com/lich0821/ccNexus/internal/logger"
"github.com/lich0821/ccNexus/internal/storage"
)

type BasicAuthConfigRequest struct {
Enabled bool `json:"enabled"`
Username string `json:"username"`
Password string `json:"password"`
}

// handleConfig handles GET and PUT for full configuration
func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
Expand All @@ -20,6 +28,76 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
}
}

func (h *Handler) handleBasicAuthConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
WriteSuccess(w, map[string]interface{}{
"enabled": h.config.BasicAuthEnabled,
"username": h.config.BasicAuthUsername,
"password": "***",
})
case http.MethodPut:
var req BasicAuthConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
WriteError(w, http.StatusBadRequest, "Invalid request body")
return
}

h.config.BasicAuthEnabled = req.Enabled
if req.Username != "" {
h.config.BasicAuthUsername = req.Username
}
if req.Password != "" && req.Password != "***" {
h.config.BasicAuthPassword = req.Password
}

adapter := storage.NewConfigStorageAdapter(h.storage)
if err := h.config.SaveToStorage(adapter); err != nil {
logger.Error("Failed to save config: %v", err)
WriteError(w, http.StatusInternalServerError, "Failed to save configuration")
return
}

WriteSuccess(w, map[string]interface{}{
"message": "Basic Auth configuration updated",
"enabled": h.config.BasicAuthEnabled,
"username": h.config.BasicAuthUsername,
})
default:
WriteError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}

func (h *Handler) handleResetBasicAuthPassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
WriteError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}

bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
WriteError(w, http.StatusInternalServerError, "Failed to generate password")
return
}
newPassword := hex.EncodeToString(bytes)[:16]

h.config.BasicAuthPassword = newPassword

adapter := storage.NewConfigStorageAdapter(h.storage)
if err := h.config.SaveToStorage(adapter); err != nil {
logger.Error("Failed to save config: %v", err)
WriteError(w, http.StatusInternalServerError, "Failed to save configuration")
return
}

logger.Info("Basic Auth password has been reset via API")

WriteSuccess(w, map[string]interface{}{
"message": "Password reset successfully",
"password": newPassword,
})
}

// getConfig returns the full configuration
func (h *Handler) getConfig(w http.ResponseWriter, r *http.Request) {
WriteSuccess(w, map[string]interface{}{
Expand Down Expand Up @@ -147,4 +225,4 @@ func (h *Handler) handleConfigLogLevel(w http.ResponseWriter, r *http.Request) {
default:
WriteError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
}
78 changes: 55 additions & 23 deletions cmd/server/webui/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"net/http"
"strings"

"github.com/lich0821/ccNexus/internal/config"
"github.com/lich0821/ccNexus/internal/proxy"
Expand All @@ -13,6 +14,7 @@ type Handler struct {
config *config.Config
proxy *proxy.Proxy
storage *storage.SQLiteStorage
auth AuthConfig
}

// NewHandler creates a new API handler
Expand All @@ -21,31 +23,61 @@ func NewHandler(cfg *config.Config, p *proxy.Proxy, s *storage.SQLiteStorage) *H
config: cfg,
proxy: p,
storage: s,
auth: AuthConfig{
Enabled: cfg.BasicAuthEnabled,
Username: cfg.BasicAuthUsername,
Password: cfg.BasicAuthPassword,
},
}
}

// RegisterRoutes registers all API routes
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Endpoint management
mux.HandleFunc("/api/endpoints", h.handleEndpoints)
mux.HandleFunc("/api/endpoints/", h.handleEndpointByName)
mux.HandleFunc("/api/endpoints/current", h.handleCurrentEndpoint)
mux.HandleFunc("/api/endpoints/switch", h.handleSwitchEndpoint)
mux.HandleFunc("/api/endpoints/reorder", h.handleReorderEndpoints)
mux.HandleFunc("/api/endpoints/fetch-models", h.handleFetchModels)

// Statistics
mux.HandleFunc("/api/stats/summary", h.handleStatsSummary)
mux.HandleFunc("/api/stats/daily", h.handleStatsDaily)
mux.HandleFunc("/api/stats/weekly", h.handleStatsWeekly)
mux.HandleFunc("/api/stats/monthly", h.handleStatsMonthly)
mux.HandleFunc("/api/stats/trends", h.handleStatsTrends)
// ServeHTTP implements http.Handler interface
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if !strings.HasPrefix(path, "/") {
path = "/" + path
}

// Configuration
mux.HandleFunc("/api/config", h.handleConfig)
mux.HandleFunc("/api/config/port", h.handleConfigPort)
mux.HandleFunc("/api/config/log-level", h.handleConfigLogLevel)
authMiddleware := BasicAuthMiddleware(h.auth)

// Real-time events
mux.HandleFunc("/api/events", h.handleEvents)
}
switch path {
case "/api/endpoints":
authMiddleware(http.HandlerFunc(h.handleEndpoints)).ServeHTTP(w, r)
case "/api/endpoints/current":
authMiddleware(http.HandlerFunc(h.handleCurrentEndpoint)).ServeHTTP(w, r)
case "/api/endpoints/switch":
authMiddleware(http.HandlerFunc(h.handleSwitchEndpoint)).ServeHTTP(w, r)
case "/api/endpoints/reorder":
authMiddleware(http.HandlerFunc(h.handleReorderEndpoints)).ServeHTTP(w, r)
case "/api/endpoints/fetch-models":
authMiddleware(http.HandlerFunc(h.handleFetchModels)).ServeHTTP(w, r)
case "/api/stats/summary":
authMiddleware(http.HandlerFunc(h.handleStatsSummary)).ServeHTTP(w, r)
case "/api/stats/daily":
authMiddleware(http.HandlerFunc(h.handleStatsDaily)).ServeHTTP(w, r)
case "/api/stats/weekly":
authMiddleware(http.HandlerFunc(h.handleStatsWeekly)).ServeHTTP(w, r)
case "/api/stats/monthly":
authMiddleware(http.HandlerFunc(h.handleStatsMonthly)).ServeHTTP(w, r)
case "/api/stats/trends":
authMiddleware(http.HandlerFunc(h.handleStatsTrends)).ServeHTTP(w, r)
case "/api/config":
authMiddleware(http.HandlerFunc(h.handleConfig)).ServeHTTP(w, r)
case "/api/config/port":
authMiddleware(http.HandlerFunc(h.handleConfigPort)).ServeHTTP(w, r)
case "/api/config/log-level":
authMiddleware(http.HandlerFunc(h.handleConfigLogLevel)).ServeHTTP(w, r)
case "/api/config/basic-auth":
authMiddleware(http.HandlerFunc(h.handleBasicAuthConfig)).ServeHTTP(w, r)
case "/api/config/basic-auth/reset-password":
authMiddleware(http.HandlerFunc(h.handleResetBasicAuthPassword)).ServeHTTP(w, r)
case "/api/events":
authMiddleware(http.HandlerFunc(h.handleEvents)).ServeHTTP(w, r)
default:
if strings.HasPrefix(path, "/api/endpoints/") {
authMiddleware(http.HandlerFunc(h.handleEndpointByName)).ServeHTTP(w, r)
return
}
http.NotFound(w, r)
}
}
62 changes: 62 additions & 0 deletions cmd/server/webui/api/middleware.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package api

import (
"crypto/subtle"
"encoding/base64"
"encoding/json"
"net/http"
"strings"

"github.com/lich0821/ccNexus/internal/logger"
)
Expand Down Expand Up @@ -77,3 +80,62 @@ func LoggingMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}

type AuthConfig struct {
Enabled bool
Username string
Password string
}

func BasicAuthMiddleware(auth AuthConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !auth.Enabled {
next.ServeHTTP(w, r)
return
}

authHeader := r.Header.Get("Authorization")
if authHeader == "" {
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
w.WriteHeader(http.StatusUnauthorized)
return
}

const prefix = "Basic "
if !strings.HasPrefix(authHeader, prefix) {
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
w.WriteHeader(http.StatusUnauthorized)
return
}

encoded := strings.TrimPrefix(authHeader, prefix)
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
w.WriteHeader(http.StatusUnauthorized)
return
}

credentials := string(decoded)
colonIndex := strings.Index(credentials, ":")
if colonIndex < 0 {
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
w.WriteHeader(http.StatusUnauthorized)
return
}

username := credentials[:colonIndex]
password := credentials[colonIndex+1:]

if subtle.ConstantTimeCompare([]byte(auth.Username), []byte(username)) != 1 ||
subtle.ConstantTimeCompare([]byte(auth.Password), []byte(password)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
w.WriteHeader(http.StatusUnauthorized)
return
}

next.ServeHTTP(w, r)
})
}
}
Loading
Loading