commit 5dcfeda1e9edd7076b71e052e67ea086c0a7460f Author: Joachim Bauch Date: Tue May 12 09:46:20 2020 +0200 Initial commit of the OpenSource version. This corresponds to nextcloud-spreed-signaling 0.0.13 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..203644a --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +bin/ +vendor/ + +*_easyjson.go +*.prof +*.socket +*.tar.gz + +cover.out +server.conf +src/signaling/continentmap.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dba13ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e326480 --- /dev/null +++ b/Makefile @@ -0,0 +1,113 @@ +all: build + +GO := $(shell which go) +GOPATH := "$(CURDIR)/vendor:$(CURDIR)" +BINDIR := "$(CURDIR)/bin" +VERSION := $(shell "$(CURDIR)/scripts/get-version.sh") +TARVERSION := $(shell "$(CURDIR)/scripts/get-version.sh" --tar) +ifneq ($(VERSION),) +INTERNALLDFLAGS := -X main.version=$(VERSION) +else +INTERNALLDFLAGS := +endif + +ifneq ($(RACE),) +BUILDARGS := -race +else +BUILDARGS := +endif + +ifneq ($(CI),) +TESTARGS := -race +else +TESTARGS := +endif + +ifeq ($(TIMEOUT),) +TIMEOUT := 60s +endif + +ifneq ($(TEST),) +TESTARGS := $(TESTARGS) -run $(TEST) +endif + +hook: + [ ! -d "$(CURDIR)/.git/hooks" ] || ln -sf "$(CURDIR)/scripts/pre-commit.hook" "$(CURDIR)/.git/hooks/pre-commit" + +godeps: + GOPATH=$(GOPATH) $(GO) get github.com/rogpeppe/godeps + +easyjson: + GOPATH=$(GOPATH) $(GO) get -d github.com/mailru/easyjson/... + GOPATH=$(GOPATH) $(GO) build -o ./vendor/bin/easyjson ./vendor/src/github.com/mailru/easyjson/easyjson/main.go + +dependencies: hook godeps easyjson src/signaling/continentmap.go + GOPATH=$(GOPATH) ./vendor/bin/godeps -u dependencies.tsv + +dependencies.tsv: godeps + set -e ;\ + TMP=$$(mktemp -d) ;\ + echo Make sure to remove $$TMP on error ;\ + cp -r "$(CURDIR)/vendor" $$TMP ;\ + GOPATH=$$TMP/vendor:"$(CURDIR)" "$(CURDIR)/vendor/bin/godeps" ./src/... > "$(CURDIR)/dependencies.tsv" ;\ + rm -rf $$TMP + +src/signaling/continentmap.go: + $(CURDIR)/scripts/get_continent_map.py $@ + +get: + GOPATH=$(GOPATH) $(GO) get $(PACKAGE) + +fmt: hook + $(GO) fmt ./src/... + +vet: dependencies common + GOPATH=$(GOPATH) $(GO) vet ./src/... + +test: dependencies vet common + GOPATH=$(GOPATH) $(GO) test -v -timeout $(TIMEOUT) $(TESTARGS) ./src/... + +cover: dependencies vet common + rm -f cover.out && \ + GOPATH=$(GOPATH) $(GO) test -v -timeout $(TIMEOUT) -coverprofile cover.out ./src/signaling/... && \ + sed -i "/_easyjson/d" cover.out && \ + GOPATH=$(GOPATH) $(GO) tool cover -func=cover.out + +coverhtml: dependencies vet common + rm -f cover.out && \ + GOPATH=$(GOPATH) $(GO) test -v -timeout $(TIMEOUT) -coverprofile cover.out ./src/signaling/... && \ + sed -i "/_easyjson/d" cover.out && \ + GOPATH=$(GOPATH) $(GO) tool cover -html=cover.out -o coverage.html + +%_easyjson.go: %.go + PATH=$(shell dirname $(GO)):$(PATH) GOPATH=$(GOPATH) ./vendor/bin/easyjson -all $*.go + +common: \ + src/signaling/api_signaling_easyjson.go \ + src/signaling/api_backend_easyjson.go \ + src/signaling/natsclient_easyjson.go \ + src/signaling/room_easyjson.go + +client: dependencies common + mkdir -p $(BINDIR) + GOPATH=$(GOPATH) $(GO) build $(BUILDARGS) -ldflags '$(INTERNALLDFLAGS)' -o $(BINDIR)/client ./src/client/... + +server: dependencies common + mkdir -p $(BINDIR) + GOPATH=$(GOPATH) $(GO) build $(BUILDARGS) -ldflags '$(INTERNALLDFLAGS)' -o $(BINDIR)/signaling ./src/server/... + +clean: + rm -f src/signaling/*_easyjson.go + rm -f src/signaling/continentmap.go + +build: server + +tarball: + git archive \ + --prefix=nextcloud-spreed-signaling-$(TARVERSION)/ \ + -o nextcloud-spreed-signaling-$(TARVERSION).tar.gz \ + HEAD + +dist: tarball + +.PHONY: src/signaling/continentmap.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..318df06 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# Spreed standalone signaling server + +This repository contains the standalone signaling server which can be used for +Nextcloud Talk (https://apps.nextcloud.com/apps/spreed). + +See https://github.com/nextcloud/spreed/wiki/Spreed-Signaling-API for further +information on the API of the signaling server. + + +## Building + +You will need at least go 1.6 to build the signaling server. All other +dependencies are fetched automatically while building. + + $ make build + +Afterwards the binary is created as `bin/signaling`. + + +## Configuration + +A default configuration file is included as `server.conf.in`. Copy this to +`server.conf` and adjust as necessary for the local setup. See the file for +comments about the different parameters that can be changed. + + +## Running + +The signaling server connects to a NATS server (https://nats.io/) to distribute +messages between different instances. See the NATS documentation on how to set +up a server and run it. + +Once the NATS server is running (and the URL to it is configured for the +signaling server), you can start the signaling server. + + $ ./bin/signaling + +By default, the configuration is loaded from `server.conf` in the current +directory, but a different path can be passed through the `--config` option. + + $ ./bin/signaling --config /etc/signaling/server.conf + + +## Setup of NATS server + +There is a detailed description on how to install and run the NATS server +available at http://nats.io/documentation/tutorials/gnatsd-install/ + +You can use the `gnatsd.conf` file as base for the configuration of the NATS +server. + + +## Setup of Janus + +A Janus server (from https://github.com/meetecho/janus-gateway) can be used to +act as a WebRTC gateway. See the documentation of Janus on how to configure and +run the server. At least the `VideoRoom` plugin and the websocket transport of +Janus must be enabled. + +The signaling server uses the `VideoRoom` plugin of Janus to manage sessions. +All gateway details are hidden from the clients, all messages are sent through +the signaling server. Only WebRTC media is exchanged directly between the +gateway and the clients. + +Edit the `server.conf` and enter the URL to the websocket endpoint of Janus in +the section `[mcu]` and key `url`. During startup, the signaling server will +connect to Janus and log information of the gateway. + +The maximum bandwidth per publishing stream can also be configured in the +section `[mcu]`, see properties `maxstreambitrate` and `maxscreenbitrate`. + + +## Setup of frontend webserver + +Usually the standalone signaling server is running behind a webserver that does +the SSL protocol or acts as a load balancer for multiple signaling servers. + +The configuration examples below assume a pre-configured webserver (nginx or +Apache) with a working HTTPS setup, that is listening on the external interface +of the server hosting the standalone signaling server. + +After everything has been set up, the configuration can be tested using `curl`: + + $ curl -i https://myserver.domain.invalid/standalone-signaling/api/v1/welcome + HTTP/1.1 200 OK + Date: Thu, 05 Jul 2018 09:28:08 GMT + Server: nextcloud-spreed-signaling/1.0.0 + Content-Type: application/json; charset=utf-8 + Content-Length: 59 + + {"nextcloud-spreed-signaling":"Welcome","version":"1.0.0"} + + +### nginx + +Nginx can be used as frontend for the standalone signaling server without any +additional requirements. + +The backend should be configured separately so it can be changed in a single +location and also to allow using multiple backends from a single frontend +server. + +Assuming the standalone signaling server is running on the local interface on +port `8080` below, add the following block to the nginx server definition in +`/etc/nginx/sites-enabled` (just before the `server` definition): + + upstream signaling { + server 127.0.0.1:8080; + } + +To proxy all requests for the standalone signaling to the correct backend, the +following `location` block must be added inside the `server` definition of +the same file: + + location /standalone-signaling/ { + proxy_pass http://signaling/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /standalone-signaling/spreed { + proxy_pass http://signaling/spreed; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + +Example (e.g. `/etc/nginx/sites-enabled/default`): + + upstream signaling { + server 127.0.0.1:8080; + } + + server { + listen 443 ssl http2; + server_name myserver.domain.invalid; + + # ... other existing configuration ... + + location /standalone-signaling/ { + proxy_pass http://signaling/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /standalone-signaling/spreed { + proxy_pass http://signaling/spreed; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } + + +### Apache + +To configure the Apache webservice as frontend for the standalone signaling +server, the modules `mod_proxy_http` and `mod_proxy_wstunnel` must be enabled +so WebSocket and API backend requests can be proxied: + + $ sudo a2enmod proxy + $ sudo a2enmod proxy_http + $ sudo a2enmod proxy_wstunnel + +Now the Apache `VirtualHost` configuration can be extended to forward requests +to the standalone signaling server (assuming the server is running on the local +interface on port `8080` below): + + + + # ... existing configuration ... + + # Enable proxying Websocket requests to the standalone signaling server. + ProxyPass "/standalone-signaling/" "ws://127.0.0.1:8080/" + + RewriteEngine On + # Websocket connections from the clients. + RewriteRule ^/standalone-signaling/spreed$ - [L] + # Backend connections from Nextcloud. + RewriteRule ^/standalone-signaling/api/(.*) http://127.0.0.1:8080/api/$1 [L,P] + + # ... existing configuration ... + + + + +## Setup of Nextcloud Talk + +Login to your Nextcloud as admin and open the additional settings page. Scroll +down to the "Talk" section and enter the base URL of your standalone signaling +server in the field "External signaling server". +Please note that you have to use `https` if your Nextcloud is also running on +`https`. Usually you should enter `https://myhostname/standalone-signaling` as +URL. + +The value "Shared secret for external signaling server" must be the same as the +property `secret` in section `backend` of your `server.conf`. + +If you are using a self-signed certificate for development, you need to uncheck +the box `Validate SSL certificate` so backend requests from Nextcloud to the +signaling server can be performed. + + +## Benchmarking the server + +A simple client exists to benchmark the server. Please note that the features +that are benchmarked might not cover the whole functionality, check the +implementation in `src/client` for details on the client. + +To authenticate new client connections to the signaling server, the client +starts a dummy authentication handler on a local interface and passes the URL +in the `hello` request. Therefore the signaling server should be configured to +allow all backend hosts (option `allowall` in section `backend`). + +The client is not compiled by default, but can be using the `client` target: + + $ make client + +Usage: + + $ ./bin/client + Usage of ./bin/client: + -addr string + http service address (default "localhost:28080") + -config string + config file to use (default "server.conf") + -maxClients int + number of client connections (default 100) + + +## Running multiple signaling servers + +IMPORTANT: This is considered experimental and might not work with all +functionality of the signaling server, especially when using the Janus +integration. + +The signaling server uses the NATS server to send messages to peers that are +not connected locally. Therefore multiple signaling servers running on different +hosts can use the same NATS server to build a simple cluster, allowing more +simultaneous connections and distribute the load. + +To set this up, make sure all signaling servers are using the same settings for +their `session` keys and the `secret` in the `backend` section. Also the URL to +the NATS server (option `url` in section `nats`) must point to the same NATS +server. + +If all this is setup correctly, clients can connect to either of the signaling +servers and exchange messages between them. diff --git a/dependencies.tsv b/dependencies.tsv new file mode 100644 index 0000000..ae64cf9 --- /dev/null +++ b/dependencies.tsv @@ -0,0 +1,12 @@ +github.com/dlintw/goconf git dcc070983490608a14480e3bf943bad464785df5 2012-02-28T08:26:10Z +github.com/gorilla/context git 08b5f424b9271eedf6f9f0ce86cb9396ed337a42 2016-08-17T18:46:32Z +github.com/gorilla/mux git ac112f7d75a0714af1bd86ab17749b31f7809640 2017-07-04T07:43:45Z +github.com/gorilla/securecookie git e59506cc896acb7f7bf732d4fdf5e25f7ccd8983 2017-02-24T19:38:04Z +github.com/gorilla/websocket git ea4d1f681babbce9545c9c5f3d5194a789c89f5b 2017-06-20T19:01:03Z +github.com/mailru/easyjson git 2f5df55504ebc322e4d52d34df6a1f5b503bf26d 2017-06-24T19:09:25Z +github.com/nats-io/go-nats git d4ca4c8b588d5da9c2ac82d6e445ce4feaba18ba 2017-06-01T15:47:09Z +github.com/nats-io/nuid git 3cf34f9fca4e88afa9da8eabd75e3326c9941b44 2017-03-03T15:02:24Z +github.com/notedit/janus-go git 8e6e2c423c03884d938d84442d37d6f6f5294197 2017-06-11T06:05:37Z +github.com/oschwald/maxminddb-golang git 1960b16a5147df3a4c61ac83b2f31cd8f811d609 2019-05-23T23:57:38Z +golang.org/x/net git f01ecb60fe3835d80d9a0b7b2bf24b228c89260e 2017-07-11T18:12:19Z +golang.org/x/sys git ac767d655b305d4e9612f5f6e33120b9176c4ad4 2018-07-15T08:55:29Z diff --git a/gnatsd.conf b/gnatsd.conf new file mode 100644 index 0000000..4916b07 --- /dev/null +++ b/gnatsd.conf @@ -0,0 +1,10 @@ +cluster { + + port: 4244 # port for inbound route connections + + routes = [ + # You can add other servers here to build up a cluster. + #nats-route://otherserver:4244 + ] + +} diff --git a/scripts/get-version.sh b/scripts/get-version.sh new file mode 100755 index 0000000..409941d --- /dev/null +++ b/scripts/get-version.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e +ROOT="$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)" + +VERSION= +if [ -s "$ROOT/../version.txt" ]; then + VERSION=$(cat "$ROOT/../version.txt" | tr -d '[:space:]') +fi +if [ -z $VERSION ] && [ -d "$ROOT/../.git" ]; then + if [ "$1" == "--tar" ]; then + VERSION=$(git describe --dirty --tags --always | sed 's/debian\///g') + else + VERSION=$(git log -1 --pretty=%H) + fi +fi + +if [ -z $VERSION ]; then + VERSION=unknown +fi + +echo $VERSION diff --git a/scripts/get_continent_map.py b/scripts/get_continent_map.py new file mode 100755 index 0000000..9a1d4c5 --- /dev/null +++ b/scripts/get_continent_map.py @@ -0,0 +1,91 @@ +#!/usr/bin/python +# +# Standalone signaling server for the Nextcloud Spreed app. +# Copyright (C) 2019 struktur AG +# +# @author Joachim Bauch +# +# @license GNU AGPL version 3 or any later version +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +try: + # Fallback for Python2 + from cStringIO import StringIO +except ImportError: + from io import StringIO +import json +import subprocess +import sys + +URL = 'https://datahub.io/JohnSnowLabs/country-and-continent-codes-list/r/country-and-continent-codes-list-csv.json' + +def tostr(s): + if isinstance(s, bytes) and not isinstance(s, str): + s = s.decode('utf-8') + return s + +try: + unicode +except NameError: + # Python 3 files are returning bytes by default. + def opentextfile(filename, mode): + if 'b' in mode: + mode = mode.replace('b', '') + return open(filename, mode, encoding='utf-8') +else: + def opentextfile(filename, mode): + return open(filename, mode) + +def generate_map(filename): + data = subprocess.check_output([ + '/usr/bin/curl', + '-L', + URL, + ]) + data = json.loads(tostr(data)) + continents = {} + for entry in data: + country = entry['Two_Letter_Country_Code'] + continent = entry['Continent_Code'] + continents.setdefault(country, []).append(continent) + + out = StringIO() + out.write('package signaling\n') + out.write('\n') + out.write('// This file has been automatically generated, do not modify.\n') + out.write('\n') + out.write('var (\n') + out.write('\tContinentMap map[string][]string = map[string][]string{\n') + for country, continents in sorted(continents.items()): + value = [] + for continent in continents: + value.append('"%s"' % (continent)) + out.write('\t\t"%s": []string{%s},\n' % (country, ', '.join(value))) + out.write('\t}\n') + out.write(')\n') + with opentextfile(filename, 'wb') as fp: + fp.write(out.getvalue()) + +def main(): + if len(sys.argv) != 2: + sys.stderr.write('USAGE: %s \n' % (sys.argv[0])) + sys.exit(1) + + filename = sys.argv[1] + generate_map(filename) + +if __name__ == '__main__': + main() diff --git a/scripts/pre-commit.hook b/scripts/pre-commit.hook new file mode 100755 index 0000000..ce69bbe --- /dev/null +++ b/scripts/pre-commit.hook @@ -0,0 +1,29 @@ +#!/bin/sh +# +# Check that Go files have been formatted +# + +for file in `git diff-index --cached --name-only HEAD --diff-filter=ACMR| grep "\.go$"` ; do + echo "Checking ${file} ..." + # nf is the temporary checkout. This makes sure we check against the + # revision in the index (and not the checked out version). + nf=`git checkout-index --temp "${file}" | cut -f 1` + newfile=`mktemp "/tmp/${nf}.XXXXXX"` || exit 1 + gofmt ${nf} > "${newfile}" 2>> /dev/null + diff -u -p "${nf}" "${newfile}" + r=$? + rm "${newfile}" + rm "${nf}" + if [ $r != 0 ] ; then +echo "=================================================================================================" +echo " Code format error in: $file " +echo " " +echo " Please fix before committing. Don't forget to run git add before trying to commit again. " +echo " If the whole file is to be committed, this should work (run from the top-level directory): " +echo " " +echo " go fmt $file; git add $file; git commit" +echo " " +echo "=================================================================================================" + exit 1 + fi +done diff --git a/server.conf.in b/server.conf.in new file mode 100644 index 0000000..fe91c1e --- /dev/null +++ b/server.conf.in @@ -0,0 +1,112 @@ +[http] +# IP and port to listen on for HTTP requests. +# Comment line to disable the listener. +#listen = 127.0.0.1:8080 + +# HTTP socket read timeout in seconds. +#readtimeout = 15 + +# HTTP socket write timeout in seconds. +#writetimeout = 15 + +[https] +# IP and port to listen on for HTTPS requests. +# Comment line to disable the listener. +#listen = 127.0.0.1:8443 + +# HTTPS socket read timeout in seconds. +#readtimeout = 15 + +# HTTPS socket write timeout in seconds. +#writetimeout = 15 + +# Certificate / private key to use for the HTTPS server. +certificate = /etc/nginx/ssl/server.crt +key = /etc/nginx/ssl/server.key + +[app] +# Set to "true" to install pprof debug handlers. +# See "https://golang.org/pkg/net/http/pprof/" for further information. +debug = false + +[sessions] +# Secret value used to generate checksums of sessions. This should be a random +# string of 32 or 64 bytes. +hashkey = the-secret-for-session-checksums + +# Optional key for encrypting data in the sessions. Must be either 16, 24 or +# 32 bytes. +# If no key is specified, data will not be encrypted (not recommended). +blockkey = -encryption-key- + +[clients] +# Shared secret for connections from internal clients. This must be the same +# value as configured in the respective internal services. +internalsecret = the-shared-secret-for-internal-clients + +[backend] +# Comma-separated list of hostnames that are allowed to be used as backend +# endpoints. +allowed = nextcloud.domain.invalid + +# Allow any hostname as backend endpoint. This is extremely insecure and should +# only be used while running the benchmark client against the server. +allowall = false + +# Shared secret for requests from and to the backend servers. This must be the +# same value as configured in the Nextcloud admin ui. +secret = the-shared-secret + +# Timeout in seconds for requests to the backend. +timeout = 10 + +# Maximum number of concurrent backend connections per host. +connectionsperhost = 8 + +# If set to "true", certificate validation of backend endpoints will be skipped. +# This should only be enabled during development, e.g. to work with self-signed +# certificates. +#skipverify = false + +[nats] +# Url of NATS backend to use. This can also be a list of URLs to connect to +# multiple backends. For local development, this can be set to ":loopback:" +# to process NATS messages internally instead of sending them through an +# external NATS backend. +#url = nats://localhost:4222 + +[mcu] +# The type of the MCU to use. Currently only "janus" is supported. +type = janus + +# The URL to the websocket endpoint of the MCU server. Leave empty to disable +# MCU functionality. +url = + +# The maximum bitrate per publishing stream (in bits per second). +# Defaults to 1 mbit/sec. +#maxstreambitrate = 1048576 + +# The maximum bitrate per screensharing stream (in bits per second). +# Default is 2 mbit/sec. +#maxscreenbitrate = 2097152 + +[turn] +# API key that the MCU will need to send when requesting TURN credentials. +#apikey = the-api-key-for-the-rest-service + +# The shared secret to use for generating TURN credentials. This must be the +# same as on the TURN server. +#secret = 6d1c17a7-c736-4e22-b02c-e2955b7ecc64 + +# A comma-separated list of TURN servers to use. Leave empty to disable the +# TURN REST API. +#servers = turn:1.2.3.4:9991?transport=udp,turn:1.2.3.4:9991?transport=tcp + +[geoip] +# License key to use when downloading the MaxMind GeoIP database. You can +# register an account at "https://www.maxmind.com/en/geolite2/signup" for +# free. See "https://dev.maxmind.com/geoip/geoip2/geolite2/"" for further +# information. +# Leave empty to disable GeoIP lookups. +#license = diff --git a/src/client/main.go b/src/client/main.go new file mode 100644 index 0000000..9dcb691 --- /dev/null +++ b/src/client/main.go @@ -0,0 +1,629 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + pseudorand "math/rand" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/dlintw/goconf" + "github.com/gorilla/mux" + "github.com/gorilla/securecookie" + "github.com/gorilla/websocket" + "github.com/mailru/easyjson" + + "signaling" +) + +var ( + addr = flag.String("addr", "localhost:28080", "http service address") + + config = flag.String("config", "server.conf", "config file to use") + + maxClients = flag.Int("maxClients", 100, "number of client connections") + + backendSecret []byte + + // Report messages that took more than 1 second. + messageReportDuration = 1000 * time.Millisecond +) + +const ( + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 + + // Maximum message size allowed from peer. + maxMessageSize = 64 * 1024 + + privateSessionName = "private-session" + publicSessionName = "public-session" +) + +type Stats struct { + numRecvMessages uint64 + numSentMessages uint64 + resetRecvMessages uint64 + resetSentMessages uint64 + + start time.Time +} + +func (s *Stats) reset(start time.Time) { + s.resetRecvMessages = atomic.AddUint64(&s.numRecvMessages, 0) + s.resetSentMessages = atomic.AddUint64(&s.numSentMessages, 0) + s.start = start +} + +func (s *Stats) Log() { + now := time.Now() + duration := now.Sub(s.start) + perSec := uint64(duration / time.Second) + if perSec == 0 { + return + } + + totalSentMessages := atomic.AddUint64(&s.numSentMessages, 0) + sentMessages := totalSentMessages - s.resetSentMessages + totalRecvMessages := atomic.AddUint64(&s.numRecvMessages, 0) + recvMessages := totalRecvMessages - s.resetRecvMessages + log.Printf("Stats: sent=%d (%d/sec), recv=%d (%d/sec), delta=%d\n", + totalSentMessages, sentMessages/perSec, + totalRecvMessages, recvMessages/perSec, + totalSentMessages-totalRecvMessages) + s.reset(now) +} + +type MessagePayload struct { + Now time.Time `json:"now"` +} + +type SignalingClient struct { + ready_wg *sync.WaitGroup + cookie *securecookie.SecureCookie + + conn *websocket.Conn + + stats *Stats + closed uint32 + + stopChan chan bool + + lock sync.Mutex + privateSessionId string + publicSessionId string + userId string +} + +func NewSignalingClient(cookie *securecookie.SecureCookie, url string, stats *Stats, ready_wg *sync.WaitGroup, done_wg *sync.WaitGroup) (*SignalingClient, error) { + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return nil, err + } + + client := &SignalingClient{ + ready_wg: ready_wg, + cookie: cookie, + + conn: conn, + + stats: stats, + + stopChan: make(chan bool), + } + done_wg.Add(2) + go func() { + defer done_wg.Done() + client.readPump() + }() + go func() { + defer done_wg.Done() + client.writePump() + }() + return client, nil +} + +func (c *SignalingClient) Close() { + if !atomic.CompareAndSwapUint32(&c.closed, 0, 1) { + return + } + + // Signal writepump to terminate + select { + case c.stopChan <- true: + default: + } + + c.lock.Lock() + c.publicSessionId = "" + c.privateSessionId = "" + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + c.conn.Close() + c.conn = nil + c.lock.Unlock() +} + +func (c *SignalingClient) Send(message *signaling.ClientMessage) { + c.lock.Lock() + if c.conn == nil { + c.lock.Unlock() + return + } + + if !c.writeInternal(message) { + c.lock.Unlock() + c.Close() + return + } + c.lock.Unlock() +} + +func (c *SignalingClient) processMessage(message *signaling.ServerMessage) { + atomic.AddUint64(&c.stats.numRecvMessages, 1) + switch message.Type { + case "hello": + c.processHelloMessage(message) + case "message": + c.processMessageMessage(message) + case "bye": + log.Printf("Received bye: %+v\n", message.Bye) + c.Close() + default: + log.Printf("Unsupported message type: %+v\n", *message) + } +} + +func (c *SignalingClient) privateToPublicSessionId(privateId string) string { + var data signaling.SessionIdData + if err := c.cookie.Decode(privateSessionName, privateId, &data); err != nil { + panic(fmt.Sprintf("could not decode private session id: %s", err)) + } + encoded, err := c.cookie.Encode(publicSessionName, data) + if err != nil { + panic(fmt.Sprintf("could not encode public id: %s", err)) + } + reversed, err := reverseSessionId(encoded) + if err != nil { + panic(fmt.Sprintf("could not reverse session id: %s", err)) + } + return reversed +} + +func (c *SignalingClient) processHelloMessage(message *signaling.ServerMessage) { + c.lock.Lock() + defer c.lock.Unlock() + c.privateSessionId = message.Hello.ResumeId + c.publicSessionId = c.privateToPublicSessionId(c.privateSessionId) + c.userId = message.Hello.UserId + log.Printf("Registered as %s (userid %s)\n", c.privateSessionId, c.userId) + c.ready_wg.Done() +} + +func (c *SignalingClient) PublicSessionId() string { + c.lock.Lock() + defer c.lock.Unlock() + return c.publicSessionId +} + +func (c *SignalingClient) processMessageMessage(message *signaling.ServerMessage) { + var msg MessagePayload + if err := json.Unmarshal(*message.Message.Data, &msg); err != nil { + log.Println("Error in unmarshal", err) + return + } + + now := time.Now() + duration := now.Sub(msg.Now) + if duration > messageReportDuration { + log.Printf("Message took %s\n", duration) + } +} + +func (c *SignalingClient) readPump() { + conn := c.conn + + defer func() { + conn.Close() + }() + + conn.SetReadLimit(maxMessageSize) + conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + var decodeBuffer bytes.Buffer + for { + conn.SetReadDeadline(time.Now().Add(pongWait)) + messageType, reader, err := conn.NextReader() + if err != nil { + if websocket.IsUnexpectedCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseNoStatusReceived) { + log.Printf("Error: %v\n", err) + } + break + } + + if messageType != websocket.TextMessage { + log.Println("Unsupported message type", messageType) + break + } + + decodeBuffer.Reset() + if _, err := decodeBuffer.ReadFrom(reader); err != nil { + c.lock.Lock() + if c.conn != nil { + log.Println("Error reading message", err) + } + c.lock.Unlock() + break + } + + var message signaling.ServerMessage + if err := message.UnmarshalJSON(decodeBuffer.Bytes()); err != nil { + log.Printf("Error: %v\n", err) + break + } + + c.processMessage(&message) + } +} + +func (c *SignalingClient) writeInternal(message *signaling.ClientMessage) bool { + var closeData []byte + + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + writer, err := c.conn.NextWriter(websocket.TextMessage) + if err == nil { + _, err = easyjson.MarshalToWriter(message, writer) + } + if err != nil { + if err == websocket.ErrCloseSent { + // Already sent a "close", won't be able to send anything else. + return false + } + + log.Println("Could not send message", message, err) + // TODO(jojo): Differentiate between JSON encode errors and websocket errors. + closeData = websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "") + goto close + } + + writer.Close() + atomic.AddUint64(&c.stats.numSentMessages, 1) + return true + +close: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + c.conn.WriteMessage(websocket.CloseMessage, closeData) + return false +} + +func (c *SignalingClient) sendPing() bool { + c.lock.Lock() + defer c.lock.Unlock() + if c.conn == nil { + return false + } + + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil { + return false + } + + return true +} + +func (c *SignalingClient) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.Close() + }() + + for { + select { + case <-ticker.C: + if !c.sendPing() { + return + } + case <-c.stopChan: + return + } + } +} + +func (c *SignalingClient) SendMessages(clients []*SignalingClient) { + session_ids := make(map[*SignalingClient]string) + for _, c := range clients { + session_ids[c] = c.PublicSessionId() + } + + for atomic.LoadUint32(&c.closed) == 0 { + now := time.Now() + + sender := c + recipient_idx := pseudorand.Int() % len(clients) + // Make sure a client is not sending to himself + for clients[recipient_idx] == sender { + recipient_idx = pseudorand.Int() % len(clients) + } + recipient := clients[recipient_idx] + msgdata := MessagePayload{ + Now: now, + } + data, _ := json.Marshal(msgdata) + msg := &signaling.ClientMessage{ + Type: "message", + Message: &signaling.MessageClientMessage{ + Recipient: signaling.MessageClientMessageRecipient{ + Type: "session", + SessionId: session_ids[recipient], + }, + Data: (*json.RawMessage)(&data), + }, + } + sender.Send(msg) + // Give some time to other clients. + time.Sleep(1 * time.Millisecond) + } +} + +func registerAuthHandler(router *mux.Router) { + router.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + log.Println("Error reading body:", err) + return + } + + rnd := r.Header.Get(signaling.HeaderBackendSignalingRandom) + checksum := r.Header.Get(signaling.HeaderBackendSignalingChecksum) + if rnd == "" || checksum == "" { + log.Println("No checksum headers found") + return + } + + if verify := signaling.CalculateBackendChecksum(rnd, body, backendSecret); verify != checksum { + log.Println("Backend checksum verification failed") + return + } + + var request signaling.BackendClientRequest + if err := request.UnmarshalJSON(body); err != nil { + log.Println(err) + return + } + + response := &signaling.BackendClientResponse{ + Type: "auth", + Auth: &signaling.BackendClientAuthResponse{ + Version: signaling.BackendVersion, + UserId: "sample-user", + }, + } + + data, err := response.MarshalJSON() + if err != nil { + log.Println(err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) + }) +} + +func getLocalIP() string { + interfaces, err := net.InterfaceAddrs() + if err != nil { + log.Fatal(err) + } + for _, intf := range interfaces { + switch t := intf.(type) { + case *net.IPNet: + if !t.IP.IsInterfaceLocalMulticast() && !t.IP.IsLoopback() { + return t.IP.String() + } + } + } + return "" +} + +func reverseSessionId(s string) (string, error) { + // Note that we are assuming base64 encoded strings here. + decoded, err := base64.URLEncoding.DecodeString(s) + if err != nil { + return "", err + } + + for i, j := 0, len(decoded)-1; i < j; i, j = i+1, j-1 { + decoded[i], decoded[j] = decoded[j], decoded[i] + } + return base64.URLEncoding.EncodeToString(decoded), nil +} + +func main() { + flag.Parse() + log.SetFlags(0) + + config, err := goconf.ReadConfigFile(*config) + if err != nil { + log.Fatal("Could not read configuration: ", err) + } + + secret, _ := config.GetString("backend", "secret") + backendSecret = []byte(secret) + + hashKey, _ := config.GetString("sessions", "hashkey") + switch len(hashKey) { + case 32: + case 64: + default: + log.Printf("WARNING: The sessions hash key should be 32 or 64 bytes but is %d bytes\n", len(hashKey)) + } + + blockKey, _ := config.GetString("sessions", "blockkey") + blockBytes := []byte(blockKey) + switch len(blockKey) { + case 0: + blockBytes = nil + case 16: + case 24: + case 32: + default: + log.Fatalf("The sessions block key must be 16, 24 or 32 bytes but is %d bytes", len(blockKey)) + } + cookie := securecookie.New([]byte(hashKey), blockBytes).MaxAge(0) + + cpus := runtime.NumCPU() + runtime.GOMAXPROCS(cpus) + log.Printf("Using a maximum of %d CPUs\n", cpus) + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + r := mux.NewRouter() + registerAuthHandler(r) + + localIP := getLocalIP() + listener, err := net.Listen("tcp", localIP+":0") + if err != nil { + log.Fatal(err) + } + + server := http.Server{ + Handler: r, + } + go func() { + server.Serve(listener) + }() + backendUrl := "http://" + listener.Addr().String() + log.Println("Backend server running on", backendUrl) + + urls := make([]url.URL, 0) + urlstrings := make([]string, 0) + for _, host := range strings.Split(*addr, ",") { + u := url.URL{ + Scheme: "ws", + Host: host, + Path: "/spreed", + } + urls = append(urls, u) + urlstrings = append(urlstrings, u.String()) + } + log.Printf("Connecting to %s\n", urlstrings) + + clients := make([]*SignalingClient, 0) + stats := &Stats{} + + if *maxClients < 2 { + log.Fatalf("Need at least 2 clients, got %d\n", *maxClients) + } + + log.Printf("Starting %d clients\n", *maxClients) + + var done_wg sync.WaitGroup + var ready_wg sync.WaitGroup + + for i := 0; i < *maxClients; i++ { + client, err := NewSignalingClient(cookie, urls[i%len(urls)].String(), stats, &ready_wg, &done_wg) + if err != nil { + log.Fatal(err) + } + defer client.Close() + ready_wg.Add(1) + + request := &signaling.ClientMessage{ + Type: "hello", + Hello: &signaling.HelloClientMessage{ + Version: signaling.HelloVersion, + Auth: signaling.HelloClientMessageAuth{ + Url: backendUrl + "/auth", + Params: &json.RawMessage{'{', '}'}, + }, + }, + } + + client.Send(request) + clients = append(clients, client) + } + + log.Println("Clients created") + ready_wg.Wait() + + log.Println("All connections established") + + for _, c := range clients { + done_wg.Add(1) + go func(c *SignalingClient) { + defer done_wg.Done() + c.SendMessages(clients) + }(c) + } + + stats.start = time.Now() + reportInterval := 10 * time.Second + report := time.NewTicker(reportInterval) +loop: + for { + select { + case <-interrupt: + log.Println("Interrupted") + break loop + case <-report.C: + stats.Log() + } + } + + log.Println("Waiting for clients to terminate ...") + for _, c := range clients { + c.Close() + } + done_wg.Wait() +} diff --git a/src/server/main.go b/src/server/main.go new file mode 100644 index 0000000..48b1b44 --- /dev/null +++ b/src/server/main.go @@ -0,0 +1,295 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package main + +import ( + "crypto/tls" + "flag" + "fmt" + "log" + "net" + "net/http" + "net/http/pprof" + "os" + "os/signal" + "runtime" + runtimepprof "runtime/pprof" + "strings" + "time" + + "github.com/dlintw/goconf" + _ "github.com/gorilla/context" + "github.com/gorilla/mux" + "github.com/nats-io/go-nats" + + "signaling" +) + +var ( + version = "unreleased" + + config = flag.String("config", "server.conf", "config file to use") + + cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") + + memprofile = flag.String("memprofile", "", "write memory profile to file") + + showVersion = flag.Bool("version", false, "show version and quit") +) + +const ( + defaultReadTimeout = 15 + defaultWriteTimeout = 15 + + initialMcuRetry = time.Second + maxMcuRetry = time.Second * 16 +) + +func createListener(addr string) (net.Listener, error) { + if addr[0] == '/' { + os.Remove(addr) + return net.Listen("unix", addr) + } else { + return net.Listen("tcp", addr) + } +} + +func createTLSListener(addr string, certFile, keyFile string) (net.Listener, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + config := tls.Config{ + Certificates: []tls.Certificate{cert}, + } + if addr[0] == '/' { + os.Remove(addr) + return tls.Listen("unix", addr, &config) + } else { + return tls.Listen("tcp", addr, &config) + } +} + +func main() { + log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile) + flag.Parse() + + if *showVersion { + fmt.Printf("nextcloud-spreed-signaling version %s/%s\n", version, runtime.Version()) + os.Exit(0) + } + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + if *cpuprofile != "" { + f, err := os.Create(*cpuprofile) + if err != nil { + log.Fatal(err) + } + + log.Printf("Writing CPU profile to %s ...\n", *cpuprofile) + runtimepprof.StartCPUProfile(f) + defer runtimepprof.StopCPUProfile() + } + + if *memprofile != "" { + f, err := os.Create(*memprofile) + if err != nil { + log.Fatal(err) + } + + defer func() { + log.Printf("Writing Memory profile to %s ...\n", *memprofile) + runtime.GC() + runtimepprof.WriteHeapProfile(f) + }() + } + + log.Printf("Starting up version %s/%s as pid %d", version, runtime.Version(), os.Getpid()) + + config, err := goconf.ReadConfigFile(*config) + if err != nil { + log.Fatal("Could not read configuration: ", err) + } + + cpus := runtime.NumCPU() + runtime.GOMAXPROCS(cpus) + log.Printf("Using a maximum of %d CPUs\n", cpus) + + natsUrl, _ := config.GetString("nats", "url") + if natsUrl == "" { + natsUrl = nats.DefaultURL + } + + nats, err := signaling.NewNatsClient(natsUrl) + if err != nil { + log.Fatal("Could not create NATS client: ", err) + } + + r := mux.NewRouter() + hub, err := signaling.NewHub(config, nats, r, version) + if err != nil { + log.Fatal("Could not create hub: ", err) + } + + mcuUrl, _ := config.GetString("mcu", "url") + if mcuUrl != "" { + mcuType, _ := config.GetString("mcu", "type") + if mcuType == "" { + mcuType = signaling.McuTypeDefault + } + + var mcu signaling.Mcu + mcuRetry := initialMcuRetry + mcuRetryTimer := time.NewTimer(mcuRetry) + for { + switch mcuType { + case signaling.McuTypeJanus: + mcu, err = signaling.NewMcuJanus(mcuUrl, config, nats) + default: + log.Fatal("Unsupported MCU type: ", mcuType) + } + if err == nil { + err = mcu.Start() + if err != nil { + log.Printf("Could not create %s MCU at %s: %s", mcuType, mcuUrl, err) + } + } + if err == nil { + break + } + + log.Printf("Could not initialize %s MCU at %s (%s) will retry in %s", mcuType, mcuUrl, err, mcuRetry) + mcuRetryTimer.Reset(mcuRetry) + select { + case <-interrupt: + log.Fatalf("Cancelled") + case <-mcuRetryTimer.C: + // Retry connection + mcuRetry = mcuRetry * 2 + if mcuRetry > maxMcuRetry { + mcuRetry = maxMcuRetry + } + } + } + defer mcu.Stop() + + log.Printf("Using MCU %s at %s\n", mcuType, mcuUrl) + hub.SetMcu(mcu) + } + + go hub.Run() + defer hub.Stop() + + server, err := signaling.NewBackendServer(config, hub, version) + if err != nil { + log.Fatal("Could not create backend server: ", err) + } + if err := server.Start(r); err != nil { + log.Fatal("Could not start backend server: ", err) + } + + if debug, _ := config.GetBool("app", "debug"); debug { + log.Println("Installing debug handlers in \"/debug/pprof\"") + r.Handle("/debug/pprof/", http.HandlerFunc(pprof.Index)) + r.Handle("/debug/pprof/cmdline", http.HandlerFunc(pprof.Cmdline)) + r.Handle("/debug/pprof/profile", http.HandlerFunc(pprof.Profile)) + r.Handle("/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol)) + r.Handle("/debug/pprof/trace", http.HandlerFunc(pprof.Trace)) + for _, profile := range runtimepprof.Profiles() { + name := profile.Name() + r.Handle("/debug/pprof/"+name, pprof.Handler(name)) + } + } + + if saddr, _ := config.GetString("https", "listen"); saddr != "" { + cert, _ := config.GetString("https", "certificate") + key, _ := config.GetString("https", "key") + if cert == "" || key == "" { + log.Fatal("Need a certificate and key for the HTTPS listener") + } + + readTimeout, _ := config.GetInt("https", "readtimeout") + if readTimeout <= 0 { + readTimeout = defaultReadTimeout + } + writeTimeout, _ := config.GetInt("https", "writetimeout") + if writeTimeout <= 0 { + writeTimeout = defaultWriteTimeout + } + for _, address := range strings.Split(saddr, " ") { + go func(address string) { + log.Println("Listening on", address) + listener, err := createTLSListener(address, cert, key) + if err != nil { + log.Fatal("Could not start listening: ", err) + } + srv := &http.Server{ + Handler: r, + + ReadTimeout: time.Duration(readTimeout) * time.Second, + WriteTimeout: time.Duration(writeTimeout) * time.Second, + } + if err := srv.Serve(listener); err != nil { + log.Fatal("Could not start server: ", err) + } + }(address) + } + } + + if addr, _ := config.GetString("http", "listen"); addr != "" { + readTimeout, _ := config.GetInt("http", "readtimeout") + if readTimeout <= 0 { + readTimeout = defaultReadTimeout + } + writeTimeout, _ := config.GetInt("http", "writetimeout") + if writeTimeout <= 0 { + writeTimeout = defaultWriteTimeout + } + + for _, address := range strings.Split(addr, " ") { + go func(address string) { + log.Println("Listening on", address) + listener, err := createListener(address) + if err != nil { + log.Fatal("Could not start listening: ", err) + } + srv := &http.Server{ + Handler: r, + Addr: addr, + + ReadTimeout: time.Duration(readTimeout) * time.Second, + WriteTimeout: time.Duration(writeTimeout) * time.Second, + } + if err := srv.Serve(listener); err != nil { + log.Fatal("Could not start server: ", err) + } + }(address) + } + } + + select { + case <-interrupt: + log.Println("Interrupted") + } +} diff --git a/src/signaling/api_backend.go b/src/signaling/api_backend.go new file mode 100644 index 0000000..ad22769 --- /dev/null +++ b/src/signaling/api_backend.go @@ -0,0 +1,272 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "net/http" +) + +const ( + BackendVersion = "1.0" + + HeaderBackendSignalingRandom = "Spreed-Signaling-Random" + HeaderBackendSignalingChecksum = "Spreed-Signaling-Checksum" +) + +func newRandomString(length int) string { + b := make([]byte, length/2) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +func CalculateBackendChecksum(random string, body []byte, secret []byte) string { + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(random)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func AddBackendChecksum(r *http.Request, body []byte, secret []byte) { + // Add checksum so the backend can validate the request. + rnd := newRandomString(64) + checksum := CalculateBackendChecksum(rnd, body, secret) + r.Header.Set(HeaderBackendSignalingRandom, rnd) + r.Header.Set(HeaderBackendSignalingChecksum, checksum) +} + +func ValidateBackendChecksum(r *http.Request, body []byte, secret []byte) bool { + rnd := r.Header.Get(HeaderBackendSignalingRandom) + checksum := r.Header.Get(HeaderBackendSignalingChecksum) + return ValidateBackendChecksumValue(checksum, rnd, body, secret) +} + +func ValidateBackendChecksumValue(checksum string, random string, body []byte, secret []byte) bool { + verify := CalculateBackendChecksum(random, body, secret) + return subtle.ConstantTimeCompare([]byte(verify), []byte(checksum)) == 1 +} + +// Requests from Nextcloud to the signaling server. + +type BackendServerRoomRequest struct { + room *Room + + Type string `json:"type"` + + Invite *BackendRoomInviteRequest `json:"invite,omitempty"` + + Disinvite *BackendRoomDisinviteRequest `json:"disinvite,omitempty"` + + Update *BackendRoomUpdateRequest `json:"update,omitempty"` + + Delete *BackendRoomDeleteRequest `json:"delete,omitempty"` + + InCall *BackendRoomInCallRequest `json:"incall,omitempty"` + + Participants *BackendRoomParticipantsRequest `json:"participants,omitempty"` + + Message *BackendRoomMessageRequest `json:"message,omitempty"` + + // Internal properties + ReceivedTime int64 `json:"received,omitempty"` +} + +type BackendRoomInviteRequest struct { + UserIds []string `json:"userids,omitempty"` + // TODO(jojo): We should get rid of "AllUserIds" and find a better way to + // notify existing users the room has changed and they need to update it. + AllUserIds []string `json:"alluserids,omitempty"` + Properties *json.RawMessage `json:"properties,omitempty"` +} + +type BackendRoomDisinviteRequest struct { + UserIds []string `json:"userids,omitempty"` + SessionIds []string `json:"sessionids,omitempty"` + // TODO(jojo): We should get rid of "AllUserIds" and find a better way to + // notify existing users the room has changed and they need to update it. + AllUserIds []string `json:"alluserids,omitempty"` + Properties *json.RawMessage `json:"properties,omitempty"` +} + +type BackendRoomUpdateRequest struct { + UserIds []string `json:"userids,omitempty"` + Properties *json.RawMessage `json:"properties,omitempty"` +} + +type BackendRoomDeleteRequest struct { + UserIds []string `json:"userids,omitempty"` +} + +type BackendRoomInCallRequest struct { + // TODO(jojo): Change "InCall" to "int" when #914 has landed in NC Talk. + InCall json.RawMessage `json:"incall,omitempty"` + Changed []map[string]interface{} `json:"changed,omitempty"` + Users []map[string]interface{} `json:"users,omitempty"` +} + +type BackendRoomParticipantsRequest struct { + Changed []map[string]interface{} `json:"changed,omitempty"` + Users []map[string]interface{} `json:"users,omitempty"` +} + +type BackendRoomMessageRequest struct { + Data *json.RawMessage `json:"data,omitempty"` +} + +// Requests from the signaling server to the Nextcloud backend. + +type BackendClientAuthRequest struct { + Version string `json:"version"` + Params *json.RawMessage `json:"params"` +} + +type BackendClientRequest struct { + Type string `json:"type"` + + Auth *BackendClientAuthRequest `json:"auth,omitempty"` + + Room *BackendClientRoomRequest `json:"room,omitempty"` + + Ping *BackendClientPingRequest `json:"ping,omitempty"` +} + +func NewBackendClientAuthRequest(params *json.RawMessage) *BackendClientRequest { + return &BackendClientRequest{ + Type: "auth", + Auth: &BackendClientAuthRequest{ + Version: BackendVersion, + Params: params, + }, + } +} + +type BackendClientResponse struct { + Type string `json:"type"` + + Error *Error `json:"error,omitempty"` + + Auth *BackendClientAuthResponse `json:"auth,omitempty"` + + Room *BackendClientRoomResponse `json:"room,omitempty"` + + Ping *BackendClientRingResponse `json:"ping,omitempty"` +} + +type BackendClientAuthResponse struct { + Version string `json:"version"` + UserId string `json:"userid"` + User *json.RawMessage `json:"user"` +} + +type BackendClientRoomRequest struct { + Version string `json:"version"` + RoomId string `json:"roomid"` + Action string `json:"action,omitempty"` + UserId string `json:"userid"` + SessionId string `json:"sessionid"` +} + +func NewBackendClientRoomRequest(roomid string, userid string, sessionid string) *BackendClientRequest { + return &BackendClientRequest{ + Type: "room", + Room: &BackendClientRoomRequest{ + Version: BackendVersion, + RoomId: roomid, + UserId: userid, + SessionId: sessionid, + }, + } +} + +type BackendClientRoomResponse struct { + Version string `json:"version"` + RoomId string `json:"roomid"` + Properties *json.RawMessage `json:"properties"` + + // Optional information about the Nextcloud Talk session. Can be used for + // example to define a "userid" for otherwise anonymous users. + // See "RoomSessionData" for a possible content. + Session *json.RawMessage `json:"session,omitempty"` + + Permissions *[]Permission `json:"permissions,omitempty"` +} + +type RoomSessionData struct { + UserId string `json:"userid,omitempty"` +} + +type BackendPingEntry struct { + UserId string `json:"userid,omitempty"` + SessionId string `json:"sessionid"` +} + +type BackendClientPingRequest struct { + Version string `json:"version"` + RoomId string `json:"roomid"` + Entries []BackendPingEntry `json:"entries"` +} + +func NewBackendClientPingRequest(roomid string, entries []BackendPingEntry) *BackendClientRequest { + return &BackendClientRequest{ + Type: "ping", + Ping: &BackendClientPingRequest{ + Version: BackendVersion, + RoomId: roomid, + Entries: entries, + }, + } +} + +type BackendClientRingResponse struct { + Version string `json:"version"` + RoomId string `json:"roomid"` +} + +type OcsMeta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` +} + +type OcsBody struct { + Meta OcsMeta `json:"meta"` + Data *json.RawMessage `json:"data"` +} + +type OcsResponse struct { + Ocs *OcsBody `json:"ocs"` +} + +// See https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 +type TurnCredentials struct { + Username string `json:"username"` + Password string `json:"password"` + TTL int64 `json:"ttl"` + URIs []string `json:"uris"` +} diff --git a/src/signaling/api_backend_test.go b/src/signaling/api_backend_test.go new file mode 100644 index 0000000..cc50e8f --- /dev/null +++ b/src/signaling/api_backend_test.go @@ -0,0 +1,58 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "net/http" + "testing" +) + +func TestBackendChecksum(t *testing.T) { + rnd := newRandomString(32) + body := []byte{1, 2, 3, 4, 5} + secret := []byte("shared-secret") + + check1 := CalculateBackendChecksum(rnd, body, secret) + check2 := CalculateBackendChecksum(rnd, body, secret) + if check1 != check2 { + t.Errorf("Expected equal checksums, got %s and %s", check1, check2) + } + + if !ValidateBackendChecksumValue(check1, rnd, body, secret) { + t.Errorf("Checksum %s could not be validated", check1) + } + if ValidateBackendChecksumValue(check1[1:], rnd, body, secret) { + t.Errorf("Checksum %s should not be valid", check1[1:]) + } + if ValidateBackendChecksumValue(check1[:len(check1)-1], rnd, body, secret) { + t.Errorf("Checksum %s should not be valid", check1[:len(check1)-1]) + } + + request := &http.Request{ + Header: make(http.Header), + } + request.Header.Set("Spreed-Signaling-Random", rnd) + request.Header.Set("Spreed-Signaling-Checksum", check1) + if !ValidateBackendChecksum(request, body, secret) { + t.Errorf("Checksum %s could not be validated from request", check1) + } +} diff --git a/src/signaling/api_signaling.go b/src/signaling/api_signaling.go new file mode 100644 index 0000000..a4af9e0 --- /dev/null +++ b/src/signaling/api_signaling.go @@ -0,0 +1,439 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "encoding/json" + "fmt" + "net/url" +) + +const ( + // Version that must be sent in a "hello" message. + HelloVersion = "1.0" +) + +// ClientMessage is a message that is sent from a client to the server. +type ClientMessage struct { + // The unique request id (optional). + Id string `json:"id,omitempty"` + + // The type of the request. + Type string `json:"type"` + + // Filled for type "hello" + Hello *HelloClientMessage `json:"hello,omitempty"` + + Bye *ByeClientMessage `json:"bye,omitempty"` + + Room *RoomClientMessage `json:"room,omitempty"` + + Message *MessageClientMessage `json:"message,omitempty"` + + Control *ControlClientMessage `json:"control,omitempty"` +} + +func (m *ClientMessage) CheckValid() error { + switch m.Type { + case "": + return fmt.Errorf("type missing") + case "hello": + if m.Hello == nil { + return fmt.Errorf("hello missing") + } else if err := m.Hello.CheckValid(); err != nil { + return err + } + case "bye": + // No additional check required. + case "room": + if m.Room == nil { + return fmt.Errorf("room missing") + } else if err := m.Room.CheckValid(); err != nil { + return err + } + case "message": + if m.Message == nil { + return fmt.Errorf("message missing") + } else if err := m.Message.CheckValid(); err != nil { + return err + } + case "control": + if m.Control == nil { + return fmt.Errorf("control missing") + } else if err := m.Control.CheckValid(); err != nil { + return err + } + } + return nil +} + +func (m *ClientMessage) NewErrorServerMessage(e *Error) *ServerMessage { + return &ServerMessage{ + Id: m.Id, + Type: "error", + Error: e, + } +} + +func (m *ClientMessage) NewWrappedErrorServerMessage(e error) *ServerMessage { + return m.NewErrorServerMessage(NewError("internal_error", e.Error())) +} + +// ServerMessage is a message that is sent from the server to a client. +type ServerMessage struct { + Id string `json:"id,omitempty"` + + Type string `json:"type"` + + Error *Error `json:"error,omitempty"` + + Hello *HelloServerMessage `json:"hello,omitempty"` + + Bye *ByeServerMessage `json:"bye,omitempty"` + + Room *RoomServerMessage `json:"room,omitempty"` + + Message *MessageServerMessage `json:"message,omitempty"` + + Control *ControlServerMessage `json:"control,omitempty"` + + Event *EventServerMessage `json:"event,omitempty"` +} + +func (r *ServerMessage) CloseAfterSend(session Session) bool { + if r.Type == "bye" { + return true + } + + if r.Type == "event" { + if evt := r.Event; evt != nil && evt.Target == "roomlist" && evt.Type == "disinvite" { + // Only close session / connection if the disinvite was for the room + // the session is currently in. + if session != nil && evt.Disinvite != nil { + if room := session.GetRoom(); room != nil && evt.Disinvite.RoomId == room.Id() { + return true + } + } + } + } + + return false +} + +func (r *ServerMessage) IsChatRefresh() bool { + if r.Type != "message" || r.Message == nil || r.Message.Data == nil || len(*r.Message.Data) == 0 { + return false + } + + var data MessageServerMessageData + if err := json.Unmarshal(*r.Message.Data, &data); err != nil { + return false + } + + if data.Type != "chat" || data.Chat == nil { + return false + } + + return data.Chat.Refresh +} + +func (r *ServerMessage) IsParticipantsUpdate() bool { + if r.Type != "event" || r.Event == nil { + return false + } + if event := r.Event; event.Target != "participants" || event.Type != "update" { + return false + } + return true +} + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details interface{} `json:"details,omitempty"` +} + +func NewError(code string, message string) *Error { + return NewErrorDetail(code, message, nil) +} + +func NewErrorDetail(code string, message string, details interface{}) *Error { + return &Error{ + Code: code, + Message: message, + Details: details, + } +} + +func (e *Error) Error() string { + return e.Message +} + +const ( + HelloClientTypeClient = "client" + HelloClientTypeInternal = "internal" +) + +type ClientTypeInternalAuthParams struct { + Random string `json:"random"` + Token string `json:"token"` +} + +type HelloClientMessageAuth struct { + // The client type that is connecting. Leave empty to use the default + // "HelloClientTypeClient" + Type string `json:"type,omitempty"` + + Params *json.RawMessage `json:"params"` + + Url string `json:"url"` + parsedUrl *url.URL + + internalParams ClientTypeInternalAuthParams +} + +// Type "hello" + +type HelloClientMessage struct { + Version string `json:"version"` + + ResumeId string `json:"resumeid"` + + Features []string `json:"features,omitempty"` + + // The authentication credentials. + Auth HelloClientMessageAuth `json:"auth"` +} + +func (m *HelloClientMessage) CheckValid() error { + if m.Version != HelloVersion { + return fmt.Errorf("unsupported hello version: %s", m.Version) + } + if m.ResumeId == "" { + if m.Auth.Params == nil || len(*m.Auth.Params) == 0 { + return fmt.Errorf("params missing") + } + if m.Auth.Type == "" { + m.Auth.Type = HelloClientTypeClient + } + switch m.Auth.Type { + case HelloClientTypeClient: + if m.Auth.Url == "" { + return fmt.Errorf("url missing") + } else if u, err := url.ParseRequestURI(m.Auth.Url); err != nil { + return err + } else { + m.Auth.parsedUrl = u + } + case HelloClientTypeInternal: + if err := json.Unmarshal(*m.Auth.Params, &m.Auth.internalParams); err != nil { + return err + } + default: + return fmt.Errorf("unsupport auth type") + } + } + return nil +} + +const ( + ServerFeatureMcu = "mcu" +) + +type HelloServerMessageServer struct { + Version string `json:"version"` + Features []string `json:"features,omitempty"` +} + +type HelloServerMessage struct { + Version string `json:"version"` + + SessionId string `json:"sessionid"` + ResumeId string `json:"resumeid"` + UserId string `json:"userid"` + Server *HelloServerMessageServer `json:"server,omitempty"` +} + +// Type "bye" + +type ByeClientMessage struct { +} + +func (m *ByeClientMessage) CheckValid() error { + // No additional validation required. + return nil +} + +type ByeServerMessage struct { + Reason string `json:"reason"` +} + +// Type "room" + +type RoomClientMessage struct { + RoomId string `json:"roomid"` + SessionId string `json:"sessionid,omitempty"` +} + +func (m *RoomClientMessage) CheckValid() error { + // No additional validation required. + return nil +} + +type RoomServerMessage struct { + RoomId string `json:"roomid"` + Properties *json.RawMessage `json:"properties,omitempty"` +} + +// Type "message" + +const ( + RecipientTypeSession = "session" + RecipientTypeUser = "user" + RecipientTypeRoom = "room" +) + +type MessageClientMessageRecipient struct { + Type string `json:"type"` + + SessionId string `json:"sessionid,omitempty"` + UserId string `json:"userid,omitempty"` +} + +type MessageClientMessage struct { + Recipient MessageClientMessageRecipient `json:"recipient"` + + Data *json.RawMessage `json:"data"` +} + +type MessageClientMessageData struct { + Type string `json:"type"` + Sid string `json:"sid"` + RoomType string `json:"roomType"` + Payload map[string]interface{} `json:"payload"` +} + +func (m *MessageClientMessage) CheckValid() error { + if m.Data == nil || len(*m.Data) == 0 { + return fmt.Errorf("message empty") + } + switch m.Recipient.Type { + case RecipientTypeRoom: + // No additional checks required. + case RecipientTypeSession: + if m.Recipient.SessionId == "" { + return fmt.Errorf("session id missing") + } + case RecipientTypeUser: + if m.Recipient.UserId == "" { + return fmt.Errorf("user id missing") + } + default: + return fmt.Errorf("unsupported recipient type %v", m.Recipient.Type) + } + return nil +} + +type MessageServerMessageSender struct { + Type string `json:"type"` + + SessionId string `json:"sessionid,omitempty"` + UserId string `json:"userid,omitempty"` +} + +type MessageServerMessageDataChat struct { + Refresh bool `json:"refresh"` +} + +type MessageServerMessageData struct { + Type string `json:"type"` + + Chat *MessageServerMessageDataChat `json:"chat,omitempty"` +} + +type MessageServerMessage struct { + Sender *MessageServerMessageSender `json:"sender"` + + Data *json.RawMessage `json:"data"` +} + +// Type "control" + +type ControlClientMessage struct { + MessageClientMessage +} + +type ControlServerMessage struct { + Sender *MessageServerMessageSender `json:"sender"` + + Data *json.RawMessage `json:"data"` +} + +// Type "event" + +type RoomEventServerMessage struct { + RoomId string `json:"roomid"` + Properties *json.RawMessage `json:"properties,omitempty"` + // TODO(jojo): Change "InCall" to "int" when #914 has landed in NC Talk. + InCall *json.RawMessage `json:"incall,omitempty"` + Changed []map[string]interface{} `json:"changed,omitempty"` + Users []map[string]interface{} `json:"users,omitempty"` +} + +type RoomEventMessage struct { + RoomId string `json:"roomid"` + Data *json.RawMessage `json:"data,omitempty"` +} + +type EventServerMessage struct { + Target string `json:"target"` + Type string `json:"type"` + + // Used for target "room" + Join []*EventServerMessageSessionEntry `json:"join,omitempty"` + Leave []string `json:"leave,omitempty"` + Change []*EventServerMessageSessionEntry `json:"change,omitempty"` + + // Used for target "roomlist" / "participants" + Invite *RoomEventServerMessage `json:"invite,omitempty"` + Disinvite *RoomEventServerMessage `json:"disinvite,omitempty"` + Update *RoomEventServerMessage `json:"update,omitempty"` + + // Used for target "message" + Message *RoomEventMessage `json:"message,omitempty"` +} + +type EventServerMessageSessionEntry struct { + SessionId string `json:"sessionid"` + UserId string `json:"userid"` + User *json.RawMessage `json:"user,omitempty"` +} + +// MCU-related types + +type AnswerOfferMessage struct { + To string `json:"to"` + From string `json:"from"` + Type string `json:"type"` + RoomType string `json:"roomType"` + Payload map[string]interface{} `json:"payload"` +} diff --git a/src/signaling/api_signaling_test.go b/src/signaling/api_signaling_test.go new file mode 100644 index 0000000..92f04f3 --- /dev/null +++ b/src/signaling/api_signaling_test.go @@ -0,0 +1,340 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "encoding/json" + "fmt" + "testing" +) + +type testCheckValid interface { + CheckValid() error +} + +func wrapMessage(messageType string, msg testCheckValid) *ClientMessage { + wrapped := &ClientMessage{ + Type: messageType, + } + switch messageType { + case "hello": + wrapped.Hello = msg.(*HelloClientMessage) + case "message": + wrapped.Message = msg.(*MessageClientMessage) + case "bye": + wrapped.Bye = msg.(*ByeClientMessage) + case "room": + wrapped.Room = msg.(*RoomClientMessage) + default: + return nil + } + return wrapped +} + +func testMessages(t *testing.T, messageType string, valid_messages []testCheckValid, invalid_messages []testCheckValid) { + for _, msg := range valid_messages { + if err := msg.CheckValid(); err != nil { + t.Errorf("Message %+v should be valid, got %s", msg, err) + } + // If the inner message is valid, it should also be valid in a wrapped + // ClientMessage. + if wrapped := wrapMessage(messageType, msg); wrapped == nil { + t.Errorf("Unknown message type: %s", messageType) + } else if err := wrapped.CheckValid(); err != nil { + t.Errorf("Message %+v should be valid, got %s", wrapped, err) + } + } + for _, msg := range invalid_messages { + if err := msg.CheckValid(); err == nil { + t.Errorf("Message %+v should not be valid", msg) + } + + // If the inner message is invalid, it should also be invalid in a + // wrapped ClientMessage. + if wrapped := wrapMessage(messageType, msg); wrapped == nil { + t.Errorf("Unknown message type: %s", messageType) + } else if err := wrapped.CheckValid(); err == nil { + t.Errorf("Message %+v should not be valid", wrapped) + } + } +} + +func TestClientMessage(t *testing.T) { + // The message needs a type. + msg := ClientMessage{} + if err := msg.CheckValid(); err == nil { + t.Errorf("Message %+v should not be valid", msg) + } +} + +func TestHelloClientMessage(t *testing.T) { + valid_messages := []testCheckValid{ + &HelloClientMessage{ + Version: HelloVersion, + Auth: HelloClientMessageAuth{ + Params: &json.RawMessage{'{', '}'}, + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersion, + Auth: HelloClientMessageAuth{ + Type: "client", + Params: &json.RawMessage{'{', '}'}, + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersion, + Auth: HelloClientMessageAuth{ + Type: "internal", + Params: &json.RawMessage{'{', '}'}, + }, + }, + &HelloClientMessage{ + Version: HelloVersion, + ResumeId: "the-resume-id", + }, + } + invalid_messages := []testCheckValid{ + &HelloClientMessage{}, + &HelloClientMessage{Version: "0.0"}, + &HelloClientMessage{Version: HelloVersion}, + &HelloClientMessage{ + Version: HelloVersion, + Auth: HelloClientMessageAuth{ + Params: &json.RawMessage{'{', '}'}, + Type: "invalid-type", + }, + }, + &HelloClientMessage{ + Version: HelloVersion, + Auth: HelloClientMessageAuth{ + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersion, + Auth: HelloClientMessageAuth{ + Params: &json.RawMessage{'{', '}'}, + }, + }, + &HelloClientMessage{ + Version: HelloVersion, + Auth: HelloClientMessageAuth{ + Params: &json.RawMessage{'{', '}'}, + Url: "invalid-url", + }, + }, + &HelloClientMessage{ + Version: HelloVersion, + Auth: HelloClientMessageAuth{ + Type: "internal", + Params: &json.RawMessage{'x', 'y', 'z'}, // Invalid JSON. + }, + }, + } + + testMessages(t, "hello", valid_messages, invalid_messages) + + // A "hello" message must be present + msg := ClientMessage{ + Type: "hello", + } + if err := msg.CheckValid(); err == nil { + t.Errorf("Message %+v should not be valid", msg) + } +} + +func TestMessageClientMessage(t *testing.T) { + valid_messages := []testCheckValid{ + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + SessionId: "the-session-id", + }, + Data: &json.RawMessage{'{', '}'}, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + UserId: "the-user-id", + }, + Data: &json.RawMessage{'{', '}'}, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "room", + }, + Data: &json.RawMessage{'{', '}'}, + }, + } + invalid_messages := []testCheckValid{ + &MessageClientMessage{}, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + SessionId: "the-session-id", + }, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + }, + Data: &json.RawMessage{'{', '}'}, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + UserId: "the-user-id", + }, + Data: &json.RawMessage{'{', '}'}, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + }, + Data: &json.RawMessage{'{', '}'}, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + UserId: "the-user-id", + }, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + SessionId: "the-user-id", + }, + Data: &json.RawMessage{'{', '}'}, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "unknown-type", + }, + Data: &json.RawMessage{'{', '}'}, + }, + } + testMessages(t, "message", valid_messages, invalid_messages) + + // A "message" message must be present + msg := ClientMessage{ + Type: "message", + } + if err := msg.CheckValid(); err == nil { + t.Errorf("Message %+v should not be valid", msg) + } +} + +func TestByeClientMessage(t *testing.T) { + // Any "bye" message is valid. + valid_messages := []testCheckValid{ + &ByeClientMessage{}, + } + invalid_messages := []testCheckValid{} + + testMessages(t, "bye", valid_messages, invalid_messages) + + // The "bye" message is optional. + msg := ClientMessage{ + Type: "bye", + } + if err := msg.CheckValid(); err != nil { + t.Errorf("Message %+v should be valid, got %s", msg, err) + } +} + +func TestRoomClientMessage(t *testing.T) { + // Any "room" message is valid. + valid_messages := []testCheckValid{ + &RoomClientMessage{}, + } + invalid_messages := []testCheckValid{} + + testMessages(t, "room", valid_messages, invalid_messages) + + // But a "room" message must be present + msg := ClientMessage{ + Type: "room", + } + if err := msg.CheckValid(); err == nil { + t.Errorf("Message %+v should not be valid", msg) + } +} + +func TestErrorMessages(t *testing.T) { + id := "request-id" + msg := ClientMessage{ + Id: id, + } + err1 := msg.NewErrorServerMessage(&Error{}) + if err1.Id != id { + t.Errorf("Expected id %s, got %+v", id, err1) + } + if err1.Type != "error" || err1.Error == nil { + t.Errorf("Expected type \"error\", got %+v", err1) + } + + err2 := msg.NewWrappedErrorServerMessage(fmt.Errorf("test-error")) + if err2.Id != id { + t.Errorf("Expected id %s, got %+v", id, err2) + } + if err2.Type != "error" || err2.Error == nil { + t.Errorf("Expected type \"error\", got %+v", err2) + } + if err2.Error.Code != "internal_error" { + t.Errorf("Expected code \"internal_error\", got %+v", err2) + } + if err2.Error.Message != "test-error" { + t.Errorf("Expected message \"test-error\", got %+v", err2) + } + // Test "error" interface + if err2.Error.Error() != "test-error" { + t.Errorf("Expected error string \"test-error\", got %+v", err2) + } +} + +func TestIsChatRefresh(t *testing.T) { + var msg ServerMessage + data_true := []byte("{\"type\":\"chat\",\"chat\":{\"refresh\":true}}") + msg = ServerMessage{ + Type: "message", + Message: &MessageServerMessage{ + Data: (*json.RawMessage)(&data_true), + }, + } + if !msg.IsChatRefresh() { + t.Error("message should be detected as chat refresh") + } + + data_false := []byte("{\"type\":\"chat\",\"chat\":{\"refresh\":false}}") + msg = ServerMessage{ + Type: "message", + Message: &MessageServerMessage{ + Data: (*json.RawMessage)(&data_false), + }, + } + if msg.IsChatRefresh() { + t.Error("message should not be detected as chat refresh") + } +} diff --git a/src/signaling/backend_client.go b/src/signaling/backend_client.go new file mode 100644 index 0000000..ba08c9c --- /dev/null +++ b/src/signaling/backend_client.go @@ -0,0 +1,387 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/dlintw/goconf" + "golang.org/x/net/context" + "golang.org/x/net/context/ctxhttp" +) + +var ( + ErrUseLastResponse = fmt.Errorf("Use last response") +) + +type BackendClient struct { + transport *http.Transport + whitelist map[string]bool + whitelistAll bool + secret []byte + version string + + mu sync.Mutex + + maxConcurrentRequestsPerHost int + clients map[string]*HttpClientPool +} + +func NewBackendClient(config *goconf.ConfigFile, maxConcurrentRequestsPerHost int, version string) (*BackendClient, error) { + whitelist := make(map[string]bool) + whitelistAll, _ := config.GetBool("backend", "allowall") + if whitelistAll { + log.Println("WARNING: All backend hostnames are allowed, only use for development!") + } else { + urls, _ := config.GetString("backend", "allowed") + for _, u := range strings.Split(urls, ",") { + u = strings.TrimSpace(u) + if idx := strings.IndexByte(u, '/'); idx != -1 { + log.Printf("WARNING: Removing path from allowed hostname \"%s\", check your configuration!", u) + u = u[:idx] + } + if u != "" { + whitelist[strings.ToLower(u)] = true + } + } + if len(whitelist) == 0 { + log.Println("WARNING: No backend hostnames are allowed, check your configuration!") + } else { + hosts := make([]string, 0, len(whitelist)) + for u := range whitelist { + hosts = append(hosts, u) + } + log.Printf("Allowed backend hostnames: %s\n", hosts) + } + } + + skipverify, _ := config.GetBool("backend", "skipverify") + if skipverify { + log.Println("WARNING: Backend verification is disabled!") + } + + secret, _ := config.GetString("backend", "secret") + + tlsconfig := &tls.Config{ + InsecureSkipVerify: skipverify, + } + transport := &http.Transport{ + MaxIdleConnsPerHost: maxConcurrentRequestsPerHost, + TLSClientConfig: tlsconfig, + } + + return &BackendClient{ + transport: transport, + whitelist: whitelist, + whitelistAll: whitelistAll, + secret: []byte(secret), + version: version, + + maxConcurrentRequestsPerHost: maxConcurrentRequestsPerHost, + clients: make(map[string]*HttpClientPool), + }, nil +} + +func (b *BackendClient) getPool(url *url.URL) (*HttpClientPool, error) { + b.mu.Lock() + defer b.mu.Unlock() + if pool, found := b.clients[url.Host]; found { + return pool, nil + } + + pool, err := NewHttpClientPool(func() *http.Client { + return &http.Client{ + Transport: b.transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Should be http.ErrUseLastResponse with go 1.8 + return ErrUseLastResponse + }, + } + }, b.maxConcurrentRequestsPerHost) + if err != nil { + return nil, err + } + + b.clients[url.Host] = pool + return pool, nil +} + +func (b *BackendClient) IsUrlAllowed(u *url.URL) bool { + if u == nil { + // Reject all invalid URLs. + return false + } + + return b.whitelistAll || b.whitelist[u.Host] +} + +func isOcsRequest(u *url.URL) bool { + return strings.Contains(u.Path, "/ocs/v2.php") || strings.Contains(u.Path, "/ocs/v1.php") +} + +func closeBody(response *http.Response) { + if response.Body != nil { + response.Body.Close() + } +} + +// refererForURL returns a referer without any authentication info or +// an empty string if lastReq scheme is https and newReq scheme is http. +func refererForURL(lastReq, newReq *url.URL) string { + // https://tools.ietf.org/html/rfc7231#section-5.5.2 + // "Clients SHOULD NOT include a Referer header field in a + // (non-secure) HTTP request if the referring page was + // transferred with a secure protocol." + if lastReq.Scheme == "https" && newReq.Scheme == "http" { + return "" + } + referer := lastReq.String() + if lastReq.User != nil { + // This is not very efficient, but is the best we can + // do without: + // - introducing a new method on URL + // - creating a race condition + // - copying the URL struct manually, which would cause + // maintenance problems down the line + auth := lastReq.User.String() + "@" + referer = strings.Replace(referer, auth, "", 1) + } + return referer +} + +// urlErrorOp returns the (*url.Error).Op value to use for the +// provided (*Request).Method value. +func urlErrorOp(method string) string { + if method == "" { + return "Get" + } + return method[:1] + strings.ToLower(method[1:]) +} + +func performRequestWithRedirects(ctx context.Context, client *http.Client, req *http.Request, body []byte) (*http.Response, error) { + var reqs []*http.Request + var resp *http.Response + + uerr := func(err error) error { + var urlStr string + if resp != nil && resp.Request != nil { + urlStr = resp.Request.URL.String() + } else { + urlStr = req.URL.String() + } + return &url.Error{ + Op: urlErrorOp(reqs[0].Method), + URL: urlStr, + Err: err, + } + } + for { + if len(reqs) >= 10 { + return nil, fmt.Errorf("stopped after 10 redirects") + } + + if len(reqs) > 0 { + loc := resp.Header.Get("Location") + if loc == "" { + closeBody(resp) + return nil, uerr(fmt.Errorf("%d response missing Location header", resp.StatusCode)) + } + u, err := req.URL.Parse(loc) + if err != nil { + closeBody(resp) + return nil, uerr(fmt.Errorf("failed to parse Location header %q: %v", loc, err)) + } + + if len(reqs) == 1 { + log.Printf("Got a redirect from %s to %s, please check your configuration", req.URL, u) + } + + host := "" + if req.Host != "" && req.Host != req.URL.Host { + // If the caller specified a custom Host header and the + // redirect location is relative, preserve the Host header + // through the redirect. See issue #22233. + if u, _ := url.Parse(loc); u != nil && !u.IsAbs() { + host = req.Host + } + } + ireq := reqs[0] + req = &http.Request{ + Method: ireq.Method, + URL: u, + Header: ireq.Header, + Host: host, + } + + // Add the Referer header from the most recent + // request URL to the new one, if it's not https->http: + if ref := refererForURL(reqs[len(reqs)-1].URL, req.URL); ref != "" { + req.Header.Set("Referer", ref) + } + // Close the previous response's body. But + // read at least some of the body so if it's + // small the underlying TCP connection will be + // re-used. No need to check for errors: if it + // fails, the Transport won't reuse it anyway. + const maxBodySlurpSize = 2 << 10 + if resp.ContentLength == -1 || resp.ContentLength <= maxBodySlurpSize { + io.CopyN(ioutil.Discard, resp.Body, maxBodySlurpSize) + } + resp.Body.Close() + } + reqs = append(reqs, req) + var err error + + if body != nil { + req.Body = ioutil.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + } + resp, err = ctxhttp.Do(ctx, client, req) + if err != nil { + if e, ok := err.(*url.Error); !ok || resp == nil || e.Err != ErrUseLastResponse { + return nil, err + } + } + + switch resp.StatusCode { + case 301, 302, 303: + break + case 307, 308: + if resp.Header.Get("Location") == "" { + // 308s have been observed in the wild being served + // without Location headers. Since Go 1.7 and earlier + // didn't follow these codes, just stop here instead + // of returning an error. + // See Issue 17773. + return resp, nil + } + if req.Body == nil { + // We had a request body, and 307/308 require + // re-sending it, but GetBody is not defined. So just + // return this response to the user instead of an + // error, like we did in Go 1.7 and earlier. + return resp, nil + } + default: + return resp, nil + } + } +} + +// PerformJSONRequest sends a JSON POST request to the given url and decodes +// the result into "response". +func (b *BackendClient) PerformJSONRequest(ctx context.Context, u *url.URL, request interface{}, response interface{}) error { + if u == nil { + return fmt.Errorf("No url passed to perform JSON request %+v", request) + } + + pool, err := b.getPool(u) + if err != nil { + log.Printf("Could not get client pool for host %s: %s\n", u.Host, err) + return err + } + + c, err := pool.Get(ctx) + if err != nil { + log.Printf("Could not get client for host %s: %s\n", u.Host, err) + return err + } + defer pool.Put(c) + + data, err := json.Marshal(request) + if err != nil { + log.Printf("Could not marshal request %+v: %s\n", request, err) + return err + } + + req := &http.Request{ + Method: "POST", + URL: u, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Host: u.Host, + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("OCS-APIRequest", "true") + req.Header.Set("User-Agent", "nextcloud-spreed-signaling/"+b.version) + + // Add checksum so the backend can validate the request. + AddBackendChecksum(req, data, b.secret) + + resp, err := performRequestWithRedirects(ctx, c, req, data) + if err != nil { + log.Printf("Could not send request %s to %s: %s\n", string(data), u.String(), err) + return err + } + defer resp.Body.Close() + + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + log.Printf("Received unsupported content-type from %s: %s (%s)\n", u.String(), ct, resp.Status) + return fmt.Errorf("unsupported_content_type") + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Printf("Could not read response body from %s: %s\n", u.String(), err) + return err + } + + if isOcsRequest(u) { + // OCS response are wrapped in an OCS container that needs to be parsed + // to get the actual contents: + // { + // "ocs": { + // "meta": { ... }, + // "data": { ... } + // } + // } + var ocs OcsResponse + if err := json.Unmarshal(body, &ocs); err != nil { + log.Printf("Could not decode OCS response %s from %s: %s", string(body), u, err) + return err + } else if ocs.Ocs == nil || ocs.Ocs.Data == nil { + log.Printf("Incomplete OCS response %s from %s", string(body), u) + return fmt.Errorf("Incomplete OCS response") + } else if err := json.Unmarshal(*ocs.Ocs.Data, response); err != nil { + log.Printf("Could not decode OCS response body %s from %s: %s", string(*ocs.Ocs.Data), u, err) + return err + } + } else if err := json.Unmarshal(body, response); err != nil { + log.Printf("Could not decode response body %s from %s: %s", string(body), u, err) + return err + } + return nil +} diff --git a/src/signaling/backend_client_test.go b/src/signaling/backend_client_test.go new file mode 100644 index 0000000..e4e9da8 --- /dev/null +++ b/src/signaling/backend_client_test.go @@ -0,0 +1,168 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "github.com/dlintw/goconf" + "github.com/gorilla/mux" + "golang.org/x/net/context" +) + +func testUrls(t *testing.T, client *BackendClient, valid_urls []string, invalid_urls []string) { + for _, u := range valid_urls { + parsed, err := url.ParseRequestURI(u) + if err != nil { + t.Errorf("The url %s should be valid, got %s", u, err) + continue + } + if !client.IsUrlAllowed(parsed) { + t.Errorf("The url %s should be allowed", u) + } + } + for _, u := range invalid_urls { + parsed, _ := url.ParseRequestURI(u) + if client.IsUrlAllowed(parsed) { + t.Errorf("The url %s should not be allowed", u) + } + } +} + +func TestIsUrlAllowed(t *testing.T) { + valid_urls := []string{ + "http://domain.invalid", + "https://domain.invalid", + } + invalid_urls := []string{ + "http://otherdomain.invalid", + "https://otherdomain.invalid", + "domain.invalid", + } + client := &BackendClient{ + whitelistAll: false, + whitelist: map[string]bool{ + "domain.invalid": true, + }, + } + testUrls(t, client, valid_urls, invalid_urls) +} + +func TestIsUrlAllowed_EmptyWhitelist(t *testing.T) { + valid_urls := []string{} + invalid_urls := []string{ + "http://domain.invalid", + "https://domain.invalid", + "domain.invalid", + } + client := &BackendClient{ + whitelistAll: false, + } + testUrls(t, client, valid_urls, invalid_urls) +} + +func TestIsUrlAllowed_WhitelistAll(t *testing.T) { + valid_urls := []string{ + "http://domain.invalid", + "https://domain.invalid", + } + invalid_urls := []string{ + "domain.invalid", + } + client := &BackendClient{ + whitelistAll: true, + } + testUrls(t, client, valid_urls, invalid_urls) +} + +func TestPostOnRedirect(t *testing.T) { + r := mux.NewRouter() + r.HandleFunc("/ocs/v2.php/one", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ocs/v2.php/two", http.StatusFound) + }) + r.HandleFunc("/ocs/v2.php/two", func(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + return + } + + var request map[string]string + if err := json.Unmarshal(body, &request); err != nil { + t.Fatal(err) + return + } + + w.Header().Set("Content-Type", "application/json") + response := OcsResponse{ + Ocs: &OcsBody{ + Meta: OcsMeta{ + Status: "OK", + StatusCode: http.StatusOK, + Message: "OK", + }, + Data: (*json.RawMessage)(&body), + }, + } + data, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(data) + }) + + server := httptest.NewServer(r) + defer server.Close() + + config := &goconf.ConfigFile{} + client, err := NewBackendClient(config, 1, "0.0") + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + u, err := url.Parse(server.URL + "/ocs/v2.php/one") + if err != nil { + t.Fatal(err) + } + request := map[string]string{ + "foo": "bar", + } + var response map[string]string + err = client.PerformJSONRequest(ctx, u, request, &response) + if err != nil { + t.Fatal(err) + } + + if response == nil || !reflect.DeepEqual(request, response) { + t.Errorf("Expected %+v, got %+v", request, response) + } +} diff --git a/src/signaling/backend_server.go b/src/signaling/backend_server.go new file mode 100644 index 0000000..3ad2d36 --- /dev/null +++ b/src/signaling/backend_server.go @@ -0,0 +1,526 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "reflect" + "strings" + "sync" + "time" + + "github.com/dlintw/goconf" + "github.com/gorilla/mux" +) + +const ( + maxBodySize = 64 * 1024 + + randomUsernameLength = 32 + + sessionIdNotInMeeting = "0" +) + +type BackendServer struct { + nats NatsClient + roomSessions RoomSessions + + version string + welcomeMessage string + + secret []byte + + turnapikey string + turnsecret []byte + turnvalid time.Duration + turnservers []string +} + +func NewBackendServer(config *goconf.ConfigFile, hub *Hub, version string) (*BackendServer, error) { + secret, _ := config.GetString("backend", "secret") + + turnapikey, _ := config.GetString("turn", "apikey") + turnsecret, _ := config.GetString("turn", "secret") + turnservers, _ := config.GetString("turn", "servers") + // TODO(jojo): Make the validity for TURN credentials configurable. + turnvalid := 24 * time.Hour + + var turnserverslist []string + for _, s := range strings.Split(turnservers, ",") { + s = strings.TrimSpace(s) + if s != "" { + turnserverslist = append(turnserverslist, s) + } + } + + if len(turnserverslist) != 0 { + if turnapikey == "" { + return nil, fmt.Errorf("Need a TURN API key if TURN servers are configured.") + } + if turnsecret == "" { + return nil, fmt.Errorf("Need a shared TURN secret if TURN servers are configured.") + } + + log.Printf("Using \"%s\" as TURN API key", turnapikey) + log.Printf("Using \"%s\" as shared TURN secret", turnsecret) + for _, s := range turnserverslist { + log.Printf("Adding \"%s\" as TURN server", s) + } + } + + return &BackendServer{ + nats: hub.nats, + roomSessions: hub.roomSessions, + version: version, + + secret: []byte(secret), + + turnapikey: turnapikey, + + turnsecret: []byte(turnsecret), + turnvalid: turnvalid, + turnservers: turnserverslist, + }, nil +} + +func (b *BackendServer) Start(r *mux.Router) error { + welcome := map[string]string{ + "nextcloud-spreed-signaling": "Welcome", + "version": b.version, + } + if welcomeMessage, err := json.Marshal(welcome); err != nil { + // Should never happen. + return err + } else { + b.welcomeMessage = string(welcomeMessage) + "\n" + } + s := r.PathPrefix("/api/v1").Subrouter() + s.HandleFunc("/welcome", b.setComonHeaders(b.welcomeFunc)).Methods("GET") + s.HandleFunc("/room/{roomid}", b.setComonHeaders(b.validateBackendRequest(b.roomHandler))).Methods("POST") + + // Provide a REST service to get TURN credentials. + // See https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 + r.HandleFunc("/turn/credentials", b.setComonHeaders(b.getTurnCredentials)).Methods("GET") + return nil +} + +func (b *BackendServer) setComonHeaders(f func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "nextcloud-spreed-signaling/"+b.version) + f(w, r) + } +} + +func (b *BackendServer) welcomeFunc(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + io.WriteString(w, b.welcomeMessage) +} + +func calculateTurnSecret(username string, secret []byte, valid time.Duration) (string, string) { + expires := time.Now().Add(valid) + username = fmt.Sprintf("%d:%s", expires.Unix(), username) + m := hmac.New(sha1.New, secret) + m.Write([]byte(username)) + password := base64.StdEncoding.EncodeToString(m.Sum(nil)) + return username, password +} + +func (b *BackendServer) getTurnCredentials(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + service := q.Get("service") + username := q.Get("username") + key := q.Get("key") + if key == "" { + // The RFC actually defines "key" to be the parameter, but Janus sends it as "api". + key = q.Get("api") + } + if service != "turn" || key == "" { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, "Invalid service and/or key sent.\n") + return + } + + if key != b.turnapikey { + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, "Not allowed to access this service.\n") + return + } + + if len(b.turnservers) == 0 { + w.WriteHeader(http.StatusNotFound) + io.WriteString(w, "No TURN servers available.\n") + return + } + + if username == "" { + // Make sure to include an actual username in the credentials. + username = newRandomString(randomUsernameLength) + } + + username, password := calculateTurnSecret(username, b.turnsecret, b.turnvalid) + result := TurnCredentials{ + Username: username, + Password: password, + TTL: int64(b.turnvalid.Seconds()), + URIs: b.turnservers, + } + + data, err := json.Marshal(result) + if err != nil { + log.Printf("Could not serialize TURN credentials %+v: %s", result, err) + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, "Could not serialize credentials.") + return + } + + if data[len(data)-1] != '\n' { + data = append(data, '\n') + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write(data) +} + +func (b *BackendServer) validateBackendRequest(f func(http.ResponseWriter, *http.Request, []byte)) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + // Sanity checks + if r.ContentLength == -1 { + http.Error(w, "Length required", http.StatusLengthRequired) + return + } else if r.ContentLength > maxBodySize { + http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) + return + } + ct := r.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + log.Printf("Received unsupported content-type: %s\n", ct) + http.Error(w, "Unsupported Content-Type", http.StatusBadRequest) + return + } + + if r.Header.Get(HeaderBackendSignalingRandom) == "" || + r.Header.Get(HeaderBackendSignalingChecksum) == "" { + http.Error(w, "Authentication check failed", http.StatusForbidden) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + log.Println("Error reading body: ", err) + http.Error(w, "Could not read body", http.StatusBadRequest) + return + } + if !ValidateBackendChecksum(r, body, b.secret) { + http.Error(w, "Authentication check failed", http.StatusForbidden) + return + } + + f(w, r, body) + } +} + +func (b *BackendServer) sendRoomInvite(roomid string, userids []string, properties *json.RawMessage) { + msg := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "roomlist", + Type: "invite", + Invite: &RoomEventServerMessage{ + RoomId: roomid, + Properties: properties, + }, + }, + } + for _, userid := range userids { + b.nats.PublishMessage(GetSubjectForUserId(userid), msg) + } +} + +func (b *BackendServer) sendRoomDisinvite(roomid string, userids []string, sessionids []string) { + msg := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "roomlist", + Type: "disinvite", + Disinvite: &RoomEventServerMessage{ + RoomId: roomid, + }, + }, + } + for _, userid := range userids { + b.nats.PublishMessage(GetSubjectForUserId(userid), msg) + } + + timeout := time.Second + var wg sync.WaitGroup + for _, sessionid := range sessionids { + if sessionid == sessionIdNotInMeeting { + // Ignore entries that are no longer in the meeting. + continue + } + + wg.Add(1) + go func(sessionid string) { + defer wg.Done() + if sid, err := b.lookupByRoomSessionId(sessionid, nil, timeout); err != nil { + log.Printf("Could not lookup by room session %s: %s", sessionid, err) + } else if sid != "" { + b.nats.PublishMessage("session."+sid, msg) + } + }(sessionid) + } + wg.Wait() +} + +func (b *BackendServer) sendRoomUpdate(roomid string, notified_userids []string, all_userids []string, properties *json.RawMessage) { + msg := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "roomlist", + Type: "update", + Update: &RoomEventServerMessage{ + RoomId: roomid, + Properties: properties, + }, + }, + } + notified := make(map[string]bool) + for _, userid := range notified_userids { + notified[userid] = true + } + // Only send to users not notified otherwise. + for _, userid := range all_userids { + if notified[userid] { + continue + } + + b.nats.PublishMessage(GetSubjectForUserId(userid), msg) + } +} + +func (b *BackendServer) lookupByRoomSessionId(roomSessionId string, cache *ConcurrentStringStringMap, timeout time.Duration) (string, error) { + if roomSessionId == sessionIdNotInMeeting { + log.Printf("Trying to lookup empty room session id: %s", roomSessionId) + return "", nil + } + + if cache != nil { + if result, found := cache.Get(roomSessionId); found { + return result, nil + } + } + + sid, err := b.roomSessions.GetSessionId(roomSessionId) + if err == ErrNoSuchRoomSession { + return "", nil + } else if err != nil { + return "", err + } + + if cache != nil { + cache.Set(roomSessionId, sid) + } + return sid, nil +} + +func (b *BackendServer) fixupUserSessions(cache *ConcurrentStringStringMap, users []map[string]interface{}, timeout time.Duration) []map[string]interface{} { + if len(users) == 0 { + return users + } + + var wg sync.WaitGroup + for _, user := range users { + roomSessionIdOb, found := user["sessionId"] + if !found { + continue + } + + roomSessionId, ok := roomSessionIdOb.(string) + if !ok { + log.Printf("User %+v has invalid room session id, ignoring", user) + delete(user, "sessionId") + continue + } + + if roomSessionId == sessionIdNotInMeeting { + log.Printf("User %+v is not in the meeting, ignoring", user) + delete(user, "sessionId") + continue + } + + wg.Add(1) + go func(roomSessionId string, u map[string]interface{}) { + defer wg.Done() + if sessionId, err := b.lookupByRoomSessionId(roomSessionId, cache, timeout); err != nil { + log.Printf("Could not lookup by room session %s: %s", roomSessionId, err) + delete(u, "sessionId") + } else if sessionId != "" { + u["sessionId"] = sessionId + } else { + // sessionId == "" + delete(u, "sessionId") + } + }(roomSessionId, user) + } + wg.Wait() + + result := make([]map[string]interface{}, 0, len(users)) + for _, user := range users { + if _, found := user["sessionId"]; found { + result = append(result, user) + } + } + return result +} + +func (b *BackendServer) sendRoomIncall(roomid string, request *BackendServerRoomRequest) error { + timeout := time.Second + + var cache ConcurrentStringStringMap + // Convert (Nextcloud) session ids to signaling session ids. + request.InCall.Users = b.fixupUserSessions(&cache, request.InCall.Users, timeout) + // Entries in "Changed" are most likely already fetched through the "Users" list. + request.InCall.Changed = b.fixupUserSessions(&cache, request.InCall.Changed, timeout) + + if len(request.InCall.Users) == 0 && len(request.InCall.Changed) == 0 { + return nil + } + + return b.nats.PublishBackendServerRoomRequest("backend.room."+roomid, request) +} + +func (b *BackendServer) sendRoomParticipantsUpdate(roomid string, request *BackendServerRoomRequest) error { + timeout := time.Second + + // Convert (Nextcloud) session ids to signaling session ids. + var cache ConcurrentStringStringMap + request.Participants.Users = b.fixupUserSessions(&cache, request.Participants.Users, timeout) + request.Participants.Changed = b.fixupUserSessions(&cache, request.Participants.Changed, timeout) + + if len(request.Participants.Users) == 0 && len(request.Participants.Changed) == 0 { + return nil + } + + var wg sync.WaitGroup +loop: + for _, user := range request.Participants.Changed { + permissionsInterface, found := user["permissions"] + if !found { + continue + } + + sessionId := user["sessionId"].(string) + permissionsList, ok := permissionsInterface.([]interface{}) + if !ok { + log.Printf("Received invalid permissions %+v (%s) for session %s", permissionsInterface, reflect.TypeOf(permissionsInterface), sessionId) + continue + } + var permissions []Permission + for idx, ob := range permissionsList { + permission, ok := ob.(string) + if !ok { + log.Printf("Received invalid permission at position %d %+v (%s) for session %s", idx, ob, reflect.TypeOf(ob), sessionId) + continue loop + } + permissions = append(permissions, Permission(permission)) + } + wg.Add(1) + + go func(sessionId string, permissions []Permission) { + defer wg.Done() + message := &NatsMessage{ + Type: "permissions", + Permissions: permissions, + } + if err := b.nats.Publish("session."+sessionId, message); err != nil { + log.Printf("Could not send permissions update (%+v) to session %s: %s", permissions, sessionId, err) + } + }(sessionId, permissions) + } + wg.Wait() + + return b.nats.PublishBackendServerRoomRequest("backend.room."+roomid, request) +} + +func (b *BackendServer) sendRoomMessage(roomid string, request *BackendServerRoomRequest) error { + return b.nats.PublishBackendServerRoomRequest("backend.room."+roomid, request) +} + +func (b *BackendServer) roomHandler(w http.ResponseWriter, r *http.Request, body []byte) { + var request BackendServerRoomRequest + if err := json.Unmarshal(body, &request); err != nil { + log.Printf("Error decoding body %s: %s\n", string(body), err) + http.Error(w, "Could not read body", http.StatusBadRequest) + return + } + + request.ReceivedTime = time.Now().UnixNano() + + v := mux.Vars(r) + roomid := v["roomid"] + var err error + switch request.Type { + case "invite": + b.sendRoomInvite(roomid, request.Invite.UserIds, request.Invite.Properties) + b.sendRoomUpdate(roomid, request.Invite.UserIds, request.Invite.AllUserIds, request.Invite.Properties) + case "disinvite": + b.sendRoomDisinvite(roomid, request.Disinvite.UserIds, request.Disinvite.SessionIds) + b.sendRoomUpdate(roomid, request.Disinvite.UserIds, request.Disinvite.AllUserIds, request.Disinvite.Properties) + case "update": + err = b.nats.PublishBackendServerRoomRequest("backend.room."+roomid, &request) + b.sendRoomUpdate(roomid, nil, request.Update.UserIds, request.Update.Properties) + case "delete": + err = b.nats.PublishBackendServerRoomRequest("backend.room."+roomid, &request) + b.sendRoomDisinvite(roomid, request.Delete.UserIds, nil) + case "incall": + err = b.sendRoomIncall(roomid, &request) + case "participants": + err = b.sendRoomParticipantsUpdate(roomid, &request) + case "message": + err = b.sendRoomMessage(roomid, &request) + default: + http.Error(w, "Unsupported request type: "+request.Type, http.StatusBadRequest) + return + } + + if err != nil { + log.Printf("Error processing %s for room %s: %s\n", string(body), roomid, err) + http.Error(w, "Error while processing", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + // TODO(jojo): Return better response struct. + w.Write([]byte("{}")) +} diff --git a/src/signaling/backend_server_test.go b/src/signaling/backend_server_test.go new file mode 100644 index 0000000..957d7bd --- /dev/null +++ b/src/signaling/backend_server_test.go @@ -0,0 +1,1268 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "bytes" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/dlintw/goconf" + "github.com/gorilla/mux" + "github.com/gorilla/websocket" + "github.com/nats-io/go-nats" + + "golang.org/x/net/context" +) + +var ( + turnApiKey = "TheApiKey" + turnSecret = "TheTurnSecret" + turnServersString = "turn:1.2.3.4:9991?transport=udp,turn:1.2.3.4:9991?transport=tcp" + turnServers = strings.Split(turnServersString, ",") +) + +func CreateBackendServerForTest(t *testing.T) (*goconf.ConfigFile, *BackendServer, NatsClient, *Hub, *mux.Router, *httptest.Server, func()) { + return CreateBackendServerForTestFromConfig(t, nil) +} + +func CreateBackendServerForTestWithTurn(t *testing.T) (*goconf.ConfigFile, *BackendServer, NatsClient, *Hub, *mux.Router, *httptest.Server, func()) { + config := goconf.NewConfigFile() + config.AddOption("turn", "apikey", turnApiKey) + config.AddOption("turn", "secret", turnSecret) + config.AddOption("turn", "servers", turnServersString) + return CreateBackendServerForTestFromConfig(t, config) +} + +func CreateBackendServerForTestFromConfig(t *testing.T, config *goconf.ConfigFile) (*goconf.ConfigFile, *BackendServer, NatsClient, *Hub, *mux.Router, *httptest.Server, func()) { + r := mux.NewRouter() + registerBackendHandler(t, r) + + server := httptest.NewServer(r) + if config == nil { + config = goconf.NewConfigFile() + } + u, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + config.AddOption("backend", "allowed", u.Host) + config.AddOption("backend", "secret", string(testBackendSecret)) + config.AddOption("sessions", "hashkey", "12345678901234567890123456789012") + config.AddOption("sessions", "blockkey", "09876543210987654321098765432109") + config.AddOption("clients", "internalsecret", string(testInternalSecret)) + config.AddOption("geoip", "url", "none") + nats, err := NewLoopbackNatsClient() + if err != nil { + t.Fatal(err) + } + hub, err := NewHub(config, nats, r, "no-version") + if err != nil { + t.Fatal(err) + } + b, err := NewBackendServer(config, hub, "no-version") + if err != nil { + t.Fatal(err) + } + if err := b.Start(r); err != nil { + t.Fatal(err) + } + + go hub.Run() + + shutdown := func() { + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + WaitForHub(ctx, t, hub) + (nats).(*LoopbackNatsClient).waitForSubscriptionsEmpty(ctx, t) + server.Close() + } + + return config, b, nats, hub, r, server, shutdown +} + +func performBackendRequest(url string, body []byte) (*http.Response, error) { + request, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + rnd := newRandomString(32) + check := CalculateBackendChecksum(rnd, body, testBackendSecret) + request.Header.Set("Spreed-Signaling-Random", rnd) + request.Header.Set("Spreed-Signaling-Checksum", check) + client := &http.Client{} + return client.Do(request) +} + +func expectRoomlistEvent(n NatsClient, ch chan *nats.Msg, subject string, msgType string) (*EventServerMessage, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + select { + case message := <-ch: + if message.Subject != subject { + return nil, fmt.Errorf("Expected subject %s, got %s", subject, message.Subject) + } + var natsMsg NatsMessage + if err := n.Decode(message, &natsMsg); err != nil { + return nil, err + } + if natsMsg.Type != "message" || natsMsg.Message == nil { + return nil, fmt.Errorf("Expected message type message, got %+v", natsMsg) + } + + msg := natsMsg.Message + if msg.Type != "event" || msg.Event == nil { + return nil, fmt.Errorf("Expected message type event, got %+v", msg) + } + if msg.Event.Target != "roomlist" || msg.Event.Type != msgType { + return nil, fmt.Errorf("Expected roomlist %s event, got %+v", msgType, msg.Event) + } + return msg.Event, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func TestBackendServer_NoAuth(t *testing.T) { + _, _, _, _, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + roomId := "the-room-id" + data := []byte{'{', '}'} + request, err := http.NewRequest("POST", server.URL+"/api/v1/room/"+roomId, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + client := &http.Client{} + res, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != http.StatusForbidden { + t.Errorf("Expected error response, got %s: %s", res.Status, string(body)) + } +} + +func TestBackendServer_InvalidAuth(t *testing.T) { + _, _, _, _, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + roomId := "the-room-id" + data := []byte{'{', '}'} + request, err := http.NewRequest("POST", server.URL+"/api/v1/room/"+roomId, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Spreed-Signaling-Random", "hello") + request.Header.Set("Spreed-Signaling-Checksum", "world") + client := &http.Client{} + res, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != http.StatusForbidden { + t.Errorf("Expected error response, got %s: %s", res.Status, string(body)) + } +} + +func TestBackendServer_InvalidBody(t *testing.T) { + _, _, _, _, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + roomId := "the-room-id" + data := []byte{1, 2, 3, 4} // Invalid JSON + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != http.StatusBadRequest { + t.Errorf("Expected error response, got %s: %s", res.Status, string(body)) + } +} + +func TestBackendServer_UnsupportedRequest(t *testing.T) { + _, _, _, _, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + msg := &BackendServerRoomRequest{ + Type: "lala", + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + roomId := "the-room-id" + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != http.StatusBadRequest { + t.Errorf("Expected error response, got %s: %s", res.Status, string(body)) + } +} + +func TestBackendServer_RoomInvite(t *testing.T) { + _, _, n, _, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + userid := "test-userid" + roomProperties := json.RawMessage("{\"foo\":\"bar\"}") + + natsChan := make(chan *nats.Msg, 1) + subject := GetSubjectForUserId(userid) + sub, err := n.Subscribe(subject, natsChan) + if err != nil { + t.Fatal(err) + } + defer sub.Unsubscribe() + + msg := &BackendServerRoomRequest{ + Type: "invite", + Invite: &BackendRoomInviteRequest{ + UserIds: []string{ + userid, + }, + AllUserIds: []string{ + userid, + }, + Properties: &roomProperties, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + roomId := "the-room-id" + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + event, err := expectRoomlistEvent(n, natsChan, subject, "invite") + if err != nil { + t.Error(err) + } else if event.Invite == nil { + t.Errorf("Expected invite, got %+v", event) + } else if event.Invite.RoomId != roomId { + t.Errorf("Expected room %s, got %+v", roomId, event) + } else if event.Invite.Properties == nil || !bytes.Equal(*event.Invite.Properties, roomProperties) { + t.Errorf("Room properties don't match: expected %s, got %s", string(roomProperties), string(*event.Invite.Properties)) + } +} + +func TestBackendServer_RoomDisinvite(t *testing.T) { + _, _, n, hub, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId := "test-room" + if room, err := client.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + // Ignore "join" events. + if err := client.DrainMessages(ctx); err != nil { + t.Error(err) + } + + roomProperties := json.RawMessage("{\"foo\":\"bar\"}") + + natsChan := make(chan *nats.Msg, 1) + subject := GetSubjectForUserId(testDefaultUserId) + sub, err := n.Subscribe(subject, natsChan) + if err != nil { + t.Fatal(err) + } + defer sub.Unsubscribe() + + msg := &BackendServerRoomRequest{ + Type: "disinvite", + Disinvite: &BackendRoomDisinviteRequest{ + UserIds: []string{ + testDefaultUserId, + }, + SessionIds: []string{ + roomId + "-" + hello.Hello.SessionId, + }, + AllUserIds: []string{}, + Properties: &roomProperties, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + event, err := expectRoomlistEvent(n, natsChan, subject, "disinvite") + if err != nil { + t.Error(err) + } else if event.Disinvite == nil { + t.Errorf("Expected disinvite, got %+v", event) + } else if event.Disinvite.RoomId != roomId { + t.Errorf("Expected room %s, got %+v", roomId, event) + } else if event.Disinvite.Properties != nil { + t.Errorf("Room properties should be omitted, got %s", string(*event.Disinvite.Properties)) + } + + if message, err := client.RunUntilRoomlistDisinvite(ctx); err != nil { + t.Error(err) + } else if message.RoomId != roomId { + t.Errorf("Expected message for room %s, got %s", roomId, message.RoomId) + } + + if message, err := client.RunUntilMessage(ctx); err != nil && !websocket.IsCloseError(err, websocket.CloseNoStatusReceived) { + t.Errorf("Received unexpected error %s", err) + } else if err == nil { + t.Errorf("Server should have closed the connection, received %+v", *message) + } +} + +func TestBackendServer_RoomDisinviteDifferentRooms(t *testing.T) { + _, _, _, hub, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := client2.RunUntilHello(ctx); err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId1 := "test-room1" + if _, err := client1.JoinRoom(ctx, roomId1); err != nil { + t.Fatal(err) + } + roomId2 := "test-room2" + if _, err := client2.JoinRoom(ctx, roomId2); err != nil { + t.Fatal(err) + } + if hubRoom := hub.getRoom(roomId1); hubRoom != nil { + defer hubRoom.Close() + } + if hubRoom := hub.getRoom(roomId2); hubRoom != nil { + defer hubRoom.Close() + } + + // Ignore "join" events. + if err := client1.DrainMessages(ctx); err != nil { + t.Error(err) + } + if err := client2.DrainMessages(ctx); err != nil { + t.Error(err) + } + + msg := &BackendServerRoomRequest{ + Type: "disinvite", + Disinvite: &BackendRoomDisinviteRequest{ + UserIds: []string{ + testDefaultUserId, + }, + SessionIds: []string{ + roomId1 + "-" + hello1.Hello.SessionId, + }, + AllUserIds: []string{}, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId1, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + if message, err := client1.RunUntilRoomlistDisinvite(ctx); err != nil { + t.Error(err) + } else if message.RoomId != roomId1 { + t.Errorf("Expected message for room %s, got %s", roomId1, message.RoomId) + } + + if message, err := client1.RunUntilMessage(ctx); err != nil && !websocket.IsCloseError(err, websocket.CloseNoStatusReceived) { + t.Errorf("Received unexpected error %s", err) + } else if err == nil { + t.Errorf("Server should have closed the connection, received %+v", *message) + } + + if message, err := client2.RunUntilRoomlistDisinvite(ctx); err != nil { + t.Error(err) + } else if message.RoomId != roomId1 { + t.Errorf("Expected message for room %s, got %s", roomId1, message.RoomId) + } + + msg = &BackendServerRoomRequest{ + Type: "update", + Update: &BackendRoomUpdateRequest{ + UserIds: []string{ + testDefaultUserId, + }, + }, + } + + data, err = json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err = performBackendRequest(server.URL+"/api/v1/room/"+roomId2, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err = ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + if message, err := client2.RunUntilRoomlistUpdate(ctx); err != nil { + t.Error(err) + } else if message.RoomId != roomId2 { + t.Errorf("Expected message for room %s, got %s", roomId2, message.RoomId) + } + +} + +func TestBackendServer_RoomUpdate(t *testing.T) { + _, _, n, hub, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + roomId := "the-room-id" + emptyProperties := json.RawMessage("{}") + room, err := hub.createRoom(roomId, &emptyProperties) + if err != nil { + t.Fatalf("Could not create room: %s", err) + } + defer room.Close() + + userid := "test-userid" + roomProperties := json.RawMessage("{\"foo\":\"bar\"}") + + natsChan := make(chan *nats.Msg, 1) + subject := GetSubjectForUserId(userid) + sub, err := n.Subscribe(subject, natsChan) + if err != nil { + t.Fatal(err) + } + defer sub.Unsubscribe() + + msg := &BackendServerRoomRequest{ + Type: "update", + Update: &BackendRoomUpdateRequest{ + UserIds: []string{ + userid, + }, + Properties: &roomProperties, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + event, err := expectRoomlistEvent(n, natsChan, subject, "update") + if err != nil { + t.Error(err) + } else if event.Update == nil { + t.Errorf("Expected update, got %+v", event) + } else if event.Update.RoomId != roomId { + t.Errorf("Expected room %s, got %+v", roomId, event) + } else if event.Update.Properties == nil || !bytes.Equal(*event.Update.Properties, roomProperties) { + t.Errorf("Room properties don't match: expected %s, got %s", string(roomProperties), string(*event.Update.Properties)) + } + + // TODO: Use event to wait for NATS messages. + time.Sleep(10 * time.Millisecond) + + room = hub.getRoom(roomId) + if room == nil { + t.Fatalf("Room %s does not exist", roomId) + } + if string(*room.Properties()) != string(roomProperties) { + t.Errorf("Expected properties %s for room %s, got %s", string(roomProperties), room.Id(), string(*room.Properties())) + } +} + +func TestBackendServer_RoomDelete(t *testing.T) { + _, _, n, hub, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + roomId := "the-room-id" + emptyProperties := json.RawMessage("{}") + if _, err := hub.createRoom(roomId, &emptyProperties); err != nil { + t.Fatalf("Could not create room: %s", err) + } + + userid := "test-userid" + + natsChan := make(chan *nats.Msg, 1) + subject := GetSubjectForUserId(userid) + sub, err := n.Subscribe(subject, natsChan) + if err != nil { + t.Fatal(err) + } + defer sub.Unsubscribe() + + msg := &BackendServerRoomRequest{ + Type: "delete", + Delete: &BackendRoomDeleteRequest{ + UserIds: []string{ + userid, + }, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + // A deleted room is signalled as a "disinvite" event. + event, err := expectRoomlistEvent(n, natsChan, subject, "disinvite") + if err != nil { + t.Error(err) + } else if event.Disinvite == nil { + t.Errorf("Expected disinvite, got %+v", event) + } else if event.Disinvite.RoomId != roomId { + t.Errorf("Expected room %s, got %+v", roomId, event) + } else if event.Disinvite.Properties != nil { + t.Errorf("Room properties should be omitted, got %s", string(*event.Disinvite.Properties)) + } + + // TODO: Use event to wait for NATS messages. + time.Sleep(10 * time.Millisecond) + + room := hub.getRoom(roomId) + if room != nil { + t.Errorf("Room %s should have been deleted", roomId) + } +} + +func TestBackendServer_ParticipantsUpdatePermissions(t *testing.T) { + _, _, _, hub, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId) + if session1 == nil { + t.Fatalf("Session %s does not exist", hello1.Hello.SessionId) + } + session2 := hub.GetSessionByPublicId(hello2.Hello.SessionId) + if session2 == nil { + t.Fatalf("Session %s does not exist", hello2.Hello.SessionId) + } + + // Sessions have all permissions initially (fallback for old-style sessions). + assertSessionHasPermission(t, session1, PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasPermission(t, session1, PERMISSION_MAY_PUBLISH_SCREEN) + assertSessionHasPermission(t, session2, PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasPermission(t, session2, PERMISSION_MAY_PUBLISH_SCREEN) + + // Join room by id. + roomId := "test-room" + if room, err := client1.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + if room, err := client2.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + // Ignore "join" events. + if err := client1.DrainMessages(ctx); err != nil { + t.Error(err) + } + if err := client2.DrainMessages(ctx); err != nil { + t.Error(err) + } + + msg := &BackendServerRoomRequest{ + Type: "participants", + Participants: &BackendRoomParticipantsRequest{ + Changed: []map[string]interface{}{ + map[string]interface{}{ + "sessionId": roomId + "-" + hello1.Hello.SessionId, + "permissions": []Permission{PERMISSION_MAY_PUBLISH_MEDIA}, + }, + map[string]interface{}{ + "sessionId": roomId + "-" + hello2.Hello.SessionId, + "permissions": []Permission{PERMISSION_MAY_PUBLISH_SCREEN}, + }, + }, + Users: []map[string]interface{}{ + map[string]interface{}{ + "sessionId": roomId + "-" + hello1.Hello.SessionId, + "permissions": []Permission{PERMISSION_MAY_PUBLISH_MEDIA}, + }, + map[string]interface{}{ + "sessionId": roomId + "-" + hello2.Hello.SessionId, + "permissions": []Permission{PERMISSION_MAY_PUBLISH_SCREEN}, + }, + }, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + // TODO: Use event to wait for NATS messages. + time.Sleep(10 * time.Millisecond) + + assertSessionHasPermission(t, session1, PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasNotPermission(t, session1, PERMISSION_MAY_PUBLISH_SCREEN) + assertSessionHasNotPermission(t, session2, PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasPermission(t, session2, PERMISSION_MAY_PUBLISH_SCREEN) +} + +func TestBackendServer_ParticipantsUpdateEmptyPermissions(t *testing.T) { + _, _, _, hub, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + session := hub.GetSessionByPublicId(hello.Hello.SessionId) + if session == nil { + t.Fatalf("Session %s does not exist", hello.Hello.SessionId) + } + + // Sessions have all permissions initially (fallback for old-style sessions). + assertSessionHasPermission(t, session, PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasPermission(t, session, PERMISSION_MAY_PUBLISH_SCREEN) + + // Join room by id. + roomId := "test-room" + room, err := client.JoinRoom(ctx, roomId) + if err != nil { + t.Fatal(err) + } + if hubRoom := hub.getRoom(room.Room.RoomId); hubRoom != nil { + defer hubRoom.Close() + } + if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + // Ignore "join" events. + if err := client.DrainMessages(ctx); err != nil { + t.Error(err) + } + + // Updating with empty permissions upgrades to non-old-style and removes + // all previously available permissions. + msg := &BackendServerRoomRequest{ + Type: "participants", + Participants: &BackendRoomParticipantsRequest{ + Changed: []map[string]interface{}{ + map[string]interface{}{ + "sessionId": roomId + "-" + hello.Hello.SessionId, + "permissions": []Permission{}, + }, + }, + Users: []map[string]interface{}{ + map[string]interface{}{ + "sessionId": roomId + "-" + hello.Hello.SessionId, + "permissions": []Permission{}, + }, + }, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + // TODO: Use event to wait for NATS messages. + time.Sleep(10 * time.Millisecond) + + assertSessionHasNotPermission(t, session, PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasNotPermission(t, session, PERMISSION_MAY_PUBLISH_SCREEN) +} + +func TestBackendServer_ParticipantsUpdateTimeout(t *testing.T) { + _, _, _, hub, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId := "test-room" + if room, err := client1.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + if room, err := client2.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + // We will receive "joined" events for all clients. The ordering is not + // defined as messages are processed and sent by asynchronous NATS handlers. + msg1_1, err := client1.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + msg1_2, err := client1.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + msg2_1, err := client2.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + msg2_2, err := client2.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + + if err := client1.checkMessageJoined(msg1_1, hello1.Hello); err != nil { + // Ordering is "joined" from client 2, then from client 1 + if err := client1.checkMessageJoined(msg1_1, hello2.Hello); err != nil { + t.Error(err) + } + if err := client1.checkMessageJoined(msg1_2, hello1.Hello); err != nil { + t.Error(err) + } + } else { + // Ordering is "joined" from client 1, then from client 2 + if err := client1.checkMessageJoined(msg1_2, hello2.Hello); err != nil { + t.Error(err) + } + } + if err := client2.checkMessageJoined(msg2_1, hello1.Hello); err != nil { + // Ordering is "joined" from client 2, then from client 1 + if err := client2.checkMessageJoined(msg2_1, hello2.Hello); err != nil { + t.Error(err) + } + if err := client2.checkMessageJoined(msg2_2, hello1.Hello); err != nil { + t.Error(err) + } + } else { + // Ordering is "joined" from client 1, then from client 2 + if err := client2.checkMessageJoined(msg2_2, hello2.Hello); err != nil { + t.Error(err) + } + } + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + msg := &BackendServerRoomRequest{ + Type: "incall", + InCall: &BackendRoomInCallRequest{ + InCall: json.RawMessage("7"), + Changed: []map[string]interface{}{ + map[string]interface{}{ + "sessionId": roomId + "-" + hello1.Hello.SessionId, + "inCall": 7, + }, + map[string]interface{}{ + "sessionId": "unknown-room-session-id", + "inCall": 3, + }, + }, + Users: []map[string]interface{}{ + map[string]interface{}{ + "sessionId": roomId + "-" + hello1.Hello.SessionId, + "inCall": 7, + }, + map[string]interface{}{ + "sessionId": "unknown-room-session-id", + "inCall": 3, + }, + }, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + }() + + // Ensure the first request is being processed. + time.Sleep(100 * time.Millisecond) + + wg.Add(1) + go func() { + defer wg.Done() + msg := &BackendServerRoomRequest{ + Type: "incall", + InCall: &BackendRoomInCallRequest{ + InCall: json.RawMessage("7"), + Changed: []map[string]interface{}{ + map[string]interface{}{ + "sessionId": roomId + "-" + hello1.Hello.SessionId, + "inCall": 7, + }, + map[string]interface{}{ + "sessionId": roomId + "-" + hello2.Hello.SessionId, + "inCall": 3, + }, + }, + Users: []map[string]interface{}{ + map[string]interface{}{ + "sessionId": roomId + "-" + hello1.Hello.SessionId, + "inCall": 7, + }, + map[string]interface{}{ + "sessionId": roomId + "-" + hello2.Hello.SessionId, + "inCall": 3, + }, + }, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + }() + + wg.Wait() + + msg1_a, err := client1.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + if in_call_1, err := checkMessageParticipantsInCall(msg1_a); err != nil { + t.Error(err) + } else if len(in_call_1.Users) != 2 { + msg1_b, err := client1.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + if in_call_2, err := checkMessageParticipantsInCall(msg1_b); err != nil { + t.Error(err) + } else if len(in_call_2.Users) != 2 { + t.Errorf("Wrong number of users received: %d, expected 2", len(in_call_2.Users)) + } + } + + msg2_a, err := client2.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + if in_call_1, err := checkMessageParticipantsInCall(msg2_a); err != nil { + t.Error(err) + } else if len(in_call_1.Users) != 2 { + msg2_b, err := client2.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + if in_call_2, err := checkMessageParticipantsInCall(msg2_b); err != nil { + t.Error(err) + } else if len(in_call_2.Users) != 2 { + t.Errorf("Wrong number of users received: %d, expected 2", len(in_call_2.Users)) + } + } + + ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second+100*time.Millisecond) + defer cancel2() + + msg1_c, err := client1.RunUntilMessage(ctx2) + if msg1_c != nil { + if in_call_2, err := checkMessageParticipantsInCall(msg1_c); err != nil { + t.Error(err) + } else if len(in_call_2.Users) != 2 { + t.Errorf("Wrong number of users received: %d, expected 2", len(in_call_2.Users)) + } + } + + ctx3, cancel3 := context.WithTimeout(context.Background(), time.Second+100*time.Millisecond) + defer cancel3() + msg2_c, err := client2.RunUntilMessage(ctx3) + if msg2_c != nil { + if in_call_2, err := checkMessageParticipantsInCall(msg2_c); err != nil { + t.Error(err) + } else if len(in_call_2.Users) != 2 { + t.Errorf("Wrong number of users received: %d, expected 2", len(in_call_2.Users)) + } + } +} + +func TestBackendServer_RoomMessage(t *testing.T) { + _, _, _, hub, _, server, shutdown := CreateBackendServerForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + if err := client.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + _, err := client.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId := "test-room" + if room, err := client.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + // Ignore "join" events. + if err := client.DrainMessages(ctx); err != nil { + t.Error(err) + } + + messageData := json.RawMessage("{\"foo\":\"bar\"}") + msg := &BackendServerRoomRequest{ + Type: "message", + Message: &BackendRoomMessageRequest{ + Data: &messageData, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + message, err := client.RunUntilRoomMessage(ctx) + if err != nil { + t.Error(err) + } else if message.RoomId != roomId { + t.Errorf("Expected message for room %s, got %s", roomId, message.RoomId) + } else if !bytes.Equal(messageData, *message.Data) { + t.Errorf("Expected message data %s, got %s", string(messageData), string(*message.Data)) + } +} + +func TestBackendServer_TurnCredentials(t *testing.T) { + _, _, _, _, _, server, shutdown := CreateBackendServerForTestWithTurn(t) + defer shutdown() + + q := make(url.Values) + q.Set("service", "turn") + q.Set("api", turnApiKey) + request, err := http.NewRequest("GET", server.URL+"/turn/credentials?"+q.Encode(), nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{} + res, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + var cred TurnCredentials + if err := json.Unmarshal(body, &cred); err != nil { + t.Fatal(err) + } + + m := hmac.New(sha1.New, []byte(turnSecret)) + m.Write([]byte(cred.Username)) + password := base64.StdEncoding.EncodeToString(m.Sum(nil)) + if cred.Password != password { + t.Errorf("Expected password %s, got %s", password, cred.Password) + } + if cred.TTL != int64((24 * time.Hour).Seconds()) { + t.Errorf("Expected a TTL of %d, got %d", int64((24 * time.Hour).Seconds()), cred.TTL) + } + if !reflect.DeepEqual(cred.URIs, turnServers) { + t.Errorf("Expected the list of servers as %s, got %s", turnServers, cred.URIs) + } +} diff --git a/src/signaling/client.go b/src/signaling/client.go new file mode 100644 index 0000000..97171fb --- /dev/null +++ b/src/signaling/client.go @@ -0,0 +1,543 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "bytes" + "encoding/json" + "log" + "net" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + "unsafe" + + "github.com/gorilla/websocket" + "github.com/mailru/easyjson" +) + +const ( + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 + + // Maximum message size allowed from peer. + maxMessageSize = 64 * 1024 +) + +var ( + _noCountry string = "no-country" + noCountry *string = &_noCountry + + _loopback string = "loopback" + loopback *string = &_loopback + + _unknownCountry string = "unknown-country" + unknownCountry *string = &_unknownCountry +) + +var ( + InvalidFormat = NewError("invalid_format", "Invalid data format.") + + bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, + } +) + +type Client struct { + hub *Hub + conn *websocket.Conn + addr string + agent string + closed uint32 + country *string + + session unsafe.Pointer + + mu sync.Mutex + + natsReceiver chan *NatsMessage + closeChan chan bool +} + +func NewClient(hub *Hub, conn *websocket.Conn, remoteAddress string, agent string) (*Client, error) { + remoteAddress = strings.TrimSpace(remoteAddress) + if remoteAddress == "" { + remoteAddress = "unknown remote address" + } + agent = strings.TrimSpace(agent) + if agent == "" { + agent = "unknown user agent" + } + client := &Client{ + hub: hub, + conn: conn, + addr: remoteAddress, + agent: agent, + natsReceiver: make(chan *NatsMessage, 64), + closeChan: make(chan bool, 1), + } + return client, nil +} + +func (c *Client) IsConnected() bool { + return atomic.LoadUint32(&c.closed) == 0 +} + +func (c *Client) IsAuthenticated() bool { + return c.GetSession() != nil +} + +func (c *Client) GetSession() *ClientSession { + return (*ClientSession)(atomic.LoadPointer(&c.session)) +} + +func (c *Client) SetSession(session *ClientSession) { + atomic.StorePointer(&c.session, unsafe.Pointer(session)) +} + +func (c *Client) RemoteAddr() string { + return c.addr +} + +func (c *Client) UserAgent() string { + return c.agent +} + +func (c *Client) Country() string { + if c.country == nil { + if c.hub.geoip == nil { + c.country = unknownCountry + return *c.country + } + ip := net.ParseIP(c.RemoteAddr()) + if ip == nil { + c.country = noCountry + return *c.country + } else if ip.IsLoopback() { + c.country = loopback + return *c.country + } + + country, err := c.hub.geoip.LookupCountry(ip) + if err != nil { + log.Printf("Could not lookup country for %s", ip) + c.country = unknownCountry + return *c.country + } + c.country = &country + } + + return *c.country +} + +func (c *Client) Close() { + if !atomic.CompareAndSwapUint32(&c.closed, 0, 1) { + return + } + + c.closeChan <- true + + c.hub.processUnregister(c) + c.SetSession(nil) + + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() +} + +func (c *Client) SendError(e *Error) bool { + message := &ServerMessage{ + Type: "error", + Error: e, + } + return c.SendMessage(message) +} + +func (c *Client) SendRoom(message *ClientMessage, room *Room) bool { + response := &ServerMessage{ + Type: "room", + } + if message != nil { + response.Id = message.Id + } + if room == nil { + response.Room = &RoomServerMessage{ + RoomId: "", + } + } else { + response.Room = &RoomServerMessage{ + RoomId: room.id, + Properties: room.properties, + } + } + return c.SendMessage(response) +} + +func (c *Client) SendHelloResponse(message *ClientMessage, session *ClientSession) bool { + response := &ServerMessage{ + Id: message.Id, + Type: "hello", + Hello: &HelloServerMessage{ + Version: HelloVersion, + SessionId: session.PublicId(), + ResumeId: session.PrivateId(), + UserId: session.UserId(), + Server: c.hub.GetServerInfo(), + }, + } + return c.SendMessage(response) +} + +func (c *Client) SendByeResponse(message *ClientMessage) bool { + return c.SendByeResponseWithReason(message, "") +} + +func (c *Client) SendByeResponseWithReason(message *ClientMessage, reason string) bool { + response := &ServerMessage{ + Type: "bye", + Bye: &ByeServerMessage{}, + } + if message != nil { + response.Id = message.Id + } + if reason != "" { + response.Bye.Reason = reason + } + return c.SendMessage(response) +} + +func (c *Client) SendMessage(message *ServerMessage) bool { + return c.writeMessage(message) +} + +func (c *Client) readPump() { + defer func() { + c.Close() + }() + + addr := c.RemoteAddr() + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + if conn == nil { + log.Printf("Connection from %s closed while starting readPump", addr) + return + } + + conn.SetReadLimit(maxMessageSize) + conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(msg string) error { + now := time.Now() + conn.SetReadDeadline(now.Add(pongWait)) + if msg == "" { + return nil + } + if ts, err := strconv.ParseInt(msg, 10, 64); err == nil { + rtt := now.Sub(time.Unix(0, ts)) + rtt_ms := rtt.Nanoseconds() / time.Millisecond.Nanoseconds() + if session := c.GetSession(); session != nil { + log.Printf("Client %s has RTT of %d ms (%s)", session.PublicId(), rtt_ms, rtt) + } else { + log.Printf("Client from %s has RTT of %d ms (%s)", addr, rtt_ms, rtt) + } + } + return nil + }) + + decodeBuffer := bufferPool.Get().(*bytes.Buffer) + defer bufferPool.Put(decodeBuffer) + for { + messageType, reader, err := conn.NextReader() + if err != nil { + if websocket.IsUnexpectedCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseNoStatusReceived) { + if session := c.GetSession(); session != nil { + log.Printf("Error reading from client %s: %v", session.PublicId(), err) + } else { + log.Printf("Error reading from %s: %v", addr, err) + } + } + break + } + + if messageType != websocket.TextMessage { + if session := c.GetSession(); session != nil { + log.Printf("Unsupported message type %v from client %s", messageType, session.PublicId()) + } else { + log.Printf("Unsupported message type %v from %s", messageType, addr) + } + c.SendError(InvalidFormat) + continue + } + + decodeBuffer.Reset() + if _, err := decodeBuffer.ReadFrom(reader); err != nil { + if session := c.GetSession(); session != nil { + log.Printf("Error reading message from client %s: %v", session.PublicId(), err) + } else { + log.Printf("Error reading message from %s: %v", addr, err) + } + break + } + + var message ClientMessage + if err := message.UnmarshalJSON(decodeBuffer.Bytes()); err != nil { + if session := c.GetSession(); session != nil { + log.Printf("Error decoding message from client %s: %v", session.PublicId(), err) + } else { + log.Printf("Error decoding message from %s: %v", addr, err) + } + c.SendError(InvalidFormat) + continue + } + + if err := message.CheckValid(); err != nil { + if session := c.GetSession(); session != nil { + log.Printf("Invalid message %+v from client %s: %v", message, session.PublicId(), err) + } else { + log.Printf("Invalid message %+v from %s: %v", message, addr, err) + } + c.SendMessage(message.NewErrorServerMessage(InvalidFormat)) + continue + } + + c.hub.processMessage(c, &message) + } +} + +func (c *Client) writeInternal(message json.Marshaler) bool { + var closeData []byte + + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + writer, err := c.conn.NextWriter(websocket.TextMessage) + if err == nil { + if m, ok := (interface{}(message)).(easyjson.Marshaler); ok { + _, err = easyjson.MarshalToWriter(m, writer) + } else { + err = json.NewEncoder(writer).Encode(message) + } + } + if err == nil { + err = writer.Close() + } + if err != nil { + if err == websocket.ErrCloseSent { + // Already sent a "close", won't be able to send anything else. + return false + } + + if session := c.GetSession(); session != nil { + log.Printf("Could not send message %+v to client %s: %v", message, session.PublicId(), err) + } else { + log.Printf("Could not send message %+v to %s: %v", message, c.RemoteAddr(), err) + } + closeData = websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "") + goto close + } + return true + +close: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.CloseMessage, closeData); err != nil { + if session := c.GetSession(); session != nil { + log.Printf("Could not send close message to client %s: %v", session.PublicId(), err) + } else { + log.Printf("Could not send close message to %s: %v", c.RemoteAddr(), err) + } + } + return false +} + +func (c *Client) writeError(e error) bool { + message := &ServerMessage{ + Type: "error", + Error: NewError("internal_error", e.Error()), + } + c.mu.Lock() + defer c.mu.Unlock() + if c.conn == nil { + return false + } + + if !c.writeMessageLocked(message) { + return false + } + + closeData := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, e.Error()) + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.CloseMessage, closeData); err != nil { + if session := c.GetSession(); session != nil { + log.Printf("Could not send close message to client %s: %v", session.PublicId(), err) + } else { + log.Printf("Could not send close message to %s: %v", c.RemoteAddr(), err) + } + } + return false +} + +func (c *Client) writeMessage(message *ServerMessage) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn == nil { + return false + } + + return c.writeMessageLocked(message) +} + +func (c *Client) writeMessageLocked(message *ServerMessage) bool { + if !c.writeInternal(message) { + return false + } + + session := c.GetSession() + if message.CloseAfterSend(session) { + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + if session != nil { + go session.Close() + } + go c.Close() + return false + } + + return true +} + +func (c *Client) ProcessNatsMessage(msg *NatsMessage) bool { + switch msg.Type { + case "message": + if msg.Message == nil { + log.Printf("Received NATS message without payload: %+v\n", msg) + return true + } + + switch msg.Message.Type { + case "message": + session := c.GetSession() + if session != nil && msg.Message.Message != nil && + msg.Message.Message.Sender != nil && + msg.Message.Message.Sender.SessionId == session.PublicId() { + // Don't send message back to sender (can happen if sent to user or room) + return true + } + case "control": + session := c.GetSession() + if session != nil && msg.Message.Control != nil && + msg.Message.Control.Sender != nil && + msg.Message.Control.Sender.SessionId == session.PublicId() { + // Don't send message back to sender (can happen if sent to user or room) + return true + } + case "event": + if msg.Message.Event.Target == "participants" && + msg.Message.Event.Type == "update" { + m := msg.Message.Event.Update + users := make(map[string]bool) + for _, entry := range m.Users { + users[entry["sessionId"].(string)] = true + } + for _, entry := range m.Changed { + if users[entry["sessionId"].(string)] { + continue + } + m.Users = append(m.Users, entry) + } + // TODO(jojo): Only send all users if current session id has + // changed its "inCall" flag to true. + m.Changed = nil + } + } + + return c.writeMessage(msg.Message) + default: + log.Printf("Received NATS message with unsupported type %s: %+v\n", msg.Type, msg) + return true + } +} + +func (c *Client) sendPing() bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn == nil { + return false + } + + now := time.Now().UnixNano() + msg := strconv.FormatInt(now, 10) + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, []byte(msg)); err != nil { + if session := c.GetSession(); session != nil { + log.Printf("Could not send ping to client %s: %v", session.PublicId(), err) + } else { + log.Printf("Could not send ping to %s: %v", c.RemoteAddr(), err) + } + return false + } + + return true +} + +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + }() + + // Fetch initial RTT before any messages have been sent to the client. + c.sendPing() + for { + select { + case message := <-c.natsReceiver: + if !c.ProcessNatsMessage(message) { + return + } + n := len(c.natsReceiver) + for i := 0; i < n; i++ { + if !c.ProcessNatsMessage(<-c.natsReceiver) { + return + } + } + case <-ticker.C: + if !c.sendPing() { + return + } + case <-c.closeChan: + return + } + } +} diff --git a/src/signaling/clientsession.go b/src/signaling/clientsession.go new file mode 100644 index 0000000..7065e95 --- /dev/null +++ b/src/signaling/clientsession.go @@ -0,0 +1,715 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "encoding/base64" + "encoding/json" + "log" + "net/url" + "sync" + "sync/atomic" + "time" + "unsafe" + + "github.com/nats-io/go-nats" + + "golang.org/x/net/context" +) + +var ( + // Sessions expire 30 seconds after the connection closed. + sessionExpireDuration = 30 * time.Second + + // Warn if a session has 32 or more pending messages. + warnPendingMessagesCount = 32 +) + +type ClientSession struct { + running int32 + hub *Hub + privateId string + publicId string + data *SessionIdData + + clientType string + features []string + userId string + userData *json.RawMessage + + supportsPermissions bool + permissions map[Permission]bool + + backendUrl string + parsedBackendUrl *url.URL + + natsReceiver chan *nats.Msg + stopRun chan bool + runStopped chan bool + expires time.Time + + mu sync.Mutex + + client *Client + room unsafe.Pointer + roomSessionId string + + userSubscription NatsSubscription + sessionSubscription NatsSubscription + roomSubscription NatsSubscription + + publishers map[string]McuPublisher + subscribers map[string]McuSubscriber + + pendingClientMessages []*NatsMessage +} + +func NewClientSession(hub *Hub, privateId string, publicId string, data *SessionIdData, hello *HelloClientMessage, auth *BackendClientAuthResponse) (*ClientSession, error) { + s := &ClientSession{ + hub: hub, + privateId: privateId, + publicId: publicId, + data: data, + + clientType: hello.Auth.Type, + features: hello.Features, + userId: auth.UserId, + userData: auth.User, + + backendUrl: hello.Auth.Url, + parsedBackendUrl: hello.Auth.parsedUrl, + + natsReceiver: make(chan *nats.Msg, 64), + stopRun: make(chan bool, 1), + runStopped: make(chan bool, 1), + } + if err := s.SubscribeNats(hub.nats); err != nil { + return nil, err + } + atomic.StoreInt32(&s.running, 1) + go s.run() + return s, nil +} + +func (s *ClientSession) PrivateId() string { + return s.privateId +} + +func (s *ClientSession) PublicId() string { + return s.publicId +} + +func (s *ClientSession) RoomSessionId() string { + return s.roomSessionId +} + +func (s *ClientSession) Data() *SessionIdData { + return s.data +} + +func (s *ClientSession) ClientType() string { + return s.clientType +} + +func (s *ClientSession) GetFeatures() []string { + return s.features +} + +func (s *ClientSession) HasFeature(feature string) bool { + for _, f := range s.features { + if f == feature { + return true + } + } + return false +} + +func (s *ClientSession) HasPermission(permission Permission) bool { + s.mu.Lock() + defer s.mu.Unlock() + if !s.supportsPermissions { + // Old-style session that doesn't receive permissions from Nextcloud. + return true + } + + if val, found := s.permissions[permission]; found { + return val + } + return false +} + +func permissionsEqual(a, b map[Permission]bool) bool { + if a == nil && b == nil { + return true + } else if a != nil && b == nil { + return false + } else if a == nil && b != nil { + return false + } + if len(a) != len(b) { + return false + } + + for k, v1 := range a { + if v2, found := b[k]; !found || v1 != v2 { + return false + } + } + return true +} + +func (s *ClientSession) SetPermissions(permissions []Permission) { + var p map[Permission]bool + for _, permission := range permissions { + if p == nil { + p = make(map[Permission]bool) + } + p[permission] = true + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.supportsPermissions && permissionsEqual(s.permissions, p) { + return + } + + s.permissions = p + s.supportsPermissions = true + log.Printf("Permissions of session %s changed: %s", s.PublicId(), permissions) +} + +func (s *ClientSession) BackendUrl() string { + return s.backendUrl +} + +func (s *ClientSession) ParsedBackendUrl() *url.URL { + return s.parsedBackendUrl +} + +func (s *ClientSession) UserId() string { + return s.userId +} + +func (s *ClientSession) UserData() *json.RawMessage { + return s.userData +} + +func (s *ClientSession) run() { +loop: + for { + select { + case msg := <-s.natsReceiver: + s.processClientMessage(msg) + case <-s.stopRun: + break loop + } + } + s.runStopped <- true +} + +func (s *ClientSession) StartExpire() { + // The hub mutex must be held when calling this method. + s.expires = time.Now().Add(sessionExpireDuration) + s.hub.expiredSessions[s] = true +} + +func (s *ClientSession) StopExpire() { + // The hub mutex must be held when calling this method. + delete(s.hub.expiredSessions, s) +} + +func (s *ClientSession) IsExpired(now time.Time) bool { + return now.After(s.expires) +} + +func (s *ClientSession) SetRoom(room *Room) { + atomic.StorePointer(&s.room, unsafe.Pointer(room)) +} + +func (s *ClientSession) GetRoom() *Room { + return (*Room)(atomic.LoadPointer(&s.room)) +} + +func (s *ClientSession) releaseMcuObjects() { + if len(s.publishers) > 0 { + go func(publishers map[string]McuPublisher) { + ctx := context.TODO() + for _, publisher := range publishers { + publisher.Close(ctx) + } + }(s.publishers) + s.publishers = nil + } + if len(s.subscribers) > 0 { + go func(subscribers map[string]McuSubscriber) { + ctx := context.TODO() + for _, subscriber := range subscribers { + subscriber.Close(ctx) + } + }(s.subscribers) + s.subscribers = nil + } +} + +func (s *ClientSession) Close() { + s.closeAndWait(true) +} + +func (s *ClientSession) closeAndWait(wait bool) { + s.hub.removeSession(s) + + s.mu.Lock() + defer s.mu.Unlock() + if s.userSubscription != nil { + s.userSubscription.Unsubscribe() + s.userSubscription = nil + } + if s.sessionSubscription != nil { + s.sessionSubscription.Unsubscribe() + s.sessionSubscription = nil + } + s.releaseMcuObjects() + s.clearClientLocked(nil) + if atomic.CompareAndSwapInt32(&s.running, 1, 0) { + s.stopRun <- true + // Only wait if called from outside the Session goroutine. + if wait { + s.mu.Unlock() + // Wait for Session goroutine to stop + <-s.runStopped + s.mu.Lock() + } + } +} + +func GetSubjectForUserId(userId string) string { + // The NATS client doesn't work if a subject contains spaces. As the user id + // can have an arbitrary format, we need to make sure the subject is valid. + // See "https://github.com/nats-io/nats.js/issues/158" for a similar report. + return "user." + base64.StdEncoding.EncodeToString([]byte(userId)) +} + +func (s *ClientSession) SubscribeNats(n NatsClient) error { + s.mu.Lock() + defer s.mu.Unlock() + + var err error + if s.userId != "" { + if s.userSubscription, err = n.Subscribe(GetSubjectForUserId(s.userId), s.natsReceiver); err != nil { + return err + } + } + + if s.sessionSubscription, err = n.Subscribe("session."+s.publicId, s.natsReceiver); err != nil { + return err + } + + return nil +} + +func (s *ClientSession) SubscribeRoomNats(n NatsClient, roomid string, roomSessionId string) error { + s.mu.Lock() + defer s.mu.Unlock() + + var err error + if s.roomSubscription, err = n.Subscribe("room."+roomid, s.natsReceiver); err != nil { + return err + } + + if roomSessionId != "" { + if err = s.hub.roomSessions.SetRoomSession(s, roomSessionId); err != nil { + s.doUnsubscribeRoomNats(true) + return err + } + } + log.Printf("Session %s joined room %s with room session id %s\n", s.PublicId(), roomid, roomSessionId) + s.roomSessionId = roomSessionId + return nil +} + +func (s *ClientSession) LeaveCall() { + s.mu.Lock() + defer s.mu.Unlock() + + room := s.GetRoom() + if room == nil { + return + } + + log.Printf("Session %s left call %s\n", s.PublicId(), room.Id()) + s.releaseMcuObjects() +} + +func (s *ClientSession) LeaveRoom(notify bool) *Room { + s.mu.Lock() + defer s.mu.Unlock() + + room := s.GetRoom() + if room == nil { + return nil + } + + s.doUnsubscribeRoomNats(notify) + s.SetRoom(nil) + s.releaseMcuObjects() + room.RemoveSession(s) + return room +} + +func (s *ClientSession) UnsubscribeRoomNats() { + s.mu.Lock() + defer s.mu.Unlock() + + s.doUnsubscribeRoomNats(true) +} + +func (s *ClientSession) doUnsubscribeRoomNats(notify bool) { + if s.roomSubscription != nil { + s.roomSubscription.Unsubscribe() + s.roomSubscription = nil + } + s.hub.roomSessions.DeleteRoomSession(s) + room := s.GetRoom() + if notify && room != nil && s.roomSessionId != "" { + // Notify + go func(sid string) { + ctx := context.Background() + request := NewBackendClientRoomRequest(room.Id(), s.UserId(), sid) + request.Room.Action = "leave" + var response map[string]interface{} + if err := s.hub.backend.PerformJSONRequest(ctx, s.ParsedBackendUrl(), request, &response); err != nil { + log.Printf("Could not notify about room session %s left room %s: %s", sid, room.Id(), err) + } else { + log.Printf("Removed room session %s: %+v", sid, response) + } + }(s.roomSessionId) + } + s.roomSessionId = "" +} + +func (s *ClientSession) ClearClient(client *Client) { + s.mu.Lock() + defer s.mu.Unlock() + + s.clearClientLocked(client) +} + +func (s *ClientSession) clearClientLocked(client *Client) { + if s.client == nil { + return + } else if client != nil && s.client != client { + log.Printf("Trying to clear other client in session %s", s.PublicId()) + return + } + + prevClient := s.client + s.client = nil + prevClient.SetSession(nil) +} + +func (s *ClientSession) GetClient() *Client { + s.mu.Lock() + defer s.mu.Unlock() + + return s.client +} + +func (s *ClientSession) SetClient(client *Client) *Client { + if client == nil { + panic("Use ClearClient to set the client to nil") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if client == s.client { + // No change + return nil + } + + client.SetSession(s) + prev := s.client + if prev != nil { + s.clearClientLocked(prev) + } + s.client = client + return prev +} + +func (s *ClientSession) sendCandidate(client McuClient, sender string, streamType string, candidate interface{}) { + candidate_message := &AnswerOfferMessage{ + To: s.PublicId(), + From: sender, + Type: "candidate", + RoomType: streamType, + Payload: map[string]interface{}{ + "candidate": candidate, + }, + } + candidate_data, err := json.Marshal(candidate_message) + if err != nil { + log.Println("Could not serialize candidate", candidate_message, err) + return + } + response_message := &ServerMessage{ + Type: "message", + Message: &MessageServerMessage{ + Sender: &MessageServerMessageSender{ + Type: "session", + SessionId: sender, + }, + Data: (*json.RawMessage)(&candidate_data), + }, + } + + if c := s.client; c != nil { + c.SendMessage(response_message) + } else { + // TODO(jojo): Should we store the candidate and send when a client is connected again? + log.Printf("Session %s received candidate %+v while no client was connected", s.PublicId(), candidate) + } +} + +func (s *ClientSession) OnIceCandidate(client McuClient, candidate interface{}) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, sub := range s.subscribers { + if sub.Id() == client.Id() { + s.sendCandidate(client, sub.Publisher(), client.StreamType(), candidate) + return + } + } + + for _, pub := range s.publishers { + if pub.Id() == client.Id() { + s.sendCandidate(client, s.PublicId(), client.StreamType(), candidate) + return + } + } + + log.Printf("Session %s received candidate %+v for unknown client %s", s.PublicId(), candidate, client.Id()) +} + +func (s *ClientSession) OnIceCompleted(client McuClient) { + // TODO(jojo): This causes a JavaScript error when creating a candidate from "null". + // Figure out a better way to signal this. + + // An empty candidate signals the end of candidates. + // s.OnIceCandidate(client, nil) +} + +func (s *ClientSession) PublisherClosed(publisher McuPublisher) { + s.mu.Lock() + defer s.mu.Unlock() + + for id, p := range s.publishers { + if p == publisher { + delete(s.publishers, id) + break + } + } +} + +func (s *ClientSession) SubscriberClosed(subscriber McuSubscriber) { + s.mu.Lock() + defer s.mu.Unlock() + + for id, sub := range s.subscribers { + if sub == subscriber { + delete(s.subscribers, id) + break + } + } +} + +func (s *ClientSession) GetOrCreatePublisher(ctx context.Context, mcu Mcu, streamType string) (McuPublisher, error) { + s.mu.Lock() + defer s.mu.Unlock() + + publisher, found := s.publishers[streamType] + if !found { + s.mu.Unlock() + var err error + publisher, err = mcu.NewPublisher(ctx, s, s.PublicId(), streamType) + s.mu.Lock() + if err != nil { + return nil, err + } + if s.publishers == nil { + s.publishers = make(map[string]McuPublisher) + } + if prev, found := s.publishers[streamType]; found { + // Another thread created the publisher while we were waiting. + go func(pub McuPublisher) { + closeCtx := context.TODO() + pub.Close(closeCtx) + }(publisher) + publisher = prev + } else { + s.publishers[streamType] = publisher + } + log.Printf("Publishing %s as %s for session %s", streamType, publisher.Id(), s.PublicId()) + } + + return publisher, nil +} + +func (s *ClientSession) GetPublisher(streamType string) McuPublisher { + s.mu.Lock() + defer s.mu.Unlock() + + return s.publishers[streamType] +} + +func (s *ClientSession) GetOrCreateSubscriber(ctx context.Context, mcu Mcu, id string, streamType string) (McuSubscriber, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // TODO(jojo): Add method to remove subscribers. + + subscriber, found := s.subscribers[id+"|"+streamType] + if !found { + s.mu.Unlock() + var err error + subscriber, err = mcu.NewSubscriber(ctx, s, id, streamType) + s.mu.Lock() + if err != nil { + return nil, err + } + if s.subscribers == nil { + s.subscribers = make(map[string]McuSubscriber) + } + if prev, found := s.subscribers[id+"|"+streamType]; found { + // Another thread created the subscriber while we were waiting. + go func(sub McuSubscriber) { + closeCtx := context.TODO() + sub.Close(closeCtx) + }(subscriber) + subscriber = prev + } else { + s.subscribers[id+"|"+streamType] = subscriber + } + log.Printf("Subscribing %s from %s as %s in session %s", streamType, id, subscriber.Id(), s.PublicId()) + } + + return subscriber, nil +} + +func (s *ClientSession) GetSubscriber(id string, streamType string) McuSubscriber { + s.mu.Lock() + defer s.mu.Unlock() + + return s.subscribers[id+"|"+streamType] +} + +func (s *ClientSession) processClientMessage(msg *nats.Msg) { + var message NatsMessage + if err := s.hub.nats.Decode(msg, &message); err != nil { + log.Printf("Could not decode NATS message %+v for session %s: %s", *msg, s.PublicId(), err) + return + } + + switch message.Type { + case "permissions": + s.SetPermissions(message.Permissions) + return + case "message": + if message.Message.Type == "bye" && message.Message.Bye.Reason == "room_session_reconnected" { + s.mu.Lock() + roomSessionId := s.RoomSessionId() + s.mu.Unlock() + log.Printf("Closing session %s because same room session %s connected", s.PublicId(), roomSessionId) + s.LeaveRoom(false) + defer s.closeAndWait(false) + } + } + + s.mu.Lock() + defer s.mu.Unlock() + client := s.client + if client == nil { + s.pendingClientMessages = append(s.pendingClientMessages, &message) + if len(s.pendingClientMessages) >= warnPendingMessagesCount { + log.Printf("Session %s has %d pending messages", s.PublicId(), len(s.pendingClientMessages)) + } + return + } + + client.natsReceiver <- &message +} + +func (s *ClientSession) combinePendingMessages(messages []*NatsMessage) ([]*NatsMessage, error) { + var result []*NatsMessage + has_chat := false + for _, message := range messages { + if message.Type == "message" && message.Message != nil && message.Message.IsChatRefresh() { + if has_chat { + // Only send a single chat refresh message to the client. + continue + } + + has_chat = true + } + + result = append(result, message) + } + return result, nil +} + +func (s *ClientSession) NotifySessionResumed(client *Client) { + s.mu.Lock() + if len(s.pendingClientMessages) == 0 { + s.mu.Unlock() + if room := s.GetRoom(); room != nil { + room.NotifySessionResumed(client) + } + return + } + + messages, err := s.combinePendingMessages(s.pendingClientMessages) + s.pendingClientMessages = nil + s.mu.Unlock() + if err != nil { + client.writeError(err) + return + } + + log.Printf("Send %d pending messages to session %s", len(messages), s.PublicId()) + had_participants_update := false + for _, message := range messages { + if !client.ProcessNatsMessage(message) { + break + } + + if !had_participants_update { + had_participants_update = message.Type == "message" && message.Message.IsParticipantsUpdate() + } + } + + if !had_participants_update { + // Only need to send initial participants list update if none was part of the pending messages. + if room := s.GetRoom(); room != nil { + room.NotifySessionResumed(client) + } + } +} diff --git a/src/signaling/clientsession_test.go b/src/signaling/clientsession_test.go new file mode 100644 index 0000000..4c45378 --- /dev/null +++ b/src/signaling/clientsession_test.go @@ -0,0 +1,120 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "testing" +) + +var ( + equalStrings = map[bool]string{ + true: "equal", + false: "not equal", + } +) + +type EqualTestData struct { + a map[Permission]bool + b map[Permission]bool + equal bool +} + +func Test_permissionsEqual(t *testing.T) { + tests := []EqualTestData{ + EqualTestData{ + a: nil, + b: nil, + equal: true, + }, + EqualTestData{ + a: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + }, + b: nil, + equal: false, + }, + EqualTestData{ + a: nil, + b: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + }, + equal: false, + }, + EqualTestData{ + a: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + }, + b: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + }, + equal: true, + }, + EqualTestData{ + a: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + PERMISSION_MAY_PUBLISH_SCREEN: true, + }, + b: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + }, + equal: false, + }, + EqualTestData{ + a: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + }, + b: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + PERMISSION_MAY_PUBLISH_SCREEN: true, + }, + equal: false, + }, + EqualTestData{ + a: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + PERMISSION_MAY_PUBLISH_SCREEN: true, + }, + b: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + PERMISSION_MAY_PUBLISH_SCREEN: true, + }, + equal: true, + }, + EqualTestData{ + a: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + PERMISSION_MAY_PUBLISH_SCREEN: true, + }, + b: map[Permission]bool{ + PERMISSION_MAY_PUBLISH_MEDIA: true, + PERMISSION_MAY_PUBLISH_SCREEN: false, + }, + equal: false, + }, + } + for _, test := range tests { + equal := permissionsEqual(test.a, test.b) + if equal != test.equal { + t.Errorf("Expected %+v to be %s to %+v but was %s", test.a, equalStrings[test.equal], test.b, equalStrings[equal]) + } + } +} diff --git a/src/signaling/concurrentmap.go b/src/signaling/concurrentmap.go new file mode 100644 index 0000000..1a4da0d --- /dev/null +++ b/src/signaling/concurrentmap.go @@ -0,0 +1,65 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "sync" +) + +type ConcurrentStringStringMap struct { + sync.Mutex + d map[string]string +} + +func (m *ConcurrentStringStringMap) Set(key, value string) { + m.Lock() + defer m.Unlock() + if m.d == nil { + m.d = make(map[string]string) + } + m.d[key] = value +} + +func (m *ConcurrentStringStringMap) Get(key string) (string, bool) { + m.Lock() + defer m.Unlock() + s, found := m.d[key] + return s, found +} + +func (m *ConcurrentStringStringMap) Del(key string) { + m.Lock() + defer m.Unlock() + delete(m.d, key) +} + +func (m *ConcurrentStringStringMap) Len() int { + m.Lock() + defer m.Unlock() + return len(m.d) +} + +func (m *ConcurrentStringStringMap) Clear() { + m.Lock() + defer m.Unlock() + m.d = nil +} diff --git a/src/signaling/concurrentmap_test.go b/src/signaling/concurrentmap_test.go new file mode 100644 index 0000000..0ae9a3f --- /dev/null +++ b/src/signaling/concurrentmap_test.go @@ -0,0 +1,125 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "strconv" + "sync" + "testing" +) + +func TestConcurrentStringStringMap(t *testing.T) { + var m ConcurrentStringStringMap + if m.Len() != 0 { + t.Errorf("Expected %d entries, got %d", 0, m.Len()) + } + if v, found := m.Get("foo"); found { + t.Errorf("Expected missing entry, got %s", v) + } + + m.Set("foo", "bar") + if m.Len() != 1 { + t.Errorf("Expected %d entries, got %d", 1, m.Len()) + } + if v, found := m.Get("foo"); !found { + t.Errorf("Expected entry") + } else if v != "bar" { + t.Errorf("Expected bar, got %s", v) + } + + m.Set("foo", "baz") + if m.Len() != 1 { + t.Errorf("Expected %d entries, got %d", 1, m.Len()) + } + if v, found := m.Get("foo"); !found { + t.Errorf("Expected entry") + } else if v != "baz" { + t.Errorf("Expected baz, got %s", v) + } + + m.Set("lala", "lolo") + if m.Len() != 2 { + t.Errorf("Expected %d entries, got %d", 2, m.Len()) + } + if v, found := m.Get("lala"); !found { + t.Errorf("Expected entry") + } else if v != "lolo" { + t.Errorf("Expected lolo, got %s", v) + } + + // Deleting missing entries doesn't do anything. + m.Del("xyz") + if m.Len() != 2 { + t.Errorf("Expected %d entries, got %d", 2, m.Len()) + } + if v, found := m.Get("foo"); !found { + t.Errorf("Expected entry") + } else if v != "baz" { + t.Errorf("Expected baz, got %s", v) + } + if v, found := m.Get("lala"); !found { + t.Errorf("Expected entry") + } else if v != "lolo" { + t.Errorf("Expected lolo, got %s", v) + } + + m.Del("lala") + if m.Len() != 1 { + t.Errorf("Expected %d entries, got %d", 2, m.Len()) + } + if v, found := m.Get("foo"); !found { + t.Errorf("Expected entry") + } else if v != "baz" { + t.Errorf("Expected baz, got %s", v) + } + if v, found := m.Get("lala"); found { + t.Errorf("Expected missing entry, got %s", v) + } + m.Clear() + + var wg sync.WaitGroup + concurrency := 100 + count := 1000 + for x := 0; x < concurrency; x++ { + wg.Add(1) + go func(x int) { + defer wg.Done() + + key := "key-" + strconv.Itoa(x) + for y := 0; y < count; y = y + 1 { + value := newRandomString(32) + m.Set(key, value) + if v, found := m.Get(key); !found { + t.Errorf("Expected entry for key %s", key) + return + } else if v != value { + t.Errorf("Expected value %s for key %s, got %s", value, key, v) + return + } + } + }(x) + } + wg.Wait() + if m.Len() != concurrency { + t.Errorf("Expected %d entries, got %d", concurrency, m.Len()) + } +} diff --git a/src/signaling/deferred_executor.go b/src/signaling/deferred_executor.go new file mode 100644 index 0000000..ee00b57 --- /dev/null +++ b/src/signaling/deferred_executor.go @@ -0,0 +1,98 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2020 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "log" + "reflect" + "runtime" + "runtime/debug" + "sync" +) + +// DeferredExecutor will asynchronously execute functions while maintaining +// their order. +type DeferredExecutor struct { + queue chan func() + closeChan chan bool + closed chan bool + closeOnce sync.Once +} + +func NewDeferredExecutor(queueSize int) *DeferredExecutor { + if queueSize < 0 { + queueSize = 0 + } + result := &DeferredExecutor{ + queue: make(chan func(), queueSize), + closeChan: make(chan bool, 1), + closed: make(chan bool, 1), + } + go result.run() + return result +} + +func (e *DeferredExecutor) run() { +loop: + for { + select { + case f := <-e.queue: + if f == nil { + break loop + } + f() + case <-e.closeChan: + break loop + } + } + e.closed <- true +} + +func getFunctionName(i interface{}) string { + return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() +} + +func (e *DeferredExecutor) Execute(f func()) { + defer func() { + if e := recover(); e != nil { + log.Printf("Could not defer function %v: %+v", getFunctionName(f), e) + log.Printf("Called from %s", string(debug.Stack())) + } + }() + + e.queue <- f +} + +func (e *DeferredExecutor) Close() { + select { + case e.closeChan <- true: + e.closeOnce.Do(func() { + close(e.queue) + }) + default: + // Already closed. + } +} + +func (e *DeferredExecutor) waitForStop() { + <-e.closed +} diff --git a/src/signaling/deferred_executor_test.go b/src/signaling/deferred_executor_test.go new file mode 100644 index 0000000..5d73324 --- /dev/null +++ b/src/signaling/deferred_executor_test.go @@ -0,0 +1,110 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2020 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "testing" + "time" +) + +func TestDeferredExecutor_MultiClose(t *testing.T) { + e := NewDeferredExecutor(0) + defer e.waitForStop() + + e.Close() + e.Close() +} + +func TestDeferredExecutor_QueueSize(t *testing.T) { + e := NewDeferredExecutor(0) + defer e.waitForStop() + defer e.Close() + + delay := 100 * time.Millisecond + e.Execute(func() { + time.Sleep(delay) + }) + + // The queue will block until the first command finishes. + a := time.Now() + e.Execute(func() { + time.Sleep(time.Millisecond) + }) + b := time.Now() + delta := b.Sub(a) + if delta < delay { + t.Errorf("Expected a delay of %s, got %s", delay, delta) + } +} + +func TestDeferredExecutor_Order(t *testing.T) { + e := NewDeferredExecutor(64) + defer e.waitForStop() + defer e.Close() + + var entries []int + getFunc := func(x int) func() { + return func() { + entries = append(entries, x) + } + } + + done := make(chan bool) + for x := 0; x < 10; x += 1 { + e.Execute(getFunc(x)) + } + + e.Execute(func() { + done <- true + }) + <-done + + for x := 0; x < 10; x += 1 { + if entries[x] != x { + t.Errorf("Expected %d at position %d, got %d", x, x, entries[x]) + } + } +} + +func TestDeferredExecutor_CloseFromFunc(t *testing.T) { + e := NewDeferredExecutor(64) + defer e.waitForStop() + + done := make(chan bool) + e.Execute(func() { + e.Close() + done <- true + }) + + <-done +} + +func TestDeferredExecutor_DeferAfterClose(t *testing.T) { + e := NewDeferredExecutor(64) + defer e.waitForStop() + + e.Close() + + e.Execute(func() { + t.Error("method should not have been called") + }) +} diff --git a/src/signaling/geoip.go b/src/signaling/geoip.go new file mode 100644 index 0000000..76d6f68 --- /dev/null +++ b/src/signaling/geoip.go @@ -0,0 +1,185 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/oschwald/maxminddb-golang" +) + +var ( + ErrDatabaseNotInitialized = fmt.Errorf("GeoIP database not initialized yet") +) + +func GetGeoIpDownloadUrl(license string) string { + if license == "" { + return "" + } + + result := "https://download.maxmind.com/app/geoip_download" + result += "?edition_id=GeoLite2-Country" + result += "&license_key=" + url.QueryEscape(license) + result += "&suffix=tar.gz" + return result +} + +type GeoLookup struct { + url string + client http.Client + mu sync.Mutex + + lastModified string + reader *maxminddb.Reader +} + +func NewGeoLookup(url string) (*GeoLookup, error) { + geoip := &GeoLookup{ + url: url, + } + return geoip, nil +} + +func (g *GeoLookup) Close() { + g.mu.Lock() + if g.reader != nil { + g.reader.Close() + g.reader = nil + } + g.mu.Unlock() +} + +func (g *GeoLookup) Update() error { + request, err := http.NewRequest("GET", g.url, nil) + if err != nil { + return err + } + if g.lastModified != "" { + request.Header.Add("If-Modified-Since", g.lastModified) + } + response, err := g.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode == http.StatusNotModified { + log.Printf("GeoIP database at %s has not changed", g.url) + return nil + } else if response.StatusCode/100 != 2 { + return fmt.Errorf("Downloading %s returned an error: %s", g.url, response.Status) + } + + body := response.Body + if strings.HasSuffix(g.url, ".gz") { + body, err = gzip.NewReader(body) + if err != nil { + return err + } + } + + tarfile := tar.NewReader(body) + var geoipdata []byte + for { + header, err := tarfile.Next() + if err == io.EOF { + break + } else if err != nil { + return err + } + + if !strings.HasSuffix(header.Name, ".mmdb") { + continue + } + + geoipdata, err = ioutil.ReadAll(tarfile) + if err != nil { + return err + } + break + } + + if len(geoipdata) == 0 { + return fmt.Errorf("Did not find MaxMind database in tarball from %s", g.url) + } + + reader, err := maxminddb.FromBytes(geoipdata) + if err != nil { + return err + } + + if err := reader.Verify(); err != nil { + return err + } + + metadata := reader.Metadata + log.Printf("Using %s GeoIP database from %s (built on %s)", metadata.DatabaseType, g.url, time.Unix(int64(metadata.BuildEpoch), 0).UTC()) + + g.mu.Lock() + if g.reader != nil { + g.reader.Close() + } + g.reader = reader + g.lastModified = response.Header.Get("Last-Modified") + g.mu.Unlock() + return nil +} + +func (g *GeoLookup) LookupCountry(ip net.IP) (string, error) { + var record struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + } `maxminddb:"country"` + } + + g.mu.Lock() + if g.reader == nil { + g.mu.Unlock() + return "", ErrDatabaseNotInitialized + } + err := g.reader.Lookup(ip, &record) + g.mu.Unlock() + if err != nil { + return "", err + } + + return record.Country.ISOCode, nil +} + +func LookupContinents(country string) []string { + if continents, found := ContinentMap[country]; !found { + return nil + } else { + return continents + } +} diff --git a/src/signaling/geoip_test.go b/src/signaling/geoip_test.go new file mode 100644 index 0000000..0b3ff06 --- /dev/null +++ b/src/signaling/geoip_test.go @@ -0,0 +1,118 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "net" + "os" + "testing" +) + +func TestGeoLookup(t *testing.T) { + license := os.Getenv("MAXMIND_GEOLITE2_LICENSE") + if license == "" { + t.Skip("No MaxMind GeoLite2 license was set in MAXMIND_GEOLITE2_LICENSE environment variable.") + } + + tests := map[string]string{ + // Example from maxminddb-golang code. + "81.2.69.142": "GB", + // Local addresses don't have a country assigned. + "127.0.0.1": "", + } + reader, err := NewGeoLookup(GetGeoIpDownloadUrl(license)) + if err != nil { + t.Fatal(err) + } + defer reader.Close() + + if err := reader.Update(); err != nil { + t.Fatal(err) + } + + for ip, expected := range tests { + country, err := reader.LookupCountry(net.ParseIP(ip)) + if err != nil { + t.Errorf("Could not lookup %s: %s", ip, err) + continue + } + + if country != expected { + t.Errorf("Expected %s for %s, got %s", expected, ip, country) + } + } +} + +func TestGeoLookupCaching(t *testing.T) { + license := os.Getenv("MAXMIND_GEOLITE2_LICENSE") + if license == "" { + t.Skip("No MaxMind GeoLite2 license was set in MAXMIND_GEOLITE2_LICENSE environment variable.") + } + + reader, err := NewGeoLookup(GetGeoIpDownloadUrl(license)) + if err != nil { + t.Fatal(err) + } + defer reader.Close() + + if err := reader.Update(); err != nil { + t.Fatal(err) + } + + // Updating the second time will most likely return a "304 Not Modified". + // Make sure this doesn't trigger an error. + if err := reader.Update(); err != nil { + t.Fatal(err) + } +} + +func TestGeoLookupContinent(t *testing.T) { + tests := map[string][]string{ + "AU": []string{"OC"}, + "DE": []string{"EU"}, + "RU": []string{"EU", "AS"}, + "": nil, + "INVALID ": nil, + } + + for country, expected := range tests { + continents := LookupContinents(country) + if len(continents) != len(expected) { + t.Errorf("Continents didn't match for %s: got %s, expected %s", country, continents, expected) + continue + } + for idx, c := range expected { + if continents[idx] != c { + t.Errorf("Continents didn't match for %s: got %s, expected %s", country, continents, expected) + break + } + } + } +} + +func TestGeoLookupCloseEmpty(t *testing.T) { + reader, err := NewGeoLookup("ignore-url") + if err != nil { + t.Fatal(err) + } + reader.Close() +} diff --git a/src/signaling/hub.go b/src/signaling/hub.go new file mode 100644 index 0000000..04ade13 --- /dev/null +++ b/src/signaling/hub.go @@ -0,0 +1,1453 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "hash/fnv" + "log" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/dlintw/goconf" + "github.com/gorilla/mux" + "github.com/gorilla/securecookie" + "github.com/gorilla/websocket" + + "golang.org/x/net/context" +) + +var ( + DuplicateClient = NewError("duplicate_client", "Client already registered.") + HelloExpected = NewError("hello_expected", "Expected Hello request.") + UserAuthFailed = NewError("auth_failed", "The user could not be authenticated.") + RoomJoinFailed = NewError("room_join_failed", "Could not join the room.") + InvalidClientType = NewError("invalid_client_type", "The client type is not supported.") + InvalidBackendUrl = NewError("invalid_backend", "The backend URL is not supported.") + InvalidToken = NewError("invalid_token", "The passed token is invalid.") + NoSuchSession = NewError("no_such_session", "The session to resume does not exist.") + + // Maximum number of concurrent requests to a backend. + defaultMaxConcurrentRequestsPerHost = 8 + + // Backend requests will be cancelled if they take too long. + defaultBackendTimeoutSeconds = 10 + + // MCU requests will be cancelled if they take too long. + defaultMcuTimeoutSeconds = 10 + + // New connections have to send a "Hello" request after 2 seconds. + initialHelloTimeout = 2 * time.Second + + // Anonymous clients have to join a room after 10 seconds. + anonmyousJoinRoomTimeout = 10 * time.Second + + // Run housekeeping jobs once per second + housekeepingInterval = time.Second + + // Number of decoded session ids to keep. + decodeCacheSize = 8192 + + // Minimum length of random data for tokens. + minTokenRandomLength = 32 + + // Number of caches to use for keeping decoded session ids. The cache will + // be selected based on the cache key to avoid lock contention. + numDecodeCaches = 32 + + // Buffer sizes when reading/writing websocket connections. + websocketReadBufferSize = 4096 + websocketWriteBufferSize = 4096 + + // Delay after which a screen publisher should be cleaned up. + cleanupScreenPublisherDelay = time.Second +) + +const ( + privateSessionName = "private-session" + publicSessionName = "public-session" +) + +type Hub struct { + nats NatsClient + upgrader websocket.Upgrader + cookie *securecookie.SecureCookie + info *HelloServerMessageServer + version string + + stopped int32 + stopChan chan bool + + roomUpdated chan *BackendServerRoomRequest + roomDeleted chan *BackendServerRoomRequest + roomInCall chan *BackendServerRoomRequest + roomParticipants chan *BackendServerRoomRequest + + mu sync.RWMutex + ru sync.RWMutex + + sid uint64 + clients map[uint64]*Client + sessions map[uint64]Session + rooms map[string]*Room + + roomSessions RoomSessions + + decodeCaches []*LruCache + + mcu Mcu + mcuTimeout time.Duration + internalClientsSecret []byte + + expiredSessions map[Session]bool + expectHelloClients map[*Client]time.Time + anonymousClients map[*Client]time.Time + + backendTimeout time.Duration + backend *BackendClient + + geoip *GeoLookup + geoipUpdating int32 +} + +func NewHub(config *goconf.ConfigFile, nats NatsClient, r *mux.Router, version string) (*Hub, error) { + hashKey, _ := config.GetString("sessions", "hashkey") + switch len(hashKey) { + case 32: + case 64: + default: + log.Printf("WARNING: The sessions hash key should be 32 or 64 bytes but is %d bytes", len(hashKey)) + } + + blockKey, _ := config.GetString("sessions", "blockkey") + blockBytes := []byte(blockKey) + switch len(blockKey) { + case 0: + blockBytes = nil + case 16: + case 24: + case 32: + default: + return nil, fmt.Errorf("The sessions block key must be 16, 24 or 32 bytes but is %d bytes", len(blockKey)) + } + + internalClientsSecret, _ := config.GetString("clients", "internalsecret") + if internalClientsSecret == "" { + log.Println("WARNING: No shared secret has been set for internal clients.") + } + + maxConcurrentRequestsPerHost, _ := config.GetInt("backend", "connectionsperhost") + if maxConcurrentRequestsPerHost <= 0 { + maxConcurrentRequestsPerHost = defaultMaxConcurrentRequestsPerHost + } + + backend, err := NewBackendClient(config, maxConcurrentRequestsPerHost, version) + if err != nil { + return nil, err + } + log.Printf("Using a maximum of %d concurrent backend connections per host", maxConcurrentRequestsPerHost) + + backendTimeoutSeconds, _ := config.GetInt("backend", "timeout") + if backendTimeoutSeconds <= 0 { + backendTimeoutSeconds = defaultBackendTimeoutSeconds + } + backendTimeout := time.Duration(backendTimeoutSeconds) * time.Second + log.Printf("Using a timeout of %s for backend connections", backendTimeout) + + mcuTimeoutSeconds, _ := config.GetInt("mcu", "timeout") + if mcuTimeoutSeconds <= 0 { + mcuTimeoutSeconds = defaultMcuTimeoutSeconds + } + mcuTimeout := time.Duration(mcuTimeoutSeconds) * time.Second + + decodeCaches := make([]*LruCache, 0, numDecodeCaches) + for i := 0; i < numDecodeCaches; i++ { + decodeCaches = append(decodeCaches, NewLruCache(decodeCacheSize)) + } + + roomSessions, err := NewBuiltinRoomSessions() + if err != nil { + return nil, err + } + + geoipUrl, _ := config.GetString("geoip", "url") + if geoipUrl == "default" || geoipUrl == "none" { + geoipUrl = "" + } + if geoipUrl == "" { + if geoipLicense, _ := config.GetString("geoip", "license"); geoipLicense != "" { + geoipUrl = GetGeoIpDownloadUrl(geoipLicense) + } + } + + var geoip *GeoLookup + if geoipUrl != "" { + log.Printf("Downloading GeoIP database from %s", geoipUrl) + geoip, err = NewGeoLookup(geoipUrl) + if err != nil { + return nil, err + } + } else { + log.Printf("Not using GeoIP database") + } + + hub := &Hub{ + nats: nats, + upgrader: websocket.Upgrader{ + ReadBufferSize: websocketReadBufferSize, + WriteBufferSize: websocketWriteBufferSize, + }, + cookie: securecookie.New([]byte(hashKey), blockBytes).MaxAge(0), + info: &HelloServerMessageServer{ + Version: version, + }, + + stopChan: make(chan bool), + + roomUpdated: make(chan *BackendServerRoomRequest), + roomDeleted: make(chan *BackendServerRoomRequest), + roomInCall: make(chan *BackendServerRoomRequest), + roomParticipants: make(chan *BackendServerRoomRequest), + + clients: make(map[uint64]*Client), + sessions: make(map[uint64]Session), + rooms: make(map[string]*Room), + + roomSessions: roomSessions, + + decodeCaches: decodeCaches, + + mcuTimeout: mcuTimeout, + internalClientsSecret: []byte(internalClientsSecret), + + expiredSessions: make(map[Session]bool), + anonymousClients: make(map[*Client]time.Time), + expectHelloClients: make(map[*Client]time.Time), + + backendTimeout: backendTimeout, + backend: backend, + + geoip: geoip, + } + hub.upgrader.CheckOrigin = hub.checkOrigin + r.HandleFunc("/spreed", func(w http.ResponseWriter, r *http.Request) { + hub.serveWs(w, r) + }) + + return hub, nil +} + +func (h *Hub) SetMcu(mcu Mcu) { + h.mcu = mcu + var newFeatures []string + if mcu == nil { + for _, f := range h.info.Features { + if f != ServerFeatureMcu { + newFeatures = append(newFeatures, f) + } + } + } else { + log.Printf("Using a timeout of %s for MCU requests", h.mcuTimeout) + added := false + for _, f := range h.info.Features { + newFeatures = append(newFeatures, f) + if f == ServerFeatureMcu { + added = true + } + } + if !added { + newFeatures = append(newFeatures, ServerFeatureMcu) + } + } + h.info.Features = newFeatures +} + +func (h *Hub) checkOrigin(r *http.Request) bool { + // We allow any Origin to connect to the service. + return true +} + +func (h *Hub) GetServerInfo() *HelloServerMessageServer { + return h.info +} + +func (h *Hub) updateGeoDatabase() { + if h.geoip == nil { + return + } + + if !atomic.CompareAndSwapInt32(&h.geoipUpdating, 0, 1) { + // Already updating + return + } + + defer atomic.CompareAndSwapInt32(&h.geoipUpdating, 1, 0) + delay := time.Second + for atomic.LoadInt32(&h.stopped) == 0 { + err := h.geoip.Update() + if err == nil { + break + } + + log.Printf("Could not update GeoIP database, will retry later (%s)", err) + time.Sleep(delay) + delay = delay * 2 + if delay > 5*time.Minute { + delay = 5 * time.Minute + } + } +} + +func (h *Hub) Run() { + go h.updateGeoDatabase() + + housekeeping := time.NewTicker(housekeepingInterval) + geoipUpdater := time.NewTicker(24 * time.Hour) + +loop: + for { + select { + // Backend notifications from Nextcloud. + case message := <-h.roomUpdated: + h.processRoomUpdated(message) + case message := <-h.roomDeleted: + h.processRoomDeleted(message) + case message := <-h.roomInCall: + h.processRoomInCallChanged(message) + case message := <-h.roomParticipants: + h.processRoomParticipants(message) + // Periodic internal housekeeping. + case now := <-housekeeping.C: + h.performHousekeeping(now) + case <-geoipUpdater.C: + go h.updateGeoDatabase() + case <-h.stopChan: + break loop + } + } + if h.geoip != nil { + h.geoip.Close() + } +} + +func (h *Hub) Stop() { + atomic.StoreInt32(&h.stopped, 1) + select { + case h.stopChan <- true: + default: + } +} + +func reverseSessionId(s string) (string, error) { + // Note that we are assuming base64 encoded strings here. + decoded, err := base64.URLEncoding.DecodeString(s) + if err != nil { + return "", err + } + + for i, j := 0, len(decoded)-1; i < j; i, j = i+1, j-1 { + decoded[i], decoded[j] = decoded[j], decoded[i] + } + return base64.URLEncoding.EncodeToString(decoded), nil +} + +func (h *Hub) encodeSessionId(data *SessionIdData, sessionType string) (string, error) { + encoded, err := h.cookie.Encode(sessionType, data) + if err != nil { + return "", err + } + if sessionType == publicSessionName { + // We are reversing the public session ids because clients compare them + // to decide who calls whom. The prefix of the session id is increasing + // (a timestamp) but the suffix the (random) hash. + // By reversing we move the hash to the front, making the comparison of + // session ids "random". + encoded, err = reverseSessionId(encoded) + } + return encoded, err +} + +func (h *Hub) getDecodeCache(cache_key string) *LruCache { + hash := fnv.New32a() + hash.Write([]byte(cache_key)) + idx := hash.Sum32() % uint32(len(h.decodeCaches)) + return h.decodeCaches[idx] +} + +func (h *Hub) invalidateSessionId(id string, sessionType string) { + if len(id) == 0 { + return + } + + cache_key := id + "|" + sessionType + cache := h.getDecodeCache(cache_key) + cache.Remove(cache_key) +} + +func (h *Hub) setDecodedSessionId(id string, sessionType string, data *SessionIdData) { + if len(id) == 0 { + return + } + + cache_key := id + "|" + sessionType + cache := h.getDecodeCache(cache_key) + cache.Set(cache_key, data) +} + +func (h *Hub) decodeSessionId(id string, sessionType string) *SessionIdData { + if len(id) == 0 { + return nil + } + + cache_key := id + "|" + sessionType + cache := h.getDecodeCache(cache_key) + if result := cache.Get(cache_key); result != nil { + return result.(*SessionIdData) + } + + if sessionType == publicSessionName { + var err error + id, err = reverseSessionId(id) + if err != nil { + return nil + } + } + + var data SessionIdData + if h.cookie.Decode(sessionType, id, &data) != nil { + return nil + } + + cache.Set(cache_key, &data) + return &data +} + +func (h *Hub) GetSessionByPublicId(sessionId string) Session { + data := h.decodeSessionId(sessionId, publicSessionName) + if data == nil { + return nil + } + + h.mu.Lock() + session, _ := h.sessions[data.Sid] + h.mu.Unlock() + return session +} + +func (h *Hub) checkExpiredSessions(now time.Time) { + for s, _ := range h.expiredSessions { + if s.IsExpired(now) { + h.mu.Unlock() + log.Printf("Closing expired session %s (private=%s)", s.PublicId(), s.PrivateId()) + s.Close() + h.mu.Lock() + // Should already be deleted by the close code, but better be sure. + delete(h.expiredSessions, s) + } + } +} + +func (h *Hub) checkExpireClients(now time.Time, clients map[*Client]time.Time, reason string) { + for client, timeout := range clients { + if now.After(timeout) { + // This will close the client connection. + h.mu.Unlock() + client.SendByeResponseWithReason(nil, reason) + if reason == "room_join_timeout" { + session := client.GetSession() + if session != nil { + session.Close() + } + } + h.mu.Lock() + } + } +} + +func (h *Hub) checkAnonymousClients(now time.Time) { + h.checkExpireClients(now, h.anonymousClients, "room_join_timeout") +} + +func (h *Hub) checkInitialHello(now time.Time) { + h.checkExpireClients(now, h.expectHelloClients, "hello_timeout") +} + +func (h *Hub) performHousekeeping(now time.Time) { + h.mu.Lock() + h.checkExpiredSessions(now) + h.checkAnonymousClients(now) + h.checkInitialHello(now) + h.mu.Unlock() +} + +func (h *Hub) removeSession(session Session) { + session.LeaveRoom(true) + h.invalidateSessionId(session.PrivateId(), privateSessionName) + h.invalidateSessionId(session.PublicId(), publicSessionName) + + h.mu.Lock() + if data := session.Data(); data != nil && data.Sid > 0 { + delete(h.clients, data.Sid) + delete(h.sessions, data.Sid) + } + delete(h.expiredSessions, session) + h.mu.Unlock() +} + +func (h *Hub) startWaitAnonymousClientRoom(client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + h.startWaitAnonymousClientRoomLocked(client) +} + +func (h *Hub) startWaitAnonymousClientRoomLocked(client *Client) { + if !client.IsConnected() { + return + } + if session := client.GetSession(); session != nil && session.ClientType() == HelloClientTypeInternal { + // Internal clients don't need to join a room. + return + } + + // Anonymous clients must join a public room within a given time, + // otherwise they get disconnected to avoid blocking resources forever. + now := time.Now() + h.anonymousClients[client] = now.Add(anonmyousJoinRoomTimeout) +} + +func (h *Hub) startExpectHello(client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + if !client.IsConnected() { + return + } + + client.mu.Lock() + defer client.mu.Unlock() + if client.IsAuthenticated() { + return + } + + // Clients must send a "Hello" request to get a session within a given time. + now := time.Now() + h.expectHelloClients[client] = now.Add(initialHelloTimeout) +} + +func (h *Hub) processNewClient(client *Client) { + h.startExpectHello(client) +} + +func (h *Hub) processRegister(client *Client, message *ClientMessage, auth *BackendClientResponse) { + if !client.IsConnected() { + // Client disconnected while waiting for "hello" response. + return + } + + if auth.Type == "error" { + client.SendMessage(message.NewErrorServerMessage(auth.Error)) + return + } else if auth.Type != "auth" { + client.SendMessage(message.NewErrorServerMessage(UserAuthFailed)) + return + } + + sid := atomic.AddUint64(&h.sid, 1) + for sid == 0 { + sid = atomic.AddUint64(&h.sid, 1) + } + sessionIdData := &SessionIdData{ + Sid: sid, + Created: time.Now(), + } + privateSessionId, err := h.encodeSessionId(sessionIdData, privateSessionName) + if err != nil { + client.SendMessage(message.NewWrappedErrorServerMessage(err)) + return + } + publicSessionId, err := h.encodeSessionId(sessionIdData, publicSessionName) + if err != nil { + client.SendMessage(message.NewWrappedErrorServerMessage(err)) + return + } + + userId := auth.Auth.UserId + if userId != "" { + log.Printf("Register user %s from %s in %s (%s) %s (private=%s)", userId, client.RemoteAddr(), client.Country(), client.UserAgent(), publicSessionId, privateSessionId) + } else if message.Hello.Auth.Type != HelloClientTypeClient { + log.Printf("Register %s from %s in %s (%s) %s (private=%s)", message.Hello.Auth.Type, client.RemoteAddr(), client.Country(), client.UserAgent(), publicSessionId, privateSessionId) + } else { + log.Printf("Register anonymous from %s in %s (%s) %s (private=%s)", client.RemoteAddr(), client.Country(), client.UserAgent(), publicSessionId, privateSessionId) + } + + session, err := NewClientSession(h, privateSessionId, publicSessionId, sessionIdData, message.Hello, auth.Auth) + if err != nil { + client.SendMessage(message.NewWrappedErrorServerMessage(err)) + return + } + + h.mu.Lock() + if !client.IsConnected() { + // Client disconnected while waiting for backend response. + h.mu.Unlock() + + session.Close() + return + } + + session.SetClient(client) + h.sessions[sessionIdData.Sid] = session + h.clients[sessionIdData.Sid] = client + delete(h.expectHelloClients, client) + if userId == "" && auth.Type != HelloClientTypeInternal { + h.startWaitAnonymousClientRoomLocked(client) + } + h.mu.Unlock() + + h.setDecodedSessionId(privateSessionId, privateSessionName, sessionIdData) + h.setDecodedSessionId(publicSessionId, publicSessionName, sessionIdData) + client.SendHelloResponse(message, session) +} + +func (h *Hub) processUnregister(client *Client) *ClientSession { + session := client.GetSession() + + h.mu.Lock() + delete(h.anonymousClients, client) + delete(h.expectHelloClients, client) + if session != nil { + delete(h.clients, session.Data().Sid) + session.StartExpire() + } + h.mu.Unlock() + if session != nil { + log.Printf("Unregister %s (private=%s)", session.PublicId(), session.PrivateId()) + session.ClearClient(client) + } + + client.Close() + return session +} + +func (h *Hub) processMessage(client *Client, message *ClientMessage) { + session := client.GetSession() + if session == nil { + if message.Type != "hello" { + client.SendMessage(message.NewErrorServerMessage(HelloExpected)) + return + } + + h.processHello(client, message) + return + } + + switch message.Type { + case "room": + h.processRoom(client, message) + case "message": + h.processMessageMsg(client, message) + case "control": + h.processControlMsg(client, message) + case "bye": + h.processByeMsg(client, message) + case "hello": + log.Printf("Ignore hello %+v for already authenticated connection %s", message.Hello, session.PublicId()) + default: + log.Printf("Ignore unknown message %+v from %s", message, session.PublicId()) + } +} + +func (h *Hub) processHello(client *Client, message *ClientMessage) { + resumeId := message.Hello.ResumeId + if resumeId != "" { + data := h.decodeSessionId(resumeId, privateSessionName) + if data == nil { + client.SendMessage(message.NewErrorServerMessage(NoSuchSession)) + return + } + + h.mu.Lock() + session, found := h.sessions[data.Sid] + if !found || resumeId != session.PrivateId() { + h.mu.Unlock() + client.SendMessage(message.NewErrorServerMessage(NoSuchSession)) + return + } + + clientSession, ok := session.(*ClientSession) + if !ok { + // Should never happen as clients only can resume their own sessions. + h.mu.Unlock() + log.Printf("Client resumed non-client session %s (private=%s)", session.PublicId(), session.PrivateId()) + client.SendMessage(message.NewErrorServerMessage(NoSuchSession)) + return + } + + if !client.IsConnected() { + // Client disconnected while checking message. + h.mu.Unlock() + return + } + + if prev := clientSession.SetClient(client); prev != nil { + log.Printf("Closing previous client from %s for session %s", prev.RemoteAddr(), session.PublicId()) + prev.SendByeResponseWithReason(nil, "session_resumed") + } + + clientSession.StopExpire() + h.clients[data.Sid] = client + delete(h.expectHelloClients, client) + h.mu.Unlock() + + log.Printf("Resume session from %s in %s (%s) %s (private=%s)", client.RemoteAddr(), client.Country(), client.UserAgent(), session.PublicId(), session.PrivateId()) + + client.SendHelloResponse(message, clientSession) + clientSession.NotifySessionResumed(client) + return + } + + // Make sure client doesn't get disconencted while calling auth backend. + h.mu.Lock() + delete(h.expectHelloClients, client) + h.mu.Unlock() + + switch message.Hello.Auth.Type { + case HelloClientTypeClient: + h.processHelloClient(client, message) + case HelloClientTypeInternal: + h.processHelloInternal(client, message) + default: + h.startExpectHello(client) + client.SendMessage(message.NewErrorServerMessage(InvalidClientType)) + } +} + +func (h *Hub) processHelloClient(client *Client, message *ClientMessage) { + // Make sure the client must send another "hello" in case of errors. + defer h.startExpectHello(client) + + url := message.Hello.Auth.parsedUrl + if !h.backend.IsUrlAllowed(url) { + client.SendMessage(message.NewErrorServerMessage(InvalidBackendUrl)) + return + } + + // Run in timeout context to prevent blocking too long. + ctx, cancel := context.WithTimeout(context.Background(), h.backendTimeout) + defer cancel() + + request := NewBackendClientAuthRequest(message.Hello.Auth.Params) + var auth BackendClientResponse + if err := h.backend.PerformJSONRequest(ctx, url, request, &auth); err != nil { + client.SendMessage(message.NewWrappedErrorServerMessage(err)) + return + } + + // TODO(jojo): Validate response + + h.processRegister(client, message, &auth) +} + +func (h *Hub) processHelloInternal(client *Client, message *ClientMessage) { + defer h.startExpectHello(client) + if len(h.internalClientsSecret) == 0 { + client.SendMessage(message.NewErrorServerMessage(InvalidClientType)) + return + } + + // Validate internal connection. + rnd := message.Hello.Auth.internalParams.Random + mac := hmac.New(sha256.New, h.internalClientsSecret) + mac.Write([]byte(rnd)) + check := hex.EncodeToString(mac.Sum(nil)) + if len(rnd) < minTokenRandomLength || check != message.Hello.Auth.internalParams.Token { + client.SendMessage(message.NewErrorServerMessage(InvalidToken)) + return + } + + auth := &BackendClientResponse{ + Type: "auth", + Auth: &BackendClientAuthResponse{}, + } + h.processRegister(client, message, auth) +} + +func (h *Hub) disconnectByRoomSessionId(roomSessionId string) { + sessionId, err := h.roomSessions.GetSessionId(roomSessionId) + if err == ErrNoSuchRoomSession { + return + } else if err != nil { + log.Printf("Could not get session id for room session %s: %s", roomSessionId, err) + return + } + + session := h.GetSessionByPublicId(sessionId) + if session == nil { + // Session is located on a different server. + msg := &ServerMessage{ + Type: "bye", + Bye: &ByeServerMessage{ + Reason: "room_session_reconnected", + }, + } + h.nats.PublishMessage("session."+sessionId, msg) + return + } + + log.Printf("Closing session %s because same room session %s connected", session.PublicId(), roomSessionId) + session.LeaveRoom(false) + switch sess := session.(type) { + case *ClientSession: + if client := sess.GetClient(); client != nil { + client.SendByeResponseWithReason(nil, "room_session_reconnected") + } + } + session.Close() +} + +func (h *Hub) processRoom(client *Client, message *ClientMessage) { + session := client.GetSession() + roomId := message.Room.RoomId + if roomId == "" { + if session == nil { + return + } + + // We can handle leaving a room directly. + if session.LeaveRoom(true) != nil { + // User was in a room before, so need to notify about leaving it. + client.SendRoom(message, nil) + } + if session.UserId() == "" && session.ClientType() != HelloClientTypeInternal { + h.startWaitAnonymousClientRoom(client) + } + return + } + + if session != nil { + if room := h.getRoom(roomId); room != nil && room.HasSession(session) { + // Session already is in that room, no action needed. + return + } + } + + var room BackendClientResponse + if session.ClientType() == HelloClientTypeInternal { + // Internal clients can join any room. + room = BackendClientResponse{ + Type: "room", + Room: &BackendClientRoomResponse{ + RoomId: roomId, + }, + } + } else { + // Run in timeout context to prevent blocking too long. + ctx, cancel := context.WithTimeout(context.Background(), h.backendTimeout) + defer cancel() + + sessionId := message.Room.SessionId + if sessionId == "" { + // TODO(jojo): Better make the session id required in the request. + log.Printf("User did not send a room session id, assuming session %s", session.PublicId()) + sessionId = session.PublicId() + } + request := NewBackendClientRoomRequest(roomId, session.UserId(), sessionId) + if err := h.backend.PerformJSONRequest(ctx, session.ParsedBackendUrl(), request, &room); err != nil { + client.SendMessage(message.NewWrappedErrorServerMessage(err)) + return + } + + // TODO(jojo): Validate response + + if message.Room.SessionId != "" { + // There can only be one connection per Nextcloud Talk session, + // disconnect any other connections without sending a "leave" event. + h.disconnectByRoomSessionId(message.Room.SessionId) + } + } + + h.processJoinRoom(client, message, &room) +} + +func (h *Hub) getRoom(id string) *Room { + h.ru.RLock() + defer h.ru.RUnlock() + return h.rooms[id] +} + +func (h *Hub) removeRoom(room *Room) { + h.ru.Lock() + delete(h.rooms, room.Id()) + h.ru.Unlock() +} + +func (h *Hub) createRoom(id string, properties *json.RawMessage) (*Room, error) { + // Note the write lock must be held. + room, err := NewRoom(id, properties, h, h.nats) + if err != nil { + return nil, err + } + + h.rooms[id] = room + return room, nil +} + +func (h *Hub) processJoinRoom(client *Client, message *ClientMessage, room *BackendClientResponse) { + session := client.GetSession() + if session == nil { + // Client disconnected while waiting for join room response. + return + } + + if room.Type == "error" { + client.SendMessage(message.NewErrorServerMessage(room.Error)) + return + } else if room.Type != "room" { + client.SendMessage(message.NewErrorServerMessage(RoomJoinFailed)) + return + } + + session.LeaveRoom(true) + + roomId := room.Room.RoomId + if err := session.SubscribeRoomNats(h.nats, roomId, message.Room.SessionId); err != nil { + client.SendMessage(message.NewWrappedErrorServerMessage(err)) + // The client (implicitly) left the room due to an error. + client.SendRoom(nil, nil) + return + } + + h.ru.Lock() + r, found := h.rooms[roomId] + if !found { + var err error + if r, err = h.createRoom(roomId, room.Room.Properties); err != nil { + h.ru.Unlock() + client.SendMessage(message.NewWrappedErrorServerMessage(err)) + // The client (implicitly) left the room due to an error. + session.UnsubscribeRoomNats() + client.SendRoom(nil, nil) + return + } + } + h.ru.Unlock() + + h.mu.Lock() + // The client now joined a room, don't expire him if he is anonymous. + delete(h.anonymousClients, client) + h.mu.Unlock() + session.SetRoom(r) + if room.Room.Permissions != nil { + session.SetPermissions(*room.Room.Permissions) + } + client.SendRoom(message, r) + h.notifyUserJoinedRoom(r, client, session, room.Room.Session) +} + +func (h *Hub) notifyUserJoinedRoom(room *Room, client *Client, session Session, sessionData *json.RawMessage) { + // Register session with the room + if sessions := room.AddSession(session, sessionData); len(sessions) > 0 { + events := make([]*EventServerMessageSessionEntry, 0, len(sessions)) + for _, s := range sessions { + events = append(events, &EventServerMessageSessionEntry{ + SessionId: s.PublicId(), + UserId: s.UserId(), + User: s.UserData(), + }) + } + msg := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "room", + Type: "join", + Join: events, + }, + } + + // No need to send through NATS, the session is connected locally. + client.SendMessage(msg) + } +} + +func (h *Hub) processMessageMsg(client *Client, message *ClientMessage) { + msg := message.Message + session := client.GetSession() + if session == nil { + // Client is not connected yet. + return + } + + var recipient *Client + var subject string + var clientData *MessageClientMessageData + switch msg.Recipient.Type { + case RecipientTypeSession: + data := h.decodeSessionId(msg.Recipient.SessionId, publicSessionName) + if data != nil { + if h.mcu != nil { + // Maybe this is a message to be processed by the MCU. + var data MessageClientMessageData + if err := json.Unmarshal(*msg.Data, &data); err == nil { + clientData = &data + switch data.Type { + case "requestoffer": + // Process asynchronously to avoid blocking regular + // message processing for this client. + go h.processMcuMessage(client, client, session, message, msg, &data) + return + case "offer": + fallthrough + case "answer": + fallthrough + case "endOfCandidates": + fallthrough + case "candidate": + h.processMcuMessage(client, client, session, message, msg, &data) + return + } + } + } + + if msg.Recipient.SessionId == session.PublicId() { + // Don't loop messages to the sender. + return + } + + subject = "session." + msg.Recipient.SessionId + h.mu.RLock() + recipient = h.clients[data.Sid] + h.mu.RUnlock() + } + case RecipientTypeUser: + if msg.Recipient.UserId != "" { + if msg.Recipient.UserId == session.UserId() { + // Don't loop messages to the sender. + // TODO(jojo): Should we allow users to send messages to their + // other sessions? + return + } + + subject = GetSubjectForUserId(msg.Recipient.UserId) + } + case RecipientTypeRoom: + if session != nil { + if room := session.GetRoom(); room != nil { + subject = "room." + room.Id() + + if h.mcu != nil { + var data MessageClientMessageData + if err := json.Unmarshal(*msg.Data, &data); err == nil { + clientData = &data + } + } + } + } + } + if subject == "" { + log.Printf("Unknown recipient in message %+v from %s", msg, session.PublicId()) + return + } + + if clientData != nil && clientData.Type == "unshareScreen" { + // User is stopping to share his screen. Firefox doesn't properly clean + // up the peer connections in all cases, so make sure to stop publishing + // in the MCU. + go func(c *Client) { + time.Sleep(cleanupScreenPublisherDelay) + session := c.GetSession() + if session == nil { + return + } + + publisher := session.GetPublisher(streamTypeScreen) + if publisher == nil { + return + } + + log.Printf("Closing screen publisher for %s\n", session.PublicId()) + ctx, cancel := context.WithTimeout(context.Background(), h.mcuTimeout) + defer cancel() + publisher.Close(ctx) + }(client) + } + + response := &ServerMessage{ + Type: "message", + Message: &MessageServerMessage{ + Sender: &MessageServerMessageSender{ + Type: msg.Recipient.Type, + SessionId: session.PublicId(), + UserId: session.UserId(), + }, + Data: msg.Data, + }, + } + if recipient != nil { + // The recipient is connected to this instance, no need to go through NATS. + if clientData != nil && clientData.Type == "sendoffer" { + if !isAllowedToSend(session, clientData) { + log.Printf("Session %s is not allowed to send offer for %s, ignoring", session.PublicId(), clientData.RoomType) + sendNotAllowed(client, message) + return + } + + if recipientSession := recipient.GetSession(); recipientSession != nil { + msg.Recipient.SessionId = session.PublicId() + // It may take some time for the publisher (which is the current + // client) to start his stream, so we must not block the active + // goroutine. + go h.processMcuMessage(client, recipient, recipientSession, message, msg, clientData) + } else { + // Client is not connected yet. + } + return + } + recipient.SendMessage(response) + } else { + if clientData != nil && clientData.Type == "sendoffer" { + // TODO(jojo): Implement this. + log.Printf("Sending offers to remote clients is not supported yet (client %s)", session.PublicId()) + return + } + h.nats.PublishMessage(subject, response) + } +} + +func isAllowedToControl(session Session) bool { + if session.ClientType() == HelloClientTypeInternal { + // Internal clients are allowed to send any control message. + return true + } + + if session.HasPermission(PERMISSION_MAY_CONTROL) { + // Moderator clients are allowed to send any control message. + return true + } + + return false +} + +func (h *Hub) processControlMsg(client *Client, message *ClientMessage) { + msg := message.Control + session := client.GetSession() + if session == nil { + // Client is not connected yet. + return + } else if !isAllowedToControl(session) { + log.Printf("Ignore control message %+v from %s", msg, session.PublicId()) + return + } + + var recipient *Client + var subject string + switch msg.Recipient.Type { + case RecipientTypeSession: + data := h.decodeSessionId(msg.Recipient.SessionId, publicSessionName) + if data != nil { + if msg.Recipient.SessionId == session.PublicId() { + // Don't loop messages to the sender. + return + } + + subject = "session." + msg.Recipient.SessionId + h.mu.RLock() + recipient = h.clients[data.Sid] + h.mu.RUnlock() + } + case RecipientTypeUser: + if msg.Recipient.UserId != "" { + if msg.Recipient.UserId == session.UserId() { + // Don't loop messages to the sender. + // TODO(jojo): Should we allow users to send messages to their + // other sessions? + return + } + + subject = GetSubjectForUserId(msg.Recipient.UserId) + } + case RecipientTypeRoom: + if session != nil { + if room := session.GetRoom(); room != nil { + subject = "room." + room.Id() + } + } + } + if subject == "" { + log.Printf("Unknown recipient in message %+v from %s", msg, session.PublicId()) + return + } + + response := &ServerMessage{ + Type: "control", + Control: &ControlServerMessage{ + Sender: &MessageServerMessageSender{ + Type: msg.Recipient.Type, + SessionId: session.PublicId(), + UserId: session.UserId(), + }, + Data: msg.Data, + }, + } + if recipient != nil { + recipient.SendMessage(response) + } else { + h.nats.PublishMessage(subject, response) + } +} + +func isAllowedToSend(session *ClientSession, data *MessageClientMessageData) bool { + var permission Permission + if data.RoomType == "screen" { + permission = PERMISSION_MAY_PUBLISH_SCREEN + } else { + permission = PERMISSION_MAY_PUBLISH_MEDIA + } + return session.HasPermission(permission) +} + +func sendNotAllowed(client *Client, message *ClientMessage) { + response := message.NewErrorServerMessage(NewError("not_allowed", "Not allowed to publish.")) + client.SendMessage(response) +} + +func sendMcuClientNotFound(client *Client, message *ClientMessage) { + response := message.NewErrorServerMessage(NewError("client_not_found", "No MCU client found to send message to.")) + client.SendMessage(response) +} + +func sendMcuProcessingFailed(client *Client, message *ClientMessage) { + response := message.NewErrorServerMessage(NewError("processing_failed", "Processing of the message failed, please check server logs.")) + client.SendMessage(response) +} + +func (h *Hub) processMcuMessage(senderClient *Client, client *Client, session *ClientSession, client_message *ClientMessage, message *MessageClientMessage, data *MessageClientMessageData) { + ctx, cancel := context.WithTimeout(context.Background(), h.mcuTimeout) + defer cancel() + + var mc McuClient + var err error + var clientType string + switch data.Type { + case "requestoffer": + if session.PublicId() == message.Recipient.SessionId { + log.Printf("Not requesting offer from itself for session %s", session.PublicId()) + return + } + + clientType = "subscriber" + mc, err = session.GetOrCreateSubscriber(ctx, h.mcu, message.Recipient.SessionId, data.RoomType) + case "sendoffer": + // Permissions have already been checked in "processMessageMsg". + clientType = "subscriber" + mc, err = session.GetOrCreateSubscriber(ctx, h.mcu, message.Recipient.SessionId, data.RoomType) + case "offer": + if !isAllowedToSend(session, data) { + log.Printf("Session %s is not allowed to offer %s, ignoring", session.PublicId(), data.RoomType) + sendNotAllowed(senderClient, client_message) + return + } + + clientType = "publisher" + mc, err = session.GetOrCreatePublisher(ctx, h.mcu, data.RoomType) + default: + if session.PublicId() == message.Recipient.SessionId { + if !isAllowedToSend(session, data) { + log.Printf("Session %s is not allowed to send candidate for %s, ignoring", session.PublicId(), data.RoomType) + sendNotAllowed(senderClient, client_message) + return + } + + clientType = "publisher" + mc = session.GetPublisher(data.RoomType) + } else { + clientType = "subscriber" + mc = session.GetSubscriber(message.Recipient.SessionId, data.RoomType) + } + } + if err != nil { + log.Printf("Could not create MCU %s for session %s to send %+v to %s: %s", clientType, session.PublicId(), data, message.Recipient.SessionId, err) + sendMcuClientNotFound(senderClient, client_message) + return + } else if mc == nil { + log.Printf("No MCU %s found for session %s to send %+v to %s", clientType, session.PublicId(), data, message.Recipient.SessionId) + sendMcuClientNotFound(senderClient, client_message) + return + } + + mc.SendMessage(context.TODO(), message, data, func(err error, response map[string]interface{}) { + if err != nil { + log.Printf("Could not send MCU message %+v for session %s to %s: %s", data, session.PublicId(), message.Recipient.SessionId, err) + sendMcuProcessingFailed(senderClient, client_message) + return + } else if response == nil { + // No response received + return + } + + h.sendMcuMessageResponse(client, session, message, data, response) + }) +} + +func (h *Hub) sendMcuMessageResponse(client *Client, session *ClientSession, message *MessageClientMessage, data *MessageClientMessageData, response map[string]interface{}) { + var response_message *ServerMessage + switch response["type"] { + case "answer": + answer_message := &AnswerOfferMessage{ + To: session.PublicId(), + From: session.PublicId(), + Type: "answer", + RoomType: data.RoomType, + Payload: response, + } + answer_data, err := json.Marshal(answer_message) + if err != nil { + log.Printf("Could not serialize answer %+v to %s: %s", answer_message, session.PublicId(), err) + return + } + response_message = &ServerMessage{ + Type: "message", + Message: &MessageServerMessage{ + Sender: &MessageServerMessageSender{ + Type: "session", + SessionId: session.PublicId(), + UserId: session.UserId(), + }, + Data: (*json.RawMessage)(&answer_data), + }, + } + case "offer": + offer_message := &AnswerOfferMessage{ + To: session.PublicId(), + From: message.Recipient.SessionId, + Type: "offer", + RoomType: data.RoomType, + Payload: response, + } + offer_data, err := json.Marshal(offer_message) + if err != nil { + log.Printf("Could not serialize offer %+v to %s: %s", offer_message, session.PublicId(), err) + return + } + response_message = &ServerMessage{ + Type: "message", + Message: &MessageServerMessage{ + Sender: &MessageServerMessageSender{ + Type: "session", + SessionId: message.Recipient.SessionId, + // TODO(jojo): Set "UserId" field if known user. + }, + Data: (*json.RawMessage)(&offer_data), + }, + } + default: + log.Printf("Unsupported response %+v received to send to %s", response, session.PublicId()) + return + } + + if response_message != nil { + client.SendMessage(response_message) + } +} + +func (h *Hub) processByeMsg(client *Client, message *ClientMessage) { + client.SendByeResponse(message) + if session := h.processUnregister(client); session != nil { + session.Close() + } +} + +func (h *Hub) processRoomUpdated(message *BackendServerRoomRequest) { + room := message.room + room.UpdateProperties(message.Update.Properties) +} + +func (h *Hub) processRoomDeleted(message *BackendServerRoomRequest) { + room := message.room + sessions := room.Close() + for _, session := range sessions { + // The session is no longer in the room + session.LeaveRoom(true) + switch sess := session.(type) { + case *ClientSession: + if client := sess.GetClient(); client != nil { + client.SendRoom(nil, nil) + } + } + } +} + +func (h *Hub) processRoomInCallChanged(message *BackendServerRoomRequest) { + room := message.room + room.PublishUsersInCallChanged(message.InCall.Changed, message.InCall.Users) +} + +func (h *Hub) processRoomParticipants(message *BackendServerRoomRequest) { + room := message.room + room.PublishUsersChanged(message.Participants.Changed, message.Participants.Users) +} + +func getRealUserIP(r *http.Request) string { + // Note this function assumes it is running behind a trusted proxy, so + // the headers can be trusted. + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + + if ip := r.Header.Get("X-Forwarded-For"); ip != "" { + // Result could be a list "clientip, proxy1, proxy2", so only use first element. + if pos := strings.Index(ip, ","); pos >= 0 { + ip = strings.TrimSpace(ip[:pos]) + } + return ip + } + + return r.RemoteAddr +} + +func (h *Hub) serveWs(w http.ResponseWriter, r *http.Request) { + addr := getRealUserIP(r) + agent := r.Header.Get("User-Agent") + + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Could not upgrade request from %s: %s", addr, err) + return + } + + client, err := NewClient(h, conn, addr, agent) + if err != nil { + log.Printf("Could not create client for %s: %s", addr, err) + return + } + + h.processNewClient(client) + go client.writePump() + go client.readPump() +} diff --git a/src/signaling/hub_test.go b/src/signaling/hub_test.go new file mode 100644 index 0000000..3205bbc --- /dev/null +++ b/src/signaling/hub_test.go @@ -0,0 +1,2021 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/dlintw/goconf" + "github.com/gorilla/mux" + "github.com/gorilla/websocket" + + "golang.org/x/net/context" +) + +const ( + testDefaultUserId = "test-userid" + authAnonymousUserId = "anonymous-userid" + + testTimeout = 10 * time.Second +) + +func getTestConfig(server *httptest.Server) (*goconf.ConfigFile, error) { + config := goconf.NewConfigFile() + u, err := url.Parse(server.URL) + if err != nil { + return nil, err + } + config.AddOption("backend", "allowed", u.Host) + config.AddOption("backend", "secret", string(testBackendSecret)) + config.AddOption("sessions", "hashkey", "12345678901234567890123456789012") + config.AddOption("sessions", "blockkey", "09876543210987654321098765432109") + config.AddOption("clients", "internalsecret", string(testInternalSecret)) + config.AddOption("geoip", "url", "none") + return config, nil +} + +func CreateHubForTest(t *testing.T) (*Hub, NatsClient, *mux.Router, *httptest.Server, func()) { + r := mux.NewRouter() + registerBackendHandler(t, r) + + server := httptest.NewServer(r) + nats, err := NewLoopbackNatsClient() + if err != nil { + t.Fatal(err) + } + config, err := getTestConfig(server) + if err != nil { + t.Fatal(err) + } + h, err := NewHub(config, nats, r, "no-version") + if err != nil { + t.Fatal(err) + } + + go h.Run() + + shutdown := func() { + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + WaitForHub(ctx, t, h) + (nats).(*LoopbackNatsClient).waitForSubscriptionsEmpty(ctx, t) + server.Close() + } + + return h, nats, r, server, shutdown +} + +func WaitForHub(ctx context.Context, t *testing.T, h *Hub) { + h.Stop() + for { + h.mu.Lock() + clients := len(h.clients) + sessions := len(h.sessions) + h.mu.Unlock() + h.ru.Lock() + rooms := len(h.rooms) + h.ru.Unlock() + if clients == 0 && rooms == 0 && sessions == 0 { + break + } + + select { + case <-ctx.Done(): + h.mu.Lock() + h.ru.Lock() + t.Errorf("Error waiting for clients %+v / rooms %+v / sessions %+v to terminate: %s", h.clients, h.rooms, h.sessions, ctx.Err()) + h.ru.Unlock() + h.mu.Unlock() + return + default: + time.Sleep(time.Millisecond) + } + } +} + +func validateBackendChecksum(t *testing.T, f func(http.ResponseWriter, *http.Request, *BackendClientRequest) *BackendClientResponse) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatal("Error reading body: ", err) + } + + rnd := r.Header.Get(HeaderBackendSignalingRandom) + checksum := r.Header.Get(HeaderBackendSignalingChecksum) + if rnd == "" || checksum == "" { + t.Fatal("No checksum headers found") + } + + if verify := CalculateBackendChecksum(rnd, body, testBackendSecret); verify != checksum { + t.Fatal("Backend checksum verification failed") + } + + var request BackendClientRequest + if err := json.Unmarshal(body, &request); err != nil { + t.Fatal(err) + } + + response := f(w, r, &request) + if response == nil { + // Function already returned a response. + return + } + + data, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) + } +} + +func processAuthRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *BackendClientRequest) *BackendClientResponse { + if request.Type != "auth" || request.Auth == nil { + t.Fatalf("Expected an auth backend request, got %+v", request) + } + + var params TestBackendClientAuthParams + if request.Auth.Params != nil && len(*request.Auth.Params) > 0 { + if err := json.Unmarshal(*request.Auth.Params, ¶ms); err != nil { + t.Fatal(err) + } + } + if params.UserId == "" { + params.UserId = testDefaultUserId + } else if params.UserId == authAnonymousUserId { + params.UserId = "" + } + + response := &BackendClientResponse{ + Type: "auth", + Auth: &BackendClientAuthResponse{ + Version: BackendVersion, + UserId: params.UserId, + }, + } + return response +} + +func processRoomRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *BackendClientRequest) *BackendClientResponse { + if request.Type != "room" || request.Room == nil { + t.Fatalf("Expected an room backend request, got %+v", request) + } + + if request.Room.RoomId == "test-room-takeover-room-session" { + // Additional checks for testcase "TestClientTakeoverRoomSession" + if request.Room.Action == "leave" && request.Room.UserId == "test-userid1" { + t.Errorf("Should not receive \"leave\" event for first user, received %+v", request.Room) + } + } + + // Allow joining any room. + response := &BackendClientResponse{ + Type: "room", + Room: &BackendClientRoomResponse{ + Version: BackendVersion, + RoomId: request.Room.RoomId, + }, + } + return response +} + +func registerBackendHandler(t *testing.T, router *mux.Router) { + router.HandleFunc("/", validateBackendChecksum(t, func(w http.ResponseWriter, r *http.Request, request *BackendClientRequest) *BackendClientResponse { + switch request.Type { + case "auth": + return processAuthRequest(t, w, r, request) + case "room": + return processRoomRequest(t, w, r, request) + default: + t.Fatalf("Unsupported request received: %+v", request) + return nil + } + })) +} + +func performHousekeeping(hub *Hub, now time.Time) *sync.WaitGroup { + var wg sync.WaitGroup + wg.Add(1) + go func() { + hub.performHousekeeping(now) + wg.Done() + }() + return &wg +} + +func TestExpectClientHello(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + // The server will send an error and close the connection if no "Hello" + // is sent. + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + // Perform housekeeping in the future, this will cause the connection to + // be terminated due to the missing "Hello" request. + performHousekeeping(hub, time.Now().Add(initialHelloTimeout+time.Second)) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + message, err := client.RunUntilMessage(ctx) + if err := checkUnexpectedClose(err); err != nil { + t.Fatal(err) + } + + message2, err := client.RunUntilMessage(ctx) + if message2 != nil { + t.Fatalf("Received multiple messages, already have %+v, also got %+v", message, message2) + } + if err := checkUnexpectedClose(err); err != nil { + t.Fatal(err) + } + + if err := checkMessageType(message, "bye"); err != nil { + t.Error(err) + } else if message.Bye.Reason != "hello_timeout" { + t.Errorf("Expected \"hello_timeout\" reason, got %+v", message.Bye) + } +} + +func TestClientHello(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + if hello, err := client.RunUntilHello(ctx); err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + } +} + +func TestClientHelloWithSpaces(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + userId := "test user with spaces" + if err := client.SendHello(userId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + if hello, err := client.RunUntilHello(ctx); err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != userId { + t.Errorf("Expected \"%s\", got %+v", userId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + } +} + +func TestSessionIdsUnordered(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + publicSessionIds := make([]string, 0) + for i := 0; i < 20; i++ { + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + if hello, err := client.RunUntilHello(ctx); err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + break + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + break + } + + data := hub.decodeSessionId(hello.Hello.SessionId, publicSessionName) + if data == nil { + t.Errorf("Could not decode session id: %s", hello.Hello.SessionId) + break + } + + hub.mu.RLock() + session := hub.sessions[data.Sid] + hub.mu.RUnlock() + if session == nil { + t.Errorf("Could not get session for id %+v", data) + break + } + + publicSessionIds = append(publicSessionIds, session.PublicId()) + } + } + + if len(publicSessionIds) == 0 { + t.Fatal("no session ids decoded") + } + + larger := 0 + smaller := 0 + prevSid := "" + for i, sid := range publicSessionIds { + if i > 0 { + if sid > prevSid { + larger++ + } else if sid < prevSid { + smaller-- + } else { + t.Error("should not have received the same session id twice") + } + } + prevSid = sid + } + + // Public session ids should not be ordered. + if len(publicSessionIds) == larger { + t.Error("the session ids are all larger than the previous ones") + } else if len(publicSessionIds) == smaller { + t.Error("the session ids are all smaller than the previous ones") + } +} + +func TestClientHelloResume(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } + + client.Close() + if err := client.WaitForClientRemoved(ctx); err != nil { + t.Error(err) + } + + client = NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHelloResume(hello.Hello.ResumeId); err != nil { + t.Fatal(err) + } + hello2, err := client.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello2.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello2.Hello) + } + if hello2.Hello.SessionId != hello.Hello.SessionId { + t.Errorf("Expected session id %s, got %+v", hello.Hello.SessionId, hello2.Hello) + } + if hello2.Hello.ResumeId != hello.Hello.ResumeId { + t.Errorf("Expected resume id %s, got %+v", hello.Hello.ResumeId, hello2.Hello) + } + } +} + +func TestClientHelloResumeExpired(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } + + client.Close() + if err := client.WaitForClientRemoved(ctx); err != nil { + t.Error(err) + } + + // Perform housekeeping in the future, this will cause the session to be + // cleaned up after it is expired. + performHousekeeping(hub, time.Now().Add(sessionExpireDuration+time.Second)).Wait() + + client = NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHelloResume(hello.Hello.ResumeId); err != nil { + t.Fatal(err) + } + msg, err := client.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } else { + if msg.Type != "error" || msg.Error == nil { + t.Errorf("Expected error message, got %+v", msg) + } else if msg.Error.Code != "no_such_session" { + t.Errorf("Expected error \"no_such_session\", got %+v", msg.Error.Code) + } + } +} + +func TestClientHelloResumeTakeover(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + + if err := client1.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client1.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } + + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + + if err := client2.SendHelloResume(hello.Hello.ResumeId); err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello2.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello2.Hello) + } + if hello2.Hello.SessionId != hello.Hello.SessionId { + t.Errorf("Expected session id %s, got %+v", hello.Hello.SessionId, hello2.Hello) + } + if hello2.Hello.ResumeId != hello.Hello.ResumeId { + t.Errorf("Expected resume id %s, got %+v", hello.Hello.ResumeId, hello2.Hello) + } + } + + // The first client got disconnected with a reason in a "Bye" message. + msg, err := client1.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } else { + if msg.Type != "bye" || msg.Bye == nil { + t.Errorf("Expected bye message, got %+v", msg) + } else if msg.Bye.Reason != "session_resumed" { + t.Errorf("Expected reason \"session_resumed\", got %+v", msg.Bye.Reason) + } + } + + if msg, err := client1.RunUntilMessage(ctx); err == nil { + t.Errorf("Expected error but received %+v", msg) + } else if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + t.Errorf("Expected close error but received %+v", err) + } +} + +func TestClientHelloResumeOtherHub(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } + + client.Close() + if err := client.WaitForClientRemoved(ctx); err != nil { + t.Error(err) + } + + // Simulate a restart of the hub. + atomic.StoreUint64(&hub.sid, 0) + sessions := make([]Session, 0) + hub.mu.Lock() + for _, session := range hub.sessions { + sessions = append(sessions, session) + } + hub.mu.Unlock() + for _, session := range sessions { + session.Close() + } + hub.mu.Lock() + count := len(hub.sessions) + hub.mu.Unlock() + if count > 0 { + t.Errorf("Should have removed all sessions (still has %d)", count) + } + + // The new client will get the same (internal) sid for his session. + newClient := NewTestClient(t, server, hub) + defer newClient.CloseWithBye() + + if err := newClient.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + if hello, err := newClient.RunUntilHello(ctx); err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } + + // The previous session (which had the same internal sid) can't be resumed. + client = NewTestClient(t, server, hub) + defer client.CloseWithBye() + if err := client.SendHelloResume(hello.Hello.ResumeId); err != nil { + t.Fatal(err) + } + msg, err := client.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } else { + if msg.Type != "error" || msg.Error == nil { + t.Errorf("Expected error message, got %+v", msg) + } else if msg.Error.Code != "no_such_session" { + t.Errorf("Expected error \"no_such_session\", got %+v", msg.Error.Code) + } + } + + // Expire old sessions + hub.performHousekeeping(time.Now().Add(2 * sessionExpireDuration)) +} + +func TestClientHelloResumePublicId(t *testing.T) { + // Test that a client can't resume a "public" session of another user. + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if hello1.Hello.SessionId == hello2.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello1.Hello.SessionId) + } + + recipient2 := MessageClientMessageRecipient{ + Type: "session", + SessionId: hello2.Hello.SessionId, + } + + data := "from-1-to-2" + client1.SendMessage(recipient2, data) + + var payload string + var sender *MessageServerMessageSender + if err := checkReceiveClientMessageWithSender(ctx, client2, "session", hello1.Hello, &payload, &sender); err != nil { + t.Error(err) + } else if payload != data { + t.Errorf("Expected payload %s, got %s", data, payload) + } + + client1.Close() + if err := client1.WaitForClientRemoved(ctx); err != nil { + t.Error(err) + } + + client1 = NewTestClient(t, server, hub) + defer client1.CloseWithBye() + + // Can't resume a session with the id received from messages of a client. + if err := client1.SendHelloResume(sender.SessionId); err != nil { + t.Fatal(err) + } + msg, err := client1.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } else { + if msg.Type != "error" || msg.Error == nil { + t.Errorf("Expected error message, got %+v", msg) + } else if msg.Error.Code != "no_such_session" { + t.Errorf("Expected error \"no_such_session\", got %+v", msg.Error.Code) + } + } + + // Expire old sessions + hub.performHousekeeping(time.Now().Add(2 * sessionExpireDuration)) +} + +func TestClientHelloByeResume(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } + + if err := client.SendBye(); err != nil { + t.Fatal(err) + } + if message, err := client.RunUntilMessage(ctx); err != nil { + t.Error(err) + } else { + if err := checkMessageType(message, "bye"); err != nil { + t.Error(err) + } + } + + client.Close() + if err := client.WaitForSessionRemoved(ctx, hello.Hello.SessionId); err != nil { + t.Error(err) + } + if err := client.WaitForClientRemoved(ctx); err != nil { + t.Error(err) + } + + client = NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHelloResume(hello.Hello.ResumeId); err != nil { + t.Fatal(err) + } + msg, err := client.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } else { + if msg.Type != "error" || msg.Error == nil { + t.Errorf("Expected \"error\", got %+v", *msg) + } else if msg.Error.Code != "no_such_session" { + t.Errorf("Expected error \"no_such_session\", got %+v", *msg) + } + } +} + +func TestClientHelloResumeAndJoin(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } + + client.Close() + if err := client.WaitForClientRemoved(ctx); err != nil { + t.Error(err) + } + + client = NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHelloResume(hello.Hello.ResumeId); err != nil { + t.Fatal(err) + } + hello2, err := client.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello2.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello2.Hello) + } + if hello2.Hello.SessionId != hello.Hello.SessionId { + t.Errorf("Expected session id %s, got %+v", hello.Hello.SessionId, hello2.Hello) + } + if hello2.Hello.ResumeId != hello.Hello.ResumeId { + t.Errorf("Expected resume id %s, got %+v", hello.Hello.ResumeId, hello2.Hello) + } + } + + // Join room by id. + roomId := "test-room" + if room, err := client.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } +} + +func TestClientHelloClient(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHelloClient(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + if hello, err := client.RunUntilHello(ctx); err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != testDefaultUserId { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId, hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } +} + +func TestClientHelloInternal(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHelloInternal(); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + if hello, err := client.RunUntilHello(ctx); err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != "" { + t.Errorf("Expected empty user id, got %+v", hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } +} + +func TestClientMessageToSessionId(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if hello1.Hello.SessionId == hello2.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello1.Hello.SessionId) + } + + recipient1 := MessageClientMessageRecipient{ + Type: "session", + SessionId: hello1.Hello.SessionId, + } + recipient2 := MessageClientMessageRecipient{ + Type: "session", + SessionId: hello2.Hello.SessionId, + } + + data1 := "from-1-to-2" + client1.SendMessage(recipient2, data1) + data2 := "from-2-to-1" + client2.SendMessage(recipient1, data2) + + var payload string + if err := checkReceiveClientMessage(ctx, client1, "session", hello2.Hello, &payload); err != nil { + t.Error(err) + } else if payload != data2 { + t.Errorf("Expected payload %s, got %s", data2, payload) + } + if err := checkReceiveClientMessage(ctx, client2, "session", hello1.Hello, &payload); err != nil { + t.Error(err) + } else if payload != data1 { + t.Errorf("Expected payload %s, got %s", data1, payload) + } +} + +func TestClientMessageToUserId(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if hello1.Hello.SessionId == hello2.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello1.Hello.SessionId) + } else if hello1.Hello.UserId == hello2.Hello.UserId { + t.Fatalf("Expected different user ids, got %s twice", hello1.Hello.UserId) + } + + recipient1 := MessageClientMessageRecipient{ + Type: "user", + UserId: hello1.Hello.UserId, + } + recipient2 := MessageClientMessageRecipient{ + Type: "user", + UserId: hello2.Hello.UserId, + } + + data1 := "from-1-to-2" + client1.SendMessage(recipient2, data1) + data2 := "from-2-to-1" + client2.SendMessage(recipient1, data2) + + var payload string + if err := checkReceiveClientMessage(ctx, client1, "user", hello2.Hello, &payload); err != nil { + t.Error(err) + } else if payload != data2 { + t.Errorf("Expected payload %s, got %s", data2, payload) + } + + if err := checkReceiveClientMessage(ctx, client2, "user", hello1.Hello, &payload); err != nil { + t.Error(err) + } else if payload != data1 { + t.Errorf("Expected payload %s, got %s", data1, payload) + } +} + +func TestClientMessageToUserIdMultipleSessions(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + client2a := NewTestClient(t, server, hub) + defer client2a.CloseWithBye() + if err := client2a.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + client2b := NewTestClient(t, server, hub) + defer client2b.CloseWithBye() + if err := client2b.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2a, err := client2a.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2b, err := client2b.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if hello1.Hello.SessionId == hello2a.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello1.Hello.SessionId) + } else if hello1.Hello.SessionId == hello2b.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello1.Hello.SessionId) + } else if hello2a.Hello.SessionId == hello2b.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello2a.Hello.SessionId) + } + if hello1.Hello.UserId == hello2a.Hello.UserId { + t.Fatalf("Expected different user ids, got %s twice", hello1.Hello.UserId) + } else if hello1.Hello.UserId == hello2b.Hello.UserId { + t.Fatalf("Expected different user ids, got %s twice", hello1.Hello.UserId) + } else if hello2a.Hello.UserId != hello2b.Hello.UserId { + t.Fatalf("Expected the same user ids, got %s and %s", hello2a.Hello.UserId, hello2b.Hello.UserId) + } + + recipient := MessageClientMessageRecipient{ + Type: "user", + UserId: hello2a.Hello.UserId, + } + + data1 := "from-1-to-2" + client1.SendMessage(recipient, data1) + + // Both clients will receive the message as it was sent to the user. + var payload string + if err := checkReceiveClientMessage(ctx, client2a, "user", hello1.Hello, &payload); err != nil { + t.Error(err) + } else if payload != data1 { + t.Errorf("Expected payload %s, got %s", data1, payload) + } + if err := checkReceiveClientMessage(ctx, client2b, "user", hello1.Hello, &payload); err != nil { + t.Error(err) + } else if payload != data1 { + t.Errorf("Expected payload %s, got %s", data1, payload) + } +} + +func WaitForUsersJoined(ctx context.Context, t *testing.T, client1 *TestClient, hello1 *ServerMessage, client2 *TestClient, hello2 *ServerMessage) { + // We will receive "joined" events for all clients. The ordering is not + // defined as messages are processed and sent by asynchronous NATS handlers. + msg1_1, err := client1.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + msg1_2, err := client1.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + msg2_1, err := client2.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + msg2_2, err := client2.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + + if err := client1.checkMessageJoined(msg1_1, hello1.Hello); err != nil { + // Ordering is "joined" from client 2, then from client 1 + if err := client1.checkMessageJoined(msg1_1, hello2.Hello); err != nil { + t.Error(err) + } + if err := client1.checkMessageJoined(msg1_2, hello1.Hello); err != nil { + t.Error(err) + } + } else { + // Ordering is "joined" from client 1, then from client 2 + if err := client1.checkMessageJoined(msg1_2, hello2.Hello); err != nil { + t.Error(err) + } + } + if err := client2.checkMessageJoined(msg2_1, hello1.Hello); err != nil { + // Ordering is "joined" from client 2, then from client 1 + if err := client2.checkMessageJoined(msg2_1, hello2.Hello); err != nil { + t.Error(err) + } + if err := client2.checkMessageJoined(msg2_2, hello1.Hello); err != nil { + t.Error(err) + } + } else { + // Ordering is "joined" from client 1, then from client 2 + if err := client2.checkMessageJoined(msg2_2, hello2.Hello); err != nil { + t.Error(err) + } + } +} + +func TestClientMessageToRoom(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if hello1.Hello.SessionId == hello2.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello1.Hello.SessionId) + } else if hello1.Hello.UserId == hello2.Hello.UserId { + t.Fatalf("Expected different user ids, got %s twice", hello1.Hello.UserId) + } + + // Join room by id. + roomId := "test-room" + if room, err := client1.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + if room, err := client2.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) + + recipient := MessageClientMessageRecipient{ + Type: "room", + } + + data1 := "from-1-to-2" + client1.SendMessage(recipient, data1) + data2 := "from-2-to-1" + client2.SendMessage(recipient, data2) + + var payload string + if err := checkReceiveClientMessage(ctx, client1, "room", hello2.Hello, &payload); err != nil { + t.Error(err) + } else if payload != data2 { + t.Errorf("Expected payload %s, got %s", data2, payload) + } + + if err := checkReceiveClientMessage(ctx, client2, "room", hello1.Hello, &payload); err != nil { + t.Error(err) + } else if payload != data1 { + t.Errorf("Expected payload %s, got %s", data1, payload) + } +} + +func TestJoinRoom(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId := "test-room" + if room, err := client.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + // We will receive a "joined" event. + if err := client.RunUntilJoined(ctx, hello.Hello); err != nil { + t.Error(err) + } + + // Leave room. + if room, err := client.JoinRoom(ctx, ""); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != "" { + t.Fatalf("Expected empty room, got %s", room.Room.RoomId) + } +} + +func TestExpectAnonymousJoinRoom(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(authAnonymousUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello.Hello.UserId != "" { + t.Errorf("Expected an anonymous user, got %+v", hello.Hello) + } + if hello.Hello.SessionId == "" { + t.Errorf("Expected session id, got %+v", hello.Hello) + } + if hello.Hello.ResumeId == "" { + t.Errorf("Expected resume id, got %+v", hello.Hello) + } + } + + // Perform housekeeping in the future, this will cause the connection to + // be terminated because the anonymous client didn't join a room. + performHousekeeping(hub, time.Now().Add(anonmyousJoinRoomTimeout+time.Second)) + + message, err := client.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + + if err := checkMessageType(message, "bye"); err != nil { + t.Error(err) + } else if message.Bye.Reason != "room_join_timeout" { + t.Errorf("Expected \"room_join_timeout\" reason, got %+v", message.Bye) + } + + // Both the client and the session get removed from the hub. + if err := client.WaitForClientRemoved(ctx); err != nil { + t.Error(err) + } + if err := client.WaitForSessionRemoved(ctx, hello.Hello.SessionId); err != nil { + t.Error(err) + } +} + +func TestJoinRoomChange(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId := "test-room" + if room, err := client.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + // We will receive a "joined" event. + if err := client.RunUntilJoined(ctx, hello.Hello); err != nil { + t.Error(err) + } + + // Change room. + roomId = "other-test-room" + if room, err := client.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + // We will receive a "joined" event. + if err := client.RunUntilJoined(ctx, hello.Hello); err != nil { + t.Error(err) + } + + // Leave room. + if room, err := client.JoinRoom(ctx, ""); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != "" { + t.Fatalf("Expected empty room, got %s", room.Room.RoomId) + } +} + +func TestJoinMultiple(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if hello1.Hello.SessionId == hello2.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello1.Hello.SessionId) + } + + // Join room by id (first client). + roomId := "test-room" + if room, err := client1.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + // We will receive a "joined" event. + if err := client1.RunUntilJoined(ctx, hello1.Hello); err != nil { + t.Error(err) + } + + // Join room by id (second client). + if room, err := client2.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + // We will receive a "joined" event for the first and the second client. + if err := client2.RunUntilJoined(ctx, hello1.Hello); err != nil { + t.Error(err) + } + if err := client2.RunUntilJoined(ctx, hello2.Hello); err != nil { + t.Error(err) + } + // The first client will also receive a "joined" event from the second client. + if err := client1.RunUntilJoined(ctx, hello2.Hello); err != nil { + t.Error(err) + } + + // Leave room. + if room, err := client1.JoinRoom(ctx, ""); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != "" { + t.Fatalf("Expected empty room, got %s", room.Room.RoomId) + } + + // The second client will now receive a "left" event + if err := client2.RunUntilLeft(ctx, hello1.Hello); err != nil { + t.Error(err) + } + + if room, err := client2.JoinRoom(ctx, ""); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != "" { + t.Fatalf("Expected empty room, got %s", room.Room.RoomId) + } +} + +func TestGetRealUserIP(t *testing.T) { + REMOTE_ATTR := "192.168.1.2" + var request *http.Request + + request = &http.Request{ + RemoteAddr: REMOTE_ATTR, + } + if ip := getRealUserIP(request); ip != REMOTE_ATTR { + t.Errorf("Expected %s but got %s", REMOTE_ATTR, ip) + } + + X_REAL_IP := "192.168.10.11" + request.Header = http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{X_REAL_IP}, + } + if ip := getRealUserIP(request); ip != X_REAL_IP { + t.Errorf("Expected %s but got %s", X_REAL_IP, ip) + } + + // "X-Real-IP" has preference before "X-Forwarded-For" + X_FORWARDED_FOR_IP := "192.168.20.21" + X_FORWARDED_FOR := X_FORWARDED_FOR_IP + ", 192.168.30.32" + request.Header = http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{X_REAL_IP}, + http.CanonicalHeaderKey("x-forwarded-for"): []string{X_FORWARDED_FOR}, + } + if ip := getRealUserIP(request); ip != X_REAL_IP { + t.Errorf("Expected %s but got %s", X_REAL_IP, ip) + } + + request.Header = http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{X_FORWARDED_FOR}, + } + if ip := getRealUserIP(request); ip != X_FORWARDED_FOR_IP { + t.Errorf("Expected %s but got %s", X_FORWARDED_FOR_IP, ip) + } +} + +func TestClientMessageToSessionIdWhileDisconnected(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if hello1.Hello.SessionId == hello2.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello1.Hello.SessionId) + } + + client2.Close() + if err := client2.WaitForClientRemoved(ctx); err != nil { + t.Error(err) + } + + recipient2 := MessageClientMessageRecipient{ + Type: "session", + SessionId: hello2.Hello.SessionId, + } + + // The two chat messages should get combined into one when receiving pending messages. + chat_refresh := "{\"type\":\"chat\",\"chat\":{\"refresh\":true}}" + var data1 map[string]interface{} + if err := json.Unmarshal([]byte(chat_refresh), &data1); err != nil { + t.Fatal(err) + } + client1.SendMessage(recipient2, data1) + client1.SendMessage(recipient2, data1) + + client2 = NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHelloResume(hello2.Hello.ResumeId); err != nil { + t.Fatal(err) + } + hello3, err := client2.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello3.Hello.UserId != testDefaultUserId+"2" { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId+"2", hello3.Hello) + } + if hello3.Hello.SessionId != hello2.Hello.SessionId { + t.Errorf("Expected session id %s, got %+v", hello2.Hello.SessionId, hello3.Hello) + } + if hello3.Hello.ResumeId != hello2.Hello.ResumeId { + t.Errorf("Expected resume id %s, got %+v", hello2.Hello.ResumeId, hello3.Hello) + } + } + + var payload map[string]interface{} + if err := checkReceiveClientMessage(ctx, client2, "session", hello1.Hello, &payload); err != nil { + t.Error(err) + } else if !reflect.DeepEqual(payload, data1) { + t.Errorf("Expected payload %+v, got %+v", data1, payload) + } + + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + + if err := checkReceiveClientMessage(ctx2, client2, "session", hello1.Hello, &payload); err != nil { + if err != NoMessageReceivedError { + t.Error(err) + } + } else { + t.Errorf("Expected no payload, got %+v", payload) + } +} + +func TestRoomParticipantsListUpdateWhileDisconnected(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if hello1.Hello.SessionId == hello2.Hello.SessionId { + t.Fatalf("Expected different session ids, got %s twice", hello1.Hello.SessionId) + } + + // Join room by id. + roomId := "test-room" + if room, err := client1.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + if room, err := client2.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) + + // Simulate request from the backend that somebody joined the call. + users := []map[string]interface{}{ + map[string]interface{}{ + "sessionId": "the-session-id", + "inCall": 1, + }, + } + room, found := hub.rooms[roomId] + if !found { + t.Fatalf("Could not find room %s", roomId) + } + room.PublishUsersInCallChanged(users, users) + if err := checkReceiveClientEvent(ctx, client2, "update", nil); err != nil { + t.Error(err) + } + + client2.Close() + if err := client2.WaitForClientRemoved(ctx); err != nil { + t.Error(err) + } + + room.PublishUsersInCallChanged(users, users) + + // Give NATS message some time to be processed. + time.Sleep(100 * time.Millisecond) + + recipient2 := MessageClientMessageRecipient{ + Type: "session", + SessionId: hello2.Hello.SessionId, + } + + chat_refresh := "{\"type\":\"chat\",\"chat\":{\"refresh\":true}}" + var data1 map[string]interface{} + if err := json.Unmarshal([]byte(chat_refresh), &data1); err != nil { + t.Fatal(err) + } + client1.SendMessage(recipient2, data1) + + client2 = NewTestClient(t, server, hub) + defer client2.CloseWithBye() + if err := client2.SendHelloResume(hello2.Hello.ResumeId); err != nil { + t.Fatal(err) + } + hello3, err := client2.RunUntilHello(ctx) + if err != nil { + t.Error(err) + } else { + if hello3.Hello.UserId != testDefaultUserId+"2" { + t.Errorf("Expected \"%s\", got %+v", testDefaultUserId+"2", hello3.Hello) + } + if hello3.Hello.SessionId != hello2.Hello.SessionId { + t.Errorf("Expected session id %s, got %+v", hello2.Hello.SessionId, hello3.Hello) + } + if hello3.Hello.ResumeId != hello2.Hello.ResumeId { + t.Errorf("Expected resume id %s, got %+v", hello2.Hello.ResumeId, hello3.Hello) + } + } + + // The participants list update event is triggered again after the session resume. + // TODO(jojo): Check contents of message and try with multiple users. + if err := checkReceiveClientEvent(ctx, client2, "update", nil); err != nil { + t.Error(err) + } + + var payload map[string]interface{} + if err := checkReceiveClientMessage(ctx, client2, "session", hello1.Hello, &payload); err != nil { + t.Error(err) + } else if !reflect.DeepEqual(payload, data1) { + t.Errorf("Expected payload %+v, got %+v", data1, payload) + } + + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + + if err := checkReceiveClientMessage(ctx2, client2, "session", hello1.Hello, &payload); err != nil { + if err != NoMessageReceivedError { + t.Error(err) + } + } else { + t.Errorf("Expected no payload, got %+v", payload) + } +} + +func TestClientTakeoverRoomSession(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId := "test-room-takeover-room-session" + roomSessionid := "room-session-id" + if room, err := client1.JoinRoomWithRoomSession(ctx, roomId, roomSessionid); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } else { + t.Fatalf("Room %s does not exist", roomId) + } + + if session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId); session1 == nil { + t.Fatalf("There should be a session %s", hello1.Hello.SessionId) + } + + client3 := NewTestClient(t, server, hub) + defer client3.CloseWithBye() + + if err := client3.SendHello(testDefaultUserId + "3"); err != nil { + t.Fatal(err) + } + + hello3, err := client3.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if room, err := client3.JoinRoomWithRoomSession(ctx, roomId, roomSessionid+"other"); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + // Wait until both users have joined. + WaitForUsersJoined(ctx, t, client1, hello1, client3, hello3) + + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + if room, err := client2.JoinRoomWithRoomSession(ctx, roomId, roomSessionid); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + // The first client got disconnected with a reason in a "Bye" message. + msg, err := client1.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } else { + if msg.Type != "bye" || msg.Bye == nil { + t.Errorf("Expected bye message, got %+v", msg) + } else if msg.Bye.Reason != "room_session_reconnected" { + t.Errorf("Expected reason \"room_session_reconnected\", got %+v", msg.Bye.Reason) + } + } + + if msg, err := client1.RunUntilMessage(ctx); err == nil { + t.Errorf("Expected error but received %+v", msg) + } else if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + t.Errorf("Expected close error but received %+v", err) + } + + // The first session has been closed + if session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId); session1 != nil { + t.Errorf("The session %s should have been removed", hello1.Hello.SessionId) + } + + // The new client will receive "joined" events for the existing client3 and + // himself. + if err := client2.RunUntilJoined(ctx, hello3.Hello); err != nil { + t.Error(err) + } + + if err := client2.RunUntilJoined(ctx, hello2.Hello); err != nil { + t.Error(err) + } + + // No message about the closing is sent to the new connection. + ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel2() + + if message, err := client2.RunUntilMessage(ctx2); err != nil && err != NoMessageReceivedError && err != context.DeadlineExceeded { + t.Error(err) + } else if message != nil { + t.Errorf("Expected no message, got %+v", message) + } + + // The permanently connected client will receive a "left" event from the + // overridden session and a "joined" for the new session. + if err := client3.RunUntilLeft(ctx, hello1.Hello); err != nil { + t.Error(err) + } + if err := client3.RunUntilJoined(ctx, hello2.Hello); err != nil { + t.Error(err) + } + + time.Sleep(time.Second) +} + +func TestClientSendOfferPermissions(t *testing.T) { + hub, _, _, server, shutdown := CreateHubForTest(t) + defer shutdown() + + mcu, err := NewTestMCU() + if err != nil { + t.Fatal(err) + } else if err := mcu.Start(); err != nil { + t.Fatal(err) + } + defer mcu.Stop() + + hub.SetMcu(mcu) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + + if err := client1.SendHello(testDefaultUserId + "1"); err != nil { + t.Fatal(err) + } + + hello1, err := client1.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + + if err := client2.SendHello(testDefaultUserId + "2"); err != nil { + t.Fatal(err) + } + + hello2, err := client2.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId := "test-room" + if room, err := client1.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + if room, err := client2.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) + + session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId).(*ClientSession) + if session1 == nil { + t.Fatalf("Session %s does not exist", hello1.Hello.SessionId) + } + session2 := hub.GetSessionByPublicId(hello2.Hello.SessionId).(*ClientSession) + if session2 == nil { + t.Fatalf("Session %s does not exist", hello2.Hello.SessionId) + } + + // Client 1 is the moderator + session1.SetPermissions([]Permission{PERMISSION_MAY_PUBLISH_MEDIA, PERMISSION_MAY_PUBLISH_SCREEN}) + // Client 2 is a guest participant. + session2.SetPermissions([]Permission{}) + + // Client 2 may not send an offer (he doesn't have the necessary permissions). + if err := client2.SendMessage(MessageClientMessageRecipient{ + Type: "session", + SessionId: hello1.Hello.SessionId, + }, MessageClientMessageData{ + Type: "sendoffer", + Sid: "12345", + RoomType: "screen", + }); err != nil { + t.Fatal(err) + } + + if msg, err := client2.RunUntilMessage(ctx); err != nil { + t.Fatal(err) + } else { + if err := checkMessageError(msg, "not_allowed"); err != nil { + t.Fatal(err) + } + } + + // Client 1 may send an offer. + if err := client1.SendMessage(MessageClientMessageRecipient{ + Type: "session", + SessionId: hello2.Hello.SessionId, + }, MessageClientMessageData{ + Type: "sendoffer", + Sid: "54321", + RoomType: "screen", + }); err != nil { + t.Fatal(err) + } + + // The test MCU doesn't support clients yet, so an error will be returned + // to the client trying to send the offer. + if msg, err := client1.RunUntilMessage(ctx); err != nil { + t.Fatal(err) + } else { + if err := checkMessageError(msg, "client_not_found"); err != nil { + t.Fatal(err) + } + } + + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + + if msg, err := client2.RunUntilMessage(ctx2); err != nil { + if err != context.DeadlineExceeded { + t.Fatal(err) + } + } else { + t.Errorf("Expected no payload, got %+v", msg) + } +} diff --git a/src/signaling/janus_client.go b/src/signaling/janus_client.go new file mode 100644 index 0000000..f10e6b6 --- /dev/null +++ b/src/signaling/janus_client.go @@ -0,0 +1,853 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Contents heavily based on + * https://github.com/notedit/janus-go/blob/master/janus.go + * + * Added error handling and improve functionality. + */ +package signaling + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + "github.com/notedit/janus-go" + + "golang.org/x/net/context" +) + +const ( + /*! \brief Success (no error) */ + JANUS_OK = 0 + + /*! \brief Unauthorized (can only happen when using apisecret/auth token) */ + JANUS_ERROR_UNAUTHORIZED = 403 + /*! \brief Unauthorized access to a plugin (can only happen when using auth token) */ + JANUS_ERROR_UNAUTHORIZED_PLUGIN = 405 + /*! \brief Unknown/undocumented error */ + JANUS_ERROR_UNKNOWN = 490 + /*! \brief Transport related error */ + JANUS_ERROR_TRANSPORT_SPECIFIC = 450 + /*! \brief The request is missing in the message */ + JANUS_ERROR_MISSING_REQUEST = 452 + /*! \brief The gateway does not suppurt this request */ + JANUS_ERROR_UNKNOWN_REQUEST = 453 + /*! \brief The payload is not a valid JSON message */ + JANUS_ERROR_INVALID_JSON = 454 + /*! \brief The object is not a valid JSON object as expected */ + JANUS_ERROR_INVALID_JSON_OBJECT = 455 + /*! \brief A mandatory element is missing in the message */ + JANUS_ERROR_MISSING_MANDATORY_ELEMENT = 456 + /*! \brief The request cannot be handled for this webserver path */ + JANUS_ERROR_INVALID_REQUEST_PATH = 457 + /*! \brief The session the request refers to doesn't exist */ + JANUS_ERROR_SESSION_NOT_FOUND = 458 + /*! \brief The handle the request refers to doesn't exist */ + JANUS_ERROR_HANDLE_NOT_FOUND = 459 + /*! \brief The plugin the request wants to talk to doesn't exist */ + JANUS_ERROR_PLUGIN_NOT_FOUND = 460 + /*! \brief An error occurring when trying to attach to a plugin and create a handle */ + JANUS_ERROR_PLUGIN_ATTACH = 461 + /*! \brief An error occurring when trying to send a message/request to the plugin */ + JANUS_ERROR_PLUGIN_MESSAGE = 462 + /*! \brief An error occurring when trying to detach from a plugin and destroy the related handle */ + JANUS_ERROR_PLUGIN_DETACH = 463 + /*! \brief The gateway doesn't support this SDP type + * \todo The gateway currently only supports OFFER and ANSWER. */ + JANUS_ERROR_JSEP_UNKNOWN_TYPE = 464 + /*! \brief The Session Description provided by the peer is invalid */ + JANUS_ERROR_JSEP_INVALID_SDP = 465 + /*! \brief The stream a trickle candidate for does not exist or is invalid */ + JANUS_ERROR_TRICKE_INVALID_STREAM = 466 + /*! \brief A JSON element is of the wrong type (e.g., an integer instead of a string) */ + JANUS_ERROR_INVALID_ELEMENT_TYPE = 467 + /*! \brief The ID provided to create a new session is already in use */ + JANUS_ERROR_SESSION_CONFLICT = 468 + /*! \brief We got an ANSWER to an OFFER we never made */ + JANUS_ERROR_UNEXPECTED_ANSWER = 469 + /*! \brief The auth token the request refers to doesn't exist */ + JANUS_ERROR_TOKEN_NOT_FOUND = 470 + + // Error codes of videoroom plugin. + JANUS_VIDEOROOM_ERROR_UNKNOWN_ERROR = 499 + JANUS_VIDEOROOM_ERROR_NO_MESSAGE = 421 + JANUS_VIDEOROOM_ERROR_INVALID_JSON = 422 + JANUS_VIDEOROOM_ERROR_INVALID_REQUEST = 423 + JANUS_VIDEOROOM_ERROR_JOIN_FIRST = 424 + JANUS_VIDEOROOM_ERROR_ALREADY_JOINED = 425 + JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM = 426 + JANUS_VIDEOROOM_ERROR_ROOM_EXISTS = 427 + JANUS_VIDEOROOM_ERROR_NO_SUCH_FEED = 428 + JANUS_VIDEOROOM_ERROR_MISSING_ELEMENT = 429 + JANUS_VIDEOROOM_ERROR_INVALID_ELEMENT = 430 + JANUS_VIDEOROOM_ERROR_INVALID_SDP_TYPE = 431 + JANUS_VIDEOROOM_ERROR_PUBLISHERS_FULL = 432 + JANUS_VIDEOROOM_ERROR_UNAUTHORIZED = 433 + JANUS_VIDEOROOM_ERROR_ALREADY_PUBLISHED = 434 + JANUS_VIDEOROOM_ERROR_NOT_PUBLISHED = 435 + JANUS_VIDEOROOM_ERROR_ID_EXISTS = 436 + JANUS_VIDEOROOM_ERROR_INVALID_SDP = 437 +) + +var ( + janusDialer = websocket.Dialer{ + Subprotocols: []string{"janus-protocol"}, + Proxy: http.ProxyFromEnvironment, + } +) + +var msgtypes = map[string]func() interface{}{ + "error": func() interface{} { return &janus.ErrorMsg{} }, + "success": func() interface{} { return &janus.SuccessMsg{} }, + "detached": func() interface{} { return &janus.DetachedMsg{} }, + "server_info": func() interface{} { return &InfoMsg{} }, + "ack": func() interface{} { return &janus.AckMsg{} }, + "event": func() interface{} { return &janus.EventMsg{} }, + "webrtcup": func() interface{} { return &janus.WebRTCUpMsg{} }, + "media": func() interface{} { return &janus.MediaMsg{} }, + "hangup": func() interface{} { return &janus.HangupMsg{} }, + "slowlink": func() interface{} { return &janus.SlowLinkMsg{} }, + "timeout": func() interface{} { return &janus.TimeoutMsg{} }, + "trickle": func() interface{} { return &TrickleMsg{} }, +} + +type InfoMsg struct { + Name string + Version int + VersionString string `json:"version_string"` + Author string + DataChannels bool `json:"data_channels"` + IPv6 bool `json:"ipv6"` + LocalIP string `json:"local-ip"` + ICE_TCP bool `json:"ice-tcp"` + FullTrickle bool `json:"full-trickle"` + Transports map[string]janus.PluginInfo + Plugins map[string]janus.PluginInfo +} + +type TrickleMsg struct { + Session uint64 `json:"session_id"` + Handle uint64 `json:"sender"` + Candidate struct { + SdpMid string `json:"sdpMid"` + SdpMLineIndex int `json:"sdpMLineIndex"` + Candidate string `json:"candidate"` + + Completed bool `json:"completed,omitempty"` + } `json:"candidate"` +} + +func unexpected(request string) error { + return fmt.Errorf("Unexpected response received to '%s' request", request) +} + +type transaction struct { + ch chan interface{} + incoming chan interface{} + quitChan chan bool +} + +func (t *transaction) run() { + for { + select { + case msg := <-t.incoming: + t.ch <- msg + case <-t.quitChan: + return + } + } +} + +func (t *transaction) add(msg interface{}) { + t.incoming <- msg +} + +func (t *transaction) quit() { + select { + case t.quitChan <- true: + default: + // Already scheduled to quit. + } +} + +func newTransaction() *transaction { + t := &transaction{ + ch: make(chan interface{}, 1), + incoming: make(chan interface{}, 8), + quitChan: make(chan bool, 1), + } + return t +} + +func newRequest(method string) (map[string]interface{}, *transaction) { + req := make(map[string]interface{}, 8) + req["janus"] = method + return req, newTransaction() +} + +type GatewayListener interface { + ConnectionInterrupted() +} + +type dummyGatewayListener struct { +} + +func (l *dummyGatewayListener) ConnectionInterrupted() { +} + +// Gateway represents a connection to an instance of the Janus Gateway. +type JanusGateway struct { + nextTransaction uint64 + + listener GatewayListener + + // Sessions is a map of the currently active sessions to the gateway. + Sessions map[uint64]*JanusSession + + // Access to the Sessions map should be synchronized with the Gateway.Lock() + // and Gateway.Unlock() methods provided by the embeded sync.Mutex. + sync.Mutex + + conn *websocket.Conn + transactions map[uint64]*transaction + + closeChan chan bool + + writeMu sync.Mutex +} + +// Connect creates a new Gateway instance, connected to the Janus Gateway. +// path should be a filesystem path to the Unix Socket that the Unix transport +// is bound to. +// On success, a new Gateway object will be returned and error will be nil. +// func Connect(path string, netType string) (*JanusGateway, error) { +// conn, err := net.Dial(netType, path) +// if err != nil { +// return nil, err +// } + +// gateway := new(Gateway) +// //gateway.conn = conn +// gateway.transactions = make(map[uint64]chan interface{}) +// gateway.Sessions = make(map[uint64]*JanusSession) + +// go gateway.recv() +// return gateway, nil +// } + +func NewJanusGateway(wsURL string, listener GatewayListener) (*JanusGateway, error) { + conn, _, err := janusDialer.Dial(wsURL, nil) + if err != nil { + return nil, err + } + + gateway := new(JanusGateway) + gateway.conn = conn + gateway.transactions = make(map[uint64]*transaction) + gateway.Sessions = make(map[uint64]*JanusSession) + gateway.closeChan = make(chan bool) + if listener == nil { + listener = new(dummyGatewayListener) + } + gateway.listener = listener + + go gateway.ping() + go gateway.recv() + return gateway, nil +} + +// Close closes the underlying connection to the Gateway. +func (gateway *JanusGateway) Close() error { + gateway.closeChan <- true + gateway.writeMu.Lock() + if gateway.conn == nil { + gateway.writeMu.Unlock() + return nil + } + + err := gateway.conn.Close() + gateway.conn = nil + gateway.writeMu.Unlock() + gateway.cancelTransactions() + return err +} + +func (gateway *JanusGateway) cancelTransactions() { + msg := &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: 500, + Reason: "cancelled", + }, + } + gateway.Lock() + for _, t := range gateway.transactions { + go func(t *transaction) { + t.add(msg) + t.quit() + }(t) + } + gateway.transactions = make(map[uint64]*transaction) + gateway.Unlock() +} + +func (gateway *JanusGateway) removeTransaction(id uint64) { + gateway.Lock() + t, found := gateway.transactions[id] + if found { + delete(gateway.transactions, id) + } + gateway.Unlock() + if t != nil { + t.quit() + } +} + +func (gateway *JanusGateway) send(msg map[string]interface{}, t *transaction) (uint64, error) { + id := atomic.AddUint64(&gateway.nextTransaction, 1) + msg["transaction"] = strconv.FormatUint(id, 10) + data, err := json.Marshal(msg) + if err != nil { + return 0, err + } + + go t.run() + gateway.Lock() + gateway.transactions[id] = t + gateway.Unlock() + + gateway.writeMu.Lock() + if gateway.conn == nil { + gateway.writeMu.Unlock() + gateway.removeTransaction(id) + return 0, fmt.Errorf("not connected") + } + + err = gateway.conn.WriteMessage(websocket.TextMessage, data) + gateway.writeMu.Unlock() + if err != nil { + gateway.removeTransaction(id) + return 0, err + } + return id, nil +} + +func passMsg(ch chan interface{}, msg interface{}) { + ch <- msg +} + +func (gateway *JanusGateway) ping() { + ticker := time.NewTicker(time.Second * 30) + defer ticker.Stop() + +loop: + for { + select { + case <-ticker.C: + gateway.writeMu.Lock() + if gateway.conn == nil { + gateway.writeMu.Unlock() + continue + } + + err := gateway.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(20*time.Second)) + gateway.writeMu.Unlock() + if err != nil { + log.Println("Error sending ping to MCU:", err) + } + case <-gateway.closeChan: + break loop + } + } +} + +func (gateway *JanusGateway) recv() { + var decodeBuffer bytes.Buffer + for { + // Read message from Gateway + + // Decode to Msg struct + var base janus.BaseMsg + + gateway.writeMu.Lock() + conn := gateway.conn + gateway.writeMu.Unlock() + if conn == nil { + return + } + + _, reader, err := conn.NextReader() + if err != nil { + log.Printf("conn.NextReader: %s", err) + gateway.writeMu.Lock() + gateway.conn = nil + gateway.writeMu.Unlock() + gateway.cancelTransactions() + go gateway.listener.ConnectionInterrupted() + return + } + + decodeBuffer.Reset() + if _, err := decodeBuffer.ReadFrom(reader); err != nil { + log.Printf("decodeBuffer.ReadFrom: %s", err) + gateway.writeMu.Lock() + gateway.conn = nil + gateway.writeMu.Unlock() + gateway.cancelTransactions() + go gateway.listener.ConnectionInterrupted() + break + } + + data := bytes.NewReader(decodeBuffer.Bytes()) + decoder := json.NewDecoder(data) + decoder.UseNumber() + if err := decoder.Decode(&base); err != nil { + log.Printf("json.Unmarshal of %s: %s", string(decodeBuffer.Bytes()), err) + continue + } + + typeFunc, ok := msgtypes[base.Type] + if !ok { + log.Printf("Unknown message type received: %s", string(decodeBuffer.Bytes())) + continue + } + + msg := typeFunc() + data = bytes.NewReader(decodeBuffer.Bytes()) + decoder = json.NewDecoder(data) + decoder.UseNumber() + if err := decoder.Decode(&msg); err != nil { + log.Printf("json.Unmarshal of %s: %s", string(decodeBuffer.Bytes()), err) + continue // Decode error + } + + // Pass message on from here + if base.Id == "" { + // Is this a Handle event? + if base.Handle == 0 { + // Nope. No idea what's going on... + // Error() + } else { + // Lookup Session + gateway.Lock() + session := gateway.Sessions[base.Session] + gateway.Unlock() + if session == nil { + log.Printf("Unable to deliver message %s. Session %d gone?", string(decodeBuffer.Bytes()), base.Session) + continue + } + + // Lookup Handle + session.Lock() + handle := session.Handles[base.Handle] + session.Unlock() + if handle == nil { + log.Printf("Unable to deliver message %s. Handle %d gone?", string(decodeBuffer.Bytes()), base.Handle) + continue + } + + // Pass msg + go passMsg(handle.Events, msg) + } + } else { + id, err := strconv.ParseUint(base.Id, 10, 64) + if err != nil { + log.Printf("Could not decode transaction id %s: %s", base.Id, err) + continue + } + + // Lookup Transaction + gateway.Lock() + transaction := gateway.transactions[id] + gateway.Unlock() + if transaction == nil { + // Error() + continue + } + + // Pass msg + transaction.add(msg) + } + } +} + +func waitForMessage(ctx context.Context, t *transaction) (interface{}, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case msg := <-t.ch: + return msg, nil + } +} + +// Info sends an info request to the Gateway. +// On success, an InfoMsg will be returned and error will be nil. +func (gateway *JanusGateway) Info(ctx context.Context) (*InfoMsg, error) { + req, ch := newRequest("info") + id, err := gateway.send(req, ch) + if err != nil { + return nil, err + } + defer gateway.removeTransaction(id) + + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + + switch msg := msg.(type) { + case *InfoMsg: + return msg, nil + case *janus.ErrorMsg: + return nil, msg + } + + return nil, unexpected("info") +} + +// Create sends a create request to the Gateway. +// On success, a new Session will be returned and error will be nil. +func (gateway *JanusGateway) Create(ctx context.Context) (*JanusSession, error) { + req, ch := newRequest("create") + id, err := gateway.send(req, ch) + if err != nil { + return nil, err + } + defer gateway.removeTransaction(id) + + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + var success *janus.SuccessMsg + switch msg := msg.(type) { + case *janus.SuccessMsg: + success = msg + case *janus.ErrorMsg: + return nil, msg + } + + // Create new session + session := new(JanusSession) + session.gateway = gateway + session.Id = success.Data.Id + session.Handles = make(map[uint64]*JanusHandle) + + // Store this session + gateway.Lock() + gateway.Sessions[session.Id] = session + gateway.Unlock() + + return session, nil +} + +// Session represents a session instance on the Janus Gateway. +type JanusSession struct { + // Id is the session_id of this session + Id uint64 + + // Handles is a map of plugin handles within this session + Handles map[uint64]*JanusHandle + + // Access to the Handles map should be synchronized with the Session.Lock() + // and Session.Unlock() methods provided by the embeded sync.Mutex. + sync.Mutex + + gateway *JanusGateway +} + +func (session *JanusSession) send(msg map[string]interface{}, t *transaction) (uint64, error) { + msg["session_id"] = session.Id + return session.gateway.send(msg, t) +} + +// Attach sends an attach request to the Gateway within this session. +// plugin should be the unique string of the plugin to attach to. +// On success, a new Handle will be returned and error will be nil. +func (session *JanusSession) Attach(ctx context.Context, plugin string) (*JanusHandle, error) { + req, ch := newRequest("attach") + req["plugin"] = plugin + id, err := session.send(req, ch) + if err != nil { + return nil, err + } + defer session.gateway.removeTransaction(id) + + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + var success *janus.SuccessMsg + switch msg := msg.(type) { + case *janus.SuccessMsg: + success = msg + case *janus.ErrorMsg: + return nil, msg + } + + handle := new(JanusHandle) + handle.session = session + handle.Id = success.Data.Id + handle.Events = make(chan interface{}, 8) + + session.Lock() + session.Handles[handle.Id] = handle + session.Unlock() + + return handle, nil +} + +// KeepAlive sends a keep-alive request to the Gateway. +// On success, an AckMsg will be returned and error will be nil. +func (session *JanusSession) KeepAlive(ctx context.Context) (*janus.AckMsg, error) { + req, ch := newRequest("keepalive") + id, err := session.send(req, ch) + if err != nil { + return nil, err + } + defer session.gateway.removeTransaction(id) + + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + switch msg := msg.(type) { + case *janus.AckMsg: + return msg, nil + case *janus.ErrorMsg: + return nil, msg + } + + return nil, unexpected("keepalive") +} + +// Destroy sends a destroy request to the Gateway to tear down this session. +// On success, the Session will be removed from the Gateway.Sessions map, an +// AckMsg will be returned and error will be nil. +func (session *JanusSession) Destroy(ctx context.Context) (*janus.AckMsg, error) { + req, ch := newRequest("destroy") + id, err := session.send(req, ch) + if err != nil { + return nil, err + } + defer session.gateway.removeTransaction(id) + + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + var ack *janus.AckMsg + switch msg := msg.(type) { + case *janus.AckMsg: + ack = msg + case *janus.ErrorMsg: + return nil, msg + } + + // Remove this session from the gateway + session.gateway.Lock() + delete(session.gateway.Sessions, session.Id) + session.gateway.Unlock() + + return ack, nil +} + +// Handle represents a handle to a plugin instance on the Gateway. +type JanusHandle struct { + // Id is the handle_id of this plugin handle + Id uint64 + + // Type // pub or sub + Type string + + //User // Userid + User string + + // Events is a receive only channel that can be used to receive events + // related to this handle from the gateway. + Events chan interface{} + + session *JanusSession +} + +func (handle *JanusHandle) send(msg map[string]interface{}, t *transaction) (uint64, error) { + msg["handle_id"] = handle.Id + return handle.session.send(msg, t) +} + +// send sync request +func (handle *JanusHandle) Request(ctx context.Context, body interface{}) (*janus.SuccessMsg, error) { + req, ch := newRequest("message") + if body != nil { + req["body"] = body + } + id, err := handle.send(req, ch) + if err != nil { + return nil, err + } + defer handle.session.gateway.removeTransaction(id) + + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + switch msg := msg.(type) { + case *janus.SuccessMsg: + return msg, nil + case *janus.ErrorMsg: + return nil, msg + } + + return nil, unexpected("message") +} + +// Message sends a message request to a plugin handle on the Gateway. +// body should be the plugin data to be passed to the plugin, and jsep should +// contain an optional SDP offer/answer to establish a WebRTC PeerConnection. +// On success, an EventMsg will be returned and error will be nil. +func (handle *JanusHandle) Message(ctx context.Context, body, jsep interface{}) (*janus.EventMsg, error) { + req, ch := newRequest("message") + if body != nil { + req["body"] = body + } + if jsep != nil { + req["jsep"] = jsep + } + id, err := handle.send(req, ch) + if err != nil { + return nil, err + } + defer handle.session.gateway.removeTransaction(id) + +GetMessage: // No tears.. + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + switch msg := msg.(type) { + case *janus.AckMsg: + goto GetMessage // ..only dreams. + case *janus.EventMsg: + return msg, nil + case *janus.ErrorMsg: + return nil, msg + } + + return nil, unexpected("message") +} + +// Trickle sends a trickle request to the Gateway as part of establishing +// a new PeerConnection with a plugin. +// candidate should be a single ICE candidate, or a completed object to +// signify that all candidates have been sent: +// { +// "completed": true +// } +// On success, an AckMsg will be returned and error will be nil. +func (handle *JanusHandle) Trickle(ctx context.Context, candidate interface{}) (*janus.AckMsg, error) { + req, ch := newRequest("trickle") + req["candidate"] = candidate + id, err := handle.send(req, ch) + if err != nil { + return nil, err + } + defer handle.session.gateway.removeTransaction(id) + + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + switch msg := msg.(type) { + case *janus.AckMsg: + return msg, nil + case *janus.ErrorMsg: + return nil, msg + } + + return nil, unexpected("trickle") +} + +// TrickleMany sends a trickle request to the Gateway as part of establishing +// a new PeerConnection with a plugin. +// candidates should be an array of ICE candidates. +// On success, an AckMsg will be returned and error will be nil. +func (handle *JanusHandle) TrickleMany(ctx context.Context, candidates interface{}) (*janus.AckMsg, error) { + req, ch := newRequest("trickle") + req["candidates"] = candidates + id, err := handle.send(req, ch) + if err != nil { + return nil, err + } + handle.session.gateway.removeTransaction(id) + + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + switch msg := msg.(type) { + case *janus.AckMsg: + return msg, nil + case *janus.ErrorMsg: + return nil, msg + } + + return nil, unexpected("trickle") +} + +// Detach sends a detach request to the Gateway to remove this handle. +// On success, an AckMsg will be returned and error will be nil. +func (handle *JanusHandle) Detach(ctx context.Context) (*janus.AckMsg, error) { + req, ch := newRequest("detach") + id, err := handle.send(req, ch) + if err != nil { + return nil, err + } + defer handle.session.gateway.removeTransaction(id) + + msg, err := waitForMessage(ctx, ch) + if err != nil { + return nil, err + } + var ack *janus.AckMsg + switch msg := msg.(type) { + case *janus.AckMsg: + ack = msg + case *janus.ErrorMsg: + return nil, msg + } + + // Remove this handle from the session + handle.session.Lock() + delete(handle.session.Handles, handle.Id) + handle.session.Unlock() + + return ack, nil +} diff --git a/src/signaling/lru.go b/src/signaling/lru.go new file mode 100644 index 0000000..1563d01 --- /dev/null +++ b/src/signaling/lru.go @@ -0,0 +1,113 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "container/list" + "sync" +) + +type cacheEntry struct { + key string + value interface{} +} + +type LruCache struct { + size int + mu sync.Mutex + entries *list.List + data map[string]*list.Element +} + +func NewLruCache(size int) *LruCache { + return &LruCache{ + size: size, + entries: list.New(), + data: make(map[string]*list.Element), + } +} + +func (c *LruCache) Set(key string, value interface{}) { + c.mu.Lock() + if v, found := c.data[key]; found { + c.entries.MoveToFront(v) + v.Value.(*cacheEntry).value = value + c.mu.Unlock() + return + } + + v := c.entries.PushFront(&cacheEntry{ + key: key, + value: value, + }) + c.data[key] = v + if c.size > 0 && c.entries.Len() > c.size { + c.removeOldestLocked() + } + c.mu.Unlock() +} + +func (c *LruCache) Get(key string) interface{} { + c.mu.Lock() + if v, found := c.data[key]; found { + c.entries.MoveToFront(v) + value := v.Value.(*cacheEntry).value + c.mu.Unlock() + return value + } + + c.mu.Unlock() + return nil +} + +func (c *LruCache) Remove(key string) { + c.mu.Lock() + if v, found := c.data[key]; found { + c.removeElement(v) + } + c.mu.Unlock() +} + +func (c *LruCache) removeOldestLocked() { + v := c.entries.Back() + if v != nil { + c.removeElement(v) + } +} + +func (c *LruCache) RemoveOldest() { + c.mu.Lock() + c.removeOldestLocked() + c.mu.Unlock() +} + +func (c *LruCache) removeElement(e *list.Element) { + c.entries.Remove(e) + entry := e.Value.(*cacheEntry) + delete(c.data, entry.key) +} + +func (c *LruCache) Len() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.entries.Len() +} diff --git a/src/signaling/lru_test.go b/src/signaling/lru_test.go new file mode 100644 index 0000000..77db25e --- /dev/null +++ b/src/signaling/lru_test.go @@ -0,0 +1,163 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "fmt" + "testing" +) + +func TestLruUnbound(t *testing.T) { + lru := NewLruCache(0) + count := 10 + for i := 0; i < count; i++ { + key := fmt.Sprintf("%d", i) + lru.Set(key, i) + } + if lru.Len() != count { + t.Errorf("Expected %d entries, got %d", count, lru.Len()) + } + for i := 0; i < count; i++ { + key := fmt.Sprintf("%d", i) + value := lru.Get(key) + if value == nil { + t.Errorf("No value found for %s", key) + continue + } else if value.(int) != i { + t.Errorf("Expected value to be %d, got %d", value.(int), i) + } + } + // The first key ("0") is now the oldest. + lru.RemoveOldest() + if lru.Len() != count-1 { + t.Errorf("Expected %d entries after RemoveOldest, got %d", count-1, lru.Len()) + } + for i := 0; i < count; i++ { + key := fmt.Sprintf("%d", i) + value := lru.Get(key) + if i == 0 { + if value != nil { + t.Errorf("The value for key %s should have been removed", key) + } + continue + } else if value == nil { + t.Errorf("No value found for %s", key) + continue + } else if value.(int) != i { + t.Errorf("Expected value to be %d, got %d", value.(int), i) + } + } + + // NOTE: Key "0" no longer exists below, so make sure to not set it again. + + // Using the same keys will update the ordering. + for i := count - 1; i >= 1; i-- { + key := fmt.Sprintf("%d", i) + lru.Set(key, i) + } + if lru.Len() != count-1 { + t.Errorf("Expected %d entries, got %d", count-1, lru.Len()) + } + // NOTE: The same ordering as the Set calls above. + for i := count - 1; i >= 1; i-- { + key := fmt.Sprintf("%d", i) + value := lru.Get(key) + if value == nil { + t.Errorf("No value found for %s", key) + continue + } else if value.(int) != i { + t.Errorf("Expected value to be %d, got %d", value.(int), i) + } + } + + // The last key ("9") is now the oldest. + lru.RemoveOldest() + if lru.Len() != count-2 { + t.Errorf("Expected %d entries after RemoveOldest, got %d", count-2, lru.Len()) + } + for i := 0; i < count; i++ { + key := fmt.Sprintf("%d", i) + value := lru.Get(key) + if i == 0 || i == count-1 { + if value != nil { + t.Errorf("The value for key %s should have been removed", key) + } + continue + } else if value == nil { + t.Errorf("No value found for %s", key) + continue + } else if value.(int) != i { + t.Errorf("Expected value to be %d, got %d", value.(int), i) + } + } + + // Remove an arbitrary key from the cache + key := fmt.Sprintf("%d", count/2) + lru.Remove(key) + if lru.Len() != count-3 { + t.Errorf("Expected %d entries after RemoveOldest, got %d", count-3, lru.Len()) + } + for i := 0; i < count; i++ { + key := fmt.Sprintf("%d", i) + value := lru.Get(key) + if i == 0 || i == count-1 || i == count/2 { + if value != nil { + t.Errorf("The value for key %s should have been removed", key) + } + continue + } else if value == nil { + t.Errorf("No value found for %s", key) + continue + } else if value.(int) != i { + t.Errorf("Expected value to be %d, got %d", value.(int), i) + } + } +} + +func TestLruBound(t *testing.T) { + size := 2 + lru := NewLruCache(size) + count := 10 + for i := 0; i < count; i++ { + key := fmt.Sprintf("%d", i) + lru.Set(key, i) + } + if lru.Len() != size { + t.Errorf("Expected %d entries, got %d", size, lru.Len()) + } + // Only the last "size" entries have been stored. + for i := 0; i < count; i++ { + key := fmt.Sprintf("%d", i) + value := lru.Get(key) + if i < count-size { + if value != nil { + t.Errorf("The value for key %s should have been removed", key) + } + continue + } else if value == nil { + t.Errorf("No value found for %s", key) + continue + } else if value.(int) != i { + t.Errorf("Expected value to be %d, got %d", value.(int), i) + } + } +} diff --git a/src/signaling/mcu_common.go b/src/signaling/mcu_common.go new file mode 100644 index 0000000..4aefa3e --- /dev/null +++ b/src/signaling/mcu_common.go @@ -0,0 +1,69 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "golang.org/x/net/context" +) + +const ( + McuTypeJanus = "janus" + + McuTypeDefault = McuTypeJanus +) + +type McuListener interface { + Session + + OnIceCandidate(client McuClient, candidate interface{}) + OnIceCompleted(client McuClient) + + PublisherClosed(publisher McuPublisher) + SubscriberClosed(subscriber McuSubscriber) +} + +type Mcu interface { + Start() error + Stop() + + NewPublisher(ctx context.Context, listener McuListener, id string, streamType string) (McuPublisher, error) + NewSubscriber(ctx context.Context, listener McuListener, publisher string, streamType string) (McuSubscriber, error) +} + +type McuClient interface { + Id() string + StreamType() string + + Close(ctx context.Context) + + SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) +} + +type McuPublisher interface { + McuClient +} + +type McuSubscriber interface { + McuClient + + Publisher() string +} diff --git a/src/signaling/mcu_janus.go b/src/signaling/mcu_janus.go new file mode 100644 index 0000000..a709b34 --- /dev/null +++ b/src/signaling/mcu_janus.go @@ -0,0 +1,1098 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "encoding/json" + "fmt" + "log" + "reflect" + "strconv" + "sync" + "time" + + "github.com/dlintw/goconf" + "github.com/nats-io/go-nats" + "github.com/notedit/janus-go" + + "golang.org/x/net/context" +) + +const ( + pluginVideoRoom = "janus.plugin.videoroom" + + keepaliveInterval = 30 * time.Second + + videoPublisherUserId = 1 + screenPublisherUserId = 2 + + initialReconnectInterval = 1 * time.Second + maxReconnectInterval = 32 * time.Second + + defaultMaxStreamBitrate = 1024 * 1024 + defaultMaxScreenBitrate = 2048 * 1024 + + streamTypeVideo = "video" + streamTypeScreen = "screen" +) + +var ( + streamTypeUserIds = map[string]uint64{ + streamTypeVideo: videoPublisherUserId, + streamTypeScreen: screenPublisherUserId, + } + userIdToStreamType = map[uint64]string{ + videoPublisherUserId: streamTypeVideo, + screenPublisherUserId: streamTypeScreen, + } + + ErrNotConnected = fmt.Errorf("Not connected") +) + +func getPluginValue(data janus.PluginData, pluginName string, key string) interface{} { + if data.Plugin != pluginName { + return nil + } + + return data.Data[key] +} + +func convertIntValue(value interface{}) (uint64, error) { + switch t := value.(type) { + case float64: + if t < 0 { + return 0, fmt.Errorf("Unsupported float64 number: %+v\n", t) + } + return uint64(t), nil + case uint64: + return t, nil + case int64: + if t < 0 { + return 0, fmt.Errorf("Unsupported int64 number: %+v\n", t) + } + return uint64(t), nil + case json.Number: + r, err := t.Int64() + if err != nil { + return 0, err + } else if r < 0 { + return 0, fmt.Errorf("Unsupported JSON number: %+v\n", t) + } + return uint64(r), nil + default: + return 0, fmt.Errorf("Unknown number type: %+v\n", t) + } +} + +func getPluginIntValue(data janus.PluginData, pluginName string, key string) uint64 { + val := getPluginValue(data, pluginName, key) + if val == nil { + return 0 + } + + result, err := convertIntValue(val) + if err != nil { + log.Printf("Invalid value %+v for %s: %s\n", val, key, err) + result = 0 + } + return result +} + +func getPluginStringValue(data janus.PluginData, pluginName string, key string) string { + val := getPluginValue(data, pluginName, key) + if val == nil { + return "" + } + + strVal, ok := val.(string) + if !ok { + return "" + } + + return strVal +} + +// TODO(jojo): Lots of error handling still missing. + +type clientInterface interface { + NotifyReconnected() +} + +type mcuJanus struct { + url string + mu sync.Mutex + nats NatsClient + + maxStreamBitrate int + maxScreenBitrate int + mcuTimeout time.Duration + + gw *JanusGateway + session *JanusSession + handle *JanusHandle + + closeChan chan bool + + muClients sync.Mutex + clients map[clientInterface]bool + + publisherRoomIds map[string]uint64 + + reconnectTimer *time.Timer + reconnectInterval time.Duration +} + +func NewMcuJanus(url string, config *goconf.ConfigFile, nats NatsClient) (Mcu, error) { + maxStreamBitrate, _ := config.GetInt("mcu", "maxstreambitrate") + if maxStreamBitrate <= 0 { + maxStreamBitrate = defaultMaxStreamBitrate + } + maxScreenBitrate, _ := config.GetInt("mcu", "maxscreenbitrate") + if maxScreenBitrate <= 0 { + maxScreenBitrate = defaultMaxScreenBitrate + } + mcuTimeoutSeconds, _ := config.GetInt("mcu", "timeout") + if mcuTimeoutSeconds <= 0 { + mcuTimeoutSeconds = defaultMcuTimeoutSeconds + } + mcuTimeout := time.Duration(mcuTimeoutSeconds) * time.Second + + mcu := &mcuJanus{ + url: url, + nats: nats, + maxStreamBitrate: maxStreamBitrate, + maxScreenBitrate: maxScreenBitrate, + mcuTimeout: mcuTimeout, + closeChan: make(chan bool, 1), + clients: make(map[clientInterface]bool), + publisherRoomIds: make(map[string]uint64), + + reconnectInterval: initialReconnectInterval, + } + mcu.reconnectTimer = time.AfterFunc(mcu.reconnectInterval, mcu.doReconnect) + mcu.reconnectTimer.Stop() + if err := mcu.reconnect(); err != nil { + return nil, err + } + return mcu, nil +} + +func (m *mcuJanus) disconnect() { + if m.handle != nil { + m.handle.Detach(context.TODO()) + m.handle = nil + } + if m.session != nil { + m.closeChan <- true + m.session.Destroy(context.TODO()) + m.session = nil + } + if m.gw != nil { + if err := m.gw.Close(); err != nil { + log.Println("Error while closing connection to MCU", err) + } + m.gw = nil + } +} + +func (m *mcuJanus) reconnect() error { + m.disconnect() + gw, err := NewJanusGateway(m.url, m) + if err != nil { + return err + } + + m.gw = gw + m.reconnectTimer.Stop() + return nil +} + +func (m *mcuJanus) doReconnect() { + if err := m.reconnect(); err != nil { + m.scheduleReconnect(err) + return + } + if err := m.Start(); err != nil { + m.scheduleReconnect(err) + return + } + + log.Println("Reconnection to Janus gateway successful") + m.mu.Lock() + m.publisherRoomIds = make(map[string]uint64) + m.reconnectInterval = initialReconnectInterval + m.mu.Unlock() + + m.muClients.Lock() + for client, _ := range m.clients { + go client.NotifyReconnected() + } + m.muClients.Unlock() +} + +func (m *mcuJanus) scheduleReconnect(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.reconnectTimer.Reset(m.reconnectInterval) + if err == nil { + log.Printf("Connection to Janus gateway was interrupted, reconnecting in %s\n", m.reconnectInterval) + } else { + log.Printf("Reconnect to Janus gateway failed (%s), reconnecting in %s\n", err, m.reconnectInterval) + } + + m.reconnectInterval = m.reconnectInterval * 2 + if m.reconnectInterval > maxReconnectInterval { + m.reconnectInterval = maxReconnectInterval + } +} + +func (m *mcuJanus) ConnectionInterrupted() { + m.scheduleReconnect(nil) +} + +func (m *mcuJanus) Start() error { + ctx := context.TODO() + info, err := m.gw.Info(ctx) + if err != nil { + return err + } + + log.Printf("Connected to %s %s by %s\n", info.Name, info.VersionString, info.Author) + if plugin, found := info.Plugins[pluginVideoRoom]; !found { + return fmt.Errorf("Plugin %s is not supported", pluginVideoRoom) + } else { + log.Printf("Found %s %s by %s\n", plugin.Name, plugin.VersionString, plugin.Author) + } + + if !info.DataChannels { + return fmt.Errorf("Data channels are not supported") + } else { + log.Println("Data channels are supported") + } + + if !info.FullTrickle { + log.Println("WARNING: Full-Trickle is NOT enabled in Janus!") + } else { + log.Println("Full-Trickle is enabled") + } + + log.Printf("Maximum bandwidth %d bits/sec per publishing stream", m.maxStreamBitrate) + log.Printf("Maximum bandwidth %d bits/sec per screensharing stream", m.maxScreenBitrate) + + if m.session, err = m.gw.Create(ctx); err != nil { + m.disconnect() + return err + } + log.Println("Created Janus session", m.session.Id) + + if m.handle, err = m.session.Attach(ctx, pluginVideoRoom); err != nil { + m.disconnect() + return err + } + log.Println("Created Janus handle", m.handle.Id) + + go m.run() + return nil +} + +func (m *mcuJanus) registerClient(client clientInterface) { + m.muClients.Lock() + m.clients[client] = true + m.muClients.Unlock() +} + +func (m *mcuJanus) unregisterClient(client clientInterface) { + m.muClients.Lock() + delete(m.clients, client) + m.muClients.Unlock() +} + +func (m *mcuJanus) run() { + ticker := time.NewTicker(keepaliveInterval) + defer ticker.Stop() + +loop: + for { + select { + case <-ticker.C: + m.sendKeepalive() + case <-m.closeChan: + break loop + } + } +} + +func (m *mcuJanus) Stop() { + m.disconnect() + m.reconnectTimer.Stop() +} + +func (m *mcuJanus) sendKeepalive() { + ctx := context.TODO() + if _, err := m.session.KeepAlive(ctx); err != nil { + log.Println("Could not send keepalive request", err) + if e, ok := err.(*janus.ErrorMsg); ok { + switch e.Err.Code { + case JANUS_ERROR_SESSION_NOT_FOUND: + m.scheduleReconnect(err) + } + } + } +} + +type mcuJanusClient struct { + mcu *mcuJanus + listener McuListener + mu sync.Mutex + + session uint64 + roomId uint64 + streamType string + + handle *JanusHandle + handleId uint64 + closeChan chan bool + deferred chan func() + + handleEvent func(event *janus.EventMsg) + handleHangup func(event *janus.HangupMsg) + handleDetached func(event *janus.DetachedMsg) + handleConnected func(event *janus.WebRTCUpMsg) + handleSlowLink func(event *janus.SlowLinkMsg) +} + +func (c *mcuJanusClient) Id() string { + return strconv.FormatUint(c.handleId, 10) +} + +func (c *mcuJanusClient) StreamType() string { + return c.streamType +} + +func (c *mcuJanusClient) Close(ctx context.Context) { +} + +func (c *mcuJanusClient) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { +} + +func (c *mcuJanusClient) closeClient(ctx context.Context) { + if handle := c.handle; handle != nil { + c.handle = nil + c.closeChan <- true + if _, err := handle.Detach(ctx); err != nil { + if e, ok := err.(*janus.ErrorMsg); !ok || e.Err.Code != JANUS_ERROR_HANDLE_NOT_FOUND { + log.Println("Could not detach client", handle.Id, err) + } + } + } +} + +func (c *mcuJanusClient) run(handle *JanusHandle, closeChan chan bool) { +loop: + for { + select { + case msg := <-handle.Events: + switch t := msg.(type) { + case *janus.EventMsg: + c.handleEvent(t) + case *janus.HangupMsg: + c.handleHangup(t) + case *janus.DetachedMsg: + c.handleDetached(t) + case *janus.MediaMsg: + // Ignore + case *janus.WebRTCUpMsg: + c.handleConnected(t) + case *janus.SlowLinkMsg: + c.handleSlowLink(t) + case *TrickleMsg: + c.handleTrickle(t) + default: + log.Println("Received unsupported event type", msg, reflect.TypeOf(msg)) + } + case f := <-c.deferred: + f() + case <-closeChan: + break loop + } + } +} + +func (c *mcuJanusClient) sendOffer(ctx context.Context, offer map[string]interface{}, callback func(error, map[string]interface{})) { + handle := c.handle + if handle == nil { + callback(ErrNotConnected, nil) + return + } + + configure_msg := map[string]interface{}{ + "request": "configure", + "audio": true, + "video": true, + "data": true, + } + answer_msg, err := handle.Message(ctx, configure_msg, offer) + if err != nil { + callback(err, nil) + return + } + + callback(nil, answer_msg.Jsep) +} + +func (c *mcuJanusClient) sendAnswer(ctx context.Context, answer map[string]interface{}, callback func(error, map[string]interface{})) { + handle := c.handle + if handle == nil { + callback(ErrNotConnected, nil) + return + } + + start_msg := map[string]interface{}{ + "request": "start", + "room": c.roomId, + } + start_response, err := handle.Message(ctx, start_msg, answer) + if err != nil { + callback(err, nil) + return + } + log.Println("Started listener", start_response) + callback(nil, nil) +} + +func (c *mcuJanusClient) sendCandidate(ctx context.Context, candidate interface{}, callback func(error, map[string]interface{})) { + handle := c.handle + if handle == nil { + callback(ErrNotConnected, nil) + return + } + + if _, err := handle.Trickle(ctx, candidate); err != nil { + callback(err, nil) + return + } + callback(nil, nil) +} + +func (c *mcuJanusClient) handleTrickle(event *TrickleMsg) { + if event.Candidate.Completed { + c.listener.OnIceCompleted(c) + } else { + c.listener.OnIceCandidate(c, event.Candidate) + } +} + +type mcuJanusPublisher struct { + mcuJanusClient + + id string +} + +func (m *mcuJanus) getOrCreatePublisherHandle(ctx context.Context, id string, streamType string) (*JanusHandle, uint64, uint64, error) { + session := m.session + if session == nil { + return nil, 0, 0, ErrNotConnected + } + handle, err := session.Attach(ctx, pluginVideoRoom) + if err != nil { + return nil, 0, 0, err + } + + log.Printf("Attached %s as publisher %d to plugin %s in session %d", streamType, handle.Id, pluginVideoRoom, session.Id) + roomId, err := m.searchPublisherRoom(ctx, id, streamType) + if err != nil { + log.Printf("Could not search for room of publisher %s: %s", id, err) + } + + if roomId == 0 { + create_msg := map[string]interface{}{ + "request": "create", + "description": id + "|" + streamType, + // We publish every stream in its own Janus room. + "publishers": 1, + // Do not use the video-orientation RTP extension as it breaks video + // orientation changes in Firefox. + "videoorient_ext": false, + } + if streamType == streamTypeScreen { + create_msg["bitrate"] = m.maxScreenBitrate + } else { + create_msg["bitrate"] = m.maxStreamBitrate + } + create_response, err := handle.Request(ctx, create_msg) + if err != nil { + handle.Detach(ctx) + return nil, 0, 0, err + } + + roomId = getPluginIntValue(create_response.PluginData, pluginVideoRoom, "room") + if roomId == 0 { + handle.Detach(ctx) + return nil, 0, 0, fmt.Errorf("No room id received: %+v", create_response) + } + + log.Println("Created room", roomId, create_response.PluginData) + } else { + log.Println("Use existing room", roomId) + } + + msg := map[string]interface{}{ + "request": "join", + "ptype": "publisher", + "room": roomId, + "id": streamTypeUserIds[streamType], + } + + response, err := handle.Message(ctx, msg, nil) + if err != nil { + handle.Detach(ctx) + return nil, 0, 0, err + } + + return handle, response.Session, roomId, nil +} + +func (m *mcuJanus) NewPublisher(ctx context.Context, listener McuListener, id string, streamType string) (McuPublisher, error) { + if _, found := streamTypeUserIds[streamType]; !found { + return nil, fmt.Errorf("Unsupported stream type %s", streamType) + } + + handle, session, roomId, err := m.getOrCreatePublisherHandle(ctx, id, streamType) + if err != nil { + return nil, err + } + + client := &mcuJanusPublisher{ + mcuJanusClient: mcuJanusClient{ + mcu: m, + listener: listener, + + session: session, + roomId: roomId, + streamType: streamType, + + handle: handle, + handleId: handle.Id, + closeChan: make(chan bool, 1), + deferred: make(chan func(), 64), + }, + id: id, + } + client.mcuJanusClient.handleEvent = client.handleEvent + client.mcuJanusClient.handleHangup = client.handleHangup + client.mcuJanusClient.handleDetached = client.handleDetached + client.mcuJanusClient.handleConnected = client.handleConnected + client.mcuJanusClient.handleSlowLink = client.handleSlowLink + m.mu.Lock() + m.publisherRoomIds[id+"|"+streamType] = roomId + m.mu.Unlock() + + m.registerClient(client) + if err := client.publishNats("created"); err != nil { + log.Printf("Could not publish \"created\" event for publisher %s: %s\n", id, err) + } + go client.run(handle, client.closeChan) + return client, nil +} + +func (p *mcuJanusPublisher) handleEvent(event *janus.EventMsg) { + if videoroom := getPluginStringValue(event.Plugindata, pluginVideoRoom, "videoroom"); videoroom != "" { + ctx := context.TODO() + switch videoroom { + case "destroyed": + log.Printf("Publisher %d: associated room has been destroyed, closing", p.handleId) + go p.Close(ctx) + case "slow_link": + // Ignore, processed through "handleSlowLink" in the general events. + default: + log.Printf("Unsupported videoroom publisher event in %d: %+v", p.handleId, event) + } + } else { + log.Printf("Unsupported publisher event in %d: %+v", p.handleId, event) + } +} + +func (p *mcuJanusPublisher) handleHangup(event *janus.HangupMsg) { + log.Printf("Publisher %d received hangup (%s), closing", p.handleId, event.Reason) + go p.Close(context.Background()) +} + +func (p *mcuJanusPublisher) handleDetached(event *janus.DetachedMsg) { + log.Printf("Publisher %d received detached, closing", p.handleId) + go p.Close(context.Background()) +} + +func (p *mcuJanusPublisher) handleConnected(event *janus.WebRTCUpMsg) { + log.Printf("Publisher %d received connected", p.handleId) + if err := p.publishNats("connected"); err != nil { + log.Printf("Could not publish \"connected\" event for publisher %s: %s\n", p.id, err) + } +} + +func (p *mcuJanusPublisher) handleSlowLink(event *janus.SlowLinkMsg) { + if event.Uplink { + log.Printf("Publisher %s (%d) is reporting %d NACKs on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId, event.Nacks) + } else { + log.Printf("Publisher %s (%d) is reporting %d NACKs on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId, event.Nacks) + } +} + +func (p *mcuJanusPublisher) publishNats(messageType string) error { + return p.mcu.nats.PublishNats("publisher-"+p.id+"|"+p.streamType, &NatsMessage{Type: messageType}) +} + +func (p *mcuJanusPublisher) NotifyReconnected() { + ctx := context.TODO() + handle, session, roomId, err := p.mcu.getOrCreatePublisherHandle(ctx, p.id, p.streamType) + if err != nil { + log.Printf("Could not reconnect publisher %s: %s\n", p.id, err) + // TODO(jojo): Retry + return + } + + p.handle = handle + p.handleId = handle.Id + p.session = session + p.roomId = roomId + + p.mcu.mu.Lock() + p.mcu.publisherRoomIds[p.id+"|"+p.streamType] = roomId + p.mcu.mu.Unlock() + log.Printf("Publisher %s reconnected\n", p.id) +} + +func (p *mcuJanusPublisher) Close(ctx context.Context) { + notify := false + p.mu.Lock() + if handle := p.handle; handle != nil && p.roomId != 0 { + destroy_msg := map[string]interface{}{ + "request": "destroy", + "room": p.roomId, + } + if _, err := handle.Request(ctx, destroy_msg); err != nil { + log.Printf("Error destroying room %d: %s", p.roomId, err) + } else { + log.Printf("Room %d destroyed", p.roomId) + } + p.mcu.mu.Lock() + delete(p.mcu.publisherRoomIds, p.id+"|"+p.streamType) + p.mcu.mu.Unlock() + p.roomId = 0 + notify = true + } + p.closeClient(ctx) + p.mu.Unlock() + + if notify { + p.mcu.unregisterClient(p) + p.listener.PublisherClosed(p) + } +} + +func (p *mcuJanusPublisher) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { + jsep_msg := data.Payload + switch data.Type { + case "offer": + p.deferred <- func() { + msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.mcuTimeout) + defer cancel() + + p.sendOffer(msgctx, jsep_msg, callback) + } + case "candidate": + p.deferred <- func() { + msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.mcuTimeout) + defer cancel() + + p.sendCandidate(msgctx, jsep_msg["candidate"], callback) + } + case "endOfCandidates": + // Ignore + default: + go callback(fmt.Errorf("Unsupported message type: %s", data.Type), nil) + } +} + +type mcuJanusSubscriber struct { + mcuJanusClient + + publisher string +} + +func (m *mcuJanus) lookupPublisherRoom(ctx context.Context, publisher string, streamType string) (uint64, error) { + handle := m.handle + if handle == nil { + return 0, ErrNotConnected + } + list_msg := map[string]interface{}{ + "request": "list", + } + response_msg, err := handle.Request(ctx, list_msg) + if err != nil { + return 0, err + } + list, found := response_msg.PluginData.Data["list"] + if !found { + return 0, fmt.Errorf("no room list received") + } + + entries, ok := list.([]interface{}) + if !ok { + return 0, fmt.Errorf("Unsupported list received: %+v (%s)", list, reflect.TypeOf(list)) + } + + for _, entry := range entries { + if entry, ok := entry.(map[string]interface{}); ok { + description, found := entry["description"] + if !found { + continue + } + if description, ok := description.(string); ok { + if description != publisher+"|"+streamType { + continue + } + + roomIdInterface, found := entry["room"] + if !found { + continue + } + + roomId, err := convertIntValue(roomIdInterface) + if err != nil { + return 0, fmt.Errorf("Invalid room id received: %+v: %s", entry, err) + } + + return roomId, nil + } + } + } + + return 0, nil +} + +func (m *mcuJanus) searchPublisherRoom(ctx context.Context, publisher string, streamType string) (uint64, error) { + // Check for publishers connected to this signaling server. + m.mu.Lock() + roomId, found := m.publisherRoomIds[publisher+"|"+streamType] + m.mu.Unlock() + if found { + return roomId, nil + } + + // Check for publishers connected to a different signaling server. + roomId, err := m.lookupPublisherRoom(ctx, publisher, streamType) + if err != nil { + return 0, err + } + + return roomId, nil +} + +func (m *mcuJanus) getPublisherRoomId(ctx context.Context, publisher string, streamType string) (uint64, error) { + // Do the direct check immediately as this should be the normal case. + m.mu.Lock() + roomId, found := m.publisherRoomIds[publisher+"|"+streamType] + if found { + m.mu.Unlock() + return roomId, nil + } + + wakeupChan := make(chan *nats.Msg, 1) + sub, err := m.nats.Subscribe("publisher-"+publisher+"|"+streamType, wakeupChan) + m.mu.Unlock() + if err != nil { + return 0, err + } + defer sub.Unsubscribe() + + for roomId == 0 { + var err error + if roomId, err = m.searchPublisherRoom(ctx, publisher, streamType); err != nil { + log.Printf("Could not search for room of publisher %s: %s", publisher, err) + } else if roomId > 0 { + break + } + + select { + case <-wakeupChan: + // We got the wakeup event through NATS, the publisher should be + // ready now. + case <-ctx.Done(): + return 0, ctx.Err() + } + } + return roomId, nil +} + +func (m *mcuJanus) getOrCreateSubscriberHandle(ctx context.Context, publisher string, streamType string) (*JanusHandle, uint64, error) { + var roomId uint64 + var err error + if roomId, err = m.getPublisherRoomId(ctx, publisher, streamType); err != nil { + return nil, 0, err + } + + session := m.session + if session == nil { + return nil, 0, ErrNotConnected + } + + handle, err := session.Attach(ctx, pluginVideoRoom) + if err != nil { + return nil, 0, err + } + + log.Printf("Attached subscriber to room %d of publisher %s in plugin %s in session %d as %d", roomId, publisher, pluginVideoRoom, session.Id, handle.Id) + return handle, roomId, nil +} + +func (m *mcuJanus) NewSubscriber(ctx context.Context, listener McuListener, publisher string, streamType string) (McuSubscriber, error) { + if _, found := streamTypeUserIds[streamType]; !found { + return nil, fmt.Errorf("Unsupported stream type %s", streamType) + } + + handle, roomId, err := m.getOrCreateSubscriberHandle(ctx, publisher, streamType) + if err != nil { + return nil, err + } + + client := &mcuJanusSubscriber{ + mcuJanusClient: mcuJanusClient{ + mcu: m, + listener: listener, + + roomId: roomId, + streamType: streamType, + + handle: handle, + handleId: handle.Id, + closeChan: make(chan bool, 1), + deferred: make(chan func(), 64), + }, + publisher: publisher, + } + client.mcuJanusClient.handleEvent = client.handleEvent + client.mcuJanusClient.handleHangup = client.handleHangup + client.mcuJanusClient.handleDetached = client.handleDetached + client.mcuJanusClient.handleConnected = client.handleConnected + client.mcuJanusClient.handleSlowLink = client.handleSlowLink + m.registerClient(client) + go client.run(handle, client.closeChan) + return client, nil +} + +func (p *mcuJanusSubscriber) Publisher() string { + return p.publisher +} + +func (p *mcuJanusSubscriber) handleEvent(event *janus.EventMsg) { + if videoroom := getPluginStringValue(event.Plugindata, pluginVideoRoom, "videoroom"); videoroom != "" { + ctx := context.TODO() + switch videoroom { + case "destroyed": + log.Printf("Subscriber %d: associated room has been destroyed, closing", p.handleId) + go p.Close(ctx) + case "event": + // Ignore events like selected substream / temporal layer. + case "slow_link": + // Ignore, processed through "handleSlowLink" in the general events. + default: + log.Printf("Unsupported videoroom event %s for subscriber %d: %+v", videoroom, p.handleId, event) + } + } else { + log.Printf("Unsupported event for subscriber %d: %+v", p.handleId, event) + } +} + +func (p *mcuJanusSubscriber) handleHangup(event *janus.HangupMsg) { + log.Printf("Subscriber %d received hangup (%s), closing", p.handleId, event.Reason) + go p.Close(context.Background()) +} + +func (p *mcuJanusSubscriber) handleDetached(event *janus.DetachedMsg) { + log.Printf("Subscriber %d received detached, closing", p.handleId) + go p.Close(context.Background()) +} + +func (p *mcuJanusSubscriber) handleConnected(event *janus.WebRTCUpMsg) { + log.Printf("Subscriber %d received connected", p.handleId) +} + +func (p *mcuJanusSubscriber) handleSlowLink(event *janus.SlowLinkMsg) { + if event.Uplink { + log.Printf("Subscriber %s (%d) is reporting %d NACKs on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId, event.Nacks) + } else { + log.Printf("Subscriber %s (%d) is reporting %d NACKs on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId, event.Nacks) + } +} + +func (p *mcuJanusSubscriber) NotifyReconnected() { + ctx, cancel := context.WithTimeout(context.Background(), p.mcu.mcuTimeout) + defer cancel() + handle, roomId, err := p.mcu.getOrCreateSubscriberHandle(ctx, p.publisher, p.streamType) + if err != nil { + // TODO(jojo): Retry? + log.Printf("Could not reconnect subscriber for publisher %s: %s\n", p.publisher, err) + p.Close(context.Background()) + return + } + + p.handle = handle + p.handleId = handle.Id + p.roomId = roomId + log.Printf("Reconnected subscriber for publisher %s\n", p.publisher) +} + +func (p *mcuJanusSubscriber) Close(ctx context.Context) { + p.mu.Lock() + p.closeClient(ctx) + p.mu.Unlock() + + p.mcu.unregisterClient(p) + p.listener.SubscriberClosed(p) +} + +func (p *mcuJanusSubscriber) joinRoom(ctx context.Context, callback func(error, map[string]interface{})) { + handle := p.handle + if handle == nil { + callback(ErrNotConnected, nil) + return + } + + wakeupChan := make(chan *nats.Msg, 1) + sub, err := p.mcu.nats.Subscribe("publisher-"+p.publisher+"|"+p.streamType, wakeupChan) + if err != nil { + callback(err, nil) + return + } + defer sub.Unsubscribe() + +retry: + join_msg := map[string]interface{}{ + "request": "join", + "ptype": "listener", + "room": p.roomId, + "feed": streamTypeUserIds[p.streamType], + } + join_response, err := handle.Message(ctx, join_msg, nil) + if err != nil { + callback(err, nil) + return + } + + if error_code := getPluginIntValue(join_response.Plugindata, pluginVideoRoom, "error_code"); error_code > 0 { + switch error_code { + case JANUS_VIDEOROOM_ERROR_ALREADY_JOINED: + // The subscriber is already connected to the room. This can happen + // if a client leaves a call but keeps the subscriber objects active. + // On joining the call again, the subscriber tries to join on the + // MCU which will fail because he is still connected. + // To get a new Offer SDP, we have to tear down the session on the + // MCU and join again. + p.mu.Lock() + p.closeClient(ctx) + p.mu.Unlock() + + var roomId uint64 + handle, roomId, err = p.mcu.getOrCreateSubscriberHandle(ctx, p.publisher, p.streamType) + if err != nil { + callback(fmt.Errorf("Already connected as subscriber for %s, error during re-joining: %s", p.streamType, err), nil) + return + } + + p.handle = handle + p.handleId = handle.Id + p.roomId = roomId + p.closeChan = make(chan bool, 1) + go p.run(p.handle, p.closeChan) + log.Printf("Already connected as subscriber for %s, leaving and re-joining", p.streamType) + goto retry + case JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM: + fallthrough + case JANUS_VIDEOROOM_ERROR_NO_SUCH_FEED: + switch error_code { + case JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM: + log.Printf("Publisher %s not created yet for %s, wait and retry to join room %d as subscriber", p.publisher, p.streamType, p.roomId) + case JANUS_VIDEOROOM_ERROR_NO_SUCH_FEED: + log.Printf("Publisher %s not sending yet for %s, wait and retry to join room %d as subscriber", p.publisher, p.streamType, p.roomId) + } + wait: + select { + case msg := <-wakeupChan: + var message NatsMessage + if err := p.mcu.nats.Decode(msg, &message); err != nil { + log.Printf("Error decoding wakup NATS message %s (%s)\n", string(msg.Data), err) + goto wait + } else if message.Type != "connected" { + log.Printf("Unsupported NATS message waiting for publisher %s: %+v\n", p.publisher, message) + goto wait + } + log.Printf("Retry subscribing %s from %s", p.streamType, p.publisher) + case <-ctx.Done(): + callback(ctx.Err(), nil) + return + } + goto retry + default: + // TODO(jojo): Should we handle other errors, too? + callback(fmt.Errorf("Error joining room as subscriber: %+v", join_response), nil) + return + } + } + //log.Println("Joined as listener", join_response) + + p.session = join_response.Session + callback(nil, join_response.Jsep) +} + +func (p *mcuJanusSubscriber) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { + jsep_msg := data.Payload + switch data.Type { + case "requestoffer": + fallthrough + case "sendoffer": + p.deferred <- func() { + msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.mcuTimeout) + defer cancel() + + p.joinRoom(msgctx, callback) + } + case "answer": + p.deferred <- func() { + msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.mcuTimeout) + defer cancel() + + p.sendAnswer(msgctx, jsep_msg, callback) + } + case "candidate": + p.deferred <- func() { + msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.mcuTimeout) + defer cancel() + + p.sendCandidate(msgctx, jsep_msg["candidate"], callback) + } + case "endOfCandidates": + // Ignore + default: + // Return error asynchronously + go callback(fmt.Errorf("Unsupported message type: %s", data.Type), nil) + } +} diff --git a/src/signaling/mcu_test.go b/src/signaling/mcu_test.go new file mode 100644 index 0000000..dc7c153 --- /dev/null +++ b/src/signaling/mcu_test.go @@ -0,0 +1,50 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "fmt" + + "golang.org/x/net/context" +) + +type TestMCU struct { +} + +func NewTestMCU() (Mcu, error) { + return &TestMCU{}, nil +} + +func (m *TestMCU) Start() error { + return nil +} + +func (m *TestMCU) Stop() { +} + +func (m *TestMCU) NewPublisher(ctx context.Context, listener McuListener, id string, streamType string) (McuPublisher, error) { + return nil, fmt.Errorf("Not implemented") +} + +func (m *TestMCU) NewSubscriber(ctx context.Context, listener McuListener, publisher string, streamType string) (McuSubscriber, error) { + return nil, fmt.Errorf("Not implemented") +} diff --git a/src/signaling/natsclient.go b/src/signaling/natsclient.go new file mode 100644 index 0000000..56757f1 --- /dev/null +++ b/src/signaling/natsclient.go @@ -0,0 +1,161 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "fmt" + "log" + "os" + "os/signal" + "time" + + "github.com/nats-io/go-nats" +) + +const ( + initialConnectInterval = time.Second + maxConnectInterval = 8 * time.Second +) + +type NatsMessage struct { + Type string `json:"type"` + + Message *ServerMessage `json:"message,omitempty"` + + Room *BackendServerRoomRequest `json:"room,omitempty"` + + Permissions []Permission `json:"permissions,omitempty"` +} + +type NatsSubscription interface { + Unsubscribe() error +} + +type NatsClient interface { + Subscribe(subject string, ch chan *nats.Msg) (NatsSubscription, error) + + Request(subject string, data []byte, timeout time.Duration) (*nats.Msg, error) + + Publish(subject string, message interface{}) error + PublishNats(subject string, message *NatsMessage) error + PublishMessage(subject string, message *ServerMessage) error + PublishBackendServerRoomRequest(subject string, message *BackendServerRoomRequest) error + + Decode(msg *nats.Msg, v interface{}) error +} + +type natsClient struct { + nc *nats.Conn + conn *nats.EncodedConn +} + +func NewNatsClient(url string) (NatsClient, error) { + if url == ":loopback:" { + log.Println("No NATS url configured, using internal loopback client") + return NewLoopbackNatsClient() + } + + client := &natsClient{} + + var err error + client.nc, err = nats.Connect(url, + nats.ClosedHandler(client.onClosed), + nats.DisconnectHandler(client.onDisconnected), + nats.ReconnectHandler(client.onReconnected)) + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + defer signal.Stop(interrupt) + + delay := initialConnectInterval + timer := time.NewTimer(delay) + // The initial connect must succeed, so we retry in the case of an error. + for err != nil { + log.Printf("Could not create connection (%s), will retry in %s", err, delay) + timer.Reset(delay) + select { + case <-interrupt: + return nil, fmt.Errorf("interrupted") + case <-timer.C: + // Retry connection + delay = delay * 2 + if delay > maxConnectInterval { + delay = maxConnectInterval + } + } + + client.nc, err = nats.Connect(url) + } + log.Printf("Connection established to %s (%s)\n", client.nc.ConnectedUrl(), client.nc.ConnectedServerId()) + + // All communication will be JSON based. + client.conn, _ = nats.NewEncodedConn(client.nc, nats.JSON_ENCODER) + return client, nil +} + +func (c *natsClient) onClosed(conn *nats.Conn) { + log.Println("NATS client closed", conn.LastError()) +} + +func (c *natsClient) onDisconnected(conn *nats.Conn) { + log.Println("NATS client disconnected") +} + +func (c *natsClient) onReconnected(conn *nats.Conn) { + log.Printf("NATS client reconnected to %s (%s)\n", conn.ConnectedUrl(), conn.ConnectedServerId()) +} + +func (c *natsClient) Subscribe(subject string, ch chan *nats.Msg) (NatsSubscription, error) { + return c.nc.ChanSubscribe(subject, ch) +} + +func (c *natsClient) Request(subject string, data []byte, timeout time.Duration) (*nats.Msg, error) { + return c.nc.Request(subject, data, timeout) +} + +func (c *natsClient) Publish(subject string, message interface{}) error { + return c.conn.Publish(subject, message) +} + +func (c *natsClient) PublishNats(subject string, message *NatsMessage) error { + return c.Publish(subject, message) +} + +func (c *natsClient) PublishMessage(subject string, message *ServerMessage) error { + msg := &NatsMessage{ + Type: "message", + Message: message, + } + return c.PublishNats(subject, msg) +} + +func (c *natsClient) PublishBackendServerRoomRequest(subject string, message *BackendServerRoomRequest) error { + msg := &NatsMessage{ + Type: "room", + Room: message, + } + return c.PublishNats(subject, msg) +} + +func (c *natsClient) Decode(msg *nats.Msg, v interface{}) error { + return c.conn.Enc.Decode(msg.Subject, msg.Data, v) +} diff --git a/src/signaling/natsclient_loopback.go b/src/signaling/natsclient_loopback.go new file mode 100644 index 0000000..ef3bfc0 --- /dev/null +++ b/src/signaling/natsclient_loopback.go @@ -0,0 +1,251 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "encoding/json" + "strconv" + "strings" + "sync" + "time" + + "github.com/nats-io/go-nats" + + "golang.org/x/net/context" +) + +type LoopbackNatsClient struct { + mu sync.Mutex + subscriptions map[string]map[*loopbackNatsSubscription]bool + replyId uint64 +} + +func NewLoopbackNatsClient() (NatsClient, error) { + return &LoopbackNatsClient{ + subscriptions: make(map[string]map[*loopbackNatsSubscription]bool), + }, nil +} + +type loopbackNatsSubscription struct { + subject string + client *LoopbackNatsClient + ch chan *nats.Msg + incoming []*nats.Msg + cond sync.Cond + quit bool +} + +func (s *loopbackNatsSubscription) Unsubscribe() error { + s.cond.L.Lock() + if !s.quit { + s.quit = true + s.cond.Signal() + } + s.cond.L.Unlock() + + s.client.unsubscribe(s) + return nil +} + +func (s *loopbackNatsSubscription) queue(msg *nats.Msg) error { + s.cond.L.Lock() + s.incoming = append(s.incoming, msg) + if len(s.incoming) == 1 { + s.cond.Signal() + } + s.cond.L.Unlock() + return nil +} + +func (s *loopbackNatsSubscription) run() { + s.cond.L.Lock() + defer s.cond.L.Unlock() + for !s.quit { + for !s.quit && len(s.incoming) == 0 { + s.cond.Wait() + } + + for !s.quit && len(s.incoming) > 0 { + msg := s.incoming[0] + s.incoming = s.incoming[1:] + s.cond.L.Unlock() + s.ch <- msg + s.cond.L.Lock() + } + } +} + +func (c *LoopbackNatsClient) Subscribe(subject string, ch chan *nats.Msg) (NatsSubscription, error) { + c.mu.Lock() + defer c.mu.Unlock() + + return c.subscribe(subject, ch) +} + +func (c *LoopbackNatsClient) subscribe(subject string, ch chan *nats.Msg) (NatsSubscription, error) { + if strings.HasSuffix(subject, ".") || strings.Contains(subject, " ") { + return nil, nats.ErrBadSubject + } + + s := &loopbackNatsSubscription{ + subject: subject, + client: c, + ch: ch, + } + s.cond.L = &sync.Mutex{} + subs, found := c.subscriptions[subject] + if !found { + subs = make(map[*loopbackNatsSubscription]bool) + c.subscriptions[subject] = subs + } + subs[s] = true + + go s.run() + return s, nil +} + +func (c *LoopbackNatsClient) unsubscribe(s *loopbackNatsSubscription) { + c.mu.Lock() + defer c.mu.Unlock() + + if subs, found := c.subscriptions[s.subject]; found { + delete(subs, s) + if len(subs) == 0 { + delete(c.subscriptions, s.subject) + } + } +} + +func (c *LoopbackNatsClient) Request(subject string, data []byte, timeout time.Duration) (*nats.Msg, error) { + if strings.HasSuffix(subject, ".") || strings.Contains(subject, " ") { + return nil, nats.ErrBadSubject + } + + c.mu.Lock() + defer c.mu.Unlock() + var response *nats.Msg + var err error + subs, found := c.subscriptions[subject] + if !found { + c.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + select { + case <-ctx.Done(): + if ctx.Err() == context.DeadlineExceeded { + err = nats.ErrTimeout + } else { + err = ctx.Err() + } + } + c.mu.Lock() + return nil, err + } + + replyId := c.replyId + c.replyId += 1 + + reply := "_reply_" + strconv.FormatUint(replyId, 10) + responder := make(chan *nats.Msg) + var replySubscriber NatsSubscription + replySubscriber, err = c.subscribe(reply, responder) + if err != nil { + return nil, err + } + + defer func() { + go replySubscriber.Unsubscribe() + }() + msg := &nats.Msg{ + Subject: subject, + Data: data, + Reply: reply, + Sub: &nats.Subscription{ + Subject: subject, + }, + } + for s := range subs { + s.queue(msg) + } + c.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + select { + case response = <-responder: + err = nil + case <-ctx.Done(): + if ctx.Err() == context.DeadlineExceeded { + err = nats.ErrTimeout + } else { + err = ctx.Err() + } + } + c.mu.Lock() + return response, err +} + +func (c *LoopbackNatsClient) Publish(subject string, message interface{}) error { + if strings.HasSuffix(subject, ".") || strings.Contains(subject, " ") { + return nats.ErrBadSubject + } + + c.mu.Lock() + defer c.mu.Unlock() + if subs, found := c.subscriptions[subject]; found { + msg := &nats.Msg{ + Subject: subject, + } + var err error + if msg.Data, err = json.Marshal(message); err != nil { + return err + } + for s := range subs { + s.queue(msg) + } + } + return nil +} + +func (c *LoopbackNatsClient) PublishNats(subject string, message *NatsMessage) error { + return c.Publish(subject, message) +} + +func (c *LoopbackNatsClient) PublishMessage(subject string, message *ServerMessage) error { + msg := &NatsMessage{ + Type: "message", + Message: message, + } + return c.PublishNats(subject, msg) +} + +func (c *LoopbackNatsClient) PublishBackendServerRoomRequest(subject string, message *BackendServerRoomRequest) error { + msg := &NatsMessage{ + Type: "room", + Room: message, + } + return c.PublishNats(subject, msg) +} + +func (c *LoopbackNatsClient) Decode(msg *nats.Msg, v interface{}) error { + return json.Unmarshal(msg.Data, v) +} diff --git a/src/signaling/natsclient_loopback_test.go b/src/signaling/natsclient_loopback_test.go new file mode 100644 index 0000000..95bc2bd --- /dev/null +++ b/src/signaling/natsclient_loopback_test.go @@ -0,0 +1,222 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2018 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "bytes" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/nats-io/go-nats" + + "golang.org/x/net/context" +) + +func (c *LoopbackNatsClient) waitForSubscriptionsEmpty(ctx context.Context, t *testing.T) { + for { + c.mu.Lock() + count := len(c.subscriptions) + c.mu.Unlock() + if count == 0 { + break + } + + select { + case <-ctx.Done(): + c.mu.Lock() + t.Errorf("Error waiting for subscriptions %+v to terminate: %s", c.subscriptions, ctx.Err()) + c.mu.Unlock() + return + default: + time.Sleep(time.Millisecond) + } + } +} + +func CreateLoopbackNatsClientForTest(t *testing.T) NatsClient { + result, err := NewLoopbackNatsClient() + if err != nil { + t.Fatal(err) + } + return result +} + +func TestLoopbackNatsClient_Subscribe(t *testing.T) { + // Give time for things to settle before capturing the number of + // go routines + time.Sleep(500 * time.Millisecond) + + base := runtime.NumGoroutine() + + client := CreateLoopbackNatsClientForTest(t) + dest := make(chan *nats.Msg) + sub, err := client.Subscribe("foo", dest) + if err != nil { + t.Fatal(err) + } + ch := make(chan bool) + + received := int32(0) + max := int32(20) + quit := make(chan bool) + go func() { + for { + select { + case <-dest: + total := atomic.AddInt32(&received, 1) + if total == max { + err := sub.Unsubscribe() + if err != nil { + t.Fatal("Unsubscribe failed with err:", err) + } + ch <- true + } + case <-quit: + return + } + } + }() + for i := int32(0); i < max; i++ { + client.Publish("foo", []byte("hello")) + } + <-ch + + r := atomic.LoadInt32(&received) + if r != max { + t.Fatalf("Received wrong # of messages: %d vs %d", r, max) + } + quit <- true + + // Give time for things to settle before capturing the number of + // go routines + time.Sleep(500 * time.Millisecond) + + delta := (runtime.NumGoroutine() - base) + if delta > 0 { + t.Fatalf("%d Go routines still exist post Close()", delta) + } +} + +func TestLoopbackNatsClient_Request(t *testing.T) { + // Give time for things to settle before capturing the number of + // go routines + time.Sleep(500 * time.Millisecond) + + base := runtime.NumGoroutine() + + client := CreateLoopbackNatsClientForTest(t) + dest := make(chan *nats.Msg) + sub, err := client.Subscribe("foo", dest) + if err != nil { + t.Fatal(err) + } + + go func() { + msg := <-dest + if err := client.Publish(msg.Reply, []byte("world")); err != nil { + t.Fatal(err) + } + if err := sub.Unsubscribe(); err != nil { + t.Fatal("Unsubscribe failed with err:", err) + } + }() + reply, err := client.Request("foo", []byte("hello"), 1*time.Second) + if err != nil { + t.Fatal(err) + } + + var response []byte + if err := client.Decode(reply, &response); err != nil { + t.Fatal(err) + } + if !bytes.Equal(response, []byte("world")) { + t.Fatalf("expected 'world', got '%s'", string(reply.Data)) + } + + // Give time for things to settle before capturing the number of + // go routines + time.Sleep(500 * time.Millisecond) + + delta := (runtime.NumGoroutine() - base) + if delta > 0 { + t.Fatalf("%d Go routines still exist post Close()", delta) + } +} + +func TestLoopbackNatsClient_RequestTimeout(t *testing.T) { + // Give time for things to settle before capturing the number of + // go routines + time.Sleep(500 * time.Millisecond) + + base := runtime.NumGoroutine() + + client := CreateLoopbackNatsClientForTest(t) + dest := make(chan *nats.Msg) + sub, err := client.Subscribe("foo", dest) + if err != nil { + t.Fatal(err) + } + + go func() { + msg := <-dest + time.Sleep(200 * time.Millisecond) + if err := client.Publish(msg.Reply, []byte("world")); err != nil { + t.Fatal(err) + } + if err := sub.Unsubscribe(); err != nil { + t.Fatal("Unsubscribe failed with err:", err) + } + }() + reply, err := client.Request("foo", []byte("hello"), 100*time.Millisecond) + if err == nil { + t.Fatalf("Request should have timed out, reeived %+v", reply) + } else if err != nats.ErrTimeout { + t.Fatalf("Request should have timed out, received error %s", err) + } + + // Give time for things to settle before capturing the number of + // go routines + time.Sleep(500 * time.Millisecond) + + delta := (runtime.NumGoroutine() - base) + if delta > 0 { + t.Fatalf("%d Go routines still exist post Close()", delta) + } +} + +func TestLoopbackNatsClient_RequestTimeoutNoReply(t *testing.T) { + client := CreateLoopbackNatsClientForTest(t) + timeout := 100 * time.Millisecond + start := time.Now() + reply, err := client.Request("foo", []byte("hello"), timeout) + end := time.Now() + if err == nil { + t.Fatalf("Request should have timed out, reeived %+v", reply) + } else if err != nats.ErrTimeout { + t.Fatalf("Request should have timed out, received error %s", err) + } + if end.Sub(start) < timeout { + t.Errorf("Expected a delay of %s but had %s", timeout, end.Sub(start)) + } +} diff --git a/src/signaling/pool.go b/src/signaling/pool.go new file mode 100644 index 0000000..7335c29 --- /dev/null +++ b/src/signaling/pool.go @@ -0,0 +1,62 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "fmt" + "net/http" + + "golang.org/x/net/context" +) + +type HttpClientPool struct { + pool chan *http.Client +} + +func NewHttpClientPool(constructor func() *http.Client, size int) (*HttpClientPool, error) { + if size <= 0 { + return nil, fmt.Errorf("can't create empty pool") + } + + p := &HttpClientPool{ + pool: make(chan *http.Client, size), + } + for size > 0 { + c := constructor() + p.pool <- c + size -= 1 + } + return p, nil +} + +func (p *HttpClientPool) Get(ctx context.Context) (client *http.Client, err error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case client := <-p.pool: + return client, nil + } +} + +func (p *HttpClientPool) Put(c *http.Client) { + p.pool <- c +} diff --git a/src/signaling/pool_test.go b/src/signaling/pool_test.go new file mode 100644 index 0000000..b861e0e --- /dev/null +++ b/src/signaling/pool_test.go @@ -0,0 +1,63 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "net/http" + "testing" + "time" + + "golang.org/x/net/context" +) + +func TestHttpClientPool(t *testing.T) { + transport := &http.Transport{} + if _, err := NewHttpClientPool(func() *http.Client { + return &http.Client{ + Transport: transport, + } + }, 0); err == nil { + t.Error("should not be possible to create empty pool") + } + + pool, err := NewHttpClientPool(func() *http.Client { + return &http.Client{ + Transport: transport, + } + }, 1) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + if _, err := pool.Get(ctx); err != nil { + t.Fatal(err) + } + + ctx2, cancel := context.WithTimeout(ctx, 10*time.Millisecond) + defer cancel() + if _, err := pool.Get(ctx2); err == nil { + t.Error("fetching from empty pool should have timed out") + } else if err != context.DeadlineExceeded { + t.Errorf("fetching from empty pool should have timed out, got %s", err) + } +} diff --git a/src/signaling/room.go b/src/signaling/room.go new file mode 100644 index 0000000..cdbbfd1 --- /dev/null +++ b/src/signaling/room.go @@ -0,0 +1,600 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "bytes" + "encoding/json" + "log" + "net/url" + "sync" + "time" + + "github.com/nats-io/go-nats" + + "golang.org/x/net/context" +) + +const ( + // Must match values in "Participant.php" from Nextcloud Talk. + FlagDisconnected = 0 + FlagInCall = 1 + FlagWithAudio = 2 + FlagWithVideo = 4 +) + +var ( + updateActiveSessionsInterval = 10 * time.Second +) + +type Room struct { + id string + hub *Hub + nats NatsClient + + properties *json.RawMessage + roomType int + + closeChan chan bool + mu *sync.RWMutex + sessions map[string]Session + + internalSessions map[Session]bool + inCallSessions map[Session]bool + roomSessionData map[string]*RoomSessionData + + natsReceiver chan *nats.Msg + backendSubscription NatsSubscription + + // Users currently in the room + users []map[string]interface{} + + // Timestamps of last NATS backend requests for the different types. + lastNatsRoomRequests map[string]int64 +} + +func NewRoom(roomId string, properties *json.RawMessage, hub *Hub, n NatsClient) (*Room, error) { + natsReceiver := make(chan *nats.Msg, 64) + backendSubscription, err := n.Subscribe("backend.room."+roomId, natsReceiver) + if err != nil { + close(natsReceiver) + return nil, err + } + + room := &Room{ + id: roomId, + hub: hub, + nats: n, + + properties: properties, + + closeChan: make(chan bool, 1), + mu: &sync.RWMutex{}, + sessions: make(map[string]Session), + + internalSessions: make(map[Session]bool), + inCallSessions: make(map[Session]bool), + roomSessionData: make(map[string]*RoomSessionData), + + natsReceiver: natsReceiver, + backendSubscription: backendSubscription, + + lastNatsRoomRequests: make(map[string]int64), + } + go room.run() + + return room, nil +} + +func (r *Room) Id() string { + return r.id +} + +func (r *Room) Properties() *json.RawMessage { + r.mu.RLock() + defer r.mu.RUnlock() + return r.properties +} + +func (r *Room) run() { + ticker := time.NewTicker(updateActiveSessionsInterval) +loop: + for { + select { + case <-r.closeChan: + break loop + case msg := <-r.natsReceiver: + if msg != nil { + r.processNatsMessage(msg) + } + case <-ticker.C: + r.publishActiveSessions() + } + } +} + +func (r *Room) doClose() { + select { + case r.closeChan <- true: + default: + } +} + +func (r *Room) unsubscribeBackend() { + if r.backendSubscription == nil { + return + } + + go func(subscription NatsSubscription) { + subscription.Unsubscribe() + close(r.natsReceiver) + }(r.backendSubscription) + r.backendSubscription = nil +} + +func (r *Room) Close() []Session { + r.hub.removeRoom(r) + r.doClose() + r.mu.Lock() + r.unsubscribeBackend() + result := make([]Session, 0, len(r.sessions)) + for _, s := range r.sessions { + result = append(result, s) + } + r.sessions = nil + r.mu.Unlock() + return result +} + +func (r *Room) processNatsMessage(message *nats.Msg) { + var msg NatsMessage + if err := r.nats.Decode(message, &msg); err != nil { + log.Printf("Could not decode nats message %+v, %s", message, err) + return + } + + switch msg.Type { + case "room": + r.processBackendRoomRequest(msg.Room) + default: + log.Printf("Unsupported NATS room request with type %s: %+v", msg.Type, msg) + } +} + +func (r *Room) processBackendRoomRequest(message *BackendServerRoomRequest) { + received := message.ReceivedTime + if last, found := r.lastNatsRoomRequests[message.Type]; found && last > received { + if msg, err := json.Marshal(message); err == nil { + log.Printf("Ignore old NATS backend room request for %s: %s", r.Id(), string(msg)) + } else { + log.Printf("Ignore old NATS backend room request for %s: %+v", r.Id(), message) + } + return + } + + r.lastNatsRoomRequests[message.Type] = received + message.room = r + switch message.Type { + case "update": + r.hub.roomUpdated <- message + case "delete": + r.hub.roomDeleted <- message + case "incall": + r.hub.roomInCall <- message + case "participants": + r.hub.roomParticipants <- message + case "message": + r.publishRoomMessage(message.Message) + default: + log.Printf("Unsupported NATS backend room request with type %s in %s: %+v", message.Type, r.Id(), message) + } +} + +func (r *Room) AddSession(session Session, sessionData *json.RawMessage) []Session { + var roomSessionData *RoomSessionData + if sessionData != nil && len(*sessionData) > 0 { + roomSessionData = &RoomSessionData{} + if err := json.Unmarshal(*sessionData, roomSessionData); err != nil { + log.Printf("Error decoding room session data \"%s\": %s", string(*sessionData), err) + roomSessionData = nil + } + } + + sid := session.PublicId() + r.mu.Lock() + _, found := r.sessions[sid] + // Return list of sessions already in the room. + result := make([]Session, 0, len(r.sessions)) + for _, s := range r.sessions { + if s != session { + result = append(result, s) + } + } + r.sessions[sid] = session + if session.ClientType() == HelloClientTypeInternal { + r.internalSessions[session] = true + } + if roomSessionData != nil { + r.roomSessionData[sid] = roomSessionData + log.Printf("Session %s sent room session data %+v", session.PublicId(), roomSessionData) + } + r.mu.Unlock() + if !found { + r.PublishSessionJoined(session, roomSessionData) + } + return result +} + +func (r *Room) HasSession(session Session) bool { + r.mu.RLock() + _, result := r.sessions[session.PublicId()] + r.mu.RUnlock() + return result +} + +// Returns "true" if there are still clients in the room. +func (r *Room) RemoveSession(session Session) bool { + r.mu.Lock() + if _, found := r.sessions[session.PublicId()]; !found { + r.mu.Unlock() + return true + } + + sid := session.PublicId() + delete(r.sessions, sid) + delete(r.internalSessions, session) + delete(r.inCallSessions, session) + delete(r.roomSessionData, sid) + if len(r.sessions) > 0 { + r.mu.Unlock() + r.PublishSessionLeft(session) + return true + } + + r.hub.removeRoom(r) + r.unsubscribeBackend() + r.doClose() + r.mu.Unlock() + return false +} + +func (r *Room) publish(message *ServerMessage) { + r.nats.PublishMessage("room."+r.id, message) +} + +func (r *Room) UpdateProperties(properties *json.RawMessage) { + r.mu.Lock() + defer r.mu.Unlock() + if (r.properties == nil && properties == nil) || + (r.properties != nil && properties != nil && bytes.Equal(*r.properties, *properties)) { + // Don't notify if properties didn't change. + return + } + + r.properties = properties + message := &ServerMessage{ + Type: "room", + Room: &RoomServerMessage{ + RoomId: r.id, + Properties: r.properties, + }, + } + r.publish(message) +} + +func (r *Room) PublishSessionJoined(session Session, sessionData *RoomSessionData) { + sessionId := session.PublicId() + if sessionId == "" { + return + } + + userid := session.UserId() + if userid == "" && sessionData != nil { + userid = sessionData.UserId + } + + message := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "room", + Type: "join", + Join: []*EventServerMessageSessionEntry{ + &EventServerMessageSessionEntry{ + SessionId: sessionId, + UserId: userid, + User: session.UserData(), + }, + }, + }, + } + r.publish(message) + + if session.ClientType() == HelloClientTypeInternal { + r.publishUsersChangedWithInternal() + } +} + +func (r *Room) PublishSessionLeft(session Session) { + sessionId := session.PublicId() + if sessionId == "" { + return + } + + message := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "room", + Type: "leave", + Leave: []string{ + sessionId, + }, + }, + } + r.publish(message) + + if session.ClientType() == HelloClientTypeInternal { + r.publishUsersChangedWithInternal() + } +} + +func (r *Room) addInternalSessions(users []map[string]interface{}) []map[string]interface{} { + now := time.Now().Unix() + r.mu.Lock() + for _, user := range users { + sessionid, found := user["sessionId"] + if !found || sessionid == "" { + continue + } + + if userid, found := user["userId"]; !found || userid == "" { + if roomSessionData, found := r.roomSessionData[sessionid.(string)]; found { + user["userId"] = roomSessionData.UserId + } + } + } + for session := range r.internalSessions { + users = append(users, map[string]interface{}{ + "inCall": true, + "sessionId": session.PublicId(), + "lastPing": now, + "internal": true, + }) + } + r.mu.Unlock() + return users +} + +func (r *Room) filterPermissions(users []map[string]interface{}) []map[string]interface{} { + for _, user := range users { + delete(user, "permissions") + } + return users +} + +func IsInCall(value interface{}) (bool, bool) { + switch value := value.(type) { + case bool: + return value, true + case float64: + // Default JSON decoder unmarshals numbers to float64. + return (int(value) & FlagInCall) == FlagInCall, true + case int: + return (value & FlagInCall) == FlagInCall, true + case json.Number: + // Expect integer when using numeric JSON decoder. + if flags, err := value.Int64(); err == nil { + return (flags & FlagInCall) == FlagInCall, true + } + return false, false + default: + return false, false + } +} + +func (r *Room) PublishUsersInCallChanged(changed []map[string]interface{}, users []map[string]interface{}) { + r.users = users + for _, user := range changed { + inCallInterface, found := user["inCall"] + if !found { + continue + } + inCall, ok := IsInCall(inCallInterface) + if !ok { + continue + } + + sessionIdInterface, found := user["sessionId"] + if !found { + sessionIdInterface, found = user["sessionid"] + if !found { + continue + } + } + + sessionId, ok := sessionIdInterface.(string) + if !ok { + continue + } + + session := r.hub.GetSessionByPublicId(sessionId) + if session == nil { + continue + } + + if inCall { + r.mu.Lock() + if !r.inCallSessions[session] { + r.inCallSessions[session] = true + log.Printf("Session %s joined call %s", session.PublicId(), r.id) + } + r.mu.Unlock() + } else { + r.mu.Lock() + delete(r.inCallSessions, session) + r.mu.Unlock() + if clientSession, ok := session.(*ClientSession); ok { + clientSession.LeaveCall() + } + } + } + + changed = r.filterPermissions(changed) + users = r.filterPermissions(users) + + message := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "participants", + Type: "update", + Update: &RoomEventServerMessage{ + RoomId: r.id, + Changed: changed, + Users: r.addInternalSessions(users), + }, + }, + } + r.publish(message) +} + +func (r *Room) PublishUsersChanged(changed []map[string]interface{}, users []map[string]interface{}) { + changed = r.filterPermissions(changed) + users = r.filterPermissions(users) + + message := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "participants", + Type: "update", + Update: &RoomEventServerMessage{ + RoomId: r.id, + Changed: changed, + Users: r.addInternalSessions(users), + }, + }, + } + r.publish(message) +} + +func (r *Room) getParticipantsUpdateMessage(users []map[string]interface{}) *ServerMessage { + users = r.filterPermissions(users) + + message := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "participants", + Type: "update", + Update: &RoomEventServerMessage{ + RoomId: r.id, + Users: r.addInternalSessions(users), + }, + }, + } + return message +} + +func (r *Room) NotifySessionResumed(client *Client) { + message := r.getParticipantsUpdateMessage(r.users) + if len(message.Event.Update.Users) == 0 { + return + } + + client.SendMessage(message) +} + +func (r *Room) publishUsersChangedWithInternal() { + message := r.getParticipantsUpdateMessage(r.users) + r.publish(message) +} + +func (r *Room) publishActiveSessions() { + r.mu.Lock() + defer r.mu.Unlock() + + entries := make(map[string][]BackendPingEntry) + urls := make(map[string]*url.URL) + for _, session := range r.sessions { + u := session.BackendUrl() + if u == "" { + continue + } + + var sid string + switch sess := session.(type) { + case *ClientSession: + // Use Nextcloud session id + sid = sess.RoomSessionId() + default: + continue + } + if sid == "" { + continue + } + e, found := entries[u] + if !found { + p := session.ParsedBackendUrl() + if p == nil { + // Should not happen, invalid URLs should get rejected earlier. + continue + } + urls[u] = p + } + + entries[u] = append(e, BackendPingEntry{ + SessionId: sid, + UserId: session.UserId(), + }) + } + if len(urls) == 0 { + return + } + for u, e := range entries { + go func(url *url.URL, entries []BackendPingEntry) { + ctx, cancel := context.WithTimeout(context.Background(), r.hub.backendTimeout) + defer cancel() + + request := NewBackendClientPingRequest(r.id, entries) + var response BackendClientResponse + if err := r.hub.backend.PerformJSONRequest(ctx, url, request, &response); err != nil { + log.Printf("Error pinging room %s for active entries %+v: %s", r.id, entries, err) + } + }(urls[u], e) + } +} + +func (r *Room) publishRoomMessage(message *BackendRoomMessageRequest) { + if message == nil || message.Data == nil { + return + } + + msg := &ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Target: "room", + Type: "message", + Message: &RoomEventMessage{ + RoomId: r.id, + Data: message.Data, + }, + }, + } + r.publish(msg) +} diff --git a/src/signaling/room_test.go b/src/signaling/room_test.go new file mode 100644 index 0000000..a7b3b3f --- /dev/null +++ b/src/signaling/room_test.go @@ -0,0 +1,362 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "testing" + "time" + + "github.com/gorilla/websocket" + + "golang.org/x/net/context" +) + +func TestRoom_InCall(t *testing.T) { + type Testcase struct { + Value interface{} + InCall bool + Valid bool + } + tests := []Testcase{ + {nil, false, false}, + {"a", false, false}, + {true, true, true}, + {false, false, true}, + {0, false, true}, + {FlagDisconnected, false, true}, + {1, true, true}, + {FlagInCall, true, true}, + {2, false, true}, + {FlagWithAudio, false, true}, + {3, true, true}, + {FlagInCall | FlagWithAudio, true, true}, + {4, false, true}, + {FlagWithVideo, false, true}, + {5, true, true}, + {FlagInCall | FlagWithVideo, true, true}, + {1.1, true, true}, + {json.Number("1"), true, true}, + {json.Number("1.1"), false, false}, + } + for _, test := range tests { + inCall, ok := IsInCall(test.Value) + if ok != test.Valid { + t.Errorf("%+v should be valid %v, got %v", test.Value, test.Valid, ok) + } + if inCall != test.InCall { + t.Errorf("%+v should convert to %v, got %v", test.Value, test.InCall, inCall) + } + } +} + +func TestRoom_Update(t *testing.T) { + hub, _, router, server, shutdown := CreateHubForTest(t) + defer shutdown() + + config, err := getTestConfig(server) + if err != nil { + t.Fatal(err) + } + b, err := NewBackendServer(config, hub, "no-version") + if err != nil { + t.Fatal(err) + } + if err := b.Start(router); err != nil { + t.Fatal(err) + } + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId := "test-room" + if room, err := client.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + if hubRoom := hub.getRoom(roomId); hubRoom != nil { + defer hubRoom.Close() + } + + // We will receive a "joined" event. + if err := client.RunUntilJoined(ctx, hello.Hello); err != nil { + t.Error(err) + } + + // Simulate backend request from Nextcloud to update the room. + roomProperties := json.RawMessage("{\"foo\":\"bar\"}") + msg := &BackendServerRoomRequest{ + Type: "update", + Update: &BackendRoomUpdateRequest{ + UserIds: []string{ + testDefaultUserId, + }, + Properties: &roomProperties, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + // The client receives a roomlist update and a changed room event. The + // ordering is not defined because messages are sent by asynchronous NATS + // handlers. + message1, err := client.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + message2, err := client.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + + if msg, err := checkMessageRoomlistUpdate(message1); err != nil { + if err := checkMessageRoomId(message1, roomId); err != nil { + t.Error(err) + } + if msg, err := checkMessageRoomlistUpdate(message2); err != nil { + t.Error(err) + } else if msg.RoomId != roomId { + t.Errorf("Expected room id %s, got %+v", roomId, msg) + } else if msg.Properties == nil || !bytes.Equal(*msg.Properties, roomProperties) { + t.Errorf("Expected room properties %s, got %+v", string(roomProperties), msg) + } + } else { + if msg.RoomId != roomId { + t.Errorf("Expected room id %s, got %+v", roomId, msg) + } else if msg.Properties == nil || !bytes.Equal(*msg.Properties, roomProperties) { + t.Errorf("Expected room properties %s, got %+v", string(roomProperties), msg) + } + if err := checkMessageRoomId(message2, roomId); err != nil { + t.Error(err) + } + } + + // Allow up to 100 milliseconds for NATS processing. + ctx2, cancel2 := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel2() + +loop: + for { + select { + case <-ctx2.Done(): + break loop + default: + // The internal room has been updated with the new properties. + hub.ru.Lock() + room, found := hub.rooms[roomId] + hub.ru.Unlock() + + if !found { + err = fmt.Errorf("Room %s not found in hub", roomId) + } else if room.Properties() == nil || !bytes.Equal(*room.Properties(), roomProperties) { + err = fmt.Errorf("Expected room properties %s, got %+v", string(roomProperties), room.Properties()) + } else { + err = nil + } + } + if err == nil { + break + } + + time.Sleep(time.Millisecond) + } + + if err != nil { + t.Error(err) + } +} + +func TestRoom_Delete(t *testing.T) { + hub, _, router, server, shutdown := CreateHubForTest(t) + defer shutdown() + + config, err := getTestConfig(server) + if err != nil { + t.Fatal(err) + } + b, err := NewBackendServer(config, hub, "no-version") + if err != nil { + t.Fatal(err) + } + if err := b.Start(router); err != nil { + t.Fatal(err) + } + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + if err := client.SendHello(testDefaultUserId); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello, err := client.RunUntilHello(ctx) + if err != nil { + t.Fatal(err) + } + + // Join room by id. + roomId := "test-room" + if room, err := client.JoinRoom(ctx, roomId); err != nil { + t.Fatal(err) + } else if room.Room.RoomId != roomId { + t.Fatalf("Expected room %s, got %s", roomId, room.Room.RoomId) + } + + // We will receive a "joined" event. + if err := client.RunUntilJoined(ctx, hello.Hello); err != nil { + t.Error(err) + } + + // Simulate backend request from Nextcloud to update the room. + msg := &BackendServerRoomRequest{ + Type: "delete", + Delete: &BackendRoomDeleteRequest{ + UserIds: []string{ + testDefaultUserId, + }, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected successful request, got %s: %s", res.Status, string(body)) + } + + // The client is no longer invited to the room and leaves it. The ordering + // of messages is not defined as they get published through NATS and handled + // by asynchronous channels. + message1, err := client.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + + if err := checkMessageType(message1, "event"); err != nil { + // Ordering should be "leave room", "disinvited". + if err := checkMessageRoomId(message1, ""); err != nil { + t.Error(err) + } + message2, err := client.RunUntilMessage(ctx) + if err != nil { + t.Error(err) + } + if _, err := checkMessageRoomlistDisinvite(message2); err != nil { + t.Error(err) + } + } else { + // Ordering should be "disinvited", "leave room". + if _, err := checkMessageRoomlistDisinvite(message1); err != nil { + t.Error(err) + } + message2, err := client.RunUntilMessage(ctx) + if err != nil { + // The connection should get closed after the "disinvited". + if websocket.IsUnexpectedCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseNoStatusReceived) { + t.Error(err) + } + } else if err := checkMessageRoomId(message2, ""); err != nil { + t.Error(err) + } + } + + // Allow up to 100 milliseconds for NATS processing. + ctx2, cancel2 := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel2() + +loop: + for { + select { + case <-ctx2.Done(): + break loop + default: + // The internal room has been updated with the new properties. + hub.ru.Lock() + _, found := hub.rooms[roomId] + hub.ru.Unlock() + + if found { + err = fmt.Errorf("Room %s still found in hub", roomId) + } else { + err = nil + } + } + if err == nil { + break + } + + time.Sleep(time.Millisecond) + } + + if err != nil { + t.Error(err) + } +} diff --git a/src/signaling/roomsessions.go b/src/signaling/roomsessions.go new file mode 100644 index 0000000..2bf15bc --- /dev/null +++ b/src/signaling/roomsessions.go @@ -0,0 +1,37 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "fmt" +) + +var ( + ErrNoSuchRoomSession = fmt.Errorf("unknown room session id") +) + +type RoomSessions interface { + SetRoomSession(session Session, roomSessionId string) error + DeleteRoomSession(session Session) + + GetSessionId(roomSessionId string) (string, error) +} diff --git a/src/signaling/roomsessions_builtin.go b/src/signaling/roomsessions_builtin.go new file mode 100644 index 0000000..b217fd1 --- /dev/null +++ b/src/signaling/roomsessions_builtin.go @@ -0,0 +1,77 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "sync" +) + +type BuiltinRoomSessions struct { + sessionIdToRoomSession map[string]string + roomSessionToSessionid map[string]string + mu sync.Mutex +} + +func NewBuiltinRoomSessions() (RoomSessions, error) { + return &BuiltinRoomSessions{ + sessionIdToRoomSession: make(map[string]string), + roomSessionToSessionid: make(map[string]string), + }, nil +} + +func (r *BuiltinRoomSessions) SetRoomSession(session Session, roomSessionId string) error { + if roomSessionId == "" { + r.DeleteRoomSession(session) + return nil + } + + r.mu.Lock() + defer r.mu.Unlock() + if sid := session.PublicId(); sid != "" { + r.sessionIdToRoomSession[sid] = roomSessionId + r.roomSessionToSessionid[roomSessionId] = sid + } + return nil +} + +func (r *BuiltinRoomSessions) DeleteRoomSession(session Session) { + r.mu.Lock() + defer r.mu.Unlock() + if sid := session.PublicId(); sid != "" { + if roomSessionId, found := r.sessionIdToRoomSession[sid]; found { + delete(r.sessionIdToRoomSession, sid) + if r.roomSessionToSessionid[roomSessionId] == sid { + delete(r.roomSessionToSessionid, roomSessionId) + } + } + } +} + +func (r *BuiltinRoomSessions) GetSessionId(roomSessionId string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if sid, found := r.roomSessionToSessionid[roomSessionId]; !found { + return "", ErrNoSuchRoomSession + } else { + return sid, nil + } +} diff --git a/src/signaling/roomsessions_builtin_test.go b/src/signaling/roomsessions_builtin_test.go new file mode 100644 index 0000000..6f212b1 --- /dev/null +++ b/src/signaling/roomsessions_builtin_test.go @@ -0,0 +1,35 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "testing" +) + +func TestBuiltinRoomSessions(t *testing.T) { + sessions, err := NewBuiltinRoomSessions() + if err != nil { + t.Fatal(err) + } + + testRoomSessions(t, sessions) +} diff --git a/src/signaling/roomsessions_test.go b/src/signaling/roomsessions_test.go new file mode 100644 index 0000000..7bc6d24 --- /dev/null +++ b/src/signaling/roomsessions_test.go @@ -0,0 +1,140 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "encoding/json" + "net/url" + "testing" + "time" +) + +type DummySession struct { + publicId string +} + +func (s *DummySession) PrivateId() string { + return "" +} + +func (s *DummySession) PublicId() string { + return s.publicId +} + +func (s *DummySession) ClientType() string { + return "" +} + +func (s *DummySession) Data() *SessionIdData { + return nil +} + +func (s *DummySession) UserId() string { + return "" +} + +func (s *DummySession) UserData() *json.RawMessage { + return nil +} + +func (s *DummySession) BackendUrl() string { + return "" +} + +func (s *DummySession) ParsedBackendUrl() *url.URL { + return nil +} + +func (s *DummySession) SetRoom(room *Room) { +} + +func (s *DummySession) GetRoom() *Room { + return nil +} + +func (s *DummySession) LeaveRoom(notify bool) *Room { + return nil +} + +func (s *DummySession) IsExpired(now time.Time) bool { + return false +} + +func (s *DummySession) Close() { +} + +func (s *DummySession) HasPermission(permission Permission) bool { + return false +} + +func checkSession(t *testing.T, sessions RoomSessions, sessionId string, roomSessionId string) Session { + session := &DummySession{ + publicId: sessionId, + } + if err := sessions.SetRoomSession(session, roomSessionId); err != nil { + t.Fatalf("Expected no error, got %s", err) + } + if sid, err := sessions.GetSessionId(roomSessionId); err != nil { + t.Errorf("Expected session id %s, got error %s", sessionId, err) + } else if sid != sessionId { + t.Errorf("Expected session id %s, got %s", sessionId, sid) + } + return session +} + +func testRoomSessions(t *testing.T, sessions RoomSessions) { + if sid, err := sessions.GetSessionId("unknown"); err != nil && err != ErrNoSuchRoomSession { + t.Errorf("Expected error about invalid room session, got %s", err) + } else if err == nil { + t.Errorf("Expected error about invalid room session, got session id %s", sid) + } + + s1 := checkSession(t, sessions, "session1", "room1") + s2 := checkSession(t, sessions, "session2", "room2") + + if sid, err := sessions.GetSessionId("room1"); err != nil { + t.Errorf("Expected session id %s, got error %s", s1.PublicId(), err) + } else if sid != s1.PublicId() { + t.Errorf("Expected session id %s, got %s", s1.PublicId(), sid) + } + + sessions.DeleteRoomSession(s1) + if sid, err := sessions.GetSessionId("room1"); err != nil && err != ErrNoSuchRoomSession { + t.Errorf("Expected error about invalid room session, got %s", err) + } else if err == nil { + t.Errorf("Expected error about invalid room session, got session id %s", sid) + } + if sid, err := sessions.GetSessionId("room2"); err != nil { + t.Errorf("Expected session id %s, got error %s", s2.PublicId(), err) + } else if sid != s2.PublicId() { + t.Errorf("Expected session id %s, got %s", s2.PublicId(), sid) + } + + sessions.SetRoomSession(s1, "room-session") + sessions.SetRoomSession(s2, "room-session") + sessions.DeleteRoomSession(s1) + if sid, err := sessions.GetSessionId("room-session"); err != nil { + t.Errorf("Expected session id %s, got error %s", s2.PublicId(), err) + } else if sid != s2.PublicId() { + t.Errorf("Expected session id %s, got %s", s2.PublicId(), sid) + } +} diff --git a/src/signaling/session.go b/src/signaling/session.go new file mode 100644 index 0000000..0065406 --- /dev/null +++ b/src/signaling/session.go @@ -0,0 +1,63 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "encoding/json" + "net/url" + "time" +) + +type Permission string + +var ( + PERMISSION_MAY_PUBLISH_MEDIA Permission = "publish-media" + PERMISSION_MAY_PUBLISH_SCREEN Permission = "publish-screen" + PERMISSION_MAY_CONTROL Permission = "control" +) + +type SessionIdData struct { + Sid uint64 + Created time.Time +} + +type Session interface { + PrivateId() string + PublicId() string + ClientType() string + Data() *SessionIdData + + UserId() string + UserData() *json.RawMessage + + BackendUrl() string + ParsedBackendUrl() *url.URL + + SetRoom(room *Room) + GetRoom() *Room + LeaveRoom(notify bool) *Room + + IsExpired(now time.Time) bool + Close() + + HasPermission(permission Permission) bool +} diff --git a/src/signaling/session_test.go b/src/signaling/session_test.go new file mode 100644 index 0000000..a29eba8 --- /dev/null +++ b/src/signaling/session_test.go @@ -0,0 +1,38 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "testing" +) + +func assertSessionHasPermission(t *testing.T, session Session, permission Permission) { + if !session.HasPermission(permission) { + t.Errorf("Session %s doesn't have permission %s", session.PublicId(), permission) + } +} + +func assertSessionHasNotPermission(t *testing.T, session Session, permission Permission) { + if session.HasPermission(permission) { + t.Errorf("Session %s has permission %s but shouldn't", session.PublicId(), permission) + } +} diff --git a/src/signaling/testclient_test.go b/src/signaling/testclient_test.go new file mode 100644 index 0000000..4856aff --- /dev/null +++ b/src/signaling/testclient_test.go @@ -0,0 +1,661 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package signaling + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + + "golang.org/x/net/context" +) + +var ( + testBackendSecret = []byte("secret") + testInternalSecret = []byte("internal-secret") + + NoMessageReceivedError = fmt.Errorf("No message was received by the server.") +) + +type TestBackendClientAuthParams struct { + UserId string `json:"userid"` +} + +func getWebsocketUrl(url string) string { + if strings.HasPrefix(url, "http://") { + return "ws://" + url[7:] + "/spreed" + } else if strings.HasPrefix(url, "https://") { + return "wss://" + url[8:] + "/spreed" + } else { + panic("Unsupported URL: " + url) + } +} + +func getPrivateSessionIdData(h *Hub, privateId string) *SessionIdData { + decodedPrivate := h.decodeSessionId(privateId, privateSessionName) + if decodedPrivate == nil { + panic("invalid private session id") + } + return decodedPrivate +} + +func getPubliceSessionIdData(h *Hub, publicId string) *SessionIdData { + decodedPublic := h.decodeSessionId(publicId, publicSessionName) + if decodedPublic == nil { + panic("invalid public session id") + } + return decodedPublic +} + +func privateToPublicSessionId(h *Hub, privateId string) string { + decodedPrivate := getPrivateSessionIdData(h, privateId) + if decodedPrivate == nil { + panic("invalid private session id") + } + encodedPublic, err := h.encodeSessionId(decodedPrivate, publicSessionName) + if err != nil { + panic(err) + } + return encodedPublic +} + +func equalPublicAndPrivateSessionId(h *Hub, publicId, privateId string) bool { + decodedPublic := h.decodeSessionId(publicId, publicSessionName) + if decodedPublic == nil { + panic("invalid public session id") + } + decodedPrivate := h.decodeSessionId(privateId, privateSessionName) + if decodedPrivate == nil { + panic("invalid private session id") + } + return decodedPublic.Sid == decodedPrivate.Sid +} + +func checkUnexpectedClose(err error) error { + if err != nil && websocket.IsUnexpectedCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseNoStatusReceived) { + return fmt.Errorf("Connection was closed with unexpected error: %s", err) + } + + return nil +} + +func toJsonString(o interface{}) string { + if s, err := json.Marshal(o); err != nil { + panic(err) + } else { + return string(s) + } +} + +func checkMessageType(message *ServerMessage, expectedType string) error { + if message == nil { + return NoMessageReceivedError + } + + if message.Type != expectedType { + return fmt.Errorf("Expected \"%s\" message, got %+v (%s)", expectedType, message, toJsonString(message)) + } + switch message.Type { + case "hello": + if message.Hello == nil { + return fmt.Errorf("Expected \"%s\" message, got %+v (%s)", expectedType, message, toJsonString(message)) + } + case "message": + if message.Message == nil { + return fmt.Errorf("Expected \"%s\" message, got %+v (%s)", expectedType, message, toJsonString(message)) + } else if message.Message.Data == nil || len(*message.Message.Data) == 0 { + return fmt.Errorf("Received message without data") + } + case "room": + if message.Room == nil { + return fmt.Errorf("Expected \"%s\" message, got %+v (%s)", expectedType, message, toJsonString(message)) + } + case "event": + if message.Event == nil { + return fmt.Errorf("Expected \"%s\" message, got %+v (%s)", expectedType, message, toJsonString(message)) + } + } + + return nil +} + +func checkMessageSender(hub *Hub, message *MessageServerMessage, senderType string, hello *HelloServerMessage) error { + if message.Sender.Type != senderType { + return fmt.Errorf("Expected sender type %s, got %s", senderType, message.Sender.SessionId) + } else if message.Sender.SessionId != hello.SessionId { + return fmt.Errorf("Expected session id %+v, got %+v", + getPubliceSessionIdData(hub, hello.SessionId), getPubliceSessionIdData(hub, message.Sender.SessionId)) + } else if message.Sender.UserId != hello.UserId { + return fmt.Errorf("Expected user id %s, got %s", hello.UserId, message.Sender.UserId) + } + + return nil +} + +func checkReceiveClientMessageWithSender(ctx context.Context, client *TestClient, senderType string, hello *HelloServerMessage, payload interface{}, sender **MessageServerMessageSender) error { + message, err := client.RunUntilMessage(ctx) + if err := checkUnexpectedClose(err); err != nil { + return err + } else if err := checkMessageType(message, "message"); err != nil { + return err + } else if err := checkMessageSender(client.hub, message.Message, senderType, hello); err != nil { + return err + } else { + if err := json.Unmarshal(*message.Message.Data, payload); err != nil { + return err + } + } + if sender != nil { + *sender = message.Message.Sender + } + return nil +} + +func checkReceiveClientMessage(ctx context.Context, client *TestClient, senderType string, hello *HelloServerMessage, payload interface{}) error { + return checkReceiveClientMessageWithSender(ctx, client, senderType, hello, payload, nil) +} + +func checkReceiveClientEvent(ctx context.Context, client *TestClient, eventType string, msg **EventServerMessage) error { + message, err := client.RunUntilMessage(ctx) + if err := checkUnexpectedClose(err); err != nil { + return err + } else if err := checkMessageType(message, "event"); err != nil { + return err + } else if message.Event.Type != eventType { + return fmt.Errorf("Expected \"%s\" event type, got \"%s\"", eventType, message.Event.Type) + } else { + if msg != nil { + *msg = message.Event + } + } + return nil +} + +type TestClient struct { + t *testing.T + hub *Hub + server *httptest.Server + + conn *websocket.Conn + localAddr net.Addr + + messageChan chan []byte + readErrorChan chan error + + publicId string +} + +func NewTestClient(t *testing.T, server *httptest.Server, hub *Hub) *TestClient { + // Reference "hub" to prevent compiler error. + conn, _, err := websocket.DefaultDialer.Dial(getWebsocketUrl(server.URL), nil) + if err != nil { + t.Fatal(err) + } + + messageChan := make(chan []byte) + readErrorChan := make(chan error) + + go func() { + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + readErrorChan <- err + return + } else if messageType != websocket.TextMessage { + t.Fatalf("Expect text message, got %d", messageType) + } + + messageChan <- data + } + }() + + return &TestClient{ + t: t, + hub: hub, + server: server, + + conn: conn, + localAddr: conn.LocalAddr(), + + messageChan: messageChan, + readErrorChan: readErrorChan, + } +} + +func (c *TestClient) CloseWithBye() { + c.SendBye() + c.Close() +} + +func (c *TestClient) Close() { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + c.conn.Close() + + // Drain any entries in the channels to terminate the read goroutine. +loop: + for { + select { + case <-c.readErrorChan: + case <-c.messageChan: + default: + break loop + } + } +} + +func (c *TestClient) WaitForClientRemoved(ctx context.Context) error { + c.hub.mu.Lock() + defer c.hub.mu.Unlock() + for { + found := false + for _, client := range c.hub.clients { + client.mu.Lock() + conn := client.conn + client.mu.Unlock() + if conn != nil && conn.RemoteAddr().String() == c.localAddr.String() { + found = true + break + } + } + if !found { + break + } + + c.hub.mu.Unlock() + select { + case <-ctx.Done(): + return ctx.Err() + default: + time.Sleep(time.Millisecond) + } + c.hub.mu.Lock() + } + return nil +} + +func (c *TestClient) WaitForSessionRemoved(ctx context.Context, sessionId string) error { + data := c.hub.decodeSessionId(sessionId, publicSessionName) + if data == nil { + return fmt.Errorf("Invalid session id passed") + } + + c.hub.mu.Lock() + defer c.hub.mu.Unlock() + for { + _, found := c.hub.sessions[data.Sid] + if !found { + break + } + + c.hub.mu.Unlock() + select { + case <-ctx.Done(): + return ctx.Err() + default: + time.Sleep(time.Millisecond) + } + c.hub.mu.Lock() + } + return nil +} + +func (c *TestClient) WriteJSON(data interface{}) error { + if msg, ok := data.(*ClientMessage); ok { + if err := msg.CheckValid(); err != nil { + return err + } + } + return c.conn.WriteJSON(data) +} + +func (c *TestClient) EnsuerWriteJSON(data interface{}) { + if err := c.WriteJSON(data); err != nil { + c.t.Fatalf("Could not write JSON %+v: %s", data, err) + } +} + +func (c *TestClient) SendHello(userid string) error { + params := TestBackendClientAuthParams{ + UserId: userid, + } + return c.SendHelloParams(c.server.URL, "", params) +} + +func (c *TestClient) SendHelloResume(resumeId string) error { + hello := &ClientMessage{ + Id: "1234", + Type: "hello", + Hello: &HelloClientMessage{ + Version: HelloVersion, + ResumeId: resumeId, + }, + } + return c.WriteJSON(hello) +} + +func (c *TestClient) SendHelloClient(userid string) error { + params := TestBackendClientAuthParams{ + UserId: userid, + } + return c.SendHelloParams(c.server.URL, "client", params) +} + +func (c *TestClient) SendHelloInternal() error { + random := newRandomString(48) + mac := hmac.New(sha256.New, testInternalSecret) + mac.Write([]byte(random)) + token := hex.EncodeToString(mac.Sum(nil)) + + params := ClientTypeInternalAuthParams{ + Random: random, + Token: token, + } + return c.SendHelloParams("", "internal", params) +} + +func (c *TestClient) SendHelloParams(url string, clientType string, params interface{}) error { + data, err := json.Marshal(params) + if err != nil { + c.t.Fatal(err) + } + + hello := &ClientMessage{ + Id: "1234", + Type: "hello", + Hello: &HelloClientMessage{ + Version: HelloVersion, + Auth: HelloClientMessageAuth{ + Type: clientType, + Url: url, + Params: (*json.RawMessage)(&data), + }, + }, + } + return c.WriteJSON(hello) +} + +func (c *TestClient) SendBye() error { + hello := &ClientMessage{ + Id: "9876", + Type: "bye", + Bye: &ByeClientMessage{}, + } + return c.WriteJSON(hello) +} + +func (c *TestClient) SendMessage(recipient MessageClientMessageRecipient, data interface{}) error { + payload, err := json.Marshal(data) + if err != nil { + c.t.Fatal(err) + } + + message := &ClientMessage{ + Id: "abcd", + Type: "message", + Message: &MessageClientMessage{ + Recipient: recipient, + Data: (*json.RawMessage)(&payload), + }, + } + return c.WriteJSON(message) +} + +func (c *TestClient) DrainMessages(ctx context.Context) error { + select { + case err := <-c.readErrorChan: + return err + case <-c.messageChan: + n := len(c.messageChan) + for i := 0; i < n; i++ { + <-c.messageChan + } + case <-ctx.Done(): + return ctx.Err() + } + return nil +} + +func (c *TestClient) RunUntilMessage(ctx context.Context) (message *ServerMessage, err error) { + select { + case err = <-c.readErrorChan: + case msg := <-c.messageChan: + var m ServerMessage + if err = json.Unmarshal(msg, &m); err == nil { + message = &m + } + case <-ctx.Done(): + err = ctx.Err() + } + return +} + +func (c *TestClient) RunUntilHello(ctx context.Context) (message *ServerMessage, err error) { + if message, err = c.RunUntilMessage(ctx); err != nil { + return nil, err + } + if err := checkUnexpectedClose(err); err != nil { + return nil, err + } + if err := checkMessageType(message, "hello"); err != nil { + return nil, err + } + c.publicId = message.Hello.SessionId + return message, nil +} + +func (c *TestClient) JoinRoom(ctx context.Context, roomId string) (message *ServerMessage, err error) { + return c.JoinRoomWithRoomSession(ctx, roomId, roomId+"-"+c.publicId) +} + +func (c *TestClient) JoinRoomWithRoomSession(ctx context.Context, roomId string, roomSessionId string) (message *ServerMessage, err error) { + msg := &ClientMessage{ + Id: "ABCD", + Type: "room", + Room: &RoomClientMessage{ + RoomId: roomId, + SessionId: roomSessionId, + }, + } + if err := c.WriteJSON(msg); err != nil { + return nil, err + } + + if message, err = c.RunUntilMessage(ctx); err != nil { + return nil, err + } + if err := checkUnexpectedClose(err); err != nil { + return nil, err + } + if err := checkMessageType(message, "room"); err != nil { + return nil, err + } + return message, nil +} + +func checkMessageRoomId(message *ServerMessage, roomId string) error { + if err := checkMessageType(message, "room"); err != nil { + return err + } + if message.Room.RoomId != roomId { + return fmt.Errorf("Expected room id %s, got %+v", roomId, message.Room) + } + return nil +} + +func (c *TestClient) RunUntilRoom(ctx context.Context, roomId string) error { + message, err := c.RunUntilMessage(ctx) + if err != nil { + return err + } + if err := checkUnexpectedClose(err); err != nil { + return err + } + return checkMessageRoomId(message, roomId) +} + +func (c *TestClient) checkMessageJoined(message *ServerMessage, hello *HelloServerMessage) error { + if err := checkMessageType(message, "event"); err != nil { + return err + } else if message.Event.Target != "room" { + return fmt.Errorf("Expected event target room, got %+v", message.Event) + } else if message.Event.Type != "join" { + return fmt.Errorf("Expected event type join, got %+v", message.Event) + } else if len(message.Event.Join) != 1 { + return fmt.Errorf("Expected one join event entry, got %+v", message.Event) + } else { + evt := message.Event.Join[0] + if evt.SessionId != hello.SessionId { + return fmt.Errorf("Expected join session id %+v, got %+v", + getPubliceSessionIdData(c.hub, hello.SessionId), getPubliceSessionIdData(c.hub, evt.SessionId)) + } + if evt.UserId != hello.UserId { + return fmt.Errorf("Expected join user id %s, got %+v", hello.UserId, evt) + } + } + return nil +} + +func (c *TestClient) RunUntilJoined(ctx context.Context, hello *HelloServerMessage) error { + if message, err := c.RunUntilMessage(ctx); err != nil { + return err + } else { + return c.checkMessageJoined(message, hello) + } +} + +func (c *TestClient) checkMessageRoomLeave(message *ServerMessage, hello *HelloServerMessage) error { + if err := checkMessageType(message, "event"); err != nil { + return err + } else if message.Event.Target != "room" { + return fmt.Errorf("Expected event target room, got %+v", message.Event) + } else if message.Event.Type != "leave" { + return fmt.Errorf("Expected event type leave, got %+v", message.Event) + } else if len(message.Event.Leave) != 1 { + return fmt.Errorf("Expected one leave event entry, got %+v", message.Event) + } else if message.Event.Leave[0] != hello.SessionId { + return fmt.Errorf("Expected leave session id %+v, got %+v", + getPubliceSessionIdData(c.hub, hello.SessionId), getPubliceSessionIdData(c.hub, message.Event.Leave[0])) + } + return nil +} + +func (c *TestClient) RunUntilLeft(ctx context.Context, hello *HelloServerMessage) error { + if message, err := c.RunUntilMessage(ctx); err != nil { + return err + } else { + return c.checkMessageRoomLeave(message, hello) + } +} + +func checkMessageRoomlistUpdate(message *ServerMessage) (*RoomEventServerMessage, error) { + if err := checkMessageType(message, "event"); err != nil { + return nil, err + } else if message.Event.Target != "roomlist" { + return nil, fmt.Errorf("Expected event target room, got %+v", message.Event) + } else if message.Event.Type != "update" || message.Event.Update == nil { + return nil, fmt.Errorf("Expected event type update, got %+v", message.Event) + } else { + return message.Event.Update, nil + } +} + +func (c *TestClient) RunUntilRoomlistUpdate(ctx context.Context) (*RoomEventServerMessage, error) { + if message, err := c.RunUntilMessage(ctx); err != nil { + return nil, err + } else { + return checkMessageRoomlistUpdate(message) + } +} + +func checkMessageRoomlistDisinvite(message *ServerMessage) (*RoomEventServerMessage, error) { + if err := checkMessageType(message, "event"); err != nil { + return nil, err + } else if message.Event.Target != "roomlist" { + return nil, fmt.Errorf("Expected event target room, got %+v", message.Event) + } else if message.Event.Type != "disinvite" || message.Event.Disinvite == nil { + return nil, fmt.Errorf("Expected event type disinvite, got %+v", message.Event) + } + + return message.Event.Disinvite, nil +} + +func (c *TestClient) RunUntilRoomlistDisinvite(ctx context.Context) (*RoomEventServerMessage, error) { + if message, err := c.RunUntilMessage(ctx); err != nil { + return nil, err + } else { + return checkMessageRoomlistDisinvite(message) + } +} + +func checkMessageParticipantsInCall(message *ServerMessage) (*RoomEventServerMessage, error) { + if err := checkMessageType(message, "event"); err != nil { + return nil, err + } else if message.Event.Target != "participants" { + return nil, fmt.Errorf("Expected event target room, got %+v", message.Event) + } else if message.Event.Type != "update" || message.Event.Update == nil { + return nil, fmt.Errorf("Expected event type incall, got %+v", message.Event) + } + + return message.Event.Update, nil +} + +func checkMessageRoomMessage(message *ServerMessage) (*RoomEventMessage, error) { + if err := checkMessageType(message, "event"); err != nil { + return nil, err + } else if message.Event.Target != "room" { + return nil, fmt.Errorf("Expected event target room, got %+v", message.Event) + } else if message.Event.Type != "message" || message.Event.Message == nil { + return nil, fmt.Errorf("Expected event type message, got %+v", message.Event) + } + + return message.Event.Message, nil +} + +func (c *TestClient) RunUntilRoomMessage(ctx context.Context) (*RoomEventMessage, error) { + if message, err := c.RunUntilMessage(ctx); err != nil { + return nil, err + } else { + return checkMessageRoomMessage(message) + } +} + +func checkMessageError(message *ServerMessage, msgid string) error { + if err := checkMessageType(message, "error"); err != nil { + return err + } else if message.Error.Code != msgid { + return fmt.Errorf("Expected error \"%s\", got \"%s\" (%+v)", msgid, message.Error.Code, message.Error) + } + + return nil +}