Files
oneterm/backend/internal/service/web_proxy/service.go

689 lines
22 KiB
Go

package web_proxy
import (
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"go.uber.org/zap"
"github.com/veops/oneterm/internal/acl"
"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
},
}
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 {
logger.L().Warn("Maximum concurrent connections exceeded",
zap.Int("assetID", req.AssetId),
zap.Int("activeCount", activeCount),
zap.Int("maxConcurrent", 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,
LastHeartbeat: now, // Initialize heartbeat timestamp
IsActive: true, // Initially active
CurrentHost: initialHost,
Permissions: permissions,
WebConfig: asset.WebConfig,
}
StoreSession(sessionId, webSession)
// Generate fixed webproxy subdomain URL
// Use the complete domain for webproxy subdomain
baseDomain := strings.Split(ctx.Request.Host, ":")[0]
scheme := lo.Ternary(ctx.GetHeader("X-Forwarded-Proto") == "https", "https", "http")
portSuffix := ""
if strings.Contains(ctx.Request.Host, ":") {
portSuffix = ":" + strings.Split(ctx.Request.Host, ":")[1]
}
// Create fixed webproxy URL with asset_id and session_id for first access
webproxyHost := fmt.Sprintf("webproxy.%s%s", baseDomain, portSuffix)
proxyURL := fmt.Sprintf("%s://%s/?asset_id=%d&session_id=%s", scheme, webproxyHost, req.AssetId, sessionId)
// Create database session record for history (same as other protocols)
currentUser, _ := acl.GetSessionFromCtx(ctx)
// Get actual protocol from asset
protocol, port := asset.GetWebProtocol()
if protocol == "" {
protocol = "http"
port = 80
}
// Format protocol as "protocol:port"
protocolStr := fmt.Sprintf("%s:%d", protocol, port)
dbSession := &model.Session{
SessionType: model.SESSIONTYPE_WEB,
SessionId: sessionId,
Uid: currentUser.GetUid(),
UserName: currentUser.GetUserName(),
AssetId: asset.Id,
AssetInfo: fmt.Sprintf("%s(%s)", asset.Name, asset.Ip),
AccountId: req.AccountId,
AccountInfo: "", // Web assets don't have named accounts
GatewayId: asset.GatewayId,
GatewayInfo: "",
ClientIp: ctx.ClientIP(),
Protocol: protocolStr, // Now shows "http:80" or "https:443" etc.
Status: model.SESSIONSTATUS_ONLINE,
CreatedAt: now,
UpdatedAt: now,
}
// Set gateway info if exists
if asset.GatewayId > 0 {
dbSession.GatewayInfo = fmt.Sprintf("Gateway_%d", asset.GatewayId)
}
// Save session to database using gsession
fullSession := &gsession.Session{Session: dbSession}
if err := gsession.UpsertSession(fullSession); err != nil {
logger.L().Error("Failed to save web session to database",
zap.String("sessionId", sessionId), zap.Error(err))
// Don't fail the request, just log the error
}
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
}
// Check if asset.Ip already includes port (case 1: 127.0.0.1:8000)
if strings.Contains(asset.Ip, ":") {
// IP already has port, use as-is
return fmt.Sprintf("%s://%s", protocol, asset.Ip)
}
// Case 2: IP without port (127.0.0.1), use port from protocol
// 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
}
// Check if host already includes port
if strings.Contains(host, ":") {
// Host already has port, use as-is
return fmt.Sprintf("%s://%s", protocol, host)
}
// 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 query parameter (fixed webproxy subdomain)
func ExtractAssetIDFromHost(host string) (int, error) {
// This is now handled by ExtractAssetIDFromRequest in the controller
// but kept for interface compatibility
return 0, fmt.Errorf("asset ID should be extracted from query parameter in fixed subdomain approach")
}
// 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 in URL path
path := ctx.Request.URL.Path
downloadExts := []string{".pdf", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".rar", ".tar", ".gz", ".csv", ".txt"}
for _, ext := range downloadExts {
if strings.HasSuffix(strings.ToLower(path), ext) {
return true
}
}
// Check query parameters that indicate download intent
if ctx.Query("download") != "" || ctx.Query("export") != "" || ctx.Query("attachment") != "" {
return true
}
// Check Accept header for file download types
accept := ctx.GetHeader("Accept")
downloadMimeTypes := []string{
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/pdf",
"application/zip",
"application/octet-stream",
}
for _, mimeType := range downloadMimeTypes {
if strings.Contains(accept, mimeType) {
return true
}
}
return false
}
// 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
}
// ProxyRequestContext holds the context for a proxy request
type ProxyRequestContext struct {
SessionID string
AssetID int
Session *WebProxySession
Host string
IsStaticResource bool
}
// ExtractSessionAndAssetInfo extracts session ID and asset ID from the request
func ExtractSessionAndAssetInfo(ctx *gin.Context, extractAssetIDFromHost func(string) (int, error)) (*ProxyRequestContext, error) {
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
}
}
// Try to get asset_id from existing session first
var assetID int
var err error
if sessionID != "" {
if session, exists := GetSession(sessionID); exists {
assetID = session.AssetId
}
}
// If no session or no asset_id from session, get from query parameter
if assetID == 0 {
assetIDStr := ctx.Query("asset_id")
if assetIDStr == "" {
return nil, fmt.Errorf("asset_id parameter required")
}
assetID, err = strconv.Atoi(assetIDStr)
if err != nil {
return nil, fmt.Errorf("invalid asset_id format")
}
}
// 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")
}
}
}
}
// 3. 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")
}
}
}
}
}
}
}
// 4. 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 := GetAllSessions()
for sid, session := range allSessions {
if session.AssetId == assetID {
sessionID = sid
break
}
}
}
}
if sessionID == "" {
return nil, fmt.Errorf("session ID required - please start a new web session")
}
// Determine if this is a static resource request
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") ||
strings.HasSuffix(ctx.Request.URL.Path, ".woff") ||
strings.HasSuffix(ctx.Request.URL.Path, ".woff2") ||
strings.HasSuffix(ctx.Request.URL.Path, ".ttf") ||
strings.HasSuffix(ctx.Request.URL.Path, ".svg")
return &ProxyRequestContext{
SessionID: sessionID,
AssetID: assetID,
Host: host,
IsStaticResource: isStaticResource,
}, nil
}
// ValidateSessionAndPermissions validates the session and checks permissions
func ValidateSessionAndPermissions(ctx *gin.Context, proxyCtx *ProxyRequestContext, checkWebAccessControls func(*gin.Context, *WebProxySession) error) error {
// Validate session ID and get session information
session, exists := GetSession(proxyCtx.SessionID)
if !exists {
return fmt.Errorf("invalid or expired session")
}
// 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 {
CloseWebSession(proxyCtx.SessionID)
return fmt.Errorf("session expired due to inactivity")
}
// Only update LastActivity for real user operations (not static resources)
if !proxyCtx.IsStaticResource {
UpdateSessionActivity(proxyCtx.SessionID)
// Auto-renew cookie for user operations
cookieMaxAge := int(model.GlobalConfig.Load().Timeout)
// Set cookie domain for webproxy subdomain
cookieDomain := ""
if strings.HasPrefix(ctx.Request.Host, "webproxy.") {
parts := strings.SplitN(ctx.Request.Host, ".", 2)
if len(parts) > 1 {
cookieDomain = "." + parts[1] // .domain.com
}
}
ctx.SetCookie("oneterm_session_id", proxyCtx.SessionID, cookieMaxAge, "/", cookieDomain, false, true)
}
// Check Web-specific access controls
if err := checkWebAccessControls(ctx, session); err != nil {
return err
}
if session.AssetId != proxyCtx.AssetID {
return fmt.Errorf("asset ID mismatch")
}
// Store session in context
proxyCtx.Session = session
return nil
}
// SetupReverseProxy creates and configures a reverse proxy
func SetupReverseProxy(ctx *gin.Context, proxyCtx *ProxyRequestContext, buildTargetURLWithHost func(*model.Asset, string) string, processHTMLResponse func(*http.Response, int, string, string, *WebProxySession), recordWebActivity func(*WebProxySession, *http.Request), isSameDomainOrSubdomain func(string, string) bool) (*httputil.ReverseProxy, error) {
targetURL := buildTargetURLWithHost(proxyCtx.Session.Asset, proxyCtx.Session.CurrentHost)
target, err := url.Parse(targetURL)
if err != nil {
return nil, fmt.Errorf("invalid target URL")
}
// Determine scheme with multiple fallback methods
currentScheme := "http"
if ctx.GetHeader("X-Forwarded-Proto") == "https" ||
ctx.GetHeader("X-Forwarded-Ssl") == "on" ||
ctx.GetHeader("X-Url-Scheme") == "https" ||
ctx.Request.TLS != nil {
currentScheme = "https"
}
// 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())
}
}
// Remove session_id from query parameters without re-encoding
if req.URL.RawQuery != "" {
q := req.URL.Query()
if q.Has("session_id") {
q.Del("session_id")
req.URL.RawQuery = q.Encode()
}
// Keep original RawQuery if no session_id to remove
}
}
// Redirect interception for bastion control
proxy.ModifyResponse = func(resp *http.Response) error {
// Check file download permissions based on response headers
contentDisposition := resp.Header.Get("Content-Disposition")
contentType := resp.Header.Get("Content-Type")
// Check if this is a file download response
isDownload := strings.Contains(contentDisposition, "attachment") ||
strings.Contains(contentType, "application/octet-stream") ||
strings.Contains(contentType, "application/vnd.ms-excel") ||
strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") ||
strings.Contains(contentType, "application/pdf") ||
strings.Contains(contentType, "application/zip")
if isDownload && proxyCtx.Session.Permissions != nil && !proxyCtx.Session.Permissions.FileDownload {
// Replace the response with a 403 error page
resp.StatusCode = http.StatusForbidden
resp.Status = "403 Forbidden"
resp.Header.Set("Content-Type", "text/html; charset=utf-8")
resp.Header.Del("Content-Disposition")
errorPage := RenderAccessDeniedPage(
"File download not permitted",
"Your user permissions do not allow file downloads through the web proxy.")
resp.Body = io.NopCloser(strings.NewReader(errorPage))
resp.ContentLength = int64(len(errorPage))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(errorPage)))
return nil
}
// Process HTML content for injection
if resp.StatusCode == 200 && strings.Contains(contentType, "text/html") {
processHTMLResponse(resp, proxyCtx.AssetID, currentScheme, proxyCtx.Host, proxyCtx.Session)
}
// Record activity if enabled
if proxyCtx.Session.WebConfig != nil && proxyCtx.Session.WebConfig.ProxySettings != nil && proxyCtx.Session.WebConfig.ProxySettings.RecordingEnabled {
recordWebActivity(proxyCtx.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(proxyCtx.Host, "webproxy."),
func() string {
parts := strings.SplitN(proxyCtx.Host, ".", 2)
return lo.Ternary(len(parts) > 1, parts[1], proxyCtx.Host)
}(),
proxyCtx.Host)
if isSameDomainOrSubdomain(target.Host, redirectURL.Host) {
UpdateSessionHost(proxyCtx.SessionID, redirectURL.Host)
newProxyURL := fmt.Sprintf("%s://webproxy.%s%s", currentScheme, baseDomain, redirectURL.Path)
if redirectURL.RawQuery != "" {
newProxyURL += "?" + redirectURL.RawQuery
}
resp.Header.Set("Location", newProxyURL)
} else {
newLocation := fmt.Sprintf("%s://webproxy.%s/external?url=%s",
currentScheme, 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(proxyCtx.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
}
return proxy, nil
}