From 03d1483f9ff11a087765fc514d44cf288a71e107 Mon Sep 17 00:00:00 2001 From: pycook Date: Wed, 30 Jul 2025 14:22:43 +0800 Subject: [PATCH] feat(backend): web proxy --- backend/go.mod | 16 +- backend/go.sum | 21 + backend/internal/api/controller/asset.go | 11 +- .../internal/api/controller/authorization.go | 5 - backend/internal/api/controller/base.go | 8 +- backend/internal/api/controller/web_proxy.go | 752 ++++++++++++++++++ backend/internal/api/docs/docs.go | 206 ++++- backend/internal/api/docs/swagger.json | 206 ++++- backend/internal/api/docs/swagger.yaml | 137 +++- backend/internal/api/router/router.go | 34 +- backend/internal/connector/handlers.go | 23 + backend/internal/connector/protocols/web.go | 71 ++ backend/internal/model/asset.go | 92 +++ backend/internal/service/web_auth.go | 497 ++++++++++++ backend/internal/session/session.go | 24 + 15 files changed, 2059 insertions(+), 44 deletions(-) create mode 100644 backend/internal/api/controller/web_proxy.go create mode 100644 backend/internal/connector/protocols/web.go create mode 100644 backend/internal/service/web_auth.go diff --git a/backend/go.mod b/backend/go.mod index 636576e..59f8e38 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,8 @@ module github.com/veops/oneterm -go 1.21.3 +go 1.23.0 + +toolchain go1.24.1 require ( github.com/Azure/azure-storage-blob-go v0.15.0 @@ -37,9 +39,9 @@ require ( github.com/swaggo/swag v1.16.3 github.com/tencentyun/cos-go-sdk-v5 v0.7.55 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.26.0 - golang.org/x/sync v0.8.0 - golang.org/x/text v0.17.0 + golang.org/x/crypto v0.37.0 + golang.org/x/sync v0.13.0 + golang.org/x/text v0.24.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.11 @@ -51,6 +53,8 @@ require github.com/stretchr/testify v1.9.0 require ( github.com/Azure/azure-pipeline-go v0.2.3 // indirect + github.com/PuerkitoBio/goquery v1.10.3 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.1.4 // indirect @@ -133,8 +137,8 @@ require ( go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sys v0.24.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 6c88659..a8ce540 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -17,6 +17,8 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0 github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= @@ -24,6 +26,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -312,6 +316,9 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -337,6 +344,9 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -345,6 +355,9 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -366,6 +379,9 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -378,6 +394,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -392,6 +410,9 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/backend/internal/api/controller/asset.go b/backend/internal/api/controller/asset.go index 93ad5db..d36a2ca 100644 --- a/backend/internal/api/controller/asset.go +++ b/backend/internal/api/controller/asset.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/samber/lo" + "github.com/veops/oneterm/internal/acl" "github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/service" "github.com/veops/oneterm/pkg/config" @@ -41,6 +42,14 @@ var ( return } }, + // Filter web_config based on write permissions + func(ctx *gin.Context, data []*model.Asset) { + for _, asset := range data { + if asset.Permissions == nil || !lo.Contains(asset.Permissions, acl.WRITE) { + asset.WebConfig = nil + } + } + }, } ) @@ -115,7 +124,7 @@ func (c *Controller) GetAssets(ctx *gin.Context) { // Apply info mode settings if info { - db = db.Select("id", "parent_id", "name", "ip", "protocols", "connectable", "authorization", "resource_id", "access_time_control", "asset_command_control") + db = db.Select("id", "parent_id", "name", "ip", "protocols", "connectable", "authorization", "resource_id", "access_time_control", "asset_command_control", "web_config") } doGet(ctx, false, db, config.RESOURCE_ASSET, assetPostHooks...) diff --git a/backend/internal/api/controller/authorization.go b/backend/internal/api/controller/authorization.go index 261e036..97db694 100644 --- a/backend/internal/api/controller/authorization.go +++ b/backend/internal/api/controller/authorization.go @@ -137,11 +137,6 @@ func (c *Controller) GetAuthorizations(ctx *gin.Context) { }) } -func handleAuthorization(ctx *gin.Context, tx *gorm.DB, action int, asset *model.Asset, auths ...*model.Authorization) (err error) { - // Use service layer instead of direct data processing - return service.DefaultAuthService.HandleAuthorization(ctx, tx, action, asset, auths...) -} - func getIdsByAuthorizationIds(ctx *gin.Context) (nodeIds, assetIds, accountIds []int) { authorizationIds, ok := ctx.Value(kAuthorizationIds).([]*model.AuthorizationIds) if !ok || len(authorizationIds) == 0 { diff --git a/backend/internal/api/controller/base.go b/backend/internal/api/controller/base.go index 9721030..fabc36b 100644 --- a/backend/internal/api/controller/base.go +++ b/backend/internal/api/controller/base.go @@ -372,6 +372,10 @@ func doGet[T any](ctx *gin.Context, needAcl bool, dbFind *gorm.DB, resourceType return } + if err = handlePermissions(ctx, list, resourceType); err != nil { + return + } + for _, hook := range postHooks { if hook == nil { continue @@ -382,10 +386,6 @@ func doGet[T any](ctx *gin.Context, needAcl bool, dbFind *gorm.DB, resourceType } } - if err = handlePermissions(ctx, list, resourceType); err != nil { - return - } - res := &ListData{ Count: count, List: lo.Map(list, func(t T, _ int) any { return t }), diff --git a/backend/internal/api/controller/web_proxy.go b/backend/internal/api/controller/web_proxy.go new file mode 100644 index 0000000..5166019 --- /dev/null +++ b/backend/internal/api/controller/web_proxy.go @@ -0,0 +1,752 @@ +package controller + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/samber/lo" + "github.com/veops/oneterm/internal/model" + "github.com/veops/oneterm/internal/service" + "github.com/veops/oneterm/pkg/logger" +) + +type WebProxyController struct{} + +func NewWebProxyController() *WebProxyController { + return &WebProxyController{} +} + +type StartWebSessionRequest struct { + AssetId int `json:"asset_id" binding:"required"` + AssetName string `json:"asset_name"` + AuthMode string `json:"auth_mode"` + AccountId int `json:"account_id"` +} + +type StartWebSessionResponse struct { + SessionId string `json:"session_id"` + ProxyURL string `json:"proxy_url"` + Message string `json:"message"` +} + +var webProxySessions = make(map[string]*WebProxySession) + +type WebProxySession struct { + SessionId string + AssetId int + Asset *model.Asset + CreatedAt time.Time + LastActivity time.Time + CurrentHost string +} + +func cleanupExpiredSessions(maxInactiveTime time.Duration) { + now := time.Now() + for sessionID, session := range webProxySessions { + if now.Sub(session.LastActivity) > maxInactiveTime { + delete(webProxySessions, sessionID) + logger.L().Info("Cleaned up expired web session", + zap.String("sessionID", sessionID)) + } + } +} + +func StartSessionCleanupRoutine() { + ticker := time.NewTicker(10 * time.Minute) + go func() { + for range ticker.C { + cleanupExpiredSessions(8 * time.Hour) + } + }() +} + +func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason string) { + html := fmt.Sprintf(` + + + Session Expired - OneTerm + + + + + +
+ ⏰ +
Session Expired
+
Your web proxy session has expired and you need to reconnect.
+
Reason: %s
+ ← Go Back + +
+ +`, reason) + + ctx.SetCookie("oneterm_session_id", "", -1, "/", "", false, true) + ctx.Header("Content-Type", "text/html; charset=utf-8") + ctx.String(http.StatusUnauthorized, html) +} + +// GetWebAssetConfig get web asset configuration +// @Summary Get web asset configuration +// @Description Get web asset configuration by asset ID +// @Tags WebProxy +// @Param asset_id path int true "Asset ID" +// @Success 200 {object} model.WebConfig +// @Router /web_proxy/config/{asset_id} [get] +func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) { + assetIdStr := ctx.Param("asset_id") + assetId, err := strconv.Atoi(assetIdStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid asset ID"}) + return + } + + assetService := service.NewAssetService() + asset, err := assetService.GetById(ctx, assetId) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Asset not found"}) + return + } + + if !asset.IsWebAsset() { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Asset is not a web asset"}) + return + } + + ctx.JSON(http.StatusOK, asset.WebConfig) +} + +// StartWebSession start a new web session +// @Summary Start web session +// @Description Start a new web session for the specified asset +// @Tags WebProxy +// @Accept json +// @Produce json +// @Param request body StartWebSessionRequest true "Start session request" +// @Success 200 {object} StartWebSessionResponse +// @Router /web_proxy/start [post] +func (c *WebProxyController) StartWebSession(ctx *gin.Context) { + var req StartWebSessionRequest + if err := ctx.ShouldBindBodyWithJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + assetService := service.NewAssetService() + asset, err := assetService.GetById(ctx, req.AssetId) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Asset not found"}) + return + } + + // Check if asset is web asset + if !asset.IsWebAsset() { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Asset is not a web asset"}) + return + } + + // Auto-detect auth_mode from asset.WebConfig if not provided + authMode := req.AuthMode + if authMode == "" && asset.WebConfig != nil { + authMode = asset.WebConfig.AuthMode + if authMode == "" { + authMode = "none" // default + } + } + + // Generate unique session ID + sessionId := fmt.Sprintf("web_%d_%d_%d", req.AssetId, req.AccountId, time.Now().Unix()) + + // Create and store web proxy session + now := time.Now() + + // Get initial target host from asset + initialHost := c.getAssetHost(asset) + + webSession := &WebProxySession{ + SessionId: sessionId, + AssetId: asset.Id, + Asset: asset, + CreatedAt: now, + LastActivity: now, + CurrentHost: initialHost, + } + webProxySessions[sessionId] = webSession + + // Generate subdomain-based proxy URL + scheme := "https" + if ctx.Request.TLS == nil { + scheme = "http" + } + + // Extract base domain and port from current host + currentHost := ctx.Request.Host + var baseDomain string + var portSuffix string + + if strings.Contains(currentHost, ":") { + hostParts := strings.Split(currentHost, ":") + baseDomain = hostParts[0] + port := hostParts[1] + + // Keep port unless it's default + isDefaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443") + if !isDefaultPort { + portSuffix = ":" + port + } + } else { + baseDomain = currentHost + } + + // Create subdomain URL with session_id for first access (cookie will handle subsequent requests) + subdomainHost := fmt.Sprintf("asset-%d.%s%s", req.AssetId, baseDomain, portSuffix) + proxyURL := fmt.Sprintf("%s://%s/?session_id=%s", scheme, subdomainHost, sessionId) + + ctx.JSON(http.StatusOK, StartWebSessionResponse{ + SessionId: sessionId, + ProxyURL: proxyURL, + Message: "Web session started successfully", + }) +} + +// ProxyWebRequest handles subdomain-based web proxy requests +// Extract asset ID from Host header like: asset-123.oneterm.com +func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) { + host := ctx.Request.Host + + // Try to get session_id from multiple sources (priority order) + sessionID := ctx.Query("session_id") + + // 1. Try from Cookie (preferred method) + if sessionID == "" { + if cookie, err := ctx.Cookie("oneterm_session_id"); err == nil && cookie != "" { + sessionID = cookie + logger.L().Debug("Extracted session_id from cookie", zap.String("sessionID", sessionID)) + } + } + + // 2. Try from redirect parameter (for login redirects) + if sessionID == "" { + if redirect := ctx.Query("redirect"); redirect != "" { + if decoded, err := url.QueryUnescape(redirect); err == nil { + if decodedURL, err := url.Parse(decoded); err == nil { + sessionID = decodedURL.Query().Get("session_id") + } + } + } + } + + // Extract asset ID from Host header: asset-11.oneterm.com -> 11 + assetID, err := c.extractAssetIDFromHost(host) + if err != nil { + logger.L().Error("Invalid subdomain format", zap.String("host", host), zap.Error(err)) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subdomain format"}) + return + } + + logger.L().Debug("Extracted asset ID", zap.Int("assetID", assetID)) + + // Try to get session_id from Referer header as fallback + if sessionID == "" { + referer := ctx.GetHeader("Referer") + if referer != "" { + if refererURL, err := url.Parse(referer); err == nil { + sessionID = refererURL.Query().Get("session_id") + // Also try to extract from fragment/hash part if URL encoded + if sessionID == "" && strings.Contains(refererURL.RawQuery, "session_id") { + // Handle URL encoded session_id in redirect parameter + if redirect := refererURL.Query().Get("redirect"); redirect != "" { + if decoded, err := url.QueryUnescape(redirect); err == nil { + if decodedURL, err := url.Parse(decoded); err == nil { + sessionID = decodedURL.Query().Get("session_id") + } + } + } + } + } + } + } + + // For static resources, try harder to find session_id + if sessionID == "" { + // Check if this looks like a static resource + isStaticResource := strings.Contains(ctx.Request.URL.Path, "/img/") || + strings.Contains(ctx.Request.URL.Path, "/css/") || + strings.Contains(ctx.Request.URL.Path, "/js/") || + strings.Contains(ctx.Request.URL.Path, "/assets/") || + strings.HasSuffix(ctx.Request.URL.Path, ".png") || + strings.HasSuffix(ctx.Request.URL.Path, ".jpg") || + strings.HasSuffix(ctx.Request.URL.Path, ".gif") || + strings.HasSuffix(ctx.Request.URL.Path, ".css") || + strings.HasSuffix(ctx.Request.URL.Path, ".js") || + strings.HasSuffix(ctx.Request.URL.Path, ".ico") + + if isStaticResource { + // For static resources, find any valid session for this asset + for sid, session := range webProxySessions { + if session.AssetId == assetID { + sessionID = sid + break + } + } + } + } + + if sessionID == "" { + logger.L().Error("Missing session ID", zap.String("host", host)) + c.renderSessionExpiredPage(ctx, "Session ID required - please start a new web session") + return + } + + // Get session from simple session store + webSession, exists := webProxySessions[sessionID] + if !exists { + c.renderSessionExpiredPage(ctx, "Session not found") + return + } + + // Check session timeout (8 hours of inactivity) + now := time.Now() + maxInactiveTime := 8 * time.Hour + if now.Sub(webSession.LastActivity) > maxInactiveTime { + // Remove expired session + delete(webProxySessions, sessionID) + c.renderSessionExpiredPage(ctx, "Session expired due to inactivity") + return + } + + // Update last activity time and auto-renew cookie + webSession.LastActivity = now + ctx.SetCookie("oneterm_session_id", sessionID, 8*3600, "/", "", false, true) + + // Verify asset ID matches session + if webSession.AssetId != assetID { + ctx.JSON(http.StatusForbidden, gin.H{"error": "Asset ID mismatch"}) + return + } + + // Build target URL using current host (may have been updated by redirects) + targetURL := c.buildTargetURLWithHost(webSession.Asset, webSession.CurrentHost) + target, err := url.Parse(targetURL) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid target URL"}) + return + } + + // Set session_id cookie with smart expiration management + // Use a longer cookie duration (8 hours) but validate session on each request + cookieMaxAge := 8 * 3600 // 8 hours + ctx.SetCookie("oneterm_session_id", sessionID, cookieMaxAge, "/", "", false, true) + + // Determine current request scheme for redirect rewriting + currentScheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http") + + // Create transparent reverse proxy + proxy := httputil.NewSingleHostReverseProxy(target) + + // Configure proxy director for transparent proxying + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + + req.Host = target.Host + req.Header.Set("Host", target.Host) + + if origin := req.Header.Get("Origin"); origin != "" { + req.Header.Set("Origin", target.Scheme+"://"+target.Host) + } + + if referer := req.Header.Get("Referer"); referer != "" { + if refererURL, err := url.Parse(referer); err == nil { + refererURL.Scheme = target.Scheme + refererURL.Host = target.Host + req.Header.Set("Referer", refererURL.String()) + } + } + + q := req.URL.Query() + q.Del("session_id") + req.URL.RawQuery = q.Encode() + } + + // Redirect interception for bastion control + proxy.ModifyResponse = func(resp *http.Response) error { + contentType := resp.Header.Get("Content-Type") + if resp.StatusCode == 200 && strings.Contains(contentType, "text/html") { + c.rewriteHTMLContent(resp, assetID, currentScheme, host) + } + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + location := resp.Header.Get("Location") + if location != "" { + redirectURL, err := url.Parse(location) + if err != nil { + return nil + } + + shouldIntercept := redirectURL.IsAbs() + + if shouldIntercept { + baseDomain := lo.Ternary(strings.HasPrefix(host, "asset-"), + func() string { + parts := strings.SplitN(host, ".", 2) + return lo.Ternary(len(parts) > 1, parts[1], host) + }(), + host) + + if c.isSameDomainOrSubdomain(target.Host, redirectURL.Host) { + webSession.CurrentHost = redirectURL.Host + newProxyURL := fmt.Sprintf("%s://asset-%d.%s%s", currentScheme, assetID, baseDomain, redirectURL.Path) + if redirectURL.RawQuery != "" { + newProxyURL += "?" + redirectURL.RawQuery + } + resp.Header.Set("Location", newProxyURL) + } else { + newLocation := fmt.Sprintf("%s://asset-%d.%s/external?url=%s", + currentScheme, assetID, baseDomain, url.QueryEscape(redirectURL.String())) + resp.Header.Set("Location", newLocation) + } + } else { + resp.Header.Set("Location", redirectURL.String()) + } + } + } + + if cookies := resp.Header["Set-Cookie"]; len(cookies) > 0 { + proxyDomain := strings.Split(host, ":")[0] + + newCookies := lo.Map(cookies, func(cookie string, _ int) string { + if strings.Contains(cookie, "Domain="+target.Host) { + return strings.Replace(cookie, "Domain="+target.Host, "Domain="+proxyDomain, 1) + } + return cookie + }) + resp.Header["Set-Cookie"] = newCookies + } + + return nil + } + + ctx.Header("Cache-Control", "no-cache") + + proxy.ServeHTTP(ctx.Writer, ctx.Request) +} + +// HandleExternalRedirect handles redirects to external domains through proxy +func (c *WebProxyController) HandleExternalRedirect(ctx *gin.Context) { + targetURL := ctx.Query("url") + + // Get session_id from cookie instead of URL parameter + sessionID, err := ctx.Cookie("oneterm_session_id") + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Session required"}) + return + } + + if targetURL == "" || sessionID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing required parameters"}) + return + } + + // Validate session + webSession, exists := webProxySessions[sessionID] + if !exists { + c.renderSessionExpiredPage(ctx, "Session not found") + return + } + + // Check session timeout (8 hours of inactivity) + now := time.Now() + maxInactiveTime := 8 * time.Hour + if now.Sub(webSession.LastActivity) > maxInactiveTime { + // Remove expired session + delete(webProxySessions, sessionID) + c.renderSessionExpiredPage(ctx, "Session expired due to inactivity") + return + } + + // Update last activity time + webSession.LastActivity = now + + // Return a simple page explaining the redirect was blocked + html := fmt.Sprintf(` + + + External Redirect Blocked + + + +
+

πŸ›‘οΈ External Redirect Blocked

+
+ The target website attempted to redirect you to an external domain, + which has been blocked by the bastion host for security reasons. +
+
Target URL:
+
%s
+
+ All web access must go through the bastion host to maintain security + and audit compliance. External redirects are not permitted. +
+ +
+ +`, targetURL) + + ctx.Header("Content-Type", "text/html; charset=utf-8") + ctx.String(http.StatusOK, html) +} + +// isSameDomainOrSubdomain checks if two hosts belong to the same domain or subdomain +// Examples: +// - baidu.com & www.baidu.com β†’ true (subdomain) +// - baidu.com & m.baidu.com β†’ true (subdomain) +// - baidu.com & google.com β†’ false (different domain) +// - sub.example.com & other.example.com β†’ true (same domain) +func (c *WebProxyController) isSameDomainOrSubdomain(host1, host2 string) bool { + if host1 == host2 { + return true + } + + // Remove port if present + host1 = strings.Split(host1, ":")[0] + host2 = strings.Split(host2, ":")[0] + + // Get domain parts + parts1 := strings.Split(host1, ".") + parts2 := strings.Split(host2, ".") + + // Need at least domain.tld (2 parts) + if len(parts1) < 2 || len(parts2) < 2 { + return false + } + + // Compare the last two parts (domain.tld) + domain1 := strings.Join(parts1[len(parts1)-2:], ".") + domain2 := strings.Join(parts2[len(parts2)-2:], ".") + + return domain1 == domain2 +} + +// getAssetHost extracts the host from asset configuration +func (c *WebProxyController) getAssetHost(asset *model.Asset) string { + targetURL := c.buildTargetURL(asset) + if u, err := url.Parse(targetURL); err == nil { + return u.Host + } + return "localhost" // fallback +} + +// buildTargetURLWithHost builds target URL with specific host +func (c *WebProxyController) buildTargetURLWithHost(asset *model.Asset, host string) string { + protocol, port := asset.GetWebProtocol() + if protocol == "" { + protocol = "http" + port = 80 + } + + // Use custom host instead of asset's original host + if port == 80 && protocol == "http" || port == 443 && protocol == "https" { + return fmt.Sprintf("%s://%s", protocol, host) + } + return fmt.Sprintf("%s://%s:%d", protocol, host, port) +} + +// buildTargetURL builds the target URL from asset information +func (c *WebProxyController) buildTargetURL(asset *model.Asset) string { + protocol, port := asset.GetWebProtocol() + if protocol == "" { + protocol = "http" + port = 80 + } + + // If port is default port for protocol, don't include it + if (protocol == "http" && port == 80) || (protocol == "https" && port == 443) { + return fmt.Sprintf("%s://%s", protocol, asset.Ip) + } + + return fmt.Sprintf("%s://%s:%d", protocol, asset.Ip, port) +} + +// rewriteHTMLContent rewrites HTML content to redirect external links through proxy +func (c *WebProxyController) rewriteHTMLContent(resp *http.Response, assetID int, scheme, proxyHost string) { + if resp.Body == nil { + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return + } + resp.Body.Close() + + baseDomain := lo.Ternary(strings.HasPrefix(proxyHost, "asset-"), + func() string { + parts := strings.SplitN(proxyHost, ".", 2) + return lo.Ternary(len(parts) > 1, parts[1], proxyHost) + }(), + proxyHost) + + content := string(body) + + // Universal URL rewriting patterns - catch ALL external URLs + patterns := []struct { + pattern string + rewrite func(matches []string) string + }{ + // JavaScript location assignments: window.location = "http://example.com/path" + { + `(window\.location(?:\.href)?\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`, + func(matches []string) string { + path := lo.Ternary(len(matches) > 3 && matches[3] != "", matches[3], "") + return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path) + }, + }, + // Form actions:
3 && matches[3] != "", matches[3], "") + return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path) + }, + }, + // Link hrefs: 3 && matches[3] != "", matches[3], "") + return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path) + }, + }, + } + + for _, p := range patterns { + re := regexp.MustCompile(p.pattern) + content = re.ReplaceAllStringFunc(content, func(match string) string { + matches := re.FindStringSubmatch(match) + if len(matches) >= 4 { + return p.rewrite(matches) + } + return match + }) + } + + newBody := bytes.NewReader([]byte(content)) + resp.Body = io.NopCloser(newBody) + resp.ContentLength = int64(len(content)) + resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(content))) +} + +// extractAssetIDFromHost extracts asset ID from subdomain host +// Examples: asset-123.oneterm.com -> 123, asset-456.localhost:8080 -> 456 +func (c *WebProxyController) extractAssetIDFromHost(host string) (int, error) { + // Remove port if present + hostParts := strings.Split(host, ":") + hostname := hostParts[0] + + // Check for asset- prefix + if !strings.HasPrefix(hostname, "asset-") { + return 0, fmt.Errorf("host does not start with asset- prefix: %s", hostname) + } + + // Extract asset ID: asset-123.domain.com -> 123 + parts := strings.Split(hostname, ".") + if len(parts) == 0 { + return 0, fmt.Errorf("invalid hostname format: %s", hostname) + } + + assetPart := parts[0] // asset-123 + assetIDStr := strings.TrimPrefix(assetPart, "asset-") + if assetIDStr == assetPart { + return 0, fmt.Errorf("failed to extract asset ID from: %s", assetPart) + } + + assetID, err := strconv.Atoi(assetIDStr) + if err != nil { + return 0, fmt.Errorf("invalid asset ID format: %s", assetIDStr) + } + + return assetID, nil +} diff --git a/backend/internal/api/docs/docs.go b/backend/internal/api/docs/docs.go index 3738df3..e5b9aea 100644 --- a/backend/internal/api/docs/docs.go +++ b/backend/internal/api/docs/docs.go @@ -1006,6 +1006,12 @@ const docTemplate = `{ "name": "page_size", "in": "query" }, + { + "type": "string", + "description": "search by name or description", + "name": "search", + "in": "query" + }, { "type": "string", "description": "template category", @@ -3988,6 +3994,12 @@ const docTemplate = `{ "name": "page_size", "in": "query" }, + { + "type": "string", + "description": "search by name or description", + "name": "search", + "in": "query" + }, { "type": "string", "description": "template category", @@ -4193,6 +4205,66 @@ const docTemplate = `{ } } } + }, + "/web_proxy/config/{asset_id}": { + "get": { + "description": "Get web asset configuration by asset ID", + "tags": [ + "WebProxy" + ], + "summary": "Get web asset configuration", + "parameters": [ + { + "type": "integer", + "description": "Asset ID", + "name": "asset_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.WebConfig" + } + } + } + } + }, + "/web_proxy/start": { + "post": { + "description": "Start a new web session for the specified asset", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "WebProxy" + ], + "summary": "Start web session", + "parameters": [ + { + "description": "Start session request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.StartWebSessionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controller.StartWebSessionResponse" + } + } + } + } } }, "definitions": { @@ -4318,6 +4390,40 @@ const docTemplate = `{ } } }, + "controller.StartWebSessionRequest": { + "type": "object", + "required": [ + "asset_id" + ], + "properties": { + "account_id": { + "type": "integer" + }, + "asset_id": { + "type": "integer" + }, + "asset_name": { + "type": "string" + }, + "auth_mode": { + "type": "string" + } + } + }, + "controller.StartWebSessionResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "proxy_url": { + "type": "string" + }, + "session_id": { + "type": "string" + } + } + }, "model.AccessAuth": { "type": "object", "properties": { @@ -4481,6 +4587,10 @@ const docTemplate = `{ "items": { "type": "integer" } + }, + "rule_id": { + "description": "V2 authorization rule ID for tracking", + "type": "integer" } } }, @@ -4559,6 +4669,14 @@ const docTemplate = `{ }, "updater_id": { "type": "integer" + }, + "web_config": { + "description": "Web-specific configuration (only valid when protocols contain http/https)", + "allOf": [ + { + "$ref": "#/definitions/model.WebConfig" + } + ] } } }, @@ -5091,15 +5209,6 @@ const docTemplate = `{ "type": "object", "additionalProperties": true }, - "model.Map-int-model_Slice-int": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "integer" - } - } - }, "model.Map-string-any": { "type": "object", "additionalProperties": {} @@ -5114,7 +5223,7 @@ const docTemplate = `{ "type": "integer" }, "authorization": { - "$ref": "#/definitions/model.Map-int-model_Slice-int" + "$ref": "#/definitions/model.AuthorizationMap" }, "children": { "type": "array", @@ -5742,6 +5851,83 @@ const docTemplate = `{ "type": "integer" } } + }, + "model.WebConfig": { + "type": "object", + "properties": { + "access_policy": { + "description": "full_access, read_only", + "type": "string" + }, + "auth_mode": { + "description": "none, smart, manual", + "type": "string" + }, + "login_accounts": { + "description": "Web login credentials", + "type": "array", + "items": { + "$ref": "#/definitions/model.WebLoginAccount" + } + }, + "proxy_settings": { + "description": "Proxy configuration", + "allOf": [ + { + "$ref": "#/definitions/model.WebProxySettings" + } + ] + } + } + }, + "model.WebLoginAccount": { + "type": "object", + "properties": { + "is_default": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "status": { + "description": "active, inactive", + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "model.WebProxySettings": { + "type": "object", + "properties": { + "allowed_methods": { + "description": "Allowed HTTP methods", + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_paths": { + "description": "Blocked URL paths", + "type": "array", + "items": { + "type": "string" + } + }, + "max_concurrent": { + "description": "Max concurrent connections", + "type": "integer" + }, + "recording_enabled": { + "description": "Enable session recording", + "type": "boolean" + }, + "watermark_enabled": { + "description": "Enable watermark", + "type": "boolean" + } + } } } }` diff --git a/backend/internal/api/docs/swagger.json b/backend/internal/api/docs/swagger.json index 869eeaf..b248aa9 100644 --- a/backend/internal/api/docs/swagger.json +++ b/backend/internal/api/docs/swagger.json @@ -995,6 +995,12 @@ "name": "page_size", "in": "query" }, + { + "type": "string", + "description": "search by name or description", + "name": "search", + "in": "query" + }, { "type": "string", "description": "template category", @@ -3977,6 +3983,12 @@ "name": "page_size", "in": "query" }, + { + "type": "string", + "description": "search by name or description", + "name": "search", + "in": "query" + }, { "type": "string", "description": "template category", @@ -4182,6 +4194,66 @@ } } } + }, + "/web_proxy/config/{asset_id}": { + "get": { + "description": "Get web asset configuration by asset ID", + "tags": [ + "WebProxy" + ], + "summary": "Get web asset configuration", + "parameters": [ + { + "type": "integer", + "description": "Asset ID", + "name": "asset_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.WebConfig" + } + } + } + } + }, + "/web_proxy/start": { + "post": { + "description": "Start a new web session for the specified asset", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "WebProxy" + ], + "summary": "Start web session", + "parameters": [ + { + "description": "Start session request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.StartWebSessionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controller.StartWebSessionResponse" + } + } + } + } } }, "definitions": { @@ -4307,6 +4379,40 @@ } } }, + "controller.StartWebSessionRequest": { + "type": "object", + "required": [ + "asset_id" + ], + "properties": { + "account_id": { + "type": "integer" + }, + "asset_id": { + "type": "integer" + }, + "asset_name": { + "type": "string" + }, + "auth_mode": { + "type": "string" + } + } + }, + "controller.StartWebSessionResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "proxy_url": { + "type": "string" + }, + "session_id": { + "type": "string" + } + } + }, "model.AccessAuth": { "type": "object", "properties": { @@ -4470,6 +4576,10 @@ "items": { "type": "integer" } + }, + "rule_id": { + "description": "V2 authorization rule ID for tracking", + "type": "integer" } } }, @@ -4548,6 +4658,14 @@ }, "updater_id": { "type": "integer" + }, + "web_config": { + "description": "Web-specific configuration (only valid when protocols contain http/https)", + "allOf": [ + { + "$ref": "#/definitions/model.WebConfig" + } + ] } } }, @@ -5080,15 +5198,6 @@ "type": "object", "additionalProperties": true }, - "model.Map-int-model_Slice-int": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "integer" - } - } - }, "model.Map-string-any": { "type": "object", "additionalProperties": {} @@ -5103,7 +5212,7 @@ "type": "integer" }, "authorization": { - "$ref": "#/definitions/model.Map-int-model_Slice-int" + "$ref": "#/definitions/model.AuthorizationMap" }, "children": { "type": "array", @@ -5731,6 +5840,83 @@ "type": "integer" } } + }, + "model.WebConfig": { + "type": "object", + "properties": { + "access_policy": { + "description": "full_access, read_only", + "type": "string" + }, + "auth_mode": { + "description": "none, smart, manual", + "type": "string" + }, + "login_accounts": { + "description": "Web login credentials", + "type": "array", + "items": { + "$ref": "#/definitions/model.WebLoginAccount" + } + }, + "proxy_settings": { + "description": "Proxy configuration", + "allOf": [ + { + "$ref": "#/definitions/model.WebProxySettings" + } + ] + } + } + }, + "model.WebLoginAccount": { + "type": "object", + "properties": { + "is_default": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "status": { + "description": "active, inactive", + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "model.WebProxySettings": { + "type": "object", + "properties": { + "allowed_methods": { + "description": "Allowed HTTP methods", + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_paths": { + "description": "Blocked URL paths", + "type": "array", + "items": { + "type": "string" + } + }, + "max_concurrent": { + "description": "Max concurrent connections", + "type": "integer" + }, + "recording_enabled": { + "description": "Enable session recording", + "type": "boolean" + }, + "watermark_enabled": { + "description": "Enable watermark", + "type": "boolean" + } + } } } } \ No newline at end of file diff --git a/backend/internal/api/docs/swagger.yaml b/backend/internal/api/docs/swagger.yaml index d6fc313..18fe828 100644 --- a/backend/internal/api/docs/swagger.yaml +++ b/backend/internal/api/docs/swagger.yaml @@ -80,6 +80,28 @@ definitions: items: {} type: array type: object + controller.StartWebSessionRequest: + properties: + account_id: + type: integer + asset_id: + type: integer + asset_name: + type: string + auth_mode: + type: string + required: + - asset_id + type: object + controller.StartWebSessionResponse: + properties: + message: + type: string + proxy_url: + type: string + session_id: + type: string + type: object model.AccessAuth: properties: allow: @@ -187,6 +209,9 @@ definitions: items: type: integer type: array + rule_id: + description: V2 authorization rule ID for tracking + type: integer type: object model.Asset: properties: @@ -236,6 +261,11 @@ definitions: type: string updater_id: type: integer + web_config: + allOf: + - $ref: '#/definitions/model.WebConfig' + description: Web-specific configuration (only valid when protocols contain + http/https) type: object model.AssetCommandControl: properties: @@ -593,12 +623,6 @@ definitions: model.JSON: additionalProperties: true type: object - model.Map-int-model_Slice-int: - additionalProperties: - items: - type: integer - type: array - type: object model.Map-string-any: additionalProperties: {} type: object @@ -609,7 +633,7 @@ definitions: asset_count: type: integer authorization: - $ref: '#/definitions/model.Map-int-model_Slice-int' + $ref: '#/definitions/model.AuthorizationMap' children: items: $ref: '#/definitions/model.Node' @@ -1034,6 +1058,58 @@ definitions: description: User ID with unique index type: integer type: object + model.WebConfig: + properties: + access_policy: + description: full_access, read_only + type: string + auth_mode: + description: none, smart, manual + type: string + login_accounts: + description: Web login credentials + items: + $ref: '#/definitions/model.WebLoginAccount' + type: array + proxy_settings: + allOf: + - $ref: '#/definitions/model.WebProxySettings' + description: Proxy configuration + type: object + model.WebLoginAccount: + properties: + is_default: + type: boolean + password: + type: string + status: + description: active, inactive + type: string + username: + type: string + type: object + model.WebProxySettings: + properties: + allowed_methods: + description: Allowed HTTP methods + items: + type: string + type: array + blocked_paths: + description: Blocked URL paths + items: + type: string + type: array + max_concurrent: + description: Max concurrent connections + type: integer + recording_enabled: + description: Enable session recording + type: boolean + watermark_enabled: + description: Enable watermark + type: boolean + type: object info: contact: {} paths: @@ -1634,6 +1710,10 @@ paths: in: query name: page_size type: integer + - description: search by name or description + in: query + name: search + type: string - description: template category in: query name: category @@ -3438,6 +3518,10 @@ paths: in: query name: page_size type: integer + - description: search by name or description + in: query + name: search + type: string - description: template category in: query name: category @@ -3560,4 +3644,43 @@ paths: $ref: '#/definitions/controller.HttpResponse' tags: - time_template + /web_proxy/config/{asset_id}: + get: + description: Get web asset configuration by asset ID + parameters: + - description: Asset ID + in: path + name: asset_id + required: true + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.WebConfig' + summary: Get web asset configuration + tags: + - WebProxy + /web_proxy/start: + post: + consumes: + - application/json + description: Start a new web session for the specified asset + parameters: + - description: Start session request + in: body + name: request + required: true + schema: + $ref: '#/definitions/controller.StartWebSessionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controller.StartWebSessionResponse' + summary: Start web session + tags: + - WebProxy swagger: "2.0" diff --git a/backend/internal/api/router/router.go b/backend/internal/api/router/router.go index e8ab1a8..ff2e174 100644 --- a/backend/internal/api/router/router.go +++ b/backend/internal/api/router/router.go @@ -5,6 +5,8 @@ import ( swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" + "strings" + "github.com/veops/oneterm/internal/api/controller" "github.com/veops/oneterm/internal/api/docs" "github.com/veops/oneterm/internal/api/middleware" @@ -12,9 +14,33 @@ import ( func SetupRouter(r *gin.Engine) { r.SetTrustedProxies([]string{"0.0.0.0/0", "::/0"}) - r.MaxMultipartMemory = 32 << 20 // 32MB, match with controller constant + r.MaxMultipartMemory = 1 << 20 // 1MB to prevent memory overflow r.Use(gin.Recovery(), middleware.LoggerMiddleware()) + // Start web session cleanup routine + controller.StartSessionCleanupRoutine() + + // Subdomain proxy middleware for asset- subdomains + webProxy := controller.NewWebProxyController() + r.Use(func(c *gin.Context) { + host := c.Request.Host + + // Check if this is an asset subdomain request + if strings.HasPrefix(host, "asset-") { + // Handle external redirect requests + if c.Request.URL.Path == "/external" { + webProxy.HandleExternalRedirect(c) + return + } + + // Handle normal proxy requests + webProxy.ProxyWebRequest(c) + return + } + + c.Next() + }) + docs.SwaggerInfo.Title = "ONETERM API" docs.SwaggerInfo.BasePath = "/api/oneterm/v1" r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) @@ -224,5 +250,11 @@ func SetupRouter(r *gin.Engine) { commandTemplate.GET("/builtin", c.GetBuiltInCommandTemplates) commandTemplate.GET("/:id/commands", c.GetTemplateCommands) } + + // Web proxy management API routes + webProxyGroup := v1.Group("/web_proxy") + { + webProxyGroup.POST("/start", webProxy.StartWebSession) + } } } diff --git a/backend/internal/connector/handlers.go b/backend/internal/connector/handlers.go index ff682c4..f992640 100644 --- a/backend/internal/connector/handlers.go +++ b/backend/internal/connector/handlers.go @@ -239,6 +239,11 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er requiredActions = append(requiredActions, model.ActionFileUpload, model.ActionFileDownload) } + // Web protocols need file download permission check + if protocol == "http" || protocol == "https" { + requiredActions = append(requiredActions, model.ActionFileDownload) + } + // RDP/VNC are handled separately in ConnectGuacd with their own batch permission check // but we still check connect permission here for consistency @@ -254,6 +259,19 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er return sess, err } + // Set permissions in session for protocol-specific usage + if protocol == "http" || protocol == "https" { + // For Web protocols, store all relevant permissions + permissions := &model.AuthPermissions{ + Connect: result.IsAllowed(model.ActionConnect), + FileDownload: result.IsAllowed(model.ActionFileDownload), + Copy: result.IsAllowed(model.ActionCopy), + Paste: result.IsAllowed(model.ActionPaste), + Share: result.IsAllowed(model.ActionShare), + } + sess.SetPermissions(permissions) + } + // For SSH, check if user has any file permissions before initializing SFTP hasFilePermissions := false if protocol == "ssh" { @@ -269,6 +287,11 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er go protocols.ConnectTelnet(ctx, sess, asset, account, gateway) case "vnc", "rdp": go protocols.ConnectGuacd(ctx, sess, asset, account, gateway) + case "http", "https": + // Web assets are handled through separate web proxy API endpoints + err = &myErrors.ApiError{Code: myErrors.ErrConnectServer, Data: map[string]any{"err": "Web assets should use web proxy API"}} + sess.Chans.ErrChan <- err + return default: logger.L().Error("wrong protocol " + sess.Protocol) } diff --git a/backend/internal/connector/protocols/web.go b/backend/internal/connector/protocols/web.go new file mode 100644 index 0000000..d0bd6c9 --- /dev/null +++ b/backend/internal/connector/protocols/web.go @@ -0,0 +1,71 @@ +package protocols + +import ( + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/veops/oneterm/internal/model" + "github.com/veops/oneterm/pkg/logger" +) + +type WebSession struct { + Asset *model.Asset + Account *model.Account + StartTime time.Time + LastActivity time.Time +} + +// NewWebSession creates a new web session +func NewWebSession(asset *model.Asset, account *model.Account) *WebSession { + now := time.Now() + return &WebSession{ + Asset: asset, + Account: account, + StartTime: now, + LastActivity: now, + } +} + +// GetTargetURL returns the target URL for the web asset +func (ws *WebSession) GetTargetURL() string { + if ws.Asset == nil { + return "" + } + + protocol, port := ws.Asset.GetWebProtocol() + if protocol == "" { + protocol = "http" + port = 80 + } + + // Build URL without port if it's the default port + if (protocol == "http" && port == 80) || (protocol == "https" && port == 443) { + return fmt.Sprintf("%s://%s", protocol, ws.Asset.Ip) + } + + return fmt.Sprintf("%s://%s:%d", protocol, ws.Asset.Ip, port) +} + +// GetAssetInfo returns asset information +func (ws *WebSession) GetAssetInfo() map[string]interface{} { + if ws.Asset == nil { + return map[string]interface{}{} + } + + return map[string]interface{}{ + "id": ws.Asset.Id, + "name": ws.Asset.Name, + "ip": ws.Asset.Ip, + "url": ws.GetTargetURL(), + } +} + +// Close closes the web session +func (ws *WebSession) Close() error { + logger.L().Info("Closing web session", + zap.String("assetName", ws.Asset.Name), + zap.Int("assetId", ws.Asset.Id)) + return nil +} diff --git a/backend/internal/model/asset.go b/backend/internal/model/asset.go index 5031c1f..294376e 100644 --- a/backend/internal/model/asset.go +++ b/backend/internal/model/asset.go @@ -3,11 +3,19 @@ package model import ( "database/sql/driver" "encoding/json" + "strconv" + "strings" "time" + "github.com/samber/lo" "gorm.io/plugin/soft_delete" ) +// Web asset constants +const ( + WebAssetDefaultAccountID = -1 // Virtual account ID for Web assets +) + const ( TABLE_NAME_ASSET = "asset" ) @@ -128,6 +136,9 @@ type Asset struct { AccessTimeControl *AccessTimeControl `json:"access_time_control,omitempty" gorm:"column:access_time_control;type:json"` AssetCommandControl *AssetCommandControl `json:"asset_command_control,omitempty" gorm:"column:asset_command_control;type:json"` + // Web-specific configuration (only valid when protocols contain http/https) + WebConfig *WebConfig `json:"web_config,omitempty" gorm:"column:web_config;type:json"` + Permissions []string `json:"permissions" gorm:"-"` ResourceId int `json:"resource_id" gorm:"column:resource_id"` CreatorId int `json:"creator_id" gorm:"column:creator_id"` @@ -207,3 +218,84 @@ type AssetIdPid struct { func (m *AssetIdPid) TableName() string { return TABLE_NAME_ASSET } + +// WebConfig contains Web-specific configuration for assets +type WebConfig struct { + AuthMode string `json:"auth_mode"` // none, smart, manual + LoginAccounts []WebLoginAccount `json:"login_accounts"` // Web login credentials + AccessPolicy string `json:"access_policy"` // full_access, read_only + ProxySettings *WebProxySettings `json:"proxy_settings"` // Proxy configuration +} + +func (w *WebConfig) Scan(value interface{}) error { + if value == nil { + return nil + } + bytes, ok := value.([]byte) + if !ok { + return nil + } + return json.Unmarshal(bytes, w) +} + +func (w WebConfig) Value() (driver.Value, error) { + if w.AuthMode == "" { + return nil, nil + } + return json.Marshal(w) +} + +// WebLoginAccount represents login credentials for Web authentication +type WebLoginAccount struct { + Username string `json:"username"` + Password string `json:"password"` + IsDefault bool `json:"is_default"` + Status string `json:"status"` // active, inactive +} + +// WebProxySettings contains proxy-specific settings +type WebProxySettings struct { + MaxConcurrent int `json:"max_concurrent"` // Max concurrent connections + AllowedMethods []string `json:"allowed_methods"` // Allowed HTTP methods + BlockedPaths []string `json:"blocked_paths"` // Blocked URL paths + RecordingEnabled bool `json:"recording_enabled"` // Enable session recording + WatermarkEnabled bool `json:"watermark_enabled"` // Enable watermark +} + +// IsWebAsset checks if the asset is a Web asset +func (a *Asset) IsWebAsset() bool { + return lo.SomeBy(a.Protocols, func(protocol string) bool { + protocolName := strings.ToLower(strings.Split(protocol, ":")[0]) + return protocolName == "http" || protocolName == "https" + }) +} + +// GetWebProtocol returns the Web protocol (http/https) and port +func (a *Asset) GetWebProtocol() (string, int) { + if protocol, found := lo.Find(a.Protocols, func(protocol string) bool { + parts := strings.Split(protocol, ":") + if len(parts) >= 1 { + protocolName := strings.ToLower(parts[0]) + return protocolName == "http" || protocolName == "https" + } + return false + }); found { + parts := strings.Split(protocol, ":") + protocolName := strings.ToLower(parts[0]) + + port := 80 + if protocolName == "https" { + port = 443 + } + + if len(parts) >= 2 { + if customPort, err := strconv.Atoi(parts[1]); err == nil { + port = customPort + } + } + + return protocolName, port + } + + return "", 0 +} diff --git a/backend/internal/service/web_auth.go b/backend/internal/service/web_auth.go new file mode 100644 index 0000000..0082caa --- /dev/null +++ b/backend/internal/service/web_auth.go @@ -0,0 +1,497 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "go.uber.org/zap" + + "github.com/samber/lo" + "github.com/veops/oneterm/pkg/logger" +) + +// WebAuthService handles Web authentication +type WebAuthService struct { + strategies []WebAuthStrategy +} + +// WebAuthStrategy defines the interface for Web authentication strategies +type WebAuthStrategy interface { + Name() string + Priority() int + CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool + Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) +} + +// WebSiteInfo contains information about the target Web site +type WebSiteInfo struct { + URL string + HTMLContent string + Headers http.Header + StatusCode int + LoginForms []WebLoginForm +} + +// WebLoginForm represents a login form found on the page +type WebLoginForm struct { + Action string `json:"action"` + Method string `json:"method"` + UsernameField WebFormField `json:"username_field"` + PasswordField WebFormField `json:"password_field"` + SubmitButton WebFormField `json:"submit_button"` + AdditionalFields []WebFormField `json:"additional_fields"` + CSRFToken string `json:"csrf_token"` +} + +// WebFormField represents a form field +type WebFormField struct { + Name string `json:"name"` + ID string `json:"id"` + Type string `json:"type"` + Value string `json:"value"` + Selector string `json:"selector"` + Placeholder string `json:"placeholder"` +} + +// WebCredentials contains authentication credentials +type WebCredentials struct { + Username string + Password string +} + +// WebAuthResult contains authentication result +type WebAuthResult struct { + Success bool + Message string + Cookies []*http.Cookie + RedirectURL string + SessionData map[string]interface{} +} + +// NewWebAuthService creates a new Web authentication service +func NewWebAuthService() *WebAuthService { + service := &WebAuthService{ + strategies: []WebAuthStrategy{ + &HTTPBasicAuthStrategy{}, + &SmartFormAuthStrategy{}, + &APILoginAuthStrategy{}, + }, + } + return service +} + +// AnalyzeSite analyzes a Web site for authentication methods +func (s *WebAuthService) AnalyzeSite(ctx context.Context, targetURL string) (*WebSiteInfo, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Don't follow redirects during analysis + return http.ErrUseLastResponse + }, + } + + resp, err := client.Get(targetURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch site: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + siteInfo := &WebSiteInfo{ + URL: targetURL, + HTMLContent: string(body), + Headers: resp.Header, + StatusCode: resp.StatusCode, + } + + // Analyze HTML for login forms + if strings.Contains(resp.Header.Get("Content-Type"), "text/html") { + forms, err := s.analyzeLoginForms(string(body)) + if err != nil { + logger.L().Warn("Failed to analyze login forms", zap.Error(err)) + } else { + siteInfo.LoginForms = forms + } + } + + return siteInfo, nil +} + +// SelectBestStrategy selects the best authentication strategy for a site +func (s *WebAuthService) SelectBestStrategy(ctx context.Context, siteInfo *WebSiteInfo) WebAuthStrategy { + var bestStrategy WebAuthStrategy + highestPriority := -1 + + for _, strategy := range s.strategies { + if strategy.CanHandle(ctx, siteInfo) && strategy.Priority() > highestPriority { + bestStrategy = strategy + highestPriority = strategy.Priority() + } + } + + return bestStrategy +} + +// Authenticate performs authentication using the best available strategy +func (s *WebAuthService) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) { + strategy := s.SelectBestStrategy(ctx, siteInfo) + if strategy == nil { + return &WebAuthResult{ + Success: false, + Message: "No suitable authentication strategy found", + }, nil + } + + return strategy.Authenticate(ctx, credentials, siteInfo) +} + +// AuthenticateWithRetry performs authentication with automatic account retry +func (s *WebAuthService) AuthenticateWithRetry(ctx context.Context, accounts []WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) { + if len(accounts) == 0 { + return &WebAuthResult{ + Success: false, + Message: "No accounts available for authentication", + }, nil + } + + strategy := s.SelectBestStrategy(ctx, siteInfo) + if strategy == nil { + return &WebAuthResult{ + Success: false, + Message: "No suitable authentication strategy found", + }, nil + } + + var lastError error + var lastResult *WebAuthResult + + // 尝试每δΈͺθ΄¦ε·οΌŒη›΄εˆ°ζˆεŠŸ + for i, credentials := range accounts { + logger.L().Info("Attempting authentication", + zap.String("strategy", strategy.Name()), + zap.String("username", credentials.Username), + zap.Int("attempt", i+1), + zap.Int("total_accounts", len(accounts))) + + result, err := strategy.Authenticate(ctx, &credentials, siteInfo) + if err != nil { + lastError = err + logger.L().Warn("Authentication error", + zap.String("username", credentials.Username), + zap.Error(err)) + continue + } + + lastResult = result + if result.Success { + logger.L().Info("Authentication successful", + zap.String("username", credentials.Username), + zap.Int("attempt", i+1)) + return result, nil + } + + logger.L().Warn("Authentication failed", + zap.String("username", credentials.Username), + zap.String("reason", result.Message)) + } + + // ζ‰€ζœ‰θ΄¦ε·ιƒ½ε€±θ΄₯δΊ† + if lastError != nil { + return nil, fmt.Errorf("all authentication attempts failed, last error: %w", lastError) + } + + if lastResult != nil { + return lastResult, nil + } + + return &WebAuthResult{ + Success: false, + Message: "All configured accounts failed to authenticate", + }, nil +} + +// analyzeLoginForms analyzes HTML content for login forms +func (s *WebAuthService) analyzeLoginForms(htmlContent string) ([]WebLoginForm, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent)) + if err != nil { + return nil, err + } + + var forms []WebLoginForm + + doc.Find("form").Each(func(i int, formSel *goquery.Selection) { + form := WebLoginForm{ + Method: strings.ToUpper(formSel.AttrOr("method", "GET")), + Action: formSel.AttrOr("action", ""), + } + + // Find username field + formSel.Find("input").Each(func(j int, inputSel *goquery.Selection) { + inputType := strings.ToLower(inputSel.AttrOr("type", "text")) + inputName := inputSel.AttrOr("name", "") + inputID := inputSel.AttrOr("id", "") + placeholder := inputSel.AttrOr("placeholder", "") + + field := WebFormField{ + Name: inputName, + ID: inputID, + Type: inputType, + Selector: s.generateSelector(inputSel), + Placeholder: placeholder, + } + + // Identify field type based on various indicators + if s.isUsernameField(inputType, inputName, inputID, placeholder) && form.UsernameField.Name == "" { + form.UsernameField = field + } else if inputType == "password" && form.PasswordField.Name == "" { + form.PasswordField = field + } + }) + + // Find submit button + formSel.Find("button, input[type=submit]").Each(func(j int, btnSel *goquery.Selection) { + if form.SubmitButton.Name == "" { + form.SubmitButton = WebFormField{ + Name: btnSel.AttrOr("name", ""), + ID: btnSel.AttrOr("id", ""), + Type: btnSel.AttrOr("type", "submit"), + Selector: s.generateSelector(btnSel), + } + } + }) + + // Only include forms that have both username and password fields + if form.UsernameField.Name != "" && form.PasswordField.Name != "" { + forms = append(forms, form) + } + }) + + return forms, nil +} + +// isUsernameField determines if a field is likely a username field +func (s *WebAuthService) isUsernameField(inputType, name, id, placeholder string) bool { + if inputType == "password" { + return false + } + + keywords := []string{"user", "login", "email", "account", "name"} + text := strings.ToLower(name + id + placeholder) + + return lo.SomeBy(keywords, func(keyword string) bool { + return strings.Contains(text, keyword) + }) +} + +// generateSelector generates a CSS selector for an element +func (s *WebAuthService) generateSelector(sel *goquery.Selection) string { + if id := sel.AttrOr("id", ""); id != "" { + return "#" + id + } + if name := sel.AttrOr("name", ""); name != "" { + return fmt.Sprintf(`[name="%s"]`, name) + } + if class := sel.AttrOr("class", ""); class != "" { + classes := strings.Split(class, " ") + if len(classes) > 0 { + return "." + strings.Join(classes, ".") + } + } + return sel.Get(0).Data // fallback to tag name +} + +// HTTPBasicAuthStrategy implements HTTP Basic Authentication +type HTTPBasicAuthStrategy struct{} + +func (s *HTTPBasicAuthStrategy) Name() string { return "http_basic" } +func (s *HTTPBasicAuthStrategy) Priority() int { return 10 } + +func (s *HTTPBasicAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool { + return siteInfo.StatusCode == 401 && + strings.Contains(siteInfo.Headers.Get("WWW-Authenticate"), "Basic") +} + +func (s *HTTPBasicAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) { + client := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequestWithContext(ctx, "GET", siteInfo.URL, nil) + if err != nil { + return nil, err + } + + req.SetBasicAuth(credentials.Username, credentials.Password) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + success := resp.StatusCode != 401 + return &WebAuthResult{ + Success: success, + Message: fmt.Sprintf("HTTP Basic auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]), + Cookies: resp.Cookies(), + }, nil +} + +// SmartFormAuthStrategy implements intelligent form-based authentication +type SmartFormAuthStrategy struct{} + +func (s *SmartFormAuthStrategy) Name() string { return "smart_form" } +func (s *SmartFormAuthStrategy) Priority() int { return 5 } + +func (s *SmartFormAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool { + return len(siteInfo.LoginForms) > 0 +} + +func (s *SmartFormAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) { + if len(siteInfo.LoginForms) == 0 { + return nil, fmt.Errorf("no login forms found") + } + + form := siteInfo.LoginForms[0] // Use the first form found + + // Prepare form data + formData := url.Values{} + formData.Set(form.UsernameField.Name, credentials.Username) + formData.Set(form.PasswordField.Name, credentials.Password) + + // Add submit button if it has a name + if form.SubmitButton.Name != "" { + formData.Set(form.SubmitButton.Name, "") + } + + // Determine the target URL + actionURL := form.Action + if actionURL == "" || strings.HasPrefix(actionURL, "/") { + baseURL, _ := url.Parse(siteInfo.URL) + if actionURL == "" { + actionURL = siteInfo.URL + } else { + actionURL = baseURL.Scheme + "://" + baseURL.Host + actionURL + } + } + + // Create HTTP client + client := &http.Client{Timeout: 30 * time.Second} + + // Submit the form + req, err := http.NewRequestWithContext(ctx, form.Method, actionURL, strings.NewReader(formData.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "OneTerm-WebProxy/1.0") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Check if authentication was successful + // Usually a successful login redirects or returns 200 with cookies + success := resp.StatusCode >= 200 && resp.StatusCode < 400 && len(resp.Cookies()) > 0 + + return &WebAuthResult{ + Success: success, + Message: fmt.Sprintf("Form auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]), + Cookies: resp.Cookies(), + RedirectURL: resp.Header.Get("Location"), + }, nil +} + +// APILoginAuthStrategy implements API-based authentication +type APILoginAuthStrategy struct{} + +func (s *APILoginAuthStrategy) Name() string { return "api_login" } +func (s *APILoginAuthStrategy) Priority() int { return 8 } + +func (s *APILoginAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool { + // Check for common API login endpoints + commonEndpoints := []string{"/api/login", "/auth/login", "/login", "/signin"} + baseURL, err := url.Parse(siteInfo.URL) + if err != nil { + return false + } + + client := &http.Client{Timeout: 5 * time.Second} + for _, endpoint := range commonEndpoints { + testURL := baseURL.Scheme + "://" + baseURL.Host + endpoint + resp, err := client.Head(testURL) + if err == nil && resp.StatusCode != 404 { + resp.Body.Close() + return true + } + } + return false +} + +func (s *APILoginAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) { + baseURL, err := url.Parse(siteInfo.URL) + if err != nil { + return nil, err + } + + // Try common API login endpoints + commonEndpoints := []string{"/api/login", "/auth/login", "/login", "/signin"} + + client := &http.Client{Timeout: 30 * time.Second} + + for _, endpoint := range commonEndpoints { + loginURL := baseURL.Scheme + "://" + baseURL.Host + endpoint + + // Prepare JSON payload + payload := map[string]string{ + "username": credentials.Username, + "password": credentials.Password, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + continue + } + + req, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewReader(jsonData)) + if err != nil { + continue + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OneTerm-WebProxy/1.0") + + resp, err := client.Do(req) + if err != nil { + continue + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + defer resp.Body.Close() + return &WebAuthResult{ + Success: true, + Message: "API login succeeded", + Cookies: resp.Cookies(), + }, nil + } + resp.Body.Close() + } + + return &WebAuthResult{ + Success: false, + Message: "API login failed - no valid endpoint found", + }, nil +} diff --git a/backend/internal/session/session.go b/backend/internal/session/session.go index 36cc398..0fb6f3e 100644 --- a/backend/internal/session/session.go +++ b/backend/internal/session/session.go @@ -132,6 +132,10 @@ type Session struct { // SSH connection reuse for file transfers SSHClient *gossh.Client `json:"-" gorm:"-"` sshMutex sync.RWMutex `json:"-" gorm:"-"` + + // Web session support + WebSession interface{} `json:"-" gorm:"-"` + Permissions *model.AuthPermissions `json:"-" gorm:"-"` } func (m *Session) HasMonitors() (has bool) { @@ -206,3 +210,23 @@ func (s *Session) HasSSHClient() bool { defer s.sshMutex.RUnlock() return s.SSHClient != nil } + +// SetWebSession stores a Web session object +func (s *Session) SetWebSession(webSession interface{}) { + s.WebSession = webSession +} + +// GetWebSession returns the stored Web session object +func (s *Session) GetWebSession() interface{} { + return s.WebSession +} + +// SetPermissions stores the user permissions for this session +func (s *Session) SetPermissions(permissions *model.AuthPermissions) { + s.Permissions = permissions +} + +// GetPermissions returns the stored permissions +func (s *Session) GetPermissions() *model.AuthPermissions { + return s.Permissions +}