Remove the need to use Chrome Canary.

Barely works but getting there.
This commit is contained in:
Luke Curley 2023-03-26 12:35:33 -07:00
parent 476f0fbce1
commit ff73adc537
15 changed files with 248 additions and 126 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
*.mp4 *.mp4
logs/ logs/
.DS_Store

View File

@ -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. 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. 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: 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 ## 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. 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. 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`. 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
```

View File

@ -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

View File

@ -3,4 +3,20 @@ set -euxo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" 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

View File

@ -1,7 +1,7 @@
{ {
"source": "src/index.html", "source": "src/index.html",
"scripts": { "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", "build": "parcel build",
"check": "tsc --noEmit" "check": "tsc --noEmit"
}, },

View File

@ -5,7 +5,7 @@
<meta charset = "UTF-8"> <meta charset = "UTF-8">
<title>WARP</title> <title>WARP</title>
<link rel="stylesheet" href="player.css"> <link rel="stylesheet" href="index.css">
</head> </head>
<body> <body>
@ -29,54 +29,6 @@
</div> </div>
</div> </div>
<script type="module"> <script src="index.ts" type="module"></script>
import { Player } from "./player.ts"
// This is so ghetto but I'm too lazy to improve it right now
const vidRef = document.getElementById("vid")
const liveRef = document.getElementById("live")
const throttleRef = document.getElementById("throttle")
const statsRef = document.getElementById("stats")
const playRef = document.getElementById("play")
const params = new URLSearchParams(window.location.search)
const player = new Player({
url: params.get("url") || "https://localhost:4443",
vid: vidRef,
stats: statsRef,
throttle: throttleRef,
})
liveRef.addEventListener("click", (e) => {
e.preventDefault()
player.goLive()
})
throttleRef.addEventListener("click", (e) => {
e.preventDefault()
player.throttle()
})
playRef.addEventListener('click', (e) => {
vidRef.play()
e.preventDefault()
})
function playFunc(e) {
playRef.style.display = "none"
//player.goLive()
// Only fire once to restore pause/play functionality
vidRef.removeEventListener('play', playFunc)
}
vidRef.addEventListener('play', playFunc)
vidRef.volume = 0.5
// Try to autoplay but ignore errors on mobile; they need to click
//vidRef.play().catch((e) => console.warn(e))
</script>
</body> </body>
</html> </html>

48
player/src/index.ts Normal file
View File

@ -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<HTMLVideoElement>("video#vid")!;
const liveRef = document.querySelector<HTMLElement>("#live")!;
const throttleRef = document.querySelector<HTMLElement>("#throttle")!;
const statsRef = document.querySelector<HTMLElement>("#stats")!;
const playRef = document.querySelector<HTMLElement>("#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))

View File

@ -7,6 +7,18 @@ import { Message, MessageInit, MessageSegment } from "./message"
///<reference path="./types/webtransport.d.ts"/> ///<reference path="./types/webtransport.d.ts"/>
export interface PlayerInit {
url: string;
videoRef: HTMLVideoElement;
statsRef: HTMLElement;
throttleRef: HTMLElement;
}
/*
*/
export class Player { export class Player {
mediaSource: MediaSource; mediaSource: MediaSource;
@ -20,17 +32,17 @@ export class Player {
// References to elements in the DOM // References to elements in the DOM
vidRef: HTMLVideoElement; // The video element itself vidRef: HTMLVideoElement; // The video element itself
statsRef: HTMLElement; // The stats div 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 throttleCount: number; // number of times we've clicked the button in a row
interval: number; interval: number;
timeRef?: DOMHighResTimeStamp; timeRef?: DOMHighResTimeStamp;
constructor(props: any) { constructor(props: PlayerInit) {
this.vidRef = props.vid this.vidRef = props.videoRef
this.statsRef = props.stats this.statsRef = props.statsRef
this.throttleRef = props.throttle this.throttleRef = props.throttleRef
this.throttleCount = 0 this.throttleCount = 0
this.mediaSource = new MediaSource() this.mediaSource = new MediaSource()
@ -43,8 +55,7 @@ export class Player {
this.interval = setInterval(this.tick.bind(this), 100) this.interval = setInterval(this.tick.bind(this), 100)
this.vidRef.addEventListener("waiting", this.tick.bind(this)) this.vidRef.addEventListener("waiting", this.tick.bind(this))
const quic = new WebTransport(props.url) this.quic = this.connect(props.url)
this.quic = quic.ready.then(() => { return quic });
// Create a unidirectional stream for all of our messages // Create a unidirectional stream for all of our messages
this.api = this.quic.then((q) => { this.api = this.quic.then((q) => {
@ -63,6 +74,38 @@ export class Player {
(await this.quic).close() (await this.quic).close()
} }
async connect(url: string): Promise<WebTransport> {
// 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) { async sendMessage(msg: any) {
const payload = JSON.stringify(msg) const payload = JSON.stringify(msg)
const size = payload.length + 8 const size = payload.length + 8

View File

@ -1,12 +1,13 @@
{ {
"include": ["src/**/*"], "include": [
"src/**/*"
],
"compilerOptions": { "compilerOptions": {
"target": "es2021", "target": "es2021",
"strict": true, "strict": true,
"typeRoots": [ "typeRoots": [
"src/types" "src/types"
], ],
"allowJs": true "allowJs": true
} }
} }

View File

@ -1,4 +1,4 @@
module github.com/kixelated/warp-demo/server module github.com/kixelated/warp/server
go 1.18 go 1.18

View File

@ -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/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 h1:0wYlvK39yQPbkwIFy+YN41AhF89WOtGyWqV2pZB39xw=
github.com/kixelated/invoker v1.0.0/go.mod h1:RjG3iqm/sKwZjOpcW4SGq+l+4DJCDR/yUtc70VjCRB8= 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/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 h1:ZtY3P7hVe1wK5fAt71b+HHnNISFDcQ913v+bvaNATxA=
github.com/kixelated/webtransport-go v1.4.1/go.mod h1:6RV5pTXF7oP53T83bosSDsLdSdw31j5cfpMDqsO4D5k= github.com/kixelated/webtransport-go v1.4.1/go.mod h1:6RV5pTXF7oP53T83bosSDsLdSdw31j5cfpMDqsO4D5k=

View File

@ -20,20 +20,23 @@ import (
) )
type Server struct { type Server struct {
inner *webtransport.Server inner *webtransport.Server
media *Media media *Media
sessions invoker.Tasks sessions invoker.Tasks
cert *tls.Certificate
} }
type ServerConfig struct { type Config struct {
Addr string Addr string
Cert *tls.Certificate Cert *tls.Certificate
LogDir string 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 = new(Server)
s.cert = config.Cert
s.media = config.Media
quicConfig := &quic.Config{} quicConfig := &quic.Config{}
@ -52,10 +55,12 @@ func NewServer(config ServerConfig, media *Media) (s *Server, err error) {
} }
tlsConfig := &tls.Config{ 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 := http.NewServeMux()
mux.HandleFunc("/watch", s.handleWatch)
s.inner = &webtransport.Server{ s.inner = &webtransport.Server{
H3: http3.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 }, 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 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) { func (s *Server) runShutdown(ctx context.Context) (err error) {
<-ctx.Done() <-ctx.Done()
s.inner.Close() s.inner.Close() // close on context shutdown
return ctx.Err() 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) 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() { defer func() {
if err != nil { if err != nil {
sess.CloseWithError(1, err.Error()) sess.CloseWithError(1, err.Error())

View File

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

View File

@ -2,13 +2,16 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"encoding/hex"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"github.com/kixelated/invoker" "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() { func main() {
@ -38,18 +41,31 @@ func run(ctx context.Context) (err error) {
return fmt.Errorf("failed to load TLS certificate: %w", err) return fmt.Errorf("failed to load TLS certificate: %w", err)
} }
config := warp.ServerConfig{ warpConfig := warp.Config{
Addr: *addr, Addr: *addr,
Cert: &tlsCert, Cert: &tlsCert,
LogDir: *logDir, LogDir: *logDir,
Media: media,
} }
ws, err := warp.NewServer(config, media) warpServer, err := warp.New(warpConfig)
if err != nil { if err != nil {
return fmt.Errorf("failed to create warp server: %w", err) 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) log.Printf("listening on %s", *addr)
return invoker.Run(ctx, invoker.Interrupt, ws.Run) return invoker.Run(ctx, invoker.Interrupt, warpServer.Run, webServer.Run)
} }