package webgateway import ( "errors" "fmt" "net" "net/http" "net/http/httputil" "net/url" "os" "path/filepath" "regexp" "strings" ) var ( channelPattern = regexp.MustCompile(`^CH(?:0[1-9]|[1-3][0-9]|4[0-8])$`) sessionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) ) type Options struct { StaticRoot string APIUpstream string SessionUpstream string } type gateway struct { static http.Handler api http.Handler session http.Handler } func NewHandler(options Options) (http.Handler, error) { root, err := filepath.Abs(options.StaticRoot) if err != nil || options.StaticRoot == "" { return nil, errors.New("tera-web static root is required") } info, err := os.Stat(root) if err != nil || !info.IsDir() { return nil, errors.New("tera-web static root is not a directory") } apiTarget, err := parseLoopbackUpstream(options.APIUpstream) if err != nil { return nil, fmt.Errorf("API upstream: %w", err) } sessionTarget, err := parseLoopbackUpstream(options.SessionUpstream) if err != nil { return nil, fmt.Errorf("session upstream: %w", err) } handler := &gateway{ static: http.FileServer(http.Dir(root)), api: reverseProxy(apiTarget), session: reverseProxy(sessionTarget), } return securityHeaders(handler), nil } func (g *gateway) ServeHTTP(response http.ResponseWriter, request *http.Request) { if strings.HasPrefix(request.URL.Path, "/api/v1/") { if isDirectStreamPath(request.URL.Path) { g.session.ServeHTTP(response, request) return } g.api.ServeHTTP(response, request) return } g.static.ServeHTTP(response, request) } func parseLoopbackUpstream(value string) (*url.URL, error) { target, err := url.Parse(strings.TrimSpace(value)) if err != nil || target.Scheme != "http" || target.Host == "" || target.User != nil { return nil, errors.New("upstream must be an unauthenticated http loopback URL") } hostname := target.Hostname() address := net.ParseIP(hostname) if hostname != "localhost" && (address == nil || !address.IsLoopback()) { return nil, errors.New("upstream host must be loopback") } if target.Path != "" && target.Path != "/" { return nil, errors.New("upstream URL must not contain a path") } target.Path = "" return target, nil } func reverseProxy(target *url.URL) http.Handler { proxy := httputil.NewSingleHostReverseProxy(target) director := proxy.Director proxy.Director = func(request *http.Request) { publicHost := request.Host forwardedProto := "http" if request.TLS != nil { forwardedProto = "https" } // Discard any client-supplied forwarding chain. ReverseProxy appends // the actual peer IP after Director returns, giving tera-sessiond one // trusted source address for its welcome banner and port-session log. request.Header.Del("X-Forwarded-For") director(request) request.Host = publicHost request.Header.Set("X-Forwarded-Proto", forwardedProto) } proxy.ErrorHandler = func(response http.ResponseWriter, _ *http.Request, _ error) { http.Error(response, "upstream unavailable", http.StatusBadGateway) } return proxy } func isDirectStreamPath(path string) bool { parts := strings.Split(strings.Trim(path, "/"), "/") return len(parts) == 7 && parts[0] == "api" && parts[1] == "v1" && parts[2] == "direct" && channelPattern.MatchString(parts[3]) && parts[4] == "sessions" && sessionPattern.MatchString(parts[5]) && parts[6] == "stream" } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { response.Header().Set("X-Content-Type-Options", "nosniff") response.Header().Set("X-Frame-Options", "DENY") response.Header().Set("Referrer-Policy", "no-referrer") response.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'") next.ServeHTTP(response, request) }) }