diff --git a/.gitignore b/.gitignore index b0eb56e..2c9e147 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.mp4 logs/ +.DS_Store diff --git a/README.md b/README.md index 38d5d6e..779289c 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ wget http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBun Use ffmpeg to create a LL-DASH playlist. This creates a segment every 2s and MP4 fragment every 10ms. ``` -ffmpeg -i media/source.mp4 -f dash -use_timeline 0 -r:v 24 -g:v 48 -keyint_min:v 48 -sc_threshold:v 0 -tune zerolatency -streaming 1 -ldash 1 -seg_duration 2 -frag_duration 0.01 -frag_type duration media/playlist.mpd +./media/generate ``` You can increase the `frag_duration` (microseconds) to slightly reduce the file size in exchange for higher latency. @@ -62,10 +62,10 @@ If you have a valid certificate you can use it instead of self-signing. The go b Otherwise, use [mkcert](https://github.com/FiloSottile/mkcert) to install a self-signed CA: ``` -mkcert -install +./generate/cert ``` -With no arguments, the server will generate self-signed cert using this root CA. +With no arguments, the server will generate self-signed cert using this root CA. This certificate is only valid for *2 weeks* due to how WebTransport performs certificate fingerprinting. ## Server The Warp server supports WebTransport, pushing media over streams once a connection has been established. A more refined implementation would load content based on the WebTransport URL or some other messaging scheme. @@ -89,13 +89,3 @@ yarn serve These can be accessed on `https://localhost:4444` by default. If you use a custom domain for the Warp server, make sure to override the server URL with the `url` query string parameter, e.g. `https://localhost:4444/?url=https://warp.demo`. - -## Chrome -Now we need to make Chrome accept these certificates, which normally would involve trusting a root CA but this was not working with WebTransport when I last tried. - -Instead, we need to run a *fresh instance* of Chrome, instructing it to allow our self-signed certificate. This command will not work if Chrome is already running, so it's easier to use Chrome Canary instead. - -Launch a new instance of Chrome Canary: -``` -/Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary --allow-insecure-localhost --origin-to-force-quic-on=localhost:4443 https://localhost:4444 -``` diff --git a/cert/fingerprint b/cert/fingerprint deleted file mode 100755 index ba533e6..0000000 --- a/cert/fingerprint +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -set -euo pipefail - -HOST="localhost" - -cd "$(dirname "${BASH_SOURCE[0]}")" - -# Outputs the certificate fingerprint in the format Chrome expects -openssl x509 -pubkey -noout -in "${HOST}.crt" | - openssl rsa -pubin -outform der 2>/dev/null | - openssl dgst -sha256 -binary | - base64 diff --git a/cert/generate b/cert/generate index 7ee63d5..5103f25 100755 --- a/cert/generate +++ b/cert/generate @@ -3,4 +3,20 @@ set -euxo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" -mkcert -cert-file localhost.crt -key-file localhost.key localhost 127.0.0.1 ::1 +# Generate a new RSA key/cert for local development +HOST="localhost" +CRT="$HOST.crt" +KEY="$HOST.key" + +# Install the system certificate if it's not already +mkcert -install + +# Generate a new certificate for localhost +mkcert -ecdsa -cert-file "$CRT" -key-file "$KEY" localhost 127.0.0.1 ::1 + +# Reduce the expiration time of the certificate to 14 days; the WebTransport maximum. +# TODO https://github.com/FiloSottile/mkcert/pull/513 +openssl x509 -days 14 -in "$CRT" -signkey "$KEY" -out "$CRT" + +# Compute the sha256 fingerprint of the certificate for WebTransport +# openssl x509 -in "$CRT" -outform der | openssl dgst -sha256 diff --git a/player/package.json b/player/package.json index cbbdf4f..56ac7ae 100644 --- a/player/package.json +++ b/player/package.json @@ -1,7 +1,7 @@ { "source": "src/index.html", "scripts": { - "serve": "parcel serve --https --host localhost --port 4444 --cert ../cert/localhost.crt --key ../cert/localhost.key", + "serve": "parcel serve --https --cert ../cert/localhost.crt --key ../cert/localhost.key --host localhost --port 4444 --open", "build": "parcel build", "check": "tsc --noEmit" }, diff --git a/player/src/player.css b/player/src/index.css similarity index 100% rename from player/src/player.css rename to player/src/index.css diff --git a/player/src/index.html b/player/src/index.html index efb254c..92e0bdd 100644 --- a/player/src/index.html +++ b/player/src/index.html @@ -5,7 +5,7 @@ WARP - + @@ -29,54 +29,6 @@ - + diff --git a/player/src/index.ts b/player/src/index.ts new file mode 100644 index 0000000..40dbb7a --- /dev/null +++ b/player/src/index.ts @@ -0,0 +1,48 @@ +import { Player } from "./player" + +// This is so ghetto but I'm too lazy to improve it right now +const videoRef = document.querySelector("video#vid")!; +const liveRef = document.querySelector("#live")!; +const throttleRef = document.querySelector("#throttle")!; +const statsRef = document.querySelector("#stats")!; +const playRef = document.querySelector("#play")!; + +const params = new URLSearchParams(window.location.search) + +const url = params.get("url") || "https://localhost:4443/watch" + +const player = new Player({ + url: url, + videoRef: videoRef, + statsRef: statsRef, + throttleRef: throttleRef, +}) + +liveRef.addEventListener("click", (e) => { + e.preventDefault() + player.goLive() +}) + +throttleRef.addEventListener("click", (e) => { + e.preventDefault() + player.throttle() +}) + +playRef.addEventListener('click', (e) => { + videoRef.play() + e.preventDefault() +}) + +function playFunc(e: Event) { + playRef.style.display = "none" + //player.goLive() + + // Only fire once to restore pause/play functionality + videoRef.removeEventListener('play', playFunc) +} + +videoRef.addEventListener('play', playFunc) +videoRef.volume = 0.5 + +// Try to autoplay but ignore errors on mobile; they need to click +//vidRef.play().catch((e) => console.warn(e)) \ No newline at end of file diff --git a/player/src/player.ts b/player/src/player.ts index 5eac5d8..46487b9 100644 --- a/player/src/player.ts +++ b/player/src/player.ts @@ -7,6 +7,18 @@ import { Message, MessageInit, MessageSegment } from "./message" /// +export interface PlayerInit { + url: string; + + videoRef: HTMLVideoElement; + statsRef: HTMLElement; + throttleRef: HTMLElement; +} + +/* +*/ + + export class Player { mediaSource: MediaSource; @@ -20,17 +32,17 @@ export class Player { // References to elements in the DOM vidRef: HTMLVideoElement; // The video element itself statsRef: HTMLElement; // The stats div - throttleRef: HTMLButtonElement; // The throttle button + throttleRef: HTMLElement; // The throttle button throttleCount: number; // number of times we've clicked the button in a row interval: number; timeRef?: DOMHighResTimeStamp; - constructor(props: any) { - this.vidRef = props.vid - this.statsRef = props.stats - this.throttleRef = props.throttle + constructor(props: PlayerInit) { + this.vidRef = props.videoRef + this.statsRef = props.statsRef + this.throttleRef = props.throttleRef this.throttleCount = 0 this.mediaSource = new MediaSource() @@ -43,8 +55,7 @@ export class Player { this.interval = setInterval(this.tick.bind(this), 100) this.vidRef.addEventListener("waiting", this.tick.bind(this)) - const quic = new WebTransport(props.url) - this.quic = quic.ready.then(() => { return quic }); + this.quic = this.connect(props.url) // Create a unidirectional stream for all of our messages this.api = this.quic.then((q) => { @@ -63,6 +74,38 @@ export class Player { (await this.quic).close() } + async connect(url: string): Promise { + // TODO remove this when WebTransport supports the system CA pool + const fingerprintURL = new URL(url); + fingerprintURL.pathname = "/fingerprint" + + const response = await fetch(fingerprintURL) + if (!response.ok) { + throw new Error('failed to get server fingerprint'); + } + + const hex = await response.text() + + // Convert the hex to binary. + let fingerprint = []; + for (let c = 0; c < hex.length; c += 2) { + fingerprint.push(parseInt(hex.substring(c, c+2), 16)); + } + + //const fingerprint = Uint8Array.from(atob(hex), c => c.charCodeAt(0)) + + const quic = new WebTransport(url, { + "serverCertificateHashes": [{ + "algorithm": "sha-256", + "value": new Uint8Array(fingerprint), + }] + }) + + await quic.ready + + return quic + } + async sendMessage(msg: any) { const payload = JSON.stringify(msg) const size = payload.length + 8 diff --git a/player/tsconfig.json b/player/tsconfig.json index e87a581..9762983 100644 --- a/player/tsconfig.json +++ b/player/tsconfig.json @@ -1,12 +1,13 @@ { - "include": ["src/**/*"], + "include": [ + "src/**/*" + ], "compilerOptions": { "target": "es2021", "strict": true, - "typeRoots": [ + "typeRoots": [ "src/types" - ], - "allowJs": true + ], + "allowJs": true } -} - +} \ No newline at end of file diff --git a/server/go.mod b/server/go.mod index 09af253..1b83d5f 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,4 +1,4 @@ -module github.com/kixelated/warp-demo/server +module github.com/kixelated/warp/server go 1.18 diff --git a/server/go.sum b/server/go.sum index fd6ebf9..a709198 100644 --- a/server/go.sum +++ b/server/go.sum @@ -68,7 +68,7 @@ github.com/kisielk/errcheck v1.4.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kixelated/invoker v1.0.0 h1:0wYlvK39yQPbkwIFy+YN41AhF89WOtGyWqV2pZB39xw= github.com/kixelated/invoker v1.0.0/go.mod h1:RjG3iqm/sKwZjOpcW4SGq+l+4DJCDR/yUtc70VjCRB8= -github.com/kixelated/quic-go v1.31.0 h1:O3JomeXPnLNSCNpZF415NWOyfpzbFfuvP6dlIDg8VEA= +github.com/kixelated/quic-go v1.31.0 h1:p2vq3Otvtmz+0EP23vjumnO/HU4Q/DFxNF6xNryVfmA= github.com/kixelated/quic-go v1.31.0/go.mod h1:AO7pURnb8HXHmdalp5e09UxQfsuwseEhl0NLmwiSOFY= github.com/kixelated/webtransport-go v1.4.1 h1:ZtY3P7hVe1wK5fAt71b+HHnNISFDcQ913v+bvaNATxA= github.com/kixelated/webtransport-go v1.4.1/go.mod h1:6RV5pTXF7oP53T83bosSDsLdSdw31j5cfpMDqsO4D5k= diff --git a/server/internal/warp/server.go b/server/internal/warp/warp.go similarity index 66% rename from server/internal/warp/server.go rename to server/internal/warp/warp.go index 1b78d17..9ad6f18 100644 --- a/server/internal/warp/server.go +++ b/server/internal/warp/warp.go @@ -20,20 +20,23 @@ import ( ) type Server struct { - inner *webtransport.Server - media *Media - + inner *webtransport.Server + media *Media sessions invoker.Tasks + cert *tls.Certificate } -type ServerConfig struct { +type Config struct { Addr string Cert *tls.Certificate LogDir string + Media *Media } -func NewServer(config ServerConfig, media *Media) (s *Server, err error) { +func New(config Config) (s *Server, err error) { s = new(Server) + s.cert = config.Cert + s.media = config.Media quicConfig := &quic.Config{} @@ -52,10 +55,12 @@ func NewServer(config ServerConfig, media *Media) (s *Server, err error) { } tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{*config.Cert}, + Certificates: []tls.Certificate{*s.cert}, } + // Host a HTTP/3 server to serve the WebTransport endpoint mux := http.NewServeMux() + mux.HandleFunc("/watch", s.handleWatch) s.inner = &webtransport.Server{ H3: http3.Server{ @@ -67,28 +72,6 @@ func NewServer(config ServerConfig, media *Media) (s *Server, err error) { CheckOrigin: func(r *http.Request) bool { return true }, } - s.media = media - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - hijacker, ok := w.(http3.Hijacker) - if !ok { - panic("unable to hijack connection: must use kixelated/quic-go") - } - - conn := hijacker.Connection() - - sess, err := s.inner.Upgrade(w, r) - if err != nil { - http.Error(w, "failed to upgrade session", 500) - return - } - - err = s.serve(r.Context(), conn, sess) - if err != nil { - log.Println(err) - } - }) - return s, nil } @@ -98,7 +81,7 @@ func (s *Server) runServe(ctx context.Context) (err error) { func (s *Server) runShutdown(ctx context.Context) (err error) { <-ctx.Done() - s.inner.Close() + s.inner.Close() // close on context shutdown return ctx.Err() } @@ -106,7 +89,27 @@ func (s *Server) Run(ctx context.Context) (err error) { return invoker.Run(ctx, s.runServe, s.runShutdown, s.sessions.Repeat) } -func (s *Server) serve(ctx context.Context, conn quic.Connection, sess *webtransport.Session) (err error) { +func (s *Server) handleWatch(w http.ResponseWriter, r *http.Request) { + hijacker, ok := w.(http3.Hijacker) + if !ok { + panic("unable to hijack connection: must use kixelated/quic-go") + } + + conn := hijacker.Connection() + + sess, err := s.inner.Upgrade(w, r) + if err != nil { + http.Error(w, "failed to upgrade session", 500) + return + } + + err = s.serveSession(r.Context(), conn, sess) + if err != nil { + log.Println(err) + } +} + +func (s *Server) serveSession(ctx context.Context, conn quic.Connection, sess *webtransport.Session) (err error) { defer func() { if err != nil { sess.CloseWithError(1, err.Error()) diff --git a/server/internal/web/web.go b/server/internal/web/web.go new file mode 100644 index 0000000..dfd766b --- /dev/null +++ b/server/internal/web/web.go @@ -0,0 +1,64 @@ +package web + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/kixelated/invoker" +) + +type Server struct { + inner http.Server + config Config +} + +type Config struct { + Addr string + CertFile string + KeyFile string + Fingerprint string // the TLS certificate fingerprint +} + +func New(config Config) (s *Server) { + s = new(Server) + s.config = config + + s.inner = http.Server{ + Addr: config.Addr, + } + + http.HandleFunc("/fingerprint", s.handleFingerprint) + + return s +} + +func (s *Server) Run(ctx context.Context) (err error) { + return invoker.Run(ctx, s.runServe, s.runShutdown) +} + +func (s *Server) runServe(context.Context) (err error) { + // NOTE: Doesn't support context, which is why we need runShutdown + err = s.inner.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile) + log.Println(err) + return err +} + +// Gracefully shut down the server when the context is cancelled +func (s *Server) runShutdown(ctx context.Context) (err error) { + <-ctx.Done() + + timeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.inner.Shutdown(timeout) + + return ctx.Err() +} + +// Return the sha256 of the certificate as a temporary work-around for local development. +// TODO remove this when WebTransport uses the system CA +func (s *Server) handleFingerprint(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + _, _ = w.Write([]byte(s.config.Fingerprint)) +} diff --git a/server/main.go b/server/main.go index 39753ba..a0b5f9b 100644 --- a/server/main.go +++ b/server/main.go @@ -2,13 +2,16 @@ package main import ( "context" + "crypto/sha256" "crypto/tls" + "encoding/hex" "flag" "fmt" "log" "github.com/kixelated/invoker" - "github.com/kixelated/warp-demo/server/internal/warp" + "github.com/kixelated/warp/server/internal/warp" + "github.com/kixelated/warp/server/internal/web" ) func main() { @@ -38,18 +41,31 @@ func run(ctx context.Context) (err error) { return fmt.Errorf("failed to load TLS certificate: %w", err) } - config := warp.ServerConfig{ + warpConfig := warp.Config{ Addr: *addr, Cert: &tlsCert, LogDir: *logDir, + Media: media, } - ws, err := warp.NewServer(config, media) + warpServer, err := warp.New(warpConfig) if err != nil { return fmt.Errorf("failed to create warp server: %w", err) } + hash := sha256.Sum256(tlsCert.Certificate[0]) + fingerprint := hex.EncodeToString(hash[:]) + + webConfig := web.Config{ + Addr: *addr, + CertFile: *cert, + KeyFile: *key, + Fingerprint: fingerprint, + } + + webServer := web.New(webConfig) + log.Printf("listening on %s", *addr) - return invoker.Run(ctx, invoker.Interrupt, ws.Run) + return invoker.Run(ctx, invoker.Interrupt, warpServer.Run, webServer.Run) }