diff --git a/backend/internal/api/controller/node.go b/backend/internal/api/controller/node.go index e00eb3b..8c19eab 100644 --- a/backend/internal/api/controller/node.go +++ b/backend/internal/api/controller/node.go @@ -96,8 +96,10 @@ func (c *Controller) GetNodes(ctx *gin.Context) { db = db.Select("id", "parent_id", "name", "authorization") } + db = db.Order("id ASC") + if recursive { - treeNodes, err := nodeService.GetNodesTree(ctx, db, !info, config.RESOURCE_NODE) + treeNodes, err := nodeService.GetNodesTree(ctx, db, false, config.RESOURCE_NODE) if err != nil { ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}}) return @@ -110,7 +112,7 @@ func (c *Controller) GetNodes(ctx *gin.Context) { ctx.JSON(http.StatusOK, NewHttpResponseWithData(res)) } else { - doGet(ctx, !info, db, config.RESOURCE_NODE, nodePostHooks...) + doGet(ctx, false, db, config.RESOURCE_NODE, nodePostHooks...) } } diff --git a/backend/internal/api/controller/web_proxy.go b/backend/internal/api/controller/web_proxy.go index 88e66ea..a616846 100644 --- a/backend/internal/api/controller/web_proxy.go +++ b/backend/internal/api/controller/web_proxy.go @@ -1,18 +1,13 @@ 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" @@ -25,11 +20,9 @@ func NewWebProxyController() *WebProxyController { return &WebProxyController{} } -// 使用service层的结构体 type StartWebSessionRequest = web_proxy.StartWebSessionRequest type StartWebSessionResponse = web_proxy.StartWebSessionResponse -// 使用service层的全局变量和结构体 type WebProxySession = web_proxy.WebProxySession func StartSessionCleanupRoutine() { @@ -47,12 +40,8 @@ func (c *WebProxyController) renderSessionExpiredPage(ctx *gin.Context, reason s // @Summary Get web asset configuration // @Description Get web asset configuration by asset ID // @Tags WebProxy -// @Accept json -// @Produce json // @Param asset_id path int true "Asset ID" // @Success 200 {object} model.WebConfig -// @Failure 400 {object} map[string]interface{} "Invalid asset ID" -// @Failure 404 {object} map[string]interface{} "Asset not found" // @Router /web_proxy/config/{asset_id} [get] func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) { assetIdStr := ctx.Param("asset_id") @@ -81,15 +70,8 @@ func (c *WebProxyController) GetWebAssetConfig(ctx *gin.Context) { // @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 @@ -122,240 +104,60 @@ func (c *WebProxyController) StartWebSession(ctx *gin.Context) { // @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) + // Extract session ID and asset ID from request + proxyCtx, err := web_proxy.ExtractSessionAndAssetInfo(ctx, c.extractAssetIDFromHost) 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") - } - } - } - } - } + logger.L().Error("Failed to extract session/asset info", zap.Error(err)) + if strings.Contains(err.Error(), "invalid subdomain format") { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subdomain format"}) + } else { + c.renderSessionExpiredPage(ctx, err.Error()) } + return } - // 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 - } - } + // Validate session and check permissions + if err := web_proxy.ValidateSessionAndPermissions(ctx, proxyCtx, c.checkWebAccessControls); err != nil { + if strings.Contains(err.Error(), "invalid or expired session") || strings.Contains(err.Error(), "session expired") { + c.renderSessionExpiredPage(ctx, err.Error()) + } else { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) } - } - - 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) + // Setup reverse proxy + proxy, err := web_proxy.SetupReverseProxy(ctx, proxyCtx, c.buildTargetURLWithHost, c.processHTMLResponse, c.recordWebActivity, c.isSameDomainOrSubdomain) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid target URL"}) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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") + // Add panic recovery for proxy requests + defer func() { + if r := recover(); r != nil { + logger.L().Error("Proxy request panic recovered", + zap.String("url", ctx.Request.URL.String()), + zap.String("host", ctx.Request.Host), + zap.Any("panic", r)) + + // Return appropriate error response instead of crashing + if !ctx.Writer.Written() { + ctx.JSON(http.StatusBadGateway, gin.H{ + "error": "Proxy request failed", + "details": "The target server is not responding properly", + }) + } + } + }() + proxy.ServeHTTP(ctx.Writer, ctx.Request) } @@ -363,8 +165,6 @@ func (c *WebProxyController) ProxyWebRequest(ctx *gin.Context) { // @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] @@ -421,12 +221,8 @@ func (c *WebProxyController) getActiveSessionsForAsset(assetId int) []map[string // @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 { @@ -446,11 +242,8 @@ func (c *WebProxyController) CloseWebSession(ctx *gin.Context) { // @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") @@ -468,12 +261,8 @@ func (c *WebProxyController) GetActiveWebSessions(ctx *gin.Context) { // @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 { @@ -486,8 +275,10 @@ func (c *WebProxyController) UpdateWebSessionHeartbeat(ctx *gin.Context) { } if session, exists := web_proxy.GetSession(req.SessionId); exists { - session.LastActivity = time.Now() - ctx.JSON(http.StatusOK, gin.H{"status": "updated"}) + // Update heartbeat - this extends session life and indicates user is still viewing + web_proxy.UpdateSessionHeartbeat(req.SessionId) + _ = session // Use the session variable to avoid unused warning + ctx.JSON(http.StatusOK, gin.H{"status": "alive"}) } else { ctx.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) } @@ -497,8 +288,6 @@ func (c *WebProxyController) UpdateWebSessionHeartbeat(ctx *gin.Context) { // @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] diff --git a/backend/internal/api/router/router.go b/backend/internal/api/router/router.go index 1855209..3aede81 100644 --- a/backend/internal/api/router/router.go +++ b/backend/internal/api/router/router.go @@ -27,6 +27,12 @@ func SetupRouter(r *gin.Engine) { // Check if this is an asset subdomain request if strings.HasPrefix(host, "asset-") { + // Allow API requests to pass through to normal routing + if strings.HasPrefix(c.Request.URL.Path, "/api/oneterm/v1/") { + c.Next() + return + } + // Handle external redirect requests if c.Request.URL.Path == "/external" { webProxy.HandleExternalRedirect(c) @@ -259,8 +265,13 @@ func SetupRouter(r *gin.Engine) { 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) + } + + // Web proxy routes that don't require auth (heartbeat, cleanup) + webProxyNoAuth := v1AuthAbandoned.Group("/web_proxy") + { + webProxyNoAuth.POST("/heartbeat", webProxy.UpdateWebSessionHeartbeat) + webProxyNoAuth.POST("/cleanup", webProxy.CleanupWebSession) } } } diff --git a/backend/internal/service/authorization_matcher.go b/backend/internal/service/authorization_matcher.go index 2eec16f..70b1f27 100644 --- a/backend/internal/service/authorization_matcher.go +++ b/backend/internal/service/authorization_matcher.go @@ -163,6 +163,10 @@ func (m *AuthorizationMatcher) matchSelector(ctx context.Context, selector model } switch selector.Type { + case "": + // Empty selector type means no restriction - skip this selector check + return true + case model.SelectorTypeAll: return true diff --git a/backend/internal/service/node.go b/backend/internal/service/node.go index ca0e053..a660ca8 100644 --- a/backend/internal/service/node.go +++ b/backend/internal/service/node.go @@ -210,24 +210,18 @@ func (s *NodeService) AttachAssetCount(ctx *gin.Context, data []*model.Node) err db := dbpkg.DB.Model(model.DefaultAsset) if !acl.IsAdmin(currentUser) { - info := cast.ToBool(ctx.Query("info")) - if info { - // Use V2 authorization system for asset filtering - authV2Service := NewAuthorizationV2Service() - _, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx) - if err != nil { - return err - } - db = db.Where("id IN ?", assetIds) + // Always use V2 authorization system for consistent permission control + authV2Service := NewAuthorizationV2Service() + _, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx) + if err != nil { + return err + } + + if len(assetIds) == 0 { + // No access to any assets + db = db.Where("1 = 0") } else { - assetResId, err := acl.GetRoleResourceIds(ctx, currentUser.GetRid(), config.RESOURCE_ASSET) - if err != nil { - return err - } - db, err = s.handleAssetIds(ctx, db, assetResId) - if err != nil { - return err - } + db = db.Where("id IN ?", assetIds) } } diff --git a/backend/internal/service/web_proxy/auth.go b/backend/internal/service/web_proxy/auth.go index adafcaa..fe0efcb 100644 --- a/backend/internal/service/web_proxy/auth.go +++ b/backend/internal/service/web_proxy/auth.go @@ -177,7 +177,6 @@ func (s *AuthService) AuthenticateWithRetry(ctx context.Context, accounts []Cred var lastError error var lastResult *AuthResult - // 尝试每个账号,直到成功 for i, credentials := range accounts { logger.L().Info("Attempting authentication", zap.String("strategy", strategy.Name()), @@ -207,7 +206,6 @@ func (s *AuthService) AuthenticateWithRetry(ctx context.Context, accounts []Cred zap.String("reason", result.Message)) } - // 所有账号都失败了 if lastError != nil { return nil, fmt.Errorf("all authentication attempts failed, last error: %w", lastError) } diff --git a/backend/internal/service/web_proxy/content.go b/backend/internal/service/web_proxy/content.go index 60bdc4f..906df21 100644 --- a/backend/internal/service/web_proxy/content.go +++ b/backend/internal/service/web_proxy/content.go @@ -66,6 +66,14 @@ func RewriteHTMLContent(resp *http.Response, assetID int, scheme, proxyHost stri return fmt.Sprintf(`%s%s://asset-%d.%s%s"`, matches[1], scheme, assetID, baseDomain, path) }, }, + // Static resources: `, session.SessionId) - if strings.Contains(content, "") { - content = strings.Replace(content, "", watermarkCSS+"", 1) - } else { - content = watermarkCSS + content - } + // Add JavaScript URL interceptor for dynamic requests (always inject - moved outside watermark condition) - if strings.Contains(content, "") { - content = strings.Replace(content, "", watermarkHTML+sessionJS+"", 1) - } else { - content = content + watermarkHTML + sessionJS + urlInterceptorJS := fmt.Sprintf(` + `, session.CurrentHost, assetID, baseDomain, scheme, session.Permissions.FileDownload) + + // Always inject session management and URL interceptor + + if strings.Contains(content, "") { + content = strings.Replace(content, "", sessionJS+urlInterceptorJS+"", 1) + } else { + content = content + sessionJS + urlInterceptorJS } // Step 3: Record activity if enabled @@ -284,7 +457,7 @@ func RenderExternalRedirectPage(targetURL string) string {