mirror of
https://github.com/veops/oneterm.git
synced 2025-10-15 03:41:41 +08:00
feat(backend): implement layered session timeout and enhanced download control for web proxy
This commit is contained in:
@@ -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...)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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]
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user