refactor(api): restructured API for type safety, maintainability and docs generation

- These changes makes the API incombatible with previous versions
- Added new types for error handling, success responses, and health checks.
- Updated health check logic to utilize the new types for better clarity and structure.
- Refactored existing handlers to improve response consistency and error handling.
- Updated Makefile to include a new target for generating API types from Swagger.
- Updated "new agent" API to respond an encrypted cert pair
This commit is contained in:
yusing
2025-08-16 13:04:05 +08:00
parent fce9ce21c9
commit 35a3e3fef6
149 changed files with 13173 additions and 2173 deletions

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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]
}

View File

@@ -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"`
}

View File

@@ -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,
}
}

View File

@@ -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())
}

View File

@@ -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),
})
}

View File

@@ -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())
}
}

View File

@@ -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)))
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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()
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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")
}
}

3377
internal/api/v1/docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

100
internal/api/v1/favicon.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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"))
}

View File

@@ -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)
}

View File

@@ -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())
}
}

View File

@@ -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())
}

View File

@@ -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))
}

View File

@@ -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, &params); err != nil {
c.Error(apitypes.InternalServerError(err, "failed to unmarshal data"))
return
}
overrides.OverrideItem(params.Which, &params.Value)
case HomepageOverrideItemsBatch:
var params HomepageOverrideItemsBatchParams
if err := json.Unmarshal(data, &params); 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, &params); 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, &params); 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"))
}

View File

@@ -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, &params); err != nil {
gphttp.ClientError(w, r, err, http.StatusBadRequest)
return
}
overrides.OverrideItem(params.Which, &params.Value)
case HomepageOverrideItemsBatch:
var params HomepageOverrideItemsBatchParams
if err := json.Unmarshal(data, &params); 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, &params); 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, &params); 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)
}

37
internal/api/v1/icons.go Normal file
View File

@@ -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)
}

View File

@@ -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"))
}

View File

@@ -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())
}
}

View File

@@ -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)
}

View File

@@ -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())
}

View File

@@ -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")))
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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())
}
}

View File

@@ -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)
}

View File

@@ -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())
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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"))
}

View File

@@ -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())
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -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
})
}

View File

@@ -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)),
}
}

View File

@@ -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)
}
}

View File

@@ -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())
}