mirror of
https://github.com/veops/oneterm.git
synced 2025-10-18 13:20:49 +08:00
refactor(backend): modularize web proxy into service layer with complete swagger docs
This commit is contained in:
@@ -1,13 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,6 +15,7 @@ import (
|
||||
"github.com/samber/lo"
|
||||
"github.com/veops/oneterm/internal/model"
|
||||
"github.com/veops/oneterm/internal/service"
|
||||
"github.com/veops/oneterm/internal/service/web_proxy"
|
||||
"github.com/veops/oneterm/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -27,117 +25,19 @@ func NewWebProxyController() *WebProxyController {
|
||||
return &WebProxyController{}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
// 使用service层的结构体
|
||||
type StartWebSessionRequest = web_proxy.StartWebSessionRequest
|
||||
type StartWebSessionResponse = web_proxy.StartWebSessionResponse
|
||||
|
||||
type StartWebSessionResponse struct {
|
||||
SessionId string `json:"session_id"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var webProxySessions = make(map[string]*WebProxySession)
|
||||
|
||||
type WebProxySession struct {
|
||||
SessionId string
|
||||
AssetId int
|
||||
Asset *model.Asset
|
||||
CreatedAt time.Time
|
||||
LastActivity time.Time
|
||||
CurrentHost string
|
||||
}
|
||||
|
||||
func cleanupExpiredSessions(maxInactiveTime time.Duration) {
|
||||
now := time.Now()
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 使用service层的全局变量和结构体
|
||||
type WebProxySession = web_proxy.WebProxySession
|
||||
|
||||
func StartSessionCleanupRoutine() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
cleanupExpiredSessions(8 * time.Hour)
|
||||
}
|
||||
}()
|
||||
web_proxy.StartSessionCleanupRoutine()
|
||||
}
|
||||
|
||||
func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason string) {
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Session Expired - OneTerm</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
width: 100%%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.icon { font-size: 4rem; margin-bottom: 20px; display: block; }
|
||||
.title { color: #333; font-size: 1.5rem; font-weight: 600; margin-bottom: 16px; }
|
||||
.message { color: #666; font-size: 1rem; line-height: 1.5; margin-bottom: 24px; }
|
||||
.reason {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #ffa726;
|
||||
padding: 12px 16px;
|
||||
margin: 20px 0;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.button {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.button:hover { background: #5a6fd8; transform: translateY(-1px); }
|
||||
.footer { margin-top: 24px; font-size: 0.8rem; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<span class="icon">⏰</span>
|
||||
<div class="title">Session Expired</div>
|
||||
<div class="message">Your web proxy session has expired and you need to reconnect.</div>
|
||||
<div class="reason">Reason: %s</div>
|
||||
<a href="javascript:history.back()" class="button">← Go Back</a>
|
||||
<div class="footer">OneTerm Bastion Host</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, reason)
|
||||
|
||||
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)
|
||||
@@ -147,8 +47,12 @@ 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")
|
||||
@@ -179,8 +83,13 @@ func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) {
|
||||
// @Tags WebProxy
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body StartWebSessionRequest true "Start session request"
|
||||
// @Success 200 {object} StartWebSessionResponse
|
||||
// @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
|
||||
@@ -189,85 +98,39 @@ func (c *WebProxyController) StartWebSession(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
assetService := service.NewAssetService()
|
||||
asset, err := assetService.GetById(ctx, req.AssetId)
|
||||
resp, err := web_proxy.StartWebSession(ctx, req)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Asset not found"})
|
||||
// Return appropriate HTTP status code based on error type
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
} else if strings.Contains(err.Error(), "not a web asset") {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
} else if strings.Contains(err.Error(), "No permission") {
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
} else if strings.Contains(err.Error(), "maximum concurrent") {
|
||||
ctx.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||
} else {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if asset is web asset
|
||||
if !asset.IsWebAsset() {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Asset is not a web asset"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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 and store web proxy session
|
||||
now := time.Now()
|
||||
|
||||
// Get initial target host from asset
|
||||
initialHost := c.getAssetHost(asset)
|
||||
|
||||
webSession := &WebProxySession{
|
||||
SessionId: sessionId,
|
||||
AssetId: asset.Id,
|
||||
Asset: asset,
|
||||
CreatedAt: now,
|
||||
LastActivity: now,
|
||||
CurrentHost: initialHost,
|
||||
}
|
||||
webProxySessions[sessionId] = webSession
|
||||
|
||||
// Generate subdomain-based proxy URL
|
||||
scheme := "https"
|
||||
if ctx.Request.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
// Extract base domain and port from current host
|
||||
currentHost := ctx.Request.Host
|
||||
var baseDomain string
|
||||
var portSuffix string
|
||||
|
||||
if strings.Contains(currentHost, ":") {
|
||||
hostParts := strings.Split(currentHost, ":")
|
||||
baseDomain = hostParts[0]
|
||||
port := hostParts[1]
|
||||
|
||||
// Keep port unless it's default
|
||||
isDefaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
|
||||
if !isDefaultPort {
|
||||
portSuffix = ":" + port
|
||||
}
|
||||
} else {
|
||||
baseDomain = currentHost
|
||||
}
|
||||
|
||||
// Create subdomain URL with session_id for first access (cookie will handle subsequent requests)
|
||||
subdomainHost := fmt.Sprintf("asset-%d.%s%s", req.AssetId, baseDomain, portSuffix)
|
||||
proxyURL := fmt.Sprintf("%s://%s/?session_id=%s", scheme, subdomainHost, sessionId)
|
||||
|
||||
ctx.JSON(http.StatusOK, StartWebSessionResponse{
|
||||
SessionId: sessionId,
|
||||
ProxyURL: proxyURL,
|
||||
Message: "Web session started successfully",
|
||||
})
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ProxyWebRequest handles subdomain-based web proxy requests
|
||||
// Extract asset ID from Host header like: asset-123.oneterm.com
|
||||
// @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
|
||||
|
||||
@@ -340,7 +203,8 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
||||
|
||||
if isStaticResource {
|
||||
// For static resources, find any valid session for this asset
|
||||
for sid, session := range webProxySessions {
|
||||
allSessions := web_proxy.GetAllSessions()
|
||||
for sid, session := range allSessions {
|
||||
if session.AssetId == assetID {
|
||||
sessionID = sid
|
||||
break
|
||||
@@ -355,47 +219,48 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get session from simple session store
|
||||
webSession, exists := webProxySessions[sessionID]
|
||||
// Validate session ID and get session information
|
||||
session, exists := web_proxy.GetSession(sessionID)
|
||||
if !exists {
|
||||
c.renderSessionExpiredPage(ctx, "Session not found")
|
||||
c.renderSessionExpiredPage(ctx, "Invalid or expired session")
|
||||
return
|
||||
}
|
||||
|
||||
// Check session timeout (8 hours of inactivity)
|
||||
// Check session timeout using system config (same as other protocols)
|
||||
now := time.Now()
|
||||
maxInactiveTime := 8 * time.Hour
|
||||
if now.Sub(webSession.LastActivity) > maxInactiveTime {
|
||||
// Remove expired session
|
||||
delete(webProxySessions, sessionID)
|
||||
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
|
||||
webSession.LastActivity = now
|
||||
ctx.SetCookie("oneterm_session_id", sessionID, 8*3600, "/", "", false, true)
|
||||
web_proxy.UpdateSessionActivity(sessionID)
|
||||
cookieMaxAge := int(model.GlobalConfig.Load().Timeout)
|
||||
ctx.SetCookie("oneterm_session_id", sessionID, cookieMaxAge, "/", "", false, true)
|
||||
|
||||
// Verify asset ID matches session
|
||||
if webSession.AssetId != assetID {
|
||||
// Update last activity
|
||||
web_proxy.UpdateSessionActivity(sessionID)
|
||||
|
||||
// Check Web-specific access controls
|
||||
if err := c.checkWebAccessControls(ctx, session); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// Build target URL using current host (may have been updated by redirects)
|
||||
targetURL := c.buildTargetURLWithHost(webSession.Asset, webSession.CurrentHost)
|
||||
targetURL := c.buildTargetURLWithHost(session.Asset, session.CurrentHost)
|
||||
target, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid target URL"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set session_id cookie with smart expiration management
|
||||
// Use a longer cookie duration (8 hours) but validate session on each request
|
||||
cookieMaxAge := 8 * 3600 // 8 hours
|
||||
ctx.SetCookie("oneterm_session_id", sessionID, cookieMaxAge, "/", "", false, true)
|
||||
|
||||
// Determine current request scheme for redirect rewriting
|
||||
currentScheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http")
|
||||
|
||||
// Create transparent reverse proxy
|
||||
@@ -430,7 +295,12 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if resp.StatusCode == 200 && strings.Contains(contentType, "text/html") {
|
||||
c.rewriteHTMLContent(resp, assetID, currentScheme, host)
|
||||
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 {
|
||||
@@ -452,7 +322,7 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
||||
host)
|
||||
|
||||
if c.isSameDomainOrSubdomain(target.Host, redirectURL.Host) {
|
||||
webSession.CurrentHost = 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
|
||||
@@ -489,264 +359,172 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
||||
proxy.ServeHTTP(ctx.Writer, ctx.Request)
|
||||
}
|
||||
|
||||
// HandleExternalRedirect handles redirects to external domains through proxy
|
||||
// HandleExternalRedirect shows a page when an external redirect is blocked
|
||||
// @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]
|
||||
func (c *WebProxyController) HandleExternalRedirect(ctx *gin.Context) {
|
||||
targetURL := ctx.Query("url")
|
||||
|
||||
// Get session_id from cookie instead of URL parameter
|
||||
sessionID, err := ctx.Cookie("oneterm_session_id")
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Session required"})
|
||||
return
|
||||
if targetURL == "" {
|
||||
targetURL = "Unknown URL"
|
||||
}
|
||||
|
||||
if targetURL == "" || sessionID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing required parameters"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate session
|
||||
webSession, exists := webProxySessions[sessionID]
|
||||
if !exists {
|
||||
c.renderSessionExpiredPage(ctx, "Session not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check session timeout (8 hours of inactivity)
|
||||
now := time.Now()
|
||||
maxInactiveTime := 8 * time.Hour
|
||||
if now.Sub(webSession.LastActivity) > maxInactiveTime {
|
||||
// Remove expired session
|
||||
delete(webProxySessions, sessionID)
|
||||
c.renderSessionExpiredPage(ctx, "Session expired due to inactivity")
|
||||
return
|
||||
}
|
||||
|
||||
// Update last activity time
|
||||
webSession.LastActivity = now
|
||||
|
||||
// Return a simple page explaining the redirect was blocked
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>External Redirect Blocked</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.blocked { color: #e74c3c; }
|
||||
.info { color: #666; margin: 20px 0; }
|
||||
.target {
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="blocked">🛡️ External Redirect Blocked</h1>
|
||||
<div class="info">
|
||||
The target website attempted to redirect you to an external domain,
|
||||
which has been blocked by the bastion host for security reasons.
|
||||
</div>
|
||||
<div class="info"><strong>Target URL:</strong></div>
|
||||
<div class="target">%s</div>
|
||||
<div class="info">
|
||||
All web access must go through the bastion host to maintain security
|
||||
and audit compliance. External redirects are not permitted.
|
||||
</div>
|
||||
<div class="info">
|
||||
<a href="javascript:history.back()">← Go Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, targetURL)
|
||||
|
||||
html := web_proxy.RenderExternalRedirectPage(targetURL)
|
||||
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
||||
ctx.String(http.StatusOK, html)
|
||||
}
|
||||
|
||||
// isSameDomainOrSubdomain checks if two hosts belong to the same domain or subdomain
|
||||
// Examples:
|
||||
// - baidu.com & www.baidu.com → true (subdomain)
|
||||
// - baidu.com & m.baidu.com → true (subdomain)
|
||||
// - baidu.com & google.com → false (different domain)
|
||||
// - sub.example.com & other.example.com → true (same domain)
|
||||
func (c *WebProxyController) 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
|
||||
}
|
||||
|
||||
// getAssetHost extracts the host from asset configuration
|
||||
func (c *WebProxyController) getAssetHost(asset *model.Asset) string {
|
||||
targetURL := c.buildTargetURL(asset)
|
||||
if u, err := url.Parse(targetURL); err == nil {
|
||||
return u.Host
|
||||
}
|
||||
return "localhost" // fallback
|
||||
return web_proxy.IsSameDomainOrSubdomain(host1, host2)
|
||||
}
|
||||
|
||||
// buildTargetURLWithHost builds target URL with specific host
|
||||
func (c *WebProxyController) buildTargetURLWithHost(asset *model.Asset, host string) string {
|
||||
protocol, port := asset.GetWebProtocol()
|
||||
if protocol == "" {
|
||||
protocol = "http"
|
||||
port = 80
|
||||
}
|
||||
|
||||
// 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)
|
||||
return web_proxy.BuildTargetURLWithHost(asset, host)
|
||||
}
|
||||
|
||||
// buildTargetURL builds the target URL from asset information
|
||||
func (c *WebProxyController) buildTargetURL(asset *model.Asset) string {
|
||||
protocol, port := asset.GetWebProtocol()
|
||||
if protocol == "" {
|
||||
protocol = "http"
|
||||
port = 80
|
||||
}
|
||||
|
||||
// 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)
|
||||
// processHTMLResponse processes HTML response for content rewriting and injection
|
||||
func (c *WebProxyController) processHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost string, session *WebProxySession) {
|
||||
web_proxy.ProcessHTMLResponse(resp, assetID, scheme, proxyHost, session)
|
||||
}
|
||||
|
||||
// rewriteHTMLContent rewrites HTML content to redirect external links through proxy
|
||||
func (c *WebProxyController) rewriteHTMLContent(resp *http.Response, assetID int, scheme, proxyHost string) {
|
||||
if resp.Body == nil {
|
||||
// checkWebAccessControls validates web-specific access controls
|
||||
func (c *WebProxyController) checkWebAccessControls(ctx *gin.Context, session *WebProxySession) error {
|
||||
return web_proxy.CheckWebAccessControls(ctx, session)
|
||||
}
|
||||
|
||||
// getActiveSessionsForAsset returns detailed info about active sessions for an asset
|
||||
func (c *WebProxyController) getActiveSessionsForAsset(assetId int) []map[string]interface{} {
|
||||
sessions := make([]map[string]interface{}, 0)
|
||||
for sessionId, session := range web_proxy.GetAllSessions() {
|
||||
if session.AssetId == assetId {
|
||||
sessions = append(sessions, map[string]interface{}{
|
||||
"session_id": sessionId,
|
||||
"asset_id": session.AssetId,
|
||||
"account_id": session.AccountId,
|
||||
"created_at": session.CreatedAt,
|
||||
"last_activity": session.LastActivity,
|
||||
"current_host": session.CurrentHost,
|
||||
})
|
||||
}
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
// CloseWebSession closes an active web session
|
||||
// @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 {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := ctx.ShouldBindBodyWithJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
web_proxy.CloseWebSession(req.SessionID)
|
||||
ctx.JSON(http.StatusOK, gin.H{"message": "Session closed successfully"})
|
||||
}
|
||||
|
||||
// GetActiveWebSessions gets active sessions for an asset
|
||||
// @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")
|
||||
assetId, err := strconv.Atoi(assetIdStr)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid asset ID"})
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
baseDomain := lo.Ternary(strings.HasPrefix(proxyHost, "asset-"),
|
||||
func() string {
|
||||
parts := strings.SplitN(proxyHost, ".", 2)
|
||||
return lo.Ternary(len(parts) > 1, parts[1], proxyHost)
|
||||
}(),
|
||||
proxyHost)
|
||||
sessions := c.getActiveSessionsForAsset(assetId)
|
||||
ctx.JSON(http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
|
||||
// Universal URL rewriting patterns - catch ALL external URLs
|
||||
patterns := []struct {
|
||||
pattern string
|
||||
rewrite func(matches []string) string
|
||||
}{
|
||||
// JavaScript location assignments: window.location = "http://example.com/path"
|
||||
{
|
||||
`(window\.location(?:\.href)?\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`,
|
||||
func(matches []string) string {
|
||||
path := lo.Ternary(len(matches) > 3 && matches[3] != "", matches[3], "")
|
||||
return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path)
|
||||
},
|
||||
},
|
||||
// Form actions: <form action="http://example.com/path"
|
||||
{
|
||||
`(action\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)
|
||||
},
|
||||
},
|
||||
// Link hrefs: <a href="http://example.com/path"
|
||||
{
|
||||
`(href\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)
|
||||
},
|
||||
},
|
||||
// UpdateWebSessionHeartbeat updates session heartbeat
|
||||
// @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 {
|
||||
SessionId string `json:"session_id" binding:"required"`
|
||||
}
|
||||
|
||||
for _, p := range patterns {
|
||||
re := regexp.MustCompile(p.pattern)
|
||||
content = re.ReplaceAllStringFunc(content, func(match string) string {
|
||||
matches := re.FindStringSubmatch(match)
|
||||
if len(matches) >= 4 {
|
||||
return p.rewrite(matches)
|
||||
}
|
||||
return match
|
||||
})
|
||||
if err := ctx.ShouldBindBodyWithJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
newBody := bytes.NewReader([]byte(content))
|
||||
resp.Body = io.NopCloser(newBody)
|
||||
resp.ContentLength = int64(len(content))
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||
if session, exists := web_proxy.GetSession(req.SessionId); exists {
|
||||
session.LastActivity = time.Now()
|
||||
ctx.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
} else {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupWebSession handles browser tab close cleanup
|
||||
// @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]
|
||||
func (c *WebProxyController) CleanupWebSession(ctx *gin.Context) {
|
||||
var req struct {
|
||||
SessionId string `json:"session_id"`
|
||||
}
|
||||
|
||||
// Best effort parsing - browser might send malformed data on page unload
|
||||
ctx.ShouldBindBodyWithJSON(&req)
|
||||
|
||||
if req.SessionId != "" {
|
||||
web_proxy.CloseWebSession(req.SessionId)
|
||||
logger.L().Info("Web session cleaned up by browser",
|
||||
zap.String("sessionId", req.SessionId))
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// recordWebActivity records web session activity for audit
|
||||
func (c *WebProxyController) recordWebActivity(session *WebProxySession, req *http.Request) {
|
||||
web_proxy.RecordWebActivity(session.SessionId, &gin.Context{Request: req})
|
||||
}
|
||||
|
||||
// extractAssetIDFromHost extracts asset ID from subdomain host
|
||||
// Examples: asset-123.oneterm.com -> 123, asset-456.localhost:8080 -> 456
|
||||
func (c *WebProxyController) extractAssetIDFromHost(host string) (int, error) {
|
||||
// Remove port if present
|
||||
hostParts := strings.Split(host, ":")
|
||||
hostname := hostParts[0]
|
||||
|
||||
// Check for asset- prefix
|
||||
if !strings.HasPrefix(hostname, "asset-") {
|
||||
return 0, fmt.Errorf("host does not start with asset- prefix: %s", hostname)
|
||||
}
|
||||
|
||||
// Extract asset ID: asset-123.domain.com -> 123
|
||||
parts := strings.Split(hostname, ".")
|
||||
if len(parts) == 0 {
|
||||
return 0, fmt.Errorf("invalid hostname format: %s", hostname)
|
||||
}
|
||||
|
||||
assetPart := parts[0] // asset-123
|
||||
assetIDStr := strings.TrimPrefix(assetPart, "asset-")
|
||||
if assetIDStr == assetPart {
|
||||
return 0, fmt.Errorf("failed to extract asset ID from: %s", assetPart)
|
||||
}
|
||||
|
||||
assetID, err := strconv.Atoi(assetIDStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid asset ID format: %s", assetIDStr)
|
||||
}
|
||||
|
||||
return assetID, nil
|
||||
return web_proxy.ExtractAssetIDFromHost(host)
|
||||
}
|
||||
|
Reference in New Issue
Block a user