mirror of
https://github.com/veops/oneterm.git
synced 2025-10-17 12:50:50 +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
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,6 +15,7 @@ import (
|
|||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
"github.com/veops/oneterm/internal/model"
|
"github.com/veops/oneterm/internal/model"
|
||||||
"github.com/veops/oneterm/internal/service"
|
"github.com/veops/oneterm/internal/service"
|
||||||
|
"github.com/veops/oneterm/internal/service/web_proxy"
|
||||||
"github.com/veops/oneterm/pkg/logger"
|
"github.com/veops/oneterm/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,117 +25,19 @@ func NewWebProxyController() *WebProxyController {
|
|||||||
return &WebProxyController{}
|
return &WebProxyController{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type StartWebSessionRequest struct {
|
// 使用service层的结构体
|
||||||
AssetId int `json:"asset_id" binding:"required"`
|
type StartWebSessionRequest = web_proxy.StartWebSessionRequest
|
||||||
AssetName string `json:"asset_name"`
|
type StartWebSessionResponse = web_proxy.StartWebSessionResponse
|
||||||
AuthMode string `json:"auth_mode"`
|
|
||||||
AccountId int `json:"account_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StartWebSessionResponse struct {
|
// 使用service层的全局变量和结构体
|
||||||
SessionId string `json:"session_id"`
|
type WebProxySession = web_proxy.WebProxySession
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func StartSessionCleanupRoutine() {
|
func StartSessionCleanupRoutine() {
|
||||||
ticker := time.NewTicker(10 * time.Minute)
|
web_proxy.StartSessionCleanupRoutine()
|
||||||
go func() {
|
|
||||||
for range ticker.C {
|
|
||||||
cleanupExpiredSessions(8 * time.Hour)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason string) {
|
func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason string) {
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
html := web_proxy.RenderSessionExpiredPage(reason)
|
||||||
<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)
|
|
||||||
|
|
||||||
ctx.SetCookie("oneterm_session_id", "", -1, "/", "", false, true)
|
ctx.SetCookie("oneterm_session_id", "", -1, "/", "", false, true)
|
||||||
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
ctx.String(http.StatusUnauthorized, html)
|
ctx.String(http.StatusUnauthorized, html)
|
||||||
@@ -147,8 +47,12 @@ func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason s
|
|||||||
// @Summary Get web asset configuration
|
// @Summary Get web asset configuration
|
||||||
// @Description Get web asset configuration by asset ID
|
// @Description Get web asset configuration by asset ID
|
||||||
// @Tags WebProxy
|
// @Tags WebProxy
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
// @Param asset_id path int true "Asset ID"
|
// @Param asset_id path int true "Asset ID"
|
||||||
// @Success 200 {object} model.WebConfig
|
// @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]
|
// @Router /web_proxy/config/{asset_id} [get]
|
||||||
func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) {
|
func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) {
|
||||||
assetIdStr := ctx.Param("asset_id")
|
assetIdStr := ctx.Param("asset_id")
|
||||||
@@ -179,8 +83,13 @@ func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) {
|
|||||||
// @Tags WebProxy
|
// @Tags WebProxy
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body StartWebSessionRequest true "Start session request"
|
// @Param request body web_proxy.StartWebSessionRequest true "Start session request"
|
||||||
// @Success 200 {object} StartWebSessionResponse
|
// @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]
|
// @Router /web_proxy/start [post]
|
||||||
func (c *WebProxyController) StartWebSession(ctx *gin.Context) {
|
func (c *WebProxyController) StartWebSession(ctx *gin.Context) {
|
||||||
var req StartWebSessionRequest
|
var req StartWebSessionRequest
|
||||||
@@ -189,85 +98,39 @@ func (c *WebProxyController) StartWebSession(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assetService := service.NewAssetService()
|
resp, err := web_proxy.StartWebSession(ctx, req)
|
||||||
asset, err := assetService.GetById(ctx, req.AssetId)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Asset not found"})
|
// Return appropriate HTTP status code based on error type
|
||||||
return
|
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") {
|
||||||
// Check if asset is web asset
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
if !asset.IsWebAsset() {
|
} else if strings.Contains(err.Error(), "No permission") {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Asset is not a web asset"})
|
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
return
|
} else if strings.Contains(err.Error(), "maximum concurrent") {
|
||||||
}
|
ctx.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||||
|
|
||||||
// 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 {
|
} else {
|
||||||
baseDomain = currentHost
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create subdomain URL with session_id for first access (cookie will handle subsequent requests)
|
ctx.JSON(http.StatusOK, resp)
|
||||||
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",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyWebRequest handles subdomain-based web proxy requests
|
// 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) {
|
func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
||||||
host := ctx.Request.Host
|
host := ctx.Request.Host
|
||||||
|
|
||||||
@@ -340,7 +203,8 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
|||||||
|
|
||||||
if isStaticResource {
|
if isStaticResource {
|
||||||
// For static resources, find any valid session for this asset
|
// 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 {
|
if session.AssetId == assetID {
|
||||||
sessionID = sid
|
sessionID = sid
|
||||||
break
|
break
|
||||||
@@ -355,47 +219,48 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get session from simple session store
|
// Validate session ID and get session information
|
||||||
webSession, exists := webProxySessions[sessionID]
|
session, exists := web_proxy.GetSession(sessionID)
|
||||||
if !exists {
|
if !exists {
|
||||||
c.renderSessionExpiredPage(ctx, "Session not found")
|
c.renderSessionExpiredPage(ctx, "Invalid or expired session")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check session timeout (8 hours of inactivity)
|
// Check session timeout using system config (same as other protocols)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
maxInactiveTime := 8 * time.Hour
|
maxInactiveTime := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second
|
||||||
if now.Sub(webSession.LastActivity) > maxInactiveTime {
|
if now.Sub(session.LastActivity) > maxInactiveTime {
|
||||||
// Remove expired session
|
web_proxy.CloseWebSession(sessionID)
|
||||||
delete(webProxySessions, sessionID)
|
|
||||||
c.renderSessionExpiredPage(ctx, "Session expired due to inactivity")
|
c.renderSessionExpiredPage(ctx, "Session expired due to inactivity")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last activity time and auto-renew cookie
|
// Update last activity time and auto-renew cookie
|
||||||
webSession.LastActivity = now
|
web_proxy.UpdateSessionActivity(sessionID)
|
||||||
ctx.SetCookie("oneterm_session_id", sessionID, 8*3600, "/", "", false, true)
|
cookieMaxAge := int(model.GlobalConfig.Load().Timeout)
|
||||||
|
ctx.SetCookie("oneterm_session_id", sessionID, cookieMaxAge, "/", "", false, true)
|
||||||
|
|
||||||
// Verify asset ID matches session
|
// Update last activity
|
||||||
if webSession.AssetId != assetID {
|
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"})
|
ctx.JSON(http.StatusForbidden, gin.H{"error": "Asset ID mismatch"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build target URL using current host (may have been updated by redirects)
|
targetURL := c.buildTargetURLWithHost(session.Asset, session.CurrentHost)
|
||||||
targetURL := c.buildTargetURLWithHost(webSession.Asset, webSession.CurrentHost)
|
|
||||||
target, err := url.Parse(targetURL)
|
target, err := url.Parse(targetURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid target URL"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid target URL"})
|
||||||
return
|
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")
|
currentScheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http")
|
||||||
|
|
||||||
// Create transparent reverse proxy
|
// Create transparent reverse proxy
|
||||||
@@ -430,7 +295,12 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
|||||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||||
contentType := resp.Header.Get("Content-Type")
|
contentType := resp.Header.Get("Content-Type")
|
||||||
if resp.StatusCode == 200 && strings.Contains(contentType, "text/html") {
|
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 {
|
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||||
@@ -452,7 +322,7 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
|||||||
host)
|
host)
|
||||||
|
|
||||||
if c.isSameDomainOrSubdomain(target.Host, redirectURL.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)
|
newProxyURL := fmt.Sprintf("%s://asset-%d.%s%s", currentScheme, assetID, baseDomain, redirectURL.Path)
|
||||||
if redirectURL.RawQuery != "" {
|
if redirectURL.RawQuery != "" {
|
||||||
newProxyURL += "?" + redirectURL.RawQuery
|
newProxyURL += "?" + redirectURL.RawQuery
|
||||||
@@ -489,264 +359,172 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
|||||||
proxy.ServeHTTP(ctx.Writer, ctx.Request)
|
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) {
|
func (c *WebProxyController) HandleExternalRedirect(ctx *gin.Context) {
|
||||||
targetURL := ctx.Query("url")
|
targetURL := ctx.Query("url")
|
||||||
|
if targetURL == "" {
|
||||||
// Get session_id from cookie instead of URL parameter
|
targetURL = "Unknown URL"
|
||||||
sessionID, err := ctx.Cookie("oneterm_session_id")
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Session required"})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if targetURL == "" || sessionID == "" {
|
html := web_proxy.RenderExternalRedirectPage(targetURL)
|
||||||
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)
|
|
||||||
|
|
||||||
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
ctx.String(http.StatusOK, html)
|
ctx.String(http.StatusOK, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSameDomainOrSubdomain checks if two hosts belong to the same domain or subdomain
|
// 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 {
|
func (c *WebProxyController) isSameDomainOrSubdomain(host1, host2 string) bool {
|
||||||
if host1 == host2 {
|
return web_proxy.IsSameDomainOrSubdomain(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTargetURLWithHost builds target URL with specific host
|
// buildTargetURLWithHost builds target URL with specific host
|
||||||
func (c *WebProxyController) buildTargetURLWithHost(asset *model.Asset, host string) string {
|
func (c *WebProxyController) buildTargetURLWithHost(asset *model.Asset, host string) string {
|
||||||
protocol, port := asset.GetWebProtocol()
|
return web_proxy.BuildTargetURLWithHost(asset, host)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTargetURL builds the target URL from asset information
|
// processHTMLResponse processes HTML response for content rewriting and injection
|
||||||
func (c *WebProxyController) buildTargetURL(asset *model.Asset) string {
|
func (c *WebProxyController) processHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost string, session *WebProxySession) {
|
||||||
protocol, port := asset.GetWebProtocol()
|
web_proxy.ProcessHTMLResponse(resp, assetID, scheme, proxyHost, session)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rewriteHTMLContent rewrites HTML content to redirect external links through proxy
|
// checkWebAccessControls validates web-specific access controls
|
||||||
func (c *WebProxyController) rewriteHTMLContent(resp *http.Response, assetID int, scheme, proxyHost string) {
|
func (c *WebProxyController) checkWebAccessControls(ctx *gin.Context, session *WebProxySession) error {
|
||||||
if resp.Body == nil {
|
return web_proxy.CheckWebAccessControls(ctx, session)
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
// getActiveSessionsForAsset returns detailed info about active sessions for an asset
|
||||||
if err != nil {
|
func (c *WebProxyController) getActiveSessionsForAsset(assetId int) []map[string]interface{} {
|
||||||
return
|
sessions := make([]map[string]interface{}, 0)
|
||||||
}
|
for sessionId, session := range web_proxy.GetAllSessions() {
|
||||||
resp.Body.Close()
|
if session.AssetId == assetId {
|
||||||
|
sessions = append(sessions, map[string]interface{}{
|
||||||
baseDomain := lo.Ternary(strings.HasPrefix(proxyHost, "asset-"),
|
"session_id": sessionId,
|
||||||
func() string {
|
"asset_id": session.AssetId,
|
||||||
parts := strings.SplitN(proxyHost, ".", 2)
|
"account_id": session.AccountId,
|
||||||
return lo.Ternary(len(parts) > 1, parts[1], proxyHost)
|
"created_at": session.CreatedAt,
|
||||||
}(),
|
"last_activity": session.LastActivity,
|
||||||
proxyHost)
|
"current_host": session.CurrentHost,
|
||||||
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
newBody := bytes.NewReader([]byte(content))
|
// CloseWebSession closes an active web session
|
||||||
resp.Body = io.NopCloser(newBody)
|
// @Summary Close web session
|
||||||
resp.ContentLength = int64(len(content))
|
// @Description Close an active web session and clean up resources
|
||||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
// @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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions := c.getActiveSessionsForAsset(assetId)
|
||||||
|
ctx.JSON(http.StatusOK, sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.ShouldBindBodyWithJSON(&req); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// 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) {
|
func (c *WebProxyController) extractAssetIDFromHost(host string) (int, error) {
|
||||||
// Remove port if present
|
return web_proxy.ExtractAssetIDFromHost(host)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
@@ -2337,6 +2337,58 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/proxy": {
|
||||||
|
"get": {
|
||||||
|
"description": "Handle web proxy requests for subdomain-based assets",
|
||||||
|
"consumes": [
|
||||||
|
"*/*"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"*/*"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Proxy web requests",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Asset subdomain (asset-123.domain.com)",
|
||||||
|
"name": "Host",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Session ID (alternative to cookie)",
|
||||||
|
"name": "session_id",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Proxied content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid subdomain format",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Session expired page"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Access denied",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/public_key": {
|
"/public_key": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4206,9 +4258,109 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/web_proxy/cleanup": {
|
||||||
|
"post": {
|
||||||
|
"description": "Clean up web session when browser tab is closed",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Cleanup web session",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Cleanup request",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Session cleaned up",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/web_proxy/close": {
|
||||||
|
"post": {
|
||||||
|
"description": "Close an active web session and clean up resources",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Close web session",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Session close request",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Session closed successfully",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Session not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/web_proxy/config/{asset_id}": {
|
"/web_proxy/config/{asset_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Get web asset configuration by asset ID",
|
"description": "Get web asset configuration by asset ID",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"WebProxy"
|
"WebProxy"
|
||||||
],
|
],
|
||||||
@@ -4228,6 +4380,146 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/model.WebConfig"
|
"$ref": "#/definitions/model.WebConfig"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid asset ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Asset not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/web_proxy/external_redirect": {
|
||||||
|
"get": {
|
||||||
|
"description": "Show a page when an external redirect is blocked by the proxy",
|
||||||
|
"consumes": [
|
||||||
|
"text/html"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"text/html"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Handle external redirect",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Target URL that was blocked",
|
||||||
|
"name": "url",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "External redirect blocked page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/web_proxy/heartbeat": {
|
||||||
|
"post": {
|
||||||
|
"description": "Update the last activity time for a web session (heartbeat)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Update session heartbeat",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Heartbeat request",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Heartbeat updated",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Session not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/web_proxy/sessions/{asset_id}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get list of active web sessions for a specific asset",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Get active web sessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Asset ID",
|
||||||
|
"name": "asset_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of active sessions",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid asset ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4252,7 +4544,7 @@ const docTemplate = `{
|
|||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/controller.StartWebSessionRequest"
|
"$ref": "#/definitions/web_proxy.StartWebSessionRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4260,7 +4552,42 @@ const docTemplate = `{
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/controller.StartWebSessionResponse"
|
"$ref": "#/definitions/web_proxy.StartWebSessionResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "No permission",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Asset not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Maximum concurrent connections exceeded",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4390,40 +4717,6 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"controller.StartWebSessionRequest": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"asset_id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"account_id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"asset_id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"asset_name": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"auth_mode": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"controller.StartWebSessionResponse": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"message": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"proxy_url": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"session_id": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"model.AccessAuth": {
|
"model.AccessAuth": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5928,6 +6221,40 @@ const docTemplate = `{
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"web_proxy.StartWebSessionRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"asset_id"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"account_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"asset_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"asset_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"auth_mode": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web_proxy.StartWebSessionResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"proxy_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
@@ -2326,6 +2326,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/proxy": {
|
||||||
|
"get": {
|
||||||
|
"description": "Handle web proxy requests for subdomain-based assets",
|
||||||
|
"consumes": [
|
||||||
|
"*/*"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"*/*"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Proxy web requests",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Asset subdomain (asset-123.domain.com)",
|
||||||
|
"name": "Host",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Session ID (alternative to cookie)",
|
||||||
|
"name": "session_id",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Proxied content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid subdomain format",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Session expired page"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Access denied",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/public_key": {
|
"/public_key": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4195,9 +4247,109 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/web_proxy/cleanup": {
|
||||||
|
"post": {
|
||||||
|
"description": "Clean up web session when browser tab is closed",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Cleanup web session",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Cleanup request",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Session cleaned up",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/web_proxy/close": {
|
||||||
|
"post": {
|
||||||
|
"description": "Close an active web session and clean up resources",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Close web session",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Session close request",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Session closed successfully",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Session not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/web_proxy/config/{asset_id}": {
|
"/web_proxy/config/{asset_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Get web asset configuration by asset ID",
|
"description": "Get web asset configuration by asset ID",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"WebProxy"
|
"WebProxy"
|
||||||
],
|
],
|
||||||
@@ -4217,6 +4369,146 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/model.WebConfig"
|
"$ref": "#/definitions/model.WebConfig"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid asset ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Asset not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/web_proxy/external_redirect": {
|
||||||
|
"get": {
|
||||||
|
"description": "Show a page when an external redirect is blocked by the proxy",
|
||||||
|
"consumes": [
|
||||||
|
"text/html"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"text/html"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Handle external redirect",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Target URL that was blocked",
|
||||||
|
"name": "url",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "External redirect blocked page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/web_proxy/heartbeat": {
|
||||||
|
"post": {
|
||||||
|
"description": "Update the last activity time for a web session (heartbeat)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Update session heartbeat",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Heartbeat request",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Heartbeat updated",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Session not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/web_proxy/sessions/{asset_id}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get list of active web sessions for a specific asset",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"WebProxy"
|
||||||
|
],
|
||||||
|
"summary": "Get active web sessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Asset ID",
|
||||||
|
"name": "asset_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of active sessions",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid asset ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4241,7 +4533,7 @@
|
|||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/controller.StartWebSessionRequest"
|
"$ref": "#/definitions/web_proxy.StartWebSessionRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4249,7 +4541,42 @@
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/controller.StartWebSessionResponse"
|
"$ref": "#/definitions/web_proxy.StartWebSessionResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "No permission",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Asset not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Maximum concurrent connections exceeded",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4379,40 +4706,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"controller.StartWebSessionRequest": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"asset_id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"account_id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"asset_id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"asset_name": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"auth_mode": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"controller.StartWebSessionResponse": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"message": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"proxy_url": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"session_id": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"model.AccessAuth": {
|
"model.AccessAuth": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5917,6 +6210,40 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"web_proxy.StartWebSessionRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"asset_id"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"account_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"asset_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"asset_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"auth_mode": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web_proxy.StartWebSessionResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"proxy_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -80,28 +80,6 @@ definitions:
|
|||||||
items: {}
|
items: {}
|
||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
controller.StartWebSessionRequest:
|
|
||||||
properties:
|
|
||||||
account_id:
|
|
||||||
type: integer
|
|
||||||
asset_id:
|
|
||||||
type: integer
|
|
||||||
asset_name:
|
|
||||||
type: string
|
|
||||||
auth_mode:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- asset_id
|
|
||||||
type: object
|
|
||||||
controller.StartWebSessionResponse:
|
|
||||||
properties:
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
proxy_url:
|
|
||||||
type: string
|
|
||||||
session_id:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
model.AccessAuth:
|
model.AccessAuth:
|
||||||
properties:
|
properties:
|
||||||
allow:
|
allow:
|
||||||
@@ -1110,6 +1088,28 @@ definitions:
|
|||||||
description: Enable watermark
|
description: Enable watermark
|
||||||
type: boolean
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
|
web_proxy.StartWebSessionRequest:
|
||||||
|
properties:
|
||||||
|
account_id:
|
||||||
|
type: integer
|
||||||
|
asset_id:
|
||||||
|
type: integer
|
||||||
|
asset_name:
|
||||||
|
type: string
|
||||||
|
auth_mode:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- asset_id
|
||||||
|
type: object
|
||||||
|
web_proxy.StartWebSessionResponse:
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
proxy_url:
|
||||||
|
type: string
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
paths:
|
paths:
|
||||||
@@ -2530,6 +2530,41 @@ paths:
|
|||||||
summary: Update user preferences
|
summary: Update user preferences
|
||||||
tags:
|
tags:
|
||||||
- Preference
|
- Preference
|
||||||
|
/proxy:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- '*/*'
|
||||||
|
description: Handle web proxy requests for subdomain-based assets
|
||||||
|
parameters:
|
||||||
|
- description: Asset subdomain (asset-123.domain.com)
|
||||||
|
in: header
|
||||||
|
name: Host
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Session ID (alternative to cookie)
|
||||||
|
in: query
|
||||||
|
name: session_id
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- '*/*'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Proxied content
|
||||||
|
"400":
|
||||||
|
description: Invalid subdomain format
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"401":
|
||||||
|
description: Session expired page
|
||||||
|
"403":
|
||||||
|
description: Access denied
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Proxy web requests
|
||||||
|
tags:
|
||||||
|
- WebProxy
|
||||||
/public_key:
|
/public_key:
|
||||||
get:
|
get:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -3644,8 +3679,72 @@ paths:
|
|||||||
$ref: '#/definitions/controller.HttpResponse'
|
$ref: '#/definitions/controller.HttpResponse'
|
||||||
tags:
|
tags:
|
||||||
- time_template
|
- time_template
|
||||||
|
/web_proxy/cleanup:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Clean up web session when browser tab is closed
|
||||||
|
parameters:
|
||||||
|
- description: Cleanup request
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Session cleaned up
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Cleanup web session
|
||||||
|
tags:
|
||||||
|
- WebProxy
|
||||||
|
/web_proxy/close:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Close an active web session and clean up resources
|
||||||
|
parameters:
|
||||||
|
- description: Session close request
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Session closed successfully
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Invalid request
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Session not found
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Close web session
|
||||||
|
tags:
|
||||||
|
- WebProxy
|
||||||
/web_proxy/config/{asset_id}:
|
/web_proxy/config/{asset_id}:
|
||||||
get:
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
description: Get web asset configuration by asset ID
|
description: Get web asset configuration by asset ID
|
||||||
parameters:
|
parameters:
|
||||||
- description: Asset ID
|
- description: Asset ID
|
||||||
@@ -3653,14 +3752,110 @@ paths:
|
|||||||
name: asset_id
|
name: asset_id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/model.WebConfig'
|
$ref: '#/definitions/model.WebConfig'
|
||||||
|
"400":
|
||||||
|
description: Invalid asset ID
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Asset not found
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
summary: Get web asset configuration
|
summary: Get web asset configuration
|
||||||
tags:
|
tags:
|
||||||
- WebProxy
|
- WebProxy
|
||||||
|
/web_proxy/external_redirect:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- text/html
|
||||||
|
description: Show a page when an external redirect is blocked by the proxy
|
||||||
|
parameters:
|
||||||
|
- description: Target URL that was blocked
|
||||||
|
in: query
|
||||||
|
name: url
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- text/html
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: External redirect blocked page
|
||||||
|
summary: Handle external redirect
|
||||||
|
tags:
|
||||||
|
- WebProxy
|
||||||
|
/web_proxy/heartbeat:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Update the last activity time for a web session (heartbeat)
|
||||||
|
parameters:
|
||||||
|
- description: Heartbeat request
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Heartbeat updated
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Invalid request
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Session not found
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Update session heartbeat
|
||||||
|
tags:
|
||||||
|
- WebProxy
|
||||||
|
/web_proxy/sessions/{asset_id}:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get list of active web sessions for a specific asset
|
||||||
|
parameters:
|
||||||
|
- description: Asset ID
|
||||||
|
in: path
|
||||||
|
name: asset_id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of active sessions
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Invalid asset ID
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Get active web sessions
|
||||||
|
tags:
|
||||||
|
- WebProxy
|
||||||
/web_proxy/start:
|
/web_proxy/start:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
@@ -3672,14 +3867,39 @@ paths:
|
|||||||
name: request
|
name: request
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/controller.StartWebSessionRequest'
|
$ref: '#/definitions/web_proxy.StartWebSessionRequest'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/controller.StartWebSessionResponse'
|
$ref: '#/definitions/web_proxy.StartWebSessionResponse'
|
||||||
|
"400":
|
||||||
|
description: Invalid request
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"403":
|
||||||
|
description: No permission
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Asset not found
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"429":
|
||||||
|
description: Maximum concurrent connections exceeded
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
summary: Start web session
|
summary: Start web session
|
||||||
tags:
|
tags:
|
||||||
- WebProxy
|
- WebProxy
|
||||||
|
@@ -254,7 +254,13 @@ func SetupRouter(r *gin.Engine) {
|
|||||||
// Web proxy management API routes
|
// Web proxy management API routes
|
||||||
webProxyGroup := v1.Group("/web_proxy")
|
webProxyGroup := v1.Group("/web_proxy")
|
||||||
{
|
{
|
||||||
|
webProxyGroup.GET("/config/:asset_id", webProxy.GetWebAssetConfig)
|
||||||
webProxyGroup.POST("/start", webProxy.StartWebSession)
|
webProxyGroup.POST("/start", webProxy.StartWebSession)
|
||||||
|
webProxyGroup.GET("/external_redirect", webProxy.HandleExternalRedirect)
|
||||||
|
webProxyGroup.POST("/close", webProxy.CloseWebSession)
|
||||||
|
webProxyGroup.GET("/sessions/:asset_id", webProxy.GetActiveWebSessions)
|
||||||
|
webProxyGroup.POST("/heartbeat", webProxy.UpdateWebSessionHeartbeat)
|
||||||
|
webProxyGroup.POST("/cleanup", webProxy.CleanupWebSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package web_proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -18,41 +18,41 @@ import (
|
|||||||
"github.com/veops/oneterm/pkg/logger"
|
"github.com/veops/oneterm/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebAuthService handles Web authentication
|
// AuthService handles Web authentication
|
||||||
type WebAuthService struct {
|
type AuthService struct {
|
||||||
strategies []WebAuthStrategy
|
strategies []AuthStrategy
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAuthStrategy defines the interface for Web authentication strategies
|
// AuthStrategy defines the interface for Web authentication strategies
|
||||||
type WebAuthStrategy interface {
|
type AuthStrategy interface {
|
||||||
Name() string
|
Name() string
|
||||||
Priority() int
|
Priority() int
|
||||||
CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool
|
CanHandle(ctx context.Context, siteInfo *SiteInfo) bool
|
||||||
Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error)
|
Authenticate(ctx context.Context, credentials *Credentials, siteInfo *SiteInfo) (*AuthResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSiteInfo contains information about the target Web site
|
// SiteInfo contains information about the target Web site
|
||||||
type WebSiteInfo struct {
|
type SiteInfo struct {
|
||||||
URL string
|
URL string
|
||||||
HTMLContent string
|
HTMLContent string
|
||||||
Headers http.Header
|
Headers http.Header
|
||||||
StatusCode int
|
StatusCode int
|
||||||
LoginForms []WebLoginForm
|
LoginForms []LoginForm
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebLoginForm represents a login form found on the page
|
// LoginForm represents a login form found on the page
|
||||||
type WebLoginForm struct {
|
type LoginForm struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
UsernameField WebFormField `json:"username_field"`
|
UsernameField FormField `json:"username_field"`
|
||||||
PasswordField WebFormField `json:"password_field"`
|
PasswordField FormField `json:"password_field"`
|
||||||
SubmitButton WebFormField `json:"submit_button"`
|
SubmitButton FormField `json:"submit_button"`
|
||||||
AdditionalFields []WebFormField `json:"additional_fields"`
|
AdditionalFields []FormField `json:"additional_fields"`
|
||||||
CSRFToken string `json:"csrf_token"`
|
CSRFToken string `json:"csrf_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebFormField represents a form field
|
// FormField represents a form field
|
||||||
type WebFormField struct {
|
type FormField struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -61,14 +61,14 @@ type WebFormField struct {
|
|||||||
Placeholder string `json:"placeholder"`
|
Placeholder string `json:"placeholder"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebCredentials contains authentication credentials
|
// Credentials contains authentication credentials
|
||||||
type WebCredentials struct {
|
type Credentials struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAuthResult contains authentication result
|
// AuthResult contains authentication result
|
||||||
type WebAuthResult struct {
|
type AuthResult struct {
|
||||||
Success bool
|
Success bool
|
||||||
Message string
|
Message string
|
||||||
Cookies []*http.Cookie
|
Cookies []*http.Cookie
|
||||||
@@ -76,10 +76,10 @@ type WebAuthResult struct {
|
|||||||
SessionData map[string]interface{}
|
SessionData map[string]interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebAuthService creates a new Web authentication service
|
// NewAuthService creates a new Web authentication service
|
||||||
func NewWebAuthService() *WebAuthService {
|
func NewAuthService() *AuthService {
|
||||||
service := &WebAuthService{
|
service := &AuthService{
|
||||||
strategies: []WebAuthStrategy{
|
strategies: []AuthStrategy{
|
||||||
&HTTPBasicAuthStrategy{},
|
&HTTPBasicAuthStrategy{},
|
||||||
&SmartFormAuthStrategy{},
|
&SmartFormAuthStrategy{},
|
||||||
&APILoginAuthStrategy{},
|
&APILoginAuthStrategy{},
|
||||||
@@ -89,7 +89,7 @@ func NewWebAuthService() *WebAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeSite analyzes a Web site for authentication methods
|
// AnalyzeSite analyzes a Web site for authentication methods
|
||||||
func (s *WebAuthService) AnalyzeSite(ctx context.Context, targetURL string) (*WebSiteInfo, error) {
|
func (s *AuthService) AnalyzeSite(ctx context.Context, targetURL string) (*SiteInfo, error) {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
@@ -109,7 +109,7 @@ func (s *WebAuthService) AnalyzeSite(ctx context.Context, targetURL string) (*We
|
|||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
siteInfo := &WebSiteInfo{
|
siteInfo := &SiteInfo{
|
||||||
URL: targetURL,
|
URL: targetURL,
|
||||||
HTMLContent: string(body),
|
HTMLContent: string(body),
|
||||||
Headers: resp.Header,
|
Headers: resp.Header,
|
||||||
@@ -130,8 +130,8 @@ func (s *WebAuthService) AnalyzeSite(ctx context.Context, targetURL string) (*We
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SelectBestStrategy selects the best authentication strategy for a site
|
// SelectBestStrategy selects the best authentication strategy for a site
|
||||||
func (s *WebAuthService) SelectBestStrategy(ctx context.Context, siteInfo *WebSiteInfo) WebAuthStrategy {
|
func (s *AuthService) SelectBestStrategy(ctx context.Context, siteInfo *SiteInfo) AuthStrategy {
|
||||||
var bestStrategy WebAuthStrategy
|
var bestStrategy AuthStrategy
|
||||||
highestPriority := -1
|
highestPriority := -1
|
||||||
|
|
||||||
for _, strategy := range s.strategies {
|
for _, strategy := range s.strategies {
|
||||||
@@ -145,10 +145,10 @@ func (s *WebAuthService) SelectBestStrategy(ctx context.Context, siteInfo *WebSi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate performs authentication using the best available strategy
|
// Authenticate performs authentication using the best available strategy
|
||||||
func (s *WebAuthService) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
|
func (s *AuthService) Authenticate(ctx context.Context, credentials *Credentials, siteInfo *SiteInfo) (*AuthResult, error) {
|
||||||
strategy := s.SelectBestStrategy(ctx, siteInfo)
|
strategy := s.SelectBestStrategy(ctx, siteInfo)
|
||||||
if strategy == nil {
|
if strategy == nil {
|
||||||
return &WebAuthResult{
|
return &AuthResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: "No suitable authentication strategy found",
|
Message: "No suitable authentication strategy found",
|
||||||
}, nil
|
}, nil
|
||||||
@@ -158,9 +158,9 @@ func (s *WebAuthService) Authenticate(ctx context.Context, credentials *WebCrede
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AuthenticateWithRetry performs authentication with automatic account retry
|
// AuthenticateWithRetry performs authentication with automatic account retry
|
||||||
func (s *WebAuthService) AuthenticateWithRetry(ctx context.Context, accounts []WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
|
func (s *AuthService) AuthenticateWithRetry(ctx context.Context, accounts []Credentials, siteInfo *SiteInfo) (*AuthResult, error) {
|
||||||
if len(accounts) == 0 {
|
if len(accounts) == 0 {
|
||||||
return &WebAuthResult{
|
return &AuthResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: "No accounts available for authentication",
|
Message: "No accounts available for authentication",
|
||||||
}, nil
|
}, nil
|
||||||
@@ -168,14 +168,14 @@ func (s *WebAuthService) AuthenticateWithRetry(ctx context.Context, accounts []W
|
|||||||
|
|
||||||
strategy := s.SelectBestStrategy(ctx, siteInfo)
|
strategy := s.SelectBestStrategy(ctx, siteInfo)
|
||||||
if strategy == nil {
|
if strategy == nil {
|
||||||
return &WebAuthResult{
|
return &AuthResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: "No suitable authentication strategy found",
|
Message: "No suitable authentication strategy found",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastError error
|
var lastError error
|
||||||
var lastResult *WebAuthResult
|
var lastResult *AuthResult
|
||||||
|
|
||||||
// 尝试每个账号,直到成功
|
// 尝试每个账号,直到成功
|
||||||
for i, credentials := range accounts {
|
for i, credentials := range accounts {
|
||||||
@@ -216,23 +216,23 @@ func (s *WebAuthService) AuthenticateWithRetry(ctx context.Context, accounts []W
|
|||||||
return lastResult, nil
|
return lastResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &WebAuthResult{
|
return &AuthResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: "All configured accounts failed to authenticate",
|
Message: "All configured accounts failed to authenticate",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// analyzeLoginForms analyzes HTML content for login forms
|
// analyzeLoginForms analyzes HTML content for login forms
|
||||||
func (s *WebAuthService) analyzeLoginForms(htmlContent string) ([]WebLoginForm, error) {
|
func (s *AuthService) analyzeLoginForms(htmlContent string) ([]LoginForm, error) {
|
||||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var forms []WebLoginForm
|
var forms []LoginForm
|
||||||
|
|
||||||
doc.Find("form").Each(func(i int, formSel *goquery.Selection) {
|
doc.Find("form").Each(func(i int, formSel *goquery.Selection) {
|
||||||
form := WebLoginForm{
|
form := LoginForm{
|
||||||
Method: strings.ToUpper(formSel.AttrOr("method", "GET")),
|
Method: strings.ToUpper(formSel.AttrOr("method", "GET")),
|
||||||
Action: formSel.AttrOr("action", ""),
|
Action: formSel.AttrOr("action", ""),
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ func (s *WebAuthService) analyzeLoginForms(htmlContent string) ([]WebLoginForm,
|
|||||||
inputID := inputSel.AttrOr("id", "")
|
inputID := inputSel.AttrOr("id", "")
|
||||||
placeholder := inputSel.AttrOr("placeholder", "")
|
placeholder := inputSel.AttrOr("placeholder", "")
|
||||||
|
|
||||||
field := WebFormField{
|
field := FormField{
|
||||||
Name: inputName,
|
Name: inputName,
|
||||||
ID: inputID,
|
ID: inputID,
|
||||||
Type: inputType,
|
Type: inputType,
|
||||||
@@ -263,7 +263,7 @@ func (s *WebAuthService) analyzeLoginForms(htmlContent string) ([]WebLoginForm,
|
|||||||
// Find submit button
|
// Find submit button
|
||||||
formSel.Find("button, input[type=submit]").Each(func(j int, btnSel *goquery.Selection) {
|
formSel.Find("button, input[type=submit]").Each(func(j int, btnSel *goquery.Selection) {
|
||||||
if form.SubmitButton.Name == "" {
|
if form.SubmitButton.Name == "" {
|
||||||
form.SubmitButton = WebFormField{
|
form.SubmitButton = FormField{
|
||||||
Name: btnSel.AttrOr("name", ""),
|
Name: btnSel.AttrOr("name", ""),
|
||||||
ID: btnSel.AttrOr("id", ""),
|
ID: btnSel.AttrOr("id", ""),
|
||||||
Type: btnSel.AttrOr("type", "submit"),
|
Type: btnSel.AttrOr("type", "submit"),
|
||||||
@@ -282,7 +282,7 @@ func (s *WebAuthService) analyzeLoginForms(htmlContent string) ([]WebLoginForm,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isUsernameField determines if a field is likely a username field
|
// isUsernameField determines if a field is likely a username field
|
||||||
func (s *WebAuthService) isUsernameField(inputType, name, id, placeholder string) bool {
|
func (s *AuthService) isUsernameField(inputType, name, id, placeholder string) bool {
|
||||||
if inputType == "password" {
|
if inputType == "password" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -296,7 +296,7 @@ func (s *WebAuthService) isUsernameField(inputType, name, id, placeholder string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// generateSelector generates a CSS selector for an element
|
// generateSelector generates a CSS selector for an element
|
||||||
func (s *WebAuthService) generateSelector(sel *goquery.Selection) string {
|
func (s *AuthService) generateSelector(sel *goquery.Selection) string {
|
||||||
if id := sel.AttrOr("id", ""); id != "" {
|
if id := sel.AttrOr("id", ""); id != "" {
|
||||||
return "#" + id
|
return "#" + id
|
||||||
}
|
}
|
||||||
@@ -318,12 +318,12 @@ type HTTPBasicAuthStrategy struct{}
|
|||||||
func (s *HTTPBasicAuthStrategy) Name() string { return "http_basic" }
|
func (s *HTTPBasicAuthStrategy) Name() string { return "http_basic" }
|
||||||
func (s *HTTPBasicAuthStrategy) Priority() int { return 10 }
|
func (s *HTTPBasicAuthStrategy) Priority() int { return 10 }
|
||||||
|
|
||||||
func (s *HTTPBasicAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool {
|
func (s *HTTPBasicAuthStrategy) CanHandle(ctx context.Context, siteInfo *SiteInfo) bool {
|
||||||
return siteInfo.StatusCode == 401 &&
|
return siteInfo.StatusCode == 401 &&
|
||||||
strings.Contains(siteInfo.Headers.Get("WWW-Authenticate"), "Basic")
|
strings.Contains(siteInfo.Headers.Get("WWW-Authenticate"), "Basic")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HTTPBasicAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
|
func (s *HTTPBasicAuthStrategy) Authenticate(ctx context.Context, credentials *Credentials, siteInfo *SiteInfo) (*AuthResult, error) {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", siteInfo.URL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", siteInfo.URL, nil)
|
||||||
@@ -340,7 +340,7 @@ func (s *HTTPBasicAuthStrategy) Authenticate(ctx context.Context, credentials *W
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
success := resp.StatusCode != 401
|
success := resp.StatusCode != 401
|
||||||
return &WebAuthResult{
|
return &AuthResult{
|
||||||
Success: success,
|
Success: success,
|
||||||
Message: fmt.Sprintf("HTTP Basic auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]),
|
Message: fmt.Sprintf("HTTP Basic auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]),
|
||||||
Cookies: resp.Cookies(),
|
Cookies: resp.Cookies(),
|
||||||
@@ -353,11 +353,11 @@ type SmartFormAuthStrategy struct{}
|
|||||||
func (s *SmartFormAuthStrategy) Name() string { return "smart_form" }
|
func (s *SmartFormAuthStrategy) Name() string { return "smart_form" }
|
||||||
func (s *SmartFormAuthStrategy) Priority() int { return 5 }
|
func (s *SmartFormAuthStrategy) Priority() int { return 5 }
|
||||||
|
|
||||||
func (s *SmartFormAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool {
|
func (s *SmartFormAuthStrategy) CanHandle(ctx context.Context, siteInfo *SiteInfo) bool {
|
||||||
return len(siteInfo.LoginForms) > 0
|
return len(siteInfo.LoginForms) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SmartFormAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
|
func (s *SmartFormAuthStrategy) Authenticate(ctx context.Context, credentials *Credentials, siteInfo *SiteInfo) (*AuthResult, error) {
|
||||||
if len(siteInfo.LoginForms) == 0 {
|
if len(siteInfo.LoginForms) == 0 {
|
||||||
return nil, fmt.Errorf("no login forms found")
|
return nil, fmt.Errorf("no login forms found")
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@ func (s *SmartFormAuthStrategy) Authenticate(ctx context.Context, credentials *W
|
|||||||
// Usually a successful login redirects or returns 200 with cookies
|
// Usually a successful login redirects or returns 200 with cookies
|
||||||
success := resp.StatusCode >= 200 && resp.StatusCode < 400 && len(resp.Cookies()) > 0
|
success := resp.StatusCode >= 200 && resp.StatusCode < 400 && len(resp.Cookies()) > 0
|
||||||
|
|
||||||
return &WebAuthResult{
|
return &AuthResult{
|
||||||
Success: success,
|
Success: success,
|
||||||
Message: fmt.Sprintf("Form auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]),
|
Message: fmt.Sprintf("Form auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]),
|
||||||
Cookies: resp.Cookies(),
|
Cookies: resp.Cookies(),
|
||||||
@@ -421,7 +421,7 @@ type APILoginAuthStrategy struct{}
|
|||||||
func (s *APILoginAuthStrategy) Name() string { return "api_login" }
|
func (s *APILoginAuthStrategy) Name() string { return "api_login" }
|
||||||
func (s *APILoginAuthStrategy) Priority() int { return 8 }
|
func (s *APILoginAuthStrategy) Priority() int { return 8 }
|
||||||
|
|
||||||
func (s *APILoginAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool {
|
func (s *APILoginAuthStrategy) CanHandle(ctx context.Context, siteInfo *SiteInfo) bool {
|
||||||
// Check for common API login endpoints
|
// Check for common API login endpoints
|
||||||
commonEndpoints := []string{"/api/login", "/auth/login", "/login", "/signin"}
|
commonEndpoints := []string{"/api/login", "/auth/login", "/login", "/signin"}
|
||||||
baseURL, err := url.Parse(siteInfo.URL)
|
baseURL, err := url.Parse(siteInfo.URL)
|
||||||
@@ -441,7 +441,7 @@ func (s *APILoginAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteI
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APILoginAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
|
func (s *APILoginAuthStrategy) Authenticate(ctx context.Context, credentials *Credentials, siteInfo *SiteInfo) (*AuthResult, error) {
|
||||||
baseURL, err := url.Parse(siteInfo.URL)
|
baseURL, err := url.Parse(siteInfo.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -481,7 +481,7 @@ func (s *APILoginAuthStrategy) Authenticate(ctx context.Context, credentials *We
|
|||||||
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return &WebAuthResult{
|
return &AuthResult{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "API login succeeded",
|
Message: "API login succeeded",
|
||||||
Cookies: resp.Cookies(),
|
Cookies: resp.Cookies(),
|
||||||
@@ -490,7 +490,7 @@ func (s *APILoginAuthStrategy) Authenticate(ctx context.Context, credentials *We
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &WebAuthResult{
|
return &AuthResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: "API login failed - no valid endpoint found",
|
Message: "API login failed - no valid endpoint found",
|
||||||
}, nil
|
}, nil
|
401
backend/internal/service/web_proxy/content.go
Normal file
401
backend/internal/service/web_proxy/content.go
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
package web_proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RewriteHTMLContent rewrites HTML content to redirect external links through proxy
|
||||||
|
func RewriteHTMLContent(resp *http.Response, assetID int, scheme, proxyHost string) {
|
||||||
|
if resp.Body == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Content-Encoding to avoid decoding issues
|
||||||
|
resp.Header.Del("Content-Encoding")
|
||||||
|
resp.Header.Del("Content-Length")
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessHTMLResponse processes HTML response for content rewriting and injection
|
||||||
|
func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost string, session *WebProxySession) {
|
||||||
|
if resp.Body == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if content is compressed
|
||||||
|
contentEncoding := resp.Header.Get("Content-Encoding")
|
||||||
|
|
||||||
|
// Remove Content-Encoding to avoid decoding issues
|
||||||
|
resp.Header.Del("Content-Encoding")
|
||||||
|
resp.Header.Del("Content-Length")
|
||||||
|
|
||||||
|
var body []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Handle compressed content
|
||||||
|
if contentEncoding == "gzip" {
|
||||||
|
gzipReader, err := gzip.NewReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
body, err = io.ReadAll(gzipReader)
|
||||||
|
} else {
|
||||||
|
body, err = io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
content := string(body)
|
||||||
|
|
||||||
|
// Step 1: URL rewriting for external links
|
||||||
|
baseDomain := lo.Ternary(strings.HasPrefix(proxyHost, "asset-"),
|
||||||
|
func() string {
|
||||||
|
parts := strings.SplitN(proxyHost, ".", 2)
|
||||||
|
return lo.Ternary(len(parts) > 1, parts[1], proxyHost)
|
||||||
|
}(),
|
||||||
|
proxyHost)
|
||||||
|
|
||||||
|
patterns := []struct {
|
||||||
|
pattern string
|
||||||
|
rewrite func(matches []string) string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
`(window\.location(?:\.href)?\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`,
|
||||||
|
func(matches []string) string {
|
||||||
|
path := lo.Ternary(len(matches) > 3 && matches[3] != "", matches[3], "")
|
||||||
|
return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`(action\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`,
|
||||||
|
func(matches []string) string {
|
||||||
|
path := lo.Ternary(len(matches) > 3 && matches[3] != "", matches[3], "")
|
||||||
|
return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, 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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range patterns {
|
||||||
|
re := regexp.MustCompile(p.pattern)
|
||||||
|
content = re.ReplaceAllStringFunc(content, func(match string) string {
|
||||||
|
matches := re.FindStringSubmatch(match)
|
||||||
|
if len(matches) >= 4 {
|
||||||
|
return p.rewrite(matches)
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Add watermark if enabled
|
||||||
|
if session.WebConfig != nil && session.WebConfig.ProxySettings != nil && session.WebConfig.ProxySettings.WatermarkEnabled {
|
||||||
|
watermarkCSS := `
|
||||||
|
<style>
|
||||||
|
.oneterm-watermark-container {
|
||||||
|
position: fixed;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.oneterm-watermark-text {
|
||||||
|
position: absolute;
|
||||||
|
color: rgba(128,128,128,0.08);
|
||||||
|
font-size: 32px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>`
|
||||||
|
|
||||||
|
// Generate watermark HTML with multiple OneTerm texts
|
||||||
|
var watermarkTexts []string
|
||||||
|
for row := 0; row < 30; row++ {
|
||||||
|
for col := 0; col < 15; col++ {
|
||||||
|
top := row * 100
|
||||||
|
left := col * 300
|
||||||
|
watermarkTexts = append(watermarkTexts,
|
||||||
|
fmt.Sprintf(`<div class="oneterm-watermark-text" style="top: %dpx; left: %dpx;">OneTerm</div>`, top, left))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watermarkHTML := fmt.Sprintf(`
|
||||||
|
<div class="oneterm-watermark-container">
|
||||||
|
%s
|
||||||
|
</div>`, strings.Join(watermarkTexts, "\n"))
|
||||||
|
|
||||||
|
// Add session management JavaScript
|
||||||
|
sessionJS := fmt.Sprintf(`
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var sessionId = '%s';
|
||||||
|
var heartbeatInterval;
|
||||||
|
|
||||||
|
// Send heartbeat every 30 seconds
|
||||||
|
function sendHeartbeat() {
|
||||||
|
fetch('/api/oneterm/v1/web_proxy/heartbeat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({session_id: sessionId})
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start heartbeat
|
||||||
|
heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
||||||
|
|
||||||
|
// Handle page unload (tab close, navigation away)
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
// Use sendBeacon for reliable cleanup on page unload
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
navigator.sendBeacon('/api/oneterm/v1/web_proxy/cleanup',
|
||||||
|
JSON.stringify({session_id: sessionId}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle visibility change (tab switching)
|
||||||
|
document.addEventListener('visibilitychange', function() {
|
||||||
|
if (document.hidden) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
} else {
|
||||||
|
heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>`, session.SessionId)
|
||||||
|
|
||||||
|
if strings.Contains(content, "</head>") {
|
||||||
|
content = strings.Replace(content, "</head>", watermarkCSS+"</head>", 1)
|
||||||
|
} else {
|
||||||
|
content = watermarkCSS + content
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(content, "</body>") {
|
||||||
|
content = strings.Replace(content, "</body>", watermarkHTML+sessionJS+"</body>", 1)
|
||||||
|
} else {
|
||||||
|
content = content + watermarkHTML + sessionJS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Record activity if enabled
|
||||||
|
if session.WebConfig != nil && session.WebConfig.ProxySettings != nil && session.WebConfig.ProxySettings.RecordingEnabled {
|
||||||
|
// Activity recording is handled elsewhere to avoid accessing ctx.Request here
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update response
|
||||||
|
newBody := bytes.NewReader([]byte(content))
|
||||||
|
resp.Body = io.NopCloser(newBody)
|
||||||
|
resp.ContentLength = int64(len(content))
|
||||||
|
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderExternalRedirectPage renders the page shown when external redirect is blocked
|
||||||
|
func RenderExternalRedirectPage(targetURL string) string {
|
||||||
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>External Redirect Blocked - 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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSessionExpiredPage renders the page shown when session has expired
|
||||||
|
func RenderSessionExpiredPage(reason string) string {
|
||||||
|
return 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)
|
||||||
|
}
|
302
backend/internal/service/web_proxy/service.go
Normal file
302
backend/internal/service/web_proxy/service.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package web_proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use standard V2 authorization check (same as other asset types)
|
||||||
|
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 {
|
||||||
|
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,
|
||||||
|
CurrentHost: initialHost,
|
||||||
|
Permissions: permissions,
|
||||||
|
WebConfig: asset.WebConfig,
|
||||||
|
}
|
||||||
|
StoreSession(sessionId, webSession)
|
||||||
|
|
||||||
|
// Generate subdomain-based proxy URL
|
||||||
|
baseDomain := strings.Split(ctx.Request.Host, ":")[0]
|
||||||
|
if strings.Contains(baseDomain, ".") {
|
||||||
|
parts := strings.Split(baseDomain, ".")
|
||||||
|
if len(parts) > 2 {
|
||||||
|
baseDomain = strings.Join(parts[1:], ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine proxy scheme based on current request only (not asset protocol)
|
||||||
|
scheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http")
|
||||||
|
|
||||||
|
portSuffix := ""
|
||||||
|
if strings.Contains(ctx.Request.Host, ":") {
|
||||||
|
portSuffix = ":" + strings.Split(ctx.Request.Host, ":")[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 subdomain host
|
||||||
|
func 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
path := ctx.Request.URL.Path
|
||||||
|
downloadExts := []string{".pdf", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".rar", ".tar", ".gz"}
|
||||||
|
return lo.SomeBy(downloadExts, func(ext string) bool {
|
||||||
|
return strings.HasSuffix(strings.ToLower(path), ext)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordWebActivity records web session activity for auditing
|
||||||
|
func RecordWebActivity(sessionId string, ctx *gin.Context) {
|
||||||
|
// Activity recording logic would go here
|
||||||
|
// This is a placeholder to maintain API compatibility
|
||||||
|
logger.L().Debug("Recording web activity", zap.String("sessionId", sessionId), zap.String("path", ctx.Request.URL.Path))
|
||||||
|
}
|
107
backend/internal/service/web_proxy/session.go
Normal file
107
backend/internal/service/web_proxy/session.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package web_proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/veops/oneterm/internal/model"
|
||||||
|
"github.com/veops/oneterm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global session storage
|
||||||
|
var webProxySessions = make(map[string]*WebProxySession)
|
||||||
|
|
||||||
|
// WebProxySession represents an active web proxy session
|
||||||
|
type WebProxySession struct {
|
||||||
|
SessionId string
|
||||||
|
AssetId int
|
||||||
|
AccountId int
|
||||||
|
Asset *model.Asset
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastActivity time.Time
|
||||||
|
CurrentHost string
|
||||||
|
Permissions *model.AuthPermissions // User permissions for this asset
|
||||||
|
WebConfig *model.WebConfig // Web-specific configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupExpiredSessions removes inactive sessions from storage
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSessionCleanupRoutine starts background cleanup routine for web sessions
|
||||||
|
func StartSessionCleanupRoutine() {
|
||||||
|
// More frequent cleanup - every 30 seconds to catch closed browser tabs quickly
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
go func() {
|
||||||
|
for range ticker.C {
|
||||||
|
// For web sessions, clean up after 2 minutes of inactivity (browser likely closed)
|
||||||
|
webInactiveTime := 2 * time.Minute
|
||||||
|
cleanupExpiredSessions(webInactiveTime)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
count := 0
|
||||||
|
for _, session := range webProxySessions {
|
||||||
|
if session.AssetId == assetID {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
delete(webProxySessions, sessionID)
|
||||||
|
}
|
Reference in New Issue
Block a user