feat(backend): web proxy

This commit is contained in:
pycook
2025-07-30 14:22:43 +08:00
parent 45df5761c8
commit 03d1483f9f
15 changed files with 2059 additions and 44 deletions

View File

@@ -1,6 +1,8 @@
module github.com/veops/oneterm
go 1.21.3
go 1.23.0
toolchain go1.24.1
require (
github.com/Azure/azure-storage-blob-go v0.15.0
@@ -37,9 +39,9 @@ require (
github.com/swaggo/swag v1.16.3
github.com/tencentyun/cos-go-sdk-v5 v0.7.55
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.26.0
golang.org/x/sync v0.8.0
golang.org/x/text v0.17.0
golang.org/x/crypto v0.37.0
golang.org/x/sync v0.13.0
golang.org/x/text v0.24.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
@@ -51,6 +53,8 @@ require github.com/stretchr/testify v1.9.0
require (
github.com/Azure/azure-pipeline-go v0.2.3 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.1.4 // indirect
@@ -133,8 +137,8 @@ require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

View File

@@ -17,6 +17,8 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
@@ -24,6 +26,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -312,6 +316,9 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -337,6 +344,9 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -345,6 +355,9 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -366,6 +379,9 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -378,6 +394,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -392,6 +410,9 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"github.com/samber/lo"
"github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/service"
"github.com/veops/oneterm/pkg/config"
@@ -41,6 +42,14 @@ var (
return
}
},
// Filter web_config based on write permissions
func(ctx *gin.Context, data []*model.Asset) {
for _, asset := range data {
if asset.Permissions == nil || !lo.Contains(asset.Permissions, acl.WRITE) {
asset.WebConfig = nil
}
}
},
}
)
@@ -115,7 +124,7 @@ func (c *Controller) GetAssets(ctx *gin.Context) {
// Apply info mode settings
if info {
db = db.Select("id", "parent_id", "name", "ip", "protocols", "connectable", "authorization", "resource_id", "access_time_control", "asset_command_control")
db = db.Select("id", "parent_id", "name", "ip", "protocols", "connectable", "authorization", "resource_id", "access_time_control", "asset_command_control", "web_config")
}
doGet(ctx, false, db, config.RESOURCE_ASSET, assetPostHooks...)

View File

@@ -137,11 +137,6 @@ func (c *Controller) GetAuthorizations(ctx *gin.Context) {
})
}
func handleAuthorization(ctx *gin.Context, tx *gorm.DB, action int, asset *model.Asset, auths ...*model.Authorization) (err error) {
// Use service layer instead of direct data processing
return service.DefaultAuthService.HandleAuthorization(ctx, tx, action, asset, auths...)
}
func getIdsByAuthorizationIds(ctx *gin.Context) (nodeIds, assetIds, accountIds []int) {
authorizationIds, ok := ctx.Value(kAuthorizationIds).([]*model.AuthorizationIds)
if !ok || len(authorizationIds) == 0 {

View File

@@ -372,6 +372,10 @@ func doGet[T any](ctx *gin.Context, needAcl bool, dbFind *gorm.DB, resourceType
return
}
if err = handlePermissions(ctx, list, resourceType); err != nil {
return
}
for _, hook := range postHooks {
if hook == nil {
continue
@@ -382,10 +386,6 @@ func doGet[T any](ctx *gin.Context, needAcl bool, dbFind *gorm.DB, resourceType
}
}
if err = handlePermissions(ctx, list, resourceType); err != nil {
return
}
res := &ListData{
Count: count,
List: lo.Map(list, func(t T, _ int) any { return t }),

View File

@@ -0,0 +1,752 @@
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(`<!DOCTYPE html>
<html>
<head>
<title>Session Expired - OneTerm</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
min-height: 100vh;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
text-align: center;
width: 100%%;
max-width: 400px;
}
.icon { font-size: 4rem; margin-bottom: 20px; display: block; }
.title { color: #333; font-size: 1.5rem; font-weight: 600; margin-bottom: 16px; }
.message { color: #666; font-size: 1rem; line-height: 1.5; margin-bottom: 24px; }
.reason {
background: #f8f9fa;
border-left: 4px solid #ffa726;
padding: 12px 16px;
margin: 20px 0;
font-size: 0.9rem;
color: #555;
border-radius: 4px;
}
.button {
background: #667eea;
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.button:hover { background: #5a6fd8; transform: translateY(-1px); }
.footer { margin-top: 24px; font-size: 0.8rem; color: #999; }
</style>
</head>
<body>
<div class="container">
<span class="icon">⏰</span>
<div class="title">Session Expired</div>
<div class="message">Your web proxy session has expired and you need to reconnect.</div>
<div class="reason">Reason: %s</div>
<a href="javascript:history.back()" class="button">← Go Back</a>
<div class="footer">OneTerm Bastion Host</div>
</div>
</body>
</html>`, 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(`<!DOCTYPE html>
<html>
<head>
<title>External Redirect Blocked</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.blocked { color: #e74c3c; }
.info { color: #666; margin: 20px 0; }
.target {
background: #f8f9fa;
padding: 10px;
border-radius: 4px;
word-break: break-all;
font-family: monospace;
}
</style>
</head>
<body>
<div class="container">
<h1 class="blocked">🛡️ External Redirect Blocked</h1>
<div class="info">
The target website attempted to redirect you to an external domain,
which has been blocked by the bastion host for security reasons.
</div>
<div class="info"><strong>Target URL:</strong></div>
<div class="target">%s</div>
<div class="info">
All web access must go through the bastion host to maintain security
and audit compliance. External redirects are not permitted.
</div>
<div class="info">
<a href="javascript:history.back()">← Go Back</a>
</div>
</div>
</body>
</html>`, 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: <form action="http://example.com/path"
{
`(action\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)
},
},
// Link hrefs: <a href="http://example.com/path"
{
`(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)
},
},
}
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
}

View File

@@ -1006,6 +1006,12 @@ const docTemplate = `{
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "search by name or description",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "template category",
@@ -3988,6 +3994,12 @@ const docTemplate = `{
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "search by name or description",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "template category",
@@ -4193,6 +4205,66 @@ const docTemplate = `{
}
}
}
},
"/web_proxy/config/{asset_id}": {
"get": {
"description": "Get web asset configuration by asset ID",
"tags": [
"WebProxy"
],
"summary": "Get web asset configuration",
"parameters": [
{
"type": "integer",
"description": "Asset ID",
"name": "asset_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.WebConfig"
}
}
}
}
},
"/web_proxy/start": {
"post": {
"description": "Start a new web session for the specified asset",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
"summary": "Start web session",
"parameters": [
{
"description": "Start session request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/controller.StartWebSessionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/controller.StartWebSessionResponse"
}
}
}
}
}
},
"definitions": {
@@ -4318,6 +4390,40 @@ const docTemplate = `{
}
}
},
"controller.StartWebSessionRequest": {
"type": "object",
"required": [
"asset_id"
],
"properties": {
"account_id": {
"type": "integer"
},
"asset_id": {
"type": "integer"
},
"asset_name": {
"type": "string"
},
"auth_mode": {
"type": "string"
}
}
},
"controller.StartWebSessionResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"proxy_url": {
"type": "string"
},
"session_id": {
"type": "string"
}
}
},
"model.AccessAuth": {
"type": "object",
"properties": {
@@ -4481,6 +4587,10 @@ const docTemplate = `{
"items": {
"type": "integer"
}
},
"rule_id": {
"description": "V2 authorization rule ID for tracking",
"type": "integer"
}
}
},
@@ -4559,6 +4669,14 @@ const docTemplate = `{
},
"updater_id": {
"type": "integer"
},
"web_config": {
"description": "Web-specific configuration (only valid when protocols contain http/https)",
"allOf": [
{
"$ref": "#/definitions/model.WebConfig"
}
]
}
}
},
@@ -5091,15 +5209,6 @@ const docTemplate = `{
"type": "object",
"additionalProperties": true
},
"model.Map-int-model_Slice-int": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "integer"
}
}
},
"model.Map-string-any": {
"type": "object",
"additionalProperties": {}
@@ -5114,7 +5223,7 @@ const docTemplate = `{
"type": "integer"
},
"authorization": {
"$ref": "#/definitions/model.Map-int-model_Slice-int"
"$ref": "#/definitions/model.AuthorizationMap"
},
"children": {
"type": "array",
@@ -5742,6 +5851,83 @@ const docTemplate = `{
"type": "integer"
}
}
},
"model.WebConfig": {
"type": "object",
"properties": {
"access_policy": {
"description": "full_access, read_only",
"type": "string"
},
"auth_mode": {
"description": "none, smart, manual",
"type": "string"
},
"login_accounts": {
"description": "Web login credentials",
"type": "array",
"items": {
"$ref": "#/definitions/model.WebLoginAccount"
}
},
"proxy_settings": {
"description": "Proxy configuration",
"allOf": [
{
"$ref": "#/definitions/model.WebProxySettings"
}
]
}
}
},
"model.WebLoginAccount": {
"type": "object",
"properties": {
"is_default": {
"type": "boolean"
},
"password": {
"type": "string"
},
"status": {
"description": "active, inactive",
"type": "string"
},
"username": {
"type": "string"
}
}
},
"model.WebProxySettings": {
"type": "object",
"properties": {
"allowed_methods": {
"description": "Allowed HTTP methods",
"type": "array",
"items": {
"type": "string"
}
},
"blocked_paths": {
"description": "Blocked URL paths",
"type": "array",
"items": {
"type": "string"
}
},
"max_concurrent": {
"description": "Max concurrent connections",
"type": "integer"
},
"recording_enabled": {
"description": "Enable session recording",
"type": "boolean"
},
"watermark_enabled": {
"description": "Enable watermark",
"type": "boolean"
}
}
}
}
}`

View File

@@ -995,6 +995,12 @@
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "search by name or description",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "template category",
@@ -3977,6 +3983,12 @@
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "search by name or description",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "template category",
@@ -4182,6 +4194,66 @@
}
}
}
},
"/web_proxy/config/{asset_id}": {
"get": {
"description": "Get web asset configuration by asset ID",
"tags": [
"WebProxy"
],
"summary": "Get web asset configuration",
"parameters": [
{
"type": "integer",
"description": "Asset ID",
"name": "asset_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.WebConfig"
}
}
}
}
},
"/web_proxy/start": {
"post": {
"description": "Start a new web session for the specified asset",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"WebProxy"
],
"summary": "Start web session",
"parameters": [
{
"description": "Start session request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/controller.StartWebSessionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/controller.StartWebSessionResponse"
}
}
}
}
}
},
"definitions": {
@@ -4307,6 +4379,40 @@
}
}
},
"controller.StartWebSessionRequest": {
"type": "object",
"required": [
"asset_id"
],
"properties": {
"account_id": {
"type": "integer"
},
"asset_id": {
"type": "integer"
},
"asset_name": {
"type": "string"
},
"auth_mode": {
"type": "string"
}
}
},
"controller.StartWebSessionResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"proxy_url": {
"type": "string"
},
"session_id": {
"type": "string"
}
}
},
"model.AccessAuth": {
"type": "object",
"properties": {
@@ -4470,6 +4576,10 @@
"items": {
"type": "integer"
}
},
"rule_id": {
"description": "V2 authorization rule ID for tracking",
"type": "integer"
}
}
},
@@ -4548,6 +4658,14 @@
},
"updater_id": {
"type": "integer"
},
"web_config": {
"description": "Web-specific configuration (only valid when protocols contain http/https)",
"allOf": [
{
"$ref": "#/definitions/model.WebConfig"
}
]
}
}
},
@@ -5080,15 +5198,6 @@
"type": "object",
"additionalProperties": true
},
"model.Map-int-model_Slice-int": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "integer"
}
}
},
"model.Map-string-any": {
"type": "object",
"additionalProperties": {}
@@ -5103,7 +5212,7 @@
"type": "integer"
},
"authorization": {
"$ref": "#/definitions/model.Map-int-model_Slice-int"
"$ref": "#/definitions/model.AuthorizationMap"
},
"children": {
"type": "array",
@@ -5731,6 +5840,83 @@
"type": "integer"
}
}
},
"model.WebConfig": {
"type": "object",
"properties": {
"access_policy": {
"description": "full_access, read_only",
"type": "string"
},
"auth_mode": {
"description": "none, smart, manual",
"type": "string"
},
"login_accounts": {
"description": "Web login credentials",
"type": "array",
"items": {
"$ref": "#/definitions/model.WebLoginAccount"
}
},
"proxy_settings": {
"description": "Proxy configuration",
"allOf": [
{
"$ref": "#/definitions/model.WebProxySettings"
}
]
}
}
},
"model.WebLoginAccount": {
"type": "object",
"properties": {
"is_default": {
"type": "boolean"
},
"password": {
"type": "string"
},
"status": {
"description": "active, inactive",
"type": "string"
},
"username": {
"type": "string"
}
}
},
"model.WebProxySettings": {
"type": "object",
"properties": {
"allowed_methods": {
"description": "Allowed HTTP methods",
"type": "array",
"items": {
"type": "string"
}
},
"blocked_paths": {
"description": "Blocked URL paths",
"type": "array",
"items": {
"type": "string"
}
},
"max_concurrent": {
"description": "Max concurrent connections",
"type": "integer"
},
"recording_enabled": {
"description": "Enable session recording",
"type": "boolean"
},
"watermark_enabled": {
"description": "Enable watermark",
"type": "boolean"
}
}
}
}
}

View File

@@ -80,6 +80,28 @@ definitions:
items: {}
type: array
type: object
controller.StartWebSessionRequest:
properties:
account_id:
type: integer
asset_id:
type: integer
asset_name:
type: string
auth_mode:
type: string
required:
- asset_id
type: object
controller.StartWebSessionResponse:
properties:
message:
type: string
proxy_url:
type: string
session_id:
type: string
type: object
model.AccessAuth:
properties:
allow:
@@ -187,6 +209,9 @@ definitions:
items:
type: integer
type: array
rule_id:
description: V2 authorization rule ID for tracking
type: integer
type: object
model.Asset:
properties:
@@ -236,6 +261,11 @@ definitions:
type: string
updater_id:
type: integer
web_config:
allOf:
- $ref: '#/definitions/model.WebConfig'
description: Web-specific configuration (only valid when protocols contain
http/https)
type: object
model.AssetCommandControl:
properties:
@@ -593,12 +623,6 @@ definitions:
model.JSON:
additionalProperties: true
type: object
model.Map-int-model_Slice-int:
additionalProperties:
items:
type: integer
type: array
type: object
model.Map-string-any:
additionalProperties: {}
type: object
@@ -609,7 +633,7 @@ definitions:
asset_count:
type: integer
authorization:
$ref: '#/definitions/model.Map-int-model_Slice-int'
$ref: '#/definitions/model.AuthorizationMap'
children:
items:
$ref: '#/definitions/model.Node'
@@ -1034,6 +1058,58 @@ definitions:
description: User ID with unique index
type: integer
type: object
model.WebConfig:
properties:
access_policy:
description: full_access, read_only
type: string
auth_mode:
description: none, smart, manual
type: string
login_accounts:
description: Web login credentials
items:
$ref: '#/definitions/model.WebLoginAccount'
type: array
proxy_settings:
allOf:
- $ref: '#/definitions/model.WebProxySettings'
description: Proxy configuration
type: object
model.WebLoginAccount:
properties:
is_default:
type: boolean
password:
type: string
status:
description: active, inactive
type: string
username:
type: string
type: object
model.WebProxySettings:
properties:
allowed_methods:
description: Allowed HTTP methods
items:
type: string
type: array
blocked_paths:
description: Blocked URL paths
items:
type: string
type: array
max_concurrent:
description: Max concurrent connections
type: integer
recording_enabled:
description: Enable session recording
type: boolean
watermark_enabled:
description: Enable watermark
type: boolean
type: object
info:
contact: {}
paths:
@@ -1634,6 +1710,10 @@ paths:
in: query
name: page_size
type: integer
- description: search by name or description
in: query
name: search
type: string
- description: template category
in: query
name: category
@@ -3438,6 +3518,10 @@ paths:
in: query
name: page_size
type: integer
- description: search by name or description
in: query
name: search
type: string
- description: template category
in: query
name: category
@@ -3560,4 +3644,43 @@ paths:
$ref: '#/definitions/controller.HttpResponse'
tags:
- time_template
/web_proxy/config/{asset_id}:
get:
description: Get web asset configuration by asset ID
parameters:
- description: Asset ID
in: path
name: asset_id
required: true
type: integer
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.WebConfig'
summary: Get web asset configuration
tags:
- WebProxy
/web_proxy/start:
post:
consumes:
- application/json
description: Start a new web session for the specified asset
parameters:
- description: Start session request
in: body
name: request
required: true
schema:
$ref: '#/definitions/controller.StartWebSessionRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/controller.StartWebSessionResponse'
summary: Start web session
tags:
- WebProxy
swagger: "2.0"

View File

@@ -5,6 +5,8 @@ import (
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"strings"
"github.com/veops/oneterm/internal/api/controller"
"github.com/veops/oneterm/internal/api/docs"
"github.com/veops/oneterm/internal/api/middleware"
@@ -12,9 +14,33 @@ import (
func SetupRouter(r *gin.Engine) {
r.SetTrustedProxies([]string{"0.0.0.0/0", "::/0"})
r.MaxMultipartMemory = 32 << 20 // 32MB, match with controller constant
r.MaxMultipartMemory = 1 << 20 // 1MB to prevent memory overflow
r.Use(gin.Recovery(), middleware.LoggerMiddleware())
// Start web session cleanup routine
controller.StartSessionCleanupRoutine()
// Subdomain proxy middleware for asset- subdomains
webProxy := controller.NewWebProxyController()
r.Use(func(c *gin.Context) {
host := c.Request.Host
// Check if this is an asset subdomain request
if strings.HasPrefix(host, "asset-") {
// Handle external redirect requests
if c.Request.URL.Path == "/external" {
webProxy.HandleExternalRedirect(c)
return
}
// Handle normal proxy requests
webProxy.ProxyWebRequest(c)
return
}
c.Next()
})
docs.SwaggerInfo.Title = "ONETERM API"
docs.SwaggerInfo.BasePath = "/api/oneterm/v1"
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
@@ -224,5 +250,11 @@ func SetupRouter(r *gin.Engine) {
commandTemplate.GET("/builtin", c.GetBuiltInCommandTemplates)
commandTemplate.GET("/:id/commands", c.GetTemplateCommands)
}
// Web proxy management API routes
webProxyGroup := v1.Group("/web_proxy")
{
webProxyGroup.POST("/start", webProxy.StartWebSession)
}
}
}

View File

@@ -239,6 +239,11 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er
requiredActions = append(requiredActions, model.ActionFileUpload, model.ActionFileDownload)
}
// Web protocols need file download permission check
if protocol == "http" || protocol == "https" {
requiredActions = append(requiredActions, model.ActionFileDownload)
}
// RDP/VNC are handled separately in ConnectGuacd with their own batch permission check
// but we still check connect permission here for consistency
@@ -254,6 +259,19 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er
return sess, err
}
// Set permissions in session for protocol-specific usage
if protocol == "http" || protocol == "https" {
// For Web protocols, store all relevant permissions
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),
}
sess.SetPermissions(permissions)
}
// For SSH, check if user has any file permissions before initializing SFTP
hasFilePermissions := false
if protocol == "ssh" {
@@ -269,6 +287,11 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er
go protocols.ConnectTelnet(ctx, sess, asset, account, gateway)
case "vnc", "rdp":
go protocols.ConnectGuacd(ctx, sess, asset, account, gateway)
case "http", "https":
// Web assets are handled through separate web proxy API endpoints
err = &myErrors.ApiError{Code: myErrors.ErrConnectServer, Data: map[string]any{"err": "Web assets should use web proxy API"}}
sess.Chans.ErrChan <- err
return
default:
logger.L().Error("wrong protocol " + sess.Protocol)
}

View File

@@ -0,0 +1,71 @@
package protocols
import (
"fmt"
"time"
"go.uber.org/zap"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/pkg/logger"
)
type WebSession struct {
Asset *model.Asset
Account *model.Account
StartTime time.Time
LastActivity time.Time
}
// NewWebSession creates a new web session
func NewWebSession(asset *model.Asset, account *model.Account) *WebSession {
now := time.Now()
return &WebSession{
Asset: asset,
Account: account,
StartTime: now,
LastActivity: now,
}
}
// GetTargetURL returns the target URL for the web asset
func (ws *WebSession) GetTargetURL() string {
if ws.Asset == nil {
return ""
}
protocol, port := ws.Asset.GetWebProtocol()
if protocol == "" {
protocol = "http"
port = 80
}
// Build URL without port if it's the default port
if (protocol == "http" && port == 80) || (protocol == "https" && port == 443) {
return fmt.Sprintf("%s://%s", protocol, ws.Asset.Ip)
}
return fmt.Sprintf("%s://%s:%d", protocol, ws.Asset.Ip, port)
}
// GetAssetInfo returns asset information
func (ws *WebSession) GetAssetInfo() map[string]interface{} {
if ws.Asset == nil {
return map[string]interface{}{}
}
return map[string]interface{}{
"id": ws.Asset.Id,
"name": ws.Asset.Name,
"ip": ws.Asset.Ip,
"url": ws.GetTargetURL(),
}
}
// Close closes the web session
func (ws *WebSession) Close() error {
logger.L().Info("Closing web session",
zap.String("assetName", ws.Asset.Name),
zap.Int("assetId", ws.Asset.Id))
return nil
}

View File

@@ -3,11 +3,19 @@ package model
import (
"database/sql/driver"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/samber/lo"
"gorm.io/plugin/soft_delete"
)
// Web asset constants
const (
WebAssetDefaultAccountID = -1 // Virtual account ID for Web assets
)
const (
TABLE_NAME_ASSET = "asset"
)
@@ -128,6 +136,9 @@ type Asset struct {
AccessTimeControl *AccessTimeControl `json:"access_time_control,omitempty" gorm:"column:access_time_control;type:json"`
AssetCommandControl *AssetCommandControl `json:"asset_command_control,omitempty" gorm:"column:asset_command_control;type:json"`
// Web-specific configuration (only valid when protocols contain http/https)
WebConfig *WebConfig `json:"web_config,omitempty" gorm:"column:web_config;type:json"`
Permissions []string `json:"permissions" gorm:"-"`
ResourceId int `json:"resource_id" gorm:"column:resource_id"`
CreatorId int `json:"creator_id" gorm:"column:creator_id"`
@@ -207,3 +218,84 @@ type AssetIdPid struct {
func (m *AssetIdPid) TableName() string {
return TABLE_NAME_ASSET
}
// WebConfig contains Web-specific configuration for assets
type WebConfig struct {
AuthMode string `json:"auth_mode"` // none, smart, manual
LoginAccounts []WebLoginAccount `json:"login_accounts"` // Web login credentials
AccessPolicy string `json:"access_policy"` // full_access, read_only
ProxySettings *WebProxySettings `json:"proxy_settings"` // Proxy configuration
}
func (w *WebConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, w)
}
func (w WebConfig) Value() (driver.Value, error) {
if w.AuthMode == "" {
return nil, nil
}
return json.Marshal(w)
}
// WebLoginAccount represents login credentials for Web authentication
type WebLoginAccount struct {
Username string `json:"username"`
Password string `json:"password"`
IsDefault bool `json:"is_default"`
Status string `json:"status"` // active, inactive
}
// WebProxySettings contains proxy-specific settings
type WebProxySettings struct {
MaxConcurrent int `json:"max_concurrent"` // Max concurrent connections
AllowedMethods []string `json:"allowed_methods"` // Allowed HTTP methods
BlockedPaths []string `json:"blocked_paths"` // Blocked URL paths
RecordingEnabled bool `json:"recording_enabled"` // Enable session recording
WatermarkEnabled bool `json:"watermark_enabled"` // Enable watermark
}
// IsWebAsset checks if the asset is a Web asset
func (a *Asset) IsWebAsset() bool {
return lo.SomeBy(a.Protocols, func(protocol string) bool {
protocolName := strings.ToLower(strings.Split(protocol, ":")[0])
return protocolName == "http" || protocolName == "https"
})
}
// GetWebProtocol returns the Web protocol (http/https) and port
func (a *Asset) GetWebProtocol() (string, int) {
if protocol, found := lo.Find(a.Protocols, func(protocol string) bool {
parts := strings.Split(protocol, ":")
if len(parts) >= 1 {
protocolName := strings.ToLower(parts[0])
return protocolName == "http" || protocolName == "https"
}
return false
}); found {
parts := strings.Split(protocol, ":")
protocolName := strings.ToLower(parts[0])
port := 80
if protocolName == "https" {
port = 443
}
if len(parts) >= 2 {
if customPort, err := strconv.Atoi(parts[1]); err == nil {
port = customPort
}
}
return protocolName, port
}
return "", 0
}

View File

@@ -0,0 +1,497 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"go.uber.org/zap"
"github.com/samber/lo"
"github.com/veops/oneterm/pkg/logger"
)
// WebAuthService handles Web authentication
type WebAuthService struct {
strategies []WebAuthStrategy
}
// WebAuthStrategy defines the interface for Web authentication strategies
type WebAuthStrategy interface {
Name() string
Priority() int
CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool
Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error)
}
// WebSiteInfo contains information about the target Web site
type WebSiteInfo struct {
URL string
HTMLContent string
Headers http.Header
StatusCode int
LoginForms []WebLoginForm
}
// WebLoginForm represents a login form found on the page
type WebLoginForm struct {
Action string `json:"action"`
Method string `json:"method"`
UsernameField WebFormField `json:"username_field"`
PasswordField WebFormField `json:"password_field"`
SubmitButton WebFormField `json:"submit_button"`
AdditionalFields []WebFormField `json:"additional_fields"`
CSRFToken string `json:"csrf_token"`
}
// WebFormField represents a form field
type WebFormField struct {
Name string `json:"name"`
ID string `json:"id"`
Type string `json:"type"`
Value string `json:"value"`
Selector string `json:"selector"`
Placeholder string `json:"placeholder"`
}
// WebCredentials contains authentication credentials
type WebCredentials struct {
Username string
Password string
}
// WebAuthResult contains authentication result
type WebAuthResult struct {
Success bool
Message string
Cookies []*http.Cookie
RedirectURL string
SessionData map[string]interface{}
}
// NewWebAuthService creates a new Web authentication service
func NewWebAuthService() *WebAuthService {
service := &WebAuthService{
strategies: []WebAuthStrategy{
&HTTPBasicAuthStrategy{},
&SmartFormAuthStrategy{},
&APILoginAuthStrategy{},
},
}
return service
}
// AnalyzeSite analyzes a Web site for authentication methods
func (s *WebAuthService) AnalyzeSite(ctx context.Context, targetURL string) (*WebSiteInfo, error) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Don't follow redirects during analysis
return http.ErrUseLastResponse
},
}
resp, err := client.Get(targetURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch site: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
siteInfo := &WebSiteInfo{
URL: targetURL,
HTMLContent: string(body),
Headers: resp.Header,
StatusCode: resp.StatusCode,
}
// Analyze HTML for login forms
if strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
forms, err := s.analyzeLoginForms(string(body))
if err != nil {
logger.L().Warn("Failed to analyze login forms", zap.Error(err))
} else {
siteInfo.LoginForms = forms
}
}
return siteInfo, nil
}
// SelectBestStrategy selects the best authentication strategy for a site
func (s *WebAuthService) SelectBestStrategy(ctx context.Context, siteInfo *WebSiteInfo) WebAuthStrategy {
var bestStrategy WebAuthStrategy
highestPriority := -1
for _, strategy := range s.strategies {
if strategy.CanHandle(ctx, siteInfo) && strategy.Priority() > highestPriority {
bestStrategy = strategy
highestPriority = strategy.Priority()
}
}
return bestStrategy
}
// Authenticate performs authentication using the best available strategy
func (s *WebAuthService) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
strategy := s.SelectBestStrategy(ctx, siteInfo)
if strategy == nil {
return &WebAuthResult{
Success: false,
Message: "No suitable authentication strategy found",
}, nil
}
return strategy.Authenticate(ctx, credentials, siteInfo)
}
// AuthenticateWithRetry performs authentication with automatic account retry
func (s *WebAuthService) AuthenticateWithRetry(ctx context.Context, accounts []WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
if len(accounts) == 0 {
return &WebAuthResult{
Success: false,
Message: "No accounts available for authentication",
}, nil
}
strategy := s.SelectBestStrategy(ctx, siteInfo)
if strategy == nil {
return &WebAuthResult{
Success: false,
Message: "No suitable authentication strategy found",
}, nil
}
var lastError error
var lastResult *WebAuthResult
// 尝试每个账号,直到成功
for i, credentials := range accounts {
logger.L().Info("Attempting authentication",
zap.String("strategy", strategy.Name()),
zap.String("username", credentials.Username),
zap.Int("attempt", i+1),
zap.Int("total_accounts", len(accounts)))
result, err := strategy.Authenticate(ctx, &credentials, siteInfo)
if err != nil {
lastError = err
logger.L().Warn("Authentication error",
zap.String("username", credentials.Username),
zap.Error(err))
continue
}
lastResult = result
if result.Success {
logger.L().Info("Authentication successful",
zap.String("username", credentials.Username),
zap.Int("attempt", i+1))
return result, nil
}
logger.L().Warn("Authentication failed",
zap.String("username", credentials.Username),
zap.String("reason", result.Message))
}
// 所有账号都失败了
if lastError != nil {
return nil, fmt.Errorf("all authentication attempts failed, last error: %w", lastError)
}
if lastResult != nil {
return lastResult, nil
}
return &WebAuthResult{
Success: false,
Message: "All configured accounts failed to authenticate",
}, nil
}
// analyzeLoginForms analyzes HTML content for login forms
func (s *WebAuthService) analyzeLoginForms(htmlContent string) ([]WebLoginForm, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
if err != nil {
return nil, err
}
var forms []WebLoginForm
doc.Find("form").Each(func(i int, formSel *goquery.Selection) {
form := WebLoginForm{
Method: strings.ToUpper(formSel.AttrOr("method", "GET")),
Action: formSel.AttrOr("action", ""),
}
// Find username field
formSel.Find("input").Each(func(j int, inputSel *goquery.Selection) {
inputType := strings.ToLower(inputSel.AttrOr("type", "text"))
inputName := inputSel.AttrOr("name", "")
inputID := inputSel.AttrOr("id", "")
placeholder := inputSel.AttrOr("placeholder", "")
field := WebFormField{
Name: inputName,
ID: inputID,
Type: inputType,
Selector: s.generateSelector(inputSel),
Placeholder: placeholder,
}
// Identify field type based on various indicators
if s.isUsernameField(inputType, inputName, inputID, placeholder) && form.UsernameField.Name == "" {
form.UsernameField = field
} else if inputType == "password" && form.PasswordField.Name == "" {
form.PasswordField = field
}
})
// Find submit button
formSel.Find("button, input[type=submit]").Each(func(j int, btnSel *goquery.Selection) {
if form.SubmitButton.Name == "" {
form.SubmitButton = WebFormField{
Name: btnSel.AttrOr("name", ""),
ID: btnSel.AttrOr("id", ""),
Type: btnSel.AttrOr("type", "submit"),
Selector: s.generateSelector(btnSel),
}
}
})
// Only include forms that have both username and password fields
if form.UsernameField.Name != "" && form.PasswordField.Name != "" {
forms = append(forms, form)
}
})
return forms, nil
}
// isUsernameField determines if a field is likely a username field
func (s *WebAuthService) isUsernameField(inputType, name, id, placeholder string) bool {
if inputType == "password" {
return false
}
keywords := []string{"user", "login", "email", "account", "name"}
text := strings.ToLower(name + id + placeholder)
return lo.SomeBy(keywords, func(keyword string) bool {
return strings.Contains(text, keyword)
})
}
// generateSelector generates a CSS selector for an element
func (s *WebAuthService) generateSelector(sel *goquery.Selection) string {
if id := sel.AttrOr("id", ""); id != "" {
return "#" + id
}
if name := sel.AttrOr("name", ""); name != "" {
return fmt.Sprintf(`[name="%s"]`, name)
}
if class := sel.AttrOr("class", ""); class != "" {
classes := strings.Split(class, " ")
if len(classes) > 0 {
return "." + strings.Join(classes, ".")
}
}
return sel.Get(0).Data // fallback to tag name
}
// HTTPBasicAuthStrategy implements HTTP Basic Authentication
type HTTPBasicAuthStrategy struct{}
func (s *HTTPBasicAuthStrategy) Name() string { return "http_basic" }
func (s *HTTPBasicAuthStrategy) Priority() int { return 10 }
func (s *HTTPBasicAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool {
return siteInfo.StatusCode == 401 &&
strings.Contains(siteInfo.Headers.Get("WWW-Authenticate"), "Basic")
}
func (s *HTTPBasicAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", siteInfo.URL, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(credentials.Username, credentials.Password)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode != 401
return &WebAuthResult{
Success: success,
Message: fmt.Sprintf("HTTP Basic auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]),
Cookies: resp.Cookies(),
}, nil
}
// SmartFormAuthStrategy implements intelligent form-based authentication
type SmartFormAuthStrategy struct{}
func (s *SmartFormAuthStrategy) Name() string { return "smart_form" }
func (s *SmartFormAuthStrategy) Priority() int { return 5 }
func (s *SmartFormAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool {
return len(siteInfo.LoginForms) > 0
}
func (s *SmartFormAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
if len(siteInfo.LoginForms) == 0 {
return nil, fmt.Errorf("no login forms found")
}
form := siteInfo.LoginForms[0] // Use the first form found
// Prepare form data
formData := url.Values{}
formData.Set(form.UsernameField.Name, credentials.Username)
formData.Set(form.PasswordField.Name, credentials.Password)
// Add submit button if it has a name
if form.SubmitButton.Name != "" {
formData.Set(form.SubmitButton.Name, "")
}
// Determine the target URL
actionURL := form.Action
if actionURL == "" || strings.HasPrefix(actionURL, "/") {
baseURL, _ := url.Parse(siteInfo.URL)
if actionURL == "" {
actionURL = siteInfo.URL
} else {
actionURL = baseURL.Scheme + "://" + baseURL.Host + actionURL
}
}
// Create HTTP client
client := &http.Client{Timeout: 30 * time.Second}
// Submit the form
req, err := http.NewRequestWithContext(ctx, form.Method, actionURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "OneTerm-WebProxy/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Check if authentication was successful
// Usually a successful login redirects or returns 200 with cookies
success := resp.StatusCode >= 200 && resp.StatusCode < 400 && len(resp.Cookies()) > 0
return &WebAuthResult{
Success: success,
Message: fmt.Sprintf("Form auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]),
Cookies: resp.Cookies(),
RedirectURL: resp.Header.Get("Location"),
}, nil
}
// APILoginAuthStrategy implements API-based authentication
type APILoginAuthStrategy struct{}
func (s *APILoginAuthStrategy) Name() string { return "api_login" }
func (s *APILoginAuthStrategy) Priority() int { return 8 }
func (s *APILoginAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool {
// Check for common API login endpoints
commonEndpoints := []string{"/api/login", "/auth/login", "/login", "/signin"}
baseURL, err := url.Parse(siteInfo.URL)
if err != nil {
return false
}
client := &http.Client{Timeout: 5 * time.Second}
for _, endpoint := range commonEndpoints {
testURL := baseURL.Scheme + "://" + baseURL.Host + endpoint
resp, err := client.Head(testURL)
if err == nil && resp.StatusCode != 404 {
resp.Body.Close()
return true
}
}
return false
}
func (s *APILoginAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
baseURL, err := url.Parse(siteInfo.URL)
if err != nil {
return nil, err
}
// Try common API login endpoints
commonEndpoints := []string{"/api/login", "/auth/login", "/login", "/signin"}
client := &http.Client{Timeout: 30 * time.Second}
for _, endpoint := range commonEndpoints {
loginURL := baseURL.Scheme + "://" + baseURL.Host + endpoint
// Prepare JSON payload
payload := map[string]string{
"username": credentials.Username,
"password": credentials.Password,
}
jsonData, err := json.Marshal(payload)
if err != nil {
continue
}
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewReader(jsonData))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "OneTerm-WebProxy/1.0")
resp, err := client.Do(req)
if err != nil {
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
defer resp.Body.Close()
return &WebAuthResult{
Success: true,
Message: "API login succeeded",
Cookies: resp.Cookies(),
}, nil
}
resp.Body.Close()
}
return &WebAuthResult{
Success: false,
Message: "API login failed - no valid endpoint found",
}, nil
}

View File

@@ -132,6 +132,10 @@ type Session struct {
// SSH connection reuse for file transfers
SSHClient *gossh.Client `json:"-" gorm:"-"`
sshMutex sync.RWMutex `json:"-" gorm:"-"`
// Web session support
WebSession interface{} `json:"-" gorm:"-"`
Permissions *model.AuthPermissions `json:"-" gorm:"-"`
}
func (m *Session) HasMonitors() (has bool) {
@@ -206,3 +210,23 @@ func (s *Session) HasSSHClient() bool {
defer s.sshMutex.RUnlock()
return s.SSHClient != nil
}
// SetWebSession stores a Web session object
func (s *Session) SetWebSession(webSession interface{}) {
s.WebSession = webSession
}
// GetWebSession returns the stored Web session object
func (s *Session) GetWebSession() interface{} {
return s.WebSession
}
// SetPermissions stores the user permissions for this session
func (s *Session) SetPermissions(permissions *model.AuthPermissions) {
s.Permissions = permissions
}
// GetPermissions returns the stored permissions
func (s *Session) GetPermissions() *model.AuthPermissions {
return s.Permissions
}