Bumps the go-modules-updates group with 11 updates in the /backend directory: | Package | From | To | | --- | --- | --- | | [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) | `1.10.2` | `1.11.0` | | [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) | `2.15.0` | `2.20.0` | | [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) | `5.2.1` | `5.2.3` | | [github.com/go-chi/cors](https://github.com/go-chi/cors) | `1.2.1` | `1.2.2` | | [github.com/go-pkgz/jrpc](https://github.com/go-pkgz/jrpc) | `0.3.1` | `0.4.0` | | [github.com/go-pkgz/lgr](https://github.com/go-pkgz/lgr) | `0.12.0` | `0.12.1` | | [github.com/go-pkgz/rest](https://github.com/go-pkgz/rest) | `1.20.3` | `1.20.4` | | [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) | `5.2.2` | `5.3.0` | | [github.com/stretchr/testify](https://github.com/stretchr/testify) | `1.10.0` | `1.11.1` | | [go.etcd.io/bbolt](https://github.com/etcd-io/bbolt) | `1.4.0` | `1.4.3` | | [golang.org/x/image](https://github.com/golang/image) | `0.26.0` | `0.33.0` | Updates `github.com/PuerkitoBio/goquery` from 1.10.2 to 1.11.0 - [Release notes](https://github.com/PuerkitoBio/goquery/releases) - [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.2...v1.11.0) Updates `github.com/alecthomas/chroma/v2` from 2.15.0 to 2.20.0 - [Release notes](https://github.com/alecthomas/chroma/releases) - [Commits](https://github.com/alecthomas/chroma/compare/v2.15.0...v2.20.0) Updates `github.com/go-chi/chi/v5` from 5.2.1 to 5.2.3 - [Release notes](https://github.com/go-chi/chi/releases) - [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md) - [Commits](https://github.com/go-chi/chi/compare/v5.2.1...v5.2.3) Updates `github.com/go-chi/cors` from 1.2.1 to 1.2.2 - [Release notes](https://github.com/go-chi/cors/releases) - [Commits](https://github.com/go-chi/cors/compare/v1.2.1...v1.2.2) Updates `github.com/go-pkgz/jrpc` from 0.3.1 to 0.4.0 - [Release notes](https://github.com/go-pkgz/jrpc/releases) - [Commits](https://github.com/go-pkgz/jrpc/compare/v0.3.1...v0.4.0) Updates `github.com/go-pkgz/lgr` from 0.12.0 to 0.12.1 - [Release notes](https://github.com/go-pkgz/lgr/releases) - [Commits](https://github.com/go-pkgz/lgr/compare/v0.12.0...v0.12.1) Updates `github.com/go-pkgz/rest` from 1.20.3 to 1.20.4 - [Release notes](https://github.com/go-pkgz/rest/releases) - [Commits](https://github.com/go-pkgz/rest/compare/v1.20.3...v1.20.4) Updates `github.com/golang-jwt/jwt/v5` from 5.2.2 to 5.3.0 - [Release notes](https://github.com/golang-jwt/jwt/releases) - [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.2...v5.3.0) Updates `github.com/stretchr/testify` from 1.10.0 to 1.11.1 - [Release notes](https://github.com/stretchr/testify/releases) - [Commits](https://github.com/stretchr/testify/compare/v1.10.0...v1.11.1) Updates `go.etcd.io/bbolt` from 1.4.0 to 1.4.3 - [Release notes](https://github.com/etcd-io/bbolt/releases) - [Commits](https://github.com/etcd-io/bbolt/compare/v1.4.0...v1.4.3) Updates `golang.org/x/image` from 0.26.0 to 0.33.0 - [Commits](https://github.com/golang/image/compare/v0.26.0...v0.33.0) --- updated-dependencies: - dependency-name: github.com/PuerkitoBio/goquery dependency-version: 1.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-modules-updates - dependency-name: github.com/alecthomas/chroma/v2 dependency-version: 2.20.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-modules-updates - dependency-name: github.com/go-chi/chi/v5 dependency-version: 5.2.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: go-modules-updates - dependency-name: github.com/go-chi/cors dependency-version: 1.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: go-modules-updates - dependency-name: github.com/go-pkgz/jrpc dependency-version: 0.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-modules-updates - dependency-name: github.com/go-pkgz/lgr dependency-version: 0.12.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: go-modules-updates - dependency-name: github.com/go-pkgz/rest dependency-version: 1.20.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: go-modules-updates - dependency-name: github.com/golang-jwt/jwt/v5 dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-modules-updates - dependency-name: github.com/stretchr/testify dependency-version: 1.11.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-modules-updates - dependency-name: go.etcd.io/bbolt dependency-version: 1.4.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: go-modules-updates - dependency-name: golang.org/x/image dependency-version: 0.33.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-modules-updates ... Signed-off-by: dependabot[bot] <support@github.com>
302 lines
8.6 KiB
Go
302 lines
8.6 KiB
Go
package jrpc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-pkgz/rest"
|
|
"github.com/go-pkgz/rest/logger"
|
|
"github.com/go-pkgz/routegroup"
|
|
)
|
|
|
|
// Server is json-rpc server with an optional basic auth
|
|
type Server struct {
|
|
api string // url path, i.e. "/command" or "/rpc" etc., required
|
|
|
|
authUser string // basic auth user name, should match Client.AuthUser, optional
|
|
authPasswd string // basic auth password, should match Client.AuthPasswd, optional
|
|
customMiddlewares middlewares // list of custom middlewares, should match array of http.Handler func, optional
|
|
|
|
signature signaturePayload // add server signature to server response headers appName, author, version), disable by default
|
|
|
|
timeouts Timeouts // values and timeouts for the server
|
|
limits limits // values and limits for the server
|
|
logger L // logger, if nil will default to NoOpLogger
|
|
|
|
funcs struct {
|
|
m map[string]ServerFn
|
|
once sync.Once
|
|
}
|
|
|
|
httpServer struct {
|
|
*http.Server
|
|
sync.Mutex
|
|
}
|
|
}
|
|
|
|
// Timeouts includes values and timeouts for the server
|
|
type Timeouts struct {
|
|
ReadHeaderTimeout time.Duration // amount of time allowed to read request headers
|
|
WriteTimeout time.Duration // max duration before timing out writes of the response
|
|
IdleTimeout time.Duration // max amount of time to wait for the next request when keep-alive enabled
|
|
CallTimeout time.Duration // max time allowed to finish the call, optional
|
|
}
|
|
|
|
// limits includes limits values for a server
|
|
type limits struct {
|
|
serverThrottle int // max number of parallel calls for the server
|
|
clientLimit float64 // max number of call/sec per client
|
|
}
|
|
|
|
// signaturePayload is the server application info which add to server response headers
|
|
type signaturePayload struct {
|
|
appName string // server version, injected from main and used for informational headers only
|
|
author string // plugin name, injected from main and used for informational headers only
|
|
version string // custom application server number
|
|
}
|
|
|
|
// ServerFn handler registered for each method with Add or Group.
|
|
// Implementations provided by consumer and defines response logic.
|
|
type ServerFn func(id uint64, params json.RawMessage) Response
|
|
|
|
// middlewares contains list of custom middlewares which user can attach to server
|
|
type middlewares []func(http.Handler) http.Handler
|
|
|
|
// NewServer the main constructor of server instance which pass API url and another options values
|
|
func NewServer(api string, options ...Option) *Server {
|
|
|
|
srv := &Server{
|
|
api: api,
|
|
timeouts: getDefaultTimeouts(),
|
|
logger: NoOpLogger,
|
|
}
|
|
|
|
for _, opt := range options {
|
|
opt(srv)
|
|
}
|
|
return srv
|
|
}
|
|
|
|
// Run http server on given port
|
|
func (s *Server) Run(port int) error {
|
|
|
|
if s.authUser == "" || s.authPasswd == "" {
|
|
s.logger.Logf("[WARN] extension server runs without auth")
|
|
}
|
|
|
|
if s.funcs.m == nil && len(s.funcs.m) == 0 {
|
|
return fmt.Errorf("nothing mapped for dispatch, Add has to be called prior to Run")
|
|
}
|
|
|
|
router := routegroup.New(http.NewServeMux())
|
|
|
|
if s.limits.serverThrottle > 0 {
|
|
router.Use(rest.Throttle(int64(s.limits.serverThrottle)))
|
|
}
|
|
|
|
router.Use(rest.RealIP, rest.Ping, rest.Recoverer(s.logger))
|
|
|
|
if s.signature.version != "" || s.signature.author != "" || s.signature.appName != "" {
|
|
router.Use(rest.AppInfo(s.signature.appName, s.signature.author, s.signature.version))
|
|
}
|
|
|
|
if s.timeouts.CallTimeout > 0 {
|
|
router.Use(timeout(s.timeouts.CallTimeout))
|
|
}
|
|
|
|
logInfoWithBody := logger.New(logger.Log(s.logger), logger.WithBody, logger.Prefix("[DEBUG]")).Handler
|
|
router.Use(logInfoWithBody)
|
|
|
|
if s.limits.clientLimit > 0 {
|
|
router.Use(rateLimitByIP(s.limits.clientLimit))
|
|
}
|
|
|
|
router.Use(rest.NoCache)
|
|
router.Use(s.basicAuth)
|
|
for _, mw := range s.customMiddlewares {
|
|
router.Use(mw)
|
|
}
|
|
router.HandleFunc("POST "+s.api, s.handler)
|
|
|
|
s.httpServer.Lock()
|
|
s.httpServer.Server = &http.Server{
|
|
Addr: fmt.Sprintf(":%d", port),
|
|
Handler: router,
|
|
ReadHeaderTimeout: s.timeouts.ReadHeaderTimeout,
|
|
WriteTimeout: s.timeouts.WriteTimeout,
|
|
IdleTimeout: s.timeouts.IdleTimeout,
|
|
}
|
|
s.httpServer.Unlock()
|
|
|
|
s.logger.Logf("[INFO] listen on %d", port)
|
|
return s.httpServer.ListenAndServe()
|
|
}
|
|
|
|
// Shutdown http server
|
|
func (s *Server) Shutdown() error {
|
|
s.httpServer.Lock()
|
|
defer s.httpServer.Unlock()
|
|
if s.httpServer.Server == nil {
|
|
return fmt.Errorf("http server is not running")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
return s.httpServer.Shutdown(ctx)
|
|
}
|
|
|
|
// Add method handler. Handler will be called on matching method (Request.Method)
|
|
func (s *Server) Add(method string, fn ServerFn) {
|
|
s.httpServer.Lock()
|
|
defer s.httpServer.Unlock()
|
|
if s.httpServer.Server != nil {
|
|
s.logger.Logf("[WARN] ignored method %s, can't be added to activated server", method)
|
|
return
|
|
}
|
|
|
|
s.funcs.once.Do(func() {
|
|
s.funcs.m = map[string]ServerFn{}
|
|
})
|
|
|
|
s.funcs.m[method] = fn
|
|
s.logger.Logf("[INFO] add handler for %s", method)
|
|
}
|
|
|
|
// HandlersGroup alias for map of handlers
|
|
type HandlersGroup map[string]ServerFn
|
|
|
|
// Group of handlers with common prefix, match on group.method
|
|
func (s *Server) Group(prefix string, m HandlersGroup) {
|
|
for k, v := range m {
|
|
s.Add(prefix+"."+k, v)
|
|
}
|
|
}
|
|
|
|
// handler is http handler multiplexing calls by req.Method
|
|
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
|
|
req := struct {
|
|
ID uint64 `json:"id"`
|
|
Method string `json:"method"`
|
|
Params *json.RawMessage `json:"params"`
|
|
}{}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
rest.SendErrorJSON(w, r, s.logger, http.StatusBadRequest, err, req.Method)
|
|
return
|
|
}
|
|
fn, ok := s.funcs.m[req.Method]
|
|
if !ok {
|
|
rest.SendErrorJSON(w, r, s.logger, http.StatusNotImplemented, fmt.Errorf("unsupported method"), req.Method)
|
|
return
|
|
|
|
}
|
|
|
|
params := json.RawMessage{}
|
|
if req.Params != nil {
|
|
params = *req.Params
|
|
}
|
|
|
|
rest.RenderJSON(w, fn(req.ID, params))
|
|
}
|
|
|
|
// basicAuth middleware. enabled only if both AuthUser and AuthPasswd defined.
|
|
func (s *Server) basicAuth(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
if s.authUser == "" || s.authPasswd == "" {
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
user, pass, ok := r.BasicAuth()
|
|
if user != s.authUser || pass != s.authPasswd || !ok {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func getDefaultTimeouts() Timeouts {
|
|
return Timeouts{
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
WriteTimeout: 10 * time.Second,
|
|
IdleTimeout: 5 * time.Second,
|
|
}
|
|
}
|
|
|
|
// timeout middleware cancels context after given duration
|
|
func timeout(dt time.Duration) func(http.Handler) http.Handler {
|
|
return func(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), dt)
|
|
defer cancel()
|
|
h.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// L defined logger interface used for an optional rest logging
|
|
type L interface {
|
|
Logf(format string, args ...interface{})
|
|
}
|
|
|
|
// LoggerFunc type is an adapter to allow the use of ordinary functions as Logger.
|
|
type LoggerFunc func(format string, args ...interface{})
|
|
|
|
// Logf calls f(id)
|
|
func (f LoggerFunc) Logf(format string, args ...interface{}) { f(format, args...) }
|
|
|
|
// NoOpLogger logger does nothing
|
|
var NoOpLogger = LoggerFunc(func(format string, args ...interface{}) {}) //nolint
|
|
|
|
// rateLimitByIP returns middleware that limits requests per second for each client IP.
|
|
// Uses X-Real-IP header (set by rest.RealIP middleware) for client identification.
|
|
func rateLimitByIP(reqPerSec float64) func(http.Handler) http.Handler {
|
|
type clientState struct {
|
|
sync.Mutex
|
|
tokens float64
|
|
lastCheck time.Time
|
|
}
|
|
var (
|
|
clients sync.Map
|
|
maxReq = reqPerSec
|
|
)
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ip := r.Header.Get("X-Real-IP")
|
|
if ip == "" {
|
|
ip = r.RemoteAddr
|
|
}
|
|
|
|
now := time.Now()
|
|
val, _ := clients.LoadOrStore(ip, &clientState{tokens: maxReq, lastCheck: now})
|
|
state := val.(*clientState)
|
|
|
|
state.Lock()
|
|
// token bucket algorithm: refill tokens based on elapsed time
|
|
elapsed := now.Sub(state.lastCheck).Seconds()
|
|
state.tokens += elapsed * reqPerSec
|
|
if state.tokens > maxReq {
|
|
state.tokens = maxReq
|
|
}
|
|
state.lastCheck = now
|
|
|
|
if state.tokens < 1 {
|
|
state.Unlock()
|
|
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
state.tokens--
|
|
state.Unlock()
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|