perf(web_proxy): convert from dynamic asset-{id} subdomain to fixed webproxy subdomain

This commit is contained in:
pycook
2025-09-06 14:20:11 +08:00
parent 6120b3bb9a
commit 1f0795d6d7
7 changed files with 171 additions and 184 deletions

View File

@@ -10,7 +10,6 @@ RUN apk add tzdata
ENV TZ=Asia/Shanghai
ENV TERM=xterm-256color
WORKDIR /oneterm
COPY --from=0 /oneterm/configs/config.example.yaml ./config.yaml
COPY --from=0 /oneterm/internal/i18n/locales ./locales
COPY --from=0 /oneterm/build/oneterm .
CMD [ "./oneterm","run","-c","./config.yaml"]

View File

@@ -337,7 +337,7 @@ func (c *WebProxyController) recordWebActivity(session *WebProxySession, req *ht
web_proxy.RecordWebActivity(session.SessionId, &gin.Context{Request: req})
}
// extractAssetIDFromHost extracts asset ID from subdomain host
// extractAssetIDFromHost is kept for compatibility but deprecated in fixed subdomain approach
func (c *WebProxyController) extractAssetIDFromHost(host string) (int, error) {
return web_proxy.ExtractAssetIDFromHost(host)
}

View File

@@ -21,7 +21,7 @@ func LoggerMiddleware() gin.HandlerFunc {
// Skip logging for web proxy requests to reduce noise
url := ctx.Request.URL.String()
host := ctx.Request.Host
if strings.HasPrefix(host, "asset-") {
if strings.HasPrefix(host, "webproxy.") {
return
}

View File

@@ -1,12 +1,12 @@
package router
import (
"strings"
"github.com/gin-gonic/gin"
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"
@@ -21,13 +21,13 @@ func SetupRouter(r *gin.Engine) {
// Start web session cleanup routine
controller.StartSessionCleanupRoutine()
// Subdomain proxy middleware for asset- subdomains
// Fixed webproxy subdomain middleware
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-") {
// Check if this is the webproxy subdomain request
if strings.HasPrefix(host, "webproxy.") {
// Allow API requests to pass through to normal routing
if strings.HasPrefix(c.Request.URL.Path, "/api/oneterm/v1/") {
c.Next()

View File

@@ -12,86 +12,6 @@ import (
"github.com/samber/lo"
)
// RewriteHTMLContent rewrites HTML content to redirect external links through proxy
func RewriteHTMLContent(resp *http.Response, assetID int, scheme, proxyHost string) {
if resp.Body == nil {
return
}
// Remove Content-Encoding to avoid decoding issues
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
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)
},
},
// Static resources: <img src=""> <script src=""> <link href="">
{
`(src\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)))
}
// ProcessHTMLResponse processes HTML response for content rewriting and injection
func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost string, session *WebProxySession) {
@@ -99,8 +19,15 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
return
}
// Check if content is compressed
// Check if content is compressed BEFORE removing headers
contentEncoding := resp.Header.Get("Content-Encoding")
// Only log search-related requests for debugging login issue
isSearchRequest := strings.Contains(resp.Request.URL.String(), "/s?") || strings.Contains(resp.Request.URL.String(), "search")
if isSearchRequest {
fmt.Printf("[SEARCH] URL: %s, Status: %d, Content-Type: %s, Content-Encoding: %s\n",
resp.Request.URL.String(), resp.StatusCode, resp.Header.Get("Content-Type"), contentEncoding)
}
// Remove Content-Encoding to avoid decoding issues
resp.Header.Del("Content-Encoding")
@@ -118,6 +45,21 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
}
defer gzipReader.Close()
body, err = io.ReadAll(gzipReader)
} else if contentEncoding == "br" || contentEncoding == "deflate" {
// For br/deflate, we need to decompress but don't have the library
// For now, keep the headers and let the browser handle decompression
resp.Header.Set("Content-Encoding", contentEncoding)
body, err = io.ReadAll(resp.Body)
// Skip HTML processing for compressed content we can't decompress
if isSearchRequest {
fmt.Printf("[SEARCH] Skipping HTML processing due to %s encoding\n", contentEncoding)
}
// Set response without HTML processing
newBody := bytes.NewReader(body)
resp.Body = io.NopCloser(newBody)
resp.ContentLength = int64(len(body))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
return
} else {
body, err = io.ReadAll(resp.Body)
}
@@ -128,10 +70,17 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
}
resp.Body.Close()
// Preserve original encoding - convert bytes to string properly
content := string(body)
// Log search content processing for debugging garbled text
if isSearchRequest {
fmt.Printf("[SEARCH] Body length: %d, Content preview: %.100s...\n",
len(body), strings.ReplaceAll(string(body[:min(100, len(body))]), "\n", "\\n"))
}
// URL rewriting for external links
baseDomain := lo.Ternary(strings.HasPrefix(proxyHost, "asset-"),
baseDomain := lo.Ternary(strings.HasPrefix(proxyHost, "webproxy."),
func() string {
parts := strings.SplitN(proxyHost, ".", 2)
return lo.Ternary(len(parts) > 1, parts[1], proxyHost)
@@ -146,28 +95,39 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
`(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)
return fmt.Sprintf(`%s%s://webproxy.%s%s"`, matches[1], scheme, baseDomain, 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)
return fmt.Sprintf(`%s%s://webproxy.%s%s"`, matches[1], scheme, baseDomain, path)
},
},
{
`(href\s*=\s*["'])https?://([^/'"]+)(/[^"']*)?["']`,
func(matches []string) string {
hostname := matches[2]
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)
},
},
{
`(src\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)
// Check if hostname belongs to the same domain family (e.g., *.baidu.com)
sessionHostParts := strings.Split(session.CurrentHost, ".")
hostnameParts := strings.Split(hostname, ".")
// Compare the last 2 parts for domain matching (e.g., baidu.com)
isSameDomain := false
if len(sessionHostParts) >= 2 && len(hostnameParts) >= 2 {
sessionDomain := strings.Join(sessionHostParts[len(sessionHostParts)-2:], ".")
hostDomain := strings.Join(hostnameParts[len(hostnameParts)-2:], ".")
isSameDomain = sessionDomain == hostDomain
}
if isSameDomain {
return fmt.Sprintf(`%s%s://webproxy.%s%s"`, matches[1], scheme, baseDomain, path)
}
// Keep external URLs unchanged
return matches[0]
},
},
}
@@ -241,18 +201,18 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
// Add session management JavaScript (always inject)
sessionJS := fmt.Sprintf(`
<script>
(function() {tbeat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_id: sessionId})
}).catch(function() {});
}
(function() {
var sessionId = '%s';
var heartbeatInterval;
// Send heartbeat every 15 seconds
function sendHeartbeat() {
fetch('/api/oneterm/v1/web_proxy/hear
fetch('/api/oneterm/v1/web_proxy/heartbeat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_id: sessionId})
}).catch(function() {});
}
// Universal heartbeat mechanism - no complex event handling
// The server will handle session cleanup based on heartbeat timeout
@@ -260,6 +220,7 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
// Send initial heartbeat immediately
sendHeartbeat();
})();
</script>`, session.SessionId)
@@ -269,7 +230,7 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
<script>
(function() {
var originalHost = '%s';
var proxyHost = 'asset-%d.%s';
var proxyHost = 'webproxy.%s';
var proxyScheme = '%s';
function rewriteUrl(url) {
@@ -277,21 +238,38 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
// Handle absolute URLs
if (url.startsWith('http://') || url.startsWith('https://')) {
var urlObj = new URL(url);
// Only rewrite external domains, not our proxy domain
if (urlObj.hostname !== window.location.hostname &&
urlObj.hostname !== 'localhost' &&
urlObj.hostname !== '127.0.0.1') {
// Preserve the original path and query, but use proxy hostname
var newUrl = proxyScheme + '://' + proxyHost + urlObj.pathname + urlObj.search + urlObj.hash;
console.log('Rewriting URL:', url, '->', newUrl);
return newUrl;
// Check if this URL belongs to the same domain we're proxying
var isSameDomain = false;
// Exact match
if (urlObj.hostname === originalHost) {
isSameDomain = true;
}
}
// Handle relative URLs starting with /
else if (url.startsWith('/')) {
// Keep relative URLs as-is, they will be relative to current proxy domain
// Check if they share the same root domain (e.g., www.baidu.com and baidu.com)
else {
var getBaseDomain = function(hostname) {
var parts = hostname.split('.');
if (parts.length >= 2) {
return parts.slice(-2).join('.');
}
return hostname;
};
if (getBaseDomain(urlObj.hostname) === getBaseDomain(originalHost)) {
isSameDomain = true;
}
}
if (isSameDomain) {
// Convert to relative URL so it goes through proxy
return urlObj.pathname + urlObj.search + urlObj.hash;
}
// For external CDN URLs, let them access directly (most secure for bastion host)
return url;
}
// Handle relative URLs - keep as is
return url;
} catch (e) {
console.warn('URL rewrite error:', e, 'for URL:', url);
@@ -426,7 +404,7 @@ func ProcessHTMLResponse(resp *http.Response, assetID int, scheme, proxyHost str
}, 1000);
}
})();
</script>`, session.CurrentHost, assetID, baseDomain, scheme, session.Permissions.FileDownload)
</script>`, session.CurrentHost, baseDomain, scheme, session.Permissions.FileDownload)
// Always inject session management and URL interceptor

View File

@@ -130,26 +130,20 @@ func StartWebSession(ctx *gin.Context, req StartWebSessionRequest) (*StartWebSes
}
StoreSession(sessionId, webSession)
// Generate subdomain-based proxy URL
// Generate fixed webproxy subdomain URL
// Use the complete domain for webproxy subdomain
baseDomain := strings.Split(ctx.Request.Host, ":")[0]
if strings.Contains(baseDomain, ".") {
parts := strings.Split(baseDomain, ".")
if len(parts) > 2 {
baseDomain = strings.Join(parts[1:], ".")
}
}
// Determine proxy scheme based on current request only (not asset protocol)
scheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http")
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 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)
// 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)
@@ -257,35 +251,11 @@ func BuildTargetURLWithHost(asset *model.Asset, host string) string {
return fmt.Sprintf("%s://%s:%d", protocol, host, port)
}
// ExtractAssetIDFromHost extracts asset ID from subdomain host
// ExtractAssetIDFromHost extracts asset ID from query parameter (fixed webproxy subdomain)
func 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
// 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
@@ -412,6 +382,30 @@ func ExtractSessionAndAssetInfo(ctx *gin.Context, extractAssetIDFromHost func(st
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 == "" {
@@ -424,13 +418,7 @@ func ExtractSessionAndAssetInfo(ctx *gin.Context, extractAssetIDFromHost func(st
}
}
// Extract asset ID from Host header: asset-11.oneterm.com -> 11
assetID, err := extractAssetIDFromHost(host)
if err != nil {
return nil, fmt.Errorf("invalid subdomain format: %w", err)
}
// Try to get session_id from Referer header as fallback
// 3. Try to get session_id from Referer header as fallback
if sessionID == "" {
referer := ctx.GetHeader("Referer")
if referer != "" {
@@ -451,7 +439,7 @@ func ExtractSessionAndAssetInfo(ctx *gin.Context, extractAssetIDFromHost func(st
}
}
// For static resources, try harder to find 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/") ||
@@ -527,7 +515,15 @@ func ValidateSessionAndPermissions(ctx *gin.Context, proxyCtx *ProxyRequestConte
// Auto-renew cookie for user operations
cookieMaxAge := int(model.GlobalConfig.Load().Timeout)
ctx.SetCookie("oneterm_session_id", proxyCtx.SessionID, cookieMaxAge, "/", "", false, true)
// 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
@@ -552,7 +548,14 @@ func SetupReverseProxy(ctx *gin.Context, proxyCtx *ProxyRequestContext, buildTar
return nil, fmt.Errorf("invalid target URL")
}
currentScheme := lo.Ternary(ctx.Request.TLS != nil, "https", "http")
// 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)
@@ -577,9 +580,15 @@ func SetupReverseProxy(ctx *gin.Context, proxyCtx *ProxyRequestContext, buildTar
}
}
q := req.URL.Query()
q.Del("session_id")
req.URL.RawQuery = q.Encode()
// 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
@@ -634,7 +643,7 @@ func SetupReverseProxy(ctx *gin.Context, proxyCtx *ProxyRequestContext, buildTar
shouldIntercept := redirectURL.IsAbs()
if shouldIntercept {
baseDomain := lo.Ternary(strings.HasPrefix(proxyCtx.Host, "asset-"),
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)
@@ -643,14 +652,14 @@ func SetupReverseProxy(ctx *gin.Context, proxyCtx *ProxyRequestContext, buildTar
if isSameDomainOrSubdomain(target.Host, redirectURL.Host) {
UpdateSessionHost(proxyCtx.SessionID, redirectURL.Host)
newProxyURL := fmt.Sprintf("%s://asset-%d.%s%s", currentScheme, proxyCtx.AssetID, baseDomain, redirectURL.Path)
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://asset-%d.%s/external?url=%s",
currentScheme, proxyCtx.AssetID, baseDomain, url.QueryEscape(redirectURL.String()))
newLocation := fmt.Sprintf("%s://webproxy.%s/external?url=%s",
currentScheme, baseDomain, url.QueryEscape(redirectURL.String()))
resp.Header.Set("Location", newLocation)
}
} else {
@@ -673,6 +682,7 @@ func SetupReverseProxy(ctx *gin.Context, proxyCtx *ProxyRequestContext, buildTar
return nil
}
return proxy, nil
}

View File

@@ -97,28 +97,28 @@ func handler(sess ssh.Session) {
func signer() ssh.Signer {
sysConfigService := service.NewSystemConfigService()
// Retry logic to wait for database table creation
var privateKey string
var err error
for i := 0; i < 10; i++ {
for i := range 10 {
privateKey, err = sysConfigService.EnsureSSHPrivateKey()
if err == nil {
break
}
// If table doesn't exist, wait and retry
if strings.Contains(err.Error(), "doesn't exist") {
logger.L().Info("Waiting for database initialization...", zap.Int("attempt", i+1))
time.Sleep(time.Second)
continue
}
// Other errors are fatal
logger.L().Fatal("failed to ensure SSH private key", zap.Error(err))
}
if err != nil {
logger.L().Fatal("failed to ensure SSH private key after retries", zap.Error(err))
}