Initial commit
This commit is contained in:
commit
2a8a20195a
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/coverage
|
||||
/vendor
|
30
Makefile
Normal file
30
Makefile
Normal 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
25
README.md
Normal 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
16
chi/mount.go
Normal 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
35
chi/mount_test.go
Normal 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
187
client/client.go
Normal 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
56
client/option.go
Normal 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
25
crypto/pem.go
Normal 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)
|
||||
}
|
2
doc/sequence-diagram/advertise.seq
Normal file
2
doc/sequence-diagram/advertise.seq
Normal file
@ -0,0 +1,2 @@
|
||||
Client -> Server: POST /advertise\n\n{"ID": <PEER_ID>, "Attributes": <PEER_ATTRIBUTES>, "PublicKey": <PUBLIC_KEY> }
|
||||
Server -> Client: 201 Created
|
35
doc/sequence-diagram/advertise.svg
Normal file
35
doc/sequence-diagram/advertise.svg
Normal 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;" >{"ID": <PEER_ID>, "Attributes": <PEER_ATTRIBUTES>, "PublicKey": <PUBLIC_KEY> }</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 |
2
doc/sequence-diagram/ping.seq
Normal file
2
doc/sequence-diagram/ping.seq
Normal file
@ -0,0 +1,2 @@
|
||||
Client -> Server: POST /ping\nAuthorization: Bearer <JWT_SIGNING_TOKEN>
|
||||
Server -> Client: 204 No Content
|
35
doc/sequence-diagram/ping.svg
Normal file
35
doc/sequence-diagram/ping.svg
Normal 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 <JWT_SIGNING_TOKEN></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 |
2
doc/sequence-diagram/update.seq
Normal file
2
doc/sequence-diagram/update.seq
Normal file
@ -0,0 +1,2 @@
|
||||
Client -> Server: POST /update\nAuthorization: Bearer <JWT_SIGNING_TOKEN>\n\n{"Attributes": <PEER_ATTRIBUTES>}
|
||||
Server -> Client: 204 No Content
|
36
doc/sequence-diagram/update.svg
Normal file
36
doc/sequence-diagram/update.svg
Normal 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 <JWT_SIGNING_TOKEN></text>
|
||||
<text x="91" y="116" style="font-family:DejaVuSans,sans-serif;font-size:14px;" >{"Attributes": <PEER_ATTRIBUTES>}</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
7
go.mod
Normal 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
8
go.sum
Normal 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
153
memory/store.go
Normal 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 ©
|
||||
}
|
8
modd.conf
Normal file
8
modd.conf
Normal file
@ -0,0 +1,8 @@
|
||||
**/*.go
|
||||
!vendor/**.go {
|
||||
prep: make test
|
||||
}
|
||||
|
||||
doc/sequence-diagram/*.seq {
|
||||
prep: make sequence-diagram
|
||||
}
|
37
peer.go
Normal file
37
peer.go
Normal 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
24
request.go
Normal 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
158
server/advertise_test.go
Normal 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
227
server/handler.go
Normal 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
157
server/middleware.go
Normal 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
60
server/option.go
Normal 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
14
server/util_test.go
Normal 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
26
store.go
Normal 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
56
test/advertise_test.go
Normal 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
46
test/ping_test.go
Normal 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
62
test/update_test.go
Normal 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
64
test/util_test.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user