diff --git a/Makefile b/Makefile index 9fe30e5f..abd9b60c 100755 --- a/Makefile +++ b/Makefile @@ -134,4 +134,13 @@ cloc: cloc --include-lang=Go --not-match-f '_test.go$$' . push-github: - git push origin $(shell git rev-parse --abbrev-ref HEAD) \ No newline at end of file + git push origin $(shell git rev-parse --abbrev-ref HEAD) + +gen-swagger: + swag init --parseDependency --parseInternal -g handler.go -d internal/api -o internal/api/v1/docs + python3 scripts/fix-swagger-json.py + +gen-api-types: gen-swagger + # --disable-throw-on-error + pnpx swagger-typescript-api generate --sort-types --generate-union-enums --axios --add-readonly --route-types \ + --responses -o ../godoxy-frontend/src/lib -n api.ts -p internal/api/v1/docs/swagger.json \ No newline at end of file diff --git a/agent/pkg/agent/new_agent.go b/agent/pkg/agent/new_agent.go index 1664ef5f..ad8de0bc 100644 --- a/agent/pkg/agent/new_agent.go +++ b/agent/pkg/agent/new_agent.go @@ -1,6 +1,8 @@ package agent import ( + "crypto/aes" + "crypto/cipher" "crypto/rand" "crypto/tls" "crypto/x509" @@ -8,6 +10,7 @@ import ( "encoding/base64" "encoding/pem" "errors" + "io" "math/big" "strings" "time" @@ -74,6 +77,62 @@ func (p *PEMPair) Load(data string) (err error) { return nil } +func (p *PEMPair) Encrypt(encKey []byte) (PEMPair, error) { + cert, err := encrypt(p.Cert, encKey) + if err != nil { + return PEMPair{}, err + } + key, err := encrypt(p.Key, encKey) + if err != nil { + return PEMPair{}, err + } + return PEMPair{Cert: cert, Key: key}, nil +} + +func (p *PEMPair) Decrypt(encKey []byte) (PEMPair, error) { + cert, err := decrypt(p.Cert, encKey) + if err != nil { + return PEMPair{}, err + } + key, err := decrypt(p.Key, encKey) + if err != nil { + return PEMPair{}, err + } + return PEMPair{Cert: cert, Key: key}, nil +} + +func encrypt(data []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, data, nil), nil +} + +func decrypt(data []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := data[:gcm.NonceSize()] + ciphertext := data[gcm.NonceSize():] + return gcm.Open(nil, nonce, ciphertext, nil) +} + func (p *PEMPair) ToTLSCert() (*tls.Certificate, error) { cert, err := tls.X509KeyPair(p.Cert, p.Key) return &cert, err diff --git a/agent/pkg/agent/new_agent_test.go b/agent/pkg/agent/new_agent_test.go index 03fb26a0..14a34b78 100644 --- a/agent/pkg/agent/new_agent_test.go +++ b/agent/pkg/agent/new_agent_test.go @@ -1,6 +1,7 @@ package agent import ( + "crypto/rand" "crypto/tls" "crypto/x509" "fmt" @@ -89,3 +90,23 @@ func TestServerClient(t *testing.T) { require.NoError(t, err) require.Equal(t, resp.StatusCode, http.StatusOK) } + +func TestPEMPairEncryptDecrypt(t *testing.T) { + encKey := make([]byte, 32) + _, err := rand.Read(encKey) + require.NoError(t, err) + + ca, _, _, err := NewAgent() + require.NoError(t, err) + + encCA, err := ca.Encrypt(encKey) + require.NoError(t, err) + require.NotNil(t, encCA) + + decCA, err := encCA.Decrypt(encKey) + require.NoError(t, err) + require.NotNil(t, decCA) + + require.Equal(t, string(ca.Cert), string(decCA.Cert)) + require.Equal(t, string(ca.Key), string(decCA.Key)) +} diff --git a/agent/pkg/handler/check_health.go b/agent/pkg/handler/check_health.go index 45e3b6ed..736e3d5e 100644 --- a/agent/pkg/handler/check_health.go +++ b/agent/pkg/handler/check_health.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/internal/watcher/health/monitor" ) @@ -22,7 +23,7 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) { return } - var result *health.HealthCheckResult + var result *types.HealthCheckResult var err error switch scheme { case "fileserver": @@ -32,7 +33,7 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) { return } _, err := os.Stat(path) - result = &health.HealthCheckResult{Healthy: err == nil} + result = &types.HealthCheckResult{Healthy: err == nil} if err != nil { result.Detail = err.Error() } diff --git a/agent/pkg/handler/check_health_test.go b/agent/pkg/handler/check_health_test.go index 2fc023f4..07d72871 100644 --- a/agent/pkg/handler/check_health_test.go +++ b/agent/pkg/handler/check_health_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/yusing/go-proxy/agent/pkg/agent" "github.com/yusing/go-proxy/agent/pkg/handler" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) func TestCheckHealthHTTP(t *testing.T) { @@ -81,7 +81,7 @@ func TestCheckHealthHTTP(t *testing.T) { require.Equal(t, recorder.Code, tt.expectedStatus) if tt.expectedStatus == http.StatusOK { - var result health.HealthCheckResult + var result types.HealthCheckResult require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result)) require.Equal(t, result.Healthy, tt.expectedHealthy) } @@ -125,7 +125,7 @@ func TestCheckHealthFileServer(t *testing.T) { require.Equal(t, recorder.Code, tt.expectedStatus) - var result health.HealthCheckResult + var result types.HealthCheckResult require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result)) require.Equal(t, result.Healthy, tt.expectedHealthy) require.Equal(t, result.Detail, tt.expectedDetail) @@ -217,7 +217,7 @@ func TestCheckHealthTCPUDP(t *testing.T) { require.Equal(t, recorder.Code, tt.expectedStatus) if tt.expectedStatus == http.StatusOK { - var result health.HealthCheckResult + var result types.HealthCheckResult require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result)) require.Equal(t, result.Healthy, tt.expectedHealthy) } diff --git a/agent/pkg/handler/handler.go b/agent/pkg/handler/handler.go index 18356164..9459c948 100644 --- a/agent/pkg/handler/handler.go +++ b/agent/pkg/handler/handler.go @@ -25,7 +25,9 @@ func NewAgentHandler() http.Handler { mux := ServeMux{http.NewServeMux()} mux.HandleFunc(agent.EndpointProxyHTTP+"/{path...}", ProxyHTTP) - mux.HandleEndpoint("GET", agent.EndpointVersion, pkg.GetVersionHTTPHandler()) + mux.HandleEndpoint("GET", agent.EndpointVersion, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, pkg.GetVersion()) + }) mux.HandleEndpoint("GET", agent.EndpointName, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, env.AgentName) }) diff --git a/internal/api/handler.go b/internal/api/handler.go index eb25b37f..6b287127 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -1,111 +1,155 @@ package api import ( - "fmt" "net/http" - v1 "github.com/yusing/go-proxy/internal/api/v1" - "github.com/yusing/go-proxy/internal/api/v1/certapi" - "github.com/yusing/go-proxy/internal/api/v1/dockerapi" - "github.com/yusing/go-proxy/internal/api/v1/favicon" + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + apitypes "github.com/yusing/go-proxy/internal/api/types" + apiV1 "github.com/yusing/go-proxy/internal/api/v1" + agentApi "github.com/yusing/go-proxy/internal/api/v1/agent" + authApi "github.com/yusing/go-proxy/internal/api/v1/auth" + certApi "github.com/yusing/go-proxy/internal/api/v1/cert" + dockerApi "github.com/yusing/go-proxy/internal/api/v1/docker" + "github.com/yusing/go-proxy/internal/api/v1/docs" + fileApi "github.com/yusing/go-proxy/internal/api/v1/file" + homepageApi "github.com/yusing/go-proxy/internal/api/v1/homepage" + metricsApi "github.com/yusing/go-proxy/internal/api/v1/metrics" + routeApi "github.com/yusing/go-proxy/internal/api/v1/route" "github.com/yusing/go-proxy/internal/auth" - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/logging/memlogger" - "github.com/yusing/go-proxy/internal/metrics/uptime" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" - "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" - "github.com/yusing/go-proxy/internal/utils/strutils" - "github.com/yusing/go-proxy/pkg" ) -type ( - ServeMux struct { - *http.ServeMux - cfg config.ConfigInstance +func NewHandler() *gin.Engine { + gin.SetMode("release") + r := gin.New() + r.Use(ErrorHandler()) + r.Use(ErrorLoggingMiddleware()) + + docs.SwaggerInfo.Title = "GoDoxy API" + docs.SwaggerInfo.BasePath = "/api/v1" + + v1Auth := r.Group("/api/v1/auth") + { + v1Auth.HEAD("/check", authApi.Check) + v1Auth.POST("/login", authApi.Login) + v1Auth.POST("/callback", authApi.Callback) + v1Auth.POST("/logout", authApi.Logout) } - WithCfgHandler = func(config.ConfigInstance, http.ResponseWriter, *http.Request) -) -func (mux ServeMux) HandleFunc(methods, endpoint string, h any, requireAuth ...bool) { - var handler http.HandlerFunc - switch h := h.(type) { - case func(http.ResponseWriter, *http.Request): - handler = h - case http.Handler: - handler = h.ServeHTTP - case WithCfgHandler: - handler = func(w http.ResponseWriter, r *http.Request) { - h(mux.cfg, w, r) + v1Swagger := r.Group("/api/v1/swagger") + { + v1Swagger.GET("/:any", func(c *gin.Context) { + c.Redirect(http.StatusTemporaryRedirect, "/api/v1/swagger/index.html") + }) + v1Swagger.GET("/:any/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + } + + v1 := r.Group("/api/v1") + v1.Use(AuthMiddleware()) + { + v1.GET("/favicon", apiV1.FavIcon) + v1.GET("/health", apiV1.Health) + v1.GET("/icons", apiV1.Icons) + v1.POST("/reload", apiV1.Reload) + v1.GET("/stats", apiV1.Stats) + v1.GET("/version", apiV1.Version) + + route := v1.Group("/route") + { + route.GET("/list", routeApi.Routes) + route.GET("/:which", routeApi.Route) + route.GET("/providers", routeApi.Providers) + route.GET("/by_provider", routeApi.ByProvider) + } + + file := v1.Group("/file") + { + file.GET("/list", fileApi.List) + file.GET("/content", fileApi.Get) + file.PUT("/content", fileApi.Set) + file.POST("/content", fileApi.Set) + file.POST("/validate", fileApi.Validate) + } + + homepage := v1.Group("/homepage") + { + homepage.GET("/categories", homepageApi.Categories) + homepage.GET("/items", homepageApi.Items) + homepage.POST("/set", homepageApi.Set) + } + + cert := v1.Group("/cert") + { + cert.GET("/info", certApi.Info) + cert.POST("/renew", certApi.Renew) + } + + agent := v1.Group("/agent") + { + agent.GET("/list", agentApi.List) + agent.POST("/create", agentApi.Create) + agent.POST("/verify", agentApi.Verify) + } + + metrics := v1.Group("/metrics") + { + metrics.GET("/system_info", metricsApi.SystemInfo) + metrics.GET("/uptime", metricsApi.Uptime) + } + + docker := v1.Group("/docker") + { + docker.GET("/containers", dockerApi.Containers) + docker.GET("/info", dockerApi.Info) + docker.GET("/logs/:server/:container", dockerApi.Logs) } - default: - panic(fmt.Errorf("unsupported handler type: %T", h)) } - matchDomains := mux.cfg.Value().MatchDomains - if len(matchDomains) > 0 { - origHandler := handler - handler = func(w http.ResponseWriter, r *http.Request) { - if httpheaders.IsWebsocket(r.Header) { - gpwebsocket.SetWebsocketAllowedDomains(r.Header, matchDomains) + return r +} + +func AuthMiddleware() gin.HandlerFunc { + if !auth.IsEnabled() { + return func(c *gin.Context) { + c.Next() + } + } + return func(c *gin.Context) { + err := auth.GetDefaultAuth().CheckToken(c.Request) + if err != nil { + c.JSON(http.StatusUnauthorized, apitypes.Error("Unauthorized", err)) + c.Abort() + return + } + c.Next() + } +} + +func ErrorHandler() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + if len(c.Errors) > 0 { + for _, err := range c.Errors { + log.Err(err.Err).Str("uri", c.Request.RequestURI).Msg("Internal error") + } + if !isWebSocketRequest(c) { + c.JSON(http.StatusInternalServerError, apitypes.Error("Internal server error")) } - origHandler(w, r) - } - } - - if len(requireAuth) > 0 && requireAuth[0] { - handler = auth.RequireAuth(handler) - } - if methods == "" { - mux.ServeMux.HandleFunc(endpoint, handler) - } else { - for _, m := range strutils.CommaSeperatedList(methods) { - mux.ServeMux.HandleFunc(m+" "+endpoint, handler) } } } -func NewHandler(cfg config.ConfigInstance) http.Handler { - mux := ServeMux{http.NewServeMux(), cfg} - mux.HandleFunc("GET", "/v1", v1.Index) - mux.HandleFunc("GET", "/v1/version", pkg.GetVersionHTTPHandler()) - - mux.HandleFunc("GET", "/v1/stats", v1.Stats, true) - mux.HandleFunc("POST", "/v1/reload", v1.Reload, true) - mux.HandleFunc("GET", "/v1/list", v1.ListRoutesHandler, true) - mux.HandleFunc("GET", "/v1/list/routes", v1.ListRoutesHandler, true) - mux.HandleFunc("GET", "/v1/list/route/{which}", v1.ListRouteHandler, true) - mux.HandleFunc("GET", "/v1/list/routes_by_provider", v1.ListRoutesByProviderHandler, true) - mux.HandleFunc("GET", "/v1/list/files", v1.ListFilesHandler, true) - mux.HandleFunc("GET", "/v1/list/homepage_config", v1.ListHomepageConfigHandler, true) - mux.HandleFunc("GET", "/v1/list/route_providers", v1.ListRouteProvidersHandler, true) - mux.HandleFunc("GET", "/v1/list/homepage_categories", v1.ListHomepageCategoriesHandler, true) - mux.HandleFunc("GET", "/v1/list/icons", v1.ListIconsHandler, true) - mux.HandleFunc("GET", "/v1/file/{type}/{filename}", v1.GetFileContent, true) - mux.HandleFunc("POST,PUT", "/v1/file/{type}/{filename}", v1.SetFileContent, true) - mux.HandleFunc("POST", "/v1/file/validate/{type}", v1.ValidateFile, true) - mux.HandleFunc("GET", "/v1/health", v1.Health, true) - mux.HandleFunc("GET", "/v1/logs", memlogger.Handler(), true) - mux.HandleFunc("GET", "/v1/favicon", favicon.GetFavIcon, true) - mux.HandleFunc("POST", "/v1/homepage/set", v1.SetHomePageOverrides, true) - mux.HandleFunc("GET", "/v1/agents", v1.ListAgents, true) - mux.HandleFunc("GET", "/v1/agents/new", v1.NewAgent, true) - mux.HandleFunc("POST", "/v1/agents/verify", v1.VerifyNewAgent, true) - mux.HandleFunc("GET", "/v1/metrics/system_info", v1.SystemInfo, true) - mux.HandleFunc("GET", "/v1/metrics/uptime", uptime.Poller.ServeHTTP, true) - mux.HandleFunc("GET", "/v1/cert/info", certapi.GetCertInfo, true) - mux.HandleFunc("", "/v1/cert/renew", certapi.RenewCert, true) - mux.HandleFunc("GET", "/v1/docker/info", dockerapi.DockerInfo, true) - mux.HandleFunc("GET", "/v1/docker/logs/{server}/{container}", dockerapi.Logs, true) - mux.HandleFunc("GET", "/v1/docker/containers", dockerapi.Containers, true) - - defaultAuth := auth.GetDefaultAuth() - if defaultAuth == nil { - return mux - } - - mux.HandleFunc("GET", "/v1/auth/check", auth.AuthCheckHandler) - mux.HandleFunc("GET,POST", "/v1/auth/redirect", defaultAuth.LoginHandler) - mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.PostAuthCallbackHandler) - mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutHandler) - return mux +func ErrorLoggingMiddleware() gin.HandlerFunc { + return gin.CustomRecoveryWithWriter(nil, func(c *gin.Context, err any) { + log.Error().Any("error", err).Str("uri", c.Request.RequestURI).Msg("Internal error") + if !isWebSocketRequest(c) { + c.JSON(http.StatusInternalServerError, apitypes.Error("Internal server error")) + } + }) +} + +func isWebSocketRequest(c *gin.Context) bool { + return c.GetHeader("Upgrade") == "websocket" } diff --git a/internal/api/types/error.go b/internal/api/types/error.go new file mode 100644 index 00000000..138bb65a --- /dev/null +++ b/internal/api/types/error.go @@ -0,0 +1,42 @@ +package apitypes + +type ErrorResponse struct { + Message string `json:"message"` + Error string `json:"error,omitempty" extensions:"x-nullable"` +} // @name ErrorResponse + +type serverError struct { + Message string + Err error +} + +// Error returns a generic error response +func Error(message string, err ...error) ErrorResponse { + if len(err) > 0 { + return ErrorResponse{ + Message: message, + Error: err[0].Error(), + } + } + return ErrorResponse{ + Message: message, + } +} + +func InternalServerError(err error, message string) error { + return serverError{ + Message: message, + Err: err, + } +} + +func (e serverError) Error() string { + if e.Err != nil { + return e.Message + ": " + e.Err.Error() + } + return e.Message +} + +func (e serverError) Unwrap() error { + return e.Err +} diff --git a/internal/api/types/error_code.go b/internal/api/types/error_code.go new file mode 100644 index 00000000..62827797 --- /dev/null +++ b/internal/api/types/error_code.go @@ -0,0 +1,17 @@ +package apitypes + +type ErrorCode int + +const ( + ErrorCodeUnauthorized ErrorCode = iota + 1 + ErrorCodeNotFound + ErrorCodeInternalServerError +) + +func (e ErrorCode) String() string { + return []string{ + "Unauthorized", + "Not Found", + "Internal Server Error", + }[e] +} diff --git a/internal/api/types/query.go b/internal/api/types/query.go new file mode 100644 index 00000000..db1e6e9a --- /dev/null +++ b/internal/api/types/query.go @@ -0,0 +1,29 @@ +package apitypes + +type QueryOptions struct { + Limit int `binding:"required,min=1,max=20" form:"limit"` + Offset int `binding:"omitempty,min=0" form:"offset"` + OrderBy QueryOrder `binding:"omitempty,oneof=created_at updated_at" form:"order_by"` + Order QueryOrderDirection `binding:"omitempty,oneof=asc desc" form:"order"` +} + +type QueryOrder string + +const ( + QueryOrderCreatedAt QueryOrder = "created_at" + QueryOrderUpdatedAt QueryOrder = "updated_at" +) + +type QueryOrderDirection string + +const ( + QueryOrderDirectionAsc QueryOrderDirection = "asc" + QueryOrderDirectionDesc QueryOrderDirection = "desc" +) + +type QueryResponse struct { + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + HasMore bool `json:"has_more"` +} diff --git a/internal/api/types/success.go b/internal/api/types/success.go new file mode 100644 index 00000000..a74c7744 --- /dev/null +++ b/internal/api/types/success.go @@ -0,0 +1,18 @@ +package apitypes + +type SuccessResponse struct { + Message string `json:"message"` + Details map[string]any `json:"details,omitempty" extensions:"x-nullable"` +} // @name SuccessResponse + +func Success(message string, extra ...map[string]any) SuccessResponse { + if len(extra) > 0 { + return SuccessResponse{ + Message: message, + Details: extra[0], + } + } + return SuccessResponse{ + Message: message, + } +} diff --git a/internal/api/v1/agent/common.go b/internal/api/v1/agent/common.go new file mode 100644 index 00000000..ed850076 --- /dev/null +++ b/internal/api/v1/agent/common.go @@ -0,0 +1,67 @@ +package agentapi + +import ( + "crypto/rand" + "encoding/base64" + "sync/atomic" + "time" + + "github.com/rs/zerolog/log" + "github.com/yusing/go-proxy/agent/pkg/agent" +) + +type PEMPairResponse struct { + Cert string `json:"cert" format:"base64"` + Key string `json:"key" format:"base64"` +} // @name PEMPairResponse + +var encryptionKey atomic.Value + +const rotateKeyInterval = 15 * time.Minute + +func init() { + if err := rotateKey(); err != nil { + log.Panic().Err(err).Msg("failed to generate encryption key") + } + go func() { + for range time.Tick(rotateKeyInterval) { + if err := rotateKey(); err != nil { + log.Error().Err(err).Msg("failed to rotate encryption key") + } + } + }() +} + +func getEncryptionKey() []byte { + return encryptionKey.Load().([]byte) +} + +func rotateKey() error { + // generate a random 32 bytes key + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return err + } + encryptionKey.Store(key) + return nil +} + +func toPEMPairResponse(encPEMPair agent.PEMPair) PEMPairResponse { + return PEMPairResponse{ + Cert: base64.StdEncoding.EncodeToString(encPEMPair.Cert), + Key: base64.StdEncoding.EncodeToString(encPEMPair.Key), + } +} + +func fromEncryptedPEMPairResponse(pemPair PEMPairResponse) (agent.PEMPair, error) { + encCert, err := base64.StdEncoding.DecodeString(pemPair.Cert) + if err != nil { + return agent.PEMPair{}, err + } + encKey, err := base64.StdEncoding.DecodeString(pemPair.Key) + if err != nil { + return agent.PEMPair{}, err + } + pair := agent.PEMPair{Cert: encCert, Key: encKey} + return pair.Decrypt(getEncryptionKey()) +} diff --git a/internal/api/v1/agent/create.go b/internal/api/v1/agent/create.go new file mode 100644 index 00000000..a53c5272 --- /dev/null +++ b/internal/api/v1/agent/create.go @@ -0,0 +1,103 @@ +package agentapi + +import ( + "fmt" + "net/http" + + _ "embed" + + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/agent/pkg/agent" + apitypes "github.com/yusing/go-proxy/internal/api/types" +) + +type NewAgentRequest struct { + Name string `form:"name" validate:"required"` + Host string `form:"host" validate:"required"` + Port int `form:"port" validate:"required,min=1,max=65535"` + Type string `form:"type" validate:"required,oneof=docker system"` + Nightly bool `form:"nightly" validate:"omitempty"` +} // @name NewAgentRequest + +type NewAgentResponse struct { + Compose string `json:"compose"` + CA PEMPairResponse `json:"ca"` + Client PEMPairResponse `json:"client"` +} // @name NewAgentResponse + +// @x-id "create" +// @BasePath /api/v1 +// @Summary Create a new agent +// @Description Create a new agent and return the docker compose file, encrypted CA and client PEMs +// @Description The returned PEMs are encrypted with a random key and will be used for verification when adding a new agent +// @Tags agent +// @Accept json +// @Produce json +// @Param request body NewAgentRequest true "Request" +// @Success 200 {object} NewAgentResponse +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 409 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /agent/create [post] +func Create(c *gin.Context) { + var request NewAgentRequest + if err := c.ShouldBindQuery(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + hostport := fmt.Sprintf("%s:%d", request.Host, request.Port) + if _, ok := agent.GetAgent(hostport); ok { + c.JSON(http.StatusConflict, apitypes.Error("agent already exists")) + return + } + + var image string + if request.Nightly { + image = agent.DockerImageNightly + } else { + image = agent.DockerImageProduction + } + + ca, srv, client, err := agent.NewAgent() + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to create agent")) + return + } + + var cfg agent.Generator = &agent.AgentEnvConfig{ + Name: request.Name, + Port: request.Port, + CACert: ca.String(), + SSLCert: srv.String(), + } + if request.Type == "docker" { + cfg = &agent.AgentComposeConfig{ + Image: image, + AgentEnvConfig: cfg.(*agent.AgentEnvConfig), + } + } + template, err := cfg.Generate() + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to generate agent config")) + return + } + + key := getEncryptionKey() + encCA, err := ca.Encrypt(key) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to encrypt CA PEMs")) + return + } + encClient, err := client.Encrypt(key) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to encrypt client PEMs")) + return + } + + c.JSON(http.StatusOK, NewAgentResponse{ + Compose: template, + CA: toPEMPairResponse(encCA), + Client: toPEMPairResponse(encClient), + }) +} diff --git a/internal/api/v1/agent/list.go b/internal/api/v1/agent/list.go new file mode 100644 index 00000000..5d448759 --- /dev/null +++ b/internal/api/v1/agent/list.go @@ -0,0 +1,32 @@ +package agentapi + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/agent/pkg/agent" + "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" +) + +// @x-id "list" +// @BasePath /api/v1 +// @Summary List agents +// @Description List agents +// @Tags agent,websocket +// @Accept json +// @Produce json +// @Success 200 {array} Agent +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /agent/list [get] +func List(c *gin.Context) { + if httpheaders.IsWebsocket(c.Request.Header) { + websocket.PeriodicWrite(c, 10*time.Second, func() (any, error) { + return agent.ListAgents(), nil + }) + } else { + c.JSON(http.StatusOK, agent.ListAgents()) + } +} diff --git a/internal/api/v1/agent/verify.go b/internal/api/v1/agent/verify.go new file mode 100644 index 00000000..69a59cc6 --- /dev/null +++ b/internal/api/v1/agent/verify.go @@ -0,0 +1,76 @@ +package agentapi + +import ( + "fmt" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/agent/pkg/certs" + . "github.com/yusing/go-proxy/internal/api/types" + config "github.com/yusing/go-proxy/internal/config/types" +) + +type VerifyNewAgentRequest struct { + Host string `json:"host"` + CA PEMPairResponse `json:"ca"` + Client PEMPairResponse `json:"client"` +} // @name VerifyNewAgentRequest + +// @x-id "verify" +// @BasePath /api/v1 +// @Summary Verify a new agent +// @Description Verify a new agent and return the number of routes added +// @Tags agent +// @Accept json +// @Produce json +// @Param request body VerifyNewAgentRequest true "Request" +// @Success 200 {object} SuccessResponse +// @Failure 400 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /agent/verify [post] +func Verify(c *gin.Context) { + var request VerifyNewAgentRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, Error("invalid request", err)) + return + } + + filename, ok := certs.AgentCertsFilepath(request.Host) + if !ok { + c.JSON(http.StatusBadRequest, Error("invalid host", nil)) + return + } + + ca, err := fromEncryptedPEMPairResponse(request.CA) + if err != nil { + c.JSON(http.StatusBadRequest, Error("invalid CA", err)) + return + } + + client, err := fromEncryptedPEMPairResponse(request.Client) + if err != nil { + c.JSON(http.StatusBadRequest, Error("invalid client", err)) + return + } + + nRoutesAdded, err := config.GetInstance().VerifyNewAgent(request.Host, ca, client) + if err != nil { + c.JSON(http.StatusBadRequest, Error("invalid request", err)) + return + } + + zip, err := certs.ZipCert(ca.Cert, client.Cert, client.Key) + if err != nil { + c.Error(InternalServerError(err, "failed to zip certs")) + return + } + + if err := os.WriteFile(filename, zip, 0o600); err != nil { + c.Error(InternalServerError(err, "failed to write certs")) + return + } + + c.JSON(http.StatusOK, Success(fmt.Sprintf("Added %d routes", nRoutesAdded))) +} diff --git a/internal/api/v1/auth/callback.go b/internal/api/v1/auth/callback.go new file mode 100644 index 00000000..0bd9bfa7 --- /dev/null +++ b/internal/api/v1/auth/callback.go @@ -0,0 +1,24 @@ +//nolint:dupword +package auth + +import ( + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/auth" +) + +// @x-id "callback" +// @Base /api/v1 +// @Summary Post Auth Callback +// @Description Handles the callback from the provider after successful authentication +// @Tags auth +// @Produce plain +// @Param body body auth.UserPassAuthCallbackRequest true "Userpass only" +// @Success 200 {string} string "Userpass: OK" +// @Success 302 {string} string "OIDC: Redirects to home page" +// @Failure 400 {string} string "OIDC: invalid request (missing state cookie or oauth state)" +// @Failure 400 {string} string "Userpass: invalid request / credentials" +// @Failure 500 {string} string "Internal server error" +// @Router /auth/callback [post] +func Callback(c *gin.Context) { + auth.GetDefaultAuth().PostAuthCallbackHandler(c.Writer, c.Request) +} diff --git a/internal/api/v1/auth/check.go b/internal/api/v1/auth/check.go new file mode 100644 index 00000000..72be3756 --- /dev/null +++ b/internal/api/v1/auth/check.go @@ -0,0 +1,19 @@ +package auth + +import ( + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/auth" +) + +// @x-id "check" +// @Base /api/v1 +// @Summary Check authentication status +// @Description Checks if the user is authenticated by validating their token +// @Tags auth +// @Produce plain +// @Success 200 {string} string "OK" +// @Failure 403 {string} string "Forbidden: use X-Redirect-To header to redirect to login page" +// @Router /auth/check [head] +func Check(c *gin.Context) { + auth.AuthCheckHandler(c.Writer, c.Request) +} diff --git a/internal/api/v1/auth/login.go b/internal/api/v1/auth/login.go new file mode 100644 index 00000000..ccaef4b5 --- /dev/null +++ b/internal/api/v1/auth/login.go @@ -0,0 +1,20 @@ +package auth + +import ( + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/auth" +) + +// @x-id "login" +// @Base /api/v1 +// @Summary Login +// @Description Initiates the login process by redirecting the user to the provider's login page +// @Tags auth +// @Produce plain +// @Success 302 {string} string "Redirects to login page or IdP" +// @Failure 403 {string} string "Forbidden(webui): follow X-Redirect-To header" +// @Failure 429 {string} string "Too Many Requests" +// @Router /auth/login [post] +func Login(c *gin.Context) { + auth.GetDefaultAuth().LoginHandler(c.Writer, c.Request) +} diff --git a/internal/api/v1/auth/logout.go b/internal/api/v1/auth/logout.go new file mode 100644 index 00000000..2ec2c3f2 --- /dev/null +++ b/internal/api/v1/auth/logout.go @@ -0,0 +1,18 @@ +package auth + +import ( + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/auth" +) + +// @x-id "logout" +// @Base /api/v1 +// @Summary Logout +// @Description Logs out the user by invalidating the token +// @Tags auth +// @Produce plain +// @Success 302 {string} string "Redirects to home page" +// @Router /auth/logout [post] +func Logout(c *gin.Context) { + auth.GetDefaultAuth().LogoutHandler(c.Writer, c.Request) +} diff --git a/internal/api/v1/certapi/cert_info.go b/internal/api/v1/cert/info.go similarity index 56% rename from internal/api/v1/certapi/cert_info.go rename to internal/api/v1/cert/info.go index 07edfd92..2d627c5b 100644 --- a/internal/api/v1/certapi/cert_info.go +++ b/internal/api/v1/cert/info.go @@ -1,9 +1,10 @@ package certapi import ( - "encoding/json" "net/http" + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" config "github.com/yusing/go-proxy/internal/config/types" ) @@ -14,18 +15,29 @@ type CertInfo struct { NotAfter int64 `json:"not_after"` DNSNames []string `json:"dns_names"` EmailAddresses []string `json:"email_addresses"` -} +} // @name CertInfo -func GetCertInfo(w http.ResponseWriter, r *http.Request) { +// @BasePath /api/v1 +// @Summary Get cert info +// @Description Get cert info +// @Tags cert +// @Accept json +// @Produce json +// @Success 200 {object} CertInfo +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 404 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /cert/info [get] +func Info(c *gin.Context) { autocert := config.GetInstance().AutoCertProvider() if autocert == nil { - http.Error(w, "autocert is not enabled", http.StatusNotFound) + c.JSON(http.StatusNotFound, apitypes.Error("autocert is not enabled")) return } cert, err := autocert.GetCert(nil) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + c.Error(apitypes.InternalServerError(err, "failed to get cert info")) return } @@ -37,5 +49,5 @@ func GetCertInfo(w http.ResponseWriter, r *http.Request) { DNSNames: cert.Leaf.DNSNames, EmailAddresses: cert.Leaf.EmailAddresses, } - json.NewEncoder(w).Encode(&certInfo) + c.JSON(http.StatusOK, certInfo) } diff --git a/internal/api/v1/cert/renew.go b/internal/api/v1/cert/renew.go new file mode 100644 index 00000000..423604e2 --- /dev/null +++ b/internal/api/v1/cert/renew.go @@ -0,0 +1,72 @@ +package certapi + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + apitypes "github.com/yusing/go-proxy/internal/api/types" + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/logging/memlogger" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" +) + +// @BasePath /api/v1 +// @Summary Renew cert +// @Description Renew cert +// @Tags cert +// @Accept json +// @Produce json +// @Success 200 {object} apitypes.SuccessResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /cert/renew [post] +func Renew(c *gin.Context) { + autocert := config.GetInstance().AutoCertProvider() + if autocert == nil { + c.JSON(http.StatusNotFound, apitypes.Error("autocert is not enabled")) + return + } + + manager, err := websocket.NewManagerWithUpgrade(c) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to create websocket manager")) + return + } + defer manager.Close() + + logs, cancel := memlogger.Events() + defer cancel() + + done := make(chan struct{}) + + go func() { + defer close(done) + + err = autocert.ObtainCert() + if err != nil { + gperr.LogError("failed to obtain cert", err) + _ = manager.WriteData(websocket.TextMessage, []byte(err.Error()), 10*time.Second) + } else { + log.Info().Msg("cert obtained successfully") + } + }() + + for { + select { + case l := <-logs: + if err != nil { + return + } + + err = manager.WriteData(websocket.TextMessage, l, 10*time.Second) + if err != nil { + return + } + case <-done: + return + } + } +} diff --git a/internal/api/v1/certapi/renew.go b/internal/api/v1/certapi/renew.go deleted file mode 100644 index edbf424c..00000000 --- a/internal/api/v1/certapi/renew.go +++ /dev/null @@ -1,54 +0,0 @@ -package certapi - -import ( - "net/http" - - "github.com/rs/zerolog/log" - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/logging/memlogger" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" -) - -func RenewCert(w http.ResponseWriter, r *http.Request) { - autocert := config.GetInstance().AutoCertProvider() - if autocert == nil { - http.Error(w, "autocert is not enabled", http.StatusNotFound) - return - } - - conn, err := gpwebsocket.Initiate(w, r) - if err != nil { - return - } - defer conn.Close() - - logs, cancel := memlogger.Events() - defer cancel() - - done := make(chan struct{}) - - go func() { - defer close(done) - err = autocert.ObtainCert() - if err != nil { - gperr.LogError("failed to obtain cert", err) - _ = gpwebsocket.WriteText(conn, err.Error()) - } else { - log.Info().Msg("cert obtained successfully") - } - }() - for { - select { - case l := <-logs: - if err != nil { - return - } - if err := gpwebsocket.WriteText(conn, string(l)); err != nil { - return - } - case <-done: - return - } - } -} diff --git a/internal/api/v1/config_file.go b/internal/api/v1/config_file.go deleted file mode 100644 index 7bb4f2f8..00000000 --- a/internal/api/v1/config_file.go +++ /dev/null @@ -1,133 +0,0 @@ -package v1 - -import ( - "fmt" - "io" - "net/http" - "os" - "path" - "strings" - - "github.com/yusing/go-proxy/internal/common" - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/gphttp/middleware" - "github.com/yusing/go-proxy/internal/route/provider" -) - -type FileType string - -const ( - FileTypeConfig FileType = "config" - FileTypeProvider FileType = "provider" - FileTypeMiddleware FileType = "middleware" -) - -func fileType(file string) FileType { - switch { - case strings.HasPrefix(path.Base(file), "config."): - return FileTypeConfig - case strings.HasPrefix(file, common.MiddlewareComposeBasePath): - return FileTypeMiddleware - } - return FileTypeProvider -} - -func (t FileType) IsValid() bool { - switch t { - case FileTypeConfig, FileTypeProvider, FileTypeMiddleware: - return true - } - return false -} - -func (t FileType) GetPath(filename string) string { - if t == FileTypeMiddleware { - return path.Join(common.MiddlewareComposeBasePath, filename) - } - return path.Join(common.ConfigBasePath, filename) -} - -func getArgs(r *http.Request) (fileType FileType, filename string, err error) { - fileType = FileType(r.PathValue("type")) - if !fileType.IsValid() { - err = fmt.Errorf("invalid file type: %s", fileType) - return - } - filename = r.PathValue("filename") - if filename == "" { - err = fmt.Errorf("missing filename") - } - return -} - -func GetFileContent(w http.ResponseWriter, r *http.Request) { - fileType, filename, err := getArgs(r) - if err != nil { - gphttp.BadRequest(w, err.Error()) - return - } - content, err := os.ReadFile(fileType.GetPath(filename)) - if err != nil { - gphttp.ServerError(w, r, err) - return - } - gphttp.WriteBody(w, content) -} - -func validateFile(fileType FileType, content []byte) gperr.Error { - switch fileType { - case FileTypeConfig: - return config.Validate(content) - case FileTypeMiddleware: - errs := gperr.NewBuilder("middleware errors") - middleware.BuildMiddlewaresFromYAML("", content, errs) - return errs.Error() - } - return provider.Validate(content) -} - -func ValidateFile(w http.ResponseWriter, r *http.Request) { - fileType := FileType(r.PathValue("type")) - if !fileType.IsValid() { - gphttp.BadRequest(w, "invalid file type") - return - } - content, err := io.ReadAll(r.Body) - if err != nil { - gphttp.ServerError(w, r, err) - return - } - r.Body.Close() - if valErr := validateFile(fileType, content); valErr != nil { - gphttp.JSONError(w, valErr, http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusOK) -} - -func SetFileContent(w http.ResponseWriter, r *http.Request) { - fileType, filename, err := getArgs(r) - if err != nil { - gphttp.BadRequest(w, err.Error()) - return - } - content, err := io.ReadAll(r.Body) - if err != nil { - gphttp.ServerError(w, r, err) - return - } - - if valErr := validateFile(fileType, content); valErr != nil { - gphttp.JSONError(w, valErr, http.StatusBadRequest) - return - } - - err = os.WriteFile(fileType.GetPath(filename), content, 0o644) - if err != nil { - gphttp.ServerError(w, r, err) - return - } - w.WriteHeader(http.StatusOK) -} diff --git a/internal/api/v1/dockerapi/containers.go b/internal/api/v1/docker/containers.go similarity index 73% rename from internal/api/v1/dockerapi/containers.go rename to internal/api/v1/docker/containers.go index 9d98076c..e1838f53 100644 --- a/internal/api/v1/dockerapi/containers.go +++ b/internal/api/v1/docker/containers.go @@ -2,10 +2,10 @@ package dockerapi import ( "context" - "net/http" "sort" "github.com/docker/docker/api/types/container" + "github.com/gin-gonic/gin" "github.com/yusing/go-proxy/internal/gperr" ) @@ -15,10 +15,20 @@ type Container struct { ID string `json:"id"` Image string `json:"image"` State string `json:"state"` -} +} // @name ContainerResponse -func Containers(w http.ResponseWriter, r *http.Request) { - serveHTTP[Container](w, r, GetContainers) +// @BasePath /api/v1 +// @Summary Get containers +// @Description Get containers +// @Tags docker +// @Accept json +// @Produce json +// @Success 200 {array} Container +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /docker/containers [get] +func Containers(c *gin.Context) { + serveHTTP[Container](c, GetContainers) } func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Container, gperr.Error) { diff --git a/internal/api/v1/docker/info.go b/internal/api/v1/docker/info.go new file mode 100644 index 00000000..04da35b4 --- /dev/null +++ b/internal/api/v1/docker/info.go @@ -0,0 +1,79 @@ +package dockerapi + +import ( + "context" + "sort" + + dockerSystem "github.com/docker/docker/api/types/system" + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +type containerStats struct { + Total int `json:"total"` + Running int `json:"running"` + Paused int `json:"paused"` + Stopped int `json:"stopped"` +} // @name ContainerStats + +type dockerInfo struct { + Name string `json:"name"` + ServerVersion string `json:"version"` + Containers containerStats `json:"containers"` + Images int `json:"images"` + NCPU int `json:"n_cpu"` + MemTotal string `json:"memory"` +} // @name ServerInfo + +func toDockerInfo(info dockerSystem.Info) dockerInfo { + return dockerInfo{ + Name: info.Name, + ServerVersion: info.ServerVersion, + Containers: containerStats{ + Total: info.ContainersRunning, + Running: info.ContainersRunning, + Paused: info.ContainersPaused, + Stopped: info.ContainersStopped, + }, + Images: info.Images, + NCPU: info.NCPU, + MemTotal: strutils.FormatByteSize(info.MemTotal), + } +} + +// @BasePath /api/v1 +// @Summary Get docker info +// @Description Get docker info +// @Tags docker +// @Accept json +// @Produce json +// @Success 200 {array} dockerInfo +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /docker/info [get] +func Info(c *gin.Context) { + serveHTTP[dockerInfo](c, GetDockerInfo) +} + +func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerInfo, gperr.Error) { + errs := gperr.NewBuilder("failed to get docker info") + dockerInfos := make([]dockerInfo, len(dockerClients)) + + i := 0 + for name, dockerClient := range dockerClients { + info, err := dockerClient.Info(ctx) + if err != nil { + errs.Add(err) + continue + } + info.Name = name + dockerInfos[i] = toDockerInfo(info) + i++ + } + + sort.Slice(dockerInfos, func(i, j int) bool { + return dockerInfos[i].Name < dockerInfos[j].Name + }) + return dockerInfos, errs.Error() +} diff --git a/internal/api/v1/docker/logs.go b/internal/api/v1/docker/logs.go new file mode 100644 index 00000000..37daf5eb --- /dev/null +++ b/internal/api/v1/docker/logs.go @@ -0,0 +1,112 @@ +package dockerapi + +import ( + "context" + "errors" + "net/http" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/pkg/stdcopy" + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" + "github.com/yusing/go-proxy/internal/task" +) + +type LogsPathParams struct { + Server string `uri:"server" binding:"required"` + ContainerID string `uri:"container" binding:"required"` +} // @name LogsPathParams + +type LogsQueryParams struct { + Stdout bool `form:"stdout,default=true"` + Stderr bool `form:"stderr,default=true"` + Since string `form:"from"` + Until string `form:"to"` + Levels string `form:"levels"` +} // @name LogsQueryParams + +// @BasePath /api/v1 +// @Summary Get docker container logs +// @Description Get docker container logs +// @Tags docker,websocket +// @Accept json +// @Produce json +// @Param server path string true "server name" +// @Param container path string true "container id" +// @Param stdout query bool false "show stdout" +// @Param stderr query bool false "show stderr" +// @Param from query string false "from timestamp" +// @Param to query string false "to timestamp" +// @Param levels query string false "levels" +// @Success 200 +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 404 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /docker/logs/{server}/{container} [get] +func Logs(c *gin.Context) { + var pathParams LogsPathParams + var queryParams LogsQueryParams + if err := c.ShouldBindQuery(&queryParams); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid query params")) + return + } + if err := c.ShouldBindUri(&pathParams); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid path params")) + return + } + // TODO: implement levels + + dockerClient, found, err := getDockerClient(pathParams.Server) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to get docker client")) + return + } + if !found { + c.JSON(http.StatusNotFound, apitypes.Error("server not found")) + return + } + defer dockerClient.Close() + + opts := container.LogsOptions{ + ShowStdout: queryParams.Stdout, + ShowStderr: queryParams.Stderr, + Since: queryParams.Since, + Until: queryParams.Until, + Timestamps: true, + Follow: true, + Tail: "100", + } + if queryParams.Levels != "" { + opts.Details = true + } + + logs, err := dockerClient.ContainerLogs(c.Request.Context(), pathParams.ContainerID, opts) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to get container logs")) + return + } + defer logs.Close() + + manager, err := websocket.NewManagerWithUpgrade(c) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to create websocket manager")) + return + } + defer manager.Close() + + writer := manager.NewWriter(websocket.TextMessage) + + _, err = stdcopy.StdCopy(writer, writer, logs) // de-multiplex logs + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, task.ErrProgramExiting) { + return + } + log.Err(err). + Str("server", pathParams.Server). + Str("container", pathParams.ContainerID). + Msg("failed to de-multiplex logs") + } +} diff --git a/internal/api/v1/dockerapi/utils.go b/internal/api/v1/docker/utils.go similarity index 70% rename from internal/api/v1/dockerapi/utils.go rename to internal/api/v1/docker/utils.go index 8567bf61..d13df679 100644 --- a/internal/api/v1/dockerapi/utils.go +++ b/internal/api/v1/docker/utils.go @@ -2,17 +2,17 @@ package dockerapi import ( "context" - "encoding/json" "net/http" "time" - "github.com/gorilla/websocket" + "github.com/gin-gonic/gin" "github.com/yusing/go-proxy/agent/pkg/agent" + apitypes "github.com/yusing/go-proxy/internal/api/types" config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" ) type ( @@ -50,7 +50,7 @@ func getDockerClients() (DockerClients, gperr.Error) { connErrs.Add(err) continue } - dockerClients[agent.Name()] = dockerClient + dockerClients[agent.Name] = dockerClient } return dockerClients, connErrs.Error() @@ -67,7 +67,7 @@ func getDockerClient(server string) (*docker.SharedClient, bool, error) { } if host == "" { for _, agent := range agent.ListAgents() { - if agent.Name() == server { + if agent.Name == server { host = agent.FakeDockerHost() break } @@ -92,35 +92,30 @@ func closeAllClients(dockerClients DockerClients) { } } -func handleResult[V any, T ResultType[V]](w http.ResponseWriter, errs error, result T) { +func handleResult[V any, T ResultType[V]](c *gin.Context, errs error, result T) { if errs != nil { - gperr.LogError("docker errors", errs) if len(result) == 0 { - http.Error(w, "docker errors", http.StatusInternalServerError) + c.Error(apitypes.InternalServerError(errs, "docker errors")) return } } - json.NewEncoder(w).Encode(result) //nolint + c.JSON(http.StatusOK, result) } -func serveHTTP[V any, T ResultType[V]](w http.ResponseWriter, r *http.Request, getResult func(ctx context.Context, dockerClients DockerClients) (T, gperr.Error)) { +func serveHTTP[V any, T ResultType[V]](c *gin.Context, getResult func(ctx context.Context, dockerClients DockerClients) (T, gperr.Error)) { dockerClients, err := getDockerClients() if err != nil { - handleResult[V, T](w, err, nil) + handleResult[V, T](c, err, nil) return } defer closeAllClients(dockerClients) - if httpheaders.IsWebsocket(r.Header) { - gpwebsocket.Periodic(w, r, 5*time.Second, func(conn *websocket.Conn) error { - result, err := getResult(r.Context(), dockerClients) - if err != nil { - return err - } - return conn.WriteJSON(result) + if httpheaders.IsWebsocket(c.Request.Header) { + websocket.PeriodicWrite(c, 5*time.Second, func() (any, error) { + return getResult(c.Request.Context(), dockerClients) }) } else { - result, err := getResult(r.Context(), dockerClients) - handleResult[V](w, err, result) + result, err := getResult(c.Request.Context(), dockerClients) + handleResult[V](c, err, result) } } diff --git a/internal/api/v1/dockerapi/info.go b/internal/api/v1/dockerapi/info.go deleted file mode 100644 index a8bd08ce..00000000 --- a/internal/api/v1/dockerapi/info.go +++ /dev/null @@ -1,56 +0,0 @@ -package dockerapi - -import ( - "context" - "encoding/json" - "net/http" - "sort" - - dockerSystem "github.com/docker/docker/api/types/system" - "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/utils/strutils" -) - -type dockerInfo dockerSystem.Info - -func (d *dockerInfo) MarshalJSON() ([]byte, error) { - return json.Marshal(map[string]any{ - "name": d.Name, - "version": d.ServerVersion, - "containers": map[string]int{ - "total": d.Containers, - "running": d.ContainersRunning, - "paused": d.ContainersPaused, - "stopped": d.ContainersStopped, - }, - "images": d.Images, - "n_cpu": d.NCPU, - "memory": strutils.FormatByteSize(d.MemTotal), - }) -} - -func DockerInfo(w http.ResponseWriter, r *http.Request) { - serveHTTP[dockerInfo](w, r, GetDockerInfo) -} - -func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerInfo, gperr.Error) { - errs := gperr.NewBuilder("failed to get docker info") - dockerInfos := make([]dockerInfo, len(dockerClients)) - - i := 0 - for name, dockerClient := range dockerClients { - info, err := dockerClient.Info(ctx) - if err != nil { - errs.Add(err) - continue - } - info.Name = name - dockerInfos[i] = dockerInfo(info) - i++ - } - - sort.Slice(dockerInfos, func(i, j int) bool { - return dockerInfos[i].Name < dockerInfos[j].Name - }) - return dockerInfos, errs.Error() -} diff --git a/internal/api/v1/dockerapi/logs.go b/internal/api/v1/dockerapi/logs.go deleted file mode 100644 index c313fedb..00000000 --- a/internal/api/v1/dockerapi/logs.go +++ /dev/null @@ -1,77 +0,0 @@ -package dockerapi - -import ( - "context" - "errors" - "net/http" - "strconv" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/pkg/stdcopy" - "github.com/gorilla/websocket" - "github.com/rs/zerolog/log" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" - "github.com/yusing/go-proxy/internal/task" -) - -// FIXME: agent logs not updating. -func Logs(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - server := r.PathValue("server") - containerID := r.PathValue("container") - stdout, _ := strconv.ParseBool(query.Get("stdout")) - stderr, _ := strconv.ParseBool(query.Get("stderr")) - since := query.Get("from") - until := query.Get("to") - levels := query.Get("levels") // TODO: implement levels - - dockerClient, found, err := getDockerClient(server) - if err != nil { - gphttp.BadRequest(w, err.Error()) - return - } - if !found { - gphttp.NotFound(w, "server not found") - return - } - defer dockerClient.Close() - - opts := container.LogsOptions{ - ShowStdout: stdout, - ShowStderr: stderr, - Since: since, - Until: until, - Timestamps: true, - Follow: true, - Tail: "100", - } - if levels != "" { - opts.Details = true - } - - logs, err := dockerClient.ContainerLogs(r.Context(), containerID, opts) - if err != nil { - gphttp.BadRequest(w, err.Error()) - return - } - defer logs.Close() - - conn, err := gpwebsocket.Initiate(w, r) - if err != nil { - return - } - defer conn.Close() - - writer := gpwebsocket.NewWriter(r.Context(), conn, websocket.TextMessage) - _, err = stdcopy.StdCopy(writer, writer, logs) // de-multiplex logs - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, task.ErrProgramExiting) { - return - } - log.Err(err). - Str("server", server). - Str("container", containerID). - Msg("failed to de-multiplex logs") - } -} diff --git a/internal/api/v1/docs/docs.go b/internal/api/v1/docs/docs.go new file mode 100644 index 00000000..c5a552c4 --- /dev/null +++ b/internal/api/v1/docs/docs.go @@ -0,0 +1,3377 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/agent/create": { + "post": { + "description": "Create a new agent and return the docker compose file, encrypted CA and client PEMs\nThe returned PEMs are encrypted with a random key and will be used for verification when adding a new agent", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agent" + ], + "summary": "Create a new agent", + "parameters": [ + { + "description": "Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewAgentRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/NewAgentResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "create" + } + }, + "/agent/list": { + "get": { + "description": "List agents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agent", + "websocket" + ], + "summary": "List agents", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Agent" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "list" + } + }, + "/agent/verify": { + "post": { + "description": "Verify a new agent and return the number of routes added", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agent" + ], + "summary": "Verify a new agent", + "parameters": [ + { + "description": "Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/VerifyNewAgentRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "verify" + } + }, + "/auth/callback": { + "post": { + "description": "Handles the callback from the provider after successful authentication", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Post Auth Callback", + "parameters": [ + { + "description": "Userpass only", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.UserPassAuthCallbackRequest" + } + } + ], + "responses": { + "200": { + "description": "Userpass: OK", + "schema": { + "type": "string" + } + }, + "302": { + "description": "OIDC: Redirects to home page", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Userpass: invalid request / credentials", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + }, + "x-id": "callback" + } + }, + "/auth/check": { + "head": { + "description": "Checks if the user is authenticated by validating their token", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Check authentication status", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden: use X-Redirect-To header to redirect to login page", + "schema": { + "type": "string" + } + } + }, + "x-id": "check" + } + }, + "/auth/login": { + "post": { + "description": "Initiates the login process by redirecting the user to the provider's login page", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Login", + "responses": { + "302": { + "description": "Redirects to login page or IdP", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden(webui): follow X-Redirect-To header", + "schema": { + "type": "string" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "type": "string" + } + } + }, + "x-id": "login" + } + }, + "/auth/logout": { + "post": { + "description": "Logs out the user by invalidating the token", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Logout", + "responses": { + "302": { + "description": "Redirects to home page", + "schema": { + "type": "string" + } + } + }, + "x-id": "logout" + } + }, + "/cert/info": { + "get": { + "description": "Get cert info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cert" + ], + "summary": "Get cert info", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/CertInfo" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/cert/renew": { + "post": { + "description": "Renew cert", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cert" + ], + "summary": "Renew cert", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/docker/containers": { + "get": { + "description": "Get containers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "docker" + ], + "summary": "Get containers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ContainerResponse" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/docker/info": { + "get": { + "description": "Get docker info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "docker" + ], + "summary": "Get docker info", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ServerInfo" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/docker/logs/{server}/{container}": { + "get": { + "description": "Get docker container logs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "docker", + "websocket" + ], + "summary": "Get docker container logs", + "parameters": [ + { + "type": "string", + "description": "server name", + "name": "server", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "container id", + "name": "container", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "show stdout", + "name": "stdout", + "in": "query" + }, + { + "type": "boolean", + "description": "show stderr", + "name": "stderr", + "in": "query" + }, + { + "type": "string", + "description": "from timestamp", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "to timestamp", + "name": "to", + "in": "query" + }, + { + "type": "string", + "description": "levels", + "name": "levels", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/favicon": { + "get": { + "description": "Get favicon", + "consumes": [ + "application/json" + ], + "produces": [ + "image/svg+xml", + "image/x-icon", + "image/png", + "image/webp" + ], + "tags": [ + "v1" + ], + "summary": "Get favicon", + "parameters": [ + { + "type": "string", + "description": "URL of the route", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "Alias of the route", + "name": "alias", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/homepage.FetchResult" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "favicon" + } + }, + "/file/content": { + "get": { + "description": "Get file content", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json", + "application/godoxy+yaml" + ], + "tags": [ + "file" + ], + "summary": "Get file content", + "parameters": [ + { + "type": "string", + "format": "filename", + "name": "filename", + "in": "query", + "required": true + }, + { + "enum": [ + "config", + "provider", + "middleware" + ], + "type": "string", + "x-enum-comments": { + "FileTypeConfig": "@name FileTypeConfig", + "FileTypeMiddleware": "@name FileTypeMiddleware", + "FileTypeProvider": "@name FileTypeProvider" + }, + "x-enum-varnames": [ + "FileTypeConfig", + "FileTypeProvider", + "FileTypeMiddleware" + ], + "name": "type", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "get" + }, + "put": { + "description": "Set file content", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "file" + ], + "summary": "Set file content", + "parameters": [ + { + "enum": [ + "config", + "provider", + "middleware" + ], + "type": "string", + "description": "Type", + "name": "type", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Filename", + "name": "filename", + "in": "query", + "required": true + }, + { + "description": "File", + "name": "file", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "set" + } + }, + "/file/list": { + "get": { + "description": "List files", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "file" + ], + "summary": "List files", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ListFilesResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "list" + } + }, + "/file/validate": { + "post": { + "description": "Validate file", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "file" + ], + "summary": "Validate file", + "parameters": [ + { + "enum": [ + "config", + "provider", + "middleware" + ], + "type": "string", + "description": "Type", + "name": "type", + "in": "query", + "required": true + }, + { + "description": "File content", + "name": "file", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File validated", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "417": { + "description": "Validation failed", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "validate" + } + }, + "/health": { + "get": { + "description": "Get health info by route name", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "v1", + "websocket" + ], + "summary": "Get routes health info", + "responses": { + "200": { + "description": "Health info by route name", + "schema": { + "$ref": "#/definitions/HealthMap" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "health" + } + }, + "/homepage/categories": { + "get": { + "description": "List homepage categories", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "homepage" + ], + "summary": "List homepage categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "categories" + } + }, + "/homepage/items": { + "get": { + "description": "Homepage items", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "homepage" + ], + "summary": "Homepage items", + "parameters": [ + { + "type": "string", + "description": "Category filter", + "name": "category", + "in": "query" + }, + { + "type": "string", + "description": "Provider filter", + "name": "provider", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/HomepageItems" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "items" + } + }, + "/homepage/set": { + "post": { + "description": "Set homepage overrides", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "homepage" + ], + "summary": "Set homepage overrides", + "parameters": [ + { + "description": "Override single item", + "name": "request", + "in": "body", + "required": true, + "schema": { + "allOf": [ + { + "$ref": "#/definitions/homepageapi.SetHomePageOverridesRequest" + }, + { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/HomepageOverrideItemParams" + } + } + } + ] + } + }, + { + "description": "Override multiple items", + "name": "request", + "in": "body", + "required": true, + "schema": { + "allOf": [ + { + "$ref": "#/definitions/homepageapi.SetHomePageOverridesRequest" + }, + { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/HomepageOverrideItemsBatchParams" + } + } + } + ] + } + }, + { + "description": "Override category order", + "name": "request", + "in": "body", + "required": true, + "schema": { + "allOf": [ + { + "$ref": "#/definitions/homepageapi.SetHomePageOverridesRequest" + }, + { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/HomepageOverrideCategoryOrderParams" + } + } + } + ] + } + }, + { + "description": "Override item visibility", + "name": "request", + "in": "body", + "required": true, + "schema": { + "allOf": [ + { + "$ref": "#/definitions/homepageapi.SetHomePageOverridesRequest" + }, + { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/HomepageOverrideItemVisibleParams" + } + } + } + ] + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "set" + } + }, + "/icons": { + "get": { + "description": "List icons", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "v1" + ], + "summary": "List icons", + "parameters": [ + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/homepage.IconMetaSearch" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "icons" + } + }, + "/metrics/system_info": { + "get": { + "description": "Get system info", + "produces": [ + "application/json" + ], + "tags": [ + "metrics", + "websocket" + ], + "summary": "Get system info", + "parameters": [ + { + "type": "string", + "description": "Agent address", + "name": "agent_addr", + "in": "query" + }, + { + "type": "string", + "description": "Period", + "name": "period", + "in": "query" + } + ], + "responses": { + "200": { + "description": "period specified", + "schema": { + "$ref": "#/definitions/SystemInfoAggregate" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "system_info" + } + }, + "/metrics/uptime": { + "get": { + "description": "Get uptime", + "produces": [ + "application/json" + ], + "tags": [ + "metrics", + "websocket" + ], + "summary": "Get uptime", + "parameters": [ + { + "enum": [ + "5m", + "15m", + "1h", + "1d", + "1mo" + ], + "type": "string", + "example": "1m", + "x-enum-comments": { + "MetricsPeriod15m": "@name MetricsPeriod15m", + "MetricsPeriod1d": "@name MetricsPeriod1d", + "MetricsPeriod1h": "@name MetricsPeriod1h", + "MetricsPeriod1mo": "@name MetricsPeriod1mo", + "MetricsPeriod5m": "@name MetricsPeriod5m" + }, + "x-enum-varnames": [ + "MetricsPeriod5m", + "MetricsPeriod15m", + "MetricsPeriod1h", + "MetricsPeriod1d", + "MetricsPeriod1mo" + ], + "name": "interval", + "in": "query" + }, + { + "type": "string", + "example": "", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "example": 10, + "name": "limit", + "in": "query" + }, + { + "type": "string", + "example": "10", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "period specified", + "schema": { + "$ref": "#/definitions/UptimeAggregate" + } + }, + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "uptime" + } + }, + "/reload": { + "post": { + "description": "Reload config", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "v1" + ], + "summary": "Reload config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "reload" + } + }, + "/route/by_provider": { + "get": { + "description": "List routes by provider", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route" + ], + "summary": "List routes by provider", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/routeApi.RoutesByProvider" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "byProvider" + } + }, + "/route/list": { + "get": { + "description": "List routes", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route", + "websocket" + ], + "summary": "List routes", + "parameters": [ + { + "type": "string", + "description": "Provider", + "name": "provider", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Route" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "routes" + } + }, + "/route/providers": { + "get": { + "description": "List route providers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route", + "websocket" + ], + "summary": "List route providers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/RouteProvider" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "providers" + } + }, + "/route/{which}": { + "get": { + "description": "List route", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route" + ], + "summary": "List route", + "parameters": [ + { + "type": "string", + "description": "Route name", + "name": "which", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "route" + } + }, + "/stats": { + "get": { + "description": "Get stats", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "v1", + "websocket" + ], + "summary": "Get GoDoxy stats", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/StatsResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "stats" + } + }, + "/version": { + "get": { + "description": "Get the version of the GoDoxy", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "v1" + ], + "summary": "Get version", + "responses": { + "200": { + "description": "version", + "schema": { + "type": "string" + } + } + }, + "x-id": "version" + } + } + }, + "definitions": { + "Agent": { + "type": "object", + "properties": { + "addr": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "CIDR": { + "type": "object", + "properties": { + "ip": { + "description": "network number", + "type": "array", + "items": { + "type": "integer" + } + }, + "mask": { + "description": "network mask", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "CertInfo": { + "type": "object", + "properties": { + "dns_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "email_addresses": { + "type": "array", + "items": { + "type": "string" + } + }, + "issuer": { + "type": "string" + }, + "not_after": { + "type": "integer" + }, + "not_before": { + "type": "integer" + }, + "subject": { + "type": "string" + } + } + }, + "Container": { + "type": "object", + "properties": { + "agent": { + "$ref": "#/definitions/Agent" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "container_id": { + "type": "string" + }, + "container_name": { + "type": "string" + }, + "docker_host": { + "type": "string" + }, + "errors": { + "type": "string" + }, + "idlewatcher_config": { + "$ref": "#/definitions/IdlewatcherConfig" + }, + "image": { + "$ref": "#/definitions/ContainerImage" + }, + "is_excluded": { + "type": "boolean" + }, + "is_explicit": { + "type": "boolean" + }, + "is_host_network_mode": { + "type": "boolean" + }, + "mounts": { + "type": "array", + "items": { + "type": "string" + } + }, + "network": { + "type": "string" + }, + "private_hostname": { + "type": "string" + }, + "private_ports": { + "description": "privatePort:types.Port", + "allOf": [ + { + "$ref": "#/definitions/types.PortMapping" + } + ] + }, + "public_hostname": { + "type": "string" + }, + "public_ports": { + "description": "non-zero publicPort:types.Port", + "allOf": [ + { + "$ref": "#/definitions/types.PortMapping" + } + ] + }, + "running": { + "type": "boolean" + } + } + }, + "ContainerImage": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "ContainerResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "image": { + "type": "string" + }, + "name": { + "type": "string" + }, + "server": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "ContainerStats": { + "type": "object", + "properties": { + "paused": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "stopped": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "ContainerStopMethod": { + "type": "string", + "enum": [ + "pause", + "stop", + "kill" + ], + "x-enum-varnames": [ + "ContainerStopMethodPause", + "ContainerStopMethodStop", + "ContainerStopMethodKill" + ] + }, + "DockerConfig": { + "type": "object", + "required": [ + "container_id", + "container_name", + "docker_host" + ], + "properties": { + "container_id": { + "type": "string" + }, + "container_name": { + "type": "string" + }, + "docker_host": { + "type": "string" + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "x-nullable": true + }, + "message": { + "type": "string" + } + } + }, + "FileType": { + "type": "string", + "enum": [ + "config", + "provider", + "middleware" + ], + "x-enum-comments": { + "FileTypeConfig": "@name FileTypeConfig", + "FileTypeMiddleware": "@name FileTypeMiddleware", + "FileTypeProvider": "@name FileTypeProvider" + }, + "x-enum-varnames": [ + "FileTypeConfig", + "FileTypeProvider", + "FileTypeMiddleware" + ] + }, + "HTTPHeader": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "HealthCheckConfig": { + "type": "object", + "properties": { + "disable": { + "type": "boolean" + }, + "interval": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "retries": { + "description": "\u003c0: immediate, \u003e=0: threshold", + "type": "integer" + }, + "timeout": { + "type": "integer" + }, + "use_get": { + "type": "boolean" + } + } + }, + "HealthExtra": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/LoadBalancerConfig" + }, + "pool": { + "type": "object", + "additionalProperties": {} + } + } + }, + "HealthJSON": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/HealthCheckConfig" + }, + "detail": { + "type": "string" + }, + "extra": { + "allOf": [ + { + "$ref": "#/definitions/HealthExtra" + } + ], + "x-nullable": true + }, + "lastSeen": { + "type": "integer" + }, + "lastSeenStr": { + "type": "string" + }, + "latency": { + "type": "number" + }, + "latencyStr": { + "type": "string" + }, + "name": { + "type": "string" + }, + "started": { + "type": "integer" + }, + "startedStr": { + "type": "string" + }, + "status": { + "type": "string" + }, + "uptime": { + "type": "number" + }, + "uptimeStr": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "HealthMap": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/routes.HealthInfo" + } + }, + "HomepageItems": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/homepage.Item" + } + } + }, + "HomepageOverrideCategoryOrderParams": { + "type": "object", + "properties": { + "value": { + "type": "integer" + }, + "which": { + "type": "string" + } + } + }, + "HomepageOverrideItemParams": { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/homepage.ItemConfig" + }, + "which": { + "type": "string" + } + } + }, + "HomepageOverrideItemVisibleParams": { + "type": "object", + "properties": { + "value": { + "type": "boolean" + }, + "which": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "HomepageOverrideItemsBatchParams": { + "type": "object", + "properties": { + "value": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/homepage.ItemConfig" + } + } + } + }, + "IdlewatcherConfig": { + "type": "object", + "properties": { + "depends_on": { + "type": "array", + "items": { + "type": "string" + } + }, + "docker": { + "$ref": "#/definitions/DockerConfig" + }, + "idle_timeout": { + "description": "0: no idle watcher.\nPositive: idle watcher with idle timeout.\nNegative: idle watcher as a dependency.\tIdleTimeout time.Duration ` + "`" + `json:\"idle_timeout\" json_ext:\"duration\"` + "`" + `", + "allOf": [ + { + "$ref": "#/definitions/time.Duration" + } + ] + }, + "proxmox": { + "$ref": "#/definitions/ProxmoxConfig" + }, + "start_endpoint": { + "description": "Optional path that must be hit to start container", + "type": "string" + }, + "stop_method": { + "$ref": "#/definitions/ContainerStopMethod" + }, + "stop_signal": { + "type": "string" + }, + "stop_timeout": { + "$ref": "#/definitions/time.Duration" + }, + "wake_timeout": { + "$ref": "#/definitions/time.Duration" + } + } + }, + "ListFilesResponse": { + "type": "object", + "properties": { + "config": { + "type": "array", + "items": { + "type": "string" + } + }, + "middleware": { + "type": "array", + "items": { + "type": "string" + } + }, + "provider": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "LoadBalancerConfig": { + "type": "object", + "properties": { + "link": { + "type": "string" + }, + "mode": { + "$ref": "#/definitions/LoadBalancerMode" + }, + "options": { + "type": "object", + "additionalProperties": {} + }, + "weight": { + "type": "integer" + } + } + }, + "LoadBalancerMode": { + "type": "string", + "enum": [ + "", + "roundrobin", + "leastconn", + "iphash" + ], + "x-enum-varnames": [ + "LoadbalanceModeUnset", + "LoadbalanceModeRoundRobin", + "LoadbalanceModeLeastConn", + "LoadbalanceModeIPHash" + ] + }, + "LogFilter-CIDR": { + "type": "object", + "properties": { + "negative": { + "type": "boolean" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/CIDR" + } + } + } + }, + "LogFilter-HTTPHeader": { + "type": "object", + "properties": { + "negative": { + "type": "boolean" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/HTTPHeader" + } + } + } + }, + "LogFilter-HTTPMethod": { + "type": "object", + "properties": { + "negative": { + "type": "boolean" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "LogFilter-Host": { + "type": "object", + "properties": { + "negative": { + "type": "boolean" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "LogFilter-StatusCodeRange": { + "type": "object", + "properties": { + "negative": { + "type": "boolean" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/StatusCodeRange" + } + } + } + }, + "LogRetention": { + "type": "object", + "properties": { + "days": { + "type": "integer" + }, + "keep_size": { + "type": "integer" + }, + "last": { + "type": "integer" + } + } + }, + "MetricsPeriod": { + "type": "string", + "enum": [ + "5m", + "15m", + "1h", + "1d", + "1mo" + ], + "x-enum-comments": { + "MetricsPeriod15m": "@name MetricsPeriod15m", + "MetricsPeriod1d": "@name MetricsPeriod1d", + "MetricsPeriod1h": "@name MetricsPeriod1h", + "MetricsPeriod1mo": "@name MetricsPeriod1mo", + "MetricsPeriod5m": "@name MetricsPeriod5m" + }, + "x-enum-varnames": [ + "MetricsPeriod5m", + "MetricsPeriod15m", + "MetricsPeriod1h", + "MetricsPeriod1d", + "MetricsPeriod1mo" + ] + }, + "NewAgentRequest": { + "type": "object", + "required": [ + "host", + "name", + "port", + "type" + ], + "properties": { + "host": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nightly": { + "type": "boolean" + }, + "port": { + "type": "integer", + "maximum": 65535, + "minimum": 1 + }, + "type": { + "type": "string", + "enum": [ + "docker", + "system" + ] + } + } + }, + "NewAgentResponse": { + "type": "object", + "properties": { + "ca": { + "$ref": "#/definitions/PEMPairResponse" + }, + "client": { + "$ref": "#/definitions/PEMPairResponse" + }, + "compose": { + "type": "string" + } + } + }, + "PEMPairResponse": { + "type": "object", + "properties": { + "cert": { + "type": "string", + "format": "base64" + }, + "key": { + "type": "string", + "format": "base64" + } + } + }, + "ProviderStats": { + "type": "object", + "properties": { + "reverse_proxies": { + "$ref": "#/definitions/RouteStats" + }, + "streams": { + "$ref": "#/definitions/RouteStats" + }, + "total": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/ProviderType" + } + } + }, + "ProviderType": { + "type": "string", + "enum": [ + "docker", + "file", + "agent" + ], + "x-enum-varnames": [ + "ProviderTypeDocker", + "ProviderTypeFile", + "ProviderTypeAgent" + ] + }, + "ProxmoxConfig": { + "type": "object", + "required": [ + "node", + "vmid" + ], + "properties": { + "node": { + "type": "string" + }, + "vmid": { + "type": "integer" + } + } + }, + "ProxyStats": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ProviderStats" + } + }, + "reverse_proxies": { + "$ref": "#/definitions/RouteStats" + }, + "streams": { + "$ref": "#/definitions/RouteStats" + }, + "total": { + "type": "integer" + } + } + }, + "RequestLoggerConfig": { + "type": "object", + "properties": { + "buffer_size": { + "description": "Deprecated: buffer size is adjusted dynamically", + "type": "integer" + }, + "fields": { + "$ref": "#/definitions/accesslog.Fields" + }, + "filters": { + "$ref": "#/definitions/accesslog.Filters" + }, + "format": { + "type": "string", + "enum": [ + "common", + "combined", + "json" + ] + }, + "path": { + "type": "string" + }, + "retention": { + "$ref": "#/definitions/LogRetention" + }, + "rotate_interval": { + "type": "integer" + }, + "stdout": { + "type": "boolean" + } + } + }, + "Route": { + "type": "object", + "properties": { + "access_log": { + "allOf": [ + { + "$ref": "#/definitions/RequestLoggerConfig" + } + ], + "x-nullable": true + }, + "agent": { + "type": "string" + }, + "alias": { + "type": "string" + }, + "container": { + "description": "Docker only", + "allOf": [ + { + "$ref": "#/definitions/Container" + } + ], + "x-nullable": true + }, + "disable_compression": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "health": { + "description": "for swagger", + "allOf": [ + { + "$ref": "#/definitions/HealthJSON" + } + ] + }, + "healthcheck": { + "$ref": "#/definitions/HealthCheckConfig" + }, + "homepage": { + "$ref": "#/definitions/homepage.ItemConfig" + }, + "host": { + "type": "string" + }, + "idlewatcher": { + "allOf": [ + { + "$ref": "#/definitions/IdlewatcherConfig" + } + ], + "x-nullable": true + }, + "load_balance": { + "allOf": [ + { + "$ref": "#/definitions/LoadBalancerConfig" + } + ], + "x-nullable": true + }, + "lurl": { + "description": "private fields", + "type": "string", + "x-nullable": true + }, + "middlewares": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/types.LabelMap" + }, + "x-nullable": true + }, + "no_tls_verify": { + "type": "boolean" + }, + "path_patterns": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": true + }, + "port": { + "$ref": "#/definitions/route.Port" + }, + "provider": { + "description": "for backward compatibility", + "type": "string", + "x-nullable": true + }, + "purl": { + "type": "string" + }, + "response_header_timeout": { + "type": "integer" + }, + "root": { + "type": "string" + }, + "rules": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/rules.Rule" + } + }, + "scheme": { + "$ref": "#/definitions/route.Scheme" + } + } + }, + "RouteProvider": { + "type": "object", + "properties": { + "full_name": { + "type": "string" + }, + "short_name": { + "type": "string" + } + } + }, + "RouteStats": { + "type": "object", + "properties": { + "error": { + "type": "integer" + }, + "healthy": { + "type": "integer" + }, + "napping": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "unhealthy": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "RouteStatus": { + "type": "object", + "properties": { + "latency": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy", + "unknown", + "napping", + "starting" + ] + }, + "timestamp": { + "type": "integer" + } + } + }, + "RouteStatusesByAlias": { + "type": "object", + "properties": { + "statuses": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/routes.HealthInfo" + } + }, + "timestamp": { + "type": "integer" + } + } + }, + "RouteUptimeAggregate": { + "type": "object", + "properties": { + "alias": { + "type": "string" + }, + "avg_latency": { + "type": "number" + }, + "display_name": { + "type": "string" + }, + "downtime": { + "type": "number" + }, + "idle": { + "type": "number" + }, + "statuses": { + "type": "array", + "items": { + "$ref": "#/definitions/RouteStatus" + } + }, + "uptime": { + "type": "number" + } + } + }, + "ServerInfo": { + "type": "object", + "properties": { + "containers": { + "$ref": "#/definitions/ContainerStats" + }, + "images": { + "type": "integer" + }, + "memory": { + "type": "string" + }, + "n_cpu": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "StatsResponse": { + "type": "object", + "properties": { + "proxies": { + "$ref": "#/definitions/ProxyStats" + }, + "uptime": { + "type": "string" + } + } + }, + "StatusCodeRange": { + "type": "object", + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + } + }, + "SuccessResponse": { + "type": "object", + "properties": { + "details": { + "type": "object", + "additionalProperties": {}, + "x-nullable": true + }, + "message": { + "type": "string" + } + } + }, + "SystemInfo": { + "type": "object", + "properties": { + "cpu_average": { + "type": "number" + }, + "disks": { + "description": "disk usage by partition", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/disk.UsageStat" + } + }, + "disks_io": { + "description": "disk IO by device", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/disk.IOCountersStat" + } + }, + "memory": { + "$ref": "#/definitions/mem.VirtualMemoryStat" + }, + "network": { + "$ref": "#/definitions/net.IOCountersStat" + }, + "sensors": { + "description": "sensor temperature by key", + "type": "array", + "items": { + "$ref": "#/definitions/sensors.TemperatureStat" + } + }, + "timestamp": { + "type": "integer" + } + } + }, + "SystemInfoAggregate": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "total": { + "type": "integer" + } + } + }, + "UptimeAggregate": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/RouteUptimeAggregate" + } + }, + "total": { + "type": "integer" + } + } + }, + "VerifyNewAgentRequest": { + "type": "object", + "properties": { + "ca": { + "$ref": "#/definitions/PEMPairResponse" + }, + "client": { + "$ref": "#/definitions/PEMPairResponse" + }, + "host": { + "type": "string" + } + } + }, + "accesslog.FieldConfig": { + "type": "object", + "properties": { + "config": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/accesslog.FieldMode" + } + }, + "default": { + "enum": [ + "keep", + "drop", + "redact" + ], + "allOf": [ + { + "$ref": "#/definitions/accesslog.FieldMode" + } + ] + } + } + }, + "accesslog.FieldMode": { + "type": "string", + "enum": [ + "keep", + "drop", + "redact" + ], + "x-enum-varnames": [ + "FieldModeKeep", + "FieldModeDrop", + "FieldModeRedact" + ] + }, + "accesslog.Fields": { + "type": "object", + "properties": { + "cookies": { + "$ref": "#/definitions/accesslog.FieldConfig" + }, + "headers": { + "$ref": "#/definitions/accesslog.FieldConfig" + }, + "query": { + "$ref": "#/definitions/accesslog.FieldConfig" + } + } + }, + "accesslog.Filters": { + "type": "object", + "properties": { + "cidr": { + "$ref": "#/definitions/LogFilter-CIDR" + }, + "headers": { + "description": "header exists or header == value", + "allOf": [ + { + "$ref": "#/definitions/LogFilter-HTTPHeader" + } + ] + }, + "host": { + "$ref": "#/definitions/LogFilter-Host" + }, + "method": { + "$ref": "#/definitions/LogFilter-HTTPMethod" + }, + "status_codes": { + "$ref": "#/definitions/LogFilter-StatusCodeRange" + } + } + }, + "auth.UserPassAuthCallbackRequest": { + "type": "object", + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "container.Port": { + "type": "object", + "properties": { + "IP": { + "description": "Host IP address that the container's port is mapped to", + "type": "string" + }, + "PrivatePort": { + "description": "Port on the container\nRequired: true", + "type": "integer" + }, + "PublicPort": { + "description": "Port exposed on the host", + "type": "integer" + }, + "Type": { + "description": "type\nRequired: true", + "type": "string" + } + } + }, + "disk.IOCountersStat": { + "type": "object", + "properties": { + "iops": { + "description": "godoxy", + "type": "integer" + }, + "name": { + "description": "ReadCount uint64 ` + "`" + `json:\"readCount\"` + "`" + `\nMergedReadCount uint64 ` + "`" + `json:\"mergedReadCount\"` + "`" + `\nWriteCount uint64 ` + "`" + `json:\"writeCount\"` + "`" + `\nMergedWriteCount uint64 ` + "`" + `json:\"mergedWriteCount\"` + "`" + `\nReadBytes uint64 ` + "`" + `json:\"readBytes\"` + "`" + `\nWriteBytes uint64 ` + "`" + `json:\"writeBytes\"` + "`" + `\nReadTime uint64 ` + "`" + `json:\"readTime\"` + "`" + `\nWriteTime uint64 ` + "`" + `json:\"writeTime\"` + "`" + `\nIopsInProgress uint64 ` + "`" + `json:\"iopsInProgress\"` + "`" + `\nIoTime uint64 ` + "`" + `json:\"ioTime\"` + "`" + `\nWeightedIO uint64 ` + "`" + `json:\"weightedIO\"` + "`" + `", + "type": "string" + }, + "read_bytes": { + "description": "SerialNumber string ` + "`" + `json:\"serialNumber\"` + "`" + `\nLabel string ` + "`" + `json:\"label\"` + "`" + `", + "type": "integer" + }, + "read_count": { + "type": "integer" + }, + "read_speed": { + "description": "godoxy", + "type": "number" + }, + "write_bytes": { + "type": "integer" + }, + "write_count": { + "type": "integer" + }, + "write_speed": { + "description": "godoxy", + "type": "number" + } + } + }, + "disk.UsageStat": { + "type": "object", + "properties": { + "free": { + "type": "integer" + }, + "fstype": { + "type": "string" + }, + "path": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "used": { + "type": "integer" + }, + "used_percent": { + "type": "number" + } + } + }, + "homepage.FetchResult": { + "type": "object", + "properties": { + "errMsg": { + "type": "string" + }, + "icon": { + "type": "array", + "items": { + "type": "integer" + } + }, + "statusCode": { + "type": "integer" + } + } + }, + "homepage.IconExtra": { + "type": "object", + "properties": { + "file_type": { + "type": "string" + }, + "is_dark": { + "type": "boolean" + }, + "is_light": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "ref": { + "type": "string" + } + } + }, + "homepage.IconMetaSearch": { + "type": "object", + "properties": { + "dark": { + "type": "boolean" + }, + "light": { + "type": "boolean" + }, + "png": { + "type": "boolean" + }, + "ref": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/homepage.IconSource" + }, + "svg": { + "type": "boolean" + }, + "webP": { + "type": "boolean" + } + } + }, + "homepage.IconSource": { + "type": "string", + "enum": [ + "https://", + "@target", + "@walkxcode", + "@selfhst" + ], + "x-enum-varnames": [ + "IconSourceAbsolute", + "IconSourceRelative", + "IconSourceWalkXCode", + "IconSourceSelfhSt" + ] + }, + "homepage.IconURL": { + "type": "object", + "properties": { + "extra": { + "description": "only for walkxcode/selfhst icons", + "allOf": [ + { + "$ref": "#/definitions/homepage.IconExtra" + } + ] + }, + "source": { + "$ref": "#/definitions/homepage.IconSource" + }, + "value": { + "description": "only for absolute/relative icons", + "type": "string" + } + } + }, + "homepage.Item": { + "type": "object", + "properties": { + "alias": { + "type": "string" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "icon": { + "$ref": "#/definitions/homepage.IconURL" + }, + "name": { + "description": "display name", + "type": "string" + }, + "origin_url": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "show": { + "type": "boolean" + }, + "sort_order": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "widget_config": { + "$ref": "#/definitions/widgets.Config" + } + } + }, + "homepage.ItemConfig": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "icon": { + "$ref": "#/definitions/homepage.IconURL" + }, + "name": { + "description": "display name", + "type": "string" + }, + "show": { + "type": "boolean" + }, + "sort_order": { + "type": "integer" + }, + "url": { + "type": "string" + } + } + }, + "homepageapi.SetHomePageOverridesRequest": { + "type": "object", + "required": [ + "value", + "what" + ], + "properties": { + "value": {}, + "what": { + "type": "string", + "enum": [ + "item", + "items_batch", + "category_order", + "item_visible" + ] + } + } + }, + "mem.VirtualMemoryStat": { + "type": "object", + "properties": { + "available": { + "description": "RAM available for programs to allocate\n\nThis value is computed from the kernel specific values.", + "type": "integer" + }, + "free": { + "description": "This is the kernel's notion of free memory; RAM chips whose bits nobody\ncares about the value of right now. For a human consumable number,\nAvailable is what you really want.", + "type": "integer" + }, + "total": { + "description": "Total amount of RAM on this system", + "type": "integer" + }, + "used": { + "description": "RAM used by programs\n\nThis value is computed from the kernel specific values.", + "type": "integer" + }, + "used_percent": { + "description": "Percentage of RAM used by programs\n\nThis value is computed from the kernel specific values.", + "type": "number" + } + } + }, + "net.IOCountersStat": { + "type": "object", + "properties": { + "bytes_recv": { + "description": "number of bytes received", + "type": "integer" + }, + "bytes_sent": { + "description": "Name string ` + "`" + `json:\"name\"` + "`" + ` // interface name", + "type": "integer" + }, + "download_speed": { + "description": "godoxy", + "type": "number" + }, + "upload_speed": { + "description": "godoxy", + "type": "number" + } + } + }, + "route.Port": { + "type": "object", + "properties": { + "listening": { + "type": "integer" + }, + "proxy": { + "type": "integer" + } + } + }, + "route.Route": { + "type": "object", + "properties": { + "access_log": { + "allOf": [ + { + "$ref": "#/definitions/RequestLoggerConfig" + } + ], + "x-nullable": true + }, + "agent": { + "type": "string" + }, + "alias": { + "type": "string" + }, + "container": { + "description": "Docker only", + "allOf": [ + { + "$ref": "#/definitions/Container" + } + ], + "x-nullable": true + }, + "disable_compression": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "health": { + "description": "for swagger", + "allOf": [ + { + "$ref": "#/definitions/HealthJSON" + } + ] + }, + "healthcheck": { + "$ref": "#/definitions/HealthCheckConfig" + }, + "homepage": { + "$ref": "#/definitions/homepage.ItemConfig" + }, + "host": { + "type": "string" + }, + "idlewatcher": { + "allOf": [ + { + "$ref": "#/definitions/IdlewatcherConfig" + } + ], + "x-nullable": true + }, + "load_balance": { + "allOf": [ + { + "$ref": "#/definitions/LoadBalancerConfig" + } + ], + "x-nullable": true + }, + "lurl": { + "description": "private fields", + "type": "string", + "x-nullable": true + }, + "middlewares": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/types.LabelMap" + }, + "x-nullable": true + }, + "no_tls_verify": { + "type": "boolean" + }, + "path_patterns": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": true + }, + "port": { + "$ref": "#/definitions/route.Port" + }, + "provider": { + "description": "for backward compatibility", + "type": "string", + "x-nullable": true + }, + "purl": { + "type": "string" + }, + "response_header_timeout": { + "type": "integer" + }, + "root": { + "type": "string" + }, + "rules": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/rules.Rule" + } + }, + "scheme": { + "$ref": "#/definitions/route.Scheme" + } + } + }, + "route.Scheme": { + "type": "string", + "enum": [ + "http", + "https", + "tcp", + "udp", + "fileserver" + ], + "x-enum-varnames": [ + "SchemeHTTP", + "SchemeHTTPS", + "SchemeTCP", + "SchemeUDP", + "SchemeFileServer" + ] + }, + "routeApi.RoutesByProvider": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/route.Route" + } + } + }, + "routes.HealthInfo": { + "type": "object", + "properties": { + "detail": { + "type": "string" + }, + "latency": { + "description": "latency in microseconds", + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy", + "napping", + "starting", + "error", + "unknown" + ] + }, + "uptime": { + "description": "uptime in milliseconds", + "type": "number" + } + } + }, + "rules.Command": { + "type": "object" + }, + "rules.Rule": { + "type": "object", + "properties": { + "do": { + "$ref": "#/definitions/rules.Command" + }, + "name": { + "type": "string" + }, + "on": { + "$ref": "#/definitions/rules.RuleOn" + } + } + }, + "rules.RuleOn": { + "type": "object" + }, + "sensors.TemperatureStat": { + "type": "object", + "properties": { + "critical": { + "type": "number" + }, + "high": { + "type": "number" + }, + "name": { + "type": "string" + }, + "temperature": { + "type": "number" + } + } + }, + "time.Duration": { + "type": "integer", + "enum": [ + -9223372036854775808, + 9223372036854775807, + 1, + 1000, + 1000000, + 1000000000, + 60000000000, + 3600000000000 + ], + "x-enum-varnames": [ + "minDuration", + "maxDuration", + "Nanosecond", + "Microsecond", + "Millisecond", + "Second", + "Minute", + "Hour" + ] + }, + "types.LabelMap": { + "type": "object", + "additionalProperties": {} + }, + "types.PortMapping": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/container.Port" + } + }, + "widgets.Config": { + "type": "object", + "properties": { + "config": {}, + "provider": { + "type": "string" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "", + Host: "", + BasePath: "", + Schemes: []string{}, + Title: "", + Description: "", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/internal/api/v1/docs/swagger.json b/internal/api/v1/docs/swagger.json new file mode 100644 index 00000000..34afc313 --- /dev/null +++ b/internal/api/v1/docs/swagger.json @@ -0,0 +1,4144 @@ +{ + "swagger": "2.0", + "info": { + "contact": {} + }, + "paths": { + "/agent/create": { + "post": { + "description": "Create a new agent and return the docker compose file, encrypted CA and client PEMs\nThe returned PEMs are encrypted with a random key and will be used for verification when adding a new agent", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agent" + ], + "summary": "Create a new agent", + "parameters": [ + { + "description": "Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewAgentRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/NewAgentResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "create", + "operationId": "create" + } + }, + "/agent/list": { + "get": { + "description": "List agents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agent", + "websocket" + ], + "summary": "List agents", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Agent" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "list", + "operationId": "list" + } + }, + "/agent/verify": { + "post": { + "description": "Verify a new agent and return the number of routes added", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "agent" + ], + "summary": "Verify a new agent", + "parameters": [ + { + "description": "Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/VerifyNewAgentRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "verify", + "operationId": "verify" + } + }, + "/auth/callback": { + "post": { + "description": "Handles the callback from the provider after successful authentication", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Post Auth Callback", + "parameters": [ + { + "description": "Userpass only", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.UserPassAuthCallbackRequest" + } + } + ], + "responses": { + "200": { + "description": "Userpass: OK", + "schema": { + "type": "string" + } + }, + "302": { + "description": "OIDC: Redirects to home page", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Userpass: invalid request / credentials", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + }, + "x-id": "callback", + "operationId": "callback" + } + }, + "/auth/check": { + "head": { + "description": "Checks if the user is authenticated by validating their token", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Check authentication status", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden: use X-Redirect-To header to redirect to login page", + "schema": { + "type": "string" + } + } + }, + "x-id": "check", + "operationId": "check" + } + }, + "/auth/login": { + "post": { + "description": "Initiates the login process by redirecting the user to the provider's login page", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Login", + "responses": { + "302": { + "description": "Redirects to login page or IdP", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden(webui): follow X-Redirect-To header", + "schema": { + "type": "string" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "type": "string" + } + } + }, + "x-id": "login", + "operationId": "login" + } + }, + "/auth/logout": { + "post": { + "description": "Logs out the user by invalidating the token", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Logout", + "responses": { + "302": { + "description": "Redirects to home page", + "schema": { + "type": "string" + } + } + }, + "x-id": "logout", + "operationId": "logout" + } + }, + "/cert/info": { + "get": { + "description": "Get cert info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cert" + ], + "summary": "Get cert info", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/CertInfo" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/cert/renew": { + "post": { + "description": "Renew cert", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cert" + ], + "summary": "Renew cert", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/docker/containers": { + "get": { + "description": "Get containers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "docker" + ], + "summary": "Get containers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ContainerResponse" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/docker/info": { + "get": { + "description": "Get docker info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "docker" + ], + "summary": "Get docker info", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ServerInfo" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/docker/logs/{server}/{container}": { + "get": { + "description": "Get docker container logs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "docker", + "websocket" + ], + "summary": "Get docker container logs", + "parameters": [ + { + "type": "string", + "description": "server name", + "name": "server", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "container id", + "name": "container", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "show stdout", + "name": "stdout", + "in": "query" + }, + { + "type": "boolean", + "description": "show stderr", + "name": "stderr", + "in": "query" + }, + { + "type": "string", + "description": "from timestamp", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "to timestamp", + "name": "to", + "in": "query" + }, + { + "type": "string", + "description": "levels", + "name": "levels", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/favicon": { + "get": { + "description": "Get favicon", + "consumes": [ + "application/json" + ], + "produces": [ + "image/svg+xml", + "image/x-icon", + "image/png", + "image/webp" + ], + "tags": [ + "v1" + ], + "summary": "Get favicon", + "parameters": [ + { + "type": "string", + "description": "URL of the route", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "Alias of the route", + "name": "alias", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/homepage.FetchResult" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "favicon", + "operationId": "favicon" + } + }, + "/file/content": { + "get": { + "description": "Get file content", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json", + "application/godoxy+yaml" + ], + "tags": [ + "file" + ], + "summary": "Get file content", + "parameters": [ + { + "type": "string", + "format": "filename", + "name": "filename", + "in": "query", + "required": true + }, + { + "enum": [ + "config", + "provider", + "middleware" + ], + "type": "string", + "x-enum-comments": { + "FileTypeConfig": "@name FileTypeConfig", + "FileTypeMiddleware": "@name FileTypeMiddleware", + "FileTypeProvider": "@name FileTypeProvider" + }, + "x-enum-varnames": [ + "FileTypeConfig", + "FileTypeProvider", + "FileTypeMiddleware" + ], + "name": "type", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "get", + "operationId": "get" + }, + "put": { + "description": "Set file content", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "file" + ], + "summary": "Set file content", + "parameters": [ + { + "enum": [ + "config", + "provider", + "middleware" + ], + "type": "string", + "description": "Type", + "name": "type", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Filename", + "name": "filename", + "in": "query", + "required": true + }, + { + "description": "File", + "name": "file", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "set", + "operationId": "set" + } + }, + "/file/list": { + "get": { + "description": "List files", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "file" + ], + "summary": "List files", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ListFilesResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "list", + "operationId": "list" + } + }, + "/file/validate": { + "post": { + "description": "Validate file", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "file" + ], + "summary": "Validate file", + "parameters": [ + { + "enum": [ + "config", + "provider", + "middleware" + ], + "type": "string", + "description": "Type", + "name": "type", + "in": "query", + "required": true + }, + { + "description": "File content", + "name": "file", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File validated", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "417": { + "description": "Validation failed", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "validate", + "operationId": "validate" + } + }, + "/health": { + "get": { + "description": "Get health info by route name", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "v1", + "websocket" + ], + "summary": "Get routes health info", + "responses": { + "200": { + "description": "Health info by route name", + "schema": { + "$ref": "#/definitions/HealthMap" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "health", + "operationId": "health" + } + }, + "/homepage/categories": { + "get": { + "description": "List homepage categories", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "homepage" + ], + "summary": "List homepage categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "categories", + "operationId": "categories" + } + }, + "/homepage/items": { + "get": { + "description": "Homepage items", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "homepage" + ], + "summary": "Homepage items", + "parameters": [ + { + "type": "string", + "description": "Category filter", + "name": "category", + "in": "query" + }, + { + "type": "string", + "description": "Provider filter", + "name": "provider", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/HomepageItems" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "items", + "operationId": "items" + } + }, + "/homepage/set": { + "post": { + "description": "Set homepage overrides", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "homepage" + ], + "summary": "Set homepage overrides", + "parameters": [ + { + "description": "Override single item", + "name": "request", + "in": "body", + "required": true, + "schema": { + "allOf": [ + { + "$ref": "#/definitions/homepageapi.SetHomePageOverridesRequest" + }, + { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/HomepageOverrideItemParams" + } + } + } + ] + } + }, + { + "description": "Override multiple items", + "name": "request", + "in": "body", + "required": true, + "schema": { + "allOf": [ + { + "$ref": "#/definitions/homepageapi.SetHomePageOverridesRequest" + }, + { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/HomepageOverrideItemsBatchParams" + } + } + } + ] + } + }, + { + "description": "Override category order", + "name": "request", + "in": "body", + "required": true, + "schema": { + "allOf": [ + { + "$ref": "#/definitions/homepageapi.SetHomePageOverridesRequest" + }, + { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/HomepageOverrideCategoryOrderParams" + } + } + } + ] + } + }, + { + "description": "Override item visibility", + "name": "request", + "in": "body", + "required": true, + "schema": { + "allOf": [ + { + "$ref": "#/definitions/homepageapi.SetHomePageOverridesRequest" + }, + { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/HomepageOverrideItemVisibleParams" + } + } + } + ] + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "set", + "operationId": "set" + } + }, + "/icons": { + "get": { + "description": "List icons", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "v1" + ], + "summary": "List icons", + "parameters": [ + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/homepage.IconMetaSearch" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "icons", + "operationId": "icons" + } + }, + "/metrics/system_info": { + "get": { + "description": "Get system info", + "produces": [ + "application/json" + ], + "tags": [ + "metrics", + "websocket" + ], + "summary": "Get system info", + "parameters": [ + { + "type": "string", + "description": "Agent address", + "name": "agent_addr", + "in": "query" + }, + { + "type": "string", + "description": "Period", + "name": "period", + "in": "query" + } + ], + "responses": { + "200": { + "description": "period specified", + "schema": { + "$ref": "#/definitions/SystemInfoAggregate" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "system_info", + "operationId": "system_info" + } + }, + "/metrics/uptime": { + "get": { + "description": "Get uptime", + "produces": [ + "application/json" + ], + "tags": [ + "metrics", + "websocket" + ], + "summary": "Get uptime", + "parameters": [ + { + "enum": [ + "5m", + "15m", + "1h", + "1d", + "1mo" + ], + "type": "string", + "example": "1m", + "x-enum-comments": { + "MetricsPeriod15m": "@name MetricsPeriod15m", + "MetricsPeriod1d": "@name MetricsPeriod1d", + "MetricsPeriod1h": "@name MetricsPeriod1h", + "MetricsPeriod1mo": "@name MetricsPeriod1mo", + "MetricsPeriod5m": "@name MetricsPeriod5m" + }, + "x-enum-varnames": [ + "MetricsPeriod5m", + "MetricsPeriod15m", + "MetricsPeriod1h", + "MetricsPeriod1d", + "MetricsPeriod1mo" + ], + "name": "interval", + "in": "query" + }, + { + "type": "string", + "example": "", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "example": 10, + "name": "limit", + "in": "query" + }, + { + "type": "string", + "example": "10", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "period specified", + "schema": { + "$ref": "#/definitions/UptimeAggregate" + } + }, + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "uptime", + "operationId": "uptime" + } + }, + "/reload": { + "post": { + "description": "Reload config", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "v1" + ], + "summary": "Reload config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "reload", + "operationId": "reload" + } + }, + "/route/by_provider": { + "get": { + "description": "List routes by provider", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route" + ], + "summary": "List routes by provider", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/routeApi.RoutesByProvider" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "byProvider", + "operationId": "byProvider" + } + }, + "/route/list": { + "get": { + "description": "List routes", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route", + "websocket" + ], + "summary": "List routes", + "parameters": [ + { + "type": "string", + "description": "Provider", + "name": "provider", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Route" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "routes", + "operationId": "routes" + } + }, + "/route/providers": { + "get": { + "description": "List route providers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route", + "websocket" + ], + "summary": "List route providers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/RouteProvider" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "providers", + "operationId": "providers" + } + }, + "/route/{which}": { + "get": { + "description": "List route", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route" + ], + "summary": "List route", + "parameters": [ + { + "type": "string", + "description": "Route name", + "name": "which", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "route", + "operationId": "route" + } + }, + "/stats": { + "get": { + "description": "Get stats", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "v1", + "websocket" + ], + "summary": "Get GoDoxy stats", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/StatsResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "stats", + "operationId": "stats" + } + }, + "/version": { + "get": { + "description": "Get the version of the GoDoxy", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "v1" + ], + "summary": "Get version", + "responses": { + "200": { + "description": "version", + "schema": { + "type": "string" + } + } + }, + "x-id": "version", + "operationId": "version" + } + } + }, + "definitions": { + "Agent": { + "type": "object", + "properties": { + "addr": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "version": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "CIDR": { + "type": "object", + "properties": { + "ip": { + "description": "network number", + "type": "array", + "items": { + "type": "integer" + }, + "x-nullable": false, + "x-omitempty": false + }, + "mask": { + "description": "network mask", + "type": "array", + "items": { + "type": "integer" + }, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "CertInfo": { + "type": "object", + "properties": { + "dns_names": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + }, + "email_addresses": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + }, + "issuer": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "not_after": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "not_before": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "subject": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "Container": { + "type": "object", + "properties": { + "agent": { + "$ref": "#/definitions/Agent", + "x-nullable": false, + "x-omitempty": false + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + }, + "container_id": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "container_name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "docker_host": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "errors": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "idlewatcher_config": { + "$ref": "#/definitions/IdlewatcherConfig", + "x-nullable": false, + "x-omitempty": false + }, + "image": { + "$ref": "#/definitions/ContainerImage", + "x-nullable": false, + "x-omitempty": false + }, + "is_excluded": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "is_explicit": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "is_host_network_mode": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "mounts": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + }, + "network": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "private_hostname": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "private_ports": { + "description": "privatePort:types.Port", + "allOf": [ + { + "$ref": "#/definitions/types.PortMapping" + } + ], + "x-nullable": false, + "x-omitempty": false + }, + "public_hostname": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "public_ports": { + "description": "non-zero publicPort:types.Port", + "allOf": [ + { + "$ref": "#/definitions/types.PortMapping" + } + ], + "x-nullable": false, + "x-omitempty": false + }, + "running": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ContainerImage": { + "type": "object", + "properties": { + "author": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "tag": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ContainerResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "image": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "server": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "state": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ContainerStats": { + "type": "object", + "properties": { + "paused": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "running": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "stopped": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "total": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ContainerStopMethod": { + "type": "string", + "enum": [ + "pause", + "stop", + "kill" + ], + "x-enum-varnames": [ + "ContainerStopMethodPause", + "ContainerStopMethodStop", + "ContainerStopMethodKill" + ], + "x-nullable": false, + "x-omitempty": false + }, + "DockerConfig": { + "type": "object", + "required": [ + "container_id", + "container_name", + "docker_host" + ], + "properties": { + "container_id": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "container_name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "docker_host": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "x-nullable": true + }, + "message": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "FileType": { + "type": "string", + "enum": [ + "config", + "provider", + "middleware" + ], + "x-enum-comments": { + "FileTypeConfig": "@name FileTypeConfig", + "FileTypeMiddleware": "@name FileTypeMiddleware", + "FileTypeProvider": "@name FileTypeProvider" + }, + "x-enum-varnames": [ + "FileTypeConfig", + "FileTypeProvider", + "FileTypeMiddleware" + ], + "x-nullable": false, + "x-omitempty": false + }, + "HTTPHeader": { + "type": "object", + "properties": { + "key": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "value": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "HealthCheckConfig": { + "type": "object", + "properties": { + "disable": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "interval": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "path": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "retries": { + "description": "<0: immediate, >=0: threshold", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "timeout": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "use_get": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "HealthExtra": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/LoadBalancerConfig", + "x-nullable": false, + "x-omitempty": false + }, + "pool": { + "type": "object", + "additionalProperties": {}, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "HealthJSON": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/HealthCheckConfig", + "x-nullable": false, + "x-omitempty": false + }, + "detail": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "extra": { + "allOf": [ + { + "$ref": "#/definitions/HealthExtra" + } + ], + "x-nullable": true + }, + "lastSeen": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "lastSeenStr": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "latency": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "latencyStr": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "started": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "startedStr": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "status": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "uptime": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "uptimeStr": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "url": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "HealthMap": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/routes.HealthInfo" + }, + "x-nullable": false, + "x-omitempty": false + }, + "HomepageItems": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/homepage.Item" + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "HomepageOverrideCategoryOrderParams": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "which": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "HomepageOverrideItemParams": { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/homepage.ItemConfig", + "x-nullable": false, + "x-omitempty": false + }, + "which": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "HomepageOverrideItemVisibleParams": { + "type": "object", + "properties": { + "value": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "which": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "HomepageOverrideItemsBatchParams": { + "type": "object", + "properties": { + "value": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/homepage.ItemConfig" + }, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "IdlewatcherConfig": { + "type": "object", + "properties": { + "depends_on": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + }, + "docker": { + "$ref": "#/definitions/DockerConfig", + "x-nullable": false, + "x-omitempty": false + }, + "idle_timeout": { + "description": "0: no idle watcher.\nPositive: idle watcher with idle timeout.\nNegative: idle watcher as a dependency.\tIdleTimeout time.Duration `json:\"idle_timeout\" json_ext:\"duration\"`", + "allOf": [ + { + "$ref": "#/definitions/time.Duration" + } + ], + "x-nullable": false, + "x-omitempty": false + }, + "proxmox": { + "$ref": "#/definitions/ProxmoxConfig", + "x-nullable": false, + "x-omitempty": false + }, + "start_endpoint": { + "description": "Optional path that must be hit to start container", + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "stop_method": { + "$ref": "#/definitions/ContainerStopMethod", + "x-nullable": false, + "x-omitempty": false + }, + "stop_signal": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "stop_timeout": { + "$ref": "#/definitions/time.Duration", + "x-nullable": false, + "x-omitempty": false + }, + "wake_timeout": { + "$ref": "#/definitions/time.Duration", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ListFilesResponse": { + "type": "object", + "properties": { + "config": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + }, + "middleware": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + }, + "provider": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "LoadBalancerConfig": { + "type": "object", + "properties": { + "link": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "mode": { + "$ref": "#/definitions/LoadBalancerMode", + "x-nullable": false, + "x-omitempty": false + }, + "options": { + "type": "object", + "additionalProperties": {}, + "x-nullable": false, + "x-omitempty": false + }, + "weight": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "LoadBalancerMode": { + "type": "string", + "enum": [ + "", + "roundrobin", + "leastconn", + "iphash" + ], + "x-enum-varnames": [ + "LoadbalanceModeUnset", + "LoadbalanceModeRoundRobin", + "LoadbalanceModeLeastConn", + "LoadbalanceModeIPHash" + ], + "x-nullable": false, + "x-omitempty": false + }, + "LogFilter-CIDR": { + "type": "object", + "properties": { + "negative": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/CIDR" + }, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "LogFilter-HTTPHeader": { + "type": "object", + "properties": { + "negative": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/HTTPHeader" + }, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "LogFilter-HTTPMethod": { + "type": "object", + "properties": { + "negative": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "LogFilter-Host": { + "type": "object", + "properties": { + "negative": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "LogFilter-StatusCodeRange": { + "type": "object", + "properties": { + "negative": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/StatusCodeRange" + }, + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "LogRetention": { + "type": "object", + "properties": { + "days": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "keep_size": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "last": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "MetricsPeriod": { + "type": "string", + "enum": [ + "5m", + "15m", + "1h", + "1d", + "1mo" + ], + "x-enum-comments": { + "MetricsPeriod15m": "@name MetricsPeriod15m", + "MetricsPeriod1d": "@name MetricsPeriod1d", + "MetricsPeriod1h": "@name MetricsPeriod1h", + "MetricsPeriod1mo": "@name MetricsPeriod1mo", + "MetricsPeriod5m": "@name MetricsPeriod5m" + }, + "x-enum-varnames": [ + "MetricsPeriod5m", + "MetricsPeriod15m", + "MetricsPeriod1h", + "MetricsPeriod1d", + "MetricsPeriod1mo" + ], + "x-nullable": false, + "x-omitempty": false + }, + "NewAgentRequest": { + "type": "object", + "required": [ + "host", + "name", + "port", + "type" + ], + "properties": { + "host": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "nightly": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "port": { + "type": "integer", + "maximum": 65535, + "minimum": 1, + "x-nullable": false, + "x-omitempty": false + }, + "type": { + "type": "string", + "enum": [ + "docker", + "system" + ], + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "NewAgentResponse": { + "type": "object", + "properties": { + "ca": { + "$ref": "#/definitions/PEMPairResponse", + "x-nullable": false, + "x-omitempty": false + }, + "client": { + "$ref": "#/definitions/PEMPairResponse", + "x-nullable": false, + "x-omitempty": false + }, + "compose": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "PEMPairResponse": { + "type": "object", + "properties": { + "cert": { + "type": "string", + "format": "base64", + "x-nullable": false, + "x-omitempty": false + }, + "key": { + "type": "string", + "format": "base64", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ProviderStats": { + "type": "object", + "properties": { + "reverse_proxies": { + "$ref": "#/definitions/RouteStats", + "x-nullable": false, + "x-omitempty": false + }, + "streams": { + "$ref": "#/definitions/RouteStats", + "x-nullable": false, + "x-omitempty": false + }, + "total": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "type": { + "$ref": "#/definitions/ProviderType", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ProviderType": { + "type": "string", + "enum": [ + "docker", + "file", + "agent" + ], + "x-enum-varnames": [ + "ProviderTypeDocker", + "ProviderTypeFile", + "ProviderTypeAgent" + ], + "x-nullable": false, + "x-omitempty": false + }, + "ProxmoxConfig": { + "type": "object", + "required": [ + "node", + "vmid" + ], + "properties": { + "node": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "vmid": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ProxyStats": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ProviderStats" + }, + "x-nullable": false, + "x-omitempty": false + }, + "reverse_proxies": { + "$ref": "#/definitions/RouteStats", + "x-nullable": false, + "x-omitempty": false + }, + "streams": { + "$ref": "#/definitions/RouteStats", + "x-nullable": false, + "x-omitempty": false + }, + "total": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "RequestLoggerConfig": { + "type": "object", + "properties": { + "buffer_size": { + "description": "Deprecated: buffer size is adjusted dynamically", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "fields": { + "$ref": "#/definitions/accesslog.Fields", + "x-nullable": false, + "x-omitempty": false + }, + "filters": { + "$ref": "#/definitions/accesslog.Filters", + "x-nullable": false, + "x-omitempty": false + }, + "format": { + "type": "string", + "enum": [ + "common", + "combined", + "json" + ], + "x-nullable": false, + "x-omitempty": false + }, + "path": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "retention": { + "$ref": "#/definitions/LogRetention", + "x-nullable": false, + "x-omitempty": false + }, + "rotate_interval": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "stdout": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "Route": { + "type": "object", + "properties": { + "access_log": { + "allOf": [ + { + "$ref": "#/definitions/RequestLoggerConfig" + } + ], + "x-nullable": true + }, + "agent": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "alias": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "container": { + "description": "Docker only", + "allOf": [ + { + "$ref": "#/definitions/Container" + } + ], + "x-nullable": true + }, + "disable_compression": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "excluded": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "health": { + "description": "for swagger", + "allOf": [ + { + "$ref": "#/definitions/HealthJSON" + } + ], + "x-nullable": false, + "x-omitempty": false + }, + "healthcheck": { + "$ref": "#/definitions/HealthCheckConfig", + "x-nullable": false, + "x-omitempty": false + }, + "homepage": { + "$ref": "#/definitions/homepage.ItemConfig", + "x-nullable": false, + "x-omitempty": false + }, + "host": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "idlewatcher": { + "allOf": [ + { + "$ref": "#/definitions/IdlewatcherConfig" + } + ], + "x-nullable": true + }, + "load_balance": { + "allOf": [ + { + "$ref": "#/definitions/LoadBalancerConfig" + } + ], + "x-nullable": true + }, + "lurl": { + "description": "private fields", + "type": "string", + "x-nullable": true + }, + "middlewares": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/types.LabelMap" + }, + "x-nullable": true + }, + "no_tls_verify": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "path_patterns": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": true + }, + "port": { + "$ref": "#/definitions/route.Port", + "x-nullable": false, + "x-omitempty": false + }, + "provider": { + "description": "for backward compatibility", + "type": "string", + "x-nullable": true + }, + "purl": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "response_header_timeout": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "root": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "rules": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/rules.Rule" + }, + "x-nullable": false, + "x-omitempty": false + }, + "scheme": { + "$ref": "#/definitions/route.Scheme", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "RouteProvider": { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "short_name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "RouteStats": { + "type": "object", + "properties": { + "error": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "healthy": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "napping": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "total": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "unhealthy": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "unknown": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "RouteStatus": { + "type": "object", + "properties": { + "latency": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy", + "unknown", + "napping", + "starting" + ], + "x-nullable": false, + "x-omitempty": false + }, + "timestamp": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "RouteStatusesByAlias": { + "type": "object", + "properties": { + "statuses": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/routes.HealthInfo" + }, + "x-nullable": false, + "x-omitempty": false + }, + "timestamp": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "RouteUptimeAggregate": { + "type": "object", + "properties": { + "alias": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "avg_latency": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "display_name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "downtime": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "idle": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "statuses": { + "type": "array", + "items": { + "$ref": "#/definitions/RouteStatus" + }, + "x-nullable": false, + "x-omitempty": false + }, + "uptime": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "ServerInfo": { + "type": "object", + "properties": { + "containers": { + "$ref": "#/definitions/ContainerStats", + "x-nullable": false, + "x-omitempty": false + }, + "images": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "memory": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "n_cpu": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "version": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "StatsResponse": { + "type": "object", + "properties": { + "proxies": { + "$ref": "#/definitions/ProxyStats", + "x-nullable": false, + "x-omitempty": false + }, + "uptime": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "StatusCodeRange": { + "type": "object", + "properties": { + "end": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "start": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "SuccessResponse": { + "type": "object", + "properties": { + "details": { + "type": "object", + "additionalProperties": {}, + "x-nullable": true + }, + "message": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "SystemInfo": { + "type": "object", + "properties": { + "cpu_average": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "disks": { + "description": "disk usage by partition", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/disk.UsageStat" + }, + "x-nullable": false, + "x-omitempty": false + }, + "disks_io": { + "description": "disk IO by device", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/disk.IOCountersStat" + }, + "x-nullable": false, + "x-omitempty": false + }, + "memory": { + "$ref": "#/definitions/mem.VirtualMemoryStat", + "x-nullable": false, + "x-omitempty": false + }, + "network": { + "$ref": "#/definitions/net.IOCountersStat", + "x-nullable": false, + "x-omitempty": false + }, + "sensors": { + "description": "sensor temperature by key", + "type": "array", + "items": { + "$ref": "#/definitions/sensors.TemperatureStat" + }, + "x-nullable": false, + "x-omitempty": false + }, + "timestamp": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "SystemInfoAggregate": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + }, + "x-nullable": false, + "x-omitempty": false + }, + "total": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "UptimeAggregate": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/RouteUptimeAggregate" + }, + "x-nullable": false, + "x-omitempty": false + }, + "total": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "VerifyNewAgentRequest": { + "type": "object", + "properties": { + "ca": { + "$ref": "#/definitions/PEMPairResponse", + "x-nullable": false, + "x-omitempty": false + }, + "client": { + "$ref": "#/definitions/PEMPairResponse", + "x-nullable": false, + "x-omitempty": false + }, + "host": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "accesslog.FieldConfig": { + "type": "object", + "properties": { + "config": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/accesslog.FieldMode" + }, + "x-nullable": false, + "x-omitempty": false + }, + "default": { + "enum": [ + "keep", + "drop", + "redact" + ], + "allOf": [ + { + "$ref": "#/definitions/accesslog.FieldMode" + } + ], + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "accesslog.FieldMode": { + "type": "string", + "enum": [ + "keep", + "drop", + "redact" + ], + "x-enum-varnames": [ + "FieldModeKeep", + "FieldModeDrop", + "FieldModeRedact" + ], + "x-nullable": false, + "x-omitempty": false + }, + "accesslog.Fields": { + "type": "object", + "properties": { + "cookies": { + "$ref": "#/definitions/accesslog.FieldConfig", + "x-nullable": false, + "x-omitempty": false + }, + "headers": { + "$ref": "#/definitions/accesslog.FieldConfig", + "x-nullable": false, + "x-omitempty": false + }, + "query": { + "$ref": "#/definitions/accesslog.FieldConfig", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "accesslog.Filters": { + "type": "object", + "properties": { + "cidr": { + "$ref": "#/definitions/LogFilter-CIDR", + "x-nullable": false, + "x-omitempty": false + }, + "headers": { + "description": "header exists or header == value", + "allOf": [ + { + "$ref": "#/definitions/LogFilter-HTTPHeader" + } + ], + "x-nullable": false, + "x-omitempty": false + }, + "host": { + "$ref": "#/definitions/LogFilter-Host", + "x-nullable": false, + "x-omitempty": false + }, + "method": { + "$ref": "#/definitions/LogFilter-HTTPMethod", + "x-nullable": false, + "x-omitempty": false + }, + "status_codes": { + "$ref": "#/definitions/LogFilter-StatusCodeRange", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "auth.UserPassAuthCallbackRequest": { + "type": "object", + "properties": { + "password": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "username": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "container.Port": { + "type": "object", + "properties": { + "IP": { + "description": "Host IP address that the container's port is mapped to", + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "PrivatePort": { + "description": "Port on the container\nRequired: true", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "PublicPort": { + "description": "Port exposed on the host", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "Type": { + "description": "type\nRequired: true", + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "disk.IOCountersStat": { + "type": "object", + "properties": { + "iops": { + "description": "godoxy", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "description": "ReadCount uint64 `json:\"readCount\"`\nMergedReadCount uint64 `json:\"mergedReadCount\"`\nWriteCount uint64 `json:\"writeCount\"`\nMergedWriteCount uint64 `json:\"mergedWriteCount\"`\nReadBytes uint64 `json:\"readBytes\"`\nWriteBytes uint64 `json:\"writeBytes\"`\nReadTime uint64 `json:\"readTime\"`\nWriteTime uint64 `json:\"writeTime\"`\nIopsInProgress uint64 `json:\"iopsInProgress\"`\nIoTime uint64 `json:\"ioTime\"`\nWeightedIO uint64 `json:\"weightedIO\"`", + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "read_bytes": { + "description": "SerialNumber string `json:\"serialNumber\"`\nLabel string `json:\"label\"`", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "read_count": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "read_speed": { + "description": "godoxy", + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "write_bytes": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "write_count": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "write_speed": { + "description": "godoxy", + "type": "number", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "disk.UsageStat": { + "type": "object", + "properties": { + "free": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "fstype": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "path": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "total": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "used": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "used_percent": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "homepage.FetchResult": { + "type": "object", + "properties": { + "errMsg": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "icon": { + "type": "array", + "items": { + "type": "integer" + }, + "x-nullable": false, + "x-omitempty": false + }, + "statusCode": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "homepage.IconExtra": { + "type": "object", + "properties": { + "file_type": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "is_dark": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "is_light": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "key": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "ref": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "homepage.IconMetaSearch": { + "type": "object", + "properties": { + "dark": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "light": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "png": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "ref": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "source": { + "$ref": "#/definitions/homepage.IconSource", + "x-nullable": false, + "x-omitempty": false + }, + "svg": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "webP": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "homepage.IconSource": { + "type": "string", + "enum": [ + "https://", + "@target", + "@walkxcode", + "@selfhst" + ], + "x-enum-varnames": [ + "IconSourceAbsolute", + "IconSourceRelative", + "IconSourceWalkXCode", + "IconSourceSelfhSt" + ], + "x-nullable": false, + "x-omitempty": false + }, + "homepage.IconURL": { + "type": "object", + "properties": { + "extra": { + "description": "only for walkxcode/selfhst icons", + "allOf": [ + { + "$ref": "#/definitions/homepage.IconExtra" + } + ], + "x-nullable": false, + "x-omitempty": false + }, + "source": { + "$ref": "#/definitions/homepage.IconSource", + "x-nullable": false, + "x-omitempty": false + }, + "value": { + "description": "only for absolute/relative icons", + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "homepage.Item": { + "type": "object", + "properties": { + "alias": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "category": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "description": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "icon": { + "$ref": "#/definitions/homepage.IconURL", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "description": "display name", + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "origin_url": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "provider": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "show": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "sort_order": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "url": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "widget_config": { + "$ref": "#/definitions/widgets.Config", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "homepage.ItemConfig": { + "type": "object", + "properties": { + "category": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "description": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "icon": { + "$ref": "#/definitions/homepage.IconURL", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "description": "display name", + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "show": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "sort_order": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "url": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "homepageapi.SetHomePageOverridesRequest": { + "type": "object", + "required": [ + "value", + "what" + ], + "properties": { + "value": { + "x-nullable": false, + "x-omitempty": false + }, + "what": { + "type": "string", + "enum": [ + "item", + "items_batch", + "category_order", + "item_visible" + ], + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "mem.VirtualMemoryStat": { + "type": "object", + "properties": { + "available": { + "description": "RAM available for programs to allocate\n\nThis value is computed from the kernel specific values.", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "free": { + "description": "This is the kernel's notion of free memory; RAM chips whose bits nobody\ncares about the value of right now. For a human consumable number,\nAvailable is what you really want.", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "total": { + "description": "Total amount of RAM on this system", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "used": { + "description": "RAM used by programs\n\nThis value is computed from the kernel specific values.", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "used_percent": { + "description": "Percentage of RAM used by programs\n\nThis value is computed from the kernel specific values.", + "type": "number", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "net.IOCountersStat": { + "type": "object", + "properties": { + "bytes_recv": { + "description": "number of bytes received", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "bytes_sent": { + "description": "Name string `json:\"name\"` // interface name", + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "download_speed": { + "description": "godoxy", + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "upload_speed": { + "description": "godoxy", + "type": "number", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "route.Port": { + "type": "object", + "properties": { + "listening": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "proxy": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "route.Route": { + "type": "object", + "properties": { + "access_log": { + "allOf": [ + { + "$ref": "#/definitions/RequestLoggerConfig" + } + ], + "x-nullable": true + }, + "agent": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "alias": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "container": { + "description": "Docker only", + "allOf": [ + { + "$ref": "#/definitions/Container" + } + ], + "x-nullable": true + }, + "disable_compression": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "excluded": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "health": { + "description": "for swagger", + "allOf": [ + { + "$ref": "#/definitions/HealthJSON" + } + ], + "x-nullable": false, + "x-omitempty": false + }, + "healthcheck": { + "$ref": "#/definitions/HealthCheckConfig", + "x-nullable": false, + "x-omitempty": false + }, + "homepage": { + "$ref": "#/definitions/homepage.ItemConfig", + "x-nullable": false, + "x-omitempty": false + }, + "host": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "idlewatcher": { + "allOf": [ + { + "$ref": "#/definitions/IdlewatcherConfig" + } + ], + "x-nullable": true + }, + "load_balance": { + "allOf": [ + { + "$ref": "#/definitions/LoadBalancerConfig" + } + ], + "x-nullable": true + }, + "lurl": { + "description": "private fields", + "type": "string", + "x-nullable": true + }, + "middlewares": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/types.LabelMap" + }, + "x-nullable": true + }, + "no_tls_verify": { + "type": "boolean", + "x-nullable": false, + "x-omitempty": false + }, + "path_patterns": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": true + }, + "port": { + "$ref": "#/definitions/route.Port", + "x-nullable": false, + "x-omitempty": false + }, + "provider": { + "description": "for backward compatibility", + "type": "string", + "x-nullable": true + }, + "purl": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "response_header_timeout": { + "type": "integer", + "x-nullable": false, + "x-omitempty": false + }, + "root": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "rules": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/rules.Rule" + }, + "x-nullable": false, + "x-omitempty": false + }, + "scheme": { + "$ref": "#/definitions/route.Scheme", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "route.Scheme": { + "type": "string", + "enum": [ + "http", + "https", + "tcp", + "udp", + "fileserver" + ], + "x-enum-varnames": [ + "SchemeHTTP", + "SchemeHTTPS", + "SchemeTCP", + "SchemeUDP", + "SchemeFileServer" + ], + "x-nullable": false, + "x-omitempty": false + }, + "routeApi.RoutesByProvider": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/route.Route" + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "routes.HealthInfo": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "latency": { + "description": "latency in microseconds", + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy", + "napping", + "starting", + "error", + "unknown" + ], + "x-nullable": false, + "x-omitempty": false + }, + "uptime": { + "description": "uptime in milliseconds", + "type": "number", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "rules.Command": { + "type": "object", + "x-nullable": false, + "x-omitempty": false + }, + "rules.Rule": { + "type": "object", + "properties": { + "do": { + "$ref": "#/definitions/rules.Command", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "on": { + "$ref": "#/definitions/rules.RuleOn", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "rules.RuleOn": { + "type": "object", + "x-nullable": false, + "x-omitempty": false + }, + "sensors.TemperatureStat": { + "type": "object", + "properties": { + "critical": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "high": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + }, + "name": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + }, + "temperature": { + "type": "number", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + }, + "time.Duration": { + "type": "integer", + "enum": [ + -9223372036854775808, + 9223372036854775807, + 1, + 1000, + 1000000, + 1000000000, + 60000000000, + 3600000000000 + ], + "x-enum-varnames": [ + "minDuration", + "maxDuration", + "Nanosecond", + "Microsecond", + "Millisecond", + "Second", + "Minute", + "Hour" + ], + "x-nullable": false, + "x-omitempty": false + }, + "types.LabelMap": { + "type": "object", + "additionalProperties": {}, + "x-nullable": false, + "x-omitempty": false + }, + "types.PortMapping": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/container.Port" + }, + "x-nullable": false, + "x-omitempty": false + }, + "widgets.Config": { + "type": "object", + "properties": { + "config": { + "x-nullable": false, + "x-omitempty": false + }, + "provider": { + "type": "string", + "x-nullable": false, + "x-omitempty": false + } + }, + "x-nullable": false, + "x-omitempty": false + } + } +} \ No newline at end of file diff --git a/internal/api/v1/docs/swagger.yaml b/internal/api/v1/docs/swagger.yaml new file mode 100644 index 00000000..feca6e1b --- /dev/null +++ b/internal/api/v1/docs/swagger.yaml @@ -0,0 +1,2263 @@ +definitions: + Agent: + properties: + addr: + type: string + name: + type: string + version: + type: string + type: object + CIDR: + properties: + ip: + description: network number + items: + type: integer + type: array + mask: + description: network mask + items: + type: integer + type: array + type: object + CertInfo: + properties: + dns_names: + items: + type: string + type: array + email_addresses: + items: + type: string + type: array + issuer: + type: string + not_after: + type: integer + not_before: + type: integer + subject: + type: string + type: object + Container: + properties: + agent: + $ref: '#/definitions/Agent' + aliases: + items: + type: string + type: array + container_id: + type: string + container_name: + type: string + docker_host: + type: string + errors: + type: string + idlewatcher_config: + $ref: '#/definitions/IdlewatcherConfig' + image: + $ref: '#/definitions/ContainerImage' + is_excluded: + type: boolean + is_explicit: + type: boolean + is_host_network_mode: + type: boolean + mounts: + items: + type: string + type: array + network: + type: string + private_hostname: + type: string + private_ports: + allOf: + - $ref: '#/definitions/types.PortMapping' + description: privatePort:types.Port + public_hostname: + type: string + public_ports: + allOf: + - $ref: '#/definitions/types.PortMapping' + description: non-zero publicPort:types.Port + running: + type: boolean + type: object + ContainerImage: + properties: + author: + type: string + name: + type: string + tag: + type: string + type: object + ContainerResponse: + properties: + id: + type: string + image: + type: string + name: + type: string + server: + type: string + state: + type: string + type: object + ContainerStats: + properties: + paused: + type: integer + running: + type: integer + stopped: + type: integer + total: + type: integer + type: object + ContainerStopMethod: + enum: + - pause + - stop + - kill + type: string + x-enum-varnames: + - ContainerStopMethodPause + - ContainerStopMethodStop + - ContainerStopMethodKill + DockerConfig: + properties: + container_id: + type: string + container_name: + type: string + docker_host: + type: string + required: + - container_id + - container_name + - docker_host + type: object + ErrorResponse: + properties: + error: + type: string + x-nullable: true + message: + type: string + type: object + FileType: + enum: + - config + - provider + - middleware + type: string + x-enum-comments: + FileTypeConfig: '@name FileTypeConfig' + FileTypeMiddleware: '@name FileTypeMiddleware' + FileTypeProvider: '@name FileTypeProvider' + x-enum-varnames: + - FileTypeConfig + - FileTypeProvider + - FileTypeMiddleware + HTTPHeader: + properties: + key: + type: string + value: + type: string + type: object + HealthCheckConfig: + properties: + disable: + type: boolean + interval: + type: integer + path: + type: string + retries: + description: '<0: immediate, >=0: threshold' + type: integer + timeout: + type: integer + use_get: + type: boolean + type: object + HealthExtra: + properties: + config: + $ref: '#/definitions/LoadBalancerConfig' + pool: + additionalProperties: {} + type: object + type: object + HealthJSON: + properties: + config: + $ref: '#/definitions/HealthCheckConfig' + detail: + type: string + extra: + allOf: + - $ref: '#/definitions/HealthExtra' + x-nullable: true + lastSeen: + type: integer + lastSeenStr: + type: string + latency: + type: number + latencyStr: + type: string + name: + type: string + started: + type: integer + startedStr: + type: string + status: + type: string + uptime: + type: number + uptimeStr: + type: string + url: + type: string + type: object + HealthMap: + additionalProperties: + $ref: '#/definitions/routes.HealthInfo' + type: object + HomepageItems: + additionalProperties: + items: + $ref: '#/definitions/homepage.Item' + type: array + type: object + HomepageOverrideCategoryOrderParams: + properties: + value: + type: integer + which: + type: string + type: object + HomepageOverrideItemParams: + properties: + value: + $ref: '#/definitions/homepage.ItemConfig' + which: + type: string + type: object + HomepageOverrideItemVisibleParams: + properties: + value: + type: boolean + which: + items: + type: string + type: array + type: object + HomepageOverrideItemsBatchParams: + properties: + value: + additionalProperties: + $ref: '#/definitions/homepage.ItemConfig' + type: object + type: object + IdlewatcherConfig: + properties: + depends_on: + items: + type: string + type: array + docker: + $ref: '#/definitions/DockerConfig' + idle_timeout: + allOf: + - $ref: '#/definitions/time.Duration' + description: "0: no idle watcher.\nPositive: idle watcher with idle timeout.\nNegative: + idle watcher as a dependency.\tIdleTimeout time.Duration `json:\"idle_timeout\" + json_ext:\"duration\"`" + proxmox: + $ref: '#/definitions/ProxmoxConfig' + start_endpoint: + description: Optional path that must be hit to start container + type: string + stop_method: + $ref: '#/definitions/ContainerStopMethod' + stop_signal: + type: string + stop_timeout: + $ref: '#/definitions/time.Duration' + wake_timeout: + $ref: '#/definitions/time.Duration' + type: object + ListFilesResponse: + properties: + config: + items: + type: string + type: array + middleware: + items: + type: string + type: array + provider: + items: + type: string + type: array + type: object + LoadBalancerConfig: + properties: + link: + type: string + mode: + $ref: '#/definitions/LoadBalancerMode' + options: + additionalProperties: {} + type: object + weight: + type: integer + type: object + LoadBalancerMode: + enum: + - "" + - roundrobin + - leastconn + - iphash + type: string + x-enum-varnames: + - LoadbalanceModeUnset + - LoadbalanceModeRoundRobin + - LoadbalanceModeLeastConn + - LoadbalanceModeIPHash + LogFilter-CIDR: + properties: + negative: + type: boolean + values: + items: + $ref: '#/definitions/CIDR' + type: array + type: object + LogFilter-HTTPHeader: + properties: + negative: + type: boolean + values: + items: + $ref: '#/definitions/HTTPHeader' + type: array + type: object + LogFilter-HTTPMethod: + properties: + negative: + type: boolean + values: + items: + type: string + type: array + type: object + LogFilter-Host: + properties: + negative: + type: boolean + values: + items: + type: string + type: array + type: object + LogFilter-StatusCodeRange: + properties: + negative: + type: boolean + values: + items: + $ref: '#/definitions/StatusCodeRange' + type: array + type: object + LogRetention: + properties: + days: + type: integer + keep_size: + type: integer + last: + type: integer + type: object + MetricsPeriod: + enum: + - 5m + - 15m + - 1h + - 1d + - 1mo + type: string + x-enum-comments: + MetricsPeriod1mo: '@name MetricsPeriod1mo' + MetricsPeriod5m: '@name MetricsPeriod5m' + MetricsPeriod15m: '@name MetricsPeriod15m' + MetricsPeriod1d: '@name MetricsPeriod1d' + MetricsPeriod1h: '@name MetricsPeriod1h' + x-enum-varnames: + - MetricsPeriod5m + - MetricsPeriod15m + - MetricsPeriod1h + - MetricsPeriod1d + - MetricsPeriod1mo + NewAgentRequest: + properties: + host: + type: string + name: + type: string + nightly: + type: boolean + port: + maximum: 65535 + minimum: 1 + type: integer + type: + enum: + - docker + - system + type: string + required: + - host + - name + - port + - type + type: object + NewAgentResponse: + properties: + ca: + $ref: '#/definitions/PEMPairResponse' + client: + $ref: '#/definitions/PEMPairResponse' + compose: + type: string + type: object + PEMPairResponse: + properties: + cert: + format: base64 + type: string + key: + format: base64 + type: string + type: object + ProviderStats: + properties: + reverse_proxies: + $ref: '#/definitions/RouteStats' + streams: + $ref: '#/definitions/RouteStats' + total: + type: integer + type: + $ref: '#/definitions/ProviderType' + type: object + ProviderType: + enum: + - docker + - file + - agent + type: string + x-enum-varnames: + - ProviderTypeDocker + - ProviderTypeFile + - ProviderTypeAgent + ProxmoxConfig: + properties: + node: + type: string + vmid: + type: integer + required: + - node + - vmid + type: object + ProxyStats: + properties: + providers: + additionalProperties: + $ref: '#/definitions/ProviderStats' + type: object + reverse_proxies: + $ref: '#/definitions/RouteStats' + streams: + $ref: '#/definitions/RouteStats' + total: + type: integer + type: object + RequestLoggerConfig: + properties: + buffer_size: + description: 'Deprecated: buffer size is adjusted dynamically' + type: integer + fields: + $ref: '#/definitions/accesslog.Fields' + filters: + $ref: '#/definitions/accesslog.Filters' + format: + enum: + - common + - combined + - json + type: string + path: + type: string + retention: + $ref: '#/definitions/LogRetention' + rotate_interval: + type: integer + stdout: + type: boolean + type: object + Route: + properties: + access_log: + allOf: + - $ref: '#/definitions/RequestLoggerConfig' + x-nullable: true + agent: + type: string + alias: + type: string + container: + allOf: + - $ref: '#/definitions/Container' + description: Docker only + x-nullable: true + disable_compression: + type: boolean + excluded: + type: boolean + health: + allOf: + - $ref: '#/definitions/HealthJSON' + description: for swagger + healthcheck: + $ref: '#/definitions/HealthCheckConfig' + homepage: + $ref: '#/definitions/homepage.ItemConfig' + host: + type: string + idlewatcher: + allOf: + - $ref: '#/definitions/IdlewatcherConfig' + x-nullable: true + load_balance: + allOf: + - $ref: '#/definitions/LoadBalancerConfig' + x-nullable: true + lurl: + description: private fields + type: string + x-nullable: true + middlewares: + additionalProperties: + $ref: '#/definitions/types.LabelMap' + type: object + x-nullable: true + no_tls_verify: + type: boolean + path_patterns: + items: + type: string + type: array + x-nullable: true + port: + $ref: '#/definitions/route.Port' + provider: + description: for backward compatibility + type: string + x-nullable: true + purl: + type: string + response_header_timeout: + type: integer + root: + type: string + rules: + items: + $ref: '#/definitions/rules.Rule' + type: array + uniqueItems: true + scheme: + $ref: '#/definitions/route.Scheme' + type: object + RouteProvider: + properties: + full_name: + type: string + short_name: + type: string + type: object + RouteStats: + properties: + error: + type: integer + healthy: + type: integer + napping: + type: integer + total: + type: integer + unhealthy: + type: integer + unknown: + type: integer + type: object + RouteStatus: + properties: + latency: + type: integer + status: + enum: + - healthy + - unhealthy + - unknown + - napping + - starting + type: string + timestamp: + type: integer + type: object + RouteStatusesByAlias: + properties: + statuses: + additionalProperties: + $ref: '#/definitions/routes.HealthInfo' + type: object + timestamp: + type: integer + type: object + RouteUptimeAggregate: + properties: + alias: + type: string + avg_latency: + type: number + display_name: + type: string + downtime: + type: number + idle: + type: number + statuses: + items: + $ref: '#/definitions/RouteStatus' + type: array + uptime: + type: number + type: object + ServerInfo: + properties: + containers: + $ref: '#/definitions/ContainerStats' + images: + type: integer + memory: + type: string + n_cpu: + type: integer + name: + type: string + version: + type: string + type: object + StatsResponse: + properties: + proxies: + $ref: '#/definitions/ProxyStats' + uptime: + type: string + type: object + StatusCodeRange: + properties: + end: + type: integer + start: + type: integer + type: object + SuccessResponse: + properties: + details: + additionalProperties: {} + type: object + x-nullable: true + message: + type: string + type: object + SystemInfo: + properties: + cpu_average: + type: number + disks: + additionalProperties: + $ref: '#/definitions/disk.UsageStat' + description: disk usage by partition + type: object + disks_io: + additionalProperties: + $ref: '#/definitions/disk.IOCountersStat' + description: disk IO by device + type: object + memory: + $ref: '#/definitions/mem.VirtualMemoryStat' + network: + $ref: '#/definitions/net.IOCountersStat' + sensors: + description: sensor temperature by key + items: + $ref: '#/definitions/sensors.TemperatureStat' + type: array + timestamp: + type: integer + type: object + SystemInfoAggregate: + properties: + data: + items: + additionalProperties: {} + type: object + type: array + total: + type: integer + type: object + UptimeAggregate: + properties: + data: + items: + $ref: '#/definitions/RouteUptimeAggregate' + type: array + total: + type: integer + type: object + VerifyNewAgentRequest: + properties: + ca: + $ref: '#/definitions/PEMPairResponse' + client: + $ref: '#/definitions/PEMPairResponse' + host: + type: string + type: object + accesslog.FieldConfig: + properties: + config: + additionalProperties: + $ref: '#/definitions/accesslog.FieldMode' + type: object + default: + allOf: + - $ref: '#/definitions/accesslog.FieldMode' + enum: + - keep + - drop + - redact + type: object + accesslog.FieldMode: + enum: + - keep + - drop + - redact + type: string + x-enum-varnames: + - FieldModeKeep + - FieldModeDrop + - FieldModeRedact + accesslog.Fields: + properties: + cookies: + $ref: '#/definitions/accesslog.FieldConfig' + headers: + $ref: '#/definitions/accesslog.FieldConfig' + query: + $ref: '#/definitions/accesslog.FieldConfig' + type: object + accesslog.Filters: + properties: + cidr: + $ref: '#/definitions/LogFilter-CIDR' + headers: + allOf: + - $ref: '#/definitions/LogFilter-HTTPHeader' + description: header exists or header == value + host: + $ref: '#/definitions/LogFilter-Host' + method: + $ref: '#/definitions/LogFilter-HTTPMethod' + status_codes: + $ref: '#/definitions/LogFilter-StatusCodeRange' + type: object + auth.UserPassAuthCallbackRequest: + properties: + password: + type: string + username: + type: string + type: object + container.Port: + properties: + IP: + description: Host IP address that the container's port is mapped to + type: string + PrivatePort: + description: |- + Port on the container + Required: true + type: integer + PublicPort: + description: Port exposed on the host + type: integer + Type: + description: |- + type + Required: true + type: string + type: object + disk.IOCountersStat: + properties: + iops: + description: godoxy + type: integer + name: + description: |- + ReadCount uint64 `json:"readCount"` + MergedReadCount uint64 `json:"mergedReadCount"` + WriteCount uint64 `json:"writeCount"` + MergedWriteCount uint64 `json:"mergedWriteCount"` + ReadBytes uint64 `json:"readBytes"` + WriteBytes uint64 `json:"writeBytes"` + ReadTime uint64 `json:"readTime"` + WriteTime uint64 `json:"writeTime"` + IopsInProgress uint64 `json:"iopsInProgress"` + IoTime uint64 `json:"ioTime"` + WeightedIO uint64 `json:"weightedIO"` + type: string + read_bytes: + description: |- + SerialNumber string `json:"serialNumber"` + Label string `json:"label"` + type: integer + read_count: + type: integer + read_speed: + description: godoxy + type: number + write_bytes: + type: integer + write_count: + type: integer + write_speed: + description: godoxy + type: number + type: object + disk.UsageStat: + properties: + free: + type: integer + fstype: + type: string + path: + type: string + total: + type: integer + used: + type: integer + used_percent: + type: number + type: object + homepage.FetchResult: + properties: + errMsg: + type: string + icon: + items: + type: integer + type: array + statusCode: + type: integer + type: object + homepage.IconExtra: + properties: + file_type: + type: string + is_dark: + type: boolean + is_light: + type: boolean + key: + type: string + ref: + type: string + type: object + homepage.IconMetaSearch: + properties: + dark: + type: boolean + light: + type: boolean + png: + type: boolean + ref: + type: string + source: + $ref: '#/definitions/homepage.IconSource' + svg: + type: boolean + webP: + type: boolean + type: object + homepage.IconSource: + enum: + - https:// + - '@target' + - '@walkxcode' + - '@selfhst' + type: string + x-enum-varnames: + - IconSourceAbsolute + - IconSourceRelative + - IconSourceWalkXCode + - IconSourceSelfhSt + homepage.IconURL: + properties: + extra: + allOf: + - $ref: '#/definitions/homepage.IconExtra' + description: only for walkxcode/selfhst icons + source: + $ref: '#/definitions/homepage.IconSource' + value: + description: only for absolute/relative icons + type: string + type: object + homepage.Item: + properties: + alias: + type: string + category: + type: string + description: + type: string + icon: + $ref: '#/definitions/homepage.IconURL' + name: + description: display name + type: string + origin_url: + type: string + provider: + type: string + show: + type: boolean + sort_order: + type: integer + url: + type: string + widget_config: + $ref: '#/definitions/widgets.Config' + type: object + homepage.ItemConfig: + properties: + category: + type: string + description: + type: string + icon: + $ref: '#/definitions/homepage.IconURL' + name: + description: display name + type: string + show: + type: boolean + sort_order: + type: integer + url: + type: string + type: object + homepageapi.SetHomePageOverridesRequest: + properties: + value: {} + what: + enum: + - item + - items_batch + - category_order + - item_visible + type: string + required: + - value + - what + type: object + mem.VirtualMemoryStat: + properties: + available: + description: |- + RAM available for programs to allocate + + This value is computed from the kernel specific values. + type: integer + free: + description: |- + This is the kernel's notion of free memory; RAM chips whose bits nobody + cares about the value of right now. For a human consumable number, + Available is what you really want. + type: integer + total: + description: Total amount of RAM on this system + type: integer + used: + description: |- + RAM used by programs + + This value is computed from the kernel specific values. + type: integer + used_percent: + description: |- + Percentage of RAM used by programs + + This value is computed from the kernel specific values. + type: number + type: object + net.IOCountersStat: + properties: + bytes_recv: + description: number of bytes received + type: integer + bytes_sent: + description: Name string `json:"name"` // interface name + type: integer + download_speed: + description: godoxy + type: number + upload_speed: + description: godoxy + type: number + type: object + route.Port: + properties: + listening: + type: integer + proxy: + type: integer + type: object + route.Route: + properties: + access_log: + allOf: + - $ref: '#/definitions/RequestLoggerConfig' + x-nullable: true + agent: + type: string + alias: + type: string + container: + allOf: + - $ref: '#/definitions/Container' + description: Docker only + x-nullable: true + disable_compression: + type: boolean + excluded: + type: boolean + health: + allOf: + - $ref: '#/definitions/HealthJSON' + description: for swagger + healthcheck: + $ref: '#/definitions/HealthCheckConfig' + homepage: + $ref: '#/definitions/homepage.ItemConfig' + host: + type: string + idlewatcher: + allOf: + - $ref: '#/definitions/IdlewatcherConfig' + x-nullable: true + load_balance: + allOf: + - $ref: '#/definitions/LoadBalancerConfig' + x-nullable: true + lurl: + description: private fields + type: string + x-nullable: true + middlewares: + additionalProperties: + $ref: '#/definitions/types.LabelMap' + type: object + x-nullable: true + no_tls_verify: + type: boolean + path_patterns: + items: + type: string + type: array + x-nullable: true + port: + $ref: '#/definitions/route.Port' + provider: + description: for backward compatibility + type: string + x-nullable: true + purl: + type: string + response_header_timeout: + type: integer + root: + type: string + rules: + items: + $ref: '#/definitions/rules.Rule' + type: array + uniqueItems: true + scheme: + $ref: '#/definitions/route.Scheme' + type: object + route.Scheme: + enum: + - http + - https + - tcp + - udp + - fileserver + type: string + x-enum-varnames: + - SchemeHTTP + - SchemeHTTPS + - SchemeTCP + - SchemeUDP + - SchemeFileServer + routeApi.RoutesByProvider: + additionalProperties: + items: + $ref: '#/definitions/route.Route' + type: array + type: object + routes.HealthInfo: + properties: + detail: + type: string + latency: + description: latency in microseconds + type: number + status: + enum: + - healthy + - unhealthy + - napping + - starting + - error + - unknown + type: string + uptime: + description: uptime in milliseconds + type: number + type: object + rules.Command: + type: object + rules.Rule: + properties: + do: + $ref: '#/definitions/rules.Command' + name: + type: string + "on": + $ref: '#/definitions/rules.RuleOn' + type: object + rules.RuleOn: + type: object + sensors.TemperatureStat: + properties: + critical: + type: number + high: + type: number + name: + type: string + temperature: + type: number + type: object + time.Duration: + enum: + - -9223372036854775808 + - 9223372036854775807 + - 1 + - 1000 + - 1000000 + - 1000000000 + - 60000000000 + - 3600000000000 + type: integer + x-enum-varnames: + - minDuration + - maxDuration + - Nanosecond + - Microsecond + - Millisecond + - Second + - Minute + - Hour + types.LabelMap: + additionalProperties: {} + type: object + types.PortMapping: + additionalProperties: + $ref: '#/definitions/container.Port' + type: object + widgets.Config: + properties: + config: {} + provider: + type: string + type: object +info: + contact: {} +paths: + /agent/create: + post: + consumes: + - application/json + description: |- + Create a new agent and return the docker compose file, encrypted CA and client PEMs + The returned PEMs are encrypted with a random key and will be used for verification when adding a new agent + parameters: + - description: Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/NewAgentRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/NewAgentResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Create a new agent + tags: + - agent + x-id: create + /agent/list: + get: + consumes: + - application/json + description: List agents + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/Agent' + type: array + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: List agents + tags: + - agent + - websocket + x-id: list + /agent/verify: + post: + consumes: + - application/json + description: Verify a new agent and return the number of routes added + parameters: + - description: Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/VerifyNewAgentRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/SuccessResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Verify a new agent + tags: + - agent + x-id: verify + /auth/callback: + post: + description: Handles the callback from the provider after successful authentication + parameters: + - description: Userpass only + in: body + name: body + required: true + schema: + $ref: '#/definitions/auth.UserPassAuthCallbackRequest' + produces: + - text/plain + responses: + "200": + description: 'Userpass: OK' + schema: + type: string + "302": + description: 'OIDC: Redirects to home page' + schema: + type: string + "400": + description: 'Userpass: invalid request / credentials' + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Post Auth Callback + tags: + - auth + x-id: callback + /auth/check: + head: + description: Checks if the user is authenticated by validating their token + produces: + - text/plain + responses: + "200": + description: OK + schema: + type: string + "403": + description: 'Forbidden: use X-Redirect-To header to redirect to login page' + schema: + type: string + summary: Check authentication status + tags: + - auth + x-id: check + /auth/login: + post: + description: Initiates the login process by redirecting the user to the provider's + login page + produces: + - text/plain + responses: + "302": + description: Redirects to login page or IdP + schema: + type: string + "403": + description: 'Forbidden(webui): follow X-Redirect-To header' + schema: + type: string + "429": + description: Too Many Requests + schema: + type: string + summary: Login + tags: + - auth + x-id: login + /auth/logout: + post: + description: Logs out the user by invalidating the token + produces: + - text/plain + responses: + "302": + description: Redirects to home page + schema: + type: string + summary: Logout + tags: + - auth + x-id: logout + /cert/info: + get: + consumes: + - application/json + description: Get cert info + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/CertInfo' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get cert info + tags: + - cert + /cert/renew: + post: + consumes: + - application/json + description: Renew cert + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/SuccessResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Renew cert + tags: + - cert + /docker/containers: + get: + consumes: + - application/json + description: Get containers + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/ContainerResponse' + type: array + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get containers + tags: + - docker + /docker/info: + get: + consumes: + - application/json + description: Get docker info + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/ServerInfo' + type: array + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get docker info + tags: + - docker + /docker/logs/{server}/{container}: + get: + consumes: + - application/json + description: Get docker container logs + parameters: + - description: server name + in: path + name: server + required: true + type: string + - description: container id + in: path + name: container + required: true + type: string + - description: show stdout + in: query + name: stdout + type: boolean + - description: show stderr + in: query + name: stderr + type: boolean + - description: from timestamp + in: query + name: from + type: string + - description: to timestamp + in: query + name: to + type: string + - description: levels + in: query + name: levels + type: string + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get docker container logs + tags: + - docker + - websocket + /favicon: + get: + consumes: + - application/json + description: Get favicon + parameters: + - description: URL of the route + in: query + name: url + type: string + - description: Alias of the route + in: query + name: alias + type: string + produces: + - image/svg+xml + - image/x-icon + - image/png + - image/webp + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/homepage.FetchResult' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get favicon + tags: + - v1 + x-id: favicon + /file/content: + get: + consumes: + - application/json + description: Get file content + parameters: + - format: filename + in: query + name: filename + required: true + type: string + - enum: + - config + - provider + - middleware + in: query + name: type + required: true + type: string + x-enum-comments: + FileTypeConfig: '@name FileTypeConfig' + FileTypeMiddleware: '@name FileTypeMiddleware' + FileTypeProvider: '@name FileTypeProvider' + x-enum-varnames: + - FileTypeConfig + - FileTypeProvider + - FileTypeMiddleware + produces: + - application/json + - application/godoxy+yaml + responses: + "200": + description: OK + schema: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get file content + tags: + - file + x-id: get + put: + consumes: + - application/json + description: Set file content + parameters: + - description: Type + enum: + - config + - provider + - middleware + in: query + name: type + required: true + type: string + - description: Filename + in: query + name: filename + required: true + type: string + - description: File + in: body + name: file + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/SuccessResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Set file content + tags: + - file + x-id: set + /file/list: + get: + consumes: + - application/json + description: List files + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ListFilesResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: List files + tags: + - file + x-id: list + /file/validate: + post: + consumes: + - application/json + description: Validate file + parameters: + - description: Type + enum: + - config + - provider + - middleware + in: query + name: type + required: true + type: string + - description: File content + in: body + name: file + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: File validated + schema: + $ref: '#/definitions/SuccessResponse' + "400": + description: Bad request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "417": + description: Validation failed + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Validate file + tags: + - file + x-id: validate + /health: + get: + consumes: + - application/json + description: Get health info by route name + produces: + - application/json + responses: + "200": + description: Health info by route name + schema: + $ref: '#/definitions/HealthMap' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get routes health info + tags: + - v1 + - websocket + x-id: health + /homepage/categories: + get: + consumes: + - application/json + description: List homepage categories + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + type: string + type: array + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + summary: List homepage categories + tags: + - homepage + x-id: categories + /homepage/items: + get: + consumes: + - application/json + description: Homepage items + parameters: + - description: Category filter + in: query + name: category + type: string + - description: Provider filter + in: query + name: provider + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/HomepageItems' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + summary: Homepage items + tags: + - homepage + x-id: items + /homepage/set: + post: + consumes: + - application/json + description: Set homepage overrides + parameters: + - description: Override single item + in: body + name: request + required: true + schema: + allOf: + - $ref: '#/definitions/homepageapi.SetHomePageOverridesRequest' + - properties: + value: + $ref: '#/definitions/HomepageOverrideItemParams' + type: object + - description: Override multiple items + in: body + name: request + required: true + schema: + allOf: + - $ref: '#/definitions/homepageapi.SetHomePageOverridesRequest' + - properties: + value: + $ref: '#/definitions/HomepageOverrideItemsBatchParams' + type: object + - description: Override category order + in: body + name: request + required: true + schema: + allOf: + - $ref: '#/definitions/homepageapi.SetHomePageOverridesRequest' + - properties: + value: + $ref: '#/definitions/HomepageOverrideCategoryOrderParams' + type: object + - description: Override item visibility + in: body + name: request + required: true + schema: + allOf: + - $ref: '#/definitions/homepageapi.SetHomePageOverridesRequest' + - properties: + value: + $ref: '#/definitions/HomepageOverrideItemVisibleParams' + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/SuccessResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Set homepage overrides + tags: + - homepage + x-id: set + /icons: + get: + consumes: + - application/json + description: List icons + parameters: + - description: Limit + in: query + name: limit + type: integer + - description: Keyword + in: query + name: keyword + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/homepage.IconMetaSearch' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + summary: List icons + tags: + - v1 + x-id: icons + /metrics/system_info: + get: + description: Get system info + parameters: + - description: Agent address + in: query + name: agent_addr + type: string + - description: Period + in: query + name: period + type: string + produces: + - application/json + responses: + "200": + description: period specified + schema: + $ref: '#/definitions/SystemInfoAggregate' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get system info + tags: + - metrics + - websocket + x-id: system_info + /metrics/uptime: + get: + description: Get uptime + parameters: + - enum: + - 5m + - 15m + - 1h + - 1d + - 1mo + example: 1m + in: query + name: interval + type: string + x-enum-comments: + MetricsPeriod15m: '@name MetricsPeriod15m' + MetricsPeriod1d: '@name MetricsPeriod1d' + MetricsPeriod1h: '@name MetricsPeriod1h' + MetricsPeriod1mo: '@name MetricsPeriod1mo' + MetricsPeriod5m: '@name MetricsPeriod5m' + x-enum-varnames: + - MetricsPeriod5m + - MetricsPeriod15m + - MetricsPeriod1h + - MetricsPeriod1d + - MetricsPeriod1mo + - example: "" + in: query + name: keyword + type: string + - example: 10 + in: query + name: limit + type: integer + - example: "10" + in: query + name: offset + type: string + produces: + - application/json + responses: + "200": + description: period specified + schema: + $ref: '#/definitions/UptimeAggregate' + "204": + description: No Content + schema: + $ref: '#/definitions/ErrorResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get uptime + tags: + - metrics + - websocket + x-id: uptime + /reload: + post: + consumes: + - application/json + description: Reload config + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/SuccessResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Reload config + tags: + - v1 + x-id: reload + /route/{which}: + get: + consumes: + - application/json + description: List route + parameters: + - description: Route name + in: path + name: which + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Route' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/ErrorResponse' + summary: List route + tags: + - route + x-id: route + /route/by_provider: + get: + consumes: + - application/json + description: List routes by provider + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/routeApi.RoutesByProvider' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: List routes by provider + tags: + - route + x-id: byProvider + /route/list: + get: + consumes: + - application/json + description: List routes + parameters: + - description: Provider + in: query + name: provider + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/Route' + type: array + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + summary: List routes + tags: + - route + - websocket + x-id: routes + /route/providers: + get: + consumes: + - application/json + description: List route providers + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/RouteProvider' + type: array + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: List route providers + tags: + - route + - websocket + x-id: providers + /stats: + get: + consumes: + - application/json + description: Get stats + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/StatsResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get GoDoxy stats + tags: + - v1 + - websocket + x-id: stats + /version: + get: + consumes: + - application/json + description: Get the version of the GoDoxy + produces: + - text/plain + responses: + "200": + description: version + schema: + type: string + summary: Get version + tags: + - v1 + x-id: version +swagger: "2.0" diff --git a/internal/api/v1/favicon.go b/internal/api/v1/favicon.go new file mode 100644 index 00000000..7a1f6cac --- /dev/null +++ b/internal/api/v1/favicon.go @@ -0,0 +1,100 @@ +package v1 + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/homepage" + "github.com/yusing/go-proxy/internal/route/routes" +) + +type GetFavIconRequest struct { + URL string `form:"url" binding:"required_without=Alias"` + Alias string `form:"alias" binding:"required_without=URL"` +} // @name GetFavIconRequest + +// GetFavIcon returns the favicon of the route +// +// Returns: +// - 200 OK: if icon found +// - 400 Bad Request: if alias is empty or route is not HTTPRoute +// - 404 Not Found: if route or icon not found +// - 500 Internal Server Error: if internal error +// - others: depends on route handler response + +// @x-id "favicon" +// @BasePath /api/v1 +// @Summary Get favicon +// @Description Get favicon +// @Tags v1 +// @Accept json +// @Produce image/svg+xml,image/x-icon,image/png,image/webp +// @Param url query string false "URL of the route" +// @Param alias query string false "Alias of the route" +// @Success 200 {array} homepage.FetchResult +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 404 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /favicon [get] +func FavIcon(c *gin.Context) { + var request GetFavIconRequest + if err := c.ShouldBindQuery(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + + // try with url + if request.URL != "" { + var iconURL homepage.IconURL + if err := iconURL.Parse(request.URL); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid url", err)) + return + } + fetchResult := homepage.FetchFavIconFromURL(c.Request.Context(), &iconURL) + if !fetchResult.OK() { + c.JSON(fetchResult.StatusCode, apitypes.Error(fetchResult.ErrMsg)) + return + } + c.Data(fetchResult.StatusCode, fetchResult.ContentType(), fetchResult.Icon) + return + } + + // try with alias + result := GetFavIconFromAlias(c.Request.Context(), request.Alias) + if !result.OK() { + c.JSON(result.StatusCode, apitypes.Error(result.ErrMsg)) + return + } + c.Data(result.StatusCode, result.ContentType(), result.Icon) +} + +func GetFavIconFromAlias(ctx context.Context, alias string) *homepage.FetchResult { + // try with route.Icon + r, ok := routes.HTTP.Get(alias) + if !ok { + return &homepage.FetchResult{ + StatusCode: http.StatusNotFound, + ErrMsg: "route not found", + } + } + + var result *homepage.FetchResult + hp := r.HomepageItem() + if hp.Icon != nil { + if hp.Icon.IconSource == homepage.IconSourceRelative { + result = homepage.FindIcon(ctx, r, *hp.Icon.FullURL) + } else { + result = homepage.FetchFavIconFromURL(ctx, hp.Icon) + } + } else { + // try extract from "link[rel=icon]" + result = homepage.FindIcon(ctx, r, "/") + } + if result.StatusCode == 0 { + result.StatusCode = http.StatusOK + } + return result +} diff --git a/internal/api/v1/favicon/favicon.go b/internal/api/v1/favicon/favicon.go deleted file mode 100644 index 8084747a..00000000 --- a/internal/api/v1/favicon/favicon.go +++ /dev/null @@ -1,81 +0,0 @@ -package favicon - -import ( - "net/http" - - "github.com/yusing/go-proxy/internal/homepage" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/route/routes" -) - -// GetFavIcon returns the favicon of the route -// -// Returns: -// - 200 OK: if icon found -// - 400 Bad Request: if alias is empty or route is not HTTPRoute -// - 404 Not Found: if route or icon not found -// - 500 Internal Server Error: if internal error -// - others: depends on route handler response -func GetFavIcon(w http.ResponseWriter, req *http.Request) { - url, alias := req.FormValue("url"), req.FormValue("alias") - if url == "" && alias == "" { - gphttp.MissingKey(w, "url or alias") - return - } - if url != "" && alias != "" { - gphttp.BadRequest(w, "url and alias are mutually exclusive") - return - } - - // try with url - if url != "" { - var iconURL homepage.IconURL - if err := iconURL.Parse(url); err != nil { - gphttp.ClientError(w, req, err, http.StatusBadRequest) - return - } - fetchResult := homepage.FetchFavIconFromURL(req.Context(), &iconURL) - if !fetchResult.OK() { - http.Error(w, fetchResult.ErrMsg, fetchResult.StatusCode) - return - } - w.Header().Set("Content-Type", fetchResult.ContentType()) - gphttp.WriteBody(w, fetchResult.Icon) - return - } - - // try with alias - GetFavIconFromAlias(w, req, alias) - return -} - -func GetFavIconFromAlias(w http.ResponseWriter, req *http.Request, alias string) { - // try with route.Icon - r, ok := routes.HTTP.Get(alias) - if !ok { - gphttp.ValueNotFound(w, "route", alias) - return - } - - var result *homepage.FetchResult - hp := r.HomepageItem() - if hp.Icon != nil { - if hp.Icon.IconSource == homepage.IconSourceRelative { - result = homepage.FindIcon(req.Context(), r, *hp.Icon.FullURL) - } else { - result = homepage.FetchFavIconFromURL(req.Context(), hp.Icon) - } - } else { - // try extract from "link[rel=icon]" - result = homepage.FindIcon(req.Context(), r, "/") - } - if result.StatusCode == 0 { - result.StatusCode = http.StatusOK - } - if !result.OK() { - http.Error(w, result.ErrMsg, result.StatusCode) - return - } - w.Header().Set("Content-Type", result.ContentType()) - gphttp.WriteBody(w, result.Icon) -} diff --git a/internal/api/v1/file/get.go b/internal/api/v1/file/get.go new file mode 100644 index 00000000..a14962d8 --- /dev/null +++ b/internal/api/v1/file/get.go @@ -0,0 +1,73 @@ +package fileapi + +import ( + "net/http" + "os" + "path" + "strings" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/common" +) + +type FileType string // @name FileType + +const ( + FileTypeConfig FileType = "config" // @name FileTypeConfig + FileTypeProvider FileType = "provider" // @name FileTypeProvider + FileTypeMiddleware FileType = "middleware" // @name FileTypeMiddleware +) + +type GetFileContentRequest struct { + FileType FileType `form:"type" binding:"required,oneof=config provider middleware"` + Filename string `form:"filename" binding:"required" format:"filename"` +} // @name GetFileContentRequest + +// @x-id "get" +// @BasePath /api/v1 +// @Summary Get file content +// @Description Get file content +// @Tags file +// @Accept json +// @Produce json,application/godoxy+yaml +// @Param query query GetFileContentRequest true "Request" +// @Success 200 {string} application/godoxy+yaml "File content" +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /file/content [get] +func Get(c *gin.Context) { + var request GetFileContentRequest + if err := c.ShouldBindQuery(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + + content, err := os.ReadFile(request.FileType.GetPath(request.Filename)) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to read file")) + return + } + + // RFC 9512: https://www.rfc-editor.org/rfc/rfc9512.html + // xxx/yyy+yaml + c.Data(http.StatusOK, "application/godoxy+yaml", content) +} + +func GetFileType(file string) FileType { + switch { + case strings.HasPrefix(path.Base(file), "config."): + return FileTypeConfig + case strings.HasPrefix(file, common.MiddlewareComposeBasePath): + return FileTypeMiddleware + } + return FileTypeProvider +} + +func (t FileType) GetPath(filename string) string { + if t == FileTypeMiddleware { + return path.Join(common.MiddlewareComposeBasePath, filename) + } + return path.Join(common.ConfigBasePath, filename) +} diff --git a/internal/api/v1/file/list.go b/internal/api/v1/file/list.go new file mode 100644 index 00000000..c79a2ecf --- /dev/null +++ b/internal/api/v1/file/list.go @@ -0,0 +1,62 @@ +package fileapi + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/common" + "github.com/yusing/go-proxy/internal/utils" +) + +type ListFilesResponse struct { + Config []string `json:"config"` + Provider []string `json:"provider"` + Middleware []string `json:"middleware"` +} // @name ListFilesResponse + +// @x-id "list" +// @BasePath /api/v1 +// @Summary List files +// @Description List files +// @Tags file +// @Accept json +// @Produce json +// @Success 200 {object} ListFilesResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /file/list [get] +func List(c *gin.Context) { + resp := map[FileType][]string{ + FileTypeConfig: make([]string, 0), + FileTypeProvider: make([]string, 0), + FileTypeMiddleware: make([]string, 0), + } + + // config/ + files, err := utils.ListFiles(common.ConfigBasePath, 0, true) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to list files")) + return + } + + for _, file := range files { + t := GetFileType(file) + file = strings.TrimPrefix(file, common.ConfigBasePath+"/") + resp[t] = append(resp[t], file) + } + + // config/middlewares/ + mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to list files")) + return + } + for _, mid := range mids { + mid = strings.TrimPrefix(mid, common.MiddlewareComposeBasePath+"/") + resp[FileTypeMiddleware] = append(resp[FileTypeMiddleware], mid) + } + + c.JSON(http.StatusOK, resp) +} diff --git a/internal/api/v1/file/set.go b/internal/api/v1/file/set.go new file mode 100644 index 00000000..c11b7890 --- /dev/null +++ b/internal/api/v1/file/set.go @@ -0,0 +1,52 @@ +package fileapi + +import ( + "net/http" + "os" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" +) + +type SetFileContentRequest GetFileContentRequest + +// @x-id "set" +// @BasePath /api/v1 +// @Summary Set file content +// @Description Set file content +// @Tags file +// @Accept json +// @Produce json +// @Param type query FileType true "Type" +// @Param filename query string true "Filename" +// @Param file body string true "File" +// @Success 200 {object} apitypes.SuccessResponse +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /file/content [put] +func Set(c *gin.Context) { + var request SetFileContentRequest + if err := c.ShouldBindQuery(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + + content, err := c.GetRawData() + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to read file")) + return + } + + if valErr := validateFile(request.FileType, content); valErr != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid file", valErr)) + return + } + + err = os.WriteFile(request.FileType.GetPath(request.Filename), content, 0o644) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to write file")) + return + } + c.JSON(http.StatusOK, apitypes.Success("file set")) +} diff --git a/internal/api/v1/file/validate.go b/internal/api/v1/file/validate.go new file mode 100644 index 00000000..b32d8a03 --- /dev/null +++ b/internal/api/v1/file/validate.go @@ -0,0 +1,64 @@ +package fileapi + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/net/gphttp/middleware" + "github.com/yusing/go-proxy/internal/route/provider" +) + +type ValidateFileRequest struct { + FileType FileType `form:"type" validate:"required,oneof=config provider middleware"` +} // @name ValidateFileRequest + +// @x-id "validate" +// @BasePath /api/v1 +// @Summary Validate file +// @Description Validate file +// @Tags file +// @Accept json +// @Produce json +// @Param type query FileType true "Type" +// @Param file body string true "File content" +// @Success 200 {object} apitypes.SuccessResponse "File validated" +// @Failure 400 {object} apitypes.ErrorResponse "Bad request" +// @Failure 403 {object} apitypes.ErrorResponse "Forbidden" +// @Failure 417 {object} apitypes.ErrorResponse "Validation failed" +// @Failure 500 {object} apitypes.ErrorResponse "Internal server error" +// @Router /file/validate [post] +func Validate(c *gin.Context) { + var request ValidateFileRequest + if err := c.ShouldBindQuery(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + + content, err := c.GetRawData() + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to read file")) + return + } + c.Request.Body.Close() + + if valErr := validateFile(request.FileType, content); valErr != nil { + c.JSON(http.StatusExpectationFailed, apitypes.Error("invalid file", valErr)) + return + } + c.JSON(http.StatusOK, apitypes.Success("file validated")) +} + +func validateFile(fileType FileType, content []byte) gperr.Error { + switch fileType { + case FileTypeConfig: + return config.Validate(content) + case FileTypeMiddleware: + errs := gperr.NewBuilder("middleware errors") + middleware.BuildMiddlewaresFromYAML("", content, errs) + return errs.Error() + } + return provider.Validate(content) +} diff --git a/internal/api/v1/health.go b/internal/api/v1/health.go index ef10fe11..53c3feb6 100644 --- a/internal/api/v1/health.go +++ b/internal/api/v1/health.go @@ -4,19 +4,31 @@ import ( "net/http" "time" - "github.com/gorilla/websocket" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" + "github.com/gin-gonic/gin" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" "github.com/yusing/go-proxy/internal/route/routes" ) -func Health(w http.ResponseWriter, r *http.Request) { - if httpheaders.IsWebsocket(r.Header) { - gpwebsocket.Periodic(w, r, 1*time.Second, func(conn *websocket.Conn) error { - return conn.WriteJSON(routes.HealthMap()) +type HealthMap = map[string]routes.HealthInfo // @name HealthMap + +// @x-id "health" +// @BasePath /api/v1 +// @Summary Get routes health info +// @Description Get health info by route name +// @Tags v1,websocket +// @Accept json +// @Produce json +// @Success 200 {object} HealthMap "Health info by route name" +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /health [get] +func Health(c *gin.Context) { + if httpheaders.IsWebsocket(c.Request.Header) { + websocket.PeriodicWrite(c, 1*time.Second, func() (any, error) { + return routes.GetHealthInfo(), nil }) } else { - gphttp.RespondJSON(w, r, routes.HealthMap()) + c.JSON(http.StatusOK, routes.GetHealthInfo()) } } diff --git a/internal/api/v1/homepage/categories.go b/internal/api/v1/homepage/categories.go new file mode 100644 index 00000000..08e524cf --- /dev/null +++ b/internal/api/v1/homepage/categories.go @@ -0,0 +1,22 @@ +package homepageapi + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/route/routes" +) + +// @x-id "categories" +// @BasePath /api/v1 +// @Summary List homepage categories +// @Description List homepage categories +// @Tags homepage +// @Accept json +// @Produce json +// @Success 200 {array} string +// @Failure 403 {object} apitypes.ErrorResponse +// @Router /homepage/categories [get] +func Categories(c *gin.Context) { + c.JSON(http.StatusOK, routes.HomepageCategories()) +} diff --git a/internal/api/v1/homepage/items.go b/internal/api/v1/homepage/items.go new file mode 100644 index 00000000..60d1f986 --- /dev/null +++ b/internal/api/v1/homepage/items.go @@ -0,0 +1,46 @@ +package homepageapi + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/route/routes" +) + +type HomepageItemsRequest struct { + Category string `form:"category" validate:"omitempty"` + Provider string `form:"provider" validate:"omitempty"` +} // @name HomepageItemsRequest + +// @x-id "items" +// @BasePath /api/v1 +// @Summary Homepage items +// @Description Homepage items +// @Tags homepage +// @Accept json +// @Produce json +// @Param category query string false "Category filter" +// @Param provider query string false "Provider filter" +// @Success 200 {object} homepage.Homepage +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Router /homepage/items [get] +func Items(c *gin.Context) { + var request HomepageItemsRequest + if err := c.ShouldBindQuery(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + + proto := "http" + if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { + proto = "https" + } + hostname := c.Request.Host + if host := c.GetHeader("X-Forwarded-Host"); host != "" { + hostname = host + } + + c.JSON(http.StatusOK, routes.HomepageItems(proto, hostname, request.Category, request.Provider)) +} diff --git a/internal/api/v1/homepage/overrides.go b/internal/api/v1/homepage/overrides.go new file mode 100644 index 00000000..d8ded137 --- /dev/null +++ b/internal/api/v1/homepage/overrides.go @@ -0,0 +1,110 @@ +package homepageapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/homepage" +) + +const ( + HomepageOverrideItem = "item" + HomepageOverrideItemsBatch = "items_batch" + HomepageOverrideCategoryOrder = "category_order" + HomepageOverrideItemVisible = "item_visible" +) + +type ( + HomepageOverrideItemParams struct { + Which string `json:"which"` + Value homepage.ItemConfig `json:"value"` + } // @name HomepageOverrideItemParams + HomepageOverrideItemsBatchParams struct { + Value map[string]*homepage.ItemConfig `json:"value"` + } // @name HomepageOverrideItemsBatchParams + HomepageOverrideCategoryOrderParams struct { + Which string `json:"which"` + Value int `json:"value"` + } // @name HomepageOverrideCategoryOrderParams + HomepageOverrideItemVisibleParams struct { + Which []string `json:"which"` + Value bool `json:"value"` + } // @name HomepageOverrideItemVisibleParams +) + +type SetHomePageOverridesRequest struct { + What string `json:"what" validate:"required,oneof=item items_batch category_order item_visible"` + Value any `json:"value" validate:"required" swaggerType:"object"` +} + +// @x-id "set" +// @BasePath /api/v1 +// @Summary Set homepage overrides +// @Description Set homepage overrides +// @Tags homepage +// @Accept json +// @Produce json +// @Param request body SetHomePageOverridesRequest{value=HomepageOverrideItemParams} true "Override single item" +// @Param request body SetHomePageOverridesRequest{value=HomepageOverrideItemsBatchParams} true "Override multiple items" +// @Param request body SetHomePageOverridesRequest{value=HomepageOverrideCategoryOrderParams} true "Override category order" +// @Param request body SetHomePageOverridesRequest{value=HomepageOverrideItemVisibleParams} true "Override item visibility" +// @Success 200 {object} apitypes.SuccessResponse +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /homepage/set [post] +func Set(c *gin.Context) { + var request SetHomePageOverridesRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + + data, err := c.GetRawData() + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to get raw data")) + return + } + + overrides := homepage.GetOverrideConfig() + switch request.What { + case HomepageOverrideItem: + var params HomepageOverrideItemParams + if err := json.Unmarshal(data, ¶ms); err != nil { + c.Error(apitypes.InternalServerError(err, "failed to unmarshal data")) + return + } + overrides.OverrideItem(params.Which, ¶ms.Value) + case HomepageOverrideItemsBatch: + var params HomepageOverrideItemsBatchParams + if err := json.Unmarshal(data, ¶ms); err != nil { + c.Error(apitypes.InternalServerError(err, "failed to unmarshal data")) + return + } + overrides.OverrideItems(params.Value) + case HomepageOverrideItemVisible: // POST /v1/item_visible [a,b,c], false => hide a, b, c + var params HomepageOverrideItemVisibleParams + if err := json.Unmarshal(data, ¶ms); err != nil { + c.Error(apitypes.InternalServerError(err, "failed to unmarshal data")) + return + } + if params.Value { + overrides.UnhideItems(params.Which) + } else { + overrides.HideItems(params.Which) + } + case HomepageOverrideCategoryOrder: + var params HomepageOverrideCategoryOrderParams + if err := json.Unmarshal(data, ¶ms); err != nil { + c.Error(apitypes.InternalServerError(err, "failed to unmarshal data")) + return + } + overrides.SetCategoryOrder(params.Which, params.Value) + default: // won't happen + c.JSON(http.StatusBadRequest, apitypes.Error("invalid what", errors.New("invalid what"))) + return + } + c.JSON(http.StatusOK, apitypes.Success("success")) +} diff --git a/internal/api/v1/homepage_overrides.go b/internal/api/v1/homepage_overrides.go deleted file mode 100644 index 97c82673..00000000 --- a/internal/api/v1/homepage_overrides.go +++ /dev/null @@ -1,90 +0,0 @@ -package v1 - -import ( - "encoding/json" - "io" - "net/http" - - "github.com/yusing/go-proxy/internal/homepage" - "github.com/yusing/go-proxy/internal/net/gphttp" -) - -const ( - HomepageOverrideItem = "item" - HomepageOverrideItemsBatch = "items_batch" - HomepageOverrideCategoryOrder = "category_order" - HomepageOverrideItemVisible = "item_visible" -) - -type ( - HomepageOverrideItemParams struct { - Which string `json:"which"` - Value homepage.ItemConfig `json:"value"` - } - HomepageOverrideItemsBatchParams struct { - Value map[string]*homepage.ItemConfig `json:"value"` - } - HomepageOverrideCategoryOrderParams struct { - Which string `json:"which"` - Value int `json:"value"` - } - HomepageOverrideItemVisibleParams struct { - Which []string `json:"which"` - Value bool `json:"value"` - } -) - -func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) { - what := r.FormValue("what") - if what == "" { - gphttp.BadRequest(w, "missing what or which") - return - } - - data, err := io.ReadAll(r.Body) - if err != nil { - gphttp.ClientError(w, r, err, http.StatusBadRequest) - return - } - r.Body.Close() - - overrides := homepage.GetOverrideConfig() - switch what { - case HomepageOverrideItem: - var params HomepageOverrideItemParams - if err := json.Unmarshal(data, ¶ms); err != nil { - gphttp.ClientError(w, r, err, http.StatusBadRequest) - return - } - overrides.OverrideItem(params.Which, ¶ms.Value) - case HomepageOverrideItemsBatch: - var params HomepageOverrideItemsBatchParams - if err := json.Unmarshal(data, ¶ms); err != nil { - gphttp.ClientError(w, r, err, http.StatusBadRequest) - return - } - overrides.OverrideItems(params.Value) - case HomepageOverrideItemVisible: // POST /v1/item_visible [a,b,c], false => hide a, b, c - var params HomepageOverrideItemVisibleParams - if err := json.Unmarshal(data, ¶ms); err != nil { - gphttp.ClientError(w, r, err, http.StatusBadRequest) - return - } - if params.Value { - overrides.UnhideItems(params.Which) - } else { - overrides.HideItems(params.Which) - } - case HomepageOverrideCategoryOrder: - var params HomepageOverrideCategoryOrderParams - if err := json.Unmarshal(data, ¶ms); err != nil { - gphttp.ClientError(w, r, err, http.StatusBadRequest) - return - } - overrides.SetCategoryOrder(params.Which, params.Value) - default: - http.Error(w, "invalid what", http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusOK) -} diff --git a/internal/api/v1/icons.go b/internal/api/v1/icons.go new file mode 100644 index 00000000..5e26a807 --- /dev/null +++ b/internal/api/v1/icons.go @@ -0,0 +1,37 @@ +package v1 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/homepage" +) + +type ListIconsRequest struct { + Limit int `form:"limit" validate:"omitempty,min=0"` + Keyword string `form:"keyword" validate:"required"` +} // @name ListIconsRequest + +// @x-id "icons" +// @BasePath /api/v1 +// @Summary List icons +// @Description List icons +// @Tags v1 +// @Accept json +// @Produce json +// @Param limit query int false "Limit" +// @Param keyword query string false "Keyword" +// @Success 200 {array} homepage.IconMetaSearch +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Router /icons [get] +func Icons(c *gin.Context) { + var request ListIconsRequest + if err := c.ShouldBindQuery(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + icons := homepage.SearchIcons(request.Keyword, request.Limit) + c.JSON(http.StatusOK, icons) +} diff --git a/internal/api/v1/index.go b/internal/api/v1/index.go deleted file mode 100644 index dcaa976a..00000000 --- a/internal/api/v1/index.go +++ /dev/null @@ -1,11 +0,0 @@ -package v1 - -import ( - "net/http" - - "github.com/yusing/go-proxy/internal/net/gphttp" -) - -func Index(w http.ResponseWriter, r *http.Request) { - gphttp.WriteBody(w, []byte("API ready")) -} diff --git a/internal/api/v1/list_agents.go b/internal/api/v1/list_agents.go deleted file mode 100644 index b90d9aac..00000000 --- a/internal/api/v1/list_agents.go +++ /dev/null @@ -1,22 +0,0 @@ -package v1 - -import ( - "net/http" - "time" - - "github.com/gorilla/websocket" - "github.com/yusing/go-proxy/agent/pkg/agent" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" - "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" -) - -func ListAgents(w http.ResponseWriter, r *http.Request) { - if httpheaders.IsWebsocket(r.Header) { - gpwebsocket.Periodic(w, r, 10*time.Second, func(conn *websocket.Conn) error { - return conn.WriteJSON(agent.ListAgents()) - }) - } else { - gphttp.RespondJSON(w, r, agent.ListAgents()) - } -} diff --git a/internal/api/v1/list_files.go b/internal/api/v1/list_files.go deleted file mode 100644 index 439600dc..00000000 --- a/internal/api/v1/list_files.go +++ /dev/null @@ -1,41 +0,0 @@ -package v1 - -import ( - "net/http" - "strings" - - "github.com/yusing/go-proxy/internal/common" - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/utils" -) - -func ListFilesHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - files, err := utils.ListFiles(common.ConfigBasePath, 0, true) - if err != nil { - gphttp.ServerError(w, r, err) - return - } - resp := map[FileType][]string{ - FileTypeConfig: make([]string, 0), - FileTypeProvider: make([]string, 0), - FileTypeMiddleware: make([]string, 0), - } - - for _, file := range files { - t := fileType(file) - file = strings.TrimPrefix(file, common.ConfigBasePath+"/") - resp[t] = append(resp[t], file) - } - - mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true) - if err != nil { - gphttp.ServerError(w, r, err) - return - } - for _, mid := range mids { - mid = strings.TrimPrefix(mid, common.MiddlewareComposeBasePath+"/") - resp[FileTypeMiddleware] = append(resp[FileTypeMiddleware], mid) - } - gphttp.RespondJSON(w, r, resp) -} diff --git a/internal/api/v1/list_homepage_categories.go b/internal/api/v1/list_homepage_categories.go deleted file mode 100644 index 8f67a43e..00000000 --- a/internal/api/v1/list_homepage_categories.go +++ /dev/null @@ -1,13 +0,0 @@ -package v1 - -import ( - "net/http" - - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/route/routes" -) - -func ListHomepageCategoriesHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - gphttp.RespondJSON(w, r, routes.HomepageCategories()) -} diff --git a/internal/api/v1/list_homepage_config.go b/internal/api/v1/list_homepage_config.go deleted file mode 100644 index 4a5b1f72..00000000 --- a/internal/api/v1/list_homepage_config.go +++ /dev/null @@ -1,13 +0,0 @@ -package v1 - -import ( - "net/http" - - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/route/routes" -) - -func ListHomepageConfigHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - gphttp.RespondJSON(w, r, routes.HomepageConfig(r.FormValue("category"), r.FormValue("provider"))) -} diff --git a/internal/api/v1/list_icons.go b/internal/api/v1/list_icons.go deleted file mode 100644 index eaef07d2..00000000 --- a/internal/api/v1/list_icons.go +++ /dev/null @@ -1,23 +0,0 @@ -package v1 - -import ( - "net/http" - "strconv" - - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/homepage" - "github.com/yusing/go-proxy/internal/net/gphttp" -) - -func ListIconsHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - limit, err := strconv.Atoi(r.FormValue("limit")) - if err != nil { - limit = 0 - } - icons, err := homepage.SearchIcons(r.FormValue("keyword"), limit) - if err != nil { - gphttp.ClientError(w, r, err) - return - } - gphttp.RespondJSON(w, r, icons) -} diff --git a/internal/api/v1/list_route.go b/internal/api/v1/list_route.go deleted file mode 100644 index e861a4ef..00000000 --- a/internal/api/v1/list_route.go +++ /dev/null @@ -1,19 +0,0 @@ -package v1 - -import ( - "net/http" - - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/route/routes" -) - -func ListRouteHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - which := r.PathValue("which") - route, ok := routes.Get(which) - if ok { - gphttp.RespondJSON(w, r, route) - } else { - gphttp.RespondJSON(w, r, nil) - } -} diff --git a/internal/api/v1/list_route_providers.go b/internal/api/v1/list_route_providers.go deleted file mode 100644 index 341c6cdf..00000000 --- a/internal/api/v1/list_route_providers.go +++ /dev/null @@ -1,22 +0,0 @@ -package v1 - -import ( - "net/http" - "time" - - "github.com/gorilla/websocket" - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" - "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" -) - -func ListRouteProvidersHandler(cfgInstance config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - if httpheaders.IsWebsocket(r.Header) { - gpwebsocket.Periodic(w, r, 5*time.Second, func(conn *websocket.Conn) error { - return conn.WriteJSON(cfgInstance.RouteProviderList()) - }) - } else { - gphttp.RespondJSON(w, r, cfgInstance.RouteProviderList()) - } -} diff --git a/internal/api/v1/list_routes.go b/internal/api/v1/list_routes.go deleted file mode 100644 index 80ab5e09..00000000 --- a/internal/api/v1/list_routes.go +++ /dev/null @@ -1,25 +0,0 @@ -package v1 - -import ( - "net/http" - "slices" - - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/route/routes" -) - -func ListRoutesHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - rts := make([]routes.Route, 0) - provider := r.FormValue("provider") - if provider == "" { - gphttp.RespondJSON(w, r, slices.Collect(routes.Iter)) - return - } - for r := range routes.Iter { - if r.ProviderName() == provider { - rts = append(rts, r) - } - } - gphttp.RespondJSON(w, r, rts) -} diff --git a/internal/api/v1/list_routes_by_provider.go b/internal/api/v1/list_routes_by_provider.go deleted file mode 100644 index f1e080d4..00000000 --- a/internal/api/v1/list_routes_by_provider.go +++ /dev/null @@ -1,13 +0,0 @@ -package v1 - -import ( - "net/http" - - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/route/routes" -) - -func ListRoutesByProviderHandler(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - gphttp.RespondJSON(w, r, routes.ByProvider()) -} diff --git a/internal/api/v1/metrics/system_info.go b/internal/api/v1/metrics/system_info.go new file mode 100644 index 00000000..5b8949b5 --- /dev/null +++ b/internal/api/v1/metrics/system_info.go @@ -0,0 +1,71 @@ +package metrics + +import ( + "net/http" + + "github.com/gin-gonic/gin" + agentPkg "github.com/yusing/go-proxy/agent/pkg/agent" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/metrics/period" + "github.com/yusing/go-proxy/internal/metrics/systeminfo" + "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" + nettypes "github.com/yusing/go-proxy/internal/net/types" +) + +type SystemInfoAggregate period.ResponseType[systeminfo.Aggregated] // @name SystemInfoAggregate + +// @x-id "system_info" +// @BasePath /api/v1 +// @Summary Get system info +// @Description Get system info +// @Tags metrics,websocket +// @Produce json +// @Param agent_addr query string false "Agent address" +// @Param period query string false "Period" +// @Success 200 {object} systeminfo.SystemInfo "no period specified" +// @Success 200 {object} SystemInfoAggregate "period specified" +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 404 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /metrics/system_info [get] +func SystemInfo(c *gin.Context) { + query := c.Request.URL.Query() + agentAddr := query.Get("agent_addr") + query.Del("agent_addr") + if agentAddr == "" { + systeminfo.Poller.ServeHTTP(c) + return + } + + agent, ok := agentPkg.GetAgent(agentAddr) + if !ok { + c.JSON(http.StatusNotFound, apitypes.Error("agent_addr not found")) + return + } + + isWS := httpheaders.IsWebsocket(c.Request.Header) + if !isWS { + respData, status, err := agent.Forward(c.Request, agentPkg.EndpointSystemInfo) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to forward request to agent")) + return + } + if status != http.StatusOK { + c.JSON(status, apitypes.Error(string(respData))) + return + } + c.JSON(status, respData) + } else { + rp := reverseproxy.NewReverseProxy("agent", nettypes.NewURL(agentPkg.AgentURL), agent.Transport()) + header := c.Request.Header.Clone() + r, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, agentPkg.EndpointSystemInfo+"?"+query.Encode(), nil) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to create request")) + return + } + r.Header = header + rp.ServeHTTP(c.Writer, r) + } +} diff --git a/internal/api/v1/metrics/upime.go b/internal/api/v1/metrics/upime.go new file mode 100644 index 00000000..6a4e30f1 --- /dev/null +++ b/internal/api/v1/metrics/upime.go @@ -0,0 +1,34 @@ +package metrics + +import ( + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/metrics/period" + "github.com/yusing/go-proxy/internal/metrics/uptime" +) + +type UptimeRequest struct { + Limit int `query:"limit" example:"10"` + Offset string `query:"offset" example:"10"` + Interval period.Filter `query:"interval" example:"1m"` + Keyword string `query:"keyword" example:""` +} // @name UptimeRequest + +type UptimeAggregate period.ResponseType[uptime.Aggregated] // @name UptimeAggregate + +// @x-id "uptime" +// @BasePath /api/v1 +// @Summary Get uptime +// @Description Get uptime +// @Tags metrics,websocket +// @Produce json +// @Param request query UptimeRequest false "Request" +// @Success 200 {object} uptime.StatusByAlias "no period specified" +// @Success 200 {object} UptimeAggregate "period specified" +// @Success 204 {object} apitypes.ErrorResponse +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /metrics/uptime [get] +func Uptime(c *gin.Context) { + uptime.Poller.ServeHTTP(c) +} diff --git a/internal/api/v1/new_agent.go b/internal/api/v1/new_agent.go deleted file mode 100644 index 6d99d226..00000000 --- a/internal/api/v1/new_agent.go +++ /dev/null @@ -1,141 +0,0 @@ -package v1 - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strconv" - - _ "embed" - - "github.com/yusing/go-proxy/agent/pkg/agent" - "github.com/yusing/go-proxy/agent/pkg/certs" - config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" -) - -func NewAgent(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - name := q.Get("name") - if name == "" { - gphttp.MissingKey(w, "name") - return - } - host := q.Get("host") - if host == "" { - gphttp.MissingKey(w, "host") - return - } - portStr := q.Get("port") - if portStr == "" { - gphttp.MissingKey(w, "port") - return - } - port, err := strconv.Atoi(portStr) - if err != nil || port < 1 || port > 65535 { - gphttp.InvalidKey(w, "port") - return - } - hostport := fmt.Sprintf("%s:%d", host, port) - if _, ok := agent.GetAgent(hostport); ok { - gphttp.KeyAlreadyExists(w, "agent", hostport) - return - } - t := q.Get("type") - switch t { - case "docker", "system": - break - case "": - gphttp.MissingKey(w, "type") - return - default: - gphttp.InvalidKey(w, "type") - return - } - - nightly, _ := strconv.ParseBool(q.Get("nightly")) - var image string - if nightly { - image = agent.DockerImageNightly - } else { - image = agent.DockerImageProduction - } - - ca, srv, client, err := agent.NewAgent() - if err != nil { - gphttp.ServerError(w, r, err) - return - } - - var cfg agent.Generator = &agent.AgentEnvConfig{ - Name: name, - Port: port, - CACert: ca.String(), - SSLCert: srv.String(), - } - if t == "docker" { - cfg = &agent.AgentComposeConfig{ - Image: image, - AgentEnvConfig: cfg.(*agent.AgentEnvConfig), - } - } - template, err := cfg.Generate() - if err != nil { - gphttp.ServerError(w, r, err) - return - } - - gphttp.RespondJSON(w, r, map[string]any{ - "compose": template, - "ca": ca, - "client": client, - }) -} - -func VerifyNewAgent(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - clientPEMData, err := io.ReadAll(r.Body) - if err != nil { - gphttp.ServerError(w, r, err) - return - } - - var data struct { - Host string `json:"host"` - CA agent.PEMPair `json:"ca"` - Client agent.PEMPair `json:"client"` - } - - if err := json.Unmarshal(clientPEMData, &data); err != nil { - gphttp.ClientError(w, r, err) - return - } - - nRoutesAdded, err := config.GetInstance().VerifyNewAgent(data.Host, data.CA, data.Client) - if err != nil { - gphttp.ClientError(w, r, err) - return - } - - zip, err := certs.ZipCert(data.CA.Cert, data.Client.Cert, data.Client.Key) - if err != nil { - gphttp.ServerError(w, r, err) - return - } - - filename, ok := certs.AgentCertsFilepath(data.Host) - if !ok { - gphttp.InvalidKey(w, "host") - return - } - - if err := os.WriteFile(filename, zip, 0600); err != nil { - gphttp.ServerError(w, r, err) - return - } - - w.WriteHeader(http.StatusOK) - w.Write(fmt.Appendf(nil, "Added %d routes", nRoutesAdded)) -} diff --git a/internal/api/v1/reload.go b/internal/api/v1/reload.go index 1460d47c..a96a6980 100644 --- a/internal/api/v1/reload.go +++ b/internal/api/v1/reload.go @@ -3,14 +3,26 @@ package v1 import ( "net/http" + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" ) -func Reload(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - if err := cfg.Reload(); err != nil { - gphttp.ServerError(w, r, err) +// @x-id "reload" +// @BasePath /api/v1 +// @Summary Reload config +// @Description Reload config +// @Tags v1 +// @Accept json +// @Produce json +// @Success 200 {object} apitypes.SuccessResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /reload [post] +func Reload(c *gin.Context) { + if err := config.GetInstance().Reload(); err != nil { + c.Error(apitypes.InternalServerError(err, "failed to reload config")) return } - gphttp.WriteBody(w, []byte("OK")) + c.JSON(http.StatusOK, apitypes.Success("config reloaded")) } diff --git a/internal/api/v1/route/by_provider.go b/internal/api/v1/route/by_provider.go new file mode 100644 index 00000000..2eadf2ed --- /dev/null +++ b/internal/api/v1/route/by_provider.go @@ -0,0 +1,26 @@ +package routeApi + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/route" + "github.com/yusing/go-proxy/internal/route/routes" +) + +type RoutesByProvider map[string][]route.Route + +// @x-id "byProvider" +// @BasePath /api/v1 +// @Summary List routes by provider +// @Description List routes by provider +// @Tags route +// @Accept json +// @Produce json +// @Success 200 {object} RoutesByProvider +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /route/by_provider [get] +func ByProvider(c *gin.Context) { + c.JSON(http.StatusOK, routes.ByProvider()) +} diff --git a/internal/api/v1/route/providers.go b/internal/api/v1/route/providers.go new file mode 100644 index 00000000..bcb12939 --- /dev/null +++ b/internal/api/v1/route/providers.go @@ -0,0 +1,33 @@ +package routeApi + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" +) + +// @x-id "providers" +// @BasePath /api/v1 +// @Summary List route providers +// @Description List route providers +// @Tags route,websocket +// @Accept json +// @Produce json +// @Success 200 {array} config.RouteProviderListResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /route/providers [get] +func Providers(c *gin.Context) { + cfg := config.GetInstance() + if httpheaders.IsWebsocket(c.Request.Header) { + websocket.PeriodicWrite(c, 5*time.Second, func() (any, error) { + return config.GetInstance().RouteProviderList(), nil + }) + } else { + c.JSON(http.StatusOK, cfg.RouteProviderList()) + } +} diff --git a/internal/api/v1/route/route.go b/internal/api/v1/route/route.go new file mode 100644 index 00000000..d3171896 --- /dev/null +++ b/internal/api/v1/route/route.go @@ -0,0 +1,41 @@ +package routeApi + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/route/routes" +) + +type ListRouteRequest struct { + Which string `uri:"which" validate:"required"` +} // @name ListRouteRequest + +// @x-id "route" +// @BasePath /api/v1 +// @Summary List route +// @Description List route +// @Tags route +// @Accept json +// @Produce json +// @Param which path string true "Route name" +// @Success 200 {object} RouteType +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 404 {object} apitypes.ErrorResponse +// @Router /route/{which} [get] +func Route(c *gin.Context) { + var request ListRouteRequest + if err := c.ShouldBindUri(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + + route, ok := routes.Get(request.Which) + if ok { + c.JSON(http.StatusOK, route) + } else { + c.JSON(http.StatusNotFound, nil) + } +} diff --git a/internal/api/v1/route/routes.go b/internal/api/v1/route/routes.go new file mode 100644 index 00000000..b2a8199d --- /dev/null +++ b/internal/api/v1/route/routes.go @@ -0,0 +1,68 @@ +package routeApi + +import ( + "net/http" + "slices" + "time" + + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" + "github.com/yusing/go-proxy/internal/route" + "github.com/yusing/go-proxy/internal/route/routes" + "github.com/yusing/go-proxy/internal/types" +) + +type RouteType route.Route // @name Route + +// @x-id "routes" +// @BasePath /api/v1 +// @Summary List routes +// @Description List routes +// @Tags route,websocket +// @Accept json +// @Produce json +// @Param provider query string false "Provider" +// @Success 200 {array} RouteType +// @Failure 403 {object} apitypes.ErrorResponse +// @Router /route/list [get] +func Routes(c *gin.Context) { + if httpheaders.IsWebsocket(c.Request.Header) { + RoutesWS(c) + return + } + + provider := c.Query("provider") + if provider == "" { + c.JSON(http.StatusOK, slices.Collect(routes.Iter)) + return + } + + rts := make([]types.Route, 0, routes.NumRoutes()) + for r := range routes.Iter { + if r.ProviderName() == provider { + rts = append(rts, r) + } + } + c.JSON(http.StatusOK, rts) +} + +func RoutesWS(c *gin.Context) { + provider := c.Query("provider") + if provider == "" { + websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) { + return slices.Collect(routes.Iter), nil + }) + return + } + + websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) { + rts := make([]types.Route, 0, routes.NumRoutes()) + for r := range routes.Iter { + if r.ProviderName() == provider { + rts = append(rts, r) + } + } + return rts, nil + }) +} diff --git a/internal/api/v1/stats.go b/internal/api/v1/stats.go index 0539c315..75a917b8 100644 --- a/internal/api/v1/stats.go +++ b/internal/api/v1/stats.go @@ -4,29 +4,52 @@ import ( "net/http" "time" - "github.com/gorilla/websocket" + "github.com/gin-gonic/gin" config "github.com/yusing/go-proxy/internal/config/types" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) -func Stats(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - if httpheaders.IsWebsocket(r.Header) { - gpwebsocket.Periodic(w, r, 1*time.Second, func(conn *websocket.Conn) error { - return conn.WriteJSON(getStats(cfg)) - }) +type StatsResponse struct { + Proxies ProxyStats `json:"proxies"` + Uptime string `json:"uptime"` +} // @name StatsResponse + +type ProxyStats struct { + Total uint16 `json:"total"` + ReverseProxies types.RouteStats `json:"reverse_proxies"` + Streams types.RouteStats `json:"streams"` + Providers map[string]types.ProviderStats `json:"providers"` +} // @name ProxyStats + +// @x-id "stats" +// @BasePath /api/v1 +// @Summary Get GoDoxy stats +// @Description Get stats +// @Tags v1,websocket +// @Accept json +// @Produce json +// @Success 200 {object} StatsResponse +// @Failure 403 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /stats [get] +func Stats(c *gin.Context) { + cfg := config.GetInstance() + getStats := func() (any, error) { + return map[string]any{ + "proxies": cfg.Statistics(), + "uptime": strutils.FormatDuration(time.Since(startTime)), + }, nil + } + + if httpheaders.IsWebsocket(c.Request.Header) { + websocket.PeriodicWrite(c, time.Second, getStats) } else { - gphttp.RespondJSON(w, r, getStats(cfg)) + stats, _ := getStats() + c.JSON(http.StatusOK, stats) } } var startTime = time.Now() - -func getStats(cfg config.ConfigInstance) map[string]any { - return map[string]any{ - "proxies": cfg.Statistics(), - "uptime": strutils.FormatDuration(time.Since(startTime)), - } -} diff --git a/internal/api/v1/system_info.go b/internal/api/v1/system_info.go deleted file mode 100644 index 2056c4de..00000000 --- a/internal/api/v1/system_info.go +++ /dev/null @@ -1,53 +0,0 @@ -package v1 - -import ( - "net/http" - - agentPkg "github.com/yusing/go-proxy/agent/pkg/agent" - "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/metrics/systeminfo" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" - "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" - nettypes "github.com/yusing/go-proxy/internal/net/types" -) - -func SystemInfo(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - agentAddr := query.Get("agent_addr") - query.Del("agent_addr") - if agentAddr == "" { - systeminfo.Poller.ServeHTTP(w, r) - return - } - - agent, ok := agentPkg.GetAgent(agentAddr) - if !ok { - gphttp.NotFound(w, "agent_addr") - return - } - - isWS := httpheaders.IsWebsocket(r.Header) - if !isWS { - respData, status, err := agent.Forward(r, agentPkg.EndpointSystemInfo) - if err != nil { - gphttp.ServerError(w, r, gperr.Wrap(err, "failed to forward request to agent")) - return - } - if status != http.StatusOK { - http.Error(w, string(respData), status) - return - } - gphttp.WriteBody(w, respData) - } else { - rp := reverseproxy.NewReverseProxy("agent", nettypes.NewURL(agentPkg.AgentURL), agent.Transport()) - header := r.Header.Clone() - r, err := http.NewRequestWithContext(r.Context(), r.Method, agentPkg.EndpointSystemInfo+"?"+query.Encode(), nil) - if err != nil { - gphttp.ServerError(w, r, gperr.Wrap(err, "failed to create request")) - return - } - r.Header = header - rp.ServeHTTP(w, r) - } -} diff --git a/internal/api/v1/version.go b/internal/api/v1/version.go new file mode 100644 index 00000000..67af997d --- /dev/null +++ b/internal/api/v1/version.go @@ -0,0 +1,21 @@ +package v1 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/pkg" +) + +// @x-id "version" +// @BasePath /api/v1 +// @Summary Get version +// @Description Get the version of the GoDoxy +// @Tags v1 +// @Accept json +// @Produce plain +// @Success 200 {string} string "version" +// @Router /version [get] +func Version(c *gin.Context) { + c.JSON(http.StatusOK, pkg.GetVersion().String()) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 68feb9b6..db19d886 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -4,7 +4,6 @@ import ( "net/http" "github.com/yusing/go-proxy/internal/common" - "github.com/yusing/go-proxy/internal/net/gphttp" ) var defaultAuth Provider @@ -42,19 +41,6 @@ type nextHandler struct{} var nextHandlerContextKey = nextHandler{} -func RequireAuth(next http.HandlerFunc) http.HandlerFunc { - if !IsEnabled() { - return next - } - return func(w http.ResponseWriter, r *http.Request) { - if err := defaultAuth.CheckToken(r); err != nil { - gphttp.Unauthorized(w, err.Error()) - return - } - next(w, r) - } -} - func ProceedNext(w http.ResponseWriter, r *http.Request) { next, ok := r.Context().Value(nextHandlerContextKey).(http.HandlerFunc) if ok { @@ -65,7 +51,8 @@ func ProceedNext(w http.ResponseWriter, r *http.Request) { } func AuthCheckHandler(w http.ResponseWriter, r *http.Request) { - if err := defaultAuth.CheckToken(r); err != nil { + err := defaultAuth.CheckToken(r) + if err != nil { defaultAuth.LoginHandler(w, r) } else { w.WriteHeader(http.StatusOK) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index d9e9d0cf..3617f2ff 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -37,6 +37,8 @@ type ( } ) +var _ Provider = (*OIDCProvider)(nil) + const ( CookieOauthState = "godoxy_oidc_state" CookieOauthToken = "godoxy_oauth_token" //nolint:gosec @@ -257,11 +259,11 @@ func (auth *OIDCProvider) PostAuthCallbackHandler(w http.ResponseWriter, r *http // verify state state, err := r.Cookie(CookieOauthState) if err != nil { - gphttp.BadRequest(w, "missing state cookie") + http.Error(w, "missing state cookie", http.StatusBadRequest) return } if r.URL.Query().Get("state") != state.Value { - gphttp.BadRequest(w, "invalid oauth state") + http.Error(w, "invalid oauth state", http.StatusBadRequest) return } @@ -335,12 +337,12 @@ func (auth *OIDCProvider) clearCookie(w http.ResponseWriter, r *http.Request) { func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) { state, err := r.Cookie(CookieOauthState) if err != nil { - gphttp.BadRequest(w, "missing state cookie") + http.Error(w, "missing state cookie", http.StatusBadRequest) return } if r.URL.Query().Get("state") != state.Value { - gphttp.BadRequest(w, "invalid oauth state") + http.Error(w, "invalid oauth state", http.StatusBadRequest) return } diff --git a/internal/auth/userpass.go b/internal/auth/userpass.go index 040304b4..52f13f24 100644 --- a/internal/auth/userpass.go +++ b/internal/auth/userpass.go @@ -32,6 +32,8 @@ type ( } ) +var _ Provider = (*UserPassAuth)(nil) + func NewUserPassAuth(username, password string, secret []byte, tokenTTL time.Duration) (*UserPassAuth, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -100,18 +102,21 @@ func (auth *UserPassAuth) CheckToken(r *http.Request) error { return nil } +type UserPassAuthCallbackRequest struct { + User string `json:"username"` + Pass string `json:"password"` +} + func (auth *UserPassAuth) PostAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { - var creds struct { - User string `json:"username"` - Pass string `json:"password"` - } + var creds UserPassAuthCallbackRequest err := json.NewDecoder(r.Body).Decode(&creds) if err != nil { - gphttp.Unauthorized(w, "invalid credentials") + http.Error(w, "invalid request", http.StatusBadRequest) return } if err := auth.validatePassword(creds.User, creds.Pass); err != nil { - gphttp.Unauthorized(w, "invalid credentials") + // NOTE: do not include the actual error here + http.Error(w, "invalid credentials", http.StatusBadRequest) return } token, err := auth.NewToken() diff --git a/internal/config/config.go b/internal/config/config.go index 55b2e04c..4fee8339 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -210,7 +210,7 @@ func (cfg *Config) StartServers(opts ...*StartServersOptions) { Name: "api", CertProvider: cfg.AutoCertProvider(), HTTPAddr: common.APIHTTPAddr, - Handler: api.NewHandler(cfg), + Handler: api.NewHandler(), }) } } diff --git a/internal/config/query.go b/internal/config/query.go index cbe4de0d..e940df94 100644 --- a/internal/config/query.go +++ b/internal/config/query.go @@ -3,6 +3,7 @@ package config import ( config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/route/provider" + "github.com/yusing/go-proxy/internal/types" ) func (cfg *Config) DumpRouteProviders() map[string]*provider.Provider { @@ -25,9 +26,9 @@ func (cfg *Config) RouteProviderList() []config.RouteProviderListResponse { } func (cfg *Config) Statistics() map[string]any { - var rps, streams provider.RouteStats + var rps, streams types.RouteStats var total uint16 - providerStats := make(map[string]provider.ProviderStats) + providerStats := make(map[string]types.ProviderStats) for _, p := range cfg.providers.Range { stats := p.Statistics() diff --git a/internal/config/types/config.go b/internal/config/types/config.go index de414b81..0cb14e43 100644 --- a/internal/config/types/config.go +++ b/internal/config/types/config.go @@ -45,7 +45,7 @@ type ( RouteProviderListResponse struct { ShortName string `json:"short_name"` FullName string `json:"full_name"` - } + } // @name RouteProvider ConfigInstance interface { Value() *Config Reload() gperr.Error diff --git a/internal/docker/container.go b/internal/docker/container.go index d568f3b0..cb2624ae 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -13,57 +13,19 @@ import ( "github.com/docker/go-connections/nat" "github.com/yusing/go-proxy/agent/pkg/agent" "github.com/yusing/go-proxy/internal/gperr" - idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" "github.com/yusing/go-proxy/internal/serialization" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils" ) -type ( - PortMapping = map[int]container.Port - Container struct { - _ utils.NoCopy - - DockerHost string `json:"docker_host"` - Image *ContainerImage `json:"image"` - ContainerName string `json:"container_name"` - ContainerID string `json:"container_id"` - - Agent *agent.AgentConfig `json:"agent"` - - Labels map[string]string `json:"-"` - IdlewatcherConfig *idlewatcher.Config `json:"idlewatcher_config"` - - Mounts []string `json:"mounts"` - - Network string `json:"network,omitempty"` - PublicPortMapping PortMapping `json:"public_ports"` // non-zero publicPort:types.Port - PrivatePortMapping PortMapping `json:"private_ports"` // privatePort:types.Port - PublicHostname string `json:"public_hostname"` - PrivateHostname string `json:"private_hostname"` - - Aliases []string `json:"aliases"` - IsExcluded bool `json:"is_excluded"` - IsExplicit bool `json:"is_explicit"` - IsHostNetworkMode bool `json:"is_host_network_mode"` - Running bool `json:"running"` - - Errors *containerError `json:"errors"` - } - ContainerImage struct { - Author string `json:"author,omitempty"` - Name string `json:"name"` - Tag string `json:"tag,omitempty"` - } -) - -var DummyContainer = new(Container) +var DummyContainer = new(types.Container) var ( ErrNetworkNotFound = errors.New("network not found") ErrNoNetwork = errors.New("no network found") ) -func FromDocker(c *container.SummaryTrimmed, dockerHost string) (res *Container) { +func FromDocker(c *container.Summary, dockerHost string) (res *types.Container) { _, isExplicit := c.Labels[LabelAliases] helper := containerHelper{c} if !isExplicit { @@ -78,7 +40,7 @@ func FromDocker(c *container.SummaryTrimmed, dockerHost string) (res *Container) network := helper.getDeleteLabel(LabelNetwork) isExcluded, _ := strconv.ParseBool(helper.getDeleteLabel(LabelExclude)) - res = &Container{ + res = &types.Container{ DockerHost: dockerHost, Image: helper.parseImage(), ContainerName: helper.getName(), @@ -103,25 +65,25 @@ func FromDocker(c *container.SummaryTrimmed, dockerHost string) (res *Container) var ok bool res.Agent, ok = agent.GetAgent(dockerHost) if !ok { - res.addError(fmt.Errorf("agent %q not found", dockerHost)) + addError(res, fmt.Errorf("agent %q not found", dockerHost)) } } - res.setPrivateHostname(helper) - res.setPublicHostname() - res.loadDeleteIdlewatcherLabels(helper) + setPrivateHostname(res, helper) + setPublicHostname(res) + loadDeleteIdlewatcherLabels(res, helper) if res.PrivateHostname == "" && res.PublicHostname == "" && res.Running { - res.addError(ErrNoNetwork) + addError(res, ErrNoNetwork) } return } -func (c *Container) IsBlacklisted() bool { - return c.Image.IsBlacklisted() || c.isDatabase() +func IsBlacklisted(c *types.Container) bool { + return IsBlacklistedImage(c.Image) || isDatabase(c) } -func (c *Container) UpdatePorts() error { +func UpdatePorts(c *types.Container) error { client, err := NewClient(c.DockerHost) if err != nil { return err @@ -148,15 +110,15 @@ func (c *Container) UpdatePorts() error { return nil } -func (c *Container) DockerComposeProject() string { +func DockerComposeProject(c *types.Container) string { return c.Labels["com.docker.compose.project"] } -func (c *Container) DockerComposeService() string { +func DockerComposeService(c *types.Container) string { return c.Labels["com.docker.compose.service"] } -func (c *Container) Dependencies() []string { +func Dependencies(c *types.Container) []string { deps := c.Labels[LabelDependsOn] if deps == "" { deps = c.Labels["com.docker.compose.depends_on"] @@ -173,7 +135,7 @@ var databaseMPs = map[string]struct{}{ "/var/lib/rabbitmq": {}, } -func (c *Container) isDatabase() bool { +func isDatabase(c *types.Container) bool { for _, m := range c.Mounts { if _, ok := databaseMPs[m]; ok { return true @@ -190,7 +152,7 @@ func (c *Container) isDatabase() bool { return false } -func (c *Container) isLocal() bool { +func isLocal(c *types.Container) bool { if strings.HasPrefix(c.DockerHost, "unix://") { return true } @@ -206,11 +168,11 @@ func (c *Container) isLocal() bool { return hostname == "localhost" } -func (c *Container) setPublicHostname() { +func setPublicHostname(c *types.Container) { if !c.Running { return } - if c.isLocal() { + if isLocal(c) { c.PublicHostname = "127.0.0.1" return } @@ -222,8 +184,8 @@ func (c *Container) setPublicHostname() { c.PublicHostname = url.Hostname() } -func (c *Container) setPrivateHostname(helper containerHelper) { - if !c.isLocal() && c.Agent == nil { +func setPrivateHostname(c *types.Container, helper containerHelper) { + if !isLocal(c) && c.Agent == nil { return } if helper.NetworkSettings == nil { @@ -236,7 +198,7 @@ func (c *Container) setPrivateHostname(helper containerHelper) { return } // try {project_name}_{network_name} - if proj := c.DockerComposeProject(); proj != "" { + if proj := DockerComposeProject(c); proj != "" { oldNetwork, newNetwork := c.Network, fmt.Sprintf("%s_%s", proj, c.Network) if newNetwork != oldNetwork { v, ok = helper.NetworkSettings.Networks[newNetwork] @@ -248,7 +210,7 @@ func (c *Container) setPrivateHostname(helper containerHelper) { } } nearest := gperr.DoYouMean(utils.NearestField(c.Network, helper.NetworkSettings.Networks)) - c.addError(fmt.Errorf("network %q not found, %w", c.Network, nearest)) + addError(c, fmt.Errorf("network %q not found, %w", c.Network, nearest)) return } // fallback to first network if no network is specified @@ -261,7 +223,7 @@ func (c *Container) setPrivateHostname(helper containerHelper) { } } -func (c *Container) loadDeleteIdlewatcherLabels(helper containerHelper) { +func loadDeleteIdlewatcherLabels(c *types.Container, helper containerHelper) { cfg := map[string]any{ "idle_timeout": helper.getDeleteLabel(LabelIdleTimeout), "wake_timeout": helper.getDeleteLabel(LabelWakeTimeout), @@ -269,7 +231,7 @@ func (c *Container) loadDeleteIdlewatcherLabels(helper containerHelper) { "stop_timeout": helper.getDeleteLabel(LabelStopTimeout), "stop_signal": helper.getDeleteLabel(LabelStopSignal), "start_endpoint": helper.getDeleteLabel(LabelStartEndpoint), - "depends_on": c.Dependencies(), + "depends_on": Dependencies(c), } // ensure it's deleted from labels @@ -278,8 +240,8 @@ func (c *Container) loadDeleteIdlewatcherLabels(helper containerHelper) { // set only if idlewatcher is enabled idleTimeout := cfg["idle_timeout"] if idleTimeout != "" { - idwCfg := new(idlewatcher.Config) - idwCfg.Docker = &idlewatcher.DockerConfig{ + idwCfg := new(types.IdlewatcherConfig) + idwCfg.Docker = &types.DockerConfig{ DockerHost: c.DockerHost, ContainerID: c.ContainerID, ContainerName: c.ContainerName, @@ -287,16 +249,16 @@ func (c *Container) loadDeleteIdlewatcherLabels(helper containerHelper) { err := serialization.MapUnmarshalValidate(cfg, idwCfg) if err != nil { - c.addError(err) + addError(c, err) } else { c.IdlewatcherConfig = idwCfg } } } -func (c *Container) addError(err error) { +func addError(c *types.Container, err error) { if c.Errors == nil { - c.Errors = new(containerError) + c.Errors = new(types.ContainerError) } c.Errors.Add(err) } diff --git a/internal/docker/container_helper.go b/internal/docker/container_helper.go index 5a8b4959..c311ef44 100644 --- a/internal/docker/container_helper.go +++ b/internal/docker/container_helper.go @@ -4,11 +4,12 @@ import ( "strings" "github.com/docker/docker/api/types/container" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) type containerHelper struct { - *container.SummaryTrimmed + *container.Summary } // getDeleteLabel gets the value of a label and then deletes it from the container. @@ -40,10 +41,10 @@ func (c containerHelper) getMounts() []string { return m } -func (c containerHelper) parseImage() *ContainerImage { +func (c containerHelper) parseImage() *types.ContainerImage { colonSep := strutils.SplitRune(c.Image, ':') slashSep := strutils.SplitRune(colonSep[0], '/') - im := new(ContainerImage) + im := new(types.ContainerImage) if len(slashSep) > 1 { im.Author = strings.Join(slashSep[:len(slashSep)-1], "/") im.Name = slashSep[len(slashSep)-1] @@ -59,8 +60,8 @@ func (c containerHelper) parseImage() *ContainerImage { return im } -func (c containerHelper) getPublicPortMapping() PortMapping { - res := make(PortMapping) +func (c containerHelper) getPublicPortMapping() types.PortMapping { + res := make(types.PortMapping) for _, v := range c.Ports { if v.PublicPort == 0 { continue @@ -70,8 +71,8 @@ func (c containerHelper) getPublicPortMapping() PortMapping { return res } -func (c containerHelper) getPrivatePortMapping() PortMapping { - res := make(PortMapping) +func (c containerHelper) getPrivatePortMapping() types.PortMapping { + res := make(types.PortMapping) for _, v := range c.Ports { res[int(v.PrivatePort)] = v } diff --git a/internal/docker/errors.go b/internal/docker/errors.go deleted file mode 100644 index a168847f..00000000 --- a/internal/docker/errors.go +++ /dev/null @@ -1,34 +0,0 @@ -package docker - -import ( - "encoding/json" - - "github.com/yusing/go-proxy/internal/gperr" -) - -type containerError struct { - errs *gperr.Builder -} - -func (e *containerError) Add(err error) { - if e.errs == nil { - e.errs = gperr.NewBuilder() - } - e.errs.Add(err) -} - -func (e *containerError) Error() string { - if e.errs == nil { - return "" - } - return e.errs.String() -} - -func (e *containerError) Unwrap() error { - return e.errs.Error() -} - -func (e *containerError) MarshalJSON() ([]byte, error) { - err := e.errs.Error().(interface{ Plain() []byte }) - return json.Marshal(string(err.Plain())) -} diff --git a/internal/docker/image_blacklist.go b/internal/docker/image_blacklist.go index a7a7c3ba..8bb2648a 100644 --- a/internal/docker/image_blacklist.go +++ b/internal/docker/image_blacklist.go @@ -1,5 +1,7 @@ package docker +import "github.com/yusing/go-proxy/internal/types" + var imageBlacklist = map[string]struct{}{ // pure databases without UI "postgres": {}, @@ -45,7 +47,7 @@ var authorBlacklist = map[string]struct{}{ "docker": {}, } -func (image *ContainerImage) IsBlacklisted() bool { +func IsBlacklistedImage(image *types.ContainerImage) bool { _, ok := imageBlacklist[image.Name] if ok { return true diff --git a/internal/docker/label.go b/internal/docker/label.go index 09130a96..7c8a4cbc 100644 --- a/internal/docker/label.go +++ b/internal/docker/label.go @@ -6,15 +6,14 @@ import ( "github.com/goccy/go-yaml" "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) -type LabelMap = map[string]any - var ErrInvalidLabel = gperr.New("invalid label") -func ParseLabels(labels map[string]string, aliases ...string) (LabelMap, gperr.Error) { - nestedMap := make(LabelMap) +func ParseLabels(labels map[string]string, aliases ...string) (types.LabelMap, gperr.Error) { + nestedMap := make(types.LabelMap) errs := gperr.NewBuilder("labels error") ExpandWildcard(labels, aliases...) @@ -38,15 +37,15 @@ func ParseLabels(labels map[string]string, aliases ...string) (LabelMap, gperr.E } else { // If the key doesn't exist, create a new map if _, exists := currentMap[k]; !exists { - currentMap[k] = make(LabelMap) + currentMap[k] = make(types.LabelMap) } // Move deeper into the nested map - m, ok := currentMap[k].(LabelMap) + m, ok := currentMap[k].(types.LabelMap) if !ok && currentMap[k] != "" { errs.Add(gperr.Errorf("expect mapping, got %T", currentMap[k]).Subject(lbl)) continue } else if !ok { - m = make(LabelMap) + m = make(types.LabelMap) currentMap[k] = m } currentMap = m diff --git a/internal/docker/list_containers.go b/internal/docker/list_containers.go index 671f5cf8..517b59d8 100644 --- a/internal/docker/list_containers.go +++ b/internal/docker/list_containers.go @@ -21,7 +21,7 @@ var listOptions = container.ListOptions{ All: true, } -func ListContainers(clientHost string) ([]container.SummaryTrimmed, error) { +func ListContainers(clientHost string) ([]container.Summary, error) { dockerClient, err := NewClient(clientHost) if err != nil { return nil, err diff --git a/internal/entrypoint/entrypoint.go b/internal/entrypoint/entrypoint.go index 1b3cec00..2278d4bc 100644 --- a/internal/entrypoint/entrypoint.go +++ b/internal/entrypoint/entrypoint.go @@ -12,13 +12,14 @@ import ( "github.com/yusing/go-proxy/internal/net/gphttp/middleware/errorpage" "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) type Entrypoint struct { middleware *middleware.Middleware accessLogger *accesslog.AccessLogger - findRouteFunc func(host string) (routes.HTTPRoute, error) + findRouteFunc func(host string) (types.HTTPRoute, error) } var ErrNoSuchRoute = errors.New("no such route") @@ -104,7 +105,7 @@ func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -func findRouteAnyDomain(host string) (routes.HTTPRoute, error) { +func findRouteAnyDomain(host string) (types.HTTPRoute, error) { hostSplit := strutils.SplitRune(host, '.') target := hostSplit[0] @@ -114,8 +115,8 @@ func findRouteAnyDomain(host string) (routes.HTTPRoute, error) { return nil, fmt.Errorf("%w: %s", ErrNoSuchRoute, target) } -func findRouteByDomains(domains []string) func(host string) (routes.HTTPRoute, error) { - return func(host string) (routes.HTTPRoute, error) { +func findRouteByDomains(domains []string) func(host string) (types.HTTPRoute, error) { + return func(host string) (types.HTTPRoute, error) { for _, domain := range domains { if strings.HasSuffix(host, domain) { target := strings.TrimSuffix(host, domain) diff --git a/internal/homepage/homepage.go b/internal/homepage/homepage.go index f030541e..75febb0a 100644 --- a/internal/homepage/homepage.go +++ b/internal/homepage/homepage.go @@ -8,8 +8,8 @@ import ( ) type ( - Homepage map[string]Category - Category []*Item + Homepage map[string]Category // @name HomepageItems + Category []*Item // @name HomepageCategory ItemConfig struct { Show bool `json:"show"` @@ -23,6 +23,7 @@ type ( Item struct { *ItemConfig + WidgetConfig *widgets.Config `json:"widget_config,omitempty" aliases:"widget"` Alias string `json:"alias"` diff --git a/internal/homepage/list_icons.go b/internal/homepage/list_icons.go index 6ed4d6c5..9e9ad514 100644 --- a/internal/homepage/list_icons.go +++ b/internal/homepage/list_icons.go @@ -150,9 +150,9 @@ func ListAvailableIcons() (*Cache, error) { return iconsCache, nil } -func SearchIcons(keyword string, limit int) ([]IconMetaSearch, error) { +func SearchIcons(keyword string, limit int) []IconMetaSearch { if keyword == "" { - return make([]IconMetaSearch, 0), nil + return make([]IconMetaSearch, 0) } iconsCache.RLock() defer iconsCache.RUnlock() @@ -174,7 +174,7 @@ func SearchIcons(keyword string, limit int) ([]IconMetaSearch, error) { break } } - return result, nil + return result } func HasIcon(icon *IconURL) bool { diff --git a/internal/idlewatcher/handle_http.go b/internal/idlewatcher/handle_http.go index e1b63266..ce3a5821 100644 --- a/internal/idlewatcher/handle_http.go +++ b/internal/idlewatcher/handle_http.go @@ -1,11 +1,12 @@ package idlewatcher import ( + "fmt" "net/http" "strconv" "time" - "github.com/yusing/go-proxy/internal/api/v1/favicon" + api "github.com/yusing/go-proxy/internal/api/v1" gphttp "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" ) @@ -62,7 +63,14 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN // handle favicon request if isFaviconPath(r.URL.Path) { - favicon.GetFavIconFromAlias(rw, r, w.route.Name()) + result := api.GetFavIconFromAlias(r.Context(), w.route.Name()) + if !result.OK() { + rw.WriteHeader(result.StatusCode) + fmt.Fprint(rw, result.ErrMsg) + return false + } + rw.Header().Set("Content-Type", result.ContentType()) + rw.WriteHeader(result.StatusCode) return false } diff --git a/internal/idlewatcher/health.go b/internal/idlewatcher/health.go index 6828e34b..2bfa6d48 100644 --- a/internal/idlewatcher/health.go +++ b/internal/idlewatcher/health.go @@ -6,7 +6,7 @@ import ( "github.com/yusing/go-proxy/internal/gperr" idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" "github.com/yusing/go-proxy/internal/task" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) // Start implements health.HealthMonitor. @@ -50,18 +50,18 @@ func (w *Watcher) Latency() time.Duration { } // Status implements health.HealthMonitor. -func (w *Watcher) Status() health.Status { +func (w *Watcher) Status() types.HealthStatus { state := w.state.Load() if state.err != nil { - return health.StatusError + return types.StatusError } if state.ready { - return health.StatusHealthy + return types.StatusHealthy } if state.status == idlewatcher.ContainerStatusRunning { - return health.StatusStarting + return types.StatusStarting } - return health.StatusNapping + return types.StatusNapping } // Detail implements health.HealthMonitor. @@ -89,7 +89,7 @@ func (w *Watcher) MarshalJSON() ([]byte, error) { if err := w.error(); err != nil { detail = err.Error() } - return (&health.JSONRepresentation{ + return (&types.HealthJSONRepr{ Name: w.Name(), Status: w.Status(), Config: dummyHealthCheckConfig, diff --git a/internal/idlewatcher/provider/docker.go b/internal/idlewatcher/provider/docker.go index 9cceed49..b9f2b95b 100644 --- a/internal/idlewatcher/provider/docker.go +++ b/internal/idlewatcher/provider/docker.go @@ -7,6 +7,7 @@ import ( "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/watcher" ) @@ -42,14 +43,14 @@ func (p *DockerProvider) ContainerStart(ctx context.Context) error { return p.client.ContainerStart(ctx, p.containerID, startOptions) } -func (p *DockerProvider) ContainerStop(ctx context.Context, signal idlewatcher.Signal, timeout int) error { +func (p *DockerProvider) ContainerStop(ctx context.Context, signal types.ContainerSignal, timeout int) error { return p.client.ContainerStop(ctx, p.containerID, container.StopOptions{ Signal: string(signal), Timeout: &timeout, }) } -func (p *DockerProvider) ContainerKill(ctx context.Context, signal idlewatcher.Signal) error { +func (p *DockerProvider) ContainerKill(ctx context.Context, signal types.ContainerSignal) error { return p.client.ContainerKill(ctx, p.containerID, string(signal)) } diff --git a/internal/idlewatcher/provider/proxmox.go b/internal/idlewatcher/provider/proxmox.go index 7cbb02d1..7dc492e7 100644 --- a/internal/idlewatcher/provider/proxmox.go +++ b/internal/idlewatcher/provider/proxmox.go @@ -8,6 +8,7 @@ import ( "github.com/yusing/go-proxy/internal/gperr" idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" "github.com/yusing/go-proxy/internal/proxmox" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/watcher" "github.com/yusing/go-proxy/internal/watcher/events" ) @@ -52,11 +53,11 @@ func (p *ProxmoxProvider) ContainerStart(ctx context.Context) error { return p.LXCAction(ctx, p.vmid, proxmox.LXCStart) } -func (p *ProxmoxProvider) ContainerStop(ctx context.Context, _ idlewatcher.Signal, _ int) error { +func (p *ProxmoxProvider) ContainerStop(ctx context.Context, _ types.ContainerSignal, _ int) error { return p.LXCAction(ctx, p.vmid, proxmox.LXCShutdown) } -func (p *ProxmoxProvider) ContainerKill(ctx context.Context, _ idlewatcher.Signal) error { +func (p *ProxmoxProvider) ContainerKill(ctx context.Context, _ types.ContainerSignal) error { return p.LXCAction(ctx, p.vmid, proxmox.LXCShutdown) } diff --git a/internal/idlewatcher/types/config.go b/internal/idlewatcher/types/config.go index 116354d4..0fd8731e 100644 --- a/internal/idlewatcher/types/config.go +++ b/internal/idlewatcher/types/config.go @@ -1,138 +1 @@ package idlewatcher - -import ( - "net/url" - "strconv" - "strings" - "time" - - "github.com/yusing/go-proxy/internal/gperr" -) - -type ( - ProviderConfig struct { - Proxmox *ProxmoxConfig `json:"proxmox,omitempty"` - Docker *DockerConfig `json:"docker,omitempty"` - } - IdlewatcherConfig struct { - // 0: no idle watcher. - // Positive: idle watcher with idle timeout. - // Negative: idle watcher as a dependency. IdleTimeout time.Duration `json:"idle_timeout" json_ext:"duration"` - IdleTimeout time.Duration `json:"idle_timeout"` - WakeTimeout time.Duration `json:"wake_timeout"` - StopTimeout time.Duration `json:"stop_timeout"` - StopMethod StopMethod `json:"stop_method"` - StopSignal Signal `json:"stop_signal,omitempty"` - } - Config struct { - ProviderConfig - IdlewatcherConfig - - StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container - DependsOn []string `json:"depends_on,omitempty"` - } - StopMethod string - Signal string - - DockerConfig struct { - DockerHost string `json:"docker_host" validate:"required"` - ContainerID string `json:"container_id" validate:"required"` - ContainerName string `json:"container_name" validate:"required"` - } - ProxmoxConfig struct { - Node string `json:"node" validate:"required"` - VMID int `json:"vmid" validate:"required"` - } -) - -const ( - WakeTimeoutDefault = 30 * time.Second - StopTimeoutDefault = 1 * time.Minute - - StopMethodPause StopMethod = "pause" - StopMethodStop StopMethod = "stop" - StopMethodKill StopMethod = "kill" -) - -func (c *Config) Key() string { - if c.Docker != nil { - return c.Docker.ContainerID - } - return c.Proxmox.Node + ":" + strconv.Itoa(c.Proxmox.VMID) -} - -func (c *Config) ContainerName() string { - if c.Docker != nil { - return c.Docker.ContainerName - } - return "lxc-" + strconv.Itoa(c.Proxmox.VMID) -} - -func (c *Config) Validate() gperr.Error { - if c.IdleTimeout == 0 { // zero idle timeout means no idle watcher - return nil - } - errs := gperr.NewBuilder("idlewatcher config validation error") - errs.AddRange( - c.validateProvider(), - c.validateTimeouts(), - c.validateStopMethod(), - c.validateStopSignal(), - c.validateStartEndpoint(), - ) - return errs.Error() -} - -func (c *Config) validateProvider() error { - if c.Docker == nil && c.Proxmox == nil { - return gperr.New("missing idlewatcher provider config") - } - return nil -} - -func (c *Config) validateTimeouts() error { //nolint:unparam - if c.WakeTimeout == 0 { - c.WakeTimeout = WakeTimeoutDefault - } - if c.StopTimeout == 0 { - c.StopTimeout = StopTimeoutDefault - } - return nil -} - -func (c *Config) validateStopMethod() error { - switch c.StopMethod { - case "": - c.StopMethod = StopMethodStop - return nil - case StopMethodPause, StopMethodStop, StopMethodKill: - return nil - default: - return gperr.New("invalid stop method").Subject(string(c.StopMethod)) - } -} - -func (c *Config) validateStopSignal() error { - switch c.StopSignal { - case "", "SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP", "INT", "TERM", "QUIT", "HUP": - return nil - default: - return gperr.New("invalid stop signal").Subject(string(c.StopSignal)) - } -} - -func (c *Config) validateStartEndpoint() error { - if c.StartEndpoint == "" { - return nil - } - // checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195 - // emulate browser and strip the '#' suffix prior to validation. see issue-#237 - if i := strings.Index(c.StartEndpoint, "#"); i > -1 { - c.StartEndpoint = c.StartEndpoint[:i] - } - if len(c.StartEndpoint) == 0 { - return gperr.New("start endpoint must not be empty if defined") - } - _, err := url.ParseRequestURI(c.StartEndpoint) - return err -} diff --git a/internal/idlewatcher/types/provider.go b/internal/idlewatcher/types/provider.go index 0df599f9..4c291ed3 100644 --- a/internal/idlewatcher/types/provider.go +++ b/internal/idlewatcher/types/provider.go @@ -4,6 +4,7 @@ import ( "context" "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/watcher/events" ) @@ -11,8 +12,8 @@ type Provider interface { ContainerPause(ctx context.Context) error ContainerUnpause(ctx context.Context) error ContainerStart(ctx context.Context) error - ContainerStop(ctx context.Context, signal Signal, timeout int) error - ContainerKill(ctx context.Context, signal Signal) error + ContainerStop(ctx context.Context, signal types.ContainerSignal, timeout int) error + ContainerKill(ctx context.Context, signal types.ContainerSignal) error ContainerStatus(ctx context.Context) (ContainerStatus, error) Watch(ctx context.Context) (eventCh <-chan events.Event, errCh <-chan gperr.Error) Close() diff --git a/internal/idlewatcher/types/waker.go b/internal/idlewatcher/types/waker.go index 0c7706f1..46c3170e 100644 --- a/internal/idlewatcher/types/waker.go +++ b/internal/idlewatcher/types/waker.go @@ -4,11 +4,11 @@ import ( "net/http" nettypes "github.com/yusing/go-proxy/internal/net/types" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) type Waker interface { - health.HealthMonitor + types.HealthMonitor http.Handler nettypes.Stream Wake() error diff --git a/internal/idlewatcher/watcher.go b/internal/idlewatcher/watcher.go index 7b42a622..e4d2af93 100644 --- a/internal/idlewatcher/watcher.go +++ b/internal/idlewatcher/watcher.go @@ -10,6 +10,7 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/idlewatcher/provider" idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" @@ -17,10 +18,10 @@ import ( nettypes "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/types" U "github.com/yusing/go-proxy/internal/utils" "github.com/yusing/go-proxy/internal/utils/atomic" "github.com/yusing/go-proxy/internal/watcher/events" - "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/internal/watcher/health/monitor" "golang.org/x/sync/errgroup" "golang.org/x/sync/singleflight" @@ -28,10 +29,10 @@ import ( type ( routeHelper struct { - route routes.Route + route types.Route rp *reverseproxy.ReverseProxy stream nettypes.Stream - hc health.HealthChecker + hc types.HealthChecker } containerState struct { @@ -46,7 +47,7 @@ type ( l zerolog.Logger - cfg *idlewatcher.Config + cfg *types.IdlewatcherConfig provider idlewatcher.Provider @@ -80,7 +81,7 @@ const ( idleWakerCheckTimeout = time.Second ) -var dummyHealthCheckConfig = &health.HealthCheckConfig{ +var dummyHealthCheckConfig = &types.HealthCheckConfig{ Interval: idleWakerCheckInterval, Timeout: idleWakerCheckTimeout, } @@ -96,7 +97,7 @@ const reqTimeout = 3 * time.Second const neverTick = time.Duration(1<<63 - 1) // TODO: fix stream type. -func NewWatcher(parent task.Parent, r routes.Route, cfg *idlewatcher.Config) (*Watcher, error) { +func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig) (*Watcher, error) { key := cfg.Key() watcherMapMu.RLock() @@ -109,7 +110,7 @@ func NewWatcher(parent task.Parent, r routes.Route, cfg *idlewatcher.Config) (*W w.cfg.DependsOn = cfg.DependsOn } if cfg.IdleTimeout > 0 { - w.cfg.IdlewatcherConfig = cfg.IdlewatcherConfig + w.cfg.IdlewatcherConfigBase = cfg.IdlewatcherConfigBase } cfg = w.cfg w.resetIdleTimer() @@ -147,12 +148,12 @@ func NewWatcher(parent task.Parent, r routes.Route, cfg *idlewatcher.Config) (*W cont := r.ContainerInfo() - var depRoute routes.Route + var depRoute types.Route var ok bool // try to find the dependency in the same provider and the same docker compose project first if cont != nil { - depRoute, ok = r.GetProvider().FindService(cont.DockerComposeProject(), dep) + depRoute, ok = r.GetProvider().FindService(docker.DockerComposeProject(cont), dep) } if !ok { @@ -178,8 +179,8 @@ func NewWatcher(parent task.Parent, r routes.Route, cfg *idlewatcher.Config) (*W depCfg := depRoute.IdlewatcherConfig() if depCfg == nil { - depCfg = new(idlewatcher.Config) - depCfg.IdlewatcherConfig = cfg.IdlewatcherConfig + depCfg = new(types.IdlewatcherConfig) + depCfg.IdlewatcherConfigBase = cfg.IdlewatcherConfigBase depCfg.IdleTimeout = neverTick // disable auto sleep for dependencies } else if depCfg.IdleTimeout > 0 { depErrors.Addf("dependency %q has positive idle timeout %s", dep, depCfg.IdleTimeout) @@ -189,12 +190,12 @@ func NewWatcher(parent task.Parent, r routes.Route, cfg *idlewatcher.Config) (*W if depCfg.Docker == nil && depCfg.Proxmox == nil { depCont := depRoute.ContainerInfo() if depCont != nil { - depCfg.Docker = &idlewatcher.DockerConfig{ + depCfg.Docker = &types.DockerConfig{ DockerHost: depCont.DockerHost, ContainerID: depCont.ContainerID, ContainerName: depCont.ContainerName, } - depCfg.DependsOn = depCont.Dependencies() + depCfg.DependsOn = docker.Dependencies(depCont) } else { depErrors.Addf("dependency %q has no idlewatcher config but is not a docker container", dep) continue @@ -258,9 +259,9 @@ func NewWatcher(parent task.Parent, r routes.Route, cfg *idlewatcher.Config) (*W w.provider = p switch r := r.(type) { - case routes.ReverseProxyRoute: + case types.ReverseProxyRoute: w.rp = r.ReverseProxy() - case routes.StreamRoute: + case types.StreamRoute: w.stream = r.Stream() default: w.provider.Close() @@ -443,11 +444,11 @@ func (w *Watcher) stopByMethod() error { // stop itself first. var err error switch cfg.StopMethod { - case idlewatcher.StopMethodPause: + case types.ContainerStopMethodPause: err = w.provider.ContainerPause(ctx) - case idlewatcher.StopMethodStop: + case types.ContainerStopMethodStop: err = w.provider.ContainerStop(ctx, cfg.StopSignal, int(cfg.StopTimeout.Seconds())) - case idlewatcher.StopMethodKill: + case types.ContainerStopMethodKill: err = w.provider.ContainerKill(ctx, cfg.StopSignal) default: err = w.newWatcherError(gperr.Errorf("unexpected stop method: %q", cfg.StopMethod)) diff --git a/internal/jsonstore/jsonstore.go b/internal/jsonstore/jsonstore.go index 8fb0df23..fae89519 100644 --- a/internal/jsonstore/jsonstore.go +++ b/internal/jsonstore/jsonstore.go @@ -51,6 +51,11 @@ func init() { func loadNS[T store](ns namespace) T { store := reflect.New(reflect.TypeFor[T]().Elem()).Interface().(T) store.Initialize() + + if common.IsTest { + return store + } + path := filepath.Join(storesPath, string(ns)+".json") file, err := os.Open(path) if err != nil { diff --git a/internal/logging/accesslog/config.go b/internal/logging/accesslog/config.go index 9ac0c5de..b350aaf5 100644 --- a/internal/logging/accesslog/config.go +++ b/internal/logging/accesslog/config.go @@ -13,7 +13,7 @@ type ( Path string `json:"path"` Stdout bool `json:"stdout"` Retention *Retention `json:"retention" aliases:"keep"` - RotateInterval time.Duration `json:"rotate_interval,omitempty"` + RotateInterval time.Duration `json:"rotate_interval,omitempty" swaggertype:"primitive,integer"` } ACLLoggerConfig struct { ConfigBase @@ -24,7 +24,7 @@ type ( Format Format `json:"format" validate:"oneof=common combined json"` Filters Filters `json:"filters"` Fields Fields `json:"fields"` - } + } // @name RequestLoggerConfig Config struct { *ConfigBase acl *ACLLoggerConfig diff --git a/internal/logging/accesslog/filter.go b/internal/logging/accesslog/filter.go index bfe1b20a..448cb50e 100644 --- a/internal/logging/accesslog/filter.go +++ b/internal/logging/accesslog/filter.go @@ -14,19 +14,19 @@ type ( LogFilter[T Filterable] struct { Negative bool Values []T - } + } // @name LogFilter Filterable interface { comparable Fulfill(req *http.Request, res *http.Response) bool } - HTTPMethod string + HTTPMethod string // @name HTTPMethod HTTPHeader struct { Key, Value string - } - Host string + } // @name HTTPHeader + Host string // @name Host CIDR struct { nettypes.CIDR - } + } // @name CIDR ) var ErrInvalidHTTPHeaderFilter = gperr.New("invalid http header filter") diff --git a/internal/logging/accesslog/retention.go b/internal/logging/accesslog/retention.go index 3a130cc2..19ceca1c 100644 --- a/internal/logging/accesslog/retention.go +++ b/internal/logging/accesslog/retention.go @@ -12,7 +12,7 @@ type Retention struct { Days uint64 `json:"days"` Last uint64 `json:"last"` KeepSize uint64 `json:"keep_size"` -} +} // @name LogRetention var ( ErrInvalidSyntax = gperr.New("invalid syntax") diff --git a/internal/logging/accesslog/status_code_range.go b/internal/logging/accesslog/status_code_range.go index 7ec94a2a..36db4aae 100644 --- a/internal/logging/accesslog/status_code_range.go +++ b/internal/logging/accesslog/status_code_range.go @@ -10,7 +10,7 @@ import ( type StatusCodeRange struct { Start int End int -} +} // @name StatusCodeRange var ErrInvalidStatusCodeRange = gperr.New("invalid status code range") diff --git a/internal/logging/memlogger/mem_logger.go b/internal/logging/memlogger/mem_logger.go index 784b1d46..70f81e27 100644 --- a/internal/logging/memlogger/mem_logger.go +++ b/internal/logging/memlogger/mem_logger.go @@ -4,13 +4,13 @@ import ( "bytes" "context" "io" - "net/http" "sync" "time" - "github.com/gorilla/websocket" + "github.com/gin-gonic/gin" "github.com/puzpuzpuz/xsync/v4" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" ) type logEntryRange struct { @@ -20,6 +20,7 @@ type logEntryRange struct { type memLogger struct { *bytes.Buffer sync.RWMutex + notifyLock sync.RWMutex connChans *xsync.Map[chan *logEntryRange, struct{}] listeners *xsync.Map[chan []byte, struct{}] @@ -31,6 +32,7 @@ const ( maxMemLogSize = 16 * 1024 truncateSize = maxMemLogSize / 2 initialWriteChunkSize = 4 * 1024 + writeTimeout = 10 * time.Second ) var memLoggerInstance = &memLogger{ @@ -43,11 +45,7 @@ func GetMemLogger() MemLogger { return memLoggerInstance } -func Handler() http.Handler { - return memLoggerInstance -} - -func HandlerFunc() http.HandlerFunc { +func HandlerFunc() gin.HandlerFunc { return memLoggerInstance.ServeHTTP } @@ -70,9 +68,10 @@ func (m *memLogger) Write(p []byte) (n int, err error) { return } -func (m *memLogger) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := gpwebsocket.Initiate(w, r) +func (m *memLogger) ServeHTTP(c *gin.Context) { + manager, err := websocket.NewManagerWithUpgrade(c) if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to create websocket manager")) return } @@ -80,20 +79,19 @@ func (m *memLogger) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.connChans.Store(logCh, struct{}{}) defer func() { - _ = conn.Close() - + manager.Close() m.notifyLock.Lock() m.connChans.Delete(logCh) close(logCh) m.notifyLock.Unlock() }() - if err := m.wsInitial(conn); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if err := m.wsInitial(manager); err != nil { + c.Error(apitypes.InternalServerError(err, "failed to send initial log")) return } - m.wsStreamLog(r.Context(), conn, logCh) + m.wsStreamLog(c.Request.Context(), manager, logCh) } func (m *memLogger) truncateIfNeeded(n int) { @@ -168,19 +166,14 @@ func (m *memLogger) events() (logs <-chan []byte, cancel func()) { } } -func (m *memLogger) writeBytes(conn *websocket.Conn, b []byte) error { - _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - return conn.WriteMessage(websocket.TextMessage, b) -} - -func (m *memLogger) wsInitial(conn *websocket.Conn) error { +func (m *memLogger) wsInitial(manager *websocket.Manager) error { m.Lock() defer m.Unlock() - return m.writeBytes(conn, m.Bytes()) + return manager.WriteData(websocket.TextMessage, m.Bytes(), writeTimeout) } -func (m *memLogger) wsStreamLog(ctx context.Context, conn *websocket.Conn, ch <-chan *logEntryRange) { +func (m *memLogger) wsStreamLog(ctx context.Context, manager *websocket.Manager, ch <-chan *logEntryRange) { for { select { case <-ctx.Done(): @@ -188,7 +181,7 @@ func (m *memLogger) wsStreamLog(ctx context.Context, conn *websocket.Conn, ch <- case logRange := <-ch: m.RLock() msg := m.Bytes()[logRange.Start:logRange.End] - err := m.writeBytes(conn, msg) + err := manager.WriteData(websocket.TextMessage, msg, writeTimeout) m.RUnlock() if err != nil { return diff --git a/internal/maxmind/maxmind.go b/internal/maxmind/maxmind.go index ec32991f..c95181e9 100644 --- a/internal/maxmind/maxmind.go +++ b/internal/maxmind/maxmind.go @@ -2,6 +2,7 @@ package maxmind import ( "archive/tar" + "bytes" "compress/gzip" "errors" "fmt" @@ -19,8 +20,15 @@ import ( "github.com/yusing/go-proxy/internal/task" ) +/* +refactor(maxmind): switch to Country database + +- In compliance with [Title 28 of the Code of Federal Regulations of the United States of America Part 202](https://www.ecfr.gov/current/title-28/chapter-I/part-202), non US IPs are blocked from downloading the City database +*/ + type MaxMind struct { *Config + lastUpdate time.Time db struct { *maxminddb.Reader @@ -49,24 +57,21 @@ var ( ) func (cfg *MaxMind) dbPath() string { - if cfg.Database == maxmind.MaxMindGeoLite { - return filepath.Join(dataDir, "GeoLite2-City.mmdb") - } - return filepath.Join(dataDir, "GeoIP2-City.mmdb") + return filepath.Join(dataDir, cfg.dbFilename()) } func (cfg *MaxMind) dbURL() string { if cfg.Database == maxmind.MaxMindGeoLite { - return "https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz" + return "https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz" } - return "https://download.maxmind.com/geoip/databases/GeoIP2-City/download?suffix=tar.gz" + return "https://download.maxmind.com/geoip/databases/GeoIP2-Country/download?suffix=tar.gz" } func (cfg *MaxMind) dbFilename() string { if cfg.Database == maxmind.MaxMindGeoLite { - return "GeoLite2-City.mmdb" + return "GeoLite2-Country.mmdb" } - return "GeoIP2-City.mmdb" + return "GeoIP2-Country.mmdb" } func (cfg *MaxMind) LoadMaxMindDB(parent task.Parent) gperr.Error { @@ -219,34 +224,17 @@ func (cfg *MaxMind) download() error { } dbFile := dbPath(cfg) - tmpGZPath := dbFile + "-tmp.tar.gz" tmpDBPath := dbFile + "-tmp" - tmpGZFile, err := os.OpenFile(tmpGZPath, os.O_CREATE|os.O_RDWR, 0o644) - if err != nil { - return err - } - - // cleanup the tar.gz file - defer func() { - _ = tmpGZFile.Close() - _ = os.Remove(tmpGZPath) - }() - cfg.Logger().Info().Msg("MaxMind DB downloading...") - _, err = io.Copy(tmpGZFile, resp.Body) + databaseGZ, err := io.ReadAll(resp.Body) if err != nil { return err } - if _, err := tmpGZFile.Seek(0, io.SeekStart); err != nil { - return err - } - // extract .tar.gz and to database - err = extractFileFromTarGz(tmpGZFile, cfg.dbFilename(), tmpDBPath) - + err = extractFileFromTarGz(databaseGZ, cfg.dbFilename(), tmpDBPath) if err != nil { return gperr.New("failed to extract database from archive").With(err) } @@ -284,15 +272,14 @@ func (cfg *MaxMind) download() error { return nil } -func extractFileFromTarGz(tarGzFile *os.File, targetFilename, destPath string) error { - defer tarGzFile.Close() - - gzr, err := gzip.NewReader(tarGzFile) +func extractFileFromTarGz(tarGzBytes []byte, targetFilename, destPath string) error { + gzr, err := gzip.NewReader(bytes.NewReader(tarGzBytes)) if err != nil { return err } defer gzr.Close() + sumSize := int64(0) tr := tar.NewReader(gzr) for { hdr, err := tr.Next() @@ -302,6 +289,12 @@ func extractFileFromTarGz(tarGzFile *os.File, targetFilename, destPath string) e if err != nil { return err } + // NOTE: it should be around 10MB, but just in case + // This is to prevent malicious tar.gz file (e.g. tar bomb) + sumSize += hdr.Size + if sumSize > 30*1024*1024 { + return errors.New("file size exceeds 30MB") + } // Only extract the file that matches targetFilename (basename match) if filepath.Base(hdr.Name) == targetFilename { outFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode()) @@ -309,7 +302,7 @@ func extractFileFromTarGz(tarGzFile *os.File, targetFilename, destPath string) e return err } defer outFile.Close() - _, err = io.Copy(outFile, tr) + _, err = io.CopyN(outFile, tr, hdr.Size) if err != nil { return err } diff --git a/internal/metrics/period/handler.go b/internal/metrics/period/handler.go index a93a977b..23f8066f 100644 --- a/internal/metrics/period/handler.go +++ b/internal/metrics/period/handler.go @@ -5,13 +5,18 @@ import ( "net/http" "time" - "github.com/gorilla/websocket" + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" metricsutils "github.com/yusing/go-proxy/internal/metrics/utils" - "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" ) +type ResponseType[AggregateT any] struct { + Total int `json:"total"` + Data AggregateT `json:"data"` +} + // ServeHTTP serves the data for the given period. // // If the period is not specified, it serves the last result. @@ -23,10 +28,10 @@ import ( // If the data is not found, it returns a 204 error. // // If the request is a websocket request, it serves the data for the given period for every interval. -func (p *Poller[T, AggregateT]) ServeHTTP(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() +func (p *Poller[T, AggregateT]) ServeHTTP(c *gin.Context) { + query := c.Request.URL.Query() - if httpheaders.IsWebsocket(r.Header) { + if httpheaders.IsWebsocket(c.Request.Header) { interval := metricsutils.QueryDuration(query, "interval", 0) minInterval := 1 * time.Second @@ -36,27 +41,20 @@ func (p *Poller[T, AggregateT]) ServeHTTP(w http.ResponseWriter, r *http.Request if interval < minInterval { interval = minInterval } - gpwebsocket.Periodic(w, r, interval, func(conn *websocket.Conn) error { - data, err := p.getRespData(r) - if err != nil { - return err - } - if data == nil { - return nil - } - return conn.WriteJSON(data) + websocket.PeriodicWrite(c, interval, func() (any, error) { + return p.getRespData(c.Request) }) } else { - data, err := p.getRespData(r) + data, err := p.getRespData(c.Request) if err != nil { - gphttp.ServerError(w, r, err) + c.Error(apitypes.InternalServerError(err, "failed to get response data")) return } if data == nil { - http.Error(w, "no data", http.StatusNoContent) + c.JSON(http.StatusNoContent, apitypes.Error("no data")) return } - gphttp.RespondJSON(w, r, data) + c.JSON(http.StatusOK, data) } } diff --git a/internal/metrics/period/period.go b/internal/metrics/period/period.go index 5c9f5046..601a6df2 100644 --- a/internal/metrics/period/period.go +++ b/internal/metrics/period/period.go @@ -10,16 +10,24 @@ type Period[T any] struct { mu sync.RWMutex } -type Filter string +type Filter string // @name MetricsPeriod + +const ( + MetricsPeriod5m Filter = "5m" // @name MetricsPeriod5m + MetricsPeriod15m Filter = "15m" // @name MetricsPeriod15m + MetricsPeriod1h Filter = "1h" // @name MetricsPeriod1h + MetricsPeriod1d Filter = "1d" // @name MetricsPeriod1d + MetricsPeriod1mo Filter = "1mo" // @name MetricsPeriod1mo +) func NewPeriod[T any]() *Period[T] { return &Period[T]{ Entries: map[Filter]*Entries[T]{ - "5m": newEntries[T](5 * time.Minute), - "15m": newEntries[T](15 * time.Minute), - "1h": newEntries[T](1 * time.Hour), - "1d": newEntries[T](24 * time.Hour), - "1mo": newEntries[T](30 * 24 * time.Hour), + MetricsPeriod5m: newEntries[T](5 * time.Minute), + MetricsPeriod15m: newEntries[T](15 * time.Minute), + MetricsPeriod1h: newEntries[T](1 * time.Hour), + MetricsPeriod1d: newEntries[T](24 * time.Hour), + MetricsPeriod1mo: newEntries[T](30 * 24 * time.Hour), }, } } diff --git a/internal/metrics/systeminfo/system_info.go b/internal/metrics/systeminfo/system_info.go index abff04d4..275181ab 100644 --- a/internal/metrics/systeminfo/system_info.go +++ b/internal/metrics/systeminfo/system_info.go @@ -23,7 +23,7 @@ import ( // json tags are left for tests type ( - Sensors []sensors.TemperatureStat + Sensors []sensors.TemperatureStat // @name Sensors Aggregated []map[string]any ) @@ -35,7 +35,7 @@ type SystemInfo struct { DisksIO map[string]*disk.IOCountersStat `json:"disks_io"` // disk IO by device Network *net.IOCountersStat `json:"network"` Sensors Sensors `json:"sensors"` // sensor temperature by key -} +} // @name SystemInfo const ( queryCPUAverage = "cpu_average" diff --git a/internal/metrics/uptime/uptime.go b/internal/metrics/uptime/uptime.go index 50340921..9929de5d 100644 --- a/internal/metrics/uptime/uptime.go +++ b/internal/metrics/uptime/uptime.go @@ -4,35 +4,46 @@ import ( "context" "encoding/json" "net/url" - "sort" + "strings" "time" + "slices" + "github.com/lithammer/fuzzysearch/fuzzy" "github.com/yusing/go-proxy/internal/metrics/period" metricsutils "github.com/yusing/go-proxy/internal/metrics/utils" "github.com/yusing/go-proxy/internal/route/routes" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) type ( StatusByAlias struct { - Map map[string]*routes.HealthInfoRaw `json:"statuses"` - Timestamp int64 `json:"timestamp"` - } + Map map[string]routes.HealthInfo `json:"statuses"` + Timestamp int64 `json:"timestamp"` + } // @name RouteStatusesByAlias Status struct { - Status health.Status `json:"status"` - Latency int64 `json:"latency"` - Timestamp int64 `json:"timestamp"` - } - RouteStatuses map[string][]*Status - Aggregated []map[string]any + Status types.HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,unknown,napping,starting"` + Latency int64 `json:"latency"` + Timestamp int64 `json:"timestamp"` + } // @name RouteStatus + RouteStatuses map[string][]*Status // @name RouteStatuses + RouteAggregate struct { + Alias string `json:"alias"` + DisplayName string `json:"display_name"` + Uptime float64 `json:"uptime"` + Downtime float64 `json:"downtime"` + Idle float64 `json:"idle"` + AvgLatency float64 `json:"avg_latency"` + Statuses []*Status `json:"statuses"` + } // @name RouteUptimeAggregate + Aggregated []RouteAggregate ) var Poller = period.NewPoller("uptime", getStatuses, aggregateStatuses) func getStatuses(ctx context.Context, _ *StatusByAlias) (*StatusByAlias, error) { return &StatusByAlias{ - Map: routes.HealthInfo(), + Map: routes.GetHealthInfo(), Timestamp: time.Now().Unix(), }, nil } @@ -78,11 +89,11 @@ func (rs RouteStatuses) calculateInfo(statuses []*Status) (up float64, down floa latency := float64(0) for _, status := range statuses { // ignoring unknown; treating napping and starting as downtime - if status.Status == health.StatusUnknown { + if status.Status == types.StatusUnknown { continue } switch { - case status.Status == health.StatusHealthy: + case status.Status == types.StatusHealthy: up++ case status.Status.Idling(): idle++ @@ -110,28 +121,39 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated { sortedAliases[i] = alias i++ } - sort.Strings(sortedAliases) + // unknown statuses are at the end, then sort by alias + slices.SortFunc(sortedAliases, func(a, b string) int { + if rs[a][len(rs[a])-1].Status == types.StatusUnknown { + return 1 + } + if rs[b][len(rs[b])-1].Status == types.StatusUnknown { + return -1 + } + return strings.Compare(a, b) + }) sortedAliases = sortedAliases[beg:end] result := make(Aggregated, len(sortedAliases)) for i, alias := range sortedAliases { statuses := rs[alias] up, down, idle, latency := rs.calculateInfo(statuses) - result[i] = map[string]any{ - "alias": alias, - "uptime": up, - "downtime": down, - "idle": idle, - "avg_latency": latency, - "statuses": statuses, + result[i] = RouteAggregate{ + Alias: alias, + Uptime: up, + Downtime: down, + Idle: idle, + AvgLatency: latency, + Statuses: statuses, } r, ok := routes.Get(alias) if ok { - result[i]["display_name"] = r.HomepageConfig().Name + result[i].DisplayName = r.HomepageConfig().Name + } else { + result[i].DisplayName = alias } } return result } func (result Aggregated) MarshalJSON() ([]byte, error) { - return json.Marshal([]map[string]any(result)) + return json.Marshal([]RouteAggregate(result)) } diff --git a/internal/net/gphttp/gpwebsocket/writer.go b/internal/net/gphttp/gpwebsocket/writer.go deleted file mode 100644 index b47cf16c..00000000 --- a/internal/net/gphttp/gpwebsocket/writer.go +++ /dev/null @@ -1,30 +0,0 @@ -package gpwebsocket - -import ( - "context" - - "github.com/gorilla/websocket" -) - -type Writer struct { - conn *websocket.Conn - msgType int - ctx context.Context -} - -func NewWriter(ctx context.Context, conn *websocket.Conn, msgType int) *Writer { - return &Writer{ - ctx: ctx, - conn: conn, - msgType: msgType, - } -} - -func (w *Writer) Write(p []byte) (int, error) { - select { - case <-w.ctx.Done(): - return 0, w.ctx.Err() - default: - return len(p), w.conn.WriteMessage(w.msgType, p) - } -} diff --git a/internal/net/gphttp/loadbalancer/ip_hash.go b/internal/net/gphttp/loadbalancer/ip_hash.go index d8a54ed9..c57f772b 100644 --- a/internal/net/gphttp/loadbalancer/ip_hash.go +++ b/internal/net/gphttp/loadbalancer/ip_hash.go @@ -8,13 +8,14 @@ import ( "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/net/gphttp/middleware" + "github.com/yusing/go-proxy/internal/types" ) type ipHash struct { *LoadBalancer realIP *middleware.Middleware - pool Servers + pool types.LoadBalancerServers mu sync.Mutex } @@ -31,7 +32,7 @@ func (lb *LoadBalancer) newIPHash() impl { return impl } -func (impl *ipHash) OnAddServer(srv Server) { +func (impl *ipHash) OnAddServer(srv types.LoadBalancerServer) { impl.mu.Lock() defer impl.mu.Unlock() @@ -48,7 +49,7 @@ func (impl *ipHash) OnAddServer(srv Server) { impl.pool = append(impl.pool, srv) } -func (impl *ipHash) OnRemoveServer(srv Server) { +func (impl *ipHash) OnRemoveServer(srv types.LoadBalancerServer) { impl.mu.Lock() defer impl.mu.Unlock() @@ -60,7 +61,7 @@ func (impl *ipHash) OnRemoveServer(srv Server) { } } -func (impl *ipHash) ServeHTTP(_ Servers, rw http.ResponseWriter, r *http.Request) { +func (impl *ipHash) ServeHTTP(_ types.LoadBalancerServers, rw http.ResponseWriter, r *http.Request) { if impl.realIP != nil { impl.realIP.ModifyRequest(impl.serveHTTP, rw, r) } else { diff --git a/internal/net/gphttp/loadbalancer/least_conn.go b/internal/net/gphttp/loadbalancer/least_conn.go index 7130e424..f6759f54 100644 --- a/internal/net/gphttp/loadbalancer/least_conn.go +++ b/internal/net/gphttp/loadbalancer/least_conn.go @@ -4,30 +4,31 @@ import ( "net/http" "sync/atomic" - F "github.com/yusing/go-proxy/internal/utils/functional" + "github.com/puzpuzpuz/xsync/v4" + "github.com/yusing/go-proxy/internal/types" ) type leastConn struct { *LoadBalancer - nConn F.Map[Server, *atomic.Int64] + nConn *xsync.Map[types.LoadBalancerServer, *atomic.Int64] } func (lb *LoadBalancer) newLeastConn() impl { return &leastConn{ LoadBalancer: lb, - nConn: F.NewMapOf[Server, *atomic.Int64](), + nConn: xsync.NewMap[types.LoadBalancerServer, *atomic.Int64](), } } -func (impl *leastConn) OnAddServer(srv Server) { +func (impl *leastConn) OnAddServer(srv types.LoadBalancerServer) { impl.nConn.Store(srv, new(atomic.Int64)) } -func (impl *leastConn) OnRemoveServer(srv Server) { +func (impl *leastConn) OnRemoveServer(srv types.LoadBalancerServer) { impl.nConn.Delete(srv) } -func (impl *leastConn) ServeHTTP(srvs Servers, rw http.ResponseWriter, r *http.Request) { +func (impl *leastConn) ServeHTTP(srvs types.LoadBalancerServers, rw http.ResponseWriter, r *http.Request) { srv := srvs[0] minConn, ok := impl.nConn.Load(srv) if !ok { diff --git a/internal/net/gphttp/loadbalancer/loadbalancer.go b/internal/net/gphttp/loadbalancer/loadbalancer.go index 90b795ce..ce7ec968 100644 --- a/internal/net/gphttp/loadbalancer/loadbalancer.go +++ b/internal/net/gphttp/loadbalancer/loadbalancer.go @@ -10,44 +10,43 @@ import ( "github.com/rs/zerolog/log" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" - "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils/pool" - "github.com/yusing/go-proxy/internal/watcher/health" ) // TODO: stats of each server. // TODO: support weighted mode. type ( impl interface { - ServeHTTP(srvs Servers, rw http.ResponseWriter, r *http.Request) - OnAddServer(srv Server) - OnRemoveServer(srv Server) + ServeHTTP(srvs types.LoadBalancerServers, rw http.ResponseWriter, r *http.Request) + OnAddServer(srv types.LoadBalancerServer) + OnRemoveServer(srv types.LoadBalancerServer) } LoadBalancer struct { impl - *Config + *types.LoadBalancerConfig task *task.Task - pool pool.Pool[Server] + pool pool.Pool[types.LoadBalancerServer] poolMu sync.Mutex - sumWeight Weight + sumWeight int startTime time.Time l zerolog.Logger } ) -const maxWeight Weight = 100 +const maxWeight int = 100 -func New(cfg *Config) *LoadBalancer { +func New(cfg *types.LoadBalancerConfig) *LoadBalancer { lb := &LoadBalancer{ - Config: new(Config), - pool: pool.New[Server]("loadbalancer." + cfg.Link), - l: log.With().Str("name", cfg.Link).Logger(), + LoadBalancerConfig: cfg, + pool: pool.New[types.LoadBalancerServer]("loadbalancer." + cfg.Link), + l: log.With().Str("name", cfg.Link).Logger(), } lb.UpdateConfigIfNeeded(cfg) return lb @@ -80,11 +79,11 @@ func (lb *LoadBalancer) Finish(reason any) { func (lb *LoadBalancer) updateImpl() { switch lb.Mode { - case types.ModeUnset, types.ModeRoundRobin: + case types.LoadbalanceModeUnset, types.LoadbalanceModeRoundRobin: lb.impl = lb.newRoundRobin() - case types.ModeLeastConn: + case types.LoadbalanceModeLeastConn: lb.impl = lb.newLeastConn() - case types.ModeIPHash: + case types.LoadbalanceModeIPHash: lb.impl = lb.newIPHash() default: // should happen in test only lb.impl = lb.newRoundRobin() @@ -94,14 +93,14 @@ func (lb *LoadBalancer) updateImpl() { } } -func (lb *LoadBalancer) UpdateConfigIfNeeded(cfg *Config) { +func (lb *LoadBalancer) UpdateConfigIfNeeded(cfg *types.LoadBalancerConfig) { if cfg != nil { lb.poolMu.Lock() defer lb.poolMu.Unlock() lb.Link = cfg.Link - if lb.Mode == types.ModeUnset && cfg.Mode != types.ModeUnset { + if lb.Mode == types.LoadbalanceModeUnset && cfg.Mode != types.LoadbalanceModeUnset { lb.Mode = cfg.Mode if !lb.Mode.ValidateUpdate() { lb.l.Error().Msgf("invalid mode %q, fallback to %q", cfg.Mode, lb.Mode) @@ -119,7 +118,7 @@ func (lb *LoadBalancer) UpdateConfigIfNeeded(cfg *Config) { } } -func (lb *LoadBalancer) AddServer(srv Server) { +func (lb *LoadBalancer) AddServer(srv types.LoadBalancerServer) { lb.poolMu.Lock() defer lb.poolMu.Unlock() @@ -135,7 +134,7 @@ func (lb *LoadBalancer) AddServer(srv Server) { lb.impl.OnAddServer(srv) } -func (lb *LoadBalancer) RemoveServer(srv Server) { +func (lb *LoadBalancer) RemoveServer(srv types.LoadBalancerServer) { lb.poolMu.Lock() defer lb.poolMu.Unlock() @@ -170,8 +169,8 @@ func (lb *LoadBalancer) rebalance() { return } if lb.sumWeight == 0 { // distribute evenly - weightEach := maxWeight / Weight(poolSize) - remainder := maxWeight % Weight(poolSize) + weightEach := maxWeight / poolSize + remainder := maxWeight % poolSize for _, srv := range lb.pool.Iter { w := weightEach lb.sumWeight += weightEach @@ -189,7 +188,7 @@ func (lb *LoadBalancer) rebalance() { lb.sumWeight = 0 for _, srv := range lb.pool.Iter { - srv.SetWeight(Weight(float64(srv.Weight()) * scaleFactor)) + srv.SetWeight(int(float64(srv.Weight()) * scaleFactor)) lb.sumWeight += srv.Weight() } @@ -241,16 +240,16 @@ func (lb *LoadBalancer) MarshalJSON() ([]byte, error) { status, numHealthy := lb.status() - return (&health.JSONRepresentation{ + return (&types.HealthJSONRepr{ Name: lb.Name(), Status: status, Detail: fmt.Sprintf("%d/%d servers are healthy", numHealthy, lb.pool.Size()), Started: lb.startTime, Uptime: lb.Uptime(), Latency: lb.Latency(), - Extra: map[string]any{ - "config": lb.Config, - "pool": extra, + Extra: &types.HealthExtra{ + Config: lb.LoadBalancerConfig, + Pool: extra, }, }).MarshalJSON() } @@ -261,7 +260,7 @@ func (lb *LoadBalancer) Name() string { } // Status implements health.HealthMonitor. -func (lb *LoadBalancer) Status() health.Status { +func (lb *LoadBalancer) Status() types.HealthStatus { status, _ := lb.status() return status } @@ -272,9 +271,9 @@ func (lb *LoadBalancer) Detail() string { return fmt.Sprintf("%d/%d servers are healthy", numHealthy, lb.pool.Size()) } -func (lb *LoadBalancer) status() (status health.Status, numHealthy int) { +func (lb *LoadBalancer) status() (status types.HealthStatus, numHealthy int) { if lb.pool.Size() == 0 { - return health.StatusUnknown, 0 + return types.StatusUnknown, 0 } // should be healthy if at least one server is healthy @@ -285,9 +284,9 @@ func (lb *LoadBalancer) status() (status health.Status, numHealthy int) { } } if numHealthy == 0 { - return health.StatusUnhealthy, numHealthy + return types.StatusUnhealthy, numHealthy } - return health.StatusHealthy, numHealthy + return types.StatusHealthy, numHealthy } // Uptime implements health.HealthMonitor. @@ -309,8 +308,8 @@ func (lb *LoadBalancer) String() string { return lb.Name() } -func (lb *LoadBalancer) availServers() []Server { - avail := make([]Server, 0, lb.pool.Size()) +func (lb *LoadBalancer) availServers() []types.LoadBalancerServer { + avail := make([]types.LoadBalancerServer, 0, lb.pool.Size()) for _, srv := range lb.pool.Iter { if srv.Status().Good() { avail = append(avail, srv) diff --git a/internal/net/gphttp/loadbalancer/loadbalancer_test.go b/internal/net/gphttp/loadbalancer/loadbalancer_test.go index 03f2bfc2..5a9d89ee 100644 --- a/internal/net/gphttp/loadbalancer/loadbalancer_test.go +++ b/internal/net/gphttp/loadbalancer/loadbalancer_test.go @@ -3,40 +3,40 @@ package loadbalancer import ( "testing" - "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" + "github.com/yusing/go-proxy/internal/types" . "github.com/yusing/go-proxy/internal/utils/testing" ) func TestRebalance(t *testing.T) { t.Parallel() t.Run("zero", func(t *testing.T) { - lb := New(new(types.Config)) + lb := New(new(types.LoadBalancerConfig)) for range 10 { - lb.AddServer(types.TestNewServer(0)) + lb.AddServer(TestNewServer(0)) } lb.rebalance() ExpectEqual(t, lb.sumWeight, maxWeight) }) t.Run("less", func(t *testing.T) { - lb := New(new(types.Config)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .1)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .2)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .3)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .2)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .1)) + lb := New(new(types.LoadBalancerConfig)) + lb.AddServer(TestNewServer(float64(maxWeight) * .1)) + lb.AddServer(TestNewServer(float64(maxWeight) * .2)) + lb.AddServer(TestNewServer(float64(maxWeight) * .3)) + lb.AddServer(TestNewServer(float64(maxWeight) * .2)) + lb.AddServer(TestNewServer(float64(maxWeight) * .1)) lb.rebalance() // t.Logf("%s", U.Must(json.MarshalIndent(lb.pool, "", " "))) ExpectEqual(t, lb.sumWeight, maxWeight) }) t.Run("more", func(t *testing.T) { - lb := New(new(types.Config)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .1)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .2)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .3)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .4)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .3)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .2)) - lb.AddServer(types.TestNewServer(float64(maxWeight) * .1)) + lb := New(new(types.LoadBalancerConfig)) + lb.AddServer(TestNewServer(float64(maxWeight) * .1)) + lb.AddServer(TestNewServer(float64(maxWeight) * .2)) + lb.AddServer(TestNewServer(float64(maxWeight) * .3)) + lb.AddServer(TestNewServer(float64(maxWeight) * .4)) + lb.AddServer(TestNewServer(float64(maxWeight) * .3)) + lb.AddServer(TestNewServer(float64(maxWeight) * .2)) + lb.AddServer(TestNewServer(float64(maxWeight) * .1)) lb.rebalance() // t.Logf("%s", U.Must(json.MarshalIndent(lb.pool, "", " "))) ExpectEqual(t, lb.sumWeight, maxWeight) diff --git a/internal/net/gphttp/loadbalancer/round_robin.go b/internal/net/gphttp/loadbalancer/round_robin.go index 09d67706..1721ca91 100644 --- a/internal/net/gphttp/loadbalancer/round_robin.go +++ b/internal/net/gphttp/loadbalancer/round_robin.go @@ -3,17 +3,19 @@ package loadbalancer import ( "net/http" "sync/atomic" + + "github.com/yusing/go-proxy/internal/types" ) type roundRobin struct { index atomic.Uint32 } -func (*LoadBalancer) newRoundRobin() impl { return &roundRobin{} } -func (lb *roundRobin) OnAddServer(srv Server) {} -func (lb *roundRobin) OnRemoveServer(srv Server) {} +func (*LoadBalancer) newRoundRobin() impl { return &roundRobin{} } +func (lb *roundRobin) OnAddServer(srv types.LoadBalancerServer) {} +func (lb *roundRobin) OnRemoveServer(srv types.LoadBalancerServer) {} -func (lb *roundRobin) ServeHTTP(srvs Servers, rw http.ResponseWriter, r *http.Request) { +func (lb *roundRobin) ServeHTTP(srvs types.LoadBalancerServers, rw http.ResponseWriter, r *http.Request) { index := lb.index.Add(1) % uint32(len(srvs)) srvs[index].ServeHTTP(rw, r) if lb.index.Load() >= 2*uint32(len(srvs)) { diff --git a/internal/net/gphttp/loadbalancer/types/server.go b/internal/net/gphttp/loadbalancer/server.go similarity index 55% rename from internal/net/gphttp/loadbalancer/types/server.go rename to internal/net/gphttp/loadbalancer/server.go index 58215cce..215c3157 100644 --- a/internal/net/gphttp/loadbalancer/types/server.go +++ b/internal/net/gphttp/loadbalancer/server.go @@ -1,39 +1,26 @@ -package types +package loadbalancer import ( "net/http" idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" nettypes "github.com/yusing/go-proxy/internal/net/types" + "github.com/yusing/go-proxy/internal/types" U "github.com/yusing/go-proxy/internal/utils" - "github.com/yusing/go-proxy/internal/watcher/health" ) -type ( - server struct { - _ U.NoCopy +type server struct { + _ U.NoCopy - name string - url *nettypes.URL - weight Weight + name string + url *nettypes.URL + weight int - http.Handler `json:"-"` - health.HealthMonitor - } + http.Handler `json:"-"` + types.HealthMonitor +} - Server interface { - http.Handler - health.HealthMonitor - Name() string - Key() string - URL() *nettypes.URL - Weight() Weight - SetWeight(weight Weight) - TryWake() error - } -) - -func NewServer(name string, url *nettypes.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server { +func NewServer(name string, url *nettypes.URL, weight int, handler http.Handler, healthMon types.HealthMonitor) types.LoadBalancerServer { srv := &server{ name: name, url: url, @@ -44,9 +31,9 @@ func NewServer(name string, url *nettypes.URL, weight Weight, handler http.Handl return srv } -func TestNewServer[T ~int | ~float32 | ~float64](weight T) Server { +func TestNewServer[T ~int | ~float32 | ~float64](weight T) types.LoadBalancerServer { srv := &server{ - weight: Weight(weight), + weight: int(weight), url: nettypes.MustParseURL("http://localhost"), } return srv @@ -64,11 +51,11 @@ func (srv *server) Key() string { return srv.url.Host } -func (srv *server) Weight() Weight { +func (srv *server) Weight() int { return srv.weight } -func (srv *server) SetWeight(weight Weight) { +func (srv *server) SetWeight(weight int) { srv.weight = weight } diff --git a/internal/net/gphttp/loadbalancer/types.go b/internal/net/gphttp/loadbalancer/types.go deleted file mode 100644 index 0322a095..00000000 --- a/internal/net/gphttp/loadbalancer/types.go +++ /dev/null @@ -1,13 +0,0 @@ -package loadbalancer - -import ( - "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" -) - -type ( - Server = types.Server - Servers = []types.Server - Weight = types.Weight - Config = types.Config - Mode = types.Mode -) diff --git a/internal/net/gphttp/loadbalancer/types/config.go b/internal/net/gphttp/loadbalancer/types/config.go deleted file mode 100644 index 8e1c38cd..00000000 --- a/internal/net/gphttp/loadbalancer/types/config.go +++ /dev/null @@ -1,8 +0,0 @@ -package types - -type Config struct { - Link string `json:"link"` - Mode Mode `json:"mode"` - Weight Weight `json:"weight"` - Options map[string]any `json:"options,omitempty"` -} diff --git a/internal/net/gphttp/loadbalancer/types/mode.go b/internal/net/gphttp/loadbalancer/types/mode.go deleted file mode 100644 index 210275a5..00000000 --- a/internal/net/gphttp/loadbalancer/types/mode.go +++ /dev/null @@ -1,32 +0,0 @@ -package types - -import ( - "github.com/yusing/go-proxy/internal/utils/strutils" -) - -type Mode string - -const ( - ModeUnset Mode = "" - ModeRoundRobin Mode = "roundrobin" - ModeLeastConn Mode = "leastconn" - ModeIPHash Mode = "iphash" -) - -func (mode *Mode) ValidateUpdate() bool { - switch strutils.ToLowerNoSnake(string(*mode)) { - case "": - return true - case string(ModeRoundRobin): - *mode = ModeRoundRobin - return true - case string(ModeLeastConn): - *mode = ModeLeastConn - return true - case string(ModeIPHash): - *mode = ModeIPHash - return true - } - *mode = ModeRoundRobin - return false -} diff --git a/internal/net/gphttp/loadbalancer/types/weight.go b/internal/net/gphttp/loadbalancer/types/weight.go deleted file mode 100644 index 2339a27c..00000000 --- a/internal/net/gphttp/loadbalancer/types/weight.go +++ /dev/null @@ -1,3 +0,0 @@ -package types - -type Weight int diff --git a/internal/net/gphttp/websocket/manager.go b/internal/net/gphttp/websocket/manager.go new file mode 100644 index 00000000..b9bc27d1 --- /dev/null +++ b/internal/net/gphttp/websocket/manager.go @@ -0,0 +1,252 @@ +package websocket + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/yusing/go-proxy/internal/common" +) + +// Manager handles WebSocket connection state and ping-pong +type Manager struct { + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + pongWriteTimeout time.Duration + pingCheckTicker *time.Ticker + lastPingTime atomic.Value + readCh chan []byte + err error +} + +var defaultUpgrader = websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + // TODO: add CORS + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +var ( + ErrReadTimeout = errors.New("read timeout") + ErrWriteTimeout = errors.New("write timeout") +) + +const ( + TextMessage = websocket.TextMessage + BinaryMessage = websocket.BinaryMessage +) + +// NewManagerWithUpgrade upgrades the HTTP connection to a WebSocket connection and returns a Manager. +// If the upgrade fails, the error is returned. +// If the upgrade succeeds, the Manager is returned. +func NewManagerWithUpgrade(c *gin.Context, upgrader ...websocket.Upgrader) (*Manager, error) { + var actualUpgrader websocket.Upgrader + if len(upgrader) == 0 { + actualUpgrader = defaultUpgrader + } else { + actualUpgrader = upgrader[0] + } + + conn, err := actualUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(c.Request.Context()) + cm := &Manager{ + conn: conn, + ctx: ctx, + cancel: cancel, + pongWriteTimeout: 2 * time.Second, + pingCheckTicker: time.NewTicker(3 * time.Second), + readCh: make(chan []byte, 1), + } + cm.lastPingTime.Store(time.Now()) + + conn.SetCloseHandler(func(code int, text string) error { + if common.IsDebug { + cm.err = fmt.Errorf("connection closed: code=%d, text=%s", code, text) + } + cm.Close() + return nil + }) + + go cm.pingCheckRoutine() + go cm.readRoutine() + + return cm, nil +} + +// Periodic writes data to the connection periodically. +// If the connection is closed, the error is returned. +// If the write timeout is reached, ErrWriteTimeout is returned. +func (cm *Manager) PeriodicWrite(interval time.Duration, getData func() (any, error)) error { + write := func() { + data, err := getData() + if err != nil { + cm.err = err + cm.Close() + return + } + if err := cm.WriteJSON(data, interval); err != nil { + cm.err = err + cm.Close() + } + } + + // initial write before the ticker starts + write() + if cm.err != nil { + return cm.err + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-cm.ctx.Done(): + return cm.err + case <-ticker.C: + write() + if cm.err != nil { + return cm.err + } + } + } +} + +// WriteJSON writes a JSON message to the connection with json. +// If the connection is closed, the error is returned. +// If the write timeout is reached, ErrWriteTimeout is returned. +func (cm *Manager) WriteJSON(data any, timeout time.Duration) error { + bytes, err := json.Marshal(data) + if err != nil { + return err + } + return cm.WriteData(websocket.TextMessage, bytes, timeout) +} + +// WriteData writes a message to the connection with sonic. +// If the connection is closed, the error is returned. +// If the write timeout is reached, ErrWriteTimeout is returned. +func (cm *Manager) WriteData(typ int, data []byte, timeout time.Duration) error { + select { + case <-cm.ctx.Done(): + return cm.err + default: + if err := cm.conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil { + return err + } + err := cm.conn.WriteMessage(typ, data) + if err != nil { + if errors.Is(err, websocket.ErrCloseSent) { + return cm.err + } + if errors.Is(err, context.DeadlineExceeded) { + return ErrWriteTimeout + } + return err + } + return nil + } +} + +// ReadJSON reads a JSON message from the connection and unmarshals it into the provided struct with sonic +// If the connection is closed, the error is returned. +// If the message fails to unmarshal, the error is returned. +// If the read timeout is reached, ErrReadTimeout is returned. +func (cm *Manager) ReadJSON(out any, timeout time.Duration) error { + select { + case <-cm.ctx.Done(): + return cm.err + case data := <-cm.readCh: + return json.Unmarshal(data, out) + case <-time.After(timeout): + return ErrReadTimeout + } +} + +// Close closes the connection and cancels the context +func (cm *Manager) Close() { + cm.cancel() + cm.pingCheckTicker.Stop() + cm.conn.Close() +} + +func (cm *Manager) GracefulClose() { + _ = cm.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + _ = cm.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + cm.Close() +} + +// Done returns a channel that is closed when the context is done or the connection is closed +func (cm *Manager) Done() <-chan struct{} { + return cm.ctx.Done() +} + +func (cm *Manager) pingCheckRoutine() { + for { + select { + case <-cm.ctx.Done(): + return + case <-cm.pingCheckTicker.C: + if time.Since(cm.lastPingTime.Load().(time.Time)) > 5*time.Second { + if common.IsDebug { + cm.err = errors.New("no ping received in 5 seconds, closing connection") + } + cm.Close() + return + } + } + } +} + +func (cm *Manager) readRoutine() { + for { + select { + case <-cm.ctx.Done(): + return + default: + typ, data, err := cm.conn.ReadMessage() + if err != nil { + if cm.ctx.Err() == nil { // connection is not closed + cm.err = fmt.Errorf("failed to read message: %w", err) + cm.Close() + } + return + } + + if typ == websocket.TextMessage && string(data) == "ping" { + cm.lastPingTime.Store(time.Now()) + if err := cm.conn.SetWriteDeadline(time.Now().Add(cm.pongWriteTimeout)); err != nil { + cm.err = fmt.Errorf("failed to set write deadline: %w", err) + cm.Close() + return + } + if err := cm.conn.WriteMessage(websocket.TextMessage, []byte("pong")); err != nil { + cm.err = fmt.Errorf("failed to write pong message: %w", err) + cm.Close() + return + } + continue + } + + if typ == websocket.TextMessage || typ == websocket.BinaryMessage { + select { + case <-cm.ctx.Done(): + return + case cm.readCh <- data: + } + } + } + } +} diff --git a/internal/net/gphttp/gpwebsocket/utils.go b/internal/net/gphttp/websocket/utils.go similarity index 71% rename from internal/net/gphttp/gpwebsocket/utils.go rename to internal/net/gphttp/websocket/utils.go index 3569d591..3a858857 100644 --- a/internal/net/gphttp/gpwebsocket/utils.go +++ b/internal/net/gphttp/websocket/utils.go @@ -1,4 +1,4 @@ -package gpwebsocket +package websocket import ( "net" @@ -7,9 +7,11 @@ import ( "sync" "time" + "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/rs/zerolog/log" "github.com/yusing/go-proxy/agent/pkg/agent" + apitypes "github.com/yusing/go-proxy/internal/api/types" ) func warnNoMatchDomains() { @@ -30,8 +32,6 @@ func SetWebsocketAllowedDomains(h http.Header, domains []string) { h[HeaderXGoDoxyWebsocketAllowedDomains] = domains } -const writeTimeout = time.Second * 10 - // Initiate upgrades the HTTP connection to a WebSocket connection. // It returns a WebSocket connection and an error if the upgrade fails. // It logs and responds with an error if the upgrade fails. @@ -76,39 +76,16 @@ func Initiate(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) { return upgrader.Upgrade(w, r, nil) } -func Periodic(w http.ResponseWriter, r *http.Request, interval time.Duration, do func(conn *websocket.Conn) error) { - conn, err := Initiate(w, r) +func PeriodicWrite(c *gin.Context, interval time.Duration, get func() (any, error)) { + manager, err := NewManagerWithUpgrade(c) if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket")) return } - defer conn.Close() - - if err := do(conn); err != nil { - return + err = manager.PeriodicWrite(interval, get) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to write to websocket")) } - - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-r.Context().Done(): - return - case <-ticker.C: - _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) - if err := do(conn); err != nil { - return - } - } - } -} - -// WriteText writes a text message to the websocket connection. -// It returns true if the message was written successfully, false otherwise. -// It logs an error if the message is not written successfully. -func WriteText(conn *websocket.Conn, msg string) error { - _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) - return conn.WriteMessage(websocket.TextMessage, []byte(msg)) } func errHandler(w http.ResponseWriter, r *http.Request, status int, reason error) { diff --git a/internal/net/gphttp/websocket/writer.go b/internal/net/gphttp/websocket/writer.go new file mode 100644 index 00000000..1ba3e5ac --- /dev/null +++ b/internal/net/gphttp/websocket/writer.go @@ -0,0 +1,26 @@ +package websocket + +import ( + "io" + "time" +) + +type Writer struct { + msgType int + manager *Manager +} + +func (cm *Manager) NewWriter(msgType int) io.Writer { + return &Writer{ + msgType: msgType, + manager: cm, + } +} + +func (w *Writer) Write(p []byte) (int, error) { + return len(p), w.manager.WriteData(w.msgType, p, 10*time.Second) +} + +func (w *Writer) Close() error { + return w.manager.conn.Close() +} diff --git a/internal/route/common.go b/internal/route/common.go index 8a6c6635..2cc34f4e 100644 --- a/internal/route/common.go +++ b/internal/route/common.go @@ -3,20 +3,21 @@ package route import ( "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/route/routes" + "github.com/yusing/go-proxy/internal/types" ) -func checkExists(r routes.Route) gperr.Error { +func checkExists(r types.Route) gperr.Error { if r.UseLoadBalance() { // skip checking for load balanced routes return nil } var ( - existing routes.Route + existing types.Route ok bool ) switch r := r.(type) { - case routes.HTTPRoute: + case types.HTTPRoute: existing, ok = routes.HTTP.Get(r.Key()) - case routes.StreamRoute: + case types.StreamRoute: existing, ok = routes.Stream.Get(r.Key()) } if ok { diff --git a/internal/route/provider/agent.go b/internal/route/provider/agent.go index c4dd88d6..dce24b88 100644 --- a/internal/route/provider/agent.go +++ b/internal/route/provider/agent.go @@ -14,7 +14,7 @@ type AgentProvider struct { } func (p *AgentProvider) ShortName() string { - return p.AgentConfig.Name() + return p.AgentConfig.Name } func (p *AgentProvider) NewWatcher() watcher.Watcher { diff --git a/internal/route/provider/docker.go b/internal/route/provider/docker.go index 77836a55..55c72572 100755 --- a/internal/route/provider/docker.go +++ b/internal/route/provider/docker.go @@ -13,6 +13,7 @@ import ( "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/route" "github.com/yusing/go-proxy/internal/serialization" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils/strutils" "github.com/yusing/go-proxy/internal/watcher" ) @@ -78,7 +79,7 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) { } if container.IsHostNetworkMode { - err := container.UpdatePorts() + err := docker.UpdatePorts(container) if err != nil { errs.Add(gperr.PrependSubject(container.ContainerName, err)) continue @@ -111,7 +112,7 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, gperr.Error) { // Returns a list of proxy entries for a container. // Always non-nil. -func (p *DockerProvider) routesFromContainerLabels(container *docker.Container) (route.Routes, gperr.Error) { +func (p *DockerProvider) routesFromContainerLabels(container *types.Container) (route.Routes, gperr.Error) { if !container.IsExplicit && p.IsExplicitOnly() { return make(route.Routes, 0), nil } @@ -138,10 +139,10 @@ func (p *DockerProvider) routesFromContainerLabels(container *docker.Container) continue } - entryMap, ok := entryMapAny.(docker.LabelMap) + entryMap, ok := entryMapAny.(types.LabelMap) if !ok { // try to deserialize to map - entryMap = make(docker.LabelMap) + entryMap = make(types.LabelMap) yamlStr, ok := entryMapAny.(string) if !ok { // should not happen diff --git a/internal/route/provider/provider.go b/internal/route/provider/provider.go index af0f71e9..26356c14 100644 --- a/internal/route/provider/provider.go +++ b/internal/route/provider/provider.go @@ -10,11 +10,12 @@ import ( "github.com/rs/zerolog" "github.com/yusing/go-proxy/agent/pkg/agent" + "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/route" provider "github.com/yusing/go-proxy/internal/route/provider/types" - "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/types" W "github.com/yusing/go-proxy/internal/watcher" "github.com/yusing/go-proxy/internal/watcher/events" ) @@ -45,7 +46,7 @@ const ( var ErrEmptyProviderName = errors.New("empty provider name") -var _ routes.Provider = (*Provider)(nil) +var _ types.RouteProvider = (*Provider)(nil) func newProvider(t provider.Type) *Provider { return &Provider{t: t} @@ -76,7 +77,7 @@ func NewAgentProvider(cfg *agent.AgentConfig) *Provider { p := newProvider(provider.ProviderTypeAgent) agent := &AgentProvider{ AgentConfig: cfg, - docker: DockerProviderImpl(cfg.Name(), cfg.FakeDockerHost()), + docker: DockerProviderImpl(cfg.Name, cfg.FakeDockerHost()), } p.ProviderImpl = agent p.watcher = p.NewWatcher() @@ -145,7 +146,7 @@ func (p *Provider) NumRoutes() int { return len(p.routes) } -func (p *Provider) IterRoutes(yield func(string, routes.Route) bool) { +func (p *Provider) IterRoutes(yield func(string, types.Route) bool) { routes := p.lockCloneRoutes() for alias, r := range routes { if !yield(alias, r.Impl()) { @@ -154,7 +155,7 @@ func (p *Provider) IterRoutes(yield func(string, routes.Route) bool) { } } -func (p *Provider) FindService(project, service string) (routes.Route, bool) { +func (p *Provider) FindService(project, service string) (types.Route, bool) { switch p.GetType() { case provider.ProviderTypeDocker, provider.ProviderTypeAgent: default: @@ -166,17 +167,17 @@ func (p *Provider) FindService(project, service string) (routes.Route, bool) { routes := p.lockCloneRoutes() for _, r := range routes { cont := r.ContainerInfo() - if cont.DockerComposeProject() != project { + if docker.DockerComposeProject(cont) != project { continue } - if cont.DockerComposeService() == service { + if docker.DockerComposeService(cont) == service { return r.Impl(), true } } return nil, false } -func (p *Provider) GetRoute(alias string) (routes.Route, bool) { +func (p *Provider) GetRoute(alias string) (types.Route, bool) { r, ok := p.lockGetRoute(alias) if !ok { return nil, false diff --git a/internal/route/provider/stats.go b/internal/route/provider/stats.go index fb05eba8..19fdd80e 100644 --- a/internal/route/provider/stats.go +++ b/internal/route/provider/stats.go @@ -1,61 +1,12 @@ package provider import ( - R "github.com/yusing/go-proxy/internal/route" - provider "github.com/yusing/go-proxy/internal/route/provider/types" route "github.com/yusing/go-proxy/internal/route/types" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) -type ( - RouteStats struct { - Total uint16 `json:"total"` - NumHealthy uint16 `json:"healthy"` - NumUnhealthy uint16 `json:"unhealthy"` - NumNapping uint16 `json:"napping"` - NumError uint16 `json:"error"` - NumUnknown uint16 `json:"unknown"` - } - ProviderStats struct { - Total uint16 `json:"total"` - RPs RouteStats `json:"reverse_proxies"` - Streams RouteStats `json:"streams"` - Type provider.Type `json:"type"` - } -) - -func (stats *RouteStats) Add(r *R.Route) { - stats.Total++ - mon := r.HealthMonitor() - if mon == nil { - stats.NumUnknown++ - return - } - switch mon.Status() { - case health.StatusHealthy: - stats.NumHealthy++ - case health.StatusUnhealthy: - stats.NumUnhealthy++ - case health.StatusNapping: - stats.NumNapping++ - case health.StatusError: - stats.NumError++ - default: - stats.NumUnknown++ - } -} - -func (stats *RouteStats) AddOther(other RouteStats) { - stats.Total += other.Total - stats.NumHealthy += other.NumHealthy - stats.NumUnhealthy += other.NumUnhealthy - stats.NumNapping += other.NumNapping - stats.NumError += other.NumError - stats.NumUnknown += other.NumUnknown -} - -func (p *Provider) Statistics() ProviderStats { - var rps, streams RouteStats +func (p *Provider) Statistics() types.ProviderStats { + var rps, streams types.RouteStats for _, r := range p.routes { switch r.Type() { case route.RouteTypeHTTP: @@ -64,7 +15,7 @@ func (p *Provider) Statistics() ProviderStats { streams.Add(r) } } - return ProviderStats{ + return types.ProviderStats{ Total: rps.Total + streams.Total, RPs: rps, Streams: streams, diff --git a/internal/route/provider/types/provider_type.go b/internal/route/provider/types/provider_type.go index 2f90347f..5d96f7f9 100644 --- a/internal/route/provider/types/provider_type.go +++ b/internal/route/provider/types/provider_type.go @@ -1,6 +1,6 @@ package provider -type Type string +type Type string // @name ProviderType const ( ProviderTypeDocker Type = "docker" diff --git a/internal/route/reverse_proxy.go b/internal/route/reverse_proxy.go index 5a82b626..0239cde2 100755 --- a/internal/route/reverse_proxy.go +++ b/internal/route/reverse_proxy.go @@ -13,12 +13,12 @@ import ( "github.com/yusing/go-proxy/internal/logging/accesslog" gphttp "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer" - loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" "github.com/yusing/go-proxy/internal/net/gphttp/middleware" "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" nettypes "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/watcher/health/monitor" ) @@ -30,7 +30,7 @@ type ReveseProxyRoute struct { rp *reverseproxy.ReverseProxy } -var _ routes.ReverseProxyRoute = (*ReveseProxyRoute)(nil) +var _ types.ReverseProxyRoute = (*ReveseProxyRoute)(nil) // var globalMux = http.NewServeMux() // TODO: support regex subdomain matching. @@ -196,7 +196,7 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) { } r.loadBalancer = lb - server := loadbalance.NewServer(r.task.Name(), r.ProxyURL, r.LoadBalance.Weight, r.handler, r.HealthMon) + server := loadbalancer.NewServer(r.task.Name(), r.ProxyURL, r.LoadBalance.Weight, r.handler, r.HealthMon) lb.AddServer(server) r.task.OnCancel("lb_remove_server", func() { lb.RemoveServer(server) diff --git a/internal/route/route.go b/internal/route/route.go index 8fde1aeb..fe81a325 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -14,18 +14,16 @@ import ( "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/homepage" - idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" netutils "github.com/yusing/go-proxy/internal/net" nettypes "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/proxmox" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils/strutils" - "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/internal/common" config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/logging/accesslog" - loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/route/rules" route "github.com/yusing/go-proxy/internal/route/types" @@ -43,39 +41,41 @@ type ( Root string `json:"root,omitempty"` route.HTTPConfig - PathPatterns []string `json:"path_patterns,omitempty"` - Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"` - HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"` - LoadBalance *loadbalance.Config `json:"load_balance,omitempty"` - Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"` - Homepage *homepage.ItemConfig `json:"homepage,omitempty"` - AccessLog *accesslog.RequestLoggerConfig `json:"access_log,omitempty"` + PathPatterns []string `json:"path_patterns,omitempty" extensions:"x-nullable"` + Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name" extension:"x-nullable"` + HealthCheck *types.HealthCheckConfig `json:"healthcheck"` + LoadBalance *types.LoadBalancerConfig `json:"load_balance,omitempty" extensions:"x-nullable"` + Middlewares map[string]types.LabelMap `json:"middlewares,omitempty" extensions:"x-nullable"` + Homepage *homepage.ItemConfig `json:"homepage"` + AccessLog *accesslog.RequestLoggerConfig `json:"access_log,omitempty" extensions:"x-nullable"` Agent string `json:"agent,omitempty"` - Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` - HealthMon health.HealthMonitor `json:"health,omitempty"` + Idlewatcher *types.IdlewatcherConfig `json:"idlewatcher,omitempty" extensions:"x-nullable"` + HealthMon types.HealthMonitor `json:"health,omitempty" swaggerignore:"true"` + // for swagger + HealthJSON *types.HealthJSON `form:"health"` Metadata `deserialize:"-"` } Metadata struct { /* Docker only */ - Container *docker.Container `json:"container,omitempty"` + Container *types.Container `json:"container,omitempty" extensions:"x-nullable"` - Provider string `json:"provider,omitempty"` // for backward compatibility + Provider string `json:"provider,omitempty" extensions:"x-nullable"` // for backward compatibility // private fields - LisURL *nettypes.URL `json:"lurl,omitempty"` - ProxyURL *nettypes.URL `json:"purl,omitempty"` + LisURL *nettypes.URL `json:"lurl,omitempty" swaggertype:"string" extensions:"x-nullable"` + ProxyURL *nettypes.URL `json:"purl,omitempty" swaggertype:"string"` Excluded *bool `json:"excluded"` - impl routes.Route + impl types.Route task *task.Task isValidated bool lastError gperr.Error - provider routes.Provider + provider types.RouteProvider agent *agent.AgentConfig @@ -212,7 +212,7 @@ func (r *Route) Validate() gperr.Error { errs := gperr.NewBuilder("entry validation failed") - var impl routes.Route + var impl types.Route var err gperr.Error switch r.Scheme { @@ -263,7 +263,7 @@ func (r *Route) Validate() gperr.Error { return nil } -func (r *Route) Impl() routes.Route { +func (r *Route) Impl() types.Route { return r.impl } @@ -318,11 +318,11 @@ func (r *Route) Started() <-chan struct{} { return r.started } -func (r *Route) GetProvider() routes.Provider { +func (r *Route) GetProvider() types.RouteProvider { return r.provider } -func (r *Route) SetProvider(p routes.Provider) { +func (r *Route) SetProvider(p types.RouteProvider) { r.provider = p r.Provider = p.ShortName() } @@ -384,26 +384,26 @@ func (r *Route) IsAgent() bool { return r.GetAgent() != nil } -func (r *Route) HealthMonitor() health.HealthMonitor { +func (r *Route) HealthMonitor() types.HealthMonitor { return r.HealthMon } -func (r *Route) SetHealthMonitor(m health.HealthMonitor) { +func (r *Route) SetHealthMonitor(m types.HealthMonitor) { if r.HealthMon != nil && r.HealthMon != m { r.HealthMon.Finish("health monitor replaced") } r.HealthMon = m } -func (r *Route) IdlewatcherConfig() *idlewatcher.Config { +func (r *Route) IdlewatcherConfig() *types.IdlewatcherConfig { return r.Idlewatcher } -func (r *Route) HealthCheckConfig() *health.HealthCheckConfig { +func (r *Route) HealthCheckConfig() *types.HealthCheckConfig { return r.HealthCheck } -func (r *Route) LoadBalanceConfig() *loadbalance.Config { +func (r *Route) LoadBalanceConfig() *types.LoadBalancerConfig { return r.LoadBalance } @@ -419,7 +419,7 @@ func (r *Route) HomepageItem() *homepage.Item { } } -func (r *Route) ContainerInfo() *docker.Container { +func (r *Route) ContainerInfo() *types.Container { return r.Container } @@ -447,7 +447,7 @@ func (r *Route) ShouldExclude() bool { return true case r.IsZeroPort() && !r.UseIdleWatcher(): return true - case !r.Container.IsExplicit && r.Container.IsBlacklisted(): + case !r.Container.IsExplicit && docker.IsBlacklisted(r.Container): return true case strings.HasPrefix(r.Container.ContainerName, "buildx_"): return true @@ -579,7 +579,7 @@ func (r *Route) Finalize() { r.Port.Listening, r.Port.Proxy = lp, pp if r.HealthCheck == nil { - r.HealthCheck = health.DefaultHealthConfig() + r.HealthCheck = types.DefaultHealthConfig() } if !r.HealthCheck.Disable { diff --git a/internal/route/route_test.go b/internal/route/route_test.go index 02045c99..59fd1973 100644 --- a/internal/route/route_test.go +++ b/internal/route/route_test.go @@ -5,11 +5,9 @@ import ( "github.com/docker/docker/api/types/container" "github.com/yusing/go-proxy/internal/common" - "github.com/yusing/go-proxy/internal/docker" - loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" route "github.com/yusing/go-proxy/internal/route/types" + "github.com/yusing/go-proxy/internal/types" expect "github.com/yusing/go-proxy/internal/utils/testing" - "github.com/yusing/go-proxy/internal/watcher/health" ) func TestRouteValidate(t *testing.T) { @@ -43,10 +41,10 @@ func TestRouteValidate(t *testing.T) { Scheme: route.SchemeHTTP, Host: "example.com", Port: route.Port{Proxy: 80}, - HealthCheck: &health.HealthCheckConfig{ + HealthCheck: &types.HealthCheckConfig{ Disable: true, }, - LoadBalance: &loadbalance.Config{ + LoadBalance: &types.LoadBalancerConfig{ Link: "test-link", }, // Minimal LoadBalance config with non-empty Link will be checked by UseLoadBalance } @@ -99,9 +97,9 @@ func TestRouteValidate(t *testing.T) { Host: "example.com", Port: route.Port{Proxy: 80}, Metadata: Metadata{ - Container: &docker.Container{ + Container: &types.Container{ ContainerID: "test-id", - Image: &docker.ContainerImage{ + Image: &types.ContainerImage{ Name: "test-image", }, }, @@ -157,9 +155,9 @@ func TestDockerRouteDisallowAgent(t *testing.T) { Port: route.Port{Proxy: 80}, Agent: "test-agent", Metadata: Metadata{ - Container: &docker.Container{ + Container: &types.Container{ ContainerID: "test-id", - Image: &docker.ContainerImage{ + Image: &types.ContainerImage{ Name: "test-image", }, }, diff --git a/internal/route/routes/context.go b/internal/route/routes/context.go index 7426ccf1..ccddcdf4 100644 --- a/internal/route/routes/context.go +++ b/internal/route/routes/context.go @@ -4,18 +4,20 @@ import ( "context" "net/http" "net/url" + + "github.com/yusing/go-proxy/internal/types" ) type RouteContext struct{} var routeContextKey = RouteContext{} -func WithRouteContext(r *http.Request, route HTTPRoute) *http.Request { +func WithRouteContext(r *http.Request, route types.HTTPRoute) *http.Request { return r.WithContext(context.WithValue(r.Context(), routeContextKey, route)) } -func TryGetRoute(r *http.Request) HTTPRoute { - if route, ok := r.Context().Value(routeContextKey).(HTTPRoute); ok { +func TryGetRoute(r *http.Request) types.HTTPRoute { + if route, ok := r.Context().Value(routeContextKey).(types.HTTPRoute); ok { return route } return nil diff --git a/internal/route/routes/query.go b/internal/route/routes/query.go index 869d54cd..4ba82dae 100644 --- a/internal/route/routes/query.go +++ b/internal/route/routes/query.go @@ -2,84 +2,80 @@ package routes import ( "encoding/json" + "fmt" + "math" + "net/url" + "strings" "time" "github.com/yusing/go-proxy/internal/homepage" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) -func getHealthInfo(r Route) map[string]string { - mon := r.HealthMonitor() - if mon == nil { - return map[string]string{ - "status": "unknown", - "uptime": "n/a", - "latency": "n/a", - "detail": "n/a", - } - } - return map[string]string{ - "status": mon.Status().String(), - "uptime": mon.Uptime().Round(time.Second).String(), - "latency": mon.Latency().Round(time.Microsecond).String(), - "detail": mon.Detail(), - } +type HealthInfo struct { + Status types.HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,napping,starting,error,unknown"` + Uptime time.Duration `json:"uptime" swaggertype:"number"` // uptime in milliseconds + Latency time.Duration `json:"latency" swaggertype:"number"` // latency in microseconds + Detail string `json:"detail"` } -type HealthInfoRaw struct { - Status health.Status `json:"status"` - Latency time.Duration `json:"latency"` -} - -func (info *HealthInfoRaw) MarshalJSON() ([]byte, error) { +func (info *HealthInfo) MarshalJSON() ([]byte, error) { return json.Marshal(map[string]any{ "status": info.Status.String(), - "latency": info.Latency.Milliseconds(), + "latency": info.Latency.Microseconds(), + "uptime": info.Uptime.Milliseconds(), + "detail": info.Detail, }) } -func (info *HealthInfoRaw) UnmarshalJSON(data []byte) error { - var v map[string]any +func (info *HealthInfo) UnmarshalJSON(data []byte) error { + var v struct { + Status string `json:"status"` + Latency int64 `json:"latency"` + Uptime int64 `json:"uptime"` + Detail string `json:"detail"` + } if err := json.Unmarshal(data, &v); err != nil { return err } - if status, ok := v["status"].(string); ok { - info.Status = health.NewStatus(status) + + // overflow check + if math.MaxInt64/time.Microsecond < time.Duration(v.Latency) { + return fmt.Errorf("latency overflow: %d", v.Latency) } - if latency, ok := v["latency"].(float64); ok { - info.Latency = time.Duration(latency) + if math.MaxInt64/time.Millisecond < time.Duration(v.Uptime) { + return fmt.Errorf("uptime overflow: %d", v.Uptime) } + + info.Status = types.NewHealthStatusFromString(v.Status) + info.Latency = time.Duration(v.Latency) * time.Microsecond + info.Uptime = time.Duration(v.Uptime) * time.Millisecond + info.Detail = v.Detail return nil } -func getHealthInfoRaw(r Route) *HealthInfoRaw { - mon := r.HealthMonitor() - if mon == nil { - return &HealthInfoRaw{ - Status: health.StatusUnknown, - Latency: time.Duration(0), - } - } - return &HealthInfoRaw{ - Status: mon.Status(), - Latency: mon.Latency(), - } -} - -func HealthMap() map[string]map[string]string { - healthMap := make(map[string]map[string]string, NumRoutes()) +func GetHealthInfo() map[string]HealthInfo { + healthMap := make(map[string]HealthInfo, NumRoutes()) for r := range Iter { healthMap[r.Name()] = getHealthInfo(r) } return healthMap } -func HealthInfo() map[string]*HealthInfoRaw { - healthMap := make(map[string]*HealthInfoRaw, NumRoutes()) - for r := range Iter { - healthMap[r.Name()] = getHealthInfoRaw(r) +func getHealthInfo(r types.Route) HealthInfo { + mon := r.HealthMonitor() + if mon == nil { + return HealthInfo{ + Status: types.StatusUnknown, + Detail: "n/a", + } + } + return HealthInfo{ + Status: mon.Status(), + Uptime: mon.Uptime(), + Latency: mon.Latency(), + Detail: mon.Detail(), } - return healthMap } func HomepageCategories() []string { @@ -99,7 +95,13 @@ func HomepageCategories() []string { return categories } -func HomepageConfig(categoryFilter, providerFilter string) homepage.Homepage { +func HomepageItems(proto, hostname, categoryFilter, providerFilter string) homepage.Homepage { + switch proto { + case "http", "https": + default: + proto = "http" + } + hp := make(homepage.Homepage) for _, r := range HTTP.Iter { @@ -110,13 +112,32 @@ func HomepageConfig(categoryFilter, providerFilter string) homepage.Homepage { if categoryFilter != "" && item.Category != categoryFilter { continue } + + // clear url if invalid + _, err := url.Parse(item.URL) + if err != nil { + item.URL = "" + } + + // append hostname if provided and only if alias is not FQDN + if hostname != "" && item.URL == "" { + if !strings.Contains(item.Alias, ".") { + item.URL = fmt.Sprintf("%s://%s.%s", proto, item.Alias, hostname) + } + } + + // prepend protocol if not exists + if !strings.HasPrefix(item.URL, "http://") && !strings.HasPrefix(item.URL, "https://") { + item.URL = fmt.Sprintf("%s://%s", proto, item.URL) + } + hp.Add(item) } return hp } -func ByProvider() map[string][]Route { - rts := make(map[string][]Route) +func ByProvider() map[string][]types.Route { + rts := make(map[string][]types.Route) for r := range Iter { rts[r.ProviderName()] = append(rts[r.ProviderName()], r) } diff --git a/internal/route/routes/routes.go b/internal/route/routes/routes.go index c3fd007c..6377b267 100644 --- a/internal/route/routes/routes.go +++ b/internal/route/routes/routes.go @@ -1,21 +1,22 @@ package routes import ( + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils/pool" ) var ( - HTTP = pool.New[HTTPRoute]("http_routes") - Stream = pool.New[StreamRoute]("stream_routes") + HTTP = pool.New[types.HTTPRoute]("http_routes") + Stream = pool.New[types.StreamRoute]("stream_routes") // All is a pool of all routes, including HTTP, Stream routes and also excluded routes. - All = pool.New[Route]("all_routes") + All = pool.New[types.Route]("all_routes") ) func init() { All.DisableLog() } -func Iter(yield func(r Route) bool) { +func Iter(yield func(r types.Route) bool) { for _, r := range All.Iter { if !yield(r) { break @@ -23,7 +24,7 @@ func Iter(yield func(r Route) bool) { } } -func IterKV(yield func(alias string, r Route) bool) { +func IterKV(yield func(alias string, r types.Route) bool) { for k, r := range All.Iter { if !yield(k, r) { break @@ -41,7 +42,7 @@ func Clear() { All.Clear() } -func GetHTTPRouteOrExact(alias, host string) (HTTPRoute, bool) { +func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) { r, ok := HTTP.Get(alias) if ok { return r, true @@ -50,6 +51,6 @@ func GetHTTPRouteOrExact(alias, host string) (HTTPRoute, bool) { return HTTP.Get(host) } -func Get(alias string) (Route, bool) { +func Get(alias string) (types.Route, bool) { return All.Get(alias) } diff --git a/internal/route/stream.go b/internal/route/stream.go index dddeae0e..0c4adcd8 100755 --- a/internal/route/stream.go +++ b/internal/route/stream.go @@ -13,6 +13,7 @@ import ( "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/route/stream" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/watcher/health/monitor" ) @@ -24,7 +25,7 @@ type StreamRoute struct { l zerolog.Logger } -func NewStreamRoute(base *Route) (routes.Route, gperr.Error) { +func NewStreamRoute(base *Route) (types.Route, gperr.Error) { // TODO: support non-coherent scheme return &StreamRoute{ Route: base, diff --git a/internal/route/types/http_config.go b/internal/route/types/http_config.go index c6462e4e..aa077bc5 100644 --- a/internal/route/types/http_config.go +++ b/internal/route/types/http_config.go @@ -6,6 +6,6 @@ import ( type HTTPConfig struct { NoTLSVerify bool `json:"no_tls_verify,omitempty"` - ResponseHeaderTimeout time.Duration `json:"response_header_timeout,omitempty"` + ResponseHeaderTimeout time.Duration `json:"response_header_timeout,omitempty" swaggertype:"primitive,integer"` DisableCompression bool `json:"disable_compression,omitempty"` } diff --git a/internal/types/docker.go b/internal/types/docker.go new file mode 100644 index 00000000..866cd223 --- /dev/null +++ b/internal/types/docker.go @@ -0,0 +1,77 @@ +package types + +import ( + "encoding/json" + + "github.com/docker/docker/api/types/container" + "github.com/yusing/go-proxy/agent/pkg/agent" + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/utils" +) + +type ( + LabelMap = map[string]any + + PortMapping = map[int]container.Port + Container struct { + _ utils.NoCopy + + DockerHost string `json:"docker_host"` + Image *ContainerImage `json:"image"` + ContainerName string `json:"container_name"` + ContainerID string `json:"container_id"` + + Agent *agent.AgentConfig `json:"agent"` + + Labels map[string]string `json:"-"` + IdlewatcherConfig *IdlewatcherConfig `json:"idlewatcher_config"` + + Mounts []string `json:"mounts"` + + Network string `json:"network,omitempty"` + PublicPortMapping PortMapping `json:"public_ports"` // non-zero publicPort:types.Port + PrivatePortMapping PortMapping `json:"private_ports"` // privatePort:types.Port + PublicHostname string `json:"public_hostname"` + PrivateHostname string `json:"private_hostname"` + + Aliases []string `json:"aliases"` + IsExcluded bool `json:"is_excluded"` + IsExplicit bool `json:"is_explicit"` + IsHostNetworkMode bool `json:"is_host_network_mode"` + Running bool `json:"running"` + + Errors *ContainerError `json:"errors" swaggertype:"string"` + } // @name Container + ContainerImage struct { + Author string `json:"author,omitempty"` + Name string `json:"name"` + Tag string `json:"tag,omitempty"` + } // @name ContainerImage + + ContainerError struct { + errs *gperr.Builder + } +) + +func (e *ContainerError) Add(err error) { + if e.errs == nil { + e.errs = gperr.NewBuilder() + } + e.errs.Add(err) +} + +func (e *ContainerError) Error() string { + if e.errs == nil { + return "" + } + return e.errs.String() +} + +func (e *ContainerError) Unwrap() error { + return e.errs.Error() +} + +func (e *ContainerError) MarshalJSON() ([]byte, error) { + err := e.errs.Error().(interface{ Plain() []byte }) + return json.Marshal(string(err.Plain())) +} diff --git a/internal/types/health.go b/internal/types/health.go new file mode 100644 index 00000000..8121ab8f --- /dev/null +++ b/internal/types/health.go @@ -0,0 +1,166 @@ +package types + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "time" + + "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +type ( + HealthStatus uint8 + + HealthCheckResult struct { + Healthy bool `json:"healthy"` + Detail string `json:"detail"` + Latency time.Duration `json:"latency"` + } // @name HealthCheckResult + WithHealthInfo interface { + Status() HealthStatus + Uptime() time.Duration + Latency() time.Duration + Detail() string + } + HealthMonitor interface { + task.TaskStarter + task.TaskFinisher + fmt.Stringer + WithHealthInfo + Name() string + json.Marshaler + } + HealthChecker interface { + CheckHealth() (result *HealthCheckResult, err error) + URL() *url.URL + Config() *HealthCheckConfig + UpdateURL(url *url.URL) + } + HealthMonCheck interface { + HealthMonitor + HealthChecker + } + HealthJSON struct { + Name string `json:"name"` + Config *HealthCheckConfig `json:"config"` + Started int64 `json:"started"` + StartedStr string `json:"startedStr"` + Status string `json:"status"` + Uptime float64 `json:"uptime"` + UptimeStr string `json:"uptimeStr"` + Latency float64 `json:"latency"` + LatencyStr string `json:"latencyStr"` + LastSeen int64 `json:"lastSeen"` + LastSeenStr string `json:"lastSeenStr"` + Detail string `json:"detail"` + URL string `json:"url"` + Extra *HealthExtra `json:"extra" extensions:"x-nullable"` + } // @name HealthJSON + + HealthJSONRepr struct { + Name string + Config *HealthCheckConfig + Status HealthStatus + Started time.Time + Uptime time.Duration + Latency time.Duration + LastSeen time.Time + Detail string + URL *url.URL + Extra *HealthExtra + } + + HealthExtra struct { + Config *LoadBalancerConfig `json:"config"` + Pool map[string]any `json:"pool"` + } // @name HealthExtra +) + +const ( + StatusUnknown HealthStatus = 0 + StatusHealthy HealthStatus = (1 << iota) + StatusNapping + StatusStarting + StatusUnhealthy + StatusError + + NumStatuses int = iota - 1 + + HealthyMask = StatusHealthy | StatusNapping | StatusStarting + IdlingMask = StatusNapping | StatusStarting +) + +func NewHealthStatusFromString(s string) HealthStatus { + switch s { + case "healthy": + return StatusHealthy + case "unhealthy": + return StatusUnhealthy + case "napping": + return StatusNapping + case "starting": + return StatusStarting + case "error": + return StatusError + default: + return StatusUnknown + } +} + +func (s HealthStatus) String() string { + switch s { + case StatusHealthy: + return "healthy" + case StatusUnhealthy: + return "unhealthy" + case StatusNapping: + return "napping" + case StatusStarting: + return "starting" + case StatusError: + return "error" + default: + return "unknown" + } +} + +func (s HealthStatus) Good() bool { + return s&HealthyMask != 0 +} + +func (s HealthStatus) Bad() bool { + return s&HealthyMask == 0 +} + +func (s HealthStatus) Idling() bool { + return s&IdlingMask != 0 +} + +func (jsonRepr *HealthJSONRepr) MarshalJSON() ([]byte, error) { + var url string + if jsonRepr.URL != nil { + url = jsonRepr.URL.String() + } + if url == "http://:0" { + url = "" + } + return json.Marshal(HealthJSON{ + Name: jsonRepr.Name, + Config: jsonRepr.Config, + Started: jsonRepr.Started.Unix(), + StartedStr: strutils.FormatTime(jsonRepr.Started), + Status: jsonRepr.Status.String(), + Uptime: jsonRepr.Uptime.Seconds(), + UptimeStr: strutils.FormatDuration(jsonRepr.Uptime), + Latency: jsonRepr.Latency.Seconds(), + LatencyStr: strconv.Itoa(int(jsonRepr.Latency.Milliseconds())) + " ms", + LastSeen: jsonRepr.LastSeen.Unix(), + LastSeenStr: strutils.FormatLastSeen(jsonRepr.LastSeen), + Detail: jsonRepr.Detail, + URL: url, + Extra: jsonRepr.Extra, + }) +} diff --git a/internal/watcher/health/config.go b/internal/types/healthcheck_config.go similarity index 87% rename from internal/watcher/health/config.go rename to internal/types/healthcheck_config.go index 787fa487..6f767863 100644 --- a/internal/watcher/health/config.go +++ b/internal/types/healthcheck_config.go @@ -1,4 +1,4 @@ -package health +package types import ( "context" @@ -11,12 +11,12 @@ type HealthCheckConfig struct { Disable bool `json:"disable,omitempty" aliases:"disabled"` Path string `json:"path,omitempty" validate:"omitempty,uri,startswith=/"` UseGet bool `json:"use_get,omitempty"` - Interval time.Duration `json:"interval" validate:"omitempty,min=1s"` - Timeout time.Duration `json:"timeout" validate:"omitempty,min=1s"` + Interval time.Duration `json:"interval" validate:"omitempty,min=1s" swaggertype:"primitive,integer"` + Timeout time.Duration `json:"timeout" validate:"omitempty,min=1s" swaggertype:"primitive,integer"` Retries int64 `json:"retries"` // <0: immediate, >=0: threshold BaseContext func() context.Context `json:"-"` -} +} // @name HealthCheckConfig func DefaultHealthConfig() *HealthCheckConfig { return &HealthCheckConfig{ diff --git a/internal/types/idlewatcher.go b/internal/types/idlewatcher.go new file mode 100644 index 00000000..0d7d2b50 --- /dev/null +++ b/internal/types/idlewatcher.go @@ -0,0 +1,138 @@ +package types + +import ( + "net/url" + "strconv" + "strings" + "time" + + "github.com/yusing/go-proxy/internal/gperr" +) + +type ( + IdlewatcherProviderConfig struct { + Proxmox *ProxmoxConfig `json:"proxmox,omitempty"` + Docker *DockerConfig `json:"docker,omitempty"` + } // @name IdlewatcherProviderConfig + IdlewatcherConfigBase struct { + // 0: no idle watcher. + // Positive: idle watcher with idle timeout. + // Negative: idle watcher as a dependency. IdleTimeout time.Duration `json:"idle_timeout" json_ext:"duration"` + IdleTimeout time.Duration `json:"idle_timeout"` + WakeTimeout time.Duration `json:"wake_timeout"` + StopTimeout time.Duration `json:"stop_timeout"` + StopMethod ContainerStopMethod `json:"stop_method"` + StopSignal ContainerSignal `json:"stop_signal,omitempty"` + } // @name IdlewatcherConfigBase + IdlewatcherConfig struct { + IdlewatcherProviderConfig + IdlewatcherConfigBase + + StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container + DependsOn []string `json:"depends_on,omitempty"` + } // @name IdlewatcherConfig + ContainerStopMethod string // @name ContainerStopMethod + ContainerSignal string // @name ContainerSignal + + DockerConfig struct { + DockerHost string `json:"docker_host" validate:"required"` + ContainerID string `json:"container_id" validate:"required"` + ContainerName string `json:"container_name" validate:"required"` + } // @name DockerConfig + ProxmoxConfig struct { + Node string `json:"node" validate:"required"` + VMID int `json:"vmid" validate:"required"` + } // @name ProxmoxConfig +) + +const ( + ContainerWakeTimeoutDefault = 30 * time.Second + ContainerStopTimeoutDefault = 1 * time.Minute + + ContainerStopMethodPause ContainerStopMethod = "pause" + ContainerStopMethodStop ContainerStopMethod = "stop" + ContainerStopMethodKill ContainerStopMethod = "kill" +) + +func (c *IdlewatcherConfig) Key() string { + if c.Docker != nil { + return c.Docker.ContainerID + } + return c.Proxmox.Node + ":" + strconv.Itoa(c.Proxmox.VMID) +} + +func (c *IdlewatcherConfig) ContainerName() string { + if c.Docker != nil { + return c.Docker.ContainerName + } + return "lxc-" + strconv.Itoa(c.Proxmox.VMID) +} + +func (c *IdlewatcherConfig) Validate() gperr.Error { + if c.IdleTimeout == 0 { // zero idle timeout means no idle watcher + return nil + } + errs := gperr.NewBuilder("idlewatcher config validation error") + errs.AddRange( + c.validateProvider(), + c.validateTimeouts(), + c.validateStopMethod(), + c.validateStopSignal(), + c.validateStartEndpoint(), + ) + return errs.Error() +} + +func (c *IdlewatcherConfig) validateProvider() error { + if c.Docker == nil && c.Proxmox == nil { + return gperr.New("missing idlewatcher provider config") + } + return nil +} + +func (c *IdlewatcherConfig) validateTimeouts() error { //nolint:unparam + if c.WakeTimeout == 0 { + c.WakeTimeout = ContainerWakeTimeoutDefault + } + if c.StopTimeout == 0 { + c.StopTimeout = ContainerStopTimeoutDefault + } + return nil +} + +func (c *IdlewatcherConfig) validateStopMethod() error { + switch c.StopMethod { + case "": + c.StopMethod = ContainerStopMethodStop + return nil + case ContainerStopMethodPause, ContainerStopMethodStop, ContainerStopMethodKill: + return nil + default: + return gperr.New("invalid stop method").Subject(string(c.StopMethod)) + } +} + +func (c *IdlewatcherConfig) validateStopSignal() error { + switch c.StopSignal { + case "", "SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP", "INT", "TERM", "QUIT", "HUP": + return nil + default: + return gperr.New("invalid stop signal").Subject(string(c.StopSignal)) + } +} + +func (c *IdlewatcherConfig) validateStartEndpoint() error { + if c.StartEndpoint == "" { + return nil + } + // checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195 + // emulate browser and strip the '#' suffix prior to validation. see issue-#237 + if i := strings.Index(c.StartEndpoint, "#"); i > -1 { + c.StartEndpoint = c.StartEndpoint[:i] + } + if len(c.StartEndpoint) == 0 { + return gperr.New("start endpoint must not be empty if defined") + } + _, err := url.ParseRequestURI(c.StartEndpoint) + return err +} diff --git a/internal/idlewatcher/types/config_test.go b/internal/types/idlewatcher_test.go similarity index 94% rename from internal/idlewatcher/types/config_test.go rename to internal/types/idlewatcher_test.go index 5d194b93..d8456f8d 100644 --- a/internal/idlewatcher/types/config_test.go +++ b/internal/types/idlewatcher_test.go @@ -1,4 +1,4 @@ -package idlewatcher +package types import ( "testing" @@ -35,7 +35,7 @@ func TestValidateStartEndpoint(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cfg := new(Config) + cfg := new(IdlewatcherConfig) cfg.StartEndpoint = tc.input err := cfg.validateStartEndpoint() if err == nil { diff --git a/internal/types/loadbalancer.go b/internal/types/loadbalancer.go new file mode 100644 index 00000000..3d21de5b --- /dev/null +++ b/internal/types/loadbalancer.go @@ -0,0 +1,54 @@ +package types + +import ( + "net/http" + + nettypes "github.com/yusing/go-proxy/internal/net/types" + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +type ( + LoadBalancerConfig struct { + Link string `json:"link"` + Mode LoadBalancerMode `json:"mode"` + Weight int `json:"weight"` + Options map[string]any `json:"options,omitempty"` + } // @name LoadBalancerConfig + LoadBalancerMode string // @name LoadBalancerMode + LoadBalancerServer interface { + http.Handler + HealthMonitor + Name() string + Key() string + URL() *nettypes.URL + Weight() int + SetWeight(weight int) + TryWake() error + } + LoadBalancerServers []LoadBalancerServer +) + +const ( + LoadbalanceModeUnset LoadBalancerMode = "" + LoadbalanceModeRoundRobin LoadBalancerMode = "roundrobin" + LoadbalanceModeLeastConn LoadBalancerMode = "leastconn" + LoadbalanceModeIPHash LoadBalancerMode = "iphash" +) + +func (mode *LoadBalancerMode) ValidateUpdate() bool { + switch strutils.ToLowerNoSnake(string(*mode)) { + case "": + return true + case string(LoadbalanceModeRoundRobin): + *mode = LoadbalanceModeRoundRobin + return true + case string(LoadbalanceModeLeastConn): + *mode = LoadbalanceModeLeastConn + return true + case string(LoadbalanceModeIPHash): + *mode = LoadbalanceModeIPHash + return true + } + *mode = LoadbalanceModeRoundRobin + return false +} diff --git a/internal/route/routes/route.go b/internal/types/routes.go similarity index 64% rename from internal/route/routes/route.go rename to internal/types/routes.go index a004148f..761eeb61 100644 --- a/internal/route/routes/route.go +++ b/internal/types/routes.go @@ -1,42 +1,36 @@ -package routes +package types import ( "net/http" "github.com/yusing/go-proxy/agent/pkg/agent" - "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/homepage" - idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" - "github.com/yusing/go-proxy/internal/task" - "github.com/yusing/go-proxy/internal/utils/pool" - "github.com/yusing/go-proxy/internal/watcher/health" - - loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" nettypes "github.com/yusing/go-proxy/internal/net/types" + "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/utils/pool" ) type ( - //nolint:interfacebloat // this is for avoiding circular imports Route interface { task.TaskStarter task.TaskFinisher pool.Object ProviderName() string - GetProvider() Provider + GetProvider() RouteProvider TargetURL() *nettypes.URL - HealthMonitor() health.HealthMonitor - SetHealthMonitor(m health.HealthMonitor) + HealthMonitor() HealthMonitor + SetHealthMonitor(m HealthMonitor) References() []string Started() <-chan struct{} - IdlewatcherConfig() *idlewatcher.Config - HealthCheckConfig() *health.HealthCheckConfig - LoadBalanceConfig() *loadbalance.Config + IdlewatcherConfig() *IdlewatcherConfig + HealthCheckConfig() *HealthCheckConfig + LoadBalanceConfig() *LoadBalancerConfig HomepageConfig() *homepage.ItemConfig HomepageItem() *homepage.Item - ContainerInfo() *docker.Container + ContainerInfo() *Container GetAgent() *agent.AgentConfig @@ -60,7 +54,7 @@ type ( nettypes.Stream Stream() nettypes.Stream } - Provider interface { + RouteProvider interface { GetRoute(alias string) (r Route, ok bool) IterRoutes(yield func(alias string, r Route) bool) FindService(project, service string) (r Route, ok bool) diff --git a/internal/types/stats.go b/internal/types/stats.go new file mode 100644 index 00000000..8b3a0807 --- /dev/null +++ b/internal/types/stats.go @@ -0,0 +1,50 @@ +package types + +import provider "github.com/yusing/go-proxy/internal/route/provider/types" + +type ( + RouteStats struct { + Total uint16 `json:"total"` + NumHealthy uint16 `json:"healthy"` + NumUnhealthy uint16 `json:"unhealthy"` + NumNapping uint16 `json:"napping"` + NumError uint16 `json:"error"` + NumUnknown uint16 `json:"unknown"` + } // @name RouteStats + ProviderStats struct { + Total uint16 `json:"total"` + RPs RouteStats `json:"reverse_proxies"` + Streams RouteStats `json:"streams"` + Type provider.Type `json:"type"` + } // @name ProviderStats +) + +func (stats *RouteStats) Add(r Route) { + stats.Total++ + mon := r.HealthMonitor() + if mon == nil { + stats.NumUnknown++ + return + } + switch mon.Status() { + case StatusHealthy: + stats.NumHealthy++ + case StatusUnhealthy: + stats.NumUnhealthy++ + case StatusNapping: + stats.NumNapping++ + case StatusError: + stats.NumError++ + default: + stats.NumUnknown++ + } +} + +func (stats *RouteStats) AddOther(other RouteStats) { + stats.Total += other.Total + stats.NumHealthy += other.NumHealthy + stats.NumUnhealthy += other.NumUnhealthy + stats.NumNapping += other.NumNapping + stats.NumError += other.NumError + stats.NumUnknown += other.NumUnknown +} diff --git a/internal/utils/go.mod b/internal/utils/go.mod index 5975328a..8e130e05 100644 --- a/internal/utils/go.mod +++ b/internal/utils/go.mod @@ -1,6 +1,6 @@ module github.com/yusing/go-proxy/internal/utils -go 1.24.5 +go 1.25.0 require ( github.com/goccy/go-yaml v1.18.0 @@ -8,7 +8,7 @@ require ( github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.10.0 go.uber.org/atomic v1.11.0 - golang.org/x/text v0.27.0 + golang.org/x/text v0.28.0 ) require ( @@ -16,6 +16,6 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/sys v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/utils/go.sum b/internal/utils/go.sum index 320267db..6f02f6e0 100644 --- a/internal/utils/go.sum +++ b/internal/utils/go.sum @@ -26,10 +26,10 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/watcher/health/json.go b/internal/watcher/health/json.go deleted file mode 100644 index b8b10dea..00000000 --- a/internal/watcher/health/json.go +++ /dev/null @@ -1,49 +0,0 @@ -package health - -import ( - "encoding/json" - "net/url" - "strconv" - "time" - - "github.com/yusing/go-proxy/internal/utils/strutils" -) - -type JSONRepresentation struct { - Name string - Config *HealthCheckConfig - Status Status - Started time.Time - Uptime time.Duration - Latency time.Duration - LastSeen time.Time - Detail string - URL *url.URL - Extra map[string]any -} - -func (jsonRepr *JSONRepresentation) MarshalJSON() ([]byte, error) { - var url string - if jsonRepr.URL != nil { - url = jsonRepr.URL.String() - } - if url == "http://:0" { - url = "" - } - return json.Marshal(map[string]any{ - "name": jsonRepr.Name, - "config": jsonRepr.Config, - "started": jsonRepr.Started.Unix(), - "startedStr": strutils.FormatTime(jsonRepr.Started), - "status": jsonRepr.Status.String(), - "uptime": jsonRepr.Uptime.Seconds(), - "uptimeStr": strutils.FormatDuration(jsonRepr.Uptime), - "latency": jsonRepr.Latency.Seconds(), - "latencyStr": strconv.Itoa(int(jsonRepr.Latency.Milliseconds())) + " ms", - "lastSeen": jsonRepr.LastSeen.Unix(), - "lastSeenStr": strutils.FormatLastSeen(jsonRepr.LastSeen), - "detail": jsonRepr.Detail, - "url": url, - "extra": jsonRepr.Extra, - }) -} diff --git a/internal/watcher/health/monitor/agent_proxied.go b/internal/watcher/health/monitor/agent_proxied.go index d28fbcb4..e8eb467b 100644 --- a/internal/watcher/health/monitor/agent_proxied.go +++ b/internal/watcher/health/monitor/agent_proxied.go @@ -8,7 +8,7 @@ import ( "time" agentPkg "github.com/yusing/go-proxy/agent/pkg/agent" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) type ( @@ -48,7 +48,7 @@ func (target *AgentCheckHealthTarget) displayURL() *url.URL { } } -func NewAgentProxiedMonitor(agent *agentPkg.AgentConfig, config *health.HealthCheckConfig, target *AgentCheckHealthTarget) *AgentProxiedMonitor { +func NewAgentProxiedMonitor(agent *agentPkg.AgentConfig, config *types.HealthCheckConfig, target *AgentCheckHealthTarget) *AgentProxiedMonitor { mon := &AgentProxiedMonitor{ agent: agent, endpointURL: agentPkg.EndpointHealth + "?" + target.buildQuery(), @@ -57,9 +57,9 @@ func NewAgentProxiedMonitor(agent *agentPkg.AgentConfig, config *health.HealthCh return mon } -func (mon *AgentProxiedMonitor) CheckHealth() (result *health.HealthCheckResult, err error) { +func (mon *AgentProxiedMonitor) CheckHealth() (result *types.HealthCheckResult, err error) { startTime := time.Now() - result = new(health.HealthCheckResult) + result = new(types.HealthCheckResult) ctx, cancel := mon.ContextWithTimeout("timeout querying agent") defer cancel() data, status, err := mon.agent.Fetch(ctx, mon.endpointURL) diff --git a/internal/watcher/health/monitor/docker.go b/internal/watcher/health/monitor/docker.go index 5bf71e38..3483bbf5 100644 --- a/internal/watcher/health/monitor/docker.go +++ b/internal/watcher/health/monitor/docker.go @@ -3,18 +3,17 @@ package monitor import ( "github.com/docker/docker/api/types/container" "github.com/yusing/go-proxy/internal/docker" - - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) type DockerHealthMonitor struct { *monitor client *docker.SharedClient containerID string - fallback health.HealthChecker + fallback types.HealthChecker } -func NewDockerHealthMonitor(client *docker.SharedClient, containerID, alias string, config *health.HealthCheckConfig, fallback health.HealthChecker) *DockerHealthMonitor { +func NewDockerHealthMonitor(client *docker.SharedClient, containerID, alias string, config *types.HealthCheckConfig, fallback types.HealthChecker) *DockerHealthMonitor { mon := new(DockerHealthMonitor) mon.client = client mon.containerID = containerID @@ -24,7 +23,7 @@ func NewDockerHealthMonitor(client *docker.SharedClient, containerID, alias stri return mon } -func (mon *DockerHealthMonitor) CheckHealth() (result *health.HealthCheckResult, err error) { +func (mon *DockerHealthMonitor) CheckHealth() (result *types.HealthCheckResult, err error) { ctx, cancel := mon.ContextWithTimeout("docker health check timed out") defer cancel() cont, err := mon.client.ContainerInspect(ctx, mon.containerID) @@ -34,12 +33,12 @@ func (mon *DockerHealthMonitor) CheckHealth() (result *health.HealthCheckResult, status := cont.State.Status switch status { case "dead", "exited", "paused", "restarting", "removing": - return &health.HealthCheckResult{ + return &types.HealthCheckResult{ Healthy: false, Detail: "container is " + status, }, nil case "created": - return &health.HealthCheckResult{ + return &types.HealthCheckResult{ Healthy: false, Detail: "container is not started", }, nil @@ -47,7 +46,7 @@ func (mon *DockerHealthMonitor) CheckHealth() (result *health.HealthCheckResult, if cont.State.Health == nil { return mon.fallback.CheckHealth() } - result = new(health.HealthCheckResult) + result = new(types.HealthCheckResult) result.Healthy = cont.State.Health.Status == container.Healthy if len(cont.State.Health.Log) > 0 { lastLog := cont.State.Health.Log[len(cont.State.Health.Log)-1] diff --git a/internal/watcher/health/monitor/fileserver.go b/internal/watcher/health/monitor/fileserver.go index 77ab415f..9f9f7696 100644 --- a/internal/watcher/health/monitor/fileserver.go +++ b/internal/watcher/health/monitor/fileserver.go @@ -4,7 +4,7 @@ import ( "os" "time" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) type FileServerHealthMonitor struct { @@ -12,24 +12,24 @@ type FileServerHealthMonitor struct { path string } -func NewFileServerHealthMonitor(config *health.HealthCheckConfig, path string) *FileServerHealthMonitor { +func NewFileServerHealthMonitor(config *types.HealthCheckConfig, path string) *FileServerHealthMonitor { mon := &FileServerHealthMonitor{path: path} mon.monitor = newMonitor(nil, config, mon.CheckHealth) return mon } -func (s *FileServerHealthMonitor) CheckHealth() (*health.HealthCheckResult, error) { +func (s *FileServerHealthMonitor) CheckHealth() (*types.HealthCheckResult, error) { start := time.Now() _, err := os.Stat(s.path) if err != nil { if os.IsNotExist(err) { - return &health.HealthCheckResult{ + return &types.HealthCheckResult{ Detail: err.Error(), }, nil } return nil, err } - return &health.HealthCheckResult{ + return &types.HealthCheckResult{ Healthy: true, Latency: time.Since(start), }, nil diff --git a/internal/watcher/health/monitor/http.go b/internal/watcher/health/monitor/http.go index d7588c1b..0e9517ad 100644 --- a/internal/watcher/health/monitor/http.go +++ b/internal/watcher/health/monitor/http.go @@ -7,7 +7,7 @@ import ( "net/url" "time" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/pkg" ) @@ -26,7 +26,7 @@ var pinger = &http.Client{ }, } -func NewHTTPHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *HTTPHealthMonitor { +func NewHTTPHealthMonitor(url *url.URL, config *types.HealthCheckConfig) *HTTPHealthMonitor { mon := new(HTTPHealthMonitor) mon.monitor = newMonitor(url, config, mon.CheckHealth) if config.UseGet { @@ -37,7 +37,7 @@ func NewHTTPHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *HTTPH return mon } -func (mon *HTTPHealthMonitor) CheckHealth() (*health.HealthCheckResult, error) { +func (mon *HTTPHealthMonitor) CheckHealth() (*types.HealthCheckResult, error) { ctx, cancel := mon.ContextWithTimeout("ping request timed out") defer cancel() @@ -67,19 +67,19 @@ func (mon *HTTPHealthMonitor) CheckHealth() (*health.HealthCheckResult, error) { // treat tls error as healthy var tlsErr *tls.CertificateVerificationError if ok := errors.As(respErr, &tlsErr); !ok { - return &health.HealthCheckResult{ + return &types.HealthCheckResult{ Latency: lat, Detail: respErr.Error(), }, nil } case resp.StatusCode == http.StatusServiceUnavailable: - return &health.HealthCheckResult{ + return &types.HealthCheckResult{ Latency: lat, Detail: resp.Status, }, nil } - return &health.HealthCheckResult{ + return &types.HealthCheckResult{ Latency: lat, Healthy: true, }, nil diff --git a/internal/watcher/health/monitor/monitor.go b/internal/watcher/health/monitor/monitor.go index de47e262..c1679b4c 100644 --- a/internal/watcher/health/monitor/monitor.go +++ b/internal/watcher/health/monitor/monitor.go @@ -13,22 +13,21 @@ import ( "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/notif" - "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/types" "github.com/yusing/go-proxy/internal/utils/atomic" "github.com/yusing/go-proxy/internal/utils/strutils" - "github.com/yusing/go-proxy/internal/watcher/health" ) type ( - HealthCheckFunc func() (result *health.HealthCheckResult, err error) + HealthCheckFunc func() (result *types.HealthCheckResult, err error) monitor struct { service string - config *health.HealthCheckConfig + config *types.HealthCheckConfig url atomic.Value[*url.URL] - status atomic.Value[health.Status] - lastResult atomic.Value[*health.HealthCheckResult] + status atomic.Value[types.HealthStatus] + lastResult atomic.Value[*types.HealthCheckResult] checkHealth HealthCheckFunc startTime time.Time @@ -45,15 +44,15 @@ type ( var ErrNegativeInterval = gperr.New("negative interval") -func NewMonitor(r routes.Route) health.HealthMonCheck { - var mon health.HealthMonCheck +func NewMonitor(r types.Route) types.HealthMonCheck { + var mon types.HealthMonCheck if r.IsAgent() { mon = NewAgentProxiedMonitor(r.GetAgent(), r.HealthCheckConfig(), AgentTargetFromURL(&r.TargetURL().URL)) } else { switch r := r.(type) { - case routes.HTTPRoute: + case types.HTTPRoute: mon = NewHTTPHealthMonitor(&r.TargetURL().URL, r.HealthCheckConfig()) - case routes.StreamRoute: + case types.StreamRoute: mon = NewRawHealthMonitor(&r.TargetURL().URL, r.HealthCheckConfig()) default: log.Panic().Msgf("unexpected route type: %T", r) @@ -71,7 +70,7 @@ func NewMonitor(r routes.Route) health.HealthMonCheck { return mon } -func newMonitor(u *url.URL, config *health.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor { +func newMonitor(u *url.URL, config *types.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor { if config.Retries == 0 { config.Retries = int64(common.HealthCheckDownNotifyDelayDefault / config.Interval) } @@ -85,13 +84,13 @@ func newMonitor(u *url.URL, config *health.HealthCheckConfig, healthCheckFunc He u = &url.URL{} } mon.url.Store(u) - mon.status.Store(health.StatusHealthy) + mon.status.Store(types.StatusHealthy) port := u.Port() mon.isZeroPort = port == "" || port == "0" if mon.isZeroPort { - mon.status.Store(health.StatusUnknown) - mon.lastResult.Store(&health.HealthCheckResult{Healthy: false, Detail: "no port detected"}) + mon.status.Store(types.StatusUnknown) + mon.lastResult.Store(&types.HealthCheckResult{Healthy: false, Detail: "no port detected"}) } return mon } @@ -125,8 +124,8 @@ func (mon *monitor) Start(parent task.Parent) gperr.Error { logger := log.With().Str("name", mon.service).Logger() defer func() { - if mon.status.Load() != health.StatusError { - mon.status.Store(health.StatusUnhealthy) + if mon.status.Load() != types.StatusError { + mon.status.Store(types.StatusUnhealthy) } mon.task.Finish(nil) }() @@ -154,7 +153,7 @@ func (mon *monitor) Start(parent task.Parent) gperr.Error { failures = 0 } if failures >= 5 { - mon.status.Store(health.StatusError) + mon.status.Store(types.StatusError) mon.task.Finish(err) logger.Error().Msg("healthchecker stopped after 5 trials") return @@ -186,12 +185,12 @@ func (mon *monitor) URL() *url.URL { } // Config implements HealthChecker. -func (mon *monitor) Config() *health.HealthCheckConfig { +func (mon *monitor) Config() *types.HealthCheckConfig { return mon.config } // Status implements HealthMonitor. -func (mon *monitor) Status() health.Status { +func (mon *monitor) Status() types.HealthStatus { return mon.status.Load() } @@ -229,7 +228,7 @@ func (mon *monitor) String() string { return mon.Name() } -var resHealthy = health.HealthCheckResult{Healthy: true} +var resHealthy = types.HealthCheckResult{Healthy: true} // MarshalJSON implements health.HealthMonitor. func (mon *monitor) MarshalJSON() ([]byte, error) { @@ -238,7 +237,7 @@ func (mon *monitor) MarshalJSON() ([]byte, error) { res = &resHealthy } - return (&health.JSONRepresentation{ + return (&types.HealthJSONRepr{ Name: mon.service, Config: mon.config, Status: mon.status.Load(), @@ -255,21 +254,21 @@ func (mon *monitor) checkUpdateHealth() error { logger := log.With().Str("name", mon.Name()).Logger() result, err := mon.checkHealth() - var lastStatus health.Status + var lastStatus types.HealthStatus switch { case err != nil: - result = &health.HealthCheckResult{Healthy: false, Detail: err.Error()} - lastStatus = mon.status.Swap(health.StatusError) + result = &types.HealthCheckResult{Healthy: false, Detail: err.Error()} + lastStatus = mon.status.Swap(types.StatusError) case result.Healthy: - lastStatus = mon.status.Swap(health.StatusHealthy) + lastStatus = mon.status.Swap(types.StatusHealthy) UpdateLastSeen(mon.service) default: - lastStatus = mon.status.Swap(health.StatusUnhealthy) + lastStatus = mon.status.Swap(types.StatusUnhealthy) } mon.lastResult.Store(result) // change of status - if result.Healthy != (lastStatus == health.StatusHealthy) { + if result.Healthy != (lastStatus == types.StatusHealthy) { if result.Healthy { mon.notifyServiceUp(&logger, result) mon.numConsecFailures.Store(0) @@ -293,7 +292,7 @@ func (mon *monitor) checkUpdateHealth() error { return err } -func (mon *monitor) notifyServiceUp(logger *zerolog.Logger, result *health.HealthCheckResult) { +func (mon *monitor) notifyServiceUp(logger *zerolog.Logger, result *types.HealthCheckResult) { logger.Info().Msg("service is up") extras := mon.buildNotificationExtras(result) extras.Add("Ping", fmt.Sprintf("%d ms", result.Latency.Milliseconds())) @@ -305,7 +304,7 @@ func (mon *monitor) notifyServiceUp(logger *zerolog.Logger, result *health.Healt }) } -func (mon *monitor) notifyServiceDown(logger *zerolog.Logger, result *health.HealthCheckResult) { +func (mon *monitor) notifyServiceDown(logger *zerolog.Logger, result *types.HealthCheckResult) { logger.Warn().Msg("service went down") extras := mon.buildNotificationExtras(result) extras.Add("Last Seen", strutils.FormatLastSeen(GetLastSeen(mon.service))) @@ -317,7 +316,7 @@ func (mon *monitor) notifyServiceDown(logger *zerolog.Logger, result *health.Hea }) } -func (mon *monitor) buildNotificationExtras(result *health.HealthCheckResult) notif.FieldsBody { +func (mon *monitor) buildNotificationExtras(result *types.HealthCheckResult) notif.FieldsBody { extras := notif.FieldsBody{ {Name: "Service Name", Value: mon.service}, {Name: "Time", Value: strutils.FormatTime(time.Now())}, diff --git a/internal/watcher/health/monitor/monitor_test.go b/internal/watcher/health/monitor/monitor_test.go index db63791d..60c7ceb9 100644 --- a/internal/watcher/health/monitor/monitor_test.go +++ b/internal/watcher/health/monitor/monitor_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/yusing/go-proxy/internal/notif" "github.com/yusing/go-proxy/internal/task" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) // Test notification tracker @@ -28,7 +28,7 @@ func (t *testNotificationTracker) getStats() (up, down int, last string) { } // Create test monitor with mock health checker - returns both monitor and tracker -func createTestMonitor(config *health.HealthCheckConfig, checkFunc HealthCheckFunc) (*monitor, *testNotificationTracker) { +func createTestMonitor(config *types.HealthCheckConfig, checkFunc HealthCheckFunc) (*monitor, *testNotificationTracker) { testURL, _ := url.Parse("http://localhost:8080") mon := newMonitor(testURL, config, checkFunc) @@ -56,14 +56,14 @@ func createTestMonitor(config *health.HealthCheckConfig, checkFunc HealthCheckFu } func TestNotification_ImmediateNotifyAfterZero(t *testing.T) { - config := &health.HealthCheckConfig{ + config := &types.HealthCheckConfig{ Interval: 100 * time.Millisecond, Timeout: 50 * time.Millisecond, Retries: -1, // Immediate notification } - mon, tracker := createTestMonitor(config, func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: true}, nil + mon, tracker := createTestMonitor(config, func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: true}, nil }) // Start with healthy service @@ -72,8 +72,8 @@ func TestNotification_ImmediateNotifyAfterZero(t *testing.T) { require.True(t, result.Healthy) // Set to unhealthy - mon.checkHealth = func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: false}, nil + mon.checkHealth = func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: false}, nil } // Simulate status change detection @@ -81,7 +81,7 @@ func TestNotification_ImmediateNotifyAfterZero(t *testing.T) { require.NoError(t, err) // With NotifyAfter=0, notification should happen immediately - require.Equal(t, health.StatusUnhealthy, mon.Status()) + require.Equal(t, types.StatusUnhealthy, mon.Status()) // Check notification counts - should have 1 down notification up, down, last := tracker.getStats() @@ -91,22 +91,22 @@ func TestNotification_ImmediateNotifyAfterZero(t *testing.T) { } func TestNotification_WithNotifyAfterThreshold(t *testing.T) { - config := &health.HealthCheckConfig{ + config := &types.HealthCheckConfig{ Interval: 50 * time.Millisecond, Timeout: 50 * time.Millisecond, Retries: 2, // Notify after 2 consecutive failures } - mon, tracker := createTestMonitor(config, func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: true}, nil + mon, tracker := createTestMonitor(config, func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: true}, nil }) // Start healthy - mon.status.Store(health.StatusHealthy) + mon.status.Store(types.StatusHealthy) // Set to unhealthy - mon.checkHealth = func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: false}, nil + mon.checkHealth = func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: false}, nil } // First failure - should not notify yet @@ -130,22 +130,22 @@ func TestNotification_WithNotifyAfterThreshold(t *testing.T) { } func TestNotification_ServiceRecoversBeforeThreshold(t *testing.T) { - config := &health.HealthCheckConfig{ + config := &types.HealthCheckConfig{ Interval: 100 * time.Millisecond, Timeout: 50 * time.Millisecond, Retries: 3, // Notify after 3 consecutive failures } - mon, tracker := createTestMonitor(config, func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: true}, nil + mon, tracker := createTestMonitor(config, func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: true}, nil }) // Start healthy - mon.status.Store(health.StatusHealthy) + mon.status.Store(types.StatusHealthy) // Set to unhealthy - mon.checkHealth = func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: false}, nil + mon.checkHealth = func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: false}, nil } // First failure @@ -162,8 +162,8 @@ func TestNotification_ServiceRecoversBeforeThreshold(t *testing.T) { require.Equal(t, 0, up) // Service recovers before third failure - mon.checkHealth = func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: true}, nil + mon.checkHealth = func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: true}, nil } // Health check with recovery @@ -179,22 +179,22 @@ func TestNotification_ServiceRecoversBeforeThreshold(t *testing.T) { } func TestNotification_ConsecutiveFailureReset(t *testing.T) { - config := &health.HealthCheckConfig{ + config := &types.HealthCheckConfig{ Interval: 100 * time.Millisecond, Timeout: 50 * time.Millisecond, Retries: 2, // Notify after 2 consecutive failures } - mon, tracker := createTestMonitor(config, func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: true}, nil + mon, tracker := createTestMonitor(config, func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: true}, nil }) // Start healthy - mon.status.Store(health.StatusHealthy) + mon.status.Store(types.StatusHealthy) // Set to unhealthy - mon.checkHealth = func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: false}, nil + mon.checkHealth = func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: false}, nil } // First failure @@ -202,8 +202,8 @@ func TestNotification_ConsecutiveFailureReset(t *testing.T) { require.NoError(t, err) // Recover briefly - mon.checkHealth = func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: true}, nil + mon.checkHealth = func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: true}, nil } err = mon.checkUpdateHealth() @@ -215,8 +215,8 @@ func TestNotification_ConsecutiveFailureReset(t *testing.T) { require.Equal(t, 1, up) // Go down again - consecutive counter should start from 0 - mon.checkHealth = func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: false}, nil + mon.checkHealth = func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: false}, nil } // First failure after recovery @@ -240,14 +240,14 @@ func TestNotification_ConsecutiveFailureReset(t *testing.T) { } func TestNotification_ContextCancellation(t *testing.T) { - config := &health.HealthCheckConfig{ + config := &types.HealthCheckConfig{ Interval: 100 * time.Millisecond, Timeout: 50 * time.Millisecond, Retries: 1, } - mon, tracker := createTestMonitor(config, func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: true}, nil + mon, tracker := createTestMonitor(config, func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: true}, nil }) // Create a task that we can cancel @@ -255,9 +255,9 @@ func TestNotification_ContextCancellation(t *testing.T) { mon.task = rootTask.Subtask("monitor", true) // Start healthy, then go unhealthy - mon.status.Store(health.StatusHealthy) - mon.checkHealth = func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: false}, nil + mon.status.Store(types.StatusHealthy) + mon.checkHealth = func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: false}, nil } // Trigger notification @@ -279,22 +279,22 @@ func TestNotification_ContextCancellation(t *testing.T) { } func TestImmediateUpNotification(t *testing.T) { - config := &health.HealthCheckConfig{ + config := &types.HealthCheckConfig{ Interval: 100 * time.Millisecond, Timeout: 50 * time.Millisecond, Retries: 2, // NotifyAfter should not affect up notifications } - mon, tracker := createTestMonitor(config, func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: false}, nil + mon, tracker := createTestMonitor(config, func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: false}, nil }) // Start unhealthy - mon.status.Store(health.StatusUnhealthy) + mon.status.Store(types.StatusUnhealthy) // Set to healthy - mon.checkHealth = func() (*health.HealthCheckResult, error) { - return &health.HealthCheckResult{Healthy: true, Latency: 50 * time.Millisecond}, nil + mon.checkHealth = func() (*types.HealthCheckResult, error) { + return &types.HealthCheckResult{Healthy: true, Latency: 50 * time.Millisecond}, nil } // Trigger health check @@ -302,7 +302,7 @@ func TestImmediateUpNotification(t *testing.T) { require.NoError(t, err) // Up notification should happen immediately regardless of NotifyAfter setting - require.Equal(t, health.StatusHealthy, mon.Status()) + require.Equal(t, types.StatusHealthy, mon.Status()) // Should have exactly 1 up notification immediately up, down, last := tracker.getStats() diff --git a/internal/watcher/health/monitor/raw.go b/internal/watcher/health/monitor/raw.go index 1d12e1af..67fd01cd 100644 --- a/internal/watcher/health/monitor/raw.go +++ b/internal/watcher/health/monitor/raw.go @@ -5,7 +5,7 @@ import ( "net/url" "time" - "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/types" ) type ( @@ -15,7 +15,7 @@ type ( } ) -func NewRawHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *RawHealthMonitor { +func NewRawHealthMonitor(url *url.URL, config *types.HealthCheckConfig) *RawHealthMonitor { mon := new(RawHealthMonitor) mon.monitor = newMonitor(url, config, mon.CheckHealth) mon.dialer = &net.Dialer{ @@ -25,7 +25,7 @@ func NewRawHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *RawHea return mon } -func (mon *RawHealthMonitor) CheckHealth() (*health.HealthCheckResult, error) { +func (mon *RawHealthMonitor) CheckHealth() (*types.HealthCheckResult, error) { ctx, cancel := mon.ContextWithTimeout("ping request timed out") defer cancel() @@ -36,7 +36,7 @@ func (mon *RawHealthMonitor) CheckHealth() (*health.HealthCheckResult, error) { return nil, err } defer conn.Close() - return &health.HealthCheckResult{ + return &types.HealthCheckResult{ Latency: time.Since(start), Healthy: true, }, nil diff --git a/internal/watcher/health/status.go b/internal/watcher/health/status.go deleted file mode 100644 index f843bcd5..00000000 --- a/internal/watcher/health/status.go +++ /dev/null @@ -1,63 +0,0 @@ -package health - -type Status uint8 - -const ( - StatusUnknown Status = 0 - StatusHealthy Status = (1 << iota) - StatusNapping - StatusStarting - StatusUnhealthy - StatusError - - NumStatuses int = iota - 1 - - HealthyMask = StatusHealthy | StatusNapping | StatusStarting - IdlingMask = StatusNapping | StatusStarting -) - -func NewStatus(s string) Status { - switch s { - case "healthy": - return StatusHealthy - case "unhealthy": - return StatusUnhealthy - case "napping": - return StatusNapping - case "starting": - return StatusStarting - case "error": - return StatusError - default: - return StatusUnknown - } -} - -func (s Status) String() string { - switch s { - case StatusHealthy: - return "healthy" - case StatusUnhealthy: - return "unhealthy" - case StatusNapping: - return "napping" - case StatusStarting: - return "starting" - case StatusError: - return "error" - default: - return "unknown" - } -} - -func (s Status) Good() bool { - return s&HealthyMask != 0 -} - -func (s Status) Bad() bool { - return s&HealthyMask == 0 -} - -func (s Status) Idling() bool { - return s&IdlingMask != 0 -} diff --git a/internal/watcher/health/types.go b/internal/watcher/health/types.go deleted file mode 100644 index 0d245cd1..00000000 --- a/internal/watcher/health/types.go +++ /dev/null @@ -1,42 +0,0 @@ -package health - -import ( - "encoding/json" - "fmt" - "net/url" - "time" - - "github.com/yusing/go-proxy/internal/task" -) - -type ( - HealthCheckResult struct { - Healthy bool `json:"healthy"` - Detail string `json:"detail"` - Latency time.Duration `json:"latency"` - } - WithHealthInfo interface { - Status() Status - Uptime() time.Duration - Latency() time.Duration - Detail() string - } - HealthMonitor interface { - task.TaskStarter - task.TaskFinisher - fmt.Stringer - WithHealthInfo - Name() string - json.Marshaler - } - HealthChecker interface { - CheckHealth() (result *HealthCheckResult, err error) - URL() *url.URL - Config() *HealthCheckConfig - UpdateURL(url *url.URL) - } - HealthMonCheck interface { - HealthMonitor - HealthChecker - } -) diff --git a/pkg/version.go b/pkg/version.go index c037f0ef..6f80b9a5 100644 --- a/pkg/version.go +++ b/pkg/version.go @@ -2,7 +2,6 @@ package pkg import ( "fmt" - "net/http" "regexp" "strconv" "strings" @@ -16,12 +15,6 @@ func GetLastVersion() Version { return lastVersion } -func GetVersionHTTPHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, GetVersion().String()) - } -} - func init() { currentVersion = ParseVersion(version) diff --git a/scripts/fix-swagger-json.py b/scripts/fix-swagger-json.py new file mode 100644 index 00000000..7faf96a7 --- /dev/null +++ b/scripts/fix-swagger-json.py @@ -0,0 +1,42 @@ +# This script aims to fix the swagger.json file by setting the x-nullable flag to False if not present for all objects and arrays. +# This prevents from generating optional (undefined) fields in the generated API client. +import json + +path = "internal/api/v1/docs/swagger.json" + +with open(path, "r") as f: + data = json.load(f) + +def set_non_nullable(data): + if not isinstance(data, dict): + return + if "x-nullable" not in data: + data["x-nullable"] = False + if "x-omitempty" not in data and data["x-nullable"] == False: + data["x-omitempty"] = False + if "type" not in data: + return + if data["type"] == "object" and "properties" in data: + for v in data["properties"].values(): + set_non_nullable(v) + if data["type"] == "array": + for v in data["items"]: + set_non_nullable(v) + +def set_operation_id(data): + if isinstance(data, dict): + if "x-id" in data: + data["operationId"] = data["x-id"] + return + for v in data.values(): + set_operation_id(v) + +for key, value in data.items(): + if key == "definitions": + for k, v in value.items(): + set_non_nullable(v) + else: + set_operation_id(value) + +with open(path, "w") as f: + json.dump(data, f, indent=2) \ No newline at end of file