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, "