├── .gitignore ├── LICENSE ├── README.md ├── browser-answer.html ├── browser-offer.html ├── browser.js ├── cli_offer.go └── local_web_server.go /.gitignore: -------------------------------------------------------------------------------- 1 | /*.exe 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Chad Retz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebRTC IPFS Signaling 2 | 3 | This project is a proof of concept to see whether we can use IPFS to do WebRTC signaling obviating the need for a 4 | separate server. 5 | 6 | ### Goal 1 - Browser to Browser Signaling 7 | 8 | Status: **Accomplished** 9 | 10 | I currently have debugging turned on so the console logs do show some details. Steps: 11 | 12 | Navigate to https://cretz.github.io/webrtc-ipfs-signaling/browser-offer.html. It will update the URL w/ a randm hash. 13 | (Of course, you could navigate to a hand-entered URL hash on a fresh tab). Take the URL given on that page and, in 14 | theory, open it up with the latest Chrome or FF anywhere in the world (that doesn't require TURN). After a few seconds, 15 | the offer/answer will communicate and WebRTC will be connected. 16 | 17 | Quick notes: 18 | 19 | * It may seem like you need some kind of server to share that URL hash, but that's just an example to make it easier. It 20 | could be any preshared value, though you'll want it unique and may want to do other forms of authentication once 21 | connected. E.g. one person could just go to 22 | https://cretz.github.io/webrtc-ipfs-signaling/browser-offer.html#someknownkey and the other person could just go to 23 | https://cretz.github.io/webrtc-ipfs-signaling/browser-answer.html#someknownkey 24 | * This works with `file:///` on Chrome and FF too, even across each other or mixed with traditional http pages. 25 | * I have tested on mobile FF on Android and it works gloriously. I haven't tested any other browsers beyond Chrome 26 | desktop, FF desktop, and FF mobile. 27 | * For this tech demo, I just use Google's public STUN server and no TURN server so if you require TURN this won't work 28 | for you. 29 | * This uses js-ipfs's pubsub support which is in an experimental state. I even hardcode a swarm to 30 | https://ws-star.discovery.libp2p.io/. So it probably won't work if this repo goes stale (which it likely will). I'm 31 | also unsure how it'd hold up to any load. 32 | * js-ipfs is pretty big at > 600k right now. 33 | * You might ask, "Why use WebRTC at all if you have a PubSub connection?" The features of WebRTC are many, in fact with 34 | the latest Chrome release, I am making a screen sharing tool requiring no local code. 35 | * This is just a tech demo so I took a lot of shortcuts like not supporting restarts, poor error handling, etc. It would 36 | be quite trivial to have multiple subscribers to a topic and group chat with multiple offer/answer handshakes. 37 | * Security...not much. Essentially you need to do some other form of security (WebRTC peerIdentity? app level ID verify 38 | once connected? etc). In practice, this is like an open signaling server. 39 | 40 | **How it Works** 41 | 42 | It's quite simple. Both sides subscribe to a pubsub topic named after the preshared identifier. Then I just send JSON'd 43 | [RTCSessionDescription](https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription)s back and forth. 44 | Specifically, the offer side sends the offer every two seconds until it gets an answer whereas the answer side waits for 45 | an offer then sends an answer. 46 | 47 | ### Goal 2 - Browser to Native App Signaling 48 | 49 | Status: **Failing** 50 | 51 | I was pretty sure I could easily hook up [ipfs/go-ipfs](https://github.com/ipfs/go-ipfs) with 52 | [pions/webrtc](https://github.com/pions/webrtc) and be all good. Although `pions/webrtc` is beautifully built, go-ipfs 53 | is not and very much not developer friendly for reading code or embedding. I was forced to use 54 | [ipsn/go-ipfs](https://github.com/ipsn/go-ipfs) which re-best-practicizes the deps. I have the code at 55 | [cli_offer.go](cli_offer.go) and while it looks right, the JS side cannot see the pubsub messages. I have a lot of 56 | annoying debugging to do in that unfriendly codebase. -------------------------------------------------------------------------------- /browser-answer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WebRTC IPFS Signaling Answerer 5 | 6 | 7 | 8 | 9 | 10 |

WebRTC IPFS Signaling Answerer

11 |
12 | URL to offer: 13 | 14 | 15 |
16 |
17 |
18 | 19 | 20 |
21 | 22 | 23 |
24 |
25 | 26 | 80 | 81 | -------------------------------------------------------------------------------- /browser-offer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WebRTC IPFS Signaling Offerer 5 | 6 | 7 | 8 | 9 | 10 |

WebRTC IPFS Signaling Offerer

11 |
12 | URL to answer: 13 | 14 | 15 |
16 |
17 |
18 | 19 | 20 |
21 | 22 | 23 |
24 |
25 | 26 | 86 | 87 | -------------------------------------------------------------------------------- /browser.js: -------------------------------------------------------------------------------- 1 | 2 | const debug = console.log 3 | // const debug = () => { } 4 | 5 | function createWindowHashIfNotPresent() { 6 | if (window.location.hash) return 7 | const base58Chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' 8 | let array = new Uint8Array(20) 9 | window.crypto.getRandomValues(array); 10 | array = array.map(x => base58Chars.charCodeAt(x % base58Chars.length)); 11 | window.history.replaceState(null, null, '#' + String.fromCharCode.apply(null, array)) 12 | } 13 | 14 | function newIPFS(cb) { 15 | const ipfs = new Ipfs({ 16 | repo: String(Math.random() + Date.now()), 17 | EXPERIMENTAL: { pubsub: true }, 18 | config: { 19 | Addresses: { 20 | Swarm: ['/dns4/ws-star.discovery.libp2p.io/tcp/443/wss/p2p-websocket-star'] 21 | } 22 | } 23 | // libp2p: { 24 | // config: { 25 | // dht: { 26 | // enabled: true 27 | // } 28 | // } 29 | // } 30 | // relay: { 31 | // enabled: true, 32 | // hop: {enabled: true} 33 | // } 34 | }) 35 | // Wait for peers 36 | ipfs.on('ready', () => { 37 | const tryListPeers = () => { 38 | ipfs.swarm.peers((err, peers) => { 39 | if (err) throw err 40 | debug('Peers', peers) 41 | if (!peers || peers.length == 0) setTimeout(() => tryListPeers(), 1000) 42 | else cb(ipfs) 43 | }) 44 | } 45 | tryListPeers() 46 | }) 47 | } 48 | 49 | function ipfsDirBase() { 50 | return 'wis-poc-' + window.location.hash.substring(1) 51 | } 52 | 53 | function ipfsSubscribe(ipfs, handle, cb) { 54 | ipfs.pubsub.subscribe( 55 | ipfsDirBase(), 56 | msg => handle(msg.data.toString('utf8')), 57 | err => { 58 | if (err) console.error('Failed subscribe', err) 59 | else { 60 | debug('Subscribe to ' + ipfsDirBase() + ' complete') 61 | cb() 62 | } 63 | }) 64 | } 65 | 66 | function ipfsPublish(ipfs, data, cb) { 67 | ipfs.pubsub.publish( 68 | ipfsDirBase(), 69 | ipfs.types.Buffer.from(data), 70 | err => { 71 | if (err) console.error('Failed publish', err) 72 | else { 73 | debug('Publish complete') 74 | cb() 75 | } 76 | }) 77 | } 78 | 79 | function setupChatChannel(channel) { 80 | const areaElem = document.getElementById('chat') 81 | const messageElem = document.getElementById('message') 82 | 83 | channel.onclose = () => areaElem.value += "**system** chat closed\n" 84 | channel.onopen = () => { 85 | messageElem.disabled = false 86 | areaElem.value += "**system** chat started\n" 87 | } 88 | channel.onmessage = e => areaElem.value += '**them** ' + e.data + "\n" 89 | 90 | messageElem.onkeypress = e => { 91 | const message = messageElem.value 92 | if (e.keyCode == 13 && message) { 93 | messageElem.value = '' 94 | areaElem.value += '**me** ' + message + "\n" 95 | channel.send(message) 96 | } 97 | } 98 | } -------------------------------------------------------------------------------- /cli_offer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "crypto/rand" 7 | "encoding/json" 8 | "flag" 9 | "fmt" 10 | "log" 11 | "os" 12 | "sync" 13 | "time" 14 | 15 | "github.com/ipsn/go-ipfs/core" 16 | "github.com/ipsn/go-ipfs/core/coreapi" 17 | coreiface "github.com/ipsn/go-ipfs/core/coreapi/interface" 18 | "github.com/ipsn/go-ipfs/core/coreapi/interface/options" 19 | "github.com/pions/webrtc" 20 | "github.com/pions/webrtc/pkg/datachannel" 21 | "github.com/pions/webrtc/pkg/ice" 22 | ) 23 | 24 | var debugEnabled = true 25 | 26 | func main() { 27 | ctx, cancelFn := context.WithCancel(context.Background()) 28 | defer cancelFn() 29 | // Get ID 30 | id, err := parseFlags() 31 | assertNoErr(err) 32 | fmt.Printf("Visit browser-answer.html#%v or go run cli_answer.go %v\n", id, id) 33 | // Create IPFS 34 | pubsub, err := newIPFSPubSub(ctx) 35 | assertNoErr(err) 36 | // Create peer conn 37 | pc, err := webrtc.New(webrtc.RTCConfiguration{ 38 | IceServers: []webrtc.RTCIceServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, 39 | }) 40 | assertNoErr(err) 41 | // Create the chat channel 42 | channel, err := pc.CreateDataChannel("chat", nil) 43 | assertNoErr(err) 44 | setupChatChannel(channel) 45 | // Debug state change info 46 | pc.OnICEConnectionStateChange(func(state ice.ConnectionState) { 47 | debugf("RTC connection state change: %v", state) 48 | }) 49 | // Subscribe to offer/answer topic 50 | topic := "wis-poc-" + id 51 | sub, err := pubsub.Subscribe(ctx, topic, options.PubSub.Discover(true)) 52 | assertNoErr(err) 53 | debugf("Subscribe to %v complete", topic) 54 | // Listen for first answer 55 | answerCh := make(chan *webrtc.RTCSessionDescription, 1) 56 | go func() { 57 | var desc webrtc.RTCSessionDescription 58 | for { 59 | msg, err := sub.Next(ctx) 60 | assertNoErr(err) 61 | assertNoErr(json.Unmarshal(msg.Data(), &desc)) 62 | debugf("Received data: %v", desc.Type) 63 | if desc.Type == webrtc.RTCSdpTypeAnswer { 64 | answerCh <- &desc 65 | break 66 | } 67 | } 68 | }() 69 | // Create the offer and keep sending until received 70 | offer, err := pc.CreateOffer(nil) 71 | assertNoErr(err) 72 | offerBytes, err := json.Marshal(offer) 73 | assertNoErr(err) 74 | var answer *webrtc.RTCSessionDescription 75 | WaitForAnswer: 76 | for { 77 | debugf("Sending offer") 78 | assertNoErr(pubsub.Publish(ctx, topic, offerBytes)) 79 | select { 80 | case answer = <-answerCh: 81 | fmt.Printf("Got answer") 82 | break WaitForAnswer 83 | case <-time.After(2 * time.Second): 84 | } 85 | } 86 | // Now that we have an answer, set it and block forever (the chat channel does the work now) 87 | assertNoErr(pc.SetRemoteDescription(*answer)) 88 | select {} 89 | } 90 | 91 | func newIPFSPubSub(ctx context.Context) (coreiface.PubSubAPI, error) { 92 | cfg := &core.BuildCfg{ 93 | Online: true, 94 | ExtraOpts: map[string]bool{"pubsub": true}, 95 | } 96 | if node, err := core.NewNode(ctx, cfg); err != nil { 97 | return nil, err 98 | } else if api, err := coreapi.NewCoreAPI(node); err != nil { 99 | return nil, err 100 | } else { 101 | return api.PubSub(), nil 102 | } 103 | } 104 | 105 | func setupChatChannel(channel *webrtc.RTCDataChannel) { 106 | channel.OnClose(func() { printChatLn("**system** chat closed") }) 107 | channel.OnOpen(func() { 108 | printChatLn("**system** chat started") 109 | // Just read stdin forever and try to send it over or panic 110 | r := bufio.NewReader(os.Stdin) 111 | for { 112 | text, _ := r.ReadString('\n') 113 | printChatLn("**me** " + text) 114 | err := channel.Send(datachannel.PayloadString{Data: []byte(text)}) 115 | assertNoErr(err) 116 | } 117 | }) 118 | channel.OnMessage(func(p datachannel.Payload) { 119 | if p.PayloadType() != datachannel.PayloadTypeString { 120 | panic("Expected string payload") 121 | } 122 | printChatLn("**them** " + string(p.(datachannel.PayloadString).Data)) 123 | }) 124 | } 125 | 126 | var chatLnMutex sync.Mutex 127 | 128 | func printChatLn(line string) { 129 | chatLnMutex.Lock() 130 | defer chatLnMutex.Unlock() 131 | fmt.Println(line) 132 | } 133 | 134 | func parseFlags() (id string, err error) { 135 | fs := flag.NewFlagSet("", flag.ExitOnError) 136 | fs.BoolVar(&debugEnabled, "v", false, "Show debug information") 137 | fs.StringVar(&id, "id", "", "The identifier to put offer for (optional, generated when not present)") 138 | assertNoErr(fs.Parse(os.Args[1:])) 139 | if fs.NArg() > 0 { 140 | return "", fmt.Errorf("Unrecognized args: %v", fs.Args()) 141 | } 142 | // Generate ID if not there 143 | if id == "" { 144 | const base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 145 | var idBytes [20]byte 146 | if _, err := rand.Read(idBytes[:]); err == nil { 147 | for _, idByte := range idBytes { 148 | id += string(base58Chars[int(idByte)%len(base58Chars)]) 149 | } 150 | } 151 | } 152 | return 153 | } 154 | 155 | func debugf(format string, v ...interface{}) { 156 | if debugEnabled { 157 | log.Printf(format, v...) 158 | } 159 | } 160 | 161 | func assertNoErr(err error) { 162 | if err != nil { 163 | panic(err) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /local_web_server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func main() { 9 | err := http.ListenAndServe(":8080", http.FileServer(http.Dir("."))) 10 | log.Fatal(err) 11 | } 12 | --------------------------------------------------------------------------------