Initial commit

This commit is contained in:
wpetit 2019-02-03 20:56:58 +01:00
commit 2a8a20195a
30 changed files with 1595 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/coverage
/vendor

30
Makefile Normal file
View File

@ -0,0 +1,30 @@
test:
go clean -testcache
go test -cover -v ./...
watch:
modd
deps:
GO111MODULE=off go get -u golang.org/x/tools/cmd/godoc
GO111MODULE=off go get -u github.com/cortesi/modd/cmd/modd
GO111MODULE=off go get -u github.com/golangci/golangci-lint/cmd/golangci-lint
GO111MODULE=off go get -u github.com/lmika/goseq
tidy:
go mod tidy
lint:
golangci-lint run --tests=false --enable-all
sequence-diagram: sd-advertise sd-update sd-ping
sd-%:
goseq doc/sequence-diagram/$*.seq > doc/sequence-diagram/$*.svg
doc:
@echo "open your browser to http://localhost:6060/pkg/forge.cadoles.com/wpetit/go-http-peering to see the documentation"
godoc -http=:6060
.PHONY: test lint doc sequence-diagram

25
README.md Normal file
View File

@ -0,0 +1,25 @@
# go-http-peering
Librairie implémentant un protocole d'authentification par "appairage" d'un serveur et client HTTP basé sur [JWT](https://jwt.io/).
[Documentation](https://godoc.org/forge.cadoles.com/wpetit/go-http-peering)
## Séquences
### Annonce du client
![](./doc/sequence-diagram/advertise.svg)
### Mise à jour des attributs
![](./doc/sequence-diagram/update.svg)
### Ping
![](./doc/sequence-diagram/ping.svg)
**Statut** Instable
## Licence
AGPL-3.0

16
chi/mount.go Normal file
View File

@ -0,0 +1,16 @@
package chi
import (
peering "forge.cadoles.com/wpetit/go-http-peering"
"forge.cadoles.com/wpetit/go-http-peering/server"
"github.com/go-chi/chi"
)
func Mount(r chi.Router, store peering.Store, funcs ...server.OptionFunc) {
r.Post(peering.AdvertisePath, server.AdvertiseHandler(store, funcs...))
r.Group(func(r chi.Router) {
r.Use(server.Authenticate(store, funcs...))
r.Post(peering.UpdatePath, server.UpdateHandler(store, funcs...))
r.Post(peering.PingPath, server.PingHandler(store, funcs...))
})
}

35
chi/mount_test.go Normal file
View File

@ -0,0 +1,35 @@
package chi
import (
"testing"
peering "forge.cadoles.com/wpetit/go-http-peering"
"forge.cadoles.com/wpetit/go-http-peering/memory"
"github.com/go-chi/chi"
)
func TestMount(t *testing.T) {
r := chi.NewRouter()
store := memory.NewStore()
Mount(r, store)
routes := r.Routes()
if g, e := len(routes), 3; g != e {
t.Errorf("len(r.Routes()): got '%v', expected '%v'", g, e)
}
if g, e := routes[0].Pattern, peering.AdvertisePath; g != e {
t.Errorf("routes[0].Pattern: got '%v', expected '%v'", g, e)
}
if g, e := routes[1].Pattern, peering.PingPath; g != e {
t.Errorf("routes[1].Pattern: got '%v', expected '%v'", g, e)
}
if g, e := routes[2].Pattern, peering.UpdatePath; g != e {
t.Errorf("routes[2].Pattern: got '%v', expected '%v'", g, e)
}
}

187
client/client.go Normal file
View File

@ -0,0 +1,187 @@
package client
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/dgrijalva/jwt-go"
peering "forge.cadoles.com/wpetit/go-http-peering"
"forge.cadoles.com/wpetit/go-http-peering/crypto"
"forge.cadoles.com/wpetit/go-http-peering/server"
)
const (
DefaultBody = "{}"
)
var (
ErrInvalidPrivateKey = errors.New("invalid private key")
ErrUnexpectedResponse = errors.New("unexpected response")
ErrUnauthorized = errors.New("unauthorized")
ErrRejected = errors.New("rejected")
)
type Client struct {
options *Options
}
func (c *Client) Advertise(attrs peering.PeerAttributes) error {
url := c.options.BaseURL + peering.AdvertisePath
publicKey, err := crypto.EncodePublicKeyToPEM(c.options.PrivateKey.Public())
if err != nil {
return err
}
data := &peering.AdvertisingRequest{
ID: c.options.PeerID,
Attributes: attrs,
PublicKey: publicKey,
}
req, _, err := c.createPostRequest(url, data)
if err != nil {
return err
}
res, err := c.options.HTTPClient.Do(req)
switch res.StatusCode {
case http.StatusCreated:
return nil
case http.StatusConflict:
return peering.ErrPeerExists
default:
return ErrUnexpectedResponse
}
}
func (c *Client) UpdateAttributes(attrs peering.PeerAttributes) error {
url := c.options.BaseURL + peering.UpdatePath
data := &peering.UpdateRequest{
Attributes: attrs,
}
res, err := c.Post(url, data)
if err != nil {
return err
}
switch res.StatusCode {
case http.StatusNoContent:
return nil
case http.StatusUnauthorized:
return ErrUnauthorized
case http.StatusForbidden:
return ErrRejected
default:
return ErrUnexpectedResponse
}
}
func (c *Client) Ping() error {
url := c.options.BaseURL + peering.PingPath
res, err := c.Post(url, nil)
if err != nil {
return err
}
switch res.StatusCode {
case http.StatusNoContent:
return nil
case http.StatusUnauthorized:
return ErrUnauthorized
case http.StatusForbidden:
return ErrRejected
default:
return ErrUnexpectedResponse
}
}
func (c *Client) Get(url string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if err := c.signRequest(req, nil); err != nil {
return nil, err
}
return c.options.HTTPClient.Do(req)
}
func (c *Client) Post(url string, data interface{}) (*http.Response, error) {
req, body, err := c.createPostRequest(url, data)
if err != nil {
return nil, err
}
if err := c.signRequest(req, body); err != nil {
return nil, err
}
return c.options.HTTPClient.Do(req)
}
func (c *Client) createPostRequest(url string, data interface{}) (*http.Request, []byte, error) {
body, err := json.Marshal(data)
if err != nil {
return nil, nil, err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, body, nil
}
func (c *Client) signRequest(r *http.Request, body []byte) error {
bodySum, err := c.createBodySum(body)
if err != nil {
return err
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, peering.PeerClaims{
StandardClaims: jwt.StandardClaims{
NotBefore: time.Now().Unix(),
Issuer: string(c.options.PeerID),
ExpiresAt: time.Now().Add(time.Minute * 10).Unix(),
},
BodySum: bodySum,
})
tokenStr, err := token.SignedString(c.options.PrivateKey)
if err != nil {
return err
}
r.Header.Set("Authorization", fmt.Sprintf("%s %s", server.AuthorizationType, tokenStr))
return nil
}
func (c *Client) createBodySum(body []byte) ([]byte, error) {
if body == nil || len(body) == 0 {
body = []byte(DefaultBody)
}
sha := sha256.New()
_, err := sha.Write(body)
if err != nil {
return nil, err
}
return sha.Sum(nil), nil
}
func New(funcs ...OptionFunc) *Client {
options := createOptions(funcs...)
return &Client{options}
}

56
client/option.go Normal file
View File

@ -0,0 +1,56 @@
package client
import (
"crypto/rsa"
"net/http"
peering "forge.cadoles.com/wpetit/go-http-peering"
)
type Options struct {
PeerID peering.PeerID
HTTPClient *http.Client
BaseURL string
PrivateKey *rsa.PrivateKey
}
type OptionFunc func(*Options)
func WithPeerID(id peering.PeerID) OptionFunc {
return func(opts *Options) {
opts.PeerID = id
}
}
func WithPrivateKey(pk *rsa.PrivateKey) OptionFunc {
return func(opts *Options) {
opts.PrivateKey = pk
}
}
func WithHTTPClient(client *http.Client) OptionFunc {
return func(opts *Options) {
opts.HTTPClient = client
}
}
func WithBaseURL(url string) OptionFunc {
return func(opts *Options) {
opts.BaseURL = url
}
}
func defaultOptions() *Options {
return &Options{
HTTPClient: http.DefaultClient,
PeerID: peering.NewPeerID(),
}
}
func createOptions(funcs ...OptionFunc) *Options {
options := defaultOptions()
for _, fn := range funcs {
fn(options)
}
return options
}

25
crypto/pem.go Normal file
View File

@ -0,0 +1,25 @@
package crypto
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
jwt "github.com/dgrijalva/jwt-go"
)
func EncodePublicKeyToPEM(key interface{}) ([]byte, error) {
pub, err := x509.MarshalPKIXPublicKey(key)
if err != nil {
return nil, err
}
data := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: pub,
})
return data, nil
}
func DecodePEMToPublicKey(pem []byte) (*rsa.PublicKey, error) {
return jwt.ParseRSAPublicKeyFromPEM(pem)
}

View File

@ -0,0 +1,2 @@
Client -> Server: POST /advertise\n\n{"ID": <PEER_ID>, "Attributes": <PEER_ATTRIBUTES>, "PublicKey": <PUBLIC_KEY> }
Server -> Client: 201 Created

View File

@ -0,0 +1,35 @@
<?xml version="1.0"?>
<!-- Generated by SVGo -->
<svg width="711" height="196"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<style>
@font-face {
font-family: 'DejaVuSans';
src: url('https://fontlibrary.org/assets/fonts/dejavu-sans/f5ec8426554a3a67ebcdd39f9c3fee83/49c0f03ec2fa354df7002bcb6331e106/DejaVuSansBook.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
</style>
</defs>
<line x1="45" y1="24" x2="45" y2="172" style="stroke-dasharray:8,8;stroke-width:2px;stroke:black;" />
<rect x="8" y="8" width="75" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="24" y="29" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Client</text>
<rect x="8" y="156" width="75" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="24" y="177" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Client</text>
<line x1="661" y1="24" x2="661" y2="172" style="stroke-dasharray:8,8;stroke-width:2px;stroke:black;" />
<rect x="619" y="8" width="84" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="635" y="29" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Server</text>
<rect x="619" y="156" width="84" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="635" y="177" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Server</text>
<rect x="61" y="56" width="584" height="46" style="fill:white;stroke:white;" />
<text x="298" y="68" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >POST /advertise</text>
<text x="61" y="100" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >{&#34;ID&#34;: &lt;PEER_ID&gt;, &#34;Attributes&#34;: &lt;PEER_ATTRIBUTES&gt;, &#34;PublicKey&#34;: &lt;PUBLIC_KEY&gt; }</text>
<line x1="45" y1="106" x2="661" y2="106" style="stroke:black;stroke-width:2px;" />
<polyline points="652,101 661,106 652,111" style="fill:black;stroke-width:2px;stroke:black;" />
<rect x="310" y="122" width="87" height="14" style="fill:white;stroke:white;" />
<text x="310" y="134" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >201 Created</text>
<line x1="661" y1="140" x2="45" y2="140" style="stroke:black;stroke-width:2px;" />
<polyline points="54,135 45,140 54,145" style="fill:black;stroke-width:2px;stroke:black;" />
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,2 @@
Client -> Server: POST /ping\nAuthorization: Bearer <JWT_SIGNING_TOKEN>
Server -> Client: 204 No Content

View File

@ -0,0 +1,35 @@
<?xml version="1.0"?>
<!-- Generated by SVGo -->
<svg width="446" height="180"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<style>
@font-face {
font-family: 'DejaVuSans';
src: url('https://fontlibrary.org/assets/fonts/dejavu-sans/f5ec8426554a3a67ebcdd39f9c3fee83/49c0f03ec2fa354df7002bcb6331e106/DejaVuSansBook.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
</style>
</defs>
<line x1="45" y1="24" x2="45" y2="156" style="stroke-dasharray:8,8;stroke-width:2px;stroke:black;" />
<rect x="8" y="8" width="75" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="24" y="29" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Client</text>
<rect x="8" y="140" width="75" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="24" y="161" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Client</text>
<line x1="396" y1="24" x2="396" y2="156" style="stroke-dasharray:8,8;stroke-width:2px;stroke:black;" />
<rect x="354" y="8" width="84" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="370" y="29" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Server</text>
<rect x="354" y="140" width="84" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="370" y="161" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Server</text>
<rect x="61" y="56" width="319" height="30" style="fill:white;stroke:white;" />
<text x="181" y="68" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >POST /ping</text>
<text x="61" y="84" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >Authorization: Bearer &lt;JWT_SIGNING_TOKEN&gt;</text>
<line x1="45" y1="90" x2="396" y2="90" style="stroke:black;stroke-width:2px;" />
<polyline points="387,85 396,90 387,95" style="fill:black;stroke-width:2px;stroke:black;" />
<rect x="166" y="106" width="111" height="14" style="fill:white;stroke:white;" />
<text x="166" y="118" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >204 No Content</text>
<line x1="396" y1="124" x2="45" y2="124" style="stroke:black;stroke-width:2px;" />
<polyline points="54,119 45,124 54,129" style="fill:black;stroke-width:2px;stroke:black;" />
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,2 @@
Client -> Server: POST /update\nAuthorization: Bearer <JWT_SIGNING_TOKEN>\n\n{"Attributes": <PEER_ATTRIBUTES>}
Server -> Client: 204 No Content

View File

@ -0,0 +1,36 @@
<?xml version="1.0"?>
<!-- Generated by SVGo -->
<svg width="446" height="212"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<style>
@font-face {
font-family: 'DejaVuSans';
src: url('https://fontlibrary.org/assets/fonts/dejavu-sans/f5ec8426554a3a67ebcdd39f9c3fee83/49c0f03ec2fa354df7002bcb6331e106/DejaVuSansBook.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
</style>
</defs>
<line x1="45" y1="24" x2="45" y2="188" style="stroke-dasharray:8,8;stroke-width:2px;stroke:black;" />
<rect x="8" y="8" width="75" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="24" y="29" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Client</text>
<rect x="8" y="172" width="75" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="24" y="193" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Client</text>
<line x1="396" y1="24" x2="396" y2="188" style="stroke-dasharray:8,8;stroke-width:2px;stroke:black;" />
<rect x="354" y="8" width="84" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="370" y="29" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Server</text>
<rect x="354" y="172" width="84" height="32" style="fill:white;stroke-width:2px;stroke:black;" />
<text x="370" y="193" style="fill:black;font-family:DejaVuSans,sans-serif;font-size:16px;" >Server</text>
<rect x="61" y="56" width="319" height="62" style="fill:white;stroke:white;" />
<text x="172" y="68" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >POST /update</text>
<text x="61" y="84" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >Authorization: Bearer &lt;JWT_SIGNING_TOKEN&gt;</text>
<text x="91" y="116" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >{&#34;Attributes&#34;: &lt;PEER_ATTRIBUTES&gt;}</text>
<line x1="45" y1="122" x2="396" y2="122" style="stroke:black;stroke-width:2px;" />
<polyline points="387,117 396,122 387,127" style="fill:black;stroke-width:2px;stroke:black;" />
<rect x="166" y="138" width="111" height="14" style="fill:white;stroke:white;" />
<text x="166" y="150" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >204 No Content</text>
<line x1="396" y1="156" x2="45" y2="156" style="stroke:black;stroke-width:2px;" />
<polyline points="54,151 45,156 54,161" style="fill:black;stroke-width:2px;stroke:black;" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

7
go.mod Normal file
View File

@ -0,0 +1,7 @@
module forge.cadoles.com/wpetit/go-http-peering
require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/go-chi/chi v4.0.1+incompatible
github.com/pborman/uuid v1.2.0
)

8
go.sum Normal file
View File

@ -0,0 +1,8 @@
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/go-chi/chi v4.0.1+incompatible h1:RSRC5qmFPtO90t7pTL0DBMNpZFsb/sHF3RXVlDgFisA=
github.com/go-chi/chi v4.0.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=

153
memory/store.go Normal file
View File

@ -0,0 +1,153 @@
package memory
import (
"sync"
"time"
peering "forge.cadoles.com/wpetit/go-http-peering"
)
type Store struct {
peers map[peering.PeerID]*peering.Peer
mutex sync.RWMutex
}
func (s *Store) Create(id peering.PeerID, attrs peering.PeerAttributes) (*peering.Peer, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
if _, exists := s.peers[id]; exists {
return nil, peering.ErrPeerExists
}
peer := &peering.Peer{
PeerHeader: peering.PeerHeader{
ID: id,
Status: peering.StatusPending,
},
Attributes: attrs,
}
s.peers[id] = peer
return copy(peer), nil
}
func (s *Store) Get(id peering.PeerID) (*peering.Peer, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
peer, exists := s.peers[id]
if !exists {
return nil, peering.ErrPeerNotFound
}
return copy(peer), nil
}
func (s *Store) List() ([]peering.PeerHeader, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
headers := make([]peering.PeerHeader, len(s.peers))
i := 0
for _, p := range s.peers {
headers[i] = p.PeerHeader
i++
}
return headers, nil
}
func (s *Store) UpdateAttributes(id peering.PeerID, attrs peering.PeerAttributes) error {
s.mutex.Lock()
defer s.mutex.Unlock()
peer, exists := s.peers[id]
if !exists {
return peering.ErrPeerNotFound
}
peer.Attributes = attrs
return nil
}
func (s *Store) UpdateLastContact(id peering.PeerID, remoteAddress string, ts time.Time) error {
s.mutex.Lock()
defer s.mutex.Unlock()
peer, exists := s.peers[id]
if !exists {
return peering.ErrPeerNotFound
}
peer.LastAddress = remoteAddress
peer.LastContact = ts
return nil
}
func (s *Store) UpdatePublicKey(id peering.PeerID, publicKey []byte) error {
s.mutex.Lock()
defer s.mutex.Unlock()
peer, exists := s.peers[id]
if !exists {
return peering.ErrPeerNotFound
}
peer.PublicKey = publicKey
return nil
}
func (s *Store) Delete(id peering.PeerID) error {
s.mutex.Lock()
defer s.mutex.Unlock()
if _, exists := s.peers[id]; !exists {
return peering.ErrPeerNotFound
}
delete(s.peers, id)
return nil
}
func (s *Store) Accept(id peering.PeerID) error {
return s.updateStatus(id, peering.StatusPeered)
}
func (s *Store) Forget(id peering.PeerID) error {
return s.updateStatus(id, peering.StatusPending)
}
func (s *Store) Reject(id peering.PeerID) error {
return s.updateStatus(id, peering.StatusRejected)
}
func (s *Store) updateStatus(id peering.PeerID, status peering.PeerStatus) error {
s.mutex.Lock()
defer s.mutex.Unlock()
peer, exists := s.peers[id]
if !exists {
return peering.ErrPeerNotFound
}
peer.Status = status
return nil
}
func NewStore() *Store {
return &Store{
peers: make(map[peering.PeerID]*peering.Peer),
}
}
func copy(p *peering.Peer) *peering.Peer {
copy := *p
return &copy
}

8
modd.conf Normal file
View File

@ -0,0 +1,8 @@
**/*.go
!vendor/**.go {
prep: make test
}
doc/sequence-diagram/*.seq {
prep: make sequence-diagram
}

37
peer.go Normal file
View File

@ -0,0 +1,37 @@
package peering
import (
"time"
"github.com/pborman/uuid"
)
type PeerID string
func NewPeerID() PeerID {
id := uuid.New()
return PeerID(id)
}
type PeerStatus int
const (
StatusPending PeerStatus = iota
StatusPeered
StatusRejected
)
type PeerHeader struct {
ID PeerID
Status PeerStatus
LastAddress string
LastContact time.Time
}
type PeerAttributes map[string]interface{}
type Peer struct {
PeerHeader
Attributes PeerAttributes
PublicKey []byte
}

24
request.go Normal file
View File

@ -0,0 +1,24 @@
package peering
import jwt "github.com/dgrijalva/jwt-go"
const (
AdvertisePath = "/advertise"
UpdatePath = "/update"
PingPath = "/ping"
)
type AdvertisingRequest struct {
ID PeerID
Attributes PeerAttributes
PublicKey []byte
}
type UpdateRequest struct {
Attributes PeerAttributes
}
type PeerClaims struct {
jwt.StandardClaims
BodySum []byte `json:"bodySum"`
}

158
server/advertise_test.go Normal file
View File

@ -0,0 +1,158 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"forge.cadoles.com/wpetit/go-http-peering/crypto"
peering "forge.cadoles.com/wpetit/go-http-peering"
"forge.cadoles.com/wpetit/go-http-peering/memory"
)
func TestAdvertiseHandlerBadRequest(t *testing.T) {
store := memory.NewStore()
handler := AdvertiseHandler(store)
req := httptest.NewRequest("POST", peering.AdvertisePath, nil)
w := httptest.NewRecorder()
handler(w, req)
res := w.Result()
if g, e := res.StatusCode, http.StatusBadRequest; g != e {
t.Errorf("res.StatusCode: got '%v', expected '%v'", g, e)
}
peers, err := store.List()
if err != nil {
t.Fatal(err)
}
if g, e := len(peers), 0; g != e {
t.Errorf("len(peers): got '%v', expected '%v'", g, e)
}
}
func TestAdvertiseHandlerInvalidPublicKeyFormat(t *testing.T) {
store := memory.NewStore()
handler := AdvertiseHandler(store)
advertising := &peering.AdvertisingRequest{
ID: peering.NewPeerID(),
PublicKey: []byte("Test"),
}
body, err := json.Marshal(advertising)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", peering.AdvertisePath, bytes.NewReader(body))
w := httptest.NewRecorder()
handler(w, req)
res := w.Result()
if g, e := res.StatusCode, http.StatusBadRequest; g != e {
t.Errorf("res.StatusCode: got '%v', expected '%v'", g, e)
}
peers, err := store.List()
if err != nil {
t.Fatal(err)
}
if g, e := len(peers), 0; g != e {
t.Errorf("len(peers): got '%v', expected '%v'", g, e)
}
}
func TestAdvertiseHandlerExistingPeer(t *testing.T) {
store := memory.NewStore()
handler := AdvertiseHandler(store)
pk := mustGeneratePrivateKey()
pem, err := crypto.EncodePublicKeyToPEM(pk.Public())
if err != nil {
t.Fatal(err)
}
peerID := peering.NewPeerID()
advertising := &peering.AdvertisingRequest{
ID: peerID,
PublicKey: pem,
}
body, err := json.Marshal(advertising)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", peering.AdvertisePath, bytes.NewReader(body))
w := httptest.NewRecorder()
handler(w, req)
req = httptest.NewRequest("POST", peering.AdvertisePath, bytes.NewReader(body))
w = httptest.NewRecorder()
handler(w, req)
res := w.Result()
if g, e := res.StatusCode, http.StatusConflict; g != e {
t.Errorf("res.StatusCode: got '%v', expected '%v'", g, e)
}
}
func TestAdvertiseHandlerValidRequest(t *testing.T) {
store := memory.NewStore()
handler := AdvertiseHandler(store)
pk := mustGeneratePrivateKey()
pem, err := crypto.EncodePublicKeyToPEM(pk.Public())
if err != nil {
t.Fatal(err)
}
peerID := peering.NewPeerID()
advertising := &peering.AdvertisingRequest{
ID: peerID,
PublicKey: pem,
}
body, err := json.Marshal(advertising)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", peering.AdvertisePath, bytes.NewReader(body))
w := httptest.NewRecorder()
handler(w, req)
res := w.Result()
if g, e := res.StatusCode, http.StatusCreated; g != e {
t.Errorf("res.StatusCode: got '%v', expected '%v'", g, e)
}
peer, err := store.Get(peerID)
if err != nil {
t.Fatal(err)
}
if g, e := peer.PublicKey, advertising.PublicKey; !bytes.Equal(peer.PublicKey, advertising.PublicKey) {
t.Errorf("peer.PublicKey: got '%v', expected '%v'", g, e)
}
}

227
server/handler.go Normal file
View File

@ -0,0 +1,227 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"time"
peering "forge.cadoles.com/wpetit/go-http-peering"
"forge.cadoles.com/wpetit/go-http-peering/crypto"
)
var (
ErrInvalidAdvertisingRequest = errors.New("invalid advertising request")
ErrInvalidUpdateRequest = errors.New("invalid update request")
ErrPeerRejected = errors.New("peer rejected")
ErrPeerIDAlreadyInUse = errors.New("peer id already in use")
ErrUnauthorized = errors.New("unauthorized")
)
func AdvertiseHandler(store peering.Store, funcs ...OptionFunc) http.HandlerFunc {
options := createOptions(funcs...)
logger := options.Logger
handler := func(w http.ResponseWriter, r *http.Request) {
advertising := &peering.AdvertisingRequest{}
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(advertising); err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, ErrInvalidAdvertisingRequest)
return
}
if !options.PeerIDValidator(advertising.ID) {
logger.Printf("[ERROR] %s", ErrInvalidAdvertisingRequest)
options.ErrorHandler(w, r, ErrInvalidAdvertisingRequest)
return
}
if _, err := crypto.DecodePEMToPublicKey(advertising.PublicKey); err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, ErrInvalidAdvertisingRequest)
return
}
peer, err := store.Get(advertising.ID)
if err == nil {
logger.Printf("[ERROR] %s", ErrPeerIDAlreadyInUse)
options.ErrorHandler(w, r, ErrPeerIDAlreadyInUse)
return
}
if err != peering.ErrPeerNotFound {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
attrs := filterAttributes(options.PeerAttributes, advertising.Attributes)
peer, err = store.Create(advertising.ID, attrs)
if err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
if err := store.UpdateLastContact(peer.ID, r.RemoteAddr, time.Now()); err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
if err := store.UpdatePublicKey(peer.ID, advertising.PublicKey); err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
w.WriteHeader(http.StatusCreated)
}
return handler
}
func UpdateHandler(store peering.Store, funcs ...OptionFunc) http.HandlerFunc {
options := createOptions(funcs...)
logger := options.Logger
handler := func(w http.ResponseWriter, r *http.Request) {
update := &peering.UpdateRequest{}
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(update); err != nil {
options.ErrorHandler(w, r, ErrInvalidUpdateRequest)
return
}
peerID, err := GetPeerID(r)
if err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
peer, err := store.Get(peerID)
if err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
if peer == nil {
logger.Printf("[ERROR] %s", ErrUnauthorized)
options.ErrorHandler(w, r, ErrUnauthorized)
return
}
if peer.Status == peering.StatusRejected {
logger.Printf("[ERROR] %s", ErrPeerRejected)
options.ErrorHandler(w, r, ErrPeerRejected)
return
}
if err := store.UpdateLastContact(peer.ID, r.RemoteAddr, time.Now()); err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
attrs := filterAttributes(options.PeerAttributes, update.Attributes)
if err := store.UpdateAttributes(peer.ID, attrs); err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
return handler
}
func PingHandler(store peering.Store, funcs ...OptionFunc) http.HandlerFunc {
options := createOptions(funcs...)
logger := options.Logger
handler := func(w http.ResponseWriter, r *http.Request) {
update := &peering.UpdateRequest{}
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(update); err != nil {
options.ErrorHandler(w, r, ErrInvalidUpdateRequest)
return
}
peerID, err := GetPeerID(r)
if err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
peer, err := store.Get(peerID)
if err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
if peer == nil {
logger.Printf("[ERROR] %s", ErrUnauthorized)
options.ErrorHandler(w, r, ErrUnauthorized)
return
}
if peer.Status == peering.StatusRejected {
logger.Printf("[ERROR] %s", ErrPeerRejected)
options.ErrorHandler(w, r, ErrPeerRejected)
return
}
if err := store.UpdateLastContact(peer.ID, r.RemoteAddr, time.Now()); err != nil {
logger.Printf("[ERROR] %s", err)
options.ErrorHandler(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
return handler
}
func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
switch err {
case ErrInvalidAdvertisingRequest:
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
case ErrPeerIDAlreadyInUse:
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
case ErrUnauthorized:
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
case ErrPeerRejected:
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
default:
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
func DefaultPeerIDValidator(id peering.PeerID) bool {
return string(id) != ""
}
func filterAttributes(filters []string, attrs peering.PeerAttributes) peering.PeerAttributes {
filtered := peering.PeerAttributes{}
for _, key := range filters {
if _, exists := attrs[key]; exists {
filtered[key] = attrs[key]
}
}
return filtered
}

157
server/middleware.go Normal file
View File

@ -0,0 +1,157 @@
package server
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"io/ioutil"
"strings"
"time"
"forge.cadoles.com/wpetit/go-http-peering/crypto"
peering "forge.cadoles.com/wpetit/go-http-peering"
jwt "github.com/dgrijalva/jwt-go"
"net/http"
)
const (
AuthorizationType = "Bearer"
KeyPeerID ContextKey = "peerID"
)
var (
ErrInvalidClaims = errors.New("invalid claims")
ErrInvalidChecksum = errors.New("invalid checksum")
ErrNotPeered = errors.New("not peered")
)
type ContextKey string
func Authenticate(store peering.Store, funcs ...OptionFunc) func(http.Handler) http.Handler {
options := createOptions(funcs...)
logger := options.Logger
middleware := func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
if authorization == "" {
sendError(w, http.StatusUnauthorized)
return
}
parts := strings.SplitN(authorization, " ", 2)
if len(parts) != 2 || parts[0] != AuthorizationType {
sendError(w, http.StatusUnauthorized)
return
}
token, err := jwt.ParseWithClaims(parts[1], &peering.PeerClaims{}, func(token *jwt.Token) (interface{}, error) {
claims, ok := token.Claims.(*peering.PeerClaims)
if !ok {
return nil, ErrInvalidClaims
}
peerID := peering.PeerID(claims.Issuer)
peer, err := store.Get(peerID)
if err != nil {
return nil, err
}
if peer.Status == peering.StatusRejected {
return nil, ErrPeerRejected
}
if peer.Status != peering.StatusPeered {
return nil, ErrNotPeered
}
publicKey, err := crypto.DecodePEMToPublicKey(peer.PublicKey)
if err != nil {
return nil, err
}
return publicKey, nil
})
if err != nil || !token.Valid {
logger.Printf("[ERROR] %s", err)
if err == ErrPeerRejected {
sendError(w, http.StatusForbidden)
} else {
sendError(w, http.StatusUnauthorized)
}
return
}
claims, ok := token.Claims.(*peering.PeerClaims)
if !ok {
logger.Printf("[ERROR] %s", ErrInvalidClaims)
sendError(w, http.StatusUnauthorized)
return
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
logger.Printf("[ERROR] %s", err)
sendError(w, http.StatusInternalServerError)
return
}
if err := r.Body.Close(); err != nil {
logger.Printf("[ERROR] %s", err)
sendError(w, http.StatusInternalServerError)
return
}
match, err := compareChecksum(body, claims.BodySum)
if err != nil {
logger.Printf("[ERROR] %s", err)
sendError(w, http.StatusUnauthorized)
return
}
if !match {
logger.Printf("[ERROR] %s", ErrInvalidChecksum)
sendError(w, http.StatusBadRequest)
return
}
peerID := peering.PeerID(claims.Issuer)
if err := store.UpdateLastContact(peerID, r.RemoteAddr, time.Now()); err != nil {
logger.Printf("[ERROR] %s", err)
sendError(w, http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), KeyPeerID, peerID)
r = r.WithContext(ctx)
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
return middleware
}
func GetPeerID(r *http.Request) (peering.PeerID, error) {
peerID, ok := r.Context().Value(KeyPeerID).(peering.PeerID)
if !ok {
return "", ErrUnauthorized
}
return peerID, nil
}
func sendError(w http.ResponseWriter, status int) {
http.Error(w, http.StatusText(status), status)
}
func compareChecksum(body []byte, sum []byte) (bool, error) {
sha := sha256.New()
_, err := sha.Write(body)
if err != nil {
return false, err
}
return bytes.Equal(sum, sha.Sum(nil)), nil
}

60
server/option.go Normal file
View File

@ -0,0 +1,60 @@
package server
import (
"log"
"net/http"
"os"
peering "forge.cadoles.com/wpetit/go-http-peering"
)
type Logger interface {
Printf(string, ...interface{})
}
type Options struct {
PeerAttributes []string
ErrorHandler ErrorHandler
PeerIDValidator func(peering.PeerID) bool
Logger Logger
}
type OptionFunc func(*Options)
type ErrorHandler func(http.ResponseWriter, *http.Request, error)
func WithPeerAttributes(attrs ...string) OptionFunc {
return func(options *Options) {
options.PeerAttributes = attrs
}
}
func WithLogger(logger Logger) OptionFunc {
return func(options *Options) {
options.Logger = logger
}
}
func WithErrorHandler(handler ErrorHandler) OptionFunc {
return func(options *Options) {
options.ErrorHandler = handler
}
}
func defaultOptions() *Options {
logger := log.New(os.Stdout, "[go-http-peering] ", log.LstdFlags|log.Lshortfile)
return &Options{
PeerAttributes: []string{"Label"},
ErrorHandler: DefaultErrorHandler,
PeerIDValidator: DefaultPeerIDValidator,
Logger: logger,
}
}
func createOptions(funcs ...OptionFunc) *Options {
options := defaultOptions()
for _, fn := range funcs {
fn(options)
}
return options
}

14
server/util_test.go Normal file
View File

@ -0,0 +1,14 @@
package server
import (
"crypto/rand"
"crypto/rsa"
)
func mustGeneratePrivateKey() *rsa.PrivateKey {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return privateKey
}

26
store.go Normal file
View File

@ -0,0 +1,26 @@
package peering
import (
"errors"
"time"
)
var (
ErrPeerNotFound = errors.New("peer not found")
ErrPeerExists = errors.New("peer exists")
)
type Store interface {
Create(id PeerID, attrs PeerAttributes) (*Peer, error)
Get(id PeerID) (*Peer, error)
Delete(id PeerID) error
List() ([]PeerHeader, error)
UpdatePublicKey(id PeerID, publicKey []byte) error
UpdateAttributes(id PeerID, attrs PeerAttributes) error
UpdateLastContact(id PeerID, remoteAddress string, ts time.Time) error
Accept(id PeerID) error
Forget(id PeerID) error
Reject(id PeerID) error
}

56
test/advertise_test.go Normal file
View File

@ -0,0 +1,56 @@
package test
import (
"reflect"
"testing"
"time"
peering "forge.cadoles.com/wpetit/go-http-peering"
"forge.cadoles.com/wpetit/go-http-peering/crypto"
)
func TestAdvertise(t *testing.T) {
if t.Skipped() {
t.SkipNow()
}
id, pk, client, store := setup(t)
attrs := peering.PeerAttributes{}
if err := client.Advertise(attrs); err != nil {
t.Fatal(err)
}
peer, err := store.Get(id)
if err != nil {
t.Error(err)
}
if g, e := peer.ID, id; g != e {
t.Errorf("peer.ID: got '%v', expected '%v'", g, e)
}
if g, e := peer.Attributes, attrs; !reflect.DeepEqual(g, e) {
t.Errorf("peer.Attributes: got '%v', expected '%v'", g, e)
}
var defaultTime time.Time
if peer.LastContact == defaultTime {
t.Error("peer.LastContact should not be time.Time zero value")
}
if peer.LastAddress == "" {
t.Error("peer.LastAddress should not be empty")
}
pem, err := crypto.EncodePublicKeyToPEM(pk.Public())
if err != nil {
t.Fatal(err)
}
if g, e := peer.PublicKey, pem; !reflect.DeepEqual(g, e) {
t.Errorf("peer.PublicKey: got '%v', expected '%v'", g, e)
}
}

46
test/ping_test.go Normal file
View File

@ -0,0 +1,46 @@
package test
import (
"testing"
peering "forge.cadoles.com/wpetit/go-http-peering"
)
func TestPing(t *testing.T) {
if t.Skipped() {
t.SkipNow()
}
id, _, client, store := setup(t)
attrs := peering.PeerAttributes{}
if err := client.Advertise(attrs); err != nil {
t.Fatal(err)
}
peer, err := store.Get(id)
if err != nil {
t.Fatal(err)
}
lastContact := peer.LastContact
if err := store.Accept(id); err != nil {
t.Error(err)
}
if err := client.Ping(); err != nil {
t.Fatal(err)
}
peer, err = store.Get(id)
if err != nil {
t.Fatal(err)
}
if peer.LastContact == lastContact {
t.Error("peer.LastContact should have been updated")
}
}

62
test/update_test.go Normal file
View File

@ -0,0 +1,62 @@
package test
import (
"reflect"
"testing"
"time"
peering "forge.cadoles.com/wpetit/go-http-peering"
"forge.cadoles.com/wpetit/go-http-peering/crypto"
)
func TestUpdate(t *testing.T) {
if t.Skipped() {
t.SkipNow()
}
id, pk, client, store := setup(t)
attrs := peering.PeerAttributes{}
if err := client.Advertise(attrs); err != nil {
t.Fatal(err)
}
if err := store.Accept(id); err != nil {
t.Error(err)
}
attrs["Label"] = "Foo Bar"
if err := client.UpdateAttributes(attrs); err != nil {
t.Fatal(err)
}
peer, err := store.Get(id)
if err != nil {
t.Fatal(err)
}
if g, e := peer.ID, id; g != e {
t.Errorf("peer.ID: got '%v', expected '%v'", g, e)
}
if g, e := peer.Attributes, attrs; !reflect.DeepEqual(g, e) {
t.Errorf("peer.Attributes: got '%v', expected '%v'", g, e)
}
var defaultTime time.Time
if peer.LastContact == defaultTime {
t.Error("peer.LastContact should not be time.Time zero value")
}
pem, err := crypto.EncodePublicKeyToPEM(pk.Public())
if err != nil {
t.Fatal(err)
}
if g, e := peer.PublicKey, pem; !reflect.DeepEqual(g, e) {
t.Errorf("peer.PublicKey: got '%v', expected '%v'", g, e)
}
}

64
test/util_test.go Normal file
View File

@ -0,0 +1,64 @@
package test
import (
"crypto/rand"
"crypto/rsa"
"fmt"
"net"
"net/http"
"testing"
peering "forge.cadoles.com/wpetit/go-http-peering"
"forge.cadoles.com/wpetit/go-http-peering/client"
"forge.cadoles.com/wpetit/go-http-peering/memory"
"forge.cadoles.com/wpetit/go-http-peering/server"
)
func mustGeneratePrivateKey() *rsa.PrivateKey {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return privateKey
}
func startServer(store peering.Store) (int, error) {
listener, err := net.Listen("tcp", ":0")
if err != nil {
return -1, err
}
mux := createServerMux(store)
go http.Serve(listener, mux)
port := listener.Addr().(*net.TCPAddr).Port
return port, nil
}
func createServerMux(store peering.Store) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc(peering.AdvertisePath, server.AdvertiseHandler(store))
update := server.Authenticate(store)(server.UpdateHandler(store))
mux.Handle(peering.UpdatePath, update)
ping := server.Authenticate(store)(server.PingHandler(store))
mux.Handle(peering.PingPath, ping)
return mux
}
func setup(t *testing.T) (peering.PeerID, *rsa.PrivateKey, *client.Client, peering.Store) {
store := memory.NewStore()
port, err := startServer(store)
if err != nil {
t.Fatal(err)
}
pk := mustGeneratePrivateKey()
id := peering.NewPeerID()
c := client.New(
client.WithBaseURL(fmt.Sprintf("http://127.0.0.1:%d", port)),
client.WithPrivateKey(pk),
client.WithPeerID(id),
)
return id, pk, c, store
}