From 82459297a42112a7ea32be5fdb3785e6ddb02a4a Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Tue, 5 Jan 2016 16:57:15 -0500 Subject: [PATCH 1/5] add support for jwt validation of infohashes --- CONFIGURATION.md | 23 ++++++ README.md | 9 ++- config/config.go | 7 ++ example_config.json | 3 + http/tracker.go | 6 ++ tracker/announce.go | 8 ++- tracker/jwt.go | 147 +++++++++++++++++++++++++++++++++++++++ tracker/models/models.go | 1 + tracker/tracker.go | 44 +++++++++--- 9 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 tracker/jwt.go diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 63bdab5..81370b0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -212,3 +212,26 @@ Whether the information about memory should be verbose. default: "5s" Interval at which to collect statistics about memory. + + +##### `jwkSetURI` + + type: string + default: "" + +If this string is not empty, then the tracker will attempt to use JWTs to validate infohashes before announces. The format for the JSON at this endpoint can be found at [the RFC for JWKs](https://tools.ietf.org/html/draft-ietf-jose-json-web-key-41#page-10) with the addition of an "issuer" key. Simply stated, this feature requires two fields at this JSON endpoint: "keys" and "issuer". "keys" is a list of JWKs that can be used to validate JWTs and "issuer" should match the "iss" claim in the JWT. The lifetime of a JWK is based upon standard HTTP caching headers and falls back to 5 minutes if no cache headers are provided. + + +#### `jwkSetUpdateInterval` + + type: duration + default: "5m" + +The interval at which keys are updated from JWKSetURI. Because the fallback lifetime for keys without cache headers is 5 minutes, this value should never be below 5 minutes unless you know your jwkSetURI has caching headers. + +#### `jwtAudience` + + type: string + default: "" + +The audience claim that is used to validate JWTs. diff --git a/README.md b/README.md index 8e4a075..fa5850f 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,18 @@ programming language. It is still heavily under development and the current `master` branch should probably not be used in production (unless you know what you're doing). -Features include: +Current features include: - Public tracker feature-set with full compatibility with what exists of the BitTorrent spec -- Private tracker feature-set with compatibility for a [Gazelle]-like deployment (WIP) - Low resource consumption, and fast, asynchronous request processing - Full IPv6 support, including handling for dual-stacked peers - Extensive metrics for visibility into the tracker and swarm's performance - Ability to prioritize peers in local subnets to reduce backbone contention -- Pluggable backend driver that can coordinate with an external database +- JWT Validation to approve the usage of a given infohash. + +Planned features include: + +- Private tracker feature-set with compatibility for a [Gazelle]-like deployment (WIP) [BitTorrent tracker]: http://en.wikipedia.org/wiki/BitTorrent_tracker [gazelle]: https://github.com/whatcd/gazelle diff --git a/config/config.go b/config/config.go index fe50f45..87f93f6 100644 --- a/config/config.go +++ b/config/config.go @@ -71,6 +71,10 @@ type TrackerConfig struct { NumWantFallback int `json:"defaultNumWant"` TorrentMapShards int `json:"torrentMapShards"` + JWKSetURI string `json:"jwkSetURI"` + JWKSetUpdateInterval Duration `json:"jwkSetUpdateInterval"` + JWTAudience string `json:"jwtAudience"` + NetConfig WhitelistConfig } @@ -119,6 +123,9 @@ var DefaultConfig = Config{ ReapRatio: 1.25, NumWantFallback: 50, TorrentMapShards: 1, + JWKSetURI: "", + JWKSetUpdateInterval: Duration{5 * time.Minute}, + JWTAudience: "", NetConfig: NetConfig{ AllowIPSpoofing: true, diff --git a/example_config.json b/example_config.json index 7342849..15e7873 100644 --- a/example_config.json +++ b/example_config.json @@ -7,6 +7,9 @@ "reapRatio": 1.25, "defaultNumWant": 50, "torrentMapShards": 1, + "jwkSetURI": "", + "jwkSetUpdateInterval": "5m", + "jwtAudience": "", "allowIPSpoofing": true, "dualStackedPeers": true, "realIPHeader": "", diff --git a/http/tracker.go b/http/tracker.go index 3a1aa59..5f37c41 100644 --- a/http/tracker.go +++ b/http/tracker.go @@ -38,6 +38,11 @@ func (s *Server) newAnnounce(r *http.Request, p httprouter.Params) (*models.Anno return nil, models.ErrMalformedRequest } + jwt, exists := q.Params["jwt"] + if s.config.JWKSetURI != "" && !exists { + return nil, models.ErrMalformedRequest + } + port, err := q.Uint64("port") if err != nil { return nil, models.ErrMalformedRequest @@ -78,6 +83,7 @@ func (s *Server) newAnnounce(r *http.Request, p httprouter.Params) (*models.Anno NumWant: numWant, PeerID: peerID, Uploaded: uploaded, + JWT: jwt, }, nil } diff --git a/tracker/announce.go b/tracker/announce.go index bb8424e..2f4bc3c 100644 --- a/tracker/announce.go +++ b/tracker/announce.go @@ -18,8 +18,14 @@ func (tkr *Tracker) HandleAnnounce(ann *models.Announce, w Writer) (err error) { } } - torrent, err := tkr.FindTorrent(ann.Infohash) + if tkr.Config.JWKSetURI != "" { + err := tkr.validateJWT(ann.JWT, ann.Infohash) + if err != nil { + return err + } + } + torrent, err := tkr.FindTorrent(ann.Infohash) if err == models.ErrTorrentDNE && tkr.Config.CreateOnAnnounce { torrent = &models.Torrent{ Infohash: ann.Infohash, diff --git a/tracker/jwt.go b/tracker/jwt.go new file mode 100644 index 0000000..3e05a5a --- /dev/null +++ b/tracker/jwt.go @@ -0,0 +1,147 @@ +// Copyright 2015 The Chihaya Authors. All rights reserved. +// Use of this source code is governed by the BSD 2-Clause license, +// which can be found in the LICENSE file. + +package tracker + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + oidchttp "github.com/coreos/go-oidc/http" + "github.com/coreos/go-oidc/jose" + "github.com/golang/glog" +) + +const jwkTTLFallback = 5 * time.Minute + +func (tkr *Tracker) updateJWKSetForever() { + defer tkr.shutdownWG.Done() + + client := &http.Client{Timeout: 5 * time.Second} + + // Get initial JWK Set. + err := tkr.updateJWKSet(client) + if err != nil { + glog.Warningf("Failed to get initial JWK Set: %s", err) + } + + for { + select { + case <-tkr.shuttingDown: + return + + case <-time.After(tkr.Config.JWKSetUpdateInterval.Duration): + err = tkr.updateJWKSet(client) + if err != nil { + glog.Warningf("Failed to update JWK Set: %s", err) + } + } + } +} + +type jwkSet struct { + Keys []jose.JWK `json:"keys"` + Issuer string `json:"issuer"` + validUntil time.Time +} + +func (tkr *Tracker) updateJWKSet(client *http.Client) error { + glog.Info("Attemping to update JWK Set") + resp, err := client.Get(tkr.Config.JWKSetURI) + if err != nil { + return err + } + defer resp.Body.Close() + + var jwks jwkSet + err = json.NewDecoder(resp.Body).Decode(&jwks) + if err != nil { + return err + } + + if len(jwks.Keys) == 0 { + return errors.New("Failed to find any keys from JWK Set URI") + } + + if jwks.Issuer == "" { + return errors.New("Failed to find any issuer from JWK Set URI") + } + + ttl, _, _ := oidchttp.Cacheable(resp.Header) + if ttl == 0 { + ttl = jwkTTLFallback + } + jwks.validUntil = time.Now().Add(ttl) + + tkr.jwkSet = jwks + glog.Info("Successfully updated JWK Set") + return nil +} + +func validateJWTSignature(jwt *jose.JWT, jwkSet *jwkSet) (bool, error) { + for _, jwk := range jwkSet.Keys { + v, err := jose.NewVerifier(jwk) + if err != nil { + return false, err + } + + if err := v.Verify(jwt.Signature, []byte(jwt.Data())); err == nil { + return true, nil + } + } + + return false, nil +} + +func (tkr *Tracker) validateJWT(jwtStr, infohash string) error { + jwkSet := tkr.jwkSet + if time.Now().After(jwkSet.validUntil) { + return fmt.Errorf("Failed verify JWT due to stale JWK Set") + } + + jwt, err := jose.ParseJWT(jwtStr) + if err != nil { + return err + } + + validated, err := validateJWTSignature(&jwt, &jwkSet) + if err != nil { + return err + } else if !validated { + return errors.New("Failed to verify JWT with all available verifiers") + } + + claims, err := jwt.Claims() + if err != nil { + return err + } + + if claimedIssuer, ok, err := claims.StringClaim("iss"); claimedIssuer != jwkSet.Issuer || err != nil || !ok { + return errors.New("Failed to validate JWT issuer claim") + } + + if claimedAudience, ok, err := claims.StringClaim("aud"); claimedAudience != tkr.Config.JWTAudience || err != nil || !ok { + return errors.New("Failed to validate JWT audience claim") + } + + claimedInfohash, ok, err := claims.StringClaim("infohash") + if err != nil || !ok { + return errors.New("Failed to validate JWT infohash claim") + } + + unescapedInfohash, err := url.QueryUnescape(claimedInfohash) + if err != nil { + return errors.New("Failed to unescape JWT infohash claim") + } + + if unescapedInfohash != infohash { + return errors.New("Failed to match infohash claim with requested infohash") + } + + return nil +} diff --git a/tracker/models/models.go b/tracker/models/models.go index 5d777d0..dc1d2a7 100644 --- a/tracker/models/models.go +++ b/tracker/models/models.go @@ -136,6 +136,7 @@ type Announce struct { NumWant int `json:"numwant"` PeerID string `json:"peer_id"` Uploaded uint64 `json:"uploaded"` + JWT string `json:"jwt"` Torrent *Torrent `json:"-"` Peer *Peer `json:"-"` diff --git a/tracker/tracker.go b/tracker/tracker.go index 34cb6c1..7ee38e5 100644 --- a/tracker/tracker.go +++ b/tracker/tracker.go @@ -7,6 +7,7 @@ package tracker import ( + "sync" "time" "github.com/golang/glog" @@ -19,6 +20,12 @@ import ( // independently of the underlying data transports used. type Tracker struct { Config *config.Config + + jwkSet jwkSet + + shuttingDown chan struct{} + shutdownWG sync.WaitGroup + *Storage } @@ -26,16 +33,25 @@ type Tracker struct { // Maintenance routines are automatically spawned in the background. func New(cfg *config.Config) (*Tracker, error) { tkr := &Tracker{ - Config: cfg, - Storage: NewStorage(cfg), + Config: cfg, + Storage: NewStorage(cfg), + shuttingDown: make(chan struct{}), } + glog.Info("Starting garbage collection goroutine") + tkr.shutdownWG.Add(1) go tkr.purgeInactivePeers( cfg.PurgeInactiveTorrents, time.Duration(float64(cfg.MinAnnounce.Duration)*cfg.ReapRatio), cfg.ReapInterval.Duration, ) + if tkr.Config.JWKSetURI != "" { + glog.Info("Starting JWK Set update goroutine") + tkr.shutdownWG.Add(1) + go tkr.updateJWKSetForever() + } + if cfg.ClientWhitelistEnabled { tkr.LoadApprovedClients(cfg.ClientWhitelist) } @@ -45,8 +61,8 @@ func New(cfg *config.Config) (*Tracker, error) { // Close gracefully shutdowns a Tracker by closing any database connections. func (tkr *Tracker) Close() error { - - // TODO(jzelinskie): shutdown purgeInactivePeers goroutine. + close(tkr.shuttingDown) + tkr.shutdownWG.Wait() return nil } @@ -73,13 +89,21 @@ type Writer interface { // purgeInactivePeers periodically walks the torrent database and removes // peers that haven't announced recently. func (tkr *Tracker) purgeInactivePeers(purgeEmptyTorrents bool, threshold, interval time.Duration) { - for _ = range time.NewTicker(interval).C { - before := time.Now().Add(-threshold) - glog.V(0).Infof("Purging peers with no announces since %s", before) + defer tkr.shutdownWG.Done() - err := tkr.PurgeInactivePeers(purgeEmptyTorrents, before) - if err != nil { - glog.Errorf("Error purging torrents: %s", err) + for { + select { + case <-tkr.shuttingDown: + return + + case <-time.NewTicker(interval).C: + before := time.Now().Add(-threshold) + glog.V(0).Infof("Purging peers with no announces since %s", before) + + err := tkr.PurgeInactivePeers(purgeEmptyTorrents, before) + if err != nil { + glog.Errorf("Error purging torrents: %s", err) + } } } } From e2c40652ad7df0c768b331e53a964ba4fede1907 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Thu, 7 Jan 2016 12:12:54 -0500 Subject: [PATCH 2/5] update godeps with jwt dependencies --- Godeps/Godeps.json | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index 15011eb..b7fd87b 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -1,18 +1,36 @@ { "ImportPath": "github.com/chihaya/chihaya", - "GoVersion": "go1.4.2", + "GoVersion": "go1.5.1", "Deps": [ { "ImportPath": "github.com/chihaya/bencode", "Rev": "3c485a8d166ff6a79baba90c2c2da01c8348e930" }, + { + "ImportPath": "github.com/coreos/go-oidc/http", + "Rev": "ec2746d2ccb220e81c41b0b0cb2d4a1cc23f7950" + }, + { + "ImportPath": "github.com/coreos/go-oidc/jose", + "Rev": "ec2746d2ccb220e81c41b0b0cb2d4a1cc23f7950" + }, + { + "ImportPath": "github.com/coreos/go-systemd/journal", + "Comment": "v4-36-gdd4f6b8", + "Rev": "dd4f6b87c2a80813d1a01790344322da19ff195e" + }, + { + "ImportPath": "github.com/coreos/pkg/capnslog", + "Rev": "2c77715c4df99b5420ffcae14ead08f52104065d" + }, { "ImportPath": "github.com/golang/glog", - "Rev": "44145f04b68cf362d9c4df2182967c2275eaefed" + "Rev": "fca8c8854093a154ff1eb580aae10276ad6b1b5f" }, { "ImportPath": "github.com/julienschmidt/httprouter", - "Rev": "8c199fb6259ffc1af525cc3ad52ee60ba8359669" + "Comment": "v1.1-14-g21439ef", + "Rev": "21439ef4d70ba4f3e2a5ed9249e7b03af4019b40" }, { "ImportPath": "github.com/pushrax/bufferpool", @@ -28,12 +46,12 @@ }, { "ImportPath": "github.com/tylerb/graceful", - "Comment": "v1-7-g0c01122", - "Rev": "0c011221e91b35f488b8818b00ca279929e9ed7d" + "Comment": "v1.2.3", + "Rev": "48afeb21e2fcbcff0f30bd5ad6b97747b0fae38e" }, { "ImportPath": "golang.org/x/net/netutil", - "Rev": "d175081df37eff8cda13f478bc11a0a65b39958b" + "Rev": "520af5de654dc4dd4f0f65aa40e66dbbd9043df1" } ] } From cb161daca10ac294ade1f98d78fa9ac438d37269 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Thu, 7 Jan 2016 14:28:31 -0500 Subject: [PATCH 3/5] dockerfile: add port range and log level --- Dockerfile | 9 ++++++--- README.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8db405a..30fc4b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,10 @@ ADD udp /go/src/github.com/chihaya/chihaya/udp # Install RUN go install github.com/chihaya/chihaya/cmd/chihaya -# docker run -p 6881:6881 -v $PATH_TO_DIR_WITH_CONF_FILE:/config quay.io/jzelinskie/chihaya +# Configuration/environment VOLUME ["/config"] -EXPOSE 6881 -CMD ["chihaya", "-config=/config/config.json", "-logtostderr=true"] +ENV CHIHAYA_LOG_LEVEL 5 +EXPOSE 6880-6882 + +# docker run -p 6880-6882:6880-6882 -v $PATH_TO_DIR_WITH_CONF_FILE:/config:ro -e CHIHAYA_LOG_LEVEL=5 quay.io/jzelinskie/chihaya:latest +CMD ["sh", "-c", "chihaya", "-config=/config/config.json", "-logtostderr=true", "-v=$CHIHAYA_LOG_LEVEL"] diff --git a/README.md b/README.md index fa5850f..7ec1dae 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ An explanation of the available keys can be found in [CONFIGURATION.md]. ```sh $ docker pull quay.io/jzelinskie/chihaya:latest -$ docker run -p 6881:6881 -v $DIR_WITH_CONFIG:/config:ro quay.io/jzelinskie/chihaya:latest +$ docker run -p 6880-6882:6880-6882 -v $PATH_TO_DIR_WITH_CONF_FILE:/config:ro -e CHIHAYA_LOG_LEVEL=5 quay.io/jzelinskie/chihaya:latest ``` ## Developing Chihaya From 9825a69ac74eb8c862258a155f839457c326c3b4 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Fri, 8 Jan 2016 15:39:04 -0500 Subject: [PATCH 4/5] dockerfile: change entrypoint to allow args --- Dockerfile | 6 +++--- README.md | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 30fc4b5..1316d62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,8 +26,8 @@ RUN go install github.com/chihaya/chihaya/cmd/chihaya # Configuration/environment VOLUME ["/config"] -ENV CHIHAYA_LOG_LEVEL 5 EXPOSE 6880-6882 -# docker run -p 6880-6882:6880-6882 -v $PATH_TO_DIR_WITH_CONF_FILE:/config:ro -e CHIHAYA_LOG_LEVEL=5 quay.io/jzelinskie/chihaya:latest -CMD ["sh", "-c", "chihaya", "-config=/config/config.json", "-logtostderr=true", "-v=$CHIHAYA_LOG_LEVEL"] +# docker run -p 6880-6882:6880-6882 -v $PATH_TO_DIR_WITH_CONF_FILE:/config:ro -e quay.io/jzelinskie/chihaya:latest -v=5 +ENTRYPOINT ["chihaya", "-config=/config/config.json", "-logtostderr=true"] +CMD ["-v=5"] diff --git a/README.md b/README.md index 7ec1dae..f1dffbd 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ An explanation of the available keys can be found in [CONFIGURATION.md]. ```sh $ docker pull quay.io/jzelinskie/chihaya:latest -$ docker run -p 6880-6882:6880-6882 -v $PATH_TO_DIR_WITH_CONF_FILE:/config:ro -e CHIHAYA_LOG_LEVEL=5 quay.io/jzelinskie/chihaya:latest +$ export CHIHAYA_LOG_LEVEL=5 # most verbose, and the default +$ docker run -p 6880-6882:6880-6882 -v $PATH_TO_DIR_WITH_CONF_FILE:/config:ro -e quay.io/jzelinskie/chihaya:latest -v=$CHIHAYA_LOG_LEVEL ``` ## Developing Chihaya From 29c206611e5a2344559c2d01aa0d7d8bf75f792d Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Fri, 8 Jan 2016 15:55:10 -0500 Subject: [PATCH 5/5] replace time.NewTicker().C with time.After They are synonymous and time.After is much easier to read. --- stats/stats.go | 2 +- tracker/tracker.go | 2 +- udp/udp.go | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/stats/stats.go b/stats/stats.go index e3a00fc..9c056b0 100644 --- a/stats/stats.go +++ b/stats/stats.go @@ -122,7 +122,7 @@ func New(cfg config.StatsConfig) *Stats { if cfg.IncludeMem { s.MemStatsWrapper = NewMemStatsWrapper(cfg.VerboseMem) - s.recordMemStats = time.NewTicker(cfg.MemUpdateInterval.Duration).C + s.recordMemStats = time.After(cfg.MemUpdateInterval.Duration) } s.flattened = flatjson.Flatten(s) diff --git a/tracker/tracker.go b/tracker/tracker.go index 7ee38e5..09a15a2 100644 --- a/tracker/tracker.go +++ b/tracker/tracker.go @@ -96,7 +96,7 @@ func (tkr *Tracker) purgeInactivePeers(purgeEmptyTorrents bool, threshold, inter case <-tkr.shuttingDown: return - case <-time.NewTicker(interval).C: + case <-time.After(interval): before := time.Now().Add(-threshold) glog.V(0).Infof("Purging peers with no announces since %s", before) diff --git a/udp/udp.go b/udp/udp.go index 9920911..fa89200 100644 --- a/udp/udp.go +++ b/udp/udp.go @@ -107,14 +107,12 @@ func (s *Server) Serve() { s.wg.Add(1) go func() { defer s.wg.Done() - // Generate a new IV every hour. - t := time.NewTicker(time.Hour) for { select { - case <-t.C: - s.connIDGen.NewIV() case <-s.closing: return + case <-time.After(time.Hour): + s.connIDGen.NewIV() } } }()