// Package console serves the loopback-only Sense engineering console. package console import ( "embed" "encoding/json" "errors" "fmt" "net" "net/http" "net/url" "strings" ) //go:embed assets/index.html assets/app.css assets/app.js var assets embed.FS type handler struct { previewBaseURL string previewOrigin string } // NewHandler builds the self-contained console handler. Config validation also // enforces this boundary; validating here keeps the package safe in isolation. func NewHandler(previewBaseURL string) (http.Handler, error) { parsed, err := url.Parse(previewBaseURL) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { return nil, errors.New("invalid Sense console WebRTC base URL") } host := parsed.Hostname() ip := net.ParseIP(host) if host != "localhost" && (ip == nil || !ip.IsLoopback()) { return nil, errors.New("Sense console WebRTC base URL is not loopback") } base := strings.TrimRight(parsed.String(), "/") return &handler{ previewBaseURL: base, previewOrigin: parsed.Scheme + "://" + parsed.Host, }, nil } func (h *handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { h.securityHeaders(writer) if request.Method != http.MethodGet && request.Method != http.MethodHead { writer.Header().Set("Allow", "GET, HEAD") http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) return } switch request.URL.Path { case "/sense-console/": h.serveAsset(writer, request, "assets/index.html", "text/html; charset=utf-8") case "/sense-console/app.css": h.serveAsset(writer, request, "assets/app.css", "text/css; charset=utf-8") case "/sense-console/app.js": h.serveAsset(writer, request, "assets/app.js", "text/javascript; charset=utf-8") case "/sense-console/config": writer.Header().Set("Content-Type", "application/json") if request.Method == http.MethodHead { writer.WriteHeader(http.StatusOK) return } _ = json.NewEncoder(writer).Encode(map[string]any{ "webrtc_base_url": h.previewBaseURL, "page_size": 16, "max_active_previews": 4, "recording_and_playback": false, }) default: http.NotFound(writer, request) } } func (h *handler) securityHeaders(writer http.ResponseWriter) { writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("Content-Security-Policy", fmt.Sprintf( "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src %s; img-src 'self' data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", h.previewOrigin, )) writer.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()") writer.Header().Set("Referrer-Policy", "no-referrer") writer.Header().Set("X-Content-Type-Options", "nosniff") writer.Header().Set("X-Frame-Options", "DENY") } func (h *handler) serveAsset(writer http.ResponseWriter, request *http.Request, path, contentType string) { contents, err := assets.ReadFile(path) if err != nil { http.Error(writer, "asset unavailable", http.StatusInternalServerError) return } writer.Header().Set("Content-Type", contentType) writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(contents))) writer.WriteHeader(http.StatusOK) if request.Method == http.MethodGet { _, _ = writer.Write(contents) } }