diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..1b36152 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,38 @@ +name: Run tests + +on: + pull_request: + paths: + - '**.go' + - 'go.*' + - .github/workflows/test.yaml + + push: + paths: + - '**.go' + - 'go.*' + - .github/workflows/test.yaml + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.13 + uses: actions/setup-go@v1 + with: + go-version: 1.13 + id: go + - uses: actions/checkout@v1 + - name: Run tests + run: | + go test ./... -v -race -coverprofile cover.out -covermode=atomic + - name: Convert coverage to lcov + uses: jandelgado/gcov2lcov-action@v1.0.0 + with: + infile: cover.out + outfile: coverage.lcov + - name: Coveralls + uses: coverallsapp/github-action@v1.0.1 + with: + github-token: ${{ secrets.github_token }} + path-to-lcov: coverage.lcov diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc67d1..41f58ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Fluux XMPP Changelog +## v0.5.0 + +### Changes + +- Added support for XEP-0198 (Stream management) +- Added message queue : when using "SendX" methods on a client, messages are also stored in a queue. When requesting +acks from the server, sent messages will be discarded, and unsent ones will be sent again. (see https://xmpp.org/extensions/xep-0198.html#acking) +- Added support for stanza_errors (see https://xmpp.org/rfcs/rfc3920.html#def C.2. Stream error namespace and https://xmpp.org/rfcs/rfc6120.html#schemas-streamerror) +- Added separate hooks for connection and reconnection on the client. One can now specify different actions to get triggered on client connect +and reconnect, at client init time. +- Client state update is now thread safe +- Changed the Config struct to use pointer semantics +- Tests +- Refactoring, including removing some Fprintf statements in favor of Marshal + Write and using structs from the library +instead of strings + +## v0.4.0 + +### Changes + +- Added support for XEP-0060 (PubSub) +(no support for 6.5.4 Returning Some Items yet as it needs XEP-0059, Result Sets) +- Added support for XEP-0050 (Commands) +- Added support for XEP-0004 (Forms) +- Updated the client example with a TUI +- Make keepalive interval configurable #134 +- Fix updating of EventManager.CurrentState #136 +- Added callbacks for error management in Component and Client. Users must now provide a callback function when using NewClient/Component. +- Moved JID from xmpp package to stanza package + +## v0.3.0 + +### Changes + +- Update requirements to go1.13 +- Add a websocket transport +- Add Client.SendIQ method +- Add IQ result routes to the Router +- Fix SIGSEGV in xmpp_component (#126) +- Add tests for Component and code style fixes + ## v0.2.0 ### Changes @@ -14,4 +55,4 @@ ### Code migration guide -TODO \ No newline at end of file +TODO diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index d159afa..0000000 --- a/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM golang:1.13 -WORKDIR /xmpp -RUN curl -o codecov.sh -s https://codecov.io/bash && chmod +x codecov.sh -COPY . ./ diff --git a/README.md b/README.md index 82d051b..3487b01 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fluux XMPP -[![Codeship Status for FluuxIO/xmpp](https://app.codeship.com/projects/dba7f300-d145-0135-6c51-26e28af241d2/status?branch=master)](https://app.codeship.com/projects/262399) [![GoDoc](https://godoc.org/gosrc.io/xmpp?status.svg)](https://godoc.org/gosrc.io/xmpp) [![GoReportCard](https://goreportcard.com/badge/gosrc.io/xmpp)](https://goreportcard.com/report/fluux.io/xmpp) [![codecov](https://codecov.io/gh/FluuxIO/go-xmpp/branch/master/graph/badge.svg)](https://codecov.io/gh/FluuxIO/go-xmpp) +[![GoDoc](https://godoc.org/gosrc.io/xmpp?status.svg)](https://godoc.org/gosrc.io/xmpp) [![GoReportCard](https://goreportcard.com/badge/gosrc.io/xmpp)](https://goreportcard.com/report/fluux.io/xmpp) [![Coverage Status](https://coveralls.io/repos/github/FluuxIO/go-xmpp/badge.svg?branch=master)](https://coveralls.io/github/FluuxIO/go-xmpp?branch=master) Fluux XMPP is a Go XMPP library, focusing on simplicity, simple automation, and IoT. @@ -11,7 +11,7 @@ The goal is to make simple to write simple XMPP clients and components: - For writing simple chatbot to control a service or a thing, - For writing XMPP servers components. -The library is designed to have minimal dependencies. For now, the library does not depend on any other library. +The library is designed to have minimal dependencies. Currently it requires at least Go 1.13. ## Configuration and connection @@ -52,6 +52,13 @@ config := xmpp.Config{ - [XEP-0355: Namespace Delegation](https://xmpp.org/extensions/xep-0355.html) - [XEP-0356: Privileged Entity](https://xmpp.org/extensions/xep-0356.html) +### Extensions + - [XEP-0060: Publish-Subscribe](https://xmpp.org/extensions/xep-0060.html) + Note : "6.5.4 Returning Some Items" requires support for [XEP-0059: Result Set Management](https://xmpp.org/extensions/xep-0059.html), + and is therefore not supported yet. + - [XEP-0004: Data Forms](https://xmpp.org/extensions/xep-0004.html) + - [XEP-0050: Ad-Hoc Commands](https://xmpp.org/extensions/xep-0050.html) + ## Package overview ### Stanza subpackage @@ -108,15 +115,16 @@ func main() { Address: "localhost:5222", }, Jid: "test@localhost", - Credential: xmpp.Password("Test"), + Credential: xmpp.Password("test"), StreamLogger: os.Stdout, Insecure: true, + // TLSConfig: tls.Config{InsecureSkipVerify: true}, } router := xmpp.NewRouter() router.HandleFunc("message", handleMessage) - client, err := xmpp.NewClient(config, router) + client, err := xmpp.NewClient(config, router, errorHandler) if err != nil { log.Fatalf("%+v", err) } @@ -138,6 +146,11 @@ func handleMessage(s xmpp.Sender, p stanza.Packet) { reply := stanza.Message{Attrs: stanza.Attrs{To: msg.From}, Body: msg.Body} _ = s.Send(reply) } + +func errorHandler(err error) { + fmt.Println(err.Error()) +} + ``` ## Reference documentation diff --git a/_examples/custom_stanza/custom_stanza.go b/_examples/custom_stanza/custom_stanza.go index 46043f2..b4bfb52 100644 --- a/_examples/custom_stanza/custom_stanza.go +++ b/_examples/custom_stanza/custom_stanza.go @@ -9,7 +9,10 @@ import ( ) func main() { - iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "service.localhost", Id: "custom-pl-1"}) + iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "service.localhost", Id: "custom-pl-1"}) + if err != nil { + log.Fatalf("failed to create IQ: %v", err) + } payload := CustomPayload{XMLName: xml.Name{Space: "my:custom:payload", Local: "query"}, Node: "test"} iq.Payload = payload @@ -44,6 +47,9 @@ func (c CustomPayload) Namespace() string { return c.XMLName.Space } -func init() { - stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{"my:custom:payload", "query"}, CustomPayload{}) +func (c CustomPayload) GetSet() *stanza.ResultSet { + return nil +} +func init() { + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{Space: "my:custom:payload", Local: "query"}, CustomPayload{}) } diff --git a/_examples/delegation/delegation.go b/_examples/delegation/delegation.go index 81642c6..d022633 100644 --- a/_examples/delegation/delegation.go +++ b/_examples/delegation/delegation.go @@ -35,7 +35,9 @@ func main() { IQNamespaces("urn:xmpp:delegation:1"). HandlerFunc(handleDelegation) - component, err := xmpp.NewComponent(opts, router) + component, err := xmpp.NewComponent(opts, router, func(err error) { + log.Println(err) + }) if err != nil { log.Fatalf("%+v", err) } @@ -78,7 +80,7 @@ const ( // ctx.Opts func discoInfo(c xmpp.Sender, p stanza.Packet, opts xmpp.ComponentOptions) { // Type conversion & sanity checks - iq, ok := p.(stanza.IQ) + iq, ok := p.(*stanza.IQ) if !ok { return } @@ -87,15 +89,18 @@ func discoInfo(c xmpp.Sender, p stanza.Packet, opts xmpp.ComponentOptions) { return } - iqResp := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id}) + iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id}) + if err != nil { + log.Fatalf("failed to create IQ response: %v", err) + } switch info.Node { case "": - discoInfoRoot(&iqResp, opts) + discoInfoRoot(iqResp, opts) case pubsubNode: - discoInfoPubSub(&iqResp) + discoInfoPubSub(iqResp) case pepNode: - discoInfoPEP(&iqResp) + discoInfoPEP(iqResp) } _ = c.Send(iqResp) @@ -155,7 +160,7 @@ func discoInfoPEP(iqResp *stanza.IQ) { func handleDelegation(s xmpp.Sender, p stanza.Packet) { // Type conversion & sanity checks - iq, ok := p.(stanza.IQ) + iq, ok := p.(*stanza.IQ) if !ok { return } @@ -166,12 +171,12 @@ func handleDelegation(s xmpp.Sender, p stanza.Packet) { } forwardedPacket := delegation.Forwarded.Stanza fmt.Println(forwardedPacket) - forwardedIQ, ok := forwardedPacket.(stanza.IQ) + forwardedIQ, ok := forwardedPacket.(*stanza.IQ) if !ok { return } - pubsub, ok := forwardedIQ.Payload.(*stanza.PubSub) + pubsub, ok := forwardedIQ.Payload.(*stanza.PubSubGeneric) if !ok { // We only support pubsub delegation return @@ -179,8 +184,11 @@ func handleDelegation(s xmpp.Sender, p stanza.Packet) { if pubsub.Publish.XMLName.Local == "publish" { // Prepare pubsub IQ reply - iqResp := stanza.NewIQ(stanza.Attrs{Type: "result", From: forwardedIQ.To, To: forwardedIQ.From, Id: forwardedIQ.Id}) - payload := stanza.PubSub{ + iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: forwardedIQ.To, To: forwardedIQ.From, Id: forwardedIQ.Id}) + if err != nil { + log.Fatalf("failed to create iqResp: %v", err) + } + payload := stanza.PubSubGeneric{ XMLName: xml.Name{ Space: "http://jabber.org/protocol/pubsub", Local: "pubsub", @@ -188,7 +196,10 @@ func handleDelegation(s xmpp.Sender, p stanza.Packet) { } iqResp.Payload = &payload // Wrap the reply in delegation 'forward' - iqForward := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id}) + iqForward, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id}) + if err != nil { + log.Fatalf("failed to create iqForward: %v", err) + } delegPayload := stanza.Delegation{ XMLName: xml.Name{ Space: "urn:xmpp:delegation:1", diff --git a/_examples/go.mod b/_examples/go.mod index 779d698..1dd46c2 100644 --- a/_examples/go.mod +++ b/_examples/go.mod @@ -5,7 +5,7 @@ go 1.13 require ( github.com/processone/mpg123 v1.0.0 github.com/processone/soundcloud v1.0.0 - gosrc.io/xmpp v0.1.1 + gosrc.io/xmpp v0.4.0 ) replace gosrc.io/xmpp => ./../ diff --git a/_examples/go.sum b/_examples/go.sum index 6e3b0bc..467aab6 100644 --- a/_examples/go.sum +++ b/_examples/go.sum @@ -1,37 +1,82 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/agnivade/wasmbrowsertest v0.3.1/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2eQm0SjC/SDz0mPWQI= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/awesome-gocui/gocui v0.6.0/go.mod h1:1QikxFaPhe2frKeKvEwZEIGia3haiOxOUXKinrv17mA= +github.com/awesome-gocui/termbox-go v0.0.0-20190427202837-c0aef3d18bcc/go.mod h1:tOy3o5Nf1bA17mnK4W41gD7PS3u4Cv0P0pqFcoWMy8s= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/chromedp/cdproto v0.0.0-20190614062957-d6d2f92b486d/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0= github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0= github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM= github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-interpreter/wagon v0.5.1-0.20190713202023-55a163980b6c/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc= github.com/go-interpreter/wagon v0.6.0/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -42,39 +87,89 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/processone/mpg123 v1.0.0 h1:o2WOyGZRM255or1Zc/LtF/jARn51B+9aQl72Qace0GA= github.com/processone/mpg123 v1.0.0/go.mod h1:X/FeL+h8vD1bYsG9tIWV3M2c4qNTZOficyvPVBP08go= +github.com/processone/soundcloud v1.0.0 h1:/+i6+Yveb7Y6IFGDSkesYI+HddblzcRTQClazzVHxoE= github.com/processone/soundcloud v1.0.0/go.mod h1:kDLeWpkRtN3C8kIReQdxoiRi92P9xR6yW6qLOJnNWfY= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.6.1/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190306220234-b354f8bf4d9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -85,21 +180,35 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522 h1:bhOzK9QyoD0ogCnFro1m2mz41+Ib0oOhfJnBp5MR4K4= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8= +nhooyr.io/websocket v1.6.5 h1:8TzpkldRfefda5JST+CnOH135bzVPz5uzfn/AF+gVKg= nhooyr.io/websocket v1.6.5/go.mod h1:F259lAzPRAH0htX2y3ehpJe09ih1aSHN7udWki1defY= diff --git a/_examples/muc_bot/README.md b/_examples/muc_bot/README.md new file mode 100644 index 0000000..24414fa --- /dev/null +++ b/_examples/muc_bot/README.md @@ -0,0 +1,3 @@ +# XMPP Multi-User (MUC) chat bot example + +This code shows how to build a simple basic XMPP Multi-User chat bot using Fluux Go XMPP library. diff --git a/_examples/xmpp_chat_client/README.md b/_examples/xmpp_chat_client/README.md new file mode 100644 index 0000000..d4db360 --- /dev/null +++ b/_examples/xmpp_chat_client/README.md @@ -0,0 +1,51 @@ +# Chat TUI example +This is a simple chat example, with a TUI. +It shows the library usage and a few of its capabilities. +## How to run +### Build +You can build the client using : +``` + go build -o example_client +``` +and then run with (on unix for example): +``` + ./example_client +``` +or you can simply build + run in one command while at the example directory root, like this: +``` + go run xmpp_chat_client.go interface.go +``` + +### Configuration +The example needs a configuration file to run. A sample file is provided. +By default, the example will look for a file named "config" in the current directory. +To provide a different configuration file, pass the following argument to the example : +``` + go run xmpp_chat_client.go interface.go -c /path/to/config +``` +where /path/to/config is the path to the directory containing the configuration file. The configuration file must be named +"config" and be using the yaml format. + +Required fields are : +```yaml +Server : + - full_address: "localhost:5222" +Client : # This is you + - jid: "testuser2@localhost" + - pass: "pass123" #Password in a config file yay + +# Contacts list, ";" separated +Contacts : "testuser1@localhost;testuser3@localhost" +# Should we log stanzas ? +LogStanzas: + - logger_on: "true" + - logfile_path: "./logs" # Path to directory, not file. +``` + +## How to use +Shortcuts : + - ctrl+space : switch between input window and menu window. + - While in input window : + - enter : sends a message if in message mode (see menu options) + - ctrl+e : sends a raw stanza when in raw mode (see menu options) + - ctrl+c : quit \ No newline at end of file diff --git a/_examples/xmpp_chat_client/config.yml b/_examples/xmpp_chat_client/config.yml new file mode 100644 index 0000000..6c6498b --- /dev/null +++ b/_examples/xmpp_chat_client/config.yml @@ -0,0 +1,13 @@ +# Sample config for the client +Server : + - full_address: "localhost:5222" +Client : + - jid: "testuser2@localhost" + - pass: "pass123" #Password in a config file yay + +Contacts : "testuser1@localhost;testuser3@localhost" + +LogStanzas: + - logger_on: "true" + - logfile_path: "./logs" + diff --git a/_examples/xmpp_chat_client/go.mod b/_examples/xmpp_chat_client/go.mod new file mode 100644 index 0000000..60d9744 --- /dev/null +++ b/_examples/xmpp_chat_client/go.mod @@ -0,0 +1,10 @@ +module go-xmpp/_examples/xmpp_chat_client + +go 1.13 + +require ( + github.com/awesome-gocui/gocui v0.6.1-0.20191115151952-a34ffb055986 + github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.6.1 + gosrc.io/xmpp v0.4.0 +) diff --git a/_examples/xmpp_chat_client/interface.go b/_examples/xmpp_chat_client/interface.go new file mode 100644 index 0000000..9f2c758 --- /dev/null +++ b/_examples/xmpp_chat_client/interface.go @@ -0,0 +1,371 @@ +package main + +import ( + "errors" + "fmt" + "github.com/awesome-gocui/gocui" + "log" + "strings" +) + +const ( + // Windows + chatLogWindow = "clw" // Where (received and sent) messages are logged + chatInputWindow = "iw" // Where messages are written + rawInputWindow = "rw" // Where raw stanzas are written + contactsListWindow = "cl" // Where the contacts list is shown, and contacts are selectable + menuWindow = "mw" // Where the menu is shown + disconnectMsg = "msg" + + // Windows titles + chatLogWindowTitle = "Chat log" + menuWindowTitle = "Menu" + chatInputWindowTitle = "Write a message :" + rawInputWindowTitle = "Write or paste a raw stanza. Press \"Ctrl+E\" to send :" + contactsListWindowTitle = "Contacts" + + // Menu options + disconnect = "Disconnect" + askServerForRoster = "Ask server for roster" + rawMode = "Switch to Send Raw Mode" + messageMode = "Switch to Send Message Mode" + contactList = "Contacts list" + backFromContacts = "<- Go back" +) + +// To store names of views on top +type viewsState struct { + input string // Which input view is on top + side string // Which side view is on top + contacts []string // Contacts list + currentContact string // Contact we are currently messaging +} + +var ( + // Which window is on top currently on top of the other. + // This is the init setup + viewState = viewsState{ + input: chatInputWindow, + side: menuWindow, + } + menuOptions = []string{contactList, rawMode, askServerForRoster, disconnect} + // Errors + servConnFail = errors.New("failed to connect to server. Check your configuration ? Exiting") +) + +func setCurrentViewOnTop(g *gocui.Gui, name string) (*gocui.View, error) { + if _, err := g.SetCurrentView(name); err != nil { + return nil, err + } + return g.SetViewOnTop(name) +} + +func layout(g *gocui.Gui) error { + maxX, maxY := g.Size() + + if v, err := g.SetView(chatLogWindow, maxX/5, 0, maxX-1, 5*maxY/6-1, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + v.Title = chatLogWindowTitle + v.Wrap = true + v.Autoscroll = true + } + + if v, err := g.SetView(contactsListWindow, 0, 0, maxX/5-1, 5*maxY/6-2, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + v.Title = contactsListWindowTitle + v.Wrap = true + // If we set this to true, the contacts list will "fit" in the window but if the number + // of contacts exceeds the maximum height, some contacts will be hidden... + // If set to false, we can scroll up and down the contact list... infinitely. Meaning lower lines + // will be unlimited and empty... Didn't find a way to quickfix yet. + v.Autoscroll = false + } + + if v, err := g.SetView(menuWindow, 0, 0, maxX/5-1, 5*maxY/6-2, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + v.Title = menuWindowTitle + v.Wrap = true + v.Autoscroll = true + fmt.Fprint(v, strings.Join(menuOptions, "\n")) + if _, err = setCurrentViewOnTop(g, menuWindow); err != nil { + return err + } + } + + if v, err := g.SetView(rawInputWindow, 0, 5*maxY/6-1, maxX/1-1, maxY-1, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + v.Title = rawInputWindowTitle + v.Editable = true + v.Wrap = true + } + + if v, err := g.SetView(chatInputWindow, 0, 5*maxY/6-1, maxX/1-1, maxY-1, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + v.Title = chatInputWindowTitle + v.Editable = true + v.Wrap = true + + if _, err = setCurrentViewOnTop(g, chatInputWindow); err != nil { + return err + } + } + + return nil +} + +func quit(g *gocui.Gui, v *gocui.View) error { + return gocui.ErrQuit +} + +// Sends an input text from the user to the backend while also printing it in the chatlog window. +// KeyEnter is viewed as "\n" by gocui, so messages should only be one line, whereas raw sending has a different key +// binding and therefor should work with this too (for multiple lines stanzas) +func writeInput(g *gocui.Gui, v *gocui.View) error { + chatLogWindow, _ := g.View(chatLogWindow) + + input := strings.Join(v.ViewBufferLines(), "\n") + + fmt.Fprintln(chatLogWindow, "Me : ", input) + if viewState.input == rawInputWindow { + rawTextChan <- input + } else { + textChan <- input + } + + v.Clear() + v.EditDeleteToStartOfLine() + return nil +} + +func setKeyBindings(g *gocui.Gui) { + // ========================== + // All views + if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { + log.Panicln(err) + } + + // ========================== + // Chat input + if err := g.SetKeybinding(chatInputWindow, gocui.KeyEnter, gocui.ModNone, writeInput); err != nil { + log.Panicln(err) + } + + if err := g.SetKeybinding(chatInputWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + log.Panicln(err) + } + + // ========================== + // Raw input + if err := g.SetKeybinding(rawInputWindow, gocui.KeyCtrlE, gocui.ModNone, writeInput); err != nil { + log.Panicln(err) + } + + if err := g.SetKeybinding(rawInputWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + log.Panicln(err) + } + + // ========================== + // Menu + if err := g.SetKeybinding(menuWindow, gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding(menuWindow, gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding(menuWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding(menuWindow, gocui.KeyEnter, gocui.ModNone, getLine); err != nil { + log.Panicln(err) + } + + // ========================== + // Contacts list + if err := g.SetKeybinding(contactsListWindow, gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding(contactsListWindow, gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding(contactsListWindow, gocui.KeyEnter, gocui.ModNone, getLine); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding(contactsListWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + log.Panicln(err) + } + + // ========================== + // Disconnect message + if err := g.SetKeybinding(disconnectMsg, gocui.KeyEnter, gocui.ModNone, delMsg); err != nil { + log.Panicln(err) + } +} + +// General +// Used to handle menu selections and navigations +func getLine(g *gocui.Gui, v *gocui.View) error { + var l string + var err error + + _, cy := v.Cursor() + if l, err = v.Line(cy); err != nil { + l = "" + } + if viewState.side == menuWindow { + if l == contactList { + cv, _ := g.View(contactsListWindow) + viewState.side = contactsListWindow + g.SetViewOnTop(contactsListWindow) + g.SetCurrentView(contactsListWindow) + if len(cv.ViewBufferLines()) == 0 { + printContactsToWindow(g, viewState.contacts) + } + } else if l == disconnect { + maxX, maxY := g.Size() + msg := "You disconnected from the server. Press enter to quit." + if v, err := g.SetView(disconnectMsg, maxX/2-30, maxY/2, maxX/2-29+len(msg), maxY/2+2, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + fmt.Fprintln(v, msg) + if _, err := g.SetCurrentView(disconnectMsg); err != nil { + return err + } + } + killChan <- disconnectErr + } else if l == askServerForRoster { + chlw, _ := g.View(chatLogWindow) + fmt.Fprintln(chlw, infoFormat+"Asking server for contacts list...") + rosterChan <- struct{}{} + } else if l == rawMode { + mw, _ := g.View(menuWindow) + viewState.input = rawInputWindow + g.SetViewOnTop(rawInputWindow) + g.SetCurrentView(rawInputWindow) + menuOptions[1] = messageMode + v.Clear() + v.EditDeleteToStartOfLine() + fmt.Fprintln(mw, strings.Join(menuOptions, "\n")) + message := "Now sending in raw stanza mode" + clv, _ := g.View(chatLogWindow) + fmt.Fprintln(clv, infoFormat+message) + } else if l == messageMode { + mw, _ := g.View(menuWindow) + viewState.input = chatInputWindow + g.SetViewOnTop(chatInputWindow) + g.SetCurrentView(chatInputWindow) + menuOptions[1] = rawMode + v.Clear() + v.EditDeleteToStartOfLine() + fmt.Fprintln(mw, strings.Join(menuOptions, "\n")) + message := "Now sending in messages mode" + clv, _ := g.View(chatLogWindow) + fmt.Fprintln(clv, infoFormat+message) + } + } else if viewState.side == contactsListWindow { + if l == backFromContacts { + viewState.side = menuWindow + g.SetViewOnTop(menuWindow) + g.SetCurrentView(menuWindow) + } else if l == "" { + return nil + } else { + // Updating the current correspondent, back-end side. + CorrespChan <- l + viewState.currentContact = l + // Showing the selected contact in contacts list + cl, _ := g.View(contactsListWindow) + cts := cl.ViewBufferLines() + cl.Clear() + printContactsToWindow(g, cts) + // Showing a message to the user, and switching back to input after the new contact is selected. + message := "Now sending messages to : " + l + " in a private conversation" + clv, _ := g.View(chatLogWindow) + fmt.Fprintln(clv, infoFormat+message) + g.SetCurrentView(chatInputWindow) + } + } + + return nil +} + +func printContactsToWindow(g *gocui.Gui, contactsList []string) { + cl, _ := g.View(contactsListWindow) + for _, c := range contactsList { + c = strings.ReplaceAll(c, " *", "") + if c == viewState.currentContact { + fmt.Fprintf(cl, c+" *\n") + } else { + fmt.Fprintf(cl, c+"\n") + } + } +} + +// Changing view between input and "menu/contacts" when pressing the specific key. +func nextView(g *gocui.Gui, v *gocui.View) error { + if v == nil || v.Name() == chatInputWindow || v.Name() == rawInputWindow { + _, err := g.SetCurrentView(viewState.side) + return err + } else if v.Name() == menuWindow || v.Name() == contactsListWindow { + _, err := g.SetCurrentView(viewState.input) + return err + } + + // Should not be reached right now + _, err := g.SetCurrentView(chatInputWindow) + return err +} + +func cursorDown(g *gocui.Gui, v *gocui.View) error { + if v != nil { + cx, cy := v.Cursor() + // Avoid going below the list of contacts. Although lines are stored in the view as a slice + // in the used lib. Therefor, if the number of lines is too big, the cursor will go past the last line since + // increasing slice capacity is done by doubling it. Last lines will be "nil" and reachable by the cursor + // in a dynamic context (such as contacts list) + cv := g.CurrentView() + h := cv.LinesHeight() + if cy+1 >= h { + return nil + } + // Lower cursor + if err := v.SetCursor(cx, cy+1); err != nil { + ox, oy := v.Origin() + if err := v.SetOrigin(ox, oy+1); err != nil { + return err + } + } + } + return nil +} + +func cursorUp(g *gocui.Gui, v *gocui.View) error { + if v != nil { + ox, oy := v.Origin() + cx, cy := v.Cursor() + if err := v.SetCursor(cx, cy-1); err != nil && oy > 0 { + if err := v.SetOrigin(ox, oy-1); err != nil { + return err + } + } + } + return nil +} + +func delMsg(g *gocui.Gui, v *gocui.View) error { + if err := g.DeleteView(disconnectMsg); err != nil { + return err + } + errChan <- gocui.ErrQuit // Quit the program + return nil +} diff --git a/_examples/xmpp_chat_client/xmpp_chat_client.go b/_examples/xmpp_chat_client/xmpp_chat_client.go new file mode 100644 index 0000000..51e3bcf --- /dev/null +++ b/_examples/xmpp_chat_client/xmpp_chat_client.go @@ -0,0 +1,339 @@ +package main + +/* +xmpp_chat_client is a demo client that connect on an XMPP server to chat with other members +*/ + +import ( + "context" + "encoding/xml" + "errors" + "flag" + "fmt" + "github.com/awesome-gocui/gocui" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" + "log" + "os" + "path" + "strconv" + "strings" + "time" +) + +const ( + infoFormat = "====== " + // Default configuration + defaultConfigFilePath = "./" + + configFileName = "config" + configType = "yaml" + logStanzasOn = "logger_on" + logFilePath = "logfile_path" + // Keys in config + serverAddressKey = "full_address" + clientJid = "jid" + clientPass = "pass" + configContactSep = ";" +) + +var ( + CorrespChan = make(chan string, 1) + textChan = make(chan string, 5) + rawTextChan = make(chan string, 5) + killChan = make(chan error, 1) + errChan = make(chan error) + rosterChan = make(chan struct{}) + + logger *log.Logger + disconnectErr = errors.New("disconnecting client") +) + +type config struct { + Server map[string]string `mapstructure:"server"` + Client map[string]string `mapstructure:"client"` + Contacts string `string:"contact"` + LogStanzas map[string]string `mapstructure:"logstanzas"` +} + +func main() { + + // ============================================================ + // Parse the flag with the config directory path as argument + flag.String("c", defaultConfigFilePath, "Provide a path to the directory that contains the configuration"+ + " file you want to use. Config file should be named \"config\" and be in YAML format..") + pflag.CommandLine.AddGoFlagSet(flag.CommandLine) + pflag.Parse() + + // ========================== + // Read configuration + c := readConfig() + + //================================ + // Setup logger + on, err := strconv.ParseBool(c.LogStanzas[logStanzasOn]) + if err != nil { + log.Panicln(err) + } + if on { + f, err := os.OpenFile(path.Join(c.LogStanzas[logFilePath], "logs.txt"), os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666) + if err != nil { + log.Panicln(err) + } + logger = log.New(f, "", log.Lshortfile|log.Ldate|log.Ltime) + logger.SetOutput(f) + defer f.Close() + } + + // ========================== + // Create TUI + g, err := gocui.NewGui(gocui.OutputNormal, true) + if err != nil { + log.Panicln(err) + } + defer g.Close() + g.Highlight = true + g.Cursor = true + g.SelFgColor = gocui.ColorGreen + g.SetManagerFunc(layout) + setKeyBindings(g) + + // ========================== + // Run TUI + go func() { + errChan <- g.MainLoop() + }() + + // ========================== + // Start XMPP client + go startClient(g, c) + + select { + case err := <-errChan: + if err == gocui.ErrQuit { + log.Println("Closing client.") + } else { + log.Panicln(err) + } + } +} + +func startClient(g *gocui.Gui, config *config) { + + // ========================== + // Client setup + clientCfg := xmpp.Config{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: config.Server[serverAddressKey], + }, + Jid: config.Client[clientJid], + Credential: xmpp.Password(config.Client[clientPass]), + Insecure: true} + + var client *xmpp.Client + var err error + router := xmpp.NewRouter() + + handlerWithGui := func(_ xmpp.Sender, p stanza.Packet) { + msg, ok := p.(stanza.Message) + if logger != nil { + m, _ := xml.Marshal(msg) + logger.Println(string(m)) + } + + v, err := g.View(chatLogWindow) + if !ok { + fmt.Fprintf(v, "%sIgnoring packet: %T\n", infoFormat, p) + return + } + if err != nil { + return + } + g.Update(func(g *gocui.Gui) error { + if msg.Error.Code != 0 { + _, err := fmt.Fprintf(v, "Error from server : %s : %s \n", msg.Error.Reason, msg.XMLName.Space) + return err + } + if len(strings.TrimSpace(msg.Body)) != 0 { + _, err := fmt.Fprintf(v, "%s : %s \n", msg.From, msg.Body) + return err + } + return nil + }) + } + + router.HandleFunc("message", handlerWithGui) + if client, err = xmpp.NewClient(clientCfg, router, errorHandler); err != nil { + log.Panicln(fmt.Sprintf("Could not create a new client ! %s", err)) + + } + + // ========================== + // Client connection + if err = client.Connect(); err != nil { + msg := fmt.Sprintf("%sXMPP connection failed: %s", infoFormat, err) + g.Update(func(g *gocui.Gui) error { + v, err := g.View(chatLogWindow) + fmt.Fprintf(v, msg) + return err + }) + fmt.Println("Failed to connect to server. Exiting...") + errChan <- servConnFail + return + } + + // ========================== + // Start working + updateRosterFromConfig(config) + // Sending the default contact in a channel. Default value is the first contact in the list from the config. + viewState.currentContact = strings.Split(config.Contacts, configContactSep)[0] + // Informing user of the default contact + clw, _ := g.View(chatLogWindow) + fmt.Fprintf(clw, infoFormat+"Now sending messages to "+viewState.currentContact+" in a private conversation\n") + CorrespChan <- viewState.currentContact + startMessaging(client, config, g) +} + +func startMessaging(client xmpp.Sender, config *config, g *gocui.Gui) { + var text string + var correspondent string + for { + select { + case err := <-killChan: + if err == disconnectErr { + sc := client.(xmpp.StreamClient) + sc.Disconnect() + } else { + logger.Println(err) + } + return + case text = <-textChan: + reply := stanza.Message{Attrs: stanza.Attrs{To: correspondent, Type: stanza.MessageTypeChat}, Body: text} + if logger != nil { + raw, _ := xml.Marshal(reply) + logger.Println(string(raw)) + } + err := client.Send(reply) + if err != nil { + fmt.Printf("There was a problem sending the message : %v", reply) + return + } + case text = <-rawTextChan: + if logger != nil { + logger.Println(text) + } + err := client.SendRaw(text) + if err != nil { + fmt.Printf("There was a problem sending the message : %v", text) + return + } + case crrsp := <-CorrespChan: + correspondent = crrsp + case <-rosterChan: + askForRoster(client, g, config) + } + + } +} + +// Only reads and parses the configuration +func readConfig() *config { + viper.SetConfigName(configFileName) // name of config file (without extension) + viper.BindPFlags(pflag.CommandLine) + viper.AddConfigPath(viper.GetString("c")) // path to look for the config file in + err := viper.ReadInConfig() // Find and read the config file + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + log.Fatalf("%s %s", err, "Please make sure you give a path to the directory of the config and not to the config itself.") + } else { + log.Panicln(err) + } + } + + viper.SetConfigType(configType) + var config config + err = viper.Unmarshal(&config) + if err != nil { + panic(fmt.Errorf("Unable to decode Config: %s \n", err)) + } + + // Check if we have contacts to message + if len(strings.TrimSpace(config.Contacts)) == 0 { + log.Panicln("You appear to have no contacts to message !") + } + // Check logging + config.LogStanzas[logFilePath] = path.Clean(config.LogStanzas[logFilePath]) + on, err := strconv.ParseBool(config.LogStanzas[logStanzasOn]) + if err != nil { + log.Panicln(err) + } + if d, e := isDirectory(config.LogStanzas[logFilePath]); (e != nil || !d) && on { + log.Panicln("The log file path could not be found or is not a directory.") + } + + return &config +} + +// If an error occurs, this is used to kill the client +func errorHandler(err error) { + killChan <- err +} + +// Read the client roster from the config. This does not check with the server that the roster is correct. +// If user tries to send a message to someone not registered with the server, the server will return an error. +func updateRosterFromConfig(config *config) { + viewState.contacts = append(strings.Split(config.Contacts, configContactSep), backFromContacts) + // Put a "go back" button at the end of the list + viewState.contacts = append(viewState.contacts, backFromContacts) +} + +// Updates the menu panel of the view with the current user's roster, by asking the server. +func askForRoster(client xmpp.Sender, g *gocui.Gui, config *config) { + // Craft a roster request + req := stanza.NewIQ(stanza.Attrs{From: config.Client[clientJid], Type: stanza.IQTypeGet}) + req.RosterItems() + if logger != nil { + m, _ := xml.Marshal(req) + logger.Println(string(m)) + } + ctx, _ := context.WithTimeout(context.Background(), 30*time.Second) + + // Send the roster request to the server + c, err := client.SendIQ(ctx, req) + if err != nil { + logger.Panicln(err) + } + + // Sending a IQ has a channel spawned to process the response once we receive it. + // In order not to block the client, we spawn a goroutine to update the TUI once the server has responded. + go func() { + serverResp := <-c + if logger != nil { + m, _ := xml.Marshal(serverResp) + logger.Println(string(m)) + } + // Update contacts with the response from the server + chlw, _ := g.View(chatLogWindow) + if rosterItems, ok := serverResp.Payload.(*stanza.RosterItems); ok { + viewState.contacts = []string{} + for _, item := range rosterItems.Items { + viewState.contacts = append(viewState.contacts, item.Jid) + } + // Put a "go back" button at the end of the list + viewState.contacts = append(viewState.contacts, backFromContacts) + fmt.Fprintln(chlw, infoFormat+"Contacts list updated !") + return + } + fmt.Fprintln(chlw, infoFormat+"Failed to update contact list !") + }() +} + +func isDirectory(path string) (bool, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return false, err + } + return fileInfo.IsDir(), err +} diff --git a/_examples/xmpp_component/xmpp_component.go b/_examples/xmpp_component/xmpp_component.go index e36b287..e3a70ce 100644 --- a/_examples/xmpp_component/xmpp_component.go +++ b/_examples/xmpp_component/xmpp_component.go @@ -35,7 +35,7 @@ func main() { IQNamespaces("jabber:iq:version"). HandlerFunc(handleVersion) - component, err := xmpp.NewComponent(opts, router) + component, err := xmpp.NewComponent(opts, router, handleError) if err != nil { log.Fatalf("%+v", err) } @@ -47,6 +47,10 @@ func main() { log.Fatal(cm.Run()) } +func handleError(err error) { + fmt.Println(err.Error()) +} + func handleMessage(_ xmpp.Sender, p stanza.Packet) { msg, ok := p.(stanza.Message) if !ok { @@ -57,12 +61,16 @@ func handleMessage(_ xmpp.Sender, p stanza.Packet) { func discoInfo(c xmpp.Sender, p stanza.Packet, opts xmpp.ComponentOptions) { // Type conversion & sanity checks - iq, ok := p.(stanza.IQ) - if !ok || iq.Type != "get" { + iq, ok := p.(*stanza.IQ) + if !ok || iq.Type != stanza.IQTypeGet { return } - iqResp := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"}) + iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"}) + // TODO: fix this... + if err != nil { + return + } disco := iqResp.DiscoInfo() disco.AddIdentity(opts.Name, opts.Category, opts.Type) disco.AddFeatures(stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1") @@ -72,8 +80,8 @@ func discoInfo(c xmpp.Sender, p stanza.Packet, opts xmpp.ComponentOptions) { // TODO: Handle iq error responses func discoItems(c xmpp.Sender, p stanza.Packet) { // Type conversion & sanity checks - iq, ok := p.(stanza.IQ) - if !ok || iq.Type != "get" { + iq, ok := p.(*stanza.IQ) + if !ok || iq.Type != stanza.IQTypeGet { return } @@ -82,7 +90,11 @@ func discoItems(c xmpp.Sender, p stanza.Packet) { return } - iqResp := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"}) + // TODO: fix this... + iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"}) + if err != nil { + return + } items := iqResp.DiscoItems() if discoItems.Node == "" { @@ -93,12 +105,15 @@ func discoItems(c xmpp.Sender, p stanza.Packet) { func handleVersion(c xmpp.Sender, p stanza.Packet) { // Type conversion & sanity checks - iq, ok := p.(stanza.IQ) + iq, ok := p.(*stanza.IQ) if !ok { return } - iqResp := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"}) + iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"}) + if err != nil { + return + } iqResp.Version().SetInfo("Fluux XMPP Component", "0.0.1", "") _ = c.Send(iqResp) } diff --git a/_examples/xmpp_component2/README.md b/_examples/xmpp_component2/README.md new file mode 100644 index 0000000..9ae4885 --- /dev/null +++ b/_examples/xmpp_component2/README.md @@ -0,0 +1,4 @@ +# xmpp_component2 + + +This program is an example of the simplest XMPP component: it connects to an XMPP server using XEP 114 protocol, perform a discovery query on the server and print the response. diff --git a/_examples/xmpp_component2/main.go b/_examples/xmpp_component2/main.go new file mode 100644 index 0000000..01415a0 --- /dev/null +++ b/_examples/xmpp_component2/main.go @@ -0,0 +1,79 @@ +package main + +/* + +Connect to an XMPP server using XEP 114 protocol, perform a discovery query on the server and print the response + +*/ + +import ( + "context" + "fmt" + "log" + "time" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +const ( + domain = "mycomponent.localhost" + address = "build.vpn.p1:8888" +) + +// Init and return a component +func makeComponent() *xmpp.Component { + opts := xmpp.ComponentOptions{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: address, + Domain: domain, + }, + Domain: domain, + Secret: "secret", + } + router := xmpp.NewRouter() + c, err := xmpp.NewComponent(opts, router, handleError) + if err != nil { + panic(err) + } + return c +} + +func handleError(err error) { + fmt.Println(err.Error()) +} + +func main() { + c := makeComponent() + + // Connect Component to the server + fmt.Printf("Connecting to %v\n", address) + err := c.Connect() + if err != nil { + panic(err) + } + + // make a disco iq + iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, + From: domain, + To: "localhost", + Id: "my-iq1"}) + if err != nil { + log.Fatalf("failed to create IQ: %v", err) + } + disco := iqReq.DiscoInfo() + iqReq.Payload = disco + + // res is the channel used to receive the result iq + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + res, _ := c.SendIQ(ctx, iqReq) + + select { + case iqResponse := <-res: + // Got response from server + fmt.Print(iqResponse.Payload) + case <-time.After(100 * time.Millisecond): + cancel() + panic("No iq response was received in time") + } +} diff --git a/_examples/xmpp_echo/xmpp_echo.go b/_examples/xmpp_echo/xmpp_echo.go index 5654a2b..d7a35c8 100644 --- a/_examples/xmpp_echo/xmpp_echo.go +++ b/_examples/xmpp_echo/xmpp_echo.go @@ -28,7 +28,7 @@ func main() { router := xmpp.NewRouter() router.HandleFunc("message", handleMessage) - client, err := xmpp.NewClient(config, router) + client, err := xmpp.NewClient(&config, router, errorHandler) if err != nil { log.Fatalf("%+v", err) } @@ -50,3 +50,7 @@ func handleMessage(s xmpp.Sender, p stanza.Packet) { reply := stanza.Message{Attrs: stanza.Attrs{To: msg.From}, Body: msg.Body} _ = s.Send(reply) } + +func errorHandler(err error) { + fmt.Println(err.Error()) +} diff --git a/_examples/xmpp_jukebox/README.md b/_examples/xmpp_jukebox/README.md new file mode 100644 index 0000000..9eeb3a4 --- /dev/null +++ b/_examples/xmpp_jukebox/README.md @@ -0,0 +1,37 @@ +# Jukebox example + +## Requirements +- You need mpg123 installed on your computer because the example runs it as a command : +[Official MPG123 website](https://mpg123.de/) +Most linux distributions have a package for it. +- You need a soundcloud ID to play a music from the website through mpg123. You currently cannot play music files with this example. +Your user ID is available in your account settings on the [soundcloud website](https://soundcloud.com/) +**One is provided for convenience.** +- You need a running jabber server. You can run your local instance of [ejabberd](https://www.ejabberd.im/) for example. +- You need a registered user on the running jabber server. + +## Run +You can edit the soundcloud ID in the example file with your own, or use the provided one : +```go +const scClientID = "dde6a0075614ac4f3bea423863076b22" +``` + +To run the example, build it with (while in the example directory) : +``` +go build xmpp_jukebox.go +``` + +then run it with (update the command arguments accordingly): +``` +./xmpp_jukebox -jid=MY_USERE@MY_DOMAIN/jukebox -password=MY_PASSWORD -address=MY_SERVER:MY_SERVER_PORT +``` +Make sure to have a resource, for instance "/jukebox", on your jid. + +Then you can send the following stanza to "MY_USERE@MY_DOMAIN/jukebox" (with the resource) to play a song (update the soundcloud URL accordingly) : +```xml + + + + + +``` diff --git a/_examples/xmpp_jukebox/xmpp_jukebox.go b/_examples/xmpp_jukebox/xmpp_jukebox.go index 10e5dfc..31f000a 100644 --- a/_examples/xmpp_jukebox/xmpp_jukebox.go +++ b/_examples/xmpp_jukebox/xmpp_jukebox.go @@ -3,6 +3,7 @@ package main import ( + "encoding/xml" "flag" "fmt" "log" @@ -19,7 +20,7 @@ import ( const scClientID = "dde6a0075614ac4f3bea423863076b22" func main() { - jid := flag.String("jid", "", "jukebok XMPP JID, resource is optional") + jid := flag.String("jid", "", "jukebok XMPP Jid, resource is optional") password := flag.String("password", "", "XMPP account password") address := flag.String("address", "", "If needed, XMPP server DNSName or IP and optional port (ie myserver:5222)") flag.Parse() @@ -48,12 +49,12 @@ func main() { handleMessage(s, p, player) }) router.NewRoute(). - Packet("message"). + Packet("iq"). HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { handleIQ(s, p, player) }) - client, err := xmpp.NewClient(config, router) + client, err := xmpp.NewClient(&config, router, errorHandler) if err != nil { log.Fatalf("%+v", err) } @@ -61,6 +62,9 @@ func main() { cm := xmpp.NewStreamManager(client, nil) log.Fatal(cm.Run()) } +func errorHandler(err error) { + fmt.Println(err.Error()) +} func handleMessage(s xmpp.Sender, p stanza.Packet, player *mpg123.Player) { msg, ok := p.(stanza.Message) @@ -77,7 +81,7 @@ func handleMessage(s xmpp.Sender, p stanza.Packet, player *mpg123.Player) { } func handleIQ(s xmpp.Sender, p stanza.Packet, player *mpg123.Player) { - iq, ok := p.(stanza.IQ) + iq, ok := p.(*stanza.IQ) if !ok { return } @@ -96,7 +100,7 @@ func handleIQ(s xmpp.Sender, p stanza.Packet, player *mpg123.Player) { setResponse := new(stanza.ControlSetResponse) // FIXME: Broken reply := stanza.IQ{Attrs: stanza.Attrs{To: iq.From, Type: "result", Id: iq.Id}, Payload: setResponse} - _ = s.Send(reply) + _ = s.Send(&reply) // TODO add Soundclound artist / title retrieval sendUserTune(s, "Radiohead", "Spectre") default: @@ -105,11 +109,29 @@ func handleIQ(s xmpp.Sender, p stanza.Packet, player *mpg123.Player) { } func sendUserTune(s xmpp.Sender, artist string, title string) { - tune := stanza.Tune{Artist: artist, Title: title} - iq := stanza.NewIQ(stanza.Attrs{Type: "set", Id: "usertune-1", Lang: "en"}) - payload := stanza.PubSub{Publish: &stanza.Publish{Node: "http://jabber.org/protocol/tune", Item: stanza.Item{Tune: &tune}}} - iq.Payload = &payload - _ = s.Send(iq) + rq, err := stanza.NewPublishItemRq("localhost", + "http://jabber.org/protocol/tune", + "", + stanza.Item{ + XMLName: xml.Name{Space: "http://jabber.org/protocol/tune", Local: "tune"}, + Any: &stanza.Node{ + Nodes: []stanza.Node{ + { + XMLName: xml.Name{Local: "artist"}, + Content: artist, + }, + { + XMLName: xml.Name{Local: "title"}, + Content: title, + }, + }, + }, + }) + if err != nil { + fmt.Printf("failed to build the publish request : %s", err.Error()) + return + } + _ = s.Send(rq) } func playSCURL(p *mpg123.Player, rawURL string) { @@ -117,7 +139,7 @@ func playSCURL(p *mpg123.Player, rawURL string) { // TODO: Maybe we need to check the track itself to get the stream URL from reply ? url := soundcloud.FormatStreamURL(songID) - _ = p.Play(url) + _ = p.Play(strings.ReplaceAll(url, "YOUR_SOUNDCLOUD_CLIENTID", scClientID)) } // TODO diff --git a/_examples/xmpp_oauth2/xmpp_oauth2.go b/_examples/xmpp_oauth2/xmpp_oauth2.go index f322447..a993049 100644 --- a/_examples/xmpp_oauth2/xmpp_oauth2.go +++ b/_examples/xmpp_oauth2/xmpp_oauth2.go @@ -28,7 +28,7 @@ func main() { router := xmpp.NewRouter() router.HandleFunc("message", handleMessage) - client, err := xmpp.NewClient(config, router) + client, err := xmpp.NewClient(&config, router, errorHandler) if err != nil { log.Fatalf("%+v", err) } @@ -39,6 +39,10 @@ func main() { log.Fatal(cm.Run()) } +func errorHandler(err error) { + fmt.Println(err.Error()) +} + func handleMessage(s xmpp.Sender, p stanza.Packet) { msg, ok := p.(stanza.Message) if !ok { diff --git a/_examples/xmpp_pubsub_client/README.md b/_examples/xmpp_pubsub_client/README.md new file mode 100644 index 0000000..b86cedd --- /dev/null +++ b/_examples/xmpp_pubsub_client/README.md @@ -0,0 +1,17 @@ +# PubSub client example + +## Description +This is a simple example of a client that : +* Creates a node on a service +* Subscribes to that node +* Publishes to that node +* Gets the notification from the publication and prints it on screen + +## Requirements +You need to have a running jabber server, like [ejabberd](https://www.ejabberd.im/) that supports [XEP-0060](https://xmpp.org/extensions/xep-0060.html). + +## How to use +Just run : +``` + go run xmpp_ps_client.go +``` diff --git a/_examples/xmpp_pubsub_client/xmpp_ps_client.go b/_examples/xmpp_pubsub_client/xmpp_ps_client.go new file mode 100644 index 0000000..2308071 --- /dev/null +++ b/_examples/xmpp_pubsub_client/xmpp_ps_client.go @@ -0,0 +1,278 @@ +package main + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" + "log" + "time" +) + +const ( + userJID = "testuser2@localhost" + serverAddress = "localhost:5222" + nodeName = "lel_node" + serviceName = "pubsub.localhost" +) + +var invalidResp = errors.New("invalid response") + +func main() { + + config := xmpp.Config{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: serverAddress, + }, + Jid: userJID, + Credential: xmpp.Password("pass123"), + // StreamLogger: os.Stdout, + Insecure: true, + } + router := xmpp.NewRouter() + router.NewRoute().Packet("message"). + HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { + data, _ := xml.Marshal(p) + log.Println("Received a message ! => \n" + string(data)) + }) + + client, err := xmpp.NewClient(&config, router, func(err error) { log.Println(err) }) + if err != nil { + log.Fatalf("%+v", err) + } + + // ========================== + // Client connection + err = client.Connect() + if err != nil { + log.Fatalf("%+v", err) + } + + // ========================== + // Create a node + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + createNode(ctx, cancel, client) + + // ================================================================================ + // Configure the node. This can also be done in a single message with the creation + configureNode(ctx, cancel, client) + + // ==================================== + // Subscribe to this node : + subToNode(ctx, cancel, client) + + // ========================== + // Publish to that node + pubToNode(ctx, cancel, client) + + // ============================= + // Let's purge the node : + purgeRq, _ := stanza.NewPurgeAllItems(serviceName, nodeName) + purgeCh, err := client.SendIQ(ctx, purgeRq) + if err != nil { + log.Fatalf("could not send purge request: %v", err) + } + select { + case purgeResp := <-purgeCh: + + if purgeResp.Type == stanza.IQTypeError { + cancel() + if vld, err := purgeResp.IsValid(); !vld { + log.Fatalf(invalidResp.Error()+" %v"+" reason: %v", purgeResp, err) + } + log.Fatalf("error while purging node : %s", purgeResp.Error.Text) + } + log.Println("node successfully purged") + case <-time.After(1000 * time.Millisecond): + cancel() + log.Fatal("No iq response was received in time while purging node") + } + + cancel() +} + +func createNode(ctx context.Context, cancel context.CancelFunc, client *xmpp.Client) { + rqCreate, err := stanza.NewCreateNode(serviceName, nodeName) + if err != nil { + log.Fatalf("%+v", err) + } + createCh, err := client.SendIQ(ctx, rqCreate) + if err != nil { + log.Fatalf("%+v", err) + } else { + + if createCh != nil { + select { + case respCr := <-createCh: + // Got response from server + if respCr.Type == stanza.IQTypeError { + if vld, err := respCr.IsValid(); !vld { + log.Fatalf(invalidResp.Error()+" %+v"+" reason: %s", respCr, err) + } + if respCr.Error.Reason != "conflict" { + log.Fatalf("%+v", respCr.Error.Text) + } + log.Println(respCr.Error.Text) + } else { + fmt.Print("successfully created channel") + } + case <-time.After(100 * time.Millisecond): + cancel() + log.Fatal("No iq response was received in time while creating node") + } + } + } +} + +func configureNode(ctx context.Context, cancel context.CancelFunc, client *xmpp.Client) { + // First, ask for a form with the config options + confRq, _ := stanza.NewConfigureNode(serviceName, nodeName) + confReqCh, err := client.SendIQ(ctx, confRq) + if err != nil { + log.Fatalf("could not send iq : %v", err) + } + select { + case confForm := <-confReqCh: + // If the request was successful, we now have a form with configuration options to update + fields, err := confForm.GetFormFields() + if err != nil { + log.Fatal("No config fields found !") + } + + // These are some common fields expected to be present. Change processing to your liking + if fields["pubsub#max_payload_size"] != nil { + fields["pubsub#max_payload_size"].ValuesList[0] = "100000" + } + + if fields["pubsub#notification_type"] != nil { + fields["pubsub#notification_type"].ValuesList[0] = "headline" + } + + // Send the modified fields as a form + submitConf, err := stanza.NewFormSubmissionOwner(serviceName, + nodeName, + []*stanza.Field{ + fields["pubsub#max_payload_size"], + fields["pubsub#notification_type"], + }) + + c, _ := client.SendIQ(ctx, submitConf) + select { + case confResp := <-c: + if confResp.Type == stanza.IQTypeError { + cancel() + if vld, err := confResp.IsValid(); !vld { + log.Fatalf(invalidResp.Error()+" %v"+" reason: %v", confResp, err) + } + log.Fatalf("node configuration failed : %s", confResp.Error.Text) + } + log.Println("node configuration was successful") + return + + case <-time.After(300 * time.Millisecond): + cancel() + log.Fatal("No iq response was received in time while configuring the node") + } + + case <-time.After(300 * time.Millisecond): + cancel() + log.Fatal("No iq response was received in time while asking for the config form") + } +} + +func subToNode(ctx context.Context, cancel context.CancelFunc, client *xmpp.Client) { + rqSubscribe, err := stanza.NewSubRq(serviceName, stanza.SubInfo{ + Node: nodeName, + Jid: userJID, + }) + if err != nil { + log.Fatalf("%+v", err) + } + subRespCh, _ := client.SendIQ(ctx, rqSubscribe) + if subRespCh != nil { + select { + case <-subRespCh: + log.Println("Subscribed to the service") + case <-time.After(300 * time.Millisecond): + cancel() + log.Fatal("No iq response was received in time while subscribing") + } + } +} + +func pubToNode(ctx context.Context, cancel context.CancelFunc, client *xmpp.Client) { + pub, err := stanza.NewPublishItemRq(serviceName, nodeName, "", stanza.Item{ + Publisher: "testuser2", + Any: &stanza.Node{ + XMLName: xml.Name{ + Space: "http://www.w3.org/2005/Atom", + Local: "entry", + }, + Nodes: []stanza.Node{ + { + XMLName: xml.Name{Space: "", Local: "title"}, + Attrs: nil, + Content: "My pub item title", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "summary"}, + Attrs: nil, + Content: "My pub item content summary", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "link"}, + Attrs: []xml.Attr{ + { + Name: xml.Name{Space: "", Local: "rel"}, + Value: "alternate", + }, + { + Name: xml.Name{Space: "", Local: "type"}, + Value: "text/html", + }, + { + Name: xml.Name{Space: "", Local: "href"}, + Value: "http://denmark.lit/2003/12/13/atom03", + }, + }, + }, + { + XMLName: xml.Name{Space: "", Local: "id"}, + Attrs: nil, + Content: "My pub item content ID", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "published"}, + Attrs: nil, + Content: "2003-12-13T18:30:02Z", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "updated"}, + Attrs: nil, + Content: "2003-12-13T18:30:02Z", + Nodes: nil, + }, + }, + }, + }) + + if err != nil { + log.Fatalf("%+v", err) + } + pubRespCh, _ := client.SendIQ(ctx, pub) + if pubRespCh != nil { + select { + case <-pubRespCh: + log.Println("Published item to the service") + case <-time.After(300 * time.Millisecond): + cancel() + log.Fatal("No iq response was received in time while publishing") + } + } +} diff --git a/_examples/xmpp_websocket/xmpp_websocket.go b/_examples/xmpp_websocket/xmpp_websocket.go index 428a1d1..3a0c1ba 100644 --- a/_examples/xmpp_websocket/xmpp_websocket.go +++ b/_examples/xmpp_websocket/xmpp_websocket.go @@ -26,7 +26,7 @@ func main() { router := xmpp.NewRouter() router.HandleFunc("message", handleMessage) - client, err := xmpp.NewClient(config, router) + client, err := xmpp.NewClient(&config, router, errorHandler) if err != nil { log.Fatalf("%+v", err) } @@ -37,6 +37,10 @@ func main() { log.Fatal(cm.Run()) } +func errorHandler(err error) { + fmt.Println(err.Error()) +} + func handleMessage(s xmpp.Sender, p stanza.Packet) { msg, ok := p.(stanza.Message) if !ok { diff --git a/auth.go b/auth.go index 726e15a..902371b 100644 --- a/auth.go +++ b/auth.go @@ -60,7 +60,21 @@ func authPlain(socket io.ReadWriter, decoder *xml.Decoder, mech string, user str raw := "\x00" + user + "\x00" + secret enc := make([]byte, base64.StdEncoding.EncodedLen(len(raw))) base64.StdEncoding.Encode(enc, []byte(raw)) - fmt.Fprintf(socket, "%s", stanza.NSSASL, mech, enc) + + a := stanza.SASLAuth{ + Mechanism: mech, + Value: string(enc), + } + data, err := xml.Marshal(a) + if err != nil { + return err + } + n, err := socket.Write(data) + if err != nil { + return err + } else if n == 0 { + return errors.New("failed to write authSASL nonza to socket : wrote 0 bytes") + } // Next message should be either success or failure. val, err := stanza.NextPacket(decoder) diff --git a/bi_dir_iterator.go b/bi_dir_iterator.go new file mode 100644 index 0000000..5293b1b --- /dev/null +++ b/bi_dir_iterator.go @@ -0,0 +1,12 @@ +package xmpp + +type BiDirIterator interface { + // Next returns the next element of this iterator, if a response is available within t milliseconds + Next(t int) (BiDirIteratorElt, error) + // Previous returns the previous element of this iterator, if a response is available within t milliseconds + Previous(t int) (BiDirIteratorElt, error) +} + +type BiDirIteratorElt interface { + NoOp() +} diff --git a/cert_checker.go b/cert_checker.go index fcee7b1..30a265a 100644 --- a/cert_checker.go +++ b/cert_checker.go @@ -79,7 +79,10 @@ func (c *ServerCheck) Check() error { } if _, ok := f.DoesStartTLS(); ok { - fmt.Fprintf(tcpconn, "") + _, err = fmt.Fprintf(tcpconn, "") + if err != nil { + return err + } var k stanza.TLSProceed if err = decoder.DecodeElement(&k, nil); err != nil { diff --git a/client.go b/client.go index a7e6c7d..9969472 100644 --- a/client.go +++ b/client.go @@ -4,9 +4,9 @@ import ( "context" "encoding/xml" "errors" - "fmt" "io" "net" + "sync" "time" "gosrc.io/xmpp/stanza" @@ -15,22 +15,45 @@ import ( //============================================================================= // EventManager -// ConnState represents the current connection state. +// SyncConnState represents the current connection state. +type SyncConnState struct { + sync.RWMutex + // Current state of the client. Please use the dedicated getter and setter for this field as they are thread safe. + state ConnState +} type ConnState = uint8 +// getState is a thread-safe getter for the current state +func (scs *SyncConnState) getState() ConnState { + var res ConnState + scs.RLock() + res = scs.state + scs.RUnlock() + return res +} + +// setState is a thread-safe setter for the current +func (scs *SyncConnState) setState(cs ConnState) { + scs.Lock() + scs.state = cs + scs.Unlock() +} + // This is a the list of events happening on the connection that the // client can be notified about. const ( StateDisconnected ConnState = iota - StateConnected + StateResuming StateSessionEstablished StateStreamError + StatePermanentError + InitialPresence = "" ) // Event is a structure use to convey event changes related to client state. This // is for example used to notify the client when the client get disconnected. type Event struct { - State ConnState + State SyncConnState Description string StreamError string SMState SMState @@ -43,38 +66,53 @@ type SMState struct { Id string // Inbound stanza count Inbound uint - // TODO Store location for IP affinity + + // IP affinity + preferredReconAddr string + + // Error + StreamErrorGroup stanza.StanzaErrorGroup + + // Track sent stanzas + *stanza.UnAckQueue + // TODO Store max and timestamp, to check if we should retry resumption or not } // EventHandler is use to pass events about state of the connection to // client implementation. -type EventHandler func(Event) +type EventHandler func(Event) error type EventManager struct { - // Store current state - CurrentState ConnState + // Store current state. Please use "getState" and "setState" to access and/or modify this. + CurrentState SyncConnState // Callback used to propagate connection state changes Handler EventHandler } -func (em EventManager) updateState(state ConnState) { - em.CurrentState = state +// updateState changes the CurrentState in the event manager. The state read is threadsafe but there is no guarantee +// regarding the triggered callback function. +func (em *EventManager) updateState(state ConnState) { + em.CurrentState.setState(state) if em.Handler != nil { em.Handler(Event{State: em.CurrentState}) } } -func (em EventManager) disconnected(state SMState) { - em.CurrentState = StateDisconnected +// disconnected changes the CurrentState in the event manager to "disconnected". The state read is threadsafe but there is no guarantee +// regarding the triggered callback function. +func (em *EventManager) disconnected(state SMState) { + em.CurrentState.setState(StateDisconnected) if em.Handler != nil { em.Handler(Event{State: em.CurrentState, SMState: state}) } } -func (em EventManager) streamError(error, desc string) { - em.CurrentState = StateStreamError +// streamError changes the CurrentState in the event manager to "streamError". The state read is threadsafe but there is no guarantee +// regarding the triggered callback function. +func (em *EventManager) streamError(error, desc string) { + em.CurrentState.setState(StateStreamError) if em.Handler != nil { em.Handler(Event{State: em.CurrentState, StreamError: error, Description: desc}) } @@ -89,7 +127,7 @@ var ErrCanOnlySendGetOrSetIq = errors.New("SendIQ can only send get and set IQ s // server. type Client struct { // Store user defined options and states - config Config + config *Config // Session gather data that can be accessed by users of this library Session *Session transport Transport @@ -97,6 +135,14 @@ type Client struct { router *Router // Track and broadcast connection state EventManager + // Handle errors from client execution + ErrorHandler func(error) + + // Post connection hook. This will be executed on first connection + PostConnectHook func() error + + // Post resume hook. This will be executed after the client resumes a lost connection using StreamManagement (XEP-0198) + PostResumeHook func() error } /* @@ -104,11 +150,14 @@ Setting up the client / Checking the parameters */ // NewClient generates a new XMPP client, based on Config passed as parameters. -// If host is not specified, the DNS SRV should be used to find the host from the domainpart of the JID. +// If host is not specified, the DNS SRV should be used to find the host from the domain part of the Jid. // Default the port to 5222. -func NewClient(config Config, r *Router) (c *Client, err error) { - // Parse JID - if config.parsedJid, err = NewJid(config.Jid); err != nil { +func NewClient(config *Config, r *Router, errorHandler func(error)) (c *Client, err error) { + if config.KeepaliveInterval == 0 { + config.KeepaliveInterval = time.Second * 30 + } + // Parse Jid + if config.parsedJid, err = stanza.NewJid(config.Jid); err != nil { err = errors.New("missing jid") return nil, NewConnError(err, true) } @@ -136,9 +185,15 @@ func NewClient(config Config, r *Router) (c *Client, err error) { } } } + if config.Domain == "" { + // Fallback to jid domain + config.Domain = config.parsedJid.Domain + } + c = new(Client) c.config = config c.router = r + c.ErrorHandler = errorHandler if c.config.ConnectTimeout == 0 { c.config.ConnectTimeout = 15 // 15 second as default @@ -147,7 +202,8 @@ func NewClient(config Config, r *Router) (c *Client, err error) { if config.TransportConfiguration.Domain == "" { config.TransportConfiguration.Domain = config.parsedJid.Domain } - c.transport = NewClientTransport(config.TransportConfiguration) + c.config.TransportConfiguration.ConnectTimeout = c.config.ConnectTimeout + c.transport = NewClientTransport(c.config.TransportConfiguration) if config.StreamLogger != nil { c.transport.LogTraffic(config.StreamLogger) @@ -156,53 +212,94 @@ func NewClient(config Config, r *Router) (c *Client, err error) { return } -// Connect triggers actual TCP connection, based on previously defined parameters. -// Connect simply triggers resumption, with an empty session state. +// Connect establishes a first time connection to a XMPP server. +// It calls the PostConnectHook func (c *Client) Connect() error { - var state SMState - return c.Resume(state) + err := c.connect() + if err != nil { + return err + } + // TODO: Do we always want to send initial presence automatically ? + // Do we need an option to avoid that or do we rely on client to send the presence itself ? + err = c.sendWithWriter(c.transport, []byte(InitialPresence)) + // Execute the post first connection hook. Typically this holds "ask for roster" and this type of actions. + if c.PostConnectHook != nil { + err = c.PostConnectHook() + if err != nil { + return err + } + } + + // Start the keepalive go routine + keepaliveQuit := make(chan struct{}) + go keepalive(c.transport, c.config.KeepaliveInterval, keepaliveQuit) + // Start the receiver go routine + go c.recv(keepaliveQuit) + return err } -// Resume attempts resuming a Stream Managed session, based on the provided stream management -// state. -func (c *Client) Resume(state SMState) error { +// connect establishes an actual TCP connection, based on previously defined parameters, as well as a XMPP session +func (c *Client) connect() error { + var state SMState var err error - + // This is the TCP connection streamId, err := c.transport.Connect() if err != nil { return err } - c.updateState(StateConnected) - // Client is ok, we now open XMPP session - if c.Session, err = NewSession(c.transport, c.config, state); err != nil { - c.transport.Close() + // Client is ok, we now open XMPP session with TLS negotiation if possible and session resume or binding + // depending on state. + if c.Session, err = NewSession(c, state); err != nil { + // Try to get the stream close tag from the server. + go func() { + for { + val, err := stanza.NextPacket(c.transport.GetDecoder()) + if err != nil { + c.ErrorHandler(err) + c.disconnected(state) + return + } + switch val.(type) { + case stanza.StreamClosePacket: + // TCP messages should arrive in order, so we can expect to get nothing more after this occurs + c.transport.ReceivedStreamClose() + return + } + } + }() + c.Disconnect() return err } c.Session.StreamId = streamId c.updateState(StateSessionEstablished) - // Start the keepalive go routine - keepaliveQuit := make(chan struct{}) - go keepalive(c.transport, keepaliveQuit) - // Start the receiver go routine - state = c.Session.SMState - go c.recv(state, keepaliveQuit) - - // We're connected and can now receive and send messages. - //fmt.Fprintf(client.conn, "%s%s", "chat", "Online") - // TODO: Do we always want to send initial presence automatically ? - // Do we need an option to avoid that or do we rely on client to send the presence itself ? - fmt.Fprintf(c.transport, "") - return err } -func (c *Client) Disconnect() { - // TODO: Add a way to wait for stream close acknowledgement from the server for clean disconnect - if c.transport != nil { - _ = c.transport.Close() +// Resume attempts resuming a Stream Managed session, based on the provided stream management +// state. See XEP-0198 +func (c *Client) Resume() error { + c.EventManager.updateState(StateResuming) + err := c.connect() + if err != nil { + return err } + // Execute post reconnect hook. This can be different from the first connection hook, and not trigger roster retrieval + // for example. + if c.PostResumeHook != nil { + err = c.PostResumeHook() + } + return err +} + +// Disconnect disconnects the client from the server, sending a stream close nonza and closing the TCP connection. +func (c *Client) Disconnect() error { + if c.transport != nil { + return c.transport.Close() + } + // No transport so no connection. + return nil } func (c *Client) SetHandler(handler EventHandler) { @@ -221,6 +318,15 @@ func (c *Client) Send(packet stanza.Packet) error { return errors.New("cannot marshal packet " + err.Error()) } + // Store stanza as non-acked as part of stream management + // See https://xmpp.org/extensions/xep-0198.html#scenarios + if c.config.StreamManagementEnable { + if _, ok := packet.(stanza.SMRequest); !ok { + toStore := stanza.UnAckedStz{Stz: string(data)} + c.Session.SMState.UnAckQueue.Push(&toStore) + } + } + return c.sendWithWriter(c.transport, data) } @@ -233,8 +339,8 @@ func (c *Client) Send(packet stanza.Packet) error { // ctx, _ := context.WithTimeout(context.Background(), 30 * time.Second) // result := <- client.SendIQ(ctx, iq) // -func (c *Client) SendIQ(ctx context.Context, iq stanza.IQ) (chan stanza.IQ, error) { - if iq.Attrs.Type != "set" && iq.Attrs.Type != "get" { +func (c *Client) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { + if iq.Attrs.Type != stanza.IQTypeSet && iq.Attrs.Type != stanza.IQTypeGet { return nil, ErrCanOnlySendGetOrSetIq } if err := c.Send(iq); err != nil { @@ -253,6 +359,12 @@ func (c *Client) SendRaw(packet string) error { return errors.New("client is not connected") } + // Store stanza as non-acked as part of stream management + // See https://xmpp.org/extensions/xep-0198.html#scenarios + if c.config.StreamManagementEnable { + toStore := stanza.UnAckedStz{Stz: packet} + c.Session.SMState.UnAckQueue.Push(&toStore) + } return c.sendWithWriter(c.transport, []byte(packet)) } @@ -266,33 +378,43 @@ func (c *Client) sendWithWriter(writer io.Writer, packet []byte) error { // Go routines // Loop: Receive data from server -func (c *Client) recv(state SMState, keepaliveQuit chan<- struct{}) (err error) { +func (c *Client) recv(keepaliveQuit chan<- struct{}) { + defer close(keepaliveQuit) + for { val, err := stanza.NextPacket(c.transport.GetDecoder()) if err != nil { - close(keepaliveQuit) - c.disconnected(state) - return err + c.ErrorHandler(err) + c.disconnected(c.Session.SMState) + return } // Handle stream errors switch packet := val.(type) { case stanza.StreamError: c.router.route(c, val) - close(keepaliveQuit) c.streamError(packet.Error.Local, packet.Text) - return errors.New("stream error: " + packet.Error.Local) + c.ErrorHandler(errors.New("stream error: " + packet.Error.Local)) + // We don't return here, because we want to wait for the stream close tag from the server, or timeout. + c.Disconnect() // Process Stream management nonzas case stanza.SMRequest: answer := stanza.SMAnswer{XMLName: xml.Name{ Space: stanza.NSStreamManagement, Local: "a", - }, H: state.Inbound} - c.Send(answer) + }, H: c.Session.SMState.Inbound} + err = c.Send(answer) + if err != nil { + c.ErrorHandler(err) + return + } + case stanza.StreamClosePacket: + // TCP messages should arrive in order, so we can expect to get nothing more after this occurs + c.transport.ReceivedStreamClose() + return default: - state.Inbound++ + c.Session.SMState.Inbound++ } - // Do normal route processing in a go-routine so we can immediately // start receiving other stanzas. This also allows route handlers to // send and receive more stanzas. @@ -303,9 +425,8 @@ func (c *Client) recv(state SMState, keepaliveQuit chan<- struct{}) (err error) // Loop: send whitespace keepalive to server // This is use to keep the connection open, but also to detect connection loss // and trigger proper client connection shutdown. -func keepalive(transport Transport, quit <-chan struct{}) { - // TODO: Make keepalive interval configurable - ticker := time.NewTicker(30 * time.Second) +func keepalive(transport Transport, interval time.Duration, quit <-chan struct{}) { + ticker := time.NewTicker(interval) for { select { case <-ticker.C: diff --git a/client_internal_test.go b/client_internal_test.go index 6daef09..140eab7 100644 --- a/client_internal_test.go +++ b/client_internal_test.go @@ -2,7 +2,16 @@ package xmpp import ( "bytes" + "encoding/xml" + "fmt" + "gosrc.io/xmpp/stanza" + "strconv" "testing" + "time" +) + +const ( + streamManagementID = "test-stream_management-id" ) func TestClient_Send(t *testing.T) { @@ -17,3 +26,583 @@ func TestClient_Send(t *testing.T) { t.Errorf("Incorrect value sent to buffer: '%s'", buffer.String()) } } + +// Stream management test. +// Connection is established, then the server sends supported features and so on. +// After the bind, client attempts a stream management enablement, and server replies in kind. +func Test_StreamManagement(t *testing.T) { + serverDone := make(chan struct{}) + clientDone := make(chan struct{}) + + client, mock := initSrvCliForResumeTests(t, func(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesStreamManagment(t, sc) // Send post auth features + bind(t, sc) + enableStreamManagement(t, sc, false, true) + serverDone <- struct{}{} + }, testClientStreamManagement, true, true) + go func() { + var state SMState + var err error + // Client is ok, we now open XMPP session + if client.Session, err = NewSession(client, state); err != nil { + t.Fatalf("failed to open XMPP session: %s", err) + } + clientDone <- struct{}{} + }() + + waitForEntity(t, clientDone) + waitForEntity(t, serverDone) + mock.Stop() +} + +// Absence of stream management test. +// Connection is established, then the server sends supported features and so on. +// Client has stream management disabled in its config, and should not ask for it. Server is not set up to reply. +func Test_NoStreamManagement(t *testing.T) { + serverDone := make(chan struct{}) + clientDone := make(chan struct{}) + + // Setup Mock server + client, mock := initSrvCliForResumeTests(t, func(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesNoStreamManagment(t, sc) // Send post auth features + bind(t, sc) + serverDone <- struct{}{} + }, testClientStreamManagement, true, false) + + go func() { + var state SMState + + // Client is ok, we now open XMPP session + var err error + if client.Session, err = NewSession(client, state); err != nil { + t.Fatalf("failed to open XMPP session: %s", err) + } + clientDone <- struct{}{} + }() + + waitForEntity(t, clientDone) + waitForEntity(t, serverDone) + + mock.Stop() +} + +func Test_StreamManagementNotSupported(t *testing.T) { + serverDone := make(chan struct{}) + clientDone := make(chan struct{}) + + client, mock := initSrvCliForResumeTests(t, func(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesNoStreamManagment(t, sc) // Send post auth features + bind(t, sc) + serverDone <- struct{}{} + }, testClientStreamManagement, true, true) + + go func() { + var state SMState + var err error + // Client is ok, we now open XMPP session + if client.Session, err = NewSession(client, state); err != nil { + t.Fatalf("failed to open XMPP session: %s", err) + } + clientDone <- struct{}{} + }() + + // Wait for client + waitForEntity(t, clientDone) + + // Check if client got a positive stream management response from the server + if client.Session.Features.DoesStreamManagement() { + t.Fatalf("server does not provide stream management") + } + + // Wait for server + waitForEntity(t, serverDone) + mock.Stop() +} + +func Test_StreamManagementNoResume(t *testing.T) { + serverDone := make(chan struct{}) + clientDone := make(chan struct{}) + + client, mock := initSrvCliForResumeTests(t, func(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesStreamManagment(t, sc) // Send post auth features + bind(t, sc) + enableStreamManagement(t, sc, false, false) + serverDone <- struct{}{} + }, testClientStreamManagement, true, true) + + go func() { + var state SMState + var err error + // Client is ok, we now open XMPP session + if client.Session, err = NewSession(client, state); err != nil { + t.Fatalf("failed to open XMPP session: %s", err) + } + clientDone <- struct{}{} + }() + waitForEntity(t, clientDone) + if IsStreamResumable(client) { + t.Fatalf("server does not support resumption but client says stream is resumable") + } + waitForEntity(t, serverDone) + mock.Stop() +} + +func Test_StreamManagementResume(t *testing.T) { + serverDone := make(chan struct{}) + clientDone := make(chan struct{}) + // Setup Mock server + mock := ServerMock{} + mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesStreamManagment(t, sc) // Send post auth features + bind(t, sc) + enableStreamManagement(t, sc, false, true) + discardPresence(t, sc) + serverDone <- struct{}{} + }) + + // Test / Check result + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testXMPPAddress, + }, + Jid: "test@localhost", + Credential: Password("test"), + Insecure: true, + StreamManagementEnable: true, + streamManagementResume: true} // Enable stream management + + var client *Client + router := NewRouter() + client, err := NewClient(&config, router, clientDefaultErrorHandler) + if err != nil { + t.Errorf("connect create XMPP client: %s", err) + } + + // ================================================================= + // Connect client, then disconnect it so we can resume the session + go func() { + err = client.Connect() + if err != nil { + t.Fatalf("could not connect client to mock server: %s", err) + } + clientDone <- struct{}{} + }() + + waitForEntity(t, clientDone) + + // =========================================================================================== + // Check that the client correctly went into "disconnected" state, after being disconnected + statusCorrectChan := make(chan struct{}) + kill := make(chan struct{}) + + transp, ok := client.transport.(*XMPPTransport) + if !ok { + t.Fatalf("problem with client transport ") + } + + transp.conn.Close() + + waitForEntity(t, serverDone) + mock.Stop() + + go checkClientResumeStatus(client, statusCorrectChan, kill) + select { + case <-statusCorrectChan: + // Test passed + case <-time.After(5 * time.Second): + kill <- struct{}{} + t.Fatalf("Client is not in disconnected state while it should be. Timed out") + } + + // Check if the client can have its connection resumed using its state but also its configuration + if !IsStreamResumable(client) { + t.Fatalf("should support resumption") + } + + // Reboot server. We need to make a new one because (at least for now) the mock server can only have one handler + // and they should be different between a first connection and a stream resume since exchanged messages + // are different (See XEP-0198) + mock2 := ServerMock{} + mock2.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) { + // Reconnect + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesStreamManagment(t, sc) // Send post auth features + resumeStream(t, sc) + serverDone <- struct{}{} + }) + + // Reconnect + go func() { + err = client.Resume() + if err != nil { + t.Fatalf("could not connect client to mock server: %s", err) + } + clientDone <- struct{}{} + }() + + waitForEntity(t, clientDone) + waitForEntity(t, serverDone) + + mock2.Stop() +} + +func Test_StreamManagementFail(t *testing.T) { + serverDone := make(chan struct{}) + clientDone := make(chan struct{}) + // Setup Mock server + mock := ServerMock{} + mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesStreamManagment(t, sc) // Send post auth features + bind(t, sc) + enableStreamManagement(t, sc, true, true) + serverDone <- struct{}{} + }) + + // Test / Check result + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testXMPPAddress, + }, + Jid: "test@localhost", + Credential: Password("test"), + Insecure: true, + StreamManagementEnable: true, + streamManagementResume: true} // Enable stream management + + var client *Client + router := NewRouter() + client, err := NewClient(&config, router, clientDefaultErrorHandler) + if err != nil { + t.Errorf("connect create XMPP client: %s", err) + } + + var state SMState + go func() { + _, err = client.transport.Connect() + if err != nil { + return + } + + // Client is ok, we now open XMPP session + if client.Session, err = NewSession(client, state); err == nil { + t.Fatalf("test is supposed to err") + } + if client.Session.SMState.StreamErrorGroup == nil { + t.Fatalf("error was not stored correctly in session state") + } + clientDone <- struct{}{} + }() + + waitForEntity(t, serverDone) + waitForEntity(t, clientDone) + + mock.Stop() +} + +func Test_SendStanzaQueueWithSM(t *testing.T) { + serverDone := make(chan struct{}) + clientDone := make(chan struct{}) + // Setup Mock server + mock := ServerMock{} + mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesStreamManagment(t, sc) // Send post auth features + bind(t, sc) + enableStreamManagement(t, sc, false, true) + + // Ignore the initial presence sent to the server by the client so we can move on to the next packet. + discardPresence(t, sc) + + // Used here to silently discard the IQ sent by the client, in order to later trigger a resend + skipPacket(t, sc) + // Respond to the client ACK request with a number of processed stanzas of 0. This should trigger a resend + // of previously ignored stanza to the server, which this handler element will be expecting. + respondWithAck(t, sc, 0) + serverDone <- struct{}{} + }) + + // Test / Check result + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testXMPPAddress, + }, + Jid: "test@localhost", + Credential: Password("test"), + Insecure: true, + StreamManagementEnable: true, + streamManagementResume: true} // Enable stream management + + var client *Client + router := NewRouter() + client, err := NewClient(&config, router, clientDefaultErrorHandler) + if err != nil { + t.Errorf("connect create XMPP client: %s", err) + } + + go func() { + err = client.Connect() + + client.SendRaw(` + + +`) + + // Last stanza was discarded silently by the server. Let's ask an ack for it. This should trigger resend as the server + // will respond with an acknowledged number of stanzas of 0. + r := stanza.SMRequest{} + client.Send(r) + clientDone <- struct{}{} + }() + waitForEntity(t, serverDone) + waitForEntity(t, clientDone) + + mock.Stop() +} + +//======================================================================== +// Helper functions for tests + +func skipPacket(t *testing.T, sc *ServerConn) { + var p stanza.IQ + se, err := stanza.NextStart(sc.decoder) + + if err != nil { + t.Fatalf("cannot read packet: %s", err) + return + } + if err := sc.decoder.DecodeElement(&p, &se); err != nil { + t.Fatalf("cannot decode packet: %s", err) + return + } +} + +func respondWithAck(t *testing.T, sc *ServerConn, h int) { + + // Mock server reads the ack request + var p stanza.SMRequest + se, err := stanza.NextStart(sc.decoder) + + if err != nil { + t.Fatalf("cannot read packet: %s", err) + return + } + if err := sc.decoder.DecodeElement(&p, &se); err != nil { + t.Fatalf("cannot decode packet: %s", err) + return + } + + // Mock server sends the ack response + a := stanza.SMAnswer{ + H: uint(h), + } + data, err := xml.Marshal(a) + _, err = sc.connection.Write(data) + if err != nil { + t.Fatalf("failed to send response ack") + } + + // Mock server reads the re-sent stanza that was previously discarded intentionally + var p2 stanza.IQ + nse, err := stanza.NextStart(sc.decoder) + + if err != nil { + t.Fatalf("cannot read packet: %s", err) + return + } + if err := sc.decoder.DecodeElement(&p2, &nse); err != nil { + t.Fatalf("cannot decode packet: %s", err) + return + } +} + +func sendFeaturesStreamManagment(t *testing.T, sc *ServerConn) { + // This is a basic server, supporting only 2 features after auth: stream management & session binding + features := ` + + +` + if _, err := fmt.Fprintln(sc.connection, features); err != nil { + t.Fatalf("cannot send stream feature: %s", err) + } +} + +func sendFeaturesNoStreamManagment(t *testing.T, sc *ServerConn) { + // This is a basic server, supporting only 2 features after auth: stream management & session binding + features := ` + +` + if _, err := fmt.Fprintln(sc.connection, features); err != nil { + t.Fatalf("cannot send stream feature: %s", err) + } +} + +// enableStreamManagement is a function for the mock server that can either mock a successful session, or fail depending on +// the value of the "fail" boolean. True means the session should fail. +func enableStreamManagement(t *testing.T, sc *ServerConn, fail bool, resume bool) { + // Decode element into pointer storage + var ed stanza.SMEnable + se, err := stanza.NextStart(sc.decoder) + + if err != nil { + t.Fatalf("cannot read stream management enable: %s", err) + return + } + if err := sc.decoder.DecodeElement(&ed, &se); err != nil { + t.Fatalf("cannot decode stream management enable: %s", err) + return + } + + if fail { + f := stanza.SMFailed{ + H: nil, + StreamErrorGroup: &stanza.UnexpectedRequest{}, + } + data, err := xml.Marshal(f) + if err != nil { + t.Fatalf("failed to marshall error response: %s", err) + } + sc.connection.Write(data) + } else { + e := &stanza.SMEnabled{ + Resume: strconv.FormatBool(resume), + Id: streamManagementID, + } + data, err := xml.Marshal(e) + if err != nil { + t.Fatalf("failed to marshall error response: %s", err) + } + sc.connection.Write(data) + } +} + +func resumeStream(t *testing.T, sc *ServerConn) { + h := uint(0) + response := stanza.SMResumed{ + PrevId: streamManagementID, + H: &h, + } + + data, err := xml.Marshal(response) + if err != nil { + t.Fatalf("failed to marshall stream management enabled response : %s", err) + } + + writtenChan := make(chan struct{}) + + go func() { + sc.connection.Write(data) + writtenChan <- struct{}{} + }() + select { + case <-writtenChan: + // We're done here + return + case <-time.After(defaultTimeout): + t.Fatalf("failed to write enabled nonza to client") + } +} + +func checkClientResumeStatus(client *Client, statusCorrectChan chan struct{}, killChan chan struct{}) { + for { + if client.CurrentState.getState() == StateDisconnected { + statusCorrectChan <- struct{}{} + } + select { + case <-killChan: + return + case <-time.After(time.Millisecond * 10): + // Keep checking status value + } + } +} + +func initSrvCliForResumeTests(t *testing.T, serverHandler func(*testing.T, *ServerConn), port int, StreamManagementEnable, StreamManagementResume bool) (*Client, *ServerMock) { + mock := &ServerMock{} + testServerAddress := fmt.Sprintf("%s:%d", testClientDomain, port) + + mock.Start(t, testServerAddress, serverHandler) + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testServerAddress, + }, + Jid: "test@localhost", + Credential: Password("test"), + Insecure: true, + StreamManagementEnable: StreamManagementEnable, + streamManagementResume: StreamManagementResume} + + var client *Client + var err error + router := NewRouter() + if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil { + t.Fatalf("connect create XMPP client: %s", err) + } + + if _, err = client.transport.Connect(); err != nil { + t.Fatalf("XMPP connection failed: %s", err) + } + + return client, mock +} + +func waitForEntity(t *testing.T, entityDone chan struct{}) { + select { + case <-entityDone: + case <-time.After(defaultTimeout): + t.Fatalf("test timed out") + } +} diff --git a/client_test.go b/client_test.go index 2636f29..4f30c0e 100644 --- a/client_test.go +++ b/client_test.go @@ -1,10 +1,10 @@ package xmpp import ( + "context" "encoding/xml" "errors" "fmt" - "net" "testing" "time" @@ -14,15 +14,34 @@ import ( const ( // Default port is not standard XMPP port to avoid interfering // with local running XMPP server - testXMPPAddress = "localhost:15222" - - defaultTimeout = 2 * time.Second + testXMPPAddress = "localhost:15222" + testClientDomain = "localhost" ) +func TestEventManager(t *testing.T) { + mgr := EventManager{} + mgr.updateState(StateResuming) + if mgr.CurrentState.getState() != StateResuming { + t.Fatal("CurrentState not updated by updateState()") + } + + mgr.disconnected(SMState{}) + + if mgr.CurrentState.getState() != StateDisconnected { + t.Fatalf("CurrentState not reset by disconnected()") + } + + mgr.streamError(ErrTLSNotSupported.Error(), "") + + if mgr.CurrentState.getState() != StateStreamError { + t.Fatalf("CurrentState not set by streamError()") + } +} + func TestClient_Connect(t *testing.T) { // Setup Mock server mock := ServerMock{} - mock.Start(t, testXMPPAddress, handlerConnectSuccess) + mock.Start(t, testXMPPAddress, handlerClientConnectSuccess) // Test / Check result config := Config{ @@ -36,7 +55,7 @@ func TestClient_Connect(t *testing.T) { var client *Client var err error router := NewRouter() - if client, err = NewClient(config, router); err != nil { + if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil { t.Errorf("connect create XMPP client: %s", err) } @@ -50,7 +69,10 @@ func TestClient_Connect(t *testing.T) { func TestClient_NoInsecure(t *testing.T) { // Setup Mock server mock := ServerMock{} - mock.Start(t, testXMPPAddress, handlerAbortTLS) + mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) { + handlerAbortTLS(t, sc) + closeConn(t, sc) + }) // Test / Check result config := Config{ @@ -64,7 +86,7 @@ func TestClient_NoInsecure(t *testing.T) { var client *Client var err error router := NewRouter() - if client, err = NewClient(config, router); err != nil { + if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil { t.Errorf("cannot create XMPP client: %s", err) } @@ -80,7 +102,10 @@ func TestClient_NoInsecure(t *testing.T) { func TestClient_FeaturesTracking(t *testing.T) { // Setup Mock server mock := ServerMock{} - mock.Start(t, testXMPPAddress, handlerAbortTLS) + mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) { + handlerAbortTLS(t, sc) + closeConn(t, sc) + }) // Test / Check result config := Config{ @@ -94,7 +119,7 @@ func TestClient_FeaturesTracking(t *testing.T) { var client *Client var err error router := NewRouter() - if client, err = NewClient(config, router); err != nil { + if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil { t.Errorf("cannot create XMPP client: %s", err) } @@ -109,7 +134,7 @@ func TestClient_FeaturesTracking(t *testing.T) { func TestClient_RFC3921Session(t *testing.T) { // Setup Mock server mock := ServerMock{} - mock.Start(t, testXMPPAddress, handlerConnectWithSession) + mock.Start(t, testXMPPAddress, handlerClientConnectWithSession) // Test / Check result config := Config{ @@ -124,7 +149,7 @@ func TestClient_RFC3921Session(t *testing.T) { var client *Client var err error router := NewRouter() - if client, err = NewClient(config, router); err != nil { + if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil { t.Errorf("connect create XMPP client: %s", err) } @@ -135,56 +160,454 @@ func TestClient_RFC3921Session(t *testing.T) { mock.Stop() } +// Testing sending an IQ to the mock server and reading its response. +func TestClient_SendIQ(t *testing.T) { + done := make(chan struct{}) + // Handler for Mock server + h := func(t *testing.T, sc *ServerConn) { + handlerClientConnectSuccess(t, sc) + discardPresence(t, sc) + respondToIQ(t, sc) + done <- struct{}{} + } + client, mock := mockClientConnection(t, h, testClientIqPort) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "test1@localhost/mremond-mbp", To: defaultServerName, Id: defaultStreamID, Lang: "en"}) + if err != nil { + t.Fatalf("failed to create the IQ request: %v", err) + } + + disco := iqReq.DiscoInfo() + iqReq.Payload = disco + + // Handle a possible error + errChan := make(chan error) + errorHandler := func(err error) { + errChan <- err + } + client.ErrorHandler = errorHandler + res, err := client.SendIQ(ctx, iqReq) + if err != nil { + t.Errorf(err.Error()) + } + + select { + case <-res: // If the server responds with an IQ, we pass the test + case err := <-errChan: // If the server sends an error, or there is a connection error + cancel() + t.Fatal(err.Error()) + case <-time.After(defaultChannelTimeout): // If we timeout + cancel() + t.Fatal("Failed to receive response, to sent IQ, from mock server") + } + select { + case <-done: + mock.Stop() + case <-time.After(defaultChannelTimeout): + cancel() + t.Fatal("The mock server failed to finish its job !") + } + cancel() +} + +func TestClient_SendIQFail(t *testing.T) { + done := make(chan struct{}) + // Handler for Mock server + h := func(t *testing.T, sc *ServerConn) { + handlerClientConnectSuccess(t, sc) + discardPresence(t, sc) + respondToIQ(t, sc) + done <- struct{}{} + } + client, mock := mockClientConnection(t, h, testClientIqFailPort) + + //================== + // Create an IQ to send + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "test1@localhost/mremond-mbp", To: defaultServerName, Id: defaultStreamID, Lang: "en"}) + if err != nil { + t.Fatalf("failed to create IQ request: %v", err) + } + disco := iqReq.DiscoInfo() + iqReq.Payload = disco + // Removing the id to make the stanza invalid. The IQ constructor makes a random one if none is specified + // so we need to overwrite it. + iqReq.Id = "" + + // Handle a possible error + errChan := make(chan error) + errorHandler := func(err error) { + errChan <- err + } + client.ErrorHandler = errorHandler + res, _ := client.SendIQ(ctx, iqReq) + + // Test + select { + case <-res: // If the server responds with an IQ + t.Errorf("Server should not respond with an IQ since the request is expected to be invalid !") + case <-errChan: // If the server sends an error, the test passes + case <-time.After(defaultChannelTimeout): // If we timeout + t.Errorf("Failed to receive response, to sent IQ, from mock server") + } + select { + case <-done: + mock.Stop() + case <-time.After(defaultChannelTimeout): + cancel() + t.Errorf("The mock server failed to finish its job !") + } + cancel() +} + +func TestClient_SendRaw(t *testing.T) { + done := make(chan struct{}) + // Handler for Mock server + h := func(t *testing.T, sc *ServerConn) { + handlerClientConnectSuccess(t, sc) + discardPresence(t, sc) + respondToIQ(t, sc) + closeConn(t, sc) + done <- struct{}{} + } + type testCase struct { + req string + shouldErr bool + port int + } + testRequests := make(map[string]testCase) + // Sending a correct IQ of type get. Not supposed to err + testRequests["Correct IQ"] = testCase{ + req: ``, + shouldErr: false, + port: testClientRawPort + 100, + } + // Sending an IQ with a missing ID. Should err + testRequests["IQ with missing ID"] = testCase{ + req: ``, + shouldErr: true, + port: testClientRawPort, + } + + // A handler for the client. + // In the failing test, the server returns a stream error, which triggers this handler, client side. + errChan := make(chan error) + errHandler := func(err error) { + errChan <- err + } + + // Tests for all the IQs + for name, tcase := range testRequests { + t.Run(name, func(st *testing.T) { + //Connecting to a mock server, initialized with given port and handler function + c, m := mockClientConnection(t, h, tcase.port) + c.ErrorHandler = errHandler + // Sending raw xml from test case + err := c.SendRaw(tcase.req) + if err != nil { + t.Errorf("Error sending Raw string") + } + // Just wait a little so the message has time to arrive + select { + // We don't use the default "long" timeout here because waiting it out means passing the test. + case <-time.After(100 * time.Millisecond): + c.Disconnect() + case err = <-errChan: + if err == nil && tcase.shouldErr { + t.Errorf("Failed to get closing stream err") + } else if err != nil && !tcase.shouldErr { + t.Errorf("This test is not supposed to err !") + } + } + select { + case <-done: + m.Stop() + case <-time.After(defaultChannelTimeout): + t.Errorf("The mock server failed to finish its job !") + } + }) + } +} + +func TestClient_Disconnect(t *testing.T) { + c, m := mockClientConnection(t, func(t *testing.T, sc *ServerConn) { + handlerClientConnectSuccess(t, sc) + closeConn(t, sc) + }, testClientBasePort) + err := c.transport.Ping() + if err != nil { + t.Errorf("Could not ping but not disconnected yet") + } + c.Disconnect() + err = c.transport.Ping() + if err == nil { + t.Errorf("Did not disconnect properly") + } + m.Stop() +} + +func TestClient_DisconnectStreamManager(t *testing.T) { + // Init mock server + // Setup Mock server + mock := ServerMock{} + mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) { + handlerAbortTLS(t, sc) + closeConn(t, sc) + }) + + // Test / Check result + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testXMPPAddress, + }, + Jid: "test@localhost", + Credential: Password("test"), + } + + var client *Client + var err error + router := NewRouter() + if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil { + t.Errorf("cannot create XMPP client: %s", err) + } + + sman := NewStreamManager(client, nil) + errChan := make(chan error) + runSMan := func(errChan chan error) { + errChan <- sman.Run() + } + + go runSMan(errChan) + select { + case <-errChan: + case <-time.After(defaultChannelTimeout): + // When insecure is not allowed: + t.Errorf("should fail as insecure connection is not allowed and server does not support TLS") + } + mock.Stop() +} + +func Test_ClientPostConnectHook(t *testing.T) { + done := make(chan struct{}) + // Handler for Mock server + h := func(t *testing.T, sc *ServerConn) { + handlerClientConnectSuccess(t, sc) + done <- struct{}{} + } + + hookChan := make(chan struct{}) + mock := &ServerMock{} + testServerAddress := fmt.Sprintf("%s:%d", testClientDomain, testClientPostConnectHook) + + mock.Start(t, testServerAddress, h) + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testServerAddress, + }, + Jid: "test@localhost", + Credential: Password("test"), + Insecure: true} + + var client *Client + var err error + router := NewRouter() + if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil { + t.Errorf("connect create XMPP client: %s", err) + } + + // The post connection client hook should just write to a channel that we will read later. + client.PostConnectHook = func() error { + go func() { + hookChan <- struct{}{} + }() + return nil + } + // Handle a possible error + errChan := make(chan error) + errorHandler := func(err error) { + errChan <- err + } + client.ErrorHandler = errorHandler + if err = client.Connect(); err != nil { + t.Errorf("XMPP connection failed: %s", err) + } + + // Check if the post connection client hook was correctly called + select { + case err := <-errChan: // If the server sends an error, or there is a connection error + t.Fatal(err.Error()) + case <-time.After(defaultChannelTimeout): // If we timeout + t.Fatal("Failed to call post connection client hook") + case <-hookChan: + // Test succeeded, channel was written to. + } + + select { + case <-done: + mock.Stop() + case <-time.After(defaultChannelTimeout): + t.Fatal("The mock server failed to finish its job !") + } +} + +func Test_ClientPostReconnectHook(t *testing.T) { + hookChan := make(chan struct{}) + // Setup Mock server + mock := ServerMock{} + mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesStreamManagment(t, sc) // Send post auth features + bind(t, sc) + enableStreamManagement(t, sc, false, true) + }) + + // Test / Check result + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testXMPPAddress, + }, + Jid: "test@localhost", + Credential: Password("test"), + Insecure: true, + StreamManagementEnable: true, + streamManagementResume: true} // Enable stream management + + var client *Client + router := NewRouter() + client, err := NewClient(&config, router, clientDefaultErrorHandler) + if err != nil { + t.Errorf("connect create XMPP client: %s", err) + } + + client.PostResumeHook = func() error { + go func() { + hookChan <- struct{}{} + }() + return nil + } + + err = client.Connect() + if err != nil { + t.Fatalf("could not connect client to mock server: %s", err) + } + + transp, ok := client.transport.(*XMPPTransport) + if !ok { + t.Fatalf("problem with client transport ") + } + + transp.conn.Close() + mock.Stop() + + // Check if the client can have its connection resumed using its state but also its configuration + if !IsStreamResumable(client) { + t.Fatalf("should support resumption") + } + + // Reboot server. We need to make a new one because (at least for now) the mock server can only have one handler + // and they should be different between a first connection and a stream resume since exchanged messages + // are different (See XEP-0198) + mock2 := ServerMock{} + mock2.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) { + // Reconnect + checkClientOpenStream(t, sc) + + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) + + checkClientOpenStream(t, sc) // Reset stream + sendFeaturesStreamManagment(t, sc) // Send post auth features + resumeStream(t, sc) + }) + + // Reconnect + err = client.Resume() + if err != nil { + t.Fatalf("could not connect client to mock server: %s", err) + } + + select { + case <-time.After(defaultChannelTimeout): // If we timeout + t.Fatal("Failed to call post connection client hook") + case <-hookChan: + // Test succeeded, channel was written to. + } + + mock2.Stop() +} + //============================================================================= // Basic XMPP Server Mock Handlers. -const serverStreamOpen = "" - // Test connection with a basic straightforward workflow -func handlerConnectSuccess(t *testing.T, c net.Conn) { - decoder := xml.NewDecoder(c) - checkOpenStream(t, c, decoder) +func handlerClientConnectSuccess(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) - sendStreamFeatures(t, c, decoder) // Send initial features - readAuth(t, decoder) - fmt.Fprintln(c, "") + checkClientOpenStream(t, sc) // Reset stream + sendBindFeature(t, sc) // Send post auth features + bind(t, sc) +} + +// closeConn closes the connection on request from the client +func closeConn(t *testing.T, sc *ServerConn) { + for { + cls, err := stanza.NextPacket(sc.decoder) + if err != nil { + t.Errorf("cannot read from socket: %s", err) + return + } + switch cls.(type) { + case stanza.StreamClosePacket: + sc.connection.Write([]byte(stanza.StreamClose)) + return + } + } - checkOpenStream(t, c, decoder) // Reset stream - sendBindFeature(t, c, decoder) // Send post auth features - bind(t, c, decoder) } // We expect client will abort on TLS -func handlerAbortTLS(t *testing.T, c net.Conn) { - decoder := xml.NewDecoder(c) - checkOpenStream(t, c, decoder) - sendStreamFeatures(t, c, decoder) // Send initial features +func handlerAbortTLS(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + sendStreamFeatures(t, sc) // Send initial features } // Test connection with mandatory session (RFC-3921) -func handlerConnectWithSession(t *testing.T, c net.Conn) { - decoder := xml.NewDecoder(c) - checkOpenStream(t, c, decoder) +func handlerClientConnectWithSession(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) - sendStreamFeatures(t, c, decoder) // Send initial features - readAuth(t, decoder) - fmt.Fprintln(c, "") + sendStreamFeatures(t, sc) // Send initial features + readAuth(t, sc.decoder) + sc.connection.Write([]byte("")) - checkOpenStream(t, c, decoder) // Reset stream - sendRFC3921Feature(t, c, decoder) // Send post auth features - bind(t, c, decoder) - session(t, c, decoder) + checkClientOpenStream(t, sc) // Reset stream + sendRFC3921Feature(t, sc) // Send post auth features + bind(t, sc) + session(t, sc) } -func checkOpenStream(t *testing.T, c net.Conn, decoder *xml.Decoder) { - c.SetDeadline(time.Now().Add(defaultTimeout)) - defer c.SetDeadline(time.Time{}) +func checkClientOpenStream(t *testing.T, sc *ServerConn) { + err := sc.connection.SetDeadline(time.Now().Add(defaultTimeout)) + if err != nil { + t.Fatalf("failed to set deadline: %v", err) + } + defer sc.connection.SetDeadline(time.Time{}) for { // TODO clean up. That for loop is not elegant and I prefer bounded recursion. var token xml.Token - token, err := decoder.Token() + token, err := sc.decoder.Token() if err != nil { - t.Errorf("cannot read next token: %s", err) + t.Fatalf("cannot read next token: %s", err) } switch elem := token.(type) { @@ -194,113 +617,43 @@ func checkOpenStream(t *testing.T, c net.Conn, decoder *xml.Decoder) { err = errors.New("xmpp: expected but got <" + elem.Name.Local + "> in " + elem.Name.Space) return } - if _, err := fmt.Fprintf(c, serverStreamOpen, "localhost", "streamid1", stanza.NSClient, stanza.NSStream); err != nil { + if _, err := fmt.Fprintf(sc.connection, serverStreamOpen, "localhost", "streamid1", stanza.NSClient, stanza.NSStream); err != nil { t.Errorf("cannot write server stream open: %s", err) } return } + } } -func sendStreamFeatures(t *testing.T, c net.Conn, _ *xml.Decoder) { - // This is a basic server, supporting only 1 stream feature: SASL Plain Auth - features := ` - - PLAIN - -` - if _, err := fmt.Fprintln(c, features); err != nil { - t.Errorf("cannot send stream feature: %s", err) +func mockClientConnection(t *testing.T, serverHandler func(*testing.T, *ServerConn), port int) (*Client, *ServerMock) { + mock := &ServerMock{} + testServerAddress := fmt.Sprintf("%s:%d", testClientDomain, port) + + mock.Start(t, testServerAddress, serverHandler) + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testServerAddress, + }, + Jid: "test@localhost", + Credential: Password("test"), + Insecure: true} + + var client *Client + var err error + router := NewRouter() + if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil { + t.Errorf("connect create XMPP client: %s", err) } + + if err = client.Connect(); err != nil { + t.Errorf("XMPP connection failed: %s", err) + } + + return client, mock } -// TODO return err in case of error reading the auth params -func readAuth(t *testing.T, decoder *xml.Decoder) string { - se, err := stanza.NextStart(decoder) - if err != nil { - t.Errorf("cannot read auth: %s", err) - return "" - } - - var nv interface{} - nv = &stanza.SASLAuth{} - // Decode element into pointer storage - if err = decoder.DecodeElement(nv, &se); err != nil { - t.Errorf("cannot decode auth: %s", err) - return "" - } - - switch v := nv.(type) { - case *stanza.SASLAuth: - return v.Value - } - return "" -} - -func sendBindFeature(t *testing.T, c net.Conn, _ *xml.Decoder) { - // This is a basic server, supporting only 1 stream feature after auth: resource binding - features := ` - -` - if _, err := fmt.Fprintln(c, features); err != nil { - t.Errorf("cannot send stream feature: %s", err) - } -} - -func sendRFC3921Feature(t *testing.T, c net.Conn, _ *xml.Decoder) { - // This is a basic server, supporting only 2 features after auth: resource & session binding - features := ` - - -` - if _, err := fmt.Fprintln(c, features); err != nil { - t.Errorf("cannot send stream feature: %s", err) - } -} - -func bind(t *testing.T, c net.Conn, decoder *xml.Decoder) { - se, err := stanza.NextStart(decoder) - if err != nil { - t.Errorf("cannot read bind: %s", err) - return - } - - iq := &stanza.IQ{} - // Decode element into pointer storage - if err = decoder.DecodeElement(&iq, &se); err != nil { - t.Errorf("cannot decode bind iq: %s", err) - return - } - - // TODO Check all elements - switch iq.Payload.(type) { - case *stanza.Bind: - result := ` - - %s - -` - fmt.Fprintf(c, result, iq.Id, "test@localhost/test") // TODO use real JID - } -} - -func session(t *testing.T, c net.Conn, decoder *xml.Decoder) { - se, err := stanza.NextStart(decoder) - if err != nil { - t.Errorf("cannot read session: %s", err) - return - } - - iq := &stanza.IQ{} - // Decode element into pointer storage - if err = decoder.DecodeElement(&iq, &se); err != nil { - t.Errorf("cannot decode session iq: %s", err) - return - } - - switch iq.Payload.(type) { - case *stanza.StreamSession: - result := `` - fmt.Fprintf(c, result, iq.Id) - } +// This really should not be used as is. +// It's just meant to be a placeholder when error handling is not needed at this level +func clientDefaultErrorHandler(err error) { } diff --git a/cmd/fluuxmpp/send.go b/cmd/fluuxmpp/send.go index 0942928..27c1a67 100644 --- a/cmd/fluuxmpp/send.go +++ b/cmd/fluuxmpp/send.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "gosrc.io/xmpp/stanza" "os" "strings" "sync" @@ -31,13 +32,17 @@ func sendxmpp(cmd *cobra.Command, args []string) { msgText := args[1] var err error - client, err := xmpp.NewClient(xmpp.Config{ + client, err := xmpp.NewClient(&xmpp.Config{ TransportConfiguration: xmpp.TransportConfiguration{ Address: viper.GetString("addr"), }, Jid: viper.GetString("jid"), Credential: xmpp.Password(viper.GetString("password")), - }, xmpp.NewRouter()) + }, + xmpp.NewRouter(), + func(err error) { + log.Println(err) + }) if err != nil { log.Errorf("error when starting xmpp client: %s", err) @@ -48,7 +53,7 @@ func sendxmpp(cmd *cobra.Command, args []string) { wg.Add(1) // FIXME: Remove global variables - var mucsToLeave []*xmpp.Jid + var mucsToLeave []*stanza.Jid cm := xmpp.NewStreamManager(client, func(c xmpp.Sender) { defer wg.Done() @@ -57,7 +62,7 @@ func sendxmpp(cmd *cobra.Command, args []string) { if isMUCRecipient { for _, muc := range receiver { - jid, err := xmpp.NewJid(muc) + jid, err := stanza.NewJid(muc) if err != nil { log.WithField("muc", muc).Errorf("skipping invalid muc jid: %w", err) continue diff --git a/cmd/fluuxmpp/xmppmuc.go b/cmd/fluuxmpp/xmppmuc.go index 984b014..a00fdfc 100644 --- a/cmd/fluuxmpp/xmppmuc.go +++ b/cmd/fluuxmpp/xmppmuc.go @@ -7,7 +7,7 @@ import ( "gosrc.io/xmpp/stanza" ) -func joinMUC(c xmpp.Sender, toJID *xmpp.Jid) error { +func joinMUC(c xmpp.Sender, toJID *stanza.Jid) error { return c.Send(stanza.Presence{Attrs: stanza.Attrs{To: toJID.Full()}, Extensions: []stanza.PresExtension{ stanza.MucPresence{ @@ -16,7 +16,7 @@ func joinMUC(c xmpp.Sender, toJID *xmpp.Jid) error { }) } -func leaveMUCs(c xmpp.Sender, mucsToLeave []*xmpp.Jid) { +func leaveMUCs(c xmpp.Sender, mucsToLeave []*stanza.Jid) { for _, muc := range mucsToLeave { if err := c.Send(stanza.Presence{Attrs: stanza.Attrs{ To: muc.Full(), diff --git a/cmd/go.mod b/cmd/go.mod index 85df002..1c4684f 100644 --- a/cmd/go.mod +++ b/cmd/go.mod @@ -6,7 +6,7 @@ require ( github.com/bdlm/log v0.1.19 github.com/bdlm/std v0.0.0-20180922040903-fd3b596111c7 github.com/spf13/cobra v0.0.5 - github.com/spf13/viper v1.4.0 + github.com/spf13/viper v1.6.1 gosrc.io/xmpp v0.1.1 ) diff --git a/cmd/go.sum b/cmd/go.sum index 258caa5..8398605 100644 --- a/cmd/go.sum +++ b/cmd/go.sum @@ -6,6 +6,8 @@ github.com/agnivade/wasmbrowsertest v0.3.1/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2e github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/awesome-gocui/gocui v0.6.0/go.mod h1:1QikxFaPhe2frKeKvEwZEIGia3haiOxOUXKinrv17mA= +github.com/awesome-gocui/termbox-go v0.0.0-20190427202837-c0aef3d18bcc/go.mod h1:tOy3o5Nf1bA17mnK4W41gD7PS3u4Cv0P0pqFcoWMy8s= github.com/bdlm/log v0.1.19 h1:GqVFZC+khJCEbtTmkaDL/araNDwxTeLBmdMK8pbRoBE= github.com/bdlm/log v0.1.19/go.mod h1:30V5Zwc5Vt5ePq5rd9KJ6JQ/A5aFUcKzq5fYtO7c9qc= github.com/bdlm/std v0.0.0-20180922040903-fd3b596111c7 h1:ggZyn+N8eoBh/qLla2kUtqm/ysjnkbzUxTQY+6LMshY= @@ -38,6 +40,7 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-interpreter/wagon v0.5.1-0.20190713202023-55a163980b6c/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc= github.com/go-interpreter/wagon v0.6.0/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -62,7 +65,9 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= @@ -73,6 +78,7 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -87,6 +93,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -97,6 +105,7 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= @@ -126,6 +135,8 @@ github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= @@ -139,16 +150,21 @@ github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb6 github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.6.1 h1:VPZzIkznI1YhVMRi6vNFLHSwhnhReBfgTxIPccpfdZk= +github.com/spf13/viper v1.6.1/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= @@ -196,6 +212,7 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190618155005-516e3c20635f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190927073244-c990c680b611 h1:q9u40nxWT5zRClI/uU9dHCiYGottAg6Nzz4YUQyHxdA= golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -203,6 +220,7 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522 h1:bhOzK9QyoD0ogCnFro1m2mz41+Ib0oOhfJnBp5MR4K4= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -219,14 +237,19 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8= +nhooyr.io/websocket v1.6.5 h1:8TzpkldRfefda5JST+CnOH135bzVPz5uzfn/AF+gVKg= nhooyr.io/websocket v1.6.5/go.mod h1:F259lAzPRAH0htX2y3ehpJe09ih1aSHN7udWki1defY= diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index db24720..0000000 --- a/codecov.yml +++ /dev/null @@ -1 +0,0 @@ -comment: off diff --git a/codeship-services.yml b/codeship-services.yml deleted file mode 100644 index bbb476e..0000000 --- a/codeship-services.yml +++ /dev/null @@ -1,5 +0,0 @@ -build: - build: - image: fluux/build - dockerfile: Dockerfile - encrypted_env_file: codeship.env.encrypted diff --git a/codeship-steps.yml b/codeship-steps.yml deleted file mode 100644 index 5528b3d..0000000 --- a/codeship-steps.yml +++ /dev/null @@ -1,5 +0,0 @@ -- type: serial - steps: - - name: test - service: build - command: ./test.sh diff --git a/codeship.env.encrypted b/codeship.env.encrypted deleted file mode 100644 index 5c92b24..0000000 --- a/codeship.env.encrypted +++ /dev/null @@ -1 +0,0 @@ -yVKgVFeKW6SSnC/KgLYpfYtTcqqTke1gOIW5GUiVvRijnhweOJiYKFPmwPjpt1FVrg4WVELQUNbxn3lmfyHVVF7r \ No newline at end of file diff --git a/component.go b/component.go index d459c00..ec0e8df 100644 --- a/component.go +++ b/component.go @@ -7,9 +7,8 @@ import ( "encoding/xml" "errors" "fmt" - "io" - "gosrc.io/xmpp/stanza" + "io" ) type ComponentOptions struct { @@ -49,22 +48,22 @@ type Component struct { transport Transport // read / write - socketProxy io.ReadWriter // TODO - decoder *xml.Decoder + socketProxy io.ReadWriter // TODO + ErrorHandler func(error) } -func NewComponent(opts ComponentOptions, r *Router) (*Component, error) { - c := Component{ComponentOptions: opts, router: r} +func NewComponent(opts ComponentOptions, r *Router, errorHandler func(error)) (*Component, error) { + c := Component{ComponentOptions: opts, router: r, ErrorHandler: errorHandler} return &c, nil } // Connect triggers component connection to XMPP server component port. // TODO: Failed handshake should be a permanent error func (c *Component) Connect() error { - var state SMState - return c.Resume(state) + return c.Resume() } -func (c *Component) Resume(sm SMState) error { + +func (c *Component) Resume() error { var err error var streamId string if c.ComponentOptions.TransportConfiguration.Domain == "" { @@ -72,26 +71,26 @@ func (c *Component) Resume(sm SMState) error { } c.transport, err = NewComponentTransport(c.ComponentOptions.TransportConfiguration) if err != nil { - c.updateState(StateStreamError) - return err + c.updateState(StatePermanentError) + return NewConnError(err, true) } if streamId, err = c.transport.Connect(); err != nil { - c.updateState(StateStreamError) - return err + c.updateState(StatePermanentError) + return NewConnError(err, true) } - c.updateState(StateConnected) // Authentication - if _, err := fmt.Fprintf(c.transport, "%s", c.handshake(streamId)); err != nil { + if err := c.sendWithWriter(c.transport, []byte(fmt.Sprintf("%s", c.handshake(streamId)))); err != nil { c.updateState(StateStreamError) + return NewConnError(errors.New("cannot send handshake "+err.Error()), false) } // Check server response for authentication - val, err := stanza.NextPacket(c.decoder) + val, err := stanza.NextPacket(c.transport.GetDecoder()) if err != nil { - c.updateState(StateDisconnected) + c.updateState(StatePermanentError) return NewConnError(err, true) } @@ -103,18 +102,20 @@ func (c *Component) Resume(sm SMState) error { // Start the receiver go routine c.updateState(StateSessionEstablished) go c.recv() - return nil + return err // Should be empty at this point default: - c.updateState(StateStreamError) + c.updateState(StatePermanentError) return NewConnError(errors.New("expecting handshake result, got "+v.Name()), true) } } -func (c *Component) Disconnect() { +func (c *Component) Disconnect() error { // TODO: Add a way to wait for stream close acknowledgement from the server for clean disconnect if c.transport != nil { - _ = c.transport.Close() + return c.transport.Close() } + // No transport so no connection. + return nil } func (c *Component) SetHandler(handler EventHandler) { @@ -122,20 +123,26 @@ func (c *Component) SetHandler(handler EventHandler) { } // Receiver Go routine receiver -func (c *Component) recv() (err error) { +func (c *Component) recv() { for { - val, err := stanza.NextPacket(c.decoder) + val, err := stanza.NextPacket(c.transport.GetDecoder()) if err != nil { c.updateState(StateDisconnected) - return err + c.ErrorHandler(err) + return } - // Handle stream errors switch p := val.(type) { case stanza.StreamError: c.router.route(c, val) c.streamError(p.Error.Local, p.Text) - return errors.New("stream error: " + p.Error.Local) + c.ErrorHandler(errors.New("stream error: " + p.Error.Local)) + // We don't return here, because we want to wait for the stream close tag from the server, or timeout. + c.Disconnect() + case stanza.StreamClosePacket: + // TCP messages should arrive in order, so we can expect to get nothing more after this occurs + c.transport.ReceivedStreamClose() + return } c.router.route(c, val) } @@ -153,12 +160,18 @@ func (c *Component) Send(packet stanza.Packet) error { return errors.New("cannot marshal packet " + err.Error()) } - if _, err := fmt.Fprintf(transport, string(data)); err != nil { + if err := c.sendWithWriter(transport, data); err != nil { return errors.New("cannot send packet " + err.Error()) } return nil } +func (c *Component) sendWithWriter(writer io.Writer, packet []byte) error { + var err error + _, err = writer.Write(packet) + return err +} + // SendIQ sends an IQ set or get stanza to the server. If a result is received // the provided handler function will automatically be called. // @@ -168,8 +181,8 @@ func (c *Component) Send(packet stanza.Packet) error { // ctx, _ := context.WithTimeout(context.Background(), 30 * time.Second) // result := <- client.SendIQ(ctx, iq) // -func (c *Component) SendIQ(ctx context.Context, iq stanza.IQ) (chan stanza.IQ, error) { - if iq.Attrs.Type != "set" && iq.Attrs.Type != "get" { +func (c *Component) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { + if iq.Attrs.Type != stanza.IQTypeSet && iq.Attrs.Type != stanza.IQTypeGet { return nil, ErrCanOnlySendGetOrSetIq } if err := c.Send(iq); err != nil { @@ -189,7 +202,7 @@ func (c *Component) SendRaw(packet string) error { } var err error - _, err = fmt.Fprintf(transport, packet) + err = c.sendWithWriter(transport, []byte(packet)) return err } diff --git a/component_test.go b/component_test.go index 5fbe45b..59ac08e 100644 --- a/component_test.go +++ b/component_test.go @@ -1,7 +1,22 @@ package xmpp import ( + "context" + "encoding/xml" + "errors" + "fmt" + "strings" "testing" + "time" + + "github.com/google/uuid" + "gosrc.io/xmpp/stanza" +) + +// Tests are ran in parallel, so each test creating a server must use a different port so we do not get any +// conflict. Using iota for this should do the trick. +const ( + defaultChannelTimeout = 5 * time.Second ) func TestHandshake(t *testing.T) { @@ -20,8 +35,103 @@ func TestHandshake(t *testing.T) { } } -func TestGenerateHandshake(t *testing.T) { - // TODO +// Tests connection process with a handshake exchange +// Tests multiple session IDs. All serverConnections should generate a unique stream ID +func TestGenerateHandshakeId(t *testing.T) { + clientDone := make(chan struct{}) + serverDone := make(chan struct{}) + // Using this array with a channel to make a queue of values to test + // These are stream IDs that will be used to test the connection process, mixing them with the "secret" to generate + // some handshake value + var uuidsArray = [5]string{} + for i := 1; i < len(uuidsArray); i++ { + id, _ := uuid.NewRandom() + uuidsArray[i] = id.String() + } + + // Channel to pass stream IDs as a queue + var uchan = make(chan string, len(uuidsArray)) + // Populate test channel + for _, elt := range uuidsArray { + uchan <- elt + } + + // Performs a Component connection with a handshake. It expects to have an ID sent its way through the "uchan" + // channel of this file. Otherwise it will hang for ever. + h := func(t *testing.T, sc *ServerConn) { + checkOpenStreamHandshakeID(t, sc, <-uchan) + readHandshakeComponent(t, sc.decoder) + sc.connection.Write([]byte("")) // That's all the server needs to return (see xep-0114) + serverDone <- struct{}{} + } + + // Init mock server + testComponentAddess := fmt.Sprintf("%s:%d", testComponentDomain, testHandshakePort) + mock := ServerMock{} + mock.Start(t, testComponentAddess, h) + + // Init component + opts := ComponentOptions{ + TransportConfiguration: TransportConfiguration{ + Address: testComponentAddess, + Domain: "localhost", + }, + Domain: testComponentDomain, + Secret: "mypass", + Name: "Test Component", + Category: "gateway", + Type: "service", + } + router := NewRouter() + c, err := NewComponent(opts, router, componentDefaultErrorHandler) + if err != nil { + t.Errorf("%+v", err) + } + c.transport, err = NewComponentTransport(c.ComponentOptions.TransportConfiguration) + if err != nil { + t.Errorf("%+v", err) + } + + // Try connecting, and storing the resulting streamID in a map. + go func() { + m := make(map[string]bool) + for range uuidsArray { + idChan := make(chan string) + go func() { + streamId, err := c.transport.Connect() + if err != nil { + t.Fatalf("failed to mock component connection to get a handshake: %s", err) + } + idChan <- streamId + }() + + var streamId string + select { + case streamId = <-idChan: + case <-time.After(defaultTimeout): + t.Fatalf("test timed out") + } + + hs := stanza.Handshake{ + Value: c.handshake(streamId), + } + m[hs.Value] = true + hsRaw, err := xml.Marshal(hs) + if err != nil { + t.Fatalf("could not marshal handshake: %s", err) + } + c.SendRaw(string(hsRaw)) + waitForEntity(t, serverDone) + c.transport.Close() + } + if len(uuidsArray) != len(m) { + t.Errorf("Handshake does not produce a unique id. Expected: %d unique ids, got: %d", len(uuidsArray), len(m)) + } + clientDone <- struct{}{} + }() + + waitForEntity(t, clientDone) + mock.Stop() } // Test that NewStreamManager can accept a Component. @@ -30,3 +140,373 @@ func TestGenerateHandshake(t *testing.T) { func TestStreamManager(t *testing.T) { NewStreamManager(&Component{}, nil) } + +// Tests that the decoder is properly initialized when connecting a component to a server. +// The decoder is expected to be built after a valid connection +// Based on the xmpp_component example. +func TestDecoder(t *testing.T) { + c, _ := mockComponentConnection(t, testDecoderPort, handlerForComponentHandshakeDefaultID) + if c.transport.GetDecoder() == nil { + t.Errorf("Failed to initialize decoder. Decoder is nil.") + } +} + +// Tests sending an IQ to the server, and getting the response +func TestSendIq(t *testing.T) { + serverDone := make(chan struct{}) + clientDone := make(chan struct{}) + h := func(t *testing.T, sc *ServerConn) { + handlerForComponentIQSend(t, sc) + serverDone <- struct{}{} + } + + //Connecting to a mock server, initialized with given port and handler function + c, m := mockComponentConnection(t, testSendIqPort, h) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "test1@localhost/mremond-mbp", To: defaultServerName, Id: defaultStreamID, Lang: "en"}) + if err != nil { + t.Fatalf("failed to create IQ request: %v", err) + } + disco := iqReq.DiscoInfo() + iqReq.Payload = disco + + // Handle a possible error + errChan := make(chan error) + errorHandler := func(err error) { + errChan <- err + } + c.ErrorHandler = errorHandler + + go func() { + var res chan stanza.IQ + res, _ = c.SendIQ(ctx, iqReq) + + select { + case <-res: + case err := <-errChan: + t.Fatalf(err.Error()) + } + clientDone <- struct{}{} + }() + + waitForEntity(t, clientDone) + waitForEntity(t, serverDone) + + cancel() + m.Stop() +} + +// Checking that error handling is done properly client side when an invalid IQ is sent and the server responds in kind. +func TestSendIqFail(t *testing.T) { + done := make(chan struct{}) + h := func(t *testing.T, sc *ServerConn) { + handlerForComponentIQSend(t, sc) + done <- struct{}{} + } + //Connecting to a mock server, initialized with given port and handler function + c, m := mockComponentConnection(t, testSendIqFailPort, h) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "test1@localhost/mremond-mbp", To: defaultServerName, Id: defaultStreamID, Lang: "en"}) + if err != nil { + t.Fatalf("failed to create IQ request: %v", err) + } + + // Removing the id to make the stanza invalid. The IQ constructor makes a random one if none is specified + // so we need to overwrite it. + iqReq.Id = "" + disco := iqReq.DiscoInfo() + iqReq.Payload = disco + + errChan := make(chan error) + errorHandler := func(err error) { + errChan <- err + } + c.ErrorHandler = errorHandler + + var res chan stanza.IQ + res, _ = c.SendIQ(ctx, iqReq) + + select { + case r := <-res: // Do we get an IQ response from the server ? + t.Errorf("We should not be getting an IQ response here : this should fail !") + fmt.Println(r) + case <-errChan: // Do we get a stream error from the server ? + // If we get an error from the server, the test passes. + case <-time.After(defaultChannelTimeout): // Timeout ? + t.Errorf("Failed to receive response, to sent IQ, from mock server") + } + + select { + case <-done: + m.Stop() + case <-time.After(defaultChannelTimeout): + t.Errorf("The mock server failed to finish its job !") + } + cancel() +} + +// Tests sending raw xml to the mock server. +// Right now, the server response is not checked and an err is passed in a channel if the test is supposed to err. +// In this test, we use IQs +func TestSendRaw(t *testing.T) { + done := make(chan struct{}) + // Handler for the mock server + h := func(t *testing.T, sc *ServerConn) { + // Completes the connection by exchanging handshakes + handlerForComponentHandshakeDefaultID(t, sc) + respondToIQ(t, sc) + done <- struct{}{} + } + + type testCase struct { + req string + shouldErr bool + port int + } + testRequests := make(map[string]testCase) + // Sending a correct IQ of type get. Not supposed to err + testRequests["Correct IQ"] = testCase{ + req: ``, + shouldErr: false, + port: testSendRawPort + 100, + } + // Sending an IQ with a missing ID. Should err + testRequests["IQ with missing ID"] = testCase{ + req: ``, + shouldErr: true, + port: testSendRawPort + 200, + } + + // A handler for the component. + // In the failing test, the server returns a stream error, which triggers this handler, component side. + errChan := make(chan error) + errHandler := func(err error) { + errChan <- err + } + + // Tests for all the IQs + for name, tcase := range testRequests { + t.Run(name, func(st *testing.T) { + //Connecting to a mock server, initialized with given port and handler function + c, m := mockComponentConnection(t, tcase.port, h) + c.ErrorHandler = errHandler + // Sending raw xml from test case + err := c.SendRaw(tcase.req) + if err != nil { + t.Errorf("Error sending Raw string") + } + // Just wait a little so the message has time to arrive + select { + // We don't use the default "long" timeout here because waiting it out means passing the test. + case <-time.After(200 * time.Millisecond): + case err = <-errChan: + if err == nil && tcase.shouldErr { + t.Errorf("Failed to get closing stream err") + } else if err != nil && !tcase.shouldErr { + t.Errorf("This test is not supposed to err ! => %s", err.Error()) + } + } + c.transport.Close() + select { + case <-done: + m.Stop() + case <-time.After(defaultChannelTimeout): + t.Errorf("The mock server failed to finish its job !") + } + }) + } +} + +// Tests the Disconnect method for Components +func TestDisconnect(t *testing.T) { + c, m := mockComponentConnection(t, testDisconnectPort, handlerForComponentHandshakeDefaultID) + err := c.transport.Ping() + if err != nil { + t.Errorf("Could not ping but not disconnected yet") + } + c.Disconnect() + err = c.transport.Ping() + if err == nil { + t.Errorf("Did not disconnect properly") + } + m.Stop() +} + +// Tests that a streamManager successfully disconnects when a handshake fails between the component and the server. +func TestStreamManagerDisconnect(t *testing.T) { + // Init mock server + testComponentAddress := fmt.Sprintf("%s:%d", testComponentDomain, testSManDisconnectPort) + mock := ServerMock{} + // Handler fails the handshake, which is currently the only option to disconnect completely when using a streamManager + // a failed handshake being a permanent error, except for a "conflict" + mock.Start(t, testComponentAddress, handlerComponentFailedHandshakeDefaultID) + + //================================== + // Create Component to connect to it + c := makeBasicComponent(defaultComponentName, testComponentAddress, t) + + //======================================== + // Connect the new Component to the server + cm := NewStreamManager(c, nil) + errChan := make(chan error) + runSMan := func(errChan chan error) { + errChan <- cm.Run() + } + + go runSMan(errChan) + select { + case <-errChan: + case <-time.After(100 * time.Millisecond): + t.Errorf("The component and server seem to still be connected while they should not.") + } + mock.Stop() +} + +//============================================================================= +// Basic XMPP Server Mock Handlers. + +//=============================== +// Init mock server and connection +// Creating a mock server and connecting a Component to it. Initialized with given port and handler function +// The Component and mock are both returned +func mockComponentConnection(t *testing.T, port int, handler func(t *testing.T, sc *ServerConn)) (*Component, *ServerMock) { + // Init mock server + testComponentAddress := fmt.Sprintf("%s:%d", testComponentDomain, port) + mock := &ServerMock{} + mock.Start(t, testComponentAddress, handler) + + //================================== + // Create Component to connect to it + c := makeBasicComponent(defaultComponentName, testComponentAddress, t) + + //======================================== + // Connect the new Component to the server + err := c.Connect() + if err != nil { + t.Errorf("%+v", err) + } + + // Now that the Component is connected, let's set the xml.Decoder for the server + + return c, mock +} + +func makeBasicComponent(name string, mockServerAddr string, t *testing.T) *Component { + opts := ComponentOptions{ + TransportConfiguration: TransportConfiguration{ + Address: mockServerAddr, + Domain: "localhost", + }, + Domain: testComponentDomain, + Secret: "mypass", + Name: name, + Category: "gateway", + Type: "service", + } + router := NewRouter() + c, err := NewComponent(opts, router, componentDefaultErrorHandler) + if err != nil { + t.Errorf("%+v", err) + } + c.transport, err = NewComponentTransport(c.ComponentOptions.TransportConfiguration) + if err != nil { + t.Errorf("%+v", err) + } + return c +} + +// This really should not be used as is. +// It's just meant to be a placeholder when error handling is not needed at this level +func componentDefaultErrorHandler(err error) { + +} + +// Sends IQ response to Component request. +// No parsing of the request here. We just check that it's valid, and send the default response. +func handlerForComponentIQSend(t *testing.T, sc *ServerConn) { + // Completes the connection by exchanging handshakes + handlerForComponentHandshakeDefaultID(t, sc) + respondToIQ(t, sc) +} + +// Used for ID and handshake related tests +func checkOpenStreamHandshakeID(t *testing.T, sc *ServerConn, streamID string) { + err := sc.connection.SetDeadline(time.Now().Add(defaultTimeout)) + if err != nil { + t.Fatalf("failed to set deadline: %v", err) + } + defer sc.connection.SetDeadline(time.Time{}) + + for { // TODO clean up. That for loop is not elegant and I prefer bounded recursion. + token, err := sc.decoder.Token() + if err != nil { + t.Errorf("cannot read next token: %s", err) + } + + switch elem := token.(type) { + // Wait for first startElement + case xml.StartElement: + if elem.Name.Space != stanza.NSStream || elem.Name.Local != "stream" { + err = errors.New("xmpp: expected but got <" + elem.Name.Local + "> in " + elem.Name.Space) + return + } + if _, err := fmt.Fprintf(sc.connection, serverStreamOpen, "localhost", streamID, stanza.NSComponent, stanza.NSStream); err != nil { + t.Errorf("cannot write server stream open: %s", err) + } + return + } + } +} + +func checkOpenStreamHandshakeDefaultID(t *testing.T, sc *ServerConn) { + checkOpenStreamHandshakeID(t, sc, defaultStreamID) +} + +// Performs a Component connection with a handshake. It uses a default ID defined in this file as a constant. +// This handler is supposed to fail by sending a "message" stanza instead of a stanza to finalize the handshake. +func handlerComponentFailedHandshakeDefaultID(t *testing.T, sc *ServerConn) { + checkOpenStreamHandshakeDefaultID(t, sc) + readHandshakeComponent(t, sc.decoder) + + // Send a message, instead of a "" tag, to fail the handshake process dans disconnect the client. + me := stanza.Message{ + Attrs: stanza.Attrs{Type: stanza.MessageTypeChat, From: defaultServerName, To: defaultComponentName, Lang: "en"}, + Body: "Fail my handshake.", + } + s, _ := xml.Marshal(me) + _, err := sc.connection.Write(s) + if err != nil { + t.Fatalf("could not write message: %v", err) + } + + return +} + +// Reads from the connection with the Component. Expects a handshake request, and returns the tag. +func readHandshakeComponent(t *testing.T, decoder *xml.Decoder) { + se, err := stanza.NextStart(decoder) + if err != nil { + t.Errorf("cannot read auth: %s", err) + return + } + nv := &stanza.Handshake{} + // Decode element into pointer storage + if err = decoder.DecodeElement(nv, &se); err != nil { + t.Errorf("cannot decode handshake: %s", err) + return + } + if len(strings.TrimSpace(nv.Value)) == 0 { + t.Errorf("did not receive handshake ID") + } +} + +// Performs a Component connection with a handshake. It uses a default ID defined in this file as a constant. +// Used in the mock server as a Handler +func handlerForComponentHandshakeDefaultID(t *testing.T, sc *ServerConn) { + checkOpenStreamHandshakeDefaultID(t, sc) + readHandshakeComponent(t, sc.decoder) + sc.connection.Write([]byte("")) // That's all the server needs to return (see xep-0114) + return +} diff --git a/config.go b/config.go index e3ea108..4609a0a 100644 --- a/config.go +++ b/config.go @@ -1,7 +1,9 @@ package xmpp import ( + "gosrc.io/xmpp/stanza" "os" + "time" ) // Config & TransportConfiguration must not be modified after having been passed to NewClient. Any @@ -9,13 +11,25 @@ import ( type Config struct { TransportConfiguration - Jid string - parsedJid *Jid // For easier manipulation - Credential Credential - StreamLogger *os.File // Used for debugging - Lang string // TODO: should default to 'en' - ConnectTimeout int // Client timeout in seconds. Default to 15 + Jid string + parsedJid *stanza.Jid // For easier manipulation + Credential Credential + StreamLogger *os.File // Used for debugging + Lang string // TODO: should default to 'en' + KeepaliveInterval time.Duration // Interval between keepalive packets + ConnectTimeout int // Client timeout in seconds. Default to 15 // Insecure can be set to true to allow to open a session without TLS. If TLS // is supported on the server, we will still try to use it. Insecure bool + + // Activate stream management process during session + StreamManagementEnable bool + // Enable stream management resume capability + streamManagementResume bool +} + +// IsStreamResumable tells if a stream session is resumable by reading the "config" part of a client. +// It checks if stream management is enabled, and if stream resumption was set and accepted by the server. +func IsStreamResumable(c *Client) bool { + return c.config.StreamManagementEnable && c.config.streamManagementResume } diff --git a/doc.go b/doc.go index 40f4f6a..f29bbf6 100644 --- a/doc.go +++ b/doc.go @@ -29,7 +29,7 @@ Components XMPP components can typically be used to extends the features of an XMPP server, in a portable way, using component protocol over persistent TCP -connections. +serverConnections. Component protocol is defined in XEP-114 (https://xmpp.org/extensions/xep-0114.html). diff --git a/go.sum b/go.sum index ae38d07..e51d671 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,55 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/agnivade/wasmbrowsertest v0.3.1/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2eQm0SjC/SDz0mPWQI= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/awesome-gocui/gocui v0.6.0/go.mod h1:1QikxFaPhe2frKeKvEwZEIGia3haiOxOUXKinrv17mA= +github.com/awesome-gocui/termbox-go v0.0.0-20190427202837-c0aef3d18bcc/go.mod h1:tOy3o5Nf1bA17mnK4W41gD7PS3u4Cv0P0pqFcoWMy8s= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/chromedp/cdproto v0.0.0-20190614062957-d6d2f92b486d/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0= github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0= github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM= github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-interpreter/wagon v0.5.1-0.20190713202023-55a163980b6c/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc= github.com/go-interpreter/wagon v0.6.0/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -27,14 +59,26 @@ github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OI github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -45,36 +89,83 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.6.1/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190306220234-b354f8bf4d9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -85,22 +176,35 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522 h1:bhOzK9QyoD0ogCnFro1m2mz41+Ib0oOhfJnBp5MR4K4= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8= nhooyr.io/websocket v1.6.5 h1:8TzpkldRfefda5JST+CnOH135bzVPz5uzfn/AF+gVKg= nhooyr.io/websocket v1.6.5/go.mod h1:F259lAzPRAH0htX2y3ehpJe09ih1aSHN7udWki1defY= diff --git a/network.go b/network.go index 75a0a60..8b03f3f 100644 --- a/network.go +++ b/network.go @@ -23,7 +23,7 @@ func ensurePort(addr string, port int) string { // This is IPV4 without port return addr + ":" + strconv.Itoa(port) case 1: - // This is IPV$ with port + // This is IPV6 with port return addr default: // This is IPV6 without port, as you need to use bracket with port in IPV6 diff --git a/network_test.go b/network_test.go index 116ecef..470f150 100644 --- a/network_test.go +++ b/network_test.go @@ -1,12 +1,10 @@ package xmpp import ( + "strings" "testing" ) -type params struct { -} - func TestParseAddr(t *testing.T) { tests := []struct { name string @@ -33,3 +31,36 @@ func TestParseAddr(t *testing.T) { }) } } + +func TestEnsurePort(t *testing.T) { + testAddresses := []string{ + "1ca3:6c07:ee3a:89ca:e065:9a70:71d:daad", + "1ca3:6c07:ee3a:89ca:e065:9a70:71d:daad:5252", + "[::1]", + "127.0.0.1:5555", + "127.0.0.1", + "[::1]:5555", + } + + for _, oldAddr := range testAddresses { + t.Run(oldAddr, func(st *testing.T) { + newAddr := ensurePort(oldAddr, 5222) + + if len(newAddr) < len(oldAddr) { + st.Errorf("incorrect Result: transformed address is shorter than input : %v (old) > %v (new)", newAddr, oldAddr) + } + // If IPv6, the new address needs brackets to specify a port, like so : [2001:db8:85a3:0:0:8a2e:370:7334]:5222 + if strings.Count(newAddr, "[") < strings.Count(oldAddr, "[") || + strings.Count(newAddr, "]") < strings.Count(oldAddr, "]") { + + st.Errorf("incorrect Result. Transformed address seems to not have correct brakets : %v => %v", oldAddr, newAddr) + } + + // Check if we messed up the colons, or didn't properly add a port + if strings.Count(newAddr, ":") < strings.Count(oldAddr, ":") { + st.Errorf("incorrect Result: transformed address doesn't seem to have a port %v (=> %v, no port ?)", oldAddr, newAddr) + } + }) + } + +} diff --git a/router.go b/router.go index 23a134e..7bba8b9 100644 --- a/router.go +++ b/router.go @@ -42,7 +42,18 @@ func NewRouter() *Router { // route is called by the XMPP client to dispatch stanza received using the set up routes. // It is also used by test, but is not supposed to be used directly by users of the library. func (r *Router) route(s Sender, p stanza.Packet) { - iq, isIq := p.(stanza.IQ) + a, isA := p.(stanza.SMAnswer) + if isA { + switch tt := s.(type) { + case *Client: + lastAcked := a.H + SendMissingStz(int(lastAcked), s, tt.Session.SMState.UnAckQueue) + case *Component: + // TODO + default: + } + } + iq, isIq := p.(*stanza.IQ) if isIq { r.IQResultRouteLock.RLock() route, ok := r.IQResultRoutes[iq.Id] @@ -51,7 +62,7 @@ func (r *Router) route(s Sender, p stanza.Packet) { r.IQResultRouteLock.Lock() delete(r.IQResultRoutes, iq.Id) r.IQResultRouteLock.Unlock() - route.result <- iq + route.result <- *iq close(route.result) return } @@ -70,7 +81,34 @@ func (r *Router) route(s Sender, p stanza.Packet) { } } -func iqNotImplemented(s Sender, iq stanza.IQ) { +// SendMissingStz sends all stanzas that did not reach the server, according to the response to an ack request (see XEP-0198, acks) +func SendMissingStz(lastSent int, s Sender, uaq *stanza.UnAckQueue) error { + uaq.RWMutex.Lock() + if len(uaq.Uslice) <= 0 { + uaq.RWMutex.Unlock() + return nil + } + last := uaq.Uslice[len(uaq.Uslice)-1] + if last.Id > lastSent { + // Remove sent stanzas from the queue + uaq.PopN(lastSent - last.Id) + // Re-send non acknowledged stanzas + for _, elt := range uaq.PopN(len(uaq.Uslice)) { + eltStz := elt.(*stanza.UnAckedStz) + err := s.SendRaw(eltStz.Stz) + if err != nil { + return err + } + + } + // Ask for updates on stanzas we just sent to the entity. Not sure I should leave this. Maybe let users call ack again by themselves ? + s.Send(stanza.SMRequest{}) + } + uaq.RWMutex.Unlock() + return nil +} + +func iqNotImplemented(s Sender, iq *stanza.IQ) { err := stanza.Err{ XMLName: xml.Name{Local: "error"}, Code: 501, @@ -232,7 +270,7 @@ func (n nameMatcher) Match(p stanza.Packet, match *RouteMatch) bool { switch p.(type) { case stanza.Message: name = "message" - case stanza.IQ: + case *stanza.IQ: name = "iq" case stanza.Presence: name = "presence" @@ -259,7 +297,7 @@ type nsTypeMatcher []string func (m nsTypeMatcher) Match(p stanza.Packet, match *RouteMatch) bool { var stanzaType stanza.StanzaType switch packet := p.(type) { - case stanza.IQ: + case *stanza.IQ: stanzaType = packet.Type case stanza.Presence: stanzaType = packet.Type @@ -291,7 +329,7 @@ func (r *Route) StanzaType(types ...string) *Route { type nsIQMatcher []string func (m nsIQMatcher) Match(p stanza.Packet, match *RouteMatch) bool { - iq, ok := p.(stanza.IQ) + iq, ok := p.(*stanza.IQ) if !ok { return false } diff --git a/router_test.go b/router_test.go index b3d253e..677ad0e 100644 --- a/router_test.go +++ b/router_test.go @@ -25,7 +25,10 @@ func TestIQResultRoutes(t *testing.T) { // Check if the IQ handler was called ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100) defer cancel() - iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, Id: "1234"}) + iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, Id: "1234"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } res := router.NewIQResultRoute(ctx, "1234") go router.route(conn, iq) select { @@ -71,7 +74,10 @@ func TestNameMatcher(t *testing.T) { // Check that an IQ packet is not matched conn = NewSenderMock() - iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "localhost", Id: "1"}) + iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } iq.Payload = &stanza.DiscoInfo{} router.route(conn, iq) if conn.String() == successFlag { @@ -89,7 +95,10 @@ func TestIQNSMatcher(t *testing.T) { // Check that an IQ with proper namespace does match conn := NewSenderMock() - iqDisco := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "localhost", Id: "1"}) + iqDisco, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create iqDisco: %v", err) + } // TODO: Add a function to generate payload with proper namespace initialisation iqDisco.Payload = &stanza.DiscoInfo{ XMLName: xml.Name{ @@ -103,7 +112,10 @@ func TestIQNSMatcher(t *testing.T) { // Check that another namespace is not matched conn = NewSenderMock() - iqVersion := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "localhost", Id: "1"}) + iqVersion, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create iqVersion: %v", err) + } // TODO: Add a function to generate payload with proper namespace initialisation iqVersion.Payload = &stanza.DiscoInfo{ XMLName: xml.Name{ @@ -146,7 +158,10 @@ func TestTypeMatcher(t *testing.T) { // We do not match on other types conn = NewSenderMock() - iqVersion := stanza.NewIQ(stanza.Attrs{Type: "get", From: "service.localhost", To: "test@localhost", Id: "1"}) + iqVersion, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "service.localhost", To: "test@localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create iqVersion: %v", err) + } iqVersion.Payload = &stanza.DiscoInfo{ XMLName: xml.Name{ Space: "jabber:iq:version", @@ -163,28 +178,37 @@ func TestCompositeMatcher(t *testing.T) { router := NewRouter() router.NewRoute(). IQNamespaces("jabber:iq:version"). - StanzaType("get"). + StanzaType(string(stanza.IQTypeGet)). HandlerFunc(func(s Sender, p stanza.Packet) { _ = s.SendRaw(successFlag) }) // Data set - getVersionIq := stanza.NewIQ(stanza.Attrs{Type: "get", From: "service.localhost", To: "test@localhost", Id: "1"}) + getVersionIq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "service.localhost", To: "test@localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create getVersionIq: %v", err) + } getVersionIq.Payload = &stanza.Version{ XMLName: xml.Name{ Space: "jabber:iq:version", Local: "query", }} - setVersionIq := stanza.NewIQ(stanza.Attrs{Type: "set", From: "service.localhost", To: "test@localhost", Id: "1"}) + setVersionIq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeSet, From: "service.localhost", To: "test@localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create setVersionIq: %v", err) + } setVersionIq.Payload = &stanza.Version{ XMLName: xml.Name{ Space: "jabber:iq:version", Local: "query", }} - GetDiscoIq := stanza.NewIQ(stanza.Attrs{Type: "get", From: "service.localhost", To: "test@localhost", Id: "1"}) - GetDiscoIq.Payload = &stanza.DiscoInfo{ + getDiscoIq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "service.localhost", To: "test@localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create getDiscoIq: %v", err) + } + getDiscoIq.Payload = &stanza.DiscoInfo{ XMLName: xml.Name{ Space: "http://jabber.org/protocol/disco#info", Local: "query", @@ -200,7 +224,7 @@ func TestCompositeMatcher(t *testing.T) { }{ {name: "match get version iq", input: getVersionIq, want: true}, {name: "ignore set version iq", input: setVersionIq, want: false}, - {name: "ignore get discoinfo iq", input: GetDiscoIq, want: false}, + {name: "ignore get discoinfo iq", input: getDiscoIq, want: false}, {name: "ignore message", input: message, want: false}, } @@ -238,7 +262,10 @@ func TestCatchallMatcher(t *testing.T) { } conn = NewSenderMock() - iqVersion := stanza.NewIQ(stanza.Attrs{Type: "get", From: "service.localhost", To: "test@localhost", Id: "1"}) + iqVersion, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "service.localhost", To: "test@localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create iqVersion: %v", err) + } iqVersion.Payload = &stanza.DiscoInfo{ XMLName: xml.Name{ Space: "jabber:iq:version", @@ -274,7 +301,7 @@ func (s SenderMock) Send(packet stanza.Packet) error { return nil } -func (s SenderMock) SendIQ(ctx context.Context, iq stanza.IQ) (chan stanza.IQ, error) { +func (s SenderMock) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { out, err := xml.Marshal(iq) if err != nil { return nil, err diff --git a/session.go b/session.go index 22d76b2..05bdce3 100644 --- a/session.go +++ b/session.go @@ -1,10 +1,11 @@ package xmpp import ( + "encoding/xml" "errors" "fmt" - "gosrc.io/xmpp/stanza" + "strconv" ) type Session struct { @@ -23,44 +24,67 @@ type Session struct { err error } -func NewSession(transport Transport, o Config, state SMState) (*Session, error) { - s := new(Session) - s.transport = transport - s.SMState = state - s.init(o) +func NewSession(c *Client, state SMState) (*Session, error) { + var s *Session + if c.Session == nil { + s = new(Session) + s.transport = c.transport + s.SMState = state + s.init() + } else { + s = c.Session + // We keep information about the previously set session, like the session ID, but we read server provided + // info again in case it changed between session break and resume, such as features. + s.init() + } if s.err != nil { return nil, NewConnError(s.err, true) } - if !transport.IsSecure() { - s.startTlsIfSupported(o) + if !c.transport.IsSecure() { + s.startTlsIfSupported(c.config) } - if !transport.IsSecure() && !o.Insecure { + if !c.transport.IsSecure() && !c.config.Insecure { err := fmt.Errorf("failed to negotiate TLS session : %s", s.err) return nil, NewConnError(err, true) } if s.TlsEnabled { - s.reset(o) + s.reset() } // auth - s.auth(o) - s.reset(o) + s.auth(c.config) + if s.err != nil { + return s, s.err + } + s.reset() + if s.err != nil { + return s, s.err + } // attempt resumption - if s.resume(o) { + if s.resume(c.config) { return s, s.err } // otherwise, bind resource and 'start' XMPP session - s.bind(o) - s.rfc3921Session(o) + s.bind(c.config) + if s.err != nil { + return s, s.err + } + s.rfc3921Session() + if s.err != nil { + return s, s.err + } // Enable stream management if supported - s.EnableStreamManagement(o) + s.EnableStreamManagement(c.config) + if s.err != nil { + return s, s.err + } return s, s.err } @@ -70,19 +94,20 @@ func (s *Session) PacketId() string { return fmt.Sprintf("%x", s.lastPacketId) } -func (s *Session) init(o Config) { - s.Features = s.open(o.parsedJid.Domain) +// init gathers information on the session such as stream features from the server. +func (s *Session) init() { + s.Features = s.extractStreamFeatures() } -func (s *Session) reset(o Config) { +func (s *Session) reset() { if s.StreamId, s.err = s.transport.StartStream(); s.err != nil { return } - s.Features = s.open(o.parsedJid.Domain) + s.Features = s.extractStreamFeatures() } -func (s *Session) open(domain string) (f stanza.StreamFeatures) { +func (s *Session) extractStreamFeatures() (f stanza.StreamFeatures) { // extract stream features if s.err = s.transport.GetDecoder().Decode(&f); s.err != nil { s.err = errors.New("stream open decode features: " + s.err.Error()) @@ -90,14 +115,14 @@ func (s *Session) open(domain string) (f stanza.StreamFeatures) { return } -func (s *Session) startTlsIfSupported(o Config) { +func (s *Session) startTlsIfSupported(o *Config) { if s.err != nil { return } if !s.transport.DoesStartTLS() { if !o.Insecure { - s.err = errors.New("Transport does not support starttls") + s.err = errors.New("transport does not support starttls") } return } @@ -119,13 +144,13 @@ func (s *Session) startTlsIfSupported(o Config) { return } - // If we do not allow cleartext connections, make it explicit that server do not support starttls + // If we do not allow cleartext serverConnections, make it explicit that server do not support starttls if !o.Insecure { s.err = errors.New("XMPP server does not advertise support for starttls") } } -func (s *Session) auth(o Config) { +func (s *Session) auth(o *Config) { if s.err != nil { return } @@ -134,7 +159,7 @@ func (s *Session) auth(o Config) { } // Attempt to resume session using stream management -func (s *Session) resume(o Config) bool { +func (s *Session) resume(o *Config) bool { if !s.Features.DoesStreamManagement() { return false } @@ -142,9 +167,16 @@ func (s *Session) resume(o Config) bool { return false } - fmt.Fprintf(s.transport, "", - stanza.NSStreamManagement, s.SMState.Inbound, s.SMState.Id) + rsm := stanza.SMResume{ + PrevId: s.SMState.Id, + H: &s.SMState.Inbound, + } + data, err := xml.Marshal(rsm) + _, err = s.transport.Write(data) + if err != nil { + return false + } var packet stanza.Packet packet, s.err = stanza.NextPacket(s.transport.GetDecoder()) if s.err == nil { @@ -165,20 +197,48 @@ func (s *Session) resume(o Config) bool { return false } -func (s *Session) bind(o Config) { +func (s *Session) bind(o *Config) { if s.err != nil { return } // Send IQ message asking to bind to the local user name. var resource = o.parsedJid.Resource - if resource != "" { - fmt.Fprintf(s.transport, "%s", - s.PacketId(), stanza.NSBind, resource) - } else { - fmt.Fprintf(s.transport, "", s.PacketId(), stanza.NSBind) + iqB, err := stanza.NewIQ(stanza.Attrs{ + Type: stanza.IQTypeSet, + Id: s.PacketId(), + }) + if err != nil { + s.err = err + return } + // Check if we already have a resource name, and include it in the request if so + if resource != "" { + iqB.Payload = &stanza.Bind{ + Resource: resource, + } + } else { + iqB.Payload = &stanza.Bind{} + + } + + // Send the bind request IQ + data, err := xml.Marshal(iqB) + if err != nil { + s.err = err + return + } + n, err := s.transport.Write(data) + if err != nil { + s.err = err + return + } else if n == 0 { + s.err = errors.New("failed to write bind iq stanza to the server : wrote 0 bytes") + return + } + + // Check the server response var iq stanza.IQ if s.err = s.transport.GetDecoder().Decode(&iq); s.err != nil { s.err = errors.New("error decoding iq bind result: " + s.err.Error()) @@ -197,7 +257,7 @@ func (s *Session) bind(o Config) { } // After the bind, if the session is not optional (as per old RFC 3921), we send the session open iq. -func (s *Session) rfc3921Session(o Config) { +func (s *Session) rfc3921Session() { if s.err != nil { return } @@ -205,7 +265,29 @@ func (s *Session) rfc3921Session(o Config) { var iq stanza.IQ // We only negotiate session binding if it is mandatory, we skip it when optional. if !s.Features.Session.IsOptional() { - fmt.Fprintf(s.transport, "", s.PacketId(), stanza.NSSession) + se, err := stanza.NewIQ(stanza.Attrs{ + Type: stanza.IQTypeSet, + Id: s.PacketId(), + }) + if err != nil { + s.err = err + return + } + se.Payload = &stanza.StreamSession{} + data, err := xml.Marshal(se) + if err != nil { + s.err = err + return + } + n, err := s.transport.Write(data) + if err != nil { + s.err = err + return + } else if n == 0 { + s.err = errors.New("there was a problem marshaling the session IQ : wrote 0 bytes to server") + return + } + if s.err = s.transport.GetDecoder().Decode(&iq); s.err != nil { s.err = errors.New("expecting iq result after session open: " + s.err.Error()) return @@ -214,28 +296,47 @@ func (s *Session) rfc3921Session(o Config) { } // Enable stream management, with session resumption, if supported. -func (s *Session) EnableStreamManagement(o Config) { +func (s *Session) EnableStreamManagement(o *Config) { if s.err != nil { return } - if !s.Features.DoesStreamManagement() { + if !s.Features.DoesStreamManagement() || !o.StreamManagementEnable { + return + } + q := stanza.NewUnAckQueue() + ebleNonza := stanza.SMEnable{Resume: &o.streamManagementResume} + pktStr, err := xml.Marshal(ebleNonza) + if err != nil { + s.err = err + return + } + _, err = s.transport.Write(pktStr) + if err != nil { + s.err = err return } - - fmt.Fprintf(s.transport, "", stanza.NSStreamManagement) var packet stanza.Packet packet, s.err = stanza.NextPacket(s.transport.GetDecoder()) if s.err == nil { switch p := packet.(type) { case stanza.SMEnabled: - s.SMState = SMState{Id: p.Id} + // Server allows resumption or not using SMEnabled attribute "resume". We must read the server response + // and update config accordingly + b, err := strconv.ParseBool(p.Resume) + if err != nil || !b { + o.StreamManagementEnable = false + } + s.SMState = SMState{Id: p.Id, preferredReconAddr: p.Location} + s.SMState.UnAckQueue = q case stanza.SMFailed: // TODO: Store error in SMState, for later inspection + s.SMState = SMState{StreamErrorGroup: p.StreamErrorGroup} + s.SMState.UnAckQueue = q + s.err = errors.New("failed to establish session : " + s.SMState.StreamErrorGroup.GroupErrorName()) default: s.err = errors.New("unexpected reply to SM enable") } } - return } diff --git a/stanza/commands.go b/stanza/commands.go new file mode 100644 index 0000000..3d9d4ea --- /dev/null +++ b/stanza/commands.go @@ -0,0 +1,153 @@ +package stanza + +import "encoding/xml" + +// Implements the XEP-0050 extension + +const ( + CommandActionCancel = "cancel" + CommandActionComplete = "complete" + CommandActionExecute = "execute" + CommandActionNext = "next" + CommandActionPrevious = "prev" + + CommandStatusCancelled = "canceled" + CommandStatusCompleted = "completed" + CommandStatusExecuting = "executing" + + CommandNoteTypeErr = "error" + CommandNoteTypeInfo = "info" + CommandNoteTypeWarn = "warn" +) + +type Command struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/commands command"` + + CommandElement CommandElement + + BadAction *struct{} `xml:"bad-action,omitempty"` + BadLocale *struct{} `xml:"bad-locale,omitempty"` + BadPayload *struct{} `xml:"bad-payload,omitempty"` + BadSessionId *struct{} `xml:"bad-sessionid,omitempty"` + MalformedAction *struct{} `xml:"malformed-action,omitempty"` + SessionExpired *struct{} `xml:"session-expired,omitempty"` + + // Attributes + Action string `xml:"action,attr,omitempty"` + Node string `xml:"node,attr"` + SessionId string `xml:"sessionid,attr,omitempty"` + Status string `xml:"status,attr,omitempty"` + Lang string `xml:"lang,attr,omitempty"` + + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` +} + +func (c *Command) Namespace() string { + return c.XMLName.Space +} + +func (c *Command) GetSet() *ResultSet { + return c.ResultSet +} + +type CommandElement interface { + Ref() string +} + +type Actions struct { + Prev *struct{} `xml:"prev,omitempty"` + Next *struct{} `xml:"next,omitempty"` + Complete *struct{} `xml:"complete,omitempty"` + + Execute string `xml:"execute,attr,omitempty"` +} + +func (a *Actions) Ref() string { + return "actions" +} + +type Note struct { + Text string `xml:",cdata"` + Type string `xml:"type,attr,omitempty"` +} + +func (n *Note) Ref() string { + return "note" +} +func (f *Form) Ref() string { return "form" } + +func (n *Node) Ref() string { + return "node" +} + +func (c *Command) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + c.XMLName = start.Name + + // Extract packet attributes + for _, attr := range start.Attr { + if attr.Name.Local == "action" { + c.Action = attr.Value + } + if attr.Name.Local == "node" { + c.Node = attr.Value + } + if attr.Name.Local == "sessionid" { + c.SessionId = attr.Value + } + if attr.Name.Local == "status" { + c.Status = attr.Value + } + if attr.Name.Local == "lang" { + c.Lang = attr.Value + } + } + + // decode inner elements + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + + case xml.StartElement: + // Decode sub-elements + var err error + switch tt.Name.Local { + + case "affiliations": + a := Actions{} + err = d.DecodeElement(&a, &tt) + c.CommandElement = &a + case "configure": + nt := Note{} + err = d.DecodeElement(&nt, &tt) + c.CommandElement = &nt + case "x": + f := Form{} + err = d.DecodeElement(&f, &tt) + c.CommandElement = &f + default: + n := Node{} + err = d.DecodeElement(&n, &tt) + c.CommandElement = &n + if err != nil { + return err + } + } + if err != nil { + return err + } + case xml.EndElement: + if tt == start.End() { + return nil + } + } + } +} + +func init() { + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "http://jabber.org/protocol/commands", Local: "command"}, Command{}) +} diff --git a/stanza/commands_test.go b/stanza/commands_test.go new file mode 100644 index 0000000..a72e5aa --- /dev/null +++ b/stanza/commands_test.go @@ -0,0 +1,40 @@ +package stanza_test + +import ( + "encoding/xml" + "gosrc.io/xmpp/stanza" + "testing" +) + +func TestMarshalCommands(t *testing.T) { + input := "Available Services" + + "" + + "httpd" + + "offoff" + + "onon" + + "postgresql" + + "offoff" + + "onon" + + "jabberd" + + "offoff" + + "onon" + var c stanza.Command + err := xml.Unmarshal([]byte(input), &c) + + if err != nil { + t.Fatalf("failed to unmarshal initial input") + } + + data, err := xml.Marshal(c) + if err != nil { + t.Fatalf("failed to marshal unmarshalled input") + } + + if err := compareMarshal(input, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} diff --git a/stanza/component.go b/stanza/component.go index 33ced33..ba3b81e 100644 --- a/stanza/component.go +++ b/stanza/component.go @@ -12,7 +12,7 @@ import ( type Handshake struct { XMLName xml.Name `xml:"jabber:component:accept handshake"` // TODO Add handshake value with test for proper serialization - // Value string `xml:",innerxml"` + Value string `xml:",innerxml"` } func (Handshake) Name() string { @@ -42,11 +42,16 @@ type Delegation struct { XMLName xml.Name `xml:"urn:xmpp:delegation:1 delegation"` Forwarded *Forwarded // This is used in iq to wrap delegated iqs Delegated *Delegated // This is used in a message to confirm delegated namespace + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` } func (d *Delegation) Namespace() string { return d.XMLName.Space } +func (d *Delegation) GetSet() *ResultSet { + return d.ResultSet +} // Forwarded is used to wrapped forwarded stanzas. // TODO: Move it in another file, as it is not limited to components. @@ -86,6 +91,6 @@ type Delegated struct { } func init() { - TypeRegistry.MapExtension(PKTMessage, xml.Name{"urn:xmpp:delegation:1", "delegation"}, Delegation{}) - TypeRegistry.MapExtension(PKTIQ, xml.Name{"urn:xmpp:delegation:1", "delegation"}, Delegation{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:delegation:1", Local: "delegation"}, Delegation{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "urn:xmpp:delegation:1", Local: "delegation"}, Delegation{}) } diff --git a/stanza/component_test.go b/stanza/component_test.go index d02531e..2fb1672 100644 --- a/stanza/component_test.go +++ b/stanza/component_test.go @@ -61,13 +61,13 @@ func TestParsingDelegationIQ(t *testing.T) { if iq.Payload != nil { if delegation, ok := iq.Payload.(*Delegation); ok { packet := delegation.Forwarded.Stanza - forwardedIQ, ok := packet.(IQ) + forwardedIQ, ok := packet.(*IQ) if !ok { t.Errorf("Could not extract packet IQ") return } if forwardedIQ.Payload != nil { - if pubsub, ok := forwardedIQ.Payload.(*PubSub); ok { + if pubsub, ok := forwardedIQ.Payload.(*PubSubGeneric); ok { node = pubsub.Publish.Node } } diff --git a/stanza/datetime_profiles.go b/stanza/datetime_profiles.go new file mode 100644 index 0000000..7a2ef74 --- /dev/null +++ b/stanza/datetime_profiles.go @@ -0,0 +1,70 @@ +package stanza + +import ( + "errors" + "strings" + "time" +) + +// Helper structures and functions to manage dates and timestamps as defined in +// XEP-0082: XMPP Date and Time Profiles (https://xmpp.org/extensions/xep-0082.html) + +const dateLayoutXEP0082 = "2006-01-02" +const timeLayoutXEP0082 = "15:04:05+00:00" + +var InvalidDateInput = errors.New("could not parse date. Input might not be in a supported format") +var InvalidDateOutput = errors.New("could not format date as desired") + +type JabberDate struct { + value time.Time +} + +func (d JabberDate) DateToString() string { + return d.value.Format(dateLayoutXEP0082) +} + +func (d JabberDate) DateTimeToString(nanos bool) string { + if nanos { + return d.value.Format(time.RFC3339Nano) + } + return d.value.Format(time.RFC3339) +} + +func (d JabberDate) TimeToString(nanos bool) (string, error) { + if nanos { + spl := strings.Split(d.value.Format(time.RFC3339Nano), "T") + if len(spl) != 2 { + return "", InvalidDateOutput + } + return spl[1], nil + } + spl := strings.Split(d.value.Format(time.RFC3339), "T") + if len(spl) != 2 { + return "", InvalidDateOutput + } + return spl[1], nil +} + +func NewJabberDateFromString(strDate string) (JabberDate, error) { + t, err := time.Parse(time.RFC3339, strDate) + if err == nil { + return JabberDate{value: t}, nil + } + + t, err = time.Parse(time.RFC3339Nano, strDate) + if err == nil { + return JabberDate{value: t}, nil + } + + t, err = time.Parse(dateLayoutXEP0082, strDate) + if err == nil { + return JabberDate{value: t}, nil + } + + t, err = time.Parse(timeLayoutXEP0082, strDate) + if err == nil { + return JabberDate{value: t}, nil + } + + return JabberDate{}, InvalidDateInput +} diff --git a/stanza/datetime_profiles_test.go b/stanza/datetime_profiles_test.go new file mode 100644 index 0000000..98aa4cd --- /dev/null +++ b/stanza/datetime_profiles_test.go @@ -0,0 +1,191 @@ +package stanza + +import ( + "testing" + "time" +) + +func TestDateToString(t *testing.T) { + t1 := JabberDate{value: time.Now()} + t2 := JabberDate{value: time.Now().Add(24 * time.Hour)} + + t1Str := t1.DateToString() + t2Str := t2.DateToString() + + if t1Str == t2Str { + t.Fatalf("time representations should not be identical") + } +} + +func TestDateToStringOracle(t *testing.T) { + expected := "2009-11-10" + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf(err.Error()) + } + t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)} + + t1Str := t1.DateToString() + if t1Str != expected { + t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str) + } +} + +func TestDateTimeToString(t *testing.T) { + t1 := JabberDate{value: time.Now()} + t2 := JabberDate{value: time.Now().Add(10 * time.Second)} + + t1Str := t1.DateTimeToString(false) + t2Str := t2.DateTimeToString(false) + + if t1Str == t2Str { + t.Fatalf("time representations should not be identical") + } +} + +func TestDateTimeToStringOracle(t *testing.T) { + expected := "2009-11-10T23:03:22+08:00" + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf(err.Error()) + } + t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)} + + t1Str := t1.DateTimeToString(false) + if t1Str != expected { + t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str) + } +} + +func TestDateTimeToStringNanos(t *testing.T) { + t1 := JabberDate{value: time.Now()} + time.After(10 * time.Millisecond) + t2 := JabberDate{value: time.Now()} + + t1Str := t1.DateTimeToString(true) + t2Str := t2.DateTimeToString(true) + + if t1Str == t2Str { + t.Fatalf("time representations should not be identical") + } +} + +func TestDateTimeToStringNanosOracle(t *testing.T) { + expected := "2009-11-10T23:03:22.000000089+08:00" + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf(err.Error()) + } + t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)} + + t1Str := t1.DateTimeToString(true) + if t1Str != expected { + t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str) + } +} + +func TestTimeToString(t *testing.T) { + t1 := JabberDate{value: time.Now()} + t2 := JabberDate{value: time.Now().Add(10 * time.Second)} + + t1Str, err := t1.TimeToString(false) + if err != nil { + t.Fatalf(err.Error()) + } + t2Str, err := t2.TimeToString(false) + if err != nil { + t.Fatalf(err.Error()) + } + + if t1Str == t2Str { + t.Fatalf("time representations should not be identical") + } +} + +func TestTimeToStringOracle(t *testing.T) { + expected := "23:03:22+08:00" + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf(err.Error()) + } + t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)} + + t1Str, err := t1.TimeToString(false) + if err != nil { + t.Fatalf(err.Error()) + } + + if t1Str != expected { + t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str) + } +} + +func TestTimeToStringNanos(t *testing.T) { + t1 := JabberDate{value: time.Now()} + time.After(10 * time.Millisecond) + t2 := JabberDate{value: time.Now()} + + t1Str, err := t1.TimeToString(true) + if err != nil { + t.Fatalf(err.Error()) + } + t2Str, err := t2.TimeToString(true) + if err != nil { + t.Fatalf(err.Error()) + } + + if t1Str == t2Str { + t.Fatalf("time representations should not be identical") + } +} +func TestTimeToStringNanosOracle(t *testing.T) { + expected := "23:03:22.000000089+08:00" + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf(err.Error()) + } + t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)} + + t1Str, err := t1.TimeToString(true) + if err != nil { + t.Fatalf(err.Error()) + } + + if t1Str != expected { + t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str) + } +} + +func TestJabberDateParsing(t *testing.T) { + date := "2009-11-10" + _, err := NewJabberDateFromString(date) + if err != nil { + t.Fatalf(err.Error()) + } + + dateTime := "2009-11-10T23:03:22+08:00" + _, err = NewJabberDateFromString(dateTime) + if err != nil { + t.Fatalf(err.Error()) + } + + dateTimeNanos := "2009-11-10T23:03:22.000000089+08:00" + _, err = NewJabberDateFromString(dateTimeNanos) + if err != nil { + t.Fatalf(err.Error()) + } + + // TODO : fix these. Parsing a time with an offset doesn't work + //time := "23:03:22+08:00" + //_, err = NewJabberDateFromString(time) + //if err != nil { + // t.Fatalf(err.Error()) + //} + + //timeNanos := "23:03:22.000000089+08:00" + //_, err = NewJabberDateFromString(timeNanos) + //if err != nil { + // t.Fatalf(err.Error()) + //} + +} diff --git a/stanza/error.go b/stanza/error.go index bcc947f..5f42018 100644 --- a/stanza/error.go +++ b/stanza/error.go @@ -3,6 +3,7 @@ package stanza import ( "encoding/xml" "strconv" + "strings" ) // ============================================================================ @@ -53,10 +54,19 @@ func (x *Err) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { } textName := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "text"} - if elt.XMLName == textName { - x.Text = string(elt.Content) - } else if elt.XMLName.Space == "urn:ietf:params:xml:ns:xmpp-stanzas" { - x.Reason = elt.XMLName.Local + // TODO : change the pubsub handling ? It kind of dilutes the information + // Handles : 6.1.3.11 Node Has Moved for XEP-0060 (PubSubGeneric) + goneName := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "gone"} + if elt.XMLName == textName || // Regular error text + elt.XMLName == goneName { // Gone text for pubsub + x.Text = elt.Content + } else if elt.XMLName.Space == "urn:ietf:params:xml:ns:xmpp-stanzas" || + elt.XMLName.Space == "http://jabber.org/protocol/pubsub#errors" { + if strings.TrimSpace(x.Reason) != "" { + x.Reason = strings.Join([]string{elt.XMLName.Local}, ":") + } else { + x.Reason = elt.XMLName.Local + } } case xml.EndElement: @@ -94,16 +104,32 @@ func (x Err) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) { // Reason if x.Reason != "" { reason := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: x.Reason} - e.EncodeToken(xml.StartElement{Name: reason}) - e.EncodeToken(xml.EndElement{Name: reason}) + err = e.EncodeToken(xml.StartElement{Name: reason}) + if err != nil { + return err + } + err = e.EncodeToken(xml.EndElement{Name: reason}) + if err != nil { + return err + } + } // Text if x.Text != "" { text := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "text"} - e.EncodeToken(xml.StartElement{Name: text}) - e.EncodeToken(xml.CharData(x.Text)) - e.EncodeToken(xml.EndElement{Name: text}) + err = e.EncodeToken(xml.StartElement{Name: text}) + if err != nil { + return err + } + err = e.EncodeToken(xml.CharData(x.Text)) + if err != nil { + return err + } + err = e.EncodeToken(xml.EndElement{Name: text}) + if err != nil { + return err + } } return e.EncodeToken(xml.EndElement{Name: start.Name}) diff --git a/stanza/fifo_queue.go b/stanza/fifo_queue.go new file mode 100644 index 0000000..ca28810 --- /dev/null +++ b/stanza/fifo_queue.go @@ -0,0 +1,34 @@ +package stanza + +// FIFO queue for string contents +// Implementations have no guarantee regarding thread safety ! +type FifoQueue interface { + // Pop returns the first inserted element still in queue and deletes it from queue. If queue is empty, returns nil + // No guarantee regarding thread safety ! + Pop() Queueable + + // PopN returns the N first inserted elements still in queue and deletes them from queue. If queue is empty or i<=0, returns nil + // If number to pop is greater than queue length, returns all queue elements + // No guarantee regarding thread safety ! + PopN(i int) []Queueable + + // Peek returns a copy of the first inserted element in queue without deleting it. If queue is empty, returns nil + // No guarantee regarding thread safety ! + Peek() Queueable + + // Peek returns a copy of the first inserted element in queue without deleting it. If queue is empty or i<=0, returns nil. + // If number to peek is greater than queue length, returns all queue elements + // No guarantee regarding thread safety ! + PeekN() []Queueable + // Push adds an element to the queue + // No guarantee regarding thread safety ! + Push(s Queueable) error + + // Empty returns true if queue is empty + // No guarantee regarding thread safety ! + Empty() bool +} + +type Queueable interface { + QueueableName() string +} diff --git a/stanza/form.go b/stanza/form.go new file mode 100644 index 0000000..f758f74 --- /dev/null +++ b/stanza/form.go @@ -0,0 +1,68 @@ +package stanza + +import "encoding/xml" + +type FormType string + +const ( + FormTypeCancel = "cancel" + FormTypeForm = "form" + FormTypeResult = "result" + FormTypeSubmit = "submit" +) + +// See XEP-0004 and XEP-0068 +// Pointer semantics +type Form struct { + XMLName xml.Name `xml:"jabber:x:data x"` + Instructions []string `xml:"instructions"` + Title string `xml:"title,omitempty"` + Fields []*Field `xml:"field,omitempty"` + Reported *FormItem `xml:"reported"` + Items []FormItem `xml:"item,omitempty"` + Type string `xml:"type,attr"` +} + +type FormItem struct { + XMLName xml.Name + Fields []Field `xml:"field,omitempty"` +} + +type Field struct { + XMLName xml.Name `xml:"field"` + Description string `xml:"desc,omitempty"` + Required *string `xml:"required"` + ValuesList []string `xml:"value"` + Options []Option `xml:"option,omitempty"` + Var string `xml:"var,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` + Label string `xml:"label,attr,omitempty"` +} + +func NewForm(fields []*Field, formType string) *Form { + return &Form{ + Type: formType, + Fields: fields, + } +} + +type FieldType string + +const ( + FieldTypeBool = "boolean" + FieldTypeFixed = "fixed" + FieldTypeHidden = "hidden" + FieldTypeJidMulti = "jid-multi" + FieldTypeJidSingle = "jid-single" + FieldTypeListMulti = "list-multi" + FieldTypeListSingle = "list-single" + FieldTypeTextMulti = "text-multi" + FieldTypeTextPrivate = "text-private" + FieldTypeTextSingle = "text-Single" +) + +type Option struct { + XMLName xml.Name `xml:"option"` + Label string `xml:"label,attr,omitempty"` + ValuesList []string `xml:"value"` +} diff --git a/stanza/form_test.go b/stanza/form_test.go new file mode 100644 index 0000000..ea6c613 --- /dev/null +++ b/stanza/form_test.go @@ -0,0 +1,110 @@ +package stanza + +import ( + "encoding/xml" + "strings" + "testing" +) + +const ( + formSubmit = "" + + "" + + "" + + "" + + "http://jabber.org/protocol/pubsub#node_config" + + "" + + "" + + "Princely Musings (Atom)" + + "" + + "" + + "1" + + "" + + "" + + "roster" + + "" + + "" + + "friends" + + "servants" + + "courtiers" + + "" + + "" + + "http://www.w3.org/2005/Atom" + + "" + + "" + + "headline" + + "" + + "" + + "" + + "" + + "" + + "" + + clientJid = "hamlet@denmark.lit/elsinore" + serviceJid = "pubsub.shakespeare.lit" + iqId = "config1" + serviceNode = "princely_musings" +) + +func TestMarshalFormSubmit(t *testing.T) { + formIQ, err := NewIQ(Attrs{From: clientJid, To: serviceJid, Id: iqId, Type: IQTypeSet}) + if err != nil { + t.Fatalf("failed to create formIQ: %v", err) + } + formIQ.Payload = &PubSubOwner{ + OwnerUseCase: &ConfigureOwner{ + Node: serviceNode, + Form: &Form{ + Type: FormTypeSubmit, + Fields: []*Field{ + {Var: "FORM_TYPE", Type: FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#node_config"}}, + {Var: "pubsub#title", ValuesList: []string{"Princely Musings (Atom)"}}, + {Var: "pubsub#deliver_notifications", ValuesList: []string{"1"}}, + {Var: "pubsub#access_model", ValuesList: []string{"roster"}}, + {Var: "pubsub#roster_groups_allowed", ValuesList: []string{"friends", "servants", "courtiers"}}, + {Var: "pubsub#type", ValuesList: []string{"http://www.w3.org/2005/Atom"}}, + { + Var: "pubsub#notification_type", + Type: "list-single", + Label: "Specify the delivery style for event notifications", + ValuesList: []string{"headline"}, + Options: []Option{ + {ValuesList: []string{"normal"}}, + {ValuesList: []string{"headline"}}, + }, + }, + }, + }, + }, + } + b, err := xml.Marshal(formIQ.Payload) + if err != nil { + t.Fatalf("Could not marshal formIQ : %v", err) + } + + if strings.ReplaceAll(string(b), " ", "") != strings.ReplaceAll(formSubmit, " ", "") { + t.Fatalf("Expected formIQ and marshalled one are different.\nExepected : %s\nMarshalled : %s", formSubmit, string(b)) + } + +} + +func TestUnmarshalFormSubmit(t *testing.T) { + var f PubSubOwner + mErr := xml.Unmarshal([]byte(formSubmit), &f) + if mErr != nil { + t.Fatalf("failed to unmarshal formSubmit ! %s", mErr) + } + + data, err := xml.Marshal(&f) + if err != nil { + t.Fatalf("failed to marshal formSubmit") + } + + if strings.ReplaceAll(string(data), " ", "") != strings.ReplaceAll(formSubmit, " ", "") { + t.Fatalf("failed unmarshal/marshal for formSubmit : %s\n%s", string(data), formSubmit) + } +} diff --git a/stanza/iot.go b/stanza/iot.go index 5e15056..b6952e0 100644 --- a/stanza/iot.go +++ b/stanza/iot.go @@ -7,12 +7,18 @@ import ( type ControlSet struct { XMLName xml.Name `xml:"urn:xmpp:iot:control set"` Fields []ControlField `xml:",any"` + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` } func (c *ControlSet) Namespace() string { return c.XMLName.Space } +func (c *ControlSet) GetSet() *ResultSet { + return c.ResultSet +} + type ControlGetForm struct { XMLName xml.Name `xml:"urn:xmpp:iot:control getForm"` } @@ -30,10 +36,13 @@ type ControlSetResponse struct { func (c *ControlSetResponse) Namespace() string { return c.XMLName.Space } +func (c *ControlSetResponse) GetSet() *ResultSet { + return nil +} // ============================================================================ // Registry init func init() { - TypeRegistry.MapExtension(PKTIQ, xml.Name{"urn:xmpp:iot:control", "set"}, ControlSet{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "urn:xmpp:iot:control", Local: "set"}, ControlSet{}) } diff --git a/stanza/iq.go b/stanza/iq.go index 923cf28..a8cd62a 100644 --- a/stanza/iq.go +++ b/stanza/iq.go @@ -2,6 +2,8 @@ package stanza import ( "encoding/xml" + "errors" + "strings" "github.com/google/uuid" ) @@ -23,52 +25,63 @@ type IQ struct { // Info/Query // child element, which specifies the semantics of the particular // request." Payload IQPayload `xml:",omitempty"` - Error Err `xml:"error,omitempty"` + Error *Err `xml:"error,omitempty"` // Any is used to decode unknown payload as a generic structure Any *Node `xml:",any"` } type IQPayload interface { Namespace() string + GetSet() *ResultSet } -func NewIQ(a Attrs) IQ { - // TODO ensure that type is set, as it is required +func NewIQ(a Attrs) (*IQ, error) { if a.Id == "" { if id, err := uuid.NewRandom(); err == nil { a.Id = id.String() } } - return IQ{ + + iq := IQ{ XMLName: xml.Name{Local: "iq"}, Attrs: a, } + + if iq.Type.IsEmpty() { + return nil, IqTypeUnset + } + return &iq, nil } -func (iq IQ) MakeError(xerror Err) IQ { +func (iq *IQ) MakeError(xerror Err) *IQ { from := iq.From to := iq.To iq.Type = "error" iq.From = to iq.To = from - iq.Error = xerror + iq.Error = &xerror return iq } -func (IQ) Name() string { +func (*IQ) Name() string { return "iq" } +// NoOp to implement BiDirIteratorElt +func (*IQ) NoOp() { + +} + type iqDecoder struct{} var iq iqDecoder -func (iqDecoder) decode(p *xml.Decoder, se xml.StartElement) (IQ, error) { +func (iqDecoder) decode(p *xml.Decoder, se xml.StartElement) (*IQ, error) { var packet IQ err := p.DecodeElement(&packet, &se) - return packet, err + return &packet, err } // UnmarshalXML implements custom parsing for IQs @@ -106,7 +119,7 @@ func (iq *IQ) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { if err != nil { return err } - iq.Error = xmppError + iq.Error = &xmppError continue } if iqExt := TypeRegistry.GetIQExtension(tt.Name); iqExt != nil { @@ -132,3 +145,48 @@ func (iq *IQ) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { } } } + +var ( + IqTypeUnset = errors.New("iq type is not set but is mandatory") + IqIDUnset = errors.New("iq stanza ID is not set but is mandatory") + IqSGetNoPl = errors.New("iq is of type get or set but has no payload") + IqResNoPl = errors.New("iq is of type result but has no payload") + IqErrNoErrPl = errors.New("iq is of type error but has no error payload") +) + +// IsValid checks if the IQ is valid. If not, return an error with the reason as a message +// Following RFC-3920 for IQs +func (iq *IQ) IsValid() (bool, error) { + // ID is required + if len(strings.TrimSpace(iq.Id)) == 0 { + return false, IqIDUnset + } + + // Type is required + if iq.Type.IsEmpty() { + return false, IqTypeUnset + } + + // Type get and set must contain one and only one child element that specifies the semantics + if iq.Type == IQTypeGet || iq.Type == IQTypeSet { + if iq.Payload == nil && iq.Any == nil { + return false, IqSGetNoPl + } + } + + // A result must include zero or one child element + if iq.Type == IQTypeResult { + if iq.Payload != nil && iq.Any != nil { + return false, IqResNoPl + } + } + + //Error type must contain an "error" child element + if iq.Type == IQTypeError { + if iq.Error == nil { + return false, IqErrNoErrPl + } + } + + return true, nil +} diff --git a/stanza/iq_disco.go b/stanza/iq_disco.go index cc94756..8e50f90 100644 --- a/stanza/iq_disco.go +++ b/stanza/iq_disco.go @@ -8,6 +8,7 @@ import ( // Disco Info const ( + // NSDiscoInfo defines the namespace for disco IQ stanzas NSDiscoInfo = "http://jabber.org/protocol/disco#info" ) @@ -15,16 +16,22 @@ const ( // Namespaces type DiscoInfo struct { - XMLName xml.Name `xml:"http://jabber.org/protocol/disco#info query"` - Node string `xml:"node,attr,omitempty"` - Identity []Identity `xml:"identity"` - Features []Feature `xml:"feature"` + XMLName xml.Name `xml:"http://jabber.org/protocol/disco#info query"` + Node string `xml:"node,attr,omitempty"` + Identity []Identity `xml:"identity"` + Features []Feature `xml:"feature"` + ResultSet *ResultSet `xml:"set,omitempty"` } +// Namespace lets DiscoInfo implement the IQPayload interface func (d *DiscoInfo) Namespace() string { return d.XMLName.Space } +func (d *DiscoInfo) GetSet() *ResultSet { + return d.ResultSet +} + // --------------- // Builder helpers @@ -100,19 +107,26 @@ type DiscoItems struct { XMLName xml.Name `xml:"http://jabber.org/protocol/disco#items query"` Node string `xml:"node,attr,omitempty"` Items []DiscoItem `xml:"item"` + + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` } func (d *DiscoItems) Namespace() string { return d.XMLName.Space } +func (d *DiscoItems) GetSet() *ResultSet { + return d.ResultSet +} + // --------------- // Builder helpers // DiscoItems builds a default DiscoItems payload func (iq *IQ) DiscoItems() *DiscoItems { d := DiscoItems{ - XMLName: xml.Name{Space: "http://jabber.org/protocol/disco#items", Local: "query"}, + XMLName: xml.Name{Space: NSDiscoItems, Local: "query"}, } iq.Payload = &d return &d @@ -144,6 +158,6 @@ type DiscoItem struct { // Registry init func init() { - TypeRegistry.MapExtension(PKTIQ, xml.Name{NSDiscoInfo, "query"}, DiscoInfo{}) - TypeRegistry.MapExtension(PKTIQ, xml.Name{NSDiscoItems, "query"}, DiscoItems{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSDiscoInfo, Local: "query"}, DiscoInfo{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSDiscoItems, Local: "query"}, DiscoItems{}) } diff --git a/stanza/iq_disco_test.go b/stanza/iq_disco_test.go index d659cde..87b7001 100644 --- a/stanza/iq_disco_test.go +++ b/stanza/iq_disco_test.go @@ -9,7 +9,10 @@ import ( // Test DiscoInfo Builder with several features func TestDiscoInfo_Builder(t *testing.T) { - iq := stanza.NewIQ(stanza.Attrs{Type: "get", To: "service.localhost", Id: "disco-get-1"}) + iq, err := stanza.NewIQ(stanza.Attrs{Type: "get", To: "service.localhost", Id: "disco-get-1"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } disco := iq.DiscoInfo() disco.AddIdentity("Test Component", "gateway", "service") disco.AddFeatures(stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1") @@ -50,8 +53,11 @@ func TestDiscoInfo_Builder(t *testing.T) { // Implements XEP-0030 example 17 // https://xmpp.org/extensions/xep-0030.html#example-17 func TestDiscoItems_Builder(t *testing.T) { - iq := stanza.NewIQ(stanza.Attrs{Type: "result", From: "catalog.shakespeare.lit", + iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: "catalog.shakespeare.lit", To: "romeo@montague.net/orchard", Id: "items-2"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } iq.DiscoItems(). AddItem("catalog.shakespeare.lit", "books", "Books by and about Shakespeare"). AddItem("catalog.shakespeare.lit", "clothing", "Wear your literary taste with pride"). @@ -73,11 +79,11 @@ func TestDiscoItems_Builder(t *testing.T) { {xml.Name{}, "catalog.shakespeare.lit", "clothing", "Wear your literary taste with pride"}, {xml.Name{}, "catalog.shakespeare.lit", "music", "Music from the time of Shakespeare"}} if len(pp.Items) != len(items) { - t.Errorf("Items length mismatch: %#v", pp.Items) + t.Errorf("List length mismatch: %#v", pp.Items) } else { for i, item := range pp.Items { if item.JID != items[i].JID { - t.Errorf("JID Mismatch (expected: %s): %s", items[i].JID, item.JID) + t.Errorf("Jid Mismatch (expected: %s): %s", items[i].JID, item.JID) } if item.Node != items[i].Node { t.Errorf("Node Mismatch (expected: %s): %s", items[i].JID, item.JID) diff --git a/stanza/iq_roster.go b/stanza/iq_roster.go new file mode 100644 index 0000000..34ab5f0 --- /dev/null +++ b/stanza/iq_roster.go @@ -0,0 +1,126 @@ +package stanza + +import ( + "encoding/xml" +) + +// ============================================================================ +// Roster + +const ( + // NSRoster is the Roster IQ namespace + NSRoster = "jabber:iq:roster" + // SubscriptionNone indicates the user does not have a subscription to + // the contact's presence, and the contact does not have a subscription + // to the user's presence; this is the default value, so if the subscription + // attribute is not included then the state is to be understood as "none" + SubscriptionNone = "none" + + // SubscriptionTo indicates the user has a subscription to the contact's + // presence, but the contact does not have a subscription to the user's presence. + SubscriptionTo = "to" + + // SubscriptionFrom indicates the contact has a subscription to the user's + // presence, but the user does not have a subscription to the contact's presence + SubscriptionFrom = "from" + + // SubscriptionBoth indicates the user and the contact have subscriptions to each + // other's presence (also called a "mutual subscription") + SubscriptionBoth = "both" +) + +// ---------- +// Namespaces + +// Roster struct represents Roster IQs +type Roster struct { + XMLName xml.Name `xml:"jabber:iq:roster query"` + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` +} + +// Namespace defines the namespace for the RosterIQ +func (r *Roster) Namespace() string { + return r.XMLName.Space +} +func (r *Roster) GetSet() *ResultSet { + return r.ResultSet +} + +// --------------- +// Builder helpers + +// RosterIQ builds a default Roster payload +func (iq *IQ) RosterIQ() *Roster { + r := Roster{ + XMLName: xml.Name{ + Space: NSRoster, + Local: "query", + }, + } + iq.Payload = &r + return &r +} + +// ----------- +// SubElements + +// RosterItems represents the list of items in a roster IQ +type RosterItems struct { + XMLName xml.Name `xml:"jabber:iq:roster query"` + Items []RosterItem `xml:"item"` + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` +} + +// Namespace lets RosterItems implement the IQPayload interface +func (r *RosterItems) Namespace() string { + return r.XMLName.Space +} + +func (r *RosterItems) GetSet() *ResultSet { + return r.ResultSet +} + +// RosterItem represents an item in the roster iq +type RosterItem struct { + XMLName xml.Name `xml:"jabber:iq:roster item"` + Jid string `xml:"jid,attr"` + Ask string `xml:"ask,attr,omitempty"` + Name string `xml:"name,attr,omitempty"` + Subscription string `xml:"subscription,attr,omitempty"` + Groups []string `xml:"group"` +} + +// --------------- +// Builder helpers + +// RosterItems builds a default RosterItems payload +func (iq *IQ) RosterItems() *RosterItems { + ri := RosterItems{ + XMLName: xml.Name{Space: "jabber:iq:roster", Local: "query"}, + } + iq.Payload = &ri + return &ri +} + +// AddItem builds an item and ads it to the roster IQ +func (r *RosterItems) AddItem(jid, subscription, ask, name string, groups []string) *RosterItems { + item := RosterItem{ + Jid: jid, + Name: name, + Groups: groups, + Subscription: subscription, + Ask: ask, + } + r.Items = append(r.Items, item) + return r +} + +// ============================================================================ +// Registry init + +func init() { + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSRoster, Local: "query"}, Roster{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSRoster, Local: "query"}, RosterItems{}) +} diff --git a/stanza/iq_roster_test.go b/stanza/iq_roster_test.go new file mode 100644 index 0000000..8eee77f --- /dev/null +++ b/stanza/iq_roster_test.go @@ -0,0 +1,112 @@ +package stanza + +import ( + "encoding/xml" + "reflect" + "testing" +) + +func TestRosterBuilder(t *testing.T) { + iq, err := NewIQ(Attrs{Type: IQTypeResult, From: "romeo@montague.net/orchard"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } + var noGroup []string + + iq.RosterItems().AddItem("xl8ceawrfu8zdneomw1h6h28d@crypho.com", + SubscriptionBoth, + "", + "xl8ceaw", + []string{"0flucpm8i2jtrjhxw01uf1nd2", + "bm2bajg9ex4e1swiuju9i9nu5", + "rvjpanomi4ejpx42fpmffoac0"}). + AddItem("9aynsym60zbu78jbdvpho7s68@crypho.com", + SubscriptionBoth, + "", + "9aynsym60", + []string{"mzaoy73i6ra5k502182zi1t97"}). + AddItem("admin@crypho.com", + SubscriptionBoth, + "", + "admin", + noGroup) + + parsedIQ, err := checkMarshalling(t, iq) + if err != nil { + return + } + + // Check result + pp, ok := parsedIQ.Payload.(*RosterItems) + if !ok { + t.Errorf("Parsed stanza does not contain correct IQ payload") + } + + // Check items + items := []RosterItem{ + { + XMLName: xml.Name{}, + Name: "xl8ceaw", + Ask: "", + Jid: "xl8ceawrfu8zdneomw1h6h28d@crypho.com", + Subscription: SubscriptionBoth, + Groups: []string{"0flucpm8i2jtrjhxw01uf1nd2", + "bm2bajg9ex4e1swiuju9i9nu5", + "rvjpanomi4ejpx42fpmffoac0"}, + }, + { + XMLName: xml.Name{}, + Name: "9aynsym60", + Ask: "", + Jid: "9aynsym60zbu78jbdvpho7s68@crypho.com", + Subscription: SubscriptionBoth, + Groups: []string{"mzaoy73i6ra5k502182zi1t97"}, + }, + { + XMLName: xml.Name{}, + Name: "admin", + Ask: "", + Jid: "admin@crypho.com", + Subscription: SubscriptionBoth, + Groups: noGroup, + }, + } + if len(pp.Items) != len(items) { + t.Errorf("List length mismatch: %#v", pp.Items) + } else { + for i, item := range pp.Items { + if item.Jid != items[i].Jid { + t.Errorf("Jid Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + if !reflect.DeepEqual(item.Groups, items[i].Groups) { + t.Errorf("Node Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + if item.Name != items[i].Name { + t.Errorf("Name Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + if item.Ask != items[i].Ask { + t.Errorf("Name Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + if item.Subscription != items[i].Subscription { + t.Errorf("Name Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + } + } +} + +func checkMarshalling(t *testing.T, iq *IQ) (*IQ, error) { + // Marshall + data, err := xml.Marshal(iq) + if err != nil { + t.Errorf("cannot marshal iq: %s\n%#v", err, iq) + return nil, err + } + + // Unmarshall + var parsedIQ IQ + err = xml.Unmarshal(data, &parsedIQ) + if err != nil { + t.Errorf("Unmarshal returned error: %s\n%s", err, data) + } + return &parsedIQ, err +} diff --git a/stanza/iq_test.go b/stanza/iq_test.go index 54a8fc5..e5af6de 100644 --- a/stanza/iq_test.go +++ b/stanza/iq_test.go @@ -36,24 +36,36 @@ func TestUnmarshalIqs(t *testing.T) { func TestGenerateIqId(t *testing.T) { t.Parallel() - iq := stanza.NewIQ(stanza.Attrs{Id: "1"}) + iq, err := stanza.NewIQ(stanza.Attrs{Id: "1", Type: "dummy type"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } if iq.Id != "1" { t.Errorf("NewIQ replaced id with %s", iq.Id) } - iq = stanza.NewIQ(stanza.Attrs{}) + iq, err = stanza.NewIQ(stanza.Attrs{Type: "dummy type"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } if iq.Id == "" { t.Error("NewIQ did not generate an Id") } - otherIq := stanza.NewIQ(stanza.Attrs{}) + otherIq, err := stanza.NewIQ(stanza.Attrs{Type: "dummy type"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } if iq.Id == otherIq.Id { t.Errorf("NewIQ generated two identical ids: %s", iq.Id) } } func TestGenerateIq(t *testing.T) { - iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: "admin@localhost", To: "test@localhost", Id: "1"}) + iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: "admin@localhost", To: "test@localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } payload := stanza.DiscoInfo{ Identity: []stanza.Identity{ {Name: "Test Gateway", @@ -111,7 +123,10 @@ func TestErrorTag(t *testing.T) { } func TestDiscoItems(t *testing.T) { - iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "romeo@montague.net/orchard", To: "catalog.shakespeare.lit", Id: "items3"}) + iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "romeo@montague.net/orchard", To: "catalog.shakespeare.lit", Id: "items3"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } payload := stanza.DiscoItems{ Node: "music", } @@ -187,3 +202,39 @@ func TestUnknownPayload(t *testing.T) { t.Errorf("could not extract namespace: '%s'", parsedIQ.Any.XMLName.Space) } } + +func TestIsValid(t *testing.T) { + type testCase struct { + iq string + shouldErr bool + } + testIQs := make(map[string]testCase) + testIQs["Valid IQ"] = testCase{ + ` + + `, + false, + } + testIQs["Invalid IQ"] = testCase{ + ` + + `, + true, + } + + for name, tcase := range testIQs { + t.Run(name, func(st *testing.T) { + parsedIQ := stanza.IQ{} + err := xml.Unmarshal([]byte(tcase.iq), &parsedIQ) + if err != nil { + t.Errorf("Unmarshal error: %#v (%s)", err, tcase.iq) + return + } + isValid, err := parsedIQ.IsValid() + if !isValid && !tcase.shouldErr { + t.Errorf("failed validation for iq because: %s\nin test case : %s", err, tcase.iq) + } + }) + } + +} diff --git a/stanza/iq_version.go b/stanza/iq_version.go index 4cfbfce..4661dba 100644 --- a/stanza/iq_version.go +++ b/stanza/iq_version.go @@ -11,12 +11,18 @@ type Version struct { Name string `xml:"name,omitempty"` Version string `xml:"version,omitempty"` OS string `xml:"os,omitempty"` + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` } func (v *Version) Namespace() string { return v.XMLName.Space } +func (v *Version) GetSet() *ResultSet { + return v.ResultSet +} + // --------------- // Builder helpers @@ -41,5 +47,5 @@ func (v *Version) SetInfo(name, version, os string) *Version { // Registry init func init() { - TypeRegistry.MapExtension(PKTIQ, xml.Name{"jabber:iq:version", "query"}, Version{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "jabber:iq:version", Local: "query"}, Version{}) } diff --git a/stanza/iq_version_test.go b/stanza/iq_version_test.go index 45d68f7..bdd621e 100644 --- a/stanza/iq_version_test.go +++ b/stanza/iq_version_test.go @@ -12,8 +12,11 @@ func TestVersion_Builder(t *testing.T) { name := "Exodus" version := "0.7.0.4" os := "Windows-XP 5.01.2600" - iq := stanza.NewIQ(stanza.Attrs{Type: "result", From: "romeo@montague.net/orchard", + iq, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: "romeo@montague.net/orchard", To: "juliet@capulet.com/balcony", Id: "version_1"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } iq.Version().SetInfo(name, version, os) parsedIQ, err := checkMarshalling(t, iq) diff --git a/jid.go b/stanza/jid.go similarity index 88% rename from jid.go rename to stanza/jid.go index f44019b..c3677a9 100644 --- a/jid.go +++ b/stanza/jid.go @@ -1,4 +1,4 @@ -package xmpp +package stanza import ( "fmt" @@ -20,9 +20,9 @@ func NewJid(sjid string) (*Jid, error) { } s1 := strings.SplitN(sjid, "@", 2) - if len(s1) == 1 { // This is a server or component JID + if len(s1) == 1 { // This is a server or component Jid jid.Domain = s1[0] - } else { // JID has a local username part + } else { // Jid has a local username part if s1[0] == "" { return jid, fmt.Errorf("invalid jid '%s", sjid) } @@ -41,10 +41,10 @@ func NewJid(sjid string) (*Jid, error) { } if !isUsernameValid(jid.Node) { - return jid, fmt.Errorf("invalid Node in JID '%s'", sjid) + return jid, fmt.Errorf("invalid Node in Jid '%s'", sjid) } if !isDomainValid(jid.Domain) { - return jid, fmt.Errorf("invalid domain in JID '%s'", sjid) + return jid, fmt.Errorf("invalid domain in Jid '%s'", sjid) } return jid, nil diff --git a/jid_test.go b/stanza/jid_test.go similarity index 99% rename from jid_test.go rename to stanza/jid_test.go index 45483dd..781221d 100644 --- a/jid_test.go +++ b/stanza/jid_test.go @@ -1,4 +1,4 @@ -package xmpp +package stanza import ( "testing" diff --git a/stanza/msg_chat_markers.go b/stanza/msg_chat_markers.go index f226379..66276f9 100644 --- a/stanza/msg_chat_markers.go +++ b/stanza/msg_chat_markers.go @@ -35,8 +35,8 @@ type MarkAcknowledged struct { } func init() { - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgChatMarkers, "markable"}, Markable{}) - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgChatMarkers, "received"}, MarkReceived{}) - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgChatMarkers, "displayed"}, MarkDisplayed{}) - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgChatMarkers, "acknowledged"}, MarkAcknowledged{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatMarkers, Local: "markable"}, Markable{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatMarkers, Local: "received"}, MarkReceived{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatMarkers, Local: "displayed"}, MarkDisplayed{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatMarkers, Local: "acknowledged"}, MarkAcknowledged{}) } diff --git a/stanza/msg_chat_state.go b/stanza/msg_chat_state.go index 728a52e..553a314 100644 --- a/stanza/msg_chat_state.go +++ b/stanza/msg_chat_state.go @@ -37,9 +37,9 @@ type StatePaused struct { } func init() { - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgChatStateNotifications, "active"}, StateActive{}) - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgChatStateNotifications, "composing"}, StateComposing{}) - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgChatStateNotifications, "gone"}, StateGone{}) - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgChatStateNotifications, "inactive"}, StateInactive{}) - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgChatStateNotifications, "paused"}, StatePaused{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "active"}, StateActive{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "composing"}, StateComposing{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "gone"}, StateGone{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "inactive"}, StateInactive{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "paused"}, StatePaused{}) } diff --git a/stanza/msg_hint.go b/stanza/msg_hint.go new file mode 100644 index 0000000..205d0ef --- /dev/null +++ b/stanza/msg_hint.go @@ -0,0 +1,36 @@ +package stanza + +import "encoding/xml" + +/* +Support for: +- XEP-0334: Message Processing Hints: https://xmpp.org/extensions/xep-0334.html +Pointers should be used to keep consistent with unmarshal. Eg : +msg.Extensions = append(msg.Extensions, &stanza.HintNoCopy{}, &stanza.HintStore{}) +*/ + +type HintNoPermanentStore struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:hints no-permanent-store"` +} + +type HintNoStore struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:hints no-store"` +} + +type HintNoCopy struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:hints no-copy"` +} +type HintStore struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:hints store"` +} + +func init() { + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:hints", Local: "no-permanent-store"}, HintNoPermanentStore{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:hints", Local: "no-store"}, HintNoStore{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:hints", Local: "no-copy"}, HintNoCopy{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:hints", Local: "store"}, HintStore{}) +} diff --git a/stanza/msg_hint_test.go b/stanza/msg_hint_test.go new file mode 100644 index 0000000..b86d2f9 --- /dev/null +++ b/stanza/msg_hint_test.go @@ -0,0 +1,72 @@ +package stanza_test + +import ( + "encoding/xml" + "gosrc.io/xmpp/stanza" + "reflect" + "strings" + "testing" +) + +const msg_const = ` + + V unir avtugf pybnx gb uvqr zr sebz gurve fvtug + + + + +` + +func TestSerializationHint(t *testing.T) { + msg := stanza.NewMessage(stanza.Attrs{To: "juliet@capulet.lit/laptop", From: "romeo@montague.lit/laptop"}) + msg.Body = "V unir avtugf pybnx gb uvqr zr sebz gurve fvtug" + msg.Extensions = append(msg.Extensions, stanza.HintNoCopy{}, stanza.HintNoPermanentStore{}, stanza.HintNoStore{}, stanza.HintStore{}) + data, _ := xml.Marshal(msg) + if strings.ReplaceAll(strings.Join(strings.Fields(msg_const), ""), "\n", "") != strings.Join(strings.Fields(string(data)), "") { + t.Fatalf("marshalled message does not match expected message") + } +} + +func TestUnmarshalHints(t *testing.T) { + // Init message as in the const value + msgConst := stanza.NewMessage(stanza.Attrs{To: "juliet@capulet.lit/laptop", From: "romeo@montague.lit/laptop"}) + msgConst.Body = "V unir avtugf pybnx gb uvqr zr sebz gurve fvtug" + msgConst.Extensions = append(msgConst.Extensions, &stanza.HintNoCopy{}, &stanza.HintNoPermanentStore{}, &stanza.HintNoStore{}, &stanza.HintStore{}) + + // Compare message with the const value + msg := stanza.Message{} + err := xml.Unmarshal([]byte(msg_const), &msg) + if err != nil { + t.Fatal(err) + } + + if msgConst.XMLName.Local != msg.XMLName.Local { + t.Fatalf("message tags do not match. Expected: %s, Actual: %s", msgConst.XMLName.Local, msg.XMLName.Local) + } + if msgConst.Body != msg.Body { + t.Fatalf("message bodies do not match. Expected: %s, Actual: %s", msgConst.Body, msg.Body) + } + + if !reflect.DeepEqual(msgConst.Attrs, msg.Attrs) { + t.Fatalf("attributes do not match") + } + + if !reflect.DeepEqual(msgConst.Error, msg.Error) { + t.Fatalf("attributes do not match") + } + var found bool + for _, ext := range msgConst.Extensions { + for _, strExt := range msg.Extensions { + if reflect.TypeOf(ext) == reflect.TypeOf(strExt) { + found = true + break + } + } + if !found { + t.Fatalf("extensions do not match") + } + found = false + } +} diff --git a/stanza/msg_html.go b/stanza/msg_html.go index 1b4016c..f8fbba6 100644 --- a/stanza/msg_html.go +++ b/stanza/msg_html.go @@ -18,5 +18,5 @@ type HTMLBody struct { } func init() { - TypeRegistry.MapExtension(PKTMessage, xml.Name{"http://jabber.org/protocol/xhtml-im", "html"}, HTML{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "http://jabber.org/protocol/xhtml-im", Local: "html"}, HTML{}) } diff --git a/stanza/msg_oob.go b/stanza/msg_oob.go index 039fac1..92ccadf 100644 --- a/stanza/msg_oob.go +++ b/stanza/msg_oob.go @@ -17,5 +17,5 @@ type OOB struct { } func init() { - TypeRegistry.MapExtension(PKTMessage, xml.Name{"jabber:x:oob", "x"}, OOB{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "jabber:x:oob", Local: "x"}, OOB{}) } diff --git a/stanza/msg_pubsub_event.go b/stanza/msg_pubsub_event.go new file mode 100644 index 0000000..70db228 --- /dev/null +++ b/stanza/msg_pubsub_event.go @@ -0,0 +1,213 @@ +package stanza + +import ( + "encoding/xml" +) + +// Implementation of the http://jabber.org/protocol/pubsub#event namespace +type PubSubEvent struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/pubsub#event event"` + MsgExtension + EventElement EventElement + //List ItemsEvent +} + +func init() { + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "http://jabber.org/protocol/pubsub#event", Local: "event"}, PubSubEvent{}) +} + +type EventElement interface { + Name() string +} + +// ********************* +// Collection +// ********************* + +const PubSubCollectionEventName = "Collection" + +type CollectionEvent struct { + AssocDisassoc AssocDisassoc + Node string `xml:"node,attr,omitempty"` +} + +func (c CollectionEvent) Name() string { + return PubSubCollectionEventName +} + +// ********************* +// Associate/Disassociate +// ********************* +type AssocDisassoc interface { + GetAssocDisassoc() string +} + +// ********************* +// Associate +// ********************* +const Assoc = "Associate" + +type AssociateEvent struct { + XMLName xml.Name `xml:"associate"` + Node string `xml:"node,attr"` +} + +func (a *AssociateEvent) GetAssocDisassoc() string { + return Assoc +} + +// ********************* +// Disassociate +// ********************* +const Disassoc = "Disassociate" + +type DisassociateEvent struct { + XMLName xml.Name `xml:"disassociate"` + Node string `xml:"node,attr"` +} + +func (e *DisassociateEvent) GetAssocDisassoc() string { + return Disassoc +} + +// ********************* +// Configuration +// ********************* + +const PubSubConfigEventName = "Configuration" + +type ConfigurationEvent struct { + Node string `xml:"node,attr,omitempty"` + Form *Form +} + +func (c ConfigurationEvent) Name() string { + return PubSubConfigEventName +} + +// ********************* +// Delete +// ********************* +const PubSubDeleteEventName = "Delete" + +type DeleteEvent struct { + Node string `xml:"node,attr"` + Redirect *RedirectEvent `xml:"redirect"` +} + +func (c DeleteEvent) Name() string { + return PubSubConfigEventName +} + +// ********************* +// Redirect +// ********************* +type RedirectEvent struct { + URI string `xml:"uri,attr"` +} + +// ********************* +// List +// ********************* + +const PubSubItemsEventName = "List" + +type ItemsEvent struct { + XMLName xml.Name `xml:"items"` + Items []ItemEvent `xml:"item,omitempty"` + Node string `xml:"node,attr"` + Retract *RetractEvent `xml:"retract"` +} + +type ItemEvent struct { + XMLName xml.Name `xml:"item"` + Id string `xml:"id,attr,omitempty"` + Publisher string `xml:"publisher,attr,omitempty"` + Any *Node `xml:",any"` +} + +func (i ItemsEvent) Name() string { + return PubSubItemsEventName +} + +// ********************* +// List +// ********************* + +type RetractEvent struct { + XMLName xml.Name `xml:"retract"` + ID string `xml:"node,attr"` +} + +// ********************* +// Purge +// ********************* +const PubSubPurgeEventName = "Purge" + +type PurgeEvent struct { + XMLName xml.Name `xml:"purge"` + Node string `xml:"node,attr"` +} + +func (p PurgeEvent) Name() string { + return PubSubPurgeEventName +} + +// ********************* +// Subscription +// ********************* +const PubSubSubscriptionEventName = "Subscription" + +type SubscriptionEvent struct { + SubStatus string `xml:"subscription,attr,omitempty"` + Expiry string `xml:"expiry,attr,omitempty"` + SubInfo `xml:",omitempty"` +} + +func (s SubscriptionEvent) Name() string { + return PubSubSubscriptionEventName +} + +func (pse *PubSubEvent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + pse.XMLName = start.Name + // decode inner elements + for { + t, err := d.Token() + if err != nil { + return err + } + var ee EventElement + switch tt := t.(type) { + case xml.StartElement: + switch tt.Name.Local { + case "collection": + ee = &CollectionEvent{} + case "configuration": + ee = &ConfigurationEvent{} + case "delete": + ee = &DeleteEvent{} + case "items": + ee = &ItemsEvent{} + case "purge": + ee = &PurgeEvent{} + case "subscription": + ee = &SubscriptionEvent{} + default: + ee = nil + } + // known child element found, decode it + if ee != nil { + err = d.DecodeElement(ee, &tt) + if err != nil { + return err + } + pse.EventElement = ee + } + case xml.EndElement: + if tt == start.End() { + return nil + } + } + + } +} diff --git a/stanza/msg_pubsub_event_test.go b/stanza/msg_pubsub_event_test.go new file mode 100644 index 0000000..29a795d --- /dev/null +++ b/stanza/msg_pubsub_event_test.go @@ -0,0 +1,162 @@ +package stanza_test + +import ( + "encoding/xml" + "gosrc.io/xmpp/stanza" + "strings" + "testing" +) + +func TestDecodeMsgEvent(t *testing.T) { + str := ` + + + + + Soliloquy + + To be, or not to be: that is the question: + Whether 'tis nobler in the mind to suffer + The slings and arrows of outrageous fortune, + Or to take arms against a sea of troubles, + And by opposing end them? + + + tag:denmark.lit,2003:entry-32397 + 2003-12-13T18:30:02Z + 2003-12-13T18:30:02Z + + + + + + ` + parsedMessage := stanza.Message{} + if err := xml.Unmarshal([]byte(str), &parsedMessage); err != nil { + t.Errorf("message receipt unmarshall error: %v", err) + return + } + + if parsedMessage.Body != "" { + t.Errorf("Unexpected body: '%s'", parsedMessage.Body) + } + + if len(parsedMessage.Extensions) < 1 { + t.Errorf("no extension found on parsed message") + return + } + + switch ext := parsedMessage.Extensions[0].(type) { + case *stanza.PubSubEvent: + if ext.XMLName.Local != "event" { + t.Fatalf("unexpected extension: %s:%s", ext.XMLName.Space, ext.XMLName.Local) + } + tmp, ok := parsedMessage.Extensions[0].(*stanza.PubSubEvent) + if !ok { + t.Fatalf("unexpected extension element: %s:%s", ext.XMLName.Space, ext.XMLName.Local) + } + ie, ok := tmp.EventElement.(*stanza.ItemsEvent) + if !ok { + t.Fatalf("unexpected extension element: %s:%s", ext.XMLName.Space, ext.XMLName.Local) + } + if ie.Items[0].Any.Nodes[0].Content != "Soliloquy" { + t.Fatalf("could not read title ! Read this : %s", ie.Items[0].Any.Nodes[0].Content) + } + + if len(ie.Items[0].Any.Nodes) != 6 { + t.Fatalf("some nodes were not correctly parsed") + } + default: + t.Fatalf("could not find pubsub event extension") + } + +} + +func TestEncodeEvent(t *testing.T) { + expected := "" + + "" + + "My pub item title" + + "My pub item content summary" + + "My pub item content ID2003-12-13T18:30:02Z" + + "2003-12-13T18:30:02Z" + message := stanza.Message{ + Extensions: []stanza.MsgExtension{ + stanza.PubSubEvent{ + EventElement: stanza.ItemsEvent{ + Items: []stanza.ItemEvent{ + { + Id: "ae890ac52d0df67ed7cfdf51b644e901", + Any: &stanza.Node{ + XMLName: xml.Name{ + Space: "http://www.w3.org/2005/Atom", + Local: "entry", + }, + Attrs: nil, + Content: "", + Nodes: []stanza.Node{ + { + XMLName: xml.Name{Space: "", Local: "title"}, + Attrs: nil, + Content: "My pub item title", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "summary"}, + Attrs: nil, + Content: "My pub item content summary", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "link"}, + Attrs: []xml.Attr{ + { + Name: xml.Name{Space: "", Local: "rel"}, + Value: "alternate", + }, + { + Name: xml.Name{Space: "", Local: "type"}, + Value: "text/html", + }, + { + Name: xml.Name{Space: "", Local: "href"}, + Value: "http://denmark.lit/2003/12/13/atom03", + }, + }, + }, + { + XMLName: xml.Name{Space: "", Local: "id"}, + Attrs: nil, + Content: "My pub item content ID", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "published"}, + Attrs: nil, + Content: "2003-12-13T18:30:02Z", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "updated"}, + Attrs: nil, + Content: "2003-12-13T18:30:02Z", + Nodes: nil, + }, + }, + }, + }, + }, + Node: "princely_musings", + Retract: nil, + }, + }, + }, + } + + data, _ := xml.Marshal(message) + if strings.TrimSpace(string(data)) != strings.TrimSpace(expected) { + t.Errorf("event was not encoded properly : \nexpected:%s \ngot: %s", expected, string(data)) + } + +} diff --git a/stanza/msg_receipts.go b/stanza/msg_receipts.go index 85c2783..71fdd52 100644 --- a/stanza/msg_receipts.go +++ b/stanza/msg_receipts.go @@ -24,6 +24,6 @@ type ReceiptReceived struct { } func init() { - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgReceipts, "request"}, ReceiptRequest{}) - TypeRegistry.MapExtension(PKTMessage, xml.Name{NSMsgReceipts, "received"}, ReceiptReceived{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgReceipts, Local: "request"}, ReceiptRequest{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgReceipts, Local: "received"}, ReceiptReceived{}) } diff --git a/stanza/node.go b/stanza/node.go index 6afa7bc..308729c 100644 --- a/stanza/node.go +++ b/stanza/node.go @@ -46,9 +46,18 @@ func (n Node) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) { start.Name = n.XMLName err = e.EncodeToken(start) - e.EncodeElement(n.Nodes, xml.StartElement{Name: n.XMLName}) + if err != nil { + return err + } + err = e.EncodeElement(n.Nodes, xml.StartElement{Name: n.XMLName}) + if err != nil { + return err + } if n.Content != "" { - e.EncodeToken(xml.CharData(n.Content)) + err = e.EncodeToken(xml.CharData(n.Content)) + if err != nil { + return err + } } return e.EncodeToken(xml.EndElement{Name: start.Name}) } diff --git a/stanza/node_test.go b/stanza/node_test.go index aae699d..33649d9 100644 --- a/stanza/node_test.go +++ b/stanza/node_test.go @@ -8,7 +8,10 @@ import ( func TestNode_Marshal(t *testing.T) { jsonData := []byte("{\"key\":\"value\"}") - iqResp := NewIQ(Attrs{Type: "result", From: "admin@localhost", To: "test@localhost", Id: "1"}) + iqResp, err := NewIQ(Attrs{Type: "result", From: "admin@localhost", To: "test@localhost", Id: "1"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } iqResp.Any = &Node{ XMLName: xml.Name{Space: "myNS", Local: "space"}, Content: string(jsonData), diff --git a/stanza/packet_enum.go b/stanza/packet_enum.go index 103966a..84dd476 100644 --- a/stanza/packet_enum.go +++ b/stanza/packet_enum.go @@ -1,5 +1,7 @@ package stanza +import "strings" + type StanzaType string // RFC 6120: part of A.5 Client Namespace and A.6 Server Namespace @@ -23,3 +25,7 @@ const ( PresenceTypeUnsubscribe StanzaType = "unsubscribe" PresenceTypeUnsubscribed StanzaType = "unsubscribed" ) + +func (s StanzaType) IsEmpty() bool { + return len(strings.TrimSpace(string(s))) == 0 +} diff --git a/stanza/parser.go b/stanza/parser.go index 75f78e7..b5f11cf 100644 --- a/stanza/parser.go +++ b/stanza/parser.go @@ -50,11 +50,20 @@ func InitStream(p *xml.Decoder) (sessionID string, err error) { // TODO make auth and bind use NextPacket instead of directly NextStart func NextPacket(p *xml.Decoder) (Packet, error) { // Read start element to find out how we want to parse the XMPP packet - se, err := NextStart(p) + t, err := NextXmppToken(p) if err != nil { return nil, err } + if ee, ok := t.(xml.EndElement); ok { + return decodeStream(p, ee) + } + + // If not an end element, then must be a start + se, ok := t.(xml.StartElement) + if !ok { + return nil, errors.New("unknown token ") + } // Decode one of the top level XMPP namespace switch se.Name.Space { case NSStream: @@ -73,7 +82,29 @@ func NextPacket(p *xml.Decoder) (Packet, error) { } } -// Scan XML token stream to find next StartElement. +// NextXmppToken scans XML token stream to find next StartElement or stream EndElement. +// We need the EndElement scan, because we must register stream close tags +func NextXmppToken(p *xml.Decoder) (xml.Token, error) { + for { + t, err := p.Token() + if err == io.EOF { + return xml.StartElement{}, errors.New("connection closed") + } + if err != nil { + return xml.StartElement{}, fmt.Errorf("NextStart %s", err) + } + switch t := t.(type) { + case xml.StartElement: + return t, nil + case xml.EndElement: + if t.Name.Space == NSStream && t.Name.Local == "stream" { + return t, nil + } + } + } +} + +// NextStart scans XML token stream to find next StartElement. func NextStart(p *xml.Decoder) (xml.StartElement, error) { for { t, err := p.Token() @@ -97,16 +128,29 @@ TODO: From all the decoder, we can return a pointer to the actual concrete type, */ // decodeStream will fully decode a stream packet -func decodeStream(p *xml.Decoder, se xml.StartElement) (Packet, error) { - switch se.Name.Local { - case "error": - return streamError.decode(p, se) - case "features": - return streamFeatures.decode(p, se) - default: - return nil, errors.New("unexpected XMPP packet " + - se.Name.Space + " <" + se.Name.Local + "/>") +func decodeStream(p *xml.Decoder, t xml.Token) (Packet, error) { + if se, ok := t.(xml.StartElement); ok { + switch se.Name.Local { + case "error": + return streamError.decode(p, se) + case "features": + return streamFeatures.decode(p, se) + default: + return nil, errors.New("unexpected XMPP packet " + + se.Name.Space + " <" + se.Name.Local + "/>") + } } + + if ee, ok := t.(xml.EndElement); ok { + if ee.Name.Local == "stream" { + return streamClose.decode(ee), nil + } + return nil, errors.New("unexpected XMPP packet " + + ee.Name.Space + " <" + ee.Name.Local + "/>") + } + + // Should not happen + return nil, errors.New("unexpected XML token ") } // decodeSASL decodes a packet related to SASL authentication. diff --git a/stanza/pep.go b/stanza/pep.go index 7de57ea..cfd50c1 100644 --- a/stanza/pep.go +++ b/stanza/pep.go @@ -15,7 +15,7 @@ type Tune struct { Uri string `xml:"uri,omitempty"` } -// Mood defines deta model for XEP-0107 - User Mood +// Mood defines data model for XEP-0107 - User Mood // See: https://xmpp.org/extensions/xep-0107.html type Mood struct { MsgExtension // Mood can be added as a message extension diff --git a/stanza/pres_muc.go b/stanza/pres_muc.go index bc0e75e..e103a48 100644 --- a/stanza/pres_muc.go +++ b/stanza/pres_muc.go @@ -144,5 +144,5 @@ func (h History) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) } func init() { - TypeRegistry.MapExtension(PKTPresence, xml.Name{"http://jabber.org/protocol/muc", "x"}, MucPresence{}) + TypeRegistry.MapExtension(PKTPresence, xml.Name{Space: "http://jabber.org/protocol/muc", Local: "x"}, MucPresence{}) } diff --git a/stanza/pubsub.go b/stanza/pubsub.go index 010f8b0..f0aa1ce 100644 --- a/stanza/pubsub.go +++ b/stanza/pubsub.go @@ -2,39 +2,432 @@ package stanza import ( "encoding/xml" + "errors" + "strings" ) -type PubSub struct { +type PubSubGeneric struct { XMLName xml.Name `xml:"http://jabber.org/protocol/pubsub pubsub"` - Publish *Publish - Retract *Retract - // TODO + + Create *Create `xml:"create,omitempty"` + Configure *Configure `xml:"configure,omitempty"` + + Subscribe *SubInfo `xml:"subscribe,omitempty"` + SubOptions *SubOptions `xml:"options,omitempty"` + + Publish *Publish `xml:"publish,omitempty"` + PublishOptions *PublishOptions `xml:"publish-options"` + + Affiliations *Affiliations `xml:"affiliations,omitempty"` + Default *Default `xml:"default,omitempty"` + + Items *Items `xml:"items,omitempty"` + Retract *Retract `xml:"retract,omitempty"` + Subscription *Subscription `xml:"subscription,omitempty"` + + Subscriptions *Subscriptions `xml:"subscriptions,omitempty"` + // To use in responses to sub/unsub for instance + // Subscription options + Unsubscribe *SubInfo `xml:"unsubscribe,omitempty"` + + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` } -func (p *PubSub) Namespace() string { +func (p *PubSubGeneric) Namespace() string { return p.XMLName.Space } +func (p *PubSubGeneric) GetSet() *ResultSet { + return p.ResultSet +} + +type Affiliations struct { + List []Affiliation `xml:"affiliation"` + Node string `xml:"node,attr,omitempty"` +} + +type Affiliation struct { + AffiliationStatus string `xml:"affiliation"` + Node string `xml:"node,attr"` +} + +type Create struct { + Node string `xml:"node,attr,omitempty"` +} + +type SubOptions struct { + SubInfo + Form *Form `xml:"x"` +} + +type Configure struct { + Form *Form `xml:"x"` +} +type Default struct { + Node string `xml:"node,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` + Form *Form `xml:"x"` +} + +type Subscribe struct { + XMLName xml.Name `xml:"subscribe"` + SubInfo +} +type Unsubscribe struct { + XMLName xml.Name `xml:"unsubscribe"` + SubInfo +} + +// SubInfo represents information about a subscription +// Node is the node related to the subscription +// Jid is the subscription JID of the subscribed entity +// SubID is the subscription ID +type SubInfo struct { + Node string `xml:"node,attr,omitempty"` + Jid string `xml:"jid,attr,omitempty"` + // Sub ID is optional + SubId *string `xml:"subid,attr,omitempty"` +} + +// validate checks if a node and a jid are present in the sub info, and if this jid is valid. +func (si *SubInfo) validate() error { + // Requests MUST contain a valid JID + if _, err := NewJid(si.Jid); err != nil { + return err + } + // SubInfo must contain both a valid JID and a node. See XEP-0060 + if strings.TrimSpace(si.Node) == "" { + return errors.New("SubInfo must contain the node AND the subscriber JID in subscription config options requests") + } + return nil +} + +// Handles the "5.6 Retrieve Subscriptions" of XEP-0060 +type Subscriptions struct { + XMLName xml.Name `xml:"subscriptions"` + List []Subscription `xml:"subscription,omitempty"` +} + +// Handles the "5.6 Retrieve Subscriptions" and the 6.1 Subscribe to a Node and so on of XEP-0060 +type Subscription struct { + SubStatus string `xml:"subscription,attr,omitempty"` + SubInfo `xml:",omitempty"` + // Seems like we can't marshal a self-closing tag for now : https://github.com/golang/go/issues/21399 + // subscribe-options should be like this as per XEP-0060: + // + // + // + // Used to indicate if configuration options is required. + Required *struct{} +} + +type PublishOptions struct { + XMLName xml.Name `xml:"publish-options"` + Form *Form +} + type Publish struct { XMLName xml.Name `xml:"publish"` Node string `xml:"node,attr"` - Item Item + Items []Item `xml:"item,omitempty"` // xsd says there can be many. See also 12.10 Batch Processing of XEP-0060 +} + +type Items struct { + List []Item `xml:"item,omitempty"` + MaxItems int `xml:"max_items,attr,omitempty"` + Node string `xml:"node,attr"` + SubId string `xml:"subid,attr,omitempty"` } type Item struct { - XMLName xml.Name `xml:"item"` - Id string `xml:"id,attr,omitempty"` - Tune *Tune - Mood *Mood + XMLName xml.Name `xml:"item"` + Id string `xml:"id,attr,omitempty"` + Publisher string `xml:"publisher,attr,omitempty"` + Any *Node `xml:",any"` } type Retract struct { XMLName xml.Name `xml:"retract"` Node string `xml:"node,attr"` - Notify string `xml:"notify,attr"` - Item Item + Notify *bool `xml:"notify,attr,omitempty"` + Items []Item `xml:"item"` +} + +type PubSubOption struct { + XMLName xml.Name `xml:"jabber:x:data options"` + Form `xml:"x"` +} + +// NewSubRq builds a subscription request to a node at the given service. +// It's a Set type IQ. +// Information about the subscription and the requester are separated. subInfo contains information about the subscription. +// 6.1 Subscribe to a Node +func NewSubRq(serviceId string, subInfo SubInfo) (*IQ, error) { + if e := subInfo.validate(); e != nil { + return nil, e + } + + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Subscribe: &subInfo, + } + return iq, nil +} + +// NewUnsubRq builds an unsub request to a node at the given service. +// It's a Set type IQ +// Information about the subscription and the requester are separated. subInfo contains information about the subscription. +// 6.2 Unsubscribe from a Node +func NewUnsubRq(serviceId string, subInfo SubInfo) (*IQ, error) { + if e := subInfo.validate(); e != nil { + return nil, e + } + + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Unsubscribe: &subInfo, + } + return iq, nil +} + +// NewSubOptsRq builds a request for the subscription options. +// It's a Get type IQ +// Information about the subscription and the requester are separated. subInfo contains information about the subscription. +// 6.3 Configure Subscription Options +func NewSubOptsRq(serviceId string, subInfo SubInfo) (*IQ, error) { + if e := subInfo.validate(); e != nil { + return nil, e + } + + iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + SubOptions: &SubOptions{ + SubInfo: subInfo, + }, + } + return iq, nil +} + +// NewFormSubmission builds a form submission pubsub IQ +// Information about the subscription and the requester are separated. subInfo contains information about the subscription. +// 6.3.5 Form Submission +func NewFormSubmission(serviceId string, subInfo SubInfo, form *Form) (*IQ, error) { + if e := subInfo.validate(); e != nil { + return nil, e + } + if form.Type != FormTypeSubmit { + return nil, errors.New("form type was expected to be submit but was : " + form.Type) + } + + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + SubOptions: &SubOptions{ + SubInfo: subInfo, + Form: form, + }, + } + return iq, nil +} + +// NewSubAndConfig builds a subscribe request that contains configuration options for the service +// From XEP-0060 : The element MUST follow the element and +// MUST NOT possess a 'node' attribute or 'jid' attribute, +// since the value of the element's 'node' attribute specifies the desired NodeID and +// the value of the element's 'jid' attribute specifies the subscriber's JID +// 6.3.7 Subscribe and Configure +func NewSubAndConfig(serviceId string, subInfo SubInfo, form *Form) (*IQ, error) { + if e := subInfo.validate(); e != nil { + return nil, e + } + if form.Type != FormTypeSubmit { + return nil, errors.New("form type was expected to be submit but was : " + form.Type) + } + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Subscribe: &subInfo, + SubOptions: &SubOptions{ + SubInfo: SubInfo{SubId: subInfo.SubId}, + Form: form, + }, + } + return iq, nil + +} + +// NewItemsRequest creates a request to query existing items from a node. +// Specify a "maxItems" value to request only the last maxItems items. If 0, requests all items. +// 6.5.2 Requesting All List AND 6.5.7 Requesting the Most Recent List +func NewItemsRequest(serviceId string, node string, maxItems int) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Items: &Items{Node: node}, + } + + if maxItems != 0 { + ps, _ := iq.Payload.(*PubSubGeneric) + ps.Items.MaxItems = maxItems + } + return iq, nil +} + +// NewItemsRequest creates a request to get a specific item from a node. +// 6.5.8 Requesting a Particular Item +func NewSpecificItemRequest(serviceId, node, itemId string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Items: &Items{Node: node, + List: []Item{ + { + Id: itemId, + }, + }, + }, + } + return iq, nil +} + +// NewPublishItemRq creates a request to publish a single item to a node identified by its provided ID +func NewPublishItemRq(serviceId, nodeID, pubItemID string, item Item) (*IQ, error) { + // "The element MUST possess a 'node' attribute, specifying the NodeID of the node." + if strings.TrimSpace(nodeID) == "" { + return nil, errors.New("cannot publish without a target node ID") + } + + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Publish: &Publish{Node: nodeID, Items: []Item{item}}, + } + + // "The element provided by the publisher MAY possess an 'id' attribute, + // specifying a unique ItemID for the item. + // If an ItemID is not provided in the publish request, + // the pubsub service MUST generate one and MUST ensure that it is unique for that node." + if strings.TrimSpace(pubItemID) != "" { + ps, _ := iq.Payload.(*PubSubGeneric) + ps.Publish.Items[0].Id = pubItemID + } + return iq, nil +} + +// NewPublishItemOptsRq creates a request to publish items to a node identified by its provided ID, along with configuration options +// A pubsub service MAY support the ability to specify options along with a publish request +//(if so, it MUST advertise support for the "http://jabber.org/protocol/pubsub#publish-options" feature). +func NewPublishItemOptsRq(serviceId, nodeID string, items []Item, options *PublishOptions) (*IQ, error) { + // "The element MUST possess a 'node' attribute, specifying the NodeID of the node." + if strings.TrimSpace(nodeID) == "" { + return nil, errors.New("cannot publish without a target node ID") + } + + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Publish: &Publish{Node: nodeID, Items: items}, + PublishOptions: options, + } + + return iq, nil +} + +// NewDelItemFromNode creates a request to delete and item from a node, given its id. +// To delete an item, the publisher sends a retract request. +// This helper function follows 7.2 Delete an Item from a Node +func NewDelItemFromNode(serviceId, nodeID, itemId string, notify *bool) (*IQ, error) { + // "The element MUST possess a 'node' attribute, specifying the NodeID of the node." + if strings.TrimSpace(nodeID) == "" { + return nil, errors.New("cannot delete item without a target node ID") + } + + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Retract: &Retract{Node: nodeID, Items: []Item{{Id: itemId}}, Notify: notify}, + } + return iq, nil +} + +// NewCreateAndConfigNode makes a request for node creation that has the desired node configuration. +// See 8.1.3 Create and Configure a Node +func NewCreateAndConfigNode(serviceId, nodeID string, confForm *Form) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Create: &Create{Node: nodeID}, + Configure: &Configure{Form: confForm}, + } + return iq, nil +} + +// NewCreateNode builds a request to create a node on the service referenced by "serviceId" +// See 8.1 Create a Node +func NewCreateNode(serviceId, nodeName string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Create: &Create{Node: nodeName}, + } + return iq, nil +} + +// NewRetrieveAllSubsRequest builds a request to retrieve all subscriptions from all nodes +// In order to make the request, the requesting entity MUST send an IQ-get whose +// child contains an empty element with no attributes. +func NewRetrieveAllSubsRequest(serviceId string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Subscriptions: &Subscriptions{}, + } + return iq, nil +} + +// NewRetrieveAllAffilsRequest builds a request to retrieve all affiliations from all nodes +// In order to make the request of the service, the requesting entity includes an empty element with no attributes. +func NewRetrieveAllAffilsRequest(serviceId string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubGeneric{ + Affiliations: &Affiliations{}, + } + return iq, nil } func init() { - TypeRegistry.MapExtension(PKTIQ, xml.Name{"http://jabber.org/protocol/pubsub", "pubsub"}, PubSub{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "http://jabber.org/protocol/pubsub", Local: "pubsub"}, PubSubGeneric{}) } diff --git a/stanza/pubsub_owner.go b/stanza/pubsub_owner.go new file mode 100644 index 0000000..32d2773 --- /dev/null +++ b/stanza/pubsub_owner.go @@ -0,0 +1,451 @@ +package stanza + +import ( + "encoding/xml" + "errors" + "strings" +) + +type PubSubOwner struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/pubsub#owner pubsub"` + OwnerUseCase OwnerUseCase + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` +} + +func (pso *PubSubOwner) Namespace() string { + return pso.XMLName.Space +} + +func (pso *PubSubOwner) GetSet() *ResultSet { + return pso.ResultSet +} + +type OwnerUseCase interface { + UseCase() string +} + +type AffiliationsOwner struct { + XMLName xml.Name `xml:"affiliations"` + Affiliations []AffiliationOwner `xml:"affiliation,omitempty"` + Node string `xml:"node,attr"` +} + +func (AffiliationsOwner) UseCase() string { + return "affiliations" +} + +type AffiliationOwner struct { + XMLName xml.Name `xml:"affiliation"` + AffiliationStatus string `xml:"affiliation,attr"` + Jid string `xml:"jid,attr"` +} + +const ( + AffiliationStatusMember = "member" + AffiliationStatusNone = "none" + AffiliationStatusOutcast = "outcast" + AffiliationStatusOwner = "owner" + AffiliationStatusPublisher = "publisher" + AffiliationStatusPublishOnly = "publish-only" +) + +type ConfigureOwner struct { + XMLName xml.Name `xml:"configure"` + Node string `xml:"node,attr,omitempty"` + Form *Form `xml:"x,omitempty"` +} + +func (*ConfigureOwner) UseCase() string { + return "configure" +} + +type DefaultOwner struct { + XMLName xml.Name `xml:"default"` + Form *Form `xml:"x,omitempty"` +} + +func (*DefaultOwner) UseCase() string { + return "default" +} + +type DeleteOwner struct { + XMLName xml.Name `xml:"delete"` + RedirectOwner *RedirectOwner `xml:"redirect,omitempty"` + Node string `xml:"node,attr,omitempty"` +} + +func (*DeleteOwner) UseCase() string { + return "delete" +} + +type RedirectOwner struct { + XMLName xml.Name `xml:"redirect"` + URI string `xml:"uri,attr"` +} + +type PurgeOwner struct { + XMLName xml.Name `xml:"purge"` + Node string `xml:"node,attr"` +} + +func (*PurgeOwner) UseCase() string { + return "purge" +} + +type SubscriptionsOwner struct { + XMLName xml.Name `xml:"subscriptions"` + Subscriptions []SubscriptionOwner `xml:"subscription"` + Node string `xml:"node,attr"` +} + +func (*SubscriptionsOwner) UseCase() string { + return "subscriptions" +} + +type SubscriptionOwner struct { + SubscriptionStatus string `xml:"subscription"` + Jid string `xml:"jid,attr"` +} + +const ( + SubscriptionStatusNone = "none" + SubscriptionStatusPending = "pending" + SubscriptionStatusSubscribed = "subscribed" + SubscriptionStatusUnconfigured = "unconfigured" +) + +// NewConfigureNode creates a request to configure a node on the given service. +// A form will be returned by the service, to which the user must respond using for instance the NewFormSubmission function. +// See 8.2 Configure a Node +func NewConfigureNode(serviceId, nodeName string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubOwner{ + OwnerUseCase: &ConfigureOwner{Node: nodeName}, + } + return iq, nil +} + +// NewDelNode creates a request to delete node "nodeID" from the "serviceId" service +// See 8.4 Delete a Node +func NewDelNode(serviceId, nodeID string) (*IQ, error) { + if strings.TrimSpace(nodeID) == "" { + return nil, errors.New("cannot delete a node without a target node ID") + } + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubOwner{ + OwnerUseCase: &DeleteOwner{Node: nodeID}, + } + return iq, nil +} + +// NewPurgeAllItems creates a new purge request for the "nodeId" node, at "serviceId" service +// See 8.5 Purge All Node Items +func NewPurgeAllItems(serviceId, nodeId string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubOwner{ + OwnerUseCase: &PurgeOwner{Node: nodeId}, + } + return iq, nil +} + +// NewRequestDefaultConfig build a request to ask the service for the default config of its nodes +// See 8.3 Request Default Node Configuration Options +func NewRequestDefaultConfig(serviceId string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubOwner{ + OwnerUseCase: &DefaultOwner{}, + } + return iq, nil +} + +// NewApproveSubRequest creates a new sub approval response to a request from the service to the owner of the node +// In order to approve the request, the owner shall submit the form and set the "pubsub#allow" field to a value of "1" or "true" +// For tracking purposes the message MUST reflect the 'id' attribute originally provided in the request. +// See 8.6 Manage Subscription Requests +func NewApproveSubRequest(serviceId, reqID string, apprForm *Form) (Message, error) { + if serviceId == "" { + return Message{}, errors.New("need a target service serviceId send approval serviceId") + } + if reqID == "" { + return Message{}, errors.New("the request ID is empty but must be used for the approval") + } + if apprForm == nil { + return Message{}, errors.New("approval form is nil") + } + apprMess := NewMessage(Attrs{To: serviceId}) + apprMess.Extensions = []MsgExtension{apprForm} + apprMess.Id = reqID + + return apprMess, nil +} + +// NewGetPendingSubRequests creates a new request for all pending subscriptions to all their nodes at a service +// This feature MUST be implemented using the Ad-Hoc Commands (XEP-0050) protocol +// 8.7 Process Pending Subscription Requests +func NewGetPendingSubRequests(serviceId string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &Command{ + // the command name ('node' attribute of the command element) MUST have a value of "http://jabber.org/protocol/pubsub#get-pending" + Node: "http://jabber.org/protocol/pubsub#get-pending", + Action: CommandActionExecute, + } + return iq, nil +} + +// NewGetPendingSubRequests creates a new request for all pending subscriptions to be approved on a given node +// Upon receiving the data form for managing subscription requests, the owner then MAY request pending subscription +// approval requests for a given node. +// See 8.7.4 Per-Node Request +func NewApprovePendingSubRequest(serviceId, sessionId, nodeId string) (*IQ, error) { + if sessionId == "" { + return nil, errors.New("the sessionId must be maintained for the command") + } + + form := &Form{ + Type: FormTypeSubmit, + Fields: []*Field{{Var: "pubsub#node", ValuesList: []string{nodeId}}}, + } + data, err := xml.Marshal(form) + if err != nil { + return nil, err + } + var n Node + err = xml.Unmarshal(data, &n) + if err != nil { + return nil, err + } + + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &Command{ + // the command name ('node' attribute of the command element) MUST have a value of "http://jabber.org/protocol/pubsub#get-pending" + Node: "http://jabber.org/protocol/pubsub#get-pending", + Action: CommandActionExecute, + SessionId: sessionId, + CommandElement: &n, + } + return iq, nil +} + +// NewSubListRequest creates a request to list subscriptions of the client, for all nodes at the service. +// It's a Get type IQ +// 8.8.1 Retrieve Subscriptions +func NewSubListRqPl(serviceId, nodeID string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubOwner{ + OwnerUseCase: &SubscriptionsOwner{Node: nodeID}, + } + return iq, nil +} + +func NewSubsForEntitiesRequest(serviceId, nodeID string, subs []SubscriptionOwner) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubOwner{ + OwnerUseCase: &SubscriptionsOwner{Node: nodeID, Subscriptions: subs}, + } + return iq, nil +} + +// NewModifAffiliationRequest creates a request to either modify one or more affiliations, or delete one or more affiliations +// 8.9.2 Modify Affiliation & 8.9.2.4 Multiple Simultaneous Modifications & 8.9.3 Delete an Entity (just set the status to "none") +func NewModifAffiliationRequest(serviceId, nodeID string, newAffils []AffiliationOwner) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubOwner{ + OwnerUseCase: &AffiliationsOwner{ + Node: nodeID, + Affiliations: newAffils, + }, + } + return iq, nil +} + +// NewAffiliationListRequest creates a request to list all affiliated entities +// See 8.9.1 Retrieve List List +func NewAffiliationListRequest(serviceId, nodeID string) (*IQ, error) { + iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId}) + if err != nil { + return nil, err + } + iq.Payload = &PubSubOwner{ + OwnerUseCase: &AffiliationsOwner{ + Node: nodeID, + }, + } + return iq, nil +} + +// NewFormSubmission builds a form submission pubsub IQ, in the Owner namespace +// This is typically used to respond to a form issued by the server when configuring a node. +// See 8.2.4 Form Submission +func NewFormSubmissionOwner(serviceId, nodeName string, fields []*Field) (*IQ, error) { + if serviceId == "" || nodeName == "" { + return nil, errors.New("serviceId and nodeName must be filled for this request to be valid") + } + + submitConf, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId}) + if err != nil { + return nil, err + } + submitConf.Payload = &PubSubOwner{ + OwnerUseCase: &ConfigureOwner{ + Node: nodeName, + Form: NewForm(fields, + FormTypeSubmit)}, + } + + return submitConf, nil +} + +// GetFormFields gets the fields from a form in a IQ stanza of type result, as a map. +// Key is the "var" attribute of the field, and field is the value. +// The user can then select and modify the fields they want to alter, and submit a new form to the service using the +// NewFormSubmission function to build the IQ. +// TODO : remove restriction on IQ type ? +func (iq *IQ) GetFormFields() (map[string]*Field, error) { + if iq.Type != IQTypeResult { + return nil, errors.New("this IQ is not a result type IQ. Cannot extract the form from it") + } + switch payload := iq.Payload.(type) { + // We support IOT Control IQ + case *PubSubGeneric: + fieldMap := make(map[string]*Field) + for _, elt := range payload.Configure.Form.Fields { + fieldMap[elt.Var] = elt + } + return fieldMap, nil + case *PubSubOwner: + fieldMap := make(map[string]*Field) + co, ok := payload.OwnerUseCase.(*ConfigureOwner) + if !ok { + return nil, errors.New("this IQ does not contain a PubSub payload with a configure tag for the owner namespace") + } + for _, elt := range co.Form.Fields { + fieldMap[elt.Var] = elt + } + return fieldMap, nil + + case *Command: + fieldMap := make(map[string]*Field) + co, ok := payload.CommandElement.(*Form) + if !ok { + return nil, errors.New("this IQ does not contain a command payload with a form") + } + for _, elt := range co.Fields { + fieldMap[elt.Var] = elt + } + return fieldMap, nil + default: + if iq.Any != nil { + fieldMap := make(map[string]*Field) + if iq.Any.XMLName.Local != "command" { + return nil, errors.New("this IQ does not contain a form") + } + + for _, nde := range iq.Any.Nodes { + if nde.XMLName.Local == "x" { + for _, n := range nde.Nodes { + if n.XMLName.Local == "field" { + f := Field{} + data, err := xml.Marshal(n) + if err != nil { + continue + } + err = xml.Unmarshal(data, &f) + if err == nil { + fieldMap[f.Var] = &f + } + } + } + } + } + return fieldMap, nil + } + return nil, errors.New("this IQ does not contain a form") + } +} + +func (pso *PubSubOwner) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + pso.XMLName = start.Name + // decode inner elements + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + + case xml.StartElement: + // Decode sub-elements + var err error + switch tt.Name.Local { + + case "affiliations": + aff := AffiliationsOwner{} + err = d.DecodeElement(&aff, &tt) + pso.OwnerUseCase = &aff + case "configure": + co := ConfigureOwner{} + err = d.DecodeElement(&co, &tt) + pso.OwnerUseCase = &co + case "default": + def := DefaultOwner{} + err = d.DecodeElement(&def, &tt) + pso.OwnerUseCase = &def + case "delete": + del := DeleteOwner{} + err = d.DecodeElement(&del, &tt) + pso.OwnerUseCase = &del + case "purge": + pu := PurgeOwner{} + err = d.DecodeElement(&pu, &tt) + pso.OwnerUseCase = &pu + case "subscriptions": + subs := SubscriptionsOwner{} + err = d.DecodeElement(&subs, &tt) + pso.OwnerUseCase = &subs + if err != nil { + return err + } + } + if err != nil { + return err + } + case xml.EndElement: + if tt == start.End() { + return nil + } + } + } +} + +func init() { + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "http://jabber.org/protocol/pubsub#owner", Local: "pubsub"}, PubSubOwner{}) +} diff --git a/stanza/pubsub_owner_test.go b/stanza/pubsub_owner_test.go new file mode 100644 index 0000000..151688b --- /dev/null +++ b/stanza/pubsub_owner_test.go @@ -0,0 +1,885 @@ +package stanza_test + +import ( + "encoding/xml" + "errors" + "gosrc.io/xmpp/stanza" + "testing" +) + +// ****************************** +// * 8.2 Configure a Node +// ****************************** +func TestNewConfigureNode(t *testing.T) { + expectedReq := " " + + " " + + " " + + subR, err := stanza.NewConfigureNode("pubsub.shakespeare.lit", "princely_musings") + if err != nil { + t.Fatalf("failed to create a configure node request: %v", err) + } + subR.Id = "config1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + + if pubsub.OwnerUseCase == nil { + t.Fatalf("owner use case is nil") + } + + ownrUsecase, ok := pubsub.OwnerUseCase.(*stanza.ConfigureOwner) + if !ok { + t.Fatalf("owner use case is not a configure tag") + } + + if ownrUsecase.Node == "" { + t.Fatalf("could not parse node from config tag") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewConfigureNodeResp(t *testing.T) { + response := ` + + + + + + http://jabber.org/protocol/pubsub#node_config + + + 0 + + + 1028 + + + + + + never + + + 0 + + + + + headline + + + http://www.w3.org/2005/Atom + + + + + + +` + + pubsub, err := getPubSubOwnerPayload(response) + if err != nil { + t.Fatalf(err.Error()) + } + if pubsub.OwnerUseCase == nil { + t.Fatalf("owner use case is nil") + } + + ownrUsecase, ok := pubsub.OwnerUseCase.(*stanza.ConfigureOwner) + if !ok { + t.Fatalf("owner use case is not a configure tag") + } + + if ownrUsecase.Form == nil { + t.Fatalf("form is nil in the parsed config tag") + } + + if len(ownrUsecase.Form.Fields) != 8 { + t.Fatalf("one or more fields in the response form could not be parsed correctly") + } +} + +// ************************************************* +// * 8.3 Request Default Node Configuration Options +// ************************************************* + +func TestNewRequestDefaultConfig(t *testing.T) { + expectedReq := " " + + " " + + subR, err := stanza.NewRequestDefaultConfig("pubsub.shakespeare.lit") + if err != nil { + t.Fatalf("failed to create a default config request: %v", err) + } + subR.Id = "def1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + + if pubsub.OwnerUseCase == nil { + t.Fatalf("owner use case is nil") + } + + _, ok = pubsub.OwnerUseCase.(*stanza.DefaultOwner) + if !ok { + t.Fatalf("owner use case is not a default tag") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewRequestDefaultConfigResp(t *testing.T) { + response := ` + + + + + + http://jabber.org/protocol/pubsub#node_config + + + 0 + + + 1028 + + + + + + never + + + 0 + + + + + headline + + + http://www.w3.org/2005/Atom + + + + + + +` + + pubsub, err := getPubSubOwnerPayload(response) + if err != nil { + t.Fatalf(err.Error()) + } + if pubsub.OwnerUseCase == nil { + t.Fatalf("owner use case is nil") + } + + ownrUsecase, ok := pubsub.OwnerUseCase.(*stanza.ConfigureOwner) + if !ok { + t.Fatalf("owner use case is not a configure tag") + } + + if ownrUsecase.Form == nil { + t.Fatalf("form is nil in the parsed config tag") + } + + if len(ownrUsecase.Form.Fields) != 8 { + t.Fatalf("one or more fields in the response form could not be parsed correctly") + } +} + +// *********************** +// * 8.4 Delete a Node +// *********************** + +func TestNewDelNode(t *testing.T) { + expectedReq := "" + + " " + + " " + + subR, err := stanza.NewDelNode("pubsub.shakespeare.lit", "princely_musings") + if err != nil { + t.Fatalf("failed to create a node delete request: %v", err) + } + subR.Id = "delete1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + + if pubsub.OwnerUseCase == nil { + t.Fatalf("owner use case is nil") + } + + _, ok = pubsub.OwnerUseCase.(*stanza.DeleteOwner) + if !ok { + t.Fatalf("owner use case is not a delete tag") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewDelNodeResp(t *testing.T) { + response := ` + + + + + + + +` + + pubsub, err := getPubSubOwnerPayload(response) + if err != nil { + t.Fatalf(err.Error()) + } + if pubsub.OwnerUseCase == nil { + t.Fatalf("owner use case is nil") + } + + ownrUsecase, ok := pubsub.OwnerUseCase.(*stanza.DeleteOwner) + if !ok { + t.Fatalf("owner use case is not a configure tag") + } + + if ownrUsecase.RedirectOwner == nil { + t.Fatalf("redirect is nil in the delete tag") + } + + if ownrUsecase.RedirectOwner.URI == "" { + t.Fatalf("could not parse redirect uri") + } +} + +// **************************** +// * 8.5 Purge All Node Items +// **************************** + +func TestNewPurgeAllItems(t *testing.T) { + expectedReq := " " + + " " + + " " + + subR, err := stanza.NewPurgeAllItems("pubsub.shakespeare.lit", "princely_musings") + if err != nil { + t.Fatalf("failed to create a purge all items request: %v", err) + } + subR.Id = "purge1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + + if pubsub.OwnerUseCase == nil { + t.Fatalf("owner use case is nil") + } + + purge, ok := pubsub.OwnerUseCase.(*stanza.PurgeOwner) + if !ok { + t.Fatalf("owner use case is not a delete tag") + } + + if purge.Node == "" { + t.Fatalf("could not parse purge targer node") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +// ************************************ +// * 8.6 Manage Subscription Requests +// ************************************ +func TestNewApproveSubRequest(t *testing.T) { + expectedReq := " " + + " " + + "http://jabber.org/protocol/pubsub#subscribe_authorization " + + " 123-abc princely_musings " + + " horatio@denmark.lit " + + "true " + + apprForm := &stanza.Form{ + Type: stanza.FormTypeSubmit, + Fields: []*stanza.Field{ + {Var: "FORM_TYPE", Type: stanza.FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#subscribe_authorization"}}, + {Var: "pubsub#subid", ValuesList: []string{"123-abc"}}, + {Var: "pubsub#node", ValuesList: []string{"princely_musings"}}, + {Var: "pubsub#subscriber_jid", ValuesList: []string{"horatio@denmark.lit"}}, + {Var: "pubsub#allow", ValuesList: []string{"true"}}, + }, + } + + subR, err := stanza.NewApproveSubRequest("pubsub.shakespeare.lit", "approve1", apprForm) + if err != nil { + t.Fatalf("failed to create a sub approval request: %v", err) + } + subR.Id = "approve1" + + frm, ok := subR.Extensions[0].(*stanza.Form) + if !ok { + t.Fatalf("extension is not a from !") + } + + var allowField *stanza.Field + + for _, f := range frm.Fields { + if f.Var == "pubsub#allow" { + allowField = f + } + } + if allowField == nil || allowField.ValuesList[0] != "true" { + t.Fatalf("could not correctly parse the allow field in the response from") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +// ******************************************** +// * 8.7 Process Pending Subscription Requests +// ******************************************** + +func TestNewGetPendingSubRequests(t *testing.T) { + expectedReq := " " + + "" + + " " + + subR, err := stanza.NewGetPendingSubRequests("pubsub.shakespeare.lit") + if err != nil { + t.Fatalf("failed to create a get pending subs request: %v", err) + } + subR.Id = "pending1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + command, ok := subR.Payload.(*stanza.Command) + if !ok { + t.Fatalf("payload is not a command !") + } + + if command.Action != stanza.CommandActionExecute { + t.Fatalf("command should be execute !") + } + + if command.Node != "http://jabber.org/protocol/pubsub#get-pending" { + t.Fatalf("command node should be http://jabber.org/protocol/pubsub#get-pending !") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewGetPendingSubRequestsResp(t *testing.T) { + response := ` + + + + + http://jabber.org/protocol/pubsub#subscribe_authorization + + + + + + + + +` + + var respIQ stanza.IQ + err := xml.Unmarshal([]byte(response), &respIQ) + if err != nil { + t.Fatalf("could not parse iq") + } + + _, ok := respIQ.Payload.(*stanza.Command) + if !ok { + t.Fatal("this iq payload is not a command") + } + + fMap, err := respIQ.GetFormFields() + if err != nil || len(fMap) != 2 { + t.Fatal("could not parse command form fields") + } + +} + +// ******************************************** +// * 8.7 Process Pending Subscription Requests +// ******************************************** + +func TestNewApprovePendingSubRequest(t *testing.T) { + expectedReq := " " + + " " + + " " + + "princely_musings " + + subR, err := stanza.NewApprovePendingSubRequest("pubsub.shakespeare.lit", + "pubsub-get-pending:20031021T150901Z-600", + "princely_musings") + if err != nil { + t.Fatalf("failed to create a approve pending sub request: %v", err) + } + subR.Id = "pending2" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + command, ok := subR.Payload.(*stanza.Command) + if !ok { + t.Fatalf("payload is not a command !") + } + + if command.Action != stanza.CommandActionExecute { + t.Fatalf("command should be execute !") + } + + //if command.Node != "http://jabber.org/protocol/pubsub#get-pending"{ + // t.Fatalf("command node should be http://jabber.org/protocol/pubsub#get-pending !") + //} + // + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +// ******************************************** +// * 8.8.1 Retrieve Subscriptions List +// ******************************************** + +func TestNewSubListRqPl(t *testing.T) { + expectedReq := " " + + " " + + " " + + subR, err := stanza.NewSubListRqPl("pubsub.shakespeare.lit", "princely_musings") + if err != nil { + t.Fatalf("failed to create a sub list request: %v", err) + } + subR.Id = "subman1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatalf("payload is not a pubsub in namespace owner !") + } + + subs, ok := pubsub.OwnerUseCase.(*stanza.SubscriptionsOwner) + if !ok { + t.Fatalf("pubsub doesn not contain a subscriptions node !") + } + + if subs.Node != "princely_musings" { + t.Fatalf("subs node attribute should be princely_musings. Found %s", subs.Node) + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewSubListRqPlResp(t *testing.T) { + response := ` + + + + + + + + + + +` + + var respIQ stanza.IQ + err := xml.Unmarshal([]byte(response), &respIQ) + if err != nil { + t.Fatalf("could not parse iq") + } + + pubsub, ok := respIQ.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatal("this iq payload is not a command") + } + + subs, ok := pubsub.OwnerUseCase.(*stanza.SubscriptionsOwner) + if !ok { + t.Fatalf("pubsub doesn not contain a subscriptions node !") + } + + if len(subs.Subscriptions) != 4 { + t.Fatalf("expected to find 4 subscriptions but got %d", len(subs.Subscriptions)) + } + +} + +// ******************************************** +// * 8.9.1 Retrieve Affiliations List +// ******************************************** + +func TestNewAffiliationListRequest(t *testing.T) { + expectedReq := " " + + " " + + " " + + subR, err := stanza.NewAffiliationListRequest("pubsub.shakespeare.lit", "princely_musings") + if err != nil { + t.Fatalf("failed to create an affiliations list request: %v", err) + } + subR.Id = "ent1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatalf("payload is not a pubsub in namespace owner !") + } + + affils, ok := pubsub.OwnerUseCase.(*stanza.AffiliationsOwner) + if !ok { + t.Fatalf("pubsub doesn not contain an affiliations node !") + } + + if affils.Node != "princely_musings" { + t.Fatalf("affils node attribute should be princely_musings. Found %s", affils.Node) + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewAffiliationListRequestResp(t *testing.T) { + response := ` + + + + + + + + +` + + var respIQ stanza.IQ + err := xml.Unmarshal([]byte(response), &respIQ) + if err != nil { + t.Fatalf("could not parse iq") + } + + pubsub, ok := respIQ.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatal("this iq payload is not a command") + } + + affils, ok := pubsub.OwnerUseCase.(*stanza.AffiliationsOwner) + if !ok { + t.Fatalf("pubsub doesn not contain an affiliations node !") + } + + if len(affils.Affiliations) != 2 { + t.Fatalf("expected to find 2 subscriptions but got %d", len(affils.Affiliations)) + } + +} + +// ******************************************** +// * 8.9.2 Modify Affiliation +// ******************************************** + +func TestNewModifAffiliationRequest(t *testing.T) { + expectedReq := " " + + " " + + " " + + " " + + " " + + "" + + affils := []stanza.AffiliationOwner{ + { + AffiliationStatus: stanza.AffiliationStatusNone, + Jid: "hamlet@denmark.lit", + }, + { + AffiliationStatus: stanza.AffiliationStatusNone, + Jid: "polonius@denmark.lit", + }, + { + AffiliationStatus: stanza.AffiliationStatusPublisher, + Jid: "bard@shakespeare.lit", + }, + } + + subR, err := stanza.NewModifAffiliationRequest("pubsub.shakespeare.lit", "princely_musings", affils) + if err != nil { + t.Fatalf("failed to create a modif affiliation request: %v", err) + } + subR.Id = "ent3" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatalf("payload is not a pubsub in namespace owner !") + } + + as, ok := pubsub.OwnerUseCase.(*stanza.AffiliationsOwner) + if !ok { + t.Fatalf("pubsub doesn not contain an affiliations node !") + } + + if as.Node != "princely_musings" { + t.Fatalf("affils node attribute should be princely_musings. Found %s", as.Node) + } + if len(as.Affiliations) != 3 { + t.Fatalf("expected 3 affiliations, found %d", len(as.Affiliations)) + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestGetFormFields(t *testing.T) { + response := ` + + + + + + http://jabber.org/protocol/pubsub#node_config + + + 0 + + + 1028 + + + + + + never + + + 0 + + + + + headline + + + http://www.w3.org/2005/Atom + + + + + + +` + var iq stanza.IQ + err := xml.Unmarshal([]byte(response), &iq) + if err != nil { + t.Fatalf("could not parse IQ") + } + + fields, err := iq.GetFormFields() + if len(fields) != 8 { + t.Fatalf("could not correctly parse fields. Expected 8, found : %v", len(fields)) + } + +} + +func TestGetFormFieldsCmd(t *testing.T) { + response := ` + + + + + http://jabber.org/protocol/pubsub#subscribe_authorization + + + + + + + + +` + var iq stanza.IQ + err := xml.Unmarshal([]byte(response), &iq) + if err != nil { + t.Fatalf("could not parse IQ") + } + + fields, err := iq.GetFormFields() + if len(fields) != 2 { + t.Fatalf("could not correctly parse fields. Expected 2, found : %v", len(fields)) + } + +} + +func TestNewFormSubmissionOwner(t *testing.T) { + expectedReq := "" + + " " + + " " + + "http://jabber.org/protocol/pubsub#node_config " + + "604800 roster " + + " friends servants " + + "courtiers " + + subR, err := stanza.NewFormSubmissionOwner("pubsub.shakespeare.lit", + "princely_musings", + []*stanza.Field{ + {Var: "FORM_TYPE", Type: stanza.FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#node_config"}}, + {Var: "pubsub#item_expire", ValuesList: []string{"604800"}}, + {Var: "pubsub#access_model", ValuesList: []string{"roster"}}, + {Var: "pubsub#roster_groups_allowed", ValuesList: []string{"friends", "servants", "courtiers"}}, + }) + if err != nil { + t.Fatalf("failed to create a form submission request: %v", err) + } + subR.Id = "config2" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubOwner) + if !ok { + t.Fatalf("payload is not a pubsub in namespace owner !") + } + + conf, ok := pubsub.OwnerUseCase.(*stanza.ConfigureOwner) + if !ok { + t.Fatalf("pubsub does not contain a configure node !") + } + + if conf.Form == nil { + t.Fatalf("the form is absent from the configuration submission !") + } + if len(conf.Form.Fields) != 4 { + t.Fatalf("expected 4 fields, found %d", len(conf.Form.Fields)) + } + if len(conf.Form.Fields[3].ValuesList) != 3 { + t.Fatalf("expected 3 values in fourth field, found %d", len(conf.Form.Fields[3].ValuesList)) + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func getPubSubOwnerPayload(response string) (*stanza.PubSubOwner, error) { + var respIQ stanza.IQ + err := xml.Unmarshal([]byte(response), &respIQ) + + if err != nil { + return &stanza.PubSubOwner{}, err + } + + pubsub, ok := respIQ.Payload.(*stanza.PubSubOwner) + if !ok { + return nil, errors.New("this iq payload is not a pubsub of the owner namespace") + } + + return pubsub, nil +} diff --git a/stanza/pubsub_test.go b/stanza/pubsub_test.go new file mode 100644 index 0000000..88d19d9 --- /dev/null +++ b/stanza/pubsub_test.go @@ -0,0 +1,922 @@ +package stanza_test + +import ( + "encoding/xml" + "errors" + "gosrc.io/xmpp/stanza" + "strings" + "testing" +) + +var submitFormExample = stanza.NewForm([]*stanza.Field{ + {Var: "FORM_TYPE", Type: stanza.FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#node_config"}}, + {Var: "pubsub#title", ValuesList: []string{"Princely Musings (Atom)"}}, + {Var: "pubsub#deliver_notifications", ValuesList: []string{"1"}}, + {Var: "pubsub#access_model", ValuesList: []string{"roster"}}, + {Var: "pubsub#roster_groups_allowed", ValuesList: []string{"friends", "servants", "courtiers"}}, + {Var: "pubsub#type", ValuesList: []string{"http://www.w3.org/2005/Atom"}}, + { + Var: "pubsub#notification_type", + Type: "list-single", + Label: "Specify the delivery style for event notifications", + ValuesList: []string{"headline"}, + Options: []stanza.Option{ + {ValuesList: []string{"normal"}}, + {ValuesList: []string{"headline"}}, + }, + }, +}, stanza.FormTypeSubmit) + +// *********************************** +// * 6.1 Subscribe to a Node +// *********************************** + +func TestNewSubRequest(t *testing.T) { + expectedReq := " " + + " " + + " " + + subInfo := stanza.SubInfo{ + Node: "princely_musings", Jid: "francisco@denmark.lit", + } + subR, err := stanza.NewSubRq("pubsub.shakespeare.lit", subInfo) + if err != nil { + t.Fatalf("failed to create a sub request: %v", err) + } + subR.Id = "sub1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } + +} + +func TestNewSubResp(t *testing.T) { + response := ` + + + + + +` + + pubsub, err := getPubSubGenericPayload(response) + if err != nil { + t.Fatalf(err.Error()) + } + + if pubsub.Subscription == nil { + t.Fatalf("subscription node is nil") + } + if pubsub.Subscription.Node == "" || + pubsub.Subscription.Jid == "" || + pubsub.Subscription.SubId == nil || + pubsub.Subscription.SubStatus == "" { + t.Fatalf("one or more of the subscription attributes was not successfully decoded") + } + +} + +// *********************************** +// * 6.2 Unsubscribe from a Node +// *********************************** + +func TestNewUnSubRequest(t *testing.T) { + expectedReq := " " + + " " + + " " + + subInfo := stanza.SubInfo{ + Node: "princely_musings", Jid: "francisco@denmark.lit", + } + subR, err := stanza.NewUnsubRq("pubsub.shakespeare.lit", subInfo) + if err != nil { + t.Fatalf("failed to create an unsub request: %v", err) + } + subR.Id = "unsub1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.Unsubscribe == nil { + t.Fatalf("Unsubscribe tag should be present in sub config options request") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewUnsubResp(t *testing.T) { + response := ` + + + + + +` + + pubsub, err := getPubSubGenericPayload(response) + if err != nil { + t.Fatalf(err.Error()) + } + + if pubsub.Subscription == nil { + t.Fatalf("subscription node is nil") + } + if pubsub.Subscription.Node == "" || + pubsub.Subscription.Jid == "" || + pubsub.Subscription.SubId == nil || + pubsub.Subscription.SubStatus == "" { + t.Fatalf("one or more of the subscription attributes was not successfully decoded") + } + +} + +// *************************************** +// * 6.3 Configure Subscription Options +// *************************************** +func TestNewSubOptsRq(t *testing.T) { + expectedReq := " " + + " " + + " " + + subInfo := stanza.SubInfo{ + Node: "princely_musings", Jid: "francisco@denmark.lit", + } + subR, err := stanza.NewSubOptsRq("pubsub.shakespeare.lit", subInfo) + if err != nil { + t.Fatalf("failed to create a sub options request: %v", err) + } + subR.Id = "options1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.SubOptions == nil { + t.Fatalf("Options tag should be present in sub config options request") + } + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewNewConfOptsRsp(t *testing.T) { + response := ` + + + + + + http://jabber.org/protocol/pubsub#subscribe_options + + + 1 + + + 0 + + + false + + + + + + + + chat + online + + + + + +` + + pubsub, err := getPubSubGenericPayload(response) + if err != nil { + t.Fatalf(err.Error()) + } + + if pubsub.SubOptions == nil { + t.Fatalf("sub options node is nil") + } + if pubsub.SubOptions.Form == nil { + t.Fatalf("the response form is nil") + } + + if len(pubsub.SubOptions.Form.Fields) != 5 { + t.Fatalf("one or more fields in the response form could not be parsed correctly") + } +} + +// *************************************** +// * 6.3.5 Form Submission +// *************************************** +func TestNewFormSubmission(t *testing.T) { + expectedReq := " " + + " " + + " " + + " http://jabber.org/protocol/pubsub#node_config " + + "Princely Musings (Atom) " + + "1 roster " + + " friends servants" + + " courtiers http://www.w3.org/2005/Atom " + + " " + + "headline " + + " " + + subInfo := stanza.SubInfo{ + Node: "princely_musings", Jid: "francisco@denmark.lit", + } + + subR, err := stanza.NewFormSubmission("pubsub.shakespeare.lit", subInfo, submitFormExample) + if err != nil { + t.Fatalf("failed to create a form submission request: %v", err) + } + subR.Id = "options2" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.SubOptions == nil { + t.Fatalf("Options tag should be present in sub config options request") + } + if pubsub.SubOptions.Form == nil { + t.Fatalf("No form in form submit request !") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +// *************************************** +// * 6.3.7 Subscribe and Configure +// *************************************** + +func TestNewSubAndConfig(t *testing.T) { + expectedReq := "" + + " " + + "" + + " " + + " http://jabber.org/protocol/pubsub#node_config " + + "Princely Musings (Atom) " + + "1 roster " + + " friends servants" + + " courtiers http://www.w3.org/2005/Atom " + + " " + + "headline " + + " " + + subInfo := stanza.SubInfo{ + Node: "princely_musings", Jid: "francisco@denmark.lit", + } + + subR, err := stanza.NewSubAndConfig("pubsub.shakespeare.lit", subInfo, submitFormExample) + if err != nil { + t.Fatalf("failed to create a sub and config request: %v", err) + } + subR.Id = "sub1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.SubOptions == nil { + t.Fatalf("Options tag should be present in sub config options request") + } + if pubsub.SubOptions.Form == nil { + t.Fatalf("No form in form submit request !") + } + + // The element MUST NOT possess a 'node' attribute or 'jid' attribute + // See XEP-0060 + if pubsub.SubOptions.SubInfo.Node != "" || pubsub.SubOptions.SubInfo.Jid != "" { + t.Fatalf("SubInfo node and jid should be empty for the options tag !") + } + if pubsub.Subscribe.Node == "" || pubsub.Subscribe.Jid == "" { + t.Fatalf("SubInfo node and jid should NOT be empty for the subscribe tag !") + } + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewSubAndConfigResp(t *testing.T) { + response := ` + + + + + + + http://jabber.org/protocol/pubsub#subscribe_options + + + 1 + + + 0 + + + false + + + chat + online + away + + + + + + +` + + pubsub, err := getPubSubGenericPayload(response) + if err != nil { + t.Fatalf(err.Error()) + } + if pubsub.Subscription == nil { + t.Fatalf("sub node is nil") + } + + if pubsub.SubOptions == nil { + t.Fatalf("sub options node is nil") + } + if pubsub.SubOptions.Form == nil { + t.Fatalf("the response form is nil") + } + + if len(pubsub.SubOptions.Form.Fields) != 5 { + t.Fatalf("one or more fields in the response form could not be parsed correctly") + } +} + +// *************************************** +// * 6.5.2 Requesting All List +// *************************************** +func TestNewItemsRequest(t *testing.T) { + subR, err := stanza.NewItemsRequest("pubsub.shakespeare.lit", "princely_musings", 0) + if err != nil { + t.Fatalf("Could not create an items request : %s", err) + } + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.Items == nil { + t.Fatalf("List tag should be present to request items from a service") + } + if len(pubsub.Items.List) != 0 { + t.Fatalf("There should be no items in the tag to request all items from a service") + } +} +func TestNewItemsResp(t *testing.T) { + response := ` + + + + + + Alone + Now I am alone. O, what a rogue and peasant slave am I! + + tag:denmark.lit,2003:entry-32396 + 2003-12-13T11:09:53Z + 2003-12-13T11:09:53Z + + + + + Soliloquy + To be, or not to be: that is the question: Whether 'tis nobler in the + mind to suffer The slings and arrows of outrageous fortune, Or to take arms + against a sea of troubles, And by opposing end them? + + tag:denmark.lit,2003:entry-32397 + 2003-12-13T18:30:02Z + 2003-12-13T18:30:02Z + + + + + +` + + pubsub, err := getPubSubGenericPayload(response) + if err != nil { + t.Fatalf(err.Error()) + } + if pubsub.Items == nil { + t.Fatalf("sub options node is nil") + } + if pubsub.Items.List == nil { + t.Fatalf("the response form is nil") + } + + if len(pubsub.Items.List) != 2 { + t.Fatalf("one or more items in the response could not be parsed correctly") + } +} + +// *************************************** +// * 6.5.8 Requesting a Particular Item +// *************************************** +func TestNewSpecificItemRequest(t *testing.T) { + expectedReq := " " + + " " + + " " + + subR, err := stanza.NewSpecificItemRequest("pubsub.shakespeare.lit", "princely_musings", "ae890ac52d0df67ed7cfdf51b644e901") + if err != nil { + t.Fatalf("failed to create a specific item request: %v", err) + } + subR.Id = "items3" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.Items == nil { + t.Fatalf("List tag should be present to request items from a service") + } + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +// *************************************** +// * 7.1 Publish an Item to a Node +// *************************************** +func TestNewPublishItemRq(t *testing.T) { + item := stanza.Item{ + XMLName: xml.Name{}, + Id: "", + Publisher: "", + Any: &stanza.Node{ + XMLName: xml.Name{ + Space: "http://www.w3.org/2005/Atom", + Local: "entry", + }, + Attrs: nil, + Content: "", + Nodes: []stanza.Node{ + { + XMLName: xml.Name{Space: "", Local: "title"}, + Attrs: nil, + Content: "My pub item title", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "summary"}, + Attrs: nil, + Content: "My pub item content summary", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "link"}, + Attrs: []xml.Attr{ + { + Name: xml.Name{Space: "", Local: "rel"}, + Value: "alternate", + }, + { + Name: xml.Name{Space: "", Local: "type"}, + Value: "text/html", + }, + { + Name: xml.Name{Space: "", Local: "href"}, + Value: "http://denmark.lit/2003/12/13/atom03", + }, + }, + }, + { + XMLName: xml.Name{Space: "", Local: "id"}, + Attrs: nil, + Content: "My pub item content ID", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "published"}, + Attrs: nil, + Content: "2003-12-13T18:30:02Z", + Nodes: nil, + }, + { + XMLName: xml.Name{Space: "", Local: "updated"}, + Attrs: nil, + Content: "2003-12-13T18:30:02Z", + Nodes: nil, + }, + }, + }, + } + + subR, err := stanza.NewPublishItemRq("pubsub.shakespeare.lit", "princely_musings", "bnd81g37d61f49fgn581", item) + if err != nil { + t.Fatalf("Could not create an item pub request : %s", err) + } + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated sub request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + + if strings.TrimSpace(pubsub.Publish.Node) == "" { + t.Fatalf("the element MUST possess a 'node' attribute, specifying the NodeID of the node.") + } + if pubsub.Publish.Items[0].Id == "" { + t.Fatalf("an id was provided for the item and it should be used") + } +} + +// *************************************** +// * 7.1.5 Publishing Options +// *************************************** + +func TestNewPublishItemOptsRq(t *testing.T) { + expectedReq := " " + + " " + + " Soliloquy " + + " To be, or not to be: that is the question: Whether \"tis nobler in the mind to suffer The " + + "slings and arrows of outrageous fortune, Or to take arms against a sea of troubles, And by opposing end them? " + + " " + + "tag:denmark.lit,2003:entry-32397 2003-12-13T18:30:02Z " + + "2003-12-13T18:30:02Z " + + " " + + "http://jabber.org/protocol/pubsub#publish-options " + + "presence " + + var iq stanza.IQ + err := xml.Unmarshal([]byte(expectedReq), &iq) + if err != nil { + t.Fatalf("could not unmarshal example request : %s", err) + } + + pubsub, ok := iq.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.Publish == nil { + t.Fatalf("Publish tag is empty") + } + if len(pubsub.Publish.Items) != 1 { + t.Fatalf("could not parse item properly") + } +} + +// *************************************** +// * 7.2 Delete an Item from a Node +// *************************************** + +func TestNewDelItemFromNode(t *testing.T) { + expectedReq := " " + + " " + + " " + + subR, err := stanza.NewDelItemFromNode("pubsub.shakespeare.lit", "princely_musings", "ae890ac52d0df67ed7cfdf51b644e901", nil) + if err != nil { + t.Fatalf("failed to create a delete item from node request: %v", err) + } + subR.Id = "retract1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated del item request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.Retract == nil { + t.Fatalf("Retract tag should be present to del an item from a service") + } + + if strings.TrimSpace(pubsub.Retract.Items[0].Id) == "" { + t.Fatalf("Item id, for the item to delete, should be non empty") + } + if pubsub.Retract.Items[0].Any != nil { + t.Fatalf("Item node must be empty") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +// *************************************** +// * 8.1 Create a Node +// *************************************** + +func TestNewCreateNode(t *testing.T) { + expectedReq := " " + + " " + + subR, err := stanza.NewCreateNode("pubsub.shakespeare.lit", "princely_musings") + if err != nil { + t.Fatalf("failed to create a create node request: %v", err) + } + subR.Id = "create1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated del item request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.Create == nil { + t.Fatalf("Create tag should be present to create a node on a service") + } + + if strings.TrimSpace(pubsub.Create.Node) == "" { + t.Fatalf("Expected node name to be present") + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestNewCreateNodeResp(t *testing.T) { + response := ` + + + + + +` + pubsub, err := getPubSubGenericPayload(response) + if err != nil { + t.Fatalf(err.Error()) + } + if pubsub.Create == nil { + t.Fatalf("create segment is nil") + } + if pubsub.Create.Node == "" { + t.Fatalf("could not parse generated nodeId") + } + +} + +// *************************************** +// * 8.1.3 Create and Configure a Node +// *************************************** + +func TestNewCreateAndConfigNode(t *testing.T) { + expectedReq := " " + + " " + + " " + + "http://jabber.org/protocol/pubsub#node_config " + + "0 0 " + + " 1028 " + + subR, err := stanza.NewCreateAndConfigNode("pubsub.shakespeare.lit", + "princely_musings", + &stanza.Form{ + Type: stanza.FormTypeSubmit, + Fields: []*stanza.Field{ + {Var: "FORM_TYPE", Type: stanza.FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#node_config"}}, + {Var: "pubsub#notify_retract", ValuesList: []string{"0"}}, + {Var: "pubsub#notify_sub", ValuesList: []string{"0"}}, + {Var: "pubsub#max_payload_size", ValuesList: []string{"1028"}}, + }, + }) + + if err != nil { + t.Fatalf("failed to create a create and config node request: %v", err) + } + subR.Id = "create1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated del item request : %s", e) + } + + pubsub, ok := subR.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("payload is not a pubsub !") + } + if pubsub.Create == nil { + t.Fatalf("Create tag should be present to create a node on a service") + } + + if strings.TrimSpace(pubsub.Create.Node) == "" { + t.Fatalf("Expected node name to be present") + } + + if pubsub.Configure == nil { + t.Fatalf("Configure tag should be present to configure a node during its creation on a service") + } + + if pubsub.Configure.Form == nil { + t.Fatalf("Expected a form to be present, to configure the node") + } + if len(pubsub.Configure.Form.Fields) != 4 { + t.Fatalf("Expected 4 elements to be present in the config form but got : %v", len(pubsub.Configure.Form.Fields)) + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expectedReq, string(data)); err != nil { + t.Fatalf(err.Error()) + } + +} + +// ******************************** +// * 5.7 Retrieve Subscriptions +// ******************************** + +func TestNewRetrieveAllSubsRequest(t *testing.T) { + expected := " " + + " " + + subR, err := stanza.NewRetrieveAllSubsRequest("pubsub.shakespeare.lit") + if err != nil { + t.Fatalf("failed to create a get all subs request: %v", err) + } + subR.Id = "subscriptions1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated del item request : %s", e) + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expected, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestRetrieveAllSubsResp(t *testing.T) { + response := ` + + + + + + + + + + + +` + var respIQ stanza.IQ + err := xml.Unmarshal([]byte(response), &respIQ) + + if err != nil { + t.Fatalf("could not unmarshal response: %s", err) + } + + pubsub, ok := respIQ.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("umarshalled payload is not a pubsub") + } + + if pubsub.Subscriptions == nil { + t.Fatalf("subscriptions node is nil") + } + if len(pubsub.Subscriptions.List) != 5 { + t.Fatalf("incorrect number of decoded subscriptions") + } +} + +// ******************************** +// * 5.7 Retrieve Affiliations +// ******************************** + +func TestNewRetrieveAllAffilsRequest(t *testing.T) { + expected := " " + + " " + + subR, err := stanza.NewRetrieveAllAffilsRequest("pubsub.shakespeare.lit") + if err != nil { + t.Fatalf("failed to create a get all affiliations request: %v", err) + } + subR.Id = "affil1" + + if _, e := checkMarshalling(t, subR); e != nil { + t.Fatalf("Failed to check marshalling for generated retreive all affiliations request : %s", e) + } + + data, err := xml.Marshal(subR) + if err := compareMarshal(expected, string(data)); err != nil { + t.Fatalf(err.Error()) + } +} + +func TestRetrieveAllAffilsResp(t *testing.T) { + response := ` + + + + + + + + + + +` + var respIQ stanza.IQ + err := xml.Unmarshal([]byte(response), &respIQ) + + if err != nil { + t.Fatalf("could not unmarshal response: %s", err) + } + + pubsub, ok := respIQ.Payload.(*stanza.PubSubGeneric) + if !ok { + t.Fatalf("umarshalled payload is not a pubsub") + } + + if pubsub.Affiliations == nil { + t.Fatalf("subscriptions node is nil") + } + if len(pubsub.Affiliations.List) != 4 { + t.Fatalf("incorrect number of decoded subscriptions") + } +} + +func getPubSubGenericPayload(response string) (*stanza.PubSubGeneric, error) { + var respIQ stanza.IQ + err := xml.Unmarshal([]byte(response), &respIQ) + + if err != nil { + return &stanza.PubSubGeneric{}, err + } + + pubsub, ok := respIQ.Payload.(*stanza.PubSubGeneric) + if !ok { + return nil, errors.New("this iq payload is not a pubsub") + } + + return pubsub, nil +} diff --git a/stanza/results_sets.go b/stanza/results_sets.go new file mode 100644 index 0000000..6cbab0f --- /dev/null +++ b/stanza/results_sets.go @@ -0,0 +1,29 @@ +package stanza + +import ( + "encoding/xml" +) + +// Support for XEP-0059 +// See https://xmpp.org/extensions/xep-0059 +const ( + // Common but not only possible namespace for query blocks in a result set context + NSQuerySet = "jabber:iq:search" +) + +type ResultSet struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/rsm set"` + After *string `xml:"after,omitempty"` + Before *string `xml:"before,omitempty"` + Count *int `xml:"count,omitempty"` + First *First `xml:"first,omitempty"` + Index *int `xml:"index,omitempty"` + Last *string `xml:"last,omitempty"` + Max *int `xml:"max,omitempty"` +} + +type First struct { + XMLName xml.Name `xml:"first"` + Content string + Index *int `xml:"index,attr,omitempty"` +} diff --git a/stanza/results_sets_test.go b/stanza/results_sets_test.go new file mode 100644 index 0000000..2ddd123 --- /dev/null +++ b/stanza/results_sets_test.go @@ -0,0 +1,28 @@ +package stanza_test + +import ( + "gosrc.io/xmpp/stanza" + "testing" +) + +// Limiting the number of items +func TestNewResultSetReq(t *testing.T) { + expectedRq := " " + + " " + + "urn:xmpp:mam:2 2010-08-07T00:00:00Z " + + " 10 " + + maxVal := 10 + rs := &stanza.ResultSet{ + Max: &maxVal, + } + + // TODO when Mam is implemented + _ = expectedRq + _ = rs +} + +func TestUnmarshalResultSeqReq(t *testing.T) { + // TODO when Mam is implemented + +} diff --git a/stanza/sasl_auth.go b/stanza/sasl_auth.go index d04174f..2fb660e 100644 --- a/stanza/sasl_auth.go +++ b/stanza/sasl_auth.go @@ -69,12 +69,18 @@ type Bind struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-bind bind"` Resource string `xml:"resource,omitempty"` Jid string `xml:"jid,omitempty"` + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` } func (b *Bind) Namespace() string { return b.XMLName.Space } +func (b *Bind) GetSet() *ResultSet { + return b.ResultSet +} + // ============================================================================ // Session (Obsolete) @@ -87,17 +93,23 @@ func (b *Bind) Namespace() string { // This is the draft defining how to handle the transition: // https://tools.ietf.org/html/draft-cridland-xmpp-session-01 type StreamSession struct { - XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-session session"` - Optional bool // If element does exist, it mean we are not required to open session + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-session session"` + Optional *struct{} // If element does exist, it mean we are not required to open session + // Result sets + ResultSet *ResultSet `xml:"set,omitempty"` } func (s *StreamSession) Namespace() string { return s.XMLName.Space } +func (s *StreamSession) GetSet() *ResultSet { + return s.ResultSet +} + func (s *StreamSession) IsOptional() bool { if s.XMLName.Local == "session" { - return s.Optional + return s.Optional != nil } // If session element is missing, then we should not use session return true @@ -107,6 +119,6 @@ func (s *StreamSession) IsOptional() bool { // Registry init func init() { - TypeRegistry.MapExtension(PKTIQ, xml.Name{"urn:ietf:params:xml:ns:xmpp-bind", "bind"}, Bind{}) - TypeRegistry.MapExtension(PKTIQ, xml.Name{"urn:ietf:params:xml:ns:xmpp-session", "session"}, StreamSession{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-bind", Local: "bind"}, Bind{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-session", Local: "session"}, StreamSession{}) } diff --git a/stanza/sasl_auth_test.go b/stanza/sasl_auth_test.go index d9ba1dc..600035c 100644 --- a/stanza/sasl_auth_test.go +++ b/stanza/sasl_auth_test.go @@ -9,7 +9,7 @@ import ( // Check that we can detect optional session from advertised stream features func TestSessionFeatures(t *testing.T) { - streamFeatures := stanza.StreamFeatures{Session: stanza.StreamSession{Optional: true}} + streamFeatures := stanza.StreamFeatures{Session: stanza.StreamSession{Optional: &struct{}{}}} data, err := xml.Marshal(streamFeatures) if err != nil { @@ -28,8 +28,11 @@ func TestSessionFeatures(t *testing.T) { // Check that the Session tag can be used in IQ decoding func TestSessionIQ(t *testing.T) { - iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeSet, Id: "session"}) - iq.Payload = &stanza.StreamSession{XMLName: xml.Name{Local: "session"}, Optional: true} + iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeSet, Id: "session"}) + if err != nil { + t.Fatalf("failed to create IQ: %v", err) + } + iq.Payload = &stanza.StreamSession{XMLName: xml.Name{Local: "session"}, Optional: &struct{}{}} data, err := xml.Marshal(iq) if err != nil { diff --git a/stanza/stanza_errors.go b/stanza/stanza_errors.go new file mode 100644 index 0000000..c66ea33 --- /dev/null +++ b/stanza/stanza_errors.go @@ -0,0 +1,171 @@ +package stanza + +import ( + "encoding/xml" +) + +type StanzaErrorGroup interface { + GroupErrorName() string +} + +type BadFormat struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas bad-format"` +} + +func (e *BadFormat) GroupErrorName() string { return "bad-format" } + +type BadNamespacePrefix struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas bad-namespace-prefix"` +} + +func (e *BadNamespacePrefix) GroupErrorName() string { return "bad-namespace-prefix" } + +type Conflict struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas conflict"` +} + +func (e *Conflict) GroupErrorName() string { return "conflict" } + +type ConnectionTimeout struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas connection-timeout"` +} + +func (e *ConnectionTimeout) GroupErrorName() string { return "connection-timeout" } + +type HostGone struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas host-gone"` +} + +func (e *HostGone) GroupErrorName() string { return "host-gone" } + +type HostUnknown struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas host-unknown"` +} + +func (e *HostUnknown) GroupErrorName() string { return "host-unknown" } + +type ImproperAddressing struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas improper-addressing"` +} + +func (e *ImproperAddressing) GroupErrorName() string { return "improper-addressing" } + +type InternalServerError struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas internal-server-error"` +} + +func (e *InternalServerError) GroupErrorName() string { return "internal-server-error" } + +type InvalidForm struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas invalid-from"` +} + +func (e *InvalidForm) GroupErrorName() string { return "invalid-from" } + +type InvalidId struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas invalid-id"` +} + +func (e *InvalidId) GroupErrorName() string { return "invalid-id" } + +type InvalidNamespace struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas invalid-namespace"` +} + +func (e *InvalidNamespace) GroupErrorName() string { return "invalid-namespace" } + +type InvalidXML struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas invalid-xml"` +} + +func (e *InvalidXML) GroupErrorName() string { return "invalid-xml" } + +type NotAuthorized struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas not-authorized"` +} + +func (e *NotAuthorized) GroupErrorName() string { return "not-authorized" } + +type NotWellFormed struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas not-well-formed"` +} + +func (e *NotWellFormed) GroupErrorName() string { return "not-well-formed" } + +type PolicyViolation struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas policy-violation"` +} + +func (e *PolicyViolation) GroupErrorName() string { return "policy-violation" } + +type RemoteConnectionFailed struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas remote-connection-failed"` +} + +func (e *RemoteConnectionFailed) GroupErrorName() string { return "remote-connection-failed" } + +type Reset struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas reset"` +} + +func (e *Reset) GroupErrorName() string { return "reset" } + +type ResourceConstraint struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas resource-constraint"` +} + +func (e *ResourceConstraint) GroupErrorName() string { return "resource-constraint" } + +type RestrictedXML struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas restricted-xml"` +} + +func (e *RestrictedXML) GroupErrorName() string { return "restricted-xml" } + +type SeeOtherHost struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas see-other-host"` +} + +func (e *SeeOtherHost) GroupErrorName() string { return "see-other-host" } + +type SystemShutdown struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas system-shutdown"` +} + +func (e *SystemShutdown) GroupErrorName() string { return "system-shutdown" } + +type UndefinedCondition struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas undefined-condition"` +} + +func (e *UndefinedCondition) GroupErrorName() string { return "undefined-condition" } + +type UnsupportedEncoding struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas unsupported-encoding"` +} + +type UnexpectedRequest struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas unexpected-request"` +} + +func (e *UnexpectedRequest) GroupErrorName() string { return "unexpected-request" } + +func (e *UnsupportedEncoding) GroupErrorName() string { return "unsupported-encoding" } + +type UnsupportedStanzaType struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas unsupported-stanza-type"` +} + +func (e *UnsupportedStanzaType) GroupErrorName() string { return "unsupported-stanza-type" } + +type UnsupportedVersion struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas unsupported-version"` +} + +func (e *UnsupportedVersion) GroupErrorName() string { return "unsupported-version" } + +type XMLNotWellFormed struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas xml-not-well-formed"` +} + +func (e *XMLNotWellFormed) GroupErrorName() string { return "xml-not-well-formed" } diff --git a/stanza/stream.go b/stanza/stream.go index 290abfe..6ab4bad 100644 --- a/stanza/stream.go +++ b/stanza/stream.go @@ -8,7 +8,9 @@ import "encoding/xml" type Stream struct { XMLName xml.Name `xml:"http://etherx.jabber.org/streams stream"` From string `xml:"from,attr"` - To string `xml:"to,attr"` + To string `xml:"to,attr"` Id string `xml:"id,attr"` Version string `xml:"version,attr"` } + +const StreamClose = "" diff --git a/stanza/stream_features.go b/stanza/stream_features.go index 11cd96b..d1b6274 100644 --- a/stanza/stream_features.go +++ b/stanza/stream_features.go @@ -15,7 +15,7 @@ type StreamFeatures struct { // Server capabilities hash Caps Caps // Stream features - StartTLS tlsStartTLS + StartTLS TlsStartTLS Mechanisms saslMechanisms Bind Bind StreamManagement streamManagement @@ -60,13 +60,13 @@ type Caps struct { // StartTLS feature // Reference: RFC 6120 - https://tools.ietf.org/html/rfc6120#section-5.4 -type tlsStartTLS struct { +type TlsStartTLS struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls starttls"` Required bool } // UnmarshalXML implements custom parsing startTLS required flag -func (stls *tlsStartTLS) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { +func (stls *TlsStartTLS) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { stls.XMLName = start.Name // Check subelements to extract required field as boolean @@ -98,7 +98,7 @@ func (stls *tlsStartTLS) UnmarshalXML(d *xml.Decoder, start xml.StartElement) er } } -func (sf *StreamFeatures) DoesStartTLS() (feature tlsStartTLS, isSupported bool) { +func (sf *StreamFeatures) DoesStartTLS() (feature TlsStartTLS, isSupported bool) { if sf.StartTLS.XMLName.Space+" "+sf.StartTLS.XMLName.Local == nsTLS+" starttls" { return sf.StartTLS, true } @@ -118,6 +118,10 @@ type streamManagement struct { XMLName xml.Name `xml:"urn:xmpp:sm:3 sm"` } +func (streamManagement) Name() string { + return "streamManagement" +} + func (sf *StreamFeatures) DoesStreamManagement() (isSupported bool) { if sf.StreamManagement.XMLName.Space+" "+sf.StreamManagement.XMLName.Local == "urn:xmpp:sm:3 sm" { return true @@ -165,3 +169,21 @@ func (streamErrorDecoder) decode(p *xml.Decoder, se xml.StartElement) (StreamErr err := p.DecodeElement(&packet, &se) return packet, err } + +// ============================================================================ +// StreamClose "Packet" + +// This is just a closing tag and hold no information +type StreamClosePacket struct{} + +func (StreamClosePacket) Name() string { + return "stream:stream" +} + +type streamCloseDecoder struct{} + +var streamClose streamCloseDecoder + +func (streamCloseDecoder) decode(_ xml.EndElement) StreamClosePacket { + return StreamClosePacket{} +} diff --git a/stanza/stream_management.go b/stanza/stream_management.go index ddbe9cd..a2a4f0b 100644 --- a/stanza/stream_management.go +++ b/stanza/stream_management.go @@ -3,12 +3,19 @@ package stanza import ( "encoding/xml" "errors" + "sync" ) const ( NSStreamManagement = "urn:xmpp:sm:3" ) +type SMEnable struct { + XMLName xml.Name `xml:"urn:xmpp:sm:3 enable"` + Max *uint `xml:"max,attr,omitempty"` + Resume *bool `xml:"resume,attr,omitempty"` +} + // Enabled as defined in Stream Management spec // Reference: https://xmpp.org/extensions/xep-0198.html#enable type SMEnabled struct { @@ -23,6 +30,112 @@ func (SMEnabled) Name() string { return "Stream Management: enabled" } +type UnAckQueue struct { + Uslice []*UnAckedStz + sync.RWMutex +} +type UnAckedStz struct { + Id int + Stz string +} + +func NewUnAckQueue() *UnAckQueue { + return &UnAckQueue{ + Uslice: make([]*UnAckedStz, 0, 10), // Capacity is 0 to comply with "Push" implementation (so that no reachable element is nil) + RWMutex: sync.RWMutex{}, + } +} + +func (u *UnAckedStz) QueueableName() string { + return "Un-acknowledged stanza" +} + +func (uaq *UnAckQueue) PeekN(n int) []Queueable { + if uaq == nil { + return nil + } + if n <= 0 { + return nil + } + if len(uaq.Uslice) < n { + n = len(uaq.Uslice) + } + + if len(uaq.Uslice) == 0 { + return nil + } + var r []Queueable + for i := 0; i < n; i++ { + r = append(r, uaq.Uslice[i]) + } + return r +} + +// No guarantee regarding thread safety ! +func (uaq *UnAckQueue) Pop() Queueable { + if uaq == nil { + return nil + } + r := uaq.Peek() + if r != nil { + uaq.Uslice = uaq.Uslice[1:] + } + return r +} + +// No guarantee regarding thread safety ! +func (uaq *UnAckQueue) PopN(n int) []Queueable { + if uaq == nil { + return nil + } + r := uaq.PeekN(n) + uaq.Uslice = uaq.Uslice[len(r):] + return r +} + +func (uaq *UnAckQueue) Peek() Queueable { + if uaq == nil { + return nil + } + if len(uaq.Uslice) == 0 { + return nil + } + r := uaq.Uslice[0] + return r +} + +func (uaq *UnAckQueue) Push(s Queueable) error { + if uaq == nil { + return nil + } + pushIdx := 1 + if len(uaq.Uslice) != 0 { + pushIdx = uaq.Uslice[len(uaq.Uslice)-1].Id + 1 + } + + sStz, ok := s.(*UnAckedStz) + if !ok { + return errors.New("element in not compatible with this queue. expected an UnAckedStz") + } + + e := UnAckedStz{ + Id: pushIdx, + Stz: sStz.Stz, + } + + uaq.Uslice = append(uaq.Uslice, &e) + + return nil +} + +func (uaq *UnAckQueue) Empty() bool { + if uaq == nil { + return true + } + r := len(uaq.Uslice) + return r == 0 +} + // Request as defined in Stream Management spec // Reference: https://xmpp.org/extensions/xep-0198.html#acking type SMRequest struct { @@ -37,7 +150,7 @@ func (SMRequest) Name() string { // Reference: https://xmpp.org/extensions/xep-0198.html#acking type SMAnswer struct { XMLName xml.Name `xml:"urn:xmpp:sm:3 a"` - H uint `xml:"h,attr,omitempty"` + H uint `xml:"h,attr"` } func (SMAnswer) Name() string { @@ -49,24 +162,175 @@ func (SMAnswer) Name() string { type SMResumed struct { XMLName xml.Name `xml:"urn:xmpp:sm:3 resumed"` PrevId string `xml:"previd,attr,omitempty"` - H uint `xml:"h,attr,omitempty"` + H *uint `xml:"h,attr,omitempty"` } func (SMResumed) Name() string { return "Stream Management: resumed" } +// Resume as defined in Stream Management spec +// Reference: https://xmpp.org/extensions/xep-0198.html#acking +type SMResume struct { + XMLName xml.Name `xml:"urn:xmpp:sm:3 resume"` + PrevId string `xml:"previd,attr,omitempty"` + H *uint `xml:"h,attr,omitempty"` +} + +func (SMResume) Name() string { + return "Stream Management: resume" +} + // Failed as defined in Stream Management spec // Reference: https://xmpp.org/extensions/xep-0198.html#acking type SMFailed struct { XMLName xml.Name `xml:"urn:xmpp:sm:3 failed"` - // TODO: Handle decoding error cause (need custom parsing). + H *uint `xml:"h,attr,omitempty"` + + StreamErrorGroup StanzaErrorGroup } func (SMFailed) Name() string { return "Stream Management: failed" } +func (smf *SMFailed) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + smf.XMLName = start.Name + + // According to https://xmpp.org/rfcs/rfc3920.html#def we should have no attributes aside from the namespace + // which we don't use internally + + // decode inner elements + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + + case xml.StartElement: + // Decode sub-elements + var err error + switch tt.Name.Local { + case "bad-format": + bf := BadFormat{} + err = d.DecodeElement(&bf, &tt) + smf.StreamErrorGroup = &bf + case "bad-namespace-prefix": + bnp := BadNamespacePrefix{} + err = d.DecodeElement(&bnp, &tt) + smf.StreamErrorGroup = &bnp + case "conflict": + c := Conflict{} + err = d.DecodeElement(&c, &tt) + smf.StreamErrorGroup = &c + case "connection-timeout": + ct := ConnectionTimeout{} + err = d.DecodeElement(&ct, &tt) + smf.StreamErrorGroup = &ct + case "host-gone": + hg := HostGone{} + err = d.DecodeElement(&hg, &tt) + smf.StreamErrorGroup = &hg + case "host-unknown": + hu := HostUnknown{} + err = d.DecodeElement(&hu, &tt) + smf.StreamErrorGroup = &hu + case "improper-addressing": + ia := ImproperAddressing{} + err = d.DecodeElement(&ia, &tt) + smf.StreamErrorGroup = &ia + case "internal-server-error": + ise := InternalServerError{} + err = d.DecodeElement(&ise, &tt) + smf.StreamErrorGroup = &ise + case "invalid-from": + ifrm := InvalidForm{} + err = d.DecodeElement(&ifrm, &tt) + smf.StreamErrorGroup = &ifrm + case "invalid-id": + id := InvalidId{} + err = d.DecodeElement(&id, &tt) + smf.StreamErrorGroup = &id + case "invalid-namespace": + ins := InvalidNamespace{} + err = d.DecodeElement(&ins, &tt) + smf.StreamErrorGroup = &ins + case "invalid-xml": + ix := InvalidXML{} + err = d.DecodeElement(&ix, &tt) + smf.StreamErrorGroup = &ix + case "not-authorized": + na := NotAuthorized{} + err = d.DecodeElement(&na, &tt) + smf.StreamErrorGroup = &na + case "not-well-formed": + nwf := NotWellFormed{} + err = d.DecodeElement(&nwf, &tt) + smf.StreamErrorGroup = &nwf + case "policy-violation": + pv := PolicyViolation{} + err = d.DecodeElement(&pv, &tt) + smf.StreamErrorGroup = &pv + case "remote-connection-failed": + rcf := RemoteConnectionFailed{} + err = d.DecodeElement(&rcf, &tt) + smf.StreamErrorGroup = &rcf + case "resource-constraint": + rc := ResourceConstraint{} + err = d.DecodeElement(&rc, &tt) + smf.StreamErrorGroup = &rc + case "restricted-xml": + rx := RestrictedXML{} + err = d.DecodeElement(&rx, &tt) + smf.StreamErrorGroup = &rx + case "see-other-host": + soh := SeeOtherHost{} + err = d.DecodeElement(&soh, &tt) + smf.StreamErrorGroup = &soh + case "system-shutdown": + ss := SystemShutdown{} + err = d.DecodeElement(&ss, &tt) + smf.StreamErrorGroup = &ss + case "undefined-condition": + uc := UndefinedCondition{} + err = d.DecodeElement(&uc, &tt) + smf.StreamErrorGroup = &uc + case "unexpected-request": + ur := UnexpectedRequest{} + err = d.DecodeElement(&ur, &tt) + smf.StreamErrorGroup = &ur + case "unsupported-encoding": + ue := UnsupportedEncoding{} + err = d.DecodeElement(&ue, &tt) + smf.StreamErrorGroup = &ue + case "unsupported-stanza-type": + ust := UnsupportedStanzaType{} + err = d.DecodeElement(&ust, &tt) + smf.StreamErrorGroup = &ust + case "unsupported-version": + uv := UnsupportedVersion{} + err = d.DecodeElement(&uv, &tt) + smf.StreamErrorGroup = &uv + case "xml-not-well-formed": + xnwf := XMLNotWellFormed{} + err = d.DecodeElement(&xnwf, &tt) + smf.StreamErrorGroup = &xnwf + default: + return errors.New("error is unknown") + } + if err != nil { + return err + } + case xml.EndElement: + if tt == start.End() { + return nil + } + } + } +} + type smDecoder struct{} var sm smDecoder @@ -78,9 +342,11 @@ func (s smDecoder) decode(p *xml.Decoder, se xml.StartElement) (Packet, error) { return s.decodeEnabled(p, se) case "resumed": return s.decodeResumed(p, se) + case "resume": + return s.decodeResume(p, se) case "r": return s.decodeRequest(p, se) - case "h": + case "a": return s.decodeAnswer(p, se) case "failed": return s.decodeFailed(p, se) @@ -102,6 +368,11 @@ func (smDecoder) decodeResumed(p *xml.Decoder, se xml.StartElement) (SMResumed, return packet, err } +func (smDecoder) decodeResume(p *xml.Decoder, se xml.StartElement) (SMResume, error) { + var packet SMResume + err := p.DecodeElement(&packet, &se) + return packet, err +} func (smDecoder) decodeRequest(p *xml.Decoder, se xml.StartElement) (SMRequest, error) { var packet SMRequest err := p.DecodeElement(&packet, &se) diff --git a/stanza/stream_management_test.go b/stanza/stream_management_test.go new file mode 100644 index 0000000..1b3443e --- /dev/null +++ b/stanza/stream_management_test.go @@ -0,0 +1,226 @@ +package stanza_test + +import ( + "gosrc.io/xmpp/stanza" + "math/rand" + "reflect" + "testing" + "time" +) + +func TestPopEmptyQueue(t *testing.T) { + var uaq stanza.UnAckQueue + popped := uaq.Pop() + if popped != nil { + t.Fatalf("queue is empty but something was popped !") + } +} + +func TestPushUnack(t *testing.T) { + uaq := initUnAckQueue() + toPush := stanza.UnAckedStz{ + Id: 3, + Stz: ` + + confucius + Qui + Kong + +`, + } + + err := uaq.Push(&toPush) + if err != nil { + t.Fatalf("could not push element to the queue : %v", err) + } + + if len(uaq.Uslice) != 4 { + t.Fatalf("push to the non-acked queue failed") + } + for i := 0; i < 4; i++ { + if uaq.Uslice[i].Id != i+1 { + t.Fatalf("indexes were not updated correctly. Expected %d got %d", i, uaq.Uslice[i].Id) + } + } + + // Check that the queue is a fifo : popped element should not be the one we just pushed. + popped := uaq.Pop() + poppedElt, ok := popped.(*stanza.UnAckedStz) + if !ok { + t.Fatalf("popped element is not a *stanza.UnAckedStz") + } + + if reflect.DeepEqual(*poppedElt, toPush) { + t.Fatalf("pushed element is at the top of the fifo queue when it should be at the bottom") + } + +} + +func TestPeekUnack(t *testing.T) { + uaq := initUnAckQueue() + + expectedPeek := stanza.UnAckedStz{ + Id: 1, + Stz: ` + + Capulet + +`, + } + + if !reflect.DeepEqual(expectedPeek, *uaq.Uslice[0]) { + t.Fatalf("peek failed to return the correct stanza") + } + +} + +func TestPeekNUnack(t *testing.T) { + uaq := initUnAckQueue() + initLen := len(uaq.Uslice) + randPop := rand.Int31n(int32(initLen)) + + peeked := uaq.PeekN(int(randPop)) + + if len(uaq.Uslice) != initLen { + t.Fatalf("queue length changed whith peek n operation : had %d found %d after peek", initLen, len(uaq.Uslice)) + } + + if len(peeked) != int(randPop) { + t.Fatalf("did not peek the correct number of element from queue. Expected %d got %d", randPop, len(peeked)) + } +} + +func TestPeekNUnackTooLong(t *testing.T) { + uaq := initUnAckQueue() + initLen := len(uaq.Uslice) + + // Have a random number of elements to peek that's greater than the queue size + randPop := rand.Int31n(int32(initLen)) + 1 + int32(initLen) + + peeked := uaq.PeekN(int(randPop)) + + if len(uaq.Uslice) != initLen { + t.Fatalf("total length changed whith peek n operation : had %d found %d after pop", initLen, len(uaq.Uslice)) + } + + if len(peeked) != initLen { + t.Fatalf("did not peek the correct number of element from queue. Expected %d got %d", initLen, len(peeked)) + } + +} + +func TestPopNUnack(t *testing.T) { + uaq := initUnAckQueue() + initLen := len(uaq.Uslice) + randPop := rand.Int31n(int32(initLen)) + + popped := uaq.PopN(int(randPop)) + + if len(uaq.Uslice)+len(popped) != initLen { + t.Fatalf("total length changed whith pop n operation : had %d found %d after pop", initLen, len(uaq.Uslice)+len(popped)) + } + + for _, elt := range popped { + for _, oldElt := range uaq.Uslice { + if reflect.DeepEqual(elt, oldElt) { + t.Fatalf("pop n operation duplicated some elements") + } + } + } +} + +func TestPopNUnackTooLong(t *testing.T) { + uaq := initUnAckQueue() + initLen := len(uaq.Uslice) + + // Have a random number of elements to pop that's greater than the queue size + randPop := rand.Int31n(int32(initLen)) + 1 + int32(initLen) + + popped := uaq.PopN(int(randPop)) + + if len(uaq.Uslice)+len(popped) != initLen { + t.Fatalf("total length changed whith pop n operation : had %d found %d after pop", initLen, len(uaq.Uslice)+len(popped)) + } + + for _, elt := range popped { + for _, oldElt := range uaq.Uslice { + if reflect.DeepEqual(elt, oldElt) { + t.Fatalf("pop n operation duplicated some elements") + } + } + } +} + +func TestPopUnack(t *testing.T) { + uaq := initUnAckQueue() + initLen := len(uaq.Uslice) + + popped := uaq.Pop() + + if len(uaq.Uslice)+1 != initLen { + t.Fatalf("total length changed whith pop operation : had %d found %d after pop", initLen, len(uaq.Uslice)+1) + } + for _, oldElt := range uaq.Uslice { + if reflect.DeepEqual(popped, oldElt) { + t.Fatalf("pop n operation duplicated some elements") + } + } + +} + +func initUnAckQueue() stanza.UnAckQueue { + q := []*stanza.UnAckedStz{ + { + Id: 1, + Stz: ` + + Capulet + +`, + }, + {Id: 2, + Stz: ` + +`}, + {Id: 3, + Stz: ` + + + + jabber:iq:search + + + male + + + +`}, + } + + return stanza.UnAckQueue{Uslice: q} + +} + +func init() { + rand.Seed(time.Now().UTC().UnixNano()) +} diff --git a/stanza/xmpp_test.go b/stanza/xmpp_test.go index 420a053..b39613b 100644 --- a/stanza/xmpp_test.go +++ b/stanza/xmpp_test.go @@ -2,16 +2,21 @@ package stanza_test import ( "encoding/xml" + "errors" + "regexp" "testing" "github.com/google/go-cmp/cmp" "gosrc.io/xmpp/stanza" ) +var reLeadcloseWhtsp = regexp.MustCompile(`^[\s\p{Zs}]+|[\s\p{Zs}]+$`) +var reInsideWhtsp = regexp.MustCompile(`[\s\p{Zs}]`) + // ============================================================================ // Marshaller / unmarshaller test -func checkMarshalling(t *testing.T, iq stanza.IQ) (*stanza.IQ, error) { +func checkMarshalling(t *testing.T, iq *stanza.IQ) (*stanza.IQ, error) { // Marshall data, err := xml.Marshal(iq) if err != nil { @@ -63,3 +68,14 @@ func xmlOpts() cmp.Options { } return opts } + +func delSpaces(s string) string { + return reInsideWhtsp.ReplaceAllString(reLeadcloseWhtsp.ReplaceAllString(s, ""), "") +} + +func compareMarshal(expected, data string) error { + if delSpaces(expected) != delSpaces(data) { + return errors.New("failed to verify unmarshal->marshal. Expected :" + expected + "\ngot: " + data) + } + return nil +} diff --git a/stream_manager.go b/stream_manager.go index 1011f6e..7bfb42c 100644 --- a/stream_manager.go +++ b/stream_manager.go @@ -25,11 +25,11 @@ import ( // set callback and trigger reconnection. type StreamClient interface { Connect() error - Resume(state SMState) error + Resume() error Send(packet stanza.Packet) error - SendIQ(ctx context.Context, iq stanza.IQ) (chan stanza.IQ, error) + SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) SendRaw(packet string) error - Disconnect() + Disconnect() error SetHandler(handler EventHandler) } @@ -37,7 +37,7 @@ type StreamClient interface { // It is mostly use in callback to pass a limited subset of the stream client interface type Sender interface { Send(packet stanza.Packet) error - SendIQ(ctx context.Context, iq stanza.IQ) (chan stanza.IQ, error) + SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) SendRaw(packet string) error } @@ -74,22 +74,24 @@ func (sm *StreamManager) Run() error { return errors.New("missing stream client") } - handler := func(e Event) { - switch e.State { - case StateConnected: - sm.Metrics.setConnectTime() + handler := func(e Event) error { + switch e.State.state { case StateSessionEstablished: sm.Metrics.setLoginTime() case StateDisconnected: // Reconnect on disconnection - sm.resume(e.SMState) + return sm.resume() case StateStreamError: sm.client.Disconnect() // Only try reconnecting if we have not been kicked by another session to avoid connection loop. + // TODO: Make this conflict exception a permanent error if e.StreamError != "conflict" { - sm.connect() + return sm.resume() } + case StatePermanentError: + // Do not attempt to reconnect } + return nil } sm.client.SetHandler(handler) @@ -111,20 +113,33 @@ func (sm *StreamManager) Stop() { } func (sm *StreamManager) connect() error { - var state SMState - return sm.resume(state) + if sm.client != nil { + if c, ok := sm.client.(*Client); ok { + if c.CurrentState.getState() == StateDisconnected { + sm.Metrics = initMetrics() + err := c.Connect() + if err != nil { + return err + } + if sm.PostConnect != nil { + sm.PostConnect(sm.client) + } + return nil + } + } + } + return errors.New("client is not disconnected") } // resume manages the reconnection loop and apply the define backoff to avoid overloading the server. -func (sm *StreamManager) resume(state SMState) error { +func (sm *StreamManager) resume() error { var backoff backoff // TODO: Group backoff calculation features with connection manager? for { var err error // TODO: Make it possible to define logger to log disconnect and reconnection attempts sm.Metrics = initMetrics() - - if err = sm.client.Resume(state); err != nil { + if err = sm.client.Resume(); err != nil { var actualErr ConnError if xerrors.As(err, &actualErr) { if actualErr.Permanent { @@ -148,11 +163,6 @@ func (sm *StreamManager) resume(state SMState) error { type Metrics struct { startTime time.Time - // ConnectTime returns the duration between client initiation of the TCP/IP - // connection to the server and actual TCP/IP session establishment. - // This time includes DNS resolution and can be slightly higher if the DNS - // resolution result was not in cache. - ConnectTime time.Duration // LoginTime returns the between client initiation of the TCP/IP // connection to the server and the return of the login result. // This includes ConnectTime, but also XMPP level protocol negotiation @@ -168,10 +178,6 @@ func initMetrics() *Metrics { } } -func (m *Metrics) setConnectTime() { - m.ConnectTime = time.Since(m.startTime) -} - func (m *Metrics) setLoginTime() { m.LoginTime = time.Since(m.startTime) } diff --git a/tcp_server_mock.go b/tcp_server_mock.go index bdc4397..d189e3a 100644 --- a/tcp_server_mock.go +++ b/tcp_server_mock.go @@ -1,26 +1,65 @@ package xmpp import ( + "encoding/xml" + "fmt" + "gosrc.io/xmpp/stanza" "net" "testing" + "time" ) //============================================================================= // TCP Server Mock +const ( + defaultTimeout = 2 * time.Second + testComponentDomain = "localhost" + defaultServerName = "testServer" + defaultStreamID = "91bd0bba-012f-4d92-bb17-5fc41e6fe545" + defaultComponentName = "Test Component" + serverStreamOpen = "" + + // Default port is not standard XMPP port to avoid interfering + // with local running XMPP server + + // Component tests + testHandshakePort = iota + 15222 + testDecoderPort + testSendIqPort + testSendIqFailPort + testSendRawPort + testDisconnectPort + testSManDisconnectPort + + // Client tests + testClientBasePort + testClientRawPort + testClientIqPort + testClientIqFailPort + testClientPostConnectHook + + // Client internal tests + testClientStreamManagement +) // ClientHandler is passed by the test client to provide custom behaviour to // the TCP server mock. This allows customizing the server behaviour to allow // testing clients under various scenarii. -type ClientHandler func(t *testing.T, conn net.Conn) +type ClientHandler func(t *testing.T, serverConn *ServerConn) // ServerMock is a simple TCP server that can be use to mock basic server // behaviour to test clients. type ServerMock struct { - t *testing.T - handler ClientHandler - listener net.Listener - connections []net.Conn - done chan struct{} + t *testing.T + handler ClientHandler + listener net.Listener + serverConnections []*ServerConn + done chan struct{} +} + +type ServerConn struct { + connection net.Conn + decoder *xml.Decoder } // Start launches the mock TCP server, listening to an actual address / port. @@ -38,9 +77,9 @@ func (mock *ServerMock) Stop() { if mock.listener != nil { mock.listener.Close() } - // Close all existing connections - for _, c := range mock.connections { - c.Close() + // Close all existing serverConnections + for _, c := range mock.serverConnections { + c.connection.Close() } } @@ -60,13 +99,14 @@ func (mock *ServerMock) init(addr string) error { return nil } -// loop accepts connections and creates a go routine per connection. +// loop accepts serverConnections and creates a go routine per connection. // The go routine is running the client handler, that is used to provide the // real TCP server behaviour. func (mock *ServerMock) loop() { listener := mock.listener for { conn, err := listener.Accept() + serverConn := &ServerConn{conn, xml.NewDecoder(conn)} if err != nil { select { case <-mock.done: @@ -76,8 +116,204 @@ func (mock *ServerMock) loop() { } return } - mock.connections = append(mock.connections, conn) + mock.serverConnections = append(mock.serverConnections, serverConn) + // TODO Create and pass a context to cancel the handler if they are still around = avoid possible leak on complex handlers - go mock.handler(mock.t, conn) + go mock.handler(mock.t, serverConn) + } +} + +//====================================================================================================================== +// A few functions commonly used for tests. Trying to avoid duplicates in client and component test files. +//====================================================================================================================== + +func respondToIQ(t *testing.T, sc *ServerConn) { + // Decoder to parse the request + iqReq, err := receiveIq(sc) + if err != nil { + t.Fatalf("failed to receive IQ : %s", err.Error()) + } + + if vld, _ := iqReq.IsValid(); !vld { + mockIQError(sc.connection) + return + } + + // Crafting response + iqResp, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: iqReq.To, To: iqReq.From, Id: iqReq.Id, Lang: "en"}) + if err != nil { + t.Fatalf("failed to create iqResp: %v", err) + } + disco := iqResp.DiscoInfo() + disco.AddFeatures("vcard-temp", + `http://jabber.org/protocol/address`) + + disco.AddIdentity("Multicast", "service", "multicast") + iqResp.Payload = disco + + // Sending response to the Component + mResp, err := xml.Marshal(iqResp) + _, err = fmt.Fprintln(sc.connection, string(mResp)) + if err != nil { + t.Errorf("Could not send response stanza : %s", err) + } + return +} + +// When a presence stanza is automatically sent (right now it's the case in the client), we may want to discard it +// and test further stanzas. +func discardPresence(t *testing.T, sc *ServerConn) { + err := sc.connection.SetDeadline(time.Now().Add(defaultTimeout)) + if err != nil { + t.Fatalf("failed to set deadline: %v", err) + } + defer sc.connection.SetDeadline(time.Time{}) + var presenceStz stanza.Presence + + recvBuf := make([]byte, len(InitialPresence)) + _, err = sc.connection.Read(recvBuf[:]) // recv data + + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + t.Errorf("read timeout: %s", err) + } else { + t.Errorf("read error: %s", err) + } + } + err = xml.Unmarshal(recvBuf, &presenceStz) + + if err != nil { + t.Errorf("Expected presence but this happened : %s", err.Error()) + } +} + +// Reads next request coming from the Component. Expecting it to be an IQ request +func receiveIq(sc *ServerConn) (*stanza.IQ, error) { + err := sc.connection.SetDeadline(time.Now().Add(defaultTimeout)) + if err != nil { + return nil, err + } + defer sc.connection.SetDeadline(time.Time{}) + var iqStz stanza.IQ + err = sc.decoder.Decode(&iqStz) + if err != nil { + return nil, err + } + return &iqStz, nil +} + +// Should be used in server handlers when an IQ sent by a client or component is invalid. +// This responds as expected from a "real" server, aside from the error message. +func mockIQError(c net.Conn) { + s := stanza.StreamError{ + XMLName: xml.Name{Local: "stream:error"}, + Error: xml.Name{Local: "xml-not-well-formed"}, + Text: `XML was not well-formed`, + } + raw, _ := xml.Marshal(s) + fmt.Fprintln(c, string(raw)) + fmt.Fprintln(c, ``) +} + +func sendStreamFeatures(t *testing.T, sc *ServerConn) { + // This is a basic server, supporting only 1 stream feature: SASL Plain Auth + features := ` + + PLAIN + +` + if _, err := fmt.Fprintln(sc.connection, features); err != nil { + t.Errorf("cannot send stream feature: %s", err) + } +} + +// TODO return err in case of error reading the auth params +func readAuth(t *testing.T, decoder *xml.Decoder) string { + se, err := stanza.NextStart(decoder) + if err != nil { + t.Errorf("cannot read auth: %s", err) + return "" + } + + var nv interface{} + nv = &stanza.SASLAuth{} + // Decode element into pointer storage + if err = decoder.DecodeElement(nv, &se); err != nil { + t.Errorf("cannot decode auth: %s", err) + return "" + } + + switch v := nv.(type) { + case *stanza.SASLAuth: + return v.Value + } + return "" +} + +func sendBindFeature(t *testing.T, sc *ServerConn) { + // This is a basic server, supporting only 1 stream feature after auth: resource binding + features := ` + +` + if _, err := fmt.Fprintln(sc.connection, features); err != nil { + t.Errorf("cannot send stream feature: %s", err) + } +} + +func sendRFC3921Feature(t *testing.T, sc *ServerConn) { + // This is a basic server, supporting only 2 features after auth: resource & session binding + features := ` + + +` + if _, err := fmt.Fprintln(sc.connection, features); err != nil { + t.Errorf("cannot send stream feature: %s", err) + } +} + +func bind(t *testing.T, sc *ServerConn) { + se, err := stanza.NextStart(sc.decoder) + if err != nil { + t.Errorf("cannot read bind: %s", err) + return + } + + iq := &stanza.IQ{} + // Decode element into pointer storage + if err = sc.decoder.DecodeElement(&iq, &se); err != nil { + t.Errorf("cannot decode bind iq: %s", err) + return + } + + // TODO Check all elements + switch iq.Payload.(type) { + case *stanza.Bind: + result := ` + + %s + +` + fmt.Fprintf(sc.connection, result, iq.Id, "test@localhost/test") // TODO use real Jid + } +} + +func session(t *testing.T, sc *ServerConn) { + se, err := stanza.NextStart(sc.decoder) + if err != nil { + t.Errorf("cannot read session: %s", err) + return + } + + iq := &stanza.IQ{} + // Decode element into pointer storage + if err = sc.decoder.DecodeElement(&iq, &se); err != nil { + t.Errorf("cannot decode session iq: %s", err) + return + } + + switch iq.Payload.(type) { + case *stanza.StreamSession: + result := `` + fmt.Fprintf(sc.connection, result, iq.Id) } } diff --git a/test.sh b/test.sh index 9730026..199c05e 100755 --- a/test.sh +++ b/test.sh @@ -5,13 +5,9 @@ export GO111MODULE=on echo "" > coverage.txt for d in $(go list ./... | grep -v vendor); do - go test -race -coverprofile=profile.out -covermode=atomic ${d} + go test -race -coverprofile=profile.out -covermode=atomic "${d}" if [ -f profile.out ]; then cat profile.out >> coverage.txt rm profile.out fi done - -if [ -f "./codecov.sh" ]; then - ./codecov.sh -fi diff --git a/transport.go b/transport.go index c6134fb..fa124ea 100644 --- a/transport.go +++ b/transport.go @@ -9,8 +9,8 @@ import ( "strings" ) -var ErrTransportProtocolNotSupported = errors.New("Transport protocol not supported") -var ErrTLSNotSupported = errors.New("Transport does not support StartTLS") +var ErrTransportProtocolNotSupported = errors.New("transport protocol not supported") +var ErrTLSNotSupported = errors.New("transport does not support StartTLS") // TODO: rename to transport config? type TransportConfiguration struct { @@ -40,6 +40,9 @@ type Transport interface { Read(p []byte) (n int, err error) Write(p []byte) (n int, err error) Close() error + // ReceivedStreamClose signals to the transport that a has been received and that the tcp connection + // should be closed. + ReceivedStreamClose() } // NewClientTransport creates a new Transport instance for clients. @@ -64,7 +67,7 @@ func NewClientTransport(config TransportConfiguration) Transport { // will be returned. func NewComponentTransport(config TransportConfiguration) (Transport, error) { if strings.HasPrefix(config.Address, "ws:") || strings.HasPrefix(config.Address, "wss:") { - return nil, fmt.Errorf("Components only support XMPP transport: %w", ErrTransportProtocolNotSupported) + return nil, fmt.Errorf("components only support XMPP transport: %w", ErrTransportProtocolNotSupported) } config.Address = ensurePort(config.Address, 5222) diff --git a/websocket_transport.go b/websocket_transport.go index d7b62c4..7631fc8 100644 --- a/websocket_transport.go +++ b/websocket_transport.go @@ -18,8 +18,9 @@ const maxPacketSize = 32768 const pingTimeout = time.Duration(5) * time.Second -var ServerDoesNotSupportXmppOverWebsocket = errors.New("The websocket server does not support the xmpp subprotocol") +var ServerDoesNotSupportXmppOverWebsocket = errors.New("the websocket server does not support the xmpp subprotocol") +// The decoder is expected to be initialized after connecting to a server. type WebsocketTransport struct { Config TransportConfiguration decoder *xml.Decoder @@ -46,6 +47,7 @@ func (t *WebsocketTransport) Connect() (string, error) { wsConn, response, err := websocket.Dial(ctx, t.Config.Address, &websocket.DialOptions{ Subprotocols: []string{"xmpp"}, }) + if err != nil { return "", NewConnError(err, true) } @@ -176,3 +178,8 @@ func (t *WebsocketTransport) cleanup(code websocket.StatusCode) error { } return err } + +// ReceivedStreamClose is not used for websockets for now +func (t *WebsocketTransport) ReceivedStreamClose() { + return +} diff --git a/xmpp_transport.go b/xmpp_transport.go index a67d5bc..9e7cb21 100644 --- a/xmpp_transport.go +++ b/xmpp_transport.go @@ -14,6 +14,7 @@ import ( ) // XMPPTransport implements the XMPP native TCP transport +// The decoder is expected to be initialized after connecting to a server. type XMPPTransport struct { openStatement string Config TransportConfiguration @@ -23,6 +24,8 @@ type XMPPTransport struct { readWriter io.ReadWriter logFile io.Writer isSecure bool + // Used to close TCP connection when a stream close message is received from the server + closeChan chan stanza.StreamClosePacket } var componentStreamOpen = fmt.Sprintf("", stanza.NSComponent, stanza.NSStream) @@ -37,13 +40,14 @@ func (t *XMPPTransport) Connect() (string, error) { return "", NewConnError(err, true) } + t.closeChan = make(chan stanza.StreamClosePacket) t.readWriter = newStreamLogger(t.conn, t.logFile) t.decoder = xml.NewDecoder(bufio.NewReaderSize(t.readWriter, maxPacketSize)) t.decoder.CharsetReader = t.Config.CharsetReader return t.StartStream() } -func (t XMPPTransport) StartStream() (string, error) { +func (t *XMPPTransport) StartStream() (string, error) { if _, err := fmt.Fprintf(t, t.openStatement, t.Config.Domain); err != nil { t.Close() return "", NewConnError(err, true) @@ -57,19 +61,19 @@ func (t XMPPTransport) StartStream() (string, error) { return sessionID, nil } -func (t XMPPTransport) DoesStartTLS() bool { +func (t *XMPPTransport) DoesStartTLS() bool { return true } -func (t XMPPTransport) GetDomain() string { +func (t *XMPPTransport) GetDomain() string { return t.Config.Domain } -func (t XMPPTransport) GetDecoder() *xml.Decoder { +func (t *XMPPTransport) GetDecoder() *xml.Decoder { return t.decoder } -func (t XMPPTransport) IsSecure() bool { +func (t *XMPPTransport) IsSecure() bool { return t.isSecure } @@ -89,6 +93,7 @@ func (t *XMPPTransport) StartTLS() error { return err } + t.isSecure = false t.conn = tlsConn t.readWriter = newStreamLogger(tlsConn, t.logFile) t.decoder = xml.NewDecoder(bufio.NewReaderSize(t.readWriter, maxPacketSize)) @@ -104,30 +109,52 @@ func (t *XMPPTransport) StartTLS() error { return nil } -func (t XMPPTransport) Ping() error { +func (t *XMPPTransport) Ping() error { n, err := t.conn.Write([]byte("\n")) if err != nil { return err } if n != 1 { - return errors.New("Could not write ping") + return errors.New("could not write ping") } return nil } -func (t XMPPTransport) Read(p []byte) (n int, err error) { +func (t *XMPPTransport) Read(p []byte) (n int, err error) { + if t.readWriter == nil { + return 0, errors.New("cannot read: not connected, no readwriter") + } return t.readWriter.Read(p) } -func (t XMPPTransport) Write(p []byte) (n int, err error) { +func (t *XMPPTransport) Write(p []byte) (n int, err error) { + if t.readWriter == nil { + return 0, errors.New("cannot write: not connected, no readwriter") + } return t.readWriter.Write(p) } -func (t XMPPTransport) Close() error { - _, _ = t.readWriter.Write([]byte("")) - return t.conn.Close() +func (t *XMPPTransport) Close() error { + if t.readWriter != nil { + _, _ = t.readWriter.Write([]byte(stanza.StreamClose)) + } + + // Try to wait for the stream close tag from the server. After a timeout, disconnect anyway. + select { + case <-t.closeChan: + case <-time.After(time.Duration(t.Config.ConnectTimeout) * time.Second): + } + + if t.conn != nil { + return t.conn.Close() + } + return nil } func (t *XMPPTransport) LogTraffic(logFile io.Writer) { t.logFile = logFile } + +func (t *XMPPTransport) ReceivedStreamClose() { + t.closeChan <- stanza.StreamClosePacket{} +}