You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
127 lines
3.9 KiB
127 lines
3.9 KiB
|
2 months ago
|
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)
|
||
|
|
})
|
||
|
|
}
|