package controller import ( "bytes" "fmt" "io" "net/http" "net/http/httputil" "net/url" "regexp" "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/pkg/logger" ) type WebProxyController struct{} func NewWebProxyController() *WebProxyController { return &WebProxyController{} } type StartWebSessionRequest struct { AssetId int `json:"asset_id" binding:"required"` AssetName string `json:"asset_name"` AuthMode string `json:"auth_mode"` AccountId int `json:"account_id"` } type StartWebSessionResponse struct { SessionId string `json:"session_id"` ProxyURL string `json:"proxy_url"` Message string `json:"message"` } var webProxySessions = make(map[string]*WebProxySession) type WebProxySession struct { SessionId string AssetId int Asset *model.Asset CreatedAt time.Time LastActivity time.Time CurrentHost string } func cleanupExpiredSessions(maxInactiveTime time.Duration) { now := time.Now() for sessionID, session := range webProxySessions { if now.Sub(session.LastActivity) > maxInactiveTime { delete(webProxySessions, sessionID) logger.L().Info("Cleaned up expired web session", zap.String("sessionID", sessionID)) } } } func StartSessionCleanupRoutine() { ticker := time.NewTicker(10 * time.Minute) go func() { for range ticker.C { cleanupExpiredSessions(8 * time.Hour) } }() } func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason string) { html := fmt.Sprintf(` Session Expired - OneTerm
Session Expired
Your web proxy session has expired and you need to reconnect.
Reason: %s
← Go Back
`, 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 // @Param asset_id path int true "Asset ID" // @Success 200 {object} model.WebConfig // @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 StartWebSessionRequest true "Start session request" // @Success 200 {object} StartWebSessionResponse // @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 } assetService := service.NewAssetService() asset, err := assetService.GetById(ctx, req.AssetId) if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "Asset not found"}) return } // Check if asset is web asset if !asset.IsWebAsset() { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Asset is not a web asset"}) return } // Auto-detect auth_mode from asset.WebConfig if not provided authMode := req.AuthMode if authMode == "" && asset.WebConfig != nil { authMode = asset.WebConfig.AuthMode if authMode == "" { authMode = "none" // default } } // Generate unique session ID sessionId := fmt.Sprintf("web_%d_%d_%d", req.AssetId, req.AccountId, time.Now().Unix()) // Create and store web proxy session now := time.Now() // Get initial target host from asset initialHost := c.getAssetHost(asset) webSession := &WebProxySession{ SessionId: sessionId, AssetId: asset.Id, Asset: asset, CreatedAt: now, LastActivity: now, CurrentHost: initialHost, } webProxySessions[sessionId] = webSession // Generate subdomain-based proxy URL scheme := "https" if ctx.Request.TLS == nil { scheme = "http" } // Extract base domain and port from current host currentHost := ctx.Request.Host var baseDomain string var portSuffix string if strings.Contains(currentHost, ":") { hostParts := strings.Split(currentHost, ":") baseDomain = hostParts[0] port := hostParts[1] // Keep port unless it's default isDefaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443") if !isDefaultPort { portSuffix = ":" + port } } else { baseDomain = currentHost } // Create subdomain URL with session_id for first access (cookie will handle subsequent requests) subdomainHost := fmt.Sprintf("asset-%d.%s%s", req.AssetId, baseDomain, portSuffix) proxyURL := fmt.Sprintf("%s://%s/?session_id=%s", scheme, subdomainHost, sessionId) ctx.JSON(http.StatusOK, StartWebSessionResponse{ SessionId: sessionId, ProxyURL: proxyURL, Message: "Web session started successfully", }) } // ProxyWebRequest handles subdomain-based web proxy requests // Extract asset ID from Host header like: asset-123.oneterm.com 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 for sid, session := range webProxySessions { 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 } // Get session from simple session store 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 and auto-renew cookie webSession.LastActivity = now ctx.SetCookie("oneterm_session_id", sessionID, 8*3600, "/", "", false, true) // Verify asset ID matches session if webSession.AssetId != assetID { ctx.JSON(http.StatusForbidden, gin.H{"error": "Asset ID mismatch"}) return } // Build target URL using current host (may have been updated by redirects) targetURL := c.buildTargetURLWithHost(webSession.Asset, webSession.CurrentHost) target, err := url.Parse(targetURL) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid target URL"}) return } // Set session_id cookie with smart expiration management // Use a longer cookie duration (8 hours) but validate session on each request cookieMaxAge := 8 * 3600 // 8 hours ctx.SetCookie("oneterm_session_id", sessionID, cookieMaxAge, "/", "", false, true) // Determine current request scheme for redirect rewriting currentScheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http") // Create transparent reverse proxy 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.rewriteHTMLContent(resp, assetID, currentScheme, host) } 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) { webSession.CurrentHost = 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 handles redirects to external domains through proxy func (c *WebProxyController) HandleExternalRedirect(ctx *gin.Context) { targetURL := ctx.Query("url") // Get session_id from cookie instead of URL parameter sessionID, err := ctx.Cookie("oneterm_session_id") if err != nil { ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Session required"}) return } if targetURL == "" || sessionID == "" { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing required parameters"}) return } // Validate session webSession, exists := webProxySessions[sessionID] if !exists { c.renderSessionExpiredPage(ctx, "Session not found") return } // Check session timeout (8 hours of inactivity) now := time.Now() maxInactiveTime := 8 * time.Hour if now.Sub(webSession.LastActivity) > maxInactiveTime { // Remove expired session delete(webProxySessions, sessionID) c.renderSessionExpiredPage(ctx, "Session expired due to inactivity") return } // Update last activity time webSession.LastActivity = now // Return a simple page explaining the redirect was blocked html := fmt.Sprintf(` External Redirect Blocked

🛡️ External Redirect Blocked

The target website attempted to redirect you to an external domain, which has been blocked by the bastion host for security reasons.
Target URL:
%s
All web access must go through the bastion host to maintain security and audit compliance. External redirects are not permitted.
← Go Back
`, targetURL) ctx.Header("Content-Type", "text/html; charset=utf-8") ctx.String(http.StatusOK, html) } // isSameDomainOrSubdomain checks if two hosts belong to the same domain or subdomain // Examples: // - baidu.com & www.baidu.com → true (subdomain) // - baidu.com & m.baidu.com → true (subdomain) // - baidu.com & google.com → false (different domain) // - sub.example.com & other.example.com → true (same domain) func (c *WebProxyController) isSameDomainOrSubdomain(host1, host2 string) bool { if host1 == host2 { return true } // Remove port if present host1 = strings.Split(host1, ":")[0] host2 = strings.Split(host2, ":")[0] // Get domain parts parts1 := strings.Split(host1, ".") parts2 := strings.Split(host2, ".") // Need at least domain.tld (2 parts) if len(parts1) < 2 || len(parts2) < 2 { return false } // Compare the last two parts (domain.tld) domain1 := strings.Join(parts1[len(parts1)-2:], ".") domain2 := strings.Join(parts2[len(parts2)-2:], ".") return domain1 == domain2 } // getAssetHost extracts the host from asset configuration func (c *WebProxyController) getAssetHost(asset *model.Asset) string { targetURL := c.buildTargetURL(asset) if u, err := url.Parse(targetURL); err == nil { return u.Host } return "localhost" // fallback } // buildTargetURLWithHost builds target URL with specific host func (c *WebProxyController) buildTargetURLWithHost(asset *model.Asset, host string) string { protocol, port := asset.GetWebProtocol() if protocol == "" { protocol = "http" port = 80 } // Use custom host instead of asset's original host if port == 80 && protocol == "http" || port == 443 && protocol == "https" { return fmt.Sprintf("%s://%s", protocol, host) } return fmt.Sprintf("%s://%s:%d", protocol, host, port) } // buildTargetURL builds the target URL from asset information func (c *WebProxyController) buildTargetURL(asset *model.Asset) string { protocol, port := asset.GetWebProtocol() if protocol == "" { protocol = "http" port = 80 } // If port is default port for protocol, don't include it if (protocol == "http" && port == 80) || (protocol == "https" && port == 443) { return fmt.Sprintf("%s://%s", protocol, asset.Ip) } return fmt.Sprintf("%s://%s:%d", protocol, asset.Ip, port) } // rewriteHTMLContent rewrites HTML content to redirect external links through proxy func (c *WebProxyController) rewriteHTMLContent(resp *http.Response, assetID int, scheme, proxyHost string) { if resp.Body == nil { return } 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:
3 && matches[3] != "", matches[3], "") return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path) }, }, // Link hrefs: 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))) } // extractAssetIDFromHost extracts asset ID from subdomain host // Examples: asset-123.oneterm.com -> 123, asset-456.localhost:8080 -> 456 func (c *WebProxyController) extractAssetIDFromHost(host string) (int, error) { // Remove port if present hostParts := strings.Split(host, ":") hostname := hostParts[0] // Check for asset- prefix if !strings.HasPrefix(hostname, "asset-") { return 0, fmt.Errorf("host does not start with asset- prefix: %s", hostname) } // Extract asset ID: asset-123.domain.com -> 123 parts := strings.Split(hostname, ".") if len(parts) == 0 { return 0, fmt.Errorf("invalid hostname format: %s", hostname) } assetPart := parts[0] // asset-123 assetIDStr := strings.TrimPrefix(assetPart, "asset-") if assetIDStr == assetPart { return 0, fmt.Errorf("failed to extract asset ID from: %s", assetPart) } assetID, err := strconv.Atoi(assetIDStr) if err != nil { return 0, fmt.Errorf("invalid asset ID format: %s", assetIDStr) } return assetID, nil }