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 } 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/releases/latest", a.latestRelease) m.HandleFunc("/api/releases/build", a.releaseBuild) m.HandleFunc("/api/builds", a.builds) m.HandleFunc("/api/builds/", a.buildDetail) 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 }