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