mirror of
https://github.com/strukturag/nextcloud-spreed-signaling
synced 2024-05-02 22:03:09 +02:00
Initial commit of the OpenSource version.
This corresponds to nextcloud-spreed-signaling 0.0.13
This commit is contained in:
commit
5dcfeda1e9
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
bin/
|
||||
vendor/
|
||||
|
||||
*_easyjson.go
|
||||
*.prof
|
||||
*.socket
|
||||
*.tar.gz
|
||||
|
||||
cover.out
|
||||
server.conf
|
||||
src/signaling/continentmap.go
|
661
LICENSE
Normal file
661
LICENSE
Normal file
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<http://www.gnu.org/licenses/>.
|
113
Makefile
Normal file
113
Makefile
Normal file
|
@ -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
|
259
README.md
Normal file
259
README.md
Normal file
|
@ -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):
|
||||
|
||||
<VirtualHost *:443>
|
||||
|
||||
# ... 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 ...
|
||||
|
||||
</VirtualHost>
|
||||
|
||||
|
||||
## 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.
|
12
dependencies.tsv
Normal file
12
dependencies.tsv
Normal file
|
@ -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
|
|
10
gnatsd.conf
Normal file
10
gnatsd.conf
Normal file
|
@ -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
|
||||
]
|
||||
|
||||
}
|
21
scripts/get-version.sh
Executable file
21
scripts/get-version.sh
Executable file
|
@ -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
|
91
scripts/get_continent_map.py
Executable file
91
scripts/get_continent_map.py
Executable file
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/python
|
||||
#
|
||||
# Standalone signaling server for the Nextcloud Spreed app.
|
||||
# Copyright (C) 2019 struktur AG
|
||||
#
|
||||
# @author Joachim Bauch <bauch@struktur.de>
|
||||
#
|
||||
# @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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
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 <filename>\n' % (sys.argv[0]))
|
||||
sys.exit(1)
|
||||
|
||||
filename = sys.argv[1]
|
||||
generate_map(filename)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
29
scripts/pre-commit.hook
Executable file
29
scripts/pre-commit.hook
Executable file
|
@ -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
|
112
server.conf.in
Normal file
112
server.conf.in
Normal file
|
@ -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 =
|
629
src/client/main.go
Normal file
629
src/client/main.go
Normal file
|
@ -0,0 +1,629 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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()
|
||||
}
|
295
src/server/main.go
Normal file
295
src/server/main.go
Normal file
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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")
|
||||
}
|
||||
}
|
272
src/signaling/api_backend.go
Normal file
272
src/signaling/api_backend.go
Normal file
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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"`
|
||||
}
|
58
src/signaling/api_backend_test.go
Normal file
58
src/signaling/api_backend_test.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
439
src/signaling/api_signaling.go
Normal file
439
src/signaling/api_signaling.go
Normal file
|
@ -0,0 +1,439 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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"`
|
||||
}
|
340
src/signaling/api_signaling_test.go
Normal file
340
src/signaling/api_signaling_test.go
Normal file
|
@ -0,0 +1,340 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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")
|
||||
}
|
||||
}
|
387
src/signaling/backend_client.go
Normal file
387
src/signaling/backend_client.go
Normal file
|
@ -0,0 +1,387 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
168
src/signaling/backend_client_test.go
Normal file
168
src/signaling/backend_client_test.go
Normal file
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
526
src/signaling/backend_server.go
Normal file
526
src/signaling/backend_server.go
Normal file
|
@ -0,0 +1,526 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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("{}"))
|
||||
}
|
1268
src/signaling/backend_server_test.go
Normal file
1268
src/signaling/backend_server_test.go
Normal file
File diff suppressed because it is too large
Load diff
543
src/signaling/client.go
Normal file
543
src/signaling/client.go
Normal file
|
@ -0,0 +1,543 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
715
src/signaling/clientsession.go
Normal file
715
src/signaling/clientsession.go
Normal file
|
@ -0,0 +1,715 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
120
src/signaling/clientsession_test.go
Normal file
120
src/signaling/clientsession_test.go
Normal file
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
65
src/signaling/concurrentmap.go
Normal file
65
src/signaling/concurrentmap.go
Normal file
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
125
src/signaling/concurrentmap_test.go
Normal file
125
src/signaling/concurrentmap_test.go
Normal file
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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())
|
||||
}
|
||||
}
|
98
src/signaling/deferred_executor.go
Normal file
98
src/signaling/deferred_executor.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2020 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
110
src/signaling/deferred_executor_test.go
Normal file
110
src/signaling/deferred_executor_test.go
Normal file
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2020 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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")
|
||||
})
|
||||
}
|
185
src/signaling/geoip.go
Normal file
185
src/signaling/geoip.go
Normal file
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
118
src/signaling/geoip_test.go
Normal file
118
src/signaling/geoip_test.go
Normal file
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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()
|
||||
}
|
1453
src/signaling/hub.go
Normal file
1453
src/signaling/hub.go
Normal file
File diff suppressed because it is too large
Load diff
2021
src/signaling/hub_test.go
Normal file
2021
src/signaling/hub_test.go
Normal file
File diff suppressed because it is too large
Load diff
853
src/signaling/janus_client.go
Normal file
853
src/signaling/janus_client.go
Normal file
|
@ -0,0 +1,853 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
113
src/signaling/lru.go
Normal file
113
src/signaling/lru.go
Normal file
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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()
|
||||
}
|
163
src/signaling/lru_test.go
Normal file
163
src/signaling/lru_test.go
Normal file
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
69
src/signaling/mcu_common.go
Normal file
69
src/signaling/mcu_common.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
1098
src/signaling/mcu_janus.go
Normal file
1098
src/signaling/mcu_janus.go
Normal file
File diff suppressed because it is too large
Load diff
50
src/signaling/mcu_test.go
Normal file
50
src/signaling/mcu_test.go
Normal file
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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")
|
||||
}
|
161
src/signaling/natsclient.go
Normal file
161
src/signaling/natsclient.go
Normal file
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
251
src/signaling/natsclient_loopback.go
Normal file
251
src/signaling/natsclient_loopback.go
Normal file
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
222
src/signaling/natsclient_loopback_test.go
Normal file
222
src/signaling/natsclient_loopback_test.go
Normal file
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2018 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
}
|
62
src/signaling/pool.go
Normal file
62
src/signaling/pool.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
63
src/signaling/pool_test.go
Normal file
63
src/signaling/pool_test.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
600
src/signaling/room.go
Normal file
600
src/signaling/room.go
Normal file
|
@ -0,0 +1,600 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
362
src/signaling/room_test.go
Normal file
362
src/signaling/room_test.go
Normal file
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
37
src/signaling/roomsessions.go
Normal file
37
src/signaling/roomsessions.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
77
src/signaling/roomsessions_builtin.go
Normal file
77
src/signaling/roomsessions_builtin.go
Normal file
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
35
src/signaling/roomsessions_builtin_test.go
Normal file
35
src/signaling/roomsessions_builtin_test.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package signaling
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuiltinRoomSessions(t *testing.T) {
|
||||
sessions, err := NewBuiltinRoomSessions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testRoomSessions(t, sessions)
|
||||
}
|
140
src/signaling/roomsessions_test.go
Normal file
140
src/signaling/roomsessions_test.go
Normal file
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
63
src/signaling/session.go
Normal file
63
src/signaling/session.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
38
src/signaling/session_test.go
Normal file
38
src/signaling/session_test.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2019 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
661
src/signaling/testclient_test.go
Normal file
661
src/signaling/testclient_test.go
Normal file
|
@ -0,0 +1,661 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
Loading…
Reference in a new issue