From 7a4b3528a7e6391ce98f0fb20e10a82c047ca513 Mon Sep 17 00:00:00 2001 From: pycook Date: Wed, 10 Sep 2025 23:46:40 +0800 Subject: [PATCH] refactor(web_proxy): migrate from header-based to parameter-based session management --- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/api/controller/web_proxy.go | 19 +- backend/internal/api/router/router.go | 4 - backend/internal/service/web_proxy/api.go | 530 ++++++ backend/internal/service/web_proxy/content.go | 1456 ++++++++++------- backend/internal/service/web_proxy/core.go | 648 ++++++++ backend/internal/service/web_proxy/service.go | 688 -------- backend/internal/service/web_proxy/session.go | 49 +- 9 files changed, 2042 insertions(+), 1355 deletions(-) create mode 100644 backend/internal/service/web_proxy/api.go create mode 100644 backend/internal/service/web_proxy/core.go delete mode 100644 backend/internal/service/web_proxy/service.go diff --git a/backend/go.mod b/backend/go.mod index a7b4a6e..fdfb0f1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -53,6 +53,7 @@ require github.com/PuerkitoBio/goquery v1.10.3 require ( 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/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 03290f5..e9a4fda 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= +github.com/andybalholm/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/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= diff --git a/backend/internal/api/controller/web_proxy.go b/backend/internal/api/controller/web_proxy.go index 23f0fa5..0eeead4 100644 --- a/backend/internal/api/controller/web_proxy.go +++ b/backend/internal/api/controller/web_proxy.go @@ -31,7 +31,6 @@ func StartSessionCleanupRoutine() { func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason string) { html := web_proxy.RenderSessionExpiredPage(reason) - ctx.SetCookie("oneterm_session_id", "", -1, "/", "", false, true) ctx.Header("Content-Type", "text/html; charset=utf-8") ctx.String(http.StatusUnauthorized, html) } @@ -146,13 +145,15 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) { } // Validate session and check permissions - if err := web_proxy.ValidateSessionAndPermissions(ctx, proxyCtx, c.checkWebAccessControls); err != nil { - if strings.Contains(err.Error(), "invalid or expired session") || strings.Contains(err.Error(), "session expired") { - c.renderSessionExpiredPage(ctx, err.Error()) - } else { - c.renderErrorPage(ctx, "access_denied", "Access Denied", err.Error(), "Your request was blocked by the security policy.") + if !proxyCtx.IsStaticResource { + if err := web_proxy.ValidateSessionAndPermissions(ctx, proxyCtx, c.checkWebAccessControls); err != nil { + if strings.Contains(err.Error(), "invalid or expired session") || strings.Contains(err.Error(), "session expired") { + c.renderSessionExpiredPage(ctx, err.Error()) + } else { + c.renderErrorPage(ctx, "access_denied", "Access Denied", err.Error(), "Your request was blocked by the security policy.") + } + return } - return } // Setup reverse proxy @@ -162,6 +163,10 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) { return } + if proxy == nil { + return + } + ctx.Header("Cache-Control", "no-cache") // Add panic recovery for proxy requests diff --git a/backend/internal/api/router/router.go b/backend/internal/api/router/router.go index cf320b9..6a1c82f 100644 --- a/backend/internal/api/router/router.go +++ b/backend/internal/api/router/router.go @@ -21,26 +21,22 @@ func SetupRouter(r *gin.Engine) { // Start web session cleanup routine controller.StartSessionCleanupRoutine() - // Fixed webproxy subdomain middleware webProxy := controller.NewWebProxyController() r.Use(func(c *gin.Context) { host := c.Request.Host // Check if this is the webproxy subdomain request if strings.HasPrefix(host, "webproxy.") { - // Allow API requests to pass through to normal routing if strings.HasPrefix(c.Request.URL.Path, "/api/oneterm/v1/") { c.Next() return } - // Handle external redirect requests if c.Request.URL.Path == "/external" { webProxy.HandleExternalRedirect(c) return } - // Handle normal proxy requests webProxy.ProxyWebRequest(c) return } diff --git a/backend/internal/service/web_proxy/api.go b/backend/internal/service/web_proxy/api.go new file mode 100644 index 0000000..70f4750 --- /dev/null +++ b/backend/internal/service/web_proxy/api.go @@ -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(` + + + Session Expired - OneTerm + + + + +
+

🕐 Session Expired

+
%s
+

← Go Back

+
+ +`, reason) +} + +func RenderErrorPage(errorType, title, reason, details string) string { + return fmt.Sprintf(` + + + %s - OneTerm + + + + +
+

❌ %s

+
%s
+ %s +

+ ← Go Back + 🔄 Refresh +

+
+ +`, title, title, reason, + func() string { + if details != "" { + return fmt.Sprintf(`
%s
`, 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") +} diff --git a/backend/internal/service/web_proxy/content.go b/backend/internal/service/web_proxy/content.go index 8fb8d12..d4b7f1f 100644 --- a/backend/internal/service/web_proxy/content.go +++ b/backend/internal/service/web_proxy/content.go @@ -3,689 +3,913 @@ package web_proxy import ( "bytes" "compress/gzip" + "context" "fmt" "io" "net/http" + "net/url" "regexp" + "strconv" "strings" + "sync" + "time" - "github.com/samber/lo" + "github.com/andybalholm/brotli" + "github.com/veops/oneterm/internal/service" + "github.com/veops/oneterm/pkg/logger" + "go.uber.org/zap" ) +var ( + compiledRegex = sync.Once{} + srcRegex *regexp.Regexp + actionRegex *regexp.Regexp + hrefRegex *regexp.Regexp +) -// ProcessHTMLResponse processes HTML response for content rewriting and injection -func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost string, session *WebProxySession) { +func initRegexPatterns() { + srcRegex = regexp.MustCompile(`(src\s*=\s*["'])([^"']*?)["']`) + actionRegex = regexp.MustCompile(`(action\s*=\s*["'])([^"']*?)["']`) + hrefRegex = regexp.MustCompile(`(href\s*=\s*["'])([^"']*?)["']`) +} + +const ( + maxContentSize = 5 * 1024 * 1024 +) + +func processHTMLContent(resp *http.Response, reqCtx *RequestContext) error { if resp.Body == nil { - return + return nil } - // Check if content is compressed BEFORE removing headers contentEncoding := resp.Header.Get("Content-Encoding") - - // Only log search-related requests for debugging login issue - isSearchRequest := strings.Contains(resp.Request.URL.String(), "/s?") || strings.Contains(resp.Request.URL.String(), "search") - if isSearchRequest { - fmt.Printf("[SEARCH] URL: %s, Status: %d, Content-Type: %s, Content-Encoding: %s\n", - resp.Request.URL.String(), resp.StatusCode, resp.Header.Get("Content-Type"), contentEncoding) - } - // Remove Content-Encoding to avoid decoding issues resp.Header.Del("Content-Encoding") resp.Header.Del("Content-Length") var body []byte var err error + var reader io.Reader - // Handle compressed content if contentEncoding == "gzip" { gzipReader, err := gzip.NewReader(resp.Body) if err != nil { resp.Body.Close() - return + return err } defer gzipReader.Close() - body, err = io.ReadAll(gzipReader) - } else if contentEncoding == "br" || contentEncoding == "deflate" { - // For br/deflate, we need to decompress but don't have the library - // For now, keep the headers and let the browser handle decompression + reader = io.LimitReader(gzipReader, maxContentSize) + } else if contentEncoding == "br" { + brotliReader := brotli.NewReader(resp.Body) + reader = io.LimitReader(brotliReader, maxContentSize) + } else if contentEncoding == "deflate" { resp.Header.Set("Content-Encoding", contentEncoding) - body, err = io.ReadAll(resp.Body) - // Skip HTML processing for compressed content we can't decompress - if isSearchRequest { - fmt.Printf("[SEARCH] Skipping HTML processing due to %s encoding\n", contentEncoding) + reader = io.LimitReader(resp.Body, maxContentSize) + body, err = io.ReadAll(reader) + if err != nil { + resp.Body.Close() + return err } - // Set response without HTML processing + resp.Body.Close() newBody := bytes.NewReader(body) resp.Body = io.NopCloser(newBody) resp.ContentLength = int64(len(body)) - resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) - return + resp.Header.Set("Content-Length", strconv.Itoa(len(body))) + return nil } else { - body, err = io.ReadAll(resp.Body) + reader = io.LimitReader(resp.Body, maxContentSize) } + body, err = io.ReadAll(reader) + if err != nil { resp.Body.Close() - return + return err } resp.Body.Close() - // Preserve original encoding - convert bytes to string properly + if len(body) >= maxContentSize { + logger.L().Warn("HTML content too large, skipping processing", + zap.String("sessionId", reqCtx.SessionID), + zap.Int("contentSize", len(body))) + newBody := bytes.NewReader(body) + resp.Body = io.NopCloser(newBody) + resp.ContentLength = int64(len(body)) + resp.Header.Set("Content-Length", strconv.Itoa(len(body))) + return nil + } + content := string(body) - - // Log search content processing for debugging garbled text - if isSearchRequest { - fmt.Printf("[SEARCH] Body length: %d, Content preview: %.100s...\n", - len(body), strings.ReplaceAll(string(body[:min(100, len(body))]), "\n", "\\n")) + + content = rewriteHTMLLinks(content, reqCtx.AssetID, reqCtx.SessionID) + + sessionJS := generateSimpleJavaScript(reqCtx.AssetID, reqCtx.SessionID) + + watermarkHTML := "" + if needsWatermark(reqCtx) { + watermarkHTML = generateWatermarkHTML(reqCtx) } - // URL rewriting for external links - baseDomain := lo.Ternary(strings.HasPrefix(proxyHost, "webproxy."), - func() string { - parts := strings.SplitN(proxyHost, ".", 2) - return lo.Ternary(len(parts) > 1, parts[1], proxyHost) - }(), - proxyHost) - - patterns := []struct { - pattern string - rewrite func(matches []string) string - }{ - { - `(window\.location(?:\.href)?\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`, - func(matches []string) string { - path := lo.Ternary(len(matches) > 3 && matches[3] != "", matches[3], "") - return fmt.Sprintf(`%s%s://webproxy.%s%s"`, matches[1], scheme, baseDomain, path) - }, - }, - { - `(action\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`, - func(matches []string) string { - path := lo.Ternary(len(matches) > 3 && matches[3] != "", matches[3], "") - return fmt.Sprintf(`%s%s://webproxy.%s%s"`, matches[1], scheme, baseDomain, path) - }, - }, - { - `(href\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`, - func(matches []string) string { - hostname := matches[2] - path := lo.Ternary(len(matches) > 3 && matches[3] != "", matches[3], "") - - // Check if hostname belongs to the same domain family (e.g., *.baidu.com) - sessionHostParts := strings.Split(session.CurrentHost, ".") - hostnameParts := strings.Split(hostname, ".") - - // Compare the last 2 parts for domain matching (e.g., baidu.com) - isSameDomain := false - if len(sessionHostParts) >= 2 && len(hostnameParts) >= 2 { - sessionDomain := strings.Join(sessionHostParts[len(sessionHostParts)-2:], ".") - hostDomain := strings.Join(hostnameParts[len(hostnameParts)-2:], ".") - isSameDomain = sessionDomain == hostDomain - } - - if isSameDomain { - return fmt.Sprintf(`%s%s://webproxy.%s%s"`, matches[1], scheme, baseDomain, path) - } - // Keep external URLs unchanged - return matches[0] - }, - }, - } - - for _, p := range patterns { - re := regexp.MustCompile(p.pattern) - content = re.ReplaceAllStringFunc(content, func(match string) string { - matches := re.FindStringSubmatch(match) - if len(matches) >= 4 { - return p.rewrite(matches) - } - return match - }) - } - - // Step 2: Add watermark if enabled - if session.WebConfig != nil && session.WebConfig.ProxySettings != nil && session.WebConfig.ProxySettings.WatermarkEnabled { - watermarkCSS := ` - ` - - // Generate watermark HTML with multiple OneTerm texts - var watermarkTexts []string - for row := 0; row < 30; row++ { - for col := 0; col < 15; col++ { - top := row * 100 - left := col * 300 - watermarkTexts = append(watermarkTexts, - fmt.Sprintf(`
OneTerm
`, top, left)) - } - } - - watermarkHTML := fmt.Sprintf(` -
- %s -
`, strings.Join(watermarkTexts, "\n")) - - if strings.Contains(content, "") { - content = strings.Replace(content, "", watermarkCSS+"", 1) - } else { - content = watermarkCSS + content - } - - if strings.Contains(content, "") { - content = strings.Replace(content, "", watermarkHTML+"", 1) - } else { - content = content + watermarkHTML - } - } - - // Add session management JavaScript (always inject) - sessionJS := fmt.Sprintf(` - `, session.SessionId) - - // Add JavaScript URL interceptor for dynamic requests (always inject - moved outside watermark condition) - - urlInterceptorJS := fmt.Sprintf(` - `, session.CurrentHost, baseDomain, scheme, session.Permissions.FileDownload) - - // Always inject session management and URL interceptor - + combinedContent := sessionJS + watermarkHTML if strings.Contains(content, "") { - content = strings.Replace(content, "", sessionJS+urlInterceptorJS+"", 1) + content = strings.Replace(content, "", combinedContent+"", 1) + } else if strings.Contains(content, "") { + content = strings.Replace(content, "", combinedContent+"", 1) } else { - content = content + sessionJS + urlInterceptorJS + content = content + sessionJS } - // Step 3: Record activity if enabled - if session.WebConfig != nil && session.WebConfig.ProxySettings != nil && session.WebConfig.ProxySettings.RecordingEnabled { - // Activity recording is handled elsewhere to avoid accessing ctx.Request here - } - - // Update response newBody := bytes.NewReader([]byte(content)) resp.Body = io.NopCloser(newBody) resp.ContentLength = int64(len(content)) - resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(content))) + resp.Header.Set("Content-Length", strconv.Itoa(len(content))) + + return nil } -// RenderExternalRedirectPage renders the page shown when external redirect is blocked -func RenderExternalRedirectPage(targetURL string) string { - return fmt.Sprintf(` - - - External Redirect Blocked - OneTerm - - - - - -
-

🛡️ External Redirect Blocked

-
- The target website attempted to redirect you to an external domain, - which has been blocked by the bastion host for security reasons. -
-
Target URL:
-
%s
-
- All web access must go through the bastion host to maintain security - and audit compliance. External redirects are not permitted. -
- -
- -`, targetURL) -} +func readResponseBody(resp *http.Response) ([]byte, error) { + if resp.Body == nil { + return []byte{}, nil + } + defer resp.Body.Close() -// RenderErrorPage renders a general error page for web proxy errors -func RenderErrorPage(errorType, title, reason, details string) string { - var bgColor, iconEmoji string + contentEncoding := resp.Header.Get("Content-Encoding") + resp.Header.Del("Content-Encoding") + resp.Header.Del("Content-Length") - switch errorType { - case "access_denied": - bgColor = "#ff6b6b 0%, #ee5a52 100%" - iconEmoji = "🚫" - case "session_expired": - bgColor = "#f39c12 0%, #e67e22 100%" - iconEmoji = "⏰" - case "connection_error": - bgColor = "#95a5a6 0%, #7f8c8d 100%" - iconEmoji = "🔌" - case "server_error": - bgColor = "#8e44ad 0%, #9b59b6 100%" - iconEmoji = "⚠️" - case "concurrent_limit": - bgColor = "#e74c3c 0%, #c0392b 100%" - iconEmoji = "🚦" - default: - bgColor = "#34495e 0%, #2c3e50 100%" - iconEmoji = "❌" + var reader io.Reader = resp.Body + + if contentEncoding == "gzip" { + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, err + } + defer gzipReader.Close() + reader = gzipReader } - detailsHtml := "" - if details != "" { - detailsHtml = fmt.Sprintf(` -
Details:
-
%s
`, details) + limitedReader := io.LimitReader(reader, maxContentSize) + return io.ReadAll(limitedReader) +} + +func processCSSContent(resp *http.Response, reqCtx *RequestContext) error { + + body, err := readResponseBody(resp) + if err != nil { + logger.L().Error("Failed to read CSS response body", zap.Error(err)) + return err } - return fmt.Sprintf(` - - - %s - OneTerm - - - - - -
-

%s %s

-
%s
- %s - -
- -`, title, bgColor, iconEmoji, title, reason, detailsHtml) + bodyStr := string(body) + + // Rewrite URL references in CSS and add session parameters + rewrittenCSS := rewriteCSSUrls(bodyStr, reqCtx.AssetID, reqCtx.SessionID) + + // Update response + newBodyBytes := []byte(rewrittenCSS) + newBody := bytes.NewReader(newBodyBytes) + resp.Body = io.NopCloser(newBody) + resp.ContentLength = int64(len(newBodyBytes)) + resp.Header.Set("Content-Length", strconv.Itoa(len(newBodyBytes))) + + return nil } -// RenderAccessDeniedPage renders the page shown when access is denied (download, read-only, etc.) -func RenderAccessDeniedPage(reason, details string) string { - return RenderErrorPage("access_denied", "Access Denied", reason, details) +// rewriteCSSUrls rewrites URL references in CSS +func rewriteCSSUrls(cssContent string, assetID int, sessionID string) string { + result := cssContent + + // Process url() references + urlStart := 0 + for { + urlIdx := strings.Index(result[urlStart:], "url(") + if urlIdx == -1 { + break + } + + urlIdx += urlStart + urlStart = urlIdx + 4 + + // Find corresponding closing bracket + parenIdx := strings.Index(result[urlStart:], ")") + if parenIdx == -1 { + continue + } + + urlContent := strings.TrimSpace(result[urlStart : urlStart+parenIdx]) + // Remove quotes + urlContent = strings.Trim(urlContent, "\"'") + + // Skip existing session parameters or external URLs + if strings.Contains(urlContent, "session_id=") || strings.HasPrefix(urlContent, "http") || strings.HasPrefix(urlContent, "//") { + continue + } + + // Only process relative paths + if strings.HasPrefix(urlContent, "/") || strings.HasPrefix(urlContent, ".") { + // Add session parameters + sessionParams := fmt.Sprintf("asset_id=%d&session_id=%s", assetID, sessionID) + var newUrl string + if strings.Contains(urlContent, "?") { + newUrl = urlContent + "&" + sessionParams + } else { + newUrl = urlContent + "?" + sessionParams + } + + // Replace original URL + newUrlPart := `"` + newUrl + `"` + result = result[:urlStart] + newUrlPart + result[urlStart+parenIdx:] + + // Adjust next search position + urlStart += len(newUrlPart) + } + } + + return result } -// RenderSessionExpiredPage renders the page shown when session has expired -func RenderSessionExpiredPage(reason string) string { - return RenderErrorPage("session_expired", "Session Expired", reason, "") +// generateSimpleJavaScript generates enhanced JavaScript proxy code +func generateSimpleJavaScript(assetID int, sessionID string) string { + return fmt.Sprintf(` +`, assetID, sessionID) } -// RenderConcurrentLimitPage renders the page when concurrent limit is exceeded -func RenderConcurrentLimitPage(maxConcurrent int) string { - reason := fmt.Sprintf("Maximum concurrent connections (%d) exceeded", maxConcurrent) - details := "Please wait for an existing session to end, or contact your administrator to increase the limit." - return RenderErrorPage("concurrent_limit", "Connection Limit Exceeded", reason, details) +// rewriteHTMLLinks rewrites links in HTML and adds session parameters +func rewriteHTMLLinks(htmlContent string, assetID int, sessionID string) string { + result := htmlContent + + compiledRegex.Do(initRegexPatterns) + + result = srcRegex.ReplaceAllStringFunc(result, func(match string) string { + matches := srcRegex.FindStringSubmatch(match) + if len(matches) < 3 || len(matches[1]) == 0 || len(matches[2]) == 0 { + return match + } + urlStr := matches[2] + if strings.Contains(urlStr, "session_id=") || strings.HasPrefix(urlStr, "javascript:") || urlStr == "" { + return match + } + sessionParams := fmt.Sprintf("asset_id=%d&session_id=%s", assetID, sessionID) + separator := "?" + if strings.Contains(urlStr, "?") { + separator = "&" + } + return matches[1] + urlStr + separator + sessionParams + `"` + }) + + result = actionRegex.ReplaceAllStringFunc(result, func(match string) string { + matches := actionRegex.FindStringSubmatch(match) + if len(matches) < 3 || len(matches[1]) == 0 || len(matches[2]) == 0 { + return match + } + urlStr := matches[2] + if strings.Contains(urlStr, "session_id=") || strings.HasPrefix(urlStr, "javascript:") || urlStr == "" { + return match + } + sessionParams := fmt.Sprintf("asset_id=%d&session_id=%s", assetID, sessionID) + separator := "?" + if strings.Contains(urlStr, "?") { + separator = "&" + } + return matches[1] + urlStr + separator + sessionParams + `"` + }) + + result = hrefRegex.ReplaceAllStringFunc(result, func(match string) string { + matches := hrefRegex.FindStringSubmatch(match) + if len(matches) < 3 || len(matches[1]) == 0 || len(matches[2]) == 0 { + return match + } + urlStr := matches[2] + if strings.Contains(urlStr, "session_id=") || strings.HasPrefix(urlStr, "javascript:") || + strings.HasPrefix(urlStr, "#") || strings.HasPrefix(urlStr, "mailto:") || + strings.HasPrefix(urlStr, "tel:") || urlStr == "" { + return match + } + + if strings.HasPrefix(urlStr, "http://") || strings.HasPrefix(urlStr, "https://") { + return match + } + + sessionParams := fmt.Sprintf("asset_id=%d&session_id=%s", assetID, sessionID) + separator := "?" + if strings.Contains(urlStr, "?") { + separator = "&" + } + return matches[1] + urlStr + separator + sessionParams + `"` + }) + + return result } -// RenderServerErrorPage renders the page for server errors -func RenderServerErrorPage(reason, details string) string { - return RenderErrorPage("server_error", "Server Error", reason, details) +// processRedirect handles redirect responses +func processRedirect(resp *http.Response, reqCtx *RequestContext) error { + location := resp.Header.Get("Location") + if location == "" { + return nil + } + + if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") { + redirectURL, err := url.Parse(location) + if err != nil { + return nil + } + + if reqCtx.Session != nil { + reqCtx.Session.CurrentHost = redirectURL.Host + } + + baseDomain := reqCtx.ProxyHost + if baseDomain == "" { + return nil + } + + if colonIdx := strings.LastIndex(baseDomain, ":"); colonIdx != -1 { + baseDomain = baseDomain[:colonIdx] + } + + if baseDomainWithoutPrefix, found := strings.CutPrefix(baseDomain, "webproxy."); found { + baseDomain = baseDomainWithoutPrefix + } + + 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) + if redirectURL.RawQuery != "" { + newProxyURL += "?" + redirectURL.RawQuery + "&asset_id=" + fmt.Sprintf("%d", reqCtx.AssetID) + "&session_id=" + reqCtx.SessionID + "&target_host=" + redirectURL.Host + } else { + newProxyURL += "?asset_id=" + fmt.Sprintf("%d", reqCtx.AssetID) + "&session_id=" + reqCtx.SessionID + "&target_host=" + redirectURL.Host + } + resp.Header.Set("Location", newProxyURL) + + } else { + if !strings.Contains(location, "session_id=") { + separator := "?" + if strings.Contains(location, "?") { + separator = "&" + } + newLocation := location + separator + fmt.Sprintf("asset_id=%d&session_id=%s", reqCtx.AssetID, reqCtx.SessionID) + resp.Header.Set("Location", newLocation) + } + } + + return nil } -// RenderConnectionErrorPage renders the page for connection errors -func RenderConnectionErrorPage(reason, details string) string { - return RenderErrorPage("connection_error", "Connection Error", reason, details) +// needsWatermark checks if watermark should be added based on asset configuration +func needsWatermark(reqCtx *RequestContext) bool { + if reqCtx.Session == nil { + return false + } + + assetService := service.NewAssetService() + asset, err := assetService.GetById(context.TODO(), reqCtx.AssetID) + if err != nil { + return false + } + + if asset.WebConfig != nil && + asset.WebConfig.ProxySettings != nil { + watermarkEnabled := asset.WebConfig.ProxySettings.WatermarkEnabled + return watermarkEnabled + } + + return false } -// Legacy function - keeping the original style for compatibility -func RenderSessionExpiredPageOld(reason string) string { - return fmt.Sprintf(` - - - Session Expired - OneTerm - - - - - -
- -
Session Expired
-
Your web proxy session has expired and you need to reconnect.
-
Reason: %s
- ← Go Back - -
- -`, reason) +// generateWatermarkHTML generates HTML for watermark overlay +func generateWatermarkHTML(reqCtx *RequestContext) string { + currentUser := "" + _ = time.Now().Format("2006-01-02 15:04:05") + assetName := "" + + if reqCtx.Session != nil { + assetService := service.NewAssetService() + asset, err := assetService.GetById(context.TODO(), reqCtx.AssetID) + if err == nil { + assetName = asset.Name + } + } else { + assetName = fmt.Sprintf("Asset-%d", reqCtx.AssetID) + } + + watermarkText := fmt.Sprintf("OneTerm %s", assetName) + if currentUser != "" { + watermarkText = fmt.Sprintf("OneTerm %s", assetName) + } + + var watermarkLines []string + for row := 0; row < 20; row++ { + var line string + for col := 0; col < 10; col++ { + line += watermarkText + " " + } + watermarkLines = append(watermarkLines, line) + } + fullWatermarkText := strings.Join(watermarkLines, "\n") + + return fmt.Sprintf(` + +
%s
+`, fullWatermarkText) } diff --git a/backend/internal/service/web_proxy/core.go b/backend/internal/service/web_proxy/core.go new file mode 100644 index 0000000..16275f6 --- /dev/null +++ b/backend/internal/service/web_proxy/core.go @@ -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, + } +} diff --git a/backend/internal/service/web_proxy/service.go b/backend/internal/service/web_proxy/service.go deleted file mode 100644 index 9a41fcf..0000000 --- a/backend/internal/service/web_proxy/service.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/service/web_proxy/session.go b/backend/internal/service/web_proxy/session.go index 57e0291..2b3e52d 100644 --- a/backend/internal/service/web_proxy/session.go +++ b/backend/internal/service/web_proxy/session.go @@ -13,17 +13,14 @@ import ( "github.com/veops/oneterm/pkg/logger" ) -// Global session storage var webProxySessions = make(map[string]*WebProxySession) -// Global cleanup context var ( cleanupCtx context.Context cleanupCancel context.CancelFunc cleanupWg sync.WaitGroup ) -// WebProxySession represents an active web proxy session type WebProxySession struct { SessionId string AssetId int @@ -34,39 +31,33 @@ type WebProxySession struct { LastHeartbeat time.Time // Track heartbeat separately IsActive bool // Active for concurrent control (heartbeat-based) CurrentHost string - Permissions *model.AuthPermissions // User permissions for this asset - WebConfig *model.WebConfig // Web-specific configuration + SessionPerms *SessionPermissions // Cached session permissions for proxy phase + WebConfig *model.WebConfig } -// cleanupExpiredSessions implements layered timeout mechanism -func cleanupExpiredSessions(maxInactiveTime time.Duration) { +func cleanupExpiredSessions(systemMaxInactiveTime time.Duration) { now := time.Now() deactivatedCount := 0 cleanedCount := 0 - // Layer 1: Concurrent control timeout (fast) - heartbeatTimeout := 45 * time.Second - - // Layer 2: Session expiry timeout (slow, system config) + heartbeatTimeout := 30 * time.Second for sessionID, session := range webProxySessions { - // Layer 1: Check heartbeat for concurrent control if session.IsActive && !session.LastHeartbeat.IsZero() && now.Sub(session.LastHeartbeat) > heartbeatTimeout { - // Deactivate session (release concurrent slot AND mark as offline) session.IsActive = false UpdateWebSessionStatus(sessionID, model.SESSIONSTATUS_OFFLINE) deactivatedCount++ } - // Layer 2: Check session expiry for final cleanup + effectiveTimeout := systemMaxInactiveTime + shouldDelete := false - if now.Sub(session.LastActivity) > maxInactiveTime { + if now.Sub(session.LastActivity) > effectiveTimeout { shouldDelete = true } if shouldDelete { - // No need to update status again - already done in Layer 1 delete(webProxySessions, sessionID) cleanedCount++ } @@ -79,25 +70,21 @@ func cleanupExpiredSessions(maxInactiveTime time.Duration) { } } -// StartSessionCleanupRoutine starts background cleanup routine for web sessions func StartSessionCleanupRoutine() { - // Initialize cleanup context cleanupCtx, cleanupCancel = context.WithCancel(context.Background()) - - // More frequent cleanup - every 30 seconds to catch closed browser tabs quickly + ticker := time.NewTicker(30 * time.Second) cleanupWg.Add(1) go func() { defer cleanupWg.Done() defer ticker.Stop() - + for { select { case <-cleanupCtx.Done(): logger.L().Info("Web proxy session cleanup stopped") return case <-ticker.C: - // Use system configured timeout (same as other protocols) systemTimeout := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second cleanupExpiredSessions(systemTimeout) } @@ -105,7 +92,6 @@ func StartSessionCleanupRoutine() { }() } -// StopSessionCleanupRoutine stops the background cleanup routine func StopSessionCleanupRoutine() { if cleanupCancel != nil { cleanupCancel() @@ -114,30 +100,25 @@ func StopSessionCleanupRoutine() { } } -// GetSession retrieves a session by ID func GetSession(sessionID string) (*WebProxySession, bool) { session, exists := webProxySessions[sessionID] return session, exists } -// StoreSession stores a session in the session map func StoreSession(sessionID string, session *WebProxySession) { webProxySessions[sessionID] = session } -// DeleteSession removes a session from the session map func DeleteSession(sessionID string) { delete(webProxySessions, sessionID) } -// UpdateSessionActivity updates the last activity time for a session func UpdateSessionActivity(sessionID string) { if session, exists := webProxySessions[sessionID]; exists { session.LastActivity = time.Now() } } -// UpdateSessionHeartbeat updates the last heartbeat time for a session func UpdateSessionHeartbeat(sessionID string) { if session, exists := webProxySessions[sessionID]; exists { now := time.Now() @@ -145,26 +126,21 @@ func UpdateSessionHeartbeat(sessionID string) { session.LastHeartbeat = now session.IsActive = true // Re-activate session on heartbeat - // Heartbeat also counts as activity (user is still viewing the page) session.LastActivity = now - // If session was previously inactive, mark it as online again if wasInactive { UpdateWebSessionStatus(sessionID, model.SESSIONSTATUS_ONLINE) } } } -// UpdateSessionHost updates the current host for a session func UpdateSessionHost(sessionID string, host string) { if session, exists := webProxySessions[sessionID]; exists { session.CurrentHost = host } } -// GetActiveSessionsForAsset returns the number of active sessions for an asset func GetActiveSessionsForAsset(assetID int) int { - // First cleanup expired sessions to get accurate count systemTimeout := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second cleanupExpiredSessions(systemTimeout) @@ -177,17 +153,14 @@ func GetActiveSessionsForAsset(assetID int) int { return count } -// GetAllSessions returns all active sessions func GetAllSessions() map[string]*WebProxySession { return webProxySessions } -// CountActiveSessions returns the total number of active sessions func CountActiveSessions() int { return len(webProxySessions) } -// CloseWebSession closes and removes a session func CloseWebSession(sessionID string) { if session, exists := webProxySessions[sessionID]; exists { logger.L().Info("Closing web session", @@ -195,16 +168,13 @@ func CloseWebSession(sessionID string) { zap.Int("assetID", session.AssetId), zap.Int("accountID", session.AccountId)) - // Update database session record to offline status UpdateWebSessionStatus(sessionID, model.SESSIONSTATUS_OFFLINE) delete(webProxySessions, sessionID) } } -// UpdateWebSessionStatus updates the session status in database func UpdateWebSessionStatus(sessionID string, status int) { - // Use repository to get and update database session status repo := repository.NewSessionRepository() if dbSession, err := repo.GetSession(context.Background(), sessionID); err == nil && dbSession != nil { now := time.Now() @@ -214,7 +184,6 @@ func UpdateWebSessionStatus(sessionID string, status int) { } dbSession.UpdatedAt = now - // Save updated session to database fullSession := &gsession.Session{Session: dbSession} if err := gsession.UpsertSession(fullSession); err != nil { logger.L().Error("Failed to update web session status in database",