├── .env.sample ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── Godeps ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── VERSION.go ├── access_key.go ├── access_key_test.go ├── app.json ├── config.go ├── configure ├── errors.go ├── hive.go ├── hive_test.go ├── main.go ├── message.go ├── philote.go ├── philote_test.go └── script └── cross-compile /.env.sample: -------------------------------------------------------------------------------- 1 | export GOPATH="$PWD"/.dependencies:"$PWD" 2 | export PORT=6380 3 | export SECRET="" 4 | export LOGEVEL="debug" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .dependencies 2 | philote 3 | admin/philote-admin 4 | src/lua/scripts/*.go 5 | pkg/ 6 | bin/ 7 | /config.mk 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | before_install: 4 | - wget https://raw.githubusercontent.com/pote/gpm/v1.4.0/bin/gpm && chmod +x gpm && sudo mv gpm /usr/local/bin 5 | 6 | go: 7 | - 1.x 8 | - 1.6 9 | - 1.7.x 10 | - master 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## The [Soveran](https://github.com/soveran) Contribution Guidelines. 2 | 3 | This code tries to solve a particular problem with a very simple 4 | implementation. We try to keep the code to a minimum while making 5 | it as clear as possible. The design is very likely finished, and 6 | if some feature is missing it is possible that it was left out on 7 | purpose. That said, new usage patterns may arise, and when that 8 | happens we are ready to adapt if necessary. 9 | 10 | A good first step for contributing is to meet us on IRC and discuss 11 | ideas. We spend a lot of time on #lesscode at freenode, always ready 12 | to talk about code and simplicity. If connecting to IRC is not an 13 | option, you can create an issue explaining the proposed change and 14 | a use case. We pay a lot of attention to use cases, because our 15 | goal is to keep the code base simple. Usually the result of a 16 | conversation is the creation of a different tool. 17 | 18 | Please don't start the conversation with a pull request. The code 19 | should come at last, and even though it may help to convey an idea, 20 | more often than not it draws the attention to a particular 21 | implementation. 22 | -------------------------------------------------------------------------------- /Godeps: -------------------------------------------------------------------------------- 1 | github.com/satori/go.uuid v1.1.0 2 | github.com/dgrijalva/jwt-go v3.0.0 3 | github.com/sirupsen/logrus v0.11.5 4 | github.com/ianschenck/envflag 9111d830d133f952887a936367fb0211c3134f0d 5 | github.com/gorilla/websocket 0868951cdb8e69bc42df4598bdc6164ff2f1a072 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016 Pablo Astigarraga (pote), 4 | Nicolas Sanguinetti (foca) , 5 | 13Floor . 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROGNAME ?= philote 2 | SOURCES = *.go 3 | DEPS = $(firstword $(subst :, ,$(GOPATH)))/up-to-date 4 | GPM ?= gpm 5 | 6 | -include config.mk 7 | 8 | all: $(PROGNAME) 9 | 10 | $(PROGNAME): bin $(SOURCES) $(DEPS) | $(dir $(PROGNAME)) 11 | go build -o bin/$(PROGNAME) 12 | 13 | server: $(PROGNAME) 14 | ./bin/$(PROGNAME) 15 | 16 | test: $(PROGNAME) $(SOURCES) 17 | LOGLEVEL=error go test 18 | 19 | clean: 20 | rm -rf pkg/ 21 | 22 | dependencies: $(DEPS) 23 | 24 | cross-compile: clean 25 | script/cross-compile 26 | 27 | config.mk: 28 | @./configure 29 | 30 | install: philote 31 | install -d $(prefix)/bin 32 | install -m 0755 bin/philote /usr/local/bin 33 | 34 | uninstall: 35 | rm -f $(prefix)/bin/philote 36 | 37 | $(DEPS): Godeps | $(dir $(DEPS)) 38 | $(GPM) get 39 | touch $@ 40 | 41 | ## 42 | # Directories 43 | ## 44 | 45 | $(dir $(PROGNAME)) $(dir $(DEPS)) bin: 46 | mkdir -p $@ 47 | 48 | 49 | ## 50 | # You're a PHONY! Just a big, fat PHONY. 51 | ## 52 | 53 | .PHONY: run test clean dependencies cross-compile 54 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: philote 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Philote: plug-and-play websockets server ![Build status](https://travis-ci.org/pote/philote.svg) 2 | 3 | Philote is a minimal solution to the websockets server problem, it implements Publish/Subscribe and has a simple authentication mechanism that accomodates browser clients securely as well as server-side or desktop applications. 4 | 5 | Simplicity is one of the design goals for Philote, ease of deployment is another: you should be able to drop the binary in any internet-accessible server and have it operational. 6 | 7 | For a short demonstration, check out the sample command line Philote client called [Jane](#cli) 8 | 9 | ## Basics 10 | 11 | Philote implements a basic topic-based [Publish-subscribe pattern](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern), messages sent over the websocket connection are classified into `channels`, and each connection is given read/write access to a given list of channels at authentication time. 12 | 13 | Messages sent over a connection for a given channel (to which it has write permission) will be received by all other connections (that have read permission to the channel in question). 14 | 15 | ### Deploy your own instance 16 | 17 | You can play around with Philote by deploying it on Heroku for free, keep in mind that Heroku's free tier dynos are not suited for production Philote usage, however, as sleeping dynos will mean websocket connections are closed. 18 | 19 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 20 | 21 | ### Configuration options 22 | 23 | Philote takes configuration options from your environment and attempts to provide sensible defaults, these are the environment variables you can set to change its behaviour: 24 | 25 | | Environment Variable | Default | Description | 26 | |:-----------------------:|:-------------------------:|:-------------------------------------------------------------------------------------------------------------------| 27 | | `SECRET` | ` ` | Secret salt used to sign authentication tokens | 28 | | `PORT` | `6380` | Port in which to serve websocket connections | 29 | | `LOGLEVEL` | `info` | Verbosity of log output, valid options are [debug,info,warning,error,fatal,panic] | 30 | | `MAX_CONNECTIONS` | `255` | Maximum amount of concurrent websocket connections allowed | 31 | | `READ_BUFFER_SIZE` | `1024` | Size of the websocket read buffer, for most cases the default should be okay. | 32 | | `WRITE_BUFFER_SIZE` | `1024` | Size of the websocket write buffer, for most cases the default should be okay. | 33 | | `CHECK_ORIGIN` | `false` | Check Origin headers during WebSocket upgrade handshake. | 34 | 35 | If the defaults work for you, simply running `philote` will start the server with the default values, or you can just manipulate the environment and run with whatever settings you need. 36 | 37 | ```bash 38 | $ PORT=9424 philote 39 | ``` 40 | 41 | ## CLI 42 | 43 | There is a trivial implementation of basic Philote interaction called [Jane](https://github.com/pote/jane) that you can run locally, it can subscribe to a channel on a Philote server, receive and publish messages. It's useful for debugging purposes. 44 | 45 | ![sample](https://stuff.pote.io/Screen-Recording-2017-05-16-15-50-30-5ivJp0cbze.gif) 46 | 47 | ## Clients 48 | 49 | * [JavaScript (browser)](https://github.com/pote/philote-js) 50 | * [Go](https://github.com/pote/philote-go) 51 | * [Python](https://github.com/taibende/pyphilote) 52 | 53 | ## Authentication 54 | 55 | Clients authenticate in Philote using [JSON Web Tokens](https://jwt.io), which consist on a JSON payload detailing the read/write permissions a given connection will have. The payload is hashed with a secret known to Philote so that incoming connections can be verified, this way you can generate tokens in your application backend and use them from the browser client without fear. 56 | 57 | Clients in different language will provide methods to generate these tokens, for now, the [Go client](https://github.com/pote/philote-go/blob/master/token.go) should be the reference implementation, although you'll notice that it's an extremely simple one so ports to other languages should be trivial to implement provided with a decent JWT library. 58 | 59 | For incoming websockets connections, Philote will look to find the authentication token in the `Authorization` header, but since the native browser JavaScript WebSocket API does not provide a way to manipulate the request headers Philote will also look for the `auth` query parameter in case it fails to authenticate using the header option. 60 | 61 | 62 | ### Install 63 | 64 | You can install Philote (and Jane) easily with homebrew. 65 | 66 | `brew install pote/philote/philote` 67 | 68 | `brew install pote/philote/jane` 69 | 70 | You can also manually get the binaries from [latest release](https://github.com/pote/philote/releases) or [install from source](#install-from-source) 71 | 72 | 73 | ### Local development 74 | 75 | ### Bootstrap it 76 | 77 | You'll need [gpm](https://github.com/pote/gpm) for dependency management. 78 | 79 | ### Set it up 80 | 81 | ``` bash 82 | $ source .env.sample # you might want to copy it to .env and source that instead if you plan on changing the settings. 83 | $ make 84 | ``` 85 | 86 | ### Run a Philote server 87 | 88 | ```bash 89 | $ make server 90 | ``` 91 | 92 | ### Run the test suite 93 | 94 | ```bash 95 | $ make test 96 | ``` 97 | 98 | ### Install from source 99 | 100 | ```bash 101 | $ make install 102 | ``` 103 | 104 | ## History 105 | 106 | The first versions of Philote were powered by Redis, it was initially thought of as a websocket bridge to a Redis instance. 107 | 108 | After a while, that design was considered inpractical: redis is a big dependency to have, publish/subscribe was easy to implement in Philote itself and the authentication mechanism was changed to use JSON Web Tokens, making Redis unnecessary. 109 | 110 | The result should be a more robust tool that anyone can drop in any operating system and get working trivially, without external dependencies. 111 | 112 | ## License 113 | 114 | Released under MIT License, check LICENSE file for details. 115 | -------------------------------------------------------------------------------- /VERSION.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const VERSION = "0.3.1" 4 | -------------------------------------------------------------------------------- /access_key.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import( 4 | "errors" 5 | 6 | "github.com/dgrijalva/jwt-go" 7 | ) 8 | 9 | type AccessKey struct { 10 | Read []string `json:"read"` 11 | Write []string `json:"write"` 12 | API bool `json:"api"` 13 | 14 | jwt.StandardClaims 15 | } 16 | 17 | func NewAccessKey(auth string) (*AccessKey, error) { 18 | ak := AccessKey{} 19 | 20 | verifyFunc := func(t *jwt.Token) (interface{}, error) { 21 | return Config.jwtSecret, nil 22 | } 23 | 24 | token, err := jwt.ParseWithClaims(auth, &ak, verifyFunc); if err != nil { 25 | return &ak, err 26 | } 27 | 28 | if claims, ok := token.Claims.(*AccessKey); ok && token.Valid { 29 | ak.Read = claims.Read 30 | ak.Write = claims.Write 31 | ak.API = !!claims.API 32 | return &ak, nil 33 | } else { 34 | return &ak, errors.New("invalid token") 35 | } 36 | 37 | return &ak, nil 38 | } 39 | 40 | func (ak *AccessKey) CanWrite(channel string) bool { 41 | for _, c := range ak.Write { 42 | if c == channel { 43 | return true 44 | } 45 | } 46 | 47 | return false 48 | } 49 | 50 | func (ak *AccessKey) CanRead(channel string) bool { 51 | for _, c := range ak.Read { 52 | if c == channel { 53 | return true 54 | } 55 | } 56 | 57 | return false 58 | } 59 | -------------------------------------------------------------------------------- /access_key_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import( 4 | "testing" 5 | 6 | "github.com/dgrijalva/jwt-go" 7 | ) 8 | 9 | 10 | func TestNewAccessKey(t *testing.T) { 11 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 12 | "read": []string{"test-channel"}, 13 | "write": []string{"test-channel"}, 14 | }) 15 | 16 | tokenString, err := token.SignedString(Config.jwtSecret); if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | ak, err := NewAccessKey(tokenString); if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | if len(ak.Read) < 1 || ak.Read[0] != "test-channel" { 25 | t.Error("Access Key does not have proper read permissions") 26 | } 27 | 28 | if len(ak.Write) < 1 || ak.Write[0] != "test-channel" { 29 | t.Error("Access Key does not have proper write permissions") 30 | } 31 | 32 | if ak.API { 33 | t.Error("By default, access keys shouldn't allow API access") 34 | } 35 | } 36 | 37 | func TestAPIAccessKey(t *testing.T) { 38 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 39 | "api": true, 40 | }) 41 | 42 | tokenString, err := token.SignedString(Config.jwtSecret); if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | ak, err := NewAccessKey(tokenString); if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | if !ak.API { 51 | t.Error("Should be able to create HTTP API access keys") 52 | } 53 | } 54 | 55 | func TestMultiChannelAccessKey(t *testing.T) { 56 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 57 | "read": []string{"test-channel", "read-channel"}, 58 | "write": []string{"test-channel", "write-channel"}, 59 | }) 60 | 61 | tokenString, err := token.SignedString(Config.jwtSecret); if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | ak, err := NewAccessKey(tokenString); if err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | if !ak.CanRead("test-channel") || !ak.CanRead("read-channel") { 70 | t.Error("Access Key does not have proper read permissions") 71 | } 72 | 73 | if !ak.CanWrite("test-channel") || !ak.CanWrite("write-channel") { 74 | t.Error("Access Key does not have proper write permissions") 75 | } 76 | 77 | if ak.CanRead("random-channel-name") || ak.CanWrite("random-channel-name") { 78 | t.Error("channel has permissions it shouldn't") 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Philote", 3 | "description": "A plug-and-play websockets server", 4 | "repository": "https://github.com/pote/philote", 5 | "keywords": ["golang", "websockets"], 6 | "buildpacks": [ 7 | { 8 | "url": "https://github.com/lucasefe/heroku-buildpack-go" 9 | } 10 | ], 11 | "env": { 12 | "SECRET": { 13 | "description": "The secret auth salt shared between Philote and your clients", 14 | "value": "" 15 | }, 16 | "LOGLEVEL": { 17 | "description": "Change for more or less verbose output [debug|info|warning|error|panic]", 18 | "value": "info" 19 | }, 20 | "MAX_CONNECTIONS": { 21 | "description": "Maximum amount of concurrent websocket connections allowed", 22 | "value": "255" 23 | }, 24 | "READ_BUFFER_SIZE": { 25 | "description": "Size of the websocket read buffer, for most cases the default should be okay", 26 | "value": "1024" 27 | }, 28 | "WRITE_BUFFER_SIZE": { 29 | "description": "Size of the websocket write buffer, for most cases the default should be okay", 30 | "value": "1024" 31 | }, 32 | "CHECK_ORIGIN": { 33 | "description": "Verify Origin header on websocket handshake", 34 | "value": "false" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gorilla/websocket" 8 | "github.com/ianschenck/envflag" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | type config struct { 13 | Upgrader websocket.Upgrader 14 | jwtSecret []byte 15 | port string 16 | version string 17 | readBufferSize int 18 | writeBufferSize int 19 | maxConnections int 20 | checkOrigin bool 21 | log log.Level 22 | launchUnixTime int64 23 | } 24 | 25 | 26 | func LoadConfig() (*config) { 27 | c := &config{} 28 | 29 | secret := envflag.String( 30 | "SECRET", 31 | "", 32 | "JWT secret used to validate access keys.") 33 | port := envflag.String( 34 | "PORT", 35 | "6380", 36 | "Port in which to serve Philote websocket connections") 37 | logLevel := envflag.String( 38 | "LOGLEVEL", 39 | "info", 40 | "Log level, accepts: 'debug', 'info', 'warning', 'error', 'fatal', 'panic'") 41 | 42 | maxConnections := envflag.Int( 43 | "MAX_CONNECTIONS", 44 | 255, 45 | "Maximum amount of permitted concurrent connections") 46 | 47 | readBufferSize := envflag.Int( 48 | "READ_BUFFER_SIZE", 49 | 1024, 50 | "Size (in bytes) for the read buffer") 51 | 52 | writeBufferSize := envflag.Int( 53 | "WRITE_BUFFER_SIZE", 54 | 1024, 55 | "Size (in bytes) for the write buffer") 56 | 57 | checkOrigin := envflag.Bool( 58 | "CHECK_ORIGIN", 59 | false, 60 | "Compare the Origin and Host request header during websocket handshake") 61 | 62 | envflag.Parse() 63 | 64 | c.jwtSecret = []byte(*secret) 65 | c.port = *port 66 | c.maxConnections = *maxConnections 67 | c.readBufferSize = *readBufferSize 68 | c.writeBufferSize = *writeBufferSize 69 | c.checkOrigin = *checkOrigin 70 | 71 | c.Upgrader = websocket.Upgrader{ 72 | ReadBufferSize: c.readBufferSize, 73 | WriteBufferSize: c.writeBufferSize, 74 | } 75 | 76 | if !c.checkOrigin { 77 | c.Upgrader.CheckOrigin = func(r *http.Request) bool { 78 | return true 79 | } 80 | } 81 | 82 | c.launchUnixTime = time.Now().Unix() 83 | 84 | var err error 85 | c.log, err = log.ParseLevel(*logLevel); if err != nil { 86 | log.WithFields(log.Fields{"error": err}).Panic("Unparsable log level") 87 | } 88 | log.SetLevel(c.log) 89 | 90 | return c 91 | } 92 | -------------------------------------------------------------------------------- /configure: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | prefix=/usr/local 4 | 5 | usage() 6 | { 7 | echo "Usage: configure [-h|--help] [--prefix=prefix]" 8 | } 9 | 10 | while [ "$1" != "" ]; do 11 | case "$1" in 12 | --prefix=*) 13 | prefix=${1#--prefix=} 14 | ;; 15 | --prefix) 16 | shift 17 | prefix=$1 18 | ;; 19 | -h|--help) 20 | usage 21 | exit 22 | ;; 23 | *) 24 | echo "$0: unknown argument $1" >&2 25 | usage 26 | exit 1 27 | ;; 28 | esac 29 | 30 | shift 31 | done 32 | 33 | cat <> config.mk 37 | EOF 38 | 39 | cat > ./config.mk <= Config.maxConnections { 57 | log.WithFields(log.Fields{"philote": p.ID}).Warn("MAX_CONNECTIONS limit reached, dropping new connection") 58 | p.disconnect() 59 | } 60 | 61 | log.WithFields(log.Fields{"philote": p.ID}).Debug("Registering Philote") 62 | p.Hive = h 63 | h.Philotes[p.ID] = p 64 | go p.Listen() 65 | case p := <- h.Disconnect: 66 | log.WithFields(log.Fields{"philote": p.ID}).Debug("Disconnecting Philote") 67 | delete(h.Philotes, p.ID) 68 | p.disconnect() 69 | } 70 | } 71 | } 72 | 73 | func (h *hive) ServeNewConnection(w http.ResponseWriter, r *http.Request) { 74 | auth := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer")) 75 | if auth == "" { 76 | r.ParseForm() 77 | auth = r.Form.Get("auth") 78 | log.WithFields(log.Fields{"auth": auth}).Debug("Empty Authorization header, trying querystring #auth param") 79 | } 80 | 81 | accessKey, err := NewAccessKey(auth); if err != nil { 82 | w.Write([]byte(err.Error())) 83 | return 84 | } 85 | 86 | if accessKey.API && strings.HasPrefix(r.URL.Path, "/api") { 87 | h.ServeAPICall(w, r) 88 | return 89 | } 90 | 91 | connection, err := Config.Upgrader.Upgrade(w, r, nil); if err != nil { 92 | log.WithFields(log.Fields{"error": err.Error()}).Warn("Can't upgrade connection") 93 | w.Write([]byte(err.Error())) 94 | return 95 | } 96 | 97 | philote := NewPhilote(accessKey, connection) 98 | h.Connect <- philote 99 | } 100 | 101 | func (h *hive) ServeAPICall(w http.ResponseWriter, r *http.Request) { 102 | if r.Method == "GET" && r.URL.Path == "/api/info" { 103 | info := h.Inspect() 104 | data, err := json.Marshal(info); if err != nil { 105 | w.WriteHeader(500) 106 | return 107 | } 108 | 109 | w.Write(data) 110 | return 111 | } 112 | 113 | w.WriteHeader(420) 114 | return 115 | } 116 | 117 | func (h *hive) Inspect() *hiveInfo { 118 | return &hiveInfo{ 119 | Version: VERSION, 120 | GoArch: runtime.GOARCH, 121 | GoOS: runtime.GOOS, 122 | GoVersion: runtime.Version(), 123 | NumCPU: runtime.NumCPU(), 124 | UptimeSeconds: time.Now().Unix() - Config.launchUnixTime, 125 | UptimeDays: (time.Now().Unix() - Config.launchUnixTime) / 60 / 60 / 24, 126 | TCPPort: Config.port, 127 | PID: os.Getpid(), 128 | Connections: len(h.Philotes), 129 | MaxConnections: Config.maxConnections, 130 | ReadBufferSize: Config.readBufferSize, 131 | WriteBufferSize: Config.writeBufferSize, 132 | CheckOrigin: Config.checkOrigin, 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /hive_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | "time" 9 | 10 | "github.com/dgrijalva/jwt-go" 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | func TestHiveSuccessfulPhiloteRegistration(t *testing.T) { 15 | h := NewHive() 16 | if len(h.Philotes) != 0 { 17 | t.Error("new Hive shouldn't have registered philotes") 18 | } 19 | 20 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 21 | "read": []string{"test-channel"}, 22 | "write": []string{"test-channel"}, 23 | }) 24 | 25 | tokenString, err := token.SignedString(Config.jwtSecret); if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | server := httptest.NewServer(http.HandlerFunc(h.ServeNewConnection)) 30 | header := map[string][]string{ 31 | "Authorization": []string{"Bearer " + tokenString}, 32 | } 33 | u, _ := url.Parse(server.URL) 34 | u.Scheme = "ws" 35 | _, _, err = websocket.DefaultDialer.Dial(u.String(), header); if err != nil { 36 | t.Error(err) 37 | } 38 | 39 | if len(h.Philotes) != 1 { 40 | t.Error("philote should be registered on successful auth") 41 | } 42 | } 43 | 44 | func TestHiveSuccessfulPhiloteRegistrationWithQuerystring(t *testing.T) { 45 | h := NewHive() 46 | if len(h.Philotes) != 0 { 47 | t.Error("new Hive shouldn't have registered philotes") 48 | } 49 | 50 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 51 | "read": []string{"test-channel"}, 52 | "write": []string{"test-channel"}, 53 | }) 54 | 55 | tokenString, err := token.SignedString(Config.jwtSecret); if err != nil { 56 | t.Fatal(err) 57 | } 58 | 59 | server := httptest.NewServer(http.HandlerFunc(h.ServeNewConnection)) 60 | 61 | u, _ := url.Parse(server.URL) 62 | q := u.Query() 63 | q.Set("auth", tokenString) 64 | u.RawQuery = q.Encode() 65 | u.Scheme = "ws" 66 | 67 | _, _, err = websocket.DefaultDialer.Dial(u.String(), nil); if err != nil { 68 | t.Error(err) 69 | } 70 | 71 | if len(h.Philotes) != 1 { 72 | t.Error("philote should be registered on successful auth") 73 | } 74 | } 75 | 76 | func TestHiveIncorrectAuth(t *testing.T) { 77 | h := NewHive() 78 | if len(h.Philotes) != 0 { 79 | t.Error("new Hive shouldn't have registered philotes") 80 | } 81 | 82 | server := httptest.NewServer(http.HandlerFunc(h.ServeNewConnection)) 83 | header := map[string][]string{ 84 | "Authorization": []string{"Bearer " + "foo"}, 85 | } 86 | u, _ := url.Parse(server.URL) 87 | u.Scheme = "ws" 88 | _, _, err := websocket.DefaultDialer.Dial(u.String(), header); if err == nil { 89 | t.Error("The Dial action should fail when there is no auth token") 90 | } 91 | 92 | if len(h.Philotes) != 0 { 93 | t.Error("philote should not be registered when missing auth") 94 | } 95 | } 96 | 97 | func TestHivePhiloteRegistrationWithNoAuth(t *testing.T) { 98 | h := NewHive() 99 | if len(h.Philotes) != 0 { 100 | t.Error("new Hive shouldn't have registered philotes") 101 | } 102 | 103 | server := httptest.NewServer(http.HandlerFunc(h.ServeNewConnection)) 104 | u, _ := url.Parse(server.URL) 105 | u.Scheme = "ws" 106 | _, _, err := websocket.DefaultDialer.Dial(u.String(), nil); if err == nil { 107 | t.Error("The Dial action should fail when there is no auth token") 108 | } 109 | 110 | if len(h.Philotes) != 0 { 111 | t.Error("philote should not be registered when missing auth") 112 | } 113 | } 114 | 115 | func TestHiveDeregisterPhilote(t *testing.T) { 116 | h := NewHive() 117 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 118 | "read": []string{"test-channel"}, 119 | "write": []string{"test-channel"}, 120 | }) 121 | tokenString, err := token.SignedString(Config.jwtSecret); if err != nil { 122 | t.Fatal(err) 123 | } 124 | server := httptest.NewServer(http.HandlerFunc(h.ServeNewConnection)) 125 | header := map[string][]string{ 126 | "Authorization": []string{"Bearer " + tokenString}, 127 | } 128 | u, _ := url.Parse(server.URL) 129 | u.Scheme = "ws" 130 | conn, _, err := websocket.DefaultDialer.Dial(u.String(), header); if err != nil { 131 | t.Error(err) 132 | } 133 | 134 | if len(h.Philotes) != 1 { 135 | t.Error("philote should be registered on successful auth") 136 | } 137 | 138 | conn.Close() 139 | time.Sleep(time.Second) 140 | 141 | if len(h.Philotes) != 0 { 142 | t.Error("Disconnected Philotes should be automatically deregistered") 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "runtime" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/gorilla/websocket" 9 | ) 10 | 11 | var Config *config = LoadConfig() 12 | var Upgrader = websocket.Upgrader{ 13 | ReadBufferSize: Config.readBufferSize, 14 | WriteBufferSize: Config.writeBufferSize, 15 | CheckOrigin: func(r *http.Request) bool { 16 | return true 17 | }, 18 | } 19 | 20 | func main() { 21 | log.WithFields(log.Fields{ 22 | "version": VERSION, 23 | "port": Config.port, 24 | "cores": runtime.NumCPU()}).Info("Initializing Philotic Network") 25 | 26 | log.WithFields(log.Fields{ 27 | "read-buffer-size": Config.readBufferSize, 28 | "write-buffer-size": Config.writeBufferSize, 29 | "max-connections": Config.maxConnections}).Debug("Configuration options:") 30 | 31 | h := NewHive() 32 | http.HandleFunc("/", h.ServeNewConnection) 33 | 34 | err := http.ListenAndServe(":" + Config.port, nil); if err != nil { 35 | log.Fatal("ListenAndServe: ", err) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type Message struct { 4 | IssuerID string `json:"issuer,omitempty"` 5 | Channel string `json:"channel,omitempty"` 6 | Data string `json:"data,omitempty"` 7 | } 8 | -------------------------------------------------------------------------------- /philote.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | 6 | "github.com/gorilla/websocket" 7 | "github.com/satori/go.uuid" 8 | ) 9 | 10 | type Philote struct { 11 | ID string 12 | AccessKey *AccessKey 13 | Hive *hive 14 | ws *websocket.Conn 15 | IncomingMessages chan *Message 16 | } 17 | 18 | func NewPhilote(ak *AccessKey, ws *websocket.Conn) (*Philote) { 19 | p := &Philote{ 20 | ws: ws, 21 | ID: uuid.NewV4().String(), 22 | AccessKey: ak, 23 | IncomingMessages: make(chan *Message), 24 | } 25 | 26 | go p.DistributeIncomingMessages() 27 | 28 | return p 29 | } 30 | 31 | func (p *Philote) DistributeIncomingMessages() { 32 | var message *Message 33 | 34 | for { 35 | message = <- p.IncomingMessages 36 | p.ws.WriteJSON(message) 37 | } 38 | } 39 | 40 | func (p *Philote) Listen() { 41 | log.WithFields(log.Fields{"philote": p.ID}).Debug("Listening to Philote") 42 | for { 43 | message := &Message{} 44 | err := p.ws.ReadJSON(&message); if err != nil { 45 | log.WithFields(log.Fields{ 46 | "philote": p.ID, 47 | "error": err.Error()}).Warn("Error reading from socket, disconnecting") 48 | 49 | p.Hive.Disconnect <- p 50 | break 51 | } 52 | 53 | // Ensure no tampering with message data 54 | message.IssuerID = p.ID 55 | 56 | log.WithFields(log.Fields{"philote": p.ID, "channel": message.Channel}).Debug("Received message from socket") 57 | 58 | if p.AccessKey.CanWrite(message.Channel) { 59 | go p.publish(message) 60 | } else { 61 | log.WithFields(log.Fields{ 62 | "philote": p.ID, 63 | "channel": message.Channel, 64 | "data": message.Data, 65 | }).Info("Message dropped due to insufficient write permissions") 66 | } 67 | } 68 | } 69 | 70 | func (p *Philote) disconnect() { 71 | log.WithFields(log.Fields{"philote": p.ID}).Debug("Closing Philote") 72 | p.ws.Close() 73 | } 74 | 75 | func (p *Philote) publish(message *Message) { 76 | message.IssuerID = p.ID 77 | 78 | for _, philote := range p.Hive.Philotes { 79 | if p.ID == philote.ID { 80 | continue 81 | } 82 | 83 | for _, channel := range philote.AccessKey.Read { 84 | if message.Channel == channel { 85 | philote.IncomingMessages <- message 86 | break 87 | } 88 | } 89 | 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /philote_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | "time" 9 | 10 | "github.com/dgrijalva/jwt-go" 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | func TestPhilotesExchangingMessages(t *testing.T) { 15 | h := NewHive() 16 | if len(h.Philotes) != 0 { 17 | t.Error("new Hive shouldn't have registered philotes") 18 | } 19 | 20 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 21 | "read": []string{"test-channel"}, 22 | "write": []string{"test-channel"}, 23 | }) 24 | 25 | tokenString, err := token.SignedString(Config.jwtSecret); if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | server := httptest.NewServer(http.HandlerFunc(h.ServeNewConnection)) 30 | header := map[string][]string{ 31 | "Authorization": []string{"Bearer " + tokenString}, 32 | } 33 | u, _ := url.Parse(server.URL) 34 | u.Scheme = "ws" 35 | conn1, _, err := websocket.DefaultDialer.Dial(u.String(), header); if err != nil { 36 | t.Error(err) 37 | } 38 | conn2, _, err := websocket.DefaultDialer.Dial(u.String(), header); if err != nil { 39 | t.Error(err) 40 | } 41 | 42 | if len(h.Philotes) != 2 { 43 | t.Error("Both philotes should be connected and registered") 44 | } 45 | originalMessage := &Message{Data: "yo!", Channel: "test-channel"} 46 | 47 | go func() { time.Sleep(time.Second); conn1.WriteJSON(originalMessage) }() 48 | 49 | receivedMessage := &Message{} 50 | err = conn2.ReadJSON(receivedMessage); if err != nil { 51 | t.Error(err) 52 | } 53 | 54 | if receivedMessage.Data != "yo!" { 55 | t.Error("incorrect message data") 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /script/cross-compile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | while read os arch; do 4 | identifier="philote-$os"-"$arch" 5 | build_dir="pkg/$identifier" 6 | mkdir -p "$build_dir" 7 | echo "Compiling for $os/$arch" 8 | 9 | GOOS=$os GOARCH=$arch go build -o "$build_dir"/philote && 10 | cd pkg && tar -zcvf "$identifier".tar.gz "$identifier" && 11 | rm -rf "$identifier" && cd .. 12 | done << EOF 13 | darwin 386 14 | darwin amd64 15 | darwin arm 16 | darwin arm64 17 | dragonfly amd64 18 | freebsd 386 19 | freebsd amd64 20 | freebsd arm 21 | linux 386 22 | linux amd64 23 | linux arm 24 | linux arm64 25 | linux ppc64 26 | linux ppc64le 27 | linux mips64 28 | linux mips64le 29 | netbsd 386 30 | netbsd amd64 31 | netbsd arm 32 | openbsd 386 33 | openbsd amd64 34 | openbsd arm 35 | plan9 386 36 | plan9 amd64 37 | solaris amd64 38 | windows 386 39 | windows amd64 40 | EOF 41 | wait 42 | 43 | echo "All Done" 44 | --------------------------------------------------------------------------------