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
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.
```
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
```

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]}")"
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",
"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"
},

View File

@ -5,7 +5,7 @@
<meta charset = "UTF-8">
<title>WARP</title>
<link rel="stylesheet" href="player.css">
<link rel="stylesheet" href="index.css">
</head>
<body>
@ -29,54 +29,6 @@
</div>
</div>
<script type="module">
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>
<script src="index.ts" type="module"></script>
</body>
</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"/>
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<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) {
const payload = JSON.stringify(msg)
const size = payload.length + 8

View File

@ -1,12 +1,13 @@
{
"include": ["src/**/*"],
"include": [
"src/**/*"
],
"compilerOptions": {
"target": "es2021",
"strict": true,
"typeRoots": [
"typeRoots": [
"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

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/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=

View File

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

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