feat(backend): implement layered session timeout and enhanced download control for web proxy

This commit is contained in:
pycook
2025-08-03 21:03:51 +08:00
parent dd11bf67ce
commit 2ef67fd396
10 changed files with 799 additions and 349 deletions

View File

@@ -96,8 +96,10 @@ func (c *Controller) GetNodes(ctx *gin.Context) {
db = db.Select("id", "parent_id", "name", "authorization")
}
db = db.Order("id ASC")
if recursive {
treeNodes, err := nodeService.GetNodesTree(ctx, db, !info, config.RESOURCE_NODE)
treeNodes, err := nodeService.GetNodesTree(ctx, db, false, config.RESOURCE_NODE)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}})
return
@@ -110,7 +112,7 @@ func (c *Controller) GetNodes(ctx *gin.Context) {
ctx.JSON(http.StatusOK, NewHttpResponseWithData(res))
} else {
doGet(ctx, !info, db, config.RESOURCE_NODE, nodePostHooks...)
doGet(ctx, false, db, config.RESOURCE_NODE, nodePostHooks...)
}
}

View File

@@ -1,18 +1,13 @@
package controller
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/samber/lo"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/service"
"github.com/veops/oneterm/internal/service/web_proxy"
@@ -25,11 +20,9 @@ func NewWebProxyController() *WebProxyController {
return &WebProxyController{}
}
// 使用service层的结构体
type StartWebSessionRequest = web_proxy.StartWebSessionRequest
type StartWebSessionResponse = web_proxy.StartWebSessionResponse
// 使用service层的全局变量和结构体
type WebProxySession = web_proxy.WebProxySession
func StartSessionCleanupRoutine() {
@@ -47,12 +40,8 @@ func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason s
// @Summary Get web asset configuration
// @Description Get web asset configuration by asset ID
// @Tags WebProxy
// @Accept json
// @Produce json
// @Param asset_id path int true "Asset ID"
// @Success 200 {object} model.WebConfig
// @Failure 400 {object} map[string]interface{} "Invalid asset ID"
// @Failure 404 {object} map[string]interface{} "Asset not found"
// @Router /web_proxy/config/{asset_id} [get]
func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) {
assetIdStr := ctx.Param("asset_id")
@@ -81,15 +70,8 @@ func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) {
// @Summary Start web session
// @Description Start a new web session for the specified asset
// @Tags WebProxy
// @Accept json
// @Produce json
// @Param request body web_proxy.StartWebSessionRequest true "Start session request"
// @Success 200 {object} web_proxy.StartWebSessionResponse
// @Failure 400 {object} map[string]interface{} "Invalid request"
// @Failure 403 {object} map[string]interface{} "No permission"
// @Failure 404 {object} map[string]interface{} "Asset not found"
// @Failure 429 {object} map[string]interface{} "Maximum concurrent connections exceeded"
// @Failure 500 {object} map[string]interface{} "Internal server error"
// @Router /web_proxy/start [post]
func (c *WebProxyController) StartWebSession(ctx *gin.Context) {
var req StartWebSessionRequest
@@ -122,240 +104,60 @@ func (c *WebProxyController) StartWebSession(ctx *gin.Context) {
// @Summary Proxy web requests
// @Description Handle web proxy requests for subdomain-based assets
// @Tags WebProxy
// @Accept */*
// @Produce */*
// @Param Host header string true "Asset subdomain (asset-123.domain.com)"
// @Param session_id query string false "Session ID (alternative to cookie)"
// @Success 200 "Proxied content"
// @Failure 400 {object} map[string]interface{} "Invalid subdomain format"
// @Failure 401 "Session expired page"
// @Failure 403 {object} map[string]interface{} "Access denied"
// @Router /proxy [get]
func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
host := ctx.Request.Host
// Try to get session_id from multiple sources (priority order)
sessionID := ctx.Query("session_id")
// 1. Try from Cookie (preferred method)
if sessionID == "" {
if cookie, err := ctx.Cookie("oneterm_session_id"); err == nil && cookie != "" {
sessionID = cookie
logger.L().Debug("Extracted session_id from cookie", zap.String("sessionID", sessionID))
}
}
// 2. Try from redirect parameter (for login redirects)
if sessionID == "" {
if redirect := ctx.Query("redirect"); redirect != "" {
if decoded, err := url.QueryUnescape(redirect); err == nil {
if decodedURL, err := url.Parse(decoded); err == nil {
sessionID = decodedURL.Query().Get("session_id")
}
}
}
}
// Extract asset ID from Host header: asset-11.oneterm.com -> 11
assetID, err := c.extractAssetIDFromHost(host)
// Extract session ID and asset ID from request
proxyCtx, err := web_proxy.ExtractSessionAndAssetInfo(ctx, c.extractAssetIDFromHost)
if err != nil {
logger.L().Error("Invalid subdomain format", zap.String("host", host), zap.Error(err))
logger.L().Error("Failed to extract session/asset info", zap.Error(err))
if strings.Contains(err.Error(), "invalid subdomain format") {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subdomain format"})
} else {
c.renderSessionExpiredPage(ctx, err.Error())
}
return
}
logger.L().Debug("Extracted asset ID", zap.Int("assetID", assetID))
// Try to get session_id from Referer header as fallback
if sessionID == "" {
referer := ctx.GetHeader("Referer")
if referer != "" {
if refererURL, err := url.Parse(referer); err == nil {
sessionID = refererURL.Query().Get("session_id")
// Also try to extract from fragment/hash part if URL encoded
if sessionID == "" && strings.Contains(refererURL.RawQuery, "session_id") {
// Handle URL encoded session_id in redirect parameter
if redirect := refererURL.Query().Get("redirect"); redirect != "" {
if decoded, err := url.QueryUnescape(redirect); err == nil {
if decodedURL, err := url.Parse(decoded); err == nil {
sessionID = decodedURL.Query().Get("session_id")
}
}
}
}
}
}
}
// For static resources, try harder to find session_id
if sessionID == "" {
// Check if this looks like a static resource
isStaticResource := strings.Contains(ctx.Request.URL.Path, "/img/") ||
strings.Contains(ctx.Request.URL.Path, "/css/") ||
strings.Contains(ctx.Request.URL.Path, "/js/") ||
strings.Contains(ctx.Request.URL.Path, "/assets/") ||
strings.HasSuffix(ctx.Request.URL.Path, ".png") ||
strings.HasSuffix(ctx.Request.URL.Path, ".jpg") ||
strings.HasSuffix(ctx.Request.URL.Path, ".gif") ||
strings.HasSuffix(ctx.Request.URL.Path, ".css") ||
strings.HasSuffix(ctx.Request.URL.Path, ".js") ||
strings.HasSuffix(ctx.Request.URL.Path, ".ico")
if isStaticResource {
// For static resources, find any valid session for this asset
allSessions := web_proxy.GetAllSessions()
for sid, session := range allSessions {
if session.AssetId == assetID {
sessionID = sid
break
}
}
}
}
if sessionID == "" {
logger.L().Error("Missing session ID", zap.String("host", host))
c.renderSessionExpiredPage(ctx, "Session ID required - please start a new web session")
return
}
// Validate session ID and get session information
session, exists := web_proxy.GetSession(sessionID)
if !exists {
c.renderSessionExpiredPage(ctx, "Invalid or expired session")
return
}
// 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 {
web_proxy.CloseWebSession(sessionID)
c.renderSessionExpiredPage(ctx, "Session expired due to inactivity")
return
}
// Update last activity time and auto-renew cookie
web_proxy.UpdateSessionActivity(sessionID)
cookieMaxAge := int(model.GlobalConfig.Load().Timeout)
ctx.SetCookie("oneterm_session_id", sessionID, cookieMaxAge, "/", "", false, true)
// Update last activity
web_proxy.UpdateSessionActivity(sessionID)
// Check Web-specific access controls
if err := c.checkWebAccessControls(ctx, session); err != nil {
// 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 {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
}
return
}
if session.AssetId != assetID {
ctx.JSON(http.StatusForbidden, gin.H{"error": "Asset ID mismatch"})
return
}
targetURL := c.buildTargetURLWithHost(session.Asset, session.CurrentHost)
target, err := url.Parse(targetURL)
// Setup reverse proxy
proxy, err := web_proxy.SetupReverseProxy(ctx, proxyCtx, c.buildTargetURLWithHost, c.processHTMLResponse, c.recordWebActivity, c.isSameDomainOrSubdomain)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid target URL"})
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
currentScheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http")
// Create transparent reverse proxy
proxy := httputil.NewSingleHostReverseProxy(target)
// Configure proxy director for transparent proxying
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = target.Host
req.Header.Set("Host", target.Host)
if origin := req.Header.Get("Origin"); origin != "" {
req.Header.Set("Origin", target.Scheme+"://"+target.Host)
}
if referer := req.Header.Get("Referer"); referer != "" {
if refererURL, err := url.Parse(referer); err == nil {
refererURL.Scheme = target.Scheme
refererURL.Host = target.Host
req.Header.Set("Referer", refererURL.String())
}
}
q := req.URL.Query()
q.Del("session_id")
req.URL.RawQuery = q.Encode()
}
// Redirect interception for bastion control
proxy.ModifyResponse = func(resp *http.Response) error {
contentType := resp.Header.Get("Content-Type")
if resp.StatusCode == 200 && strings.Contains(contentType, "text/html") {
c.processHTMLResponse(resp, assetID, currentScheme, host, session)
}
// Record activity if enabled
if session.WebConfig != nil && session.WebConfig.ProxySettings != nil && session.WebConfig.ProxySettings.RecordingEnabled {
c.recordWebActivity(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(host, "asset-"),
func() string {
parts := strings.SplitN(host, ".", 2)
return lo.Ternary(len(parts) > 1, parts[1], host)
}(),
host)
if c.isSameDomainOrSubdomain(target.Host, redirectURL.Host) {
web_proxy.UpdateSessionHost(sessionID, redirectURL.Host)
newProxyURL := fmt.Sprintf("%s://asset-%d.%s%s", currentScheme, assetID, baseDomain, redirectURL.Path)
if redirectURL.RawQuery != "" {
newProxyURL += "?" + redirectURL.RawQuery
}
resp.Header.Set("Location", newProxyURL)
} else {
newLocation := fmt.Sprintf("%s://asset-%d.%s/external?url=%s",
currentScheme, assetID, baseDomain, url.QueryEscape(redirectURL.String()))
resp.Header.Set("Location", newLocation)
}
} else {
resp.Header.Set("Location", redirectURL.String())
}
}
}
if cookies := resp.Header["Set-Cookie"]; len(cookies) > 0 {
proxyDomain := strings.Split(host, ":")[0]
newCookies := lo.Map(cookies, func(cookie string, _ int) string {
if strings.Contains(cookie, "Domain="+target.Host) {
return strings.Replace(cookie, "Domain="+target.Host, "Domain="+proxyDomain, 1)
}
return cookie
})
resp.Header["Set-Cookie"] = newCookies
}
return nil
}
ctx.Header("Cache-Control", "no-cache")
// Add panic recovery for proxy requests
defer func() {
if r := recover(); r != nil {
logger.L().Error("Proxy request panic recovered",
zap.String("url", ctx.Request.URL.String()),
zap.String("host", ctx.Request.Host),
zap.Any("panic", r))
// Return appropriate error response instead of crashing
if !ctx.Writer.Written() {
ctx.JSON(http.StatusBadGateway, gin.H{
"error": "Proxy request failed",
"details": "The target server is not responding properly",
})
}
}
}()
proxy.ServeHTTP(ctx.Writer, ctx.Request)
}
@@ -363,8 +165,6 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
// @Summary Handle external redirect
// @Description Show a page when an external redirect is blocked by the proxy
// @Tags WebProxy
// @Accept html
// @Produce html
// @Param url query string true "Target URL that was blocked"
// @Success 200 "External redirect blocked page"
// @Router /web_proxy/external_redirect [get]
@@ -421,12 +221,8 @@ func (c *WebProxyController) getActiveSessionsForAsset(assetId int) []map[string
// @Summary Close web session
// @Description Close an active web session and clean up resources
// @Tags WebProxy
// @Accept json
// @Produce json
// @Param request body map[string]string true "Session close request" example({"session_id": "web_123_456_1640000000"})
// @Success 200 {object} map[string]string "Session closed successfully"
// @Failure 400 {object} map[string]interface{} "Invalid request"
// @Failure 404 {object} map[string]interface{} "Session not found"
// @Router /web_proxy/close [post]
func (c *WebProxyController) CloseWebSession(ctx *gin.Context) {
var req struct {
@@ -446,11 +242,8 @@ func (c *WebProxyController) CloseWebSession(ctx *gin.Context) {
// @Summary Get active web sessions
// @Description Get list of active web sessions for a specific asset
// @Tags WebProxy
// @Accept json
// @Produce json
// @Param asset_id path int true "Asset ID"
// @Success 200 {array} map[string]interface{} "List of active sessions"
// @Failure 400 {object} map[string]interface{} "Invalid asset ID"
// @Router /web_proxy/sessions/{asset_id} [get]
func (c *WebProxyController) GetActiveWebSessions(ctx *gin.Context) {
assetIdStr := ctx.Param("asset_id")
@@ -468,12 +261,8 @@ func (c *WebProxyController) GetActiveWebSessions(ctx *gin.Context) {
// @Summary Update session heartbeat
// @Description Update the last activity time for a web session (heartbeat)
// @Tags WebProxy
// @Accept json
// @Produce json
// @Param request body map[string]string true "Heartbeat request" example({"session_id": "web_123_456_1640000000"})
// @Success 200 {object} map[string]string "Heartbeat updated"
// @Failure 400 {object} map[string]interface{} "Invalid request"
// @Failure 404 {object} map[string]interface{} "Session not found"
// @Router /web_proxy/heartbeat [post]
func (c *WebProxyController) UpdateWebSessionHeartbeat(ctx *gin.Context) {
var req struct {
@@ -486,8 +275,10 @@ func (c *WebProxyController) UpdateWebSessionHeartbeat(ctx *gin.Context) {
}
if session, exists := web_proxy.GetSession(req.SessionId); exists {
session.LastActivity = time.Now()
ctx.JSON(http.StatusOK, gin.H{"status": "updated"})
// Update heartbeat - this extends session life and indicates user is still viewing
web_proxy.UpdateSessionHeartbeat(req.SessionId)
_ = session // Use the session variable to avoid unused warning
ctx.JSON(http.StatusOK, gin.H{"status": "alive"})
} else {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
}
@@ -497,8 +288,6 @@ func (c *WebProxyController) UpdateWebSessionHeartbeat(ctx *gin.Context) {
// @Summary Cleanup web session
// @Description Clean up web session when browser tab is closed
// @Tags WebProxy
// @Accept json
// @Produce json
// @Param request body map[string]string true "Cleanup request" example({"session_id": "web_123_456_1640000000"})
// @Success 200 {object} map[string]string "Session cleaned up"
// @Router /web_proxy/cleanup [post]

View File

@@ -27,6 +27,12 @@ func SetupRouter(r *gin.Engine) {
// Check if this is an asset subdomain request
if strings.HasPrefix(host, "asset-") {
// 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)
@@ -259,8 +265,13 @@ func SetupRouter(r *gin.Engine) {
webProxyGroup.GET("/external_redirect", webProxy.HandleExternalRedirect)
webProxyGroup.POST("/close", webProxy.CloseWebSession)
webProxyGroup.GET("/sessions/:asset_id", webProxy.GetActiveWebSessions)
webProxyGroup.POST("/heartbeat", webProxy.UpdateWebSessionHeartbeat)
webProxyGroup.POST("/cleanup", webProxy.CleanupWebSession)
}
// Web proxy routes that don't require auth (heartbeat, cleanup)
webProxyNoAuth := v1AuthAbandoned.Group("/web_proxy")
{
webProxyNoAuth.POST("/heartbeat", webProxy.UpdateWebSessionHeartbeat)
webProxyNoAuth.POST("/cleanup", webProxy.CleanupWebSession)
}
}
}

View File

@@ -163,6 +163,10 @@ func (m *AuthorizationMatcher) matchSelector(ctx context.Context, selector model
}
switch selector.Type {
case "":
// Empty selector type means no restriction - skip this selector check
return true
case model.SelectorTypeAll:
return true

View File

@@ -210,24 +210,18 @@ func (s *NodeService) AttachAssetCount(ctx *gin.Context, data []*model.Node) err
db := dbpkg.DB.Model(model.DefaultAsset)
if !acl.IsAdmin(currentUser) {
info := cast.ToBool(ctx.Query("info"))
if info {
// Use V2 authorization system for asset filtering
// Always use V2 authorization system for consistent permission control
authV2Service := NewAuthorizationV2Service()
_, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx)
if err != nil {
return err
}
db = db.Where("id IN ?", assetIds)
if len(assetIds) == 0 {
// No access to any assets
db = db.Where("1 = 0")
} else {
assetResId, err := acl.GetRoleResourceIds(ctx, currentUser.GetRid(), config.RESOURCE_ASSET)
if err != nil {
return err
}
db, err = s.handleAssetIds(ctx, db, assetResId)
if err != nil {
return err
}
db = db.Where("id IN ?", assetIds)
}
}

View File

@@ -177,7 +177,6 @@ func (s *AuthService) AuthenticateWithRetry(ctx context.Context, accounts []Cred
var lastError error
var lastResult *AuthResult
// 尝试每个账号,直到成功
for i, credentials := range accounts {
logger.L().Info("Attempting authentication",
zap.String("strategy", strategy.Name()),
@@ -207,7 +206,6 @@ func (s *AuthService) AuthenticateWithRetry(ctx context.Context, accounts []Cred
zap.String("reason", result.Message))
}
// 所有账号都失败了
if lastError != nil {
return nil, fmt.Errorf("all authentication attempts failed, last error: %w", lastError)
}

View File

@@ -66,6 +66,14 @@ func RewriteHTMLContent(resp *http.Response, assetID int, scheme, proxyHost stri
return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path)
},
},
// Static resources: <img src=""> <script src=""> <link href="">
{
`(src\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`,
func(matches []string) string {
path := lo.Ternary(len(matches) > 3 && matches[3] != "", matches[3], "")
return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path)
},
},
}
for _, p := range patterns {
@@ -122,7 +130,7 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
content := string(body)
// Step 1: URL rewriting for external links
// URL rewriting for external links
baseDomain := lo.Ternary(strings.HasPrefix(proxyHost, "asset-"),
func() string {
parts := strings.SplitN(proxyHost, ".", 2)
@@ -155,6 +163,13 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path)
},
},
{
`(src\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`,
func(matches []string) string {
path := lo.Ternary(len(matches) > 3 && matches[3] != "", matches[3], "")
return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path)
},
},
}
for _, p := range patterns {
@@ -210,14 +225,27 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
%s
</div>`, strings.Join(watermarkTexts, "\n"))
// Add session management JavaScript
if strings.Contains(content, "</head>") {
content = strings.Replace(content, "</head>", watermarkCSS+"</head>", 1)
} else {
content = watermarkCSS + content
}
if strings.Contains(content, "</body>") {
content = strings.Replace(content, "</body>", watermarkHTML+"</body>", 1)
} else {
content = content + watermarkHTML
}
}
// Add session management JavaScript (always inject)
sessionJS := fmt.Sprintf(`
<script>
(function() {
var sessionId = '%s';
var heartbeatInterval;
// Send heartbeat every 30 seconds
// Send heartbeat every 15 seconds
function sendHeartbeat() {
fetch('/api/oneterm/v1/web_proxy/heartbeat', {
method: 'POST',
@@ -226,43 +254,188 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
}).catch(function() {});
}
// Start heartbeat
heartbeatInterval = setInterval(sendHeartbeat, 30000);
// Universal heartbeat mechanism - no complex event handling
// The server will handle session cleanup based on heartbeat timeout
heartbeatInterval = setInterval(sendHeartbeat, 15000);
// Handle page unload (tab close, navigation away)
window.addEventListener('beforeunload', function() {
clearInterval(heartbeatInterval);
// Use sendBeacon for reliable cleanup on page unload
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/oneterm/v1/web_proxy/cleanup',
JSON.stringify({session_id: sessionId}));
}
});
// Handle visibility change (tab switching)
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
clearInterval(heartbeatInterval);
} else {
heartbeatInterval = setInterval(sendHeartbeat, 30000);
}
});
// Send initial heartbeat immediately
sendHeartbeat();
})();
</script>`, session.SessionId)
if strings.Contains(content, "</head>") {
content = strings.Replace(content, "</head>", watermarkCSS+"</head>", 1)
} else {
content = watermarkCSS + content
// Add JavaScript URL interceptor for dynamic requests (always inject - moved outside watermark condition)
urlInterceptorJS := fmt.Sprintf(`
<script>
(function() {
var originalHost = '%s';
var proxyHost = 'asset-%d.%s';
var proxyScheme = '%s';
function rewriteUrl(url) {
try {
// Handle absolute URLs
if (url.startsWith('http://') || url.startsWith('https://')) {
var urlObj = new URL(url);
// Only rewrite external domains, not our proxy domain
if (urlObj.hostname !== window.location.hostname &&
urlObj.hostname !== 'localhost' &&
urlObj.hostname !== '127.0.0.1') {
// Preserve the original path and query, but use proxy hostname
var newUrl = proxyScheme + '://' + proxyHost + urlObj.pathname + urlObj.search + urlObj.hash;
console.log('Rewriting URL:', url, '->', newUrl);
return newUrl;
}
}
// Handle relative URLs starting with /
else if (url.startsWith('/')) {
// Keep relative URLs as-is, they will be relative to current proxy domain
return url;
}
return url;
} catch (e) {
console.warn('URL rewrite error:', e, 'for URL:', url);
return url;
}
}
if strings.Contains(content, "</body>") {
content = strings.Replace(content, "</body>", watermarkHTML+sessionJS+"</body>", 1)
} else {
content = content + watermarkHTML + sessionJS
// Download control enforcement
var hasDownloadPermission = %t;
// Override fetch API with download control
if (window.fetch) {
var originalFetch = window.fetch;
window.fetch = function(input, init) {
// Handle both string URLs and Request objects
if (typeof input === 'string') {
input = rewriteUrl(input);
} else if (input && typeof input === 'object' && input.url) {
// Handle Request object
var rewrittenUrl = rewriteUrl(input.url);
if (rewrittenUrl !== input.url) {
input = new Request(rewrittenUrl, input);
}
}
// Monitor for potential data export APIs
if (!hasDownloadPermission) {
var url = typeof input === 'string' ? input : (input.url || '');
if (url.includes('/export') || url.includes('/download') ||
url.includes('/report') || url.includes('/data')) {
console.warn('Data export API access detected, but download permission denied');
alert('File download denied: You do not have download permission to access data export APIs');
return Promise.reject(new Error('Download permission required'));
}
}
return originalFetch.call(this, input, init);
};
}
// Override XMLHttpRequest with download control
if (window.XMLHttpRequest) {
var OriginalXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
var xhr = new OriginalXHR();
var originalOpen = xhr.open;
xhr.open = function(method, url, async, user, password) {
if (typeof url === 'string') {
url = rewriteUrl(url);
// Monitor for potential data export APIs in XHR
if (!hasDownloadPermission && (
url.includes('/export') || url.includes('/download') ||
url.includes('/report') || url.includes('/data'))) {
console.warn('XHR data export API access detected, download permission denied');
alert('File download denied: You do not have download permission to access data export APIs');
throw new Error('Download permission required');
}
}
return originalOpen.call(this, method, url, async, user, password);
};
return xhr;
};
// Copy static properties
for (var prop in OriginalXHR) {
if (OriginalXHR.hasOwnProperty(prop)) {
window.XMLHttpRequest[prop] = OriginalXHR[prop];
}
}
}
// Override window.open for popup windows
if (window.open) {
var originalOpen = window.open;
window.open = function(url, name, specs) {
if (typeof url === 'string') {
url = rewriteUrl(url);
}
return originalOpen.call(this, url, name, specs);
};
}
// Client-side download monitoring
if (!hasDownloadPermission) {
// Monitor blob URL creation (used by XLSX.js and similar libraries)
if (window.URL && window.URL.createObjectURL) {
var originalCreateObjectURL = window.URL.createObjectURL;
window.URL.createObjectURL = function(blob) {
console.warn('Blob URL creation detected, download permission denied');
// Block blob URL creation for file downloads
if (blob && blob.type && (
blob.type.includes('sheet') ||
blob.type.includes('excel') ||
blob.type.includes('pdf') ||
blob.type.includes('zip') ||
blob.type.includes('octet-stream')
)) {
alert('File download denied: You do not have download permission to create file download links');
throw new Error('File download not permitted');
}
return originalCreateObjectURL.call(this, blob);
};
}
// Monitor file download through anchor elements with download attribute
document.addEventListener('click', function(e) {
if (e.target && e.target.tagName === 'A' && e.target.hasAttribute('download')) {
console.warn('Direct file download attempt detected, download permission denied');
e.preventDefault();
e.stopPropagation();
alert('File download denied: You do not have download permission');
return false;
}
}, true);
// Monitor common file export libraries
setTimeout(function() {
// Block XLSX library
if (window.XLSX && window.XLSX.writeFile) {
window.XLSX.writeFile = function() {
alert('File export denied: You do not have download permission to export Excel files');
throw new Error('Excel export not permitted');
};
}
// Block FileSaver.js
if (window.saveAs) {
window.saveAs = function() {
alert('File save denied: You do not have download permission to save files');
throw new Error('File save not permitted');
};
}
}, 1000);
}
})();
</script>`, session.CurrentHost, assetID, baseDomain, scheme, session.Permissions.FileDownload)
// Always inject session management and URL interceptor
if strings.Contains(content, "</body>") {
content = strings.Replace(content, "</body>", sessionJS+urlInterceptorJS+"</body>", 1)
} else {
content = content + sessionJS + urlInterceptorJS
}
// 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

View File

@@ -2,6 +2,9 @@ package web_proxy
import (
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
@@ -11,6 +14,7 @@ import (
"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"
@@ -67,7 +71,6 @@ func StartWebSession(ctx *gin.Context, req StartWebSessionRequest) (*StartWebSes
},
}
// Use standard V2 authorization check (same as other asset types)
requiredActions := []model.AuthAction{
model.ActionConnect,
model.ActionFileDownload,
@@ -99,6 +102,10 @@ func StartWebSession(ctx *gin.Context, req StartWebSessionRequest) (*StartWebSes
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)
}
}
@@ -115,6 +122,8 @@ func StartWebSession(ctx *gin.Context, req StartWebSessionRequest) (*StartWebSes
Asset: asset,
CreatedAt: now,
LastActivity: now,
LastHeartbeat: now, // Initialize heartbeat timestamp
IsActive: true, // Initially active
CurrentHost: initialHost,
Permissions: permissions,
WebConfig: asset.WebConfig,
@@ -142,6 +151,50 @@ func StartWebSession(ctx *gin.Context, req StartWebSessionRequest) (*StartWebSes
subdomainHost := fmt.Sprintf("asset-%d.%s%s", req.AssetId, baseDomain, portSuffix)
proxyURL := fmt.Sprintf("%s://%s/?session_id=%s", scheme, subdomainHost, 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{
@@ -168,6 +221,13 @@ func BuildTargetURL(asset *model.Asset) string {
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)
@@ -184,8 +244,14 @@ func BuildTargetURLWithHost(asset *model.Asset, host string) string {
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" {
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)
@@ -286,17 +352,325 @@ func IsDownloadRequest(ctx *gin.Context) bool {
return true
}
// Check common download file extensions
// Check common download file extensions in URL path
path := ctx.Request.URL.Path
downloadExts := []string{".pdf", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".rar", ".tar", ".gz"}
return lo.SomeBy(downloadExts, func(ext string) bool {
return strings.HasSuffix(strings.ToLower(path), ext)
})
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
logger.L().Debug("Recording web activity", zap.String("sessionId", sessionId), zap.String("path", ctx.Request.URL.Path))
}
// 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
}
}
// 2. Try from redirect parameter (for login redirects)
if sessionID == "" {
if redirect := ctx.Query("redirect"); redirect != "" {
if decoded, err := url.QueryUnescape(redirect); err == nil {
if decodedURL, err := url.Parse(decoded); err == nil {
sessionID = decodedURL.Query().Get("session_id")
}
}
}
}
// Extract asset ID from Host header: asset-11.oneterm.com -> 11
assetID, err := extractAssetIDFromHost(host)
if err != nil {
return nil, fmt.Errorf("invalid subdomain format: %w", err)
}
// Try to get session_id from Referer header as fallback
if sessionID == "" {
referer := ctx.GetHeader("Referer")
if referer != "" {
if refererURL, err := url.Parse(referer); err == nil {
sessionID = refererURL.Query().Get("session_id")
// Also try to extract from fragment/hash part if URL encoded
if sessionID == "" && strings.Contains(refererURL.RawQuery, "session_id") {
// Handle URL encoded session_id in redirect parameter
if redirect := refererURL.Query().Get("redirect"); redirect != "" {
if decoded, err := url.QueryUnescape(redirect); err == nil {
if decodedURL, err := url.Parse(decoded); err == nil {
sessionID = decodedURL.Query().Get("session_id")
}
}
}
}
}
}
}
// For static resources, try harder to find session_id
if sessionID == "" {
// Check if this looks like a static resource
isStaticResource := strings.Contains(ctx.Request.URL.Path, "/img/") ||
strings.Contains(ctx.Request.URL.Path, "/css/") ||
strings.Contains(ctx.Request.URL.Path, "/js/") ||
strings.Contains(ctx.Request.URL.Path, "/assets/") ||
strings.HasSuffix(ctx.Request.URL.Path, ".png") ||
strings.HasSuffix(ctx.Request.URL.Path, ".jpg") ||
strings.HasSuffix(ctx.Request.URL.Path, ".gif") ||
strings.HasSuffix(ctx.Request.URL.Path, ".css") ||
strings.HasSuffix(ctx.Request.URL.Path, ".js") ||
strings.HasSuffix(ctx.Request.URL.Path, ".ico")
if isStaticResource {
// For static resources, find any valid session for this asset
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)
ctx.SetCookie("oneterm_session_id", proxyCtx.SessionID, cookieMaxAge, "/", "", 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")
}
currentScheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http")
// Create transparent reverse proxy
proxy := httputil.NewSingleHostReverseProxy(target)
// Configure proxy director for transparent proxying
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = target.Host
req.Header.Set("Host", target.Host)
if origin := req.Header.Get("Origin"); origin != "" {
req.Header.Set("Origin", target.Scheme+"://"+target.Host)
}
if referer := req.Header.Get("Referer"); referer != "" {
if refererURL, err := url.Parse(referer); err == nil {
refererURL.Scheme = target.Scheme
refererURL.Host = target.Host
req.Header.Set("Referer", refererURL.String())
}
}
q := req.URL.Query()
q.Del("session_id")
req.URL.RawQuery = q.Encode()
}
// Redirect interception for bastion control
proxy.ModifyResponse = func(resp *http.Response) error {
// 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
resp.StatusCode = http.StatusForbidden
resp.Status = "403 Forbidden"
resp.Header.Set("Content-Type", "application/json")
resp.Header.Del("Content-Disposition")
errorMsg := `{"error":"File download not permitted"}`
resp.Body = io.NopCloser(strings.NewReader(errorMsg))
resp.ContentLength = int64(len(errorMsg))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(errorMsg)))
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, "asset-"),
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://asset-%d.%s%s", currentScheme, proxyCtx.AssetID, baseDomain, redirectURL.Path)
if redirectURL.RawQuery != "" {
newProxyURL += "?" + redirectURL.RawQuery
}
resp.Header.Set("Location", newProxyURL)
} else {
newLocation := fmt.Sprintf("%s://asset-%d.%s/external?url=%s",
currentScheme, proxyCtx.AssetID, baseDomain, url.QueryEscape(redirectURL.String()))
resp.Header.Set("Location", newLocation)
}
} else {
resp.Header.Set("Location", redirectURL.String())
}
}
}
if cookies := resp.Header["Set-Cookie"]; len(cookies) > 0 {
proxyDomain := strings.Split(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

@@ -1,11 +1,14 @@
package web_proxy
import (
"context"
"time"
"go.uber.org/zap"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository"
gsession "github.com/veops/oneterm/internal/session"
"github.com/veops/oneterm/pkg/logger"
)
@@ -20,20 +23,51 @@ type WebProxySession struct {
Asset *model.Asset
CreatedAt time.Time
LastActivity time.Time
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
}
// cleanupExpiredSessions removes inactive sessions from storage
// cleanupExpiredSessions implements layered timeout mechanism
func cleanupExpiredSessions(maxInactiveTime 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)
for sessionID, session := range webProxySessions {
if now.Sub(session.LastActivity) > maxInactiveTime {
delete(webProxySessions, sessionID)
logger.L().Info("Cleaned up expired web session",
zap.String("sessionID", sessionID))
// 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
shouldDelete := false
if now.Sub(session.LastActivity) > maxInactiveTime {
shouldDelete = true
}
if shouldDelete {
// No need to update status again - already done in Layer 1
delete(webProxySessions, sessionID)
cleanedCount++
}
}
if deactivatedCount > 0 || cleanedCount > 0 {
logger.L().Debug("Session cleanup completed",
zap.Int("deactivated", deactivatedCount),
zap.Int("deleted", cleanedCount))
}
}
@@ -43,9 +77,9 @@ func StartSessionCleanupRoutine() {
ticker := time.NewTicker(30 * time.Second)
go func() {
for range ticker.C {
// For web sessions, clean up after 2 minutes of inactivity (browser likely closed)
webInactiveTime := 2 * time.Minute
cleanupExpiredSessions(webInactiveTime)
// Use system configured timeout (same as other protocols)
systemTimeout := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second
cleanupExpiredSessions(systemTimeout)
}
}()
}
@@ -73,6 +107,24 @@ func UpdateSessionActivity(sessionID string) {
}
}
// UpdateSessionHeartbeat updates the last heartbeat time for a session
func UpdateSessionHeartbeat(sessionID string) {
if session, exists := webProxySessions[sessionID]; exists {
now := time.Now()
wasInactive := !session.IsActive
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 {
@@ -82,9 +134,13 @@ func UpdateSessionHost(sessionID string, host string) {
// 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)
count := 0
for _, session := range webProxySessions {
if session.AssetId == assetID {
if session.AssetId == assetID && session.IsActive {
count++
}
}
@@ -103,5 +159,38 @@ func CountActiveSessions() int {
// CloseWebSession closes and removes a session
func CloseWebSession(sessionID string) {
if session, exists := webProxySessions[sessionID]; exists {
logger.L().Info("Closing web session",
zap.String("sessionID", sessionID),
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()
dbSession.Status = status
if status == model.SESSIONSTATUS_OFFLINE {
dbSession.ClosedAt = &now
}
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",
zap.String("sessionID", sessionID),
zap.Int("status", status),
zap.Error(err))
}
}
}

View File

@@ -236,16 +236,32 @@ func getAvailablePort() (int, error) {
// Proxy establishes a proxy connection to an asset through a gateway if necessary
func Proxy(isConnectable bool, sessionId string, protocol string, asset *model.Asset, gateway *model.Gateway) (ip string, port int, err error) {
// Handle case 1: asset.Ip already contains port (e.g., "127.0.0.1:8000")
if strings.Contains(asset.Ip, ":") {
ipParts := strings.Split(asset.Ip, ":")
if len(ipParts) >= 2 {
ip = ipParts[0]
port = cast.ToInt(ipParts[1])
} else {
ip = asset.Ip
port = 0
}
} else {
// Case 2: asset.Ip without port (e.g., "127.0.0.1"), extract port from protocol
ip, port = asset.Ip, 0
for _, tp := range strings.Split(protocol, ",") {
for _, p := range asset.Protocols {
if strings.HasPrefix(strings.ToLower(p), tp) {
if port = cast.ToInt(strings.Split(p, ":")[1]); port != 0 {
parts := strings.Split(p, ":")
if len(parts) >= 2 {
if port = cast.ToInt(parts[1]); port != 0 {
break
}
}
}
}
}
}
if asset.GatewayId == 0 || gateway == nil {
return