yao/tai/tunnel/forward.go
Max 9c9701ed7e feat(tunnel): refactor VNC and proxy handling with structured routing
- Updated VNC and proxy handling in the tunnel to utilize a structured routing approach, enhancing clarity and maintainability.
- Replaced direct port checks with a new `forwardRoute` struct to encapsulate routing information, including channel type, container ID, and port.
- Modified request handling to streamline the forwarding process and improve error handling for unknown routes.
- Enhanced tests to validate the new routing logic and ensure consistent behavior across VNC and proxy requests.

Made-with: Cursor
2026-03-14 21:51:32 +08:00

205 lines
5.9 KiB
Go

package tunnel
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/tai/tunnel/taipb"
"github.com/yaoapp/yao/tai/types"
)
const defaultVNCPort = 5900
// forwardRoute holds the structured routing information extracted from the
// incoming request URL. It is passed to RequestForward so that Yao can
// populate the TunnelControl proto fields and Tai can route directly without
// parsing the first packet.
type forwardRoute struct {
channelType string // "proxy" | "vnc"
containerID string // target container or "__host__"
containerPort int // container-internal port (vnc default 5900)
subpath string // rewritten request path for the container
}
// HandleForward handles HTTP/VNC/any TCP-level forwarding through the gRPC tunnel.
// Route: ANY /tai/:taiID/proxy/*path and GET /tai/:taiID/vnc/*path
//
// It hijacks the browser's raw TCP connection, asks Tai to open a Forward stream
// with explicit routing information, rewrites the request path, and then performs
// bidirectional byte-level bridging. No protocol parsing beyond HTTP hijack.
func (h *TunnelHandler) HandleForward(c *gin.Context) {
logger := h.logger
reg := h.reg
taiID := c.Param("taiID")
node, ok := reg.Get(taiID)
if !ok || node.Status != "online" {
c.JSON(http.StatusBadGateway, gin.H{"error": "tai node not available"})
return
}
route, err := resolveRoute(c, node)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rewrittenReq := rewriteRequest(c.Request, taiID, route)
logger.Debug("[forward] "+node.Mode+" → tai",
"tai_id", taiID,
"type", route.channelType,
"container", route.containerID,
"container_port", route.containerPort,
"path", rewrittenReq.URL.Path,
)
hijacker, ok := c.Writer.(http.Hijacker)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "hijack not supported"})
return
}
browserConn, bufrw, err := hijacker.Hijack()
if err != nil {
logger.Error("[forward] hijack failed", "tai_id", taiID, "err", err)
return
}
defer browserConn.Close()
fwd, err := h.RequestForward(taiID, route)
if err != nil {
logger.Error("[forward] stream failed",
"tai_id", taiID, "type", route.channelType, "err", err)
browserConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
return
}
var reqBuf bytes.Buffer
rewrittenReq.Write(&reqBuf)
if bufrw.Reader.Buffered() > 0 {
buffered, _ := bufrw.Peek(bufrw.Reader.Buffered())
reqBuf.Write(buffered)
}
if err := fwd.Send(&taipb.ForwardData{Data: reqBuf.Bytes()}); err != nil {
logger.Error("[forward] send failed", "tai_id", taiID, "err", err)
return
}
streamConn := newForwardConn(fwd)
bridgeTCP(
&netConnAdapter{ReadWriteCloser: browserConn},
streamConn,
)
logger.Debug("[forward] closed", "tai_id", taiID)
}
// HandleForwardLazy is a gin.HandlerFunc that resolves the global TunnelHandler
// at call time (not registration time), so routes can be registered before the
// gRPC server starts.
func HandleForwardLazy(c *gin.Context) {
h := GlobalHandler()
if h == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "tunnel handler not initialized"})
return
}
h.HandleForward(c)
}
// resolveRoute extracts structured routing info from the request URL path.
//
// For proxy requests (/tai/:taiID/proxy/{containerID}:{port}/{subpath}):
//
// channelType = "proxy", containerPort from URL, subpath = remaining path.
//
// For VNC requests (/tai/:taiID/vnc/{containerID}/ws):
//
// channelType = "vnc", containerPort = 5900, subpath = /vnc/{containerID}/ws.
func resolveRoute(c *gin.Context, node *types.NodeMeta) (*forwardRoute, error) {
path := c.Request.URL.Path
taiID := c.Param("taiID")
marker := "/tai/" + taiID
idx := strings.Index(path, marker)
if idx < 0 {
return nil, fmt.Errorf("cannot locate /tai/%s in path", taiID)
}
rest := path[idx+len(marker):]
if strings.HasPrefix(rest, "/vnc/") {
// /vnc/{containerID}/ws → containerID, port=5900
tail := strings.TrimPrefix(rest, "/vnc/")
containerID := tail
if slashIdx := strings.IndexByte(tail, '/'); slashIdx >= 0 {
containerID = tail[:slashIdx]
}
if containerID == "" {
return nil, fmt.Errorf("missing container ID in VNC path: %s", path)
}
return &forwardRoute{
channelType: "vnc",
containerID: containerID,
containerPort: defaultVNCPort,
subpath: rest, // keep /vnc/{containerID}/ws
}, nil
}
if strings.HasPrefix(rest, "/proxy/") {
// /proxy/{containerID}:{port}/{subpath}
proxyPath := strings.TrimPrefix(rest, "/proxy")
// proxyPath = /{containerID}:{port}/{subpath}
proxyPath = strings.TrimPrefix(proxyPath, "/")
if proxyPath == "" {
return nil, fmt.Errorf("empty proxy path")
}
slash := strings.IndexByte(proxyPath, '/')
var head, subpath string
if slash == -1 {
head = proxyPath
subpath = "/"
} else {
head = proxyPath[:slash]
subpath = proxyPath[slash:]
}
colon := strings.LastIndexByte(head, ':')
if colon < 0 {
return nil, fmt.Errorf("missing port in proxy path: %s", path)
}
containerID := head[:colon]
portStr := head[colon+1:]
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid port %q in proxy path: %w", portStr, err)
}
return &forwardRoute{
channelType: "proxy",
containerID: containerID,
containerPort: port,
subpath: subpath,
}, nil
}
return nil, fmt.Errorf("unknown route pattern: %s", rest)
}
// rewriteRequest clones the request and sets the path to the route's subpath.
//
// For proxy: the path becomes the subpath (e.g. /foo/bar).
// For VNC: the path keeps /vnc/{containerID}/ws as-is.
func rewriteRequest(orig *http.Request, taiID string, route *forwardRoute) *http.Request {
r := orig.Clone(orig.Context())
r.URL.Path = route.subpath
r.RequestURI = r.URL.RequestURI()
return r
}
// netConnAdapter wraps an io.ReadWriteCloser as needed by bridgeTCP.
type netConnAdapter struct {
io.ReadWriteCloser
}