refactor(web_proxy): migrate from header-based to parameter-based session management

This commit is contained in:
pycook
2025-09-10 23:46:40 +08:00
parent f2afd5b87d
commit 7a4b3528a7
9 changed files with 2042 additions and 1355 deletions

View File

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

View File

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

View File

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

View File

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

View 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

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

View File

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

View File

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