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

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)
})
}