Files
dsh-web/internal/api/api.go
T
m1saka 9a71772771 Harden image admission, resource recovery and session renewal
Add exact-image compatibility policy, experimental admission, paginated build history and shared reauthentication checks. Preserve uncertain Docker creation records, bound frontend resource waits and renew runtime sessions proactively.

Lazy-load and split Monaco, add regression and opt-in live Docker fault tests, and record full race and real-browser acceptance results.
2026-10-14 13:41:54 +08:00

291 lines
8.4 KiB
Go

package api
import (
"encoding/json"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
)
type API struct {
s *store.Store
origin string
root string
docker *docker.Client
readersMu sync.Mutex
workspaceReaders map[string]int
instanceLocks keyedLocks
userLocks keyedLocks
loginMu sync.Mutex
loginSources map[string]loginSource
trustedProxies []*net.IPNet
loginSlots chan struct{}
loginConfigErr error
authKey []byte
authErr error
authLoginMu sync.Mutex
// Protected by buildQueue; invalidates image observations across deletions.
imageDeleteGeneration uint64
// Per-image admission evaluations cached for the intent poller; guarded
// by evalCacheMu and invalidated by imageDeleteGeneration (buildQueue)
// plus a short TTL so Docker observations stay fresh.
evalCacheMu sync.Mutex
evalCache map[string]evalCacheEntry
evalCacheGen uint64
}
func New(s *store.Store, root string) *API {
a := &API{s: s, root: root, docker: docker.New(os.Getenv("DOCKER_HOST")), workspaceReaders: make(map[string]int), loginSources: make(map[string]loginSource), loginSlots: make(chan struct{}, 8)}
a.origin, a.loginConfigErr = configuredOrigin(os.Getenv("PANEL_PUBLIC_URL"))
var proxyErr error
a.trustedProxies, proxyErr = configuredProxyCIDRs(os.Getenv("TRUSTED_PROXY_CIDRS"), a.loginConfigErr)
if a.loginConfigErr == nil {
a.loginConfigErr = proxyErr
}
if a.loginConfigErr != nil {
a.authErr = a.loginConfigErr
} else {
a.authErr = a.initAuth()
}
return a
}
func (a *API) Handler() http.Handler {
m := http.NewServeMux()
m.HandleFunc("/api/login", a.login)
m.HandleFunc("/api/refresh", a.refresh)
m.HandleFunc("/api/runtime-session.js", runtimeSessionScript)
m.HandleFunc("/api/logout", a.logout)
m.HandleFunc("/api/me", a.me)
m.HandleFunc("/api/me/password", a.changePassword)
m.HandleFunc("/api/instances", a.instances)
m.HandleFunc("/api/instances/", a.instance)
m.HandleFunc("/api/images", a.images)
m.HandleFunc("/api/images/admission", a.imageAdmission)
m.HandleFunc("/api/compatibility", a.compatibility)
m.HandleFunc("/api/latest", a.latest)
m.HandleFunc("/api/builds", a.builds)
m.HandleFunc("/api/builds/", a.buildDetail)
m.HandleFunc("/api/intents", a.intents)
m.HandleFunc("/api/intents/", a.intent)
m.HandleFunc("/api/ws/", a.workspace)
m.HandleFunc("/api/users", a.users)
m.HandleFunc("/api/users/reset-password", a.resetPassword)
m.HandleFunc("/api/audit", a.audit)
m.HandleFunc("/api/orphans", a.orphans)
m.HandleFunc("/dsh/", a.proxy)
return a.guard(m)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func fail(w http.ResponseWriter, n int, s string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(n)
writeJSON(w, map[string]string{"error": s})
}
func (a *API) guard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if a.loginConfigErr != nil {
fail(w, 503, "authentication configuration invalid")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
if strings.HasPrefix(r.URL.Path, "/dsh/") {
next.ServeHTTP(w, r)
return
}
// API bodies and downloads are bounded; runtime WS/SSE above remain streaming.
controller := http.NewResponseController(w)
_ = controller.SetReadDeadline(time.Now().Add(60 * time.Second))
_ = controller.SetWriteDeadline(time.Now().Add(5 * time.Minute))
defer controller.SetReadDeadline(time.Time{})
defer controller.SetWriteDeadline(time.Time{})
if r.Method != "GET" && r.URL.Path != "/api/login" {
if !a.panelOriginAllowed(r) {
fail(w, 403, "origin denied")
return
}
c, e := r.Cookie("dsh_session")
if e != nil {
fail(w, 401, "authentication required")
return
}
_, csrf, e := a.s.SessionUser(c.Value)
if e != nil || r.Header.Get("X-CSRF-Token") != csrf {
fail(w, 403, "csrf denied")
return
}
if r.URL.Path != "/api/refresh" && r.URL.Path != "/api/logout" {
if _, _, e := a.user(r); e != nil {
fail(w, 401, "authentication required")
return
}
}
}
next.ServeHTTP(w, r)
})
}
func (a *API) user(r *http.Request) (store.User, string, error) {
c, e := r.Cookie("dsh_session")
if e != nil {
return store.User{}, "", e
}
u, csrf, e := a.s.SessionUser(c.Value)
if e != nil {
return store.User{}, "", e
}
if e = a.verifyAccess(r, c.Value, u.ID); e != nil {
return store.User{}, "", e
}
return u, csrf, nil
}
// dummyPasswordHash keeps login latency uniform for unknown usernames so the
// response timing cannot reveal whether an account exists.
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("timing-equalization"), bcrypt.DefaultCost)
func (a *API) login(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
if !a.panelOriginAllowed(r) {
fail(w, 403, "origin denied")
return
}
var in struct{ Username, Password string }
r.Body = http.MaxBytesReader(w, r.Body, 4096)
decoder := json.NewDecoder(r.Body)
if decoder.Decode(&in) != nil {
fail(w, 400, "invalid json")
return
}
if decoder.Decode(new(any)) != io.EOF {
fail(w, 400, "invalid json")
return
}
if !a.allowLoginSource(a.loginSource(r)) {
fail(w, 429, "too many login attempts; retry in a minute")
return
}
if a.authErr != nil {
fail(w, 503, "authentication unavailable")
return
}
if !a.acquireLoginSlot() {
fail(w, 503, "authentication overloaded")
return
}
defer func() { <-a.loginSlots }()
a.authLoginMu.Lock()
defer a.authLoginMu.Unlock()
locked, e := a.accountLocked(in.Username)
if e != nil {
fail(w, 503, "authentication unavailable")
return
}
if locked {
fail(w, 429, "account temporarily locked; retry later")
return
}
h, e := a.s.Password(in.Username)
if e != nil {
_ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(in.Password))
fail(w, 401, "invalid credentials")
return
}
if bcrypt.CompareHashAndPassword([]byte(h), []byte(in.Password)) != nil {
if e = a.failedLogin(in.Username); e != nil {
fail(w, 503, "authentication unavailable")
return
}
fail(w, 401, "invalid credentials")
return
}
u, e := a.s.UserByName(in.Username)
if e != nil {
fail(w, 401, "invalid credentials")
return
}
if _, e = a.s.DB.Exec("DELETE FROM auth_failures WHERE username=?", in.Username); e != nil {
fail(w, 503, "authentication unavailable")
return
}
sid, csrf, e := a.s.Session(u.ID)
if e != nil {
fail(w, 500, "session error")
return
}
token, e := refreshToken()
if e == nil {
_, e = a.s.DB.Exec("INSERT INTO auth_refresh(sid,token_hash) VALUES(?,?)", sid, refreshHash(token))
}
if e != nil {
a.s.DeleteSession(sid)
fail(w, 500, "session error")
return
}
a.authCookies(w, r, sid, u.ID, csrf, token)
a.s.AddAudit(u.ID, "login", u.ID, "")
writeJSON(w, map[string]any{"user": u, "csrf_token": csrf})
}
func (a *API) logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
if c, e := r.Cookie("dsh_session"); e == nil {
if _, e = a.s.DB.Exec("DELETE FROM sessions WHERE id=?", c.Value); e != nil {
fail(w, 500, "session error")
return
}
}
for _, name := range []string{"dsh_session", "dsh_access", "dsh_refresh", "dsh_csrf"} {
path := "/"
if name == "dsh_refresh" {
path = "/api"
}
http.SetCookie(w, &http.Cookie{Name: name, MaxAge: -1, Path: path, Secure: a.secureCookies(r), HttpOnly: name != "dsh_csrf", SameSite: http.SameSiteLaxMode})
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, map[string]bool{"ok": true})
}
func (a *API) me(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
u, c, e := a.user(r)
if e != nil {
fail(w, 401, "authentication required")
return
}
writeJSON(w, map[string]any{"user": u, "csrf_token": c})
}
func (a *API) panelOriginAllowed(r *http.Request) bool {
if a.loginConfigErr != nil {
return false
}
expected := a.origin
if expected == "" {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
expected = scheme + "://" + r.Host
}
return r.Header.Get("Origin") == expected
}