Files
dsfree2api/internal/admin/server.go
T
nyoungo 18b2c04006 initial import
- Go service bridging deepseek.de/es/fr V4 models behind an OpenAI-compatible API
- /v1/chat/completions, /v1/responses, /v1/messages (Anthropic) with tool calling
- web admin console: dashboard, models/sites, keys, proxies, Turnstile, playground, live logs
- Turnstile solvers: solver API / local browser via CDP / manual cookie import
- MIT license, README with screenshots, Dockerfile and Makefile
2026-10-07 15:46:45 +08:00

279 lines
8.2 KiB
Go

package admin
import (
"embed"
"encoding/json"
"io/fs"
"log/slog"
"net/http"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/nyoungo/dsfree2api/internal/config"
"github.com/nyoungo/dsfree2api/internal/logbuf"
"github.com/nyoungo/dsfree2api/internal/metrics"
"github.com/nyoungo/dsfree2api/internal/turnstile"
"github.com/nyoungo/dsfree2api/internal/upstream"
)
// Version is reported by the console and /api/overview.
var Version = "0.3.0"
//go:embed web/*
var webFS embed.FS
type Server struct {
cfg *config.Config
up *upstream.Client
met *metrics.Recorder
logs *logbuf.Buffer
ts *turnstile.Solver
log *slog.Logger
start time.Time
mu sync.Mutex
tokens map[string]time.Time
password string
}
func New(cfg *config.Config, up *upstream.Client, met *metrics.Recorder, logs *logbuf.Buffer, ts *turnstile.Solver, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
pw := cfg.Admin.Password
generated := false
if pw == "" {
pw = randomToken(12)
generated = true
}
s := &Server{
cfg: cfg, up: up, met: met, logs: logs, ts: ts, log: log,
start: time.Now(), tokens: map[string]time.Time{}, password: pw,
}
if generated {
s.log.Warn("admin password was not configured — generated a one-time password",
"password", pw, "hint", "set admin.password in config.toml or ADMIN_PASSWORD to make it stable")
}
return s
}
func (s *Server) Handler() http.Handler {
sub, _ := fs.Sub(webFS, "web")
static := http.FileServer(http.FS(sub))
mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleLogin)
mux.HandleFunc("GET /api/session", s.handleSession)
mux.HandleFunc("POST /api/logout", s.handleLogout)
mux.HandleFunc("GET /api/overview", s.authed(s.handleOverview))
mux.HandleFunc("GET /api/config", s.authed(s.handleGetConfig))
mux.HandleFunc("PUT /api/config", s.authed(s.handlePutConfig))
mux.HandleFunc("POST /api/actions", s.authed(s.handleActions))
mux.HandleFunc("GET /api/logs", s.authed(s.handleLogs))
mux.HandleFunc("POST /api/playground", s.authed(s.handlePlayground))
mux.Handle("GET /", static)
return mux
}
// ── auth ─────────────────────────────────────────────────────────
func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := r.Header.Get("X-Admin-Token")
if tok == "" {
if c, err := r.Cookie("dsfr_admin"); err == nil {
tok = c.Value
}
}
if tok == "" {
if scheme, value, ok := strings.Cut(r.Header.Get("Authorization"), " "); ok &&
strings.EqualFold(strings.TrimSpace(scheme), "bearer") {
tok = strings.TrimSpace(value)
}
}
if !s.validToken(tok) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
return
}
next(w, r)
}
}
func (s *Server) validToken(tok string) bool {
if tok == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.tokens[tok]
if !ok {
return false
}
if time.Now().After(exp) {
delete(s.tokens, tok)
return false
}
return true
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Password != s.password {
time.Sleep(300 * time.Millisecond)
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "invalid password"})
return
}
tok := randomToken(24)
s.mu.Lock()
for k, exp := range s.tokens {
if time.Now().After(exp) {
delete(s.tokens, k)
}
}
s.tokens[tok] = time.Now().Add(24 * time.Hour)
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "dsfr_admin", Value: tok, Path: "/", HttpOnly: true,
SameSite: http.SameSiteStrictMode, MaxAge: 86400,
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "token": tok})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("dsfr_admin"); err == nil {
s.mu.Lock()
delete(s.tokens, c.Value)
s.mu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: "dsfr_admin", Value: "", Path: "/", MaxAge: -1})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
tok := ""
if c, err := r.Cookie("dsfr_admin"); err == nil {
tok = c.Value
}
if h := r.Header.Get("X-Admin-Token"); h != "" {
tok = h
}
writeJSON(w, http.StatusOK, map[string]any{"authenticated": s.validToken(tok)})
}
// ── overview ─────────────────────────────────────────────────────
func (s *Server) handleOverview(w http.ResponseWriter, _ *http.Request) {
s.cfg.RLock()
models := make([]map[string]any, 0, len(s.cfg.Models))
for id, m := range s.cfg.Models {
models = append(models, map[string]any{
"id": id, "site": m.Site, "label": m.Label, "upstream_id": m.UpstreamID,
"page_path": m.PagePath, "bot_id": m.BotID, "post_id": m.PostID, "enabled": m.Enabled,
})
}
sites := make([]map[string]any, 0, len(s.cfg.Sites))
siteCodes := make([]string, 0, len(s.cfg.Sites))
for code, st := range s.cfg.Sites {
siteCodes = append(siteCodes, code)
sites = append(sites, map[string]any{
"code": code, "base_url": st.BaseURL, "ajax_url": st.AJAXURL,
"language": st.Language, "sitekey": st.SiteKey, "enabled": st.Enabled,
"verify_action": st.VerifyAction,
})
}
proxyCfg := struct {
URL string `json:"url"`
Fallbacks []string `json:"fallbacks"`
SlowStart float64 `json:"slow_start"`
MaxConc int `json:"max_concurrent"`
Rate float64 `json:"rate_per_minute"`
CrossSite bool `json:"cross_site_failover"`
}{s.cfg.Proxy.URL, s.cfg.Proxy.FallbackURLs, s.cfg.Proxy.SlowStartSeconds,
s.cfg.Limits.MaxConcurrentPerSite, s.cfg.Limits.RatePerMinute, s.cfg.Upstream.CrossSiteFailover}
turnstileCfg := s.cfg.Turnstile
keys := append([]string(nil), s.cfg.Security.APIKeys...)
s.cfg.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{
"metrics": s.met.Snapshot(),
"models": models,
"sites": sites,
"proxy": proxyCfg,
"turnstile": map[string]any{"config": turnstileCfg, "status": s.ts.Status(siteCodes), "history": s.ts.History()},
"keys": keys,
"cache": map[string]any{"chat_config_entries": s.up.ConfigCacheSize(), "routes": routeList(s.up)},
"system": map[string]any{
"version": Version, "go": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH,
"uptime_s": int(time.Since(s.start).Seconds()), "pid": os.Getpid(),
"config_path": s.cfg.Path(),
},
})
}
func routeList(c *upstream.Client) []map[string]string {
routes := c.Routes()
out := make([]map[string]string, 0, len(routes))
for _, r := range routes {
label := r.Proxy
if label == "" {
label = "direct"
}
out = append(out, map[string]string{"name": r.Name, "proxy": label})
}
return out
}
// ── config get/put ───────────────────────────────────────────────
func (s *Server) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
s.cfg.RLock()
defer s.cfg.RUnlock()
writeJSON(w, http.StatusOK, s.cfg)
}
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
body, err := readBody(r, 1<<20)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
s.cfg.Lock()
err = json.Unmarshal(body, s.cfg)
if err == nil {
err = s.cfg.Reindex()
}
s.cfg.Unlock()
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
if err := s.cfg.Validate(); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
s.applyRuntime()
if err := s.cfg.Save(); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// applyRuntime pushes a freshly edited config into the live subsystems.
func (s *Server) applyRuntime() {
s.ts.SetConfig(s.currentTurnstile())
}
func (s *Server) currentTurnstile() config.Turnstile {
s.cfg.RLock()
defer s.cfg.RUnlock()
return s.cfg.Turnstile
}