286 lines
8.1 KiB
Go
286 lines
8.1 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
|
|
}
|
|
|
|
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
|
|
}
|
|
if e = a.authCookies(w, r, sid, u.ID, csrf, token); e != nil {
|
|
a.s.DeleteSession(sid)
|
|
fail(w, 500, "session error")
|
|
return
|
|
}
|
|
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
|
|
}
|