mirror of
https://github.com/veops/oneterm.git
synced 2025-09-26 19:31:14 +08:00
refactor(web_proxy): migrate from header-based to parameter-based session management
This commit is contained in:
@@ -53,6 +53,7 @@ require github.com/PuerkitoBio/goquery v1.10.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/azure-pipeline-go v0.2.3 // indirect
|
github.com/Azure/azure-pipeline-go v0.2.3 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
@@ -26,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/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 h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
|
||||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
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/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 h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
|
@@ -31,7 +31,6 @@ func StartSessionCleanupRoutine() {
|
|||||||
|
|
||||||
func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason string) {
|
func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason string) {
|
||||||
html := web_proxy.RenderSessionExpiredPage(reason)
|
html := web_proxy.RenderSessionExpiredPage(reason)
|
||||||
ctx.SetCookie("oneterm_session_id", "", -1, "/", "", false, true)
|
|
||||||
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
ctx.String(http.StatusUnauthorized, html)
|
ctx.String(http.StatusUnauthorized, html)
|
||||||
}
|
}
|
||||||
@@ -146,13 +145,15 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate session and check permissions
|
// Validate session and check permissions
|
||||||
if err := web_proxy.ValidateSessionAndPermissions(ctx, proxyCtx, c.checkWebAccessControls); err != nil {
|
if !proxyCtx.IsStaticResource {
|
||||||
if strings.Contains(err.Error(), "invalid or expired session") || strings.Contains(err.Error(), "session expired") {
|
if err := web_proxy.ValidateSessionAndPermissions(ctx, proxyCtx, c.checkWebAccessControls); err != nil {
|
||||||
c.renderSessionExpiredPage(ctx, err.Error())
|
if strings.Contains(err.Error(), "invalid or expired session") || strings.Contains(err.Error(), "session expired") {
|
||||||
} else {
|
c.renderSessionExpiredPage(ctx, err.Error())
|
||||||
c.renderErrorPage(ctx, "access_denied", "Access Denied", err.Error(), "Your request was blocked by the security policy.")
|
} else {
|
||||||
|
c.renderErrorPage(ctx, "access_denied", "Access Denied", err.Error(), "Your request was blocked by the security policy.")
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup reverse proxy
|
// Setup reverse proxy
|
||||||
@@ -162,6 +163,10 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if proxy == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx.Header("Cache-Control", "no-cache")
|
ctx.Header("Cache-Control", "no-cache")
|
||||||
|
|
||||||
// Add panic recovery for proxy requests
|
// Add panic recovery for proxy requests
|
||||||
|
@@ -21,26 +21,22 @@ func SetupRouter(r *gin.Engine) {
|
|||||||
// Start web session cleanup routine
|
// Start web session cleanup routine
|
||||||
controller.StartSessionCleanupRoutine()
|
controller.StartSessionCleanupRoutine()
|
||||||
|
|
||||||
// Fixed webproxy subdomain middleware
|
|
||||||
webProxy := controller.NewWebProxyController()
|
webProxy := controller.NewWebProxyController()
|
||||||
r.Use(func(c *gin.Context) {
|
r.Use(func(c *gin.Context) {
|
||||||
host := c.Request.Host
|
host := c.Request.Host
|
||||||
|
|
||||||
// Check if this is the webproxy subdomain request
|
// Check if this is the webproxy subdomain request
|
||||||
if strings.HasPrefix(host, "webproxy.") {
|
if strings.HasPrefix(host, "webproxy.") {
|
||||||
// Allow API requests to pass through to normal routing
|
|
||||||
if strings.HasPrefix(c.Request.URL.Path, "/api/oneterm/v1/") {
|
if strings.HasPrefix(c.Request.URL.Path, "/api/oneterm/v1/") {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle external redirect requests
|
|
||||||
if c.Request.URL.Path == "/external" {
|
if c.Request.URL.Path == "/external" {
|
||||||
webProxy.HandleExternalRedirect(c)
|
webProxy.HandleExternalRedirect(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle normal proxy requests
|
|
||||||
webProxy.ProxyWebRequest(c)
|
webProxy.ProxyWebRequest(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
530
backend/internal/service/web_proxy/api.go
Normal file
530
backend/internal/service/web_proxy/api.go
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
package web_proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/veops/oneterm/internal/acl"
|
||||||
|
"github.com/veops/oneterm/internal/model"
|
||||||
|
"github.com/veops/oneterm/internal/service"
|
||||||
|
gsession "github.com/veops/oneterm/internal/session"
|
||||||
|
"github.com/veops/oneterm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyRequestContext struct {
|
||||||
|
SessionID string
|
||||||
|
AssetID int
|
||||||
|
Session *WebProxySession
|
||||||
|
Host string
|
||||||
|
IsStaticResource bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartWebSession starts a web session - compatible with existing API
|
||||||
|
func StartWebSession(ctx *gin.Context, req StartWebSessionRequest) (*StartWebSessionResponse, error) {
|
||||||
|
assetService := service.NewAssetService()
|
||||||
|
asset, err := assetService.GetById(ctx, req.AssetId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("asset not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !asset.IsWebAsset() {
|
||||||
|
return nil, fmt.Errorf("asset is not a web asset")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, err := acl.GetSessionFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
authService := service.NewAuthorizationV2Service()
|
||||||
|
authResult, err := authService.GetAssetPermissions(ctx, req.AssetId, req.AccountId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check permissions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has connect permission
|
||||||
|
connectResult, exists := authResult.Results[model.ActionConnect]
|
||||||
|
if !exists || !connectResult.Allowed {
|
||||||
|
reason := "access denied"
|
||||||
|
if exists && connectResult.Reason != "" {
|
||||||
|
reason = connectResult.Reason
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("connection not allowed: %s", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions := &SessionPermissions{
|
||||||
|
CanRead: true,
|
||||||
|
CanWrite: true,
|
||||||
|
CanDownload: false,
|
||||||
|
CanUpload: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check specific action permissions
|
||||||
|
if downloadResult, exists := authResult.Results[model.ActionFileDownload]; exists && downloadResult.Allowed {
|
||||||
|
permissions.CanDownload = true
|
||||||
|
}
|
||||||
|
if uploadResult, exists := authResult.Results[model.ActionFileUpload]; exists && uploadResult.Allowed {
|
||||||
|
permissions.CanUpload = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply access policy restrictions from asset configuration
|
||||||
|
if asset.WebConfig != nil && asset.WebConfig.AccessPolicy == "read_only" {
|
||||||
|
permissions.CanWrite = false
|
||||||
|
permissions.CanUpload = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check concurrent connections limit
|
||||||
|
if asset.WebConfig != nil && asset.WebConfig.ProxySettings != nil && asset.WebConfig.ProxySettings.MaxConcurrent > 0 {
|
||||||
|
activeCount := GetActiveSessionsForAsset(req.AssetId)
|
||||||
|
if activeCount >= asset.WebConfig.ProxySettings.MaxConcurrent {
|
||||||
|
return nil, fmt.Errorf("maximum concurrent connections (%d) exceeded", asset.WebConfig.ProxySettings.MaxConcurrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetHost := getAssetHost(asset)
|
||||||
|
|
||||||
|
protocol, port := asset.GetWebProtocol()
|
||||||
|
if protocol == "" {
|
||||||
|
protocol = "http"
|
||||||
|
port = 80
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := GetCore().CreateSessionWithProtocol(req.AssetId, targetHost, currentUser.GetUserName(), permissions, protocol, port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate proxy URL
|
||||||
|
baseDomain := strings.Split(ctx.Request.Host, ":")[0]
|
||||||
|
scheme := "http"
|
||||||
|
if ctx.GetHeader("X-Forwarded-Proto") == "https" || ctx.Request.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
portSuffix := ""
|
||||||
|
if strings.Contains(ctx.Request.Host, ":") {
|
||||||
|
portSuffix = ":" + strings.Split(ctx.Request.Host, ":")[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
webproxyHost := fmt.Sprintf("webproxy.%s%s", baseDomain, portSuffix)
|
||||||
|
proxyURL := fmt.Sprintf("%s://%s/?asset_id=%d&session_id=%s", scheme, webproxyHost, req.AssetId, session.ID)
|
||||||
|
|
||||||
|
protocolStr := fmt.Sprintf("%s:%d", protocol, port)
|
||||||
|
dbSession := &model.Session{
|
||||||
|
SessionType: model.SESSIONTYPE_WEB,
|
||||||
|
SessionId: session.ID,
|
||||||
|
Uid: currentUser.GetUid(),
|
||||||
|
UserName: currentUser.GetUserName(),
|
||||||
|
AssetId: asset.Id,
|
||||||
|
AssetInfo: fmt.Sprintf("%s(%s)", asset.Name, asset.Ip),
|
||||||
|
AccountId: req.AccountId,
|
||||||
|
AccountInfo: "",
|
||||||
|
GatewayId: asset.GatewayId,
|
||||||
|
GatewayInfo: "",
|
||||||
|
ClientIp: ctx.ClientIP(),
|
||||||
|
Protocol: protocolStr,
|
||||||
|
Status: model.SESSIONSTATUS_ONLINE,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if asset.GatewayId > 0 {
|
||||||
|
dbSession.GatewayInfo = fmt.Sprintf("Gateway_%d", asset.GatewayId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fullSession := &gsession.Session{Session: dbSession}
|
||||||
|
if err := gsession.UpsertSession(fullSession); err != nil {
|
||||||
|
logger.L().Error("Failed to save web session to database", zap.String("sessionId", session.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StartWebSessionResponse{
|
||||||
|
SessionId: session.ID,
|
||||||
|
ProxyURL: proxyURL,
|
||||||
|
Message: "Web session started successfully",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractSessionAndAssetInfo compatible with existing controller
|
||||||
|
func ExtractSessionAndAssetInfo(ctx *gin.Context, extractAssetIDFromHost func(string) (int, error)) (*ProxyRequestContext, error) {
|
||||||
|
reqCtx, err := GetCore().ParseRequestContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// For static resources, if session is found, use it directly
|
||||||
|
if reqCtx.IsStatic && reqCtx.Session != nil {
|
||||||
|
assetService := service.NewAssetService()
|
||||||
|
asset, err := assetService.GetById(ctx, reqCtx.Session.AssetID)
|
||||||
|
if err == nil {
|
||||||
|
webProxySession := &WebProxySession{
|
||||||
|
SessionId: reqCtx.Session.ID,
|
||||||
|
AssetId: reqCtx.Session.AssetID,
|
||||||
|
AccountId: -1,
|
||||||
|
Asset: asset,
|
||||||
|
CreatedAt: reqCtx.Session.CreatedAt,
|
||||||
|
LastActivity: reqCtx.Session.LastActivity,
|
||||||
|
IsActive: reqCtx.Session.IsActive,
|
||||||
|
CurrentHost: reqCtx.Session.CurrentHost,
|
||||||
|
SessionPerms: reqCtx.Session.Permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProxyRequestContext{
|
||||||
|
SessionID: reqCtx.Session.ID,
|
||||||
|
AssetID: reqCtx.Session.AssetID,
|
||||||
|
Session: webProxySession,
|
||||||
|
Host: reqCtx.ProxyHost,
|
||||||
|
IsStaticResource: reqCtx.IsStatic,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reqCtx.IsStatic && reqCtx.Session == nil {
|
||||||
|
return &ProxyRequestContext{
|
||||||
|
SessionID: reqCtx.SessionID,
|
||||||
|
AssetID: reqCtx.AssetID,
|
||||||
|
Session: nil,
|
||||||
|
Host: reqCtx.ProxyHost,
|
||||||
|
IsStaticResource: reqCtx.IsStatic,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get asset information
|
||||||
|
assetService := service.NewAssetService()
|
||||||
|
asset, err := assetService.GetById(ctx, reqCtx.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get asset info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert WebSession to WebProxySession (compatibility layer)
|
||||||
|
webProxySession := &WebProxySession{
|
||||||
|
SessionId: reqCtx.Session.ID,
|
||||||
|
AssetId: reqCtx.Session.AssetID,
|
||||||
|
AccountId: -1,
|
||||||
|
Asset: asset, // Set asset information
|
||||||
|
CreatedAt: reqCtx.Session.CreatedAt,
|
||||||
|
LastActivity: reqCtx.Session.LastActivity,
|
||||||
|
IsActive: reqCtx.Session.IsActive,
|
||||||
|
CurrentHost: reqCtx.Session.CurrentHost,
|
||||||
|
SessionPerms: reqCtx.Session.Permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProxyRequestContext{
|
||||||
|
SessionID: reqCtx.SessionID,
|
||||||
|
AssetID: reqCtx.AssetID,
|
||||||
|
Session: webProxySession,
|
||||||
|
Host: reqCtx.ProxyHost,
|
||||||
|
IsStaticResource: reqCtx.IsStatic,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSessionAndPermissions compatible with existing controller
|
||||||
|
func ValidateSessionAndPermissions(ctx *gin.Context, proxyCtx *ProxyRequestContext, checkWebAccessControls func(*gin.Context, *WebProxySession) error) error {
|
||||||
|
if !proxyCtx.IsStaticResource {
|
||||||
|
GetCore().UpdateSessionActivity(proxyCtx.SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkWebAccessControls(ctx, proxyCtx.Session); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupReverseProxy compatible with existing controller
|
||||||
|
func SetupReverseProxy(ctx *gin.Context, proxyCtx *ProxyRequestContext, buildTargetURLWithHost func(*model.Asset, string) string, processHTMLResponse func(*http.Response, int, string, string, *WebProxySession), recordWebActivity func(*WebProxySession, *http.Request), isSameDomainOrSubdomain func(string, string) bool) (*httputil.ReverseProxy, error) {
|
||||||
|
|
||||||
|
if proxyCtx.IsStaticResource && proxyCtx.Session == nil {
|
||||||
|
ctx.Status(404)
|
||||||
|
ctx.String(404, "Static resource not available")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get asset information to determine protocol and port
|
||||||
|
var targetScheme string = "http"
|
||||||
|
var targetPort int = 80
|
||||||
|
|
||||||
|
if proxyCtx.Session != nil && proxyCtx.Session.Asset != nil {
|
||||||
|
protocol, port := proxyCtx.Session.Asset.GetWebProtocol()
|
||||||
|
if protocol != "" {
|
||||||
|
targetScheme = protocol
|
||||||
|
targetPort = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached permissions from session (set during Start phase for performance)
|
||||||
|
permissions := proxyCtx.Session.SessionPerms
|
||||||
|
if permissions == nil {
|
||||||
|
return nil, fmt.Errorf("session permissions not initialized - please restart session")
|
||||||
|
}
|
||||||
|
|
||||||
|
webSession := &WebSession{
|
||||||
|
ID: proxyCtx.Session.SessionId,
|
||||||
|
AssetID: proxyCtx.Session.AssetId,
|
||||||
|
AssetHost: proxyCtx.Session.CurrentHost,
|
||||||
|
UserID: "webproxy_user",
|
||||||
|
CreatedAt: proxyCtx.Session.CreatedAt,
|
||||||
|
LastActivity: proxyCtx.Session.LastActivity,
|
||||||
|
IsActive: proxyCtx.Session.IsActive,
|
||||||
|
CurrentHost: proxyCtx.Session.CurrentHost,
|
||||||
|
TargetScheme: targetScheme,
|
||||||
|
TargetPort: targetPort,
|
||||||
|
Permissions: permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx := &RequestContext{
|
||||||
|
SessionID: proxyCtx.SessionID,
|
||||||
|
AssetID: proxyCtx.AssetID,
|
||||||
|
Session: webSession,
|
||||||
|
IsStatic: proxyCtx.IsStaticResource,
|
||||||
|
OriginalURL: ctx.Request.URL.String(),
|
||||||
|
ProxyHost: proxyCtx.Host,
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetCore().CreateReverseProxy(reqCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAssetHost(asset *model.Asset) string {
|
||||||
|
protocol, port := asset.GetWebProtocol()
|
||||||
|
if protocol == "" {
|
||||||
|
protocol = "http"
|
||||||
|
port = 80
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(asset.Ip, ":") {
|
||||||
|
return asset.Ip
|
||||||
|
}
|
||||||
|
|
||||||
|
if (protocol == "http" && port == 80) || (protocol == "https" && port == 443) {
|
||||||
|
return asset.Ip
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s:%d", asset.Ip, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractAssetIDFromHost(host string) (int, error) {
|
||||||
|
return 0, fmt.Errorf("not supported in fixed webproxy subdomain approach")
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTargetURLWithHost(asset *model.Asset, host string) string {
|
||||||
|
protocol, port := asset.GetWebProtocol()
|
||||||
|
if protocol == "" {
|
||||||
|
protocol = "http"
|
||||||
|
port = 80
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
return fmt.Sprintf("%s://%s", protocol, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSameDomain checks if two hosts belong to the same domain
|
||||||
|
func isSameDomain(host1, host2 string) bool {
|
||||||
|
if host1 == host2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
host1 = strings.Split(host1, ":")[0]
|
||||||
|
host2 = strings.Split(host2, ":")[0]
|
||||||
|
|
||||||
|
parts1 := strings.Split(host1, ".")
|
||||||
|
parts2 := strings.Split(host2, ".")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSameDomainOrSubdomain compatible with existing controller
|
||||||
|
func IsSameDomainOrSubdomain(host1, host2 string) bool {
|
||||||
|
return isSameDomain(host1, host2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckWebAccessControls(ctx *gin.Context, session *WebProxySession) error {
|
||||||
|
if session == nil || session.Asset == nil {
|
||||||
|
return fmt.Errorf("invalid session or asset")
|
||||||
|
}
|
||||||
|
|
||||||
|
method := strings.ToUpper(ctx.Request.Method)
|
||||||
|
requestPath := ctx.Request.URL.Path
|
||||||
|
|
||||||
|
// Check access policy restrictions
|
||||||
|
if session.Asset.WebConfig != nil {
|
||||||
|
if session.Asset.WebConfig.AccessPolicy == "read_only" {
|
||||||
|
// Only allow safe HTTP methods for read-only access
|
||||||
|
allowedReadOnlyMethods := []string{"GET", "HEAD", "OPTIONS"}
|
||||||
|
allowed := false
|
||||||
|
for _, allowedMethod := range allowedReadOnlyMethods {
|
||||||
|
if method == allowedMethod {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return fmt.Errorf("method %s not allowed in read-only mode", method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check proxy settings if available
|
||||||
|
if session.Asset.WebConfig.ProxySettings != nil {
|
||||||
|
proxySettings := session.Asset.WebConfig.ProxySettings
|
||||||
|
|
||||||
|
// Check allowed HTTP methods
|
||||||
|
if len(proxySettings.AllowedMethods) > 0 {
|
||||||
|
methodAllowed := false
|
||||||
|
for _, allowedMethod := range proxySettings.AllowedMethods {
|
||||||
|
if strings.ToUpper(allowedMethod) == method {
|
||||||
|
methodAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !methodAllowed {
|
||||||
|
return fmt.Errorf("HTTP method %s is not allowed", method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check blocked paths
|
||||||
|
if len(proxySettings.BlockedPaths) > 0 {
|
||||||
|
for _, blockedPath := range proxySettings.BlockedPaths {
|
||||||
|
if strings.HasPrefix(requestPath, blockedPath) {
|
||||||
|
return fmt.Errorf("access to path %s is blocked", requestPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordWebActivity compatible with existing controller
|
||||||
|
func RecordWebActivity(sessionId string, ctx *gin.Context) {
|
||||||
|
// Placeholder implementation for recording activity
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessHTMLResponse compatible with existing controller
|
||||||
|
func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost string, session *WebProxySession) {
|
||||||
|
webSession := &WebSession{
|
||||||
|
ID: session.SessionId,
|
||||||
|
AssetID: session.AssetId,
|
||||||
|
CreatedAt: session.CreatedAt,
|
||||||
|
LastActivity: session.LastActivity,
|
||||||
|
IsActive: session.IsActive,
|
||||||
|
CurrentHost: session.CurrentHost,
|
||||||
|
// Simplified permission conversion
|
||||||
|
Permissions: &SessionPermissions{
|
||||||
|
CanRead: true, // Default allow read
|
||||||
|
CanWrite: true, // Default allow write
|
||||||
|
CanDownload: true, // Default allow download
|
||||||
|
CanUpload: true, // Default allow upload
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx := &RequestContext{
|
||||||
|
AssetID: assetID,
|
||||||
|
SessionID: session.SessionId,
|
||||||
|
Session: webSession,
|
||||||
|
ProxyHost: proxyHost,
|
||||||
|
OriginalURL: fmt.Sprintf("%s://%s", scheme, proxyHost),
|
||||||
|
}
|
||||||
|
processHTMLContent(resp, reqCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render functions
|
||||||
|
func RenderSessionExpiredPage(reason string) string {
|
||||||
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Session Expired - OneTerm</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
|
||||||
|
.container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; }
|
||||||
|
.title { color: #d32f2f; font-size: 24px; margin-bottom: 20px; }
|
||||||
|
.message { color: #666; line-height: 1.6; }
|
||||||
|
.btn { background: #1976d2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">🕐 Session Expired</h1>
|
||||||
|
<div class="message">%s</div>
|
||||||
|
<p><a href="javascript:history.back()" class="btn">← Go Back</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderErrorPage(errorType, title, reason, details string) string {
|
||||||
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>%s - OneTerm</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; }
|
||||||
|
.title { color: #d32f2f; font-size: 24px; margin-bottom: 20px; }
|
||||||
|
.message { color: #666; line-height: 1.6; margin-bottom: 20px; }
|
||||||
|
.details { background: #f8f9fa; padding: 15px; border-left: 4px solid #d32f2f; font-family: monospace; }
|
||||||
|
.btn { background: #1976d2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; margin-right: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">❌ %s</h1>
|
||||||
|
<div class="message">%s</div>
|
||||||
|
%s
|
||||||
|
<p>
|
||||||
|
<a href="javascript:history.back()" class="btn">← Go Back</a>
|
||||||
|
<a href="javascript:location.reload()" class="btn">🔄 Refresh</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, title, title, reason,
|
||||||
|
func() string {
|
||||||
|
if details != "" {
|
||||||
|
return fmt.Sprintf(`<div class="details">%s</div>`, details)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}())
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderAccessDeniedPage(reason, details string) string {
|
||||||
|
return RenderErrorPage("access_denied", "Access Denied", reason, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderExternalRedirectPage(targetURL string) string {
|
||||||
|
return RenderErrorPage("external_redirect", "External Redirect Blocked",
|
||||||
|
fmt.Sprintf("Target URL: %s", targetURL),
|
||||||
|
"External redirects are blocked for security reasons")
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
648
backend/internal/service/web_proxy/core.go
Normal file
648
backend/internal/service/web_proxy/core.go
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
package web_proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/veops/oneterm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebProxyCore struct {
|
||||||
|
sessions sync.Map // session storage
|
||||||
|
config *ProxyConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyConfig struct {
|
||||||
|
SessionTimeout time.Duration
|
||||||
|
MaxSessions int
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebSession struct {
|
||||||
|
ID string
|
||||||
|
AssetID int
|
||||||
|
AssetHost string
|
||||||
|
UserID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastActivity time.Time
|
||||||
|
IsActive bool
|
||||||
|
|
||||||
|
// Proxy state
|
||||||
|
CurrentHost string
|
||||||
|
TargetScheme string
|
||||||
|
TargetPort int
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
Permissions *SessionPermissions
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionPermissions struct {
|
||||||
|
CanRead bool
|
||||||
|
CanWrite bool
|
||||||
|
CanDownload bool
|
||||||
|
CanUpload bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestContext struct {
|
||||||
|
SessionID string
|
||||||
|
AssetID int
|
||||||
|
Session *WebSession
|
||||||
|
IsStatic bool
|
||||||
|
OriginalURL string
|
||||||
|
ProxyHost string
|
||||||
|
TargetURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalCore *WebProxyCore
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
globalCore = &WebProxyCore{
|
||||||
|
config: &ProxyConfig{
|
||||||
|
SessionTimeout: 30 * time.Minute,
|
||||||
|
MaxSessions: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCore returns the global proxy core instance
|
||||||
|
func GetCore() *WebProxyCore {
|
||||||
|
return globalCore
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession creates a new web session with default HTTP protocol
|
||||||
|
func (c *WebProxyCore) CreateSession(assetID int, assetHost, userID string, permissions *SessionPermissions) (*WebSession, error) {
|
||||||
|
return c.CreateSessionWithProtocol(assetID, assetHost, userID, permissions, "http", 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSessionWithProtocol creates a new web session with specified protocol and port
|
||||||
|
func (c *WebProxyCore) CreateSessionWithProtocol(assetID int, assetHost, userID string, permissions *SessionPermissions, scheme string, port int) (*WebSession, error) {
|
||||||
|
// Generate unique session ID: web_{assetId}_{timestamp}
|
||||||
|
sessionID := fmt.Sprintf("web_%d_%d", assetID, time.Now().UnixMicro())
|
||||||
|
|
||||||
|
session := &WebSession{
|
||||||
|
ID: sessionID,
|
||||||
|
AssetID: assetID,
|
||||||
|
AssetHost: assetHost,
|
||||||
|
UserID: userID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
LastActivity: time.Now(),
|
||||||
|
IsActive: true,
|
||||||
|
CurrentHost: assetHost,
|
||||||
|
TargetScheme: scheme,
|
||||||
|
TargetPort: port,
|
||||||
|
Permissions: permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sessions.Store(sessionID, session)
|
||||||
|
|
||||||
|
// Store in legacy global session storage for compatibility
|
||||||
|
webProxySession := &WebProxySession{
|
||||||
|
SessionId: sessionID,
|
||||||
|
AssetId: assetID,
|
||||||
|
CreatedAt: session.CreatedAt,
|
||||||
|
LastActivity: session.LastActivity,
|
||||||
|
LastHeartbeat: time.Now(),
|
||||||
|
IsActive: true,
|
||||||
|
CurrentHost: assetHost,
|
||||||
|
SessionPerms: permissions, // Cache permissions for proxy phase
|
||||||
|
}
|
||||||
|
StoreSession(sessionID, webProxySession)
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession retrieves a session by ID and checks if it's expired
|
||||||
|
func (c *WebProxyCore) GetSession(sessionID string) (*WebSession, bool) {
|
||||||
|
if val, ok := c.sessions.Load(sessionID); ok {
|
||||||
|
session := val.(*WebSession)
|
||||||
|
|
||||||
|
if time.Since(session.LastActivity) > c.config.SessionTimeout {
|
||||||
|
c.CloseSession(sessionID)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSessionActivity updates the last activity time for a session
|
||||||
|
func (c *WebProxyCore) UpdateSessionActivity(sessionID string) {
|
||||||
|
if val, ok := c.sessions.Load(sessionID); ok {
|
||||||
|
session := val.(*WebSession)
|
||||||
|
session.LastActivity = time.Now()
|
||||||
|
c.sessions.Store(sessionID, session)
|
||||||
|
|
||||||
|
if oldSession, exists := GetSession(sessionID); exists {
|
||||||
|
oldSession.LastActivity = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSessionHost updates the current host for a session to handle redirects
|
||||||
|
func (c *WebProxyCore) UpdateSessionHost(sessionID string, newHost string) {
|
||||||
|
if val, ok := c.sessions.Load(sessionID); ok {
|
||||||
|
session := val.(*WebSession)
|
||||||
|
session.CurrentHost = newHost
|
||||||
|
c.sessions.Store(sessionID, session)
|
||||||
|
|
||||||
|
if oldSession, exists := GetSession(sessionID); exists {
|
||||||
|
oldSession.CurrentHost = newHost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseSession closes and removes a session
|
||||||
|
func (c *WebProxyCore) CloseSession(sessionID string) {
|
||||||
|
if val, ok := c.sessions.Load(sessionID); ok {
|
||||||
|
session := val.(*WebSession)
|
||||||
|
session.IsActive = false
|
||||||
|
c.sessions.Delete(sessionID)
|
||||||
|
|
||||||
|
CloseWebSession(sessionID)
|
||||||
|
|
||||||
|
logger.L().Info("Web session closed", zap.String("sessionId", sessionID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveSessionsForAsset returns the number of active sessions for an asset
|
||||||
|
func (c *WebProxyCore) GetActiveSessionsForAsset(assetID int) int {
|
||||||
|
count := 0
|
||||||
|
c.sessions.Range(func(key, value any) bool {
|
||||||
|
session := value.(*WebSession)
|
||||||
|
if session.AssetID == assetID && session.IsActive {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRequestContext extracts session and asset information from request
|
||||||
|
func (c *WebProxyCore) ParseRequestContext(ctx *gin.Context) (*RequestContext, error) {
|
||||||
|
var sessionID string
|
||||||
|
var assetID int
|
||||||
|
|
||||||
|
isStatic := c.isStaticResource(ctx.Request.URL.Path)
|
||||||
|
|
||||||
|
// 1. Extract from URL parameters first (supports both static and non-static resources)
|
||||||
|
sessionID = ctx.Query("session_id")
|
||||||
|
assetIDStr := ctx.Query("asset_id")
|
||||||
|
targetHost := ctx.Query("target_host")
|
||||||
|
|
||||||
|
if sessionID != "" && assetIDStr != "" {
|
||||||
|
if id, err := strconv.Atoi(assetIDStr); err == nil {
|
||||||
|
assetID = id
|
||||||
|
} else {
|
||||||
|
logger.L().Warn("Failed to parse asset_id", zap.String("assetIDStr", assetIDStr), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For static resources, prioritize Referer over all other fallbacks
|
||||||
|
if isStatic && (sessionID == "" || assetID == 0) {
|
||||||
|
refererSessionID, refererAssetID := c.extractFromReferer(ctx.GetHeader("Referer"))
|
||||||
|
|
||||||
|
// For static resources, Referer is the most reliable source - use it if available
|
||||||
|
if refererSessionID != "" && refererAssetID != 0 {
|
||||||
|
sessionID = refererSessionID
|
||||||
|
assetID = refererAssetID
|
||||||
|
} else {
|
||||||
|
// If Referer extraction fails completely, return empty context
|
||||||
|
// This prevents random session mixing which causes wrong asset_id usage
|
||||||
|
return &RequestContext{
|
||||||
|
SessionID: "",
|
||||||
|
AssetID: 0,
|
||||||
|
Session: nil,
|
||||||
|
IsStatic: true,
|
||||||
|
OriginalURL: ctx.Request.URL.String(),
|
||||||
|
ProxyHost: ctx.Request.Host,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If URL parameters are incomplete, try to parse asset ID from session ID
|
||||||
|
if assetID == 0 && sessionID != "" {
|
||||||
|
assetID = c.extractAssetIDFromSession(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If still missing, try to extract from Referer (non-static resources)
|
||||||
|
if !isStatic && (sessionID == "" || assetID == 0) {
|
||||||
|
refererSessionID, refererAssetID := c.extractFromReferer(ctx.GetHeader("Referer"))
|
||||||
|
if refererSessionID != "" {
|
||||||
|
sessionID = refererSessionID
|
||||||
|
}
|
||||||
|
if refererAssetID != 0 {
|
||||||
|
assetID = refererAssetID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Static resources without proper session context should be handled by fallback
|
||||||
|
// Removed: dangerous random session selection that causes cross-session pollution
|
||||||
|
|
||||||
|
// 5. For non-static resources, validate parameter completeness
|
||||||
|
if !isStatic && (sessionID == "" || assetID == 0) {
|
||||||
|
logger.L().Error("Missing parameters after all extraction attempts",
|
||||||
|
zap.String("sessionID", sessionID),
|
||||||
|
zap.Int("assetID", assetID))
|
||||||
|
return nil, fmt.Errorf("missing session_id or asset_id parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Get session (static resources may not have session)
|
||||||
|
var session *WebSession
|
||||||
|
if sessionID != "" {
|
||||||
|
if sess, exists := c.GetSession(sessionID); exists {
|
||||||
|
session = sess
|
||||||
|
// 6. Validate asset matching (only when session exists)
|
||||||
|
if session.AssetID != assetID {
|
||||||
|
return nil, fmt.Errorf("asset mismatch: session=%d, request=%d", session.AssetID, assetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. If target_host is specified in URL, update session's CurrentHost (for redirect handling)
|
||||||
|
if targetHost != "" {
|
||||||
|
session.CurrentHost = targetHost
|
||||||
|
}
|
||||||
|
} else if !isStatic {
|
||||||
|
return nil, fmt.Errorf("invalid or expired session: %s", sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RequestContext{
|
||||||
|
SessionID: sessionID,
|
||||||
|
AssetID: assetID,
|
||||||
|
Session: session,
|
||||||
|
IsStatic: isStatic,
|
||||||
|
OriginalURL: ctx.Request.URL.String(),
|
||||||
|
ProxyHost: ctx.Request.Host,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAssetIDFromSession extracts asset ID from session ID format
|
||||||
|
func (c *WebProxyCore) extractAssetIDFromSession(sessionID string) int {
|
||||||
|
parts := strings.Split(sessionID, "_")
|
||||||
|
if len(parts) >= 2 && parts[0] == "web" {
|
||||||
|
if assetID, err := strconv.Atoi(parts[1]); err == nil {
|
||||||
|
return assetID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFromReferer extracts session information from Referer header
|
||||||
|
func (c *WebProxyCore) extractFromReferer(referer string) (string, int) {
|
||||||
|
if referer == "" {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if refURL, err := url.Parse(referer); err == nil {
|
||||||
|
sessionID := refURL.Query().Get("session_id")
|
||||||
|
assetIDStr := refURL.Query().Get("asset_id")
|
||||||
|
|
||||||
|
assetID := 0
|
||||||
|
if assetIDStr != "" {
|
||||||
|
assetID, _ = strconv.Atoi(assetIDStr)
|
||||||
|
} else if sessionID != "" {
|
||||||
|
assetID = c.extractAssetIDFromSession(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionID, assetID
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// isStaticResource checks if the path refers to a static resource
|
||||||
|
func (c *WebProxyCore) isStaticResource(path string) bool {
|
||||||
|
staticExts := []string{
|
||||||
|
".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
|
||||||
|
".woff", ".woff2", ".ttf", ".eot", ".mp3", ".mp4", ".pdf",
|
||||||
|
".zip", ".rar", ".doc", ".docx", ".xls", ".xlsx",
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerPath := strings.ToLower(path)
|
||||||
|
for _, ext := range staticExts {
|
||||||
|
if strings.HasSuffix(lowerPath, ext) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
staticPaths := []string{"/static/", "/assets/", "/css/", "/js/", "/img/", "/images/", "/fonts/"}
|
||||||
|
for _, staticPath := range staticPaths {
|
||||||
|
if strings.Contains(lowerPath, staticPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReverseProxy creates a reverse proxy for the session
|
||||||
|
func (c *WebProxyCore) CreateReverseProxy(reqCtx *RequestContext) (*httputil.ReverseProxy, error) {
|
||||||
|
if reqCtx.IsStatic && reqCtx.Session == nil {
|
||||||
|
return nil, fmt.Errorf("static resource request without valid session context")
|
||||||
|
}
|
||||||
|
|
||||||
|
session := reqCtx.Session
|
||||||
|
|
||||||
|
if session.TargetScheme == "" {
|
||||||
|
session.TargetScheme = "http"
|
||||||
|
session.TargetPort = 80
|
||||||
|
logger.L().Warn("Session missing target scheme, using default",
|
||||||
|
zap.String("sessionId", session.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.CurrentHost == "" {
|
||||||
|
return nil, fmt.Errorf("session has no target host configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetHost := session.CurrentHost
|
||||||
|
if targetHost == "" {
|
||||||
|
targetHost = session.AssetHost
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := net.SplitHostPort(targetHost)
|
||||||
|
hasPort := err == nil
|
||||||
|
|
||||||
|
var targetURL string
|
||||||
|
if hasPort {
|
||||||
|
targetURL = fmt.Sprintf("%s://%s", session.TargetScheme, targetHost)
|
||||||
|
} else {
|
||||||
|
targetURL = fmt.Sprintf("%s://%s", session.TargetScheme, targetHost)
|
||||||
|
if (session.TargetScheme == "http" && session.TargetPort != 80) ||
|
||||||
|
(session.TargetScheme == "https" && session.TargetPort != 443) {
|
||||||
|
targetURL = fmt.Sprintf("%s://%s:%d", session.TargetScheme, targetHost, session.TargetPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := url.Parse(targetURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid target URL: %s", targetURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx.TargetURL = targetURL
|
||||||
|
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
|
||||||
|
// Custom request handler
|
||||||
|
originalDirector := proxy.Director
|
||||||
|
proxy.Director = func(req *http.Request) {
|
||||||
|
originalDirector(req)
|
||||||
|
|
||||||
|
req.Host = target.Host
|
||||||
|
req.Header.Set("Host", target.Host)
|
||||||
|
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Del("X-Forwarded-For")
|
||||||
|
req.Header.Del("X-Forwarded-Proto")
|
||||||
|
req.Header.Del("X-Forwarded-Host")
|
||||||
|
req.Header.Del("X-Real-IP")
|
||||||
|
req.Header.Del("X-Proxy-Authorization")
|
||||||
|
req.Header.Del("Proxy-Authorization")
|
||||||
|
req.Header.Del("Proxy-Connection")
|
||||||
|
req.Header.Del("Via")
|
||||||
|
req.Header.Del("X-Proxy-Connection")
|
||||||
|
req.Header.Del("Proxy-Authenticate")
|
||||||
|
req.Header.Del("X-Forwarded-Server")
|
||||||
|
|
||||||
|
// Don't add any IP-related headers to make request look direct
|
||||||
|
// Server will see proxy's own IP, which looks more like CDN
|
||||||
|
|
||||||
|
// Add common browser headers for enhanced stealth
|
||||||
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||||
|
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||||
|
req.Header.Set("DNT", "1")
|
||||||
|
req.Header.Set("Connection", "keep-alive")
|
||||||
|
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||||
|
|
||||||
|
// Add modern browser client hints
|
||||||
|
req.Header.Set("sec-ch-ua", `"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"`)
|
||||||
|
req.Header.Set("sec-ch-ua-mobile", "?0")
|
||||||
|
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
|
||||||
|
|
||||||
|
// Simulate real browser cache control
|
||||||
|
if req.Header.Get("Cache-Control") == "" {
|
||||||
|
req.Header.Set("Cache-Control", "max-age=0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add standard browser Sec-Fetch headers (no special handling)
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||||
|
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||||
|
req.Header.Set("Sec-Fetch-User", "?1")
|
||||||
|
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||||
|
|
||||||
|
req.Header.Del("X-Forwarded-Host")
|
||||||
|
|
||||||
|
// Rewrite Origin header to match target host
|
||||||
|
if origin := req.Header.Get("Origin"); origin != "" {
|
||||||
|
req.Header.Set("Origin", target.Scheme+"://"+target.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite Referer header - critical: convert to target server URL
|
||||||
|
if referer := req.Header.Get("Referer"); referer != "" {
|
||||||
|
if refererURL, err := url.Parse(referer); err == nil {
|
||||||
|
// Convert proxy URL to target URL
|
||||||
|
refererURL.Scheme = target.Scheme
|
||||||
|
refererURL.Host = target.Host
|
||||||
|
req.Header.Set("Referer", refererURL.String())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Smart Referer setting - enhanced anti-detection
|
||||||
|
if req.URL.Path == "/" {
|
||||||
|
// Homepage request: set reasonable external referrer or leave empty
|
||||||
|
if req.Method == "GET" {
|
||||||
|
// Simulate direct access or from search engine, no Referer is more natural
|
||||||
|
// req.Header.Set("Referer", "https://www.google.com/")
|
||||||
|
}
|
||||||
|
} else if strings.Contains(req.URL.Path, "/s") && strings.Contains(req.URL.RawQuery, "wd=") {
|
||||||
|
// Search request: set homepage as Referer, normal user behavior
|
||||||
|
req.Header.Set("Referer", target.Scheme+"://"+target.Host+"/")
|
||||||
|
} else if req.URL.RawQuery != "" {
|
||||||
|
// Other requests with parameters: set homepage as Referer
|
||||||
|
req.Header.Set("Referer", target.Scheme+"://"+target.Host+"/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force CSS files to return new content instead of 304 cache, so we can process URLs in CSS
|
||||||
|
if strings.Contains(req.URL.Path, ".css") {
|
||||||
|
req.Header.Set("Cache-Control", "no-cache")
|
||||||
|
req.Header.Set("If-None-Match", "")
|
||||||
|
req.Header.Set("If-Modified-Since", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove session parameters, don't send to target server - hybrid method: precise removal while keeping other parameter encoding
|
||||||
|
if req.URL.RawQuery != "" {
|
||||||
|
q := req.URL.Query()
|
||||||
|
// Check if we have internal parameters to remove
|
||||||
|
if q.Has("session_id") || q.Has("asset_id") || q.Has("target_host") {
|
||||||
|
// Only rebuild query string when parameters need to be removed
|
||||||
|
// Use manual method to keep original encoding of non-internal parameters
|
||||||
|
var newParts []string
|
||||||
|
|
||||||
|
// Split original query string
|
||||||
|
parts := strings.Split(req.URL.RawQuery, "&")
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if this parameter is an internal parameter we need to remove
|
||||||
|
if strings.HasPrefix(part, "session_id=") ||
|
||||||
|
strings.HasPrefix(part, "asset_id=") ||
|
||||||
|
strings.HasPrefix(part, "target_host=") {
|
||||||
|
// Skip internal parameters
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newParts = append(newParts, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.URL.RawQuery = strings.Join(newParts, "&")
|
||||||
|
}
|
||||||
|
// If no session parameters, keep original RawQuery completely unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom response handler
|
||||||
|
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||||
|
return c.processResponse(resp, reqCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom error handler for network connection issues
|
||||||
|
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
|
||||||
|
logger.L().Error("Web proxy connection error",
|
||||||
|
zap.String("sessionId", reqCtx.SessionID),
|
||||||
|
zap.String("targetHost", req.Host),
|
||||||
|
zap.String("error", err.Error()))
|
||||||
|
|
||||||
|
// Analyze error type and provide appropriate user-friendly message
|
||||||
|
var errorTitle, errorReason, errorDetails string
|
||||||
|
|
||||||
|
if strings.Contains(err.Error(), "tls: failed to verify certificate") {
|
||||||
|
errorTitle = "SSL Certificate Error"
|
||||||
|
errorReason = fmt.Sprintf("The target website (%s) has an invalid SSL certificate.", req.Host)
|
||||||
|
errorDetails = "This could be due to: certificate expired/untrusted, hostname mismatch, or self-signed certificate."
|
||||||
|
} else if strings.Contains(err.Error(), "no such host") || strings.Contains(err.Error(), "dns") {
|
||||||
|
errorTitle = "DNS Resolution Failed"
|
||||||
|
errorReason = fmt.Sprintf("Cannot resolve hostname: %s", req.Host)
|
||||||
|
errorDetails = "Check if the domain name is correct. The website may be temporarily unavailable."
|
||||||
|
} else if strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "connect: connection refused") {
|
||||||
|
errorTitle = "Connection Refused"
|
||||||
|
errorReason = fmt.Sprintf("Cannot connect to %s", req.Host)
|
||||||
|
errorDetails = "The server may be down, firewall blocking, or port closed."
|
||||||
|
} else if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline exceeded") {
|
||||||
|
errorTitle = "Connection Timeout"
|
||||||
|
errorReason = fmt.Sprintf("Connection to %s timed out", req.Host)
|
||||||
|
errorDetails = "Server taking too long to respond. Network issues or server overload."
|
||||||
|
} else {
|
||||||
|
errorTitle = "Connection Error"
|
||||||
|
errorReason = fmt.Sprintf("Failed to connect to %s", req.Host)
|
||||||
|
errorDetails = fmt.Sprintf("Network error: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing RenderErrorPage function with session info appended
|
||||||
|
sessionInfo := fmt.Sprintf("Session ID: %s | Asset ID: %d | Host: %s", reqCtx.SessionID, reqCtx.AssetID, req.Host)
|
||||||
|
finalDetails := fmt.Sprintf("%s\n\n%s", errorDetails, sessionInfo)
|
||||||
|
|
||||||
|
errorHTML := RenderErrorPage("connection_error", errorTitle, errorReason, finalDetails)
|
||||||
|
|
||||||
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
rw.WriteHeader(http.StatusBadGateway)
|
||||||
|
rw.Write([]byte(errorHTML))
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processResponse processes the response and injects necessary modifications
|
||||||
|
func (c *WebProxyCore) processResponse(resp *http.Response, reqCtx *RequestContext) error {
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
|
||||||
|
// Add CORS headers to resolve cross-origin issues
|
||||||
|
resp.Header.Set("Access-Control-Allow-Origin", "*")
|
||||||
|
resp.Header.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
resp.Header.Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Requested-With")
|
||||||
|
resp.Header.Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 && strings.Contains(contentType, "text/html") {
|
||||||
|
return processHTMLContent(resp, reqCtx)
|
||||||
|
} else if resp.StatusCode >= 200 && resp.StatusCode < 300 && strings.Contains(contentType, "text/css") {
|
||||||
|
return processCSSContent(resp, reqCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
if location != "" {
|
||||||
|
|
||||||
|
redirectURL, err := url.Parse(location)
|
||||||
|
if err == nil && redirectURL.IsAbs() {
|
||||||
|
if reqCtx.Session != nil {
|
||||||
|
reqCtx.Session.CurrentHost = redirectURL.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDomain := strings.Split(reqCtx.ProxyHost, ":")[0]
|
||||||
|
if strings.HasPrefix(baseDomain, "webproxy.") {
|
||||||
|
parts := strings.SplitN(baseDomain, ".", 2)
|
||||||
|
if len(parts) > 1 {
|
||||||
|
baseDomain = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := "http"
|
||||||
|
if strings.HasPrefix(reqCtx.OriginalURL, "https://") ||
|
||||||
|
strings.Contains(reqCtx.ProxyHost, ":443") {
|
||||||
|
protocol = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
newProxyURL := fmt.Sprintf("%s://webproxy.%s%s", protocol, baseDomain, redirectURL.Path)
|
||||||
|
|
||||||
|
q := redirectURL.Query()
|
||||||
|
q.Set("session_id", reqCtx.SessionID)
|
||||||
|
q.Set("asset_id", strconv.Itoa(reqCtx.AssetID))
|
||||||
|
q.Set("target_host", redirectURL.Host)
|
||||||
|
newProxyURL += "?" + q.Encode()
|
||||||
|
|
||||||
|
resp.Header.Set("Location", newProxyURL)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
processRedirect(resp, reqCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionStats returns statistics about active sessions
|
||||||
|
func (c *WebProxyCore) GetSessionStats() map[string]any {
|
||||||
|
totalSessions := 0
|
||||||
|
activeSessions := 0
|
||||||
|
assetCount := make(map[int]int)
|
||||||
|
|
||||||
|
c.sessions.Range(func(key, value any) bool {
|
||||||
|
session := value.(*WebSession)
|
||||||
|
totalSessions++
|
||||||
|
if session.IsActive {
|
||||||
|
activeSessions++
|
||||||
|
assetCount[session.AssetID]++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"total_sessions": totalSessions,
|
||||||
|
"active_sessions": activeSessions,
|
||||||
|
"assets_count": len(assetCount),
|
||||||
|
"asset_breakdown": assetCount,
|
||||||
|
}
|
||||||
|
}
|
@@ -1,688 +0,0 @@
|
|||||||
package web_proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/samber/lo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"github.com/veops/oneterm/internal/acl"
|
|
||||||
"github.com/veops/oneterm/internal/model"
|
|
||||||
"github.com/veops/oneterm/internal/service"
|
|
||||||
gsession "github.com/veops/oneterm/internal/session"
|
|
||||||
"github.com/veops/oneterm/pkg/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StartWebSessionRequest represents the request to start a web session
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartWebSessionResponse represents the response from starting a web session
|
|
||||||
type StartWebSessionResponse struct {
|
|
||||||
SessionId string `json:"session_id"`
|
|
||||||
ProxyURL string `json:"proxy_url"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartWebSession creates a new web proxy session
|
|
||||||
func StartWebSession(ctx *gin.Context, req StartWebSessionRequest) (*StartWebSessionResponse, error) {
|
|
||||||
assetService := service.NewAssetService()
|
|
||||||
asset, err := assetService.GetById(ctx, req.AssetId)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Asset not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if asset is web asset
|
|
||||||
if !asset.IsWebAsset() {
|
|
||||||
return nil, fmt.Errorf("Asset is not a web asset")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 session for permission checking (following standard pattern)
|
|
||||||
tempSession := &gsession.Session{
|
|
||||||
Session: &model.Session{
|
|
||||||
SessionId: sessionId,
|
|
||||||
AssetId: req.AssetId,
|
|
||||||
AccountId: req.AccountId,
|
|
||||||
Protocol: "http", // Web assets use http/https protocol
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
requiredActions := []model.AuthAction{
|
|
||||||
model.ActionConnect,
|
|
||||||
model.ActionFileDownload,
|
|
||||||
model.ActionCopy,
|
|
||||||
model.ActionPaste,
|
|
||||||
model.ActionShare,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, tempSession, requiredActions...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Authorization check failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check connect permission (required for all protocols)
|
|
||||||
if !result.IsAllowed(model.ActionConnect) {
|
|
||||||
return nil, fmt.Errorf("No permission to connect to this asset")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build permissions object from authorization result (same as other protocols)
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check max concurrent connections (only when creating new session)
|
|
||||||
if asset.WebConfig != nil && asset.WebConfig.ProxySettings != nil && asset.WebConfig.ProxySettings.MaxConcurrent > 0 {
|
|
||||||
activeCount := GetActiveSessionsForAsset(req.AssetId)
|
|
||||||
if activeCount >= asset.WebConfig.ProxySettings.MaxConcurrent {
|
|
||||||
logger.L().Warn("Maximum concurrent connections exceeded",
|
|
||||||
zap.Int("assetID", req.AssetId),
|
|
||||||
zap.Int("activeCount", activeCount),
|
|
||||||
zap.Int("maxConcurrent", asset.WebConfig.ProxySettings.MaxConcurrent))
|
|
||||||
return nil, fmt.Errorf("maximum concurrent connections (%d) exceeded", asset.WebConfig.ProxySettings.MaxConcurrent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Get initial target host from asset
|
|
||||||
initialHost := GetAssetHost(asset)
|
|
||||||
|
|
||||||
webSession := &WebProxySession{
|
|
||||||
SessionId: sessionId,
|
|
||||||
AssetId: asset.Id,
|
|
||||||
AccountId: req.AccountId,
|
|
||||||
Asset: asset,
|
|
||||||
CreatedAt: now,
|
|
||||||
LastActivity: now,
|
|
||||||
LastHeartbeat: now, // Initialize heartbeat timestamp
|
|
||||||
IsActive: true, // Initially active
|
|
||||||
CurrentHost: initialHost,
|
|
||||||
Permissions: permissions,
|
|
||||||
WebConfig: asset.WebConfig,
|
|
||||||
}
|
|
||||||
StoreSession(sessionId, webSession)
|
|
||||||
|
|
||||||
// Generate fixed webproxy subdomain URL
|
|
||||||
// Use the complete domain for webproxy subdomain
|
|
||||||
baseDomain := strings.Split(ctx.Request.Host, ":")[0]
|
|
||||||
|
|
||||||
scheme := lo.Ternary(ctx.GetHeader("X-Forwarded-Proto") == "https", "https", "http")
|
|
||||||
|
|
||||||
portSuffix := ""
|
|
||||||
if strings.Contains(ctx.Request.Host, ":") {
|
|
||||||
portSuffix = ":" + strings.Split(ctx.Request.Host, ":")[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create fixed webproxy URL with asset_id and session_id for first access
|
|
||||||
webproxyHost := fmt.Sprintf("webproxy.%s%s", baseDomain, portSuffix)
|
|
||||||
proxyURL := fmt.Sprintf("%s://%s/?asset_id=%d&session_id=%s", scheme, webproxyHost, req.AssetId, sessionId)
|
|
||||||
|
|
||||||
// Create database session record for history (same as other protocols)
|
|
||||||
currentUser, _ := acl.GetSessionFromCtx(ctx)
|
|
||||||
|
|
||||||
// Get actual protocol from asset
|
|
||||||
protocol, port := asset.GetWebProtocol()
|
|
||||||
if protocol == "" {
|
|
||||||
protocol = "http"
|
|
||||||
port = 80
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format protocol as "protocol:port"
|
|
||||||
protocolStr := fmt.Sprintf("%s:%d", protocol, port)
|
|
||||||
|
|
||||||
dbSession := &model.Session{
|
|
||||||
SessionType: model.SESSIONTYPE_WEB,
|
|
||||||
SessionId: sessionId,
|
|
||||||
Uid: currentUser.GetUid(),
|
|
||||||
UserName: currentUser.GetUserName(),
|
|
||||||
AssetId: asset.Id,
|
|
||||||
AssetInfo: fmt.Sprintf("%s(%s)", asset.Name, asset.Ip),
|
|
||||||
AccountId: req.AccountId,
|
|
||||||
AccountInfo: "", // Web assets don't have named accounts
|
|
||||||
GatewayId: asset.GatewayId,
|
|
||||||
GatewayInfo: "",
|
|
||||||
ClientIp: ctx.ClientIP(),
|
|
||||||
Protocol: protocolStr, // Now shows "http:80" or "https:443" etc.
|
|
||||||
Status: model.SESSIONSTATUS_ONLINE,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set gateway info if exists
|
|
||||||
if asset.GatewayId > 0 {
|
|
||||||
dbSession.GatewayInfo = fmt.Sprintf("Gateway_%d", asset.GatewayId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save session to database using gsession
|
|
||||||
fullSession := &gsession.Session{Session: dbSession}
|
|
||||||
if err := gsession.UpsertSession(fullSession); err != nil {
|
|
||||||
logger.L().Error("Failed to save web session to database",
|
|
||||||
zap.String("sessionId", sessionId), zap.Error(err))
|
|
||||||
// Don't fail the request, just log the error
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.L().Info("Web session started", zap.String("sessionId", sessionId), zap.String("proxyURL", proxyURL), zap.String("authMode", authMode))
|
|
||||||
|
|
||||||
return &StartWebSessionResponse{
|
|
||||||
SessionId: sessionId,
|
|
||||||
ProxyURL: proxyURL,
|
|
||||||
Message: "Web session started successfully",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAssetHost extracts the host from asset configuration
|
|
||||||
func GetAssetHost(asset *model.Asset) string {
|
|
||||||
targetURL := BuildTargetURL(asset)
|
|
||||||
if u, err := url.Parse(targetURL); err == nil {
|
|
||||||
return u.Host
|
|
||||||
}
|
|
||||||
return "localhost" // fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildTargetURL builds the target URL from asset information
|
|
||||||
func BuildTargetURL(asset *model.Asset) string {
|
|
||||||
protocol, port := asset.GetWebProtocol()
|
|
||||||
if protocol == "" {
|
|
||||||
protocol = "http"
|
|
||||||
port = 80
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if asset.Ip already includes port (case 1: 127.0.0.1:8000)
|
|
||||||
if strings.Contains(asset.Ip, ":") {
|
|
||||||
// IP already has port, use as-is
|
|
||||||
return fmt.Sprintf("%s://%s", protocol, asset.Ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: IP without port (127.0.0.1), use port from protocol
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildTargetURLWithHost builds target URL with specific host
|
|
||||||
func BuildTargetURLWithHost(asset *model.Asset, host string) string {
|
|
||||||
protocol, port := asset.GetWebProtocol()
|
|
||||||
if protocol == "" {
|
|
||||||
protocol = "http"
|
|
||||||
port = 80
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if host already includes port
|
|
||||||
if strings.Contains(host, ":") {
|
|
||||||
// Host already has port, use as-is
|
|
||||||
return fmt.Sprintf("%s://%s", protocol, host)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractAssetIDFromHost extracts asset ID from query parameter (fixed webproxy subdomain)
|
|
||||||
func ExtractAssetIDFromHost(host string) (int, error) {
|
|
||||||
// This is now handled by ExtractAssetIDFromRequest in the controller
|
|
||||||
// but kept for interface compatibility
|
|
||||||
return 0, fmt.Errorf("asset ID should be extracted from query parameter in fixed subdomain approach")
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSameDomainOrSubdomain checks if two hosts belong to the same domain or subdomain
|
|
||||||
func 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckWebAccessControls validates web-specific access controls
|
|
||||||
func CheckWebAccessControls(ctx *gin.Context, session *WebProxySession) error {
|
|
||||||
// Check access policy (read-only mode)
|
|
||||||
if session.WebConfig != nil && session.WebConfig.AccessPolicy == "read_only" {
|
|
||||||
method := strings.ToUpper(ctx.Request.Method)
|
|
||||||
if method != "GET" && method != "HEAD" && method != "OPTIONS" {
|
|
||||||
return fmt.Errorf("read-only access mode - %s method not allowed", method)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check blocked paths
|
|
||||||
if session.WebConfig != nil && session.WebConfig.ProxySettings != nil && len(session.WebConfig.ProxySettings.BlockedPaths) > 0 {
|
|
||||||
requestPath := ctx.Request.URL.Path
|
|
||||||
for _, blockedPath := range session.WebConfig.ProxySettings.BlockedPaths {
|
|
||||||
if strings.Contains(requestPath, blockedPath) {
|
|
||||||
return fmt.Errorf("access to path '%s' is blocked", requestPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file download permissions
|
|
||||||
if session.Permissions != nil && !session.Permissions.FileDownload {
|
|
||||||
if IsDownloadRequest(ctx) {
|
|
||||||
return fmt.Errorf("file download not permitted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsDownloadRequest checks if the request is a file download
|
|
||||||
func IsDownloadRequest(ctx *gin.Context) bool {
|
|
||||||
// Check Content-Disposition header for downloads
|
|
||||||
contentDisposition := ctx.GetHeader("Content-Disposition")
|
|
||||||
if strings.Contains(contentDisposition, "attachment") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check common download file extensions in URL path
|
|
||||||
path := ctx.Request.URL.Path
|
|
||||||
downloadExts := []string{".pdf", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".rar", ".tar", ".gz", ".csv", ".txt"}
|
|
||||||
for _, ext := range downloadExts {
|
|
||||||
if strings.HasSuffix(strings.ToLower(path), ext) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check query parameters that indicate download intent
|
|
||||||
if ctx.Query("download") != "" || ctx.Query("export") != "" || ctx.Query("attachment") != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Accept header for file download types
|
|
||||||
accept := ctx.GetHeader("Accept")
|
|
||||||
downloadMimeTypes := []string{
|
|
||||||
"application/vnd.ms-excel",
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
"application/pdf",
|
|
||||||
"application/zip",
|
|
||||||
"application/octet-stream",
|
|
||||||
}
|
|
||||||
for _, mimeType := range downloadMimeTypes {
|
|
||||||
if strings.Contains(accept, mimeType) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordWebActivity records web session activity for auditing
|
|
||||||
func RecordWebActivity(sessionId string, ctx *gin.Context) {
|
|
||||||
// Activity recording logic would go here
|
|
||||||
// This is a placeholder to maintain API compatibility
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProxyRequestContext holds the context for a proxy request
|
|
||||||
type ProxyRequestContext struct {
|
|
||||||
SessionID string
|
|
||||||
AssetID int
|
|
||||||
Session *WebProxySession
|
|
||||||
Host string
|
|
||||||
IsStaticResource bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractSessionAndAssetInfo extracts session ID and asset ID from the request
|
|
||||||
func ExtractSessionAndAssetInfo(ctx *gin.Context, extractAssetIDFromHost func(string) (int, error)) (*ProxyRequestContext, error) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Try to get asset_id from existing session first
|
|
||||||
var assetID int
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if sessionID != "" {
|
|
||||||
if session, exists := GetSession(sessionID); exists {
|
|
||||||
assetID = session.AssetId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no session or no asset_id from session, get from query parameter
|
|
||||||
if assetID == 0 {
|
|
||||||
assetIDStr := ctx.Query("asset_id")
|
|
||||||
if assetIDStr == "" {
|
|
||||||
return nil, fmt.Errorf("asset_id parameter required")
|
|
||||||
}
|
|
||||||
|
|
||||||
assetID, err = strconv.Atoi(assetIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid asset_id format")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 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
|
|
||||||
allSessions := GetAllSessions()
|
|
||||||
for sid, session := range allSessions {
|
|
||||||
if session.AssetId == assetID {
|
|
||||||
sessionID = sid
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sessionID == "" {
|
|
||||||
return nil, fmt.Errorf("session ID required - please start a new web session")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if this is a static resource request
|
|
||||||
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") ||
|
|
||||||
strings.HasSuffix(ctx.Request.URL.Path, ".woff") ||
|
|
||||||
strings.HasSuffix(ctx.Request.URL.Path, ".woff2") ||
|
|
||||||
strings.HasSuffix(ctx.Request.URL.Path, ".ttf") ||
|
|
||||||
strings.HasSuffix(ctx.Request.URL.Path, ".svg")
|
|
||||||
|
|
||||||
return &ProxyRequestContext{
|
|
||||||
SessionID: sessionID,
|
|
||||||
AssetID: assetID,
|
|
||||||
Host: host,
|
|
||||||
IsStaticResource: isStaticResource,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateSessionAndPermissions validates the session and checks permissions
|
|
||||||
func ValidateSessionAndPermissions(ctx *gin.Context, proxyCtx *ProxyRequestContext, checkWebAccessControls func(*gin.Context, *WebProxySession) error) error {
|
|
||||||
// Validate session ID and get session information
|
|
||||||
session, exists := GetSession(proxyCtx.SessionID)
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("invalid or expired session")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session timeout using system config (same as other protocols)
|
|
||||||
now := time.Now()
|
|
||||||
maxInactiveTime := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second
|
|
||||||
if now.Sub(session.LastActivity) > maxInactiveTime {
|
|
||||||
CloseWebSession(proxyCtx.SessionID)
|
|
||||||
return fmt.Errorf("session expired due to inactivity")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update LastActivity for real user operations (not static resources)
|
|
||||||
if !proxyCtx.IsStaticResource {
|
|
||||||
UpdateSessionActivity(proxyCtx.SessionID)
|
|
||||||
|
|
||||||
// Auto-renew cookie for user operations
|
|
||||||
cookieMaxAge := int(model.GlobalConfig.Load().Timeout)
|
|
||||||
// Set cookie domain for webproxy subdomain
|
|
||||||
cookieDomain := ""
|
|
||||||
if strings.HasPrefix(ctx.Request.Host, "webproxy.") {
|
|
||||||
parts := strings.SplitN(ctx.Request.Host, ".", 2)
|
|
||||||
if len(parts) > 1 {
|
|
||||||
cookieDomain = "." + parts[1] // .domain.com
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.SetCookie("oneterm_session_id", proxyCtx.SessionID, cookieMaxAge, "/", cookieDomain, false, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Web-specific access controls
|
|
||||||
if err := checkWebAccessControls(ctx, session); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.AssetId != proxyCtx.AssetID {
|
|
||||||
return fmt.Errorf("asset ID mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store session in context
|
|
||||||
proxyCtx.Session = session
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupReverseProxy creates and configures a reverse proxy
|
|
||||||
func SetupReverseProxy(ctx *gin.Context, proxyCtx *ProxyRequestContext, buildTargetURLWithHost func(*model.Asset, string) string, processHTMLResponse func(*http.Response, int, string, string, *WebProxySession), recordWebActivity func(*WebProxySession, *http.Request), isSameDomainOrSubdomain func(string, string) bool) (*httputil.ReverseProxy, error) {
|
|
||||||
targetURL := buildTargetURLWithHost(proxyCtx.Session.Asset, proxyCtx.Session.CurrentHost)
|
|
||||||
target, err := url.Parse(targetURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid target URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine scheme with multiple fallback methods
|
|
||||||
currentScheme := "http"
|
|
||||||
if ctx.GetHeader("X-Forwarded-Proto") == "https" ||
|
|
||||||
ctx.GetHeader("X-Forwarded-Ssl") == "on" ||
|
|
||||||
ctx.GetHeader("X-Url-Scheme") == "https" ||
|
|
||||||
ctx.Request.TLS != nil {
|
|
||||||
currentScheme = "https"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove session_id from query parameters without re-encoding
|
|
||||||
if req.URL.RawQuery != "" {
|
|
||||||
q := req.URL.Query()
|
|
||||||
if q.Has("session_id") {
|
|
||||||
q.Del("session_id")
|
|
||||||
req.URL.RawQuery = q.Encode()
|
|
||||||
}
|
|
||||||
// Keep original RawQuery if no session_id to remove
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect interception for bastion control
|
|
||||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
|
||||||
// Check file download permissions based on response headers
|
|
||||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
|
||||||
|
|
||||||
// Check if this is a file download response
|
|
||||||
isDownload := strings.Contains(contentDisposition, "attachment") ||
|
|
||||||
strings.Contains(contentType, "application/octet-stream") ||
|
|
||||||
strings.Contains(contentType, "application/vnd.ms-excel") ||
|
|
||||||
strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") ||
|
|
||||||
strings.Contains(contentType, "application/pdf") ||
|
|
||||||
strings.Contains(contentType, "application/zip")
|
|
||||||
|
|
||||||
if isDownload && proxyCtx.Session.Permissions != nil && !proxyCtx.Session.Permissions.FileDownload {
|
|
||||||
// Replace the response with a 403 error page
|
|
||||||
resp.StatusCode = http.StatusForbidden
|
|
||||||
resp.Status = "403 Forbidden"
|
|
||||||
resp.Header.Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
resp.Header.Del("Content-Disposition")
|
|
||||||
|
|
||||||
errorPage := RenderAccessDeniedPage(
|
|
||||||
"File download not permitted",
|
|
||||||
"Your user permissions do not allow file downloads through the web proxy.")
|
|
||||||
resp.Body = io.NopCloser(strings.NewReader(errorPage))
|
|
||||||
resp.ContentLength = int64(len(errorPage))
|
|
||||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(errorPage)))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process HTML content for injection
|
|
||||||
if resp.StatusCode == 200 && strings.Contains(contentType, "text/html") {
|
|
||||||
processHTMLResponse(resp, proxyCtx.AssetID, currentScheme, proxyCtx.Host, proxyCtx.Session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record activity if enabled
|
|
||||||
if proxyCtx.Session.WebConfig != nil && proxyCtx.Session.WebConfig.ProxySettings != nil && proxyCtx.Session.WebConfig.ProxySettings.RecordingEnabled {
|
|
||||||
recordWebActivity(proxyCtx.Session, ctx.Request)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(proxyCtx.Host, "webproxy."),
|
|
||||||
func() string {
|
|
||||||
parts := strings.SplitN(proxyCtx.Host, ".", 2)
|
|
||||||
return lo.Ternary(len(parts) > 1, parts[1], proxyCtx.Host)
|
|
||||||
}(),
|
|
||||||
proxyCtx.Host)
|
|
||||||
|
|
||||||
if isSameDomainOrSubdomain(target.Host, redirectURL.Host) {
|
|
||||||
UpdateSessionHost(proxyCtx.SessionID, redirectURL.Host)
|
|
||||||
newProxyURL := fmt.Sprintf("%s://webproxy.%s%s", currentScheme, baseDomain, redirectURL.Path)
|
|
||||||
if redirectURL.RawQuery != "" {
|
|
||||||
newProxyURL += "?" + redirectURL.RawQuery
|
|
||||||
}
|
|
||||||
resp.Header.Set("Location", newProxyURL)
|
|
||||||
} else {
|
|
||||||
newLocation := fmt.Sprintf("%s://webproxy.%s/external?url=%s",
|
|
||||||
currentScheme, 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(proxyCtx.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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return proxy, nil
|
|
||||||
}
|
|
@@ -13,17 +13,14 @@ import (
|
|||||||
"github.com/veops/oneterm/pkg/logger"
|
"github.com/veops/oneterm/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Global session storage
|
|
||||||
var webProxySessions = make(map[string]*WebProxySession)
|
var webProxySessions = make(map[string]*WebProxySession)
|
||||||
|
|
||||||
// Global cleanup context
|
|
||||||
var (
|
var (
|
||||||
cleanupCtx context.Context
|
cleanupCtx context.Context
|
||||||
cleanupCancel context.CancelFunc
|
cleanupCancel context.CancelFunc
|
||||||
cleanupWg sync.WaitGroup
|
cleanupWg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebProxySession represents an active web proxy session
|
|
||||||
type WebProxySession struct {
|
type WebProxySession struct {
|
||||||
SessionId string
|
SessionId string
|
||||||
AssetId int
|
AssetId int
|
||||||
@@ -34,39 +31,33 @@ type WebProxySession struct {
|
|||||||
LastHeartbeat time.Time // Track heartbeat separately
|
LastHeartbeat time.Time // Track heartbeat separately
|
||||||
IsActive bool // Active for concurrent control (heartbeat-based)
|
IsActive bool // Active for concurrent control (heartbeat-based)
|
||||||
CurrentHost string
|
CurrentHost string
|
||||||
Permissions *model.AuthPermissions // User permissions for this asset
|
SessionPerms *SessionPermissions // Cached session permissions for proxy phase
|
||||||
WebConfig *model.WebConfig // Web-specific configuration
|
WebConfig *model.WebConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupExpiredSessions implements layered timeout mechanism
|
func cleanupExpiredSessions(systemMaxInactiveTime time.Duration) {
|
||||||
func cleanupExpiredSessions(maxInactiveTime time.Duration) {
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
deactivatedCount := 0
|
deactivatedCount := 0
|
||||||
cleanedCount := 0
|
cleanedCount := 0
|
||||||
|
|
||||||
// Layer 1: Concurrent control timeout (fast)
|
heartbeatTimeout := 30 * time.Second
|
||||||
heartbeatTimeout := 45 * time.Second
|
|
||||||
|
|
||||||
// Layer 2: Session expiry timeout (slow, system config)
|
|
||||||
|
|
||||||
for sessionID, session := range webProxySessions {
|
for sessionID, session := range webProxySessions {
|
||||||
// Layer 1: Check heartbeat for concurrent control
|
|
||||||
if session.IsActive && !session.LastHeartbeat.IsZero() &&
|
if session.IsActive && !session.LastHeartbeat.IsZero() &&
|
||||||
now.Sub(session.LastHeartbeat) > heartbeatTimeout {
|
now.Sub(session.LastHeartbeat) > heartbeatTimeout {
|
||||||
// Deactivate session (release concurrent slot AND mark as offline)
|
|
||||||
session.IsActive = false
|
session.IsActive = false
|
||||||
UpdateWebSessionStatus(sessionID, model.SESSIONSTATUS_OFFLINE)
|
UpdateWebSessionStatus(sessionID, model.SESSIONSTATUS_OFFLINE)
|
||||||
deactivatedCount++
|
deactivatedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer 2: Check session expiry for final cleanup
|
effectiveTimeout := systemMaxInactiveTime
|
||||||
|
|
||||||
shouldDelete := false
|
shouldDelete := false
|
||||||
if now.Sub(session.LastActivity) > maxInactiveTime {
|
if now.Sub(session.LastActivity) > effectiveTimeout {
|
||||||
shouldDelete = true
|
shouldDelete = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldDelete {
|
if shouldDelete {
|
||||||
// No need to update status again - already done in Layer 1
|
|
||||||
delete(webProxySessions, sessionID)
|
delete(webProxySessions, sessionID)
|
||||||
cleanedCount++
|
cleanedCount++
|
||||||
}
|
}
|
||||||
@@ -79,25 +70,21 @@ func cleanupExpiredSessions(maxInactiveTime time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartSessionCleanupRoutine starts background cleanup routine for web sessions
|
|
||||||
func StartSessionCleanupRoutine() {
|
func StartSessionCleanupRoutine() {
|
||||||
// Initialize cleanup context
|
|
||||||
cleanupCtx, cleanupCancel = context.WithCancel(context.Background())
|
cleanupCtx, cleanupCancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
// More frequent cleanup - every 30 seconds to catch closed browser tabs quickly
|
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
cleanupWg.Add(1)
|
cleanupWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer cleanupWg.Done()
|
defer cleanupWg.Done()
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-cleanupCtx.Done():
|
case <-cleanupCtx.Done():
|
||||||
logger.L().Info("Web proxy session cleanup stopped")
|
logger.L().Info("Web proxy session cleanup stopped")
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Use system configured timeout (same as other protocols)
|
|
||||||
systemTimeout := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second
|
systemTimeout := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second
|
||||||
cleanupExpiredSessions(systemTimeout)
|
cleanupExpiredSessions(systemTimeout)
|
||||||
}
|
}
|
||||||
@@ -105,7 +92,6 @@ func StartSessionCleanupRoutine() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopSessionCleanupRoutine stops the background cleanup routine
|
|
||||||
func StopSessionCleanupRoutine() {
|
func StopSessionCleanupRoutine() {
|
||||||
if cleanupCancel != nil {
|
if cleanupCancel != nil {
|
||||||
cleanupCancel()
|
cleanupCancel()
|
||||||
@@ -114,30 +100,25 @@ func StopSessionCleanupRoutine() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSession retrieves a session by ID
|
|
||||||
func GetSession(sessionID string) (*WebProxySession, bool) {
|
func GetSession(sessionID string) (*WebProxySession, bool) {
|
||||||
session, exists := webProxySessions[sessionID]
|
session, exists := webProxySessions[sessionID]
|
||||||
return session, exists
|
return session, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreSession stores a session in the session map
|
|
||||||
func StoreSession(sessionID string, session *WebProxySession) {
|
func StoreSession(sessionID string, session *WebProxySession) {
|
||||||
webProxySessions[sessionID] = session
|
webProxySessions[sessionID] = session
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSession removes a session from the session map
|
|
||||||
func DeleteSession(sessionID string) {
|
func DeleteSession(sessionID string) {
|
||||||
delete(webProxySessions, sessionID)
|
delete(webProxySessions, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSessionActivity updates the last activity time for a session
|
|
||||||
func UpdateSessionActivity(sessionID string) {
|
func UpdateSessionActivity(sessionID string) {
|
||||||
if session, exists := webProxySessions[sessionID]; exists {
|
if session, exists := webProxySessions[sessionID]; exists {
|
||||||
session.LastActivity = time.Now()
|
session.LastActivity = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSessionHeartbeat updates the last heartbeat time for a session
|
|
||||||
func UpdateSessionHeartbeat(sessionID string) {
|
func UpdateSessionHeartbeat(sessionID string) {
|
||||||
if session, exists := webProxySessions[sessionID]; exists {
|
if session, exists := webProxySessions[sessionID]; exists {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -145,26 +126,21 @@ func UpdateSessionHeartbeat(sessionID string) {
|
|||||||
|
|
||||||
session.LastHeartbeat = now
|
session.LastHeartbeat = now
|
||||||
session.IsActive = true // Re-activate session on heartbeat
|
session.IsActive = true // Re-activate session on heartbeat
|
||||||
// Heartbeat also counts as activity (user is still viewing the page)
|
|
||||||
session.LastActivity = now
|
session.LastActivity = now
|
||||||
|
|
||||||
// If session was previously inactive, mark it as online again
|
|
||||||
if wasInactive {
|
if wasInactive {
|
||||||
UpdateWebSessionStatus(sessionID, model.SESSIONSTATUS_ONLINE)
|
UpdateWebSessionStatus(sessionID, model.SESSIONSTATUS_ONLINE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSessionHost updates the current host for a session
|
|
||||||
func UpdateSessionHost(sessionID string, host string) {
|
func UpdateSessionHost(sessionID string, host string) {
|
||||||
if session, exists := webProxySessions[sessionID]; exists {
|
if session, exists := webProxySessions[sessionID]; exists {
|
||||||
session.CurrentHost = host
|
session.CurrentHost = host
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveSessionsForAsset returns the number of active sessions for an asset
|
|
||||||
func GetActiveSessionsForAsset(assetID int) int {
|
func GetActiveSessionsForAsset(assetID int) int {
|
||||||
// First cleanup expired sessions to get accurate count
|
|
||||||
systemTimeout := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second
|
systemTimeout := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second
|
||||||
cleanupExpiredSessions(systemTimeout)
|
cleanupExpiredSessions(systemTimeout)
|
||||||
|
|
||||||
@@ -177,17 +153,14 @@ func GetActiveSessionsForAsset(assetID int) int {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllSessions returns all active sessions
|
|
||||||
func GetAllSessions() map[string]*WebProxySession {
|
func GetAllSessions() map[string]*WebProxySession {
|
||||||
return webProxySessions
|
return webProxySessions
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountActiveSessions returns the total number of active sessions
|
|
||||||
func CountActiveSessions() int {
|
func CountActiveSessions() int {
|
||||||
return len(webProxySessions)
|
return len(webProxySessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseWebSession closes and removes a session
|
|
||||||
func CloseWebSession(sessionID string) {
|
func CloseWebSession(sessionID string) {
|
||||||
if session, exists := webProxySessions[sessionID]; exists {
|
if session, exists := webProxySessions[sessionID]; exists {
|
||||||
logger.L().Info("Closing web session",
|
logger.L().Info("Closing web session",
|
||||||
@@ -195,16 +168,13 @@ func CloseWebSession(sessionID string) {
|
|||||||
zap.Int("assetID", session.AssetId),
|
zap.Int("assetID", session.AssetId),
|
||||||
zap.Int("accountID", session.AccountId))
|
zap.Int("accountID", session.AccountId))
|
||||||
|
|
||||||
// Update database session record to offline status
|
|
||||||
UpdateWebSessionStatus(sessionID, model.SESSIONSTATUS_OFFLINE)
|
UpdateWebSessionStatus(sessionID, model.SESSIONSTATUS_OFFLINE)
|
||||||
|
|
||||||
delete(webProxySessions, sessionID)
|
delete(webProxySessions, sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWebSessionStatus updates the session status in database
|
|
||||||
func UpdateWebSessionStatus(sessionID string, status int) {
|
func UpdateWebSessionStatus(sessionID string, status int) {
|
||||||
// Use repository to get and update database session status
|
|
||||||
repo := repository.NewSessionRepository()
|
repo := repository.NewSessionRepository()
|
||||||
if dbSession, err := repo.GetSession(context.Background(), sessionID); err == nil && dbSession != nil {
|
if dbSession, err := repo.GetSession(context.Background(), sessionID); err == nil && dbSession != nil {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -214,7 +184,6 @@ func UpdateWebSessionStatus(sessionID string, status int) {
|
|||||||
}
|
}
|
||||||
dbSession.UpdatedAt = now
|
dbSession.UpdatedAt = now
|
||||||
|
|
||||||
// Save updated session to database
|
|
||||||
fullSession := &gsession.Session{Session: dbSession}
|
fullSession := &gsession.Session{Session: dbSession}
|
||||||
if err := gsession.UpsertSession(fullSession); err != nil {
|
if err := gsession.UpsertSession(fullSession); err != nil {
|
||||||
logger.L().Error("Failed to update web session status in database",
|
logger.L().Error("Failed to update web session status in database",
|
||||||
|
Reference in New Issue
Block a user