Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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
|
||||
mu sync.Mutex
|
||||
workspaceReaders map[string]int
|
||||
loginMu sync.Mutex
|
||||
loginSources map[string]loginSource
|
||||
trustedProxies []*net.IPNet
|
||||
loginSlots chan struct{}
|
||||
loginConfigErr error
|
||||
authKey []byte
|
||||
authErr error
|
||||
authLoginMu sync.Mutex
|
||||
}
|
||||
|
||||
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/latest", a.latest)
|
||||
m.HandleFunc("/api/builds", a.builds)
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user