diff --git a/api/config.go b/api/config.go new file mode 100644 index 0000000..e091b6b --- /dev/null +++ b/api/config.go @@ -0,0 +1,35 @@ +/** + * OpenBmclAPI (Golang Edition) + * Copyright (C) 2024 Kevin Z + * All rights reserved + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package api + +import ( + "encoding/json" +) + +type ConfigHandler interface { + json.Marshaler + json.Unmarshaler + UnmarshalYAML(data []byte) error + MarshalJSONPath(path string) ([]byte, error) + UnmarshalJSONPath(path string, data []byte) error + + Fingerprint() string + DoLockedAction(fingerprint string, callback func(ConfigHandler) error) error +} diff --git a/api/request.go b/api/request.go new file mode 100644 index 0000000..74588c3 --- /dev/null +++ b/api/request.go @@ -0,0 +1,45 @@ +/** + * OpenBmclAPI (Golang Edition) + * Copyright (C) 2024 Kevin Z + * All rights reserved + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package api + +import ( + "net/http" +) + +const ( + RealAddrCtxKey = "handle.real.addr" + RealPathCtxKey = "handle.real.path" + AccessLogExtraCtxKey = "handle.access.extra" +) + +func GetRequestRealAddr(req *http.Request) string { + addr, _ := req.Context().Value(RealAddrCtxKey).(string) + return addr +} + +func GetRequestRealPath(req *http.Request) string { + return req.Context().Value(RealPathCtxKey).(string) +} + +func SetAccessInfo(req *http.Request, key string, value any) { + if info, ok := req.Context().Value(AccessLogExtraCtxKey).(map[string]any); ok { + info[key] = value + } +} diff --git a/api/stats.go b/api/stats.go new file mode 100644 index 0000000..6ddd0f7 --- /dev/null +++ b/api/stats.go @@ -0,0 +1,89 @@ +/** + * OpenBmclAPI (Golang Edition) + * Copyright (C) 2024 Kevin Z + * All rights reserved + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package api + +import ( + "time" +) + +type StatsManager interface { + GetStatus() StatusData + // if name is empty then gets the overall access data + GetAccessStat(name string) *AccessStatData +} + +type StatusData struct { + StartAt time.Time `json:"startAt"` + Clusters []string `json:"clusters"` + Storages []string `json:"storages"` +} + +type statInstData struct { + Hits int32 `json:"hits"` + Bytes int64 `json:"bytes"` +} + +func (d *statInstData) update(o *statInstData) { + d.Hits += o.Hits + d.Bytes += o.Bytes +} + +// statTime always save a UTC time +type statTime struct { + Hour int `json:"hour"` + Day int `json:"day"` + Month int `json:"month"` + Year int `json:"year"` +} + +func makeStatTime(t time.Time) (st statTime) { + t = t.UTC() + st.Hour = t.Hour() + y, m, d := t.Date() + st.Day = d - 1 + st.Month = (int)(m) - 1 + st.Year = y + return +} + +func (t statTime) IsLastDay() bool { + return time.Date(t.Year, (time.Month)(t.Month+1), t.Day+1+1, 0, 0, 0, 0, time.UTC).Day() == 1 +} + +type ( + statDataHours = [24]statInstData + statDataDays = [31]statInstData + statDataMonths = [12]statInstData +) + +type accessStatHistoryData struct { + Hours statDataHours `json:"hours"` + Days statDataDays `json:"days"` + Months statDataMonths `json:"months"` +} + +type AccessStatData struct { + Date statTime `json:"date"` + accessStatHistoryData + Prev accessStatHistoryData `json:"prev"` + Years map[string]statInstData `json:"years"` + + Accesses map[string]int `json:"accesses"` +} diff --git a/api/subscription.go b/api/subscription.go index da5034c..7b88ad9 100644 --- a/api/subscription.go +++ b/api/subscription.go @@ -22,6 +22,8 @@ package api import ( "database/sql" "database/sql/driver" + "encoding/json" + "errors" "fmt" "time" @@ -30,6 +32,10 @@ import ( "github.com/LiterMC/go-openbmclapi/utils" ) +var ( + ErrNotFound = errors.New("Item not found") +) + type SubscriptionManager interface { GetWebPushKey() string diff --git a/api/token.go b/api/token.go index 3f2081f..8dfcbdc 100644 --- a/api/token.go +++ b/api/token.go @@ -24,12 +24,15 @@ import ( ) type TokenVerifier interface { + VerifyChallengeToken(clientId string, token string, action string) (err error) VerifyAuthToken(clientId string, token string) (tokenId string, userId string, err error) VerifyAPIToken(clientId string, token string, path string, query url.Values) (userId string, err error) } type TokenManager interface { TokenVerifier + GenerateChallengeToken(clientId string, action string) (token string, err error) GenerateAuthToken(clientId string, userId string) (token string, err error) GenerateAPIToken(clientId string, userId string, path string, query map[string]string) (token string, err error) + InvalidToken(tokenId string) error } diff --git a/api/v0/api.go b/api/v0/api.go index b721a2f..65b288c 100644 --- a/api/v0/api.go +++ b/api/v0/api.go @@ -20,56 +20,32 @@ package v0 import ( - "compress/gzip" - "context" - "crypto/subtle" "encoding/json" "errors" - "fmt" - "io" "mime" "net/http" - "os" - "path/filepath" "strconv" - "strings" - "sync/atomic" - "time" - "github.com/google/uuid" "github.com/gorilla/schema" - "runtime/pprof" "github.com/LiterMC/go-openbmclapi/api" - "github.com/LiterMC/go-openbmclapi/database" - "github.com/LiterMC/go-openbmclapi/internal/build" - "github.com/LiterMC/go-openbmclapi/limited" - "github.com/LiterMC/go-openbmclapi/log" - "github.com/LiterMC/go-openbmclapi/notify" "github.com/LiterMC/go-openbmclapi/utils" ) -const ( - clientIdCookieName = "_id" - - clientIdKey = "go-openbmclapi.cluster.client.id" -) - -func apiGetClientId(req *http.Request) (id string) { - return req.Context().Value(clientIdKey).(string) -} - type Handler struct { handler *utils.HttpMiddleWareHandler router *http.ServeMux + config api.ConfigHandler users api.UserManager tokens api.TokenManager subscriptions api.SubscriptionManager + stats api.StatsManager } var _ http.Handler = (*Handler)(nil) func NewHandler( + config api.ConfigHandler, users api.UserManager, tokenManager api.TokenManager, subManager api.SubscriptionManager, @@ -78,13 +54,13 @@ func NewHandler( h := &Handler{ router: mux, handler: utils.NewHttpMiddleWareHandler(mux), + config: config, users: users, tokens: tokenManager, subscriptions: subManager, } h.buildRoute() - h.handler.Use(cliIdMiddleWare) - h.handler.Use(h.authMiddleWare) + h.handler.UseFunc(cliIdMiddleWare, h.authMiddleWare) return h } @@ -102,715 +78,16 @@ func (h *Handler) buildRoute() { }) }) - mux.HandleFunc("/ping", h.routePing) - mux.HandleFunc("/status", h.routeStatus) - mux.Handle("/stat/", http.StripPrefix("/stat/", (http.HandlerFunc)(h.routeStat))) - - mux.HandleFunc("/challenge", h.routeChallenge) - mux.HandleFunc("/login", h.routeLogin) - mux.Handle("/requestToken", authHandleFunc(h.routeRequestToken)) - mux.Handle("/logout", authHandleFunc(h.routeLogout)) - - mux.HandleFunc("/log.io", h.routeLogIO) - mux.Handle("/pprof", permHandleFunc(api.DebugPerm, h.routePprof)) - mux.HandleFunc("/subscribeKey", h.routeSubscribeKey) - mux.Handle("/subscribe", permHandleFunc(api.SubscribePerm, &utils.HttpMethodHandler{ - Get: h.routeSubscribeGET, - Post: h.routeSubscribePOST, - Delete: h.routeSubscribeDELETE, - })) - mux.Handle("/subscribe_email", permHandleFunc(api.SubscribePerm, &utils.HttpMethodHandler{ - Get: h.routeSubscribeEmailGET, - Post: h.routeSubscribeEmailPOST, - Patch: h.routeSubscribeEmailPATCH, - Delete: h.routeSubscribeEmailDELETE, - })) - mux.Handle("/webhook", permHandleFunc(api.SubscribePerm, &utils.HttpMethodHandler{ - Get: h.routeWebhookGET, - Post: h.routeWebhookPOST, - Patch: h.routeWebhookPATCH, - Delete: h.routeWebhookDELETE, - })) - - mux.Handle("/log_files", permHandleFunc(api.LogPerm, h.routeLogFiles)) - mux.Handle("/log_file/", permHandle(api.LogPerm, http.StripPrefix("/log_file/", (http.HandlerFunc)(h.routeLogFile)))) - - mux.Handle("/configure/cluster", permHandleFunc(api.ClusterPerm, h.routeConfigureCluster)) + h.buildStatRoute(mux) + h.buildAuthRoute(mux) + h.buildSubscriptionRoute(mux) + h.buildConfigureRoute(mux) } func (h *Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { h.handler.ServeHTTP(rw, req) } -func (h *Handler) routePing(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - errorMethodNotAllowed(rw, req, http.MethodGet) - return - } - limited.SetSkipRateLimit(req) - authed := getRequestTokenType(req) == tokenTypeAuth - writeJson(rw, http.StatusOK, Map{ - "version": build.BuildVersion, - "time": time.Now().UnixMilli(), - "authed": authed, - }) -} - -func (cr *Cluster) routeStatus(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - errorMethodNotAllowed(rw, req, http.MethodGet) - return - } - limited.SetSkipRateLimit(req) - type syncData struct { - Prog int64 `json:"prog"` - Total int64 `json:"total"` - } - type statusData struct { - StartAt time.Time `json:"startAt"` - Stats *notify.Stats `json:"stats"` - Enabled bool `json:"enabled"` - IsSync bool `json:"isSync"` - Sync *syncData `json:"sync,omitempty"` - Storages []string `json:"storages"` - } - storages := make([]string, len(cr.storageOpts)) - for i, opt := range cr.storageOpts { - storages[i] = opt.Id - } - status := statusData{ - StartAt: startTime, - Stats: &cr.stats, - Enabled: cr.enabled.Load(), - IsSync: cr.issync.Load(), - Storages: storages, - } - if status.IsSync { - status.Sync = &syncData{ - Prog: cr.syncProg.Load(), - Total: cr.syncTotal.Load(), - } - } - writeJson(rw, http.StatusOK, &status) -} - -func (cr *Cluster) routeStat(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - errorMethodNotAllowed(rw, req, http.MethodGet) - return - } - limited.SetSkipRateLimit(req) - name := req.URL.Path - if name == "" { - rw.Header().Set("Cache-Control", "public, max-age=60") - writeJson(rw, http.StatusOK, &cr.stats) - return - } - data, err := cr.stats.MarshalSubStat(name) - if err != nil { - http.Error(rw, "Error when encoding response: "+err.Error(), http.StatusInternalServerError) - return - } - rw.Header().Set("Cache-Control", "public, max-age=30") - writeJson(rw, http.StatusOK, (json.RawMessage)(data)) -} - -func (h *Handler) routeChallenge(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - errorMethodNotAllowed(rw, req, http.MethodGet) - return - } - cli := apiGetClientId(req) - query := req.URL.Query() - action := query.Get("action") - token, err := h.generateChallengeToken(cli, action) - if err != nil { - writeJson(rw, http.StatusInternalServerError, Map{ - "error": "Cannot generate token", - "message": err.Error(), - }) - return - } - writeJson(rw, http.StatusOK, Map{ - "token": token, - }) -} - -func (h *Handler) routeLogin(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - errorMethodNotAllowed(rw, req, http.MethodPost) - return - } - if !config.Dashboard.Enable { - writeJson(rw, http.StatusServiceUnavailable, Map{ - "error": "dashboard is disabled in the config", - }) - return - } - cli := apiGetClientId(req) - - var data struct { - User string `json:"username" schema:"username"` - Challenge string `json:"challenge" schema:"challenge"` - Signature string `json:"signature" schema:"signature"` - } - if !parseRequestBody(rw, req, &data) { - return - } - - if err := h.tokens.VerifyChallengeToken(cli, "login", data.Challenge); err != nil { - writeJson(rw, http.StatusUnauthorized, Map{ - "error": "Invalid challenge", - }) - return - } - if err := h.tokens.VerifyUserPassword(data.User, func(password string) bool { - expectSignature := utils.HMACSha256HexBytes(password, data.Challenge) - return subtle.ConstantTimeCompare(expectSignature, ([]byte)(data.Signature)) == 0 - }); err != nil { - writeJson(rw, http.StatusUnauthorized, Map{ - "error": "The username or password is incorrect", - }) - return - } - token, err := cr.generateAuthToken(cli, data.User) - if err != nil { - writeJson(rw, http.StatusInternalServerError, Map{ - "error": "Cannot generate token", - "message": err.Error(), - }) - return - } - writeJson(rw, http.StatusOK, Map{ - "token": token, - }) -} - -func (cr *Cluster) routeRequestToken(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - errorMethodNotAllowed(rw, req, http.MethodPost) - return - } - defer req.Body.Close() - if getRequestTokenType(req) != tokenTypeAuth { - writeJson(rw, http.StatusUnauthorized, Map{ - "error": "invalid authorization type", - }) - return - } - - var payload struct { - Path string `json:"path"` - Query map[string]string `json:"query,omitempty"` - } - if !parseRequestBody(rw, req, &payload) { - return - } - log.Debugf("payload: %#v", payload) - if payload.Path == "" || payload.Path[0] != '/' { - writeJson(rw, http.StatusBadRequest, Map{ - "error": "path is invalid", - "message": "'path' must be a non empty string which starts with '/'", - }) - return - } - cli := apiGetClientId(req) - user := getLoggedUser(req) - token, err := cr.generateAPIToken(cli, user, payload.Path, payload.Query) - if err != nil { - writeJson(rw, http.StatusInternalServerError, Map{ - "error": "cannot generate token", - "message": err.Error(), - }) - return - } - writeJson(rw, http.StatusOK, Map{ - "token": token, - }) -} - -func (cr *Cluster) routeLogout(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - errorMethodNotAllowed(rw, req, http.MethodPost) - return - } - limited.SetSkipRateLimit(req) - tid := req.Context().Value(tokenIdKey).(string) - cr.database.RemoveJTI(tid) - rw.WriteHeader(http.StatusNoContent) -} - -func (cr *Cluster) routeLogIO(rw http.ResponseWriter, req *http.Request) { - addr, _ := req.Context().Value(RealAddrCtxKey).(string) - - conn, err := cr.wsUpgrader.Upgrade(rw, req, nil) - if err != nil { - log.Debugf("[log.io]: Websocket upgrade error: %v", err) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer conn.Close() - - cli := apiGetClientId(req) - - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - - conn.SetReadLimit(1024 * 4) - pongTimeoutTimer := time.NewTimer(time.Second * 75) - go func() { - defer conn.Close() - defer cancel() - defer pongTimeoutTimer.Stop() - select { - case _, ok := <-pongTimeoutTimer.C: - if !ok { - return - } - log.Error("[log.io]: Did not receive packet from client longer than 75s") - return - case <-ctx.Done(): - return - } - }() - - var authData struct { - Token string `json:"token"` - } - deadline := time.Now().Add(time.Second * 10) - conn.SetReadDeadline(deadline) - err = conn.ReadJSON(&authData) - conn.SetReadDeadline(time.Time{}) - if err != nil { - if time.Now().After(deadline) { - conn.WriteJSON(Map{ - "type": "error", - "message": "auth timeout", - }) - } else { - conn.WriteJSON(Map{ - "type": "error", - "message": "unexpected auth data: " + err.Error(), - }) - } - return - } - if _, _, err = cr.verifyAuthToken(cli, authData.Token); err != nil { - conn.WriteJSON(Map{ - "type": "error", - "message": "auth failed", - }) - return - } - if err := conn.WriteJSON(Map{ - "type": "ready", - }); err != nil { - return - } - - var level atomic.Int32 - level.Store((int32)(log.LevelInfo)) - - type logObj struct { - Type string `json:"type"` - Time int64 `json:"time"` // UnixMilli - Level string `json:"lvl"` - Log string `json:"log"` - } - c := make(chan *logObj, 64) - unregister := log.RegisterLogMonitor(log.LevelDebug, func(ts int64, l log.Level, msg string) { - if (log.Level)(level.Load()) > l&log.LevelMask { - return - } - select { - case c <- &logObj{ - Type: "log", - Time: ts, - Level: l.String(), - Log: msg, - }: - default: - } - }) - defer unregister() - - go func() { - defer log.RecoverPanic(nil) - defer conn.Close() - defer cancel() - var data map[string]any - for { - clear(data) - if err := conn.ReadJSON(&data); err != nil { - log.Errorf("[log.io]: Cannot read from peer: %v", err) - return - } - typ, ok := data["type"].(string) - if !ok { - continue - } - switch typ { - case "pong": - log.Debugf("[log.io]: received PONG from %s: %v", addr, data["data"]) - pongTimeoutTimer.Reset(time.Second * 75) - case "set-level": - l, ok := data["level"].(string) - if ok { - switch l { - case "DBUG": - level.Store((int32)(log.LevelDebug)) - case "INFO": - level.Store((int32)(log.LevelInfo)) - case "WARN": - level.Store((int32)(log.LevelWarn)) - case "ERRO": - level.Store((int32)(log.LevelError)) - default: - continue - } - select { - case c <- &logObj{ - Type: "log", - Time: time.Now().UnixMilli(), - Level: log.LevelInfo.String(), - Log: "[dashboard]: Set log level to " + l + " for this log.io", - }: - default: - } - } - } - } - }() - - sendMsgCh := make(chan any, 64) - go func() { - for { - select { - case v := <-c: - select { - case sendMsgCh <- v: - case <-ctx.Done(): - return - } - case <-ctx.Done(): - return - } - } - }() - - pingTicker := time.NewTicker(time.Second * 45) - defer pingTicker.Stop() - forceSendTimer := time.NewTimer(time.Second) - if !forceSendTimer.Stop() { - <-forceSendTimer.C - } - - batchMsg := make([]any, 0, 64) - for { - select { - case v := <-sendMsgCh: - batchMsg = append(batchMsg, v) - forceSendTimer.Reset(time.Second) - WAIT_MORE: - for { - select { - case v := <-sendMsgCh: - batchMsg = append(batchMsg, v) - case <-time.After(time.Millisecond * 20): - if !forceSendTimer.Stop() { - <-forceSendTimer.C - } - break WAIT_MORE - case <-forceSendTimer.C: - break WAIT_MORE - case <-ctx.Done(): - forceSendTimer.Stop() - return - } - } - if len(batchMsg) == 1 { - if err := conn.WriteJSON(batchMsg[0]); err != nil { - return - } - } else { - if err := conn.WriteJSON(batchMsg); err != nil { - return - } - } - // release objects - for i, _ := range batchMsg { - batchMsg[i] = nil - } - batchMsg = batchMsg[:0] - case <-pingTicker.C: - if err := conn.WriteJSON(Map{ - "type": "ping", - "data": time.Now().UnixMilli(), - }); err != nil { - log.Errorf("[log.io]: Error when sending ping packet: %v", err) - return - } - case <-ctx.Done(): - return - } - } -} - -func (cr *Cluster) routePprof(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - errorMethodNotAllowed(rw, req, http.MethodGet) - return - } - query := req.URL.Query() - lookup := query.Get("lookup") - p := pprof.Lookup(lookup) - if p == nil { - http.Error(rw, fmt.Sprintf("pprof.Lookup(%q) returned nil", lookup), http.StatusBadRequest) - return - } - view := query.Get("view") - debug, err := strconv.Atoi(query.Get("debug")) - if err != nil { - debug = 1 - } - if debug == 1 { - rw.Header().Set("Content-Type", "text/plain; charset=utf-8") - } else { - rw.Header().Set("Content-Type", "application/octet-stream") - } - if view != "1" { - name := fmt.Sprintf(time.Now().Format("dump-%s-20060102-150405"), lookup) - if debug == 1 { - name += ".txt" - } else { - name += ".dump" - } - rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) - } - rw.WriteHeader(http.StatusOK) - if debug == 1 { - fmt.Fprintf(rw, "version: %s (%s)\n", build.BuildVersion, build.ClusterVersion) - } - p.WriteTo(rw, debug) -} - -func (cr *Cluster) routeWebhookGET(rw http.ResponseWriter, req *http.Request) { - user := getLoggedUser(req) - if sid := req.URL.Query().Get("id"); sid != "" { - id, err := uuid.Parse(sid) - if err != nil { - writeJson(rw, http.StatusBadRequest, Map{ - "error": "uuid format error", - "message": err.Error(), - }) - return - } - record, err := cr.database.GetWebhook(user, id) - if err != nil { - if err == database.ErrNotFound { - writeJson(rw, http.StatusNotFound, Map{ - "error": "no webhook was found", - }) - return - } - writeJson(rw, http.StatusInternalServerError, Map{ - "error": "database error", - "message": err.Error(), - }) - return - } - writeJson(rw, http.StatusOK, record) - return - } - records := make([]database.WebhookRecord, 0, 4) - if err := cr.database.ForEachUsersWebhook(user, func(rec *database.WebhookRecord) error { - records = append(records, *rec) - return nil - }); err != nil { - writeJson(rw, http.StatusInternalServerError, Map{ - "error": "database error", - "message": err.Error(), - }) - return - } - writeJson(rw, http.StatusOK, records) -} - -func (cr *Cluster) routeWebhookPOST(rw http.ResponseWriter, req *http.Request) { - user := getLoggedUser(req) - var data database.WebhookRecord - if !parseRequestBody(rw, req, &data) { - return - } - - data.User = user - if err := cr.database.AddWebhook(data); err != nil { - writeJson(rw, http.StatusInternalServerError, Map{ - "error": "Database update failed", - "message": err.Error(), - }) - return - } - rw.WriteHeader(http.StatusCreated) -} - -func (cr *Cluster) routeWebhookPATCH(rw http.ResponseWriter, req *http.Request) { - user := getLoggedUser(req) - id := req.URL.Query().Get("id") - var data database.WebhookRecord - if !parseRequestBody(rw, req, &data) { - return - } - data.User = user - var err error - if data.Id, err = uuid.Parse(id); err != nil { - writeJson(rw, http.StatusBadRequest, Map{ - "error": "uuid format error", - "message": err.Error(), - }) - return - } - if err := cr.database.UpdateWebhook(data); err != nil { - if err == database.ErrNotFound { - writeJson(rw, http.StatusNotFound, Map{ - "error": "no webhook was found", - }) - return - } - writeJson(rw, http.StatusInternalServerError, Map{ - "error": "database error", - "message": err.Error(), - }) - return - } - rw.WriteHeader(http.StatusNoContent) -} - -func (cr *Cluster) routeWebhookDELETE(rw http.ResponseWriter, req *http.Request) { - user := getLoggedUser(req) - id, err := uuid.Parse(req.URL.Query().Get("id")) - if err != nil { - writeJson(rw, http.StatusBadRequest, Map{ - "error": "uuid format error", - "message": err.Error(), - }) - return - } - if err := cr.database.RemoveWebhook(user, id); err != nil { - if err == database.ErrNotFound { - writeJson(rw, http.StatusNotFound, Map{ - "error": "no webhook was found", - }) - return - } - writeJson(rw, http.StatusInternalServerError, Map{ - "error": "database error", - "message": err.Error(), - }) - return - } - rw.WriteHeader(http.StatusNoContent) -} - -func (cr *Cluster) routeLogFiles(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - errorMethodNotAllowed(rw, req, http.MethodGet) - return - } - files := log.ListLogs() - type FileInfo struct { - Name string `json:"name"` - Size int64 `json:"size"` - } - data := make([]FileInfo, 0, len(files)) - for _, file := range files { - if s, err := os.Stat(filepath.Join(log.BaseDir(), file)); err == nil { - data = append(data, FileInfo{ - Name: file, - Size: s.Size(), - }) - } - } - writeJson(rw, http.StatusOK, Map{ - "files": data, - }) -} - -func (cr *Cluster) routeLogFile(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet && req.Method != http.MethodHead { - errorMethodNotAllowed(rw, req, http.MethodGet+", "+http.MethodHead) - return - } - query := req.URL.Query() - fd, err := os.Open(filepath.Join(log.BaseDir(), req.URL.Path)) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - writeJson(rw, http.StatusNotFound, Map{ - "error": "file not exists", - "message": "Cannot find log file", - "path": req.URL.Path, - }) - return - } - writeJson(rw, http.StatusInternalServerError, Map{ - "error": "cannot open file", - "message": err.Error(), - }) - return - } - defer fd.Close() - name := filepath.Base(req.URL.Path) - isGzip := filepath.Ext(name) == ".gz" - if query.Get("no_encrypt") == "1" { - var modTime time.Time - if stat, err := fd.Stat(); err == nil { - modTime = stat.ModTime() - } - rw.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=600") - if isGzip { - rw.Header().Set("Content-Type", "application/octet-stream") - } else { - rw.Header().Set("Content-Type", "text/plain; charset=utf-8") - } - rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) - http.ServeContent(rw, req, name, modTime, fd) - } else { - if !isGzip { - name += ".gz" - } - rw.Header().Set("Content-Type", "application/octet-stream") - rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name+".encrypted")) - cr.routeLogFileEncrypted(rw, req, fd, !isGzip) - } -} - -func (cr *Cluster) routeLogFileEncrypted(rw http.ResponseWriter, req *http.Request, r io.Reader, useGzip bool) { - rw.WriteHeader(http.StatusOK) - if req.Method == http.MethodHead { - return - } - if useGzip { - pr, pw := io.Pipe() - defer pr.Close() - go func(r io.Reader) { - gw := gzip.NewWriter(pw) - if _, err := io.Copy(gw, r); err != nil { - pw.CloseWithError(err) - return - } - if err := gw.Close(); err != nil { - pw.CloseWithError(err) - return - } - pw.Close() - }(r) - r = pr - } - if err := utils.EncryptStream(rw, r, utils.DeveloporPublicKey); err != nil { - log.Errorf("Cannot write encrypted log stream: %v", err) - } -} - type Map = map[string]any var errUnknownContent = errors.New("unknown content-type") @@ -877,5 +154,4 @@ func writeJson(rw http.ResponseWriter, code int, data any) (err error) { func errorMethodNotAllowed(rw http.ResponseWriter, req *http.Request, allow string) { rw.Header().Set("Allow", allow) rw.WriteHeader(http.StatusMethodNotAllowed) - return true } diff --git a/api/v0/auth.go b/api/v0/auth.go index 217d6ab..0af1303 100644 --- a/api/v0/auth.go +++ b/api/v0/auth.go @@ -20,17 +20,29 @@ package v0 import ( + "context" + "crypto/subtle" "errors" - "fmt" "net/http" - "net/url" + "strings" "time" - "github.com/golang-jwt/jwt/v5" - + "github.com/LiterMC/go-openbmclapi/api" + "github.com/LiterMC/go-openbmclapi/limited" + "github.com/LiterMC/go-openbmclapi/log" "github.com/LiterMC/go-openbmclapi/utils" ) +const ( + clientIdCookieName = "_id" + + clientIdKey = "go-openbmclapi.cluster.client.id" +) + +func apiGetClientId(req *http.Request) (id string) { + return req.Context().Value(clientIdKey).(string) +} + const jwtIssuerPrefix = "GOBA.dash.api" const ( @@ -95,26 +107,26 @@ func (h *Handler) authMiddleWare(rw http.ResponseWriter, req *http.Request, next ) if req.Method == http.MethodGet { if tk := req.URL.Query().Get("_t"); tk != "" { - path := GetRequestRealPath(req) - if id, uid, err = h.tokens.VerifyAPIToken(cli, tk, path, req.URL.Query()); err == nil { + path := api.GetRequestRealPath(req) + if uid, err = h.tokens.VerifyAPIToken(cli, tk, path, req.URL.Query()); err == nil { typ = tokenTypeAPI } } } - if id == "" { + if typ == "" { auth := req.Header.Get("Authorization") tk, ok := strings.CutPrefix(auth, "Bearer ") if !ok { if err == nil { - err = ErrUnsupportAuthType + err = errors.New("Unsupported authorization type") } } else if id, uid, err = h.tokens.VerifyAuthToken(cli, tk); err == nil { typ = tokenTypeAuth } } if typ != "" { - user, err := h.users.GetUser(uid) - if err == nil { + user := h.users.GetUser(uid) + if user != nil { ctx = context.WithValue(ctx, tokenTypeKey, typ) ctx = context.WithValue(ctx, loggedUserKey, user) ctx = context.WithValue(ctx, tokenIdKey, id) @@ -142,7 +154,7 @@ func permHandle(perm api.PermissionFlag, next http.Handler) http.Handler { }) return } - if user.Permissions & perm != perm { + if user.Permissions&perm != perm { writeJson(rw, http.StatusForbidden, Map{ "error": "Permission denied", }) @@ -156,214 +168,328 @@ func permHandleFunc(perm api.PermissionFlag, next http.HandlerFunc) http.Handler return permHandle(perm, next) } -var ( - ErrUnsupportAuthType = errors.New("unsupported authorization type") - ErrScopeNotMatch = errors.New("scope not match") - ErrJTINotExists = errors.New("jti not exists") - - ErrStrictPathNotMatch = errors.New("strict path not match") - ErrStrictQueryNotMatch = errors.New("strict query value not match") -) - -func (cr *Cluster) getJWTKey(t *jwt.Token) (any, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"]) - } - return cr.apiHmacKey, nil -} - -const ( - challengeTokenScope = "GOBA-challenge" - authTokenScope = "GOBA-auth" - apiTokenScope = "GOBA-API" -) - -type challengeTokenClaims struct { - jwt.RegisteredClaims - - Scope string `json:"scope"` - Action string `json:"act"` -} - -func (cr *Cluster) generateChallengeToken(cliId string, action string) (string, error) { - now := time.Now() - exp := now.Add(time.Minute * 1) - token := jwt.NewWithClaims(jwt.SigningMethodHS256, &challengeTokenClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Subject: cliId, - Issuer: cr.jwtIssuer, - IssuedAt: jwt.NewNumericDate(now), - ExpiresAt: jwt.NewNumericDate(exp), - }, - Scope: challengeTokenScope, - Action: action, - }) - tokenStr, err := token.SignedString(cr.apiHmacKey) - if err != nil { - return "", err - } - return tokenStr, nil +func (h *Handler) buildAuthRoute(mux *http.ServeMux) { + mux.HandleFunc("/challenge", h.routeChallenge) + mux.HandleFunc("POST /login", h.routeLogin) + mux.Handle("POST /requestToken", authHandleFunc(h.routeRequestToken)) + mux.Handle("POST /logout", authHandleFunc(h.routeLogout)) } -func (cr *Cluster) verifyChallengeToken(cliId string, action string, token string) (err error) { - var claims challengeTokenClaims - if _, err = jwt.ParseWithClaims( - token, - &claims, - cr.getJWTKey, - jwt.WithSubject(cliId), - jwt.WithIssuedAt(), - jwt.WithIssuer(cr.jwtIssuer), - ); err != nil { +func (h *Handler) routeChallenge(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + errorMethodNotAllowed(rw, req, http.MethodGet) return } - if claims.Scope != challengeTokenScope { - return ErrScopeNotMatch - } - if claims.Action != action { - return ErrJTINotExists - } - return -} - -type authTokenClaims struct { - jwt.RegisteredClaims - - Scope string `json:"scope"` - User string `json:"usr"` -} - -func (cr *Cluster) generateAuthToken(cliId string, userId string) (string, error) { - jti, err := utils.GenRandB64(16) + cli := apiGetClientId(req) + query := req.URL.Query() + action := query.Get("action") + token, err := h.tokens.GenerateChallengeToken(cli, action) if err != nil { - return "", err + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "Cannot generate token", + "message": err.Error(), + }) + return } - now := time.Now() - exp := now.Add(time.Hour * 24) - token := jwt.NewWithClaims(jwt.SigningMethodHS256, &authTokenClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ID: jti, - Subject: cliId, - Issuer: cr.jwtIssuer, - IssuedAt: jwt.NewNumericDate(now), - ExpiresAt: jwt.NewNumericDate(exp), - }, - Scope: authTokenScope, - User: userId, + writeJson(rw, http.StatusOK, Map{ + "token": token, }) - tokenStr, err := token.SignedString(cr.apiHmacKey) - if err != nil { - return "", err - } - if err = cr.database.AddJTI(jti, exp); err != nil { - return "", err - } - return tokenStr, nil } -func (cr *Cluster) verifyAuthToken(cliId string, token string) (id string, user string, err error) { - var claims authTokenClaims - if _, err = jwt.ParseWithClaims( - token, - &claims, - cr.getJWTKey, - jwt.WithSubject(cliId), - jwt.WithIssuedAt(), - jwt.WithIssuer(cr.jwtIssuer), - ); err != nil { - return +func (h *Handler) routeLogin(rw http.ResponseWriter, req *http.Request) { + cli := apiGetClientId(req) + + var data struct { + User string `json:"username" schema:"username"` + Challenge string `json:"challenge" schema:"challenge"` + Signature string `json:"signature" schema:"signature"` } - if claims.Scope != authTokenScope { - err = ErrScopeNotMatch + if !parseRequestBody(rw, req, &data) { return } - if user = claims.User; user == "" { - // reject old token - err = ErrJTINotExists + + if err := h.tokens.VerifyChallengeToken(cli, data.Challenge, "login"); err != nil { + writeJson(rw, http.StatusUnauthorized, Map{ + "error": "Invalid challenge", + }) return } - id = claims.ID - if ok, _ := cr.database.ValidJTI(id); !ok { - err = ErrJTINotExists + if err := h.users.VerifyUserPassword(data.User, func(password string) bool { + expectSignature := utils.HMACSha256HexBytes(password, data.Challenge) + return subtle.ConstantTimeCompare(expectSignature, ([]byte)(data.Signature)) == 0 + }); err != nil { + writeJson(rw, http.StatusUnauthorized, Map{ + "error": "The username or password is incorrect", + }) return } - return -} - -type apiTokenClaims struct { - jwt.RegisteredClaims - - Scope string `json:"scope"` - User string `json:"usr"` - StrictPath string `json:"str-p"` - StrictQuery map[string]string `json:"str-q,omitempty"` -} - -func (cr *Cluster) generateAPIToken(cliId string, userId string, path string, query map[string]string) (string, error) { - jti, err := utils.GenRandB64(8) + token, err := h.tokens.GenerateAuthToken(cli, data.User) if err != nil { - return "", err + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "Cannot generate token", + "message": err.Error(), + }) + return } - now := time.Now() - exp := now.Add(time.Minute * 10) - token := jwt.NewWithClaims(jwt.SigningMethodHS256, &apiTokenClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - ID: jti, - Subject: cliId, - Issuer: cr.jwtIssuer, - IssuedAt: jwt.NewNumericDate(now), - ExpiresAt: jwt.NewNumericDate(exp), - }, - Scope: apiTokenScope, - User: userId, - StrictPath: path, - StrictQuery: query, + writeJson(rw, http.StatusOK, Map{ + "token": token, }) - tokenStr, err := token.SignedString(cr.apiHmacKey) - if err != nil { - return "", err - } - if err = cr.database.AddJTI(jti, exp); err != nil { - return "", err - } - return tokenStr, nil } -func (h *Handler) verifyAPIToken(cliId string, token string, path string, query url.Values) (id string, user string, err error) { - var claims apiTokenClaims - _, err = jwt.ParseWithClaims( - token, - &claims, - cr.getJWTKey, - jwt.WithSubject(cliId), - jwt.WithIssuedAt(), - jwt.WithIssuer(cr.jwtIssuer), - ) - if err != nil { +func (h *Handler) routeRequestToken(rw http.ResponseWriter, req *http.Request) { + defer req.Body.Close() + if getRequestTokenType(req) != tokenTypeAuth { + writeJson(rw, http.StatusUnauthorized, Map{ + "error": "invalid authorization type", + }) return } - if claims.Scope != apiTokenScope { - err = ErrScopeNotMatch - return + + var payload struct { + Path string `json:"path"` + Query map[string]string `json:"query,omitempty"` } - if user = claims.User; user == "" { - err = ErrJTINotExists + if !parseRequestBody(rw, req, &payload) { return } - id = claims.ID - if ok, _ := cr.database.ValidJTI(id); !ok { - err = ErrJTINotExists + log.Debugf("payload: %#v", payload) + if payload.Path == "" || payload.Path[0] != '/' { + writeJson(rw, http.StatusBadRequest, Map{ + "error": "path is invalid", + "message": "'path' must be a non empty string which starts with '/'", + }) return } - if claims.StrictPath != path { - err = ErrStrictPathNotMatch + cli := apiGetClientId(req) + user := getLoggedUser(req) + token, err := h.tokens.GenerateAPIToken(cli, user.Username, payload.Path, payload.Query) + if err != nil { + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "cannot generate token", + "message": err.Error(), + }) return } - for k, v := range claims.StrictQuery { - if query.Get(k) != v { - err = ErrStrictQueryNotMatch - return - } - } - return + writeJson(rw, http.StatusOK, Map{ + "token": token, + }) +} + +func (h *Handler) routeLogout(rw http.ResponseWriter, req *http.Request) { + limited.SetSkipRateLimit(req) + tid := req.Context().Value(tokenIdKey).(string) + h.tokens.InvalidToken(tid) + rw.WriteHeader(http.StatusNoContent) } + +// var ( +// ErrUnsupportAuthType = errors.New("unsupported authorization type") +// ErrScopeNotMatch = errors.New("scope not match") +// ErrJTINotExists = errors.New("jti not exists") + +// ErrStrictPathNotMatch = errors.New("strict path not match") +// ErrStrictQueryNotMatch = errors.New("strict query value not match") +// ) + +// func (cr *Cluster) getJWTKey(t *jwt.Token) (any, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"]) +// } +// return cr.apiHmacKey, nil +// } + +// const ( +// challengeTokenScope = "GOBA-challenge" +// authTokenScope = "GOBA-auth" +// apiTokenScope = "GOBA-API" +// ) + +// type challengeTokenClaims struct { +// jwt.RegisteredClaims + +// Scope string `json:"scope"` +// Action string `json:"act"` +// } + +// func (cr *Cluster) generateChallengeToken(cliId string, action string) (string, error) { +// now := time.Now() +// exp := now.Add(time.Minute * 1) +// token := jwt.NewWithClaims(jwt.SigningMethodHS256, &challengeTokenClaims{ +// RegisteredClaims: jwt.RegisteredClaims{ +// Subject: cliId, +// Issuer: cr.jwtIssuer, +// IssuedAt: jwt.NewNumericDate(now), +// ExpiresAt: jwt.NewNumericDate(exp), +// }, +// Scope: challengeTokenScope, +// Action: action, +// }) +// tokenStr, err := token.SignedString(cr.apiHmacKey) +// if err != nil { +// return "", err +// } +// return tokenStr, nil +// } + +// func (cr *Cluster) verifyChallengeToken(cliId string, action string, token string) (err error) { +// var claims challengeTokenClaims +// if _, err = jwt.ParseWithClaims( +// token, +// &claims, +// cr.getJWTKey, +// jwt.WithSubject(cliId), +// jwt.WithIssuedAt(), +// jwt.WithIssuer(cr.jwtIssuer), +// ); err != nil { +// return +// } +// if claims.Scope != challengeTokenScope { +// return ErrScopeNotMatch +// } +// if claims.Action != action { +// return ErrJTINotExists +// } +// return +// } + +// type authTokenClaims struct { +// jwt.RegisteredClaims + +// Scope string `json:"scope"` +// User string `json:"usr"` +// } + +// func (cr *Cluster) generateAuthToken(cliId string, userId string) (string, error) { +// jti, err := utils.GenRandB64(16) +// if err != nil { +// return "", err +// } +// now := time.Now() +// exp := now.Add(time.Hour * 24) +// token := jwt.NewWithClaims(jwt.SigningMethodHS256, &authTokenClaims{ +// RegisteredClaims: jwt.RegisteredClaims{ +// ID: jti, +// Subject: cliId, +// Issuer: cr.jwtIssuer, +// IssuedAt: jwt.NewNumericDate(now), +// ExpiresAt: jwt.NewNumericDate(exp), +// }, +// Scope: authTokenScope, +// User: userId, +// }) +// tokenStr, err := token.SignedString(cr.apiHmacKey) +// if err != nil { +// return "", err +// } +// if err = cr.database.AddJTI(jti, exp); err != nil { +// return "", err +// } +// return tokenStr, nil +// } + +// func (cr *Cluster) verifyAuthToken(cliId string, token string) (id string, user string, err error) { +// var claims authTokenClaims +// if _, err = jwt.ParseWithClaims( +// token, +// &claims, +// cr.getJWTKey, +// jwt.WithSubject(cliId), +// jwt.WithIssuedAt(), +// jwt.WithIssuer(cr.jwtIssuer), +// ); err != nil { +// return +// } +// if claims.Scope != authTokenScope { +// err = ErrScopeNotMatch +// return +// } +// if user = claims.User; user == "" { +// // reject old token +// err = ErrJTINotExists +// return +// } +// id = claims.ID +// if ok, _ := cr.database.ValidJTI(id); !ok { +// err = ErrJTINotExists +// return +// } +// return +// } + +// type apiTokenClaims struct { +// jwt.RegisteredClaims + +// Scope string `json:"scope"` +// User string `json:"usr"` +// StrictPath string `json:"str-p"` +// StrictQuery map[string]string `json:"str-q,omitempty"` +// } + +// func (cr *Cluster) generateAPIToken(cliId string, userId string, path string, query map[string]string) (string, error) { +// jti, err := utils.GenRandB64(8) +// if err != nil { +// return "", err +// } +// now := time.Now() +// exp := now.Add(time.Minute * 10) +// token := jwt.NewWithClaims(jwt.SigningMethodHS256, &apiTokenClaims{ +// RegisteredClaims: jwt.RegisteredClaims{ +// ID: jti, +// Subject: cliId, +// Issuer: cr.jwtIssuer, +// IssuedAt: jwt.NewNumericDate(now), +// ExpiresAt: jwt.NewNumericDate(exp), +// }, +// Scope: apiTokenScope, +// User: userId, +// StrictPath: path, +// StrictQuery: query, +// }) +// tokenStr, err := token.SignedString(cr.apiHmacKey) +// if err != nil { +// return "", err +// } +// if err = cr.database.AddJTI(jti, exp); err != nil { +// return "", err +// } +// return tokenStr, nil +// } + +// func (h *Handler) verifyAPIToken(cliId string, token string, path string, query url.Values) (id string, user string, err error) { +// var claims apiTokenClaims +// _, err = jwt.ParseWithClaims( +// token, +// &claims, +// cr.getJWTKey, +// jwt.WithSubject(cliId), +// jwt.WithIssuedAt(), +// jwt.WithIssuer(cr.jwtIssuer), +// ) +// if err != nil { +// return +// } +// if claims.Scope != apiTokenScope { +// err = ErrScopeNotMatch +// return +// } +// if user = claims.User; user == "" { +// err = ErrJTINotExists +// return +// } +// id = claims.ID +// if ok, _ := cr.database.ValidJTI(id); !ok { +// err = ErrJTINotExists +// return +// } +// if claims.StrictPath != path { +// err = ErrStrictPathNotMatch +// return +// } +// for k, v := range claims.StrictQuery { +// if query.Get(k) != v { +// err = ErrStrictQueryNotMatch +// return +// } +// } +// return +// } diff --git a/api/v0/configure.go b/api/v0/configure.go index e1e1687..276b62f 100644 --- a/api/v0/configure.go +++ b/api/v0/configure.go @@ -20,13 +20,160 @@ package v0 import ( + "fmt" + "io" + "mime" "net/http" + + "github.com/LiterMC/go-openbmclapi/api" ) -func (h *Handler) apiConfigureCluster(rw http.ResponseWriter, req *http.Request) { - // +func (h *Handler) buildConfigureRoute(mux *http.ServeMux) { + mux.Handle("GET /config", permHandleFunc(api.FullConfigPerm, h.routeConfigGET)) + mux.Handle("GET /config/{path}", permHandleFunc(api.FullConfigPerm, h.routeConfigGETPath)) + mux.Handle("PUT /config", permHandleFunc(api.FullConfigPerm, h.routeConfigPUT)) + mux.Handle("PATCH /config/{path}", permHandleFunc(api.FullConfigPerm, h.routeConfigPATCH)) + mux.Handle("DELETE /config/{path}", permHandleFunc(api.FullConfigPerm, h.routeConfigDELETE)) + + mux.Handle("GET /configure/clusters", permHandleFunc(api.ClusterPerm, h.routeConfigureClustersGET)) + mux.Handle("GET /configure/cluster/{cluster_id}", permHandleFunc(api.ClusterPerm, h.routeConfigureClusterGET)) + mux.Handle("PUT /configure/cluster/{cluster_id}", permHandleFunc(api.ClusterPerm, h.routeConfigureClusterPUT)) + mux.Handle("PATCH /configure/cluster/{cluster_id}/{path}", permHandleFunc(api.ClusterPerm, h.routeConfigureClusterPATCH)) + mux.Handle("DELETE /configure/cluster/{cluster_id}", permHandleFunc(api.ClusterPerm, h.routeConfigureClusterDELETE)) + + mux.Handle("GET /configure/storages", permHandleFunc(api.StoragePerm, h.routeConfigureStoragesGET)) + mux.Handle("GET /configure/storage/{storage_index}", permHandleFunc(api.StoragePerm, h.routeConfigureStorageGET)) + mux.Handle("PUT /configure/storage/{storage_index}", permHandleFunc(api.StoragePerm, h.routeConfigureStoragePUT)) + mux.Handle("PATCH /configure/storage/{storage_index}/{path}", permHandleFunc(api.StoragePerm, h.routeConfigureStoragePATCH)) + mux.Handle("DELETE /configure/storage/{storage_index}", permHandleFunc(api.StoragePerm, h.routeConfigureStorageDELETE)) + mux.Handle("POST /configure/storage/{storage_index}/move", permHandleFunc(api.StoragePerm, h.routeConfigureStorageMove)) } -func (h *Handler) apiConfigureStorage(rw http.ResponseWriter, req *http.Request) { +func (h *Handler) routeConfigGET(rw http.ResponseWriter, req *http.Request) { + buf, err := h.config.MarshalJSON() + if err != nil { + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "MarshalJSONError", + "message": err.Error(), + }) + return + } + rw.WriteHeader(http.StatusOK) + rw.Write(buf) +} + +func (h *Handler) routeConfigPUT(rw http.ResponseWriter, req *http.Request) { + contentType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")) + if err != nil { + writeJson(rw, http.StatusBadRequest, Map{ + "error": "Unexpected Content-Type", + "content-type": req.Header.Get("Content-Type"), + "message": err.Error(), + }) + return + } + etag := req.Header.Get("If-Match") + if len(etag) > 2 && etag[0] == '"' && etag[len(etag)-1] == '"' { + etag = etag[1 : len(etag)-1] + } else { + etag = "" + } + err = h.config.DoLockedAction(etag, func(config api.ConfigHandler) error { + switch contentType { + case "application/json": + buf, err := io.ReadAll(req.Body) + if err != nil { + return fmt.Errorf("Failed to read request body: %w", err) + } + return config.UnmarshalJSON(buf) + case "application/x-yaml": + buf, err := io.ReadAll(req.Body) + if err != nil { + return fmt.Errorf("Failed to read request body: %w", err) + } + return config.UnmarshalYAML(buf) + default: + return errUnknownContent + } + }) + if err != nil { + if err == errUnknownContent { + writeJson(rw, http.StatusBadRequest, Map{ + "error": "Unexpected Content-Type", + "content-type": req.Header.Get("Content-Type"), + "message": "Expected application/json, application/x-yaml", + }) + return + } + writeJson(rw, http.StatusBadRequest, Map{ + "error": "UnmarshalError", + "message": err.Error(), + }) + return + } +} + +func (h *Handler) routeConfigGETPath(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) +} + +func (h *Handler) routeConfigPATCH(rw http.ResponseWriter, req *http.Request) { +} + +func (h *Handler) routeConfigDELETE(rw http.ResponseWriter, req *http.Request) { +} + +func (h *Handler) routeConfigureClustersGET(rw http.ResponseWriter, req *http.Request) { // } + +func (h *Handler) routeConfigureClusterGET(rw http.ResponseWriter, req *http.Request) { + clusterId := req.PathValue("cluster_id") + _ = clusterId +} + +func (h *Handler) routeConfigureClusterPUT(rw http.ResponseWriter, req *http.Request) { + clusterId := req.PathValue("cluster_id") + _ = clusterId +} + +func (h *Handler) routeConfigureClusterPATCH(rw http.ResponseWriter, req *http.Request) { + clusterId := req.PathValue("cluster_id") + path := req.PathValue("path") + _, _ = clusterId, path +} + +func (h *Handler) routeConfigureClusterDELETE(rw http.ResponseWriter, req *http.Request) { + clusterId := req.PathValue("cluster_id") + _ = clusterId +} + +func (h *Handler) routeConfigureStoragesGET(rw http.ResponseWriter, req *http.Request) { +} + +func (h *Handler) routeConfigureStorageGET(rw http.ResponseWriter, req *http.Request) { + storageIndex := req.PathValue("storage_index") + _ = storageIndex +} + +func (h *Handler) routeConfigureStoragePUT(rw http.ResponseWriter, req *http.Request) { + storageIndex := req.PathValue("storage_index") + _ = storageIndex +} + +func (h *Handler) routeConfigureStoragePATCH(rw http.ResponseWriter, req *http.Request) { + storageIndex := req.PathValue("storage_index") + path := req.PathValue("path") + _, _ = storageIndex, path +} + +func (h *Handler) routeConfigureStorageDELETE(rw http.ResponseWriter, req *http.Request) { + storageIndex := req.PathValue("storage_index") + _ = storageIndex +} + +func (h *Handler) routeConfigureStorageMove(rw http.ResponseWriter, req *http.Request) { + storageIndex := req.PathValue("storage_index") + storageIndexTo := req.URL.Query().Get("to") + _, _ = storageIndex, storageIndexTo +} diff --git a/api/v0/debug.go b/api/v0/debug.go new file mode 100644 index 0000000..9727d62 --- /dev/null +++ b/api/v0/debug.go @@ -0,0 +1,393 @@ +/** + * OpenBmclAPI (Golang Edition) + * Copyright (C) 2023 Kevin Z + * All rights reserved + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package v0 + +import ( + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime/pprof" + "strconv" + "sync/atomic" + "time" + + "github.com/LiterMC/go-openbmclapi/api" + "github.com/LiterMC/go-openbmclapi/internal/build" + "github.com/LiterMC/go-openbmclapi/log" + "github.com/LiterMC/go-openbmclapi/utils" +) + +func (h *Handler) buildDebugRoute(mux *http.ServeMux) { + mux.HandleFunc("/log.io", h.routeLogIO) + mux.Handle("/pprof", permHandleFunc(api.DebugPerm, h.routePprof)) + mux.Handle("GET /log_files", permHandleFunc(api.LogPerm, h.routeLogFiles)) + mux.Handle("GET /log_file/{file_name}", permHandleFunc(api.LogPerm, h.routeLogFile)) +} + +func (h *Handler) routeLogIO(rw http.ResponseWriter, req *http.Request) { + addr := api.GetRequestRealAddr(req) + + conn, err := h.wsUpgrader.Upgrade(rw, req, nil) + if err != nil { + log.Debugf("[log.io]: Websocket upgrade error: %v", err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer conn.Close() + + cli := apiGetClientId(req) + + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + + conn.SetReadLimit(1024 * 4) + pongTimeoutTimer := time.NewTimer(time.Second * 75) + go func() { + defer conn.Close() + defer cancel() + defer pongTimeoutTimer.Stop() + select { + case _, ok := <-pongTimeoutTimer.C: + if !ok { + return + } + log.Error("[log.io]: Did not receive packet from client longer than 75s") + return + case <-ctx.Done(): + return + } + }() + + var authData struct { + Token string `json:"token"` + } + deadline := time.Now().Add(time.Second * 10) + conn.SetReadDeadline(deadline) + err = conn.ReadJSON(&authData) + conn.SetReadDeadline(time.Time{}) + if err != nil { + if time.Now().After(deadline) { + conn.WriteJSON(Map{ + "type": "error", + "message": "auth timeout", + }) + } else { + conn.WriteJSON(Map{ + "type": "error", + "message": "unexpected auth data: " + err.Error(), + }) + } + return + } + if _, _, err = h.tokens.VerifyAuthToken(cli, authData.Token); err != nil { + conn.WriteJSON(Map{ + "type": "error", + "message": "auth failed", + }) + return + } + if err := conn.WriteJSON(Map{ + "type": "ready", + }); err != nil { + return + } + + var level atomic.Int32 + level.Store((int32)(log.LevelInfo)) + + type logObj struct { + Type string `json:"type"` + Time int64 `json:"time"` // UnixMilli + Level string `json:"lvl"` + Log string `json:"log"` + } + c := make(chan *logObj, 64) + unregister := log.RegisterLogMonitor(log.LevelDebug, func(ts int64, l log.Level, msg string) { + if (log.Level)(level.Load()) > l&log.LevelMask { + return + } + select { + case c <- &logObj{ + Type: "log", + Time: ts, + Level: l.String(), + Log: msg, + }: + default: + } + }) + defer unregister() + + go func() { + defer log.RecoverPanic(nil) + defer conn.Close() + defer cancel() + var data map[string]any + for { + clear(data) + if err := conn.ReadJSON(&data); err != nil { + log.Errorf("[log.io]: Cannot read from peer: %v", err) + return + } + typ, ok := data["type"].(string) + if !ok { + continue + } + switch typ { + case "pong": + log.Debugf("[log.io]: received PONG from %s: %v", addr, data["data"]) + pongTimeoutTimer.Reset(time.Second * 75) + case "set-level": + l, ok := data["level"].(string) + if ok { + switch l { + case "DBUG": + level.Store((int32)(log.LevelDebug)) + case "INFO": + level.Store((int32)(log.LevelInfo)) + case "WARN": + level.Store((int32)(log.LevelWarn)) + case "ERRO": + level.Store((int32)(log.LevelError)) + default: + continue + } + select { + case c <- &logObj{ + Type: "log", + Time: time.Now().UnixMilli(), + Level: log.LevelInfo.String(), + Log: "[dashboard]: Set log level to " + l + " for this log.io", + }: + default: + } + } + } + } + }() + + sendMsgCh := make(chan any, 64) + go func() { + for { + select { + case v := <-c: + select { + case sendMsgCh <- v: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } + }() + + pingTicker := time.NewTicker(time.Second * 45) + defer pingTicker.Stop() + forceSendTimer := time.NewTimer(time.Second) + if !forceSendTimer.Stop() { + <-forceSendTimer.C + } + + batchMsg := make([]any, 0, 64) + for { + select { + case v := <-sendMsgCh: + batchMsg = append(batchMsg, v) + forceSendTimer.Reset(time.Second) + WAIT_MORE: + for { + select { + case v := <-sendMsgCh: + batchMsg = append(batchMsg, v) + case <-time.After(time.Millisecond * 20): + if !forceSendTimer.Stop() { + <-forceSendTimer.C + } + break WAIT_MORE + case <-forceSendTimer.C: + break WAIT_MORE + case <-ctx.Done(): + forceSendTimer.Stop() + return + } + } + if len(batchMsg) == 1 { + if err := conn.WriteJSON(batchMsg[0]); err != nil { + return + } + } else { + if err := conn.WriteJSON(batchMsg); err != nil { + return + } + } + // release objects + for i, _ := range batchMsg { + batchMsg[i] = nil + } + batchMsg = batchMsg[:0] + case <-pingTicker.C: + if err := conn.WriteJSON(Map{ + "type": "ping", + "data": time.Now().UnixMilli(), + }); err != nil { + log.Errorf("[log.io]: Error when sending ping packet: %v", err) + return + } + case <-ctx.Done(): + return + } + } +} + +func (h *Handler) routePprof(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + errorMethodNotAllowed(rw, req, http.MethodGet) + return + } + query := req.URL.Query() + lookup := query.Get("lookup") + p := pprof.Lookup(lookup) + if p == nil { + http.Error(rw, fmt.Sprintf("pprof.Lookup(%q) returned nil", lookup), http.StatusBadRequest) + return + } + view := query.Get("view") + debug, err := strconv.Atoi(query.Get("debug")) + if err != nil { + debug = 1 + } + if debug == 1 { + rw.Header().Set("Content-Type", "text/plain; charset=utf-8") + } else { + rw.Header().Set("Content-Type", "application/octet-stream") + } + if view != "1" { + name := fmt.Sprintf(time.Now().Format("dump-%s-20060102-150405"), lookup) + if debug == 1 { + name += ".txt" + } else { + name += ".dump" + } + rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) + } + rw.WriteHeader(http.StatusOK) + if debug == 1 { + fmt.Fprintf(rw, "version: %s (%s)\n", build.BuildVersion, build.ClusterVersion) + } + p.WriteTo(rw, debug) +} + +func (h *Handler) routeLogFiles(rw http.ResponseWriter, req *http.Request) { + files := log.ListLogs() + type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + } + data := make([]FileInfo, 0, len(files)) + for _, file := range files { + if s, err := os.Stat(filepath.Join(log.BaseDir(), file)); err == nil { + data = append(data, FileInfo{ + Name: file, + Size: s.Size(), + }) + } + } + writeJson(rw, http.StatusOK, Map{ + "files": data, + }) +} + +func (h *Handler) routeLogFile(rw http.ResponseWriter, req *http.Request) { + fileName := req.PathValue("file_name") + query := req.URL.Query() + fd, err := os.Open(filepath.Join(log.BaseDir(), fileName)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + writeJson(rw, http.StatusNotFound, Map{ + "error": "file not exists", + "message": "Cannot find log file", + "path": req.URL.Path, + }) + return + } + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "cannot open file", + "message": err.Error(), + }) + return + } + defer fd.Close() + name := filepath.Base(req.URL.Path) + isGzip := filepath.Ext(name) == ".gz" + if query.Get("no_encrypt") == "1" { + var modTime time.Time + if stat, err := fd.Stat(); err == nil { + modTime = stat.ModTime() + } + rw.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=600") + if isGzip { + rw.Header().Set("Content-Type", "application/octet-stream") + } else { + rw.Header().Set("Content-Type", "text/plain; charset=utf-8") + } + rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) + http.ServeContent(rw, req, name, modTime, fd) + } else { + if !isGzip { + name += ".gz" + } + rw.Header().Set("Content-Type", "application/octet-stream") + rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name+".encrypted")) + h.routeLogFileEncrypted(rw, req, fd, !isGzip) + } +} + +func (h *Handler) routeLogFileEncrypted(rw http.ResponseWriter, req *http.Request, r io.Reader, useGzip bool) { + rw.WriteHeader(http.StatusOK) + if req.Method == http.MethodHead { + return + } + if useGzip { + pr, pw := io.Pipe() + defer pr.Close() + go func(r io.Reader) { + gw := gzip.NewWriter(pw) + if _, err := io.Copy(gw, r); err != nil { + pw.CloseWithError(err) + return + } + if err := gw.Close(); err != nil { + pw.CloseWithError(err) + return + } + pw.Close() + }(r) + r = pr + } + if err := utils.EncryptStream(rw, r, utils.DeveloporPublicKey); err != nil { + log.Errorf("Cannot write encrypted log stream: %v", err) + } +} diff --git a/api/v0/stat.go b/api/v0/stat.go new file mode 100644 index 0000000..125aaf2 --- /dev/null +++ b/api/v0/stat.go @@ -0,0 +1,63 @@ +/** + * OpenBmclAPI (Golang Edition) + * Copyright (C) 2023 Kevin Z + * All rights reserved + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package v0 + +import ( + "net/http" + "time" + + "github.com/LiterMC/go-openbmclapi/internal/build" + "github.com/LiterMC/go-openbmclapi/limited" +) + +func (h *Handler) buildStatRoute(mux *http.ServeMux) { + mux.HandleFunc("GET /ping", h.routePing) + mux.HandleFunc("GET /status", h.routeStatus) + mux.HandleFunc("GET /stat/{name}", h.routeStat) +} + +func (h *Handler) routePing(rw http.ResponseWriter, req *http.Request) { + limited.SetSkipRateLimit(req) + authed := getRequestTokenType(req) == tokenTypeAuth + writeJson(rw, http.StatusOK, Map{ + "version": build.BuildVersion, + "time": time.Now().UnixMilli(), + "authed": authed, + }) +} + +func (h *Handler) routeStatus(rw http.ResponseWriter, req *http.Request) { + limited.SetSkipRateLimit(req) + writeJson(rw, http.StatusOK, h.stats.GetStatus()) +} + +func (h *Handler) routeStat(rw http.ResponseWriter, req *http.Request) { + limited.SetSkipRateLimit(req) + name := req.PathValue("name") + data := h.stats.GetAccessStat(name) + if data == nil { + writeJson(rw, http.StatusNotFound, Map{ + "error": "AccessStatNotFoudn", + "name": name, + }) + return + } + writeJson(rw, http.StatusOK, data) +} diff --git a/api/v0/subscription.go b/api/v0/subscription.go index 0a5f470..cdf30fc 100644 --- a/api/v0/subscription.go +++ b/api/v0/subscription.go @@ -21,14 +21,36 @@ package v0 import ( "net/http" + + "github.com/google/uuid" + + "github.com/LiterMC/go-openbmclapi/api" + "github.com/LiterMC/go-openbmclapi/utils" ) +func (h *Handler) buildSubscriptionRoute(mux *http.ServeMux) { + mux.HandleFunc("GET /subscribeKey", h.routeSubscribeKey) + mux.Handle("/subscribe", permHandle(api.SubscribePerm, &utils.HttpMethodHandler{ + Get: (http.HandlerFunc)(h.routeSubscribeGET), + Post: (http.HandlerFunc)(h.routeSubscribePOST), + Delete: (http.HandlerFunc)(h.routeSubscribeDELETE), + })) + mux.Handle("/subscribe_email", permHandle(api.SubscribePerm, &utils.HttpMethodHandler{ + Get: (http.HandlerFunc)(h.routeSubscribeEmailGET), + Post: (http.HandlerFunc)(h.routeSubscribeEmailPOST), + Patch: (http.HandlerFunc)(h.routeSubscribeEmailPATCH), + Delete: (http.HandlerFunc)(h.routeSubscribeEmailDELETE), + })) + mux.Handle("/webhook", permHandle(api.SubscribePerm, &utils.HttpMethodHandler{ + Get: (http.HandlerFunc)(h.routeWebhookGET), + Post: (http.HandlerFunc)(h.routeWebhookPOST), + Patch: (http.HandlerFunc)(h.routeWebhookPATCH), + Delete: (http.HandlerFunc)(h.routeWebhookDELETE), + })) +} + func (h *Handler) routeSubscribeKey(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - errorMethodNotAllowed(rw, req, http.MethodGet) - return - } - key := h.subManager.GetWebPushKey() + key := h.subscriptions.GetWebPushKey() etag := `"` + utils.AsSha256(key) + `"` rw.Header().Set("ETag", etag) if cachedTag := req.Header.Get("If-None-Match"); cachedTag == etag { @@ -43,9 +65,9 @@ func (h *Handler) routeSubscribeKey(rw http.ResponseWriter, req *http.Request) { func (h *Handler) routeSubscribeGET(rw http.ResponseWriter, req *http.Request) { client := apiGetClientId(req) user := getLoggedUser(req) - record, err := h.subManager.GetSubscribe(user, client) + record, err := h.subscriptions.GetSubscribe(user.Username, client) if err != nil { - if err == database.ErrNotFound { + if err == api.ErrNotFound { writeJson(rw, http.StatusNotFound, Map{ "error": "no subscription was found", }) @@ -66,13 +88,13 @@ func (h *Handler) routeSubscribeGET(rw http.ResponseWriter, req *http.Request) { func (h *Handler) routeSubscribePOST(rw http.ResponseWriter, req *http.Request) { client := apiGetClientId(req) user := getLoggedUser(req) - data, ok := parseRequestBody[database.SubscribeRecord](rw, req, nil) - if !ok { + var data api.SubscribeRecord + if !parseRequestBody(rw, req, &data) { return } - data.User = user + data.User = user.Username data.Client = client - if err := h.subManager.SetSubscribe(data); err != nil { + if err := h.subscriptions.SetSubscribe(data); err != nil { writeJson(rw, http.StatusInternalServerError, Map{ "error": "Database update failed", "message": err.Error(), @@ -85,8 +107,8 @@ func (h *Handler) routeSubscribePOST(rw http.ResponseWriter, req *http.Request) func (h *Handler) routeSubscribeDELETE(rw http.ResponseWriter, req *http.Request) { client := apiGetClientId(req) user := getLoggedUser(req) - if err := h.subManager.RemoveSubscribe(user, client); err != nil { - if err == database.ErrNotFound { + if err := h.subscriptions.RemoveSubscribe(user.Username, client); err != nil { + if err == api.ErrNotFound { writeJson(rw, http.StatusNotFound, Map{ "error": "no subscription was found", }) @@ -104,9 +126,9 @@ func (h *Handler) routeSubscribeDELETE(rw http.ResponseWriter, req *http.Request func (h *Handler) routeSubscribeEmailGET(rw http.ResponseWriter, req *http.Request) { user := getLoggedUser(req) if addr := req.URL.Query().Get("addr"); addr != "" { - record, err := h.subManager.GetEmailSubscription(user, addr) + record, err := h.subscriptions.GetEmailSubscription(user.Username, addr) if err != nil { - if err == database.ErrNotFound { + if err == api.ErrNotFound { writeJson(rw, http.StatusNotFound, Map{ "error": "no email subscription was found", }) @@ -121,8 +143,8 @@ func (h *Handler) routeSubscribeEmailGET(rw http.ResponseWriter, req *http.Reque writeJson(rw, http.StatusOK, record) return } - records := make([]database.EmailSubscriptionRecord, 0, 4) - if err := h.subManager.ForEachUsersEmailSubscription(user, func(rec *database.EmailSubscriptionRecord) error { + records := make([]api.EmailSubscriptionRecord, 0, 4) + if err := h.subscriptions.ForEachUsersEmailSubscription(user.Username, func(rec *api.EmailSubscriptionRecord) error { records = append(records, *rec) return nil }); err != nil { @@ -137,13 +159,13 @@ func (h *Handler) routeSubscribeEmailGET(rw http.ResponseWriter, req *http.Reque func (h *Handler) routeSubscribeEmailPOST(rw http.ResponseWriter, req *http.Request) { user := getLoggedUser(req) - data, ok := parseRequestBody[database.EmailSubscriptionRecord](rw, req, nil) - if !ok { + var data api.EmailSubscriptionRecord + if !parseRequestBody(rw, req, &data) { return } - data.User = user - if err := h.subManager.AddEmailSubscription(data); err != nil { + data.User = user.Username + if err := h.subscriptions.AddEmailSubscription(data); err != nil { writeJson(rw, http.StatusInternalServerError, Map{ "error": "Database update failed", "message": err.Error(), @@ -156,14 +178,14 @@ func (h *Handler) routeSubscribeEmailPOST(rw http.ResponseWriter, req *http.Requ func (h *Handler) routeSubscribeEmailPATCH(rw http.ResponseWriter, req *http.Request) { user := getLoggedUser(req) addr := req.URL.Query().Get("addr") - data, ok := parseRequestBody[database.EmailSubscriptionRecord](rw, req, nil) - if !ok { + var data api.EmailSubscriptionRecord + if !parseRequestBody(rw, req, &data) { return } - data.User = user + data.User = user.Username data.Addr = addr - if err := h.subManager.UpdateEmailSubscription(data); err != nil { - if err == database.ErrNotFound { + if err := h.subscriptions.UpdateEmailSubscription(data); err != nil { + if err == api.ErrNotFound { writeJson(rw, http.StatusNotFound, Map{ "error": "no email subscription was found", }) @@ -181,8 +203,8 @@ func (h *Handler) routeSubscribeEmailPATCH(rw http.ResponseWriter, req *http.Req func (h *Handler) routeSubscribeEmailDELETE(rw http.ResponseWriter, req *http.Request) { user := getLoggedUser(req) addr := req.URL.Query().Get("addr") - if err := h.subManager.RemoveEmailSubscription(user, addr); err != nil { - if err == database.ErrNotFound { + if err := h.subscriptions.RemoveEmailSubscription(user.Username, addr); err != nil { + if err == api.ErrNotFound { writeJson(rw, http.StatusNotFound, Map{ "error": "no email subscription was found", }) @@ -196,3 +218,121 @@ func (h *Handler) routeSubscribeEmailDELETE(rw http.ResponseWriter, req *http.Re } rw.WriteHeader(http.StatusNoContent) } + +func (h *Handler) routeWebhookGET(rw http.ResponseWriter, req *http.Request) { + user := getLoggedUser(req) + if sid := req.URL.Query().Get("id"); sid != "" { + id, err := uuid.Parse(sid) + if err != nil { + writeJson(rw, http.StatusBadRequest, Map{ + "error": "uuid format error", + "message": err.Error(), + }) + return + } + record, err := h.subscriptions.GetWebhook(user.Username, id) + if err != nil { + if err == api.ErrNotFound { + writeJson(rw, http.StatusNotFound, Map{ + "error": "no webhook was found", + }) + return + } + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "database error", + "message": err.Error(), + }) + return + } + writeJson(rw, http.StatusOK, record) + return + } + records := make([]api.WebhookRecord, 0, 4) + if err := h.subscriptions.ForEachUsersWebhook(user.Username, func(rec *api.WebhookRecord) error { + records = append(records, *rec) + return nil + }); err != nil { + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "database error", + "message": err.Error(), + }) + return + } + writeJson(rw, http.StatusOK, records) +} + +func (h *Handler) routeWebhookPOST(rw http.ResponseWriter, req *http.Request) { + user := getLoggedUser(req) + var data api.WebhookRecord + if !parseRequestBody(rw, req, &data) { + return + } + + data.User = user.Username + if err := h.subscriptions.AddWebhook(data); err != nil { + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "Database update failed", + "message": err.Error(), + }) + return + } + rw.WriteHeader(http.StatusCreated) +} + +func (h *Handler) routeWebhookPATCH(rw http.ResponseWriter, req *http.Request) { + user := getLoggedUser(req) + id := req.URL.Query().Get("id") + var data api.WebhookRecord + if !parseRequestBody(rw, req, &data) { + return + } + data.User = user.Username + var err error + if data.Id, err = uuid.Parse(id); err != nil { + writeJson(rw, http.StatusBadRequest, Map{ + "error": "uuid format error", + "message": err.Error(), + }) + return + } + if err := h.subscriptions.UpdateWebhook(data); err != nil { + if err == api.ErrNotFound { + writeJson(rw, http.StatusNotFound, Map{ + "error": "no webhook was found", + }) + return + } + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "database error", + "message": err.Error(), + }) + return + } + rw.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) routeWebhookDELETE(rw http.ResponseWriter, req *http.Request) { + user := getLoggedUser(req) + id, err := uuid.Parse(req.URL.Query().Get("id")) + if err != nil { + writeJson(rw, http.StatusBadRequest, Map{ + "error": "uuid format error", + "message": err.Error(), + }) + return + } + if err := h.subscriptions.RemoveWebhook(user.Username, id); err != nil { + if err == api.ErrNotFound { + writeJson(rw, http.StatusNotFound, Map{ + "error": "no webhook was found", + }) + return + } + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "database error", + "message": err.Error(), + }) + return + } + rw.WriteHeader(http.StatusNoContent) +} diff --git a/config.go b/config.go index 6baa51d..03e3174 100644 --- a/config.go +++ b/config.go @@ -52,7 +52,6 @@ type AdvancedConfig struct { NoGC bool `yaml:"no-gc"` HeavyCheckInterval int `yaml:"heavy-check-interval"` KeepaliveTimeout int `yaml:"keepalive-timeout"` - SkipFirstSync bool `yaml:"skip-first-sync"` SkipSignatureCheck bool `yaml:"skip-signature-check"` NoFastEnable bool `yaml:"no-fast-enable"` WaitBeforeEnable int `yaml:"wait-before-enable"` @@ -193,13 +192,12 @@ type Config struct { PublicHost string `yaml:"public-host"` PublicPort uint16 `yaml:"public-port"` Port uint16 `yaml:"port"` - ClusterId string `yaml:"cluster-id"` - ClusterSecret string `yaml:"cluster-secret"` SyncInterval int `yaml:"sync-interval"` OnlyGcWhenStart bool `yaml:"only-gc-when-start"` DownloadMaxConn int `yaml:"download-max-conn"` MaxReconnectCount int `yaml:"max-reconnect-count"` + Clusters map[string]ClusterItem `yaml:"clusters"` Certificates []CertificateConfig `yaml:"certificates"` Tunneler TunnelConfig `yaml:"tunneler"` Cache CacheConfig `yaml:"cache"` @@ -223,111 +221,107 @@ func (cfg *Config) applyWebManifest(manifest map[string]any) { } } -var defaultConfig = Config{ - LogSlots: 7, - NoAccessLog: false, - AccessLogSlots: 16, - Byoc: false, - TrustedXForwardedFor: false, - PublicHost: "", - PublicPort: 0, - Port: 4000, - ClusterId: "${CLUSTER_ID}", - ClusterSecret: "${CLUSTER_SECRET}", - SyncInterval: 10, - OnlyGcWhenStart: false, - DownloadMaxConn: 16, - MaxReconnectCount: 10, - - Certificates: []CertificateConfig{ - { - Cert: "/path/to/cert.pem", - Key: "/path/to/key.pem", +func getDefaultConfig() *Config { + return &Config{ + LogSlots: 7, + NoAccessLog: false, + AccessLogSlots: 16, + Byoc: false, + TrustedXForwardedFor: false, + PublicHost: "", + PublicPort: 0, + Port: 4000, + SyncInterval: 10, + OnlyGcWhenStart: false, + DownloadMaxConn: 16, + MaxReconnectCount: 10, + + Clusters: map[string]ClusterItem{}, + + Certificates: []CertificateConfig{}, + + Tunneler: TunnelConfig{ + Enable: false, + TunnelProg: "./path/to/tunnel/program", + OutputRegex: `\bNATedAddr\s+(?P[0-9.]+|\[[0-9a-f:]+\]):(?P\d+)$`, + TunnelTimeout: 0, }, - }, - - Tunneler: TunnelConfig{ - Enable: false, - TunnelProg: "./path/to/tunnel/program", - OutputRegex: `\bNATedAddr\s+(?P[0-9.]+|\[[0-9a-f:]+\]):(?P\d+)$`, - TunnelTimeout: 0, - }, - - Cache: CacheConfig{ - Type: "inmem", - newCache: func() cache.Cache { return cache.NewInMemCache() }, - }, - - ServeLimit: ServeLimitConfig{ - Enable: false, - MaxConn: 16384, - UploadRate: 1024 * 12, // 12MB - }, - - RateLimit: APIRateLimitConfig{ - Anonymous: limited.RateLimit{ - PerMin: 10, - PerHour: 120, + + Cache: CacheConfig{ + Type: "inmem", + newCache: func() cache.Cache { return cache.NewInMemCache() }, }, - Logged: limited.RateLimit{ - PerMin: 120, - PerHour: 6000, + + ServeLimit: ServeLimitConfig{ + Enable: false, + MaxConn: 16384, + UploadRate: 1024 * 12, // 12MB }, - }, - - Notification: NotificationConfig{ - EnableEmail: false, - EmailSMTP: "smtp.example.com:25", - EmailSMTPEncryption: "tls", - EmailSender: "noreply@example.com", - EmailSenderPassword: "example-password", - EnableWebhook: true, - }, - - Dashboard: DashboardConfig{ - Enable: true, - PwaName: "GoOpenBmclApi Dashboard", - PwaShortName: "GOBA Dash", - PwaDesc: "Go-Openbmclapi Internal Dashboard", - NotifySubject: "mailto:user@example.com", - }, - - GithubAPI: GithubAPIConfig{ - UpdateCheckInterval: (utils.YAMLDuration)(time.Hour), - }, - - Database: DatabaseConfig{ - Driver: "sqlite", - DSN: filepath.Join("data", "files.db"), - }, - - Hijack: HijackConfig{ - Enable: false, - RequireAuth: false, - EnableLocalCache: false, - LocalCachePath: "hijack_cache", - AuthUsers: []UserItem{ - { - Username: "example-username", - Password: "example-password", + + RateLimit: APIRateLimitConfig{ + Anonymous: limited.RateLimit{ + PerMin: 10, + PerHour: 120, + }, + Logged: limited.RateLimit{ + PerMin: 120, + PerHour: 6000, + }, + }, + + Notification: NotificationConfig{ + EnableEmail: false, + EmailSMTP: "smtp.example.com:25", + EmailSMTPEncryption: "tls", + EmailSender: "noreply@example.com", + EmailSenderPassword: "example-password", + EnableWebhook: true, + }, + + Dashboard: DashboardConfig{ + Enable: true, + PwaName: "GoOpenBmclApi Dashboard", + PwaShortName: "GOBA Dash", + PwaDesc: "Go-Openbmclapi Internal Dashboard", + NotifySubject: "mailto:user@example.com", + }, + + GithubAPI: GithubAPIConfig{ + UpdateCheckInterval: (utils.YAMLDuration)(time.Hour), + }, + + Database: DatabaseConfig{ + Driver: "sqlite", + DSN: filepath.Join("data", "files.db"), + }, + + Hijack: HijackConfig{ + Enable: false, + RequireAuth: false, + EnableLocalCache: false, + LocalCachePath: "hijack_cache", + AuthUsers: []UserItem{ + { + Username: "example-username", + Password: "example-password", + }, }, }, - }, - - Storages: nil, - - WebdavUsers: map[string]*storage.WebDavUser{}, - - Advanced: AdvancedConfig{ - DebugLog: false, - NoHeavyCheck: false, - NoGC: false, - HeavyCheckInterval: 120, - KeepaliveTimeout: 10, - SkipFirstSync: false, - NoFastEnable: false, - WaitBeforeEnable: 0, - }, + + Storages: nil, + + WebdavUsers: map[string]*storage.WebDavUser{}, + + Advanced: AdvancedConfig{ + DebugLog: false, + NoHeavyCheck: false, + NoGC: false, + HeavyCheckInterval: 120, + KeepaliveTimeout: 10, + NoFastEnable: false, + WaitBeforeEnable: 0, + }, + } } func migrateConfig(data []byte, config *Config) { @@ -345,12 +339,24 @@ func migrateConfig(data []byte, config *Config) { if v, ok := oldConfig["keepalive-timeout"].(int); ok { config.Advanced.KeepaliveTimeout = v } + if oldConfig["clusters"].(map[string]any) == nil { + id, ok1 := oldConfig["cluster-id"].(string) + secret, ok2 := oldConfig["cluster-secret"].(string) + if ok1 && ok2 { + config.Clusters = map[string]ClusterItem{ + "main": { + Id: id, + Secret: secret, + }, + } + } + } } -func readConfig() (config Config) { +func readConfig() (config Config, err error) { const configPath = "config.yaml" - config = defaultConfig + config = getDefaultConfig() data, err := os.ReadFile(configPath) notexists := false @@ -362,11 +368,27 @@ func readConfig() (config Config) { log.Error(Tr("error.config.not.exists")) notexists = true } else { - migrateConfig(data, &config) - if err = yaml.Unmarshal(data, &config); err != nil { + migrateConfig(data, config) + if err = yaml.Unmarshal(data, config); err != nil { log.Errorf(Tr("error.config.parse.failed"), err) osExit(CodeClientError) } + if len(config.Clusters) == 0 { + config.Clusters = map[string]ClusterItem{ + "main": { + Id: "${CLUSTER_ID}", + Secret: "${CLUSTER_SECRET}", + }, + } + } + if len(config.Certificates) == 0 { + config.Certificates = []CertificateConfig{ + { + Cert: "/path/to/cert.pem", + Key: "/path/to/key.pem", + }, + } + } if len(config.Storages) == 0 { config.Storages = []storage.StorageOption{ { @@ -396,9 +418,15 @@ func readConfig() (config Config) { } if j, ok := ids[s.Id]; ok { log.Errorf("Duplicated storage id %q at [%d] and [%d], please edit the config.", s.Id, i, j) - osExit(CodeClientError) + os.Exit(CodeClientError) } ids[s.Id] = i + if s.Cluster != "" && s.Cluster != "-" { + if _, ok := config.Clusters[s.Cluster]; !ok { + log.Errorf("Storage %q is trying to connect to a not exists cluster %q.", s.Id, s.Cluster) + os.Exit(CodeClientError) + } + } } } @@ -409,7 +437,7 @@ func readConfig() (config Config) { user, ok := config.WebdavUsers[alias] if !ok { log.Errorf(Tr("error.config.alias.user.not.exists"), alias) - osExit(CodeClientError) + os.Exit(CodeClientError) } opt.AliasUser = user var end *url.URL @@ -436,45 +464,14 @@ func readConfig() (config Config) { encoder.SetIndent(2) if err = encoder.Encode(config); err != nil { log.Errorf(Tr("error.config.encode.failed"), err) - osExit(CodeClientError) + os.Exit(CodeClientError) } if err = os.WriteFile(configPath, buf.Bytes(), 0600); err != nil { log.Errorf(Tr("error.config.write.failed"), err) - osExit(CodeClientError) + os.Exit(CodeClientError) } if notexists { log.Error(Tr("error.config.created")) - osExit(0xff) - } - - if os.Getenv("DEBUG") == "true" { - config.Advanced.DebugLog = true - } - if v := os.Getenv("CLUSTER_IP"); v != "" { - config.PublicHost = v - } - if v := os.Getenv("CLUSTER_PORT"); v != "" { - if n, err := strconv.Atoi(v); err != nil { - log.Errorf("Cannot parse CLUSTER_PORT %q: %v", v, err) - } else { - config.Port = (uint16)(n) - } - } - if v := os.Getenv("CLUSTER_PUBLIC_PORT"); v != "" { - if n, err := strconv.Atoi(v); err != nil { - log.Errorf("Cannot parse CLUSTER_PUBLIC_PORT %q: %v", v, err) - } else { - config.PublicPort = (uint16)(n) - } - } - if v := os.Getenv("CLUSTER_ID"); v != "" { - config.ClusterId = v - } - if v := os.Getenv("CLUSTER_SECRET"); v != "" { - config.ClusterSecret = v - } - if byoc := os.Getenv("CLUSTER_BYOC"); byoc != "" { - config.Byoc = byoc == "true" } return } diff --git a/handler.go b/handler.go index 7aafcca..1106d80 100644 --- a/handler.go +++ b/handler.go @@ -52,22 +52,6 @@ func init() { }) } -const ( - RealAddrCtxKey = "handle.real.addr" - RealPathCtxKey = "handle.real.path" - AccessLogExtraCtxKey = "handle.access.extra" -) - -func GetRequestRealPath(req *http.Request) string { - return req.Context().Value(RealPathCtxKey).(string) -} - -func SetAccessInfo(req *http.Request, key string, value any) { - if info, ok := req.Context().Value(AccessLogExtraCtxKey).(map[string]any); ok { - info[key] = value - } -} - type preAccessRecord struct { Type string `json:"type"` Time time.Time `json:"time"`