mirror of
https://github.com/veops/oneterm.git
synced 2025-10-05 23:37:03 +08:00
531 lines
18 KiB
Go
531 lines
18 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/samber/lo"
|
|
"github.com/veops/oneterm/internal/model"
|
|
"github.com/veops/oneterm/internal/service"
|
|
"github.com/veops/oneterm/internal/service/web_proxy"
|
|
"github.com/veops/oneterm/pkg/logger"
|
|
)
|
|
|
|
type WebProxyController struct{}
|
|
|
|
func NewWebProxyController() *WebProxyController {
|
|
return &WebProxyController{}
|
|
}
|
|
|
|
// 使用service层的结构体
|
|
type StartWebSessionRequest = web_proxy.StartWebSessionRequest
|
|
type StartWebSessionResponse = web_proxy.StartWebSessionResponse
|
|
|
|
// 使用service层的全局变量和结构体
|
|
type WebProxySession = web_proxy.WebProxySession
|
|
|
|
func StartSessionCleanupRoutine() {
|
|
web_proxy.StartSessionCleanupRoutine()
|
|
}
|
|
|
|
func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason string) {
|
|
html := web_proxy.RenderSessionExpiredPage(reason)
|
|
ctx.SetCookie("oneterm_session_id", "", -1, "/", "", false, true)
|
|
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
|
ctx.String(http.StatusUnauthorized, html)
|
|
}
|
|
|
|
// GetWebAssetConfig get web asset configuration
|
|
// @Summary Get web asset configuration
|
|
// @Description Get web asset configuration by asset ID
|
|
// @Tags WebProxy
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param asset_id path int true "Asset ID"
|
|
// @Success 200 {object} model.WebConfig
|
|
// @Failure 400 {object} map[string]interface{} "Invalid asset ID"
|
|
// @Failure 404 {object} map[string]interface{} "Asset not found"
|
|
// @Router /web_proxy/config/{asset_id} [get]
|
|
func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) {
|
|
assetIdStr := ctx.Param("asset_id")
|
|
assetId, err := strconv.Atoi(assetIdStr)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid asset ID"})
|
|
return
|
|
}
|
|
|
|
assetService := service.NewAssetService()
|
|
asset, err := assetService.GetById(ctx, assetId)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Asset not found"})
|
|
return
|
|
}
|
|
|
|
if !asset.IsWebAsset() {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Asset is not a web asset"})
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, asset.WebConfig)
|
|
}
|
|
|
|
// StartWebSession start a new web session
|
|
// @Summary Start web session
|
|
// @Description Start a new web session for the specified asset
|
|
// @Tags WebProxy
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body web_proxy.StartWebSessionRequest true "Start session request"
|
|
// @Success 200 {object} web_proxy.StartWebSessionResponse
|
|
// @Failure 400 {object} map[string]interface{} "Invalid request"
|
|
// @Failure 403 {object} map[string]interface{} "No permission"
|
|
// @Failure 404 {object} map[string]interface{} "Asset not found"
|
|
// @Failure 429 {object} map[string]interface{} "Maximum concurrent connections exceeded"
|
|
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
|
// @Router /web_proxy/start [post]
|
|
func (c *WebProxyController) StartWebSession(ctx *gin.Context) {
|
|
var req StartWebSessionRequest
|
|
if err := ctx.ShouldBindBodyWithJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
resp, err := web_proxy.StartWebSession(ctx, req)
|
|
if err != nil {
|
|
// Return appropriate HTTP status code based on error type
|
|
if strings.Contains(err.Error(), "not found") {
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
} else if strings.Contains(err.Error(), "not a web asset") {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
} else if strings.Contains(err.Error(), "No permission") {
|
|
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
|
} else if strings.Contains(err.Error(), "maximum concurrent") {
|
|
ctx.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
|
} else {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
}
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// ProxyWebRequest handles subdomain-based web proxy requests
|
|
// @Summary Proxy web requests
|
|
// @Description Handle web proxy requests for subdomain-based assets
|
|
// @Tags WebProxy
|
|
// @Accept */*
|
|
// @Produce */*
|
|
// @Param Host header string true "Asset subdomain (asset-123.domain.com)"
|
|
// @Param session_id query string false "Session ID (alternative to cookie)"
|
|
// @Success 200 "Proxied content"
|
|
// @Failure 400 {object} map[string]interface{} "Invalid subdomain format"
|
|
// @Failure 401 "Session expired page"
|
|
// @Failure 403 {object} map[string]interface{} "Access denied"
|
|
// @Router /proxy [get]
|
|
func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) {
|
|
host := ctx.Request.Host
|
|
|
|
// Try to get session_id from multiple sources (priority order)
|
|
sessionID := ctx.Query("session_id")
|
|
|
|
// 1. Try from Cookie (preferred method)
|
|
if sessionID == "" {
|
|
if cookie, err := ctx.Cookie("oneterm_session_id"); err == nil && cookie != "" {
|
|
sessionID = cookie
|
|
logger.L().Debug("Extracted session_id from cookie", zap.String("sessionID", sessionID))
|
|
}
|
|
}
|
|
|
|
// 2. Try from redirect parameter (for login redirects)
|
|
if sessionID == "" {
|
|
if redirect := ctx.Query("redirect"); redirect != "" {
|
|
if decoded, err := url.QueryUnescape(redirect); err == nil {
|
|
if decodedURL, err := url.Parse(decoded); err == nil {
|
|
sessionID = decodedURL.Query().Get("session_id")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract asset ID from Host header: asset-11.oneterm.com -> 11
|
|
assetID, err := c.extractAssetIDFromHost(host)
|
|
if err != nil {
|
|
logger.L().Error("Invalid subdomain format", zap.String("host", host), zap.Error(err))
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subdomain format"})
|
|
return
|
|
}
|
|
|
|
logger.L().Debug("Extracted asset ID", zap.Int("assetID", assetID))
|
|
|
|
// Try to get session_id from Referer header as fallback
|
|
if sessionID == "" {
|
|
referer := ctx.GetHeader("Referer")
|
|
if referer != "" {
|
|
if refererURL, err := url.Parse(referer); err == nil {
|
|
sessionID = refererURL.Query().Get("session_id")
|
|
// Also try to extract from fragment/hash part if URL encoded
|
|
if sessionID == "" && strings.Contains(refererURL.RawQuery, "session_id") {
|
|
// Handle URL encoded session_id in redirect parameter
|
|
if redirect := refererURL.Query().Get("redirect"); redirect != "" {
|
|
if decoded, err := url.QueryUnescape(redirect); err == nil {
|
|
if decodedURL, err := url.Parse(decoded); err == nil {
|
|
sessionID = decodedURL.Query().Get("session_id")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// For static resources, try harder to find session_id
|
|
if sessionID == "" {
|
|
// Check if this looks like a static resource
|
|
isStaticResource := strings.Contains(ctx.Request.URL.Path, "/img/") ||
|
|
strings.Contains(ctx.Request.URL.Path, "/css/") ||
|
|
strings.Contains(ctx.Request.URL.Path, "/js/") ||
|
|
strings.Contains(ctx.Request.URL.Path, "/assets/") ||
|
|
strings.HasSuffix(ctx.Request.URL.Path, ".png") ||
|
|
strings.HasSuffix(ctx.Request.URL.Path, ".jpg") ||
|
|
strings.HasSuffix(ctx.Request.URL.Path, ".gif") ||
|
|
strings.HasSuffix(ctx.Request.URL.Path, ".css") ||
|
|
strings.HasSuffix(ctx.Request.URL.Path, ".js") ||
|
|
strings.HasSuffix(ctx.Request.URL.Path, ".ico")
|
|
|
|
if isStaticResource {
|
|
// For static resources, find any valid session for this asset
|
|
allSessions := web_proxy.GetAllSessions()
|
|
for sid, session := range allSessions {
|
|
if session.AssetId == assetID {
|
|
sessionID = sid
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if sessionID == "" {
|
|
logger.L().Error("Missing session ID", zap.String("host", host))
|
|
c.renderSessionExpiredPage(ctx, "Session ID required - please start a new web session")
|
|
return
|
|
}
|
|
|
|
// Validate session ID and get session information
|
|
session, exists := web_proxy.GetSession(sessionID)
|
|
if !exists {
|
|
c.renderSessionExpiredPage(ctx, "Invalid or expired session")
|
|
return
|
|
}
|
|
|
|
// Check session timeout using system config (same as other protocols)
|
|
now := time.Now()
|
|
maxInactiveTime := time.Duration(model.GlobalConfig.Load().Timeout) * time.Second
|
|
if now.Sub(session.LastActivity) > maxInactiveTime {
|
|
web_proxy.CloseWebSession(sessionID)
|
|
c.renderSessionExpiredPage(ctx, "Session expired due to inactivity")
|
|
return
|
|
}
|
|
|
|
// Update last activity time and auto-renew cookie
|
|
web_proxy.UpdateSessionActivity(sessionID)
|
|
cookieMaxAge := int(model.GlobalConfig.Load().Timeout)
|
|
ctx.SetCookie("oneterm_session_id", sessionID, cookieMaxAge, "/", "", false, true)
|
|
|
|
// Update last activity
|
|
web_proxy.UpdateSessionActivity(sessionID)
|
|
|
|
// Check Web-specific access controls
|
|
if err := c.checkWebAccessControls(ctx, session); err != nil {
|
|
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if session.AssetId != assetID {
|
|
ctx.JSON(http.StatusForbidden, gin.H{"error": "Asset ID mismatch"})
|
|
return
|
|
}
|
|
|
|
targetURL := c.buildTargetURLWithHost(session.Asset, session.CurrentHost)
|
|
target, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid target URL"})
|
|
return
|
|
}
|
|
|
|
currentScheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http")
|
|
|
|
// Create transparent reverse proxy
|
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
|
|
|
// Configure proxy director for transparent proxying
|
|
originalDirector := proxy.Director
|
|
proxy.Director = func(req *http.Request) {
|
|
originalDirector(req)
|
|
|
|
req.Host = target.Host
|
|
req.Header.Set("Host", target.Host)
|
|
|
|
if origin := req.Header.Get("Origin"); origin != "" {
|
|
req.Header.Set("Origin", target.Scheme+"://"+target.Host)
|
|
}
|
|
|
|
if referer := req.Header.Get("Referer"); referer != "" {
|
|
if refererURL, err := url.Parse(referer); err == nil {
|
|
refererURL.Scheme = target.Scheme
|
|
refererURL.Host = target.Host
|
|
req.Header.Set("Referer", refererURL.String())
|
|
}
|
|
}
|
|
|
|
q := req.URL.Query()
|
|
q.Del("session_id")
|
|
req.URL.RawQuery = q.Encode()
|
|
}
|
|
|
|
// Redirect interception for bastion control
|
|
proxy.ModifyResponse = func(resp *http.Response) error {
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if resp.StatusCode == 200 && strings.Contains(contentType, "text/html") {
|
|
c.processHTMLResponse(resp, assetID, currentScheme, host, session)
|
|
}
|
|
|
|
// Record activity if enabled
|
|
if session.WebConfig != nil && session.WebConfig.ProxySettings != nil && session.WebConfig.ProxySettings.RecordingEnabled {
|
|
c.recordWebActivity(session, ctx.Request)
|
|
}
|
|
|
|
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
|
location := resp.Header.Get("Location")
|
|
if location != "" {
|
|
redirectURL, err := url.Parse(location)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
shouldIntercept := redirectURL.IsAbs()
|
|
|
|
if shouldIntercept {
|
|
baseDomain := lo.Ternary(strings.HasPrefix(host, "asset-"),
|
|
func() string {
|
|
parts := strings.SplitN(host, ".", 2)
|
|
return lo.Ternary(len(parts) > 1, parts[1], host)
|
|
}(),
|
|
host)
|
|
|
|
if c.isSameDomainOrSubdomain(target.Host, redirectURL.Host) {
|
|
web_proxy.UpdateSessionHost(sessionID, redirectURL.Host)
|
|
newProxyURL := fmt.Sprintf("%s://asset-%d.%s%s", currentScheme, assetID, baseDomain, redirectURL.Path)
|
|
if redirectURL.RawQuery != "" {
|
|
newProxyURL += "?" + redirectURL.RawQuery
|
|
}
|
|
resp.Header.Set("Location", newProxyURL)
|
|
} else {
|
|
newLocation := fmt.Sprintf("%s://asset-%d.%s/external?url=%s",
|
|
currentScheme, assetID, baseDomain, url.QueryEscape(redirectURL.String()))
|
|
resp.Header.Set("Location", newLocation)
|
|
}
|
|
} else {
|
|
resp.Header.Set("Location", redirectURL.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
if cookies := resp.Header["Set-Cookie"]; len(cookies) > 0 {
|
|
proxyDomain := strings.Split(host, ":")[0]
|
|
|
|
newCookies := lo.Map(cookies, func(cookie string, _ int) string {
|
|
if strings.Contains(cookie, "Domain="+target.Host) {
|
|
return strings.Replace(cookie, "Domain="+target.Host, "Domain="+proxyDomain, 1)
|
|
}
|
|
return cookie
|
|
})
|
|
resp.Header["Set-Cookie"] = newCookies
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
ctx.Header("Cache-Control", "no-cache")
|
|
|
|
proxy.ServeHTTP(ctx.Writer, ctx.Request)
|
|
}
|
|
|
|
// HandleExternalRedirect shows a page when an external redirect is blocked
|
|
// @Summary Handle external redirect
|
|
// @Description Show a page when an external redirect is blocked by the proxy
|
|
// @Tags WebProxy
|
|
// @Accept html
|
|
// @Produce html
|
|
// @Param url query string true "Target URL that was blocked"
|
|
// @Success 200 "External redirect blocked page"
|
|
// @Router /web_proxy/external_redirect [get]
|
|
func (c *WebProxyController) HandleExternalRedirect(ctx *gin.Context) {
|
|
targetURL := ctx.Query("url")
|
|
if targetURL == "" {
|
|
targetURL = "Unknown URL"
|
|
}
|
|
|
|
html := web_proxy.RenderExternalRedirectPage(targetURL)
|
|
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
|
ctx.String(http.StatusOK, html)
|
|
}
|
|
|
|
// isSameDomainOrSubdomain checks if two hosts belong to the same domain or subdomain
|
|
func (c *WebProxyController) isSameDomainOrSubdomain(host1, host2 string) bool {
|
|
return web_proxy.IsSameDomainOrSubdomain(host1, host2)
|
|
}
|
|
|
|
// buildTargetURLWithHost builds target URL with specific host
|
|
func (c *WebProxyController) buildTargetURLWithHost(asset *model.Asset, host string) string {
|
|
return web_proxy.BuildTargetURLWithHost(asset, host)
|
|
}
|
|
|
|
// processHTMLResponse processes HTML response for content rewriting and injection
|
|
func (c *WebProxyController) processHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost string, session *WebProxySession) {
|
|
web_proxy.ProcessHTMLResponse(resp, assetID, scheme, proxyHost, session)
|
|
}
|
|
|
|
// checkWebAccessControls validates web-specific access controls
|
|
func (c *WebProxyController) checkWebAccessControls(ctx *gin.Context, session *WebProxySession) error {
|
|
return web_proxy.CheckWebAccessControls(ctx, session)
|
|
}
|
|
|
|
// getActiveSessionsForAsset returns detailed info about active sessions for an asset
|
|
func (c *WebProxyController) getActiveSessionsForAsset(assetId int) []map[string]interface{} {
|
|
sessions := make([]map[string]interface{}, 0)
|
|
for sessionId, session := range web_proxy.GetAllSessions() {
|
|
if session.AssetId == assetId {
|
|
sessions = append(sessions, map[string]interface{}{
|
|
"session_id": sessionId,
|
|
"asset_id": session.AssetId,
|
|
"account_id": session.AccountId,
|
|
"created_at": session.CreatedAt,
|
|
"last_activity": session.LastActivity,
|
|
"current_host": session.CurrentHost,
|
|
})
|
|
}
|
|
}
|
|
return sessions
|
|
}
|
|
|
|
// CloseWebSession closes an active web session
|
|
// @Summary Close web session
|
|
// @Description Close an active web session and clean up resources
|
|
// @Tags WebProxy
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body map[string]string true "Session close request" example({"session_id": "web_123_456_1640000000"})
|
|
// @Success 200 {object} map[string]string "Session closed successfully"
|
|
// @Failure 400 {object} map[string]interface{} "Invalid request"
|
|
// @Failure 404 {object} map[string]interface{} "Session not found"
|
|
// @Router /web_proxy/close [post]
|
|
func (c *WebProxyController) CloseWebSession(ctx *gin.Context) {
|
|
var req struct {
|
|
SessionID string `json:"session_id" binding:"required"`
|
|
}
|
|
|
|
if err := ctx.ShouldBindBodyWithJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
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
|
|
func (c *WebProxyController) extractAssetIDFromHost(host string) (int, error) {
|
|
return web_proxy.ExtractAssetIDFromHost(host)
|
|
}
|