mirror of
https://github.com/veops/oneterm.git
synced 2025-10-16 04:10:39 +08:00
feat(backend): web proxy
This commit is contained in:
@@ -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
|
||||
|
@@ -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=
|
||||
|
@@ -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...)
|
||||
|
@@ -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 {
|
||||
|
@@ -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 }),
|
||||
|
752
backend/internal/api/controller/web_proxy.go
Normal file
752
backend/internal/api/controller/web_proxy.go
Normal file
@@ -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(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Session Expired - OneTerm</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
width: 100%%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.icon { font-size: 4rem; margin-bottom: 20px; display: block; }
|
||||
.title { color: #333; font-size: 1.5rem; font-weight: 600; margin-bottom: 16px; }
|
||||
.message { color: #666; font-size: 1rem; line-height: 1.5; margin-bottom: 24px; }
|
||||
.reason {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #ffa726;
|
||||
padding: 12px 16px;
|
||||
margin: 20px 0;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.button {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.button:hover { background: #5a6fd8; transform: translateY(-1px); }
|
||||
.footer { margin-top: 24px; font-size: 0.8rem; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<span class="icon">⏰</span>
|
||||
<div class="title">Session Expired</div>
|
||||
<div class="message">Your web proxy session has expired and you need to reconnect.</div>
|
||||
<div class="reason">Reason: %s</div>
|
||||
<a href="javascript:history.back()" class="button">← Go Back</a>
|
||||
<div class="footer">OneTerm Bastion Host</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, 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(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>External Redirect Blocked</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.blocked { color: #e74c3c; }
|
||||
.info { color: #666; margin: 20px 0; }
|
||||
.target {
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="blocked">🛡️ External Redirect Blocked</h1>
|
||||
<div class="info">
|
||||
The target website attempted to redirect you to an external domain,
|
||||
which has been blocked by the bastion host for security reasons.
|
||||
</div>
|
||||
<div class="info"><strong>Target URL:</strong></div>
|
||||
<div class="target">%s</div>
|
||||
<div class="info">
|
||||
All web access must go through the bastion host to maintain security
|
||||
and audit compliance. External redirects are not permitted.
|
||||
</div>
|
||||
<div class="info">
|
||||
<a href="javascript:history.back()">← Go Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, 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: <form action="http://example.com/path"
|
||||
{
|
||||
`(action\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)
|
||||
},
|
||||
},
|
||||
// Link hrefs: <a href="http://example.com/path"
|
||||
{
|
||||
`(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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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"
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
71
backend/internal/connector/protocols/web.go
Normal file
71
backend/internal/connector/protocols/web.go
Normal file
@@ -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
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
497
backend/internal/service/web_auth.go
Normal file
497
backend/internal/service/web_auth.go
Normal file
@@ -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
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user