├── .github └── workflows │ └── go.yml ├── .gitignore ├── CNAME ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── _config.yml ├── build ├── cmd └── inbox.go ├── docs ├── benchmarks.md ├── concept.md ├── swagger.yml └── tutorial.md ├── go.mod ├── inbox.go ├── inbox_test.go ├── mailboxes.go ├── mailboxes_test.go ├── public ├── receive.html ├── receive.js ├── send.html ├── send.js ├── signal_channel.js └── webrtc.js ├── server.go └── server_test.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: 7 | - v* 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 1.x 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: ^1.14 20 | id: go 21 | 22 | - name: Check out code into the Go module directory 23 | uses: actions/checkout@v2 24 | 25 | - name: Get dependencies 26 | run: go get -v -t -d ./... 27 | 28 | - name: Build 29 | run: go build -v cmd/inbox.go 30 | 31 | - name: Test 32 | run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 33 | 34 | - name: Push to CodeCov 35 | run: bash <(curl -s https://codecov.io/bash) 36 | 37 | - name: Build binaries 38 | if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} 39 | run: | 40 | sudo apt install zip 41 | ./build 42 | 43 | - name: Create Release 44 | id: create_release 45 | if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} 46 | uses: actions/create-release@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | tag_name: ${{ github.ref }} 51 | release_name: Release ${{ github.ref }} 52 | body: "Automated release" 53 | draft: false 54 | prerelease: false 55 | 56 | - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} 57 | uses: actions/upload-release-asset@v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | upload_url: ${{ steps.create_release.outputs.upload_url }} 62 | asset_path: ./inbox_darwin_amd64.zip 63 | asset_name: inbox_darwin_amd64.zip 64 | asset_content_type: application/zip 65 | 66 | - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} 67 | uses: actions/upload-release-asset@v1 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | upload_url: ${{ steps.create_release.outputs.upload_url }} 72 | asset_path: ./inbox_linux_386.zip 73 | asset_name: inbox_linux_386.zip 74 | asset_content_type: application/zip 75 | 76 | - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} 77 | uses: actions/upload-release-asset@v1 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | with: 81 | upload_url: ${{ steps.create_release.outputs.upload_url }} 82 | asset_path: ./inbox_linux_amd64.zip 83 | asset_name: inbox_linux_amd64.zip 84 | asset_content_type: application/zip 85 | 86 | - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} 87 | uses: actions/upload-release-asset@v1 88 | env: 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | with: 91 | upload_url: ${{ steps.create_release.outputs.upload_url }} 92 | asset_path: ./inbox_linux_arm.zip 93 | asset_name: inbox_linux_arm.zip 94 | asset_content_type: application/zip 95 | 96 | - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} 97 | uses: actions/upload-release-asset@v1 98 | env: 99 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 100 | with: 101 | upload_url: ${{ steps.create_release.outputs.upload_url }} 102 | asset_path: ./inbox_windows_amd64.zip 103 | asset_name: inbox_windows_amd64.zip 104 | asset_content_type: application/zip 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | server.crt 2 | server.csr 3 | server.key -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | www.inboxgo.org -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Realistic Code of Conduct 2 | 3 | 1. We mostly care about making our softwares better. 4 | 5 | 2. Everybody is free to decide how to contribute. 6 | 7 | 3. We believe in free speech. Everyone's entitled to their opinion. 8 | 9 | 4. Feel offended? This might be very well-deserved. 10 | 11 | 5. We don't need a code of conduct imposed on us, thanks. 12 | 13 | 6. Ignoring this Realistic Code of Conduct is welcome. 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS build-env 2 | ADD . /src 3 | RUN cd /src && go build cmd/inbox.go 4 | 5 | FROM alpine 6 | WORKDIR /app 7 | COPY --from=build-env /src/inbox /app/ 8 | COPY --from=build-env /src/public /app/public 9 | CMD ./inbox 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Emad Elsaid 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 | 📮 INBOX 2 | ========= 3 | 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/emad-elsaid/inbox)](https://goreportcard.com/report/github.com/emad-elsaid/inbox) 5 | [![GoDoc](https://godoc.org/github.com/emad-elsaid/inbox?status.svg)](https://godoc.org/github.com/emad-elsaid/inbox) 6 | [![codecov](https://codecov.io/gh/emad-elsaid/inbox/branch/master/graph/badge.svg)](https://codecov.io/gh/emad-elsaid/inbox) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/fd33b21eee2826d1f763/maintainability)](https://codeclimate.com/github/emad-elsaid/inbox/maintainability) 8 | [![Join the chat at https://gitter.im/inbox-server/community](https://badges.gitter.im/inbox-server/community.svg)](https://gitter.im/inbox-server/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 9 | 10 | Inbox makes it easy to setup a WebRTC HTTPS signaling server 11 | 12 | 13 | **Table of Contents** 14 | 15 | - [📮 INBOX](#📮-inbox) 16 | - [Features](#features) 17 | - [Install](#install) 18 | - [Download latest binary](#download-latest-binary) 19 | - [Compile from source](#compile-from-source) 20 | - [Docker image](#docker-image) 21 | - [Free public instances](#free-public-instances) 22 | - [Usage](#usage) 23 | - [Tutorial](#tutorial) 24 | - [API Documentation](#api-documentation) 25 | - [Purpose](#purpose) 26 | - [How is it working?](#how-is-it-working) 27 | - [The General Concept](#the-general-concept) 28 | - [The implementation](#the-implementation) 29 | - [How to run the example](#how-to-run-the-example) 30 | - [Benchmarks](#benchmarks) 31 | - [Contribute](#contribute) 32 | - [License](#license) 33 | 34 | 35 | 36 | ## Features 37 | 38 | * HTTP and HTTPS mode 39 | * Allows period polling or long polling 40 | * In-memory state, no need for persistent storage 41 | * Allows Cross-Origin Resource Sharing (CORS) 42 | * Serves Static files from a directory 43 | * Allows limiting request body size and headers size 44 | * Flexible way to limit maximum number of messages per user 45 | 46 | ## Install 47 | 48 | ### Download latest binary 49 | 50 | You can download [the latest version from 51 | releases](https://github.com/emad-elsaid/inbox/releases/latest) for your 52 | system/architecture 53 | 54 | ### Compile from source 55 | 56 | - Have the [Go toolchain](https://golang.org/dl/) installed 57 | - Clone the repository and compile and install the binary to $GOBIN 58 | ``` 59 | git clone git@github.com:emad-elsaid/inbox.git 60 | cd inbox 61 | go install cmd/inbox.go 62 | ``` 63 | 64 | ### Docker image 65 | 66 | - If you want to run it in http mode 67 | ``` 68 | docker run --rm -it -p 3000:3000 emadelsaid/inbox ./inbox --https=false 69 | ``` 70 | - You can use generate a self signed SSL certificate, or if you already have a 71 | certificate if you want to have HTTPS enabled 72 | ``` 73 | docker run --rm -it -v /path/to/cert/directory:/cert -p 3000:3000 emadelsaid/inbox ./inbox --server-cert=/cert/server.crt --server-key=/cert/server.key 74 | ``` 75 | 76 | ### Free public instances 77 | 78 | The following are public instances you can use instead of running your own: 79 | 80 | - https://connect.inboxgo.org/inbox Official public instance 81 | 82 | ## Usage 83 | 84 | ``` 85 | -bind string 86 | a bind for the http server (default "0.0.0.0:3000") 87 | -cleanup-interval int 88 | Interval in seconds between server cleaning up inboxes (default 1) 89 | -cors 90 | Allow CORS 91 | -https 92 | Run server in HTTPS mode or HTTP (default true) 93 | -inbox-capacity int 94 | Maximum number of messages each inbox can hold (default 100) 95 | -inbox-timeout int 96 | Number of seconds for the inbox to be inactive before deleting (default 60) 97 | -long-polling 98 | Allow blocking get requests until a message is available in the inbox (default true) 99 | -max-body-size int 100 | Maximum request body size in bytes (default 1048576) 101 | -max-header-size int 102 | Maximum request body size in bytes (default 1048576) 103 | -public string 104 | Directory path of static files to serve (default "public") 105 | -server-cert string 106 | HTTPS server certificate file (default "server.crt") 107 | -server-key string 108 | HTTPS server private key file (default "server.key") 109 | ``` 110 | 111 | ## Tutorial 112 | 113 | If you're new to writing WebRTC application I recommend you make yourself 114 | familiar with [the basic concepts from 115 | google](https://webrtc.org/getting-started/overview), For a tutorial about how 116 | to use Inbox I got you covered from installation to Javascript communication 117 | [Read more](/docs/tutorial.md) 118 | 119 | ## API Documentation 120 | 121 | - Swagger documentation is under [/docs/swagger.yml](/docs/swagger.yml) 122 | - You can show it [ online here ](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/emad-elsaid/inbox/master/docs/swagger.yml) 123 | 124 | ## Purpose 125 | 126 | - When building a WebRTC based project you need a way to signal peers. 127 | - One of the ways to signal peers is to use a central HTTP server 128 | - Alice uses **Inbox** to pass WebRTC offer to Bob 129 | - Bob gets the offer uses **Inbox** to send a WebRTC answer to Alice 130 | - Alice and Bob use **Inbox** to exchange ICE Candidates information 131 | - When Alice and Bob have enough ICE candidates they disconnect from **Inbox** and connect to each other directly 132 | 133 | ## How is it working? 134 | 135 | - The server works in HTTPS mode by default unless `-https=false` passed to it. 136 | - If you wish to generate self signed SSL certificate `server.key` and `server.crt`: 137 | ``` 138 | openssl genrsa -des3 -passout pass:secretpassword -out server.pass.key 2048 139 | openssl rsa -passin pass:secretpassword -in server.pass.key -out server.key 140 | openssl req -new -key server.key -out server.csr 141 | openssl x509 -req -sha256 -days 365 -in server.csr -signkey server.key -out server.crt 142 | ``` 143 | - it uses Go to run a HTTPS server on port 3000 that serves `./public` directory 144 | - The local server has 1 other route `/inbox` for the sender and receiver to signal each 145 | other the webRTC offer and answer. 146 | 147 | ## The General Concept 148 | 149 | Inbox acts as a temporary mailbox between peers, the server creates the inbox 150 | upon the first interaction with the user and deletes it after a duration of 151 | inactivity which is 1 minute by default [Read more](/docs/concept.md) 152 | 153 | ## The implementation 154 | 155 | - This is a HTTPS server written in Go 156 | - No third party dependencies 157 | - Stores all data in memory in one big memory structure 158 | - Every second the server removes inboxes exceeded timeouts 159 | - Serves `/public` in current working directory as static files 160 | - CORS is disabled by default 161 | 162 | ## How to run the example 163 | 164 | - Install [Go](https://golang.org/) 165 | - Clone this repository `git clone git@github.com:emad-elsaid/inbox.git` 166 | - Run the server `go run ./cmd/inbox.go` 167 | - Open `https://your-ip-address:3000/send.html` on the camera machine 168 | - Open `https://your-ip-address:3000/receive.html` on the receiver machine 169 | - Choose the camera from the list on the sender and press `start` button 170 | - The receiver should display the camera shortly after 171 | 172 | ## Benchmarks 173 | 174 | Inbox inherits the high speed of Go and as it uses the RAM as storage its mostly 175 | a CPU bound process, the project comes with go benchmarks you can test it on 176 | your hardware yourself, You can checkout my CPU specs and benchmark numbers on 177 | my machine here [Read more](/docs/benchmarks.md) 178 | 179 | ## Contribute 180 | 181 | Expected contribution flow will be as follows: 182 | 183 | * Read and understand the code 184 | * Make some changes related to your idea 185 | * Open a PR to validate you're in the right direction, describe what you're 186 | trying to do 187 | * Continue working on your changes until it's fully implemented 188 | * I'll merge it and release a new version 189 | 190 | ## License 191 | 192 | MIT License (c) Emad Elsaid 193 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: emad-elsaid/bulma-jekyll-theme 2 | title: 📮 INBOX 3 | description: WebRTC fast signaling HTTP server 4 | show_downloads: true 5 | google_analytics: UA-172490931-1 6 | download: https://github.com/emad-elsaid/inbox/releases/latest 7 | tutorial: /docs/tutorial 8 | nav: 9 | - title: Tutorial 10 | url: /docs/tutorial 11 | - title: Concept 12 | url: /docs/concept 13 | - title: Benchmarks 14 | url: /docs/benchmarks 15 | -------------------------------------------------------------------------------- /build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | function build_for { 3 | echo "Building for $1 $2" 4 | dist=inbox_$1_$2 5 | 6 | rm -rf $dist 7 | rm $dist.zip 8 | 9 | mkdir $dist 10 | cp -r public $dist/ 11 | GOOS=$1 GOARCH=$2 go build -o $dist/ cmd/inbox.go 12 | zip -9 -r $dist.zip $dist/ 13 | 14 | rm -rf $dist 15 | } 16 | 17 | build_for darwin amd64 18 | build_for linux 386 19 | build_for linux amd64 20 | build_for linux arm 21 | build_for windows amd64 22 | -------------------------------------------------------------------------------- /cmd/inbox.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "inbox" 6 | "log" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | func main() { 12 | bind := flag.String("bind", "0.0.0.0:3000", "a bind for the http server") 13 | serverCert := flag.String("server-cert", "server.crt", "HTTPS server certificate file") 14 | serverKey := flag.String("server-key", "server.key", "HTTPS server private key file") 15 | cleanupInterval := flag.Int("cleanup-interval", 1, "Interval in seconds between server cleaning up inboxes") 16 | inboxTimeout := flag.Int("inbox-timeout", 60, "Number of seconds for the inbox to be inactive before deleting") 17 | public := flag.String("public", "public", "Directory path of static files to serve") 18 | https := flag.Bool("https", true, "Run server in HTTPS mode or HTTP") 19 | cors := flag.Bool("cors", false, "Allow CORS") 20 | maxBodySize := flag.Int64("max-body-size", 10*1024, "Maximum request body size in bytes") 21 | maxHeaderSize := flag.Int("max-header-size", http.DefaultMaxHeaderBytes, "Maximum request body size in bytes") 22 | inboxCapacity := flag.Int("inbox-capacity", 100, "Maximum number of messages each inbox can hold") 23 | longPolling := flag.Bool("long-polling", true, "Allow blocking get requests until a message is available in the inbox") 24 | 25 | flag.Parse() 26 | 27 | log.Println("Bind:", *bind) 28 | log.Println("Certificate:", *serverCert, *serverKey) 29 | log.Println("Cleanup interval:", *cleanupInterval) 30 | log.Println("Inbox timeout:", *inboxTimeout) 31 | log.Println("HTTPS:", *https) 32 | log.Println("CORS:", *cors) 33 | log.Println("Max body size:", *maxBodySize) 34 | log.Println("Max header size:", *maxHeaderSize) 35 | log.Println("Inbox capacity:", *inboxCapacity) 36 | log.Println("Long polling:", *longPolling) 37 | 38 | mailboxes := inbox.New() 39 | mailboxes.InboxTimeout = time.Second * time.Duration(*inboxTimeout) 40 | mailboxes.InboxCapacity = *inboxCapacity 41 | 42 | server := inbox.Server{ 43 | CORS: *cors, 44 | Mailboxes: mailboxes, 45 | CleanupInterval: time.Second * time.Duration(*cleanupInterval), 46 | MaxBodySize: *maxBodySize, 47 | LongPolling: *longPolling, 48 | } 49 | 50 | go server.Clean() 51 | 52 | http.Handle("/", http.FileServer(http.Dir(*public))) 53 | http.Handle("/inbox", &server) 54 | 55 | httpServer := http.Server{ 56 | Addr: *bind, 57 | MaxHeaderBytes: *maxHeaderSize, 58 | } 59 | 60 | if *https { 61 | log.Fatal(httpServer.ListenAndServeTLS(*serverCert, *serverKey)) 62 | } else { 63 | log.Fatal(httpServer.ListenAndServe()) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/benchmarks.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | 3 | On a machine with the following specifications 4 | 5 | ``` 6 | $ lscpu 7 | Architecture: x86_64 8 | CPU op-mode(s): 32-bit, 64-bit 9 | Byte Order: Little Endian 10 | Address sizes: 39 bits physical, 48 bits virtual 11 | CPU(s): 4 12 | On-line CPU(s) list: 0-3 13 | Thread(s) per core: 2 14 | Core(s) per socket: 2 15 | Socket(s): 1 16 | NUMA node(s): 1 17 | Vendor ID: GenuineIntel 18 | CPU family: 6 19 | Model: 142 20 | Model name: Intel(R) Core(TM) i7-7600U CPU @ 2.80GHz 21 | Stepping: 9 22 | CPU MHz: 2108.897 23 | CPU max MHz: 3900.0000 24 | CPU min MHz: 400.0000 25 | BogoMIPS: 5802.42 26 | Virtualization: VT-x 27 | L1d cache: 64 KiB 28 | L1i cache: 64 KiB 29 | L2 cache: 512 KiB 30 | L3 cache: 4 MiB 31 | NUMA node0 CPU(s): 0-3 32 | Vulnerability Itlb multihit: KVM: Mitigation: Split huge pages 33 | Vulnerability L1tf: Mitigation; PTE Inversion; VMX conditional cache flushes, SMT vulnerable 34 | Vulnerability Mds: Mitigation; Clear CPU buffers; SMT vulnerable 35 | Vulnerability Meltdown: Mitigation; PTI 36 | Vulnerability Spec store bypass: Mitigation; Speculative Store Bypass disabled via prctl and seccomp 37 | Vulnerability Spectre v1: Mitigation; usercopy/swapgs barriers and __user pointer sanitization 38 | Vulnerability Spectre v2: Mitigation; Full generic retpoline, IBPB conditional, IBRS_FW, STIBP conditional, RSB filling 39 | Vulnerability Tsx async abort: Mitigation; Clear CPU buffers; SMT vulnerable 40 | Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss 41 | ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc art arch_perfmon pebs bts rep_good nopl xtopology nonsto 42 | p_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid 43 | sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpu 44 | id_fault epb invpcid_single pti ssbd ibrs ibpb stibp tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase ts 45 | c_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm mpx rdseed adx smap clflushopt intel_pt xsaveopt xsavec xge 46 | tbv1 xsaves dtherm ida arat pln pts hwp hwp_notify hwp_act_window hwp_epp md_clear flush_l1d 47 | ``` 48 | 49 | Go benchmark command for 1 second produces the following results 50 | 51 | ``` 52 | go test -bench=. -benchtime=1s 53 | goos: linux 54 | goarch: amd64 55 | pkg: inbox 56 | BenchmarkInboxPut-4 10642203 104 ns/op 57 | BenchmarkInboxPutThenGet-4 5866248 204 ns/op 58 | BenchmarkServerPost-4 1288088 900 ns/op 59 | PASS 60 | ok inbox 4.840s 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/concept.md: -------------------------------------------------------------------------------- 1 | # The Concept 2 | 3 | - The server acts as temporary mailbox for peers 4 | - Peers use basic authentication (username, password) to get or send messages 5 | - Username can be anything: random number, UUID, public key...etc 6 | - Whenever a peer authenticate with username and password an inbox will be 7 | created for them if it doesn't exist 8 | - If the username exists and the password is correct then the server will 9 | respond with the oldest message in the inbox and deletes it from it's memory, 10 | and will respond with header `X-From` with the peer username that sent this 11 | message. 12 | - If the username exists and the password is incorrect an Unauthorized arror is 13 | returned 14 | - Now the Inbox with this username is ready to receive messages from another 15 | peer. 16 | - A peer can use another peer username to send a message to his inbox 17 | - The peer inbox will expire after a period of time (1 minute by default) of not 18 | asking for any message 19 | - So peers has to keep asking the server for new messages with short delays that 20 | doesn't exceed the timeout until they got enough information to connect to 21 | each other 22 | 23 | # Usecase 24 | - Assuming 2 peers (Alice and Bob) want to connect with WebRTC 25 | - **Alice** need to choose an identifier `alice-uuid` and pass it to **Bob** in 26 | any other medium (Chat or write it on a paper or pre share it) 27 | - **Alice** uses `alice-uuid` as username to create her inbox and wait for 28 | messages from any peer **Bob** in our case 29 | - **Bob** will create an inbox with any username `bob-uuid` and sends WebRTC 30 | offer to initiate connect to the pre shared username `alice-uuid`. 31 | - **Alice** will ask the server for new messages with her username `alice-uuid` 32 | - The server responds with **Bob** WebRTC offer message in reponse body and 33 | `X-From` header with `bob-uuid` as value 34 | - **Alice** will send WebRTC answer to `bob-uuid` 35 | - **Bob** Asks the server for new messages 36 | - The server responds with **Alice** webRTC answer message and `X-From` header 37 | with `alice-uuid` 38 | - **Bob** sends **Alice** ICE candidates information in a message each time he's 39 | aware of new candidate 40 | - **Alice** will receive ICE candidates messages and sends **Bob** candidates 41 | messages 42 | - **Alice** and **Bob** keep sending each other messages through the server 43 | until they have enough information to connect to each other 44 | - **Alice** and **Bob** are now connected to each other directly with WebRTC 45 | - The server will delete both inboxes after 1 minute of inactivity 46 | -------------------------------------------------------------------------------- /docs/swagger.yml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | description: Inbox is a WebRTC signaling server, use it to exchange offer and answer and ICE Candidates between WebRTC peers, to know more about how it works visit https://github.com/emad-elsaid/inbox 4 | version: 1.0.0 5 | title: WebRTC Inbox 6 | 7 | tags: 8 | - name: inbox 9 | description: To exchange WebRTC connection offer/answer/ice candidates messages 10 | 11 | schemes: 12 | - https 13 | - http 14 | 15 | securityDefinitions: 16 | basicAuth: 17 | type: basic 18 | 19 | security: 20 | - basicAuth: [] 21 | 22 | paths: 23 | /inbox: 24 | get: 25 | tags: 26 | - inbox 27 | summary: Register new inbox or get a message from existing inbox 28 | description: | 29 | It creates an inbox with username and password if it doesn't exist, or returns a message from the inbox if it exists and the password is correct, 30 | if long polling is enabled the endpoint will wait until a message is available the responds to the client, 31 | if long polling is disabled and a message is not available you'll get an empty respond 32 | produces: 33 | - plain/text 34 | responses: 35 | 200: 36 | description: Inbox is created successfully if it doesn't exist, and a message will be returned as body if a message exist in this inbox 37 | headers: 38 | X-From: 39 | description: The sender inbox ID for this message 40 | type: string 41 | 401: 42 | $ref: '#/responses/UnauthorizedError' 43 | 204: 44 | description: Inbox is empty 45 | post: 46 | tags: 47 | - inbox 48 | summary: Send a message to an existing inbox 49 | description: This will store the request body as a message from the current peer to another peer inbox, messages are stored in a queue where the oldest message served first 50 | produces: 51 | - plain/text 52 | parameters: 53 | - name: to 54 | in: query 55 | description: The other peer inbox ID 56 | required: true 57 | type: string 58 | responses: 59 | 200: 60 | description: Sender Inbox is created successfully if it doesn't exist, and message is sent to receiver inbox 61 | 401: 62 | $ref: '#/responses/UnauthorizedError' 63 | 404: 64 | description: Receiver inbox doesn't exist 65 | 204: 66 | description: Inbox is empty or message is empty 67 | 413: 68 | description: Reading request body encountered an error mostly because the body exceed max body size allowed by the server 69 | 503: 70 | description: The inbox your trying to send to is full, try later when the user pull the message. 71 | 72 | responses: 73 | UnauthorizedError: 74 | description: Authentication information is missing or username exists and password is incorrect 75 | headers: 76 | WWW_Authenticate: 77 | type: string 78 | -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | # Tutorial 2 | 3 | ## Installation 4 | 5 | * Download the latest version from github [the latest version from releases](https://github.com/emad-elsaid/inbox/releases/latest) 6 | * Extract the zip file to current directory 7 | * You should see the main binary `inbox` and examples directory `public` 8 | 9 | ## Generating self signed certificate for development 10 | 11 | WebRTC is not allowed in browsers unless your connection is encrypted, inbox 12 | runs in HTTPS mode by default given that you have certificate files in the 13 | current directory. 14 | 15 | Lets generate certificate files. 16 | ``` 17 | openssl genrsa -des3 -passout pass:secretpassword -out server.pass.key 2048 18 | openssl rsa -passin pass:secretpassword -in server.pass.key -out server.key 19 | openssl req -new -key server.key -out server.csr 20 | openssl x509 -req -sha256 -days 365 -in server.csr -signkey server.key -out server.crt 21 | ``` 22 | 23 | When you're asked about any information no need to enter any of it, also don't 24 | add a password/secret phrase to the certificate, remember this is for you on 25 | your development machine. 26 | 27 | ## Running the server 28 | 29 | * Start the server in the directory that contains the certificate files and the 30 | `public` directory 31 | * The server will pickup the certificate files as long as it's named 32 | `server.key` and `server.crt` 33 | * Also it will serve all files in the `public` directory 34 | * On linux you can run it by executing `./inbox` it's this simple 35 | 36 | ## Accessing the server 37 | 38 | * The server listen on port `3000` 39 | * In your browser open `https://localhost:3000` make sure it's `https` not 40 | 'http' 41 | * You should see a list of files in `public` directory now `send.html` and 42 | `receive.html` should allow you to share the camera between two tabs give it a 43 | try, if you terminated the server after starting the connection it will 44 | continue to work as it doesn't need the server any more. 45 | 46 | ## Writing you WebRTC application from scratch 47 | 48 | * The server serves all static files under `public` but you can pass `-public` 49 | with the directory path you want to serve instead. 50 | * The server has CORS turned off by default you can turn it on by passing 51 | `-cors=true` so in case you want to split the signaling server (inbox) from 52 | your assets server this will be useful. 53 | * Inbox has long polling turned on by default so when a user asks Inbox about a 54 | message it will wait until a message is received from another peer then it 55 | will respond with this message, you can turn off this feature to respond 56 | directly instead of waiting by passing `-long-polling=false` 57 | 58 | ## Signal Javascript class 59 | 60 | To use inbox in your javascript application you can write a small class that 61 | asks the server for new messages or send messages to another peer with his ID. 62 | 63 | The following snippet will send a message to a peer by his user name: 64 | 65 | ```js 66 | async send(server, from, password, to, data) { 67 | const response = await fetch(`${server}/inbox?to=${to}`, { 68 | method: 'POST', 69 | cache: 'no-cache', 70 | headers: new Headers({ 'Authorization': 'Basic ' + window.btoa(from + ":" + password) }), 71 | body: JSON.stringify(data) 72 | }); 73 | } 74 | ``` 75 | 76 | Inbox will get the message and will create an inbox for the `from` user with 77 | `password` provided if the inbox doesn't exist. 78 | 79 | To send a message to another user this user has to exist on the server, this is 80 | why every user should first start by asking the server about any new messages, 81 | then send a message to another user if he wants to. 82 | 83 | The following function should allow you to get the latest message from the 84 | server 85 | 86 | ```js 87 | async receive(server, from, password) { 88 | const response = await fetch("${server}/inbox", { 89 | method: 'GET', 90 | cache: 'no-cache', 91 | headers: new Headers({ 'Authorization': 'Basic ' + window.btoa(from + ":" + password) }), 92 | }); 93 | 94 | try { 95 | var data = await response.json(); 96 | return data; 97 | } catch(e) { 98 | return null; 99 | } 100 | } 101 | ``` 102 | 103 | This will get the latest message (the server will not respond until there is a 104 | message, if inbox is empty it will wait until it gets a message from the other 105 | end) the server will create an inbox for the user with the provided username and 106 | password 107 | 108 | You'll need to keep polling the server for new message as long as you expect new 109 | messages from peers, if you are connected to all expected peers then no need to 110 | ask the server anymore. 111 | 112 | There is no technical limitation that stops you from continueing to poll the 113 | server even after connecting to your peers, but it will consume server resources 114 | so it's a good practice to be conservative with your requests to allow the 115 | server to serve as many users as possible. 116 | 117 | Your javascript code now can use these two functions to send and receive message 118 | from other peers in conjunction with `RTCPeerConnection` javascript class to 119 | generate WebRTC offers/answers and send it to the other peer until the 120 | connection is established, you can check `/public/webrtc.js` for an example of 121 | how to do that. 122 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module inbox 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /inbox.go: -------------------------------------------------------------------------------- 1 | package inbox 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "sync/atomic" 8 | "time" 9 | ) 10 | 11 | var ( 12 | ErrorInboxIsFull = errors.New("Inbox is full") 13 | ) 14 | 15 | type message struct { 16 | createdAt time.Time 17 | from string 18 | message []byte 19 | } 20 | 21 | type inbox struct { 22 | sync.Mutex 23 | lastAccessedAt time.Time 24 | password string 25 | messages chan *message 26 | blocking int32 27 | cancelContext context.CancelFunc 28 | } 29 | 30 | func newInbox(password string, size int) *inbox { 31 | return &inbox{ 32 | lastAccessedAt: time.Now(), 33 | password: password, 34 | messages: make(chan *message, size), 35 | } 36 | } 37 | 38 | func (i *inbox) Put(from string, msg []byte) error { 39 | select { 40 | case i.messages <- &message{ 41 | createdAt: time.Now(), 42 | from: from, 43 | message: msg, 44 | }: 45 | return nil 46 | default: 47 | return ErrorInboxIsFull 48 | } 49 | } 50 | 51 | func (i *inbox) Get(ctx *context.Context) (from string, msg []byte) { 52 | i.Lock() 53 | i.lastAccessedAt = time.Now() 54 | i.Unlock() 55 | 56 | if ctx != nil { 57 | return i.getWithContext(ctx) 58 | } else { 59 | return i.getWithoutContext() 60 | } 61 | } 62 | 63 | func (i *inbox) getWithContext(ctx *context.Context) (from string, msg []byte) { 64 | atomic.AddInt32(&i.blocking, 1) 65 | 66 | wrapperCtx, cancel := context.WithCancel(*ctx) 67 | 68 | i.Lock() 69 | if i.cancelContext != nil { 70 | i.cancelContext() 71 | } 72 | i.cancelContext = cancel 73 | i.Unlock() 74 | 75 | select { 76 | case message := <-i.messages: 77 | from = message.from 78 | msg = message.message 79 | case <-wrapperCtx.Done(): 80 | } 81 | 82 | atomic.AddInt32(&i.blocking, -1) 83 | i.Lock() 84 | i.lastAccessedAt = time.Now() 85 | i.Unlock() 86 | 87 | return 88 | } 89 | 90 | func (i *inbox) getWithoutContext() (from string, msg []byte) { 91 | select { 92 | case message := <-i.messages: 93 | return message.from, message.message 94 | default: 95 | return 96 | } 97 | } 98 | 99 | func (i *inbox) IsEmpty() bool { 100 | return len(i.messages) == 0 101 | } 102 | 103 | func (i *inbox) CheckPassword(password string) bool { 104 | return i.password == password 105 | } 106 | 107 | func (i *inbox) Locked() bool { 108 | return atomic.LoadInt32(&i.blocking) > 0 109 | } 110 | -------------------------------------------------------------------------------- /inbox_test.go: -------------------------------------------------------------------------------- 1 | package inbox 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestInbox(t *testing.T) { 11 | t.Run("Inbox.Get", func(t *testing.T) { 12 | i := newInbox("password", 100) 13 | i.Put("Joe", []byte("hello")) 14 | if len(i.messages) != 1 { 15 | t.Errorf("len(messages) = %d; want 1", len(i.messages)) 16 | } 17 | }) 18 | 19 | t.Run("Inbox.Put", func(t *testing.T) { 20 | i := newInbox("password", 100) 21 | 22 | from, msg := i.Get(nil) 23 | if from != "" { 24 | t.Errorf("from = %s; want empty string", from) 25 | } 26 | 27 | if len(msg) != 0 { 28 | t.Errorf("message = %s; want empty bytes", msg) 29 | } 30 | 31 | i.Put("Joe", []byte("hello")) 32 | from, msg = i.Get(nil) 33 | if from != "Joe" { 34 | t.Errorf("from = %s; want Joe", from) 35 | } 36 | 37 | if string(msg) != "hello" { 38 | t.Errorf("message = %s; want hello", msg) 39 | } 40 | 41 | t.Run("Waits for context", func(t *testing.T) { 42 | i := newInbox("password", 100) 43 | ctx, cancel := context.WithCancel(context.Background()) 44 | go func() { 45 | from, msg := i.Get(&ctx) 46 | if from != "" { 47 | t.Errorf("from = %s; want empty string", from) 48 | } 49 | 50 | if len(msg) != 0 { 51 | t.Errorf("message = %s; want empty bytes", msg) 52 | } 53 | }() 54 | 55 | time.Sleep(time.Millisecond) 56 | cancel() 57 | }) 58 | 59 | t.Run("Waits for a message", func(t *testing.T) { 60 | i := newInbox("password", 100) 61 | ctx, _ := context.WithCancel(context.Background()) 62 | go func() { 63 | from, msg := i.Get(&ctx) 64 | if from != "Bob" { 65 | t.Errorf("from = %s; want Bob", from) 66 | } 67 | 68 | if string(msg) != "message" { 69 | t.Errorf("message = %s; want message", msg) 70 | } 71 | }() 72 | 73 | time.Sleep(time.Millisecond) 74 | i.Put("Bob", []byte("message")) 75 | }) 76 | }) 77 | 78 | t.Run("When two gets are waiting the newest gets themessage", func(t *testing.T) { 79 | i := newInbox("password", 100) 80 | wg := sync.WaitGroup{} 81 | wg.Add(2) 82 | 83 | go func() { 84 | ctx, _ := context.WithCancel(context.Background()) 85 | from, msg := i.Get(&ctx) 86 | if from != "" { 87 | t.Errorf("from = %s; want empty string", from) 88 | } 89 | 90 | if string(msg) != "" { 91 | t.Errorf("message = %s; want empty message", msg) 92 | } 93 | wg.Done() 94 | }() 95 | 96 | time.Sleep(time.Millisecond) 97 | 98 | go func() { 99 | ctx, _ := context.WithCancel(context.Background()) 100 | from, msg := i.Get(&ctx) 101 | if from != "Bob" { 102 | t.Errorf("from = %s; want Bob", from) 103 | } 104 | 105 | if string(msg) != "message" { 106 | t.Errorf("message = %s; want message", msg) 107 | } 108 | wg.Done() 109 | }() 110 | 111 | time.Sleep(time.Millisecond) 112 | 113 | i.Put("Bob", []byte("message")) 114 | wg.Wait() 115 | }) 116 | 117 | t.Run("Inbox.IsEmpty", func(t *testing.T) { 118 | i := newInbox("password", 100) 119 | if !i.IsEmpty() { 120 | t.Errorf("expect inbox to be empty but it wasn't") 121 | } 122 | 123 | i.Put("Bob", []byte("message")) 124 | if i.IsEmpty() { 125 | t.Errorf("Expect inbox not to be empty but it was found empty") 126 | } 127 | }) 128 | } 129 | 130 | func BenchmarkInboxPut(b *testing.B) { 131 | i := newInbox("password", 100) 132 | for n := 0; n < b.N; n++ { 133 | i.Put("Alice", []byte("Hello")) 134 | } 135 | } 136 | 137 | var ( 138 | from string 139 | msg []byte 140 | ) 141 | 142 | func BenchmarkInboxPutThenGet(b *testing.B) { 143 | i := newInbox("password", 100) 144 | for n := 0; n < b.N; n++ { 145 | i.Put("Alice", []byte("Hello")) 146 | from, msg = i.Get(nil) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /mailboxes.go: -------------------------------------------------------------------------------- 1 | package inbox 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // Mailboxes holds all inboxes and when to timeout inboxes 11 | type Mailboxes struct { 12 | sync.RWMutex 13 | inboxes map[string]*inbox 14 | InboxCapacity int 15 | InboxTimeout time.Duration 16 | } 17 | 18 | // New creates new empty Mailboxes structure with default timeouts 19 | func New() *Mailboxes { 20 | return &Mailboxes{ 21 | inboxes: map[string]*inbox{}, 22 | InboxCapacity: 100, 23 | InboxTimeout: time.Minute, 24 | } 25 | } 26 | 27 | var ( 28 | // ErrorIncorrectPassword is used whenever an operation tried to validate 29 | // inbox password and the password doesn't match the one in the inbox 30 | ErrorIncorrectPassword = errors.New("Incorrect password") 31 | // ErrorInboxNotFound is used when an operation tries to access an inbox but 32 | // the inbox doesn't exist 33 | ErrorInboxNotFound = errors.New("Inbox not found") 34 | ) 35 | 36 | // Get the oldest message from `to` inbox, making sure the inbox password 37 | // matches, it returns the message sender and the message, and an error if 38 | // occurred This will also will restart the timeout for this inbox if context is 39 | // passed the function will wait until the context is done or a message is 40 | // available 41 | func (m *Mailboxes) Get(to, password string, ctx *context.Context) (from string, message []byte, err error) { 42 | inbox, ok := m.inboxes[to] 43 | if !ok { 44 | inbox = newInbox(password, m.InboxCapacity) 45 | m.RLock() 46 | m.inboxes[to] = inbox 47 | m.RUnlock() 48 | } 49 | 50 | if !inbox.CheckPassword(password) { 51 | err = ErrorIncorrectPassword 52 | return 53 | } 54 | 55 | from, message = inbox.Get(ctx) 56 | return 57 | } 58 | 59 | // Put will put a message `msg` at the end of `to` inbox from inbox owned by 60 | // `from` if the password `password` matches the one stored in `from` inbox. 61 | // If `from` Inbox doesn't exist it will be created with `password`. 62 | // If `to` inbox doesn't exist it will return ErrorInboxNotFound 63 | // When the `from` inbox exist and the password doesn't match it will return ErrorIncorrectPassword 64 | func (m *Mailboxes) Put(from, to, password string, msg []byte) error { 65 | m.RLock() 66 | toInbox, ok := m.inboxes[to] 67 | m.RUnlock() 68 | if !ok { 69 | return ErrorInboxNotFound 70 | } 71 | 72 | m.RLock() 73 | fromInbox, ok := m.inboxes[from] 74 | m.RUnlock() 75 | if !ok { 76 | fromInbox = newInbox(password, m.InboxCapacity) 77 | m.Lock() 78 | m.inboxes[from] = fromInbox 79 | m.Unlock() 80 | } 81 | 82 | if !fromInbox.CheckPassword(password) { 83 | return ErrorIncorrectPassword 84 | } 85 | 86 | return toInbox.Put(from, msg) 87 | } 88 | 89 | // Clean will delete timed out inboxes and messages 90 | func (m *Mailboxes) Clean() { 91 | inboxDeadline := time.Now().Add(m.InboxTimeout * -1) 92 | m.Lock() 93 | for k, v := range m.inboxes { 94 | if !v.Locked() && v.lastAccessedAt.Before(inboxDeadline) { 95 | delete(m.inboxes, k) 96 | } 97 | } 98 | m.Unlock() 99 | } 100 | -------------------------------------------------------------------------------- /mailboxes_test.go: -------------------------------------------------------------------------------- 1 | package inbox 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestMailboxes(t *testing.T) { 10 | t.Run("Mailboxes.Put", func(t *testing.T) { 11 | m := New() 12 | m.Get("Bob", "bob secret", nil) 13 | 14 | err := m.Put("Alice", "Bob", "alice secret", []byte("message")) 15 | if err != nil { 16 | t.Errorf("got %s, expected no error", err) 17 | } 18 | 19 | err = m.Put("Alice", "Bob", "incorrect secret", []byte("message")) 20 | if err != ErrorIncorrectPassword { 21 | t.Errorf("got %s, expect %s", err, ErrorIncorrectPassword) 22 | } 23 | 24 | err = m.Put("Alice", "Fred", "alice secret", []byte("message")) 25 | if err != ErrorInboxNotFound { 26 | t.Errorf("Got %s, expected %s", err, ErrorInboxNotFound) 27 | } 28 | 29 | m.InboxCapacity = 0 30 | m.Get("BobFull", "bob secret", nil) 31 | err = m.Put("Alice", "BobFull", "alice secret", []byte("message")) 32 | if err != ErrorInboxIsFull { 33 | t.Errorf("Got %s, expected %s", err, ErrorInboxIsFull) 34 | } 35 | 36 | }) 37 | 38 | t.Run("Mailboxes.Get", func(t *testing.T) { 39 | m := New() 40 | from, msg, err := m.Get("Bob", "Bob secret", nil) 41 | if from != "" { 42 | t.Errorf("Got %s, expected empty string", from) 43 | } 44 | 45 | if string(msg) != "" { 46 | t.Errorf("Got %s, expected empty string", msg) 47 | } 48 | 49 | m.Put("Alice", "Bob", "alice secret", []byte("hello")) 50 | from, msg, err = m.Get("Bob", "Bob secret", nil) 51 | if from != "Alice" { 52 | t.Errorf("Got %s, expected Alice", from) 53 | } 54 | 55 | if string(msg) != "hello" { 56 | t.Errorf("Got %s, expected hello", msg) 57 | } 58 | 59 | if err != nil { 60 | t.Errorf("Got %s, expected no error", err) 61 | } 62 | 63 | from, msg, err = m.Get("Bob", "wrong secret", nil) 64 | if from != "" { 65 | t.Errorf("Got %s, expected empty string", from) 66 | } 67 | 68 | if string(msg) != "" { 69 | t.Errorf("Got %s, expected empty string", msg) 70 | } 71 | 72 | if err != ErrorIncorrectPassword { 73 | t.Errorf("Got %s, expected %s", err, ErrorIncorrectPassword) 74 | } 75 | }) 76 | 77 | t.Run("Mailboxes.Clean", func(t *testing.T) { 78 | m := New() 79 | m.InboxTimeout = 0 80 | m.Get("Alice", "secret", nil) 81 | m.Get("Bob", "secret", nil) 82 | m.Clean() 83 | err := m.Put("Bob", "Alice", "secret", []byte("hello")) 84 | m.Clean() 85 | if err != ErrorInboxNotFound { 86 | t.Errorf("Got %s, expected %s", err, ErrorInboxNotFound) 87 | } 88 | 89 | t.Run("When one of the inboxes are blocking a Get it shouldn't be deleted", func(t *testing.T) { 90 | ctx, cancel := context.WithCancel(context.Background()) 91 | 92 | m := New() 93 | m.InboxTimeout = 0 94 | go m.Get("Alice", "secret", &ctx) 95 | time.Sleep(time.Millisecond) 96 | m.Clean() 97 | 98 | if len(m.inboxes) != 1 { 99 | t.Errorf("Inbox is deleted while Get is waiting: %d inboxes", len(m.inboxes)) 100 | } 101 | cancel() 102 | }) 103 | }) 104 | } 105 | -------------------------------------------------------------------------------- /public/receive.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

7 | 8 |

9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/receive.js: -------------------------------------------------------------------------------- 1 | async function RTCTrack(event) { 2 | console.log('RTCTrack', event); 3 | 4 | var remoteStream = new MediaStream(); 5 | remoteStream.addTrack(event.track); 6 | 7 | var preview = document.getElementById('preview'); 8 | preview.srcObject = remoteStream; 9 | } 10 | 11 | signaling = new SignalingChannel({ 12 | from: 'receiver', 13 | to: 'sender', 14 | password: 'secretreceiverpassword' 15 | }); 16 | peer = new Peer(signaling); 17 | peer.connection.addEventListener('track', RTCTrack); 18 | 19 | peer.addEventListener('connected', (e) => signaling.disconnect()); 20 | peer.addEventListener('failed', (e) => document.location.reload()); 21 | peer.addEventListener('disconnected', (e) => document.location.reload()); 22 | -------------------------------------------------------------------------------- /public/send.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

9 | 10 |

11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/send.js: -------------------------------------------------------------------------------- 1 | async function videoDevices() { 2 | var all = await navigator.mediaDevices.enumerateDevices(); 3 | var video = all.filter(d => d.kind == 'videoinput'); 4 | return video; 5 | } 6 | 7 | async function videoStream(deviceId) { 8 | return await navigator.mediaDevices.getUserMedia({ 9 | video: { 10 | width: { ideal: 4096 }, 11 | height: { ideal: 4208 }, 12 | deviceId: { exact: deviceId } 13 | } 14 | }); 15 | } 16 | 17 | async function listDevices() { 18 | var dom = document.getElementById('devices'); 19 | var devices = await videoDevices(); 20 | var options = devices.map(videoDevice => { 21 | return ``; 22 | }); 23 | dom.innerHTML = options.join(''); 24 | } 25 | 26 | async function start() { 27 | var deviceSelect = document.getElementById('devices'); 28 | var deviceId = deviceSelect.value; 29 | var device = await videoStream(deviceId); 30 | var video = document.getElementById('preview'); 31 | video.srcObject = device; 32 | 33 | peer.addStream(device); 34 | peer.connect(); 35 | } 36 | document.getElementById('start').addEventListener('click', start); 37 | 38 | (async function() { 39 | var media = await navigator.mediaDevices.getUserMedia({video: true}); 40 | media.getVideoTracks()[0].stop(); 41 | listDevices(); 42 | })(); 43 | 44 | signaling = new SignalingChannel({ 45 | from: 'sender', 46 | to: 'receiver', 47 | password: 'secretpassword' 48 | }); 49 | peer = new Peer(signaling); 50 | 51 | peer.addEventListener('connected', (e) => signaling.disconnect()); 52 | peer.addEventListener('failed', (e) => document.location.reload()); 53 | peer.addEventListener('disconnected', (e) => document.location.reload()); 54 | -------------------------------------------------------------------------------- /public/signal_channel.js: -------------------------------------------------------------------------------- 1 | class SignalingChannel extends EventTarget { 2 | constructor(opts) { 3 | super(); 4 | this.from = opts['from']; 5 | this.to = opts['to']; 6 | this.password = opts['password']; 7 | this.connected = false; 8 | this.headers = new Headers({ 'Authorization': 'Basic ' + window.btoa(this.from + ":" + this.password) }); 9 | this.connect(); 10 | } 11 | 12 | connect() { 13 | if ( !this.connected ) { 14 | this.connected = true; 15 | this.poll(); 16 | } 17 | } 18 | 19 | disconnect() { 20 | this.connected = false; 21 | } 22 | 23 | async send(data) { 24 | console.log('Sending', data); 25 | 26 | const response = await fetch(`/inbox?to=${this.to}`, { 27 | method: 'POST', 28 | cache: 'no-cache', 29 | headers: this.headers, 30 | body: JSON.stringify(data) 31 | }); 32 | } 33 | 34 | async receive() { 35 | const response = await fetch("/inbox", { 36 | method: 'GET', 37 | cache: 'no-cache', 38 | headers: this.headers 39 | }); 40 | 41 | try { 42 | var data = await response.json(); 43 | console.log('Received', data); 44 | return data; 45 | } catch(e) { 46 | return null; 47 | } 48 | } 49 | 50 | async poll() { 51 | var message = await this.receive(); 52 | if ( message != null ) this.dispatchEvent(new CustomEvent(message.type || 'message', { detail: message })); 53 | if ( this.connected ) setTimeout(this.poll.bind(this), 1000); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/webrtc.js: -------------------------------------------------------------------------------- 1 | class Peer extends EventTarget{ 2 | constructor(channel) { 3 | super(); 4 | this.connection = new RTCPeerConnection(); 5 | this.connection.addEventListener('connectionstatechange', e => this.stateChanged(e)); 6 | this.connection.addEventListener('icecandidate', e => this.sendIceCandidate(e)); 7 | this.channel = channel; 8 | this.channel.addEventListener('offer', e => this.answer(e.detail)); 9 | this.channel.addEventListener('answer', e => this.acceptAnswer(e.detail)); 10 | this.channel.addEventListener('icecandidate', e => this.receiveIceCandidate(e.detail)); 11 | } 12 | 13 | get status() { 14 | return this.connection.connectionState; 15 | } 16 | 17 | stateChanged(event) { 18 | console.log("Connection changed", this.connection.connectionState); 19 | this.dispatchEvent(new CustomEvent(this.connection.connectionState)); 20 | } 21 | 22 | addStream(stream) { 23 | stream.getTracks().forEach(t => this.connection.addTrack(t)); 24 | } 25 | 26 | async connect() { 27 | const peerOffer = await this.connection.createOffer(); 28 | await this.connection.setLocalDescription(peerOffer); 29 | this.channel.send(peerOffer.toJSON()); 30 | } 31 | 32 | async answer(peerOffer) { 33 | this.connection.setRemoteDescription(new RTCSessionDescription(peerOffer)); 34 | 35 | const answer = await this.connection.createAnswer(); 36 | this.connection.setLocalDescription(answer); 37 | this.channel.send(answer.toJSON()); 38 | } 39 | 40 | async acceptAnswer(peerAnswer) { 41 | const remoteDesc = new RTCSessionDescription(peerAnswer); 42 | await this.connection.setRemoteDescription(remoteDesc); 43 | } 44 | 45 | sendIceCandidate(event) { 46 | if( event.candidate == null ) return; 47 | 48 | this.channel.send({ type: 'icecandidate', candidate: event.candidate.toJSON() }); 49 | } 50 | 51 | receiveIceCandidate(event) { 52 | if( event.candidate == null ) return; 53 | 54 | var candidate = new RTCIceCandidate(event.candidate); 55 | this.connection.addIceCandidate(candidate); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package inbox 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // Server is an HTTP handler struct that holds all mailboxes in memory and when 11 | // to clean them 12 | type Server struct { 13 | CORS bool 14 | Mailboxes *Mailboxes 15 | CleanupInterval time.Duration 16 | MaxBodySize int64 17 | LongPolling bool 18 | } 19 | 20 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 | r.Body = http.MaxBytesReader(w, r.Body, s.MaxBodySize) 22 | 23 | if err := r.ParseForm(); err != nil { 24 | w.WriteHeader(http.StatusBadRequest) 25 | return 26 | } 27 | 28 | switch r.Method { 29 | case http.MethodGet: 30 | s.inboxGet(w, r) 31 | case http.MethodPost: 32 | s.inboxPost(w, r) 33 | case http.MethodOptions: 34 | s.inboxOptions(w, r) 35 | default: 36 | w.WriteHeader(http.StatusNotFound) 37 | } 38 | } 39 | 40 | func (s *Server) inboxGet(w http.ResponseWriter, r *http.Request) { 41 | s.writeCORS(w) 42 | 43 | to, password, ok := r.BasicAuth() 44 | if !ok { 45 | w.Header().Set("WWW-Authenticate", "Basic") 46 | w.WriteHeader(http.StatusUnauthorized) 47 | return 48 | } 49 | 50 | requestCtx := r.Context() 51 | var ctx *context.Context 52 | 53 | if s.LongPolling { 54 | ctx = &requestCtx 55 | } 56 | 57 | from, message, err := s.Mailboxes.Get(to, password, ctx) 58 | if err != nil { 59 | switch err { 60 | case ErrorIncorrectPassword: 61 | w.Header().Set("WWW-Authenticate", "Basic") 62 | w.WriteHeader(http.StatusUnauthorized) 63 | } 64 | return 65 | } 66 | 67 | w.Header().Add("X-From", from) 68 | w.Write(message) 69 | } 70 | 71 | func (s *Server) inboxPost(w http.ResponseWriter, r *http.Request) { 72 | s.writeCORS(w) 73 | 74 | from, password, ok := r.BasicAuth() 75 | if !ok { 76 | w.Header().Set("WWW-Authenticate", "Basic") 77 | w.WriteHeader(http.StatusUnauthorized) 78 | return 79 | } 80 | 81 | message, err := ioutil.ReadAll(r.Body) 82 | if err != nil { 83 | w.WriteHeader(http.StatusRequestEntityTooLarge) 84 | return 85 | } 86 | r.Body.Close() 87 | 88 | to := r.FormValue("to") 89 | if err := s.Mailboxes.Put(from, to, password, message); err != nil { 90 | switch err { 91 | case ErrorIncorrectPassword: 92 | w.Header().Set("WWW-Authenticate", "Basic") 93 | w.WriteHeader(http.StatusUnauthorized) 94 | case ErrorInboxNotFound: 95 | w.WriteHeader(http.StatusNotFound) 96 | case ErrorInboxIsFull: 97 | w.WriteHeader(http.StatusServiceUnavailable) 98 | } 99 | } 100 | } 101 | 102 | func (s *Server) inboxOptions(w http.ResponseWriter, r *http.Request) { 103 | s.writeCORS(w) 104 | w.WriteHeader(http.StatusNoContent) 105 | } 106 | 107 | func (s *Server) writeCORS(w http.ResponseWriter) { 108 | if !s.CORS { 109 | return 110 | } 111 | 112 | headers := w.Header() 113 | headers.Add("Vary", "Origin") 114 | headers.Add("Vary", "Access-Control-Request-Method") 115 | headers.Add("Vary", "Access-Control-Request-Headers") 116 | 117 | headers.Set("Access-Control-Allow-Origin", "*") 118 | headers.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") 119 | headers.Set("Access-Control-Allow-Credentials", "true") 120 | headers.Set("Access-Control-Allow-Headers", "Authorization") 121 | headers.Set("Access-Control-Expose-Headers", "X-From") 122 | } 123 | 124 | // Clean will delete old inboxes periodically with an interval of CleanupInterval 125 | func (s Server) Clean() { 126 | for { 127 | s.Mailboxes.Clean() 128 | time.Sleep(s.CleanupInterval) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package inbox 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "strings" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestServer(t *testing.T) { 15 | handler := Server{CORS: true, Mailboxes: New(), MaxBodySize: 1 * 1024 * 1024} 16 | 17 | t.Run("GET", func(t *testing.T) { 18 | t.Run("without authorization", func(t *testing.T) { 19 | rr := httptest.NewRecorder() 20 | req, err := http.NewRequest("GET", "/inbox", nil) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | handler.ServeHTTP(rr, req) 26 | 27 | if status := rr.Code; status != http.StatusUnauthorized { 28 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusUnauthorized) 29 | } 30 | 31 | if header := rr.HeaderMap.Get("WWW-Authenticate"); header != "Basic" { 32 | t.Errorf("handler returned unexpected WWW-Authenticate header: got %v wanted Basic", header) 33 | } 34 | 35 | if rr.Body.String() != "" { 36 | t.Errorf("handler returned unexpected body: got %v wanted empty string", rr.Body.String()) 37 | } 38 | }) 39 | 40 | t.Run("wrong body format", func(t *testing.T) { 41 | rr := httptest.NewRecorder() 42 | req, err := http.NewRequest("GET", "/inbox", nil) 43 | req.URL.RawQuery = "someWeird%