From 8cf0f84ddcbe437087e7d0f290e47258aeeaebdf Mon Sep 17 00:00:00 2001 From: sunglocto Date: Fri, 17 Jul 2026 17:18:21 +0100 Subject: [PATCH] uhh whats a submodule i have never heard of that bro --- go-xmpp | 1 - go-xmpp/.github/workflows/test.yaml | 38 + go-xmpp/.gitignore | 36 + go-xmpp/CHANGELOG.md | 58 ++ go-xmpp/CODE_OF_CONDUCT.md | 76 ++ go-xmpp/CONTRIBUTING.md | 139 +++ go-xmpp/LICENSE | 29 + go-xmpp/README.md | 158 +++ go-xmpp/_examples/custom_stanza/README.md | 5 + .../_examples/custom_stanza/custom_stanza.go | 55 ++ go-xmpp/_examples/delegation/README.md | 5 + go-xmpp/_examples/delegation/delegation.go | 220 +++++ go-xmpp/_examples/go.mod | 11 + go-xmpp/_examples/go.sum | 218 +++++ go-xmpp/_examples/muc_bot/README.md | 3 + go-xmpp/_examples/xmpp_chat_client/README.md | 51 + go-xmpp/_examples/xmpp_chat_client/config.yml | 13 + go-xmpp/_examples/xmpp_chat_client/go.mod | 10 + .../_examples/xmpp_chat_client/interface.go | 371 +++++++ .../xmpp_chat_client/xmpp_chat_client.go | 339 +++++++ go-xmpp/_examples/xmpp_component/README.md | 22 + .../xmpp_component/xmpp_component.go | 119 +++ go-xmpp/_examples/xmpp_component2/README.md | 4 + go-xmpp/_examples/xmpp_component2/main.go | 79 ++ go-xmpp/_examples/xmpp_echo/xmpp_echo.go | 56 ++ go-xmpp/_examples/xmpp_jukebox/README.md | 37 + .../_examples/xmpp_jukebox/xmpp_jukebox.go | 149 +++ go-xmpp/_examples/xmpp_oauth2/xmpp_oauth2.go | 54 + .../_examples/xmpp_pubsub_client/README.md | 17 + .../xmpp_pubsub_client/xmpp_ps_client.go | 278 ++++++ .../xmpp_websocket/xmpp_websocket.go | 52 + go-xmpp/auth.go | 105 ++ go-xmpp/backoff.go | 101 ++ go-xmpp/backoff_test.go | 22 + go-xmpp/bi_dir_iterator.go | 12 + go-xmpp/cert_checker.go | 151 +++ go-xmpp/client.go | 449 +++++++++ go-xmpp/client_internal_test.go | 608 ++++++++++++ go-xmpp/client_test.go | 659 +++++++++++++ go-xmpp/cmd/fluuxmpp/README.md | 198 ++++ go-xmpp/cmd/fluuxmpp/TODO.md | 21 + go-xmpp/cmd/fluuxmpp/check.go | 41 + go-xmpp/cmd/fluuxmpp/doc.go | 5 + go-xmpp/cmd/fluuxmpp/log.go | 34 + go-xmpp/cmd/fluuxmpp/main.go | 19 + go-xmpp/cmd/fluuxmpp/send.go | 141 +++ go-xmpp/cmd/fluuxmpp/xmppmuc.go | 28 + go-xmpp/cmd/fluuxmpp/xmppsend.go | 36 + go-xmpp/cmd/go.mod | 13 + go-xmpp/cmd/go.sum | 259 +++++ go-xmpp/component.go | 230 +++++ go-xmpp/component_test.go | 512 ++++++++++ go-xmpp/config.go | 35 + go-xmpp/conn_error.go | 33 + go-xmpp/doc.go | 42 + go-xmpp/go.mod | 10 + go-xmpp/go.sum | 8 + go-xmpp/network.go | 32 + go-xmpp/network_test.go | 66 ++ go-xmpp/router.go | 374 +++++++ go-xmpp/router_test.go | 332 +++++++ go-xmpp/session.go | 342 +++++++ go-xmpp/stanza/README.md | 142 +++ go-xmpp/stanza/commands.go | 157 +++ go-xmpp/stanza/commands_test.go | 41 + go-xmpp/stanza/component.go | 96 ++ go-xmpp/stanza/component_test.go | 79 ++ go-xmpp/stanza/datetime_profiles.go | 70 ++ go-xmpp/stanza/datetime_profiles_test.go | 191 ++++ go-xmpp/stanza/doc.go | 4 + go-xmpp/stanza/error.go | 136 +++ go-xmpp/stanza/error_enum.go | 13 + go-xmpp/stanza/error_test.go | 31 + go-xmpp/stanza/fifo_queue.go | 34 + go-xmpp/stanza/form.go | 68 ++ go-xmpp/stanza/form_test.go | 110 +++ go-xmpp/stanza/iot.go | 48 + go-xmpp/stanza/iot_test.go | 26 + go-xmpp/stanza/iq.go | 192 ++++ go-xmpp/stanza/iq_disco.go | 163 ++++ go-xmpp/stanza/iq_disco_test.go | 96 ++ go-xmpp/stanza/iq_roster.go | 126 +++ go-xmpp/stanza/iq_roster_test.go | 112 +++ go-xmpp/stanza/iq_test.go | 240 +++++ go-xmpp/stanza/iq_version.go | 51 + go-xmpp/stanza/iq_version_test.go | 43 + go-xmpp/stanza/jid.go | 101 ++ go-xmpp/stanza/jid_test.go | 101 ++ go-xmpp/stanza/message.go | 148 +++ go-xmpp/stanza/message_test.go | 76 ++ go-xmpp/stanza/msg_chat_markers.go | 42 + go-xmpp/stanza/msg_chat_state.go | 45 + go-xmpp/stanza/msg_hint.go | 36 + go-xmpp/stanza/msg_hint_test.go | 72 ++ go-xmpp/stanza/msg_html.go | 22 + go-xmpp/stanza/msg_html_test.go | 44 + go-xmpp/stanza/msg_oob.go | 21 + go-xmpp/stanza/msg_pubsub_event.go | 213 ++++ go-xmpp/stanza/msg_pubsub_event_test.go | 162 +++ go-xmpp/stanza/msg_receipts.go | 29 + go-xmpp/stanza/msg_receipts_test.go | 42 + go-xmpp/stanza/node.go | 63 ++ go-xmpp/stanza/node_test.go | 33 + go-xmpp/stanza/ns.go | 12 + go-xmpp/stanza/open.go | 13 + go-xmpp/stanza/packet.go | 18 + go-xmpp/stanza/packet_enum.go | 31 + go-xmpp/stanza/parser.go | 199 ++++ go-xmpp/stanza/pep.go | 27 + go-xmpp/stanza/pres_muc.go | 148 +++ go-xmpp/stanza/pres_muc_test.go | 97 ++ go-xmpp/stanza/presence.go | 139 +++ go-xmpp/stanza/presence_enum.go | 12 + go-xmpp/stanza/presence_test.go | 63 ++ go-xmpp/stanza/pubsub.go | 433 ++++++++ go-xmpp/stanza/pubsub_owner.go | 458 +++++++++ go-xmpp/stanza/pubsub_owner_test.go | 886 +++++++++++++++++ go-xmpp/stanza/pubsub_test.go | 923 ++++++++++++++++++ go-xmpp/stanza/registry.go | 119 +++ go-xmpp/stanza/registry_test.go | 47 + go-xmpp/stanza/results_sets.go | 29 + go-xmpp/stanza/results_sets_test.go | 28 + go-xmpp/stanza/sasl_auth.go | 124 +++ go-xmpp/stanza/sasl_auth_test.go | 60 ++ go-xmpp/stanza/stanza_errors.go | 171 ++++ go-xmpp/stanza/starttls.go | 14 + go-xmpp/stanza/stream.go | 16 + go-xmpp/stanza/stream_features.go | 189 ++++ go-xmpp/stanza/stream_features_test.go | 64 ++ go-xmpp/stanza/stream_management.go | 392 ++++++++ go-xmpp/stanza/stream_management_test.go | 226 +++++ go-xmpp/stanza/xmpp_test.go | 81 ++ go-xmpp/stream_logger.go | 52 + go-xmpp/stream_manager.go | 183 ++++ go-xmpp/tcp_server_mock.go | 319 ++++++ go-xmpp/test.sh | 13 + go-xmpp/transport.go | 78 ++ go-xmpp/websocket_transport.go | 185 ++++ go-xmpp/xmpp_transport.go | 163 ++++ 139 files changed, 17151 insertions(+), 1 deletion(-) delete mode 160000 go-xmpp create mode 100644 go-xmpp/.github/workflows/test.yaml create mode 100755 go-xmpp/.gitignore create mode 100644 go-xmpp/CHANGELOG.md create mode 100644 go-xmpp/CODE_OF_CONDUCT.md create mode 100644 go-xmpp/CONTRIBUTING.md create mode 100644 go-xmpp/LICENSE create mode 100644 go-xmpp/README.md create mode 100644 go-xmpp/_examples/custom_stanza/README.md create mode 100644 go-xmpp/_examples/custom_stanza/custom_stanza.go create mode 100644 go-xmpp/_examples/delegation/README.md create mode 100644 go-xmpp/_examples/delegation/delegation.go create mode 100644 go-xmpp/_examples/go.mod create mode 100644 go-xmpp/_examples/go.sum create mode 100644 go-xmpp/_examples/muc_bot/README.md create mode 100644 go-xmpp/_examples/xmpp_chat_client/README.md create mode 100644 go-xmpp/_examples/xmpp_chat_client/config.yml create mode 100644 go-xmpp/_examples/xmpp_chat_client/go.mod create mode 100644 go-xmpp/_examples/xmpp_chat_client/interface.go create mode 100644 go-xmpp/_examples/xmpp_chat_client/xmpp_chat_client.go create mode 100644 go-xmpp/_examples/xmpp_component/README.md create mode 100644 go-xmpp/_examples/xmpp_component/xmpp_component.go create mode 100644 go-xmpp/_examples/xmpp_component2/README.md create mode 100644 go-xmpp/_examples/xmpp_component2/main.go create mode 100644 go-xmpp/_examples/xmpp_echo/xmpp_echo.go create mode 100644 go-xmpp/_examples/xmpp_jukebox/README.md create mode 100644 go-xmpp/_examples/xmpp_jukebox/xmpp_jukebox.go create mode 100644 go-xmpp/_examples/xmpp_oauth2/xmpp_oauth2.go create mode 100644 go-xmpp/_examples/xmpp_pubsub_client/README.md create mode 100644 go-xmpp/_examples/xmpp_pubsub_client/xmpp_ps_client.go create mode 100644 go-xmpp/_examples/xmpp_websocket/xmpp_websocket.go create mode 100644 go-xmpp/auth.go create mode 100644 go-xmpp/backoff.go create mode 100644 go-xmpp/backoff_test.go create mode 100644 go-xmpp/bi_dir_iterator.go create mode 100644 go-xmpp/cert_checker.go create mode 100644 go-xmpp/client.go create mode 100644 go-xmpp/client_internal_test.go create mode 100644 go-xmpp/client_test.go create mode 100644 go-xmpp/cmd/fluuxmpp/README.md create mode 100644 go-xmpp/cmd/fluuxmpp/TODO.md create mode 100644 go-xmpp/cmd/fluuxmpp/check.go create mode 100644 go-xmpp/cmd/fluuxmpp/doc.go create mode 100644 go-xmpp/cmd/fluuxmpp/log.go create mode 100644 go-xmpp/cmd/fluuxmpp/main.go create mode 100644 go-xmpp/cmd/fluuxmpp/send.go create mode 100644 go-xmpp/cmd/fluuxmpp/xmppmuc.go create mode 100644 go-xmpp/cmd/fluuxmpp/xmppsend.go create mode 100644 go-xmpp/cmd/go.mod create mode 100644 go-xmpp/cmd/go.sum create mode 100644 go-xmpp/component.go create mode 100644 go-xmpp/component_test.go create mode 100644 go-xmpp/config.go create mode 100644 go-xmpp/conn_error.go create mode 100644 go-xmpp/doc.go create mode 100644 go-xmpp/go.mod create mode 100644 go-xmpp/go.sum create mode 100644 go-xmpp/network.go create mode 100644 go-xmpp/network_test.go create mode 100644 go-xmpp/router.go create mode 100644 go-xmpp/router_test.go create mode 100644 go-xmpp/session.go create mode 100644 go-xmpp/stanza/README.md create mode 100644 go-xmpp/stanza/commands.go create mode 100644 go-xmpp/stanza/commands_test.go create mode 100644 go-xmpp/stanza/component.go create mode 100644 go-xmpp/stanza/component_test.go create mode 100644 go-xmpp/stanza/datetime_profiles.go create mode 100644 go-xmpp/stanza/datetime_profiles_test.go create mode 100644 go-xmpp/stanza/doc.go create mode 100644 go-xmpp/stanza/error.go create mode 100644 go-xmpp/stanza/error_enum.go create mode 100644 go-xmpp/stanza/error_test.go create mode 100644 go-xmpp/stanza/fifo_queue.go create mode 100644 go-xmpp/stanza/form.go create mode 100644 go-xmpp/stanza/form_test.go create mode 100644 go-xmpp/stanza/iot.go create mode 100644 go-xmpp/stanza/iot_test.go create mode 100644 go-xmpp/stanza/iq.go create mode 100644 go-xmpp/stanza/iq_disco.go create mode 100644 go-xmpp/stanza/iq_disco_test.go create mode 100644 go-xmpp/stanza/iq_roster.go create mode 100644 go-xmpp/stanza/iq_roster_test.go create mode 100644 go-xmpp/stanza/iq_test.go create mode 100644 go-xmpp/stanza/iq_version.go create mode 100644 go-xmpp/stanza/iq_version_test.go create mode 100644 go-xmpp/stanza/jid.go create mode 100644 go-xmpp/stanza/jid_test.go create mode 100644 go-xmpp/stanza/message.go create mode 100644 go-xmpp/stanza/message_test.go create mode 100644 go-xmpp/stanza/msg_chat_markers.go create mode 100644 go-xmpp/stanza/msg_chat_state.go create mode 100644 go-xmpp/stanza/msg_hint.go create mode 100644 go-xmpp/stanza/msg_hint_test.go create mode 100644 go-xmpp/stanza/msg_html.go create mode 100644 go-xmpp/stanza/msg_html_test.go create mode 100644 go-xmpp/stanza/msg_oob.go create mode 100644 go-xmpp/stanza/msg_pubsub_event.go create mode 100644 go-xmpp/stanza/msg_pubsub_event_test.go create mode 100644 go-xmpp/stanza/msg_receipts.go create mode 100644 go-xmpp/stanza/msg_receipts_test.go create mode 100644 go-xmpp/stanza/node.go create mode 100644 go-xmpp/stanza/node_test.go create mode 100644 go-xmpp/stanza/ns.go create mode 100644 go-xmpp/stanza/open.go create mode 100644 go-xmpp/stanza/packet.go create mode 100644 go-xmpp/stanza/packet_enum.go create mode 100644 go-xmpp/stanza/parser.go create mode 100644 go-xmpp/stanza/pep.go create mode 100644 go-xmpp/stanza/pres_muc.go create mode 100644 go-xmpp/stanza/pres_muc_test.go create mode 100644 go-xmpp/stanza/presence.go create mode 100644 go-xmpp/stanza/presence_enum.go create mode 100644 go-xmpp/stanza/presence_test.go create mode 100644 go-xmpp/stanza/pubsub.go create mode 100644 go-xmpp/stanza/pubsub_owner.go create mode 100644 go-xmpp/stanza/pubsub_owner_test.go create mode 100644 go-xmpp/stanza/pubsub_test.go create mode 100644 go-xmpp/stanza/registry.go create mode 100644 go-xmpp/stanza/registry_test.go create mode 100644 go-xmpp/stanza/results_sets.go create mode 100644 go-xmpp/stanza/results_sets_test.go create mode 100644 go-xmpp/stanza/sasl_auth.go create mode 100644 go-xmpp/stanza/sasl_auth_test.go create mode 100644 go-xmpp/stanza/stanza_errors.go create mode 100644 go-xmpp/stanza/starttls.go create mode 100644 go-xmpp/stanza/stream.go create mode 100644 go-xmpp/stanza/stream_features.go create mode 100644 go-xmpp/stanza/stream_features_test.go create mode 100644 go-xmpp/stanza/stream_management.go create mode 100644 go-xmpp/stanza/stream_management_test.go create mode 100644 go-xmpp/stanza/xmpp_test.go create mode 100644 go-xmpp/stream_logger.go create mode 100644 go-xmpp/stream_manager.go create mode 100644 go-xmpp/tcp_server_mock.go create mode 100755 go-xmpp/test.sh create mode 100644 go-xmpp/transport.go create mode 100644 go-xmpp/websocket_transport.go create mode 100644 go-xmpp/xmpp_transport.go diff --git a/go-xmpp b/go-xmpp deleted file mode 160000 index bc2b7ce..0000000 --- a/go-xmpp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bc2b7ceee4b4d4c9ba5ec2451afdd43462f0b188 diff --git a/go-xmpp/.github/workflows/test.yaml b/go-xmpp/.github/workflows/test.yaml new file mode 100644 index 0000000..043d9d8 --- /dev/null +++ b/go-xmpp/.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.25 + uses: actions/setup-go@v1 + with: + go-version: 1.25 + 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 + with: + infile: cover.out + outfile: coverage.lcov + - name: Coveralls + uses: coverallsapp/github-action@v1 + with: + github-token: ${{ secrets.github_token }} + path-to-lcov: coverage.lcov diff --git a/go-xmpp/.gitignore b/go-xmpp/.gitignore new file mode 100755 index 0000000..79a4126 --- /dev/null +++ b/go-xmpp/.gitignore @@ -0,0 +1,36 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof +coverage.out +coverage.txt + +.idea/ +*.iml +.DS_Store + +# Do not commit codeship key +codeship.aes +codeship.env + +priv/ \ No newline at end of file diff --git a/go-xmpp/CHANGELOG.md b/go-xmpp/CHANGELOG.md new file mode 100644 index 0000000..41f58ae --- /dev/null +++ b/go-xmpp/CHANGELOG.md @@ -0,0 +1,58 @@ +# 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 + +- XMPP Over Websocket support +- Add support for getting IQ responses to client IQ queries (synchronously or asynchronously, passing an handler + function). +- Implement X-OAUTH2 authentication method. You can read more details here: + [Understanding ejabberd OAuth Support & Roadmap: Step 4](https://blog.process-one.net/understanding-ejabberd-oauth-support-roadmap/) +- Fix issues in the stanza builder when trying to add text inside and XMPP node. +- Fix issues with unescaped % characters in XMPP payload. + +### Code migration guide + +TODO diff --git a/go-xmpp/CODE_OF_CONDUCT.md b/go-xmpp/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c248c38 --- /dev/null +++ b/go-xmpp/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at contact@process-one.net. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/go-xmpp/CONTRIBUTING.md b/go-xmpp/CONTRIBUTING.md new file mode 100644 index 0000000..1d625f5 --- /dev/null +++ b/go-xmpp/CONTRIBUTING.md @@ -0,0 +1,139 @@ +# Contributing + +We'd love for you to contribute to our source code and to make our project even better than it is +today! Here are the guidelines we'd like you to follow: + +* [Code of Conduct](#coc) +* [Questions and Problems](#question) +* [Issues and Bugs](#issue) +* [Feature Requests](#feature) +* [Issue Submission Guidelines](#submit) +* [Pull Request Submission Guidelines](#submit-pr) +* [Signing the CLA](#cla) + +## Code of Conduct + +Help us keep our community open-minded and inclusive. Please read and follow our [Code of Conduct][coc]. + +## Questions, Bugs, Features + +### Got a Question or Problem? + +Do not open issues for general support questions as we want to keep GitHub issues for bug reports +and feature requests. You've got much better chances of getting your question answered on dedicated +support platforms, the best being [Stack Overflow][stackoverflow]. + +Stack Overflow is a much better place to ask questions since: + +- there are thousands of people willing to help on Stack Overflow +- questions and answers stay available for public viewing so your question / answer might help + someone else +- Stack Overflow's voting system assures that the best answers are prominently visible. + +To save your and our time, we will systematically close all issues that are requests for general +support and redirect people to the section you are reading right now. + +### Found an Issue or Bug? + +If you find a bug in the source code, you can help us by submitting an issue to our +[GitHub Repository][github]. Even better, you can submit a Pull Request with a fix. + +### Missing a Feature? + +You can request a new feature by submitting an issue to our [GitHub Repository][github-issues]. + +If you would like to implement a new feature then consider what kind of change it is: + +* **Major Changes** that you wish to contribute to the project should be discussed first in an + [GitHub issue][github-issues] that clearly outlines the changes and benefits of the feature. +* **Small Changes** can directly be crafted and submitted to the [GitHub Repository][github] + as a Pull Request. See the section about [Pull Request Submission Guidelines](#submit-pr). + +## Issue Submission Guidelines + +Before you submit your issue search the archive, maybe your question was already answered. + +If your issue appears to be a bug, and hasn't been reported, open a new issue. Help us to maximize +the effort we can spend fixing issues and adding new features, by not reporting duplicate issues. + +The "[new issue][github-new-issue]" form contains a number of prompts that you should fill out to +make it easier to understand and categorize the issue. + +## Pull Request Submission Guidelines + +By submitting a pull request for a code or doc contribution, you need to have the right +to grant your contribution's copyright license to ProcessOne. Please check [ProcessOne CLA][cla] +for details. + +Before you submit your pull request consider the following guidelines: + +* Search [GitHub][github-pr] for an open or closed Pull Request + that relates to your submission. You don't want to duplicate effort. +* Make your changes in a new git branch: + + ```shell + git checkout -b my-fix-branch master + ``` +* Test your changes and, if relevant, expand the automated test suite. +* Create your patch commit, including appropriate test cases. +* If the changes affect public APIs, change or add relevant documentation. +* Commit your changes using a descriptive commit message. + + ```shell + git commit -a + ``` + Note: the optional commit `-a` command line option will automatically "add" and "rm" edited files. + +* Push your branch to GitHub: + + ```shell + git push origin my-fix-branch + ``` + +* In GitHub, send a pull request to `master` branch. This will trigger the continuous integration and run the test. +We will also notify you if you have not yet signed the [contribution agreement][cla]. + +* If you find that the continunous integration has failed, look into the logs to find out +if your changes caused test failures, the commit message was malformed etc. If you find that the +tests failed or times out for unrelated reasons, you can ping a team member so that the build can be +restarted. + +* If we suggest changes, then: + + * Make the required updates. + * Test your changes and test cases. + * Commit your changes to your branch (e.g. `my-fix-branch`). + * Push the changes to your GitHub repository (this will update your Pull Request). + + You can also amend the initial commits and force push them to the branch. + + ```shell + git rebase master -i + git push origin my-fix-branch -f + ``` + + This is generally easier to follow, but separate commits are useful if the Pull Request contains + iterations that might be interesting to see side-by-side. + +That's it! Thank you for your contribution! + +## Signing the Contributor License Agreement (CLA) + +Upon submitting a Pull Request, we will ask you to sign our CLA if you haven't done +so before. It's a quick process, we promise, and you will be able to do it all online + +You can read [ProcessOne Contribution License Agreement][cla] in PDF. + +This is part of the legal framework of the open-source ecosystem that adds some red tape, +but protects both the contributor and the company / foundation behind the project. It also +gives us the option to relicense the code with a more permissive license in the future. + + +[coc]: https://github.com/FluuxIO/go-xmpp/blob/master/CODE_OF_CONDUCT.md +[stackoverflow]: https://stackoverflow.com/ +[github]: https://github.com/FluuxIO/go-xmpp +[github-issues]: https://github.com/FluuxIO/go-xmpp/issues +[github-new-issue]: https://github.com/FluuxIO/go-xmpp/issues/new +[github-pr]: https://github.com/FluuxIO/go-xmpp/pulls +[cla]: https://www.process-one.net/resources/ejabberd-cla.pdf +[license]: https://github.com/FluuxIO/go-xmpp/blob/master/LICENSE diff --git a/go-xmpp/LICENSE b/go-xmpp/LICENSE new file mode 100644 index 0000000..6b4272c --- /dev/null +++ b/go-xmpp/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2017, ProcessOne SARL +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/go-xmpp/README.md b/go-xmpp/README.md new file mode 100644 index 0000000..8c91c28 --- /dev/null +++ b/go-xmpp/README.md @@ -0,0 +1,158 @@ +# Fluux 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. + +The goal is to make simple to write simple XMPP clients and components: + +- For automation (like for example monitoring of an XMPP service), +- For building connected "things" by plugging them on an XMPP server, +- For writing simple chatbot to control a service or a thing, +- For writing XMPP servers components. + +The library is designed to have minimal dependencies. Currently it requires at least Go 1.13. + +## Configuration and connection + +### Allowing Insecure TLS connection during development + +It is not recommended to disable the check for domain name and certificate chain. Doing so would open your client +to man-in-the-middle attacks. + +However, in development, XMPP servers often use self-signed certificates. In that situation, it is better to add the +root CA that signed the certificate to your trusted list of root CA. It avoids changing the code and limit the risk +of shipping an insecure client to production. + +That said, if you really want to allow your client to trust any TLS certificate, you can customize Go standard +`tls.Config` and set it in Config struct. + +Here is an example code to configure a client to allow connecting to a server with self-signed certificate. Note the +`InsecureSkipVerify` option. When using this `tls.Config` option, all the checks on the certificate are skipped. + +```go +config := xmpp.Config{ + Address: "localhost:5222", + Jid: "test@localhost", + Credential: xmpp.Password("Test"), + TLSConfig: tls.Config{InsecureSkipVerify: true}, +} +``` + +## Supported specifications + +### Clients + +- [RFC 6120: XMPP Core](https://xmpp.org/rfcs/rfc6120.html) +- [RFC 6121: XMPP Instant Messaging and Presence](https://xmpp.org/rfcs/rfc6121.html) + +### Components + + - [XEP-0114: Jabber Component Protocol](https://xmpp.org/extensions/xep-0114.html) + - [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 + +XMPP stanzas are basic and extensible XML elements. Stanzas (or sometimes special stanzas called 'nonzas') are used to +leverage the XMPP protocol features. During a session, a client (or a component) and a server will be exchanging stanzas +back and forth. + +At a low-level, stanzas are XML fragments. However, Fluux XMPP library provides the building blocks to interact with +stanzas at a high-level, providing a Go-friendly API. + +The `stanza` subpackage provides support for XMPP stream parsing, marshalling and unmarshalling of XMPP stanza. It is a +bridge between high-level Go structure and low-level XMPP protocol. + +Parsing, marshalling and unmarshalling is automatically handled by Fluux XMPP client library. As a developer, you will +generally manipulates only the high-level structs provided by the stanza package. + +The XMPP protocol, as the name implies is extensible. If your application is using custom stanza extensions, you can +implement your own extensions directly in your own application. + +To learn more about the stanza package, you can read more in the +[stanza package documentation](https://github.com/FluuxIO/go-xmpp/blob/master/stanza/README.md). + +### Router + +TODO + +### Getting IQ response from server + +TODO + +## Examples + +We have several [examples](https://github.com/FluuxIO/go-xmpp/tree/master/_examples) to help you get started using +Fluux XMPP library. + +Here is the demo "echo" client: + +```go +package main + +import ( + "fmt" + "log" + "os" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func main() { + config := xmpp.Config{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: "localhost:5222", + }, + Jid: "test@localhost", + 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, errorHandler) + if err != nil { + log.Fatalf("%+v", err) + } + + // If you pass the client to a connection manager, it will handle the reconnect policy + // for you automatically. + cm := xmpp.NewStreamManager(client, nil) + log.Fatal(cm.Run()) +} + +func handleMessage(s xmpp.Sender, p stanza.Packet) { + msg, ok := p.(stanza.Message) + if !ok { + _, _ = fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", p) + return + } + + _, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From) + 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 + +The code documentation is available on GoDoc: [gosrc.io/xmpp](https://godoc.org/gosrc.io/xmpp) diff --git a/go-xmpp/_examples/custom_stanza/README.md b/go-xmpp/_examples/custom_stanza/README.md new file mode 100644 index 0000000..1eb3b28 --- /dev/null +++ b/go-xmpp/_examples/custom_stanza/README.md @@ -0,0 +1,5 @@ +# Custom Stanza example + +This module show how to implement a custom extension for your own client, without having to modify or fork Fluux XMPP. + +It help integrating your custom extension in the standard stream parsing, marshalling and unmarshalling workflow. diff --git a/go-xmpp/_examples/custom_stanza/custom_stanza.go b/go-xmpp/_examples/custom_stanza/custom_stanza.go new file mode 100644 index 0000000..b4bfb52 --- /dev/null +++ b/go-xmpp/_examples/custom_stanza/custom_stanza.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/xml" + "fmt" + "log" + + "gosrc.io/xmpp/stanza" +) + +func main() { + 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 + + data, err := xml.Marshal(iq) + if err != nil { + log.Fatalf("Cannot marshal iq with custom payload: %s", err) + } + + var parsedIQ stanza.IQ + if err = xml.Unmarshal(data, &parsedIQ); err != nil { + log.Fatalf("Cannot unmarshal(%s): %s", data, err) + } + + parsedPayload, ok := parsedIQ.Payload.(*CustomPayload) + if !ok { + log.Fatalf("Incorrect payload type: %#v", parsedIQ.Payload) + } + + fmt.Printf("Parsed Payload: %#v", parsedPayload) + + if parsedPayload.Node != "test" { + log.Fatalf("Incorrect node value: %s", parsedPayload.Node) + } +} + +type CustomPayload struct { + XMLName xml.Name `xml:"my:custom:payload query"` + Node string `xml:"node,attr,omitempty"` +} + +func (c CustomPayload) Namespace() string { + return c.XMLName.Space +} + +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/go-xmpp/_examples/delegation/README.md b/go-xmpp/_examples/delegation/README.md new file mode 100644 index 0000000..4389a36 --- /dev/null +++ b/go-xmpp/_examples/delegation/README.md @@ -0,0 +1,5 @@ +# Advanced component: delegation + +`delegation` is an example of advanced component supporting Namespace Delegation +([XEP-0355](https://xmpp.org/extensions/xep-0355.html)) and privileged entity +([XEP-356](https://xmpp.org/extensions/xep-0356.html)). diff --git a/go-xmpp/_examples/delegation/delegation.go b/go-xmpp/_examples/delegation/delegation.go new file mode 100644 index 0000000..d022633 --- /dev/null +++ b/go-xmpp/_examples/delegation/delegation.go @@ -0,0 +1,220 @@ +package main + +import ( + "encoding/xml" + "fmt" + "log" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func main() { + opts := xmpp.ComponentOptions{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: "localhost:9999", + Domain: "service.localhost", + }, + Domain: "service.localhost", + Secret: "mypass", + + // TODO: Move that part to a component discovery handler + Name: "Test Component", + Category: "gateway", + Type: "service", + } + + router := xmpp.NewRouter() + router.HandleFunc("message", handleMessage) + router.NewRoute(). + IQNamespaces(stanza.NSDiscoInfo). + HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { + discoInfo(s, p, opts) + }) + router.NewRoute(). + IQNamespaces("urn:xmpp:delegation:1"). + HandlerFunc(handleDelegation) + + component, err := xmpp.NewComponent(opts, router, func(err error) { + log.Println(err) + }) + if err != nil { + log.Fatalf("%+v", err) + } + + // If you pass the component to a stream manager, it will handle the reconnect policy + // for you automatically. + // TODO: Post Connect could be a feature of the router or the client. Move it somewhere else. + cm := xmpp.NewStreamManager(component, nil) + log.Fatal(cm.Run()) +} + +func handleMessage(_ xmpp.Sender, p stanza.Packet) { + msg, ok := p.(stanza.Message) + if !ok { + return + } + var msgProcessed bool + for _, ext := range msg.Extensions { + delegation, ok := ext.(*stanza.Delegation) + if ok { + msgProcessed = true + fmt.Printf("Delegation confirmed for namespace %s\n", delegation.Delegated.Namespace) + } + } + // TODO: Decode privilege message + // + + if !msgProcessed { + fmt.Printf("Ignored received message, not related to delegation: %v\n", msg) + } +} + +const ( + pubsubNode = "urn:xmpp:delegation:1::http://jabber.org/protocol/pubsub" + pepNode = "urn:xmpp:delegation:1:bare:http://jabber.org/protocol/pubsub" +) + +// TODO: replace xmpp.Sender by ctx xmpp.Context ? +// ctx.Stream.Send / SendRaw +// ctx.Opts +func discoInfo(c xmpp.Sender, p stanza.Packet, opts xmpp.ComponentOptions) { + // Type conversion & sanity checks + iq, ok := p.(*stanza.IQ) + if !ok { + return + } + info, ok := iq.Payload.(*stanza.DiscoInfo) + if !ok { + return + } + + 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) + case pubsubNode: + discoInfoPubSub(iqResp) + case pepNode: + discoInfoPEP(iqResp) + } + + _ = c.Send(iqResp) +} + +func discoInfoRoot(iqResp *stanza.IQ, opts xmpp.ComponentOptions) { + disco := iqResp.DiscoInfo() + disco.AddIdentity(opts.Name, opts.Category, opts.Type) + disco.AddFeatures(stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1") +} + +func discoInfoPubSub(iqResp *stanza.IQ) { + payload := stanza.DiscoInfo{ + XMLName: xml.Name{ + Space: stanza.NSDiscoInfo, + Local: "query", + }, + Node: pubsubNode, + Features: []stanza.Feature{ + {Var: "http://jabber.org/protocol/pubsub"}, + {Var: "http://jabber.org/protocol/pubsub#publish"}, + {Var: "http://jabber.org/protocol/pubsub#subscribe"}, + {Var: "http://jabber.org/protocol/pubsub#publish-options"}, + }, + } + iqResp.Payload = &payload +} + +func discoInfoPEP(iqResp *stanza.IQ) { + identity := stanza.Identity{ + Category: "pubsub", + Type: "pep", + } + payload := stanza.DiscoInfo{ + XMLName: xml.Name{ + Space: stanza.NSDiscoInfo, + Local: "query", + }, + Identity: []stanza.Identity{identity}, + Node: pepNode, + Features: []stanza.Feature{ + {Var: "http://jabber.org/protocol/pubsub#access-presence"}, + {Var: "http://jabber.org/protocol/pubsub#auto-create"}, + {Var: "http://jabber.org/protocol/pubsub#auto-subscribe"}, + {Var: "http://jabber.org/protocol/pubsub#config-node"}, + {Var: "http://jabber.org/protocol/pubsub#create-and-configure"}, + {Var: "http://jabber.org/protocol/pubsub#create-nodes"}, + {Var: "http://jabber.org/protocol/pubsub#filtered-notifications"}, + {Var: "http://jabber.org/protocol/pubsub#persistent-items"}, + {Var: "http://jabber.org/protocol/pubsub#publish"}, + {Var: "http://jabber.org/protocol/pubsub#retrieve-items"}, + {Var: "http://jabber.org/protocol/pubsub#subscribe"}, + }, + } + iqResp.Payload = &payload +} + +func handleDelegation(s xmpp.Sender, p stanza.Packet) { + // Type conversion & sanity checks + iq, ok := p.(*stanza.IQ) + if !ok { + return + } + + delegation, ok := iq.Payload.(*stanza.Delegation) + if !ok { + return + } + forwardedPacket := delegation.Forwarded.Stanza + fmt.Println(forwardedPacket) + forwardedIQ, ok := forwardedPacket.(*stanza.IQ) + if !ok { + return + } + + pubsub, ok := forwardedIQ.Payload.(*stanza.PubSubGeneric) + if !ok { + // We only support pubsub delegation + return + } + + if pubsub.Publish.XMLName.Local == "publish" { + // Prepare pubsub IQ reply + 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", + }, + } + iqResp.Payload = &payload + // Wrap the reply in delegation 'forward' + 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", + Local: "delegation", + }, + Forwarded: &stanza.Forwarded{ + XMLName: xml.Name{ + Space: "urn:xmpp:forward:0", + Local: "forward", + }, + Stanza: iqResp, + }, + } + iqForward.Payload = &delegPayload + _ = s.Send(iqForward) + // TODO: The component should actually broadcast the mood to subscribers + } +} diff --git a/go-xmpp/_examples/go.mod b/go-xmpp/_examples/go.mod new file mode 100644 index 0000000..16b2ec9 --- /dev/null +++ b/go-xmpp/_examples/go.mod @@ -0,0 +1,11 @@ +module gosrc.io/xmpp/_examples + +go 1.25 + +require ( + github.com/processone/mpg123 v1.0.0 + github.com/processone/soundcloud v1.0.0 + gosrc.io/xmpp v0.4.0 +) + +replace gosrc.io/xmpp => ./../ diff --git a/go-xmpp/_examples/go.sum b/go-xmpp/_examples/go.sum new file mode 100644 index 0000000..b5e9447 --- /dev/null +++ b/go-xmpp/_examples/go.sum @@ -0,0 +1,218 @@ +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/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/google/uuid v1.6.0/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= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +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= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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/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= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +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= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/go-xmpp/_examples/muc_bot/README.md b/go-xmpp/_examples/muc_bot/README.md new file mode 100644 index 0000000..24414fa --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_chat_client/README.md b/go-xmpp/_examples/xmpp_chat_client/README.md new file mode 100644 index 0000000..d4db360 --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_chat_client/config.yml b/go-xmpp/_examples/xmpp_chat_client/config.yml new file mode 100644 index 0000000..6c6498b --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_chat_client/go.mod b/go-xmpp/_examples/xmpp_chat_client/go.mod new file mode 100644 index 0000000..60d9744 --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_chat_client/interface.go b/go-xmpp/_examples/xmpp_chat_client/interface.go new file mode 100644 index 0000000..9f2c758 --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_chat_client/xmpp_chat_client.go b/go-xmpp/_examples/xmpp_chat_client/xmpp_chat_client.go new file mode 100644 index 0000000..51e3bcf --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_component/README.md b/go-xmpp/_examples/xmpp_component/README.md new file mode 100644 index 0000000..3cd25dd --- /dev/null +++ b/go-xmpp/_examples/xmpp_component/README.md @@ -0,0 +1,22 @@ +# xmpp_component + +This component will connect to ejabberd and act as a subdomain "service" of your primary XMPP domain +(in that case localhost). + +This component does nothing expect connect and show up in service discovery. + +To be able to connect this component, you need to add a listener to your XMPP server. + +Here is an example ejabberd configuration for that component listener: + +```yaml +listen: +... + - + port: 8888 + module: ejabberd_service + password: "mypass" +``` + +ejabberd will listen for a component (service) on port 8888 and allows it to connect using the +secret "mypass". \ No newline at end of file diff --git a/go-xmpp/_examples/xmpp_component/xmpp_component.go b/go-xmpp/_examples/xmpp_component/xmpp_component.go new file mode 100644 index 0000000..e3a70ce --- /dev/null +++ b/go-xmpp/_examples/xmpp_component/xmpp_component.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "log" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func main() { + opts := xmpp.ComponentOptions{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: "localhost:8888", + Domain: "service2.localhost", + }, + Domain: "service2.localhost", + Secret: "mypass", + Name: "Test Component", + Category: "gateway", + Type: "service", + } + + router := xmpp.NewRouter() + router.HandleFunc("message", handleMessage) + router.NewRoute(). + IQNamespaces(stanza.NSDiscoInfo). + HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { + discoInfo(s, p, opts) + }) + router.NewRoute(). + IQNamespaces(stanza.NSDiscoItems). + HandlerFunc(discoItems) + router.NewRoute(). + IQNamespaces("jabber:iq:version"). + HandlerFunc(handleVersion) + + component, err := xmpp.NewComponent(opts, router, handleError) + if err != nil { + log.Fatalf("%+v", err) + } + + // If you pass the component to a stream manager, it will handle the reconnect policy + // for you automatically. + // TODO: Post Connect could be a feature of the router or the client. Move it somewhere else. + cm := xmpp.NewStreamManager(component, nil) + 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 { + return + } + fmt.Println("Received message:", msg.Body) +} + +func discoInfo(c xmpp.Sender, p stanza.Packet, opts xmpp.ComponentOptions) { + // Type conversion & sanity checks + iq, ok := p.(*stanza.IQ) + if !ok || iq.Type != stanza.IQTypeGet { + return + } + + 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") + _ = c.Send(iqResp) +} + +// 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 != stanza.IQTypeGet { + return + } + + discoItems, ok := iq.Payload.(*stanza.DiscoItems) + if !ok { + return + } + + // 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 == "" { + items.AddItem("service.localhost", "node1", "test node") + } + _ = c.Send(iqResp) +} + +func handleVersion(c xmpp.Sender, p stanza.Packet) { + // Type conversion & sanity checks + iq, ok := p.(*stanza.IQ) + if !ok { + return + } + + 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/go-xmpp/_examples/xmpp_component2/README.md b/go-xmpp/_examples/xmpp_component2/README.md new file mode 100644 index 0000000..9ae4885 --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_component2/main.go b/go-xmpp/_examples/xmpp_component2/main.go new file mode 100644 index 0000000..01415a0 --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_echo/xmpp_echo.go b/go-xmpp/_examples/xmpp_echo/xmpp_echo.go new file mode 100644 index 0000000..d7a35c8 --- /dev/null +++ b/go-xmpp/_examples/xmpp_echo/xmpp_echo.go @@ -0,0 +1,56 @@ +/* +xmpp_echo is a demo client that connect on an XMPP server and echo message received back to original sender. +*/ + +package main + +import ( + "fmt" + "log" + "os" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func main() { + config := xmpp.Config{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: "localhost:5222", + }, + Jid: "test@localhost", + 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, errorHandler) + if err != nil { + log.Fatalf("%+v", err) + } + + // If you pass the client to a connection manager, it will handle the reconnect policy + // for you automatically. + cm := xmpp.NewStreamManager(client, nil) + log.Fatal(cm.Run()) +} + +func handleMessage(s xmpp.Sender, p stanza.Packet) { + msg, ok := p.(stanza.Message) + if !ok { + _, _ = fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", p) + return + } + + _, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From) + 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/go-xmpp/_examples/xmpp_jukebox/README.md b/go-xmpp/_examples/xmpp_jukebox/README.md new file mode 100644 index 0000000..9eeb3a4 --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_jukebox/xmpp_jukebox.go b/go-xmpp/_examples/xmpp_jukebox/xmpp_jukebox.go new file mode 100644 index 0000000..31f000a --- /dev/null +++ b/go-xmpp/_examples/xmpp_jukebox/xmpp_jukebox.go @@ -0,0 +1,149 @@ +// Can be launched with: +// ./xmpp_jukebox -jid=test@localhost/jukebox -password=test -address=localhost:5222 +package main + +import ( + "encoding/xml" + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/processone/mpg123" + "github.com/processone/soundcloud" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +// Get the actual song Stream URL from SoundCloud website song URL and play it with mpg123 player. +const scClientID = "dde6a0075614ac4f3bea423863076b22" + +func main() { + 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() + + // 1. Create mpg player + player, err := mpg123.NewPlayer() + if err != nil { + log.Fatal(err) + } + + // 2. Prepare XMPP client + config := xmpp.Config{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: *address, + }, + Jid: *jid, + Credential: xmpp.Password(*password), + // StreamLogger: os.Stdout, + Insecure: true, + } + + router := xmpp.NewRouter() + router.NewRoute(). + Packet("message"). + HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { + handleMessage(s, p, player) + }) + router.NewRoute(). + Packet("iq"). + HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { + handleIQ(s, p, player) + }) + + client, err := xmpp.NewClient(&config, router, errorHandler) + if err != nil { + log.Fatalf("%+v", err) + } + + 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) + if !ok { + return + } + command := strings.Trim(msg.Body, " ") + if command == "stop" { + player.Stop() + } else { + playSCURL(player, command) + sendUserTune(s, "Radiohead", "Spectre") + } +} + +func handleIQ(s xmpp.Sender, p stanza.Packet, player *mpg123.Player) { + iq, ok := p.(*stanza.IQ) + if !ok { + return + } + switch payload := iq.Payload.(type) { + // We support IOT Control IQ + case *stanza.ControlSet: + var url string + for _, element := range payload.Fields { + if element.XMLName.Local == "string" && element.Name == "url" { + url = strings.Trim(element.Value, " ") + break + } + } + + playSCURL(player, url) + 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) + // TODO add Soundclound artist / title retrieval + sendUserTune(s, "Radiohead", "Spectre") + default: + _, _ = fmt.Fprintf(os.Stdout, "Other IQ Payload: %T\n", iq.Payload) + } +} + +func sendUserTune(s xmpp.Sender, artist string, title string) { + 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) { + songID, _ := soundcloud.GetSongID(rawURL) + // TODO: Maybe we need to check the track itself to get the stream URL from reply ? + url := soundcloud.FormatStreamURL(songID) + + _ = p.Play(strings.ReplaceAll(url, "YOUR_SOUNDCLOUD_CLIENTID", scClientID)) +} + +// TODO +// - Have a player API to play, play next, or add to queue +// - Have the ability to parse custom packet to play sound +// - Use PEP to display tunes status +// - Ability to "speak" messages diff --git a/go-xmpp/_examples/xmpp_oauth2/xmpp_oauth2.go b/go-xmpp/_examples/xmpp_oauth2/xmpp_oauth2.go new file mode 100644 index 0000000..a993049 --- /dev/null +++ b/go-xmpp/_examples/xmpp_oauth2/xmpp_oauth2.go @@ -0,0 +1,54 @@ +/* +xmpp_oauth2 is a demo client that connect on an XMPP server using OAuth2 and prints received messages. +*/ + +package main + +import ( + "fmt" + "log" + "os" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func main() { + config := xmpp.Config{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: "localhost:5222", + // TLSConfig: tls.Config{InsecureSkipVerify: true}, + }, + Jid: "test@localhost", + Credential: xmpp.OAuthToken("OdAIsBlY83SLBaqQoClAn7vrZSHxixT8"), + StreamLogger: os.Stdout, + // Insecure: true, + } + + router := xmpp.NewRouter() + router.HandleFunc("message", handleMessage) + + client, err := xmpp.NewClient(&config, router, errorHandler) + if err != nil { + log.Fatalf("%+v", err) + } + + // If you pass the client to a connection manager, it will handle the reconnect policy + // for you automatically. + 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) { + msg, ok := p.(stanza.Message) + if !ok { + _, _ = fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", p) + return + } + + _, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From) +} diff --git a/go-xmpp/_examples/xmpp_pubsub_client/README.md b/go-xmpp/_examples/xmpp_pubsub_client/README.md new file mode 100644 index 0000000..b86cedd --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_pubsub_client/xmpp_ps_client.go b/go-xmpp/_examples/xmpp_pubsub_client/xmpp_ps_client.go new file mode 100644 index 0000000..2308071 --- /dev/null +++ b/go-xmpp/_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/go-xmpp/_examples/xmpp_websocket/xmpp_websocket.go b/go-xmpp/_examples/xmpp_websocket/xmpp_websocket.go new file mode 100644 index 0000000..3a0c1ba --- /dev/null +++ b/go-xmpp/_examples/xmpp_websocket/xmpp_websocket.go @@ -0,0 +1,52 @@ +/* +xmpp_websocket is a demo client that connect on an XMPP server using websocket and prints received messages.ß +*/ + +package main + +import ( + "fmt" + "log" + "os" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func main() { + config := xmpp.Config{ + TransportConfiguration: xmpp.TransportConfiguration{ + Address: "wss://localhost:5443/ws", + }, + Jid: "test@localhost", + Credential: xmpp.Password("test"), + StreamLogger: os.Stdout, + } + + router := xmpp.NewRouter() + router.HandleFunc("message", handleMessage) + + client, err := xmpp.NewClient(&config, router, errorHandler) + if err != nil { + log.Fatalf("%+v", err) + } + + // If you pass the client to a connection manager, it will handle the reconnect policy + // for you automatically. + 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) { + msg, ok := p.(stanza.Message) + if !ok { + _, _ = fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", p) + return + } + + _, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From) +} diff --git a/go-xmpp/auth.go b/go-xmpp/auth.go new file mode 100644 index 0000000..902371b --- /dev/null +++ b/go-xmpp/auth.go @@ -0,0 +1,105 @@ +package xmpp + +import ( + "encoding/base64" + "encoding/xml" + "errors" + "fmt" + "io" + + "gosrc.io/xmpp/stanza" +) + +// Credential is used to pass the type of secret that will be used to connect to XMPP server. +// It can be either a password or an OAuth 2 bearer token. +type Credential struct { + secret string + mechanisms []string +} + +func Password(pwd string) Credential { + credential := Credential{ + secret: pwd, + mechanisms: []string{"PLAIN"}, + } + return credential +} + +func OAuthToken(token string) Credential { + credential := Credential{ + secret: token, + mechanisms: []string{"X-OAUTH2"}, + } + return credential +} + +// ============================================================================ +// Authentication flow for SASL mechanisms + +func authSASL(socket io.ReadWriter, decoder *xml.Decoder, f stanza.StreamFeatures, user string, credential Credential) (err error) { + var matchingMech string + for _, mech := range credential.mechanisms { + if isSupportedMech(mech, f.Mechanisms.Mechanism) { + matchingMech = mech + break + } + } + + switch matchingMech { + case "PLAIN", "X-OAUTH2": + // TODO: Implement other type of SASL mechanisms + return authPlain(socket, decoder, matchingMech, user, credential.secret) + default: + err := fmt.Errorf("no matching authentication (%v) supported by server: %v", credential.mechanisms, f.Mechanisms.Mechanism) + return NewConnError(err, true) + } +} + +// Plain authentication: send base64-encoded \x00 user \x00 password +func authPlain(socket io.ReadWriter, decoder *xml.Decoder, mech string, user string, secret string) error { + raw := "\x00" + user + "\x00" + secret + enc := make([]byte, base64.StdEncoding.EncodedLen(len(raw))) + base64.StdEncoding.Encode(enc, []byte(raw)) + + 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) + if err != nil { + return err + } + + switch v := val.(type) { + case stanza.SASLSuccess: + case stanza.SASLFailure: + // v.Any is type of sub-element in failure, which gives a description of what failed. + err := errors.New("auth failure: " + v.Any.Local) + return NewConnError(err, true) + default: + return errors.New("expected SASL success or failure, got " + v.Name()) + } + return err +} + +// isSupportedMech returns true if the mechanism is supported in the provided list. +func isSupportedMech(mech string, mechanisms []string) bool { + for _, m := range mechanisms { + if mech == m { + return true + } + } + return false +} diff --git a/go-xmpp/backoff.go b/go-xmpp/backoff.go new file mode 100644 index 0000000..2dfec8d --- /dev/null +++ b/go-xmpp/backoff.go @@ -0,0 +1,101 @@ +/* +Interesting reference on backoff: +- Exponential Backoff And Jitter (AWS Blog): + https://www.awsarchitectureblog.com/2015/03/backoff.html + +We use Jitter as a default for exponential backoff, as the goal of +this module is not to provide precise 'ticks', but good behaviour to +implement retries that are helping the server to recover faster in +case of congestion. + +It can be used in several ways: +- Using duration to get next sleep time. +- Using ticker channel to trigger callback function on tick + +The functions for Backoff are not threadsafe, but you can: +- Keep the attempt counter on your end and use durationForAttempt(int) +- Use lock in your own code to protect the Backoff structure. + +TODO: Implement Backoff Ticker channel +TODO: Implement throttler interface. Throttler could be used to implement various reconnect strategies. +*/ + +package xmpp + +import ( + "math" + "math/rand" + "time" +) + +const ( + defaultBase int = 20 // Backoff base, in ms + defaultFactor int = 2 + defaultCap int = 180000 // 3 minutes +) + +// backoff provides increasing duration with the number of attempt +// performed. The structure is used to support exponential backoff on +// connection attempts to avoid hammering the server we are connecting +// to. +type backoff struct { + NoJitter bool + Base int + Factor int + Cap int + lastDuration int + attempt int +} + +// duration returns the duration to apply to the current attempt. +func (b *backoff) duration() time.Duration { + d := b.durationForAttempt(b.attempt) + b.attempt++ + return d +} + +// wait sleeps for backoff duration for current attempt. +func (b *backoff) wait() { + time.Sleep(b.duration()) +} + +// durationForAttempt returns a duration for an attempt number, in a stateless way. +func (b *backoff) durationForAttempt(attempt int) time.Duration { + b.setDefault() + expBackoff := math.Min(float64(b.Cap), float64(b.Base)*math.Pow(float64(b.Factor), float64(b.attempt))) + d := int(math.Trunc(expBackoff)) + if !b.NoJitter { + d = rand.Intn(d) + } + return time.Duration(d) * time.Millisecond +} + +// reset sets back the number of attempts to 0. This is to be called after a successful operation has been performed, +// to reset the exponential backoff interval. +func (b *backoff) reset() { + b.attempt = 0 +} + +func (b *backoff) setDefault() { + if b.Base == 0 { + b.Base = defaultBase + } + + if b.Cap == 0 { + b.Cap = defaultCap + } + + if b.Factor == 0 { + b.Factor = defaultFactor + } +} + +/* +We use full jitter as default for now as it seems to provide good behaviour for reconnect. + +Base is the default interval between attempts (if backoff Factor was equal to 1) + +Attempt is the number of retry for operation. If we start attempt at 0, first sleep equals base. + +Cap is the maximum sleep time duration we tolerate between attempts +*/ diff --git a/go-xmpp/backoff_test.go b/go-xmpp/backoff_test.go new file mode 100644 index 0000000..9a7fde7 --- /dev/null +++ b/go-xmpp/backoff_test.go @@ -0,0 +1,22 @@ +package xmpp + +import ( + "testing" + "time" +) + +func TestDurationForAttempt_NoJitter(t *testing.T) { + b := backoff{Base: 25, NoJitter: true} + bInMS := time.Duration(b.Base) * time.Millisecond + if b.durationForAttempt(0) != bInMS { + t.Errorf("incorrect default duration for attempt #0 (%d) = %d", b.durationForAttempt(0)/time.Millisecond, bInMS/time.Millisecond) + } + var prevDuration, d time.Duration + for i := 0; i < 10; i++ { + d = b.durationForAttempt(i) + if !(d >= prevDuration) { + t.Errorf("duration should be increasing between attempts. #%d (%d) > %d", i, d, prevDuration) + } + prevDuration = d + } +} diff --git a/go-xmpp/bi_dir_iterator.go b/go-xmpp/bi_dir_iterator.go new file mode 100644 index 0000000..5293b1b --- /dev/null +++ b/go-xmpp/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/go-xmpp/cert_checker.go b/go-xmpp/cert_checker.go new file mode 100644 index 0000000..30a265a --- /dev/null +++ b/go-xmpp/cert_checker.go @@ -0,0 +1,151 @@ +package xmpp + +import ( + "crypto/tls" + "encoding/xml" + "errors" + "fmt" + "net" + "strings" + "time" + + "gosrc.io/xmpp/stanza" +) + +// TODO: Should I move this as an extension of the client? +// I should probably make the code more modular, but keep concern separated to keep it simple. +type ServerCheck struct { + address string + domain string +} + +func NewChecker(address, domain string) (*ServerCheck, error) { + client := ServerCheck{} + + var err error + var host string + if client.address, host, err = extractParams(address); err != nil { + return &client, err + } + + if domain != "" { + client.domain = domain + } else { + client.domain = host + } + + return &client, nil +} + +// Check triggers actual TCP connection, based on previously defined parameters. +func (c *ServerCheck) Check() error { + var tcpconn net.Conn + var err error + + timeout := 15 * time.Second + tcpconn, err = net.DialTimeout("tcp", c.address, timeout) + if err != nil { + return err + } + + decoder := xml.NewDecoder(tcpconn) + + // Send stream open tag + if _, err = fmt.Fprintf(tcpconn, clientStreamOpen, c.domain); err != nil { + return err + } + + // Set xml decoder and extract streamID from reply (not used for now) + _, err = stanza.InitStream(decoder) + if err != nil { + return err + } + + // extract stream features + var f stanza.StreamFeatures + packet, err := stanza.NextPacket(decoder) + if err != nil { + err = fmt.Errorf("stream open decode features: %s", err) + return err + } + + switch p := packet.(type) { + case stanza.StreamFeatures: + f = p + case stanza.StreamError: + return errors.New("open stream error: " + p.Error.Local) + default: + return errors.New("expected packet received while expecting features, got " + p.Name()) + } + + if _, ok := f.DoesStartTLS(); ok { + _, err = fmt.Fprintf(tcpconn, "") + if err != nil { + return err + } + + var k stanza.TLSProceed + if err = decoder.DecodeElement(&k, nil); err != nil { + return fmt.Errorf("expecting starttls proceed: %s", err) + } + + var tlsConfig tls.Config + tlsConfig.ServerName = c.domain + tlsConn := tls.Client(tcpconn, &tlsConfig) + // We convert existing connection to TLS + if err = tlsConn.Handshake(); err != nil { + return err + } + + // We check that cert matches hostname + if err = tlsConn.VerifyHostname(c.domain); err != nil { + return err + } + + if err = checkExpiration(tlsConn); err != nil { + return err + } + return nil + } + return errors.New("TLS not supported on server") +} + +// Check expiration date for the whole certificate chain and returns an error +// if the expiration date is in less than 48 hours. +func checkExpiration(tlsConn *tls.Conn) error { + checkedCerts := make(map[string]struct{}) + for _, chain := range tlsConn.ConnectionState().VerifiedChains { + for _, cert := range chain { + if _, checked := checkedCerts[string(cert.Signature)]; checked { + continue + } + checkedCerts[string(cert.Signature)] = struct{}{} + + // Check the expiration. + timeNow := time.Now() + expiresInHours := int64(cert.NotAfter.Sub(timeNow).Hours()) + // fmt.Printf("Cert '%s' expires in %d days\n", cert.Subject.CommonName, expiresInHours/24) + if expiresInHours <= 48 { + return fmt.Errorf("certificate '%s' will expire on %s", cert.Subject.CommonName, cert.NotAfter) + } + } + } + return nil +} + +func extractParams(addr string) (string, string, error) { + var err error + hostport := strings.Split(addr, ":") + if len(hostport) > 2 { + err = errors.New("too many colons in xmpp server address") + return addr, hostport[0], err + } + + // Address is composed of two parts, we are good + if len(hostport) == 2 && hostport[1] != "" { + return addr, hostport[0], err + } + + // Port was not passed, we append XMPP default port: + return strings.Join([]string{hostport[0], "5222"}, ":"), hostport[0], err +} diff --git a/go-xmpp/client.go b/go-xmpp/client.go new file mode 100644 index 0000000..0a29605 --- /dev/null +++ b/go-xmpp/client.go @@ -0,0 +1,449 @@ +package xmpp + +import ( + "context" + "encoding/xml" + "errors" + "io" + "net" + "sync" + "time" + + "gosrc.io/xmpp/stanza" +) + +//============================================================================= +// EventManager + +// 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 + 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 SyncConnState + Description string + StreamError string + SMState SMState +} + +// SMState holds Stream Management information regarding the session that can be +// used to resume session after disconnect +type SMState struct { + // Stream Management ID + Id string + // Inbound stanza count + Inbound uint + + // 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) error + +type EventManager struct { + // 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 +} + +// 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}) + } +} + +// 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}) + } +} + +// 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}) + } +} + +// Client +// ============================================================================ + +var ErrCanOnlySendGetOrSetIq = errors.New("SendIQ can only send get and set IQ stanzas") + +// Client is the main structure used to connect as a client on an XMPP +// server. +type Client struct { + // Store user defined options and states + config *Config + // Session gather data that can be accessed by users of this library + Session *Session + transport Transport + // Router is used to dispatch packets + 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 +} + +/* +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 domain part of the Jid. +// Default the port to 5222. +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) + } + + if config.Credential.secret == "" { + err = errors.New("missing credential") + return nil, NewConnError(err, true) + } + + // Fallback to jid domain + if config.Address == "" { + config.Address = config.parsedJid.Domain + + // Fetch SRV DNS-Entries + _, srvEntries, err := net.LookupSRV("xmpp-client", "tcp", config.parsedJid.Domain) + + if err == nil && len(srvEntries) > 0 { + // If we found matching DNS records, use the entry with highest weight + bestSrv := srvEntries[0] + for _, srv := range srvEntries { + if srv.Priority <= bestSrv.Priority && srv.Weight >= bestSrv.Weight { + bestSrv = srv + config.Address = ensurePort(srv.Target, int(srv.Port)) + } + } + } + } + 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 + } + + if config.TransportConfiguration.Domain == "" { + config.TransportConfiguration.Domain = config.parsedJid.Domain + } + c.config.TransportConfiguration.ConnectTimeout = c.config.ConnectTimeout + c.transport = NewClientTransport(c.config.TransportConfiguration) + + if config.StreamLogger != nil { + c.transport.LogTraffic(config.StreamLogger) + } + + return +} + +// Connect establishes a first time connection to a XMPP server. +// It calls the PostConnectHook +func (c *Client) Connect() error { + 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 +} + +// 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 + } + + // 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) + + return err +} + +// 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 { + err := c.transport.Close() + if c.Session != nil { + c.disconnected(c.Session.SMState) + } + return err + } + // No transport so no connection. + return nil +} + +func (c *Client) SetHandler(handler EventHandler) { + c.Handler = handler +} + +// Send marshals XMPP stanza and sends it to the server. +func (c *Client) Send(packet stanza.Packet) error { + conn := c.transport + if conn == nil { + return errors.New("client is not connected") + } + + data, err := xml.Marshal(packet) + if err != nil { + 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) +} + +// SendIQ sends an IQ set or get stanza to the server. If a result is received +// the provided handler function will automatically be called. +// +// The provided context should have a timeout to prevent the client from waiting +// forever for an IQ result. For example: +// +// 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 != stanza.IQTypeSet && iq.Attrs.Type != stanza.IQTypeGet { + return nil, ErrCanOnlySendGetOrSetIq + } + if err := c.Send(iq); err != nil { + return nil, err + } + return c.router.NewIQResultRoute(ctx, iq.Attrs.Id), nil +} + +// SendRaw sends an XMPP stanza as a string to the server. +// It can be invalid XML or XMPP content. In that case, the server will +// disconnect the client. It is up to the user of this method to +// carefully craft the XML content to produce valid XMPP. +func (c *Client) SendRaw(packet string) error { + conn := c.transport + if conn == nil { + 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)) +} + +func (c *Client) sendWithWriter(writer io.Writer, packet []byte) error { + var err error + _, err = writer.Write(packet) + return err +} + +// ============================================================================ +// Go routines + +// Loop: Receive data from server +func (c *Client) recv(keepaliveQuit chan<- struct{}) { + defer close(keepaliveQuit) + + for { + val, err := stanza.NextPacket(c.transport.GetDecoder()) + if err != nil { + c.ErrorHandler(err) + c.disconnected(c.Session.SMState) + return + } + + // Handle stream errors + switch packet := val.(type) { + case stanza.StreamError: + c.router.route(c, val) + c.streamError(packet.Error.Local, packet.Text) + 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: 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() + c.Disconnect() + continue + default: + 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. + go c.router.route(c, val) + } +} + +// 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, interval time.Duration, quit <-chan struct{}) { + ticker := time.NewTicker(interval) + for { + select { + case <-ticker.C: + if err := transport.Ping(); err != nil { + // When keepalive fails, we force close the transport. In all cases, the recv will also fail. + ticker.Stop() + _ = transport.Close() + return + } + case <-quit: + ticker.Stop() + return + } + } +} diff --git a/go-xmpp/client_internal_test.go b/go-xmpp/client_internal_test.go new file mode 100644 index 0000000..140eab7 --- /dev/null +++ b/go-xmpp/client_internal_test.go @@ -0,0 +1,608 @@ +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) { + buffer := bytes.NewBufferString("") + client := Client{} + data := []byte("https://da.wikipedia.org/wiki/J%C3%A6vnd%C3%B8gn") + if err := client.sendWithWriter(buffer, data); err != nil { + t.Errorf("Writing failed: %v", err) + } + + if buffer.String() != string(data) { + 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/go-xmpp/client_test.go b/go-xmpp/client_test.go new file mode 100644 index 0000000..0ff3997 --- /dev/null +++ b/go-xmpp/client_test.go @@ -0,0 +1,659 @@ +package xmpp + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "testing" + "time" + + "gosrc.io/xmpp/stanza" +) + +const ( + // Default port is not standard XMPP port to avoid interfering + // with local running XMPP server + 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, handlerClientConnectSuccess) + + // Test / Check result + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testXMPPAddress, + }, + 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) + } + + mock.Stop() +} + +func TestClient_NoInsecure(t *testing.T) { + // 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) + } + + if err = client.Connect(); err == nil { + // When insecure is not allowed: + t.Errorf("should fail as insecure connection is not allowed and server does not support TLS") + } + + mock.Stop() +} + +// Check that the client is properly tracking features, as session negotiation progresses. +func TestClient_FeaturesTracking(t *testing.T) { + // 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) + } + + if err = client.Connect(); err == nil { + // When insecure is not allowed: + t.Errorf("should fail as insecure connection is not allowed and server does not support TLS") + } + + mock.Stop() +} + +func TestClient_RFC3921Session(t *testing.T) { + // Setup Mock server + mock := ServerMock{} + mock.Start(t, testXMPPAddress, handlerClientConnectWithSession) + + // Test / Check result + config := Config{ + TransportConfiguration: TransportConfiguration{ + Address: testXMPPAddress, + }, + 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) + } + + 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.Error(err) + } + + 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. + +// Test connection with a basic straightforward workflow +func handlerClientConnectSuccess(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 + 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 + } + } + +} + +// We expect client will abort on TLS +func handlerAbortTLS(t *testing.T, sc *ServerConn) { + checkClientOpenStream(t, sc) + sendStreamFeatures(t, sc) // Send initial features +} + +// Test connection with mandatory session (RFC-3921) +func handlerClientConnectWithSession(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 + sendRFC3921Feature(t, sc) // Send post auth features + bind(t, sc) + session(t, sc) +} + +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 := sc.decoder.Token() + if err != nil { + t.Fatalf("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", "streamid1", stanza.NSClient, stanza.NSStream); err != nil { + t.Errorf("cannot write server stream open: %s", err) + } + return + } + + } +} + +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 +} + +// 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/go-xmpp/cmd/fluuxmpp/README.md b/go-xmpp/cmd/fluuxmpp/README.md new file mode 100644 index 0000000..de53d4b --- /dev/null +++ b/go-xmpp/cmd/fluuxmpp/README.md @@ -0,0 +1,198 @@ +# fluuxmpp + +fluuxIO's XMPP command-line tool + +## Installation + +To install `fluuxmpp` in your Go path: + +``` +$ go get -u gosrc.io/xmpp/cmd/fluuxmpp +``` + +## Usage + +``` +$ fluuxmpp --help +fluuxIO's xmpp comandline tool + +Usage: + fluuxmpp [command] + +Available Commands: + check is a command-line to check if you XMPP TLS certificate is valid and warn you before it expires + help Help about any command + send is a command-line tool to send to send XMPP messages to users + +Flags: + -h, --help help for fluuxmpp + +Use "fluuxmpp [command] --help" for more information about a command. +``` + +### check tls + +``` +$ fluuxmpp check --help +is a command-line to check if you XMPP TLS certificate is valid and warn you before it expires + +Usage: + fluuxmpp check [flags] + +Examples: +fluuxmpp check chat.sum7.eu:5222 --domain meckerspace.de + +Flags: + -d, --domain string domain if host handle multiple domains + -h, --help help for check +``` + +### sending messages + +``` +$ fluuxmpp send --help +is a command-line tool to send to send XMPP messages to users + +Usage: + fluuxmpp send [message] [flags] + +Examples: +fluuxmpp send to@chat.sum7.eu "Hello World!" + +Flags: + --addr string host[:port] + --config string config file (default is ~/.config/fluuxmpp.yml) + -h, --help help for send + --jid string using jid (required) + -m, --muc recipient is a muc (join it before sending messages) + --password string using password for your jid (required) +``` + + +## Examples + +### check tls + +If you server is on standard port and XMPP domains matches the hostname you can simply use: + +``` +$ fluuxmpp check chat.sum7.eu + info All checks passed + ⇢ address="chat.sum7.eu" domain="" + ⇢ main.go:43 main.runCheck + ⇢ 2019-07-16T22:01:39.765+02:00 +``` + +You can also pass the port and the XMPP domain if different from the server hostname: + +``` +$ fluuxmpp check chat.sum7.eu:5222 --domain meckerspace.de + info All checks passed + ⇢ address="chat.sum7.eu:5222" domain="meckerspace.de" + ⇢ main.go:43 main.runCheck + ⇢ 2019-07-16T22:01:33.270+02:00 +``` + +Error code will be non-zero in case of error. You can thus use it directly with your usual +monitoring scripts. + + +### sending messages + +Message from arguments: +```bash +$ fluuxmpp send to@example.org "Hello World!" + info client connected + ⇢ cmd.go:56 main.glob..func1.1 + ⇢ 2019-07-17T23:42:43.310+02:00 + info send message + ⇢ muc=false text="Hello World!" to="to@example.org" + ⇢ send.go:31 main.send + ⇢ 2019-07-17T23:42:43.310+02:00 +``` + +Message from STDIN: +```bash +$ journalctl -f | fluuxmpp send to@example.org - + info client connected + ⇢ cmd.go:56 main.glob..func1.1 + ⇢ 2019-07-17T23:40:03.177+02:00 + info send message + ⇢ muc=false text="-- Logs begin at Mon 2019-07-08 22:16:54 CEST. --" to="to@example.org" + ⇢ send.go:31 main.send + ⇢ 2019-07-17T23:40:03.178+02:00 + info send message + ⇢ muc=false text="Jul 17 23:36:46 RECHNERNAME systemd[755]: Started Fetch mails." to="to@example.org" + ⇢ send.go:31 main.send + ⇢ 2019-07-17T23:40:03.178+02:00 +^C +``` + + +Multiple recipients: +```bash +$ fluuxmpp send to1@example.org,to2@example.org "Multiple recipient" + info client connected + ⇢ cmd.go:56 main.glob..func1.1 + ⇢ 2019-07-17T23:47:57.650+02:00 + info send message + ⇢ muc=false text="Multiple recipient" to="to1@example.org" + ⇢ send.go:31 main.send + ⇢ 2019-07-17T23:47:57.651+02:00 + info send message + ⇢ muc=false text="Multiple recipient" to="to2@example.org" + ⇢ send.go:31 main.send + ⇢ 2019-07-17T23:47:57.652+02:00 +``` + +Send to MUC: +```bash +journalctl -f | fluuxmpp send testit@conference.chat.sum7.eu - --muc + info client connected + ⇢ cmd.go:56 main.glob..func1.1 + ⇢ 2019-07-17T23:52:56.269+02:00 + info send message + ⇢ muc=true text="-- Logs begin at Mon 2019-07-08 22:16:54 CEST. --" to="testit@conference.chat.sum7.eu" + ⇢ send.go:31 main.send + ⇢ 2019-07-17T23:52:56.270+02:00 + info send message + ⇢ muc=true text="Jul 17 23:48:58 RECHNERNAME systemd[755]: mail.service: Succeeded." to="testit@conference.chat.sum7.eu" + ⇢ send.go:31 main.send + ⇢ 2019-07-17T23:52:56.277+02:00 +^C +``` + +## Authentification + +### Configuration file + +In `/etc/`, `~/.config` and `.` (here). +You could create the file name `fluuxmpp` with you favorite file extension (e.g. `toml`, `yml`). + +e.g. ~/.config/fluuxmpp.toml +```toml +jid = "bot@example.org" +password = "secret" + +addr = "example.com:5222" +``` + +### Environment variables + +```bash +export FLUXXMPP_JID='bot@example.org'; +export FLUXXMPP_PASSWORD='secret'; + +export FLUXXMPP_ADDR='example.com:5222'; + +fluuxmpp send to@example.org "Hello Welt"; +``` + +### Parameters + +Warning: This should not be used for production systems, as all users on the system +can read the running processes, and their parameters (and thus the password). + +```bash +fluuxmpp send to@example.org "Hello World!" --jid bot@example.org --password secret --addr example.com:5222; +``` diff --git a/go-xmpp/cmd/fluuxmpp/TODO.md b/go-xmpp/cmd/fluuxmpp/TODO.md new file mode 100644 index 0000000..61866f3 --- /dev/null +++ b/go-xmpp/cmd/fluuxmpp/TODO.md @@ -0,0 +1,21 @@ +# TODO + +## check +### Features + +- Use a config file to define the checks to perform as client on an XMPP server. + +## send + +### Issues + +- Remove global variable (like mucToleave) +- Does not report error when trying to connect to a non open port (for example localhost with no server running). + +### Features + +- configuration + - allow unencrypted + - skip tls verification +- support muc and single user at same time +- send html -> parse console colors to xhtml (is there a easy way or lib for it ?) diff --git a/go-xmpp/cmd/fluuxmpp/check.go b/go-xmpp/cmd/fluuxmpp/check.go new file mode 100644 index 0000000..0ddfe3e --- /dev/null +++ b/go-xmpp/cmd/fluuxmpp/check.go @@ -0,0 +1,41 @@ +package main + +import ( + "github.com/bdlm/log" + "github.com/spf13/cobra" + "gosrc.io/xmpp" +) + +var domain = "" +var cmdCheck = &cobra.Command{ + Use: "check ", + Short: "is a command-line to check if you XMPP TLS certificate is valid and warn you before it expires", + Example: "fluuxmpp check chat.sum7.eu:5222 --domain meckerspace.de", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runCheck(args[0], domain) + }, +} + +func init() { + cmdRoot.AddCommand(cmdCheck) + cmdCheck.Flags().StringVarP(&domain, "domain", "d", "", "domain if host handle multiple domains") +} + +func runCheck(address, domain string) { + logger := log.WithFields(map[string]interface{}{ + "address": address, + "domain": domain, + }) + client, err := xmpp.NewChecker(address, domain) + + if err != nil { + log.Fatal("Error: ", err) + } + + if err = client.Check(); err != nil { + logger.Fatal("Failed connection check: ", err) + } + + logger.Println("All checks passed") +} diff --git a/go-xmpp/cmd/fluuxmpp/doc.go b/go-xmpp/cmd/fluuxmpp/doc.go new file mode 100644 index 0000000..d01e4da --- /dev/null +++ b/go-xmpp/cmd/fluuxmpp/doc.go @@ -0,0 +1,5 @@ +/* + +fluuxmpp: fluuxIO's xmpp comandline tool +*/ +package main diff --git a/go-xmpp/cmd/fluuxmpp/log.go b/go-xmpp/cmd/fluuxmpp/log.go new file mode 100644 index 0000000..eb67593 --- /dev/null +++ b/go-xmpp/cmd/fluuxmpp/log.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + + "github.com/bdlm/log" + stdLogger "github.com/bdlm/std/logger" +) + +type hook struct{} + +func (h *hook) Fire(entry *log.Entry) error { + switch entry.Level { + case log.PanicLevel: + entry.Logger.Out = os.Stderr + case log.FatalLevel: + entry.Logger.Out = os.Stderr + case log.ErrorLevel: + entry.Logger.Out = os.Stderr + case log.WarnLevel: + entry.Logger.Out = os.Stdout + case log.InfoLevel: + entry.Logger.Out = os.Stdout + case log.DebugLevel: + entry.Logger.Out = os.Stdout + default: + } + + return nil +} + +func (h *hook) Levels() []stdLogger.Level { + return log.AllLevels +} diff --git a/go-xmpp/cmd/fluuxmpp/main.go b/go-xmpp/cmd/fluuxmpp/main.go new file mode 100644 index 0000000..176666d --- /dev/null +++ b/go-xmpp/cmd/fluuxmpp/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "github.com/bdlm/log" + "github.com/spf13/cobra" +) + +// cmdRoot represents the base command when called without any subcommands +var cmdRoot = &cobra.Command{ + Use: "fluuxmpp", + Short: "fluuxIO's xmpp comandline tool", +} + +func main() { + log.AddHook(&hook{}) + if err := cmdRoot.Execute(); err != nil { + log.Fatal(err) + } +} diff --git a/go-xmpp/cmd/fluuxmpp/send.go b/go-xmpp/cmd/fluuxmpp/send.go new file mode 100644 index 0000000..27c1a67 --- /dev/null +++ b/go-xmpp/cmd/fluuxmpp/send.go @@ -0,0 +1,141 @@ +package main + +import ( + "bufio" + "gosrc.io/xmpp/stanza" + "os" + "strings" + "sync" + + "github.com/bdlm/log" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "gosrc.io/xmpp" +) + +var configFile = "" + +// FIXME: Remove global variables +var isMUCRecipient = false + +var cmdSend = &cobra.Command{ + Use: "send [message]", + Short: "is a command-line tool to send to send XMPP messages to users", + Example: `fluuxmpp send to@chat.sum7.eu "Hello World!"`, + Args: cobra.ExactArgs(2), + Run: sendxmpp, +} + +func sendxmpp(cmd *cobra.Command, args []string) { + receiver := strings.Split(args[0], ",") + msgText := args[1] + + var err error + 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(), + func(err error) { + log.Println(err) + }) + + if err != nil { + log.Errorf("error when starting xmpp client: %s", err) + return + } + + wg := sync.WaitGroup{} + wg.Add(1) + + // FIXME: Remove global variables + var mucsToLeave []*stanza.Jid + + cm := xmpp.NewStreamManager(client, func(c xmpp.Sender) { + defer wg.Done() + + log.Info("client connected") + + if isMUCRecipient { + for _, muc := range receiver { + jid, err := stanza.NewJid(muc) + if err != nil { + log.WithField("muc", muc).Errorf("skipping invalid muc jid: %w", err) + continue + } + jid.Resource = "sendxmpp" + + if err := joinMUC(c, jid); err != nil { + log.WithField("muc", muc).Errorf("error joining muc: %w", err) + continue + } + mucsToLeave = append(mucsToLeave, jid) + } + } + + if msgText != "-" { + send(c, receiver, msgText) + return + } + + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + send(c, receiver, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + log.Errorf("error on reading stdin: %s", err) + } + }) + go func() { + err := cm.Run() + log.Panic("closed connection:", err) + wg.Done() + }() + + wg.Wait() + + leaveMUCs(client, mucsToLeave) +} + +func init() { + cmdRoot.AddCommand(cmdSend) + + cobra.OnInitialize(initConfigFile) + cmdSend.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is ~/.config/fluuxmpp.yml)") + + cmdSend.Flags().StringP("jid", "", "", "using jid (required)") + viper.BindPFlag("jid", cmdSend.Flags().Lookup("jid")) + + cmdSend.Flags().StringP("password", "", "", "using password for your jid (required)") + viper.BindPFlag("password", cmdSend.Flags().Lookup("password")) + + cmdSend.Flags().StringP("addr", "", "", "host[:port]") + viper.BindPFlag("addr", cmdSend.Flags().Lookup("addr")) + + cmdSend.Flags().BoolVarP(&isMUCRecipient, "muc", "m", false, "recipient is a muc (join it before sending messages)") +} + +// initConfig reads in config file and ENV variables if set. +func initConfigFile() { + if configFile != "" { + viper.SetConfigFile(configFile) + } + + viper.SetConfigName("fluuxmpp") + viper.AddConfigPath("/etc/") + viper.AddConfigPath("$HOME/.config") + viper.AddConfigPath(".") + + viper.SetEnvPrefix("FLUXXMPP") + viper.AutomaticEnv() + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err != nil { + log.Warnf("no configuration found (somebody could read your password from process argument list): %s", err) + } +} diff --git a/go-xmpp/cmd/fluuxmpp/xmppmuc.go b/go-xmpp/cmd/fluuxmpp/xmppmuc.go new file mode 100644 index 0000000..a00fdfc --- /dev/null +++ b/go-xmpp/cmd/fluuxmpp/xmppmuc.go @@ -0,0 +1,28 @@ +package main + +import ( + "github.com/bdlm/log" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +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{ + History: stanza.History{MaxStanzas: stanza.NewNullableInt(0)}, + }}, + }) +} + +func leaveMUCs(c xmpp.Sender, mucsToLeave []*stanza.Jid) { + for _, muc := range mucsToLeave { + if err := c.Send(stanza.Presence{Attrs: stanza.Attrs{ + To: muc.Full(), + Type: stanza.PresenceTypeUnavailable, + }}); err != nil { + log.WithField("muc", muc).Errorf("error on leaving muc: %s", err) + } + } +} diff --git a/go-xmpp/cmd/fluuxmpp/xmppsend.go b/go-xmpp/cmd/fluuxmpp/xmppsend.go new file mode 100644 index 0000000..a2c79c4 --- /dev/null +++ b/go-xmpp/cmd/fluuxmpp/xmppsend.go @@ -0,0 +1,36 @@ +package main + +import ( + "github.com/bdlm/log" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func send(c xmpp.Sender, recipient []string, msgText string) { + msg := stanza.Message{ + Attrs: stanza.Attrs{Type: stanza.MessageTypeChat}, + Body: msgText, + } + + if isMUCRecipient { + msg.Type = stanza.MessageTypeGroupchat + } + + for _, to := range recipient { + msg.To = to + if err := c.Send(msg); err != nil { + log.WithFields(map[string]interface{}{ + "muc": isMUCRecipient, + "to": to, + "text": msgText, + }).Errorf("error on send message: %s", err) + } else { + log.WithFields(map[string]interface{}{ + "muc": isMUCRecipient, + "to": to, + "text": msgText, + }).Info("send message") + } + } +} diff --git a/go-xmpp/cmd/go.mod b/go-xmpp/cmd/go.mod new file mode 100644 index 0000000..7d61113 --- /dev/null +++ b/go-xmpp/cmd/go.mod @@ -0,0 +1,13 @@ +module gosrc.io/xmpp/cmd + +go 1.25 + +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.6.1 + gosrc.io/xmpp v0.1.1 +) + +replace gosrc.io/xmpp => ./../ diff --git a/go-xmpp/cmd/go.sum b/go-xmpp/cmd/go.sum new file mode 100644 index 0000000..af7eda1 --- /dev/null +++ b/go-xmpp/cmd/go.sum @@ -0,0 +1,259 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +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/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= +github.com/bdlm/std v0.0.0-20180922040903-fd3b596111c7/go.mod h1:E4vIYZDcEPVbE/Dbxc7GpA3YJpXnsF5csRt8LptMGWI= +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-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +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/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +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 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= +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= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/google/uuid v1.6.0/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 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +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/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= +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 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +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 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= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +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= +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 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +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/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +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= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +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 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= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +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-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +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-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= +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= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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= +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= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +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 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +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= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/go-xmpp/component.go b/go-xmpp/component.go new file mode 100644 index 0000000..ec0e8df --- /dev/null +++ b/go-xmpp/component.go @@ -0,0 +1,230 @@ +package xmpp + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "gosrc.io/xmpp/stanza" + "io" +) + +type ComponentOptions struct { + TransportConfiguration + + // ================================= + // Component Connection Info + + // Domain is the XMPP server subdomain that the component will handle + Domain string + // Secret is the "password" used by the XMPP server to secure component access + Secret string + + // ================================= + // Component discovery + + // Component human readable name, that will be shown in XMPP discovery + Name string + // Typical categories and types: https://xmpp.org/registrar/disco-categories.html + Category string + Type string + + // ================================= + // Communication with developer client / StreamManager + + // Track and broadcast connection state + EventManager +} + +// Component implements an XMPP extension allowing to extend XMPP server +// using external components. Component specifications are defined +// in XEP-0114, XEP-0355 and XEP-0356. +type Component struct { + ComponentOptions + router *Router + + transport Transport + + // read / write + socketProxy io.ReadWriter // TODO + ErrorHandler func(error) +} + +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 { + return c.Resume() +} + +func (c *Component) Resume() error { + var err error + var streamId string + if c.ComponentOptions.TransportConfiguration.Domain == "" { + c.ComponentOptions.TransportConfiguration.Domain = c.ComponentOptions.Domain + } + c.transport, err = NewComponentTransport(c.ComponentOptions.TransportConfiguration) + if err != nil { + c.updateState(StatePermanentError) + return NewConnError(err, true) + } + + if streamId, err = c.transport.Connect(); err != nil { + c.updateState(StatePermanentError) + return NewConnError(err, true) + } + + // Authentication + 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.transport.GetDecoder()) + if err != nil { + c.updateState(StatePermanentError) + return NewConnError(err, true) + } + + switch v := val.(type) { + case stanza.StreamError: + c.streamError("conflict", "no auth loop") + return NewConnError(errors.New("handshake failed "+v.Error.Local), true) + case stanza.Handshake: + // Start the receiver go routine + c.updateState(StateSessionEstablished) + go c.recv() + return err // Should be empty at this point + default: + c.updateState(StatePermanentError) + return NewConnError(errors.New("expecting handshake result, got "+v.Name()), true) + } +} + +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 { + return c.transport.Close() + } + // No transport so no connection. + return nil +} + +func (c *Component) SetHandler(handler EventHandler) { + c.Handler = handler +} + +// Receiver Go routine receiver +func (c *Component) recv() { + for { + val, err := stanza.NextPacket(c.transport.GetDecoder()) + if err != nil { + c.updateState(StateDisconnected) + 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) + 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) + } +} + +// Send marshalls XMPP stanza and sends it to the server. +func (c *Component) Send(packet stanza.Packet) error { + transport := c.transport + if transport == nil { + return errors.New("component is not connected") + } + + data, err := xml.Marshal(packet) + if err != nil { + return errors.New("cannot marshal packet " + err.Error()) + } + + 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. +// +// The provided context should have a timeout to prevent the client from waiting +// forever for an IQ result. For example: +// +// 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 != stanza.IQTypeSet && iq.Attrs.Type != stanza.IQTypeGet { + return nil, ErrCanOnlySendGetOrSetIq + } + if err := c.Send(iq); err != nil { + return nil, err + } + return c.router.NewIQResultRoute(ctx, iq.Attrs.Id), nil +} + +// SendRaw sends an XMPP stanza as a string to the server. +// It can be invalid XML or XMPP content. In that case, the server will +// disconnect the component. It is up to the user of this method to +// carefully craft the XML content to produce valid XMPP. +func (c *Component) SendRaw(packet string) error { + transport := c.transport + if transport == nil { + return errors.New("component is not connected") + } + + var err error + err = c.sendWithWriter(transport, []byte(packet)) + return err +} + +// handshake generates an authentication token based on StreamID and shared secret. +func (c *Component) handshake(streamId string) string { + // 1. Concatenate the Stream ID received from the server with the shared secret. + concatStr := streamId + c.Secret + + // 2. Hash the concatenated string according to the SHA1 algorithm, i.e., SHA1( concat (sid, password)). + h := sha1.New() + h.Write([]byte(concatStr)) + hash := h.Sum(nil) + + // 3. Ensure that the hash output is in hexadecimal format, not binary or base64. + // 4. Convert the hash output to all lowercase characters. + encodedStr := hex.EncodeToString(hash) + + return encodedStr +} + +/* +TODO: Add support for discovery management directly in component +TODO: Support multiple identities on disco info +TODO: Support returning features on disco info +*/ diff --git a/go-xmpp/component_test.go b/go-xmpp/component_test.go new file mode 100644 index 0000000..b5b9f35 --- /dev/null +++ b/go-xmpp/component_test.go @@ -0,0 +1,512 @@ +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) { + opts := ComponentOptions{ + Domain: "test.localhost", + Secret: "mypass", + } + c := Component{ComponentOptions: opts} + + streamID := "1263952298440005243" + expected := "c77e2ef0109fbbc5161e83b51629cd1353495332" + + result := c.handshake(streamID) + if result != expected { + t.Errorf("incorrect handshake calculation '%s' != '%s'", result, expected) + } +} + +// 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. +// +// This validates that Component conforms to StreamClient interface. +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.Fatal(err) + } + 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/go-xmpp/config.go b/go-xmpp/config.go new file mode 100644 index 0000000..4609a0a --- /dev/null +++ b/go-xmpp/config.go @@ -0,0 +1,35 @@ +package xmpp + +import ( + "gosrc.io/xmpp/stanza" + "os" + "time" +) + +// Config & TransportConfiguration must not be modified after having been passed to NewClient. Any +// changes made after connecting are ignored. +type Config struct { + TransportConfiguration + + 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/go-xmpp/conn_error.go b/go-xmpp/conn_error.go new file mode 100644 index 0000000..2b1501c --- /dev/null +++ b/go-xmpp/conn_error.go @@ -0,0 +1,33 @@ +package xmpp + +import ( + "fmt" + + "golang.org/x/xerrors" +) + +type ConnError struct { + frame xerrors.Frame + err error + // Permanent will be true if error is not recoverable + Permanent bool +} + +func NewConnError(err error, permanent bool) ConnError { + return ConnError{err: err, frame: xerrors.Caller(1), Permanent: permanent} +} + +func (e ConnError) Format(s fmt.State, verb rune) { + xerrors.FormatError(e, s, verb) +} + +func (e ConnError) FormatError(p xerrors.Printer) error { + e.frame.Format(p) + return e.err +} + +func (e ConnError) Error() string { + return fmt.Sprint(e) +} + +func (e ConnError) Unwrap() error { return e.err } diff --git a/go-xmpp/doc.go b/go-xmpp/doc.go new file mode 100644 index 0000000..f29bbf6 --- /dev/null +++ b/go-xmpp/doc.go @@ -0,0 +1,42 @@ +/* +Fluux XMPP is an modern and full-featured XMPP library that can be used to build clients or +server components. + +The goal is to make simple to write modern compliant XMPP software: + + - For automation (like for example monitoring of an XMPP service), + - For building connected "things" by plugging them on an XMPP server, + - For writing simple chatbots to control a service or a thing. + - For writing XMPP servers components. Fluux XMPP supports: + - XEP-0114: Jabber Component Protocol + - XEP-0355: Namespace Delegation + - XEP-0356: Privileged Entity + +The library is designed to have minimal dependencies. For now, the library does not depend on any other library. + +The library includes a StreamManager that provides features like autoreconnect exponential back-off. + +The library is implementing latest versions of the XMPP specifications (RFC 6120 and RFC 6121), and includes +support for many extensions. + +Clients + +Fluux XMPP can be use to create fully interactive XMPP clients (for +example console-based), but it is more commonly used to build automated +clients (connected devices, automation scripts, chatbots, etc.). + +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 +serverConnections. + +Component protocol is defined in XEP-114 (https://xmpp.org/extensions/xep-0114.html). + +Compliance + +Fluux XMPP has been primarily tested with ejabberd (https://www.ejabberd.im) +but it should work with any XMPP compliant server. + +*/ +package xmpp diff --git a/go-xmpp/go.mod b/go-xmpp/go.mod new file mode 100644 index 0000000..0d6e57b --- /dev/null +++ b/go-xmpp/go.mod @@ -0,0 +1,10 @@ +module gosrc.io/xmpp + +go 1.25 + +require ( + github.com/google/go-cmp v0.7.0 + github.com/google/uuid v1.6.0 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da + nhooyr.io/websocket v1.8.17 +) diff --git a/go-xmpp/go.sum b/go-xmpp/go.sum new file mode 100644 index 0000000..a345e2f --- /dev/null +++ b/go-xmpp/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/go-xmpp/network.go b/go-xmpp/network.go new file mode 100644 index 0000000..8b03f3f --- /dev/null +++ b/go-xmpp/network.go @@ -0,0 +1,32 @@ +package xmpp + +import ( + "strconv" + "strings" +) + +// ensurePort adds a port to an address if none are provided. +// It handles both IPV4 and IPV6 addresses. +func ensurePort(addr string, port int) string { + // This is an IPV6 address literal + if strings.HasPrefix(addr, "[") { + // if address has no port (behind his ipv6 address) - add default port + if strings.LastIndex(addr, ":") <= strings.LastIndex(addr, "]") { + return addr + ":" + strconv.Itoa(port) + } + return addr + } + + // This is either an IPV6 address without bracket or an IPV4 address + switch strings.Count(addr, ":") { + case 0: + // This is IPV4 without port + return addr + ":" + strconv.Itoa(port) + case 1: + // This is IPV6 with port + return addr + default: + // This is IPV6 without port, as you need to use bracket with port in IPV6 + return "[" + addr + "]:" + strconv.Itoa(port) + } +} diff --git a/go-xmpp/network_test.go b/go-xmpp/network_test.go new file mode 100644 index 0000000..470f150 --- /dev/null +++ b/go-xmpp/network_test.go @@ -0,0 +1,66 @@ +package xmpp + +import ( + "strings" + "testing" +) + +func TestParseAddr(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "ipv4-no-port-1", input: "localhost", want: "localhost:5222"}, + {name: "ipv4-with-port-1", input: "localhost:5555", want: "localhost:5555"}, + {name: "ipv4-no-port-2", input: "127.0.0.1", want: "127.0.0.1:5222"}, + {name: "ipv4-with-port-2", input: "127.0.0.1:5555", want: "127.0.0.1:5555"}, + {name: "ipv6-no-port-1", input: "::1", want: "[::1]:5222"}, + {name: "ipv6-no-port-2", input: "[::1]", want: "[::1]:5222"}, + {name: "ipv6-no-port-3", input: "2001::7334", want: "[2001::7334]:5222"}, + {name: "ipv6-no-port-4", input: "2001:db8:85a3:0:0:8a2e:370:7334", want: "[2001:db8:85a3:0:0:8a2e:370:7334]:5222"}, + {name: "ipv6-with-port-1", input: "[::1]:5555", want: "[::1]:5555"}, + } + for _, tc := range tests { + t.Run(tc.name, func(st *testing.T) { + addr := ensurePort(tc.input, 5222) + + if addr != tc.want { + st.Errorf("incorrect Result: %v (!= %v)", addr, tc.want) + } + }) + } +} + +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/go-xmpp/router.go b/go-xmpp/router.go new file mode 100644 index 0000000..7bba8b9 --- /dev/null +++ b/go-xmpp/router.go @@ -0,0 +1,374 @@ +package xmpp + +import ( + "context" + "encoding/xml" + "strings" + "sync" + + "gosrc.io/xmpp/stanza" +) + +/* +The XMPP router helps client and component developers select which XMPP they would like to process, +and associate processing code depending on the router configuration. + +Here are important rules to keep in mind while setting your routes and matchers: +- Routes are evaluated in the order they are set. +- When a route matches, it is executed and all others routes are ignored. For each packet, only a single + route is executed. +- An empty route will match everything. Adding an empty route as the last route in your router will + allow you to get all stanzas that did not match any previous route. You can for example use this to + log all unexpected stanza received by your client or component. + +TODO: Automatically reply to IQ that do not match any route, to comply to XMPP standard. +*/ + +type Router struct { + // Routes to be matched, in order. + routes []*Route + + IQResultRoutes map[string]*IQResultRoute + IQResultRouteLock sync.RWMutex +} + +// NewRouter returns a new router instance. +func NewRouter() *Router { + return &Router{ + IQResultRoutes: make(map[string]*IQResultRoute), + } +} + +// 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) { + 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] + r.IQResultRouteLock.RUnlock() + if ok { + r.IQResultRouteLock.Lock() + delete(r.IQResultRoutes, iq.Id) + r.IQResultRouteLock.Unlock() + route.result <- *iq + close(route.result) + return + } + } + + var match RouteMatch + if r.Match(p, &match) { + // If we match, route the packet + match.Handler.HandlePacket(s, p) + return + } + + // If there is no match and we receive an iq set or get, we need to send a reply + if isIq && (iq.Type == stanza.IQTypeGet || iq.Type == stanza.IQTypeSet) { + iqNotImplemented(s, 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, + Type: "cancel", + Reason: "feature-not-implemented", + } + reply := iq.MakeError(err) + _ = s.Send(reply) +} + +// NewRoute registers an empty routes +func (r *Router) NewRoute() *Route { + route := &Route{} + r.routes = append(r.routes, route) + return route +} + +// NewIQResultRoute register a route that will catch an IQ result stanza with +// the given Id. The route will only match ones, after which it will automatically +// be unregistered +func (r *Router) NewIQResultRoute(ctx context.Context, id string) chan stanza.IQ { + route := NewIQResultRoute(ctx) + r.IQResultRouteLock.Lock() + r.IQResultRoutes[id] = route + r.IQResultRouteLock.Unlock() + + // Start a go function to make sure the route is unregistered when the context + // is done. + go func() { + <-route.context.Done() + r.IQResultRouteLock.Lock() + delete(r.IQResultRoutes, id) + r.IQResultRouteLock.Unlock() + }() + + return route.result +} + +func (r *Router) Match(p stanza.Packet, match *RouteMatch) bool { + for _, route := range r.routes { + if route.Match(p, match) { + return true + } + } + return false +} + +// Handle registers a new route with a matcher for a given packet name (iq, message, presence) +// See Route.Packet() and Route.Handler(). +func (r *Router) Handle(name string, handler Handler) *Route { + return r.NewRoute().Packet(name).Handler(handler) +} + +// HandleFunc registers a new route with a matcher for for a given packet name (iq, message, presence) +// See Route.Path() and Route.HandlerFunc(). +func (r *Router) HandleFunc(name string, f func(s Sender, p stanza.Packet)) *Route { + return r.NewRoute().Packet(name).HandlerFunc(f) +} + +// ============================================================================ + +// TimeoutHandlerFunc is a function type for handling IQ result timeouts. +type TimeoutHandlerFunc func(err error) + +// IQResultRoute is a temporary route to match IQ result stanzas +type IQResultRoute struct { + context context.Context + result chan stanza.IQ +} + +// NewIQResultRoute creates a new IQResultRoute instance +func NewIQResultRoute(ctx context.Context) *IQResultRoute { + return &IQResultRoute{ + context: ctx, + result: make(chan stanza.IQ), + } +} + +// ============================================================================ +// IQ result handler + +// IQResultHandler is a utility interface for IQ result handlers +type IQResultHandler interface { + HandleIQ(ctx context.Context, s Sender, iq stanza.IQ) +} + +// IQResultHandlerFunc is an adapter to allow using functions as IQ result handlers. +type IQResultHandlerFunc func(ctx context.Context, s Sender, iq stanza.IQ) + +// HandleIQ is a proxy function to implement IQResultHandler using a function. +func (f IQResultHandlerFunc) HandleIQ(ctx context.Context, s Sender, iq stanza.IQ) { + f(ctx, s, iq) +} + +// ============================================================================ +// Route + +type Handler interface { + HandlePacket(s Sender, p stanza.Packet) +} + +type Route struct { + handler Handler + // Matchers are used to "specialize" routes and focus on specific packet features + matchers []Matcher +} + +func (r *Route) Handler(handler Handler) *Route { + r.handler = handler + return r +} + +// The HandlerFunc type is an adapter to allow the use of +// ordinary functions as XMPP handlers. If f is a function +// with the appropriate signature, HandlerFunc(f) is a +// Handler that calls f. +type HandlerFunc func(s Sender, p stanza.Packet) + +// HandlePacket calls f(s, p) +func (f HandlerFunc) HandlePacket(s Sender, p stanza.Packet) { + f(s, p) +} + +// HandlerFunc sets a handler function for the route +func (r *Route) HandlerFunc(f HandlerFunc) *Route { + return r.Handler(f) +} + +// AddMatcher adds a matcher to the route +func (r *Route) AddMatcher(m Matcher) *Route { + r.matchers = append(r.matchers, m) + return r +} + +func (r *Route) Match(p stanza.Packet, match *RouteMatch) bool { + for _, m := range r.matchers { + if matched := m.Match(p, match); !matched { + return false + } + } + + // We have a match, let's pass info route match info + match.Route = r + match.Handler = r.handler + return true +} + +// -------------------- +// Match on packet name + +type nameMatcher string + +func (n nameMatcher) Match(p stanza.Packet, match *RouteMatch) bool { + var name string + // TODO: To avoid type switch everywhere in matching, I think we will need to have + // to move to a concrete type for packets, to make matching and comparison more natural. + // Current code structure is probably too rigid. + // Maybe packet types should even be from an enum. + switch p.(type) { + case stanza.Message: + name = "message" + case *stanza.IQ: + name = "iq" + case stanza.Presence: + name = "presence" + } + if name == string(n) { + return true + } + return false +} + +// Packet matches on a packet name (iq, message, presence, ...) +// It matches on the Local part of the xml.Name +func (r *Route) Packet(name string) *Route { + name = strings.ToLower(name) + return r.AddMatcher(nameMatcher(name)) +} + +// ------------------------- +// Match on stanza type + +// nsTypeMather matches on a list of IQ payload namespaces +type nsTypeMatcher []string + +func (m nsTypeMatcher) Match(p stanza.Packet, match *RouteMatch) bool { + var stanzaType stanza.StanzaType + switch packet := p.(type) { + case *stanza.IQ: + stanzaType = packet.Type + case stanza.Presence: + stanzaType = packet.Type + case stanza.Message: + if packet.Type == "" { + // optional on message, normal is the default type + stanzaType = "normal" + } else { + stanzaType = packet.Type + } + default: + return false + } + return matchInArray(m, string(stanzaType)) +} + +// IQNamespaces adds an IQ matcher, expecting both an IQ and a +func (r *Route) StanzaType(types ...string) *Route { + for k, v := range types { + types[k] = strings.ToLower(v) + } + return r.AddMatcher(nsTypeMatcher(types)) +} + +// ------------------------- +// Match on IQ and namespace + +// nsIqMather matches on a list of IQ payload namespaces +type nsIQMatcher []string + +func (m nsIQMatcher) Match(p stanza.Packet, match *RouteMatch) bool { + iq, ok := p.(*stanza.IQ) + if !ok { + return false + } + if iq.Payload == nil { + return false + } + return matchInArray(m, iq.Payload.Namespace()) +} + +// IQNamespaces adds an IQ matcher, expecting both an IQ and a +func (r *Route) IQNamespaces(namespaces ...string) *Route { + for k, v := range namespaces { + namespaces[k] = strings.ToLower(v) + } + return r.AddMatcher(nsIQMatcher(namespaces)) +} + +// ============================================================================ +// Matchers + +// Matchers are used to "specialize" routes and focus on specific packet features. +// You can register attach them to a route via the AddMatcher method. +type Matcher interface { + Match(stanza.Packet, *RouteMatch) bool +} + +// RouteMatch extracts and gather match information +type RouteMatch struct { + Route *Route + Handler Handler +} + +// matchInArray is a generic matching function to check if a string is a list +// of specific function +func matchInArray(arr []string, value string) bool { + for _, str := range arr { + if str == value { + return true + } + } + return false +} diff --git a/go-xmpp/router_test.go b/go-xmpp/router_test.go new file mode 100644 index 0000000..677ad0e --- /dev/null +++ b/go-xmpp/router_test.go @@ -0,0 +1,332 @@ +package xmpp + +import ( + "bytes" + "context" + "encoding/xml" + "testing" + "time" + + "gosrc.io/xmpp/stanza" +) + +// ============================================================================ +// Test route & matchers + +func TestIQResultRoutes(t *testing.T) { + t.Parallel() + router := NewRouter() + conn := NewSenderMock() + + if router.IQResultRoutes == nil { + t.Fatal("NewRouter does not initialize isResultRoutes") + } + + // Check if the IQ handler was called + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100) + defer cancel() + 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 { + case <-ctx.Done(): + t.Fatal("IQ result was not matched") + case <-res: + // Success + } + + // The match must only happen once, so the id should no longer be in IQResultRoutes + if _, ok := router.IQResultRoutes[iq.Attrs.Id]; ok { + t.Fatal("IQ ID was not removed from the route map") + } + + // Check other IQ does not matcah + ctx, cancel = context.WithTimeout(context.Background(), time.Millisecond*100) + defer cancel() + iq.Attrs.Id = "4321" + res = router.NewIQResultRoute(ctx, "1234") + go router.route(conn, iq) + select { + case <-ctx.Done(): + // Success + case <-res: + t.Fatal("IQ result with wrong ID was matched") + } +} + +func TestNameMatcher(t *testing.T) { + router := NewRouter() + router.HandleFunc("message", func(s Sender, p stanza.Packet) { + _ = s.SendRaw(successFlag) + }) + + // Check that a message packet is properly matched + conn := NewSenderMock() + msg := stanza.NewMessage(stanza.Attrs{Type: stanza.MessageTypeChat, To: "test@localhost", Id: "1"}) + msg.Body = "Hello" + router.route(conn, msg) + if conn.String() != successFlag { + t.Error("Message was not matched and routed properly") + } + + // Check that an IQ packet is not matched + conn = NewSenderMock() + 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 { + t.Error("IQ should not have been matched and routed") + } +} + +func TestIQNSMatcher(t *testing.T) { + router := NewRouter() + router.NewRoute(). + IQNamespaces(stanza.NSDiscoInfo, stanza.NSDiscoItems). + HandlerFunc(func(s Sender, p stanza.Packet) { + _ = s.SendRaw(successFlag) + }) + + // Check that an IQ with proper namespace does match + conn := NewSenderMock() + 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{ + Space: stanza.NSDiscoInfo, + Local: "query", + }} + router.route(conn, iqDisco) + if conn.String() != successFlag { + t.Errorf("IQ should have been matched and routed: %v", iqDisco) + } + + // Check that another namespace is not matched + conn = NewSenderMock() + 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{ + Space: "jabber:iq:version", + Local: "query", + }} + router.route(conn, iqVersion) + if conn.String() == successFlag { + t.Errorf("IQ should not have been matched and routed: %v", iqVersion) + } +} + +func TestTypeMatcher(t *testing.T) { + router := NewRouter() + router.NewRoute(). + StanzaType("normal"). + HandlerFunc(func(s Sender, p stanza.Packet) { + _ = s.SendRaw(successFlag) + }) + + // Check that a packet with the proper type matches + conn := NewSenderMock() + message := stanza.NewMessage(stanza.Attrs{Type: "normal", To: "test@localhost", Id: "1"}) + message.Body = "hello" + router.route(conn, message) + + if conn.String() != successFlag { + t.Errorf("'normal' message should have been matched and routed: %v", message) + } + + // We should match on default type 'normal' for message without a type + conn = NewSenderMock() + message = stanza.NewMessage(stanza.Attrs{To: "test@localhost", Id: "1"}) + message.Body = "hello" + router.route(conn, message) + + if conn.String() != successFlag { + t.Errorf("message should have been matched and routed: %v", message) + } + + // We do not match on other types + conn = NewSenderMock() + 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", + Local: "query", + }} + router.route(conn, iqVersion) + + if conn.String() == successFlag { + t.Errorf("iq get should not have been matched and routed: %v", iqVersion) + } +} + +func TestCompositeMatcher(t *testing.T) { + router := NewRouter() + router.NewRoute(). + IQNamespaces("jabber:iq:version"). + StanzaType(string(stanza.IQTypeGet)). + HandlerFunc(func(s Sender, p stanza.Packet) { + _ = s.SendRaw(successFlag) + }) + + // Data set + 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, 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, 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", + }} + + message := stanza.NewMessage(stanza.Attrs{Type: "normal", To: "test@localhost", Id: "1"}) + message.Body = "hello" + + tests := []struct { + name string + input stanza.Packet + want bool + }{ + {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 message", input: message, want: false}, + } + + // + for _, tc := range tests { + t.Run(tc.name, func(st *testing.T) { + conn := NewSenderMock() + router.route(conn, tc.input) + + res := conn.String() == successFlag + if tc.want != res { + st.Errorf("incorrect result for %#v\nMatch = %#v, expecting %#v", tc.input, res, tc.want) + } + }) + } +} + +// A blank route with empty matcher will always match +// It can be use to receive all packets that do not match any of the previous route. +func TestCatchallMatcher(t *testing.T) { + router := NewRouter() + router.NewRoute(). + HandlerFunc(func(s Sender, p stanza.Packet) { + _ = s.SendRaw(successFlag) + }) + + // Check that we match on several packets + conn := NewSenderMock() + message := stanza.NewMessage(stanza.Attrs{Type: "chat", To: "test@localhost", Id: "1"}) + message.Body = "hello" + router.route(conn, message) + + if conn.String() != successFlag { + t.Errorf("chat message should have been matched and routed: %v", message) + } + + conn = NewSenderMock() + 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", + Local: "query", + }} + router.route(conn, iqVersion) + + if conn.String() != successFlag { + t.Errorf("iq get should have been matched and routed: %v", iqVersion) + } +} + +// ============================================================================ +// SenderMock + +const successFlag = "matched" +const cancelledFlag = "cancelled" + +type SenderMock struct { + buffer *bytes.Buffer +} + +func NewSenderMock() SenderMock { + return SenderMock{buffer: new(bytes.Buffer)} +} + +func (s SenderMock) Send(packet stanza.Packet) error { + out, err := xml.Marshal(packet) + if err != nil { + return err + } + s.buffer.Write(out) + return nil +} + +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 + } + s.buffer.Write(out) + return nil, nil +} + +func (s SenderMock) SendRaw(str string) error { + s.buffer.WriteString(str) + return nil +} + +func (s SenderMock) String() string { + return s.buffer.String() +} + +func TestSenderMock(t *testing.T) { + conn := NewSenderMock() + msg := stanza.NewMessage(stanza.Attrs{To: "test@localhost", Id: "1"}) + msg.Body = "Hello" + if err := conn.Send(msg); err != nil { + t.Error("Could not send message") + } + if conn.String() != "Hello" { + t.Errorf("Incorrect packet sent: %s", conn.String()) + } +} diff --git a/go-xmpp/session.go b/go-xmpp/session.go new file mode 100644 index 0000000..05bdce3 --- /dev/null +++ b/go-xmpp/session.go @@ -0,0 +1,342 @@ +package xmpp + +import ( + "encoding/xml" + "errors" + "fmt" + "gosrc.io/xmpp/stanza" + "strconv" +) + +type Session struct { + // Session info + BindJid string // Jabber ID as provided by XMPP server + StreamId string + SMState SMState + Features stanza.StreamFeatures + TlsEnabled bool + lastPacketId int + + // read / write + transport Transport + + // error management + err error +} + +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 !c.transport.IsSecure() { + s.startTlsIfSupported(c.config) + } + + 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() + } + + // auth + 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(c.config) { + return s, s.err + } + + // otherwise, bind resource and 'start' XMPP session + 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(c.config) + if s.err != nil { + return s, s.err + } + + return s, s.err +} + +func (s *Session) PacketId() string { + s.lastPacketId++ + return fmt.Sprintf("%x", s.lastPacketId) +} + +// 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() { + if s.StreamId, s.err = s.transport.StartStream(); s.err != nil { + return + } + + s.Features = s.extractStreamFeatures() +} + +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()) + } + return +} + +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") + } + return + } + + if _, ok := s.Features.DoesStartTLS(); ok { + fmt.Fprintf(s.transport, "") + + var k stanza.TLSProceed + if s.err = s.transport.GetDecoder().DecodeElement(&k, nil); s.err != nil { + s.err = errors.New("expecting starttls proceed: " + s.err.Error()) + return + } + + s.err = s.transport.StartTLS() + + if s.err == nil { + s.TlsEnabled = true + } + return + } + + // 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) { + if s.err != nil { + return + } + + s.err = authSASL(s.transport, s.transport.GetDecoder(), s.Features, o.parsedJid.Node, o.Credential) +} + +// Attempt to resume session using stream management +func (s *Session) resume(o *Config) bool { + if !s.Features.DoesStreamManagement() { + return false + } + if s.SMState.Id == "" { + return false + } + + 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 { + switch p := packet.(type) { + case stanza.SMResumed: + if p.PrevId != s.SMState.Id { + s.err = errors.New("session resumption: mismatched id") + s.SMState = SMState{} + return false + } + return true + case stanza.SMFailed: + default: + s.err = errors.New("unexpected reply to SM resume") + } + } + s.SMState = SMState{} + return false +} + +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 + 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()) + return + } + + // TODO Check all elements + switch payload := iq.Payload.(type) { + case *stanza.Bind: + s.BindJid = payload.Jid // our local id (with possibly randomly generated resource + default: + s.err = errors.New("iq bind result missing") + } + + return +} + +// After the bind, if the session is not optional (as per old RFC 3921), we send the session open iq. +func (s *Session) rfc3921Session() { + if s.err != nil { + return + } + + var iq stanza.IQ + // We only negotiate session binding if it is mandatory, we skip it when optional. + if !s.Features.Session.IsOptional() { + 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 + } + } +} + +// Enable stream management, with session resumption, if supported. +func (s *Session) EnableStreamManagement(o *Config) { + if s.err != nil { + return + } + 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 + } + + var packet stanza.Packet + packet, s.err = stanza.NextPacket(s.transport.GetDecoder()) + if s.err == nil { + switch p := packet.(type) { + case stanza.SMEnabled: + // 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/go-xmpp/stanza/README.md b/go-xmpp/stanza/README.md new file mode 100644 index 0000000..6ffb822 --- /dev/null +++ b/go-xmpp/stanza/README.md @@ -0,0 +1,142 @@ +# XMPP Stanza + +XMPP `stanza` package is used to parse, marshal and unmarshal XMPP stanzas and nonzas. + +## Stanza creation + +When creating stanzas, you can use two approaches: + +1. You can create IQ, Presence or Message structs, set the fields and manually prepare extensions struct to add to the +stanza. +2. You can use `stanza` build helper to be guided when creating the stanza, and have more controls performed on the +final stanza. + +The methods are equivalent and you can use whatever suits you best. The helpers will finally generate the same type of +struct that you can build by hand. + +### Composing stanzas manually with structs + +Here is for example how you would generate an IQ discovery result: + + iqResp := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id}) + identity := stanza.Identity{ + Name: opts.Name, + Category: opts.Category, + Type: opts.Type, + } + payload := stanza.DiscoInfo{ + XMLName: xml.Name{ + Space: stanza.NSDiscoInfo, + Local: "query", + }, + Identity: []stanza.Identity{identity}, + Features: []stanza.Feature{ + {Var: stanza.NSDiscoInfo}, + {Var: stanza.NSDiscoItems}, + {Var: "jabber:iq:version"}, + {Var: "urn:xmpp:delegation:1"}, + }, + } + iqResp.Payload = &payload + +### Using helpers + +Here is for example how you would generate an IQ discovery result using Builder: + + iq := stanza.NewIQ(stanza.Attrs{Type: "get", To: "service.localhost", Id: "disco-get-1"}) + disco := iq.DiscoInfo() + disco.AddIdentity("Test Component", "gateway", "service") + disco.AddFeatures(stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1") + +## Payload and extensions + +### Message + +Here is the list of implemented message extensions: + +- `Delegation` + +- `Markable` +- `MarkAcknowledged` +- `MarkDisplayed` +- `MarkReceived` + +- `StateActive` +- `StateComposing` +- `StateGone` +- `StateInactive` +- `StatePaused` + +- `HTML` + +- `OOB` + +- `ReceiptReceived` +- `ReceiptRequest` + +- `Mood` + +### Presence + +Here is the list of implemented presence extensions: + +- `MucPresence` + +### IQ + +IQ (Information Queries) contain a payload associated with the request and possibly an error. The main difference with +Message and Presence extension is that you can only have one payload per IQ. The XMPP specification does not support +having multiple payloads. + +Here is the list of structs implementing IQPayloads: + +- `ControlSet` +- `ControlSetResponse` +- `Delegation` +- `DiscoInfo` +- `DiscoItems` +- `Pubsub` +- `Version` +- `Node` + +Finally, when the payload of the parsed stanza is unknown, the parser will provide the unknown payload as a generic +`Node` element. You can also use the Node struct to add custom information on stanza generation. However, in both cases, +you may also consider [adding your own custom extensions on stanzas](). + + +## Adding your own custom extensions on stanzas + +Extensions are registered on launch using the `Registry`. It can be used to register you own custom payload. You may +want to do so to support extensions we did not yet implement, or to add your own custom extensions to your XMPP stanzas. + +To create an extension you need: +1. to create a struct for that extension. It need to have XMLName for consistency and to tagged at the struct level with +`xml` info. +2. It need to implement one or several extensions interface: stanza.IQPayload, stanza.MsgExtension and / or +stanza.PresExtension +3. Add that custom extension to the stanza.TypeRegistry during the file init. + +Here an example code showing how to create a custom IQPayload. + +```go +package myclient + +import ( + "encoding/xml" + + "gosrc.io/xmpp/stanza" +) + +type CustomPayload struct { + XMLName xml.Name `xml:"my:custom:payload query"` + Node string `xml:"node,attr,omitempty"` +} + +func (c CustomPayload) Namespace() string { + return c.XMLName.Space +} + +func init() { + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{"my:custom:payload", "query"}, CustomPayload{}) +} +``` \ No newline at end of file diff --git a/go-xmpp/stanza/commands.go b/go-xmpp/stanza/commands.go new file mode 100644 index 0000000..5ce8b14 --- /dev/null +++ b/go-xmpp/stanza/commands.go @@ -0,0 +1,157 @@ +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"` + + CommandElements []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 { + XMLName xml.Name `xml:"actions"` + + 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 { + XMLName xml.Name `xml:"note"` + + 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 "actions": + a := Actions{} + err = d.DecodeElement(&a, &tt) + c.CommandElements = append(c.CommandElements, &a) + case "note": + nt := Note{} + err = d.DecodeElement(&nt, &tt) + c.CommandElements = append(c.CommandElements, &nt) + case "x": + f := Form{} + err = d.DecodeElement(&f, &tt) + c.CommandElements = append(c.CommandElements, &f) + default: + n := Node{} + err = d.DecodeElement(&n, &tt) + c.CommandElements = append(c.CommandElements, &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/go-xmpp/stanza/commands_test.go b/go-xmpp/stanza/commands_test.go new file mode 100644 index 0000000..04ed7cf --- /dev/null +++ b/go-xmpp/stanza/commands_test.go @@ -0,0 +1,41 @@ +package stanza_test + +import ( + "encoding/xml" + "testing" + + "gosrc.io/xmpp/stanza" +) + +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.Fatal(err) + } +} diff --git a/go-xmpp/stanza/component.go b/go-xmpp/stanza/component.go new file mode 100644 index 0000000..ba3b81e --- /dev/null +++ b/go-xmpp/stanza/component.go @@ -0,0 +1,96 @@ +package stanza + +import ( + "encoding/xml" +) + +// ============================================================================ +// Handshake Stanza + +// Handshake is a stanza used by XMPP components to authenticate on XMPP +// component port. +type Handshake struct { + XMLName xml.Name `xml:"jabber:component:accept handshake"` + // TODO Add handshake value with test for proper serialization + Value string `xml:",innerxml"` +} + +func (Handshake) Name() string { + return "component:handshake" +} + +// Handshake decoding wrapper + +type handshakeDecoder struct{} + +var handshake handshakeDecoder + +func (handshakeDecoder) decode(p *xml.Decoder, se xml.StartElement) (Handshake, error) { + var packet Handshake + err := p.DecodeElement(&packet, &se) + return packet, err +} + +// ============================================================================ +// Component delegation +// XEP-0355 + +// Delegation can be used both on message (for delegated) and IQ (for Forwarded), +// depending on the context. +type Delegation struct { + MsgExtension + 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. +type Forwarded struct { + XMLName xml.Name `xml:"urn:xmpp:forward:0 forwarded"` + Stanza Packet +} + +// UnmarshalXML is a custom unmarshal function used by xml.Unmarshal to +// transform generic XML content into hierarchical Node structure. +func (f *Forwarded) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + // Check subelements to extract required field as boolean + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + + case xml.StartElement: + if packet, err := decodeClient(d, tt); err == nil { + f.Stanza = packet + } + + case xml.EndElement: + if tt == start.End() { + return nil + } + } + } +} + +type Delegated struct { + XMLName xml.Name `xml:"delegated"` + Namespace string `xml:"namespace,attr,omitempty"` +} + +func init() { + 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/go-xmpp/stanza/component_test.go b/go-xmpp/stanza/component_test.go new file mode 100644 index 0000000..2fb1672 --- /dev/null +++ b/go-xmpp/stanza/component_test.go @@ -0,0 +1,79 @@ +package stanza + +import ( + "encoding/xml" + "testing" +) + +// We should be able to properly parse delegation confirmation messages +func TestParsingDelegationMessage(t *testing.T) { + packetStr := ` + + + +` + var msg Message + data := []byte(packetStr) + if err := xml.Unmarshal(data, &msg); err != nil { + t.Errorf("Unmarshal(%s) returned error", data) + } + + // Check that we have extracted the delegation info as MsgExtension + var nsDelegated string + for _, ext := range msg.Extensions { + if delegation, ok := ext.(*Delegation); ok { + nsDelegated = delegation.Delegated.Namespace + } + } + if nsDelegated != "http://jabber.org/protocol/pubsub" { + t.Errorf("Could not find delegated namespace in delegation: %#v\n", msg) + } +} + +// Check that we can parse a delegation IQ. +// The most important thing is to be able to +func TestParsingDelegationIQ(t *testing.T) { + packetStr := ` + + + + + + + + + + + + + + + +` + var iq IQ + data := []byte(packetStr) + if err := xml.Unmarshal(data, &iq); err != nil { + t.Errorf("Unmarshal(%s) returned error", data) + } + + // Check that we have extracted the delegation info as IQPayload + var node string + if iq.Payload != nil { + if delegation, ok := iq.Payload.(*Delegation); ok { + packet := delegation.Forwarded.Stanza + forwardedIQ, ok := packet.(*IQ) + if !ok { + t.Errorf("Could not extract packet IQ") + return + } + if forwardedIQ.Payload != nil { + if pubsub, ok := forwardedIQ.Payload.(*PubSubGeneric); ok { + node = pubsub.Publish.Node + } + } + } + } + if node != "http://jabber.org/protocol/mood" { + t.Errorf("Could not find mood node name on delegated publish: %#v\n", iq) + } +} diff --git a/go-xmpp/stanza/datetime_profiles.go b/go-xmpp/stanza/datetime_profiles.go new file mode 100644 index 0000000..7a2ef74 --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/datetime_profiles_test.go b/go-xmpp/stanza/datetime_profiles_test.go new file mode 100644 index 0000000..5355b7d --- /dev/null +++ b/go-xmpp/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.Fatal(err) + } + 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.Fatal(err) + } + 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.Fatal(err) + } + 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.Fatal(err) + } + t2Str, err := t2.TimeToString(false) + if err != nil { + t.Fatal(err) + } + + 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.Fatal(err) + } + t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)} + + t1Str, err := t1.TimeToString(false) + if err != nil { + t.Fatal(err) + } + + 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.Sleep(10 * time.Millisecond) + t2 := JabberDate{value: time.Now()} + + t1Str, err := t1.TimeToString(true) + if err != nil { + t.Fatal(err) + } + t2Str, err := t2.TimeToString(true) + if err != nil { + t.Fatal(err) + } + + 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.Fatal(err) + } + t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)} + + t1Str, err := t1.TimeToString(true) + if err != nil { + t.Fatal(err) + } + + 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.Fatal(err) + } + + dateTime := "2009-11-10T23:03:22+08:00" + _, err = NewJabberDateFromString(dateTime) + if err != nil { + t.Fatal(err) + } + + dateTimeNanos := "2009-11-10T23:03:22.000000089+08:00" + _, err = NewJabberDateFromString(dateTimeNanos) + if err != nil { + t.Fatal(err) + } + + // 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/go-xmpp/stanza/doc.go b/go-xmpp/stanza/doc.go new file mode 100644 index 0000000..fee61b7 --- /dev/null +++ b/go-xmpp/stanza/doc.go @@ -0,0 +1,4 @@ +/* +XMPP stanza package is used to parse, marshal and unmarshal XMPP stanzas and nonzas. +*/ +package stanza diff --git a/go-xmpp/stanza/error.go b/go-xmpp/stanza/error.go new file mode 100644 index 0000000..5f42018 --- /dev/null +++ b/go-xmpp/stanza/error.go @@ -0,0 +1,136 @@ +package stanza + +import ( + "encoding/xml" + "strconv" + "strings" +) + +// ============================================================================ +// XMPP Errors + +// Err is an XMPP stanza payload that is used to report error on message, +// presence or iq stanza. +// It is intended to be added in the payload of the erroneous stanza. +type Err struct { + XMLName xml.Name `xml:"error"` + Code int `xml:"code,attr,omitempty"` + Type ErrorType `xml:"type,attr"` // required + Reason string + Text string `xml:"urn:ietf:params:xml:ns:xmpp-stanzas text,omitempty"` +} + +// UnmarshalXML implements custom parsing for XMPP errors +func (x *Err) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + x.XMLName = start.Name + + // Extract attributes + for _, attr := range start.Attr { + if attr.Name.Local == "type" { + x.Type = ErrorType(attr.Value) + } + if attr.Name.Local == "code" { + if code, err := strconv.Atoi(attr.Value); err == nil { + x.Code = code + } + } + } + + // Check subelements to extract error text and reason (from local namespace). + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + + case xml.StartElement: + elt := new(Node) + + err = d.DecodeElement(elt, &tt) + if err != nil { + return err + } + + textName := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "text"} + // 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: + if tt == start.End() { + return nil + } + } + } +} + +func (x Err) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) { + if x.Code == 0 { + return nil + } + + // Encode start element and attributes + start.Name = xml.Name{Local: "error"} + + code := xml.Attr{ + Name: xml.Name{Local: "code"}, + Value: strconv.Itoa(x.Code), + } + start.Attr = append(start.Attr, code) + + if len(x.Type) > 0 { + typ := xml.Attr{ + Name: xml.Name{Local: "type"}, + Value: string(x.Type), + } + start.Attr = append(start.Attr, typ) + } + err = e.EncodeToken(start) + + // SubTags + // Reason + if x.Reason != "" { + reason := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: x.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"} + 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/go-xmpp/stanza/error_enum.go b/go-xmpp/stanza/error_enum.go new file mode 100644 index 0000000..710de0e --- /dev/null +++ b/go-xmpp/stanza/error_enum.go @@ -0,0 +1,13 @@ +package stanza + +// ErrorType is a Enum of error attribute type +type ErrorType string + +// RFC 6120: part of A.5 Client Namespace and A.6 Server Namespace +const ( + ErrorTypeAuth ErrorType = "auth" + ErrorTypeCancel ErrorType = "cancel" + ErrorTypeContinue ErrorType = "continue" + ErrorTypeModify ErrorType = "modify" + ErrorTypeWait ErrorType = "wait" +) diff --git a/go-xmpp/stanza/error_test.go b/go-xmpp/stanza/error_test.go new file mode 100644 index 0000000..5d933a5 --- /dev/null +++ b/go-xmpp/stanza/error_test.go @@ -0,0 +1,31 @@ +package stanza + +import ( + "encoding/xml" + "testing" +) + +func TestErr_UnmarshalXML(t *testing.T) { + packet := ` + + + + System overloaded, please retry + + ` + + parsedIQ := IQ{} + data := []byte(packet) + if err := xml.Unmarshal(data, &parsedIQ); err != nil { + t.Errorf("Unmarshal(%s) returned error", data) + } + + xmppError := parsedIQ.Error + if xmppError.Text != "System overloaded, please retry" { + t.Errorf("Could not extract error text: '%s'", xmppError.Text) + } +} diff --git a/go-xmpp/stanza/fifo_queue.go b/go-xmpp/stanza/fifo_queue.go new file mode 100644 index 0000000..ca28810 --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/form.go b/go-xmpp/stanza/form.go new file mode 100644 index 0000000..f758f74 --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/form_test.go b/go-xmpp/stanza/form_test.go new file mode 100644 index 0000000..ea6c613 --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/iot.go b/go-xmpp/stanza/iot.go new file mode 100644 index 0000000..b6952e0 --- /dev/null +++ b/go-xmpp/stanza/iot.go @@ -0,0 +1,48 @@ +package stanza + +import ( + "encoding/xml" +) + +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"` +} + +type ControlField struct { + XMLName xml.Name + Name string `xml:"name,attr,omitempty"` + Value string `xml:"value,attr,omitempty"` +} + +type ControlSetResponse struct { + XMLName xml.Name `xml:"urn:xmpp:iot:control setResponse"` +} + +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{Space: "urn:xmpp:iot:control", Local: "set"}, ControlSet{}) +} diff --git a/go-xmpp/stanza/iot_test.go b/go-xmpp/stanza/iot_test.go new file mode 100644 index 0000000..c0953e9 --- /dev/null +++ b/go-xmpp/stanza/iot_test.go @@ -0,0 +1,26 @@ +package stanza + +import ( + "encoding/xml" + "testing" +) + +func TestControlSet(t *testing.T) { + packet := ` + + + + + +` + + parsedIQ := IQ{} + data := []byte(packet) + if err := xml.Unmarshal(data, &parsedIQ); err != nil { + t.Errorf("Unmarshal(%s) returned error", data) + } + + if cs, ok := parsedIQ.Payload.(*ControlSet); !ok { + t.Errorf("Payload is not an iot control set: %v", cs) + } +} diff --git a/go-xmpp/stanza/iq.go b/go-xmpp/stanza/iq.go new file mode 100644 index 0000000..a8cd62a --- /dev/null +++ b/go-xmpp/stanza/iq.go @@ -0,0 +1,192 @@ +package stanza + +import ( + "encoding/xml" + "errors" + "strings" + + "github.com/google/uuid" +) + +/* +TODO support ability to put Raw payload inside IQ +*/ + +// ============================================================================ +// IQ Packet + +// IQ implements RFC 6120 - A.5 Client Namespace (a part) +type IQ struct { // Info/Query + XMLName xml.Name `xml:"iq"` + // MUST have a ID + Attrs + // We can only have one payload on IQ: + // "An IQ stanza of type "get" or "set" MUST contain exactly one + // child element, which specifies the semantics of the particular + // request." + Payload IQPayload `xml:",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, error) { + if a.Id == "" { + if id, err := uuid.NewRandom(); err == nil { + a.Id = id.String() + } + } + + 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 { + from := iq.From + to := iq.To + + iq.Type = "error" + iq.From = to + iq.To = from + iq.Error = &xerror + + return iq +} + +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) { + var packet IQ + err := p.DecodeElement(&packet, &se) + return &packet, err +} + +// UnmarshalXML implements custom parsing for IQs +func (iq *IQ) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + iq.XMLName = start.Name + + // Extract IQ attributes + for _, attr := range start.Attr { + if attr.Name.Local == "id" { + iq.Id = attr.Value + } + if attr.Name.Local == "type" { + iq.Type = StanzaType(attr.Value) + } + if attr.Name.Local == "to" { + iq.To = attr.Value + } + if attr.Name.Local == "from" { + iq.From = attr.Value + } + } + + // decode inner elements + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + case xml.StartElement: + if tt.Name.Local == "error" { + var xmppError Err + err = d.DecodeElement(&xmppError, &tt) + if err != nil { + return err + } + iq.Error = &xmppError + continue + } + if iqExt := TypeRegistry.GetIQExtension(tt.Name); iqExt != nil { + // Decode payload extension + err = d.DecodeElement(iqExt, &tt) + if err != nil { + return err + } + iq.Payload = iqExt + continue + } + // TODO: If unknown decode as generic node + node := new(Node) + err = d.DecodeElement(node, &tt) + if err != nil { + return err + } + iq.Any = node + case xml.EndElement: + if tt == start.End() { + return nil + } + } + } +} + +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/go-xmpp/stanza/iq_disco.go b/go-xmpp/stanza/iq_disco.go new file mode 100644 index 0000000..8e50f90 --- /dev/null +++ b/go-xmpp/stanza/iq_disco.go @@ -0,0 +1,163 @@ +package stanza + +import ( + "encoding/xml" +) + +// ============================================================================ +// Disco Info + +const ( + // NSDiscoInfo defines the namespace for disco IQ stanzas + NSDiscoInfo = "http://jabber.org/protocol/disco#info" +) + +// ---------- +// 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"` + 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 + +// DiscoInfo builds a default DiscoInfo payload +func (iq *IQ) DiscoInfo() *DiscoInfo { + d := DiscoInfo{ + XMLName: xml.Name{ + Space: NSDiscoInfo, + Local: "query", + }, + } + iq.Payload = &d + return &d +} + +func (d *DiscoInfo) AddIdentity(name, category, typ string) { + identity := Identity{ + XMLName: xml.Name{Local: "identity"}, + Name: name, + Category: category, + Type: typ, + } + d.Identity = append(d.Identity, identity) +} + +func (d *DiscoInfo) AddFeatures(namespace ...string) { + for _, ns := range namespace { + d.Features = append(d.Features, Feature{Var: ns}) + } +} + +func (d *DiscoInfo) SetNode(node string) *DiscoInfo { + d.Node = node + return d +} + +func (d *DiscoInfo) SetIdentities(ident ...Identity) *DiscoInfo { + d.Identity = ident + return d +} + +func (d *DiscoInfo) SetFeatures(namespace ...string) *DiscoInfo { + d.Features = []Feature{} + for _, ns := range namespace { + d.Features = append(d.Features, Feature{Var: ns}) + } + return d +} + +// ----------- +// SubElements + +type Identity struct { + XMLName xml.Name `xml:"identity,omitempty"` + Name string `xml:"name,attr,omitempty"` + Category string `xml:"category,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` +} + +type Feature struct { + XMLName xml.Name `xml:"feature"` + Var string `xml:"var,attr"` +} + +// ============================================================================ +// Disco Info + +const ( + NSDiscoItems = "http://jabber.org/protocol/disco#items" +) + +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: NSDiscoItems, Local: "query"}, + } + iq.Payload = &d + return &d +} + +func (d *DiscoItems) SetNode(node string) *DiscoItems { + d.Node = node + return d +} + +func (d *DiscoItems) AddItem(jid, node, name string) *DiscoItems { + item := DiscoItem{ + JID: jid, + Node: node, + Name: name, + } + d.Items = append(d.Items, item) + return d +} + +type DiscoItem struct { + XMLName xml.Name `xml:"item"` + JID string `xml:"jid,attr,omitempty"` + Node string `xml:"node,attr,omitempty"` + Name string `xml:"name,attr,omitempty"` +} + +// ============================================================================ +// Registry init + +func init() { + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSDiscoInfo, Local: "query"}, DiscoInfo{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSDiscoItems, Local: "query"}, DiscoItems{}) +} diff --git a/go-xmpp/stanza/iq_disco_test.go b/go-xmpp/stanza/iq_disco_test.go new file mode 100644 index 0000000..87b7001 --- /dev/null +++ b/go-xmpp/stanza/iq_disco_test.go @@ -0,0 +1,96 @@ +package stanza_test + +import ( + "encoding/xml" + "testing" + + "gosrc.io/xmpp/stanza" +) + +// Test DiscoInfo Builder with several features +func TestDiscoInfo_Builder(t *testing.T) { + 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") + + parsedIQ, err := checkMarshalling(t, iq) + if err != nil { + return + } + + // Check result + pp, ok := parsedIQ.Payload.(*stanza.DiscoInfo) + if !ok { + t.Errorf("Parsed stanza does not contain correct IQ payload") + } + + // Check features + features := []string{stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1"} + if len(pp.Features) != len(features) { + t.Errorf("Features length mismatch: %#v", pp.Features) + } else { + for i, f := range pp.Features { + if f.Var != features[i] { + t.Errorf("Missing feature: %s", features[i]) + } + } + } + + // Check identity + if len(pp.Identity) != 1 { + t.Errorf("Identity length mismatch: %#v", pp.Identity) + } else { + if pp.Identity[0].Name != "Test Component" { + t.Errorf("Incorrect identity name: %#v", pp.Identity[0].Name) + } + } +} + +// Implements XEP-0030 example 17 +// https://xmpp.org/extensions/xep-0030.html#example-17 +func TestDiscoItems_Builder(t *testing.T) { + 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"). + AddItem("catalog.shakespeare.lit", "music", "Music from the time of Shakespeare") + + parsedIQ, err := checkMarshalling(t, iq) + if err != nil { + return + } + + // Check result + pp, ok := parsedIQ.Payload.(*stanza.DiscoItems) + if !ok { + t.Errorf("Parsed stanza does not contain correct IQ payload") + } + + // Check items + items := []stanza.DiscoItem{{xml.Name{}, "catalog.shakespeare.lit", "books", "Books by and about Shakespeare"}, + {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("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 item.Node != items[i].Node { + 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) + } + } + } +} diff --git a/go-xmpp/stanza/iq_roster.go b/go-xmpp/stanza/iq_roster.go new file mode 100644 index 0000000..34ab5f0 --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/iq_roster_test.go b/go-xmpp/stanza/iq_roster_test.go new file mode 100644 index 0000000..8eee77f --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/iq_test.go b/go-xmpp/stanza/iq_test.go new file mode 100644 index 0000000..e5af6de --- /dev/null +++ b/go-xmpp/stanza/iq_test.go @@ -0,0 +1,240 @@ +package stanza_test + +import ( + "encoding/xml" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "gosrc.io/xmpp/stanza" +) + +func TestUnmarshalIqs(t *testing.T) { + //var cs1 = new(iot.ControlSet) + var tests = []struct { + iqString string + parsedIQ stanza.IQ + }{ + {"", + stanza.IQ{XMLName: xml.Name{Local: "iq"}, Attrs: stanza.Attrs{Type: stanza.IQTypeSet, To: "test@localhost", Id: "1"}}}, + //{"", IQ{XMLName: xml.Name{Space: "jabber:client", Local: "iq"}, PacketAttrs: PacketAttrs{To: "test@localhost", From: "server", Type: "set", Id: "2"}, Payload: cs1}}, + } + + for _, test := range tests { + parsedIQ := stanza.IQ{} + err := xml.Unmarshal([]byte(test.iqString), &parsedIQ) + if err != nil { + t.Errorf("Unmarshal(%s) returned error", test.iqString) + } + + if !xmlEqual(parsedIQ, test.parsedIQ) { + t.Errorf("non matching items\n%s", cmp.Diff(parsedIQ, test.parsedIQ)) + } + + } +} + +func TestGenerateIqId(t *testing.T) { + t.Parallel() + 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, 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, 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, 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", + Category: "gateway", + Type: "mqtt", + }}, + Features: []stanza.Feature{ + {Var: stanza.NSDiscoInfo}, + {Var: stanza.NSDiscoItems}, + }, + } + iq.Payload = &payload + + data, err := xml.Marshal(iq) + if err != nil { + t.Errorf("cannot marshal xml structure") + } + + if strings.Contains(string(data), " + + + + Not subscribed + +` + + parsedIQ := stanza.IQ{} + err := xml.Unmarshal([]byte(iq), &parsedIQ) + if err != nil { + t.Errorf("Unmarshal error: %s", iq) + return + } + + if parsedIQ.Error.Reason != "subscription-required" { + t.Errorf("incorrect error value: '%s'", parsedIQ.Error.Reason) + } +} + +func TestUnknownPayload(t *testing.T) { + iq := ` + +` + parsedIQ := stanza.IQ{} + err := xml.Unmarshal([]byte(iq), &parsedIQ) + if err != nil { + t.Errorf("Unmarshal error: %#v (%s)", err, iq) + return + } + + if parsedIQ.Any.XMLName.Space != "unknown:ns" { + 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/go-xmpp/stanza/iq_version.go b/go-xmpp/stanza/iq_version.go new file mode 100644 index 0000000..4661dba --- /dev/null +++ b/go-xmpp/stanza/iq_version.go @@ -0,0 +1,51 @@ +package stanza + +import "encoding/xml" + +// ============================================================================ +// Software Version (XEP-0092) + +// Version +type Version struct { + XMLName xml.Name `xml:"jabber:iq:version query"` + 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 + +// Version builds a default software version payload +func (iq *IQ) Version() *Version { + d := Version{ + XMLName: xml.Name{Space: "jabber:iq:version", Local: "query"}, + } + iq.Payload = &d + return &d +} + +// Set all software version info +func (v *Version) SetInfo(name, version, os string) *Version { + v.Name = name + v.Version = version + v.OS = os + return v +} + +// ============================================================================ +// Registry init + +func init() { + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "jabber:iq:version", Local: "query"}, Version{}) +} diff --git a/go-xmpp/stanza/iq_version_test.go b/go-xmpp/stanza/iq_version_test.go new file mode 100644 index 0000000..bdd621e --- /dev/null +++ b/go-xmpp/stanza/iq_version_test.go @@ -0,0 +1,43 @@ +package stanza_test + +import ( + "testing" + + "gosrc.io/xmpp/stanza" +) + +// Build a Software Version reply +// https://xmpp.org/extensions/xep-0092.html#example-2 +func TestVersion_Builder(t *testing.T) { + name := "Exodus" + version := "0.7.0.4" + os := "Windows-XP 5.01.2600" + 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) + if err != nil { + return + } + + // Check result + pp, ok := parsedIQ.Payload.(*stanza.Version) + if !ok { + t.Errorf("Parsed stanza does not contain correct IQ payload") + } + + // Check version info + if pp.Name != name { + t.Errorf("Name Mismatch (expected: %s): %s", name, pp.Name) + } + if pp.Version != version { + t.Errorf("Version Mismatch (expected: %s): %s", version, pp.Version) + } + if pp.OS != os { + t.Errorf("OS Mismatch (expected: %s): %s", os, pp.OS) + } +} diff --git a/go-xmpp/stanza/jid.go b/go-xmpp/stanza/jid.go new file mode 100644 index 0000000..c3677a9 --- /dev/null +++ b/go-xmpp/stanza/jid.go @@ -0,0 +1,101 @@ +package stanza + +import ( + "fmt" + "strings" + "unicode" +) + +type Jid struct { + Node string + Domain string + Resource string +} + +func NewJid(sjid string) (*Jid, error) { + jid := new(Jid) + + if sjid == "" { + return jid, fmt.Errorf("jid cannot be empty") + } + + s1 := strings.SplitN(sjid, "@", 2) + if len(s1) == 1 { // This is a server or component Jid + jid.Domain = s1[0] + } else { // Jid has a local username part + if s1[0] == "" { + return jid, fmt.Errorf("invalid jid '%s", sjid) + } + jid.Node = s1[0] + if s1[1] == "" { + return jid, fmt.Errorf("domain cannot be empty") + } + jid.Domain = s1[1] + } + + // Extract resource from domain field + s2 := strings.SplitN(jid.Domain, "/", 2) + if len(s2) == 2 { // If len = 1, domain is already correct, and resource is already empty + jid.Domain = s2[0] + jid.Resource = s2[1] + } + + if !isUsernameValid(jid.Node) { + 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, nil +} + +func (j *Jid) Full() string { + if j.Resource == "" { + return j.Bare() + } else if j.Node == "" { + return j.Node + "/" + j.Resource + } else { + return j.Node + "@" + j.Domain + "/" + j.Resource + } +} + +func (j *Jid) Bare() string { + if j.Node == "" { + return j.Domain + } else { + return j.Node + "@" + j.Domain + } +} + +// ============================================================================ +// Helpers, for parsing / validation + +func isUsernameValid(username string) bool { + invalidRunes := []rune{'@', '/', '\'', '"', ':', '<', '>'} + return strings.IndexFunc(username, isInvalid(invalidRunes)) < 0 +} + +func isDomainValid(domain string) bool { + if len(domain) == 0 { + return false + } + + invalidRunes := []rune{'@', '/'} + return strings.IndexFunc(domain, isInvalid(invalidRunes)) < 0 +} + +func isInvalid(invalidRunes []rune) func(c rune) bool { + isInvalid := func(c rune) bool { + if unicode.IsSpace(c) { + return true + } + for _, r := range invalidRunes { + if c == r { + return true + } + } + return false + } + return isInvalid +} diff --git a/go-xmpp/stanza/jid_test.go b/go-xmpp/stanza/jid_test.go new file mode 100644 index 0000000..781221d --- /dev/null +++ b/go-xmpp/stanza/jid_test.go @@ -0,0 +1,101 @@ +package stanza + +import ( + "testing" +) + +func TestValidJids(t *testing.T) { + tests := []struct { + jidstr string + expected Jid + }{ + {jidstr: "test@domain.com", expected: Jid{"test", "domain.com", ""}}, + {jidstr: "test@domain.com/resource", expected: Jid{"test", "domain.com", "resource"}}, + // resource can contain '/' or '@' + {jidstr: "test@domain.com/a/b", expected: Jid{"test", "domain.com", "a/b"}}, + {jidstr: "test@domain.com/a@b", expected: Jid{"test", "domain.com", "a@b"}}, + {jidstr: "domain.com", expected: Jid{"", "domain.com", ""}}, + } + + for _, tt := range tests { + jid, err := NewJid(tt.jidstr) + if err != nil { + t.Errorf("could not parse correct jid: %s", tt.jidstr) + continue + } + + if jid == nil { + t.Error("jid should not be nil") + } + + if jid.Node != tt.expected.Node { + t.Errorf("incorrect jid Node (%s): %s", tt.expected.Node, jid.Node) + } + + if jid.Node != tt.expected.Node { + t.Errorf("incorrect jid domain (%s): %s", tt.expected.Domain, jid.Domain) + } + + if jid.Resource != tt.expected.Resource { + t.Errorf("incorrect jid resource (%s): %s", tt.expected.Resource, jid.Resource) + } + } +} + +func TestIncorrectJids(t *testing.T) { + badJids := []string{ + "", + "user@", + "@domain.com", + "user:name@domain.com", + "user + + + +` + + parsedMessage := stanza.Message{} + if err := xml.Unmarshal([]byte(str), &parsedMessage); err != nil { + t.Errorf("message error stanza unmarshall error: %v", err) + return + } + if parsedMessage.Error.Type != "cancel" { + t.Errorf("incorrect error type: %s", parsedMessage.Error.Type) + } +} + +func TestGetOOB(t *testing.T) { + image := "https://localhost/image.png" + msg := stanza.NewMessage(stanza.Attrs{To: "test@localhost"}) + ext := stanza.OOB{ + XMLName: xml.Name{Space: "jabber:x:oob", Local: "x"}, + URL: image, + } + msg.Extensions = append(msg.Extensions, &ext) + + // OOB can properly be found + var oob stanza.OOB + // Try to find and + if ok := msg.Get(&oob); !ok { + t.Error("could not find oob extension") + return + } + if oob.URL != image { + t.Errorf("OOB URL was not properly extracted: ''%s", oob.URL) + } + + // Markable is not found + var m stanza.Markable + if ok := msg.Get(&m); ok { + t.Error("we should not have found markable extension") + } +} diff --git a/go-xmpp/stanza/msg_chat_markers.go b/go-xmpp/stanza/msg_chat_markers.go new file mode 100644 index 0000000..66276f9 --- /dev/null +++ b/go-xmpp/stanza/msg_chat_markers.go @@ -0,0 +1,42 @@ +package stanza + +import ( + "encoding/xml" +) + +/* +Support for: +- XEP-0333 - Chat Markers: https://xmpp.org/extensions/xep-0333.html +*/ + +const NSMsgChatMarkers = "urn:xmpp:chat-markers:0" + +type Markable struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:chat-markers:0 markable"` +} + +type MarkReceived struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:chat-markers:0 received"` + ID string `xml:"id,attr"` +} + +type MarkDisplayed struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:chat-markers:0 displayed"` + ID string `xml:"id,attr"` +} + +type MarkAcknowledged struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:chat-markers:0 acknowledged"` + ID string `xml:"id,attr"` +} + +func init() { + 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/go-xmpp/stanza/msg_chat_state.go b/go-xmpp/stanza/msg_chat_state.go new file mode 100644 index 0000000..553a314 --- /dev/null +++ b/go-xmpp/stanza/msg_chat_state.go @@ -0,0 +1,45 @@ +package stanza + +import ( + "encoding/xml" +) + +/* +Support for: +- XEP-0085 - Chat State Notifications: https://xmpp.org/extensions/xep-0085.html +*/ + +const NSMsgChatStateNotifications = "http://jabber.org/protocol/chatstates" + +type StateActive struct { + MsgExtension + XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates active"` +} + +type StateComposing struct { + MsgExtension + XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates composing"` +} + +type StateGone struct { + MsgExtension + XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates gone"` +} + +type StateInactive struct { + MsgExtension + XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates inactive"` +} + +type StatePaused struct { + MsgExtension + XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates paused"` +} + +func init() { + 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/go-xmpp/stanza/msg_hint.go b/go-xmpp/stanza/msg_hint.go new file mode 100644 index 0000000..205d0ef --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/msg_hint_test.go b/go-xmpp/stanza/msg_hint_test.go new file mode 100644 index 0000000..b86d2f9 --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/msg_html.go b/go-xmpp/stanza/msg_html.go new file mode 100644 index 0000000..f8fbba6 --- /dev/null +++ b/go-xmpp/stanza/msg_html.go @@ -0,0 +1,22 @@ +package stanza + +import ( + "encoding/xml" +) + +type HTML struct { + MsgExtension + XMLName xml.Name `xml:"http://jabber.org/protocol/xhtml-im html"` + Body HTMLBody + Lang string `xml:"xml:lang,attr,omitempty"` +} + +type HTMLBody struct { + XMLName xml.Name `xml:"http://www.w3.org/1999/xhtml body"` + // InnerXML MUST be valid xhtml. We do not check if it is valid when generating the XMPP stanza. + InnerXML string `xml:",innerxml"` +} + +func init() { + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "http://jabber.org/protocol/xhtml-im", Local: "html"}, HTML{}) +} diff --git a/go-xmpp/stanza/msg_html_test.go b/go-xmpp/stanza/msg_html_test.go new file mode 100644 index 0000000..b37e9d6 --- /dev/null +++ b/go-xmpp/stanza/msg_html_test.go @@ -0,0 +1,44 @@ +package stanza_test + +import ( + "encoding/xml" + "testing" + + "gosrc.io/xmpp/stanza" +) + +func TestHTMLGen(t *testing.T) { + htmlBody := "

Hello World

" + msg := stanza.NewMessage(stanza.Attrs{To: "test@localhost"}) + msg.Body = "Hello World" + body := stanza.HTMLBody{ + InnerXML: htmlBody, + } + html := stanza.HTML{Body: body} + msg.Extensions = append(msg.Extensions, html) + + result := msg.XMPPFormat() + str := `Hello World

Hello World

` + if result != str { + t.Errorf("incorrect serialize message:\n%s", result) + } + + parsedMessage := stanza.Message{} + if err := xml.Unmarshal([]byte(str), &parsedMessage); err != nil { + t.Errorf("message HTML unmarshall error: %v", err) + return + } + + if parsedMessage.Body != msg.Body { + t.Errorf("incorrect parsed body: '%s'", parsedMessage.Body) + } + + var h stanza.HTML + if ok := parsedMessage.Get(&h); !ok { + t.Error("could not extract HTML body") + } + + if h.Body.InnerXML != htmlBody { + t.Errorf("could not extract html body: '%s'", h.Body.InnerXML) + } +} diff --git a/go-xmpp/stanza/msg_oob.go b/go-xmpp/stanza/msg_oob.go new file mode 100644 index 0000000..92ccadf --- /dev/null +++ b/go-xmpp/stanza/msg_oob.go @@ -0,0 +1,21 @@ +package stanza + +import ( + "encoding/xml" +) + +/* +Support for: +- XEP-0066 - Out of Band Data: https://xmpp.org/extensions/xep-0066.html +*/ + +type OOB struct { + MsgExtension + XMLName xml.Name `xml:"jabber:x:oob x"` + URL string `xml:"url"` + Desc string `xml:"desc,omitempty"` +} + +func init() { + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "jabber:x:oob", Local: "x"}, OOB{}) +} diff --git a/go-xmpp/stanza/msg_pubsub_event.go b/go-xmpp/stanza/msg_pubsub_event.go new file mode 100644 index 0000000..70db228 --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/msg_pubsub_event_test.go b/go-xmpp/stanza/msg_pubsub_event_test.go new file mode 100644 index 0000000..29a795d --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/msg_receipts.go b/go-xmpp/stanza/msg_receipts.go new file mode 100644 index 0000000..71fdd52 --- /dev/null +++ b/go-xmpp/stanza/msg_receipts.go @@ -0,0 +1,29 @@ +package stanza + +import ( + "encoding/xml" +) + +/* +Support for: +- XEP-0184 - Message Delivery Receipts: https://xmpp.org/extensions/xep-0184.html +*/ + +const NSMsgReceipts = "urn:xmpp:receipts" + +// Used on outgoing message, to tell the recipient that you are requesting a message receipt / ack. +type ReceiptRequest struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:receipts request"` +} + +type ReceiptReceived struct { + MsgExtension + XMLName xml.Name `xml:"urn:xmpp:receipts received"` + ID string `xml:"id,attr"` +} + +func init() { + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgReceipts, Local: "request"}, ReceiptRequest{}) + TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgReceipts, Local: "received"}, ReceiptReceived{}) +} diff --git a/go-xmpp/stanza/msg_receipts_test.go b/go-xmpp/stanza/msg_receipts_test.go new file mode 100644 index 0000000..bf379a1 --- /dev/null +++ b/go-xmpp/stanza/msg_receipts_test.go @@ -0,0 +1,42 @@ +package stanza_test + +import ( + "encoding/xml" + "testing" + + "gosrc.io/xmpp/stanza" +) + +func TestDecodeRequest(t *testing.T) { + str := ` + My lord, dispatch; read o'er these articles. + +` + parsedMessage := stanza.Message{} + if err := xml.Unmarshal([]byte(str), &parsedMessage); err != nil { + t.Errorf("message receipt unmarshall error: %v", err) + return + } + + if parsedMessage.Body != "My lord, dispatch; read o'er these articles." { + 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.ReceiptRequest: + if ext.XMLName.Local != "request" { + t.Errorf("unexpected extension: %s:%s", ext.XMLName.Space, ext.XMLName.Local) + } + default: + t.Errorf("could not find receipts extension") + } + +} diff --git a/go-xmpp/stanza/node.go b/go-xmpp/stanza/node.go new file mode 100644 index 0000000..308729c --- /dev/null +++ b/go-xmpp/stanza/node.go @@ -0,0 +1,63 @@ +package stanza + +import "encoding/xml" + +// ============================================================================ +// Generic / unknown content + +// Node is a generic structure to represent XML data. It is used to parse +// unreferenced or custom stanza payload. +type Node struct { + XMLName xml.Name + Attrs []xml.Attr `xml:"-"` + Content string `xml:",cdata"` + Nodes []Node `xml:",any"` +} + +func (n *Node) Namespace() string { + return n.XMLName.Space +} + +// Attr represents generic XML attributes, as used on the generic XML Node +// representation. +type Attr struct { + K string + V string +} + +// UnmarshalXML is a custom unmarshal function used by xml.Unmarshal to +// transform generic XML content into hierarchical Node structure. +func (n *Node) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + // Assign "n.Attrs = start.Attr", without repeating xmlns in attributes: + for _, attr := range start.Attr { + // Do not repeat xmlns, it is already in XMLName + if attr.Name.Local != "xmlns" { + n.Attrs = append(n.Attrs, attr) + } + } + type node Node + return d.DecodeElement((*node)(n), &start) +} + +// MarshalXML is a custom XML serializer used by xml.Marshal to serialize a +// Node structure to XML. +func (n Node) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) { + start.Attr = n.Attrs + start.Name = n.XMLName + + err = e.EncodeToken(start) + if err != nil { + return err + } + err = e.EncodeElement(n.Nodes, xml.StartElement{Name: n.XMLName}) + if err != nil { + return err + } + if 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/go-xmpp/stanza/node_test.go b/go-xmpp/stanza/node_test.go new file mode 100644 index 0000000..33649d9 --- /dev/null +++ b/go-xmpp/stanza/node_test.go @@ -0,0 +1,33 @@ +package stanza + +import ( + "encoding/xml" + "testing" +) + +func TestNode_Marshal(t *testing.T) { + jsonData := []byte("{\"key\":\"value\"}") + + 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), + } + + bytes, err := xml.Marshal(iqResp) + if err != nil { + t.Errorf("Could not marshal XML: %v", err) + } + + parsedIQ := IQ{} + if err := xml.Unmarshal(bytes, &parsedIQ); err != nil { + t.Errorf("Unmarshal returned error: %v", err) + } + + if parsedIQ.Any.Content != string(jsonData) { + t.Errorf("Cannot find generic any payload in parsedIQ: '%s'", parsedIQ.Any.Content) + } +} diff --git a/go-xmpp/stanza/ns.go b/go-xmpp/stanza/ns.go new file mode 100644 index 0000000..f1be9b1 --- /dev/null +++ b/go-xmpp/stanza/ns.go @@ -0,0 +1,12 @@ +package stanza + +const ( + NSStream = "http://etherx.jabber.org/streams" + nsTLS = "urn:ietf:params:xml:ns:xmpp-tls" + NSSASL = "urn:ietf:params:xml:ns:xmpp-sasl" + NSBind = "urn:ietf:params:xml:ns:xmpp-bind" + NSSession = "urn:ietf:params:xml:ns:xmpp-session" + NSFraming = "urn:ietf:params:xml:ns:xmpp-framing" + NSClient = "jabber:client" + NSComponent = "jabber:component:accept" +) diff --git a/go-xmpp/stanza/open.go b/go-xmpp/stanza/open.go new file mode 100644 index 0000000..32ece17 --- /dev/null +++ b/go-xmpp/stanza/open.go @@ -0,0 +1,13 @@ +package stanza + +import "encoding/xml" + +// Open Packet +// Reference: WebSocket connections must start with this element +// https://tools.ietf.org/html/rfc7395#section-3.4 +type WebsocketOpen struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-framing open"` + From string `xml:"from,attr"` + Id string `xml:"id,attr"` + Version string `xml:"version,attr"` +} diff --git a/go-xmpp/stanza/packet.go b/go-xmpp/stanza/packet.go new file mode 100644 index 0000000..567796d --- /dev/null +++ b/go-xmpp/stanza/packet.go @@ -0,0 +1,18 @@ +package stanza + +type Packet interface { + Name() string +} + +// Attrs represents the common structure for base XMPP packets. +type Attrs struct { + Type StanzaType `xml:"type,attr,omitempty"` + Id string `xml:"id,attr,omitempty"` + From string `xml:"from,attr,omitempty"` + To string `xml:"to,attr,omitempty"` + Lang string `xml:"lang,attr,omitempty"` +} + +type packetFormatter interface { + XMPPFormat() string +} diff --git a/go-xmpp/stanza/packet_enum.go b/go-xmpp/stanza/packet_enum.go new file mode 100644 index 0000000..84dd476 --- /dev/null +++ b/go-xmpp/stanza/packet_enum.go @@ -0,0 +1,31 @@ +package stanza + +import "strings" + +type StanzaType string + +// RFC 6120: part of A.5 Client Namespace and A.6 Server Namespace +const ( + IQTypeError StanzaType = "error" + IQTypeGet StanzaType = "get" + IQTypeResult StanzaType = "result" + IQTypeSet StanzaType = "set" + + MessageTypeChat StanzaType = "chat" + MessageTypeError StanzaType = "error" + MessageTypeGroupchat StanzaType = "groupchat" + MessageTypeHeadline StanzaType = "headline" + MessageTypeNormal StanzaType = "normal" // Default + + PresenceTypeError StanzaType = "error" + PresenceTypeProbe StanzaType = "probe" + PresenceTypeSubscribe StanzaType = "subscribe" + PresenceTypeSubscribed StanzaType = "subscribed" + PresenceTypeUnavailable StanzaType = "unavailable" + PresenceTypeUnsubscribe StanzaType = "unsubscribe" + PresenceTypeUnsubscribed StanzaType = "unsubscribed" +) + +func (s StanzaType) IsEmpty() bool { + return len(strings.TrimSpace(string(s))) == 0 +} diff --git a/go-xmpp/stanza/parser.go b/go-xmpp/stanza/parser.go new file mode 100644 index 0000000..b5f11cf --- /dev/null +++ b/go-xmpp/stanza/parser.go @@ -0,0 +1,199 @@ +package stanza + +import ( + "encoding/xml" + "errors" + "fmt" + "io" +) + +// Reads and checks the opening XMPP stream element. +// TODO It returns a stream structure containing: +// - Host: You can check the host against the host you were expecting to connect to +// - Id: the Stream ID is a temporary shared secret used for some hash calculation. It is also used by ProcessOne +// reattach features (allowing to resume an existing stream at the point the connection was interrupted, without +// getting through the authentication process. +// TODO We should handle stream error from XEP-0114 ( or ) +func InitStream(p *xml.Decoder) (sessionID string, err error) { + for { + var t xml.Token + t, err = p.Token() + if err != nil { + return sessionID, err + } + + switch elem := t.(type) { + case xml.StartElement: + isStreamOpen := elem.Name.Space == NSStream && elem.Name.Local == "stream" + isFrameOpen := elem.Name.Space == NSFraming && elem.Name.Local == "open" + if !isStreamOpen && !isFrameOpen { + err = errors.New("xmpp: expected or but got <" + elem.Name.Local + "> in " + elem.Name.Space) + return sessionID, err + } + + // Parse XMPP stream attributes + for _, attrs := range elem.Attr { + switch attrs.Name.Local { + case "id": + sessionID = attrs.Value + } + } + return sessionID, err + } + } +} + +// NextPacket scans XML token stream for next complete XMPP stanza. +// Once the type of stanza has been identified, a structure is created to decode +// that stanza and returned. +// TODO Use an interface to return packets interface xmppDecoder +// 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 + 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: + return decodeStream(p, se) + case NSSASL: + return decodeSASL(p, se) + case NSClient: + return decodeClient(p, se) + case NSComponent: + return decodeComponent(p, se) + case NSStreamManagement: + return sm.decode(p, se) + default: + return nil, errors.New("unknown namespace " + + se.Name.Space + " <" + se.Name.Local + "/>") + } +} + +// 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() + 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 + } + } +} + +/* +TODO: From all the decoder, we can return a pointer to the actual concrete type, instead of directly that + type. + That way, we have a consistent way to do type assertion, always matching against pointers. +*/ + +// decodeStream will fully decode a stream packet +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. +func decodeSASL(p *xml.Decoder, se xml.StartElement) (Packet, error) { + switch se.Name.Local { + case "success": + return saslSuccess.decode(p, se) + case "failure": + return saslFailure.decode(p, se) + default: + return nil, errors.New("unexpected XMPP packet " + + se.Name.Space + " <" + se.Name.Local + "/>") + } +} + +// decodeClient decodes all known packets in the client namespace. +func decodeClient(p *xml.Decoder, se xml.StartElement) (Packet, error) { + switch se.Name.Local { + case "message": + return message.decode(p, se) + case "presence": + return presence.decode(p, se) + case "iq": + return iq.decode(p, se) + default: + return nil, errors.New("unexpected XMPP packet " + + se.Name.Space + " <" + se.Name.Local + "/>") + } +} + +// decodeComponent decodes all known packets in the component namespace. +func decodeComponent(p *xml.Decoder, se xml.StartElement) (Packet, error) { + switch se.Name.Local { + case "handshake": // handshake is used to authenticate components + return handshake.decode(p, se) + case "message": + return message.decode(p, se) + case "presence": + return presence.decode(p, se) + case "iq": + return iq.decode(p, se) + default: + return nil, errors.New("unexpected XMPP packet " + + se.Name.Space + " <" + se.Name.Local + "/>") + } +} diff --git a/go-xmpp/stanza/pep.go b/go-xmpp/stanza/pep.go new file mode 100644 index 0000000..cfd50c1 --- /dev/null +++ b/go-xmpp/stanza/pep.go @@ -0,0 +1,27 @@ +package stanza + +import ( + "encoding/xml" +) + +type Tune struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/tune tune"` + Artist string `xml:"artist,omitempty"` + Length int `xml:"length,omitempty"` + Rating int `xml:"rating,omitempty"` + Source string `xml:"source,omitempty"` + Title string `xml:"title,omitempty"` + Track string `xml:"track,omitempty"` + Uri string `xml:"uri,omitempty"` +} + +// 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 + XMLName xml.Name `xml:"http://jabber.org/protocol/mood mood"` + // TODO: Custom parsing to extract mood type from tag name. + // Note: the list is predefined. + // Mood type + Text string `xml:"text,omitempty"` +} diff --git a/go-xmpp/stanza/pres_muc.go b/go-xmpp/stanza/pres_muc.go new file mode 100644 index 0000000..e103a48 --- /dev/null +++ b/go-xmpp/stanza/pres_muc.go @@ -0,0 +1,148 @@ +package stanza + +import ( + "encoding/xml" + "strconv" + "time" +) + +// ============================================================================ +// MUC Presence extension + +// MucPresence implements XEP-0045: Multi-User Chat - 19.1 +type MucPresence struct { + PresExtension + XMLName xml.Name `xml:"http://jabber.org/protocol/muc x"` + Password string `xml:"password,omitempty"` + History History `xml:"history,omitempty"` +} + +const timeLayout = "2006-01-02T15:04:05Z" + +// History implements XEP-0045: Multi-User Chat - 19.1 +type History struct { + XMLName xml.Name + MaxChars NullableInt `xml:"maxchars,attr,omitempty"` + MaxStanzas NullableInt `xml:"maxstanzas,attr,omitempty"` + Seconds NullableInt `xml:"seconds,attr,omitempty"` + Since time.Time `xml:"since,attr,omitempty"` +} + +type NullableInt struct { + Value int + isSet bool +} + +func NewNullableInt(val int) NullableInt { + return NullableInt{val, true} +} + +func (n NullableInt) Get() (v int, ok bool) { + return n.Value, n.isSet +} + +// UnmarshalXML implements custom parsing for history element +func (h *History) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + h.XMLName = start.Name + + // Extract attributes + for _, attr := range start.Attr { + switch attr.Name.Local { + case "maxchars": + v, err := strconv.Atoi(attr.Value) + if err != nil { + return err + } + h.MaxChars = NewNullableInt(v) + case "maxstanzas": + v, err := strconv.Atoi(attr.Value) + if err != nil { + return err + } + h.MaxStanzas = NewNullableInt(v) + case "seconds": + v, err := strconv.Atoi(attr.Value) + if err != nil { + return err + } + h.Seconds = NewNullableInt(v) + case "since": + t, err := time.Parse(timeLayout, attr.Value) + if err != nil { + return err + } + h.Since = t + } + } + + // Consume remaining data until element end + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + case xml.EndElement: + if tt == start.End() { + return nil + } + } + } +} + +func (h History) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) { + mc, isMcSet := h.MaxChars.Get() + ms, isMsSet := h.MaxStanzas.Get() + s, isSSet := h.Seconds.Get() + + // We do not have any value, ignore history element + if h.Since.IsZero() && !isMcSet && !isMsSet && !isSSet { + return nil + } + + // Encode start element and attributes + start.Name = xml.Name{Local: "history"} + + if isMcSet { + attr := xml.Attr{ + Name: xml.Name{Local: "maxchars"}, + Value: strconv.Itoa(mc), + } + start.Attr = append(start.Attr, attr) + } + + if isMsSet { + attr := xml.Attr{ + Name: xml.Name{Local: "maxstanzas"}, + Value: strconv.Itoa(ms), + } + start.Attr = append(start.Attr, attr) + } + + if isSSet { + attr := xml.Attr{ + Name: xml.Name{Local: "seconds"}, + Value: strconv.Itoa(s), + } + start.Attr = append(start.Attr, attr) + } + + if !h.Since.IsZero() { + attr := xml.Attr{ + Name: xml.Name{Local: "since"}, + Value: h.Since.Format(timeLayout), + } + start.Attr = append(start.Attr, attr) + } + if err := e.EncodeToken(start); err != nil { + return err + } + + return e.EncodeToken(xml.EndElement{Name: start.Name}) + +} + +func init() { + TypeRegistry.MapExtension(PKTPresence, xml.Name{Space: "http://jabber.org/protocol/muc", Local: "x"}, MucPresence{}) +} diff --git a/go-xmpp/stanza/pres_muc_test.go b/go-xmpp/stanza/pres_muc_test.go new file mode 100644 index 0000000..209dc93 --- /dev/null +++ b/go-xmpp/stanza/pres_muc_test.go @@ -0,0 +1,97 @@ +package stanza_test + +import ( + "encoding/xml" + "testing" + + "gosrc.io/xmpp/stanza" +) + +// https://xmpp.org/extensions/xep-0045.html#example-27 +func TestMucPassword(t *testing.T) { + str := ` + + cauldronburn + +` + + var parsedPresence stanza.Presence + if err := xml.Unmarshal([]byte(str), &parsedPresence); err != nil { + t.Errorf("Unmarshal(%s) returned error", str) + } + + var muc stanza.MucPresence + if ok := parsedPresence.Get(&muc); !ok { + t.Error("muc presence extension was not found") + } + + if muc.Password != "cauldronburn" { + t.Errorf("incorrect password: '%s'", muc.Password) + } +} + +// https://xmpp.org/extensions/xep-0045.html#example-37 +func TestMucHistory(t *testing.T) { + str := ` + + + +` + + var parsedPresence stanza.Presence + if err := xml.Unmarshal([]byte(str), &parsedPresence); err != nil { + t.Errorf("Unmarshal(%s) returned error: %s", str, err) + return + } + + var muc stanza.MucPresence + if ok := parsedPresence.Get(&muc); !ok { + t.Error("muc presence extension was not found") + return + } + + if v, ok := muc.History.MaxStanzas.Get(); !ok || v != 20 { + t.Errorf("incorrect MaxStanzas: '%#v'", muc.History.MaxStanzas) + } +} + +// https://xmpp.org/extensions/xep-0045.html#example-37 +func TestMucNoHistory(t *testing.T) { + str := "" + + "" + + "" + + "" + + "
" + + maxstanzas := 0 + + pres := stanza.Presence{Attrs: stanza.Attrs{ + From: "hag66@shakespeare.lit/pda", + Id: "n13mt3l", + To: "coven@chat.shakespeare.lit/thirdwitch", + }, + Extensions: []stanza.PresExtension{ + stanza.MucPresence{ + History: stanza.History{MaxStanzas: stanza.NewNullableInt(maxstanzas)}, + }, + }, + } + data, err := xml.Marshal(&pres) + if err != nil { + t.Error("error on encode:", err) + return + } + + if string(data) != str { + t.Errorf("incorrect stanza: \n%s\n%s", str, data) + } +} diff --git a/go-xmpp/stanza/presence.go b/go-xmpp/stanza/presence.go new file mode 100644 index 0000000..d3a8d1a --- /dev/null +++ b/go-xmpp/stanza/presence.go @@ -0,0 +1,139 @@ +package stanza + +import ( + "encoding/xml" + "reflect" +) + +// ============================================================================ +// Presence Packet + +// Presence implements RFC 6120 - A.5 Client Namespace (a part) +type Presence struct { + XMLName xml.Name `xml:"presence"` + Attrs + Show PresenceShow `xml:"show,omitempty"` + Status string `xml:"status,omitempty"` + Priority int8 `xml:"priority,omitempty"` // default: 0 + Error Err `xml:"error,omitempty"` + Extensions []PresExtension `xml:",omitempty"` +} + +func (Presence) Name() string { + return "presence" +} + +func NewPresence(a Attrs) Presence { + return Presence{ + XMLName: xml.Name{Local: "presence"}, + Attrs: a, + } +} + +// Get search and extracts a specific extension on a presence stanza. +// It receives a pointer to an PresExtension. It will panic if the caller +// does not pass a pointer. +// It will return true if the passed extension is found and set the pointer +// to the extension passed as parameter to the found extension. +// It will return false if the extension is not found on the presence. +// +// Example usage: +// var muc xmpp.MucPresence +// if ok := msg.Get(&muc); ok { +// // muc presence extension has been found +// } +func (pres *Presence) Get(ext PresExtension) bool { + target := reflect.ValueOf(ext) + if target.Kind() != reflect.Ptr { + panic("you must pass a pointer to the message Get method") + } + + for _, e := range pres.Extensions { + if reflect.TypeOf(e) == target.Type() { + source := reflect.ValueOf(e) + if source.Kind() != reflect.Ptr { + source = source.Elem() + } + target.Elem().Set(source.Elem()) + return true + } + } + return false +} + +type presenceDecoder struct{} + +var presence presenceDecoder + +func (presenceDecoder) decode(p *xml.Decoder, se xml.StartElement) (Presence, error) { + var packet Presence + err := p.DecodeElement(&packet, &se) + // TODO Add default presence type (when omitted) + return packet, err +} + +// UnmarshalXML implements custom parsing for presence stanza +func (pres *Presence) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + pres.XMLName = start.Name + + // Extract packet attributes + for _, attr := range start.Attr { + if attr.Name.Local == "id" { + pres.Id = attr.Value + } + if attr.Name.Local == "type" { + pres.Type = StanzaType(attr.Value) + } + if attr.Name.Local == "to" { + pres.To = attr.Value + } + if attr.Name.Local == "from" { + pres.From = attr.Value + } + if attr.Name.Local == "lang" { + pres.Lang = attr.Value + } + } + + // decode inner elements + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + + case xml.StartElement: + if presExt := TypeRegistry.GetPresExtension(tt.Name); presExt != nil { + // Decode message extension + err = d.DecodeElement(presExt, &tt) + if err != nil { + return err + } + pres.Extensions = append(pres.Extensions, presExt) + } else { + // Decode standard message sub-elements + var err error + switch tt.Name.Local { + case "show": + err = d.DecodeElement(&pres.Show, &tt) + case "status": + err = d.DecodeElement(&pres.Status, &tt) + case "priority": + err = d.DecodeElement(&pres.Priority, &tt) + case "error": + err = d.DecodeElement(&pres.Error, &tt) + } + if err != nil { + return err + } + } + + case xml.EndElement: + if tt == start.End() { + return nil + } + } + } +} diff --git a/go-xmpp/stanza/presence_enum.go b/go-xmpp/stanza/presence_enum.go new file mode 100644 index 0000000..c92691f --- /dev/null +++ b/go-xmpp/stanza/presence_enum.go @@ -0,0 +1,12 @@ +package stanza + +// PresenceShow is a Enum of presence element show +type PresenceShow string + +// RFC 6120: part of A.5 Client Namespace and A.6 Server Namespace +const ( + PresenceShowAway PresenceShow = "away" + PresenceShowChat PresenceShow = "chat" + PresenceShowDND PresenceShow = "dnd" + PresenceShowXA PresenceShow = "xa" +) diff --git a/go-xmpp/stanza/presence_test.go b/go-xmpp/stanza/presence_test.go new file mode 100644 index 0000000..94431d8 --- /dev/null +++ b/go-xmpp/stanza/presence_test.go @@ -0,0 +1,63 @@ +package stanza_test + +import ( + "encoding/xml" + "testing" + + "github.com/google/go-cmp/cmp" + "gosrc.io/xmpp/stanza" +) + +func TestGeneratePresence(t *testing.T) { + presence := stanza.NewPresence(stanza.Attrs{From: "admin@localhost", To: "test@localhost", Id: "1"}) + presence.Show = stanza.PresenceShowChat + + data, err := xml.Marshal(presence) + if err != nil { + t.Errorf("cannot marshal xml structure") + } + + var parsedPresence stanza.Presence + if err = xml.Unmarshal(data, &parsedPresence); err != nil { + t.Errorf("Unmarshal(%s) returned error", data) + } + + if !xmlEqual(parsedPresence, presence) { + t.Errorf("non matching items\n%s", cmp.Diff(parsedPresence, presence)) + } +} + +func TestPresenceSubElt(t *testing.T) { + // Test structure to ensure that show, status and priority are correctly defined as presence + // package sub-elements + type pres struct { + Show stanza.PresenceShow `xml:"show"` + Status string `xml:"status"` + Priority int8 `xml:"priority"` + } + + presence := stanza.NewPresence(stanza.Attrs{From: "admin@localhost", To: "test@localhost", Id: "1"}) + presence.Show = stanza.PresenceShowXA + presence.Status = "Coding" + presence.Priority = 10 + + data, err := xml.Marshal(presence) + if err != nil { + t.Errorf("cannot marshal xml structure") + } + + var parsedPresence pres + if err = xml.Unmarshal(data, &parsedPresence); err != nil { + t.Errorf("Unmarshal(%s) returned error", data) + } + + if parsedPresence.Show != presence.Show { + t.Errorf("cannot read 'show' as presence subelement (%s)", parsedPresence.Show) + } + if parsedPresence.Status != presence.Status { + t.Errorf("cannot read 'status' as presence subelement (%s)", parsedPresence.Status) + } + if parsedPresence.Priority != presence.Priority { + t.Errorf("cannot read 'priority' as presence subelement (%d)", parsedPresence.Priority) + } +} diff --git a/go-xmpp/stanza/pubsub.go b/go-xmpp/stanza/pubsub.go new file mode 100644 index 0000000..f0aa1ce --- /dev/null +++ b/go-xmpp/stanza/pubsub.go @@ -0,0 +1,433 @@ +package stanza + +import ( + "encoding/xml" + "errors" + "strings" +) + +type PubSubGeneric struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/pubsub pubsub"` + + 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 *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"` + 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"` + Publisher string `xml:"publisher,attr,omitempty"` + Any *Node `xml:",any"` +} + +type Retract struct { + XMLName xml.Name `xml:"retract"` + Node string `xml:"node,attr"` + 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{Space: "http://jabber.org/protocol/pubsub", Local: "pubsub"}, PubSubGeneric{}) +} diff --git a/go-xmpp/stanza/pubsub_owner.go b/go-xmpp/stanza/pubsub_owner.go new file mode 100644 index 0000000..d4c9843 --- /dev/null +++ b/go-xmpp/stanza/pubsub_owner.go @@ -0,0 +1,458 @@ +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, + CommandElements: []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) + var form *Form + for _, ce := range payload.CommandElements { + fo, ok := ce.(*Form) + if ok { + form = fo + break + } + } + if form == nil { + return nil, errors.New("this IQ does not contain a command payload with a form") + } + for _, elt := range form.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/go-xmpp/stanza/pubsub_owner_test.go b/go-xmpp/stanza/pubsub_owner_test.go new file mode 100644 index 0000000..8ebd16b --- /dev/null +++ b/go-xmpp/stanza/pubsub_owner_test.go @@ -0,0 +1,886 @@ +package stanza_test + +import ( + "encoding/xml" + "errors" + "testing" + + "gosrc.io/xmpp/stanza" +) + +// ****************************** +// * 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.Fatal(err) + } +} + +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.Fatal(err) + } + 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.Fatal(err) + } +} + +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.Fatal(err) + } + 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.Fatal(err) + } +} + +func TestNewDelNodeResp(t *testing.T) { + response := ` + + + + + + + +` + + pubsub, err := getPubSubOwnerPayload(response) + if err != nil { + t.Fatal(err) + } + 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.Fatal(err) + } +} + +// ************************************ +// * 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.Fatal(err) + } +} + +// ******************************************** +// * 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.Fatal(err) + } +} + +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.Fatal(err) + } +} + +// ******************************************** +// * 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.Fatal(err) + } +} + +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.Fatal(err) + } +} + +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.Fatal(err) + } +} + +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.Fatal(err) + } +} + +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/go-xmpp/stanza/pubsub_test.go b/go-xmpp/stanza/pubsub_test.go new file mode 100644 index 0000000..ed3299a --- /dev/null +++ b/go-xmpp/stanza/pubsub_test.go @@ -0,0 +1,923 @@ +package stanza_test + +import ( + "encoding/xml" + "errors" + "strings" + "testing" + + "gosrc.io/xmpp/stanza" +) + +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.Fatal(err) + } + +} + +func TestNewSubResp(t *testing.T) { + response := ` + + + + + +` + + pubsub, err := getPubSubGenericPayload(response) + if err != nil { + t.Fatal(err) + } + + 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.Fatal(err) + } +} + +func TestNewUnsubResp(t *testing.T) { + response := ` + + + + + +` + + pubsub, err := getPubSubGenericPayload(response) + if err != nil { + t.Fatal(err) + } + + 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.Fatal(err) + } +} + +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.Fatal(err) + } + + 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.Fatal(err) + } +} + +// *************************************** +// * 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.Fatal(err) + } +} + +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.Fatal(err) + } + 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.Fatal(err) + } + 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.Fatal(err) + } +} + +// *************************************** +// * 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.Fatal(err) + } +} + +// *************************************** +// * 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.Fatal(err) + } +} + +func TestNewCreateNodeResp(t *testing.T) { + response := ` + + + + + +` + pubsub, err := getPubSubGenericPayload(response) + if err != nil { + t.Fatal(err) + } + 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.Fatal(err) + } + +} + +// ******************************** +// * 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.Fatal(err) + } +} + +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.Fatal(err) + } +} + +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/go-xmpp/stanza/registry.go b/go-xmpp/stanza/registry.go new file mode 100644 index 0000000..beb35df --- /dev/null +++ b/go-xmpp/stanza/registry.go @@ -0,0 +1,119 @@ +package stanza + +import ( + "encoding/xml" + "reflect" + "sync" +) + +type MsgExtension interface{} +type PresExtension interface{} + +// The Registry for msg and IQ types is a global variable. +// TODO: Move to the client init process to remove the dependency on a global variable. +// That should make it possible to be able to share the decoder. +// TODO: Ensure that a client can add its own custom namespace to the registry (or overload existing ones). + +type PacketType uint8 + +const ( + PKTPresence PacketType = iota + PKTMessage + PKTIQ +) + +var TypeRegistry = newRegistry() + +// We store different registries per packet type and namespace. +type registryKey struct { + packetType PacketType + namespace string +} + +type registryForNamespace map[string]reflect.Type + +type registry struct { + // We store different registries per packet type and namespace. + msgTypes map[registryKey]registryForNamespace + // Handle concurrent access + msgTypesLock *sync.RWMutex +} + +func newRegistry() *registry { + return ®istry{ + msgTypes: make(map[registryKey]registryForNamespace), + msgTypesLock: &sync.RWMutex{}, + } +} + +// MapExtension stores extension type for packet payload. +// The match is done per PacketType (iq, message, or presence) and XML tag name. +// You can use the alias "*" as local XML name to be able to match all unknown tag name for that +// packet type and namespace. +func (r *registry) MapExtension(pktType PacketType, name xml.Name, extension MsgExtension) { + key := registryKey{pktType, name.Space} + r.msgTypesLock.RLock() + store := r.msgTypes[key] + r.msgTypesLock.RUnlock() + + r.msgTypesLock.Lock() + defer r.msgTypesLock.Unlock() + if store == nil { + store = make(map[string]reflect.Type) + } + store[name.Local] = reflect.TypeOf(extension) + r.msgTypes[key] = store +} + +// GetExtensionType returns extension type for packet payload, based on packet type and tag name. +func (r *registry) GetExtensionType(pktType PacketType, name xml.Name) reflect.Type { + key := registryKey{pktType, name.Space} + + r.msgTypesLock.RLock() + defer r.msgTypesLock.RUnlock() + store := r.msgTypes[key] + result := store[name.Local] + if result == nil && name.Local != "*" { + return store["*"] + } + return result +} + +// GetPresExtension returns an instance of PresExtension, by matching packet type and XML +// tag name against the registry. +func (r *registry) GetPresExtension(name xml.Name) PresExtension { + if extensionType := r.GetExtensionType(PKTPresence, name); extensionType != nil { + val := reflect.New(extensionType) + elt := val.Interface() + if presExt, ok := elt.(PresExtension); ok { + return presExt + } + } + return nil +} + +// GetMsgExtension returns an instance of MsgExtension, by matching packet type and XML +// tag name against the registry. +func (r *registry) GetMsgExtension(name xml.Name) MsgExtension { + if extensionType := r.GetExtensionType(PKTMessage, name); extensionType != nil { + val := reflect.New(extensionType) + elt := val.Interface() + if msgExt, ok := elt.(MsgExtension); ok { + return msgExt + } + } + return nil +} + +// GetIQExtension returns an instance of IQPayload, by matching packet type and XML +// tag name against the registry. +func (r *registry) GetIQExtension(name xml.Name) IQPayload { + if extensionType := r.GetExtensionType(PKTIQ, name); extensionType != nil { + val := reflect.New(extensionType) + elt := val.Interface() + if iqExt, ok := elt.(IQPayload); ok { + return iqExt + } + } + return nil +} diff --git a/go-xmpp/stanza/registry_test.go b/go-xmpp/stanza/registry_test.go new file mode 100644 index 0000000..02d321b --- /dev/null +++ b/go-xmpp/stanza/registry_test.go @@ -0,0 +1,47 @@ +package stanza + +import ( + "encoding/xml" + "reflect" + "testing" +) + +func TestRegistry_RegisterMsgExt(t *testing.T) { + // Setup registry + typeRegistry := newRegistry() + + // Register an element + name := xml.Name{Space: "urn:xmpp:receipts", Local: "request"} + typeRegistry.MapExtension(PKTMessage, name, ReceiptRequest{}) + + // Match that element + receipt := typeRegistry.GetMsgExtension(name) + if receipt == nil { + t.Error("cannot read element type from registry") + return + } + + switch r := receipt.(type) { + case *ReceiptRequest: + default: + t.Errorf("Registry did not return expected type ReceiptRequest: %v", reflect.TypeOf(r)) + } +} + +func BenchmarkRegistryGet(b *testing.B) { + // Setup registry + typeRegistry := newRegistry() + + // Register an element + name := xml.Name{Space: "urn:xmpp:receipts", Local: "request"} + typeRegistry.MapExtension(PKTMessage, name, ReceiptRequest{}) + + for i := 0; i < b.N; i++ { + // Match that element + receipt := typeRegistry.GetExtensionType(PKTMessage, name) + if receipt == nil { + b.Error("cannot read element type from registry") + return + } + } +} diff --git a/go-xmpp/stanza/results_sets.go b/go-xmpp/stanza/results_sets.go new file mode 100644 index 0000000..ecb0759 --- /dev/null +++ b/go-xmpp/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 `xml:",chardata"` + Index *int `xml:"index,attr,omitempty"` +} diff --git a/go-xmpp/stanza/results_sets_test.go b/go-xmpp/stanza/results_sets_test.go new file mode 100644 index 0000000..2ddd123 --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/sasl_auth.go b/go-xmpp/stanza/sasl_auth.go new file mode 100644 index 0000000..2fb660e --- /dev/null +++ b/go-xmpp/stanza/sasl_auth.go @@ -0,0 +1,124 @@ +package stanza + +import "encoding/xml" + +// ============================================================================ + +// SASLAuth implements SASL Authentication initiation. +// Reference: https://tools.ietf.org/html/rfc6120#section-6.4.2 +type SASLAuth struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl auth"` + Mechanism string `xml:"mechanism,attr"` + Value string `xml:",innerxml"` +} + +// ============================================================================ + +// SASLSuccess implements SASL Success nonza, sent by server as a result of the +// SASL auth negotiation. +// Reference: https://tools.ietf.org/html/rfc6120#section-6.4.6 +type SASLSuccess struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl success"` +} + +func (SASLSuccess) Name() string { + return "sasl:success" +} + +// SASLSuccess decoding +type saslSuccessDecoder struct{} + +var saslSuccess saslSuccessDecoder + +func (saslSuccessDecoder) decode(p *xml.Decoder, se xml.StartElement) (SASLSuccess, error) { + var packet SASLSuccess + err := p.DecodeElement(&packet, &se) + return packet, err +} + +// ============================================================================ + +// SASLFailure +type SASLFailure struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl failure"` + Any xml.Name // error reason is a subelement +} + +func (SASLFailure) Name() string { + return "sasl:failure" +} + +// SASLFailure decoding +type saslFailureDecoder struct{} + +var saslFailure saslFailureDecoder + +func (saslFailureDecoder) decode(p *xml.Decoder, se xml.StartElement) (SASLFailure, error) { + var packet SASLFailure + err := p.DecodeElement(&packet, &se) + return packet, err +} + +// =========================================================================== +// Resource binding + +// Bind is an IQ payload used during session negotiation to bind user resource +// to the current XMPP stream. +// Reference: https://tools.ietf.org/html/rfc6120#section-7 +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) + +// Session is both a stream feature and an obsolete IQ Payload, used to bind a +// resource to the current XMPP stream on RFC 3121 only XMPP servers. +// Session is obsolete in RFC 6121. It is added to Fluux XMPP for compliance +// with RFC 3121. +// Reference: https://xmpp.org/rfcs/rfc3921.html#session +// +// 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 *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 != nil + } + // If session element is missing, then we should not use session + return true +} + +// ============================================================================ +// Registry init + +func init() { + 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/go-xmpp/stanza/sasl_auth_test.go b/go-xmpp/stanza/sasl_auth_test.go new file mode 100644 index 0000000..600035c --- /dev/null +++ b/go-xmpp/stanza/sasl_auth_test.go @@ -0,0 +1,60 @@ +package stanza_test + +import ( + "encoding/xml" + "testing" + + "gosrc.io/xmpp/stanza" +) + +// Check that we can detect optional session from advertised stream features +func TestSessionFeatures(t *testing.T) { + streamFeatures := stanza.StreamFeatures{Session: stanza.StreamSession{Optional: &struct{}{}}} + + data, err := xml.Marshal(streamFeatures) + if err != nil { + t.Errorf("cannot marshal xml structure: %s", err) + } + + parsedStream := stanza.StreamFeatures{} + if err = xml.Unmarshal(data, &parsedStream); err != nil { + t.Errorf("Unmarshal(%s) returned error: %s", data, err) + } + + if !parsedStream.Session.IsOptional() { + t.Error("Session should be optional") + } +} + +// Check that the Session tag can be used in IQ decoding +func TestSessionIQ(t *testing.T) { + 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 { + t.Errorf("cannot marshal xml structure: %s", err) + return + } + + parsedIQ := stanza.IQ{} + if err = xml.Unmarshal(data, &parsedIQ); err != nil { + t.Errorf("Unmarshal(%s) returned error: %s", data, err) + return + } + + session, ok := parsedIQ.Payload.(*stanza.StreamSession) + if !ok { + t.Error("Missing session payload") + return + } + + if !session.IsOptional() { + t.Error("Session should be optional") + } +} + +// TODO Test Sasl mechanism diff --git a/go-xmpp/stanza/stanza_errors.go b/go-xmpp/stanza/stanza_errors.go new file mode 100644 index 0000000..c66ea33 --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/starttls.go b/go-xmpp/stanza/starttls.go new file mode 100644 index 0000000..b8fce94 --- /dev/null +++ b/go-xmpp/stanza/starttls.go @@ -0,0 +1,14 @@ +package stanza + +import ( + "encoding/xml" +) + +// Used during stream initiation / session establishment +type TLSProceed struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls proceed"` +} + +type tlsFailure struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls failure"` +} diff --git a/go-xmpp/stanza/stream.go b/go-xmpp/stanza/stream.go new file mode 100644 index 0000000..6ab4bad --- /dev/null +++ b/go-xmpp/stanza/stream.go @@ -0,0 +1,16 @@ +package stanza + +import "encoding/xml" + +// Start of stream +// Reference: XMPP Core stream open +// https://tools.ietf.org/html/rfc6120#section-4.2 +type Stream struct { + XMLName xml.Name `xml:"http://etherx.jabber.org/streams stream"` + From string `xml:"from,attr"` + To string `xml:"to,attr"` + Id string `xml:"id,attr"` + Version string `xml:"version,attr"` +} + +const StreamClose = "" diff --git a/go-xmpp/stanza/stream_features.go b/go-xmpp/stanza/stream_features.go new file mode 100644 index 0000000..d1b6274 --- /dev/null +++ b/go-xmpp/stanza/stream_features.go @@ -0,0 +1,189 @@ +package stanza + +import ( + "encoding/xml" +) + +// ============================================================================ +// StreamFeatures Packet +// Reference: The active stream features are published on +// https://xmpp.org/registrar/stream-features.html +// Note: That page misses draft and experimental XEP (i.e CSI, etc) + +type StreamFeatures struct { + XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"` + // Server capabilities hash + Caps Caps + // Stream features + StartTLS TlsStartTLS + Mechanisms saslMechanisms + Bind Bind + StreamManagement streamManagement + // Obsolete + Session StreamSession + // ProcessOne Stream Features + P1Push p1Push + P1Rebind p1Rebind + p1Ack p1Ack + Any []xml.Name `xml:",any"` +} + +func (StreamFeatures) Name() string { + return "stream:features" +} + +type streamFeatureDecoder struct{} + +var streamFeatures streamFeatureDecoder + +func (streamFeatureDecoder) decode(p *xml.Decoder, se xml.StartElement) (StreamFeatures, error) { + var packet StreamFeatures + err := p.DecodeElement(&packet, &se) + return packet, err +} + +// Capabilities +// Reference: https://xmpp.org/extensions/xep-0115.html#stream +// "A server MAY include its entity capabilities in a stream feature element so that connecting clients +// and peer servers do not need to send service discovery requests each time they connect." +// This is not a stream feature but a way to let client cache server disco info. +type Caps struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/caps c"` + Hash string `xml:"hash,attr"` + Node string `xml:"node,attr"` + Ver string `xml:"ver,attr"` + Ext string `xml:"ext,attr,omitempty"` +} + +// ============================================================================ +// Supported Stream Features + +// StartTLS feature +// Reference: RFC 6120 - https://tools.ietf.org/html/rfc6120#section-5.4 +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 { + stls.XMLName = start.Name + + // Check subelements to extract required field as boolean + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + + case xml.StartElement: + elt := new(Node) + + err = d.DecodeElement(elt, &tt) + if err != nil { + return err + } + + if elt.XMLName.Local == "required" { + stls.Required = true + } + + case xml.EndElement: + if tt == start.End() { + return nil + } + } + } +} + +func (sf *StreamFeatures) DoesStartTLS() (feature TlsStartTLS, isSupported bool) { + if sf.StartTLS.XMLName.Space+" "+sf.StartTLS.XMLName.Local == nsTLS+" starttls" { + return sf.StartTLS, true + } + return feature, false +} + +// Mechanisms +// Reference: RFC 6120 - https://tools.ietf.org/html/rfc6120#section-6.4.1 +type saslMechanisms struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl mechanisms"` + Mechanism []string `xml:"mechanism"` +} + +// StreamManagement +// Reference: XEP-0198 - https://xmpp.org/extensions/xep-0198.html#feature +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 + } + return false +} + +// P1 extensions +// Reference: https://docs.ejabberd.im/developer/mobile/core-features/ + +// p1:push support +type p1Push struct { + XMLName xml.Name `xml:"p1:push push"` +} + +// p1:rebind suppor +type p1Rebind struct { + XMLName xml.Name `xml:"p1:rebind rebind"` +} + +// p1:ack support +type p1Ack struct { + XMLName xml.Name `xml:"p1:ack ack"` +} + +// ============================================================================ +// StreamError Packet + +type StreamError struct { + XMLName xml.Name `xml:"http://etherx.jabber.org/streams error"` + Error xml.Name `xml:",any"` + Text string `xml:"urn:ietf:params:xml:ns:xmpp-streams text"` +} + +func (StreamError) Name() string { + return "stream:error" +} + +type streamErrorDecoder struct{} + +var streamError streamErrorDecoder + +func (streamErrorDecoder) decode(p *xml.Decoder, se xml.StartElement) (StreamError, error) { + var packet StreamError + 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/go-xmpp/stanza/stream_features_test.go b/go-xmpp/stanza/stream_features_test.go new file mode 100644 index 0000000..621bd9c --- /dev/null +++ b/go-xmpp/stanza/stream_features_test.go @@ -0,0 +1,64 @@ +package stanza_test + +import ( + "encoding/xml" + "testing" + + "gosrc.io/xmpp/stanza" +) + +func TestNoStartTLS(t *testing.T) { + streamFeatures := ` +` + + var parsedSF stanza.StreamFeatures + if err := xml.Unmarshal([]byte(streamFeatures), &parsedSF); err != nil { + t.Errorf("Unmarshal(%s) returned error: %v", streamFeatures, err) + } + + startTLS, ok := parsedSF.DoesStartTLS() + if ok { + t.Error("StartTLS feature should not be enabled") + } + if startTLS.Required { + t.Error("StartTLS cannot be required as default") + } +} + +func TestStartTLS(t *testing.T) { + streamFeatures := ` + + + +` + + var parsedSF stanza.StreamFeatures + if err := xml.Unmarshal([]byte(streamFeatures), &parsedSF); err != nil { + t.Errorf("Unmarshal(%s) returned error: %v", streamFeatures, err) + } + + startTLS, ok := parsedSF.DoesStartTLS() + if !ok { + t.Error("StartTLS feature should be enabled") + } + if !startTLS.Required { + t.Error("StartTLS feature should be required") + } +} + +// TODO: Ability to support / detect previous version of stream management feature +func TestStreamManagement(t *testing.T) { + streamFeatures := ` + +` + + var parsedSF stanza.StreamFeatures + if err := xml.Unmarshal([]byte(streamFeatures), &parsedSF); err != nil { + t.Errorf("Unmarshal(%s) returned error: %v", streamFeatures, err) + } + + ok := parsedSF.DoesStreamManagement() + if !ok { + t.Error("Stream Management feature should have been detected") + } +} diff --git a/go-xmpp/stanza/stream_management.go b/go-xmpp/stanza/stream_management.go new file mode 100644 index 0000000..a2a4f0b --- /dev/null +++ b/go-xmpp/stanza/stream_management.go @@ -0,0 +1,392 @@ +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 { + XMLName xml.Name `xml:"urn:xmpp:sm:3 enabled"` + Id string `xml:"id,attr,omitempty"` + Location string `xml:"location,attr,omitempty"` + Resume string `xml:"resume,attr,omitempty"` + Max uint `xml:"max,attr,omitempty"` +} + +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 { + XMLName xml.Name `xml:"urn:xmpp:sm:3 r"` +} + +func (SMRequest) Name() string { + return "Stream Management: request" +} + +// Answer as defined in Stream Management spec +// 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"` +} + +func (SMAnswer) Name() string { + return "Stream Management: answer" +} + +// Resumed as defined in Stream Management spec +// Reference: https://xmpp.org/extensions/xep-0198.html#acking +type SMResumed struct { + XMLName xml.Name `xml:"urn:xmpp:sm:3 resumed"` + PrevId string `xml:"previd,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"` + 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 + +// decode decodes all known nonza in the stream management namespace. +func (s smDecoder) decode(p *xml.Decoder, se xml.StartElement) (Packet, error) { + switch se.Name.Local { + case "enabled": + 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 "a": + return s.decodeAnswer(p, se) + case "failed": + return s.decodeFailed(p, se) + default: + return nil, errors.New("unexpected XMPP packet " + + se.Name.Space + " <" + se.Name.Local + "/>") + } +} + +func (smDecoder) decodeEnabled(p *xml.Decoder, se xml.StartElement) (SMEnabled, error) { + var packet SMEnabled + err := p.DecodeElement(&packet, &se) + return packet, err +} + +func (smDecoder) decodeResumed(p *xml.Decoder, se xml.StartElement) (SMResumed, error) { + var packet SMResumed + err := p.DecodeElement(&packet, &se) + 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) + return packet, err +} + +func (smDecoder) decodeAnswer(p *xml.Decoder, se xml.StartElement) (SMAnswer, error) { + var packet SMAnswer + err := p.DecodeElement(&packet, &se) + return packet, err +} + +func (smDecoder) decodeFailed(p *xml.Decoder, se xml.StartElement) (SMFailed, error) { + var packet SMFailed + err := p.DecodeElement(&packet, &se) + return packet, err +} diff --git a/go-xmpp/stanza/stream_management_test.go b/go-xmpp/stanza/stream_management_test.go new file mode 100644 index 0000000..1b3443e --- /dev/null +++ b/go-xmpp/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/go-xmpp/stanza/xmpp_test.go b/go-xmpp/stanza/xmpp_test.go new file mode 100644 index 0000000..b39613b --- /dev/null +++ b/go-xmpp/stanza/xmpp_test.go @@ -0,0 +1,81 @@ +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) { + // 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 stanza.IQ + err = xml.Unmarshal(data, &parsedIQ) + if err != nil { + t.Errorf("Unmarshal returned error: %s\n%s", err, data) + } + return &parsedIQ, err +} + +// ============================================================================ +// XML structs comparison + +// Compare iq structure but ignore empty namespace as they are set properly on +// marshal / unmarshal. There is no need to manage them on the manually +// crafted structure. +func xmlEqual(x, y interface{}) bool { + return cmp.Equal(x, y, xmlOpts()) +} + +// xmlDiff compares xml structures ignoring namespace preferences +func xmlDiff(x, y interface{}) string { + return cmp.Diff(x, y, xmlOpts()) +} + +func xmlOpts() cmp.Options { + alwaysEqual := cmp.Comparer(func(_, _ interface{}) bool { return true }) + opts := cmp.Options{ + cmp.FilterValues(func(x, y interface{}) bool { + xx, xok := x.(xml.Name) + yy, yok := y.(xml.Name) + if xok && yok { + zero := xml.Name{} + if xx == zero || yy == zero { + return true + } + if xx.Space == "" || yy.Space == "" { + return true + } + } + return false + }, alwaysEqual), + } + 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/go-xmpp/stream_logger.go b/go-xmpp/stream_logger.go new file mode 100644 index 0000000..0e6cc7b --- /dev/null +++ b/go-xmpp/stream_logger.go @@ -0,0 +1,52 @@ +package xmpp + +import ( + "io" +) + +// Mediated Read / Write on socket +// Used if logFile from Config is not nil +type streamLogger struct { + socket io.ReadWriter // Actual connection + logFile io.Writer +} + +func newStreamLogger(conn io.ReadWriter, logFile io.Writer) io.ReadWriter { + if logFile == nil { + return conn + } else { + return &streamLogger{conn, logFile} + } +} + +func (sl *streamLogger) Read(p []byte) (n int, err error) { + n, err = sl.socket.Read(p) + if n > 0 { + sl.logFile.Write([]byte("RECV:\n")) // Prefix + if n, err := sl.logFile.Write(p[:n]); err != nil { + return n, err + } + sl.logFile.Write([]byte("\n\n")) // Separator + } + return +} + +func (sl *streamLogger) Write(p []byte) (n int, err error) { + sl.logFile.Write([]byte("SEND:\n")) // Prefix + for _, w := range []io.Writer{sl.socket, sl.logFile} { + n, err = w.Write(p) + if err != nil { + return + } + if n != len(p) { + err = io.ErrShortWrite + return + } + } + sl.logFile.Write([]byte("\n\n")) // Separator + return len(p), nil +} + +/* +TODO: Make RECV, SEND prefixes + +*/ diff --git a/go-xmpp/stream_manager.go b/go-xmpp/stream_manager.go new file mode 100644 index 0000000..7bfb42c --- /dev/null +++ b/go-xmpp/stream_manager.go @@ -0,0 +1,183 @@ +package xmpp + +import ( + "context" + "errors" + "sync" + "time" + + "golang.org/x/xerrors" + "gosrc.io/xmpp/stanza" +) + +// The Fluux XMPP lib can manage client or component XMPP streams. +// The StreamManager handles the stream workflow handling the common +// stream events and doing the right operations. +// +// It can handle: +// - Client +// - Stream establishment workflow +// - Reconnection strategies, with exponential backoff. It also takes into account +// permanent errors to avoid useless reconnection loops. +// - Metrics processing + +// StreamClient is an interface used by StreamManager to control Client lifecycle, +// set callback and trigger reconnection. +type StreamClient interface { + Connect() error + Resume() error + Send(packet stanza.Packet) error + SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) + SendRaw(packet string) error + Disconnect() error + SetHandler(handler EventHandler) +} + +// Sender is an interface provided by Stream clients to allow sending XMPP data. +// 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) + SendRaw(packet string) error +} + +// StreamManager supervises an XMPP client connection. Its role is to handle connection events and +// apply reconnection strategy. +type StreamManager struct { + client StreamClient + PostConnect PostConnect + + // Store low level metrics + Metrics *Metrics + + wg sync.WaitGroup +} + +type PostConnect func(c Sender) + +// NewStreamManager creates a new StreamManager structure, intended to support +// handling XMPP client state event changes and auto-trigger reconnection +// based on StreamManager configuration. +// TODO: Move parameters to Start and remove factory method +func NewStreamManager(client StreamClient, pc PostConnect) *StreamManager { + return &StreamManager{ + client: client, + PostConnect: pc, + } +} + +// Run launches the connection of the underlying client or component +// and wait until Disconnect is called, or for the manager to terminate due +// to an unrecoverable error. +func (sm *StreamManager) Run() error { + if sm.client == nil { + return errors.New("missing stream client") + } + + handler := func(e Event) error { + switch e.State.state { + case StateSessionEstablished: + sm.Metrics.setLoginTime() + case StateDisconnected: + // Reconnect on disconnection + 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" { + return sm.resume() + } + case StatePermanentError: + // Do not attempt to reconnect + } + return nil + } + sm.client.SetHandler(handler) + + sm.wg.Add(1) + if err := sm.connect(); err != nil { + sm.wg.Done() + return err + } + sm.wg.Wait() + return nil +} + +// Stop cancels pending operations and terminates existing XMPP client. +func (sm *StreamManager) Stop() { + // Remove on disconnect handler to avoid triggering reconnect + sm.client.SetHandler(nil) + sm.client.Disconnect() + sm.wg.Done() +} + +func (sm *StreamManager) connect() error { + 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() 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(); err != nil { + var actualErr ConnError + if xerrors.As(err, &actualErr) { + if actualErr.Permanent { + return xerrors.Errorf("unrecoverable connect error %#v", actualErr) + } + } + backoff.wait() + } else { // We are connected, we can leave the retry loop + break + } + } + + if sm.PostConnect != nil { + sm.PostConnect(sm.client) + } + return nil +} + +// Stream Metrics +// ============================================================================ + +type Metrics struct { + startTime time.Time + // 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 + // like starttls. + LoginTime time.Duration +} + +// initMetrics set metrics with default value and define the starting point +// for duration calculation (connect time, login time, etc). +func initMetrics() *Metrics { + return &Metrics{ + startTime: time.Now(), + } +} + +func (m *Metrics) setLoginTime() { + m.LoginTime = time.Since(m.startTime) +} diff --git a/go-xmpp/tcp_server_mock.go b/go-xmpp/tcp_server_mock.go new file mode 100644 index 0000000..d189e3a --- /dev/null +++ b/go-xmpp/tcp_server_mock.go @@ -0,0 +1,319 @@ +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, 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 + 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. +func (mock *ServerMock) Start(t *testing.T, addr string, handler ClientHandler) { + mock.t = t + mock.handler = handler + if err := mock.init(addr); err != nil { + return + } + go mock.loop() +} + +func (mock *ServerMock) Stop() { + close(mock.done) + if mock.listener != nil { + mock.listener.Close() + } + // Close all existing serverConnections + for _, c := range mock.serverConnections { + c.connection.Close() + } +} + +//============================================================================= +// Mock Server internals + +// init starts listener on the provided address. +func (mock *ServerMock) init(addr string) error { + mock.done = make(chan struct{}) + + l, err := net.Listen("tcp", addr) + if err != nil { + mock.t.Errorf("TCPServerMock cannot listen on address: %q", addr) + return err + } + mock.listener = l + return nil +} + +// 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: + return + default: + mock.t.Error("TCPServerMock accept error:", err.Error()) + } + return + } + 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, 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/go-xmpp/test.sh b/go-xmpp/test.sh new file mode 100755 index 0000000..199c05e --- /dev/null +++ b/go-xmpp/test.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -e +export GO111MODULE=on +echo "" > coverage.txt + +for d in $(go list ./... | grep -v vendor); do + go test -race -coverprofile=profile.out -covermode=atomic "${d}" + if [ -f profile.out ]; then + cat profile.out >> coverage.txt + rm profile.out + fi +done diff --git a/go-xmpp/transport.go b/go-xmpp/transport.go new file mode 100644 index 0000000..fa124ea --- /dev/null +++ b/go-xmpp/transport.go @@ -0,0 +1,78 @@ +package xmpp + +import ( + "crypto/tls" + "encoding/xml" + "errors" + "fmt" + "io" + "strings" +) + +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 { + // Address is the XMPP Host and port to connect to. Host is of + // the form 'serverhost:port' i.e "localhost:8888" + Address string + Domain string + ConnectTimeout int // Client timeout in seconds. Default to 15 + // tls.Config must not be modified after having been passed to NewClient. Any + // changes made after connecting are ignored. + TLSConfig *tls.Config + CharsetReader func(charset string, input io.Reader) (io.Reader, error) // passed to xml decoder +} + +type Transport interface { + Connect() (string, error) + DoesStartTLS() bool + StartTLS() error + + LogTraffic(logFile io.Writer) + + StartStream() (string, error) + GetDecoder() *xml.Decoder + IsSecure() bool + + Ping() error + 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. +// The type of transport is determined by the address in the configuration: +// - if the address is a URL with the `ws` or `wss` scheme WebsocketTransport is used +// - in all other cases a XMPPTransport is used +// For XMPPTransport it is mandatory for the address to have a port specified. +func NewClientTransport(config TransportConfiguration) Transport { + if strings.HasPrefix(config.Address, "ws:") || strings.HasPrefix(config.Address, "wss:") { + return &WebsocketTransport{Config: config} + } + + config.Address = ensurePort(config.Address, 5222) + return &XMPPTransport{ + Config: config, + openStatement: clientStreamOpen, + } +} + +// NewComponentTransport creates a new Transport instance for components. +// Only XMPP transports are allowed. If you try to use any other protocol an error +// 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) + } + + config.Address = ensurePort(config.Address, 5222) + return &XMPPTransport{ + Config: config, + openStatement: componentStreamOpen, + }, nil +} diff --git a/go-xmpp/websocket_transport.go b/go-xmpp/websocket_transport.go new file mode 100644 index 0000000..7631fc8 --- /dev/null +++ b/go-xmpp/websocket_transport.go @@ -0,0 +1,185 @@ +package xmpp + +import ( + "bufio" + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "strings" + "time" + + "gosrc.io/xmpp/stanza" + "nhooyr.io/websocket" +) + +const maxPacketSize = 32768 + +const pingTimeout = time.Duration(5) * time.Second + +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 + wsConn *websocket.Conn + queue chan []byte + logFile io.Writer + + closeCtx context.Context + closeFunc context.CancelFunc +} + +func (t *WebsocketTransport) Connect() (string, error) { + t.queue = make(chan []byte, 256) + t.closeCtx, t.closeFunc = context.WithCancel(context.Background()) + + var ctx context.Context + ctx = context.Background() + if t.Config.ConnectTimeout > 0 { + var cancelConnect context.CancelFunc + ctx, cancelConnect = context.WithTimeout(t.closeCtx, time.Duration(t.Config.ConnectTimeout)*time.Second) + defer cancelConnect() + } + + wsConn, response, err := websocket.Dial(ctx, t.Config.Address, &websocket.DialOptions{ + Subprotocols: []string{"xmpp"}, + }) + + if err != nil { + return "", NewConnError(err, true) + } + if response.Header.Get("Sec-WebSocket-Protocol") != "xmpp" { + t.cleanup(websocket.StatusBadGateway) + return "", NewConnError(ServerDoesNotSupportXmppOverWebsocket, true) + } + + wsConn.SetReadLimit(maxPacketSize) + t.wsConn = wsConn + t.startReader() + + t.decoder = xml.NewDecoder(bufio.NewReaderSize(t, maxPacketSize)) + t.decoder.CharsetReader = t.Config.CharsetReader + + return t.StartStream() +} + +func (t WebsocketTransport) StartStream() (string, error) { + if _, err := fmt.Fprintf(t, ``, t.Config.Domain); err != nil { + t.cleanup(websocket.StatusBadGateway) + return "", NewConnError(err, true) + } + + sessionID, err := stanza.InitStream(t.GetDecoder()) + if err != nil { + t.Close() + return "", NewConnError(err, false) + } + return sessionID, nil +} + +// startReader runs a go function that keeps reading from the websocket. This +// is required to allow Ping() to work: Ping requires a Reader to be running +// to process incoming control frames. +func (t WebsocketTransport) startReader() { + go func() { + buffer := make([]byte, maxPacketSize) + for { + _, reader, err := t.wsConn.Reader(t.closeCtx) + if err != nil { + return + } + n, err := reader.Read(buffer) + if err != nil && err != io.EOF { + return + } + if n > 0 { + // We need to make a copy, otherwise we will overwrite the slice content + // on the next iteration of the for loop. + tmp := make([]byte, n) + copy(tmp, buffer) + t.queue <- tmp + } + } + }() +} + +func (t WebsocketTransport) StartTLS() error { + return ErrTLSNotSupported +} + +func (t WebsocketTransport) DoesStartTLS() bool { + return false +} + +func (t WebsocketTransport) GetDomain() string { + return t.Config.Domain +} + +func (t WebsocketTransport) GetDecoder() *xml.Decoder { + return t.decoder +} + +func (t WebsocketTransport) IsSecure() bool { + return strings.HasPrefix(t.Config.Address, "wss:") +} + +func (t WebsocketTransport) Ping() error { + ctx, cancel := context.WithTimeout(t.closeCtx, pingTimeout) + defer cancel() + return t.wsConn.Ping(ctx) +} + +func (t *WebsocketTransport) Read(p []byte) (int, error) { + select { + case <-t.closeCtx.Done(): + return 0, t.closeCtx.Err() + case data := <-t.queue: + if t.logFile != nil && len(data) > 0 { + _, _ = fmt.Fprintf(t.logFile, "RECV:\n%s\n\n", data) + } + copy(p, data) + return len(data), nil + } +} + +func (t WebsocketTransport) Write(p []byte) (int, error) { + if t.logFile != nil { + _, _ = fmt.Fprintf(t.logFile, "SEND:\n%s\n\n", p) + } + return len(p), t.wsConn.Write(t.closeCtx, websocket.MessageText, p) +} + +func (t WebsocketTransport) Close() error { + t.Write([]byte("")) + return t.cleanup(websocket.StatusGoingAway) +} + +func (t *WebsocketTransport) LogTraffic(logFile io.Writer) { + t.logFile = logFile +} + +func (t *WebsocketTransport) cleanup(code websocket.StatusCode) error { + var err error + if t.queue != nil { + close(t.queue) + t.queue = nil + } + if t.wsConn != nil { + err = t.wsConn.Close(websocket.StatusGoingAway, "Done") + t.wsConn = nil + } + if t.closeFunc != nil { + t.closeFunc() + t.closeFunc = nil + t.closeCtx = nil + } + return err +} + +// ReceivedStreamClose is not used for websockets for now +func (t *WebsocketTransport) ReceivedStreamClose() { + return +} diff --git a/go-xmpp/xmpp_transport.go b/go-xmpp/xmpp_transport.go new file mode 100644 index 0000000..c7d2022 --- /dev/null +++ b/go-xmpp/xmpp_transport.go @@ -0,0 +1,163 @@ +package xmpp + +import ( + "bufio" + "crypto/tls" + "encoding/xml" + "errors" + "fmt" + "io" + "net" + "time" + + "gosrc.io/xmpp/stanza" +) + +// 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 + TLSConfig *tls.Config + decoder *xml.Decoder + conn net.Conn + 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) + +var clientStreamOpen = fmt.Sprintf("", stanza.NSClient, stanza.NSStream) + +func (t *XMPPTransport) Connect() (string, error) { + var err error + + // Since we're starting a new connection, reset the encryption status + t.isSecure = false + + t.conn, err = net.DialTimeout("tcp", t.Config.Address, time.Duration(t.Config.ConnectTimeout)*time.Second) + if err != nil { + return "", NewConnError(err, true) + } + + t.closeChan = make(chan stanza.StreamClosePacket, 1) + 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) { + if _, err := fmt.Fprintf(t, t.openStatement, t.Config.Domain); err != nil { + t.Close() + return "", NewConnError(err, true) + } + + sessionID, err := stanza.InitStream(t.GetDecoder()) + if err != nil { + t.Close() + return "", NewConnError(err, false) + } + return sessionID, nil +} + +func (t *XMPPTransport) DoesStartTLS() bool { + return true +} + +func (t *XMPPTransport) GetDomain() string { + return t.Config.Domain +} + +func (t *XMPPTransport) GetDecoder() *xml.Decoder { + return t.decoder +} + +func (t *XMPPTransport) IsSecure() bool { + return t.isSecure +} + +func (t *XMPPTransport) StartTLS() error { + if t.Config.TLSConfig == nil { + t.TLSConfig = &tls.Config{} + } else { + t.TLSConfig = t.Config.TLSConfig.Clone() + } + + if t.TLSConfig.ServerName == "" { + t.TLSConfig.ServerName = t.Config.Domain + } + tlsConn := tls.Client(t.conn, t.TLSConfig) + // We convert existing connection to TLS + if err := tlsConn.Handshake(); err != nil { + return err + } + + t.isSecure = false + t.conn = tlsConn + t.readWriter = newStreamLogger(tlsConn, t.logFile) + t.decoder = xml.NewDecoder(bufio.NewReaderSize(t.readWriter, maxPacketSize)) + t.decoder.CharsetReader = t.Config.CharsetReader + + if !t.TLSConfig.InsecureSkipVerify { + if err := tlsConn.VerifyHostname(t.Config.Domain); err != nil { + return err + } + } + + t.isSecure = true + return nil +} + +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 nil +} + +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) { + if t.readWriter == nil { + return 0, errors.New("cannot write: not connected, no readwriter") + } + return t.readWriter.Write(p) +} + +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{} +}