pax_global_header00006660000000000000000000000064147707247700014531gustar00rootroot0000000000000052 comment=fb0ff07f0ee6a377b081719649884125d527eb78 soju-0.9.0/000077500000000000000000000000001477072477000125175ustar00rootroot00000000000000soju-0.9.0/.build.yml000066400000000000000000000010241477072477000144140ustar00rootroot00000000000000image: alpine/latest packages: - go - scdoc - postgresql sources: - https://codeberg.org/emersion/soju.git tasks: - build: | cd soju go build -v ./... scdoc /dev/null - setup-postgresql: | sudo /etc/init.d/postgresql start sudo -u postgres -- createuser "$USER" sudo -u postgres -- createdb soju - test: | cd soju export SOJU_TEST_POSTGRES="host=/run/postgresql dbname=soju" go test -v ./... - gofmt: | cd soju test -z $(gofmt -l .) soju-0.9.0/.editorconfig000066400000000000000000000002441477072477000151740ustar00rootroot00000000000000root = true [*] charset = utf-8 end_of_line = lf indent_style = tab insert_final_newline = true trim_trailing_whitespace = true [*.{md,scd}] max_line_length = 80 soju-0.9.0/.gitignore000066400000000000000000000000731477072477000145070ustar00rootroot00000000000000/soju /sojuctl /sojudb /soju.db /doc/soju.1 /doc/sojuctl.1 soju-0.9.0/LICENSE000066400000000000000000001033331477072477000135270ustar00rootroot00000000000000 GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . soju-0.9.0/Makefile000066400000000000000000000023221477072477000141560ustar00rootroot00000000000000GO ?= go RM ?= rm SCDOC ?= scdoc GOFLAGS ?= PREFIX ?= /usr/local BINDIR ?= bin MANDIR ?= share/man SYSCONFDIR ?= /etc RUNDIR ?= /run sharedstatedir := /var/lib config_path := $(SYSCONFDIR)/soju/config admin_socket_path := $(RUNDIR)/soju/admin goldflags := -X 'codeberg.org/emersion/soju/config.DefaultPath=$(config_path)' \ -X 'codeberg.org/emersion/soju/config.DefaultUnixAdminPath=$(admin_socket_path)' goflags := $(GOFLAGS) -ldflags="$(goldflags)" commands := soju sojuctl sojudb man_pages := doc/soju.1 doc/sojuctl.1 all: $(commands) $(man_pages) soju: $(GO) build $(goflags) -o . ./cmd/soju ./cmd/sojudb ./cmd/sojuctl sojudb sojuctl: soju doc/soju.1: doc/soju.1.scd $(SCDOC) doc/soju.1 doc/sojuctl.1: doc/sojuctl.1.scd $(SCDOC) doc/sojuctl.1 clean: $(RM) -f $(commands) $(man_pages) install: mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR) mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR)/man1 mkdir -p $(DESTDIR)$(SYSCONFDIR)/soju mkdir -p $(DESTDIR)$(sharedstatedir)/soju cp -f $(commands) $(DESTDIR)$(PREFIX)/$(BINDIR) cp -f $(man_pages) $(DESTDIR)$(PREFIX)/$(MANDIR)/man1 [ -f $(DESTDIR)$(config_path) ] || cp -f config.in $(DESTDIR)$(config_path) .PHONY: soju sojudb sojuctl clean install soju-0.9.0/README.md000066400000000000000000000027151477072477000140030ustar00rootroot00000000000000# [soju] soju is a user-friendly IRC bouncer. soju connects to upstream IRC servers on behalf of the user to provide extra functionality. soju supports many features such as multiple users, numerous [IRCv3] extensions, chat history playback and detached channels. It is well-suited for both small and large deployments. ## Usage * [Getting started] * [Man page] ## Building and installing Dependencies: - Go - BSD or GNU make - a C89 compiler (optional, for SQLite) - scdoc (optional, for man pages) For end users, a `Makefile` is provided: make sudo make install For development, you can use `go run ./cmd/soju` as usual. To link with the system libsqlite3, set `GOFLAGS="-tags=libsqlite3"`. To disable SQLite support, set `GOFLAGS="-tags=nosqlite"`. To use an alternative SQLite library that does not require CGO, set `GOFLAGS="-tags=moderncsqlite"`. To build with PAM authentication support, set `GOFLAGS="-tags=pam"`. ## Contributing Send patches on [Codeberg] or on [GitHub], report bugs on the [issue tracker]. Discuss in [#soju on Libera Chat][IRC channel]. ## License AGPLv3, see LICENSE. Copyright (C) 2020 The soju Contributors [soju]: https://soju.im [Getting started]: doc/getting-started.md [Man page]: https://soju.im/doc/soju.1.html [Codeberg]: https://codeberg.org/emersion/soju [GitHub]: https://github.com/emersion/soju [issue tracker]: https://todo.sr.ht/~emersion/soju [IRC channel]: ircs://irc.libera.chat/#soju [IRCv3]: https://ircv3.net/ soju-0.9.0/auth/000077500000000000000000000000001477072477000134605ustar00rootroot00000000000000soju-0.9.0/auth/auth.go000066400000000000000000000035671477072477000147630ustar00rootroot00000000000000package auth import ( "context" "fmt" "codeberg.org/emersion/soju/database" ) type Authenticator struct { Plain PlainAuthenticator OAuthBearer OAuthBearerAuthenticator } type PlainAuthenticator interface { AuthPlain(ctx context.Context, db database.Database, username, password string) error } type OAuthBearerAuthenticator interface { AuthOAuthBearer(ctx context.Context, db database.Database, token string) (username string, err error) } type OAuthPlainAuthenticator struct { OAuthBearer OAuthBearerAuthenticator } func (auth OAuthPlainAuthenticator) AuthPlain(ctx context.Context, db database.Database, username, password string) error { effectiveUsername, err := auth.OAuthBearer.AuthOAuthBearer(ctx, db, password) if err != nil { return err } if username != effectiveUsername { return newInvalidCredentialsError(fmt.Errorf("username mismatch (OAuth 2.0 server returned %q)", effectiveUsername)) } return nil } func New(driver, source string) (*Authenticator, error) { switch driver { case "internal": return NewInternal(), nil case "http": return newHTTP(source) case "oauth2": return newOAuth2(source) case "pam": return newPAM() default: return nil, fmt.Errorf("unknown auth driver %q", driver) } } // Error is an authentication error. type Error struct { // Internal error cause. This will not be revealed to the user. InternalErr error // Message which can safely be sent to the user without compromising // security. ExternalMsg string } func (err *Error) Error() string { return err.InternalErr.Error() } func (err *Error) Unwrap() error { return err.InternalErr } // newInvalidCredentialsError wraps the provided error into an Error and // indicates to the user that the provided credentials were invalid. func newInvalidCredentialsError(err error) *Error { return &Error{ InternalErr: err, ExternalMsg: "Invalid credentials", } } soju-0.9.0/auth/http.go000066400000000000000000000020071477072477000147650ustar00rootroot00000000000000package auth import ( "context" "errors" "fmt" "net/http" "codeberg.org/emersion/soju/database" ) type httpAuth struct { url string } var ( _ PlainAuthenticator = (*httpAuth)(nil) ) func newHTTP(url string) (*Authenticator, error) { return &Authenticator{ Plain: &httpAuth{ url: url, }, }, nil } func (auth *httpAuth) AuthPlain(ctx context.Context, db database.Database, username, password string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, auth.url, nil) if err != nil { return fmt.Errorf("failed to create HTTP auth request: %v", err) } req.SetBasicAuth(username, password) resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("failed to send HTTP auth request: %v", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusForbidden { return newInvalidCredentialsError(errors.New("HTTP auth server returned forbidden")) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP auth error: %v", resp.Status) } return nil } soju-0.9.0/auth/internal.go000066400000000000000000000011741477072477000156260ustar00rootroot00000000000000package auth import ( "context" "fmt" "codeberg.org/emersion/soju/database" ) type internal struct{} func NewInternal() *Authenticator { return &Authenticator{ Plain: internal{}, } } func (internal) AuthPlain(ctx context.Context, db database.Database, username, password string) error { u, err := db.GetUser(ctx, username) if err != nil { return newInvalidCredentialsError(fmt.Errorf("user not found: %w", err)) } upgraded, err := u.CheckPassword(password) if err != nil { return newInvalidCredentialsError(err) } if upgraded { if err := db.StoreUser(ctx, u); err != nil { return err } } return nil } soju-0.9.0/auth/oauth2.go000066400000000000000000000113261477072477000152140ustar00rootroot00000000000000package auth import ( "context" "encoding/json" "fmt" "net/http" "net/url" "path" "strings" "time" "codeberg.org/emersion/soju/database" ) type oauth2 struct { introspectionURL *url.URL clientID string clientSecret string } var ( _ OAuthBearerAuthenticator = (*oauth2)(nil) ) func newOAuth2(authURL string) (*Authenticator, error) { ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second) defer cancel() u, err := url.Parse(authURL) if err != nil { return nil, fmt.Errorf("failed to parse OAuth 2.0 server URL: %v", err) } var clientID, clientSecret string if u.User != nil { clientID = u.User.Username() clientSecret, _ = u.User.Password() } discoveryURL := *u discoveryURL.User = nil discoveryURL.Path = path.Join("/.well-known/oauth-authorization-server", u.Path) server, err := discoverOAuth2(ctx, discoveryURL.String()) if err != nil { return nil, fmt.Errorf("OAuth 2.0 discovery failed: %v", err) } if server.IntrospectionEndpoint == "" { return nil, fmt.Errorf("OAuth 2.0 server doesn't support token introspection") } introspectionURL, err := url.Parse(server.IntrospectionEndpoint) if err != nil { return nil, fmt.Errorf("failed to parse OAuth 2.0 introspection URL") } if server.IntrospectionEndpointAuthMethodsSupported != nil { var supportsNone, supportsBasic bool for _, name := range server.IntrospectionEndpointAuthMethodsSupported { switch name { case "none": supportsNone = true case "client_secret_basic": supportsBasic = true } } if clientID == "" && !supportsNone { return nil, fmt.Errorf("OAuth 2.0 server requires authentication for introspection") } if clientID != "" && !supportsBasic { return nil, fmt.Errorf("OAuth 2.0 server doesn't support Basic HTTP authentication for introspection") } } return &Authenticator{ OAuthBearer: &oauth2{ introspectionURL: introspectionURL, clientID: clientID, clientSecret: clientSecret, }, }, nil } func (auth *oauth2) AuthOAuthBearer(ctx context.Context, db database.Database, token string) (username string, err error) { reqValues := make(url.Values) reqValues.Set("token", token) reqBody := strings.NewReader(reqValues.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodPost, auth.introspectionURL.String(), reqBody) if err != nil { return "", fmt.Errorf("failed to create OAuth 2.0 introspection request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") if auth.clientID != "" { req.SetBasicAuth(url.QueryEscape(auth.clientID), url.QueryEscape(auth.clientSecret)) } resp, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("failed to send OAuth 2.0 introspection request: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("OAuth 2.0 introspection error: %v", resp.Status) } var data oauth2Introspection if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return "", fmt.Errorf("failed to decode OAuth 2.0 introspection response: %v", err) } if !data.Active { return "", newInvalidCredentialsError(fmt.Errorf("invalid access token")) } if data.Username == "" { // We really need the username here, otherwise an OAuth 2.0 user can // impersonate any other user. return "", fmt.Errorf("missing username in OAuth 2.0 introspection response") } return data.Username, nil } type oauth2Introspection struct { Active bool `json:"active"` Username string `json:"username"` } type oauth2Server struct { Issuer string `json:"issuer"` IntrospectionEndpoint string `json:"introspection_endpoint"` IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported"` } type oauth2HTTPError string func (err oauth2HTTPError) Error() string { return fmt.Sprintf("OAuth 2.0 HTTP error: %v", string(err)) } func discoverOAuth2(ctx context.Context, discoveryURL string) (*oauth2Server, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %v", err) } req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, oauth2HTTPError(resp.Status) } var data oauth2Server if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, fmt.Errorf("failed to decode response: %v", err) } if data.Issuer == "" { return nil, fmt.Errorf("missing issuer in response") } return &data, nil } soju-0.9.0/auth/pam.go000066400000000000000000000025151477072477000145670ustar00rootroot00000000000000//go:build pam package auth import ( "context" "fmt" "github.com/msteinert/pam/v2" "codeberg.org/emersion/soju/database" ) type pamAuth struct{} var ( _ PlainAuthenticator = (*pamAuth)(nil) ) func newPAM() (*Authenticator, error) { return &Authenticator{ Plain: pamAuth{}, }, nil } func (pamAuth) AuthPlain(ctx context.Context, db database.Database, username, password string) error { t, err := pam.StartFunc("login", username, func(s pam.Style, msg string) (string, error) { switch s { case pam.PromptEchoOff: return password, nil case pam.PromptEchoOn, pam.ErrorMsg, pam.TextInfo: return "", nil default: return "", fmt.Errorf("unsupported PAM conversation style: %v", s) } }) if err != nil { return fmt.Errorf("failed to start PAM conversation: %v", err) } defer t.End() if err := t.Authenticate(0); err != nil { return newInvalidCredentialsError(fmt.Errorf("PAM auth error: %v", err)) } if err := t.AcctMgmt(0); err != nil { return fmt.Errorf("PAM account unavailable: %v", err) } user, err := t.GetItem(pam.User) if err != nil { return fmt.Errorf("failed to get PAM user: %v", err) } else if user != username { return fmt.Errorf("PAM user doesn't match supplied username") } if err := t.End(); err != nil { return fmt.Errorf("failed to end PAM conversation: %v", err) } return nil } soju-0.9.0/auth/pam_stub.go000066400000000000000000000002221477072477000156150ustar00rootroot00000000000000//go:build !pam package auth import ( "errors" ) func newPAM() (*Authenticator, error) { return nil, errors.New("PAM support is disabled") } soju-0.9.0/certfp.go000066400000000000000000000033761477072477000143420ustar00rootroot00000000000000package soju import ( "crypto" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "math/big" "time" ) func generateCertFP(keyType string, bits int) (privKeyBytes, certBytes []byte, err error) { var ( privKey crypto.PrivateKey pubKey crypto.PublicKey ) switch keyType { case "rsa": key, err := rsa.GenerateKey(rand.Reader, bits) if err != nil { return nil, nil, err } privKey = key pubKey = key.Public() case "ecdsa": key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) if err != nil { return nil, nil, err } privKey = key pubKey = key.Public() case "ed25519": var err error pubKey, privKey, err = ed25519.GenerateKey(rand.Reader) if err != nil { return nil, nil, err } } // Using PKCS#8 allows easier extension for new key types. privKeyBytes, err = x509.MarshalPKCS8PrivateKey(privKey) if err != nil { return nil, nil, err } notBefore := time.Now() // Lets make a fair assumption nobody will use the same cert for more than 20 years... notAfter := notBefore.Add(24 * time.Hour * 365 * 20) serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { return nil, nil, err } cert := &x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{CommonName: "soju auto-generated certificate"}, NotBefore: notBefore, NotAfter: notAfter, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, } certBytes, err = x509.CreateCertificate(rand.Reader, cert, cert, pubKey, privKey) if err != nil { return nil, nil, err } return privKeyBytes, certBytes, nil } soju-0.9.0/cmd/000077500000000000000000000000001477072477000132625ustar00rootroot00000000000000soju-0.9.0/cmd/soju/000077500000000000000000000000001477072477000142425ustar00rootroot00000000000000soju-0.9.0/cmd/soju/main.go000066400000000000000000000337761477072477000155350ustar00rootroot00000000000000package main import ( "context" "crypto/tls" "flag" "fmt" "io/ioutil" "log" "net" "net/http" _ "net/http/pprof" "net/url" "os" "os/signal" "strings" "sync/atomic" "syscall" "time" "github.com/pires/go-proxyproto" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "codeberg.org/emersion/soju" "codeberg.org/emersion/soju/auth" "codeberg.org/emersion/soju/config" "codeberg.org/emersion/soju/database" "codeberg.org/emersion/soju/fileupload" "codeberg.org/emersion/soju/identd" ) // TCP keep-alive interval for downstream TCP connections const downstreamKeepAlive = 1 * time.Hour type stringSliceFlag []string func (v *stringSliceFlag) String() string { return fmt.Sprint([]string(*v)) } func (v *stringSliceFlag) Set(s string) error { *v = append(*v, s) return nil } var ( configPath string debug bool tlsCert atomic.Value // *tls.Certificate ) func loadConfig() (*config.Server, *soju.Config, error) { var raw *config.Server if configPath != "" { var err error raw, err = config.Load(configPath) if err != nil { return nil, nil, fmt.Errorf("failed to load config file: %v", err) } } else { raw = config.Defaults() } // A hostname without a dot can confuse clients if !strings.Contains(raw.Hostname, ".") { log.Printf("warning: hostname %q is not a fully qualified domain name", raw.Hostname) } var motd string if raw.MOTDPath != "" { b, err := ioutil.ReadFile(raw.MOTDPath) if err != nil { return nil, nil, fmt.Errorf("failed to load MOTD: %v", err) } motd = strings.TrimSuffix(string(b), "\n") } var authenticator auth.Authenticator for _, authCfg := range raw.Auth { a, err := auth.New(authCfg.Driver, authCfg.Source) if err != nil { return nil, nil, fmt.Errorf("failed to create authenticator %v: %v", authCfg.Driver, err) } if a.Plain != nil { if authenticator.Plain != nil { return nil, nil, fmt.Errorf("failed to load authenticators: multiple plain authentication methods specified") } authenticator.Plain = a.Plain } if a.OAuthBearer != nil { if authenticator.OAuthBearer != nil { return nil, nil, fmt.Errorf("failed to load authenticators: multiple OAuth authentication methods specified") } authenticator.OAuthBearer = a.OAuthBearer } } if authenticator.OAuthBearer != nil && authenticator.Plain == nil { authenticator.Plain = auth.OAuthPlainAuthenticator{ OAuthBearer: authenticator.OAuthBearer, } } if raw.TLS != nil { cert, err := tls.LoadX509KeyPair(raw.TLS.CertPath, raw.TLS.KeyPath) if err != nil { return nil, nil, fmt.Errorf("failed to load TLS certificate and key: %v", err) } tlsCert.Store(&cert) } var fileUploader fileupload.Uploader if raw.FileUpload != nil { var err error fileUploader, err = fileupload.New(raw.FileUpload.Driver, raw.FileUpload.Source) if err != nil { return nil, nil, fmt.Errorf("failed to create file uploader: %v", err) } } cfg := &soju.Config{ Hostname: raw.Hostname, Title: raw.Title, MsgStoreDriver: raw.MsgStore.Driver, MsgStorePath: raw.MsgStore.Source, HTTPOrigins: raw.HTTPOrigins, HTTPIngress: raw.HTTPIngress, AcceptProxyIPs: raw.AcceptProxyIPs, MaxUserNetworks: raw.MaxUserNetworks, UpstreamUserIPs: raw.UpstreamUserIPs, DisableInactiveUsersDelay: raw.DisableInactiveUsersDelay, EnableUsersOnAuth: raw.EnableUsersOnAuth, MOTD: motd, Auth: &authenticator, FileUploader: fileUploader, } return raw, cfg, nil } func main() { var listen []string flag.Var((*stringSliceFlag)(&listen), "listen", "listening address") flag.StringVar(&configPath, "config", config.DefaultPath, "path to configuration file") flag.BoolVar(&debug, "debug", false, "enable debug logging") flag.Parse() cfg, serverCfg, err := loadConfig() if err != nil { log.Fatal(err) } cfg.Listen = append(cfg.Listen, listen...) if len(cfg.Listen) == 0 { cfg.Listen = []string{":6697"} } db, err := database.Open(cfg.DB.Driver, cfg.DB.Source) if err != nil { log.Fatalf("failed to open database: %v", err) } var tlsCfg *tls.Config if cfg.TLS != nil { tlsCfg = &tls.Config{ GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return tlsCert.Load().(*tls.Certificate), nil }, } } srv := soju.NewServer(db) srv.SetConfig(serverCfg) srv.Logger = soju.NewLogger(log.Writer(), debug) fileUploadHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cfg := srv.Config() h := fileupload.Handler{ Uploader: cfg.FileUploader, DB: db, Auth: cfg.Auth, HTTPOrigins: cfg.HTTPOrigins, } h.ServeHTTP(w, r) }) httpMux := http.NewServeMux() httpMux.Handle("/socket", srv) httpMux.Handle("/uploads", fileUploadHandler) httpMux.Handle("/uploads/", fileUploadHandler) var httpServers []*http.Server for _, listen := range cfg.Listen { listen := listen // copy listenURI := listen if !strings.Contains(listenURI, ":/") { // This is a raw domain name, make it an URL with an empty scheme listenURI = "//" + listenURI } u, err := url.Parse(listenURI) if err != nil { log.Fatalf("failed to parse listen URI %q: %v", listen, err) } switch u.Scheme { case "ircs", "": if tlsCfg == nil { log.Fatalf("failed to listen on %q: missing TLS configuration", listen) } addr := withDefaultPort(u.Host, "6697") ircsTLSCfg := tlsCfg.Clone() ircsTLSCfg.NextProtos = []string{"irc"} lc := net.ListenConfig{ KeepAlive: downstreamKeepAlive, } l, err := lc.Listen(context.Background(), "tcp", addr) if err != nil { log.Fatalf("failed to start TLS listener on %q: %v", listen, err) } ln := tls.NewListener(l, ircsTLSCfg) ln = proxyProtoListener(ln, srv) go func() { if err := srv.Serve(ln, srv.Handle); err != nil { log.Printf("serving %q: %v", listen, err) } }() case "irc": if u.Hostname() != "localhost" { log.Fatalf("Plain-text IRC listening host must be localhost unless marked as insecure") } fallthrough case "irc+insecure": addr := withDefaultPort(u.Host, "6667") lc := net.ListenConfig{ KeepAlive: downstreamKeepAlive, } ln, err := lc.Listen(context.Background(), "tcp", addr) if err != nil { log.Fatalf("failed to start listener on %q: %v", listen, err) } ln = proxyProtoListener(ln, srv) go func() { if err := srv.Serve(ln, srv.Handle); err != nil { log.Printf("serving %q: %v", listen, err) } }() case "unix": ln, err := net.Listen("unix", u.Host+u.Path) if err != nil { log.Fatalf("failed to start listener on %q: %v", listen, err) } ln = proxyProtoListener(ln, srv) if err := os.Chmod(u.Path, 0775); err != nil { log.Printf("failed to chmod Unix IRC socket: %v", err) } go func() { if err := srv.Serve(ln, srv.Handle); err != nil { log.Printf("serving %q: %v", listen, err) } }() case "unix+admin": path := u.Host + u.Path if path == "" { path = config.DefaultUnixAdminPath } ln, err := net.Listen("unix", path) if err != nil { log.Fatalf("failed to start listener on %q: %v", listen, err) } ln = proxyProtoListener(ln, srv) // TODO: this is racy if err := os.Chmod(path, 0600); err != nil { log.Fatalf("failed to chmod Unix admin socket: %v", err) } go func() { if err := srv.Serve(ln, srv.HandleAdmin); err != nil { log.Printf("serving %q: %v", listen, err) } }() case "wss": if tlsCfg == nil { log.Fatalf("failed to listen on %q: missing TLS configuration", listen) } httpSrv := &http.Server{ Addr: withDefaultPort(u.Host, "https"), TLSConfig: tlsCfg, Handler: srv, } httpServers = append(httpServers, httpSrv) go func() { if err := httpSrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { log.Fatalf("serving %q: %v", listen, err) } }() case "ws": if u.Hostname() != "localhost" { log.Fatalf("Plain-text WebSocket listening host must be localhost unless marked as insecure") } fallthrough case "ws+insecure": httpSrv := &http.Server{ Addr: withDefaultPort(u.Host, "http"), Handler: srv, } httpServers = append(httpServers, httpSrv) go func() { if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("serving %q: %v", listen, err) } }() case "ws+unix": ln, err := net.Listen("unix", u.Path) if err != nil { log.Fatalf("failed to start listener on %q: %v", listen, err) } if err := os.Chmod(u.Path, 0775); err != nil { log.Printf("failed to chmod Unix WS socket: %v", err) } httpSrv := &http.Server{Handler: srv} httpServers = append(httpServers, httpSrv) go func() { if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { log.Fatalf("serving %q: %v", listen, err) } }() case "ident": if srv.Identd == nil { srv.Identd = identd.New() } addr := withDefaultPort(u.Host, "113") ln, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("failed to start listener on %q: %v", listen, err) } ln = proxyProtoListener(ln, srv) ln = soju.NewRetryListener(ln) go func() { if err := srv.Identd.Serve(ln); err != nil { log.Printf("serving %q: %v", listen, err) } }() case "http+prometheus": if srv.MetricsRegistry == nil { srv.MetricsRegistry = prometheus.DefaultRegisterer } // Only allow localhost as listening host for security reasons. // Users can always explicitly setup reverse proxies if desirable. hostname, _, err := net.SplitHostPort(u.Host) if err != nil { log.Fatalf("invalid host in URI %q: %v", listen, err) } else if hostname != "localhost" { log.Fatalf("Prometheus listening host must be localhost") } metricsHandler := promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{ MaxRequestsInFlight: 10, Timeout: 10 * time.Second, EnableOpenMetrics: true, }) metricsHandler = promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, metricsHandler) httpSrv := http.Server{ Addr: u.Host, Handler: metricsHandler, } go func() { if err := httpSrv.ListenAndServe(); err != nil { log.Fatalf("serving %q: %v", listen, err) } }() case "http+pprof": // Only allow localhost as listening host for security reasons. // Users can always explicitly setup reverse proxies if desirable. hostname, _, err := net.SplitHostPort(u.Host) if err != nil { log.Fatalf("invalid host in URI %q: %v", listen, err) } else if hostname != "localhost" { log.Fatalf("pprof listening host must be localhost") } // net/http/pprof registers its handlers in http.DefaultServeMux httpSrv := http.Server{ Addr: u.Host, Handler: http.DefaultServeMux, } go func() { if err := httpSrv.ListenAndServe(); err != nil { log.Fatalf("serving %q: %v", listen, err) } }() case "https": if tlsCfg == nil { log.Fatalf("failed to listen on %q: missing TLS configuration", listen) } httpSrv := &http.Server{ Addr: withDefaultPort(u.Host, "https"), TLSConfig: tlsCfg, Handler: httpMux, } httpServers = append(httpServers, httpSrv) go func() { if err := httpSrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { log.Fatalf("serving %q: %v", listen, err) } }() case "http": if u.Hostname() != "localhost" { log.Fatalf("Plain-text HTTP listening host must be localhost unless marked as insecure") } fallthrough case "http+insecure": httpSrv := &http.Server{ Addr: withDefaultPort(u.Host, "http"), Handler: httpMux, } httpServers = append(httpServers, httpSrv) go func() { if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("serving %q: %v", listen, err) } }() case "http+unix": ln, err := net.Listen("unix", u.Path) if err != nil { log.Fatalf("failed to start listener on %q: %v", listen, err) } if err := os.Chmod(u.Path, 0775); err != nil { log.Printf("failed to chmod Unix HTTP socket: %v", err) } httpSrv := &http.Server{Handler: httpMux} httpServers = append(httpServers, httpSrv) go func() { if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { log.Fatalf("serving %q: %v", listen, err) } }() default: log.Fatalf("failed to listen on %q: unsupported scheme", listen) } log.Printf("server listening on %q", listen) } if db, ok := db.(database.MetricsCollectorDatabase); ok && srv.MetricsRegistry != nil { if err := db.RegisterMetrics(srv.MetricsRegistry); err != nil { log.Fatalf("failed to register database metrics: %v", err) } } sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) if err := srv.Start(); err != nil { log.Fatal(err) } for sig := range sigCh { switch sig { case syscall.SIGHUP: log.Print("reloading configuration") _, serverCfg, err := loadConfig() if err != nil { log.Printf("failed to reloading configuration: %v", err) } else { srv.SetConfig(serverCfg) } case syscall.SIGINT, syscall.SIGTERM: for _, httpSrv := range httpServers { if err := httpSrv.Close(); err != nil { log.Printf("failed to close HTTP server: %v", err) } } srv.Shutdown() return } } } func proxyProtoListener(ln net.Listener, srv *soju.Server) net.Listener { return &proxyproto.Listener{ Listener: ln, Policy: func(upstream net.Addr) (proxyproto.Policy, error) { tcpAddr, ok := upstream.(*net.TCPAddr) if !ok { return proxyproto.IGNORE, nil } if srv.Config().AcceptProxyIPs.Contains(tcpAddr.IP) { return proxyproto.USE, nil } return proxyproto.IGNORE, nil }, ReadHeaderTimeout: 5 * time.Second, } } func withDefaultPort(addr, port string) string { if _, _, err := net.SplitHostPort(addr); err != nil { addr += ":" + port } return addr } soju-0.9.0/cmd/sojuctl/000077500000000000000000000000001477072477000147455ustar00rootroot00000000000000soju-0.9.0/cmd/sojuctl/main.go000066400000000000000000000040221477072477000162160ustar00rootroot00000000000000package main import ( "context" "flag" "fmt" "gopkg.in/irc.v4" "log" "net" "net/url" "strconv" "strings" "codeberg.org/emersion/soju/config" ) const usage = `usage: sojuctl [-config path] ` func init() { log.SetFlags(0) flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), usage) } } func run(ctx context.Context, cfg *config.Server, words []string) error { var path string for _, listen := range cfg.Listen { u, err := url.Parse(listen) if err != nil { continue } if u.Scheme != "unix+admin" { continue } path = u.Host + u.Path if path == "" { path = config.DefaultUnixAdminPath } break } if path == "" { return fmt.Errorf("no listen unix+admin directive found in config") } var d net.Dialer uc, err := d.DialContext(ctx, "unix", path) if err != nil { return fmt.Errorf("dial %v: %v", path, err) } defer uc.Close() c := irc.NewConn(uc) if err := c.WriteMessage(&irc.Message{ Command: "BOUNCERSERV", Params: []string{quoteWords(words)}, }); err != nil { return fmt.Errorf("write: %v", err) } for { m, err := c.ReadMessage() if err != nil { return fmt.Errorf("read: %v", err) } switch m.Command { case "PRIVMSG": fmt.Println(m.Trailing()) case "BOUNCERSERV": if m.Param(0) == "OK" { return nil } return fmt.Errorf(m.Trailing()) default: return fmt.Errorf(m.Trailing()) } } } func main() { var configPath string flag.StringVar(&configPath, "config", config.DefaultPath, "path to configuration file") flag.Parse() var cfg *config.Server if configPath != "" { var err error cfg, err = config.Load(configPath) if err != nil { log.Fatalf("failed to load config file: %v", err) } } else { cfg = config.Defaults() } ctx := context.Background() if err := run(ctx, cfg, flag.Args()); err != nil { log.Fatalln(err) } } func quoteWords(words []string) string { var s strings.Builder for _, word := range words { if s.Len() > 0 { s.WriteRune(' ') } s.WriteString(strconv.Quote(word)) } return s.String() } soju-0.9.0/cmd/sojudb/000077500000000000000000000000001477072477000145505ustar00rootroot00000000000000soju-0.9.0/cmd/sojudb/main.go000066400000000000000000000061021477072477000160220ustar00rootroot00000000000000package main import ( "bufio" "context" "flag" "fmt" "io" "log" "os" "golang.org/x/crypto/ssh/terminal" "codeberg.org/emersion/soju/config" "codeberg.org/emersion/soju/database" ) const usage = `usage: sojudb [-config path] [options...] Edit the soju database. Note, the soju daemon must be restarted after database changes. Commands: create-user [-admin] Create a new user change-password Change password for a user help Show this help message ` func init() { flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), usage) } } func main() { var configPath string flag.StringVar(&configPath, "config", config.DefaultPath, "path to configuration file") flag.Parse() var cfg *config.Server if configPath != "" { var err error cfg, err = config.Load(configPath) if err != nil { log.Fatalf("failed to load config file: %v", err) } } else { cfg = config.Defaults() } db, err := database.Open(cfg.DB.Driver, cfg.DB.Source) if err != nil { log.Fatalf("failed to open database: %v", err) } ctx := context.Background() switch cmd := flag.Arg(0); cmd { case "create-user": username := flag.Arg(1) if username == "" { flag.Usage() os.Exit(1) } fs := flag.NewFlagSet("", flag.ExitOnError) admin := fs.Bool("admin", false, "make the new user admin") fs.Parse(flag.Args()[2:]) password, err := readPassword() if err != nil { log.Fatalf("failed to read password: %v", err) } user := database.NewUser(username) user.Admin = *admin if err := user.SetPassword(password); err != nil { log.Fatalf("failed to set user password: %v", err) } if err := db.StoreUser(ctx, user); err != nil { log.Fatalf("failed to create user: %v", err) } case "change-password": username := flag.Arg(1) if username == "" { flag.Usage() os.Exit(1) } user, err := db.GetUser(ctx, username) if err != nil { log.Fatalf("failed to get user: %v", err) } password, err := readPassword() if err != nil { log.Fatalf("failed to read password: %v", err) } if err := user.SetPassword(password); err != nil { log.Fatalf("failed to set user password: %v", err) } if err := db.StoreUser(ctx, user); err != nil { log.Fatalf("failed to update password: %v", err) } default: flag.Usage() if cmd != "help" { os.Exit(1) } } } func readPassword() (string, error) { var password []byte var err error fd := int(os.Stdin.Fd()) if terminal.IsTerminal(fd) { fmt.Printf("Password: ") password, err = terminal.ReadPassword(int(os.Stdin.Fd())) if err != nil { return "", err } fmt.Printf("\n") } else { fmt.Fprintf(os.Stderr, "Warning: Reading password from stdin.\n") // TODO: the buffering messes up repeated calls to readPassword scanner := bufio.NewScanner(os.Stdin) if !scanner.Scan() { if err := scanner.Err(); err != nil { return "", err } return "", io.ErrUnexpectedEOF } password = scanner.Bytes() if len(password) == 0 { return "", fmt.Errorf("zero length password") } } return string(password), nil } soju-0.9.0/config.in000066400000000000000000000001521477072477000143120ustar00rootroot00000000000000db sqlite3 /var/lib/soju/main.db message-store fs /var/lib/soju/logs/ listen ircs:// listen unix+admin:// soju-0.9.0/config/000077500000000000000000000000001477072477000137645ustar00rootroot00000000000000soju-0.9.0/config/config.go000066400000000000000000000162671477072477000155740ustar00rootroot00000000000000package config import ( "fmt" "net" "os" "path" "strconv" "strings" "time" "codeberg.org/emersion/go-scfg" ) var ( DefaultPath string DefaultUnixAdminPath = "/run/soju/admin" ) type IPSet []*net.IPNet func (set IPSet) Contains(ip net.IP) bool { for _, n := range set { if n.Contains(ip) { return true } } return false } // loopbackIPs contains the loopback networks 127.0.0.0/8 and ::1/128. var loopbackIPs = IPSet{ &net.IPNet{ IP: net.IP{127, 0, 0, 0}, Mask: net.CIDRMask(8, 32), }, &net.IPNet{ IP: net.IPv6loopback, Mask: net.CIDRMask(128, 128), }, } func parseDuration(s string) (time.Duration, error) { if !strings.HasSuffix(s, "d") { return 0, fmt.Errorf("missing 'd' suffix in duration") } s = strings.TrimSuffix(s, "d") v, err := strconv.ParseFloat(s, 64) if err != nil { return 0, fmt.Errorf("invalid duration: %v", err) } return time.Duration(v * 24 * float64(time.Hour)), nil } type TLS struct { CertPath, KeyPath string } type DB struct { Driver, Source string } type MsgStore struct { Driver, Source string } type Auth struct { Driver, Source string } type FileUpload struct { Driver, Source string } type Server struct { Listen []string TLS *TLS Hostname string Title string MOTDPath string DB DB MsgStore MsgStore Auth []Auth FileUpload *FileUpload HTTPOrigins []string HTTPIngress string AcceptProxyIPs IPSet MaxUserNetworks int UpstreamUserIPs []*net.IPNet DisableInactiveUsersDelay time.Duration EnableUsersOnAuth bool } func Defaults() *Server { hostname, err := os.Hostname() if err != nil { hostname = "localhost" } return &Server{ Hostname: hostname, DB: DB{ Driver: "sqlite3", Source: "soju.db", }, MsgStore: MsgStore{ Driver: "db", }, Auth: []Auth{{ Driver: "internal", }}, HTTPIngress: "https://" + hostname, MaxUserNetworks: -1, } } func Load(filename string) (*Server, error) { var raw struct { Listen []struct { Addr string `scfg:",param"` } `scfg:"listen"` Hostname string `scfg:"hostname"` Title string `scfg:"title"` MOTD string `scfg:"motd"` TLS *[2]string `scfg:"tls"` DB *[2]string `scfg:"db"` MessageStore []string `scfg:"message-store"` Log []string `scfg:"log"` Auth []struct { Params []string `scfg:",param"` } `scfg:"auth"` FileUpload []string `scfg:"file-upload"` HTTPOrigin []string `scfg:"http-origin"` HTTPIngress string `scfg:"http-ingress"` AcceptProxyIP []string `scfg:"accept-proxy-ip"` MaxUserNetworks int `scfg:"max-user-networks"` UpstreamUserIP []string `scfg:"upstream-user-ip"` DisableInactiveUser string `scfg:"disable-inactive-user"` EnableUserOnAuth string `scfg:"enable-user-on-auth"` } raw.MaxUserNetworks = -1 f, err := os.Open(filename) if err != nil { return nil, err } defer f.Close() if err := scfg.NewDecoder(f).Decode(&raw); err != nil { return nil, err } srv := Defaults() for _, listen := range raw.Listen { srv.Listen = append(srv.Listen, listen.Addr) } if raw.Hostname != "" { srv.Hostname = raw.Hostname } srv.Title = raw.Title srv.MOTDPath = raw.MOTD if raw.TLS != nil { srv.TLS = &TLS{CertPath: raw.TLS[0], KeyPath: raw.TLS[1]} } if raw.DB != nil { srv.DB = DB{Driver: raw.DB[0], Source: raw.DB[1]} } if raw.MessageStore == nil { raw.MessageStore = raw.Log } if raw.MessageStore != nil { driver, source, err := parseDriverSource("message-store", raw.MessageStore) if err != nil { return nil, err } switch driver { case "memory", "db": if source != "" { return nil, fmt.Errorf("directive message-store: driver %q requires zero parameters", driver) } case "fs": if source == "" { return nil, fmt.Errorf("directive message-store: driver %q requires a source", driver) } default: return nil, fmt.Errorf("directive message-store: unknown driver %q", driver) } srv.MsgStore = MsgStore{driver, source} } if len(raw.Auth) != 0 { // drop default auth if we are explicitly defining any auth srv.Auth = nil } for _, auth := range raw.Auth { driver, source, err := parseDriverSource("auth", auth.Params) if err != nil { return nil, err } switch driver { case "internal", "pam": if source != "" { return nil, fmt.Errorf("directive auth: driver %q requires zero parameters", driver) } case "http", "oauth2": if source == "" { return nil, fmt.Errorf("directive auth: driver %q requires a source", driver) } default: return nil, fmt.Errorf("directive auth: unknown driver %q", driver) } srv.Auth = append(srv.Auth, Auth{driver, source}) } if raw.FileUpload != nil { driver, source, err := parseDriverSource("file-upload", raw.FileUpload) if err != nil { return nil, err } switch driver { case "fs", "http": if source == "" { return nil, fmt.Errorf("directive file-upload: driver %q requires a source", driver) } default: return nil, fmt.Errorf("directive file-upload: unknown driver %q", driver) } srv.FileUpload = &FileUpload{driver, source} } for _, origin := range raw.HTTPOrigin { if _, err := path.Match(origin, origin); err != nil { return nil, fmt.Errorf("directive http-origin: %v", err) } } srv.HTTPOrigins = raw.HTTPOrigin if raw.HTTPIngress != "" { srv.HTTPIngress = raw.HTTPIngress } else { srv.HTTPIngress = "https://" + srv.Hostname } for _, s := range raw.AcceptProxyIP { if s == "localhost" { srv.AcceptProxyIPs = append(srv.AcceptProxyIPs, loopbackIPs...) continue } _, n, err := net.ParseCIDR(s) if err != nil { return nil, fmt.Errorf("directive accept-proxy-ip: failed to parse CIDR: %v", err) } srv.AcceptProxyIPs = append(srv.AcceptProxyIPs, n) } srv.MaxUserNetworks = raw.MaxUserNetworks var hasIPv4, hasIPv6 bool for _, s := range raw.UpstreamUserIP { _, n, err := net.ParseCIDR(s) if err != nil { return nil, fmt.Errorf("directive upstream-user-ip: failed to parse CIDR: %v", err) } if n.IP.To4() == nil { if hasIPv6 { return nil, fmt.Errorf("directive upstream-user-ip: found two IPv6 CIDRs") } hasIPv6 = true } else { if hasIPv4 { return nil, fmt.Errorf("directive upstream-user-ip: found two IPv4 CIDRs") } hasIPv4 = true } srv.UpstreamUserIPs = append(srv.UpstreamUserIPs, n) } if raw.DisableInactiveUser != "" { dur, err := parseDuration(raw.DisableInactiveUser) if err != nil { return nil, fmt.Errorf("directive disable-inactive-user: %v", err) } else if dur < 0 { return nil, fmt.Errorf("directive disable-inactive-user: duration must be positive") } srv.DisableInactiveUsersDelay = dur } if raw.EnableUserOnAuth != "" { b, err := strconv.ParseBool(raw.EnableUserOnAuth) if err != nil { return nil, fmt.Errorf("directive enable-user-on-auth: %v", err) } srv.EnableUsersOnAuth = b } return srv, nil } func parseDriverSource(name string, params []string) (driver, source string, err error) { switch len(params) { case 2: source = params[1] fallthrough case 1: driver = params[0] default: err = fmt.Errorf("directive %v requires exactly 1 or 2 parameters", name) } return driver, source, err } soju-0.9.0/conn.go000066400000000000000000000145731477072477000140150ustar00rootroot00000000000000package soju import ( "context" "errors" "io" "net" "strings" "sync/atomic" "time" "unicode" "github.com/coder/websocket" "golang.org/x/time/rate" "gopkg.in/irc.v4" ) // ircConn is a generic IRC connection. It's similar to net.Conn but focuses on // reading and writing IRC messages. type ircConn interface { ReadMessage() (*irc.Message, error) WriteMessage(*irc.Message) error Close() error SetReadDeadline(time.Time) error SetWriteDeadline(time.Time) error RemoteAddr() net.Addr LocalAddr() net.Addr } func newNetIRCConn(c net.Conn) ircConn { type netConn net.Conn return struct { *irc.Conn netConn }{irc.NewConn(c), c} } type websocketIRCConn struct { conn *websocket.Conn readDeadline, writeDeadline time.Time remoteAddr string } func newWebsocketIRCConn(c *websocket.Conn, remoteAddr string) ircConn { return &websocketIRCConn{conn: c, remoteAddr: remoteAddr} } func (wic *websocketIRCConn) ReadMessage() (*irc.Message, error) { ctx := context.Background() if !wic.readDeadline.IsZero() { var cancel context.CancelFunc ctx, cancel = context.WithDeadline(ctx, wic.readDeadline) defer cancel() } _, b, err := wic.conn.Read(ctx) if err != nil { switch websocket.CloseStatus(err) { case websocket.StatusNormalClosure, websocket.StatusGoingAway: return nil, io.EOF default: return nil, err } } return irc.ParseMessage(string(b)) } func (wic *websocketIRCConn) WriteMessage(msg *irc.Message) error { b := []byte(strings.ToValidUTF8(msg.String(), string(unicode.ReplacementChar))) ctx := context.Background() if !wic.writeDeadline.IsZero() { var cancel context.CancelFunc ctx, cancel = context.WithDeadline(ctx, wic.writeDeadline) defer cancel() } return wic.conn.Write(ctx, websocket.MessageText, b) } func (wic *websocketIRCConn) Close() error { return wic.conn.Close(websocket.StatusNormalClosure, "") } func (wic *websocketIRCConn) SetReadDeadline(t time.Time) error { wic.readDeadline = t return nil } func (wic *websocketIRCConn) SetWriteDeadline(t time.Time) error { wic.writeDeadline = t return nil } func (wic *websocketIRCConn) RemoteAddr() net.Addr { return websocketAddr(wic.remoteAddr) } func (wic *websocketIRCConn) LocalAddr() net.Addr { // Behind a reverse HTTP proxy, we don't have access to the real listening // address return websocketAddr("") } type websocketAddr string func (websocketAddr) Network() string { return "ws" } func (wa websocketAddr) String() string { return string(wa) } type connOptions struct { Logger Logger RateLimitDelay time.Duration RateLimitBurst int } type conn struct { conn ircConn srv *Server logger Logger closed atomic.Bool outgoingCh chan<- *irc.Message closedCh chan struct{} rateLimit bool } func newConn(srv *Server, ic ircConn, options *connOptions) *conn { outgoingCh := make(chan *irc.Message, 64) c := &conn{ conn: ic, srv: srv, logger: options.Logger, outgoingCh: outgoingCh, closedCh: make(chan struct{}), rateLimit: true, } go func() { ctx, cancel := c.NewContext(context.Background()) defer cancel() rl := rate.NewLimiter(rate.Every(options.RateLimitDelay), options.RateLimitBurst) for { var msg *irc.Message select { case msg = <-outgoingCh: case <-c.closedCh: } if msg == nil { break } if c.rateLimit { if err := rl.Wait(ctx); err != nil { break } } c.logger.Debugf("sent: %v", msg) c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)) if err := c.conn.WriteMessage(msg); err != nil { c.logger.Printf("failed to write message: %v", err) break } } if err := c.Close(); err != nil && !errors.Is(err, net.ErrClosed) { c.logger.Printf("failed to close connection: %v", err) } else { c.logger.Debugf("connection closed") } }() c.logger.Debugf("new connection") return c } func (c *conn) isClosed() bool { return c.closed.Load() } // Close closes the connection. It is safe to call from any goroutine. func (c *conn) Close() error { if c.closed.Swap(true) { return net.ErrClosed } err := c.conn.Close() close(c.closedCh) return err } // Read reads an incoming message. It must be called from a single goroutine // at a time. // // io.EOF is returned when there are no more messages to read. func (c *conn) ReadMessage() (*irc.Message, error) { msg, err := c.conn.ReadMessage() if errors.Is(err, net.ErrClosed) { return nil, io.EOF } else if err != nil { return nil, err } c.logger.Debugf("received: %v", msg) return msg, nil } // SendMessage queues a new outgoing message. It is safe to call from any // goroutine. // // If the connection is closed before the message is sent, SendMessage silently // drops the message. func (c *conn) SendMessage(ctx context.Context, msg *irc.Message) { if c.closed.Load() { return } select { case c.outgoingCh <- msg: // Success case <-c.closedCh: // Ignore case <-ctx.Done(): c.logger.Printf("failed to send message: %v", ctx.Err()) } } // Shutdown gracefully closes the connection, flushing any pending message. func (c *conn) Shutdown(ctx context.Context) { if c.closed.Load() { return } select { case c.outgoingCh <- nil: // Success case <-c.closedCh: // Ignore case <-ctx.Done(): c.logger.Printf("failed to shutdown connection: %v", ctx.Err()) // Forcibly close the connection if err := c.Close(); err != nil && !errors.Is(err, net.ErrClosed) { c.logger.Printf("failed to close connection: %v", err) } } } func (c *conn) RemoteAddr() net.Addr { return c.conn.RemoteAddr() } func (c *conn) LocalAddr() net.Addr { return c.conn.LocalAddr() } // NewContext returns a copy of the parent context with a new Done channel. The // returned context's Done channel is closed when the connection is closed, // when the returned cancel function is called, or when the parent context's // Done channel is closed, whichever happens first. // // Canceling this context releases resources associated with it, so code should // call cancel as soon as the operations running in this Context complete. func (c *conn) NewContext(parent context.Context) (context.Context, context.CancelFunc) { ctx, cancel := context.WithCancel(parent) go func() { defer cancel() select { case <-ctx.Done(): // The parent context has been cancelled, or the caller has called // cancel() case <-c.closedCh: // The connection has been closed } }() return ctx, cancel } soju-0.9.0/contrib/000077500000000000000000000000001477072477000141575ustar00rootroot00000000000000soju-0.9.0/contrib/casemap-logs.sh000077500000000000000000000022431477072477000170720ustar00rootroot00000000000000#!/bin/sh -eu # Converts a log dir to its case-mapped form. # # soju needs to be stopped for this script to work properly. The script may # re-order messages that happened within the same second interval if merging # two daily log files is necessary. # # usage: casemap-logs.sh root="$1" for net_dir in "$root"/*/*; do for chan in $(ls "$net_dir"); do cm_chan="$(echo $chan | tr '[:upper:]' '[:lower:]')" if [ "$chan" = "$cm_chan" ]; then continue fi if ! [ -d "$net_dir/$cm_chan" ]; then echo >&2 "Moving case-mapped channel dir: '$net_dir/$chan' -> '$cm_chan'" mv "$net_dir/$chan" "$net_dir/$cm_chan" continue fi echo "Merging case-mapped channel dir: '$net_dir/$chan' -> '$cm_chan'" for day in $(ls "$net_dir/$chan"); do if ! [ -e "$net_dir/$cm_chan/$day" ]; then echo >&2 " Moving log file: '$day'" mv "$net_dir/$chan/$day" "$net_dir/$cm_chan/$day" continue fi echo >&2 " Merging log file: '$day'" sort "$net_dir/$chan/$day" "$net_dir/$cm_chan/$day" >"$net_dir/$cm_chan/$day.new" mv "$net_dir/$cm_chan/$day.new" "$net_dir/$cm_chan/$day" rm "$net_dir/$chan/$day" done rmdir "$net_dir/$chan" done done soju-0.9.0/contrib/certbot.md000066400000000000000000000017111477072477000161430ustar00rootroot00000000000000# Setting up Certbot for soju If you are using [Certbot] to obtain HTTPS certificates, you can set up soju like so: - Obtain the certificate: certbot certonly -d - Allow all local users to access certificates (private keys are still protected): chmod 0755 /etc/letsencrypt/{live,archive} - Allow the soju user to read the private key: chmod 0640 /etc/letsencrypt/live//privkey.pem chgrp soju /etc/letsencrypt/live//privkey.pem - Set the `tls` directive in the soju configuration file: tls /etc/letsencrypt/live//fullchain.pem /etc/letsencrypt/live//privkey.pem - Configure Certbot to reload soju. Edit `/etc/letsencrypt/renewal-hooks/post/soju.sh` and add a command to reload soju, for instance: #!/bin/sh -eu systemctl reload soju Then mark the script as executable: chmod 755 /etc/letsencrypt/renewal-hooks/post/soju.sh [Certbot]: https://certbot.eff.org/ soju-0.9.0/contrib/clients.md000066400000000000000000000103231477072477000161410ustar00rootroot00000000000000# Clients This page describes how to configure IRC clients to better integrate with soju. Also see the [IRCv3 support tables] for a more general list of clients. # catgirl catgirl doesn't implement cap-3.2, so many capabilities will be disabled. catgirl developers have publicly stated that supporting bouncers such as soju is a non-goal. # [Emacs] There are two clients provided with Emacs. They require some setup to work properly. ## Erc Create an interactive function for connecting: ```elisp (defun run-erc () (interactive) (erc-tls :server "" :port 6697 :nick "" :user "/irc.libera.chat" ;; Example with Libera.Chat :password "")) ``` Then run `M-x run-erc`. ## Rcirc The only thing needed here is the general config: ```elisp (setq rcirc-server-alist '(("" :port 6697 :encryption tls :nick "" :user-name "/irc.libera.chat" ;; Example with Libera.Chat :password ""))) ``` Then run `M-x irc`. # [gamja] gamja has been designed together with soju, so should have excellent integration. gamja supports many IRCv3 features including chat history. gamja also provides UI to manage soju networks via the `soju.im/bouncer-networks` extension. # [goguma] Much like gamja, goguma has been designed together with soju, so should have excellent integration. goguma supports many IRCv3 features including chat history. goguma should seamlessly connect to all networks configured in soju via the `soju.im/bouncer-networks` extension. # [Halloy] Halloy has support for many IRCv3 features including chat history as of release 2025.1. Below is an example configuration to connect to soju networks: ```toml [servers.liberachat] nickname = "network_nickname" username = "soju_username/irc.libera.chat@hostname" password = "soju_password" server = "soju_server_hostname" port = 6697 chathistory = true ``` For more details, see the [guide on connecting to soju] and [server chathistory] found in the Halloy docs. # [Hexchat] Hexchat has support for a small set of IRCv3 capabilities. To prevent automatically reconnecting to channels parted from soju, and prevent buffering outgoing messages: /set irc_reconnect_rejoin off /set net_throttle off # [irssi] To connect irssi to a network, for example Libera Chat: /network add -user /irc.libera.chat libera /server add -auto -tls -network libera Then, to actually connect: /connect libera # [senpai] senpai is being developed with soju in mind, so should have excellent integration. senpai supports many IRCv3 features including chat history. senpai should seamlessly connect to all networks configured in soju via the `soju.im/bouncer-networks` extension. # [Weechat] A [soju.py] Weechat script is available to provide better integration with soju. The script will automatically connect to all of your networks once a single connection to soju is set up in Weechat. Additionally, [read_marker.py] can be enabled to synchronize the read marker between multiple clients. On WeeChat 3.2-, no IRCv3 capabilities are enabled by default. To enable them: /set irc.server_default.capabilities account-notify,away-notify,cap-notify,chghost,extended-join,invite-notify,multi-prefix,server-time,userhost-in-names /save /reconnect -all See `/help cap` for more information. [IRCv3 support tables]: https://ircv3.net/software/clients [gamja]: https://codeberg.org/emersion/gamja [goguma]: https://codeberg.org/emersion/goguma [senpai]: https://sr.ht/~delthas/senpai/ [Weechat]: https://weechat.org/ [soju.py]: https://weechat.org/scripts/source/soju.py.html/ [read_marker.py]: https://weechat.org/scripts/source/read_marker.py.html/ [Halloy]: https://halloy.squidowl.org/index.html [guide on connecting to soju]: https://halloy.squidowl.org/guides/connect-with-soju.html [server chathistory]: https://halloy.squidowl.org/configuration/servers.html#chathistory [Hexchat]: https://hexchat.github.io/ [hexchat password length fix]: https://github.com/hexchat/hexchat/commit/778047bc65e529804c3342ee0f3a8d5d7550fde5 [Emacs]: https://www.gnu.org/software/emacs/ [irssi]: https://irssi.org/ soju-0.9.0/contrib/migrate-db/000077500000000000000000000000001477072477000161725ustar00rootroot00000000000000soju-0.9.0/contrib/migrate-db/main.go000066400000000000000000000105131477072477000174450ustar00rootroot00000000000000package main import ( "context" "flag" "fmt" "log" "strings" "codeberg.org/emersion/soju/database" ) const usage = `usage: migrate-db Migrates an existing Soju database to another system. Database is specified in the format of "driver:source" where driver is sqlite3 or postgres and source is the string that would be in the Soju config file. Options: -help Show this help message ` func init() { flag.Usage = func() { fmt.Fprint(flag.CommandLine.Output(), usage) } } func main() { flag.Parse() ctx := context.Background() source := strings.Split(flag.Arg(0), ":") destination := strings.Split(flag.Arg(1), ":") if len(source) != 2 || len(destination) != 2 { log.Fatalf("source or destination not properly specified: %s %s", flag.Arg(0), flag.Arg(1)) } sourcedb, err := database.Open(source[0], source[1]) if err != nil { log.Fatalf("failed to open database: %v", err) } defer sourcedb.Close() destinationdb, err := database.Open(destination[0], destination[1]) if err != nil { log.Fatalf("failed to open database: %v", err) } defer destinationdb.Close() users, err := sourcedb.ListUsers(ctx) if err != nil { log.Fatal("unable to get source users") } for _, user := range users { log.Printf("Storing user: %s\n", user.Username) user.ID = 0 err := destinationdb.StoreUser(ctx, &user) if err != nil { log.Fatalf("unable to store user: #%d %s", user.ID, user.Username) } networks, err := sourcedb.ListNetworks(ctx, user.ID) if err != nil { log.Fatalf("unable to get source networks for user: #%d %s", user.ID, user.Username) } for _, srcNetwork := range networks { log.Printf("Storing network: %s\n", srcNetwork.GetName()) destNetwork := srcNetwork destNetwork.ID = 0 err := destinationdb.StoreNetwork(ctx, user.ID, &destNetwork) if err != nil { log.Fatalf("unable to store network: #%d %s", srcNetwork.ID, srcNetwork.GetName()) } channels, err := sourcedb.ListChannels(ctx, srcNetwork.ID) if err != nil { log.Fatalf("unable to get source channels for network: #%d %s", srcNetwork.ID, srcNetwork.GetName()) } for _, channel := range channels { log.Printf("Storing channel: %s\n", channel.Name) channel.ID = 0 err := destinationdb.StoreChannel(ctx, destNetwork.ID, &channel) if err != nil { log.Fatalf("unable to store channel: #%d %s", channel.ID, channel.Name) } } deliveryReceipts, err := sourcedb.ListDeliveryReceipts(ctx, srcNetwork.ID) if err != nil { log.Fatalf("unable to get source delivery receipts for network: #%d %s", srcNetwork.ID, srcNetwork.GetName()) } drcpts := make(map[string][]database.DeliveryReceipt) for _, d := range deliveryReceipts { if drcpts[d.Client] == nil { drcpts[d.Client] = make([]database.DeliveryReceipt, 0) } d.ID = 0 drcpts[d.Client] = append(drcpts[d.Client], d) } for client, rcpts := range drcpts { log.Printf("Storing delivery receipt for: %s.%s.%s", user.Username, srcNetwork.GetName(), client) err := destinationdb.StoreClientDeliveryReceipts(ctx, destNetwork.ID, client, rcpts) if err != nil { log.Fatalf("unable to store delivery receipts for network and client: %s %s", srcNetwork.GetName(), client) } } // TODO: migrate read receipts as well webPushSubscriptions, err := sourcedb.ListWebPushSubscriptions(ctx, user.ID, srcNetwork.ID) if err != nil { log.Fatalf("unable to get source web push subscriptions for user and network: %s %s", user.Username, srcNetwork.GetName()) } for _, sub := range webPushSubscriptions { log.Printf("Storing web push subscription: %s.%s.%d", user.Username, srcNetwork.GetName(), sub.ID) sub.ID = 0 err := destinationdb.StoreWebPushSubscription(ctx, user.ID, destNetwork.ID, &sub) if err != nil { log.Fatalf("unable to store web push subscription for user and network: %s %s", user.Username, srcNetwork.GetName()) } } } } webPushConfigs, err := sourcedb.ListWebPushConfigs(ctx) if err != nil { log.Fatal("unable to get source web push configs") } for _, config := range webPushConfigs { log.Printf("Storing web push config: %d", config.ID) config.ID = 0 err := destinationdb.StoreWebPushConfig(ctx, &config) if err != nil { log.Fatalf("unable to store web push config: #%d", config.ID) } } } soju-0.9.0/contrib/migrate-logs/000077500000000000000000000000001477072477000165515ustar00rootroot00000000000000soju-0.9.0/contrib/migrate-logs/main.go000066400000000000000000000075641477072477000200400ustar00rootroot00000000000000package main import ( "bufio" "context" "flag" "fmt" "log" "os" "path/filepath" "sort" "strings" "time" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/database" "codeberg.org/emersion/soju/msgstore" "codeberg.org/emersion/soju/msgstore/znclog" ) const usage = `usage: migrate-logs Migrates existing Soju logs stored on disk to a Soju database. Database is specified in the format of "driver:source" where driver is sqlite3 or postgres and source is the string that would be in the Soju config file. Options: -help Show this help message ` var logRoot string func init() { flag.Usage = func() { fmt.Fprint(flag.CommandLine.Output(), usage) } } func migrateNetwork(ctx context.Context, db database.Database, user *database.User, network *database.Network) error { log.Printf("Migrating logs for network: %s\n", network.GetName()) rootPath := filepath.Join(logRoot, msgstore.EscapeFilename(user.Username), msgstore.EscapeFilename(network.GetName())) root, err := os.Open(rootPath) if os.IsNotExist(err) { return nil } if err != nil { return fmt.Errorf("unable to open network folder: %s", rootPath) } // The returned targets are escaped, and there is no way to un-escape // TODO: switch to ReadDir (Go 1.16+) targets, err := root.Readdirnames(0) root.Close() if err != nil { return fmt.Errorf("unable to read network folder: %s", rootPath) } for _, target := range targets { log.Printf("Migrating logs for target: %s\n", target) // target is already escaped here targetPath := filepath.Join(rootPath, target) targetDir, err := os.Open(targetPath) if err != nil { return fmt.Errorf("unable to open target folder: %s", targetPath) } entryNames, err := targetDir.Readdirnames(0) targetDir.Close() if err != nil { return fmt.Errorf("unable to read target folder: %s", targetPath) } sort.Strings(entryNames) for _, entryName := range entryNames { entryPath := filepath.Join(targetPath, entryName) var year, month, day int _, err := fmt.Sscanf(entryName, "%04d-%02d-%02d.log", &year, &month, &day) if err != nil { return fmt.Errorf("invalid entry name: %s", entryName) } ref := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) entry, err := os.Open(entryPath) if err != nil { return fmt.Errorf("unable to open entry: %s", entryPath) } sc := bufio.NewScanner(entry) var msgs []*irc.Message for sc.Scan() { msg, _, err := znclog.UnmarshalLine(sc.Text(), user, network, target, ref, true) if err != nil { return fmt.Errorf("unable to parse entry: %s: %s", entryPath, sc.Text()) } else if msg == nil { continue } msgs = append(msgs, msg) } if sc.Err() != nil { return fmt.Errorf("unable to parse entry: %s: %v", entryPath, sc.Err()) } _, err = db.StoreMessages(ctx, network.ID, target, msgs) if err != nil { return fmt.Errorf("unable to store messages: %s: %s: %v", entryPath, sc.Text(), err) } entry.Close() } } return nil } func main() { flag.Parse() ctx := context.Background() logRoot = flag.Arg(0) dbParams := strings.Split(flag.Arg(1), ":") if len(dbParams) != 2 { log.Fatalf("database not properly specified: %s", flag.Arg(1)) } db, err := database.Open(dbParams[0], dbParams[1]) if err != nil { log.Fatalf("failed to open database: %v", err) } defer db.Close() users, err := db.ListUsers(ctx) if err != nil { log.Fatalf("unable to get users: %v", err) } for _, user := range users { log.Printf("Migrating logs for user: %s\n", user.Username) networks, err := db.ListNetworks(ctx, user.ID) if err != nil { log.Fatalf("unable to get networks for user: #%d %s", user.ID, user.Username) } for _, network := range networks { if err := migrateNetwork(ctx, db, &user, &network); err != nil { log.Fatalf("migrating %v: %v", network.GetName(), err) } } } } soju-0.9.0/contrib/openbsd-relayd.md000066400000000000000000000023031477072477000174070ustar00rootroot00000000000000# Setting up OpenBSD relayd(8) with soju [relayd(8)] can be used in front of soju to take care of TLS. ## relayd configuration Edit this `/etc/relayd.conf`: ```relayd.conf tcp protocol "ircs" { tls keypair example.com } relay ircs { listen on 0.0.0.0 port 6697 tls protocol ircs forward to 127.0.0.1 port 6667 } relay ircs6 { listen on :: port 6697 tls protocol ircs forward to 127.0.0.1 port 6667 } ``` First section declares a named "ircs" generic tcp protocol and configure it to look for TLS files: - /etc/ssl/name.crt - /etc/ssl/private/name.key Theses files may be handled by [acme-client(1)] and does not required more permissions for soju. The rest of the configuration file set up two relays to listen on all addresses from both inet4 and inet6 interfaces to do TLS termination and forward traffic to soju. ## soju configuration ```soju-config listen irc+insecure://127.0.0.1:6667 ``` The important part is to make soju listen only on local address using non-secure irc port as the secure connection is already looked after by relayd. [relayd(8)]: https://man.openbsd.org/relayd.8 [acme-client(1)]: https://man.openbsd.org/acme-client.1 soju-0.9.0/contrib/soju.service000066400000000000000000000007131477072477000165220ustar00rootroot00000000000000[Unit] Description=soju IRC bouncer service Documentation=https://soju.im/ Documentation=man:soju(1) man:sojuctl(1) Wants=network-online.target After=network-online.target [Service] Type=simple User=soju Group=soju DynamicUser=yes StateDirectory=soju ConfigurationDirectory=soju RuntimeDirectory=soju AmbientCapabilities=CAP_NET_BIND_SERVICE ExecStart=/usr/bin/soju ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure [Install] WantedBy=multi-user.target soju-0.9.0/contrib/tlstunnel.md000066400000000000000000000005571477072477000165400ustar00rootroot00000000000000# Setting up tlstunnel with soju [tlstunnel] can be used in front of soju to take care of TLS. ## tlstunnel configuration ``` frontend { listen irc.example.org:6697 backend tcp+proxy://localhost:6667 protocol irc } ``` ## soju configuration ``` listen irc+insecure://localhost accept-proxy-ip localhost ``` [tlstunnel]: https://git.sr.ht/~emersion/tlstunnel soju-0.9.0/contrib/znc-import/000077500000000000000000000000001477072477000162615ustar00rootroot00000000000000soju-0.9.0/contrib/znc-import/main.go000066400000000000000000000242501477072477000175370ustar00rootroot00000000000000package main import ( "bufio" "context" "flag" "fmt" "io" "log" "net/url" "os" "strings" "unicode" "codeberg.org/emersion/soju/config" "codeberg.org/emersion/soju/database" ) const usage = `usage: znc-import [options...] Imports configuration from a ZNC file. Users and networks are merged if they already exist in the soju database. ZNC settings overwrite existing soju settings. Options: -help Show this help message -config Path to soju config file -user Limit import to username (may be specified multiple times) -network Limit import to network (may be specified multiple times) ` func init() { flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), usage) } } func main() { var configPath string users := make(map[string]bool) networks := make(map[string]bool) flag.StringVar(&configPath, "config", "", "path to configuration file") flag.Var((*stringSetFlag)(&users), "user", "") flag.Var((*stringSetFlag)(&networks), "network", "") flag.Parse() zncPath := flag.Arg(0) if zncPath == "" { flag.Usage() os.Exit(1) } var cfg *config.Server if configPath != "" { var err error cfg, err = config.Load(configPath) if err != nil { log.Fatalf("failed to load config file: %v", err) } } else { cfg = config.Defaults() } ctx := context.Background() db, err := database.Open(cfg.DB.Driver, cfg.DB.Source) if err != nil { log.Fatalf("failed to open database: %v", err) } defer db.Close() f, err := os.Open(zncPath) if err != nil { log.Fatalf("failed to open ZNC configuration file: %v", err) } defer f.Close() zp := zncParser{bufio.NewReader(f), 1} root, err := zp.sectionBody("", "") if err != nil { log.Fatalf("failed to parse %q: line %v: %v", zncPath, zp.line, err) } l, err := db.ListUsers(ctx) if err != nil { log.Fatalf("failed to list users in DB: %v", err) } existingUsers := make(map[string]*database.User, len(l)) for i, u := range l { existingUsers[u.Username] = &l[i] } usersCreated := 0 usersImported := 0 networksImported := 0 channelsImported := 0 root.ForEach("User", func(section *zncSection) { username := section.Name if len(users) > 0 && !users[username] { return } usersImported++ u, ok := existingUsers[username] if ok { log.Printf("user %q: updating existing user", username) } else { u = database.NewUser(username) usersCreated++ log.Printf("user %q: creating new user", username) } u.Admin = section.Values.Get("Admin") == "true" if err := db.StoreUser(ctx, u); err != nil { log.Fatalf("failed to store user %q: %v", username, err) } userID := u.ID l, err := db.ListNetworks(ctx, userID) if err != nil { log.Fatalf("failed to list networks for user %q: %v", username, err) } existingNetworks := make(map[string]*database.Network, len(l)) for i, n := range l { existingNetworks[n.GetName()] = &l[i] } nick := section.Values.Get("Nick") realname := section.Values.Get("RealName") ident := section.Values.Get("Ident") section.ForEach("Network", func(section *zncSection) { netName := section.Name if len(networks) > 0 && !networks[netName] { return } networksImported++ logPrefix := fmt.Sprintf("user %q: network %q: ", username, netName) logger := log.New(os.Stderr, logPrefix, log.LstdFlags|log.Lmsgprefix) netNick := section.Values.Get("Nick") if netNick == "" { netNick = nick } netRealname := section.Values.Get("RealName") if netRealname == "" { netRealname = realname } netIdent := section.Values.Get("Ident") if netIdent == "" { netIdent = ident } for _, name := range section.Values["LoadModule"] { switch name { case "sasl": logger.Printf("warning: SASL credentials not imported") case "nickserv": logger.Printf("warning: NickServ credentials not imported") case "perform": logger.Printf("warning: \"perform\" plugin commands not imported") } } u, pass, err := importNetworkServer(section.Values.Get("Server")) if err != nil { logger.Fatalf("failed to import server %q: %v", section.Values.Get("Server"), err) } n, ok := existingNetworks[netName] if ok { logger.Printf("updating existing network") } else { n = database.NewNetwork("") n.Name = netName logger.Printf("creating new network") } n.Addr = u.String() n.Nick = netNick n.Username = netIdent n.Realname = netRealname n.Pass = pass n.Enabled = section.Values.Get("IRCConnectEnabled") != "false" if err := db.StoreNetwork(ctx, userID, n); err != nil { logger.Fatalf("failed to store network: %v", err) } l, err := db.ListChannels(ctx, n.ID) if err != nil { logger.Fatalf("failed to list channels: %v", err) } existingChannels := make(map[string]*database.Channel, len(l)) for i, ch := range l { existingChannels[ch.Name] = &l[i] } section.ForEach("Chan", func(section *zncSection) { chName := section.Name if section.Values.Get("Disabled") == "true" { logger.Printf("skipping import of disabled channel %q", chName) return } channelsImported++ ch, ok := existingChannels[chName] if ok { logger.Printf("channel %q: updating existing channel", chName) } else { ch = &database.Channel{Name: chName} logger.Printf("channel %q: creating new channel", chName) } ch.Key = section.Values.Get("Key") ch.Detached = section.Values.Get("Detached") == "true" if err := db.StoreChannel(ctx, n.ID, ch); err != nil { logger.Printf("channel %q: failed to store channel: %v", chName, err) } }) }) }) if err := db.Close(); err != nil { log.Printf("failed to close database: %v", err) } if usersCreated > 0 { log.Printf("warning: user passwords haven't been imported, please set them with `sojudb change-password `") } log.Printf("imported %v users, %v networks and %v channels", usersImported, networksImported, channelsImported) } func importNetworkServer(s string) (u *url.URL, pass string, err error) { parts := strings.Fields(s) if len(parts) < 2 { return nil, "", fmt.Errorf("expected space-separated host and port") } scheme := "irc+insecure" host := parts[0] port := parts[1] if strings.HasPrefix(port, "+") { port = port[1:] scheme = "ircs" } if len(parts) > 2 { pass = parts[2] } u = &url.URL{ Scheme: scheme, Host: host + ":" + port, } return u, pass, nil } type zncSection struct { Type string Name string Values zncValues Children []zncSection } func (s *zncSection) ForEach(typ string, f func(*zncSection)) { for _, section := range s.Children { if section.Type == typ { f(§ion) } } } type zncValues map[string][]string func (zv zncValues) Get(k string) string { if len(zv[k]) == 0 { return "" } return zv[k][0] } type zncParser struct { br *bufio.Reader line int } func (zp *zncParser) readByte() (byte, error) { b, err := zp.br.ReadByte() if b == '\n' { zp.line++ } return b, err } func (zp *zncParser) readRune() (rune, int, error) { r, n, err := zp.br.ReadRune() if r == '\n' { zp.line++ } return r, n, err } func (zp *zncParser) sectionBody(typ, name string) (*zncSection, error) { section := &zncSection{Type: typ, Name: name, Values: make(zncValues)} Loop: for { if err := zp.skipSpace(); err != nil { return nil, err } b, err := zp.br.Peek(2) if err == io.EOF { break } else if err != nil { return nil, err } switch b[0] { case '<': if b[1] == '/' { break Loop } else { childType, childName, err := zp.sectionHeader() if err != nil { return nil, err } child, err := zp.sectionBody(childType, childName) if err != nil { return nil, err } if footerType, err := zp.sectionFooter(); err != nil { return nil, err } else if footerType != childType { return nil, fmt.Errorf("invalid section footer: expected type %q, got %q", childType, footerType) } section.Children = append(section.Children, *child) } case '/': if b[1] == '/' { if err := zp.skipComment(); err != nil { return nil, err } break } fallthrough default: k, v, err := zp.keyValuePair() if err != nil { return nil, err } section.Values[k] = append(section.Values[k], v) } } return section, nil } func (zp *zncParser) skipSpace() error { for { r, _, err := zp.readRune() if err == io.EOF { return nil } else if err != nil { return err } if !unicode.IsSpace(r) { zp.br.UnreadRune() return nil } } } func (zp *zncParser) skipComment() error { if err := zp.expectRune('/'); err != nil { return err } if err := zp.expectRune('/'); err != nil { return err } for { b, err := zp.readByte() if err == io.EOF { return nil } else if err != nil { return err } if b == '\n' { return nil } } } func (zp *zncParser) sectionHeader() (string, string, error) { if err := zp.expectRune('<'); err != nil { return "", "", err } typ, err := zp.readWord(' ') if err != nil { return "", "", err } name, err := zp.readWord('>') return typ, name, err } func (zp *zncParser) sectionFooter() (string, error) { if err := zp.expectRune('<'); err != nil { return "", err } if err := zp.expectRune('/'); err != nil { return "", err } return zp.readWord('>') } func (zp *zncParser) keyValuePair() (string, string, error) { k, err := zp.readWord('=') if err != nil { return "", "", err } v, err := zp.readWord('\n') return strings.TrimSpace(k), strings.TrimSpace(v), err } func (zp *zncParser) expectRune(expected rune) error { r, _, err := zp.readRune() if err != nil { return err } else if r != expected { return fmt.Errorf("expected %q, got %q", expected, r) } return nil } func (zp *zncParser) readWord(delim byte) (string, error) { var sb strings.Builder for { b, err := zp.readByte() if err != nil { return "", err } if b == delim { return sb.String(), nil } if b == '\n' { return "", fmt.Errorf("expected %q before newline", delim) } sb.WriteByte(b) } } type stringSetFlag map[string]bool func (v *stringSetFlag) String() string { return fmt.Sprint(map[string]bool(*v)) } func (v *stringSetFlag) Set(s string) error { (*v)[s] = true return nil } soju-0.9.0/database/000077500000000000000000000000001477072477000142635ustar00rootroot00000000000000soju-0.9.0/database/database.go000066400000000000000000000161331477072477000163620ustar00rootroot00000000000000package database import ( "context" "database/sql" "fmt" "net/url" "strings" "time" "github.com/prometheus/client_golang/prometheus" "golang.org/x/crypto/bcrypt" "gopkg.in/irc.v4" ) type MessageTargetLast struct { Name string LatestMessage time.Time } type MessageOptions struct { AfterID int64 AfterTime time.Time BeforeTime time.Time Limit int Events bool Sender string Text string TakeLast bool } type Database interface { Close() error Stats(ctx context.Context) (*DatabaseStats, error) ListUsers(ctx context.Context) ([]User, error) GetUser(ctx context.Context, username string) (*User, error) StoreUser(ctx context.Context, user *User) error DeleteUser(ctx context.Context, id int64) error ListInactiveUsernames(ctx context.Context, limit time.Time) ([]string, error) ListNetworks(ctx context.Context, userID int64) ([]Network, error) StoreNetwork(ctx context.Context, userID int64, network *Network) error DeleteNetwork(ctx context.Context, id int64) error ListChannels(ctx context.Context, networkID int64) ([]Channel, error) StoreChannel(ctx context.Context, networKID int64, ch *Channel) error DeleteChannel(ctx context.Context, id int64) error ListDeliveryReceipts(ctx context.Context, networkID int64) ([]DeliveryReceipt, error) StoreClientDeliveryReceipts(ctx context.Context, networkID int64, client string, receipts []DeliveryReceipt) error GetReadReceipt(ctx context.Context, networkID int64, name string) (*ReadReceipt, error) StoreReadReceipt(ctx context.Context, networkID int64, receipt *ReadReceipt) error ListWebPushConfigs(ctx context.Context) ([]WebPushConfig, error) StoreWebPushConfig(ctx context.Context, config *WebPushConfig) error ListWebPushSubscriptions(ctx context.Context, userID, networkID int64) ([]WebPushSubscription, error) StoreWebPushSubscription(ctx context.Context, userID, networkID int64, sub *WebPushSubscription) error DeleteWebPushSubscription(ctx context.Context, id int64) error GetMessageLastID(ctx context.Context, networkID int64, name string) (int64, error) GetMessageTarget(ctx context.Context, networkID int64, target string) (*MessageTarget, error) StoreMessageTarget(ctx context.Context, networkID int64, mt *MessageTarget) error StoreMessages(ctx context.Context, networkID int64, name string, msgs []*irc.Message) ([]int64, error) ListMessageLastPerTarget(ctx context.Context, networkID int64, options *MessageOptions) ([]MessageTargetLast, error) ListMessages(ctx context.Context, networkID int64, name string, options *MessageOptions) ([]*irc.Message, error) } type MetricsCollectorDatabase interface { Database RegisterMetrics(r prometheus.Registerer) error } func Open(driver, source string) (Database, error) { switch driver { case "sqlite3": return OpenSqliteDB(source) case "postgres": return OpenPostgresDB(source) default: return nil, fmt.Errorf("unsupported database driver: %q", driver) } } type DatabaseStats struct { Users int64 Networks int64 Channels int64 } type User struct { ID int64 Username string Password string // hashed Nick string Realname string Admin bool Enabled bool DownstreamInteractedAt time.Time MaxNetworks int } func NewUser(username string) *User { return &User{ Username: username, Enabled: true, MaxNetworks: -1, } } func (u *User) CheckPassword(password string) (upgraded bool, err error) { if u.Password == "" { return false, fmt.Errorf("password auth disabled") } err = bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) if err != nil { return false, fmt.Errorf("wrong password: %v", err) } passCost, err := bcrypt.Cost([]byte(u.Password)) if err != nil { return false, fmt.Errorf("invalid password cost: %v", err) } if passCost < bcrypt.DefaultCost { return true, u.SetPassword(password) } return false, nil } func (u *User) SetPassword(password string) error { hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("failed to hash password: %v", err) } u.Password = string(hashed) return nil } type SASL struct { Mechanism string Plain struct { Username string Password string } // TLS client certificate authentication. External struct { // X.509 certificate in DER form. CertBlob []byte // PKCS#8 private key in DER form. PrivKeyBlob []byte } } type Network struct { ID int64 Name string Addr string Nick string Username string Realname string Pass string ConnectCommands []string CertFP string SASL SASL AutoAway bool Enabled bool } func NewNetwork(addr string) *Network { return &Network{ Addr: addr, AutoAway: true, Enabled: true, } } func (net *Network) GetName() string { if net.Name != "" { return net.Name } return net.Addr } func (net *Network) URL() (*url.URL, error) { s := net.Addr if !strings.Contains(s, "://") { // This is a raw domain name, make it a URL with the default scheme s = "ircs://" + s } u, err := url.Parse(s) if err != nil { return nil, fmt.Errorf("failed to parse upstream server URL: %v", err) } switch u.Scheme { case "irc+unix", "unix": u.Path = u.Host + u.Path u.Host = "" } return u, nil } func GetNick(user *User, net *Network) string { if net != nil && net.Nick != "" { return net.Nick } if user.Nick != "" { return user.Nick } return user.Username } func GetUsername(user *User, net *Network) string { if net != nil && net.Username != "" { return net.Username } return GetNick(user, net) } func GetRealname(user *User, net *Network) string { if net != nil && net.Realname != "" { return net.Realname } if user.Realname != "" { return user.Realname } return GetNick(user, net) } type MessageFilter int const ( // TODO: use customizable user defaults for FilterDefault FilterDefault MessageFilter = iota FilterNone FilterHighlight FilterMessage ) type Channel struct { ID int64 Name string Key string Detached bool DetachedInternalMsgID string RelayDetached MessageFilter ReattachOn MessageFilter DetachAfter time.Duration DetachOn MessageFilter } type DeliveryReceipt struct { ID int64 Target string // channel or nick Client string InternalMsgID string } type ReadReceipt struct { ID int64 Target string // channel or nick Timestamp time.Time } type WebPushConfig struct { ID int64 VAPIDKeys struct { Public, Private string } } type WebPushSubscription struct { ID int64 Endpoint string CreatedAt, UpdatedAt time.Time // read-only Keys struct { Auth string P256DH string VAPID string } } type MessageTarget struct { ID int64 Target string Pinned bool Muted bool } func toNullString(s string) sql.NullString { return sql.NullString{ String: s, Valid: s != "", } } func toNullTime(t time.Time) sql.NullTime { return sql.NullTime{ Time: t, Valid: !t.IsZero(), } } soju-0.9.0/database/postgres.go000066400000000000000000000713211477072477000164640ustar00rootroot00000000000000package database import ( "context" "database/sql" _ "embed" "errors" "fmt" "math" "strings" "time" _ "github.com/lib/pq" "github.com/prometheus/client_golang/prometheus" promcollectors "github.com/prometheus/client_golang/prometheus/collectors" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/xirc" ) const postgresQueryTimeout = 5 * time.Second const postgresConfigSchema = ` CREATE TABLE IF NOT EXISTS "Config" ( id SMALLINT PRIMARY KEY, version INTEGER NOT NULL, CHECK(id = 1) ); ` //go:embed postgres_schema.sql var postgresSchema string type PostgresDB struct { db *sql.DB temp bool } func OpenPostgresDB(source string) (Database, error) { sqlPostgresDB, err := sql.Open("postgres", source) if err != nil { return nil, err } // By default sql.DB doesn't have a connection limit. This can cause errors // because PostgreSQL has a default of 100 max connections. sqlPostgresDB.SetMaxOpenConns(25) db := &PostgresDB{db: sqlPostgresDB} if err := db.upgrade(); err != nil { sqlPostgresDB.Close() return nil, err } return db, nil } func openTempPostgresDB(source string) (*sql.DB, error) { db, err := sql.Open("postgres", source) if err != nil { return nil, fmt.Errorf("failed to connect to PostgreSQL: %v", err) } // Store all tables in a temporary schema which will be dropped when the // connection to PostgreSQL is closed. db.SetMaxOpenConns(1) if _, err := db.Exec("SET search_path TO pg_temp"); err != nil { return nil, fmt.Errorf("failed to set PostgreSQL search_path: %v", err) } return db, nil } func OpenTempPostgresDB(source string) (Database, error) { sqlPostgresDB, err := openTempPostgresDB(source) if err != nil { return nil, err } db := &PostgresDB{db: sqlPostgresDB, temp: true} if err := db.upgrade(); err != nil { sqlPostgresDB.Close() return nil, err } return db, nil } func (db *PostgresDB) template(t string) string { // Hack to convince postgres to lookup text search configurations in // pg_temp if db.temp { return strings.ReplaceAll(t, "@SCHEMA_PREFIX@", "pg_temp.") } return strings.ReplaceAll(t, "@SCHEMA_PREFIX@", "") } func (db *PostgresDB) upgrade() error { tx, err := db.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(postgresConfigSchema); err != nil { return fmt.Errorf("failed to create Config table: %s", err) } var version int err = tx.QueryRow(`SELECT version FROM "Config"`).Scan(&version) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("failed to query schema version: %s", err) } if version == len(postgresMigrations) { return nil } if version > len(postgresMigrations) { return fmt.Errorf("soju (version %d) older than schema (version %d)", len(postgresMigrations), version) } if version == 0 { if _, err := tx.Exec(db.template(postgresSchema)); err != nil { return fmt.Errorf("failed to initialize schema: %s", err) } } else { for i := version; i < len(postgresMigrations); i++ { if _, err := tx.Exec(db.template(postgresMigrations[i])); err != nil { return fmt.Errorf("failed to execute migration #%v: %v", i, err) } } } _, err = tx.Exec(`INSERT INTO "Config" (id, version) VALUES (1, $1) ON CONFLICT (id) DO UPDATE SET version = $1`, len(postgresMigrations)) if err != nil { return fmt.Errorf("failed to bump schema version: %v", err) } return tx.Commit() } func (db *PostgresDB) Close() error { return db.db.Close() } func (db *PostgresDB) RegisterMetrics(r prometheus.Registerer) error { if err := r.Register(&postgresMetricsCollector{db}); err != nil { return err } return r.Register(promcollectors.NewDBStatsCollector(db.db, "main")) } func (db *PostgresDB) Stats(ctx context.Context) (*DatabaseStats, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() var stats DatabaseStats row := db.db.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM "User") AS users, (SELECT COUNT(*) FROM "Network") AS networks, (SELECT COUNT(*) FROM "Channel") AS channels`) if err := row.Scan(&stats.Users, &stats.Networks, &stats.Channels); err != nil { return nil, err } return &stats, nil } func (db *PostgresDB) ListUsers(ctx context.Context) ([]User, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, `SELECT id, username, password, admin, nick, realname, enabled, downstream_interacted_at, max_networks FROM "User"`) if err != nil { return nil, err } defer rows.Close() var users []User for rows.Next() { var user User var password, nick, realname sql.NullString var downstreamInteractedAt sql.NullTime if err := rows.Scan(&user.ID, &user.Username, &password, &user.Admin, &nick, &realname, &user.Enabled, &downstreamInteractedAt, &user.MaxNetworks); err != nil { return nil, err } user.Password = password.String user.Nick = nick.String user.Realname = realname.String user.DownstreamInteractedAt = downstreamInteractedAt.Time users = append(users, user) } if err := rows.Err(); err != nil { return nil, err } return users, nil } func (db *PostgresDB) GetUser(ctx context.Context, username string) (*User, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() user := &User{Username: username} var password, nick, realname sql.NullString var downstreamInteractedAt sql.NullTime row := db.db.QueryRowContext(ctx, `SELECT id, password, admin, nick, realname, enabled, downstream_interacted_at, max_networks FROM "User" WHERE username = $1`, username) if err := row.Scan(&user.ID, &password, &user.Admin, &nick, &realname, &user.Enabled, &downstreamInteractedAt, &user.MaxNetworks); err != nil { return nil, err } user.Password = password.String user.Nick = nick.String user.Realname = realname.String user.DownstreamInteractedAt = downstreamInteractedAt.Time return user, nil } func (db *PostgresDB) ListInactiveUsernames(ctx context.Context, limit time.Time) ([]string, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, `SELECT username FROM "User" WHERE COALESCE(downstream_interacted_at, created_at) < $1`, limit) if err != nil { return nil, err } defer rows.Close() var usernames []string for rows.Next() { var username string if err := rows.Scan(&username); err != nil { return nil, err } usernames = append(usernames, username) } if err := rows.Err(); err != nil { return nil, err } return usernames, nil } func (db *PostgresDB) StoreUser(ctx context.Context, user *User) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() password := toNullString(user.Password) nick := toNullString(user.Nick) realname := toNullString(user.Realname) downstreamInteractedAt := toNullTime(user.DownstreamInteractedAt) var err error if user.ID == 0 { err = db.db.QueryRowContext(ctx, ` INSERT INTO "User" (username, password, admin, nick, realname, enabled, downstream_interacted_at, max_networks) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, user.Username, password, user.Admin, nick, realname, user.Enabled, downstreamInteractedAt, user.MaxNetworks).Scan(&user.ID) } else { _, err = db.db.ExecContext(ctx, ` UPDATE "User" SET password = $1, admin = $2, nick = $3, realname = $4, enabled = $5, downstream_interacted_at = $6, max_networks = $7 WHERE id = $8`, password, user.Admin, nick, realname, user.Enabled, downstreamInteractedAt, user.MaxNetworks, user.ID) } return err } func (db *PostgresDB) DeleteUser(ctx context.Context, id int64) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() _, err := db.db.ExecContext(ctx, `DELETE FROM "User" WHERE id = $1`, id) return err } func (db *PostgresDB) ListNetworks(ctx context.Context, userID int64) ([]Network, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, ` SELECT id, name, addr, nick, username, realname, certfp, pass, connect_commands, sasl_mechanism, sasl_plain_username, sasl_plain_password, sasl_external_cert, sasl_external_key, auto_away, enabled FROM "Network" WHERE "user" = $1`, userID) if err != nil { return nil, err } defer rows.Close() var networks []Network for rows.Next() { var net Network var name, nick, username, realname, certfp, pass, connectCommands sql.NullString var saslMechanism, saslPlainUsername, saslPlainPassword sql.NullString err := rows.Scan(&net.ID, &name, &net.Addr, &nick, &username, &realname, &certfp, &pass, &connectCommands, &saslMechanism, &saslPlainUsername, &saslPlainPassword, &net.SASL.External.CertBlob, &net.SASL.External.PrivKeyBlob, &net.AutoAway, &net.Enabled) if err != nil { return nil, err } net.Name = name.String net.Nick = nick.String net.Username = username.String net.Realname = realname.String net.CertFP = certfp.String net.Pass = pass.String if connectCommands.Valid { net.ConnectCommands = strings.Split(connectCommands.String, "\r\n") } net.SASL.Mechanism = saslMechanism.String net.SASL.Plain.Username = saslPlainUsername.String net.SASL.Plain.Password = saslPlainPassword.String networks = append(networks, net) } if err := rows.Err(); err != nil { return nil, err } return networks, nil } func (db *PostgresDB) StoreNetwork(ctx context.Context, userID int64, network *Network) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() netName := toNullString(network.Name) nick := toNullString(network.Nick) netUsername := toNullString(network.Username) realname := toNullString(network.Realname) certfp := toNullString(network.CertFP) pass := toNullString(network.Pass) connectCommands := toNullString(strings.Join(network.ConnectCommands, "\r\n")) var saslMechanism, saslPlainUsername, saslPlainPassword sql.NullString if network.SASL.Mechanism != "" { saslMechanism = toNullString(network.SASL.Mechanism) switch network.SASL.Mechanism { case "PLAIN": saslPlainUsername = toNullString(network.SASL.Plain.Username) saslPlainPassword = toNullString(network.SASL.Plain.Password) network.SASL.External.CertBlob = nil network.SASL.External.PrivKeyBlob = nil case "EXTERNAL": // keep saslPlain* nil default: return fmt.Errorf("soju: cannot store network: unsupported SASL mechanism %q", network.SASL.Mechanism) } } var err error if network.ID == 0 { err = db.db.QueryRowContext(ctx, ` INSERT INTO "Network" ("user", name, addr, nick, username, realname, certfp, pass, connect_commands, sasl_mechanism, sasl_plain_username, sasl_plain_password, sasl_external_cert, sasl_external_key, auto_away, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id`, userID, netName, network.Addr, nick, netUsername, realname, certfp, pass, connectCommands, saslMechanism, saslPlainUsername, saslPlainPassword, network.SASL.External.CertBlob, network.SASL.External.PrivKeyBlob, network.AutoAway, network.Enabled).Scan(&network.ID) } else { _, err = db.db.ExecContext(ctx, ` UPDATE "Network" SET name = $2, addr = $3, nick = $4, username = $5, realname = $6, certfp = $7, pass = $8, connect_commands = $9, sasl_mechanism = $10, sasl_plain_username = $11, sasl_plain_password = $12, sasl_external_cert = $13, sasl_external_key = $14, auto_away = $15, enabled = $16 WHERE id = $1`, network.ID, netName, network.Addr, nick, netUsername, realname, certfp, pass, connectCommands, saslMechanism, saslPlainUsername, saslPlainPassword, network.SASL.External.CertBlob, network.SASL.External.PrivKeyBlob, network.AutoAway, network.Enabled) } return err } func (db *PostgresDB) DeleteNetwork(ctx context.Context, id int64) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() _, err := db.db.ExecContext(ctx, `DELETE FROM "Network" WHERE id = $1`, id) return err } func (db *PostgresDB) ListChannels(ctx context.Context, networkID int64) ([]Channel, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, ` SELECT id, name, key, detached, detached_internal_msgid, relay_detached, reattach_on, detach_after, detach_on FROM "Channel" WHERE network = $1`, networkID) if err != nil { return nil, err } defer rows.Close() var channels []Channel for rows.Next() { var ch Channel var key, detachedInternalMsgID sql.NullString var detachAfter int64 if err := rows.Scan(&ch.ID, &ch.Name, &key, &ch.Detached, &detachedInternalMsgID, &ch.RelayDetached, &ch.ReattachOn, &detachAfter, &ch.DetachOn); err != nil { return nil, err } ch.Key = key.String ch.DetachedInternalMsgID = detachedInternalMsgID.String ch.DetachAfter = time.Duration(detachAfter) * time.Second channels = append(channels, ch) } if err := rows.Err(); err != nil { return nil, err } return channels, nil } func (db *PostgresDB) StoreChannel(ctx context.Context, networkID int64, ch *Channel) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() key := toNullString(ch.Key) detachAfter := int64(math.Ceil(ch.DetachAfter.Seconds())) var err error if ch.ID == 0 { err = db.db.QueryRowContext(ctx, ` INSERT INTO "Channel" (network, name, key, detached, detached_internal_msgid, relay_detached, reattach_on, detach_after, detach_on) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, networkID, ch.Name, key, ch.Detached, toNullString(ch.DetachedInternalMsgID), ch.RelayDetached, ch.ReattachOn, detachAfter, ch.DetachOn).Scan(&ch.ID) } else { _, err = db.db.ExecContext(ctx, ` UPDATE "Channel" SET name = $2, key = $3, detached = $4, detached_internal_msgid = $5, relay_detached = $6, reattach_on = $7, detach_after = $8, detach_on = $9 WHERE id = $1`, ch.ID, ch.Name, key, ch.Detached, toNullString(ch.DetachedInternalMsgID), ch.RelayDetached, ch.ReattachOn, detachAfter, ch.DetachOn) } return err } func (db *PostgresDB) DeleteChannel(ctx context.Context, id int64) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() _, err := db.db.ExecContext(ctx, `DELETE FROM "Channel" WHERE id = $1`, id) return err } func (db *PostgresDB) ListDeliveryReceipts(ctx context.Context, networkID int64) ([]DeliveryReceipt, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, ` SELECT id, target, client, internal_msgid FROM "DeliveryReceipt" WHERE network = $1`, networkID) if err != nil { return nil, err } defer rows.Close() var receipts []DeliveryReceipt for rows.Next() { var rcpt DeliveryReceipt if err := rows.Scan(&rcpt.ID, &rcpt.Target, &rcpt.Client, &rcpt.InternalMsgID); err != nil { return nil, err } receipts = append(receipts, rcpt) } if err := rows.Err(); err != nil { return nil, err } return receipts, nil } func (db *PostgresDB) StoreClientDeliveryReceipts(ctx context.Context, networkID int64, client string, receipts []DeliveryReceipt) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() tx, err := db.db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.ExecContext(ctx, `DELETE FROM "DeliveryReceipt" WHERE network = $1 AND client = $2`, networkID, client) if err != nil { return err } stmt, err := tx.PrepareContext(ctx, ` INSERT INTO "DeliveryReceipt" (network, target, client, internal_msgid) VALUES ($1, $2, $3, $4) RETURNING id`) if err != nil { return err } defer stmt.Close() for i := range receipts { rcpt := &receipts[i] err := stmt. QueryRowContext(ctx, networkID, rcpt.Target, client, rcpt.InternalMsgID). Scan(&rcpt.ID) if err != nil { return err } } return tx.Commit() } func (db *PostgresDB) GetReadReceipt(ctx context.Context, networkID int64, name string) (*ReadReceipt, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() receipt := &ReadReceipt{ Target: name, } row := db.db.QueryRowContext(ctx, `SELECT id, timestamp FROM "ReadReceipt" WHERE network = $1 AND target = $2`, networkID, name) if err := row.Scan(&receipt.ID, &receipt.Timestamp); err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, err } return receipt, nil } func (db *PostgresDB) StoreReadReceipt(ctx context.Context, networkID int64, receipt *ReadReceipt) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() var err error if receipt.ID != 0 { _, err = db.db.ExecContext(ctx, ` UPDATE "ReadReceipt" SET timestamp = $1 WHERE id = $2`, receipt.Timestamp, receipt.ID) } else { err = db.db.QueryRowContext(ctx, ` INSERT INTO "ReadReceipt" (network, target, timestamp) VALUES ($1, $2, $3) RETURNING id`, networkID, receipt.Target, receipt.Timestamp).Scan(&receipt.ID) } return err } func (db *PostgresDB) listTopNetworkAddrs(ctx context.Context) (map[string]int, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() addrs := make(map[string]int) rows, err := db.db.QueryContext(ctx, ` SELECT addr, COUNT(addr) AS n FROM "Network" GROUP BY addr ORDER BY n DESC`) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var ( addr string n int ) if err := rows.Scan(&addr, &n); err != nil { return nil, err } addrs[addr] = n } return addrs, rows.Err() } func (db *PostgresDB) ListWebPushConfigs(ctx context.Context) ([]WebPushConfig, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, ` SELECT id, vapid_key_public, vapid_key_private FROM "WebPushConfig"`) if err != nil { return nil, err } defer rows.Close() var configs []WebPushConfig for rows.Next() { var config WebPushConfig if err := rows.Scan(&config.ID, &config.VAPIDKeys.Public, &config.VAPIDKeys.Private); err != nil { return nil, err } configs = append(configs, config) } return configs, rows.Err() } func (db *PostgresDB) StoreWebPushConfig(ctx context.Context, config *WebPushConfig) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() if config.ID != 0 { return fmt.Errorf("cannot update a WebPushConfig") } err := db.db.QueryRowContext(ctx, ` INSERT INTO "WebPushConfig" (created_at, vapid_key_public, vapid_key_private) VALUES (NOW(), $1, $2) RETURNING id`, config.VAPIDKeys.Public, config.VAPIDKeys.Private).Scan(&config.ID) return err } func (db *PostgresDB) ListWebPushSubscriptions(ctx context.Context, userID, networkID int64) ([]WebPushSubscription, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() nullNetworkID := sql.NullInt64{ Int64: networkID, Valid: networkID != 0, } rows, err := db.db.QueryContext(ctx, ` SELECT id, endpoint, created_at, updated_at, key_auth, key_p256dh, key_vapid FROM "WebPushSubscription" WHERE "user" = $1 AND network IS NOT DISTINCT FROM $2`, userID, nullNetworkID) if err != nil { return nil, err } defer rows.Close() var subs []WebPushSubscription for rows.Next() { var sub WebPushSubscription if err := rows.Scan(&sub.ID, &sub.Endpoint, &sub.CreatedAt, &sub.UpdatedAt, &sub.Keys.Auth, &sub.Keys.P256DH, &sub.Keys.VAPID); err != nil { return nil, err } subs = append(subs, sub) } return subs, rows.Err() } func (db *PostgresDB) StoreWebPushSubscription(ctx context.Context, userID, networkID int64, sub *WebPushSubscription) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() nullNetworkID := sql.NullInt64{ Int64: networkID, Valid: networkID != 0, } var err error if sub.ID != 0 { _, err = db.db.ExecContext(ctx, ` UPDATE "WebPushSubscription" SET updated_at = NOW(), key_auth = $1, key_p256dh = $2, key_vapid = $3 WHERE id = $4`, sub.Keys.Auth, sub.Keys.P256DH, sub.Keys.VAPID, sub.ID) } else { err = db.db.QueryRowContext(ctx, ` INSERT INTO "WebPushSubscription" (created_at, updated_at, "user", network, endpoint, key_auth, key_p256dh, key_vapid) VALUES (NOW(), NOW(), $1, $2, $3, $4, $5, $6) RETURNING id`, userID, nullNetworkID, sub.Endpoint, sub.Keys.Auth, sub.Keys.P256DH, sub.Keys.VAPID).Scan(&sub.ID) } return err } func (db *PostgresDB) DeleteWebPushSubscription(ctx context.Context, id int64) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() _, err := db.db.ExecContext(ctx, `DELETE FROM "WebPushSubscription" WHERE id = $1`, id) return err } func (db *PostgresDB) GetMessageLastID(ctx context.Context, networkID int64, name string) (int64, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() var msgID int64 row := db.db.QueryRowContext(ctx, ` SELECT id FROM "Message" WHERE target = ( SELECT id FROM "MessageTarget" WHERE network = $1 AND target = $2 ) ORDER BY time DESC LIMIT 1`, networkID, name, ) if err := row.Scan(&msgID); err != nil { if err == sql.ErrNoRows { return 0, nil } return 0, err } return msgID, nil } func (db *PostgresDB) GetMessageTarget(ctx context.Context, networkID int64, target string) (*MessageTarget, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() mt := &MessageTarget{ Target: target, } row := db.db.QueryRowContext(ctx, `SELECT id, pinned, muted FROM "MessageTarget" WHERE network = $1 AND target = $2`, networkID, target) if err := row.Scan(&mt.ID, &mt.Pinned, &mt.Muted); err != nil && err != sql.ErrNoRows { return nil, err } return mt, nil } func (db *PostgresDB) StoreMessageTarget(ctx context.Context, networkID int64, mt *MessageTarget) error { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() var err error if mt.ID != 0 { _, err = db.db.ExecContext(ctx, ` UPDATE "MessageTarget" SET pinned = $1, muted = $2 WHERE id = $3`, mt.Pinned, mt.Muted, mt.ID) } else { err = db.db.QueryRowContext(ctx, ` INSERT INTO "MessageTarget" (network, target, pinned, muted) VALUES ($1, $2, $3, $4) RETURNING id`, networkID, mt.Target, mt.Pinned, mt.Muted).Scan(&mt.ID) } return err } func (db *PostgresDB) StoreMessages(ctx context.Context, networkID int64, name string, msgs []*irc.Message) ([]int64, error) { if len(msgs) == 0 { return nil, nil } ctx, cancel := context.WithTimeout(ctx, time.Duration(len(msgs))*postgresQueryTimeout) defer cancel() tx, err := db.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() _, err = tx.ExecContext(ctx, ` INSERT INTO "MessageTarget" (network, target) VALUES ($1, $2) ON CONFLICT DO NOTHING`, networkID, name, ) if err != nil { return nil, err } insertStmt, err := tx.PrepareContext(ctx, ` INSERT INTO "Message" (target, raw, time, sender, text) SELECT id, $1, $2, $3, $4 FROM "MessageTarget" as t WHERE network = $5 AND target = $6 RETURNING id`) if err != nil { return nil, err } ids := make([]int64, len(msgs)) for i, msg := range msgs { var t time.Time if tag, ok := msg.Tags["time"]; ok { var err error t, err = time.Parse(xirc.ServerTimeLayout, tag) if err != nil { return nil, fmt.Errorf("failed to parse message time tag: %w", err) } } else { t = time.Now() } var text sql.NullString switch msg.Command { case "PRIVMSG", "NOTICE": if len(msg.Params) > 1 { text.Valid = true text.String = stripANSI(msg.Params[1]) } } err = insertStmt.QueryRowContext(ctx, msg.String(), t, msg.Name, text, networkID, name, ).Scan(&ids[i]) if err != nil { return nil, err } } err = tx.Commit() return ids, err } func (db *PostgresDB) ListMessageLastPerTarget(ctx context.Context, networkID int64, options *MessageOptions) ([]MessageTargetLast, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() parameters := []interface{}{ networkID, } query := ` SELECT t.target, l.latest FROM "MessageTarget" t JOIN LATERAL ( SELECT m.target, m.time AS latest, m.text FROM "Message" m WHERE m.target = t.id ` if !options.Events { query += `AND m.text IS NOT NULL ` } query += ` ORDER BY m.time DESC LIMIT 1 ) AS l ON t.id = l.target WHERE t.network = $1 ` if !options.AfterTime.IsZero() { // compares time strings by lexicographical order parameters = append(parameters, options.AfterTime) query += fmt.Sprintf(`AND l.latest > $%d `, len(parameters)) } if !options.BeforeTime.IsZero() { // compares time strings by lexicographical order parameters = append(parameters, options.BeforeTime) query += fmt.Sprintf(`AND l.latest < $%d `, len(parameters)) } if options.TakeLast { query += `ORDER BY l.latest DESC ` } else { query += `ORDER BY l.latest ASC ` } parameters = append(parameters, options.Limit) query += fmt.Sprintf(`LIMIT $%d`, len(parameters)) rows, err := db.db.QueryContext(ctx, query, parameters...) if err != nil { return nil, err } defer rows.Close() var l []MessageTargetLast for rows.Next() { var mt MessageTargetLast if err := rows.Scan(&mt.Name, &mt.LatestMessage); err != nil { return nil, err } l = append(l, mt) } if err := rows.Err(); err != nil { return nil, err } if options.TakeLast { // We ordered by DESC to limit to the last lines. // Reverse the list to order by ASC these last lines. for i, j := 0, len(l)-1; i < j; i, j = i+1, j-1 { l[i], l[j] = l[j], l[i] } } return l, nil } func (db *PostgresDB) ListMessages(ctx context.Context, networkID int64, name string, options *MessageOptions) ([]*irc.Message, error) { ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout) defer cancel() parameters := []interface{}{ networkID, name, } query := ` SELECT raw FROM "Message" WHERE target = ( SELECT id FROM "MessageTarget" WHERE network = $1 AND target = $2 ) ` if options.AfterID > 0 { parameters = append(parameters, options.AfterID) query += fmt.Sprintf(`AND id > $%d `, len(parameters)) } if !options.AfterTime.IsZero() { // compares time strings by lexicographical order parameters = append(parameters, options.AfterTime) query += fmt.Sprintf(`AND time > $%d `, len(parameters)) } if !options.BeforeTime.IsZero() { // compares time strings by lexicographical order parameters = append(parameters, options.BeforeTime) query += fmt.Sprintf(`AND time < $%d `, len(parameters)) } if options.Sender != "" { parameters = append(parameters, options.Sender) query += fmt.Sprintf(`AND sender = $%d `, len(parameters)) } if options.Text != "" { parameters = append(parameters, options.Text) query += fmt.Sprintf(`AND text_search @@ plainto_tsquery('search_simple', $%d) `, len(parameters)) } if !options.Events { query += `AND text IS NOT NULL ` } if options.TakeLast { query += `ORDER BY time DESC ` } else { query += `ORDER BY time ASC ` } parameters = append(parameters, options.Limit) query += fmt.Sprintf(`LIMIT $%d`, len(parameters)) rows, err := db.db.QueryContext(ctx, query, parameters...) if err != nil { return nil, err } defer rows.Close() var l []*irc.Message for rows.Next() { var raw string if err := rows.Scan(&raw); err != nil { return nil, err } msg, err := irc.ParseMessage(raw) if err != nil { return nil, err } l = append(l, msg) } if err := rows.Err(); err != nil { return nil, err } if options.TakeLast { // We ordered by DESC to limit to the last lines. // Reverse the list to order by ASC these last lines. for i, j := 0, len(l)-1; i < j; i, j = i+1, j-1 { l[i], l[j] = l[j], l[i] } } return l, nil } var postgresNetworksTotalDesc = prometheus.NewDesc("soju_networks_total", "Number of networks", []string{"hostname"}, nil) type postgresMetricsCollector struct { db *PostgresDB } var _ prometheus.Collector = (*postgresMetricsCollector)(nil) func (c *postgresMetricsCollector) Describe(ch chan<- *prometheus.Desc) { ch <- postgresNetworksTotalDesc } func (c *postgresMetricsCollector) Collect(ch chan<- prometheus.Metric) { addrs, err := c.db.listTopNetworkAddrs(context.TODO()) if err != nil { ch <- prometheus.NewInvalidMetric(postgresNetworksTotalDesc, err) return } // Group by hostname hostnames := make(map[string]int) for addr, n := range addrs { hostname := addr network := Network{Addr: addr} if u, err := network.URL(); err == nil { hostname = u.Hostname() } hostnames[hostname] += n } // Group networks with low counts for privacy watermark := 10 grouped := 0 for hostname, n := range hostnames { if n >= watermark && hostname != "" && hostname != "*" { ch <- prometheus.MustNewConstMetric(postgresNetworksTotalDesc, prometheus.GaugeValue, float64(n), hostname) } else { grouped += n } } if grouped > 0 { ch <- prometheus.MustNewConstMetric(postgresNetworksTotalDesc, prometheus.GaugeValue, float64(grouped), "*") } } soju-0.9.0/database/postgres_migrations.go000066400000000000000000000073511477072477000207220ustar00rootroot00000000000000package database var postgresMigrations = []string{ "", // migration #0 is reserved for schema initialization `ALTER TABLE "Network" ALTER COLUMN nick DROP NOT NULL`, ` CREATE TYPE sasl_mechanism AS ENUM ('PLAIN', 'EXTERNAL'); ALTER TABLE "Network" ALTER COLUMN sasl_mechanism TYPE sasl_mechanism USING sasl_mechanism::sasl_mechanism; `, ` CREATE TABLE "ReadReceipt" ( id SERIAL PRIMARY KEY, network INTEGER NOT NULL REFERENCES "Network"(id) ON DELETE CASCADE, target VARCHAR(255) NOT NULL, timestamp TIMESTAMP WITH TIME ZONE NOT NULL, UNIQUE(network, target) ); `, ` CREATE TABLE "WebPushConfig" ( id SERIAL PRIMARY KEY, created_at TIMESTAMP WITH TIME ZONE NOT NULL, vapid_key_public TEXT NOT NULL, vapid_key_private TEXT NOT NULL, UNIQUE(vapid_key_public) ); CREATE TABLE "WebPushSubscription" ( id SERIAL PRIMARY KEY, created_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL, network INTEGER REFERENCES "Network"(id) ON DELETE CASCADE, endpoint TEXT NOT NULL, key_vapid TEXT, key_auth TEXT, key_p256dh TEXT, UNIQUE(network, endpoint) ); `, ` ALTER TABLE "WebPushSubscription" ADD COLUMN "user" INTEGER REFERENCES "User"(id) ON DELETE CASCADE `, `ALTER TABLE "User" ADD COLUMN nick VARCHAR(255)`, // Before this migration, a bug swapped user and network, so empty the // web push subscriptions table ` DELETE FROM "WebPushSubscription"; ALTER TABLE "WebPushSubscription" ALTER COLUMN "user" SET NOT NULL; `, `ALTER TABLE "Network" ADD COLUMN auto_away BOOLEAN NOT NULL DEFAULT TRUE`, `ALTER TABLE "Network" ADD COLUMN certfp TEXT`, `ALTER TABLE "User" ADD COLUMN created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`, `ALTER TABLE "User" ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE`, `ALTER TABLE "User" ADD COLUMN downstream_interacted_at TIMESTAMP WITH TIME ZONE`, ` CREATE TABLE "MessageTarget" ( id SERIAL PRIMARY KEY, network INTEGER NOT NULL REFERENCES "Network"(id) ON DELETE CASCADE, target TEXT NOT NULL, UNIQUE(network, target) ); CREATE TEXT SEARCH DICTIONARY search_simple_dictionary ( TEMPLATE = pg_catalog.simple ); CREATE TEXT SEARCH CONFIGURATION @SCHEMA_PREFIX@search_simple ( COPY = pg_catalog.simple ); ALTER TEXT SEARCH CONFIGURATION @SCHEMA_PREFIX@search_simple ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, hword, hword_part, word WITH @SCHEMA_PREFIX@search_simple_dictionary; CREATE TABLE "Message" ( id SERIAL PRIMARY KEY, target INTEGER NOT NULL REFERENCES "MessageTarget"(id) ON DELETE CASCADE, raw TEXT NOT NULL, time TIMESTAMP WITH TIME ZONE NOT NULL, sender TEXT NOT NULL, text TEXT, text_search tsvector GENERATED ALWAYS AS (to_tsvector('@SCHEMA_PREFIX@search_simple', text)) STORED ); CREATE INDEX "MessageIndex" ON "Message" (target, time); CREATE INDEX "MessageSearchIndex" ON "Message" USING GIN (text_search); `, `ALTER TABLE "User" ADD COLUMN max_networks INTEGER NOT NULL DEFAULT -1`, ` ALTER TABLE "MessageTarget" ADD COLUMN pinned BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE "MessageTarget" ADD COLUMN muted BOOLEAN NOT NULL DEFAULT FALSE; `, ` CREATE INDEX "Network_user_index" ON "Network" ("user"); CREATE INDEX "Channel_network_index" ON "Channel" (network); CREATE INDEX "DeliveryReceipt_network_index" ON "DeliveryReceipt" (network); CREATE INDEX "ReadReceipt_network_index" ON "ReadReceipt" (network); CREATE INDEX "WebPushSubscription_user_index" ON "WebPushSubscription" ("user"); CREATE INDEX "WebPushSubscription_network_index" ON "WebPushSubscription" (network); CREATE INDEX "MessageTarget_network_index" ON "MessageTarget" (network); CREATE INDEX "Message_target_index" ON "MessageTarget" (target); `, } soju-0.9.0/database/postgres_schema.sql000066400000000000000000000102111477072477000201650ustar00rootroot00000000000000CREATE TABLE "User" ( id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE, password VARCHAR(255), admin BOOLEAN NOT NULL DEFAULT FALSE, nick VARCHAR(255), realname VARCHAR(255), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), enabled BOOLEAN NOT NULL DEFAULT TRUE, downstream_interacted_at TIMESTAMP WITH TIME ZONE, max_networks INTEGER NOT NULL DEFAULT -1 ); CREATE TYPE sasl_mechanism AS ENUM ('PLAIN', 'EXTERNAL'); CREATE TABLE "Network" ( id SERIAL PRIMARY KEY, name VARCHAR(255), "user" INTEGER NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, addr VARCHAR(255) NOT NULL, nick VARCHAR(255), username VARCHAR(255), realname VARCHAR(255), certfp TEXT, pass VARCHAR(255), connect_commands VARCHAR(1023), sasl_mechanism sasl_mechanism, sasl_plain_username VARCHAR(255), sasl_plain_password VARCHAR(255), sasl_external_cert BYTEA, sasl_external_key BYTEA, auto_away BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE, UNIQUE("user", addr, nick), UNIQUE("user", name) ); CREATE INDEX "Network_user_index" ON "Network" ("user"); CREATE TABLE "Channel" ( id SERIAL PRIMARY KEY, network INTEGER NOT NULL REFERENCES "Network"(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, key VARCHAR(255), detached BOOLEAN NOT NULL DEFAULT FALSE, detached_internal_msgid VARCHAR(255), relay_detached INTEGER NOT NULL DEFAULT 0, reattach_on INTEGER NOT NULL DEFAULT 0, detach_after INTEGER NOT NULL DEFAULT 0, detach_on INTEGER NOT NULL DEFAULT 0, UNIQUE(network, name) ); CREATE INDEX "Channel_network_index" ON "Channel" (network); CREATE TABLE "DeliveryReceipt" ( id SERIAL PRIMARY KEY, network INTEGER NOT NULL REFERENCES "Network"(id) ON DELETE CASCADE, target VARCHAR(255) NOT NULL, client VARCHAR(255) NOT NULL DEFAULT '', internal_msgid VARCHAR(255) NOT NULL, UNIQUE(network, target, client) ); CREATE INDEX "DeliveryReceipt_network_index" ON "DeliveryReceipt" (network); CREATE TABLE "ReadReceipt" ( id SERIAL PRIMARY KEY, network INTEGER NOT NULL REFERENCES "Network"(id) ON DELETE CASCADE, target VARCHAR(255) NOT NULL, timestamp TIMESTAMP WITH TIME ZONE NOT NULL, UNIQUE(network, target) ); CREATE INDEX "ReadReceipt_network_index" ON "ReadReceipt" (network); CREATE TABLE "WebPushConfig" ( id SERIAL PRIMARY KEY, created_at TIMESTAMP WITH TIME ZONE NOT NULL, vapid_key_public TEXT NOT NULL, vapid_key_private TEXT NOT NULL, UNIQUE(vapid_key_public) ); CREATE TABLE "WebPushSubscription" ( id SERIAL PRIMARY KEY, created_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL, "user" INTEGER NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, network INTEGER REFERENCES "Network"(id) ON DELETE CASCADE, endpoint TEXT NOT NULL, key_vapid TEXT, key_auth TEXT, key_p256dh TEXT, UNIQUE(network, endpoint) ); CREATE INDEX "WebPushSubscription_user_index" ON "WebPushSubscription" ("user"); CREATE INDEX "WebPushSubscription_network_index" ON "WebPushSubscription" (network); CREATE TABLE "MessageTarget" ( id SERIAL PRIMARY KEY, network INTEGER NOT NULL REFERENCES "Network"(id) ON DELETE CASCADE, target TEXT NOT NULL, pinned BOOLEAN NOT NULL DEFAULT FALSE, muted BOOLEAN NOT NULL DEFAULT FALSE, UNIQUE(network, target) ); CREATE INDEX "MessageTarget_network_index" ON "MessageTarget" (network); CREATE TEXT SEARCH DICTIONARY search_simple_dictionary ( TEMPLATE = pg_catalog.simple ); CREATE TEXT SEARCH CONFIGURATION @SCHEMA_PREFIX@search_simple ( COPY = pg_catalog.simple ); ALTER TEXT SEARCH CONFIGURATION @SCHEMA_PREFIX@search_simple ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, hword, hword_part, word WITH @SCHEMA_PREFIX@search_simple_dictionary; CREATE TABLE "Message" ( id SERIAL PRIMARY KEY, target INTEGER NOT NULL REFERENCES "MessageTarget"(id) ON DELETE CASCADE, raw TEXT NOT NULL, time TIMESTAMP WITH TIME ZONE NOT NULL, sender TEXT NOT NULL, text TEXT, text_search tsvector GENERATED ALWAYS AS (to_tsvector('@SCHEMA_PREFIX@search_simple', text)) STORED ); CREATE INDEX "MessageIndex" ON "Message" (target, time); CREATE INDEX "Message_target_index" ON "MessageTarget" (target); CREATE INDEX "MessageSearchIndex" ON "Message" USING GIN (text_search); soju-0.9.0/database/postgres_test.go000066400000000000000000000044031477072477000175200ustar00rootroot00000000000000package database import ( "os" "testing" ) // PostgreSQL version 0 schema. DO NOT EDIT. const postgresV0Schema = ` CREATE TABLE "Config" ( id SMALLINT PRIMARY KEY, version INTEGER NOT NULL, CHECK(id = 1) ); INSERT INTO "Config" (id, version) VALUES (1, 1); CREATE TABLE "User" ( id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE, password VARCHAR(255), admin BOOLEAN NOT NULL DEFAULT FALSE, realname VARCHAR(255) ); CREATE TABLE "Network" ( id SERIAL PRIMARY KEY, name VARCHAR(255), "user" INTEGER NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, addr VARCHAR(255) NOT NULL, nick VARCHAR(255) NOT NULL, username VARCHAR(255), realname VARCHAR(255), pass VARCHAR(255), connect_commands VARCHAR(1023), sasl_mechanism VARCHAR(255), sasl_plain_username VARCHAR(255), sasl_plain_password VARCHAR(255), sasl_external_cert BYTEA DEFAULT NULL, sasl_external_key BYTEA DEFAULT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, UNIQUE("user", addr, nick), UNIQUE("user", name) ); CREATE TABLE "Channel" ( id SERIAL PRIMARY KEY, network INTEGER NOT NULL REFERENCES "Network"(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, key VARCHAR(255), detached BOOLEAN NOT NULL DEFAULT FALSE, detached_internal_msgid VARCHAR(255), relay_detached INTEGER NOT NULL DEFAULT 0, reattach_on INTEGER NOT NULL DEFAULT 0, detach_after INTEGER NOT NULL DEFAULT 0, detach_on INTEGER NOT NULL DEFAULT 0, UNIQUE(network, name) ); CREATE TABLE "DeliveryReceipt" ( id SERIAL PRIMARY KEY, network INTEGER NOT NULL REFERENCES "Network"(id) ON DELETE CASCADE, target VARCHAR(255) NOT NULL, client VARCHAR(255) NOT NULL DEFAULT '', internal_msgid VARCHAR(255) NOT NULL, UNIQUE(network, target, client) ); ` func TestPostgresMigrations(t *testing.T) { source, ok := os.LookupEnv("SOJU_TEST_POSTGRES") if !ok { t.Skip("set SOJU_TEST_POSTGRES to a connection string to execute PostgreSQL tests") } sqlDB, err := openTempPostgresDB(source) if err != nil { t.Fatalf("openTempPostgresDB() failed: %v", err) } db := &PostgresDB{db: sqlDB, temp: true} defer db.Close() if _, err := db.db.Exec(postgresV0Schema); err != nil { t.Fatalf("DB.Exec() failed for v0 schema: %v", err) } if err := db.upgrade(); err != nil { t.Fatalf("PostgresDB.Upgrade() failed: %v", err) } } soju-0.9.0/database/sqlite.go000066400000000000000000001024621477072477000161200ustar00rootroot00000000000000//go:build !nosqlite package database import ( "context" "database/sql" sqldriver "database/sql/driver" _ "embed" "fmt" "math" "strings" "time" "unicode" "github.com/prometheus/client_golang/prometheus" promcollectors "github.com/prometheus/client_golang/prometheus/collectors" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/xirc" ) const SqliteEnabled = true const sqliteQueryTimeout = 5 * time.Second const sqliteTimeLayout = "2006-01-02T15:04:05.000Z" const sqliteTimeFormat = "%Y-%m-%dT%H:%M:%fZ" type sqliteTime struct { time.Time } var ( _ sql.Scanner = (*sqliteTime)(nil) _ sqldriver.Valuer = sqliteTime{} ) func (t *sqliteTime) Scan(value interface{}) error { if value == nil { t.Time = time.Time{} return nil } if s, ok := value.(string); ok { tt, err := time.Parse(sqliteTimeLayout, s) if err != nil { return err } t.Time = tt return nil } return fmt.Errorf("cannot scan time from type %T", value) } func (t sqliteTime) Value() (sqldriver.Value, error) { if t.Time.IsZero() { return nil, nil } return t.UTC().Format(sqliteTimeLayout), nil } //go:embed sqlite_schema.sql var sqliteSchema string type SqliteDB struct { db *sql.DB } func OpenSqliteDB(source string) (Database, error) { sqlSqliteDB, err := sql.Open(sqliteDriver, source+"?"+sqliteOptions) if err != nil { return nil, err } db := &SqliteDB{db: sqlSqliteDB} if err := db.upgrade(); err != nil { sqlSqliteDB.Close() return nil, err } return db, nil } func OpenTempSqliteDB() (Database, error) { // :memory: will open a separate database for each new connection. Make // sure the sql package only uses a single connection via SetMaxOpenConns. // An alternative solution is to use "file::memory:?cache=shared". db, err := OpenSqliteDB(":memory:") if err != nil { return nil, err } db.(*SqliteDB).db.SetMaxOpenConns(1) return db, nil } func (db *SqliteDB) Close() error { return db.db.Close() } func (db *SqliteDB) upgrade() error { var version int if err := db.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { return fmt.Errorf("failed to query schema version: %v", err) } if version == len(sqliteMigrations) { return nil } else if version > len(sqliteMigrations) { return fmt.Errorf("soju (version %d) older than schema (version %d)", len(sqliteMigrations), version) } tx, err := db.db.Begin() if err != nil { return err } defer tx.Rollback() if version == 0 { if _, err := tx.Exec(sqliteSchema); err != nil { return fmt.Errorf("failed to initialize schema: %v", err) } } else { for i := version; i < len(sqliteMigrations); i++ { if _, err := tx.Exec(sqliteMigrations[i]); err != nil { return fmt.Errorf("failed to execute migration #%v: %v", i, err) } } } // For some reason prepared statements don't work here _, err = tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", len(sqliteMigrations))) if err != nil { return fmt.Errorf("failed to bump schema version: %v", err) } return tx.Commit() } func (db *SqliteDB) RegisterMetrics(r prometheus.Registerer) error { return r.Register(promcollectors.NewDBStatsCollector(db.db, "main")) } func (db *SqliteDB) Stats(ctx context.Context) (*DatabaseStats, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() var stats DatabaseStats row := db.db.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM User) AS users, (SELECT COUNT(*) FROM Network) AS networks, (SELECT COUNT(*) FROM Channel) AS channels`) if err := row.Scan(&stats.Users, &stats.Networks, &stats.Channels); err != nil { return nil, err } return &stats, nil } func (db *SqliteDB) ListUsers(ctx context.Context) ([]User, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, `SELECT id, username, password, admin, nick, realname, enabled, downstream_interacted_at, max_networks FROM User`) if err != nil { return nil, err } defer rows.Close() var users []User for rows.Next() { var user User var password, nick, realname sql.NullString var downstreamInteractedAt sqliteTime if err := rows.Scan(&user.ID, &user.Username, &password, &user.Admin, &nick, &realname, &user.Enabled, &downstreamInteractedAt, &user.MaxNetworks); err != nil { return nil, err } user.Password = password.String user.Nick = nick.String user.Realname = realname.String user.DownstreamInteractedAt = downstreamInteractedAt.Time users = append(users, user) } if err := rows.Err(); err != nil { return nil, err } return users, nil } func (db *SqliteDB) GetUser(ctx context.Context, username string) (*User, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() user := &User{Username: username} var password, nick, realname sql.NullString var downstreamInteractedAt sqliteTime row := db.db.QueryRowContext(ctx, `SELECT id, password, admin, nick, realname, enabled, downstream_interacted_at, max_networks FROM User WHERE username = ?`, username) if err := row.Scan(&user.ID, &password, &user.Admin, &nick, &realname, &user.Enabled, &downstreamInteractedAt, &user.MaxNetworks); err != nil { return nil, err } user.Password = password.String user.Nick = nick.String user.Realname = realname.String user.DownstreamInteractedAt = downstreamInteractedAt.Time return user, nil } func (db *SqliteDB) ListInactiveUsernames(ctx context.Context, limit time.Time) ([]string, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, "SELECT username FROM User WHERE coalesce(downstream_interacted_at, created_at) < ?", sqliteTime{limit}) if err != nil { return nil, err } defer rows.Close() var usernames []string for rows.Next() { var username string if err := rows.Scan(&username); err != nil { return nil, err } usernames = append(usernames, username) } if err := rows.Err(); err != nil { return nil, err } return usernames, nil } func (db *SqliteDB) StoreUser(ctx context.Context, user *User) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() args := []interface{}{ sql.Named("username", user.Username), sql.Named("password", toNullString(user.Password)), sql.Named("admin", user.Admin), sql.Named("nick", toNullString(user.Nick)), sql.Named("realname", toNullString(user.Realname)), sql.Named("enabled", user.Enabled), sql.Named("now", sqliteTime{time.Now()}), sql.Named("downstream_interacted_at", sqliteTime{user.DownstreamInteractedAt}), sql.Named("max_networks", user.MaxNetworks), } var err error if user.ID != 0 { _, err = db.db.ExecContext(ctx, ` UPDATE User SET password = :password, admin = :admin, nick = :nick, realname = :realname, enabled = :enabled, downstream_interacted_at = :downstream_interacted_at, max_networks = :max_networks WHERE username = :username`, args...) } else { var res sql.Result res, err = db.db.ExecContext(ctx, ` INSERT INTO User(username, password, admin, nick, realname, created_at, enabled, downstream_interacted_at, max_networks) VALUES (:username, :password, :admin, :nick, :realname, :now, :enabled, :downstream_interacted_at, :max_networks)`, args...) if err != nil { return err } user.ID, err = res.LastInsertId() } return err } func (db *SqliteDB) DeleteUser(ctx context.Context, id int64) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() tx, err := db.db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.ExecContext(ctx, `DELETE FROM DeliveryReceipt WHERE id IN ( SELECT DeliveryReceipt.id FROM DeliveryReceipt JOIN Network ON DeliveryReceipt.network = Network.id WHERE Network.user = ? )`, id) if err != nil { return err } _, err = tx.ExecContext(ctx, `DELETE FROM ReadReceipt WHERE id IN ( SELECT ReadReceipt.id FROM ReadReceipt JOIN Network ON ReadReceipt.network = Network.id WHERE Network.user = ? )`, id) if err != nil { return err } _, err = tx.ExecContext(ctx, `DELETE FROM Message WHERE id IN ( SELECT Message.id FROM Message, MessageTarget, Network WHERE Message.target = MessageTarget.id AND MessageTarget.network = Network.id AND Network.user = ? )`, id) if err != nil { return err } _, err = tx.ExecContext(ctx, `DELETE FROM MessageTarget WHERE id IN ( SELECT MessageTarget.id FROM MessageTarget, Network WHERE MessageTarget.network = Network.id AND Network.user = ? )`, id) if err != nil { return err } _, err = tx.ExecContext(ctx, `DELETE FROM WebPushSubscription WHERE user = ?`, id) if err != nil { return err } _, err = tx.ExecContext(ctx, `DELETE FROM Channel WHERE id IN ( SELECT Channel.id FROM Channel JOIN Network ON Channel.network = Network.id WHERE Network.user = ? )`, id) if err != nil { return err } _, err = tx.ExecContext(ctx, "DELETE FROM Network WHERE user = ?", id) if err != nil { return err } _, err = tx.ExecContext(ctx, "DELETE FROM User WHERE id = ?", id) if err != nil { return err } return tx.Commit() } func (db *SqliteDB) ListNetworks(ctx context.Context, userID int64) ([]Network, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, ` SELECT id, name, addr, nick, username, realname, certfp, pass, connect_commands, sasl_mechanism, sasl_plain_username, sasl_plain_password, sasl_external_cert, sasl_external_key, auto_away, enabled FROM Network WHERE user = ?`, userID) if err != nil { return nil, err } defer rows.Close() var networks []Network for rows.Next() { var net Network var name, nick, username, realname, certfp, pass, connectCommands sql.NullString var saslMechanism, saslPlainUsername, saslPlainPassword sql.NullString err := rows.Scan(&net.ID, &name, &net.Addr, &nick, &username, &realname, &certfp, &pass, &connectCommands, &saslMechanism, &saslPlainUsername, &saslPlainPassword, &net.SASL.External.CertBlob, &net.SASL.External.PrivKeyBlob, &net.AutoAway, &net.Enabled) if err != nil { return nil, err } net.Name = name.String net.Nick = nick.String net.Username = username.String net.Realname = realname.String net.CertFP = certfp.String net.Pass = pass.String if connectCommands.Valid { net.ConnectCommands = strings.Split(connectCommands.String, "\r\n") } net.SASL.Mechanism = saslMechanism.String net.SASL.Plain.Username = saslPlainUsername.String net.SASL.Plain.Password = saslPlainPassword.String networks = append(networks, net) } if err := rows.Err(); err != nil { return nil, err } return networks, nil } func (db *SqliteDB) StoreNetwork(ctx context.Context, userID int64, network *Network) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() var saslMechanism, saslPlainUsername, saslPlainPassword sql.NullString if network.SASL.Mechanism != "" { saslMechanism = toNullString(network.SASL.Mechanism) switch network.SASL.Mechanism { case "PLAIN": saslPlainUsername = toNullString(network.SASL.Plain.Username) saslPlainPassword = toNullString(network.SASL.Plain.Password) network.SASL.External.CertBlob = nil network.SASL.External.PrivKeyBlob = nil case "EXTERNAL": // keep saslPlain* nil default: return fmt.Errorf("soju: cannot store network: unsupported SASL mechanism %q", network.SASL.Mechanism) } } args := []interface{}{ sql.Named("name", toNullString(network.Name)), sql.Named("addr", network.Addr), sql.Named("nick", toNullString(network.Nick)), sql.Named("username", toNullString(network.Username)), sql.Named("realname", toNullString(network.Realname)), sql.Named("certfp", toNullString(network.CertFP)), sql.Named("pass", toNullString(network.Pass)), sql.Named("connect_commands", toNullString(strings.Join(network.ConnectCommands, "\r\n"))), sql.Named("sasl_mechanism", saslMechanism), sql.Named("sasl_plain_username", saslPlainUsername), sql.Named("sasl_plain_password", saslPlainPassword), sql.Named("sasl_external_cert", network.SASL.External.CertBlob), sql.Named("sasl_external_key", network.SASL.External.PrivKeyBlob), sql.Named("auto_away", network.AutoAway), sql.Named("enabled", network.Enabled), sql.Named("id", network.ID), // only for UPDATE sql.Named("user", userID), // only for INSERT } var err error if network.ID != 0 { _, err = db.db.ExecContext(ctx, ` UPDATE Network SET name = :name, addr = :addr, nick = :nick, username = :username, realname = :realname, certfp = :certfp, pass = :pass, connect_commands = :connect_commands, sasl_mechanism = :sasl_mechanism, sasl_plain_username = :sasl_plain_username, sasl_plain_password = :sasl_plain_password, sasl_external_cert = :sasl_external_cert, sasl_external_key = :sasl_external_key, auto_away = :auto_away, enabled = :enabled WHERE id = :id`, args...) } else { var res sql.Result res, err = db.db.ExecContext(ctx, ` INSERT INTO Network(user, name, addr, nick, username, realname, certfp, pass, connect_commands, sasl_mechanism, sasl_plain_username, sasl_plain_password, sasl_external_cert, sasl_external_key, auto_away, enabled) VALUES (:user, :name, :addr, :nick, :username, :realname, :certfp, :pass, :connect_commands, :sasl_mechanism, :sasl_plain_username, :sasl_plain_password, :sasl_external_cert, :sasl_external_key, :auto_away, :enabled)`, args...) if err != nil { return err } network.ID, err = res.LastInsertId() } return err } func (db *SqliteDB) DeleteNetwork(ctx context.Context, id int64) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() tx, err := db.db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.ExecContext(ctx, "DELETE FROM Message WHERE target IN (SELECT id FROM MessageTarget WHERE network = ?)", id) if err != nil { return err } _, err = tx.ExecContext(ctx, "DELETE FROM MessageTarget WHERE network = ?", id) if err != nil { return err } _, err = tx.ExecContext(ctx, "DELETE FROM WebPushSubscription WHERE network = ?", id) if err != nil { return err } _, err = tx.ExecContext(ctx, "DELETE FROM DeliveryReceipt WHERE network = ?", id) if err != nil { return err } _, err = tx.ExecContext(ctx, "DELETE FROM ReadReceipt WHERE network = ?", id) if err != nil { return err } _, err = tx.ExecContext(ctx, "DELETE FROM Channel WHERE network = ?", id) if err != nil { return err } _, err = tx.ExecContext(ctx, "DELETE FROM Network WHERE id = ?", id) if err != nil { return err } return tx.Commit() } func (db *SqliteDB) ListChannels(ctx context.Context, networkID int64) ([]Channel, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, `SELECT id, name, key, detached, detached_internal_msgid, relay_detached, reattach_on, detach_after, detach_on FROM Channel WHERE network = ?`, networkID) if err != nil { return nil, err } defer rows.Close() var channels []Channel for rows.Next() { var ch Channel var key, detachedInternalMsgID sql.NullString var detachAfter int64 if err := rows.Scan(&ch.ID, &ch.Name, &key, &ch.Detached, &detachedInternalMsgID, &ch.RelayDetached, &ch.ReattachOn, &detachAfter, &ch.DetachOn); err != nil { return nil, err } ch.Key = key.String ch.DetachedInternalMsgID = detachedInternalMsgID.String ch.DetachAfter = time.Duration(detachAfter) * time.Second channels = append(channels, ch) } if err := rows.Err(); err != nil { return nil, err } return channels, nil } func (db *SqliteDB) StoreChannel(ctx context.Context, networkID int64, ch *Channel) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() args := []interface{}{ sql.Named("network", networkID), sql.Named("name", ch.Name), sql.Named("key", toNullString(ch.Key)), sql.Named("detached", ch.Detached), sql.Named("detached_internal_msgid", toNullString(ch.DetachedInternalMsgID)), sql.Named("relay_detached", ch.RelayDetached), sql.Named("reattach_on", ch.ReattachOn), sql.Named("detach_after", int64(math.Ceil(ch.DetachAfter.Seconds()))), sql.Named("detach_on", ch.DetachOn), sql.Named("id", ch.ID), // only for UPDATE } var err error if ch.ID != 0 { _, err = db.db.ExecContext(ctx, `UPDATE Channel SET network = :network, name = :name, key = :key, detached = :detached, detached_internal_msgid = :detached_internal_msgid, relay_detached = :relay_detached, reattach_on = :reattach_on, detach_after = :detach_after, detach_on = :detach_on WHERE id = :id`, args...) } else { var res sql.Result res, err = db.db.ExecContext(ctx, `INSERT INTO Channel(network, name, key, detached, detached_internal_msgid, relay_detached, reattach_on, detach_after, detach_on) VALUES (:network, :name, :key, :detached, :detached_internal_msgid, :relay_detached, :reattach_on, :detach_after, :detach_on)`, args...) if err != nil { return err } ch.ID, err = res.LastInsertId() } return err } func (db *SqliteDB) DeleteChannel(ctx context.Context, id int64) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() _, err := db.db.ExecContext(ctx, "DELETE FROM Channel WHERE id = ?", id) return err } func (db *SqliteDB) ListDeliveryReceipts(ctx context.Context, networkID int64) ([]DeliveryReceipt, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, ` SELECT id, target, client, internal_msgid FROM DeliveryReceipt WHERE network = ?`, networkID) if err != nil { return nil, err } defer rows.Close() var receipts []DeliveryReceipt for rows.Next() { var rcpt DeliveryReceipt var client sql.NullString if err := rows.Scan(&rcpt.ID, &rcpt.Target, &client, &rcpt.InternalMsgID); err != nil { return nil, err } rcpt.Client = client.String receipts = append(receipts, rcpt) } if err := rows.Err(); err != nil { return nil, err } return receipts, nil } func (db *SqliteDB) StoreClientDeliveryReceipts(ctx context.Context, networkID int64, client string, receipts []DeliveryReceipt) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() tx, err := db.db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.ExecContext(ctx, "DELETE FROM DeliveryReceipt WHERE network = ? AND client IS ?", networkID, toNullString(client)) if err != nil { return err } for i := range receipts { rcpt := &receipts[i] res, err := tx.ExecContext(ctx, ` INSERT INTO DeliveryReceipt(network, target, client, internal_msgid) VALUES (:network, :target, :client, :internal_msgid)`, sql.Named("network", networkID), sql.Named("target", rcpt.Target), sql.Named("client", toNullString(client)), sql.Named("internal_msgid", rcpt.InternalMsgID)) if err != nil { return err } rcpt.ID, err = res.LastInsertId() if err != nil { return err } } return tx.Commit() } func (db *SqliteDB) GetReadReceipt(ctx context.Context, networkID int64, name string) (*ReadReceipt, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() receipt := &ReadReceipt{ Target: name, } row := db.db.QueryRowContext(ctx, ` SELECT id, timestamp FROM ReadReceipt WHERE network = :network AND target = :target`, sql.Named("network", networkID), sql.Named("target", name), ) var timestamp sqliteTime if err := row.Scan(&receipt.ID, ×tamp); err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, err } receipt.Timestamp = timestamp.Time return receipt, nil } func (db *SqliteDB) StoreReadReceipt(ctx context.Context, networkID int64, receipt *ReadReceipt) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() args := []interface{}{ sql.Named("id", receipt.ID), sql.Named("timestamp", sqliteTime{receipt.Timestamp}), sql.Named("network", networkID), sql.Named("target", receipt.Target), } var err error if receipt.ID != 0 { _, err = db.db.ExecContext(ctx, ` UPDATE ReadReceipt SET timestamp = :timestamp WHERE id = :id`, args...) } else { var res sql.Result res, err = db.db.ExecContext(ctx, ` INSERT INTO ReadReceipt(network, target, timestamp) VALUES (:network, :target, :timestamp)`, args...) if err != nil { return err } receipt.ID, err = res.LastInsertId() } return err } func (db *SqliteDB) ListWebPushConfigs(ctx context.Context) ([]WebPushConfig, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() rows, err := db.db.QueryContext(ctx, ` SELECT id, vapid_key_public, vapid_key_private FROM WebPushConfig`) if err != nil { return nil, err } defer rows.Close() var configs []WebPushConfig for rows.Next() { var config WebPushConfig if err := rows.Scan(&config.ID, &config.VAPIDKeys.Public, &config.VAPIDKeys.Private); err != nil { return nil, err } configs = append(configs, config) } return configs, rows.Err() } func (db *SqliteDB) StoreWebPushConfig(ctx context.Context, config *WebPushConfig) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() if config.ID != 0 { return fmt.Errorf("cannot update a WebPushConfig") } res, err := db.db.ExecContext(ctx, ` INSERT INTO WebPushConfig(created_at, vapid_key_public, vapid_key_private) VALUES (:now, :vapid_key_public, :vapid_key_private)`, sql.Named("vapid_key_public", config.VAPIDKeys.Public), sql.Named("vapid_key_private", config.VAPIDKeys.Private), sql.Named("now", sqliteTime{time.Now()})) if err != nil { return err } config.ID, err = res.LastInsertId() return err } func (db *SqliteDB) ListWebPushSubscriptions(ctx context.Context, userID, networkID int64) ([]WebPushSubscription, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() nullNetworkID := sql.NullInt64{ Int64: networkID, Valid: networkID != 0, } rows, err := db.db.QueryContext(ctx, ` SELECT id, endpoint, created_at, updated_at, key_auth, key_p256dh, key_vapid FROM WebPushSubscription WHERE user = ? AND network IS ?`, userID, nullNetworkID) if err != nil { return nil, err } defer rows.Close() var subs []WebPushSubscription for rows.Next() { var sub WebPushSubscription var createdAt, updatedAt sqliteTime if err := rows.Scan(&sub.ID, &sub.Endpoint, &createdAt, &updatedAt, &sub.Keys.Auth, &sub.Keys.P256DH, &sub.Keys.VAPID); err != nil { return nil, err } sub.CreatedAt = createdAt.Time sub.UpdatedAt = updatedAt.Time subs = append(subs, sub) } return subs, rows.Err() } func (db *SqliteDB) StoreWebPushSubscription(ctx context.Context, userID, networkID int64, sub *WebPushSubscription) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() args := []interface{}{ sql.Named("id", sub.ID), sql.Named("user", userID), sql.Named("network", sql.NullInt64{ Int64: networkID, Valid: networkID != 0, }), sql.Named("now", sqliteTime{time.Now()}), sql.Named("endpoint", sub.Endpoint), sql.Named("key_auth", sub.Keys.Auth), sql.Named("key_p256dh", sub.Keys.P256DH), sql.Named("key_vapid", sub.Keys.VAPID), } var err error if sub.ID != 0 { _, err = db.db.ExecContext(ctx, ` UPDATE WebPushSubscription SET updated_at = :now, key_auth = :key_auth, key_p256dh = :key_p256dh, key_vapid = :key_vapid WHERE id = :id`, args...) } else { var res sql.Result res, err = db.db.ExecContext(ctx, ` INSERT INTO WebPushSubscription(created_at, updated_at, user, network, endpoint, key_auth, key_p256dh, key_vapid) VALUES (:now, :now, :user, :network, :endpoint, :key_auth, :key_p256dh, :key_vapid)`, args...) if err != nil { return err } sub.ID, err = res.LastInsertId() } return err } func (db *SqliteDB) DeleteWebPushSubscription(ctx context.Context, id int64) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() _, err := db.db.ExecContext(ctx, "DELETE FROM WebPushSubscription WHERE id = ?", id) return err } func (db *SqliteDB) GetMessageLastID(ctx context.Context, networkID int64, name string) (int64, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() var msgID int64 row := db.db.QueryRowContext(ctx, ` SELECT m.id FROM Message AS m, MessageTarget AS t WHERE t.network = :network AND t.target = :target AND m.target = t.id ORDER BY m.time DESC LIMIT 1`, sql.Named("network", networkID), sql.Named("target", name), ) if err := row.Scan(&msgID); err != nil { if err == sql.ErrNoRows { return 0, nil } return 0, err } return msgID, nil } func (db *SqliteDB) GetMessageTarget(ctx context.Context, networkID int64, target string) (*MessageTarget, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() mt := &MessageTarget{ Target: target, } row := db.db.QueryRowContext(ctx, ` SELECT id, pinned, muted FROM MessageTarget WHERE network = :network AND target = :target`, sql.Named("network", networkID), sql.Named("target", target), ) if err := row.Scan(&mt.ID, &mt.Pinned, &mt.Muted); err != nil && err != sql.ErrNoRows { return nil, err } return mt, nil } func (db *SqliteDB) StoreMessageTarget(ctx context.Context, networkID int64, mt *MessageTarget) error { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() args := []interface{}{ sql.Named("id", mt.ID), sql.Named("network", networkID), sql.Named("target", mt.Target), sql.Named("pinned", mt.Pinned), sql.Named("muted", mt.Muted), } var err error if mt.ID != 0 { _, err = db.db.ExecContext(ctx, ` UPDATE MessageTarget SET pinned = :pinned, muted = :muted WHERE id = :id`, args...) } else { var res sql.Result res, err = db.db.ExecContext(ctx, ` INSERT INTO MessageTarget(network, target, pinned, muted) VALUES (:network, :target, :pinned, :muted)`, args...) if err != nil { return err } mt.ID, err = res.LastInsertId() } return err } func (db *SqliteDB) StoreMessages(ctx context.Context, networkID int64, name string, msgs []*irc.Message) ([]int64, error) { if len(msgs) == 0 { return nil, nil } ctx, cancel := context.WithTimeout(ctx, time.Duration(len(msgs))*sqliteQueryTimeout) defer cancel() tx, err := db.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() res, err := tx.ExecContext(ctx, ` INSERT INTO MessageTarget(network, target) VALUES (:network, :target) ON CONFLICT DO NOTHING`, sql.Named("network", networkID), sql.Named("target", name), ) if err != nil { return nil, err } insertStmt, err := tx.PrepareContext(ctx, ` INSERT INTO Message(target, raw, time, sender, text) SELECT id, :raw, :time, :sender, :text FROM MessageTarget as t WHERE network = :network AND target = :target`) if err != nil { return nil, err } ids := make([]int64, len(msgs)) for i, msg := range msgs { var t time.Time if tag, ok := msg.Tags["time"]; ok { var err error t, err = time.Parse(xirc.ServerTimeLayout, tag) if err != nil { return nil, fmt.Errorf("failed to parse message time tag: %w", err) } } else { t = time.Now() } var text sql.NullString switch msg.Command { case "PRIVMSG", "NOTICE": if len(msg.Params) > 1 { text.Valid = true text.String = stripANSI(msg.Params[1]) } } res, err = insertStmt.ExecContext(ctx, sql.Named("network", networkID), sql.Named("target", name), sql.Named("raw", msg.String()), sql.Named("time", sqliteTime{t}), sql.Named("sender", msg.Name), sql.Named("text", text), ) if err != nil { return nil, err } ids[i], err = res.LastInsertId() if err != nil { return nil, err } } err = tx.Commit() return ids, err } func (db *SqliteDB) ListMessageLastPerTarget(ctx context.Context, networkID int64, options *MessageOptions) ([]MessageTargetLast, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() innerQuery := ` SELECT time FROM Message WHERE target = MessageTarget.id ` if !options.Events { innerQuery += `AND text IS NOT NULL ` } innerQuery += ` ORDER BY time DESC LIMIT 1 ` query := ` SELECT target, (` + innerQuery + `) latest FROM MessageTarget WHERE network = :network ` if !options.AfterTime.IsZero() { // compares time strings by lexicographical order query += `AND latest > :after ` } if !options.BeforeTime.IsZero() { // compares time strings by lexicographical order query += `AND latest < :before ` } if options.TakeLast { query += `ORDER BY latest DESC ` } else { query += `ORDER BY latest ASC ` } query += `LIMIT :limit` rows, err := db.db.QueryContext(ctx, query, sql.Named("network", networkID), sql.Named("after", sqliteTime{options.AfterTime}), sql.Named("before", sqliteTime{options.BeforeTime}), sql.Named("limit", options.Limit), ) if err != nil { return nil, err } defer rows.Close() var l []MessageTargetLast for rows.Next() { var mt MessageTargetLast var ts sqliteTime if err := rows.Scan(&mt.Name, &ts); err != nil { return nil, err } mt.LatestMessage = ts.Time l = append(l, mt) } if err := rows.Err(); err != nil { return nil, err } if options.TakeLast { // We ordered by DESC to limit to the last lines. // Reverse the list to order by ASC these last lines. for i, j := 0, len(l)-1; i < j; i, j = i+1, j-1 { l[i], l[j] = l[j], l[i] } } return l, nil } func (db *SqliteDB) ListMessages(ctx context.Context, networkID int64, name string, options *MessageOptions) ([]*irc.Message, error) { ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) defer cancel() query := ` SELECT m.raw FROM Message AS m, MessageTarget AS t WHERE m.target = t.id AND t.network = :network AND t.target = :target ` if options.AfterID > 0 { query += `AND m.id > :afterID ` } if !options.AfterTime.IsZero() { // compares time strings by lexicographical order query += `AND m.time > :after ` } if !options.BeforeTime.IsZero() { // compares time strings by lexicographical order query += `AND m.time < :before ` } if options.Sender != "" { query += `AND m.sender = :sender ` } if options.Text != "" { query += `AND m.id IN (SELECT ROWID FROM MessageFTS WHERE MessageFTS MATCH :text) ` } if !options.Events { query += `AND m.text IS NOT NULL ` } if options.TakeLast { query += `ORDER BY m.time DESC ` } else { query += `ORDER BY m.time ASC ` } query += `LIMIT :limit` rows, err := db.db.QueryContext(ctx, query, sql.Named("network", networkID), sql.Named("target", name), sql.Named("afterID", options.AfterID), sql.Named("after", sqliteTime{options.AfterTime}), sql.Named("before", sqliteTime{options.BeforeTime}), sql.Named("sender", options.Sender), sql.Named("text", quoteFTSQuery(options.Text)), sql.Named("limit", options.Limit), ) if err != nil { return nil, err } defer rows.Close() var l []*irc.Message for rows.Next() { var raw string if err := rows.Scan(&raw); err != nil { return nil, err } msg, err := irc.ParseMessage(raw) if err != nil { return nil, err } l = append(l, msg) } if err := rows.Err(); err != nil { return nil, err } if options.TakeLast { // We ordered by DESC to limit to the last lines. // Reverse the list to order by ASC these last lines. for i, j := 0, len(l)-1; i < j; i, j = i+1, j-1 { l[i], l[j] = l[j], l[i] } } return l, nil } var ftsQueryTokenEscaper = strings.NewReplacer(`"`, `""`) func quoteFTSQuery(query string) string { // By default, FTS5 queries have a specific syntax, can include logical operators, ... // In order to mirror the behavior of the other stores, we quote the query so that the string is matched as is. // We could quote the whole string, e.g. `"foo baz"`, but then this would match the exact substring, and not the // presence of the two tokens `foo` and `baz` in the line, like in `foo bar baz`, which would be nice to have. // So, we need to quote each token, i.e. `"foo" "baz"`. // In order to quote each token, we must split on "separators", then quote each token with `"`. // The specification of a separator depends on the tokenizer used. We currently use the default tokenizer, which // specifies separators as anything that is not \pL, \pN, \p{Co} (see below). // We must additionally escape double quote characters in the tokens, with a simple replacer. // https://www.sqlite.org/fts5.html#fts5_strings // Within an FTS expression a string may be specified in one of two ways: // * By enclosing it in double quotes ("). // Within a string, any embedded double quote characters may be escaped SQL-style by adding a second double-quote // character. // * As an FTS5 bareword [...] a string of one or more consecutive characters that are all [...]. // Strings that include any other characters must be quoted. // [...] // FTS5 features three built-in tokenizer modules [...]: // * The unicode61 tokenizer, based on the Unicode 6.1 standard. This is the default. // [...] // The unicode tokenizer classifies all unicode characters as either "separator" or "token" characters. [...] // All unicode characters assigned to a general category beginning with "L" or "N" (letters and numbers, // specifically) or to category "Co" ("other, private use") are considered tokens. // All other characters are separators. tokens := strings.FieldsFunc(query, func(r rune) bool { return !unicode.In(r, unicode.L, unicode.N, unicode.Co) }) var sb strings.Builder for _, token := range tokens { if sb.Len() > 0 { sb.WriteRune(' ') } sb.WriteRune('"') ftsQueryTokenEscaper.WriteString(&sb, token) sb.WriteRune('"') } return sb.String() } soju-0.9.0/database/sqlite_mattn.go000066400000000000000000000005741477072477000173240ustar00rootroot00000000000000//go:build !moderncsqlite && !nosqlite package database import ( _ "git.sr.ht/~emersion/go-sqlite3-fts5" _ "github.com/mattn/go-sqlite3" ) var sqliteDriver = "sqlite3" // See https://kerkour.com/sqlite-for-servers // Keep in sync with modernc counterpart. const sqliteOptions = "_foreign_keys=true&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_txlock=immediate" soju-0.9.0/database/sqlite_migrations.go000066400000000000000000000150101477072477000203440ustar00rootroot00000000000000//go:build !nosqlite package database var sqliteMigrations = []string{ "", // migration #0 is reserved for schema initialization "ALTER TABLE Network ADD COLUMN connect_commands VARCHAR(1023)", "ALTER TABLE Channel ADD COLUMN detached INTEGER NOT NULL DEFAULT 0", "ALTER TABLE Network ADD COLUMN sasl_external_cert BLOB DEFAULT NULL", "ALTER TABLE Network ADD COLUMN sasl_external_key BLOB DEFAULT NULL", "ALTER TABLE User ADD COLUMN admin INTEGER NOT NULL DEFAULT 0", ` CREATE TABLE UserNew ( id INTEGER PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE, password VARCHAR(255), admin INTEGER NOT NULL DEFAULT 0 ); INSERT INTO UserNew SELECT rowid, username, password, admin FROM User; DROP TABLE User; ALTER TABLE UserNew RENAME TO User; `, ` CREATE TABLE NetworkNew ( id INTEGER PRIMARY KEY, name VARCHAR(255), user INTEGER NOT NULL, addr VARCHAR(255) NOT NULL, nick VARCHAR(255) NOT NULL, username VARCHAR(255), realname VARCHAR(255), pass VARCHAR(255), connect_commands VARCHAR(1023), sasl_mechanism VARCHAR(255), sasl_plain_username VARCHAR(255), sasl_plain_password VARCHAR(255), sasl_external_cert BLOB DEFAULT NULL, sasl_external_key BLOB DEFAULT NULL, FOREIGN KEY(user) REFERENCES User(id), UNIQUE(user, addr, nick), UNIQUE(user, name) ); INSERT INTO NetworkNew SELECT Network.id, name, User.id as user, addr, nick, Network.username, realname, pass, connect_commands, sasl_mechanism, sasl_plain_username, sasl_plain_password, sasl_external_cert, sasl_external_key FROM Network JOIN User ON Network.user = User.username; DROP TABLE Network; ALTER TABLE NetworkNew RENAME TO Network; `, ` ALTER TABLE Channel ADD COLUMN relay_detached INTEGER NOT NULL DEFAULT 0; ALTER TABLE Channel ADD COLUMN reattach_on INTEGER NOT NULL DEFAULT 0; ALTER TABLE Channel ADD COLUMN detach_after INTEGER NOT NULL DEFAULT 0; ALTER TABLE Channel ADD COLUMN detach_on INTEGER NOT NULL DEFAULT 0; `, ` CREATE TABLE DeliveryReceipt ( id INTEGER PRIMARY KEY, network INTEGER NOT NULL, target VARCHAR(255) NOT NULL, client VARCHAR(255), internal_msgid VARCHAR(255) NOT NULL, FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, target, client) ); `, "ALTER TABLE Channel ADD COLUMN detached_internal_msgid VARCHAR(255)", "ALTER TABLE Network ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1", "ALTER TABLE User ADD COLUMN realname VARCHAR(255)", ` CREATE TABLE NetworkNew ( id INTEGER PRIMARY KEY, name TEXT, user INTEGER NOT NULL, addr TEXT NOT NULL, nick TEXT, username TEXT, realname TEXT, pass TEXT, connect_commands TEXT, sasl_mechanism TEXT, sasl_plain_username TEXT, sasl_plain_password TEXT, sasl_external_cert BLOB, sasl_external_key BLOB, enabled INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(user) REFERENCES User(id), UNIQUE(user, addr, nick), UNIQUE(user, name) ); INSERT INTO NetworkNew SELECT id, name, user, addr, nick, username, realname, pass, connect_commands, sasl_mechanism, sasl_plain_username, sasl_plain_password, sasl_external_cert, sasl_external_key, enabled FROM Network; DROP TABLE Network; ALTER TABLE NetworkNew RENAME TO Network; `, ` CREATE TABLE ReadReceipt ( id INTEGER PRIMARY KEY, network INTEGER NOT NULL, target TEXT NOT NULL, timestamp TEXT NOT NULL, FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, target) ); `, ` CREATE TABLE WebPushConfig ( id INTEGER PRIMARY KEY, created_at TEXT NOT NULL, vapid_key_public TEXT NOT NULL, vapid_key_private TEXT NOT NULL, UNIQUE(vapid_key_public) ); CREATE TABLE WebPushSubscription ( id INTEGER PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, network INTEGER, endpoint TEXT NOT NULL, key_vapid TEXT, key_auth TEXT, key_p256dh TEXT, FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, endpoint) ); `, ` ALTER TABLE WebPushSubscription ADD COLUMN user INTEGER REFERENCES User(id); UPDATE WebPushSubscription AS wps SET user = (SELECT n.user FROM Network AS n WHERE n.id = wps.network); `, "ALTER TABLE User ADD COLUMN nick TEXT;", "ALTER TABLE Network ADD COLUMN auto_away INTEGER NOT NULL DEFAULT 1;", "ALTER TABLE Network ADD COLUMN certfp TEXT;", // SQLite doesn't support non-constant default values, so use an empty // string as default and update all columns in a separate statement ` ALTER TABLE User ADD COLUMN created_at TEXT NOT NULL DEFAULT ''; UPDATE User SET created_at = strftime('` + sqliteTimeFormat + `', 'now'); `, "ALTER TABLE User ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1", "ALTER TABLE User ADD COLUMN downstream_interacted_at TEXT;", ` CREATE TABLE Message ( id INTEGER PRIMARY KEY, target INTEGER NOT NULL, raw TEXT NOT NULL, time TEXT NOT NULL, sender TEXT NOT NULL, text TEXT, FOREIGN KEY(target) REFERENCES MessageTarget(id) ); CREATE INDEX MessageIndex ON Message(target, time); CREATE TABLE MessageTarget ( id INTEGER PRIMARY KEY, network INTEGER NOT NULL, target TEXT NOT NULL, FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, target) ); CREATE VIRTUAL TABLE MessageFTS USING fts5 ( text, content=Message, content_rowid=id ); CREATE TRIGGER MessageFTSInsert AFTER INSERT ON Message BEGIN INSERT INTO MessageFTS(rowid, text) VALUES (new.id, new.text); END; CREATE TRIGGER MessageFTSDelete AFTER DELETE ON Message BEGIN INSERT INTO MessageFTS(MessageFTS, rowid, text) VALUES ('delete', old.id, old.text); END; CREATE TRIGGER MessageFTSUpdate AFTER UPDATE ON Message BEGIN INSERT INTO MessageFTS(MessageFTS, rowid, text) VALUES ('delete', old.id, old.text); INSERT INTO MessageFTS(rowid, text) VALUES (new.id, new.text); END; `, "ALTER TABLE User ADD COLUMN max_networks INTEGER NOT NULL DEFAULT -1", ` ALTER TABLE MessageTarget ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0; ALTER TABLE MessageTarget ADD COLUMN muted INTEGER NOT NULL DEFAULT 0; `, ` CREATE INDEX Network_user_index ON Network(user); CREATE INDEX Channel_network_index ON Channel(network); CREATE INDEX DeliveryReceipt_network_index ON DeliveryReceipt(network); CREATE INDEX ReadReceipt_network_index ON ReadReceipt(network); CREATE INDEX WebPushSubscription_user_index ON WebPushSubscription(user); CREATE INDEX WebPushSubscription_network_index ON WebPushSubscription(network); CREATE INDEX Message_target_index ON Message(target); CREATE INDEX MessageTarget_network_index ON MessageTarget(network); `, } soju-0.9.0/database/sqlite_modernc.go000066400000000000000000000004701477072477000176230ustar00rootroot00000000000000//go:build moderncsqlite && !nosqlite package database import ( _ "modernc.org/sqlite" ) var sqliteDriver = "sqlite" // Keep in sync with mattn counterpart. const sqliteOptions = "_pragma=foreign_keys(true)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_txlock=immediate" soju-0.9.0/database/sqlite_schema.sql000066400000000000000000000073211477072477000176300ustar00rootroot00000000000000CREATE TABLE User ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password TEXT, admin INTEGER NOT NULL DEFAULT 0, realname TEXT, nick TEXT, created_at TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, downstream_interacted_at TEXT, max_networks INTEGER NOT NULL DEFAULT -1 ); CREATE TABLE Network ( id INTEGER PRIMARY KEY, name TEXT, user INTEGER NOT NULL, addr TEXT NOT NULL, nick TEXT, username TEXT, realname TEXT, certfp TEXT, pass TEXT, connect_commands TEXT, sasl_mechanism TEXT, sasl_plain_username TEXT, sasl_plain_password TEXT, sasl_external_cert BLOB, sasl_external_key BLOB, auto_away INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(user) REFERENCES User(id), UNIQUE(user, addr, nick), UNIQUE(user, name) ); CREATE INDEX Network_user_index ON Network(user); CREATE TABLE Channel ( id INTEGER PRIMARY KEY, network INTEGER NOT NULL, name TEXT NOT NULL, key TEXT, detached INTEGER NOT NULL DEFAULT 0, detached_internal_msgid TEXT, relay_detached INTEGER NOT NULL DEFAULT 0, reattach_on INTEGER NOT NULL DEFAULT 0, detach_after INTEGER NOT NULL DEFAULT 0, detach_on INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, name) ); CREATE INDEX Channel_network_index ON Channel(network); CREATE TABLE DeliveryReceipt ( id INTEGER PRIMARY KEY, network INTEGER NOT NULL, target TEXT NOT NULL, client TEXT, internal_msgid TEXT NOT NULL, FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, target, client) ); CREATE INDEX DeliveryReceipt_network_index ON DeliveryReceipt(network); CREATE TABLE ReadReceipt ( id INTEGER PRIMARY KEY, network INTEGER NOT NULL, target TEXT NOT NULL, timestamp TEXT NOT NULL, FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, target) ); CREATE INDEX ReadReceipt_network_index ON ReadReceipt(network); CREATE TABLE WebPushConfig ( id INTEGER PRIMARY KEY, created_at TEXT NOT NULL, vapid_key_public TEXT NOT NULL, vapid_key_private TEXT NOT NULL, UNIQUE(vapid_key_public) ); CREATE TABLE WebPushSubscription ( id INTEGER PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, user INTEGER NOT NULL, network INTEGER, endpoint TEXT NOT NULL, key_vapid TEXT, key_auth TEXT, key_p256dh TEXT, FOREIGN KEY(user) REFERENCES User(id), FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, endpoint) ); CREATE INDEX WebPushSubscription_user_index ON WebPushSubscription(user); CREATE INDEX WebPushSubscription_network_index ON WebPushSubscription(network); CREATE TABLE Message ( id INTEGER PRIMARY KEY, target INTEGER NOT NULL, raw TEXT NOT NULL, time TEXT NOT NULL, sender TEXT NOT NULL, text TEXT, FOREIGN KEY(target) REFERENCES MessageTarget(id) ); CREATE INDEX MessageIndex ON Message(target, time); CREATE INDEX Message_target_index ON Message(target); CREATE TABLE MessageTarget ( id INTEGER PRIMARY KEY, network INTEGER NOT NULL, target TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0, muted INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, target) ); CREATE INDEX MessageTarget_network_index ON MessageTarget(network); CREATE VIRTUAL TABLE MessageFTS USING fts5 ( text, content=Message, content_rowid=id ); CREATE TRIGGER MessageFTSInsert AFTER INSERT ON Message BEGIN INSERT INTO MessageFTS(rowid, text) VALUES (new.id, new.text); END; CREATE TRIGGER MessageFTSDelete AFTER DELETE ON Message BEGIN INSERT INTO MessageFTS(MessageFTS, rowid, text) VALUES ('delete', old.id, old.text); END; CREATE TRIGGER MessageFTSUpdate AFTER UPDATE ON Message BEGIN INSERT INTO MessageFTS(MessageFTS, rowid, text) VALUES ('delete', old.id, old.text); INSERT INTO MessageFTS(rowid, text) VALUES (new.id, new.text); END; soju-0.9.0/database/sqlite_stub.go000066400000000000000000000004171477072477000171520ustar00rootroot00000000000000//go:build nosqlite package database import ( "errors" ) const SqliteEnabled = false func OpenSqliteDB(source string) (Database, error) { return nil, errors.New("SQLite support is disabled") } func OpenTempSqliteDB() (Database, error) { return OpenSqliteDB("") } soju-0.9.0/database/sqlite_test.go000066400000000000000000000023551477072477000171570ustar00rootroot00000000000000//go:build !nosqlite package database import ( "database/sql" "testing" ) // SQLite version 0 schema. DO NOT EDIT. const sqliteV0Schema = ` CREATE TABLE User ( username VARCHAR(255) NOT NULL UNIQUE, password VARCHAR(255) ); CREATE TABLE Network ( id INTEGER PRIMARY KEY, name VARCHAR(255), user VARCHAR(255) NOT NULL, addr VARCHAR(255) NOT NULL, nick VARCHAR(255) NOT NULL, username VARCHAR(255), realname VARCHAR(255), pass VARCHAR(255), sasl_mechanism VARCHAR(255), sasl_plain_username VARCHAR(255), sasl_plain_password VARCHAR(255), UNIQUE(user, addr, nick), UNIQUE(user, name) ); CREATE TABLE Channel ( id INTEGER PRIMARY KEY, network INTEGER NOT NULL, name VARCHAR(255) NOT NULL, key VARCHAR(255), FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, name) ); PRAGMA user_version = 1; ` func TestSqliteMigrations(t *testing.T) { sqlDB, err := sql.Open(sqliteDriver, ":memory:") if err != nil { t.Fatalf("failed to create temporary SQLite database: %v", err) } if _, err := sqlDB.Exec(sqliteV0Schema); err != nil { t.Fatalf("DB.Exec() failed for v0 schema: %v", err) } db := &SqliteDB{db: sqlDB} defer db.Close() if err := db.upgrade(); err != nil { t.Fatalf("SqliteDB.Upgrade() failed: %v", err) } } soju-0.9.0/database/utils.go000066400000000000000000000021421477072477000157510ustar00rootroot00000000000000package database import "strings" func startsHexColor(s string) bool { if len(s) < 6 { return false } for _, r := range s[:6] { switch { case r >= '0' && r <= '9': case r >= 'a' && r <= 'f': case r >= 'A' && r <= 'F': default: return false } } return true } func stripANSI(s string) string { if !strings.ContainsAny(s, "\x02\x1D\x1F\x1E\x11\x16\x0F\x03\x04") { // Fast case: no formatting return s } var sb strings.Builder sb.Grow(len(s)) for i := 0; i < len(s); i++ { b := s[i] switch b { case '\x02', '\x1D', '\x1F', '\x1E', '\x11', '\x16', '\x0F': case '\x03': if len(s) <= i+1 || s[i+1] < '0' || s[i+1] > '9' { break } i++ if len(s) > i+1 && s[i+1] >= '0' && s[i+1] <= '9' { i++ } if len(s) > i+2 && s[i+1] == ',' && s[i+2] >= '0' && s[i+2] <= '9' { i += 2 if len(s) > i+1 && s[i+1] >= '0' && s[i+1] <= '9' { i++ } } case '\x04': if !startsHexColor(s[i+1:]) { break } i += 6 if len(s) > i+1 && s[i+1] == ',' && startsHexColor(s[i+2:]) { i += 7 } default: sb.WriteByte(b) } } return sb.String() } soju-0.9.0/doc/000077500000000000000000000000001477072477000132645ustar00rootroot00000000000000soju-0.9.0/doc/architecture.md000066400000000000000000000026621477072477000162760ustar00rootroot00000000000000# soju architecture soju manages two types of connections: - Upstream connections: soju maintains persistent connections to user-configured IRC servers - Downstream connections: soju accepts connections from IRC clients On startup, soju will iterate over the list of networks stored in the database and try to open an upstream connection for each network. ## Ring buffer In order to correctly send history to each downstream client, soju maintains for each upstream channel a single-producer multiple-consumer ring buffer. The network's upstream connection produces messages and multiple downstream connections consume these messages. Each downstream client may have a different cursor in the history: for instance a client may be 10 messages late while another has consumed all pending messages. ## Goroutines Each type of connection has two dedicated goroutines: the first one reads incoming messages, the second one writes outgoing messages. Each user has a dedicated goroutine responsible for dispatching all messages. It communicates via channels with the per-connection reader and writer goroutines. This allows to keep the dispatching logic simple (by avoiding any race condition or inconsistent state) and to rate-limit each user. The user dispatcher goroutine receives from the `user.events` channel. Upstream and downstream message handlers are called from this goroutine, thus they can safely access both upstream and downstream state. soju-0.9.0/doc/ext/000077500000000000000000000000001477072477000140645ustar00rootroot00000000000000soju-0.9.0/doc/ext/README.md000066400000000000000000000013741477072477000153500ustar00rootroot00000000000000# soju IRC extensions This directory documents various soju-specific IRC extensions. All of the extensions are under the vendored `soju.im` namespace. Introducing a vendored extension is a good way to experiment with new ideas and do some field testing. If a vendored extension is successful, please open a pull request on the [IRCv3 specifications] repository for standardization. ## License All of the specifications in this directory are dual-licensed under the terms of the AGPLv3, or the following terms, at your discretion: > Unlimited redistribution and modification of this document is allowed provided > that the above copyright notice and this permission notice remains intact. [IRCv3 specifications]: https://github.com/ircv3/ircv3-specifications soju-0.9.0/doc/ext/account-required.md000066400000000000000000000012111477072477000176530ustar00rootroot00000000000000# account-required This specification defines the informational `soju.im/account-required` capability. If present, it indicates that the connection to the server cannot be completed unless the clients authenticates, typically via SASL. Note, the absence of this capability does not indicate that connection registration can be completed without authentication; it may be disallowed due to specific properties of the connection (e.g. an untrustworthy IP address), which will be indicated instead by `FAIL * ACCOUNT_REQUIRED`. Clients MUST NOT request `soju.im/account-required`; servers MUST reject any `CAP REQ` command including this capability. soju-0.9.0/doc/ext/bouncer-networks.md000066400000000000000000000213031477072477000177140ustar00rootroot00000000000000--- title: Bouncer networks extension layout: spec work-in-progress: true copyrights: - name: "Darren Whitlen" period: "2020" email: "darren@kiwiirc.com" - name: "Simon Ser" period: "2021" email: "contact@emersion.fr" --- # bouncer-networks ## Description This document describes the `soju.im/bouncer-networks` extension. This enables clients to discover servers that are bouncers, list and edit upstream networks the bouncer is connected to. Each network has a unique per-user ID called "netid". It MUST NOT change during the lifetime of the network. TODO: character restrictions for network IDs. Networks also have attributes. Attributes are encoded in the message-tag format. Clients MUST ignore unknown attributes. ## Implementation The `soju.im/bouncer-networks` extension defines a new `RPL_ISUPPORT` token and a new `BOUNCER` command. The `soju.im/bouncer-networks` capability MUST be negotiated. This allows the server and client to behave differently when the client is aware of the bouncer networks. The `soju.im/bouncer-networks-notify` capability MAY be negotiated. This allows the client to signal that it is capable of receiving and correctly processing bouncer network notifications. ### `RPL_ISUPPORT` token The server can advertise a `BOUNCER_NETID` token in its `RPL_ISUPPORT` message. Its optional value is the network ID bound for the current connection. ### `soju.im/bouncer-networks` batch The `soju.im/bouncer-networks` batch does not take any parameter and can only contain `BOUNCER NETWORK` messages. ### `BOUNCER` command A new `BOUNCER` command is introduced. It has a case-insensitive subcommand: BOUNCER #### `BIND` subcommand The `BIND` subcommand selects an upstream network to bind to for the lifetime of the current connection. Clients can only send it before the connection registration completes. BOUNCER BIND #### `LISTNETWORKS` subcommand The `LISTNETWORKS` subcommand queries the list of upstream networks. BOUNCER LISTNETWORKS The server replies with a `soju.im/bouncer-networks` batch, containing any number of `BOUNCER NETWORK` messages: BOUNCER NETWORK #### `ADDNETWORK` subcommand The `ADDNETWORK` subcommand registers a new upstream network in the bouncer. BOUNCER ADDNETWORK The bouncer MAY reject this new network for any reason, in this case it MUST reply with an error. If the request is accepted, the bouncer MUST generate a new unique network ID. The bouncer MAY populate unspecified attributes with implementation-defined defaults. Clients MUST specify at least the `host` attribute. If the client doesn't specify the `tls` attribute, the server SHOULD use the default `1`. If the client doesn't specify the `port` attribute, the server SHOULD use the default `6697` if `tls=1` or `6667` if `tls=0`. On success, the server replies with: BOUNCER ADDNETWORK #### `CHANGENETWORK` subcommand The `CHANGENETWORK` subcommand changes attributes of an existing upstream network. BOUNCER CHANGENETWORK The bouncer MAY reject the change for any reason, in this case it MUST reply with an error. At least one attribute MUST be specified by the client. On success, the server replies with: BOUNCER CHANGENETWORK #### `DELNETWORK` subcommand The `DELNETWORK` subcommand removes an existing upstream network. BOUNCER DELNETWORK The bouncer MAY reject the change for any reason, in this case it MUST reply with an error. On success, the server replies with: BOUNCER DELNETWORK ### Network notifications If the client has negotiated the `soju.im/bouncer-networks-notify` capability, the server MUST send an initial batch of `BOUNCER NETWORK` messages with the current list of network, and MUST send notification messages whenever a network is added, updated or removed. If the client has not negotiated the `soju.im/bouncer-networks-notify` capability, the server MUST NOT send implicit `BOUNCER NETWORK` messages. When network attributes are updated, the bouncer MUST broadcast a `BOUNCER NETWORK` message with the updated attributes to all connected clients with the `soju.im/bouncer-networks-notify` capability enabled: BOUNCER NETWORK The notification SHOULD NOT contain attributes that haven't been updated. An attribute without a value means that the attribute has been removed. When a network is removed, the bouncer MUST broadcast a `BOUNCER NETWORK` message with the special argument `*` to all connected clients with the `soju.im/bouncer-networks-notify` capability enabled: BOUNCER NETWORK * ### Errors Errors are returned using the standard replies syntax. The general syntax is: FAIL BOUNCER [context...] If a client sends an unknown subcommand, the server MUST reply with: FAIL BOUNCER UNKNOWN_COMMAND :Unknown subcommand #### `ACCOUNT_REQUIRED` error If a client sends a `BIND` subcommand before authentication, the server MAY reply with: FAIL BOUNCER ACCOUNT_REQUIRED BIND :Authentication required #### `REGISTRATION_IS_COMPLETED` error If a client sends a `BIND` subcommand after registration, the server MAY reply with: FAIL BOUNCER REGISTRATION_IS_COMPLETED BIND :Cannot bind to a network after registration #### `INVALID_NETID` error If a client sends a subcommand with an invalid network ID, the server MUST reply with: FAIL BOUNCER INVALID_NETID :Network not found #### `INVALID_ATTRIBUTE` error If a client sends an `ADDNETWORK` or a `CHANGENETWORK` subcommand with an invalid attribute, the server MUST reply with: FAIL BOUNCER INVALID_ATTRIBUTE :Invalid attribute value If the `subcommand` is `ADDNETWORK`, `netid` MUST be set to the special `*` value. #### `READ_ONLY_ATTRIBUTE` error If a client attempts to change a read-only network attribute using the `ADDNETWORK` or `CHANGENETWORK` subcommand, the server MUST reply with: FAIL BOUNCER READ_ONLY_ATTRIBUTE :Read-only attribute If the `subcommand` is `ADDNETWORK`, `netid` MUST be set to the special `*` value. #### `UNKNOWN_ATTRIBUTE` error If a client sends an `ADDNETWORK` or a `CHANGENETWORK` subcommand with an unknown attribute, the server MUST reply with: FAIL BOUNCER UNKNOWN_ATTRIBUTE :Unknown attribute If the `subcommand` is `ADDNETWORK`, `netid` MUST be set to the special `*` value. #### `NEED_ATTRIBUTE` error If a client sends an `ADDNETWORK` subcommand without a mandatory attribute, the server MUST reply with: FAIL BOUNCER NEED_ATTRIBUTE ADDNETWORK :Missing required attribute TODO: more errors ### Standard network attributes Bouncers MUST recognise the following network attributes: * `name`: the human-readable name for the network. * `state` (read-only): one of `connected`, `connecting` or `disconnected`. Indicates the current state of the connection to the upstream network. * `host`: the hostname or literal IP address to connect to. * `port`: the TCP port to connect to. * `tls`: `1` to use a TLS connection, `0` to use a cleartext connection. * `nickname`: the nickname to use during registration. * `username`: the username to use during registration. * `realname`: the realname to use during registration. * `pass`: the server password (PASS) to use during registration. Bouncers MAY recognise the following network attributes: * `error` (read-only): a human-readable short text describing an error with the current network. This is typically used when the bouncer state is `disconnected` to describe the reason why the bouncer is disconnected. TODO: more attributes ### Examples Binding to a network: C: CAP LS 302 C: NICK emersion C: USER emersion 0 0 :Simon S: CAP * LS :sasl=PLAIN soju.im/bouncer-networks soju.im/bouncer-networks-notify C: CAP REQ :sasl soju.im/bouncer-networks [SASL authentication] C: BOUNCER BIND 42 C: CAP END Listing networks: C: BOUNCER LISTNETWORKS S: BATCH +asdf soju.im/bouncer-networks S: @batch=asdf BOUNCER NETWORK 42 name=Freenode;state=connected S: @batch=asdf BOUNCER NETWORK 43 name=My\sAwesome\sNetwork;state=disconnected S: BATCH -asdf Adding a new network: C: BOUNCER ADDNETWORK name=OFTC;host=irc.oftc.net S: BOUNCER NETWORK 44 name=OFTC;host=irc.oftc.net;state=connecting S: BOUNCER ADDNETWORK 44 S: BOUNCER NETWORK 44 state=connected Changing an existing network: C: BOUNCER CHANGENETWORK 44 realname=Simon S: BOUNCER NETWORK 44 realname=Simon S: BOUNCER CHANGENETWORK 44 Removing an existing network: C: BOUNCER DELNETWORK 44 S: BOUNCER NETWORK 44 * S: BOUNCER DELNETWORK 44 soju-0.9.0/doc/ext/filehost.md000066400000000000000000000043421477072477000162260ustar00rootroot00000000000000--- title: The Filehost ISUPPORT token layout: spec work-in-progress: true copyrights: - name: "Val Lorentz" email: "progval+ircv3@progval.net" period: "2022" - name: "Simon Ser" period: "2024" --- # filehost This is a work-in-progress specification. ## Motivation This specification offers a way for servers to advertise a hosting service for users to upload files (such as text or images), so they can post them on IRC. ## Architecture This specification introduces the `soju.im/FILEHOST` isupport token. Its value MUST be a URI and SHOULD use the `https` scheme. Clients MUST ignore tokens with an URI scheme they don't support. Clients MUST refuse to use unencrypted URI transports (such as plain `http`) if the IRC connection is encrypted (e.g. via TLS). Servers MUST accept OPTIONS requests on the upload URI. Servers MAY return an `Accept-Post` header field to indicate the MIME types they accept. When clients wish to upload a file using the server's recommended service, they can send a request to the upload URI. The request method MUST be POST. Clients SHOULD authenticate their HTTP request with the same credentials used on the IRC connection (e.g. HTTP Basic for SASL PLAIN, HTTP Bearer for SASL OAUTHBEARER). Clients SHOULD use the `Content-Type`, `Content-Disposition` and `Content-Length` header fields to indicate the MIME type, name and size of the file to be uploaded. On success, servers MUST reply with a `201 Created` status code and with a `Location` header field indicating the URI of the uploaded file. Servers MUST support HEAD and GET requests on the uploaded file URI. Clients SHOULD gracefully handle other common HTTP status codes that could occur. ## Examples Example isupport token: :irc.example.org 005 seunghye soju.im/FILEHOST=https://irc.example.org/upload Example OPTIONS response: HTTP/1.1 204 No Content Allow: OPTIONS, POST Accept-Post: image/*, video/* Example POST request: POST /upload HTTP/1.1 Host: irc.example.org Content-Type: image/jpeg Content-Disposition: attachment; filename="picture.jpeg" Content-Length: 4242 Authorization: Basic c2V1bmdoeWU6bm8= Example POST response: HTTP/1.1 201 Created Location: /upload/hoh5eFThae4e.jpeg soju-0.9.0/doc/ext/no-implicit-names.md000066400000000000000000000014501477072477000177330ustar00rootroot00000000000000# no-implicit-names This specification has been superseded by the IRC `draft/no-implicit-names` extension. ## Description This document describes the `no-implicit-names` extension. This allows clients to opt-out from the implicit `NAMES` reply servers send after `JOIN` messages. Some clients don't need to query the list of channel members for all joined channels. Omitting this information can reduce the time taken to connect to the server, especially on mobile devices and when a large number of channels are joined. ## Implementation The `no-implicit-names` extension introduces the `soju.im/no-implicit-names` capability. When negotiated, servers MUST NOT send an implicit `NAMES` reply after sending a `JOIN` message. Servers MUST reply to explicit `NAMES` commands sent by the client as usual. soju-0.9.0/doc/ext/read.md000066400000000000000000000153541477072477000153310ustar00rootroot00000000000000# read This specification has been superseded by the IRC `draft/read-marker` extension. ## Description This document describes the format of the `read` extension. This enables several clients of the same user connected to a bouncer to tell each other about which messages have been read in each buffer (channel or query). These "read" receipts mean that the actual user has read the message, and is typically useful to clear highlight notifications on other clients. This specification is *not* about message delivery receipts at the client socket level. The server as mentioned in this document refers to the IRC bouncer the clients are connected to. No messages or capabilities introduced by this specification are exchanged with the actual upstream server the bouncer is connected to. ## Implementation The `read` extension uses the `soju.im/read` capability and introduces a new command, `READ`. The `soju.im/read` capability MAY be negotiated, and affects which messages are sent by the server as specified below. ### `READ` Command The `READ` command can be sent by both clients and servers. This command has the following general syntax: READ [] The `target` parameter specifies a single buffer (channel or nickname). The `timestamp` parameter, if specified, MUST be a literal `*`, or have the format `timestamp=YYYY-MM-DDThh:mm:ss.sssZ`, as in the [server-time](https://github.com/ircv3/ircv3-specifications/blob/master/extensions/server-time-3.2.md) extension. #### `READ` client set command READ When sent from a client, this `READ` command signals to the server that the last message read by the user, to the best knowledge of the client, has the specified timestamp. The timestamp MUST correspond to a previous message `time` tag. The timestamp MUST NOT be a literal `*`. The server MUST reply to a successful `READ` set command using a `READ` server command, or using an error message. #### `READ` client get command READ When sent from a client, this `READ` command requests the server about the timestamp of the last message read by the user. The server MUST reply to a successful `READ` get command using a `READ` server command, or using an error message. #### `READ` server command When sent from a server, the `READ` command signals to the client that the last message read by the user, to the best knowledge of the server, has the specified timestamp. In that case, the command has the following syntax: READ The `prefix` is the prefix of the client the message is sent to. If there is no known last message read timestamp, the `timestamp` parameter is a literal `*`. Otherwise, it is the formatted timestamp of the last read message. #### Command flows The server sends a `READ` command to a client in the following cases. If the `soju.im/read` capability is negotiated, after the server sends a server `JOIN` command to the client for a corresponding channel, the server MUST send a `READ` command for that channel. The command MUST be sent before the `RPL_ENDOFNAMES` reply for that channel following the `JOIN`. If the `soju.im/read` capability is negotiated, after the last read timestamp of a target changes, the server SHOULD send a `READ` command for that target to all the clients of the user. #### Read timestamp notes The last read timestamp of a target SHOULD only ever increase. If a client sends a `READ` command with a timestamp that is below or equal to the current known timestamp of the server, the server SHOULD reply with a `READ` command with the newer, previous value that was stored and ignore the client timestamp. #### Errors and Warnings Errors are returned using the standard replies syntax. If the server receives a `READ` command with missing parameters, the `NEED_MORE_PARAMS` error code MUST be returned. FAIL READ NEED_MORE_PARAMS :Missing parameters If the selectors were invalid, the `INVALID_PARAMS` error code SHOULD be returned. FAIL READ INVALID_PARAMS [invalid_parameters] :Invalid parameters If the read timestamp cannot be set or returned due to an error, the `INTERNAL_ERROR` error code SHOULD be returned. FAIL READ INTERNAL_ERROR the_given_target [extra_context] :The read timestamp could not be set ### Examples Updating the read timestamp after the user receives and reads a message ~~~~ [s] @2019-01-04T14:33:26.123Z :nick!ident@host PRIVMSG #channel :message [c] READ #channel timestamp=2019-01-04T14:33:26.123Z [s] :irc.host READ #channel timestamp=2019-01-04T14:33:26.123Z ~~~~ Getting the read timestamp automatically after joining a channel when the capability is negotiated ~~~~ [s] :nick!ident@host JOIN #channel [s] :irc.host READ #channel timestamp=2019-01-04T14:33:26.123Z ~~~~ Getting the read timestamp automatically for a channel without any set timestamp ~~~~ [s] :nick!ident@host JOIN #channel [s] :irc.host READ #channel * ~~~~ Asking the server about the read timestamp for a particular user ~~~~ [c] READ target [s] :irc.host READ target timestamp=2019-01-04T14:33:26.123Z ~~~~ ## Use Cases Clients can know whether a user has already read newly received messages. For clients that display notifications about new messages or highlights, knowing when messages have been read can enable them to clear notifications for messages that were already read on another device. Clients never have to actively get the read timestamp because it is provided to them on join and as updated by the server, except for user targets where they have to request the initial read timestamp by sending a `READ` client get command. ## Implementation Considerations Server implementations can typically store a per-target timestamp variable that stores the timestamp of the last read message. When it receives a new timestamp, it can clamp it between the last read timestamp and the current time, and broadcast the new value to all clients if it was changed. Client implementations can know when a user has read messages by using various techniques such as when the focus shifts to their window or activity, when the messages are scrolled, when the user is idle, etc. They should not assume that any message appended to the buffer is being read by the client right now, especially when the window does not have the focus or is not visible. It is indeed a best-effort value. Clients should typically only need to use the `READ` get client command to get the initial read timestamp of user buffers they open. They will automatically receive initial channels read timestamps and updates, as well as user target timestamp updates. ## Security Considerations No last read timestamp is ever exchanged with the actual upstream server the bouncer is connected to, so there is no privacy risk that the server might leak or use this read data to infer when the user is online. soju-0.9.0/doc/ext/saferate.md000066400000000000000000000017071477072477000162050ustar00rootroot00000000000000--- title: The SAFERATE ISUPPORT token layout: spec work-in-progress: true copyrights: - name: "delthas" period: "2024" --- # saferate This is a work-in-progress specification. ## Motivation This specification offers a way for servers to advertise that clients will not be disconnected due to server rate-limiting / anti-flood, and so that the client can send messages without doing its own conservative rate-limiting. ## Architecture This specification introduces the `soju.im/SAFERATE` isupport token. When advertised, the server ensures that a client will not be disconnected due to server rate-limiting. The token MUST NOT be advertised with a value. ## Implementation notes In order to support this specification, a server can use fakelag to delay processing new messages rather than disconnect a client. The queue of incoming messages to process should be bounded. A client can disable its internal rate limiting when receiving this token. soju-0.9.0/doc/ext/search.md000066400000000000000000000134231477072477000156560ustar00rootroot00000000000000# search This is a work-in-progress specification. ## Description This document describes the format of the `search` extension. This enables clients to run a server-side search of messages according to specified selectors. This specification lets clients run an efficient search query on a bouncer or server who has quick access to the client message history, instead of having to download all logs and run the search locally. The server as mentioned in this document may refer to either an IRC server or an IRC bouncer. ## Implementation The `search` extension uses the `soju.im/search` capability and introduces a new command, `SEARCH`, and batch type, `soju.im/search`. Full support for this extension requires support for the batch, server-time and message-tags capabilities. However, limited functionality is available to clients without support for these CAPs. Servers SHOULD NOT enforce that clients support all related capabilities before using the search extension. The `soju.im/search` capability MUST be negotiated. ### `SEARCH` Command The client can request a message search by sending the `SEARCH` command to the server. This command has the following general syntax: SEARCH If the batch capability was negotiated, the server MUST reply to a successful SEARCH command using a batch with batch type `search`. If no content exists to return, the server SHOULD return an empty batch in order to avoid the client waiting for a reply. The server then replies with a batch of batch type `search` containing messages matching all the specified attributes. These messages MUST be `PRIVMSG` or `NOTICE` messages. ### Returned message notes The order of returned messages within the batch is implementation-defined, but SHOULD be ascending time order or some approximation thereof, regardless of the subcommand used. The server-time tag on each message SHOULD be the time at which the message was received by the IRC server. When provided, the msgid tag that identifies each individual message in a response MUST be the msgid tag as originally sent by the IRC server. Servers SHOULD provide clients with a consistent message order that is valid across the lifetime of a single connection, and which determinately orders any two messages (even if they share a timestamp). This order SHOULD coincide with the order in which messages are returned within a response batch. It need not coincide with the delivery order of messages when they were relayed on any particular server. #### Errors and Warnings Errors are returned using the standard replies syntax. If the selectors were invalid, the `INVALID_PARAMS` error code SHOULD be returned. FAIL SEARCH INVALID_PARAMS [invalid_parameters] :Invalid parameters If the search cannot be run due to an internal error, the `INTERNAL_ERROR` error code SHOULD be returned. FAIL SEARCH INTERNAL_ERROR [extra_context] :The search could not be run ### Standard search attributes Servers MUST recognise the following attributes. The following attributes are considered a match when: * `in`: the message was sent to this target (channel or user). * `from`: the message was sent with this nick. * `after`: the message was sent at or after this time (same format as the `server-time` specification). * `before`: the message was sent at or before this time (same format as the `server-time` specification). * `text`: the message text matches the specified text. The actual algorithm used for matching the text is implementation defined. If `after` is specified, messages SHOULD be searched from that time. Otherwise, messages SHOULD be searched from the `before` time, which defaults to the current server time. Additionally, the following attributes MUST be recognized: * `limit`: a number representing an upper bound on the count of messages to return. The server MAY return less messages than this number. ### Examples Searching messages sent by `jackie` in `#chan` ~~~~ [c] SEARCH from=jackie;in=#chan [s] :irc.host BATCH +ID soju.im/search [s] @batch=ID;msgid=1234;time=2019-01-04T14:33:26.123Z :jackie!indent@host PRIVMSG #chan :Be what you want [s] @batch=ID;msgid=1234;time=2019-01-04T14:35:26.123Z :jackie!indent@host PRIVMSG #chan :Want what you be [s] :irc.host BATCH -ID ~~~~ Searching messages matching the text `fast` in `#chan`, returning up to 2 messages ~~~~ [c] SEARCH text=fast;in=#chan;limit=2 [s] :irc.host BATCH +ID soju.im/search [s] @batch=ID;msgid=1234;time=2019-01-04T14:33:26.123Z :bill!indent@host PRIVMSG #chan :That was fast! [s] @batch=ID;msgid=1234;time=2019-01-04T14:35:26.123Z :jackie!indent@host PRIVMSG #chan :Fasting is hard. [s] :irc.host BATCH -ID ~~~~ Searching messages when none match ~~~~ [c] SEARCH before=2010-01-01T00:00:00.000Z;in=#chan [s] :irc.host BATCH +ID soju.im/search [s] :irc.host BATCH -ID ~~~~ ## Use Cases Clients can run a fast server-side search across months of history and channels without having to download all their logs and run the search locally. This enables client interfaces to provide a search feature with quick matches. Additional context can be fetched thanks to the separate `CHATHISTORY` extension. ## Implementation Considerations Server implementations may use different algorithms for matching messages against the specified `text`. Some implementation may choose to match by substrings, by whole words, or by other algorithms such as what is offered by their database (e.g. SQLite full-text search). The comparison may be case-insensitive or case-sensitive. ## Security Considerations Processing logs can be slow, and arbitrary regular expressions can take a virtually infinite amount of time when maliciously crafted, even on small input sizes. Servers offering this feature should implement a timeout on their total request time, including regular expression compile time, as well as message fetching, parsing and selecting. soju-0.9.0/doc/ext/webpush.md000066400000000000000000000161031477072477000160640ustar00rootroot00000000000000--- title: "Web Push Extension" layout: spec copyrights: - name: "Simon Ser" period: "2021" email: "contact@emersion.fr" --- ## Notes for implementing experimental vendor extension This is an experimental specification for a vendored extension. No guarantees are made regarding the stability of this extension. Backwards-incompatible changes can be made at any time without prior notice. Software implementing this work-in-progress specification MUST NOT use the unprefixed `webpush` CAP name. Instead, implementations SHOULD use the `soju.im/webpush` CAP name to be interoperable with other software implementing a compatible work-in-progress version. ## Description Historically, IRC clients have relied on keeping a TCP connection alive to receive notifications about new events. However, this design has limitations: - It doesn't bode well with some platforms such as Android, iOS or the Web. On these platforms, the connection to the IRC server can be severed (e.g. when the IRC client isn't in the foreground), resulting in IRC events not received. - Battery-powered devices aim to avoid any unnecessary wake-up of the modem hardware. IRC connections don't make the difference between messages which may be important to the user (e.g. messages targeting the user directly) and the rest of the messages. As a result messages are frequently sent over the IRC connection, resulting in battery drain. To address these limitations, various push notification mechanisms have been designed. This specification standardizes an extension for Web Push. ``` ┌────────────┐ ┌────────────┐ │ │ Subscribe │ │ │ ├─────────────►│ │ │ IRC client │ │ IRC server │ │ │ │ │ │ │ │ │ └────────────┘ └─────┬──────┘ ▲ │ │ │ Push │ │Push notification │ │notification │ ┌──────────┐ │ │ │ │ │ └───────┤ Web Push │◄──────┘ │ Server │ │ │ └──────────┘ ``` Web Push is defined in [RFC 8030], [RFC 8291] and [RFC 8292]. Although Web Push has been designed for the Web, it can be used on other platforms as well. Web Push provides a vendor-neutral standard to send push notifications. ## Implementation The `soju.im/webpush` capability allows clients to subscribe to Web Push and receive notifications for messages of interest. Once a client has subscribed, the server will send push notifications for a server-defined subset of IRC messages. Each push notification MUST contain exactly one IRC message as the payload, without the final CRLF. The messages follow the same capabilities and the same `RPL_ISUPPORT` as when the client registered for Web Push notifications. Because of size limits on the payload of push notifications, servers MAY drop some or all message tags from the original message. Servers MUST NOT drop the `msgid` tag if present. ## `VAPID` ISUPPORT token If the server supports [Voluntary Application Server Identification (VAPID)][RFC 8292] and the client has enabled the `soju.im/webpush` capability, the server MUST advertise its public key in the `VAPID` ISUPPORT token. This key can be used to verify notifications upon reception by the Web Push server. The value MUST be the [URL-safe base64-encoded][RFC 4648 section 5] public key usable with the Elliptic Curve Digital Signature Algorithm (ECDSA) over the P-256 curve. The value MUST NOT change over the lifetime of the connection to avoid race conditions. ## `WEBPUSH` Command A new `WEBPUSH` command is introduced. It has a case-insensitive subcommand: WEBPUSH ### `REGISTER` Subcommand The `REGISTER` subcommand creates a new Web Push subscription. WEBPUSH REGISTER The `` is an URL pointing to a push server, which can be used to send push messages for this particular subscription. `` is a string encoded in the message-tag format. The values are [URL-safe base64-encoded][RFC 4648 section 5]. For the `aes128gcm` encryption algorithm, it MUST contain at least: - One public key with the name `p256dh` set to the client's P-256 ECDH public key. - One shared key with the name `auth` set to a 16-byte client-generated authentication secret. If the server has advertised the `VAPID` ISUPPORT token, they MUST use this VAPID public key when sending push notifications. Servers MUST replace any previous subscription with the same ``. If the registration is successful, the server MUST reply with a `WEBPUSH REGISTER` message: WEBPUSH REGISTER On error, the server MUST reply with a `FAIL` message. Servers MAY expire a subscription at any time. ### `UNREGISTER` Subcommand The `UNREGISTER` subcommand removes an existing Web Push subscription. WEBPUSH UNREGISTER Servers MUST silently ignore `UNREGISTER` commands for non-existing subscriptions. If the unregistration is successful, the server MUST echo back the `WEBPUSH UNREGISTER` message. On error, the server MUST reply with a `FAIL` message. ### Errors Errors are returned using the standard replies syntax. If the server receives a syntactically invalid `WEBPUSH` command, e.g., an unknown subcommand, missing parameters, excess parameters, or parameters that cannot be parsed, the `INVALID_PARAMS` error code SHOULD be returned: ``` FAIL WEBPUSH INVALID_PARAMS ``` If the server cannot fullfill a client command due to an internal error, the `INTERNAL_ERROR` error code SHOULD be returned: ``` FAIL WEBPUSH INTERNAL_ERROR ``` ### Examples The server advertises its VAPID public key: ``` S: 005 emersion NETWORK=TestNet VAPID=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs :are supported by this server ``` The client generates a public and private key, then registers a push endpoint: ``` C: WEBPUSH REGISTER https://example.org/YBJNBIMwwA_Ag8EtD47J4A p256dh=BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4;auth=BTBZMqHH6r4Tts7J_aSIgg S: WEBPUSH REGISTER https://example.org/YBJNBIMwwA_Ag8EtD47J4A ``` The client unregisters a push endpoint: ``` C: WEBPUSH UNREGISTER https://example.org/YBJNBIMwwA_Ag8EtD47J4A S: WEBPUSH UNREGISTER https://example.org/YBJNBIMwwA_Ag8EtD47J4A ``` [RFC 8030]: https://datatracker.ietf.org/doc/html/rfc8030 [RFC 8291]: https://datatracker.ietf.org/doc/html/rfc8291 [RFC 8292]: https://datatracker.ietf.org/doc/html/rfc8292 [RFC 4648 section 5]: https://www.rfc-editor.org/rfc/rfc4648.html#section-5 soju-0.9.0/doc/file-upload.md000066400000000000000000000011771477072477000160150ustar00rootroot00000000000000# Setting up file uploads Add the `file-upload` directive to your configuration file: file-upload fs ./uploads Ensure an HTTP listener is set up. For instance, when using an HTTP reverse proxy: listen http://localhost:8080 Configure your HTTP reverse proxy to forward requests to soju. In particular, `/socket`, `/uploads` and `/uploads/*` need to be forwarded. Ensure your hostname is correctly configured. If the `hostname` directive matches your HTTP server hostname (ie, your HTTP server can be reached via `https://`), there is nothing to do. For more complex setups, the `http-ingress` directive can be used. soju-0.9.0/doc/getting-started.md000066400000000000000000000045711477072477000167220ustar00rootroot00000000000000# Getting started ## Server side Start by installing soju via your distribution's [package manager]. Alternatively, you can compile it from source (see the [README]). To create an admin user and start soju, run these commands: sojudb create-user -admin soju -listen irc+insecure://127.0.0.1:6667 soju will listen for unencrypted IRC connections on the default port. This is enough for local experiments, but for a proper setup you will need to configure TLS (e.g. by setting up a reverse proxy, or by specifying the TLS certificates in the soju configuration file). Setting up on-disk chat logs is recommended. If you're migrating from ZNC, a tool is available to import users, networks and channels from a ZNC config file: go run ./contrib/znc-import ## Client side ### Client supporting `soju.im/bouncer-networks` If you are using a client supporting the `soju.im/bouncer-networks` IRC extension (see the [client list]), then you can just connect to soju with your username and password. If your client doesn't provide a UI to manage IRC networks, you can talk to `BouncerServ`. See the [man page] or use `/msg BouncerServ help`. ### Other clients You will need to setup one separate server in your client for each server you want soju to connect to. The easiest way to get started is to specify the IRC server address directly in the username in the client configuration. For example to connect to Libera Chat, your username will be: `/irc.libera.chat`. Also set your soju password in the password field of your client configuration. This will autoconfigure soju by adding a network with the address `irc.libera.chat` and then autoconnect to it. You will now be able to join any channel like you would normally do. For more advanced configuration options, you can talk to `BouncerServ`. See the [man page] or use `/msg BouncerServ help`. If you intend to connect to the bouncer from multiple clients, you will need to append a client name in your username. For instance, to connect from a laptop and a workstation, you can setup each client to use the respective usernames `/irc.libera.chat@laptop` and `/irc.libera.chat@workstation`. [package manager]: https://repology.org/project/soju/versions [README]: ../README.md [man page]: https://soju.im/doc/soju.1.html#IRC_SERVICE [client list]: ../contrib/clients.md soju-0.9.0/doc/packaging.md000066400000000000000000000024731477072477000155400ustar00rootroot00000000000000# Packaging soju ## Building Using `make` is recommended for building. The `GOFLAGS` variable can be used to customize flags passed to Go. In particular, `GOFLAGS="-tags=libsqlite3"` can be used to link to the system's libsqlite3. The `Makefile` will configure the binary with the default locations for the config file and the admin Unix socket. These can be customized via the `SYSCONFDIR` and `RUNDIR` variables. ## Default configuration file `make install` will set up a default configuration file which: - Uses a SQLite3 database in `/var/lib/soju/main.db`. - Uses a filesystem message store in `/var/lib/soju/logs/`. - Enables the admin Unix socket (required for `sojuctl`). The default configuration file's template is stored in `config.in`. ## Binding to privileged ports soju might need to bind to privileged ports: the built-in identd will need to listen on port 113. On Linux, unless your service manager provides a way to give extra capabilities to soju, the `CAP_NET_BIND_SERVICE` capability can be assigned to the soju executable: setcap 'cap_net_bind_service=+ep' soju ## Service manager integration soju is designed to be run as a system-wide service under a separate user account. SIGHUP can be sent to soju to reload the configuration file. A template for systemd is available in `contrib/soju.service`. soju-0.9.0/doc/per-user-ip.md000066400000000000000000000017151477072477000157620ustar00rootroot00000000000000# Setting up per-user IP addresses If your bouncer hosts many users, you may want to assign a unique IP address for each user. This allows upstream networks to easily ban a single user when a misbehavior is detected, instead of banning the whole bouncer. Assuming you're running Linux and want to use the IPv6 prefix `2001:db8::/32`: 1. Setup the router to redirect ingress packets with one of these IP addresses as the destination to your bouncer. 2. Enable `net.ipv6.ip_nonlocal_bind=1` with `sysctl`. 3. Setup a local route for this prefix: `ip route add local 2001:db8::/32 dev lo` 4. Check network connectivity: `curl -6 --interface 2001:db8::42 https://emersion.fr` 5. Configure soju to use this IP range: `upstream-user-ip 2001:db8::/32` The address `2001:db8::1` will be left unused. Users will be assigned IP addresses starting from `2001:db8::2`. The IRC `/whois` command can be used to double-check that the expected IPv6 addresses are being used. soju-0.9.0/doc/soju.1.scd000066400000000000000000000507041477072477000151040ustar00rootroot00000000000000soju(1) # NAME soju - IRC bouncer # SYNOPSIS *soju* [options...] # DESCRIPTION soju is a user-friendly IRC bouncer. It connects to upstream IRC servers on behalf of the user to provide extra features. - Multiple separate users sharing the same bouncer, each with their own upstream servers - Sending the backlog (messages received while the user was disconnected from the bouncer), with per-client buffers To connect to the bouncer, use the bouncer username and password. To use a client which doesn't support the _soju.im/bouncer-networks_ IRC extension, setup one connection per server configured in soju, and indicate the network name in the username: "/". Then channels can be joined and parted as if you were directly connected to the upstream server. For per-client history to work on clients which don't support the IRCv3 _chathistory_ extension, clients need to indicate their name. This can be done by adding a "@" suffix to the username. When joining a channel, the channel will be saved and automatically joined on the next connection. When registering or authenticating with NickServ, the credentials will be saved and automatically used on the next connection if the server supports SASL. When parting a channel with the reason "detach", the channel will be detached instead of being left. If a network specified in the username doesn't exist, and the network name is a valid hostname, the network will be automatically added. When all clients are disconnected from the bouncer, the user is automatically marked as away by default. soju will reload the configuration file, the TLS certificate/key and the MOTD file when it receives the HUP signal. The configuration options _listen_, _db_ and _log_ cannot be reloaded. Administrators can broadcast a message to all bouncer users via _/notice $ _, or via _/notice $\* _ if the connection isn't bound to a particular network. All currently connected bouncer users will receive the message from the special _BouncerServ_ service. # OPTIONS *-h, -help* Show help message and quit. *-config* Path to the config file. If unset, a default config file is used. *-debug* Enable debug logging (this will leak sensitive information such as passwords). This can be overriden at run time with the service command _server debug_. *-listen* Listening URI (default: ":6697"). Can be specified multiple times. # CONFIG FILE The config file has one directive per line. Example: ``` listen ircs:// tls cert.pem key.pem hostname example.org ``` The following directives are supported: *listen* Listening URI (default: ":6697"). The following URIs are supported: - _[ircs://][host][:port]_ listens with TLS over TCP (default port if omitted: 6697) - _irc://localhost[:port]_ listens with plain-text over TCP (default port if omitted: 6667, host must be "localhost") - _irc+insecure://[host][:port]_ listens with plain-text over TCP (default port if omitted: 6667) - _unix://_ listens on a Unix domain socket - _https://[host][:port]_ listens for HTTPS connections (default port: 443) and handles the following requests: - _/socket_ for WebSocket - _/uploads_ (and subdirectories) for file uploads - _http://localhost[:port]_ listens for plain-text HTTP connections (default port: 80, host must be "localhost") and handles requests like _https://_ does - _http+insecure://[host][:port]_ listens for plain-text HTTP connections (default port: 80) and handles requests like _https://_ does - _http+unix://_ listens for plain-text HTTP connections on a Unix domain socket and handles requests like _https://_ does - _wss://[host][:port]_ listens for WebSocket connections over TLS (default port: 443) - _ws://localhost[:port]_ listens for plain-text WebSocket connections (default port: 80, host must be "localhost") - _ws+insecure://[host][:port]_ listens for plain-text WebSocket connections (default port: 80) - _ws+unix://_ listens for plain-text WebSocket connections on a Unix domain socket - _ident://[host][:port]_ listens for plain-text ident connections (default port: 113) - _http+prometheus://localhost:_ listens for plain-text HTTP connections and serves Prometheus metrics (host must be "localhost") - _http+pprof://localhost:_ listens for plain-text HTTP connections and serves pprof runtime profiling data (host must be "localhost"). For more information, see: . - _unix+admin://[path]_ listens on a Unix domain socket for administrative connections, such as sojuctl (default path: /run/soju/admin) If the scheme is omitted, "ircs" is assumed. If multiple *listen* directives are specified, soju will listen on each of them. *hostname* Server hostname (default: system hostname). This should be set to a fully qualified domain name. *title* Server title. This will be sent as the _ISUPPORT NETWORK_ value when clients don't select a specific network. *tls* <cert> <key> Enable TLS support. The certificate and the key files must be PEM-encoded. *db* <driver> <source> Set the database location for user, network and channel storage. By default, a _sqlite3_ database is opened in "./soju.db". Supported drivers: - _sqlite3_ expects _source_ to be a path to the SQLite file - _postgres_ expects _source_ to be a space-separated list of _key=value_ parameters, e.g. _db postgres "host=/run/postgresql dbname=soju"_. Note that _sslmode_ defaults to _require_. For more information on connection strings, see: <https://pkg.go.dev/github.com/lib/pq#hdr-Connection_String_Parameters>. *message-store* <driver> [source] Set the database location for IRC messages. By default, _db_ is used. Supported drivers: - _db_ stores messages in the database. A full-text search index is used to speed up search queries. - _fs_ stores messages on disk, in the same format as ZNC. _source_ is required and is the root directory path for the database. This on-disk format is lossy: some IRCv3 messages (e.g. TAGMSG) and all message tags are discarded. - _memory_ stores messages in memory. For each channel/user, only the latest 4K messages are kept in memory, older messages are discarded. This driver is very basic and doesn't support features such as the _chathistory_ extension and search. (_log_ is a deprecated alias for this directive.) *file-upload* <driver> [source] Set the database location for uploaded files. File upload requires setting up an HTTP listener (see _https://_ and _http+insecure://_ URIs in the _listen_ directive). Supported drivers: - _fs_ stores uploaded files on disk. _source_ is required. - _http_ stores uploaded files through an external HTTP service. _source_ is required and must be an HTTP URL. When receiving a file, soju will send an HTTP POST request to that URL, sending the file as is in the HTTP request body; additionally sending the uploader soju username in the _Soju-Username_ header. The HTTP server must respond with an HTTP 201 on success, with the _Location_ header being set to the URL of the uploaded file. *http-origin* <patterns...> List of allowed HTTP origins for WebSocket listeners. The parameters are interpreted as shell patterns, see *glob*(7). By default, only the request host is authorized. Use this directive to enable cross-origin WebSockets. *http-ingress* <url> External URL on which HTTPS listeners are exposed. By default, this is _https://<hostname>_. *accept-proxy-ip* <cidr...> Allow the specified IPs to act as a proxy. Proxys have the ability to overwrite the remote and local connection addresses (via the PROXY protocol, the Forwarded HTTP header field defined in RFC 7239 or the X-Forwarded-\* HTTP header fields). The special name "localhost" accepts the loopback addresses 127.0.0.0/8 and ::1/128. By default, all IPs are rejected. *max-user-networks* <limit> Maximum number of enabled networks per user. By default, there is no limit. *motd* <path> Path to the MOTD file. The bouncer MOTD is sent to clients which aren't bound to a specific network. By default, no MOTD is sent. *upstream-user-ip* <cidr...> Enable per-user IP addresses. One IPv4 range and/or one IPv6 range can be specified in CIDR notation. One IP address per range will be assigned to each user and will be used as the source address when connecting to an upstream network. This can be useful to avoid having the whole bouncer banned from an upstream network because of one malicious user. *disable-inactive-user* <duration> Disable inactive users after the specified duration. A user is inactive when the last downstream connection is closed. The duration is a positive decimal number followed by the unit "d" (days). For instance, "30d" disables users 30 days after they last disconnect from the bouncer. *enable-user-on-auth* true|false Enable users when they successfully authenticate. This can be used together with _disable-inactive-user_ to seamlessly disable and re-enable users during lengthy inactivity. When external authentication is used (e.g. _auth oauth2_), bouncer users are automatically created after successful authentication. *auth* <driver> ... Set the authentication method. By default, internal authentication is used. Supported drivers: *auth internal* Use internal authentication. *auth http* <url> Use external HTTP basic authentication. An HTTP request is made against the URL, passing the user credentials in an Authorization Basic header. The credentials are considered valid on HTTP 200; invalid on HTTP 403; and any other error code means an error occured. *auth oauth2* <url> Use external OAuth 2.0 authentication. The authorization server URL must be provided. The client ID and client secret can be provided as username and password in the URL. The authorization server must support OAuth 2.0 Authorization Server Metadata (RFC 8414) and OAuth 2.0 Token Introspection (RFC 7662). *auth pam* Use PAM authentication. # IRC SERVICE soju exposes an IRC service called *BouncerServ* to manage the bouncer. Commands can be sent via regular private messages (_/msg BouncerServ <command> [args...]_). Commands may be written in full or abbreviated form, for instance *network* can be abbreviated as *net* or just *n*. Commands are parsed according the POSIX shell rules. In particular, words can be quoted (via double or single quotes) and a backslash escapes the next character. *help* [command] Show a list of commands. If _command_ is specified, show a help message for the command. *network create* *-addr* <addr> [options...] Connect to a new network at _addr_. _-addr_ is mandatory. _addr_ supports several connection types: - _[ircs://]<host>[:port]_ connects with TLS over TCP - _irc+insecure://<host>[:port]_ connects with plain-text TCP - _irc+unix:///<path>_ connects to a Unix socket For example, to connect to Libera Chat: ``` net create -addr irc.libera.chat ``` Other options are: *-name* <name> Short network name. This will be used instead of _addr_ to refer to the network. *-username* <username> Connect with the specified username. By default, the nickname is used. *-pass* <pass> Connect with the specified server password. *-realname* <realname> Connect with the specified real name. By default, the account's realname is used if set, otherwise the network's nickname is used. *-certfp* <fingerprint> Instead of using certificate authorities to check the server's TLS certificate, check whether the server certificate matches the provided fingerprint. This can be used to connect to servers using self-signed certificates. The fingerprint format is SHA512. An empty string removes any previous fingerprint. The following command can be used to fetch the certificate fingerprint of an IRC server: ``` openssl s_client -connect irc.example.org:6697 -verify_quiet </dev/null | openssl x509 -fingerprint -sha512 -noout -in /dev/stdin ``` *-nick* <nickname> Connect with the specified nickname. By default, the account's username is used. *-auto-away* true|false Enable or disable the auto-away feature. If the feature is enabled, the user will be marked as away when all clients are disconnected from the bouncer. By default, auto-away is enabled. *-enabled* true|false Enable or disable the network. If the network is disabled, the bouncer won't connect to it. By default, the network is enabled. *-ignore-limit* true|false Ignore the max networks limit for this command. Only admin users can ignore the limit. *-connect-command* <command> Send the specified quoted string as a raw IRC command right after connecting to the server. This can be used to identify to an account when the server doesn't support SASL. For instance, to identify with _NickServ_, the following command can be used: ``` "PRIVMSG NickServ :IDENTIFY <password>" ``` The flag can be specified multiple times to send multiple IRC messages. To clear all commands, set it to the empty string. *network update* [name] [options...] Update an existing network. The options are the same as the _network create_ command. When this command is executed, soju will disconnect and re-connect to the network. If _name_ is not specified, the current network is updated. *network delete* [name] Disconnect and delete a network. If _name_ is not specified, the current network is deleted. *network quote* [name] <command> Send a raw IRC line as-is to a network. If _name_ is not specified, the command is sent to the current network. *network status* Show a list of saved networks and their current status. *channel status* [options...] Show a list of saved channels and their current status. Options: *-network* <name> Only show channels for the specified network. By default, only the channels in the current network are displayed. *channel create* <name> [options...] Join a new channel. Joining a channel should usually be done with a simple join from the client, but this command can be used to join another user to a channel when used with _user run_. Options are: *-detached* true|false Attach or detach this channel. A detached channel is joined but is hidden by the bouncer. This is useful to e.g. collect logs and highlights in low-interest or high-traffic channels. *-relay-detached* <mode> Set when to relay messages from detached channels to the user with a BouncerServ NOTICE. Modes are: *message* Relay any message from this channel when detached. *highlight* Relay only messages mentioning you when detached. *none* Don't relay any messages from this channel when detached. *default* Currently same as *highlight*. This is the default behaviour. *-reattach-on* <mode> Set when to automatically reattach to detached channels. Modes are: *message* Reattach to this channel when any message is received. *highlight* Reattach to this channel when any message mentioning you is received. *none* Never automatically reattach to this channel. *default* Currently same as *none*. This is the default behaviour. *-detach-after* <duration> Automatically detach this channel after the specified duration has elapsed without receving any message corresponding to *-detach-on*. Example duration values: *1h30m*, *30s*, *2.5h*. Setting this value to 0 will disable this behaviour, i.e. this channel will never be automatically detached. This is the default behaviour. *-detach-on* <mode> Set when to reset the auto-detach timer used by *-detach-after*, causing it to wait again for the auto-detach duration timer before detaching. Joining, reattaching, sending a message, or changing any channel option will reset the timer, in addition to the messages specified by the mode. Modes are: *message* Receiving any message from this channel will reset the auto-detach timer. *highlight* Receiving any message mentioning you from this channel will reset the auto-detach timer. *none* Receiving messages from this channel will not reset the auto-detach timer. Sending messages or joining the channel will still reset the timer. *default* Currently same as *message*. This is the default behaviour. *channel update* <name> [options...] Update the options of an existing channel. The options are the same as the _network create_ command. *channel delete* <name> Leave and forget a channel. *certfp generate* [options...] Generate self-signed certificate and use it for authentication (via SASL EXTERNAL). Generates a 3072-bit RSA private key by default. Note, reconnection to the upstream network is required to use the newly generated certificate. Options are: *-network* <name> Select a network. By default, the current network is selected, if any. *-key-type* <type> Private key algorithm to use. Valid values are: _rsa_, _ecdsa_ and _ed25519_. _ecdsa_ uses the NIST P-521 curve. *-bits* <bits> Size of RSA key to generate. Ignored for other key types. *certfp fingerprint* [options...] Show SHA-1 and SHA-256 fingerprints for the certificate currently used with the network. Options are: *-network* <name> Select a network. By default, the current network is selected, if any. *sasl status* [options...] Show current SASL status. Options are: *-network* <name> Select a network. By default, the current network is selected, if any. *sasl set-plain* [options...] <username> <password> Set SASL PLAIN credentials. Note, reconnection to the upstream network is required to apply the new settings. Options are: *-network* <name> Select a network. By default, the current network is selected, if any. *sasl reset* [options...] Disable SASL authentication and remove stored credentials. Note, reconnection to the upstream network is required to apply the new settings. Options are: *-network* <name> Select a network. By default, the current network is selected, if any. *user status* [username] Show a list of users on this server. Only admins can query this information. If _username_ is specified, statistics are only displayed for this user. *user create* -username <username> -password <password> [options...] Create a new soju user. Only admin users can create new accounts. The _-username_ and _-password_ flags are mandatory. Options are: *-username* <username> The bouncer username. This cannot be changed after the user has been created. *-password* <password> The bouncer password. *-disable-password* Disable password authentication. The user will be unable to login. *-admin* true|false Make the new user an administrator. *-nick* <nick> Set the user's nickname. This is used as a fallback if there is no nickname set for a network. *-realname* <realname> Set the user's realname. This is used as a fallback if there is no realname set for a network. *-enabled* true|false Enable or disable the user. If the user is disabled, the bouncer will not connect to any of their networks, and downstream connections will be immediately closed. By default, users are enabled. *-max-networks* <max-networks> Set a limit on the number of enabled networks this user can use. A limit of 0 means no network, and -1 means to default to the global _max-user-networks_ configuration value. *user update* [username] [options...] Update a user. The options are the same as the _user create_ command. If _username_ is omitted, the current user is updated. Only admins can update other users. Not all flags are valid in all contexts: - The _-username_ flag is never valid, usernames are immutable. - The _-nick_ and _-realname_ flag are only valid when updating the current user. - The _-admin_, _-enabled_ and _-max_networks_ flags are only valid when updating another user. *user delete* <username> [confirmation token] Delete a soju user. Only admins can delete other users. *user run* <username> <command...> Execute a command as another user. Only admins can use this command. *server status* Show some bouncer statistics. Only admins can query this information. *server notice* <message> Broadcast a notice. All currently connected bouncer users will receive the message from the special _BouncerServ_ service. Only admins can broadcast a notice. *server debug* <true|false> Enable/disable debug logging (this will leak sensitive information). This overrides any value passed to soju in _-debug_. # AUTHORS Maintained by Simon Ser <contact@emersion.fr>, who is assisted by other open-source contributors. For more information about soju development, see <https://soju.im>. # SEE ALSO *sojuctl*(1) ������������������������������������������������������������soju-0.9.0/doc/sojuctl.1.scd������������������������������������������������������������������������0000664�0000000�0000000�00000001425�14770724770�0015603�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������sojuctl(1) # NAME sojuctl - Manage a running instance of the soju IRC bouncer # SYNOPSIS *sojuctl* [options...] <command...> # DESCRIPTION sojuctl sends a _BouncerServ_ command to a running soju instance. See the _IRC SERVICE_ section in *soju*(1) for more information. sojuctl requires a _listen unix+admin://_ directive in the soju configuration file. sojuctl needs to be run with write permissions on the soju admin socket. # OPTIONS *-h, -help* Show help message and quit. *-config* <path> Path to the config file. If unset, the default config file path is used, if any. # AUTHORS Maintained by Simon Ser <contact@emersion.fr>, who is assisted by other open-source contributors. For more information about soju development, see <https://soju.im>. # SEE ALSO *soju*(1) �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/downstream.go����������������������������������������������������������������������������0000664�0000000�0000000�00000304344�14770724770�0015241�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "bytes" "context" "crypto/tls" "encoding/base64" "errors" "fmt" "io" "net" "net/url" "strconv" "strings" "time" "github.com/SherClockHolmes/webpush-go" "github.com/emersion/go-sasl" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/auth" "codeberg.org/emersion/soju/database" "codeberg.org/emersion/soju/msgstore" "codeberg.org/emersion/soju/xirc" ) type ircError struct { Message *irc.Message } func (err ircError) Error() string { return err.Message.String() } func newUnknownCommandError(cmd string) ircError { return ircError{&irc.Message{ Command: irc.ERR_UNKNOWNCOMMAND, Params: []string{ "*", cmd, "Unknown command", }, }} } func newUnknownIRCError(cmd, text string) ircError { return ircError{&irc.Message{ Command: xirc.ERR_UNKNOWNERROR, Params: []string{"*", cmd, text}, }} } func newNeedMoreParamsError(cmd string) ircError { return ircError{&irc.Message{ Command: irc.ERR_NEEDMOREPARAMS, Params: []string{ "*", cmd, "Not enough parameters", }, }} } func newChatHistoryError(subcommand string, target string) ircError { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"CHATHISTORY", "MESSAGE_ERROR", subcommand, target, "Messages could not be retrieved"}, }} } // authErrorReason returns the user-friendly reason of an authentication // failure. func authErrorReason(err error) string { if authErr, ok := err.(*auth.Error); ok { return authErr.ExternalMsg } else { return "Authentication failed" } } func parseBouncerNetID(subcommand, s string) (int64, error) { id, err := strconv.ParseInt(s, 10, 64) if err != nil || id <= 0 { return 0, ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "INVALID_NETID", subcommand, s, "Invalid network ID"}, }} } return id, nil } func fillNetworkAddrAttrs(attrs irc.Tags, network *database.Network) { u, err := network.URL() if err != nil { return } hasHostPort := true switch u.Scheme { case "ircs": attrs["tls"] = "1" case "irc+insecure": attrs["tls"] = "0" default: // e.g. unix:// hasHostPort = false } if host, port, err := net.SplitHostPort(u.Host); err == nil && hasHostPort { attrs["host"] = host attrs["port"] = port } else if hasHostPort { attrs["host"] = u.Host } } func getNetworkAttrs(network *network) irc.Tags { state := "disconnected" if uc := network.conn; uc != nil { state = "connected" } attrs := irc.Tags{ "name": network.GetName(), "state": state, "nickname": database.GetNick(&network.user.User, &network.Network), } if network.Username != "" { attrs["username"] = network.Username } if realname := database.GetRealname(&network.user.User, &network.Network); realname != "" { attrs["realname"] = realname } if network.lastError != nil { attrs["error"] = network.lastError.Error() } fillNetworkAddrAttrs(attrs, &network.Network) return attrs } func networkAddrFromAttrs(attrs irc.Tags) string { host := string(attrs["host"]) if host == "" { return "" } addr := host if port := string(attrs["port"]); port != "" { addr += ":" + port } if tlsStr := string(attrs["tls"]); tlsStr == "0" { addr = "irc+insecure://" + addr } return addr } func updateNetworkAttrs(record *database.Network, attrs irc.Tags, subcommand string) error { addrAttrs := irc.Tags{} fillNetworkAddrAttrs(addrAttrs, record) updateAddr := false for k, v := range attrs { s := string(v) switch k { case "host", "port", "tls": updateAddr = true addrAttrs[k] = v case "name": record.Name = s case "nickname": record.Nick = s case "username": record.Username = s case "realname": record.Realname = s case "pass": record.Pass = s default: return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "UNKNOWN_ATTRIBUTE", subcommand, k, "Unknown attribute"}, }} } } if updateAddr { record.Addr = networkAddrFromAttrs(addrAttrs) if record.Addr == "" { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "NEED_ATTRIBUTE", subcommand, "host", "Missing required host attribute"}, }} } } return nil } // illegalNickChars is the list of characters forbidden in a nickname. // // - ' ' and ':' break the IRC message wire format // - '@' and '!' break prefixes // - '*' breaks masks and is the reserved nickname for registration // - '?' breaks masks // - '$' breaks server masks in PRIVMSG/NOTICE // - ',' breaks lists // - '.' is reserved for server names // // See https://modern.ircdocs.horse/#clients const illegalNickChars = " :@!*?$,." // illegalChanChars is the list of characters forbidden in a channel name. // // See https://modern.ircdocs.horse/#channels const illegalChanChars = " ,\x07" // permanentDownstreamCaps is the list of always-supported downstream // capabilities. var permanentDownstreamCaps = map[string]string{ "batch": "", "cap-notify": "", "echo-message": "", "invite-notify": "", "server-time": "", "setname": "", "draft/metadata-2": "before-connect,max-keys=0,max-value-bytes=1", "draft/pre-away": "", "draft/read-marker": "", "draft/no-implicit-names": "", "soju.im/account-required": "", "soju.im/bouncer-networks": "", "soju.im/bouncer-networks-notify": "", "soju.im/no-implicit-names": "", "soju.im/read": "", "soju.im/webpush": "", } // needAllDownstreamCaps is the list of downstream capabilities that // require support from all upstreams to be enabled. var needAllDownstreamCaps = map[string]string{ "account-notify": "", "account-tag": "", "away-notify": "", "chghost": "", "extended-join": "", "extended-monitor": "", "message-tags": "", "multi-prefix": "", "draft/extended-monitor": "", } // permanentIsupport is the set of ISUPPORT tokens that are always passed // to downstream clients. var permanentIsupport = []string{ "soju.im/SAFERATE", } // passthroughIsupport is the set of ISUPPORT tokens that are directly passed // through from the upstream server to downstream clients. var passthroughIsupport = map[string]bool{ "ACCOUNTEXTBAN": true, "AWAYLEN": true, "BOT": true, "CASEMAPPING": true, "CHANLIMIT": true, "CHANMODES": true, "CHANNELLEN": true, "CHANTYPES": true, "CLIENTTAGDENY": true, "ELIST": true, "EXCEPTS": true, "EXTBAN": true, "HOSTLEN": true, "INVEX": true, "KICKLEN": true, "LINELEN": true, "MAXLIST": true, "MAXTARGETS": true, "MODES": true, "MONITOR": true, "NAMELEN": true, "NETWORK": true, "NICKLEN": true, "PREFIX": true, "SAFELIST": true, "STATUSMSG": true, "TARGMAX": true, "TOPICLEN": true, "USERLEN": true, "UTF8ONLY": true, "WHOX": true, } type saslPlain struct { Identity, Username, Password string } type downstreamSASL struct { server sasl.Server mechanism string plain *saslPlain oauthBearer *sasl.OAuthBearerOptions pendingResp bytes.Buffer } type downstreamRegistration struct { nick string username string pass string networkName string networkID int64 negotiatingCaps bool authUsername string } func serverSASLMechanisms(srv *Server) []string { var l []string if srv.Config().Auth.Plain != nil { l = append(l, "PLAIN") } if srv.Config().Auth.OAuthBearer != nil { l = append(l, "OAUTHBEARER") } return l } type downstreamConn struct { *conn id uint64 // These don't change after connection registration registered bool user *user network *network // can be nil clientName string impersonating bool nick string nickCM string realname string username string hostname string account string // RPL_LOGGEDIN/OUT state away *string capVersion int caps xirc.CapRegistry sasl *downstreamSASL // nil unless SASL is underway registration *downstreamRegistration // nil after RPL_WELCOME lastBatchRef uint64 casemap xirc.CaseMapping monitored xirc.CaseMappingMap[struct{}] metadataSubs map[string]struct{} } func newDownstreamConn(srv *Server, ic ircConn, id uint64) *downstreamConn { remoteAddr := ic.RemoteAddr().String() logger := &prefixLogger{srv.Logger, fmt.Sprintf("downstream %q: ", remoteAddr)} options := connOptions{Logger: logger} cm := xirc.CaseMappingASCII dc := &downstreamConn{ conn: newConn(srv, ic, &options), id: id, nick: "*", nickCM: "*", username: "~u", caps: xirc.NewCapRegistry(), casemap: cm, monitored: xirc.NewCaseMappingMap[struct{}](cm), metadataSubs: map[string]struct{}{}, registration: new(downstreamRegistration), } if host, _, err := net.SplitHostPort(remoteAddr); err == nil { dc.hostname = host } else { dc.hostname = remoteAddr } for k, v := range permanentDownstreamCaps { dc.caps.Available[k] = v } dc.caps.Available["sasl"] = strings.Join(serverSASLMechanisms(dc.srv), ",") // TODO: this is racy, we should only enable chathistory after // authentication and then check that user.msgStore implements // chatHistoryMessageStore switch srv.Config().MsgStoreDriver { case "fs", "db": dc.caps.Available["draft/chathistory"] = "" dc.caps.Available["soju.im/search"] = "" } return dc } func (dc *downstreamConn) prefix() *irc.Prefix { return &irc.Prefix{ Name: dc.nick, User: dc.username, Host: dc.hostname, } } func (dc *downstreamConn) forEachNetwork(f func(*network)) { if dc.network != nil { f(dc.network) } } func (dc *downstreamConn) forEachUpstream(f func(*upstreamConn)) { if dc.network == nil { return } dc.user.forEachUpstream(func(uc *upstreamConn) { if dc.network != nil && uc.network != dc.network { return } f(uc) }) } // upstream returns the upstream connection, if any. If there are zero upstream // connections, it returns nil. func (dc *downstreamConn) upstream() *upstreamConn { if dc.network == nil { return nil } return dc.network.conn } func (dc *downstreamConn) upstreamForCommand(cmd string) (*upstreamConn, error) { if dc.network == nil { return nil, newUnknownIRCError(cmd, "Cannot interact with channels and users on the bouncer connection. Did you mean to use a specific network?") } if dc.network.conn == nil { return nil, newUnknownIRCError(cmd, "Disconnected from upstream network") } return dc.network.conn, nil } func isOurNick(net *network, nick string) bool { // TODO: this doesn't account for nick changes if net.conn != nil { return net.conn.isOurNick(nick) } // We're not currently connected to the upstream connection, so we don't // know whether this name is our nickname. Best-effort: use the network's // configured nickname and hope it was the one being used when we were // connected. return net.casemap(nick) == net.casemap(database.GetNick(&net.user.User, &net.Network)) } func (dc *downstreamConn) ReadMessage() (*irc.Message, error) { msg, err := dc.conn.ReadMessage() if err != nil { return nil, err } dc.srv.metrics.downstreamInMessagesTotal.Inc() return msg, nil } func (dc *downstreamConn) readMessages(ch chan<- event) error { for { msg, err := dc.ReadMessage() if errors.Is(err, io.EOF) { break } else if err != nil { return fmt.Errorf("failed to read IRC command: %v", err) } ch <- eventDownstreamMessage{msg, dc} } return nil } // SendMessage sends an outgoing message. // // This can only called from the user goroutine. func (dc *downstreamConn) SendMessage(ctx context.Context, msg *irc.Message) { if !dc.caps.IsEnabled("message-tags") { if msg.Command == "TAGMSG" { return } msg = msg.Copy() for name := range msg.Tags { supported := false switch name { case "time": supported = dc.caps.IsEnabled("server-time") case "account": supported = dc.caps.IsEnabled("account-tag") case "batch": supported = dc.caps.IsEnabled("batch") } if !supported { delete(msg.Tags, name) } } } if !dc.caps.IsEnabled("batch") && msg.Tags["batch"] != "" { msg = msg.Copy() delete(msg.Tags, "batch") } if msg.Command == "JOIN" && !dc.caps.IsEnabled("extended-join") { msg = msg.Copy() msg.Params = msg.Params[:1] } if msg.Command == "SETNAME" && !dc.caps.IsEnabled("setname") { return } if msg.Command == "CHGHOST" && !dc.caps.IsEnabled("chghost") { return } if msg.Command == "AWAY" && !dc.caps.IsEnabled("away-notify") { return } if msg.Command == "ACCOUNT" && !dc.caps.IsEnabled("account-notify") { return } if msg.Command == "MARKREAD" && !dc.caps.IsEnabled("draft/read-marker") { return } if msg.Command == "READ" && !dc.caps.IsEnabled("soju.im/read") { return } if msg.Command == "METADATA" && !dc.caps.IsEnabled("draft/metadata-2") { return } if msg.Prefix != nil && msg.Prefix.Name == "*" { // We use "*" as a sentinel value to simplify upstream message handling msgCopy := *msg msg = &msgCopy msg.Prefix = nil } if msg.Prefix == nil && isNumeric(msg.Command) { // Numerics must always carry a source msgCopy := *msg msg = &msgCopy msg.Prefix = dc.srv.prefix() } dc.srv.metrics.downstreamOutMessagesTotal.Inc() dc.conn.SendMessage(ctx, msg) } func (dc *downstreamConn) SendBatch(ctx context.Context, typ string, params []string, tags irc.Tags, f func(batchRef string)) { dc.lastBatchRef++ ref := fmt.Sprintf("%v", dc.lastBatchRef) if dc.caps.IsEnabled("batch") { dc.SendMessage(ctx, &irc.Message{ Tags: tags, Command: "BATCH", Params: append([]string{"+" + ref, typ}, params...), }) } f(ref) if dc.caps.IsEnabled("batch") { dc.SendMessage(ctx, &irc.Message{ Command: "BATCH", Params: []string{"-" + ref}, }) } } // sendMessageWithID sends an outgoing message with the specified internal ID. func (dc *downstreamConn) sendMessageWithID(ctx context.Context, msg *irc.Message, id string) { dc.SendMessage(ctx, msg) if id == "" || !dc.messageSupportsBacklog(msg) || dc.caps.IsEnabled("draft/chathistory") { return } dc.sendPing(ctx, id) } // advanceMessageWithID advances history to the specified message ID without // sending a message. This is useful e.g. for self-messages when echo-message // isn't enabled. func (dc *downstreamConn) advanceMessageWithID(ctx context.Context, msg *irc.Message, id string) { if id == "" || !dc.messageSupportsBacklog(msg) || dc.caps.IsEnabled("draft/chathistory") { return } dc.sendPing(ctx, id) } // ackMsgID acknowledges that a message has been received. func (dc *downstreamConn) ackMsgID(id string) { netID, entity, err := msgstore.ParseMsgID(id, nil) if err != nil { dc.logger.Printf("failed to ACK message ID %q: %v", id, err) return } network := dc.user.getNetworkByID(netID) if network == nil { return } network.delivered.StoreID(entity, dc.clientName, id) } func (dc *downstreamConn) sendPing(ctx context.Context, msgID string) { token := "soju-msgid-" + msgID dc.SendMessage(ctx, &irc.Message{ Command: "PING", Params: []string{token}, }) } func (dc *downstreamConn) handlePong(token string) { if !strings.HasPrefix(token, "soju-msgid-") { dc.logger.Printf("received unrecognized PONG token %q", token) return } msgID := strings.TrimPrefix(token, "soju-msgid-") dc.ackMsgID(msgID) } func (dc *downstreamConn) handleMessage(ctx context.Context, msg *irc.Message) error { ctx, cancel := dc.conn.NewContext(ctx) defer cancel() ctx, cancel = context.WithTimeout(ctx, handleDownstreamMessageTimeout) defer cancel() switch msg.Command { case "QUIT": dc.conn.Shutdown(ctx) return nil // TODO: stop handling commands default: if dc.registered { return dc.handleMessageRegistered(ctx, msg) } else { return dc.handleMessageUnregistered(ctx, msg) } } } func (dc *downstreamConn) handleMessageUnregistered(ctx context.Context, msg *irc.Message) error { switch msg.Command { case "NICK": if err := parseMessageParams(msg, &dc.registration.nick); err != nil { return err } case "USER": if err := parseMessageParams(msg, &dc.registration.username, nil, nil, nil); err != nil { return err } case "PASS": if err := parseMessageParams(msg, &dc.registration.pass); err != nil { return err } case "CAP": return dc.handleCap(ctx, msg) case "AUTHENTICATE": credentials, err := dc.handleAuthenticate(ctx, msg) if err != nil { return err } else if credentials == nil { break } var username, clientName, networkName string var impersonating bool switch credentials.mechanism { case "PLAIN": username, clientName, networkName = unmarshalUsername(credentials.plain.Username) password := credentials.plain.Password auth := dc.srv.Config().Auth.Plain if auth == nil { err = fmt.Errorf("SASL PLAIN not supported") break } if err = auth.AuthPlain(ctx, dc.srv.db, username, password); err != nil { err = fmt.Errorf("%v (username %q)", err, username) break } if credentials.plain.Identity != "" && credentials.plain.Identity != credentials.plain.Username { var u *database.User u, err = dc.srv.db.GetUser(ctx, username) if err != nil { err = fmt.Errorf("%v (username %q)", err, username) break } if !u.Admin { err = fmt.Errorf("SASL impersonation only allowed for admin users") break } username, clientName, networkName = unmarshalUsername(credentials.plain.Identity) if _, err = dc.srv.db.GetUser(ctx, username); err != nil { err = fmt.Errorf("%v (username %q)", err, username) break } impersonating = true } case "OAUTHBEARER": auth := dc.srv.Config().Auth.OAuthBearer if auth == nil { err = fmt.Errorf("SASL OAUTHBEARER not supported") break } username, err = auth.AuthOAuthBearer(ctx, dc.srv.db, credentials.oauthBearer.Token) if err != nil { break } if credentials.oauthBearer.Username != "" && credentials.oauthBearer.Username != username { err = fmt.Errorf("username mismatch (client provided %q, but server returned %q)", credentials.oauthBearer.Username, username) break } default: err = fmt.Errorf("unsupported SASL mechanism") } if err != nil { dc.logger.Printf("SASL %v authentication error for nick %q: %v", credentials.mechanism, dc.registration.nick, err) dc.endSASL(ctx, &irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, authErrorReason(err)}, }) break } if username == "" { panic(fmt.Errorf("username unset after SASL authentication")) } dc.setAuthUsername(username, clientName, networkName) dc.impersonating = impersonating // Technically we should send RPL_LOGGEDIN here. However we use // RPL_LOGGEDIN to mirror the upstream connection status. Let's // see how many clients that breaks. See: // https://github.com/ircv3/ircv3-specifications/pull/476 dc.endSASL(ctx, nil) case "BOUNCER": var subcommand string if err := parseMessageParams(msg, &subcommand); err != nil { return err } switch strings.ToUpper(subcommand) { case "BIND": var idStr string if err := parseMessageParams(msg, nil, &idStr); err != nil { return err } if dc.registration.authUsername == "" { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "ACCOUNT_REQUIRED", "BIND", "Authentication needed to bind to bouncer network"}, }} } id, err := parseBouncerNetID(subcommand, idStr) if err != nil { return err } dc.registration.networkID = id default: return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "UNKNOWN_COMMAND", subcommand, "Unknown subcommand"}, }} } case "AWAY": if len(msg.Params) > 0 { dc.away = &msg.Params[0] } else { dc.away = nil } dc.SendMessage(ctx, generateAwayReply(dc.away != nil)) case "METADATA": var subcommand string if err := parseMessageParams(msg, nil, &subcommand); err != nil { return err } if handled, err := dc.handleMetadataSub(ctx, msg); handled { return err } return ircError{&irc.Message{ Command: "FAIL", Params: []string{"METADATA", "INVALID_PARAMS", subcommand, "Unknown command"}, }} default: dc.logger.Debugf("unhandled message: %v", msg) return newUnknownCommandError(msg.Command) } return nil } func (dc *downstreamConn) handleCap(ctx context.Context, msg *irc.Message) error { var cmd string if err := parseMessageParams(msg, &cmd); err != nil { return err } args := msg.Params[1:] switch cmd = strings.ToUpper(cmd); cmd { case "LS": if len(args) > 0 { var err error if dc.capVersion, err = strconv.Atoi(args[0]); err != nil { return err } } if !dc.registered && dc.capVersion >= 302 { // Let downstream show everything it supports, and trim // down the available capabilities when upstreams are // known. for k, v := range needAllDownstreamCaps { dc.caps.Available[k] = v } } caps := make([]string, 0, len(dc.caps.Available)) for k, v := range dc.caps.Available { if dc.capVersion >= 302 && v != "" { caps = append(caps, k+"="+v) } else { caps = append(caps, k) } } // TODO: multi-line replies dc.SendMessage(ctx, &irc.Message{ Prefix: dc.srv.prefix(), Command: "CAP", Params: []string{dc.nick, "LS", strings.Join(caps, " ")}, }) if dc.capVersion >= 302 { // CAP version 302 implicitly enables cap-notify dc.caps.SetEnabled("cap-notify", true) } if !dc.registered { dc.registration.negotiatingCaps = true } case "LIST": var caps []string for name := range dc.caps.Enabled { caps = append(caps, name) } // TODO: multi-line replies dc.SendMessage(ctx, &irc.Message{ Prefix: dc.srv.prefix(), Command: "CAP", Params: []string{dc.nick, "LIST", strings.Join(caps, " ")}, }) case "REQ": if len(args) == 0 { return ircError{&irc.Message{ Command: xirc.ERR_INVALIDCAPCMD, Params: []string{dc.nick, cmd, "Missing argument in CAP REQ command"}, }} } caps := strings.Fields(args[0]) ack := true m := make(map[string]bool, len(caps)) for _, name := range caps { name = strings.ToLower(name) enable := !strings.HasPrefix(name, "-") if !enable { name = strings.TrimPrefix(name, "-") } if enable == dc.caps.IsEnabled(name) { continue } if !dc.caps.IsAvailable(name) { ack = false break } if name == "cap-notify" && dc.capVersion >= 302 && !enable { // cap-notify cannot be disabled with CAP version 302 ack = false break } if name == "soju.im/account-required" { // account-required is an informational cap ack = false break } m[name] = enable } // Atomically ack the whole capability set if ack { for name, enable := range m { dc.caps.SetEnabled(name, enable) } } reply := "NAK" if ack { reply = "ACK" } dc.SendMessage(ctx, &irc.Message{ Prefix: dc.srv.prefix(), Command: "CAP", Params: []string{dc.nick, reply, args[0]}, }) if !dc.registered { dc.registration.negotiatingCaps = true } case "END": if !dc.registered { dc.registration.negotiatingCaps = false } default: return ircError{&irc.Message{ Command: xirc.ERR_INVALIDCAPCMD, Params: []string{dc.nick, cmd, "Unknown CAP command"}, }} } return nil } func (dc *downstreamConn) handleAuthenticate(ctx context.Context, msg *irc.Message) (result *downstreamSASL, err error) { defer func() { if err != nil { dc.sasl = nil } }() if !dc.caps.IsEnabled("sasl") { return nil, ircError{&irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "AUTHENTICATE requires the \"sasl\" capability to be enabled"}, }} } if len(msg.Params) == 0 { return nil, ircError{&irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "Missing AUTHENTICATE argument"}, }} } if msg.Params[0] == "*" { return nil, ircError{&irc.Message{ Command: irc.ERR_SASLABORTED, Params: []string{dc.nick, "SASL authentication aborted"}, }} } var resp []byte if dc.sasl == nil { mech := strings.ToUpper(msg.Params[0]) var server sasl.Server switch mech { case "PLAIN": server = sasl.NewPlainServer(sasl.PlainAuthenticator(func(identity, username, password string) error { dc.sasl.plain = &saslPlain{ Identity: identity, Username: username, Password: password, } return nil })) case "OAUTHBEARER": server = sasl.NewOAuthBearerServer(sasl.OAuthBearerAuthenticator(func(options sasl.OAuthBearerOptions) *sasl.OAuthBearerError { dc.sasl.oauthBearer = &options return nil })) case "ANONYMOUS": server = sasl.NewAnonymousServer(func(trace string) error { return nil }) default: return nil, ircError{&irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "Unsupported SASL mechanism"}, }} } dc.sasl = &downstreamSASL{server: server, mechanism: mech} } else { chunk := msg.Params[0] if chunk == "+" { chunk = "" } if dc.sasl.pendingResp.Len()+len(chunk) > 10*1024 { return nil, ircError{&irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "Response too long"}, }} } dc.sasl.pendingResp.WriteString(chunk) if len(chunk) == xirc.MaxSASLLength { return nil, nil // Multi-line response, wait for the next command } resp, err = base64.StdEncoding.DecodeString(dc.sasl.pendingResp.String()) if err != nil { return nil, ircError{&irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "Invalid base64-encoded response"}, }} } dc.sasl.pendingResp.Reset() } challenge, done, err := dc.sasl.server.Next(resp) if err != nil { return nil, err } else if done { return dc.sasl, nil } else { challengeStr := "+" if len(challenge) > 0 { challengeStr = base64.StdEncoding.EncodeToString(challenge) } // TODO: multi-line messages dc.SendMessage(ctx, &irc.Message{ Command: "AUTHENTICATE", Params: []string{challengeStr}, }) return nil, nil } } func (dc *downstreamConn) endSASL(ctx context.Context, msg *irc.Message) { if dc.sasl == nil { return } dc.sasl = nil if msg != nil { dc.SendMessage(ctx, msg) } else { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_SASLSUCCESS, Params: []string{dc.nick, "SASL authentication successful"}, }) } } func (dc *downstreamConn) setSupportedCap(ctx context.Context, name, value string) { prevValue, hasPrev := dc.caps.Available[name] changed := !hasPrev || prevValue != value dc.caps.Available[name] = value if !dc.caps.IsEnabled("cap-notify") || !changed { return } cap := name if value != "" && dc.capVersion >= 302 { cap = name + "=" + value } dc.SendMessage(ctx, &irc.Message{ Prefix: dc.srv.prefix(), Command: "CAP", Params: []string{dc.nick, "NEW", cap}, }) } func (dc *downstreamConn) unsetSupportedCap(ctx context.Context, name string) { hasPrev := dc.caps.IsAvailable(name) dc.caps.Del(name) if !dc.caps.IsEnabled("cap-notify") || !hasPrev { return } dc.SendMessage(ctx, &irc.Message{ Prefix: dc.srv.prefix(), Command: "CAP", Params: []string{dc.nick, "DEL", name}, }) } func (dc *downstreamConn) updateSupportedCaps(ctx context.Context) { supportedCaps := make(map[string]bool) for cap := range needAllDownstreamCaps { supportedCaps[cap] = true } dc.forEachUpstream(func(uc *upstreamConn) { for cap, supported := range supportedCaps { supportedCaps[cap] = supported && uc.caps.IsEnabled(cap) } }) for cap, supported := range supportedCaps { if supported { dc.setSupportedCap(ctx, cap, needAllDownstreamCaps[cap]) } else { dc.unsetSupportedCap(ctx, cap) } } if uc := dc.upstream(); uc != nil && uc.supportsSASL("PLAIN") { dc.setSupportedCap(ctx, "sasl", "PLAIN,ANONYMOUS") } else if dc.network != nil { dc.unsetSupportedCap(ctx, "sasl") } if uc := dc.upstream(); uc != nil && uc.caps.IsEnabled("draft/account-registration") { // Strip "before-connect", because we require downstreams to be fully // connected before attempting account registration. values := strings.Split(uc.caps.Available["draft/account-registration"], ",") for i, v := range values { if v == "before-connect" { values = append(values[:i], values[i+1:]...) break } } dc.setSupportedCap(ctx, "draft/account-registration", strings.Join(values, ",")) } else { dc.unsetSupportedCap(ctx, "draft/account-registration") } if _, ok := dc.user.msgStore.(msgstore.ChatHistoryStore); ok && dc.network != nil { dc.setSupportedCap(ctx, "draft/event-playback", "") } else { dc.unsetSupportedCap(ctx, "draft/event-playback") } } func (dc *downstreamConn) updateNick(ctx context.Context) { var nick string if uc := dc.upstream(); uc != nil { nick = uc.nick } else if dc.network != nil { nick = database.GetNick(&dc.user.User, &dc.network.Network) } else { nick = database.GetNick(&dc.user.User, nil) } if nick == dc.nick { return } dc.SendMessage(ctx, &irc.Message{ Prefix: dc.prefix(), Command: "NICK", Params: []string{nick}, }) dc.nick = nick dc.nickCM = dc.casemap(dc.nick) } func (dc *downstreamConn) updateHost(ctx context.Context) { uc := dc.upstream() if uc == nil || uc.hostname == "" { return } if uc.hostname == dc.hostname && uc.username == dc.username { return } if dc.caps.IsEnabled("chghost") { dc.SendMessage(ctx, &irc.Message{ Prefix: dc.prefix(), Command: "CHGHOST", Params: []string{uc.username, uc.hostname}, }) } else if uc.hostname != dc.hostname { dc.SendMessage(ctx, &irc.Message{ Prefix: dc.prefix(), Command: xirc.RPL_VISIBLEHOST, Params: []string{dc.nick, uc.hostname, "is now your visible host"}, }) } dc.hostname = uc.hostname dc.username = uc.username } func (dc *downstreamConn) updateRealname(ctx context.Context) { if !dc.caps.IsEnabled("setname") { return } var realname string if uc := dc.upstream(); uc != nil { realname = uc.realname } else if dc.network != nil { realname = database.GetRealname(&dc.user.User, &dc.network.Network) } else { realname = database.GetRealname(&dc.user.User, nil) } if realname != dc.realname { dc.SendMessage(ctx, &irc.Message{ Prefix: dc.prefix(), Command: "SETNAME", Params: []string{realname}, }) dc.realname = realname } } func (dc *downstreamConn) updateAccount(ctx context.Context) { var account string if dc.network == nil { account = dc.user.Username } else if uc := dc.upstream(); uc != nil { account = uc.account } else { return } if dc.account == account || !dc.caps.IsEnabled("sasl") { return } if account != "" { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_LOGGEDIN, Params: []string{dc.nick, dc.prefix().String(), account, "You are logged in as " + account}, }) } else { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_LOGGEDOUT, Params: []string{dc.nick, dc.prefix().String(), "You are logged out"}, }) } dc.account = account } func (dc *downstreamConn) updateCasemapping() { cm := xirc.CaseMappingASCII if dc.network != nil { cm = dc.network.casemap } dc.casemap = cm dc.nickCM = cm(dc.nick) dc.monitored.SetCaseMapping(cm) } func sanityCheckServer(ctx context.Context, addr string) error { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() conn, err := new(tls.Dialer).DialContext(ctx, "tcp", addr) if err != nil { return err } return conn.Close() } func unmarshalUsername(rawUsername string) (username, client, network string) { username = rawUsername i := strings.IndexAny(username, "/@") j := strings.LastIndexAny(username, "/@") if i >= 0 { username = rawUsername[:i] } if j >= 0 { if rawUsername[j] == '@' { client = rawUsername[j+1:] } else { network = rawUsername[j+1:] } } if i >= 0 && j >= 0 && i < j { if rawUsername[i] == '@' { client = rawUsername[i+1 : j] } else { network = rawUsername[i+1 : j] } } return username, client, network } func (dc *downstreamConn) setAuthUsername(username, clientName, networkName string) { dc.clientName = clientName dc.registration.authUsername = username dc.registration.networkName = networkName } func (dc *downstreamConn) register(ctx context.Context) error { if dc.registered { panic("tried to register twice") } if dc.sasl != nil { dc.endSASL(ctx, &irc.Message{ Command: irc.ERR_SASLABORTED, Params: []string{dc.nick, "SASL authentication aborted"}, }) } password := dc.registration.pass dc.registration.pass = "" if dc.registration.authUsername == "" { if password == "" { if dc.caps.IsEnabled("sasl") { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"*", "ACCOUNT_REQUIRED", "Authentication required"}, }} } else { return ircError{&irc.Message{ Command: irc.ERR_PASSWDMISMATCH, Params: []string{dc.nick, "Authentication required"}, }} } } plainAuth := dc.srv.Config().Auth.Plain if plainAuth == nil { return ircError{&irc.Message{ Command: irc.ERR_PASSWDMISMATCH, Params: []string{dc.nick, "PASS authentication disabled"}, }} } username, clientName, networkName := unmarshalUsername(dc.registration.username) if err := plainAuth.AuthPlain(ctx, dc.srv.db, username, password); err != nil { dc.logger.Printf("PASS authentication error for user %q: %v", dc.registration.username, err) return ircError{&irc.Message{ Command: irc.ERR_PASSWDMISMATCH, Params: []string{dc.nick, authErrorReason(err)}, }} } dc.setAuthUsername(username, clientName, networkName) } _, fallbackClientName, fallbackNetworkName := unmarshalUsername(dc.registration.username) if dc.clientName == "" { dc.clientName = fallbackClientName } else if fallbackClientName != "" && dc.clientName != fallbackClientName { return ircError{&irc.Message{ Command: irc.ERR_ERRONEUSNICKNAME, Params: []string{dc.nick, "Client name mismatch in usernames"}, }} } if dc.registration.networkName == "" { dc.registration.networkName = fallbackNetworkName } else if fallbackNetworkName != "" && dc.registration.networkName != fallbackNetworkName { return ircError{&irc.Message{ Command: irc.ERR_ERRONEUSNICKNAME, Params: []string{dc.nick, "Network name mismatch in usernames"}, }} } dc.registered = true dc.username = dc.registration.authUsername dc.logger.Printf("registration complete for user %q", dc.username) return nil } func (dc *downstreamConn) loadNetwork(ctx context.Context) error { if id := dc.registration.networkID; id != 0 { network := dc.user.getNetworkByID(id) if network == nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "INVALID_NETID", fmt.Sprintf("%v", id), "Unknown network ID"}, }} } dc.network = network return nil } if dc.registration.networkName == "*" { return ircError{&irc.Message{ Command: irc.ERR_PASSWDMISMATCH, Params: []string{dc.nick, fmt.Sprintf("Multi-upstream mode is no longer supported")}, }} } if dc.registration.networkName == "" { return nil } network := dc.user.getNetwork(dc.registration.networkName) if network == nil { addr := dc.registration.networkName if !strings.ContainsRune(addr, ':') { addr = addr + ":6697" } dc.logger.Printf("trying to connect to new network %q", addr) if err := sanityCheckServer(ctx, addr); err != nil { dc.logger.Printf("failed to connect to %q: %v", addr, err) return ircError{&irc.Message{ Command: irc.ERR_PASSWDMISMATCH, Params: []string{dc.nick, fmt.Sprintf("Failed to connect to %q", dc.registration.networkName)}, }} } // Some clients only allow specifying the nickname (and use the // nickname as a username too). Strip the network name from the // nickname when auto-saving networks. nick, _, _ := unmarshalUsername(dc.registration.nick) if nick == "" || strings.ContainsAny(nick, illegalNickChars) { return ircError{&irc.Message{ Command: irc.ERR_ERRONEUSNICKNAME, Params: []string{dc.nick, dc.registration.nick, "Nickname contains illegal characters"}, }} } if xirc.CaseMappingASCII(nick) == serviceNickCM { return ircError{&irc.Message{ Command: irc.ERR_NICKNAMEINUSE, Params: []string{dc.nick, dc.registration.nick, "Nickname reserved for bouncer service"}, }} } record := database.NewNetwork(dc.registration.networkName) record.Nick = nick dc.logger.Printf("auto-saving network %q", dc.registration.networkName) var err error network, err = dc.user.createNetwork(ctx, record, true) if err != nil { return err } } dc.network = network return nil } func (dc *downstreamConn) welcome(ctx context.Context, user *user) error { if !dc.registered { panic("tried to welcome an unregistered connection") } if dc.user != nil { panic("tried to welcome the same connection twice") } dc.user = user remoteAddr := dc.conn.RemoteAddr().String() dc.logger = &prefixLogger{dc.srv.Logger, fmt.Sprintf("user %q: downstream %q: ", dc.user.Username, remoteAddr)} // TODO: doing this might take some time. We should do it in dc.register // instead, but we'll potentially be adding a new network and this must be // done in the user goroutine. if err := dc.loadNetwork(ctx); err != nil { return err } dc.registration = nil dc.updateSupportedCaps(ctx) if uc := dc.upstream(); uc != nil { dc.nick = uc.nick } else if dc.network != nil { dc.nick = database.GetNick(&dc.user.User, &dc.network.Network) } else { dc.nick = dc.user.Username } dc.nickCM = dc.casemap(dc.nick) var isupport []string isupport = append(isupport, permanentIsupport...) if dc.network != nil { isupport = append(isupport, fmt.Sprintf("BOUNCER_NETID=%v", dc.network.ID)) } else { isupport = append(isupport, "BOT=B", "CASEMAPPING=ascii") } if title := dc.srv.Config().Title; dc.network == nil && title != "" { isupport = append(isupport, "NETWORK="+title) } if dc.network == nil { isupport = append(isupport, "WHOX") isupport = append(isupport, "CHANTYPES=") // channels are not supported isupport = append(isupport, "LINELEN=4096") // default bufio.Reader size } if _, ok := dc.user.msgStore.(msgstore.ChatHistoryStore); ok && dc.network != nil { isupport = append(isupport, fmt.Sprintf("CHATHISTORY=%v", chatHistoryLimit)) isupport = append(isupport, "MSGREFTYPES=timestamp") } if dc.caps.IsEnabled("soju.im/webpush") { isupport = append(isupport, "VAPID="+dc.srv.webPush.VAPIDKeys.Public) } if dc.srv.Config().FileUploader != nil { isupport = append(isupport, "soju.im/FILEHOST="+dc.srv.Config().HTTPIngress+"/uploads") } if uc := dc.upstream(); uc != nil { // If upstream doesn't support message-tags, indicate that we'll drop // all of them if _, ok := uc.isupport["CLIENTTAGDENY"]; !ok && !uc.caps.IsEnabled("message-tags") { isupport = append(isupport, "CLIENTTAGDENY=*") } for k := range passthroughIsupport { v, ok := uc.isupport[k] if !ok { continue } if v != nil { isupport = append(isupport, fmt.Sprintf("%v=%v", k, *v)) } else { isupport = append(isupport, k) } } } dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_WELCOME, Params: []string{dc.nick, "Welcome to soju, " + dc.nick}, }) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_YOURHOST, Params: []string{dc.nick, "Your host is " + dc.srv.Config().Hostname}, }) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_MYINFO, Params: []string{dc.nick, dc.srv.Config().Hostname, "soju", "aiwroO", "OovaimnqpsrtklbeI"}, }) for _, msg := range xirc.GenerateIsupport(isupport) { dc.SendMessage(ctx, msg) } if uc := dc.upstream(); uc != nil { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_UMODEIS, Params: []string{dc.nick, "+" + string(uc.modes)}, }) } if dc.network == nil && dc.user.Admin { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_UMODEIS, Params: []string{dc.nick, "+o"}, }) } dc.updateHost(ctx) dc.updateRealname(ctx) dc.updateAccount(ctx) dc.updateCasemapping() if motd := dc.user.srv.Config().MOTD; motd != "" && dc.network == nil { for _, msg := range xirc.GenerateMOTD(motd) { dc.SendMessage(ctx, msg) } } else { motdHint := "No MOTD" if dc.network != nil { motdHint = "Use /motd to read the message of the day" } dc.SendMessage(ctx, &irc.Message{ Command: irc.ERR_NOMOTD, Params: []string{dc.nick, motdHint}, }) } if dc.caps.IsEnabled("soju.im/bouncer-networks-notify") { dc.SendBatch(ctx, "soju.im/bouncer-networks", nil, nil, func(batchRef string) { for _, network := range dc.user.networks { idStr := fmt.Sprintf("%v", network.ID) attrs := getNetworkAttrs(network) dc.SendMessage(ctx, &irc.Message{ Tags: irc.Tags{"batch": batchRef}, Command: "BOUNCER", Params: []string{"NETWORK", idStr, attrs.String()}, }) } }) } if dc.caps.IsEnabled("draft/metadata-2") { // No user-specific metadata dc.SendBatch(ctx, "metadata", nil, nil, func(batchRef string) {}) } dc.forEachUpstream(func(uc *upstreamConn) { uc.channels.ForEach(func(_ string, ch *upstreamChannel) { if !ch.complete { return } record := uc.network.channels.Get(ch.Name) if record != nil && record.Detached { return } params := []string{ch.Name} if dc.caps.IsEnabled("extended-join") { account := uc.account if account == "" { account = "*" } params = append(params, account, uc.realname) } dc.SendMessage(ctx, &irc.Message{ Prefix: dc.prefix(), Command: "JOIN", Params: params, }) forwardChannel(ctx, dc, ch) }) }) dc.forEachNetwork(func(net *network) { if dc.caps.IsEnabled("draft/chathistory") || dc.user.msgStore == nil { return } // Only send history if we're the first connected client with that name // for the network firstClient := true for _, c := range dc.user.downstreamConns { if c != dc && c.clientName == dc.clientName && c.network == dc.network { firstClient = false } } if firstClient { net.delivered.ForEachTarget(func(target string) { lastDelivered := net.delivered.LoadID(target, dc.clientName) if lastDelivered == "" { return } dc.sendTargetBacklog(ctx, net, target, lastDelivered) // Fast-forward history to last message targetCM := net.casemap(target) lastID, err := dc.user.msgStore.LastMsgID(&net.Network, targetCM, time.Now()) if err != nil { dc.logger.Printf("failed to get last message ID: %v", err) return } net.delivered.StoreID(target, dc.clientName, lastID) }) } }) return nil } // messageSupportsBacklog checks whether the provided message can be sent as // part of an history batch. func (dc *downstreamConn) messageSupportsBacklog(msg *irc.Message) bool { // Don't replay all messages, because that would mess up client // state. For instance we just sent the list of users, sending // PART messages for one of these users would be incorrect. switch msg.Command { case "PRIVMSG", "NOTICE": return true } return false } func (dc *downstreamConn) sendTargetBacklog(ctx context.Context, net *network, target, msgID string) { if dc.caps.IsEnabled("draft/chathistory") || dc.user.msgStore == nil { return } ch := net.channels.Get(target) ctx, cancel := context.WithTimeout(ctx, backlogTimeout) defer cancel() targetCM := net.casemap(target) loadOptions := msgstore.LoadMessageOptions{ Network: &net.Network, Entity: targetCM, Limit: backlogLimit, } history, err := dc.user.msgStore.LoadLatestID(ctx, msgID, &loadOptions) if err != nil { dc.logger.Printf("failed to send backlog for %q: %v", target, err) return } dc.SendBatch(ctx, "chathistory", []string{target}, nil, func(batchRef string) { for _, msg := range history { if ch != nil && ch.Detached { if net.detachedMessageNeedsRelay(ch, msg) { dc.relayDetachedMessage(net, msg) } } else { msg.Tags["batch"] = batchRef dc.SendMessage(ctx, msg) } } }) } func (dc *downstreamConn) relayDetachedMessage(net *network, msg *irc.Message) { if msg.Command != "PRIVMSG" && msg.Command != "NOTICE" { return } sender := msg.Prefix.Name target, text := msg.Params[0], msg.Params[1] if net.isHighlight(msg) { sendServiceNOTICE(dc, fmt.Sprintf("highlight in %v: <%v> %v", target, sender, text)) } else { sendServiceNOTICE(dc, fmt.Sprintf("message in %v: <%v> %v", target, sender, text)) } } func (dc *downstreamConn) runUntilRegistered() error { ctx, cancel := context.WithTimeout(context.TODO(), downstreamRegisterTimeout) defer cancel() // Close the connection with an error if the deadline is exceeded go func() { <-ctx.Done() if err := ctx.Err(); err == context.DeadlineExceeded { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() dc.SendMessage(ctx, &irc.Message{ Prefix: dc.srv.prefix(), Command: "ERROR", Params: []string{"Connection registration timed out"}, }) dc.Shutdown(ctx) } }() for !dc.registered { msg, err := dc.ReadMessage() if err != nil { return fmt.Errorf("failed to read IRC command: %w", err) } err = dc.handleMessage(ctx, msg) if ircErr, ok := err.(ircError); ok { ircErr.Message.Prefix = dc.srv.prefix() dc.SendMessage(ctx, ircErr.Message) } else if err != nil { return fmt.Errorf("failed to handle IRC command %q: %v", msg, err) } if dc.registration.nick == "" || dc.registration.username == "" || dc.registration.negotiatingCaps { continue } if err := dc.register(ctx); err == nil { break } else if ircErr, ok := err.(ircError); ok { ircErr.Message.Prefix = dc.srv.prefix() dc.SendMessage(ctx, ircErr.Message) return io.EOF } else { return fmt.Errorf("connection registration failed: %v", err) } } return nil } func (dc *downstreamConn) handleMessageRegistered(ctx context.Context, msg *irc.Message) error { switch msg.Command { case "CAP": return dc.handleCap(ctx, msg) case "PING": var source, destination string if err := parseMessageParams(msg, &source); err != nil { return err } if len(msg.Params) > 1 { destination = msg.Params[1] } hostname := dc.srv.Config().Hostname if destination != "" && destination != hostname { return ircError{&irc.Message{ Command: irc.ERR_NOSUCHSERVER, Params: []string{dc.nick, destination, "No such server"}, }} } dc.SendMessage(ctx, &irc.Message{ Prefix: dc.srv.prefix(), Command: "PONG", Params: []string{hostname, source}, }) return nil case "PONG": if len(msg.Params) == 0 { return newNeedMoreParamsError(msg.Command) } token := msg.Params[len(msg.Params)-1] dc.handlePong(token) case "USER": return ircError{&irc.Message{ Command: irc.ERR_ALREADYREGISTERED, Params: []string{dc.nick, "You may not reregister"}, }} case "NICK": var nick string if err := parseMessageParams(msg, &nick); err != nil { return err } if nick == "" || strings.ContainsAny(nick, illegalNickChars) { return ircError{&irc.Message{ Command: irc.ERR_ERRONEUSNICKNAME, Params: []string{dc.nick, nick, "Nickname contains illegal characters"}, }} } if dc.casemap(nick) == serviceNickCM { return ircError{&irc.Message{ Command: irc.ERR_NICKNAMEINUSE, Params: []string{dc.nick, nick, "Nickname reserved for bouncer service"}, }} } var err error if dc.network != nil { record := dc.network.Network record.Nick = nick err = dc.srv.db.StoreNetwork(ctx, dc.user.ID, &record) } else { err = dc.user.updateUser(ctx, func(record *database.User) error { record.Nick = nick return nil }) } if err != nil { dc.logger.Printf("failed to update nick: %v", err) return ircError{&irc.Message{ Command: xirc.ERR_UNKNOWNERROR, Params: []string{dc.nick, "NICK", "Failed to update nick"}, }} } if dc.network != nil { dc.network.Network.Nick = nick if uc := dc.upstream(); uc != nil { uc.SendMessageLabeled(ctx, dc.id, &irc.Message{ Command: "NICK", Params: []string{nick}, }) } else { dc.updateNick(ctx) } } else { for _, c := range dc.user.downstreamConns { if c.network == nil { c.updateNick(ctx) } } } case "SETNAME": var realname string if err := parseMessageParams(msg, &realname); err != nil { return err } if dc.realname == realname { dc.SendMessage(ctx, &irc.Message{ Prefix: dc.prefix(), Command: "SETNAME", Params: []string{realname}, }) return nil } var err error if dc.network != nil { // If the client just resets to the default, just wipe the per-network // preference record := dc.network.Network record.Realname = realname if realname == dc.user.Realname { record.Realname = "" } if uc := dc.upstream(); uc != nil && uc.caps.IsEnabled("setname") { // Upstream will reply with a SETNAME message on success uc.SendMessageLabeled(ctx, dc.id, &irc.Message{ Command: "SETNAME", Params: []string{realname}, }) err = dc.srv.db.StoreNetwork(ctx, dc.user.ID, &record) } else { // This will disconnect then re-connect the upstream connection _, err = dc.user.updateNetwork(ctx, &record, false) } } else { err = dc.user.updateUser(ctx, func(record *database.User) error { record.Realname = realname return nil }) } if err != nil { dc.logger.Printf("failed to update realname: %v", err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"SETNAME", "CANNOT_CHANGE_REALNAME", "Failed to update realname"}, }} } if dc.network == nil { for _, c := range dc.user.downstreamConns { if c.network == nil { c.updateRealname(ctx) } } } case "JOIN": uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } var namesStr string if err := parseMessageParams(msg, &namesStr); err != nil { return err } var keys []string if len(msg.Params) > 1 { keys = strings.Split(msg.Params[1], ",") } for i, name := range strings.Split(namesStr, ",") { var key string if len(keys) > i { key = keys[i] } if name == "" || strings.ContainsAny(name, illegalChanChars) { dc.SendMessage(ctx, &irc.Message{ Command: irc.ERR_BADCHANMASK, Params: []string{dc.nick, name, "Invalid channel name"}, }) continue } if !uc.isChannel(name) { dc.SendMessage(ctx, &irc.Message{ Command: irc.ERR_NOSUCHCHANNEL, Params: []string{dc.nick, name, "Not a channel name"}, }) continue } // Most servers ignore duplicate JOIN messages. We ignore them here // because some clients automatically send JOIN messages in bulk // when reconnecting to the bouncer. We don't want to flood the // upstream connection with these. if !uc.channels.Has(name) { params := []string{name} if key != "" { params = append(params, key) } uc.SendMessageLabeled(ctx, dc.id, &irc.Message{ Command: "JOIN", Params: params, }) } ch := uc.network.channels.Get(name) if ch != nil { // Don't clear the channel key if there's one set // TODO: add a way to unset the channel key if key != "" { ch.Key = key } uc.network.attach(ctx, ch) } else { ch = &database.Channel{ Name: name, Key: key, } uc.network.channels.Set(ch.Name, ch) } if err := dc.srv.db.StoreChannel(ctx, uc.network.ID, ch); err != nil { dc.logger.Printf("failed to create or update channel %q: %v", name, err) } } case "PART": uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } var namesStr string if err := parseMessageParams(msg, &namesStr); err != nil { return err } var reason string if len(msg.Params) > 1 { reason = msg.Params[1] } for _, name := range strings.Split(namesStr, ",") { if strings.EqualFold(reason, "detach") { ch := uc.network.channels.Get(name) if ch != nil { uc.network.detach(ch) } else { ch = &database.Channel{ Name: name, Detached: true, } uc.network.channels.Set(ch.Name, ch) } if err := dc.srv.db.StoreChannel(ctx, uc.network.ID, ch); err != nil { dc.logger.Printf("failed to create or update channel %q: %v", name, err) } } else { params := []string{name} if reason != "" { params = append(params, reason) } uc.SendMessageLabeled(ctx, dc.id, &irc.Message{ Command: "PART", Params: params, }) if err := uc.network.deleteChannel(ctx, name); err != nil { dc.logger.Printf("failed to delete channel %q: %v", name, err) } uc.network.pushTargets.Del(name) } } case "KICK": uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } uc.SendMessageLabeled(ctx, dc.id, msg) case "MODE": var name string if err := parseMessageParams(msg, &name); err != nil { return err } var modeStr string if len(msg.Params) > 1 { modeStr = msg.Params[1] } if dc.casemap(name) == dc.nickCM { if modeStr != "" { uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } uc.SendMessageLabeled(ctx, dc.id, msg) } else { var userMode string if uc := dc.upstream(); uc != nil { userMode = string(uc.modes) } dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_UMODEIS, Params: []string{dc.nick, "+" + userMode}, }) } return nil } uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } if !uc.isChannel(name) { return ircError{&irc.Message{ Command: irc.ERR_USERSDONTMATCH, Params: []string{dc.nick, "Cannot change mode for other users"}, }} } if modeStr != "" { params := []string{name, modeStr} params = append(params, msg.Params[2:]...) uc.SendMessageLabeled(ctx, dc.id, &irc.Message{ Command: "MODE", Params: params, }) } else { ch := uc.channels.Get(name) if ch == nil { // we're not on that channel, pass command to upstream uc.SendMessageLabeled(ctx, dc.id, msg) return nil } if ch.modes == nil { // we haven't received the initial RPL_CHANNELMODEIS yet // ignore the request, we will broadcast the modes later when we receive RPL_CHANNELMODEIS return nil } modeStr, modeParams := ch.modes.Format() params := []string{dc.nick, name, modeStr} params = append(params, modeParams...) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_CHANNELMODEIS, Params: params, }) if ch.creationTime != "" { dc.SendMessage(ctx, &irc.Message{ Command: xirc.RPL_CREATIONTIME, Params: []string{dc.nick, name, ch.creationTime}, }) } } case "TOPIC": var name string if err := parseMessageParams(msg, &name); err != nil { return err } uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } if len(msg.Params) > 1 { // setting topic topic := msg.Params[1] uc.SendMessageLabeled(ctx, dc.id, &irc.Message{ Command: "TOPIC", Params: []string{name, topic}, }) } else { // getting topic ch := uc.channels.Get(name) if ch == nil { // we're not on that channel, pass command to upstream uc.SendMessageLabeled(ctx, dc.id, msg) } else { sendTopic(ctx, dc, ch) } } case "LIST": uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } uc.enqueueCommand(dc, msg) case "NAMES": uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } if len(msg.Params) == 0 { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFNAMES, Params: []string{dc.nick, "*", "End of /NAMES list"}, }) return nil } channels := strings.Split(msg.Params[0], ",") for _, name := range channels { ch := uc.channels.Get(name) if ch != nil { sendNames(ctx, dc, ch) } else { // NAMES on a channel we have not joined, ask upstream uc.SendMessageLabeled(ctx, dc.id, &irc.Message{ Command: "NAMES", Params: []string{name}, }) } } case "WHO": if len(msg.Params) == 0 { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFWHO, Params: []string{"*", "*", "End of /WHO list"}, }) return nil } // Clients will use the first mask to match RPL_ENDOFWHO endOfWhoToken := msg.Params[0] mask := msg.Params[0] var options string if len(msg.Params) > 1 { options = msg.Params[1] } fields, whoxToken := xirc.ParseWHOXOptions(options) // TODO: support mixed bouncer/upstream WHO queries maskCM := dc.casemap(mask) if dc.network == nil && maskCM == dc.nickCM { flags := "H" if dc.away != nil { flags = "G" } if dc.user.Admin { flags += "*" } info := xirc.WHOXInfo{ Token: whoxToken, Username: dc.user.Username, Hostname: dc.hostname, Server: dc.srv.Config().Hostname, Nickname: dc.nick, Flags: flags, Account: dc.user.Username, Realname: dc.realname, } dc.SendMessage(ctx, xirc.GenerateWHOXReply(fields, &info)) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFWHO, Params: []string{"*", endOfWhoToken, "End of /WHO list"}, }) return nil } if maskCM == serviceNickCM { flags := "H*" if dc.network == nil { flags += "B" } else if uc := dc.upstream(); uc != nil { if v := uc.isupport["BOT"]; v != nil && len(*v) == 1 { flags += *v } } info := xirc.WHOXInfo{ Token: whoxToken, Username: servicePrefix.User, Hostname: servicePrefix.Host, Server: dc.srv.Config().Hostname, Nickname: serviceNick, Flags: flags, Account: serviceNick, Realname: serviceRealname, } dc.SendMessage(ctx, xirc.GenerateWHOXReply(fields, &info)) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFWHO, Params: []string{"*", endOfWhoToken, "End of /WHO list"}, }) return nil } if dc.network == nil { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFWHO, Params: []string{"*", endOfWhoToken, "End of /WHO list"}, }) return nil } uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } // Check if we have the reply cached if l, ok := uc.getCachedWHO(mask, fields); ok { for _, uu := range l { info := xirc.WHOXInfo{ Token: whoxToken, Username: uu.Username, Hostname: uu.Hostname, Server: uu.Server, Nickname: uu.Nickname, Flags: uu.Flags, Account: uu.Account, Realname: uu.Realname, } if uc.isChannel(mask) { info.Channel = mask // Set channel membership prefixes from cached NAMES reply ch := uc.channels.Get(info.Channel) memberships := ch.Members.Get(info.Nickname) prefixes := formatMemberPrefix(*memberships, dc) // Channel membership prefixes are listed after away status ('G'/'H') // and optional server operator indicator ('*') i := strings.IndexFunc(info.Flags, func(f rune) bool { return f != 'G' && f != 'H' && f != '*' }) if i == -1 { info.Flags += prefixes } else { info.Flags = info.Flags[:i] + prefixes + info.Flags[i:] } } dc.SendMessage(ctx, xirc.GenerateWHOXReply(fields, &info)) } dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFWHO, Params: []string{"*", endOfWhoToken, "End of /WHO list"}, }) return nil } uc.enqueueCommand(dc, msg) case "WHOIS": if len(msg.Params) == 0 { return ircError{&irc.Message{ Command: irc.ERR_NONICKNAMEGIVEN, Params: []string{dc.nick, "No nickname given"}, }} } var mask string if len(msg.Params) == 1 { mask = msg.Params[0] } else { mask = msg.Params[1] } // TODO: support multiple WHOIS users if i := strings.IndexByte(mask, ','); i >= 0 { mask = mask[:i] } if dc.network == nil && dc.casemap(mask) == dc.nickCM { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_WHOISUSER, Params: []string{dc.nick, dc.nick, dc.user.Username, dc.hostname, "*", dc.realname}, }) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_WHOISSERVER, Params: []string{dc.nick, dc.nick, dc.srv.Config().Hostname, "soju"}, }) if dc.user.Admin { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_WHOISOPERATOR, Params: []string{dc.nick, dc.nick, "is a bouncer administrator"}, }) } dc.SendMessage(ctx, &irc.Message{ Command: xirc.RPL_WHOISACCOUNT, Params: []string{dc.nick, dc.nick, dc.user.Username, "is logged in as"}, }) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFWHOIS, Params: []string{dc.nick, dc.nick, "End of /WHOIS list"}, }) return nil } if dc.casemap(mask) == serviceNickCM { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_WHOISUSER, Params: []string{dc.nick, serviceNick, servicePrefix.User, servicePrefix.Host, "*", serviceRealname}, }) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_WHOISSERVER, Params: []string{dc.nick, serviceNick, dc.srv.Config().Hostname, "soju"}, }) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_WHOISOPERATOR, Params: []string{dc.nick, serviceNick, "is the bouncer service"}, }) dc.SendMessage(ctx, &irc.Message{ Command: xirc.RPL_WHOISACCOUNT, Params: []string{dc.nick, serviceNick, serviceNick, "is logged in as"}, }) dc.SendMessage(ctx, &irc.Message{ Command: xirc.RPL_WHOISBOT, Params: []string{dc.nick, serviceNick, "is a bot"}, }) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFWHOIS, Params: []string{dc.nick, serviceNick, "End of /WHOIS list"}, }) return nil } uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } uc.enqueueCommand(dc, msg) case "PRIVMSG", "NOTICE", "TAGMSG": var targetsStr, text string if msg.Command != "TAGMSG" { if err := parseMessageParams(msg, &targetsStr, &text); err != nil { return err } } else { if err := parseMessageParams(msg, &targetsStr); err != nil { return err } } tags := copyClientTags(msg.Tags) for _, name := range strings.Split(targetsStr, ",") { params := []string{name} if msg.Command != "TAGMSG" { params = append(params, text) } if name == "$"+dc.srv.Config().Hostname || (name == "$*" && dc.network == nil) { // "$" means a server mask follows. If it's the bouncer's // hostname, broadcast the message to all bouncer users. if !dc.user.Admin { return ircError{&irc.Message{ Command: irc.ERR_BADMASK, Params: []string{dc.nick, name, "Permission denied to broadcast message to all bouncer users"}, }} } dc.logger.Printf("broadcasting bouncer-wide %v: %v", msg.Command, text) broadcastTags := tags.Copy() broadcastTags["time"] = dc.user.FormatServerTime(time.Now()) broadcastMsg := &irc.Message{ Tags: broadcastTags, Prefix: servicePrefix, Command: msg.Command, Params: params, } dc.srv.forEachUser(func(u *user) { u.events <- eventBroadcast{broadcastMsg} }) continue } if dc.network == nil && dc.casemap(name) == dc.nickCM { dc.SendMessage(ctx, &irc.Message{ Tags: msg.Tags.Copy(), Prefix: dc.prefix(), Command: msg.Command, Params: params, }) continue } if dc.casemap(name) == serviceNickCM { if dc.caps.IsEnabled("echo-message") { echoTags := tags.Copy() echoTags["time"] = dc.user.FormatServerTime(time.Now()) dc.SendMessage(ctx, &irc.Message{ Tags: echoTags, Prefix: dc.prefix(), Command: msg.Command, Params: params, }) } if msg.Command == "PRIVMSG" { if err := handleServicePRIVMSG(&serviceContext{ Context: ctx, nick: dc.nick, network: dc.network, user: dc.user, srv: dc.user.srv, admin: dc.user.Admin, print: func(text string) { sendServicePRIVMSG(dc, text) }, }, text); err != nil { sendServicePRIVMSG(dc, fmt.Sprintf("error: %v", err)) } } continue } uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } if msg.Command == "PRIVMSG" && uc.network.casemap(name) == "nickserv" { dc.handleNickServPRIVMSG(ctx, uc, text) } upstreamParams := []string{name} if msg.Command != "TAGMSG" { upstreamParams = append(upstreamParams, text) } uc.SendMessageLabeled(ctx, dc.id, &irc.Message{ Tags: tags, Command: msg.Command, Params: upstreamParams, }) // If the upstream supports echo message, we'll produce the message // when it is echoed from the upstream. // Otherwise, produce/log it here because it's the last time we'll see it. if !uc.caps.IsEnabled("echo-message") { echoParams := []string{name} if msg.Command != "TAGMSG" { echoParams = append(echoParams, text) } echoTags := tags.Copy() echoTags["time"] = dc.user.FormatServerTime(time.Now()) if uc.account != "" { echoTags["account"] = uc.account } echoMsg := &irc.Message{ Tags: echoTags, Prefix: &irc.Prefix{ Name: uc.nick, User: uc.username, Host: uc.hostname, }, Command: msg.Command, Params: echoParams, } uc.produce(name, echoMsg, dc.id) } uc.updateChannelAutoDetach(name) } case "INVITE": uc, err := dc.upstreamForCommand(msg.Command) if err != nil { return err } uc.SendMessageLabeled(ctx, dc.id, msg) case "AUTHENTICATE": // Post-connection-registration AUTHENTICATE is only supported if an // upstream is bound and supports SASL uc := dc.upstream() if uc == nil || !uc.caps.IsEnabled("sasl") { return ircError{&irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "Upstream network authentication not supported"}, }} } credentials, err := dc.handleAuthenticate(ctx, msg) if err != nil { return err } else if credentials == nil { break } if uc.saslClient != nil { dc.endSASL(ctx, &irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "Another authentication attempt is already in progress"}, }) break } switch credentials.mechanism { case "PLAIN": if credentials.plain.Identity != "" && credentials.plain.Identity != credentials.plain.Username { return ircError{&irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "SASL PLAIN identity not supported"}, }} } uc.logger.Printf("starting post-registration SASL PLAIN authentication with username %q", credentials.plain.Username) uc.saslClient = sasl.NewPlainClient("", credentials.plain.Username, credentials.plain.Password) uc.enqueueCommand(dc, &irc.Message{ Command: "AUTHENTICATE", Params: []string{"PLAIN"}, }) case "ANONYMOUS": if uc.network.SASL.Mechanism != "" { record := uc.network.Network // copy network record because we'll mutate it record.SASL.Plain.Username = "" record.SASL.Plain.Password = "" record.SASL.External.CertBlob = nil record.SASL.External.PrivKeyBlob = nil record.SASL.Mechanism = "" _, err := dc.user.updateNetwork(ctx, &record, false) if err != nil { dc.logger.Printf("failed to clear SASL credentials") dc.endSASL(ctx, &irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "Internal server error"}, }) break } } dc.endSASL(ctx, nil) default: dc.endSASL(ctx, &irc.Message{ Command: irc.ERR_SASLFAIL, Params: []string{dc.nick, "Unsupported SASL authentication mechanism"}, }) } case "REGISTER", "VERIFY": // Check number of params here, since we'll use that to save the // credentials on command success if (msg.Command == "REGISTER" && len(msg.Params) < 3) || (msg.Command == "VERIFY" && len(msg.Params) < 2) { return newNeedMoreParamsError(msg.Command) } uc := dc.upstream() if uc == nil || !uc.caps.IsEnabled("draft/account-registration") { return ircError{&irc.Message{ Command: "FAIL", Params: []string{msg.Command, "TEMPORARILY_UNAVAILABLE", "*", "Upstream network account registration not supported"}, }} } uc.logger.Printf("starting %v with account name %v", msg.Command, msg.Params[0]) uc.enqueueCommand(dc, msg) case "AWAY": if len(msg.Params) > 0 { dc.away = &msg.Params[0] } else { dc.away = nil } dc.SendMessage(ctx, generateAwayReply(dc.away != nil)) uc := dc.upstream() if uc != nil { uc.updateAway() } case "INFO": if dc.network == nil { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_INFO, Params: []string{dc.nick, "soju <https://soju.im>"}, }) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFINFO, Params: []string{dc.nick, "End of INFO"}, }) break } if uc := dc.upstream(); uc == nil { return ircError{&irc.Message{ Command: irc.ERR_UNKNOWNCOMMAND, Params: []string{dc.nick, msg.Command, "Disconnected from upstream network"}, }} } else { uc.SendMessageLabeled(ctx, dc.id, msg) } case "MONITOR": uc := dc.upstream() if uc == nil { return newUnknownCommandError(msg.Command) } if _, ok := uc.isupport["MONITOR"]; !ok { return newUnknownCommandError(msg.Command) } var subcommand string if err := parseMessageParams(msg, &subcommand); err != nil { return err } switch strings.ToUpper(subcommand) { case "+", "-": var targets string if err := parseMessageParams(msg, nil, &targets); err != nil { return err } for _, target := range strings.Split(targets, ",") { if subcommand == "+" { // Hard limit, just to avoid having downstreams fill our map if dc.monitored.Len() >= 1000 { dc.SendMessage(ctx, &irc.Message{ Command: irc.ERR_MONLISTFULL, Params: []string{dc.nick, "1000", target, "Bouncer monitor list is full"}, }) continue } dc.monitored.Set(target, struct{}{}) if uc.network.casemap(target) == serviceNickCM { // BouncerServ is never tired dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_MONONLINE, Params: []string{dc.nick, target}, }) continue } if uc.monitored.Has(target) { cmd := irc.RPL_MONOFFLINE if online := uc.monitored.Get(target); online { cmd = irc.RPL_MONONLINE } dc.SendMessage(ctx, &irc.Message{ Command: cmd, Params: []string{dc.nick, target}, }) } } else { dc.monitored.Del(target) } } uc.updateMonitor() case "C": // clear dc.monitored = xirc.NewCaseMappingMap[struct{}](uc.network.casemap) uc.updateMonitor() case "L": // list // TODO: be less lazy and pack the list dc.monitored.ForEach(func(name string, _ struct{}) { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_MONLIST, Params: []string{dc.nick, name}, }) }) dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFMONLIST, Params: []string{dc.nick, "End of MONITOR list"}, }) case "S": // status // TODO: be less lazy and pack the lists dc.monitored.ForEach(func(target string, _ struct{}) { cmd := irc.RPL_MONOFFLINE if online := uc.monitored.Get(target); online { cmd = irc.RPL_MONONLINE } if uc.network.casemap(target) == serviceNickCM { cmd = irc.RPL_MONONLINE } dc.SendMessage(ctx, &irc.Message{ Command: cmd, Params: []string{dc.nick, target}, }) }) } case "METADATA": var target, subcommand string if err := parseMessageParams(msg, &target, &subcommand); err != nil { return err } if handled, err := dc.handleMetadataSub(ctx, msg); handled { return err } var mt *database.MessageTarget if dc.network != nil && target != "*" { targetCM := dc.network.casemap(target) var err error mt, err = dc.srv.db.GetMessageTarget(ctx, dc.network.ID, targetCM) if err != nil { dc.logger.Printf("failed to get the message target for %q: %v", target, err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"METADATA", "INTERNAL_ERROR", target, "Internal error"}, }} } } switch subcommand { case "LIST", "GET": var m map[string]string if mt != nil { m = getMessageTargetMetadata(mt) } switch subcommand { case "LIST": dc.SendBatch(ctx, "metadata", nil, nil, func(batchRef string) { for k, v := range m { dc.SendMessage(ctx, &irc.Message{ Tags: irc.Tags{"batch": batchRef}, Command: xirc.RPL_KEYVALUE, Params: []string{"*", target, k, "*", v}, }) } }) case "GET": dc.SendBatch(ctx, "metadata", nil, nil, func(batchRef string) { for _, k := range msg.Params[2:] { k = strings.ToLower(k) v, ok := m[k] if ok { dc.SendMessage(ctx, &irc.Message{ Tags: irc.Tags{"batch": batchRef}, Command: xirc.RPL_KEYVALUE, Params: []string{"*", target, k, "*", v}, }) } else { dc.SendMessage(ctx, &irc.Message{ Tags: irc.Tags{"batch": batchRef}, Command: "FAIL", Params: []string{"METADATA", "KEY_INVALID", k, "Invalid key"}, }) } } }) } case "SET", "CLEAR": m := make(map[string]*string) switch subcommand { case "SET": var k string if err := parseMessageParams(msg, nil, nil, &k); err != nil { return err } k = strings.ToLower(k) var v *string if len(msg.Params) > 3 { v = &msg.Params[3] } m[k] = v case "CLEAR": m["soju.im/pinned"] = nil m["soju.im/muted"] = nil } switch subcommand { case "SET": dc.setMessageTargetMetadata(ctx, target, mt, m, "") case "CLEAR": dc.SendBatch(ctx, "metadata", nil, nil, func(batchRef string) { dc.setMessageTargetMetadata(ctx, target, mt, m, batchRef) }) } if mt != nil { if err := dc.srv.db.StoreMessageTarget(ctx, dc.network.ID, mt); err != nil { dc.logger.Printf("failed to store the message target for %q: %v", target, err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"METADATA", "INTERNAL_ERROR", target, "Internal error"}, }} } } default: // TODO: support SYNC return ircError{&irc.Message{ Command: "FAIL", Params: []string{"METADATA", "INVALID_PARAMS", subcommand, "Unknown command"}, }} } case "CHATHISTORY": var subcommand string if err := parseMessageParams(msg, &subcommand); err != nil { return err } var target, limitStr string var boundsStr [2]string switch subcommand { case "AFTER", "BEFORE", "LATEST": if err := parseMessageParams(msg, nil, &target, &boundsStr[0], &limitStr); err != nil { return err } case "BETWEEN": if err := parseMessageParams(msg, nil, &target, &boundsStr[0], &boundsStr[1], &limitStr); err != nil { return err } case "TARGETS": if dc.network == nil { dc.SendBatch(ctx, "draft/chathistory-targets", nil, nil, func(batchRef string) {}) return nil } if err := parseMessageParams(msg, nil, &boundsStr[0], &boundsStr[1], &limitStr); err != nil { return err } default: // TODO: support AROUND return ircError{&irc.Message{ Command: "FAIL", Params: []string{"CHATHISTORY", "INVALID_PARAMS", subcommand, "Unknown command"}, }} } // We don't save history for our service if dc.casemap(target) == serviceNickCM { dc.SendBatch(ctx, "chathistory", []string{target}, nil, func(batchRef string) {}) return nil } store, ok := dc.user.msgStore.(msgstore.ChatHistoryStore) if !ok { return ircError{&irc.Message{ Command: irc.ERR_UNKNOWNCOMMAND, Params: []string{dc.nick, "CHATHISTORY", "Chat history disabled"}, }} } network := dc.network if network == nil { return newChatHistoryError(subcommand, "Cannot fetch chat history on bouncer connection") } target = network.casemap(target) // TODO: support msgid criteria var bounds [2]time.Time bounds[0] = parseChatHistoryBound(boundsStr[0]) if subcommand == "LATEST" && boundsStr[0] == "*" { bounds[0] = time.Time{} } else if bounds[0].IsZero() { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"CHATHISTORY", "INVALID_PARAMS", subcommand, boundsStr[0], "Invalid first bound"}, }} } if boundsStr[1] != "" { bounds[1] = parseChatHistoryBound(boundsStr[1]) if bounds[1].IsZero() { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"CHATHISTORY", "INVALID_PARAMS", subcommand, boundsStr[1], "Invalid second bound"}, }} } } limit, err := strconv.Atoi(limitStr) if err != nil || limit < 0 || limit > chatHistoryLimit { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"CHATHISTORY", "INVALID_PARAMS", subcommand, limitStr, "Invalid limit"}, }} } eventPlayback := dc.caps.IsEnabled("draft/event-playback") options := msgstore.LoadMessageOptions{ Network: &network.Network, Entity: target, Limit: limit, Events: eventPlayback, } var history []*irc.Message switch subcommand { case "BEFORE": history, err = store.LoadBeforeTime(ctx, bounds[0], time.Time{}, &options) case "LATEST": history, err = store.LoadBeforeTime(ctx, time.Now(), bounds[0], &options) case "AFTER": history, err = store.LoadAfterTime(ctx, bounds[0], time.Now(), &options) case "BETWEEN": if bounds[0].Before(bounds[1]) { history, err = store.LoadAfterTime(ctx, bounds[0], bounds[1], &options) } else { history, err = store.LoadBeforeTime(ctx, bounds[0], bounds[1], &options) } case "TARGETS": targets, err := store.ListTargets(ctx, &network.Network, bounds[0], bounds[1], limit, eventPlayback) if err != nil { dc.logger.Printf("failed fetching targets for chathistory: %v", err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"CHATHISTORY", "MESSAGE_ERROR", subcommand, "Failed to retrieve targets"}, }} } dc.SendBatch(ctx, "draft/chathistory-targets", nil, nil, func(batchRef string) { for _, target := range targets { if ch := network.channels.Get(target.Name); ch != nil && ch.Detached { continue } dc.SendMessage(ctx, &irc.Message{ Tags: irc.Tags{"batch": batchRef}, Command: "CHATHISTORY", Params: []string{"TARGETS", target.Name, xirc.FormatServerTime(target.LatestMessage)}, }) } }) return nil } if err != nil { dc.logger.Printf("failed fetching %q messages for chathistory: %v", target, err) return newChatHistoryError(subcommand, target) } dc.SendBatch(ctx, "chathistory", []string{target}, nil, func(batchRef string) { for _, msg := range history { msg.Tags["batch"] = batchRef dc.SendMessage(ctx, msg) } }) case "READ", "MARKREAD": var target, criteria string if err := parseMessageParams(msg, &target); err != nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{msg.Command, "NEED_MORE_PARAMS", "Missing parameters"}, }} } if len(msg.Params) > 1 { criteria = msg.Params[1] } // We don't save read receipts for our service if dc.casemap(target) == serviceNickCM { dc.SendMessage(ctx, &irc.Message{ Prefix: dc.prefix(), Command: msg.Command, Params: []string{target, "*"}, }) return nil } network := dc.network if network == nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{msg.Command, "INTERNAL_ERROR", target, "Cannot set read markers on bouncer connection"}, }} } targetCM := network.casemap(target) r, err := dc.srv.db.GetReadReceipt(ctx, network.ID, targetCM) if err != nil { dc.logger.Printf("failed to get the read receipt for %q: %v", target, err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{msg.Command, "INTERNAL_ERROR", target, "Internal error"}, }} } else if r == nil { r = &database.ReadReceipt{ Target: targetCM, } } broadcast := false if len(criteria) > 0 { // TODO: support msgid criteria criteriaParts := strings.SplitN(criteria, "=", 2) if len(criteriaParts) != 2 || criteriaParts[0] != "timestamp" { return ircError{&irc.Message{ Command: "FAIL", Params: []string{msg.Command, "INVALID_PARAMS", criteria, "Unknown criteria"}, }} } timestamp, err := time.Parse(xirc.ServerTimeLayout, criteriaParts[1]) if err != nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{msg.Command, "INVALID_PARAMS", criteria, "Invalid criteria"}, }} } now := time.Now() if timestamp.After(now) { timestamp = now } if r.Timestamp.Before(timestamp) { r.Timestamp = timestamp if err := dc.srv.db.StoreReadReceipt(ctx, network.ID, r); err != nil { dc.logger.Printf("failed to store receipt for %q: %v", target, err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{msg.Command, "INTERNAL_ERROR", target, "Internal error"}, }} } broadcast = true } } timestampStr := "*" if !r.Timestamp.IsZero() { timestampStr = fmt.Sprintf("timestamp=%s", xirc.FormatServerTime(r.Timestamp)) } network.forEachDownstream(func(d *downstreamConn) { if broadcast || dc.id == d.id { cmd := "MARKREAD" if !d.caps.IsEnabled("draft/read-marker") { cmd = "READ" } d.SendMessage(ctx, &irc.Message{ Prefix: d.prefix(), Command: cmd, Params: []string{target, timestampStr}, }) } }) if broadcast && network.pushTargets.Has(target) { // TODO: only broadcast if draft/read-marker has been negotiated if !r.Timestamp.Before(network.pushTargets.Get(target)) { network.pushTargets.Del(target) } go network.broadcastWebPush(&irc.Message{ Command: "MARKREAD", Params: []string{target, timestampStr}, }) } case "SEARCH": store, ok := dc.user.msgStore.(msgstore.SearchStore) if !ok { return ircError{&irc.Message{ Command: irc.ERR_UNKNOWNCOMMAND, Params: []string{dc.nick, "SEARCH", "Unknown command"}, }} } var attrsStr string if err := parseMessageParams(msg, &attrsStr); err != nil { return err } attrs := irc.ParseTags(attrsStr) var network *network const searchMaxLimit = 100 opts := msgstore.SearchMessageOptions{ Limit: searchMaxLimit, } for name, v := range attrs { value := string(v) switch name { case "before", "after": timestamp, err := time.Parse(xirc.ServerTimeLayout, value) if err != nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"SEARCH", "INVALID_PARAMS", name, "Invalid criteria"}, }} } switch name { case "after": opts.Start = timestamp case "before": opts.End = timestamp } case "from": opts.From = value case "in": network = dc.network if network == nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"SEARCH", "INVALID_PARAMS", name, "Cannot search on bouncer connection"}, }} } opts.In = network.casemap(value) case "text": opts.Text = value case "limit": limit, err := strconv.Atoi(value) if err != nil || limit <= 0 { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"SEARCH", "INVALID_PARAMS", name, "Invalid limit"}, }} } opts.Limit = limit } } if network == nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"SEARCH", "INVALID_PARAMS", "in", "The in parameter is mandatory"}, }} } if opts.Limit > searchMaxLimit { opts.Limit = searchMaxLimit } messages, err := store.Search(ctx, &network.Network, &opts) if err != nil { dc.logger.Printf("failed fetching messages for search: %v", err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"SEARCH", "INTERNAL_ERROR", "Messages could not be retrieved"}, }} } dc.SendBatch(ctx, "soju.im/search", nil, nil, func(batchRef string) { for _, msg := range messages { msg.Tags["batch"] = batchRef dc.SendMessage(ctx, msg) } }) case "BOUNCER": var subcommand string if err := parseMessageParams(msg, &subcommand); err != nil { return err } switch strings.ToUpper(subcommand) { case "BIND": return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "REGISTRATION_IS_COMPLETED", "BIND", "Cannot bind to a network after registration"}, }} case "LISTNETWORKS": dc.SendBatch(ctx, "soju.im/bouncer-networks", nil, nil, func(batchRef string) { for _, network := range dc.user.networks { idStr := fmt.Sprintf("%v", network.ID) attrs := getNetworkAttrs(network) dc.SendMessage(ctx, &irc.Message{ Tags: irc.Tags{"batch": batchRef}, Command: "BOUNCER", Params: []string{"NETWORK", idStr, attrs.String()}, }) } }) case "ADDNETWORK": var attrsStr string if err := parseMessageParams(msg, nil, &attrsStr); err != nil { return err } attrs := irc.ParseTags(attrsStr) record := database.NewNetwork("") record.Nick = dc.nick if err := updateNetworkAttrs(record, attrs, subcommand); err != nil { return err } if record.Nick == dc.user.Username { record.Nick = "" } if record.Realname == dc.user.Realname { record.Realname = "" } network, err := dc.user.createNetwork(ctx, record, true) if err != nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "UNKNOWN_ERROR", subcommand, fmt.Sprintf("Failed to create network: %v", err)}, }} } dc.SendMessage(ctx, &irc.Message{ Command: "BOUNCER", Params: []string{"ADDNETWORK", fmt.Sprintf("%v", network.ID)}, }) case "CHANGENETWORK": var idStr, attrsStr string if err := parseMessageParams(msg, nil, &idStr, &attrsStr); err != nil { return err } id, err := parseBouncerNetID(subcommand, idStr) if err != nil { return err } attrs := irc.ParseTags(attrsStr) net := dc.user.getNetworkByID(id) if net == nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "INVALID_NETID", subcommand, idStr, "Invalid network ID"}, }} } record := net.Network // copy network record because we'll mutate it wasEnabled := record.Enabled if err := updateNetworkAttrs(&record, attrs, subcommand); err != nil { return err } if record.Nick == dc.user.Username { record.Nick = "" } if record.Realname == dc.user.Realname { record.Realname = "" } _, err = dc.user.updateNetwork(ctx, &record, !wasEnabled) if err != nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "UNKNOWN_ERROR", subcommand, fmt.Sprintf("Failed to update network: %v", err)}, }} } dc.SendMessage(ctx, &irc.Message{ Command: "BOUNCER", Params: []string{"CHANGENETWORK", idStr}, }) case "DELNETWORK": var idStr string if err := parseMessageParams(msg, nil, &idStr); err != nil { return err } id, err := parseBouncerNetID(subcommand, idStr) if err != nil { return err } net := dc.user.getNetworkByID(id) if net == nil { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "INVALID_NETID", subcommand, idStr, "Invalid network ID"}, }} } if err := dc.user.deleteNetwork(ctx, net.ID); err != nil { return err } dc.SendMessage(ctx, &irc.Message{ Command: "BOUNCER", Params: []string{"DELNETWORK", idStr}, }) default: return ircError{&irc.Message{ Command: "FAIL", Params: []string{"BOUNCER", "UNKNOWN_COMMAND", subcommand, "Unknown subcommand"}, }} } case "WEBPUSH": if !dc.caps.IsEnabled("soju.im/webpush") { return newUnknownCommandError(msg.Command) } var subcommand string if err := parseMessageParams(msg, &subcommand); err != nil { return err } switch subcommand { case "REGISTER": var endpoint, keysStr string if err := parseMessageParams(msg, nil, &endpoint, &keysStr); err != nil { return err } rawKeys := irc.ParseTags(keysStr) authKey, hasAuthKey := rawKeys["auth"] p256dhKey, hasP256dh := rawKeys["p256dh"] if !hasAuthKey || !hasP256dh { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"WEBPUSH", "INVALID_PARAMS", subcommand, "Missing auth or p256dh key"}, }} } newSub := database.WebPushSubscription{ Endpoint: endpoint, } newSub.Keys.VAPID = dc.srv.webPush.VAPIDKeys.Public newSub.Keys.Auth = string(authKey) newSub.Keys.P256DH = string(p256dhKey) subs, err := dc.listWebPushSubscriptions(ctx) if err != nil { dc.logger.Printf("failed to fetch Web push subscription: %v", err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"WEBPUSH", "INTERNAL_ERROR", subcommand, "Internal error"}, }} } if len(subs) > 25 { return ircError{&irc.Message{ Command: "FAIL", Params: []string{"WEBPUSH", "INTERNAL_ERROR", subcommand, "Too many subscriptions"}, }} } updateSub := true oldSub := findWebPushSubscription(subs, endpoint) if oldSub != nil { // Update the old subscription instead of creating a new one newSub.ID = oldSub.ID // Rate-limit subscription checks updateSub = time.Since(oldSub.UpdatedAt) > webpushCheckSubscriptionDelay || oldSub.Keys != newSub.Keys } var networkID int64 if dc.network != nil { networkID = dc.network.ID } // Send a test Web Push message, to make sure the endpoint is valid if updateSub { if err := sanityCheckWebPushEndpoint(newSub.Endpoint); err != nil { dc.logger.Printf("failed to sanity check Web push endpoint %q: %v", newSub.Endpoint, err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"WEBPUSH", "INVALID_PARAMS", subcommand, "Invalid endpoint"}, }} } err = dc.srv.sendWebPush(ctx, &webpush.Subscription{ Endpoint: newSub.Endpoint, Keys: webpush.Keys{ Auth: newSub.Keys.Auth, P256dh: newSub.Keys.P256DH, }, }, newSub.Keys.VAPID, &irc.Message{ Command: "NOTE", Params: []string{"WEBPUSH", "REGISTERED", "Push notifications enabled"}, }) if err != nil { dc.logger.Printf("failed to send Web push notification to endpoint %q: %v", newSub.Endpoint, err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"WEBPUSH", "INVALID_PARAMS", subcommand, "Invalid endpoint"}, }} } if err := dc.user.srv.db.StoreWebPushSubscription(ctx, dc.user.ID, networkID, &newSub); err != nil { dc.logger.Printf("failed to store Web push subscription: %v", err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"WEBPUSH", "INTERNAL_ERROR", subcommand, "Internal error"}, }} } } dc.SendMessage(ctx, &irc.Message{ Command: "WEBPUSH", Params: []string{"REGISTER", endpoint}, }) case "UNREGISTER": var endpoint string if err := parseMessageParams(msg, nil, &endpoint); err != nil { return err } subs, err := dc.listWebPushSubscriptions(ctx) if err != nil { dc.logger.Printf("failed to fetch Web push subscription: %v", err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"WEBPUSH", "INTERNAL_ERROR", subcommand, "Internal error"}, }} } oldSub := findWebPushSubscription(subs, endpoint) if oldSub == nil { dc.SendMessage(ctx, &irc.Message{ Command: "WEBPUSH", Params: []string{"UNREGISTER", endpoint}, }) return nil } if err := dc.srv.db.DeleteWebPushSubscription(ctx, oldSub.ID); err != nil { dc.logger.Printf("failed to delete Web push subscription: %v", err) return ircError{&irc.Message{ Command: "FAIL", Params: []string{"WEBPUSH", "INTERNAL_ERROR", subcommand, "Internal error"}, }} } dc.SendMessage(ctx, &irc.Message{ Command: "WEBPUSH", Params: []string{"UNREGISTER", endpoint}, }) default: return ircError{&irc.Message{ Command: "FAIL", Params: []string{"WEBPUSH", "INVALID_PARAMS", subcommand, "Unknown command"}, }} } default: dc.logger.Debugf("unhandled message: %v", msg) if dc.network == nil { return newUnknownCommandError(msg.Command) } uc := dc.upstream() if uc == nil { return ircError{&irc.Message{ Command: irc.ERR_UNKNOWNCOMMAND, Params: []string{"*", msg.Command, "Disconnected from upstream network"}, }} } uc.SendMessageLabeled(ctx, dc.id, msg) } return nil } func (dc *downstreamConn) handleNickServPRIVMSG(ctx context.Context, uc *upstreamConn, text string) { username, password, ok := parseNickServCredentials(text, uc.nick) if ok { uc.network.autoSaveSASLPlain(ctx, username, password) } } func (dc *downstreamConn) handleMetadataSub(ctx context.Context, msg *irc.Message) (bool, error) { var subcommand string if err := parseMessageParams(msg, nil, &subcommand); err != nil { return true, err } switch subcommand { case "SUB", "UNSUB": for _, k := range msg.Params[2:] { k = strings.ToLower(k) switch k { case "soju.im/pinned": case "soju.im/muted": default: dc.SendMessage(ctx, &irc.Message{ Command: "FAIL", Params: []string{msg.Command, "KEY_INVALID", k, "Invalid key"}, }) continue } switch subcommand { case "SUB": dc.metadataSubs[k] = struct{}{} dc.SendMessage(ctx, &irc.Message{ Command: xirc.RPL_METADATASUBOK, Params: []string{"*", k}, }) case "UNSUB": delete(dc.metadataSubs, k) dc.SendMessage(ctx, &irc.Message{ Command: xirc.RPL_METADATAUNSUBOK, Params: []string{"*", k}, }) } } return true, nil case "SUBS": for k := range dc.metadataSubs { dc.SendMessage(ctx, &irc.Message{ Command: xirc.RPL_METADATASUBS, Params: []string{"*", k}, }) } return true, nil default: return false, nil } } func (dc *downstreamConn) listWebPushSubscriptions(ctx context.Context) ([]database.WebPushSubscription, error) { var networkID int64 if dc.network != nil { networkID = dc.network.ID } return dc.user.srv.db.ListWebPushSubscriptions(ctx, dc.user.ID, networkID) } func (dc *downstreamConn) sendChannelMetadata(ctx context.Context, target string) { if !dc.caps.IsEnabled("draft/metadata-2") { return } if dc.network != nil { target = dc.network.casemap(target) } mt, err := dc.srv.db.GetMessageTarget(ctx, dc.network.ID, target) if err != nil { return } m := getMessageTargetMetadata(mt) dc.SendBatch(ctx, "metadata", nil, nil, func(batchRef string) { for k := range dc.metadataSubs { v, ok := m[k] if !ok { continue } dc.SendMessage(ctx, &irc.Message{ Tags: irc.Tags{"batch": batchRef}, Command: "METADATA", Params: []string{target, k, "*", v}, }) } }) } func (dc *downstreamConn) setMessageTargetMetadata(ctx context.Context, target string, mt *database.MessageTarget, m map[string]*string, batchRef string) { var tags irc.Tags if batchRef != "" { tags = irc.Tags{"batch": batchRef} } for k, v := range m { if mt == nil { dc.SendMessage(ctx, &irc.Message{ Tags: tags, Command: "FAIL", Params: []string{"METADATA", "KEY_INVALID", k, "Invalid key"}, }) continue } var mv string var ok bool switch k { case "soju.im/pinned": mt.Pinned, mv, ok = parseMessageTargetMetadataBool(v) case "soju.im/muted": mt.Muted, mv, ok = parseMessageTargetMetadataBool(v) default: dc.SendMessage(ctx, &irc.Message{ Tags: tags, Command: "FAIL", Params: []string{"METADATA", "KEY_INVALID", k, "Invalid key"}, }) continue } if !ok { dc.SendMessage(ctx, &irc.Message{ Tags: tags, Command: "FAIL", Params: []string{"METADATA", "VALUE_INVALID", "Invalid value"}, }) continue } dc.SendMessage(ctx, &irc.Message{ Tags: tags, Command: xirc.RPL_KEYVALUE, Params: []string{"*", target, k, "*", mv}, }) dc.network.forEachDownstream(func(dc *downstreamConn) { dc.SendMessage(ctx, &irc.Message{ Command: "METADATA", Params: []string{target, k, "*", mv}, }) }) } } func parseMessageTargetMetadataBool(v *string) (b bool, s string, ok bool) { if v == nil { return false, "0", true } if *v == "1" { return true, "1", true } if *v == "0" { return false, "0", true } return false, "", false } func getMessageTargetMetadata(mt *database.MessageTarget) map[string]string { m := make(map[string]string) if mt.Pinned { m["soju.im/pinned"] = "1" } else { m["soju.im/pinned"] = "0" } if mt.Muted { m["soju.im/muted"] = "1" } else { m["soju.im/muted"] = "0" } return m } func findWebPushSubscription(subs []database.WebPushSubscription, endpoint string) *database.WebPushSubscription { for i, sub := range subs { if sub.Endpoint == endpoint { return &subs[i] } } return nil } func parseNickServCredentials(text, nick string) (username, password string, ok bool) { fields := strings.Fields(text) if len(fields) < 2 { return "", "", false } cmd := strings.ToUpper(fields[0]) params := fields[1:] switch cmd { case "REGISTER": username = nick password = params[0] case "IDENTIFY": if len(params) == 1 { username = nick password = params[0] } else { username = params[0] password = params[1] } case "SET": if len(params) == 2 && strings.EqualFold(params[0], "PASSWORD") { username = nick password = params[1] } default: return "", "", false } return username, password, true } func forwardChannel(ctx context.Context, dc *downstreamConn, ch *upstreamChannel) { if !ch.complete { panic("Tried to forward a partial channel") } // RPL_NOTOPIC shouldn't be sent on JOIN if ch.Topic != "" { sendTopic(ctx, dc, ch) } var markReadCmd string if dc.caps.IsEnabled("draft/read-marker") { markReadCmd = "MARKREAD" } else if dc.caps.IsEnabled("soju.im/read") { markReadCmd = "READ" } if markReadCmd != "" { channelCM := ch.conn.network.casemap(ch.Name) r, err := dc.srv.db.GetReadReceipt(ctx, ch.conn.network.ID, channelCM) if err != nil { dc.logger.Printf("failed to get the read receipt for %q: %v", ch.Name, err) } else { timestampStr := "*" if r != nil { timestampStr = fmt.Sprintf("timestamp=%s", xirc.FormatServerTime(r.Timestamp)) } dc.SendMessage(ctx, &irc.Message{ Prefix: dc.prefix(), Command: markReadCmd, Params: []string{ch.Name, timestampStr}, }) } } if !dc.caps.IsEnabled("soju.im/no-implicit-names") && !dc.caps.IsEnabled("draft/no-implicit-names") { sendNames(ctx, dc, ch) } dc.sendChannelMetadata(ctx, ch.Name) } func sendTopic(ctx context.Context, dc *downstreamConn, ch *upstreamChannel) { if ch.Topic != "" { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_TOPIC, Params: []string{dc.nick, ch.Name, ch.Topic}, }) if ch.TopicWho != nil { topicTime := strconv.FormatInt(ch.TopicTime.Unix(), 10) dc.SendMessage(ctx, &irc.Message{ Command: xirc.RPL_TOPICWHOTIME, Params: []string{dc.nick, ch.Name, ch.TopicWho.String(), topicTime}, }) } } else { dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_NOTOPIC, Params: []string{dc.nick, ch.Name, "No topic is set"}, }) } } func sendNames(ctx context.Context, dc *downstreamConn, ch *upstreamChannel) { var members []string ch.Members.ForEach(func(nick string, memberships *xirc.MembershipSet) { s := formatMemberPrefix(*memberships, dc) + nick members = append(members, s) }) msgs := xirc.GenerateNamesReply(ch.Name, ch.Status, members) for _, msg := range msgs { dc.SendMessage(ctx, msg) } } func sanityCheckWebPushEndpoint(endpoint string) error { u, err := url.Parse(endpoint) if err != nil { return err } if u.Scheme != "https" { return fmt.Errorf("scheme must be HTTPS") } return nil } ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/fileupload/������������������������������������������������������������������������������0000775�0000000�0000000�00000000000�14770724770�0014643�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/fileupload/fileupload.go�����������������������������������������������������������������0000664�0000000�0000000�00000022536�14770724770�0017326�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package fileupload import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "mime" "net/http" "net/url" "path" "strings" "time" "codeberg.org/emersion/soju/auth" "codeberg.org/emersion/soju/database" ) const maxSize = 50 * 1024 * 1024 // 50 MiB // inlineMIMETypes contains MIME types which are allowed to be displayed inline // by the Web browser. This has security implications: we don't want the // browser to execute any kind of script. For instance, SVG images are // intentionally omitted. var inlineMIMETypes = map[string]bool{ "application/pdf": true, "audio/aac": true, "audio/mp4": true, "audio/mpeg": true, "audio/ogg": true, "audio/webm": true, "image/apng": true, "image/gif": true, "image/jpeg": true, "image/png": true, "image/webp": true, "text/plain": true, "video/mp4": true, "video/ogg": true, "video/webm": true, } // Some MIME types have multiple possible extensions, and // mime.ExtensionsByType returns them out-of-order. We have to hardcode // a few MIME types to work around this unfortunately (e.g. to not use // ".jfif" for "image/jpeg"). // // Note, this is not for registering new MIME types (use mime.AddExtensionType // for that purpose). var primaryExts = map[string]string{ "audio/aac": "aac", "audio/mp4": "mp4", "audio/mpeg": "mp3", "audio/ogg": "oga", "image/jpeg": "jpeg", "text/plain": "txt", "video/mp4": "mp4", } type httpError struct { Code int ContentType string Body io.Reader } func (h *httpError) Error() string { return fmt.Sprintf("error %v: %v", h.Code, h.Body) } func (h *httpError) Write(w http.ResponseWriter) { defer func() { if c, ok := h.Body.(io.Closer); ok { c.Close() } }() if h.ContentType != "" { w.Header().Set("Content-Type", h.ContentType) } w.WriteHeader(h.Code) io.Copy(w, h.Body) } type Uploader interface { load(ctx context.Context, filename string) (basename string, modTime time.Time, content io.ReadSeekCloser, err error) store(ctx context.Context, r io.Reader, username, mimeType, basename string) (out string, err error) } func New(driver, source string) (Uploader, error) { switch driver { case "fs": return &fileuploadFS{source}, nil case "http": return &fileuploadHTTP{source}, nil default: return nil, fmt.Errorf("unknown file upload driver %q", driver) } } type Handler struct { Uploader Uploader Auth *auth.Authenticator DB database.Database HTTPOrigins []string } func (h *Handler) checkOrigin(reqOrigin string) bool { for _, origin := range h.HTTPOrigins { match, err := path.Match(origin, reqOrigin) if err != nil { panic(err) // patterns are checked at config load time } else if match { return true } } return false } func (h *Handler) setCORS(resp http.ResponseWriter, req *http.Request) error { resp.Header().Set("Access-Control-Allow-Credentials", "true") resp.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Content-Disposition") resp.Header().Set("Access-Control-Expose-Headers", "Location, Content-Disposition") reqOrigin := req.Header.Get("Origin") if reqOrigin == "" { return nil } u, err := url.Parse(reqOrigin) if err != nil { return fmt.Errorf("invalid Origin header field: %v", err) } if !strings.EqualFold(u.Host, req.Host) && !h.checkOrigin(u.Host) { return fmt.Errorf("unauthorized Origin") } resp.Header().Set("Access-Control-Allow-Origin", reqOrigin) resp.Header().Set("Vary", "Origin") return nil } func (h *Handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { resp.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';") if err := h.setCORS(resp, req); err != nil { http.Error(resp, err.Error(), http.StatusForbidden) return } if h.Uploader == nil { http.NotFound(resp, req) return } switch req.Method { case http.MethodOptions: resp.WriteHeader(http.StatusNoContent) case http.MethodHead, http.MethodGet: h.fetch(resp, req) case http.MethodPost: h.store(resp, req) default: http.Error(resp, "only OPTIONS, HEAD, GET and POST are allowed", http.StatusMethodNotAllowed) } } func (h *Handler) fetch(resp http.ResponseWriter, req *http.Request) { prefix := "/uploads/" if !strings.HasPrefix(req.URL.Path, prefix) { http.Error(resp, "invalid path", http.StatusNotFound) return } filename := strings.TrimPrefix(req.URL.Path, prefix) filename = path.Join("/", filename)[1:] // prevent directory traversal if filename == "" { http.Error(resp, "invalid path", http.StatusNotFound) return } basename, modTime, content, err := h.Uploader.load(req.Context(), filename) if err != nil { var httpErr *httpError if errors.As(err, &httpErr) { httpErr.Write(resp) return } http.Error(resp, "failed to open file", http.StatusNotFound) return } defer content.Close() // Guess MIME type from extension, then from content contentType := mime.TypeByExtension(path.Ext(basename)) if contentType == "" { var buf [512]byte n, _ := io.ReadFull(content, buf[:]) contentType = http.DetectContentType(buf[:n]) _, err := content.Seek(0, io.SeekStart) // rewind to output whole file if err != nil { http.Error(resp, "failed to seek file", http.StatusInternalServerError) return } } if contentType != "" { resp.Header().Set("Content-Type", contentType) } contentDispMode := "attachment" mimeType, _, _ := mime.ParseMediaType(contentType) if inlineMIMETypes[mimeType] { contentDispMode = "inline" } contentDisp := mime.FormatMediaType(contentDispMode, map[string]string{ "filename": basename, }) resp.Header().Set("Content-Disposition", contentDisp) http.ServeContent(resp, req, basename, modTime, content) } func (h *Handler) store(resp http.ResponseWriter, req *http.Request) { if req.URL.Path != "/uploads" { http.Error(resp, "invalid path", http.StatusNotFound) return } authz := req.Header.Get("Authorization") if authz == "" { http.Error(resp, "missing Authorization header", http.StatusUnauthorized) return } var ( username string err error ) scheme, param, _ := strings.Cut(authz, " ") switch strings.ToLower(scheme) { case "basic": plainAuth := h.Auth.Plain if plainAuth == nil { http.Error(resp, "Basic scheme in Authorization header not supported", http.StatusBadRequest) return } var password string var ok bool username, password, ok = req.BasicAuth() if !ok { http.Error(resp, "invalid Authorization header", http.StatusBadRequest) return } err = plainAuth.AuthPlain(req.Context(), h.DB, username, password) case "bearer": oauthAuth := h.Auth.OAuthBearer if oauthAuth == nil { http.Error(resp, "Bearer scheme in Authorization header not supported", http.StatusBadRequest) return } username, err = oauthAuth.AuthOAuthBearer(req.Context(), h.DB, param) default: http.Error(resp, "unsupported Authorization header scheme", http.StatusBadRequest) return } if err != nil { var msg string if authErr, ok := err.(*auth.Error); ok { msg = authErr.ExternalMsg } else { msg = "authentication failed" } http.Error(resp, msg, http.StatusForbidden) return } var mimeType string if contentType := req.Header.Get("Content-Type"); contentType != "" { var ( params map[string]string err error ) mimeType, params, err = mime.ParseMediaType(contentType) if err != nil { http.Error(resp, "failed to parse Content-Type", http.StatusBadRequest) return } if mimeType == "application/octet-stream" { mimeType = "" } switch strings.ToLower(params["charset"]) { case "", "utf-8", "us-ascii": // OK default: http.Error(resp, "unsupported charset", http.StatusUnsupportedMediaType) return } } var basename string if contentDisp := req.Header.Get("Content-Disposition"); contentDisp != "" { _, params, err := mime.ParseMediaType(contentDisp) if err != nil { http.Error(resp, "failed to parse Content-Disposition", http.StatusBadRequest) return } basename = path.Base(params["filename"]) } var out string if req.ContentLength > maxSize { // Check the Content-Length, best-effort, before we start reading from it. // If the client sends us the length right away, we can signal to him immediately // that the file is too large, before it uploads the entire file. err = &http.MaxBytesError{ Limit: maxSize, } } else { r := http.MaxBytesReader(resp, req.Body, maxSize) out, err = h.Uploader.store(req.Context(), r, username, mimeType, basename) } if err != nil { var httpErr *httpError if errors.As(err, &httpErr) { httpErr.Write(resp) return } status := http.StatusInternalServerError var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { status = http.StatusRequestEntityTooLarge resp.Header().Set("Connection", "close") resp.Header().Set("Upload-Limit", fmt.Sprintf("maxsize=%v", maxBytesErr.Limit)) } http.Error(resp, "failed to write file", status) return } u, err := url.Parse(out) if err != nil { http.Error(resp, "failed to write file", http.StatusInternalServerError) return } baseURL := url.URL{Path: "/uploads/"} out = baseURL.ResolveReference(u).String() resp.Header().Set("Location", out) resp.WriteHeader(http.StatusCreated) } func generateToken(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } ������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/fileupload/fs.go�������������������������������������������������������������������������0000664�0000000�0000000�00000004545�14770724770�0015612�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package fileupload import ( "context" "fmt" "io" "mime" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) type fileuploadFS struct { dir string } var _ Uploader = (*fileuploadFS)(nil) func (fs *fileuploadFS) load(ctx context.Context, filename string) (basename string, modTime time.Time, content io.ReadSeekCloser, err error) { f, err := os.Open(filepath.Join(fs.dir, filepath.FromSlash(filename))) if err != nil { return "", time.Time{}, nil, &httpError{ Code: http.StatusNotFound, Body: strings.NewReader("file not found"), } } fi, err := f.Stat() if err != nil { f.Close() return "", time.Time{}, nil, err } else if fi.IsDir() { f.Close() return "", time.Time{}, nil, fmt.Errorf("file is a directory") } basename = filepath.Base(filename) if i := strings.IndexByte(basename, '-'); i >= 0 { basename = basename[i+1:] } return basename, fi.ModTime(), f, nil } func (fs *fileuploadFS) store(ctx context.Context, r io.Reader, username, mimeType, origBasename string) (out string, err error) { var suffix string if filepath.Ext(origBasename) == "" && mimeType != "" { if ext, ok := primaryExts[mimeType]; ok { suffix = "." + ext } else if exts, _ := mime.ExtensionsByType(mimeType); len(exts) == 1 { suffix = exts[0] } } dir := filepath.Join(fs.dir, username) if err := os.MkdirAll(dir, 0700); err != nil { return "", fmt.Errorf("failed to create user upload directory: %w", err) } var f *os.File for i := 0; i < 100; i++ { tokenLen := 8 if origBasename != "" && i == 0 { tokenLen = 4 } prefix, err := generateToken(tokenLen) if err != nil { return "", fmt.Errorf("failed to generate file base: %w", err) } basename := prefix if origBasename != "" { basename += "-" + origBasename } basename += suffix f, err = os.OpenFile(filepath.Join(dir, basename), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err == nil { break } else if !os.IsExist(err) { return "", fmt.Errorf("failed to open file: %w", err) } } if f == nil { return "", fmt.Errorf("failed to pick filename") } defer f.Close() if _, err := io.Copy(f, r); err != nil { return "", fmt.Errorf("failed to write file: %w", err) } if err := f.Close(); err != nil { return "", fmt.Errorf("failed to close file: %w", err) } return url.PathEscape(username) + "/" + url.PathEscape(filepath.Base(f.Name())), nil } �����������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/fileupload/http.go�����������������������������������������������������������������������0000664�0000000�0000000�00000003027�14770724770�0016153�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package fileupload import ( "context" "errors" "fmt" "io" "mime" "net/http" "time" ) type fileuploadHTTP struct { url string } var _ Uploader = (*fileuploadHTTP)(nil) func (fs *fileuploadHTTP) load(ctx context.Context, filename string) (basename string, modTime time.Time, content io.ReadSeekCloser, err error) { // TODO: perhaps proxy requests to the http backend return "", time.Time{}, nil, errors.New("fetching files is not supported for the file-upload http backend") } func (fs *fileuploadHTTP) store(ctx context.Context, r io.Reader, username, mimeType, origBasename string) (out string, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, fs.url, r) if err != nil { return "", err } req.Header.Set("User-Agent", "soju") if origBasename != "" { req.Header.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": origBasename})) } if mimeType != "" { req.Header.Set("Content-Type", mimeType) } req.Header.Set("Soju-Username", username) res, err := http.DefaultClient.Do(req) if err != nil { return "", err } if res.StatusCode >= 400 && res.StatusCode < 600 { return "", &httpError{ Code: res.StatusCode, ContentType: res.Header.Get("Content-Type"), Body: res.Body, } } defer res.Body.Close() if res.StatusCode != http.StatusCreated { return "", fmt.Errorf("unexpected response code: %v", res.StatusCode) } out = res.Header.Get("Location") if out == "" { return "", fmt.Errorf("no location found") } return out, nil } ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/go.mod�����������������������������������������������������������������������������������0000664�0000000�0000000�00000003537�14770724770�0013635�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������module codeberg.org/emersion/soju go 1.19 require ( codeberg.org/emersion/go-scfg v0.1.0 git.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20240124102820-f3a72e8b79b1 git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 github.com/SherClockHolmes/webpush-go v1.4.0 github.com/coder/websocket v1.8.12 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.24 github.com/msteinert/pam/v2 v2.0.0 github.com/pires/go-proxyproto v0.8.0 github.com/prometheus/client_golang v1.20.5 golang.org/x/crypto v0.32.0 golang.org/x/time v0.10.0 gopkg.in/irc.v4 v4.0.0 modernc.org/sqlite v1.34.1 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.59.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/term v0.29.0 // indirect google.golang.org/protobuf v1.34.2 // indirect modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect modernc.org/libc v1.61.0 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect modernc.org/strutil v1.2.0 // indirect modernc.org/token v1.1.0 // indirect ) �����������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/go.sum�����������������������������������������������������������������������������������0000664�0000000�0000000�00000036272�14770724770�0013664�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������codeberg.org/emersion/go-scfg v0.1.0 h1:6dnGU0ZI4gX+O5rMjwhoaySItzHG710eXL5TIQKl+uM= codeberg.org/emersion/go-scfg v0.1.0/go.mod h1:0nooW1ufBB4SlJEdTtiVN9Or+bnNM1icOkQ6Tbrq6O0= git.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20240124102820-f3a72e8b79b1 h1:0j/o1v+RUUN5OqE1ms4+ptMYGaUj+QnndNsHsX+0SnY= git.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20240124102820-f3a72e8b79b1/go.mod h1:W+na+JMhhelFn525wvV3enh0zvvhtZF8kndnRanLLq0= git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw= git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 h1:Ahny8Ud1LjVMMAlt8utUFKhhxJtwBAualvsbc/Sk7cE= git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9/go.mod h1:BVJwbDfVjCjoFiKrhkei6NdGcZYpkDkdyCdg1ukytRA= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/msteinert/pam/v2 v2.0.0 h1:jnObb8MT6jvMbmrUQO5J/puTUjxy7Av+55zVJRJsCyE= github.com/msteinert/pam/v2 v2.0.0/go.mod h1:KT28NNIcDFf3PcBmNI2mIGO4zZJ+9RSs/At2PB3IDVc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0= github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.59.1 h1:LXb1quJHWm1P6wq/U824uxYi4Sg0oGvNeUm1z5dJoX0= github.com/prometheus/common v0.59.1/go.mod h1:GpWM7dewqmVYcd7SmRaiWVe9SSqjf0UrwnYnpEZNuT0= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/irc.v4 v4.0.0 h1:5jsLkU2Tg+R2nGNqmkGCrciasyi4kNkDXhyZD+C31yY= gopkg.in/irc.v4 v4.0.0/go.mod h1:BfjDz9MmuWW6OZY7iq4naOhudO8+QQCdO4Ko18jcsRE= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY= modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE= modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/identd/����������������������������������������������������������������������������������0000775�0000000�0000000�00000000000�14770724770�0013766�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/identd/identd.go�������������������������������������������������������������������������0000664�0000000�0000000�00000006277�14770724770�0015600�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������// Package identd implements an Identification Protocol server. // // The Identification Protocol is defined in RFC 1413. package identd import ( "bufio" "fmt" "net" "strconv" "strings" "sync" "time" ) var identdTimeout = 10 * time.Second type identKey struct { remoteHost string remotePort int localPort int } func newIdentKey(remoteAddr, localAddr string) (*identKey, error) { remoteHost, remotePort, err := splitHostPort(remoteAddr) if err != nil { return nil, err } _, localPort, err := splitHostPort(localAddr) if err != nil { return nil, err } return &identKey{ remoteHost: remoteHost, remotePort: remotePort, localPort: localPort, }, nil } func splitHostPort(addr string) (host string, port int, err error) { host, portStr, err := net.SplitHostPort(addr) if err != nil { return "", 0, err } port, err = strconv.Atoi(portStr) return host, port, err } // Identd implements an ident server, as described in RFC 1413. type Identd struct { entries map[identKey]string lock sync.RWMutex } func New() *Identd { return &Identd{entries: make(map[identKey]string)} } func (s *Identd) Store(remoteAddr, localAddr, ident string) { k, err := newIdentKey(remoteAddr, localAddr) if err != nil { return } s.lock.Lock() s.entries[*k] = ident s.lock.Unlock() } func (s *Identd) Delete(remoteAddr, localAddr string) { k, err := newIdentKey(remoteAddr, localAddr) if err != nil { return } s.lock.Lock() delete(s.entries, *k) s.lock.Unlock() } func (s *Identd) Serve(ln net.Listener) error { for { conn, err := ln.Accept() if err != nil { return fmt.Errorf("failed to accept connection: %v", err) } go s.handle(conn) } } func (s *Identd) handle(c net.Conn) { defer c.Close() remoteHost, _, err := net.SplitHostPort(c.RemoteAddr().String()) if err != nil { return } scanner := bufio.NewScanner(c) // We only read to read lines with two port numbers var buf [512]byte scanner.Buffer(buf[:], len(buf)) for { c.SetDeadline(time.Now().Add(identdTimeout)) if !scanner.Scan() { break } l := scanner.Text() localPort, remotePort, err := parseIdentQuery(l) if err != nil { fmt.Fprintf(c, "%s : ERROR : INVALID-PORT\r\n", l) break } k := identKey{ remoteHost: remoteHost, remotePort: remotePort, localPort: localPort, } s.lock.RLock() ident := s.entries[k] s.lock.RUnlock() if ident == "" { fmt.Fprintf(c, "%s : ERROR : NO-USER\r\n", l) break } // The "OTHER" operating system may be rejected by IRC servers, because // it may be used when the ident string isn't stable. Use "UNKNOWN" // from RFC 1340 instead. fmt.Fprintf(c, "%s : USERID : UNKNOWN : %s\r\n", l, ident) } } func parseIdentQuery(l string) (localPort, remotePort int, err error) { parts := strings.SplitN(l, ",", 2) if len(parts) != 2 { return 0, 0, fmt.Errorf("expected two ports") } localStr, remoteStr := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) if localPort, err = strconv.Atoi(localStr); err != nil { return 0, 0, err } if remotePort, err = strconv.Atoi(remoteStr); err != nil { return 0, 0, err } if localPort <= 0 || remotePort <= 0 { return 0, 0, fmt.Errorf("invalid port") } return localPort, remotePort, nil } ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/irc.go�����������������������������������������������������������������������������������0000664�0000000�0000000�00000016577�14770724770�0013643�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "fmt" "strings" "time" "unicode" "unicode/utf8" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/xirc" ) // TODO: generalize and move helpers to the xirc package type userModes string func (ms userModes) Has(c byte) bool { return strings.IndexByte(string(ms), c) >= 0 } func (ms *userModes) Add(c byte) { if !ms.Has(c) { *ms += userModes(c) } } func (ms *userModes) Del(c byte) { i := strings.IndexByte(string(*ms), c) if i >= 0 { *ms = (*ms)[:i] + (*ms)[i+1:] } } func (ms *userModes) Apply(s string) error { var plusMinus byte for i := 0; i < len(s); i++ { switch c := s[i]; c { case '+', '-': plusMinus = c default: switch plusMinus { case '+': ms.Add(c) case '-': ms.Del(c) default: return fmt.Errorf("malformed modestring %q: missing plus/minus", s) } } } return nil } type channelModeType byte // standard channel mode types, as explained in https://modern.ircdocs.horse/#mode-message const ( // modes that add or remove an address to or from a list modeTypeA channelModeType = iota // modes that change a setting on a channel, and must always have a parameter modeTypeB // modes that change a setting on a channel, and must have a parameter when being set, and no parameter when being unset modeTypeC // modes that change a setting on a channel, and must not have a parameter modeTypeD ) var stdChannelModes = map[byte]channelModeType{ 'b': modeTypeA, // ban list 'e': modeTypeA, // ban exception list 'I': modeTypeA, // invite exception list 'k': modeTypeB, // channel key 'l': modeTypeC, // channel user limit 'i': modeTypeD, // channel is invite-only 'm': modeTypeD, // channel is moderated 'n': modeTypeD, // channel has no external messages 's': modeTypeD, // channel is secret 't': modeTypeD, // channel has protected topic } type channelModes map[byte]string // applyChannelModes parses a mode string and mode arguments from a MODE message, // and applies the corresponding channel mode and user membership changes on that channel. // // If ch.modes is nil, channel modes are not updated. func applyChannelModes(ch *upstreamChannel, modeStr string, arguments []string) error { nextArgument := 0 var plusMinus byte outer: for i := 0; i < len(modeStr); i++ { mode := modeStr[i] if mode == '+' || mode == '-' { plusMinus = mode continue } if plusMinus != '+' && plusMinus != '-' { return fmt.Errorf("malformed modestring %q: missing plus/minus", modeStr) } for _, membership := range ch.conn.availableMemberships { if membership.Mode == mode { if nextArgument >= len(arguments) { return fmt.Errorf("malformed modestring %q: missing mode argument for %c%c", modeStr, plusMinus, mode) } member := arguments[nextArgument] m := ch.Members.Get(member) if m != nil { if plusMinus == '+' { m.Add(ch.conn.availableMemberships, membership) } else { // TODO: for upstreams without multi-prefix, query the user modes again m.Remove(membership) } } nextArgument++ continue outer } } mt, ok := ch.conn.availableChannelModes[mode] if !ok { continue } if mt == modeTypeA { nextArgument++ } else if mt == modeTypeB || (mt == modeTypeC && plusMinus == '+') { if plusMinus == '+' { var argument string // some sentitive arguments (such as channel keys) can be omitted for privacy // (this will only happen for RPL_CHANNELMODEIS, never for MODE messages) if nextArgument < len(arguments) { argument = arguments[nextArgument] } if ch.modes != nil { ch.modes[mode] = argument } } else { delete(ch.modes, mode) } nextArgument++ } else if mt == modeTypeC || mt == modeTypeD { if plusMinus == '+' { if ch.modes != nil { ch.modes[mode] = "" } } else { delete(ch.modes, mode) } } } return nil } func (cm channelModes) Format() (modeString string, parameters []string) { var modesWithValues strings.Builder var modesWithoutValues strings.Builder parameters = make([]string, 0, 16) for mode, value := range cm { if value != "" { modesWithValues.WriteString(string(mode)) parameters = append(parameters, value) } else { modesWithoutValues.WriteString(string(mode)) } } modeString = "+" + modesWithValues.String() + modesWithoutValues.String() return } const stdChannelTypes = "#&+!" var stdMemberships = []xirc.Membership{ {'q', '~'}, // founder {'a', '&'}, // protected {'o', '@'}, // operator {'h', '%'}, // halfop {'v', '+'}, // voice } func formatMemberPrefix(ms xirc.MembershipSet, dc *downstreamConn) string { if !dc.caps.IsEnabled("multi-prefix") { if len(ms) == 0 { return "" } return string(ms[0].Prefix) } prefixes := make([]byte, len(ms)) for i, m := range ms { prefixes[i] = m.Prefix } return string(prefixes) } // Remove channel membership prefixes from flags func stripMemberPrefixes(flags string, uc *upstreamConn) string { return strings.Map(func(r rune) rune { for _, v := range uc.availableMemberships { if byte(r) == v.Prefix { return -1 } } return r }, flags) } func parseMessageParams(msg *irc.Message, out ...*string) error { if len(msg.Params) < len(out) { return newNeedMoreParamsError(msg.Command) } for i := range out { if out[i] != nil { *out[i] = msg.Params[i] } } return nil } func copyClientTags(tags irc.Tags) irc.Tags { t := make(irc.Tags, len(tags)) for k, v := range tags { if strings.HasPrefix(k, "+") { t[k] = v } } return t } var stdCaseMapping = xirc.CaseMappingRFC1459 func isWordBoundary(r rune) bool { switch r { case '-', '_', '|': // inspired from weechat.look.highlight_regex return false default: return !unicode.IsLetter(r) && !unicode.IsNumber(r) } } func isURIPrefix(text string) bool { if i := strings.LastIndexFunc(text, unicode.IsSpace); i >= 0 { text = text[i:] } i := strings.Index(text, "://") if i < 0 { return false } // See RFC 3986 section 3 r, _ := utf8.DecodeLastRuneInString(text[:i]) switch r { case '+', '-', '.': return true default: return ('0' <= r && r <= '9') || ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') } } func isHighlight(text, nick string) bool { if len(nick) == 0 { panic("isHighlight called with empty nick") } for { i := strings.Index(text, nick) if i < 0 { return false } left, _ := utf8.DecodeLastRuneInString(text[:i]) right, _ := utf8.DecodeRuneInString(text[i+len(nick):]) if isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text[:i]) { return true } text = text[i+len(nick):] } } // parseChatHistoryBound parses the given CHATHISTORY parameter as a bound. // The zero time is returned on error. func parseChatHistoryBound(param string) time.Time { parts := strings.SplitN(param, "=", 2) if len(parts) != 2 { return time.Time{} } switch parts[0] { case "timestamp": timestamp, err := time.Parse(xirc.ServerTimeLayout, parts[1]) if err != nil { return time.Time{} } return timestamp default: return time.Time{} } } func isNumeric(cmd string) bool { if len(cmd) != 3 { return false } for i := 0; i < 3; i++ { if cmd[i] < '0' || cmd[i] > '9' { return false } } return true } func generateAwayReply(away bool) *irc.Message { cmd := irc.RPL_NOWAWAY desc := "You have been marked as being away" if !away { cmd = irc.RPL_UNAWAY desc = "You are no longer marked as being away" } return &irc.Message{ Command: cmd, Params: []string{"*", desc}, } } ���������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/irc_test.go������������������������������������������������������������������������������0000664�0000000�0000000�00000002460�14770724770�0014664�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "testing" ) func TestIsHighlight(t *testing.T) { nick := "SojuUser" testCases := []struct { name string text string hl bool }{ {"noContains", "hi there Soju User!", false}, {"standalone", "SojuUser", true}, {"middle", "hi there SojuUser!", true}, {"start", "SojuUser: how are you doing?", true}, {"end", "maybe ask SojuUser", true}, {"inWord", "but OtherSojuUserSan is a different nick", false}, {"startWord", "and OtherSojuUser is another different nick", false}, {"endWord", "and SojuUserSan is yet a different nick", false}, {"underscore", "and SojuUser_san has nothing to do with me", false}, {"zeroWidthSpace", "writing S\u200BojuUser shouldn't trigger a highlight", false}, {"url", "https://SojuUser.example", false}, {"startURL", "https://SojuUser.example is a nice website", false}, {"endURL", "check out my website: https://SojuUser.example", false}, {"parenthesizedURL", "see my website (https://SojuUser.example)", false}, {"afterURL", "see https://SojuUser.example (cc SojuUser)", true}, } for _, tc := range testCases { tc := tc // capture range variable t.Run(tc.name, func(t *testing.T) { hl := isHighlight(tc.text, nick) if hl != tc.hl { t.Errorf("isHighlight(%q, %q) = %v, but want %v", tc.text, nick, hl, tc.hl) } }) } } ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/msgstore/��������������������������������������������������������������������������������0000775�0000000�0000000�00000000000�14770724770�0014362�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/msgstore/db.go���������������������������������������������������������������������������0000664�0000000�0000000�00000007765�14770724770�0015315�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package msgstore import ( "context" "time" "codeberg.org/emersion/soju/database" "git.sr.ht/~sircmpwn/go-bare" "gopkg.in/irc.v4" ) type dbMsgID struct { ID bare.Uint } func (dbMsgID) msgIDType() msgIDType { return msgIDDB } func parseDBMsgID(s string) (msgID int64, err error) { var id dbMsgID _, _, err = ParseMsgID(s, &id) if err != nil { return 0, err } return int64(id.ID), nil } func formatDBMsgID(netID int64, target string, msgID int64) string { id := dbMsgID{bare.Uint(msgID)} return formatMsgID(netID, target, &id) } // dbMessageStore is a persistent store for IRC messages, that // stores messages in the soju database. type dbMessageStore struct { db database.Database } var ( _ Store = (*dbMessageStore)(nil) _ ChatHistoryStore = (*dbMessageStore)(nil) _ SearchStore = (*dbMessageStore)(nil) ) func NewDBStore(db database.Database) *dbMessageStore { return &dbMessageStore{ db: db, } } func (ms *dbMessageStore) Close() error { return nil } func (ms *dbMessageStore) LastMsgID(network *database.Network, entity string, t time.Time) (string, error) { // TODO: what should we do with t? id, err := ms.db.GetMessageLastID(context.TODO(), network.ID, entity) if err != nil { return "", err } return formatDBMsgID(network.ID, entity, id), nil } func (ms *dbMessageStore) LoadLatestID(ctx context.Context, id string, options *LoadMessageOptions) ([]*irc.Message, error) { msgID, err := parseDBMsgID(id) if err != nil { return nil, err } l, err := ms.db.ListMessages(ctx, options.Network.ID, options.Entity, &database.MessageOptions{ AfterID: msgID, Limit: options.Limit, TakeLast: true, }) if err != nil { return nil, err } return l, nil } func (ms *dbMessageStore) Append(network *database.Network, entity string, msg *irc.Message) (string, error) { ids, err := ms.db.StoreMessages(context.TODO(), network.ID, entity, []*irc.Message{msg}) if err != nil { return "", err } return formatDBMsgID(network.ID, entity, ids[0]), nil } func (ms *dbMessageStore) ListTargets(ctx context.Context, network *database.Network, start, end time.Time, limit int, events bool) ([]ChatHistoryTarget, error) { var opts *database.MessageOptions if start.Before(end) { opts = &database.MessageOptions{ AfterTime: start, BeforeTime: end, Limit: limit, Events: events, } } else { opts = &database.MessageOptions{ AfterTime: end, BeforeTime: start, Limit: limit, Events: events, TakeLast: true, } } l, err := ms.db.ListMessageLastPerTarget(ctx, network.ID, opts) if err != nil { return nil, err } targets := make([]ChatHistoryTarget, len(l)) for i, v := range l { targets[i] = ChatHistoryTarget{ Name: v.Name, LatestMessage: v.LatestMessage, } } return targets, nil } func (ms *dbMessageStore) LoadBeforeTime(ctx context.Context, start, end time.Time, options *LoadMessageOptions) ([]*irc.Message, error) { l, err := ms.db.ListMessages(ctx, options.Network.ID, options.Entity, &database.MessageOptions{ AfterTime: end, BeforeTime: start, Limit: options.Limit, Events: options.Events, TakeLast: true, }) if err != nil { return nil, err } return l, nil } func (ms *dbMessageStore) LoadAfterTime(ctx context.Context, start, end time.Time, options *LoadMessageOptions) ([]*irc.Message, error) { l, err := ms.db.ListMessages(ctx, options.Network.ID, options.Entity, &database.MessageOptions{ AfterTime: start, BeforeTime: end, Limit: options.Limit, Events: options.Events, }) if err != nil { return nil, err } return l, nil } func (ms *dbMessageStore) Search(ctx context.Context, network *database.Network, options *SearchMessageOptions) ([]*irc.Message, error) { l, err := ms.db.ListMessages(ctx, network.ID, options.In, &database.MessageOptions{ AfterTime: options.Start, BeforeTime: options.End, Limit: options.Limit, Sender: options.From, Text: options.Text, TakeLast: true, }) if err != nil { return nil, err } return l, nil } �����������soju-0.9.0/msgstore/fs.go���������������������������������������������������������������������������0000664�0000000�0000000�00000035737�14770724770�0015340�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package msgstore import ( "bufio" "context" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" "git.sr.ht/~sircmpwn/go-bare" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/database" "codeberg.org/emersion/soju/msgstore/znclog" "codeberg.org/emersion/soju/xirc" ) const ( fsMessageStoreMaxFiles = 20 fsMessageStoreMaxTries = 100 ) func EscapeFilename(unsafe string) (safe string) { if unsafe == "." { return "-" } else if unsafe == ".." { return "--" } else { return strings.NewReplacer("/", "-", "\\", "-").Replace(unsafe) } } type date struct { Year, Month, Day int } func newDate(t time.Time) date { year, month, day := t.Date() return date{year, int(month), day} } func (d date) Time() time.Time { return time.Date(d.Year, time.Month(d.Month), d.Day, 0, 0, 0, 0, time.Local) } type fsMsgID struct { Date date Offset bare.Int } func (fsMsgID) msgIDType() msgIDType { return msgIDFS } func parseFSMsgID(s string) (netID int64, entity string, t time.Time, offset int64, err error) { var id fsMsgID netID, entity, err = ParseMsgID(s, &id) if err != nil { return 0, "", time.Time{}, 0, err } return netID, entity, id.Date.Time(), int64(id.Offset), nil } func formatFSMsgID(netID int64, entity string, t time.Time, offset int64) string { id := fsMsgID{ Date: newDate(t), Offset: bare.Int(offset), } return formatMsgID(netID, entity, &id) } type fsMessageStoreFile struct { *os.File lastUse time.Time } // fsMessageStore is a per-user on-disk store for IRC messages. // // It mimicks the ZNC log layout and format. See the ZNC source: // https://github.com/znc/znc/blob/master/modules/log.cpp type fsMessageStore struct { root string user *database.User // Write-only files used by Append files map[string]*fsMessageStoreFile // indexed by entity } var ( _ Store = (*fsMessageStore)(nil) _ ChatHistoryStore = (*fsMessageStore)(nil) _ SearchStore = (*fsMessageStore)(nil) _ RenameNetworkStore = (*fsMessageStore)(nil) ) func IsFSStore(store Store) bool { _, ok := store.(*fsMessageStore) return ok } func NewFSStore(root string, user *database.User) *fsMessageStore { return &fsMessageStore{ root: filepath.Join(root, EscapeFilename(user.Username)), user: user, files: make(map[string]*fsMessageStoreFile), } } func (ms *fsMessageStore) logPath(network *database.Network, entity string, t time.Time) string { year, month, day := t.Date() filename := fmt.Sprintf("%04d-%02d-%02d.log", year, month, day) return filepath.Join(ms.root, EscapeFilename(network.GetName()), EscapeFilename(entity), filename) } // nextMsgID queries the message ID for the next message to be written to f. func nextFSMsgID(network *database.Network, entity string, t time.Time, f *os.File) (string, error) { offset, err := f.Seek(0, io.SeekEnd) if err != nil { return "", fmt.Errorf("failed to query next FS message ID: %v", err) } return formatFSMsgID(network.ID, entity, t, offset), nil } func (ms *fsMessageStore) LastMsgID(network *database.Network, entity string, t time.Time) (string, error) { p := ms.logPath(network, entity, t) fi, err := os.Stat(p) if os.IsNotExist(err) { return formatFSMsgID(network.ID, entity, t, -1), nil } else if err != nil { return "", fmt.Errorf("failed to query last FS message ID: %v", err) } return formatFSMsgID(network.ID, entity, t, fi.Size()-1), nil } func (ms *fsMessageStore) Append(network *database.Network, entity string, msg *irc.Message) (string, error) { var t time.Time if tag, ok := msg.Tags["time"]; ok { var err error t, err = time.Parse(xirc.ServerTimeLayout, string(tag)) if err != nil { return "", fmt.Errorf("failed to parse message time tag: %v", err) } t = t.In(time.Local) } else { t = time.Now() } s := znclog.MarshalLine(msg, t) if s == "" { return "", nil } f := ms.files[entity] // TODO: handle non-monotonic clock behaviour path := ms.logPath(network, entity, t) if f == nil || f.Name() != path { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0750); err != nil { return "", fmt.Errorf("failed to create message logs directory %q: %v", dir, err) } ff, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0640) if err != nil { return "", fmt.Errorf("failed to open message log file %q: %v", path, err) } if f != nil { f.Close() } f = &fsMessageStoreFile{File: ff} ms.files[entity] = f } f.lastUse = time.Now() if len(ms.files) > fsMessageStoreMaxFiles { entities := make([]string, 0, len(ms.files)) for name := range ms.files { entities = append(entities, name) } sort.Slice(entities, func(i, j int) bool { a, b := entities[i], entities[j] return ms.files[a].lastUse.Before(ms.files[b].lastUse) }) entities = entities[0 : len(entities)-fsMessageStoreMaxFiles] for _, name := range entities { ms.files[name].Close() delete(ms.files, name) } } msgID, err := nextFSMsgID(network, entity, t, f.File) if err != nil { return "", fmt.Errorf("failed to generate message ID: %v", err) } _, err = fmt.Fprintf(f, "%s\n", s) if err != nil { return "", fmt.Errorf("failed to log message to %q: %v", f.Name(), err) } return msgID, nil } func (ms *fsMessageStore) Close() error { var closeErr error for _, f := range ms.files { if err := f.Close(); err != nil { closeErr = fmt.Errorf("failed to close message store: %v", err) } } return closeErr } func (ms *fsMessageStore) parseMessage(line string, network *database.Network, entity string, ref time.Time, events bool) (*irc.Message, time.Time, error) { return znclog.UnmarshalLine(line, ms.user, network, entity, ref, events) } func (ms *fsMessageStore) parseMessagesBefore(ref time.Time, end time.Time, options *LoadMessageOptions, afterOffset int64, selector func(m *irc.Message) bool) ([]*irc.Message, error) { path := ms.logPath(options.Network, options.Entity, ref) f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("failed to parse messages before ref: %v", err) } defer f.Close() historyRing := make([]*irc.Message, options.Limit) cur := 0 sc := bufio.NewScanner(f) if afterOffset >= 0 { if _, err := f.Seek(afterOffset, io.SeekStart); err != nil { return nil, nil } sc.Scan() // skip till next newline } for sc.Scan() { msg, t, err := ms.parseMessage(sc.Text(), options.Network, options.Entity, ref, options.Events) if err != nil { return nil, err } else if msg == nil || !t.After(end) { continue } else if !t.Before(ref) { break } if selector != nil && !selector(msg) { continue } historyRing[cur%options.Limit] = msg cur++ } if sc.Err() != nil { return nil, fmt.Errorf("failed to parse messages before ref: scanner error: %v", sc.Err()) } n := options.Limit if cur < options.Limit { n = cur } start := (cur - n + options.Limit) % options.Limit if start+n <= options.Limit { // ring doesnt wrap return historyRing[start : start+n], nil } else { // ring wraps history := make([]*irc.Message, n) r := copy(history, historyRing[start:]) copy(history[r:], historyRing[:n-r]) return history, nil } } func (ms *fsMessageStore) parseMessagesAfter(ref time.Time, end time.Time, options *LoadMessageOptions, selector func(m *irc.Message) bool) ([]*irc.Message, error) { path := ms.logPath(options.Network, options.Entity, ref) f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("failed to parse messages after ref: %v", err) } defer f.Close() var history []*irc.Message sc := bufio.NewScanner(f) for sc.Scan() && len(history) < options.Limit { msg, t, err := ms.parseMessage(sc.Text(), options.Network, options.Entity, ref, options.Events) if err != nil { return nil, err } else if msg == nil || !t.After(ref) { continue } else if !t.Before(end) { break } if selector != nil && !selector(msg) { continue } history = append(history, msg) } if sc.Err() != nil { return nil, fmt.Errorf("failed to parse messages after ref: scanner error: %v", sc.Err()) } return history, nil } func (ms *fsMessageStore) getBeforeTime(ctx context.Context, start time.Time, end time.Time, options *LoadMessageOptions, selector func(m *irc.Message) bool) ([]*irc.Message, error) { if start.IsZero() { start = time.Now() } else { start = start.In(time.Local) } end = end.In(time.Local) messages := make([]*irc.Message, options.Limit) remaining := options.Limit tries := 0 for remaining > 0 && tries < fsMessageStoreMaxTries && end.Before(start) { parseOptions := *options parseOptions.Limit = remaining buf, err := ms.parseMessagesBefore(start, end, &parseOptions, -1, selector) if err != nil { return nil, err } if len(buf) == 0 { tries++ } else { tries = 0 } copy(messages[remaining-len(buf):], buf) remaining -= len(buf) year, month, day := start.Date() start = time.Date(year, month, day, 0, 0, 0, 0, start.Location()).Add(-1) if err := ctx.Err(); err != nil { return nil, err } } return messages[remaining:], nil } func (ms *fsMessageStore) LoadBeforeTime(ctx context.Context, start time.Time, end time.Time, options *LoadMessageOptions) ([]*irc.Message, error) { return ms.getBeforeTime(ctx, start, end, options, nil) } func (ms *fsMessageStore) getAfterTime(ctx context.Context, start time.Time, end time.Time, options *LoadMessageOptions, selector func(m *irc.Message) bool) ([]*irc.Message, error) { start = start.In(time.Local) if end.IsZero() { end = time.Now() } else { end = end.In(time.Local) } var messages []*irc.Message remaining := options.Limit tries := 0 for remaining > 0 && tries < fsMessageStoreMaxTries && start.Before(end) { parseOptions := *options parseOptions.Limit = remaining buf, err := ms.parseMessagesAfter(start, end, &parseOptions, selector) if err != nil { return nil, err } if len(buf) == 0 { tries++ } else { tries = 0 } messages = append(messages, buf...) remaining -= len(buf) year, month, day := start.Date() start = time.Date(year, month, day+1, 0, 0, 0, 0, start.Location()) if err := ctx.Err(); err != nil { return nil, err } } return messages, nil } func (ms *fsMessageStore) LoadAfterTime(ctx context.Context, start time.Time, end time.Time, options *LoadMessageOptions) ([]*irc.Message, error) { return ms.getAfterTime(ctx, start, end, options, nil) } func (ms *fsMessageStore) LoadLatestID(ctx context.Context, id string, options *LoadMessageOptions) ([]*irc.Message, error) { var afterTime time.Time var afterOffset int64 if id != "" { var idNet int64 var idEntity string var err error idNet, idEntity, afterTime, afterOffset, err = parseFSMsgID(id) if err != nil { return nil, err } if idNet != options.Network.ID || idEntity != options.Entity { return nil, fmt.Errorf("cannot find message ID: message ID doesn't match network/entity") } } history := make([]*irc.Message, options.Limit) t := time.Now() remaining := options.Limit tries := 0 for remaining > 0 && tries < fsMessageStoreMaxTries && !truncateDay(t).Before(afterTime) { var offset int64 = -1 if afterOffset >= 0 && truncateDay(t).Equal(afterTime) { offset = afterOffset } parseOptions := *options parseOptions.Limit = remaining buf, err := ms.parseMessagesBefore(t, time.Time{}, &parseOptions, offset, nil) if err != nil { return nil, err } if len(buf) == 0 { tries++ } else { tries = 0 } copy(history[remaining-len(buf):], buf) remaining -= len(buf) year, month, day := t.Date() t = time.Date(year, month, day, 0, 0, 0, 0, t.Location()).Add(-1) if err := ctx.Err(); err != nil { return nil, err } } return history[remaining:], nil } func (ms *fsMessageStore) ListTargets(ctx context.Context, network *database.Network, start, end time.Time, limit int, events bool) ([]ChatHistoryTarget, error) { start = start.In(time.Local) end = end.In(time.Local) rootPath := filepath.Join(ms.root, EscapeFilename(network.GetName())) root, err := os.Open(rootPath) if os.IsNotExist(err) { return nil, nil } else if err != nil { return nil, err } // The returned targets are escaped, and there is no way to un-escape // TODO: switch to ReadDir (Go 1.16+) targetNames, err := root.Readdirnames(0) root.Close() if err != nil { return nil, err } var targets []ChatHistoryTarget for _, target := range targetNames { // target is already escaped here targetPath := filepath.Join(rootPath, target) targetDir, err := os.Open(targetPath) if err != nil { return nil, err } entries, err := targetDir.Readdir(0) targetDir.Close() if err != nil { return nil, err } // We use mtime here, which may give imprecise or incorrect results var t time.Time for _, entry := range entries { if entry.ModTime().After(t) { t = entry.ModTime() } } // The timestamps we get from logs have second granularity t = truncateSecond(t) // Filter out targets that don't fullfil the time bounds if !isTimeBetween(t, start, end) { continue } targets = append(targets, ChatHistoryTarget{ Name: target, LatestMessage: t, }) if err := ctx.Err(); err != nil { return nil, err } } // Sort targets by latest message time, backwards or forwards depending on // the order of the time bounds sort.Slice(targets, func(i, j int) bool { t1, t2 := targets[i].LatestMessage, targets[j].LatestMessage if start.Before(end) { return t1.Before(t2) } else { return !t1.Before(t2) } }) // Truncate the result if necessary if len(targets) > limit { targets = targets[:limit] } return targets, nil } func (ms *fsMessageStore) Search(ctx context.Context, network *database.Network, opts *SearchMessageOptions) ([]*irc.Message, error) { text := strings.ToLower(opts.Text) selector := func(m *irc.Message) bool { if opts.From != "" && m.Name != opts.From { return false } if text != "" && !strings.Contains(strings.ToLower(m.Params[1]), text) { return false } return true } loadOptions := LoadMessageOptions{ Network: network, Entity: opts.In, Limit: opts.Limit, } if !opts.Start.IsZero() { return ms.getAfterTime(ctx, opts.Start, opts.End, &loadOptions, selector) } else { return ms.getBeforeTime(ctx, opts.End, opts.Start, &loadOptions, selector) } } func (ms *fsMessageStore) RenameNetwork(oldNet, newNet *database.Network) error { oldDir := filepath.Join(ms.root, EscapeFilename(oldNet.GetName())) newDir := filepath.Join(ms.root, EscapeFilename(newNet.GetName())) // Avoid loosing data by overwriting an existing directory if _, err := os.Stat(newDir); err == nil { return fmt.Errorf("destination %q already exists", newDir) } return os.Rename(oldDir, newDir) } func truncateDay(t time.Time) time.Time { year, month, day := t.Date() return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) } func truncateSecond(t time.Time) time.Time { year, month, day := t.Date() return time.Date(year, month, day, t.Hour(), t.Minute(), t.Second(), 0, t.Location()) } func isTimeBetween(t, start, end time.Time) bool { if end.Before(start) { end, start = start, end } return start.Before(t) && t.Before(end) } ���������������������������������soju-0.9.0/msgstore/memory.go�����������������������������������������������������������������������0000664�0000000�0000000�00000007450�14770724770�0016227�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package msgstore import ( "context" "fmt" "time" "git.sr.ht/~sircmpwn/go-bare" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/database" ) const messageRingBufferCap = 4096 type memoryMsgID struct { Seq bare.Uint } func (memoryMsgID) msgIDType() msgIDType { return msgIDMemory } func parseMemoryMsgID(s string) (netID int64, entity string, seq uint64, err error) { var id memoryMsgID netID, entity, err = ParseMsgID(s, &id) if err != nil { return 0, "", 0, err } return netID, entity, uint64(id.Seq), nil } func formatMemoryMsgID(netID int64, entity string, seq uint64) string { id := memoryMsgID{bare.Uint(seq)} return formatMsgID(netID, entity, &id) } type ringBufferKey struct { networkID int64 entity string } func IsMemoryStore(store Store) bool { _, ok := store.(*memoryMessageStore) return ok } type memoryMessageStore struct { buffers map[ringBufferKey]*messageRingBuffer } var _ Store = (*memoryMessageStore)(nil) func NewMemoryStore() *memoryMessageStore { return &memoryMessageStore{ buffers: make(map[ringBufferKey]*messageRingBuffer), } } func (ms *memoryMessageStore) Close() error { ms.buffers = nil return nil } func (ms *memoryMessageStore) get(network *database.Network, entity string) *messageRingBuffer { k := ringBufferKey{networkID: network.ID, entity: entity} if rb, ok := ms.buffers[k]; ok { return rb } rb := newMessageRingBuffer(messageRingBufferCap) ms.buffers[k] = rb return rb } func (ms *memoryMessageStore) LastMsgID(network *database.Network, entity string, t time.Time) (string, error) { var seq uint64 k := ringBufferKey{networkID: network.ID, entity: entity} if rb, ok := ms.buffers[k]; ok { seq = rb.cur } return formatMemoryMsgID(network.ID, entity, seq), nil } func (ms *memoryMessageStore) Append(network *database.Network, entity string, msg *irc.Message) (string, error) { switch msg.Command { case "PRIVMSG", "NOTICE": // Only append these messages, because LoadLatestID shouldn't return // other kinds of message. default: return "", nil } k := ringBufferKey{networkID: network.ID, entity: entity} rb, ok := ms.buffers[k] if !ok { rb = newMessageRingBuffer(messageRingBufferCap) ms.buffers[k] = rb } seq := rb.Append(msg) return formatMemoryMsgID(network.ID, entity, seq), nil } func (ms *memoryMessageStore) LoadLatestID(ctx context.Context, id string, options *LoadMessageOptions) ([]*irc.Message, error) { if options.Events { return nil, fmt.Errorf("events are unsupported for memory message store") } _, _, seq, err := parseMemoryMsgID(id) if err != nil { return nil, err } k := ringBufferKey{networkID: options.Network.ID, entity: options.Entity} rb, ok := ms.buffers[k] if !ok { return nil, nil } return rb.LoadLatestSeq(seq, options.Limit) } type messageRingBuffer struct { buf []*irc.Message cur uint64 } func newMessageRingBuffer(capacity int) *messageRingBuffer { return &messageRingBuffer{ buf: make([]*irc.Message, capacity), cur: 1, } } func (rb *messageRingBuffer) cap() uint64 { return uint64(len(rb.buf)) } func (rb *messageRingBuffer) Append(msg *irc.Message) uint64 { seq := rb.cur i := int(seq % rb.cap()) rb.buf[i] = msg rb.cur++ return seq } func (rb *messageRingBuffer) LoadLatestSeq(seq uint64, limit int) ([]*irc.Message, error) { if seq > rb.cur { return nil, fmt.Errorf("loading messages from sequence number (%v) greater than current (%v)", seq, rb.cur) } else if seq == rb.cur { return nil, nil } // The query excludes the message with the sequence number seq diff := rb.cur - seq - 1 if diff > rb.cap() { // We dropped diff - cap entries diff = rb.cap() } if int(diff) > limit { diff = uint64(limit) } l := make([]*irc.Message, int(diff)) for i := 0; i < int(diff); i++ { j := int((rb.cur - diff + uint64(i)) % rb.cap()) l[i] = rb.buf[j] } return l, nil } ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/msgstore/msgstore.go���������������������������������������������������������������������0000664�0000000�0000000�00000010735�14770724770�0016562�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package msgstore import ( "bytes" "context" "encoding/base64" "fmt" "time" "git.sr.ht/~sircmpwn/go-bare" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/database" ) type LoadMessageOptions struct { Network *database.Network Entity string Limit int Events bool } // Store is a per-user store for IRC messages. type Store interface { Close() error // LastMsgID queries the last message ID for the given network, entity and // date. The message ID returned may not refer to a valid message, but can be // used in history queries. LastMsgID(network *database.Network, entity string, t time.Time) (string, error) // LoadLatestID queries the latest non-event messages for the given network, // entity and date, up to a count of limit messages, sorted from oldest to newest. LoadLatestID(ctx context.Context, id string, options *LoadMessageOptions) ([]*irc.Message, error) Append(network *database.Network, entity string, msg *irc.Message) (id string, err error) } type ChatHistoryTarget struct { Name string LatestMessage time.Time } // ChatHistoryStore is a message store that supports chat history operations. type ChatHistoryStore interface { Store // ListTargets lists channels and nicknames by time of the latest message. // It returns up to limit targets, starting from start and ending on end, // both excluded. end may be before or after start. // If events is false, only PRIVMSG/NOTICE messages are considered. ListTargets(ctx context.Context, network *database.Network, start, end time.Time, limit int, events bool) ([]ChatHistoryTarget, error) // LoadBeforeTime loads up to limit messages before start down to end. The // returned messages must be between and excluding the provided bounds. // end is before start. // If events is false, only PRIVMSG/NOTICE messages are considered. LoadBeforeTime(ctx context.Context, start, end time.Time, options *LoadMessageOptions) ([]*irc.Message, error) // LoadAfterTime loads up to limit messages after start up to end. The // returned messages must be between and excluding the provided bounds. // end is after start. // If events is false, only PRIVMSG/NOTICE messages are considered. LoadAfterTime(ctx context.Context, start, end time.Time, options *LoadMessageOptions) ([]*irc.Message, error) } type SearchMessageOptions struct { Start time.Time End time.Time Limit int From string In string Text string } // SearchStore is a message store that supports server-side search operations. type SearchStore interface { Store // Search returns messages matching the specified options. Search(ctx context.Context, network *database.Network, options *SearchMessageOptions) ([]*irc.Message, error) } // RenameNetworkStore is a message store which needs to be notified of network // name changes. type RenameNetworkStore interface { Store RenameNetwork(oldNet, newNet *database.Network) error } type msgIDType uint const ( msgIDNone msgIDType = iota msgIDMemory msgIDFS msgIDDB ) const msgIDVersion uint = 0 type msgIDHeader struct { Version uint Network bare.Int Target string Type msgIDType } type msgIDBody interface { msgIDType() msgIDType } func formatMsgID(netID int64, target string, body msgIDBody) string { var buf bytes.Buffer w := bare.NewWriter(&buf) header := msgIDHeader{ Version: msgIDVersion, Network: bare.Int(netID), Target: target, Type: body.msgIDType(), } if err := bare.MarshalWriter(w, &header); err != nil { panic(err) } if err := bare.MarshalWriter(w, body); err != nil { panic(err) } return base64.RawURLEncoding.EncodeToString(buf.Bytes()) } func ParseMsgID(s string, body msgIDBody) (netID int64, target string, err error) { b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return 0, "", fmt.Errorf("invalid internal message ID: %v", err) } r := bare.NewReader(bytes.NewReader(b)) var header msgIDHeader if err := bare.UnmarshalBareReader(r, &header); err != nil { return 0, "", fmt.Errorf("invalid internal message ID: %v", err) } if header.Version != msgIDVersion { return 0, "", fmt.Errorf("invalid internal message ID: got version %v, want %v", header.Version, msgIDVersion) } if body != nil { typ := body.msgIDType() if header.Type != typ { return 0, "", fmt.Errorf("invalid internal message ID: got type %v, want %v", header.Type, typ) } if err := bare.UnmarshalBareReader(r, body); err != nil { return 0, "", fmt.Errorf("invalid internal message ID: %v", err) } } return int64(header.Network), header.Target, nil } �����������������������������������soju-0.9.0/msgstore/znclog/�������������������������������������������������������������������������0000775�0000000�0000000�00000000000�14770724770�0015656�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/msgstore/znclog/reader.go����������������������������������������������������������������0000664�0000000�0000000�00000010406�14770724770�0017450�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package znclog import ( "fmt" "strings" "time" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/database" "codeberg.org/emersion/soju/xirc" ) var timestampPrefixLen = len("[01:02:03] ") func UnmarshalLine(line string, user *database.User, network *database.Network, entity string, ref time.Time, events bool) (*irc.Message, time.Time, error) { var hour, minute, second int _, err := fmt.Sscanf(line, "[%02d:%02d:%02d] ", &hour, &minute, &second) if err != nil { return nil, time.Time{}, fmt.Errorf("malformed timestamp prefix: %v", err) } else if len(line) < timestampPrefixLen { return nil, time.Time{}, fmt.Errorf("malformed timestamp prefix: too short") } line = line[timestampPrefixLen:] var cmd string var prefix *irc.Prefix var params []string if events && strings.HasPrefix(line, "*** ") { parts := strings.SplitN(line[4:], " ", 2) if len(parts) != 2 { return nil, time.Time{}, nil } switch parts[0] { case "Joins:", "Parts:", "Quits:": args := strings.SplitN(parts[1], " ", 3) if len(args) < 2 { return nil, time.Time{}, nil } nick := args[0] mask := strings.TrimSuffix(strings.TrimPrefix(args[1], "("), ")") maskParts := strings.SplitN(mask, "@", 2) if len(maskParts) != 2 { return nil, time.Time{}, nil } prefix = &irc.Prefix{ Name: nick, User: maskParts[0], Host: maskParts[1], } var reason string if len(args) > 2 { reason = strings.TrimSuffix(strings.TrimPrefix(args[2], "("), ")") } switch parts[0] { case "Joins:": cmd = "JOIN" params = []string{entity} case "Parts:": cmd = "PART" if reason != "" { params = []string{entity, reason} } else { params = []string{entity} } case "Quits:": cmd = "QUIT" if reason != "" { params = []string{reason} } } default: nick := parts[0] rem := parts[1] if r := strings.TrimPrefix(rem, "is now known as "); r != rem { cmd = "NICK" prefix = &irc.Prefix{ Name: nick, } params = []string{r} } else if r := strings.TrimPrefix(rem, "was kicked by "); r != rem { args := strings.SplitN(r, " ", 2) if len(args) != 2 { return nil, time.Time{}, nil } cmd = "KICK" prefix = &irc.Prefix{ Name: args[0], } reason := strings.TrimSuffix(strings.TrimPrefix(args[1], "("), ")") params = []string{entity, nick} if reason != "" { params = append(params, reason) } } else if r := strings.TrimPrefix(rem, "changes topic to "); r != rem { cmd = "TOPIC" prefix = &irc.Prefix{ Name: nick, } topic := strings.TrimSuffix(strings.TrimPrefix(r, "'"), "'") params = []string{entity, topic} } else if r := strings.TrimPrefix(rem, "sets mode: "); r != rem { cmd = "MODE" prefix = &irc.Prefix{ Name: nick, } params = append([]string{entity}, strings.Split(r, " ")...) } else { return nil, time.Time{}, nil } } } else { var sender, text string if strings.HasPrefix(line, "<") { cmd = "PRIVMSG" parts := strings.SplitN(line[1:], "> ", 2) if len(parts) != 2 { return nil, time.Time{}, nil } sender, text = parts[0], parts[1] } else if strings.HasPrefix(line, "-") { cmd = "NOTICE" parts := strings.SplitN(line[1:], "- ", 2) if len(parts) != 2 { return nil, time.Time{}, nil } sender, text = parts[0], parts[1] } else if strings.HasPrefix(line, "* ") { cmd = "PRIVMSG" parts := strings.SplitN(line[2:], " ", 2) if len(parts) != 2 { return nil, time.Time{}, nil } sender, text = parts[0], "\x01ACTION "+parts[1]+"\x01" } else { return nil, time.Time{}, nil } prefix = &irc.Prefix{Name: sender} if entity == sender { // This is a direct message from a user to us. We don't store own // our nickname in the logs, so grab it from the network settings. // Not very accurate since this may not match our nick at the time // the message was received, but we can't do a lot better. entity = database.GetNick(user, network) } params = []string{entity, text} } year, month, day := ref.Date() t := time.Date(year, month, day, hour, minute, second, 0, time.Local) msg := &irc.Message{ Tags: map[string]string{ "time": xirc.FormatServerTime(t), }, Prefix: prefix, Command: cmd, Params: params, } return msg, t, nil } ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/msgstore/znclog/writer.go����������������������������������������������������������������0000664�0000000�0000000�00000003553�14770724770�0017527�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package znclog import ( "fmt" "strings" "time" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/xirc" ) func MarshalLine(msg *irc.Message, t time.Time) string { s := formatMessage(msg) if s == "" { return "" } return fmt.Sprintf("[%02d:%02d:%02d] %s", t.Hour(), t.Minute(), t.Second(), s) } // formatMessage formats a message log line. It assumes a well-formed IRC // message. func formatMessage(msg *irc.Message) string { switch strings.ToUpper(msg.Command) { case "NICK": return fmt.Sprintf("*** %s is now known as %s", msg.Prefix.Name, msg.Params[0]) case "JOIN": return fmt.Sprintf("*** Joins: %s (%s@%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host) case "PART": var reason string if len(msg.Params) > 1 { reason = msg.Params[1] } return fmt.Sprintf("*** Parts: %s (%s@%s) (%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host, reason) case "KICK": nick := msg.Params[1] var reason string if len(msg.Params) > 2 { reason = msg.Params[2] } return fmt.Sprintf("*** %s was kicked by %s (%s)", nick, msg.Prefix.Name, reason) case "QUIT": var reason string if len(msg.Params) > 0 { reason = msg.Params[0] } return fmt.Sprintf("*** Quits: %s (%s@%s) (%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host, reason) case "TOPIC": var topic string if len(msg.Params) > 1 { topic = msg.Params[1] } return fmt.Sprintf("*** %s changes topic to '%s'", msg.Prefix.Name, topic) case "MODE": return fmt.Sprintf("*** %s sets mode: %s", msg.Prefix.Name, strings.Join(msg.Params[1:], " ")) case "NOTICE": return fmt.Sprintf("-%s- %s", msg.Prefix.Name, msg.Params[1]) case "PRIVMSG": if cmd, params, ok := xirc.ParseCTCPMessage(msg); ok && cmd == "ACTION" { return fmt.Sprintf("* %s %s", msg.Prefix.Name, params) } else { return fmt.Sprintf("<%s> %s", msg.Prefix.Name, msg.Params[1]) } default: return "" } } �����������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/rate.go����������������������������������������������������������������������������������0000664�0000000�0000000�00000001133�14770724770�0013777�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "math/rand" "time" ) // backoffer implements a simple exponential backoff. type backoffer struct { min, max, jitter time.Duration n int64 } func newBackoffer(min, max, jitter time.Duration) *backoffer { return &backoffer{min: min, max: max, jitter: jitter} } func (b *backoffer) Reset() { b.n = 0 } func (b *backoffer) Next() time.Duration { if b.n == 0 { b.n = 1 return 0 } d := time.Duration(b.n) * b.min if d > b.max { d = b.max } else { b.n *= 2 } if b.jitter != 0 { d += time.Duration(rand.Int63n(int64(b.jitter))) } return d } �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/server.go��������������������������������������������������������������������������������0000664�0000000�0000000�00000047112�14770724770�0014361�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "context" "errors" "fmt" "io" "log" "mime" "net" "net/http" "net/netip" "runtime/debug" "sync" "sync/atomic" "syscall" "time" "github.com/SherClockHolmes/webpush-go" "github.com/coder/websocket" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/auth" "codeberg.org/emersion/soju/config" "codeberg.org/emersion/soju/database" "codeberg.org/emersion/soju/fileupload" "codeberg.org/emersion/soju/identd" ) var ( retryConnectMinDelay = time.Minute retryConnectMaxDelay = 10 * time.Minute retryConnectJitter = time.Minute connectTimeout = 15 * time.Second writeTimeout = 10 * time.Second upstreamMessageDelay = 2 * time.Second upstreamMessageBurst = 10 backlogTimeout = 10 * time.Second handleDownstreamMessageTimeout = 10 * time.Second downstreamRegisterTimeout = 30 * time.Second webpushCheckSubscriptionDelay = 24 * time.Hour webpushPruneSubscriptionDelay = 30 * 24 * time.Hour chatHistoryLimit = 1000 backlogLimit = 4000 ) var errWebPushSubscriptionExpired = fmt.Errorf("Web Push subscription expired") var errWebPushToInternalIP = fmt.Errorf("cannot connect to internal IP address") var webPushHTTPClient webpush.HTTPClient = buildWebPushHTTPClient() type Logger interface { Printf(format string, v ...interface{}) Debugf(format string, v ...interface{}) } type logger interface { Printf(format string, v ...interface{}) } type DebugLogger struct { logger debug atomic.Bool } func (l *DebugLogger) Debugf(format string, v ...interface{}) { if !l.debug.Load() { return } l.logger.Printf(format, v...) } func NewLogger(out io.Writer, debug bool) *DebugLogger { l := &DebugLogger{ logger: log.New(out, "", log.LstdFlags), } l.debug.Store(debug) return l } type prefixLogger struct { logger Logger prefix string } var _ Logger = (*prefixLogger)(nil) func (l *prefixLogger) Printf(format string, v ...interface{}) { v = append([]interface{}{l.prefix}, v...) l.logger.Printf("%v"+format, v...) } func (l *prefixLogger) Debugf(format string, v ...interface{}) { v = append([]interface{}{l.prefix}, v...) l.logger.Debugf("%v"+format, v...) } type int64Gauge struct { v int64 // atomic } func (g *int64Gauge) Add(delta int64) { atomic.AddInt64(&g.v, delta) } func (g *int64Gauge) Value() int64 { return atomic.LoadInt64(&g.v) } func (g *int64Gauge) Float64() float64 { return float64(g.Value()) } type retryListener struct { net.Listener Logger Logger delay time.Duration } func NewRetryListener(ln net.Listener) net.Listener { return &retryListener{Listener: ln} } func (ln *retryListener) Accept() (net.Conn, error) { for { conn, err := ln.Listener.Accept() if ne, ok := err.(net.Error); ok && ne.Temporary() { if ln.delay == 0 { ln.delay = 5 * time.Millisecond } else { ln.delay *= 2 } if max := 1 * time.Second; ln.delay > max { ln.delay = max } if ln.Logger != nil { ln.Logger.Printf("accept error (retrying in %v): %v", ln.delay, err) } time.Sleep(ln.delay) } else { ln.delay = 0 return conn, err } } } type Config struct { Hostname string Title string MsgStoreDriver string MsgStorePath string HTTPOrigins []string HTTPIngress string AcceptProxyIPs config.IPSet MaxUserNetworks int MOTD string UpstreamUserIPs []*net.IPNet DisableInactiveUsersDelay time.Duration EnableUsersOnAuth bool Auth *auth.Authenticator FileUploader fileupload.Uploader } type Server struct { Logger *DebugLogger Identd *identd.Identd // can be nil MetricsRegistry prometheus.Registerer // can be nil config atomic.Value // *Config db database.Database stopWG sync.WaitGroup stopCh chan struct{} lock sync.Mutex listeners map[net.Listener]struct{} users map[string]*user shutdown bool metrics struct { downstreams int64Gauge upstreams int64Gauge upstreamOutMessagesTotal prometheus.Counter upstreamInMessagesTotal prometheus.Counter downstreamOutMessagesTotal prometheus.Counter downstreamInMessagesTotal prometheus.Counter upstreamConnectErrorsTotal prometheus.Counter workerPanicsTotal prometheus.Counter } webPush *database.WebPushConfig } func NewServer(db database.Database) *Server { srv := &Server{ Logger: NewLogger(log.Writer(), true), db: db, listeners: make(map[net.Listener]struct{}), users: make(map[string]*user), stopCh: make(chan struct{}), } srv.config.Store(&Config{ Hostname: "localhost", MaxUserNetworks: -1, Auth: auth.NewInternal(), }) return srv } func (s *Server) prefix() *irc.Prefix { return &irc.Prefix{Name: s.Config().Hostname} } func (s *Server) Config() *Config { return s.config.Load().(*Config) } func (s *Server) SetConfig(cfg *Config) { s.config.Store(cfg) } func (s *Server) Start() error { s.registerMetrics() if err := s.loadWebPushConfig(context.TODO()); err != nil { return err } users, err := s.db.ListUsers(context.TODO()) if err != nil { return err } s.lock.Lock() for i := range users { s.addUserLocked(&users[i]) } s.lock.Unlock() s.stopWG.Add(1) go func() { defer s.stopWG.Done() s.disableInactiveUsersLoop() }() return nil } func (s *Server) registerMetrics() { factory := promauto.With(s.MetricsRegistry) factory.NewGaugeFunc(prometheus.GaugeOpts{ Name: "soju_users_active", Help: "Current number of active users", }, func() float64 { s.lock.Lock() n := len(s.users) s.lock.Unlock() return float64(n) }) factory.NewGaugeFunc(prometheus.GaugeOpts{ Name: "soju_downstreams_active", Help: "Current number of downstream connections", }, s.metrics.downstreams.Float64) factory.NewGaugeFunc(prometheus.GaugeOpts{ Name: "soju_upstreams_active", Help: "Current number of upstream connections", }, s.metrics.upstreams.Float64) s.metrics.upstreamOutMessagesTotal = factory.NewCounter(prometheus.CounterOpts{ Name: "soju_upstream_out_messages_total", Help: "Total number of outgoing messages sent to upstream servers", }) s.metrics.upstreamInMessagesTotal = factory.NewCounter(prometheus.CounterOpts{ Name: "soju_upstream_in_messages_total", Help: "Total number of incoming messages received from upstream servers", }) s.metrics.downstreamOutMessagesTotal = factory.NewCounter(prometheus.CounterOpts{ Name: "soju_downstream_out_messages_total", Help: "Total number of outgoing messages sent to downstream clients", }) s.metrics.downstreamInMessagesTotal = factory.NewCounter(prometheus.CounterOpts{ Name: "soju_downstream_in_messages_total", Help: "Total number of incoming messages received from downstream clients", }) s.metrics.upstreamConnectErrorsTotal = factory.NewCounter(prometheus.CounterOpts{ Name: "soju_upstream_connect_errors_total", Help: "Total number of upstream connection errors", }) s.metrics.workerPanicsTotal = factory.NewCounter(prometheus.CounterOpts{ Name: "soju_worker_panics_total", Help: "Total number of panics in worker goroutines", }) } func (s *Server) loadWebPushConfig(ctx context.Context) error { configs, err := s.db.ListWebPushConfigs(ctx) if err != nil { return fmt.Errorf("failed to list Web push configs: %v", err) } if len(configs) > 1 { return fmt.Errorf("expected zero or one Web push config, got %v", len(configs)) } else if len(configs) == 1 { s.webPush = &configs[0] return nil } s.Logger.Printf("generating Web push VAPID key pair") priv, pub, err := webpush.GenerateVAPIDKeys() if err != nil { return fmt.Errorf("failed to generate Web push VAPID key pair: %v", err) } config := new(database.WebPushConfig) config.VAPIDKeys.Public = pub config.VAPIDKeys.Private = priv if err := s.db.StoreWebPushConfig(ctx, config); err != nil { return fmt.Errorf("failed to store Web push config: %v", err) } s.webPush = config return nil } func (s *Server) sendWebPush(ctx context.Context, sub *webpush.Subscription, vapidPubKey string, msg *irc.Message) error { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() var urgency webpush.Urgency switch msg.Command { case "PRIVMSG", "NOTICE", "INVITE": urgency = webpush.UrgencyHigh default: urgency = webpush.UrgencyNormal } options := webpush.Options{ HTTPClient: webPushHTTPClient, VAPIDPublicKey: s.webPush.VAPIDKeys.Public, VAPIDPrivateKey: s.webPush.VAPIDKeys.Private, Subscriber: "https://soju.im", TTL: 7 * 24 * 60 * 60, // seconds Urgency: urgency, RecordSize: 2048, } if vapidPubKey != options.VAPIDPublicKey { return fmt.Errorf("unknown VAPID public key %q", vapidPubKey) } payload := []byte(msg.String()) resp, err := webpush.SendNotificationWithContext(ctx, payload, sub, &options) if err != nil { return err } resp.Body.Close() // 404 means the subscription has expired as per RFC 8030 section 7.3 if resp.StatusCode == http.StatusNotFound { return errWebPushSubscriptionExpired } else if resp.StatusCode/100 != 2 { return fmt.Errorf("HTTP error: %v", resp.Status) } return nil } func (s *Server) Shutdown() { s.Logger.Printf("shutting down server") close(s.stopCh) s.lock.Lock() s.shutdown = true for ln := range s.listeners { if err := ln.Close(); err != nil { s.Logger.Printf("failed to stop listener: %v", err) } } for _, u := range s.users { u.events <- eventStop{} } s.lock.Unlock() s.Logger.Printf("waiting for users to finish") s.stopWG.Wait() if err := s.db.Close(); err != nil { s.Logger.Printf("failed to close DB: %v", err) } } func (s *Server) createUser(ctx context.Context, user *database.User) (*user, error) { s.lock.Lock() defer s.lock.Unlock() if _, ok := s.users[user.Username]; ok { return nil, fmt.Errorf("user %q already exists", user.Username) } err := s.db.StoreUser(ctx, user) if err != nil { return nil, fmt.Errorf("could not create user in db: %v", err) } return s.addUserLocked(user), nil } func (s *Server) forEachUser(f func(*user)) { s.lock.Lock() for _, u := range s.users { f(u) } s.lock.Unlock() } func (s *Server) getUser(name string) *user { s.lock.Lock() u := s.users[name] s.lock.Unlock() return u } func (s *Server) addUserLocked(user *database.User) *user { s.Logger.Printf("starting bouncer for user %q", user.Username) u := newUser(s, user) s.users[u.Username] = u s.stopWG.Add(1) go func() { defer func() { if err := recover(); err != nil { s.Logger.Printf("panic serving user %q: %v\n%v", user.Username, err, string(debug.Stack())) s.metrics.workerPanicsTotal.Inc() } s.lock.Lock() delete(s.users, u.Username) s.lock.Unlock() s.stopWG.Done() }() u.run() }() return u } var lastDownstreamID uint64 func (s *Server) Handle(ic ircConn) { defer func() { if err := recover(); err != nil { s.Logger.Printf("panic serving downstream %q: %v\n%v", ic.RemoteAddr(), err, string(debug.Stack())) } }() s.lock.Lock() shutdown := s.shutdown s.lock.Unlock() s.metrics.downstreams.Add(1) defer s.metrics.downstreams.Add(-1) id := atomic.AddUint64(&lastDownstreamID, 1) dc := newDownstreamConn(s, ic, id) defer dc.Shutdown(context.TODO()) if shutdown { dc.SendMessage(context.TODO(), &irc.Message{ Command: "ERROR", Params: []string{"Server is shutting down"}, }) return } if err := dc.runUntilRegistered(); err != nil { if !errors.Is(err, io.EOF) { dc.logger.Printf("%v", err) } return } user, err := s.getOrCreateUser(context.TODO(), dc.registration.authUsername) if err != nil { dc.logger.Printf("failed to get/create user: %v", err) dc.SendMessage(context.TODO(), &irc.Message{ Command: "ERROR", Params: []string{"Internal server error"}, }) return } user.events <- eventDownstreamConnected{dc} if err := dc.readMessages(user.events); err != nil { dc.logger.Printf("%v", err) } user.events <- eventDownstreamDisconnected{dc} } func (s *Server) getOrCreateUser(ctx context.Context, username string) (*user, error) { user := s.getUser(username) if user != nil { return user, nil } if _, err := s.db.GetUser(ctx, username); err == nil { return nil, fmt.Errorf("user %q exists in the DB but hasn't been loaded by the bouncer -- a restart may help", username) } if !s.Config().EnableUsersOnAuth { return nil, fmt.Errorf("cannot find user %q in the DB", username) } // Can't find the user in the DB -- try to create it record := database.NewUser(username) user, err := s.createUser(ctx, record) if err != nil { return nil, fmt.Errorf("failed to automatically create user %q after successful authentication: %v", username, err) } return user, nil } func (s *Server) HandleAdmin(ic ircConn) { defer func() { if err := recover(); err != nil { s.Logger.Printf("panic serving admin client %q: %v\n%v", ic.RemoteAddr(), err, string(debug.Stack())) } }() s.lock.Lock() shutdown := s.shutdown s.lock.Unlock() ctx := context.TODO() remoteAddr := ic.RemoteAddr().String() logger := &prefixLogger{s.Logger, fmt.Sprintf("admin %q: ", remoteAddr)} c := newConn(s, ic, &connOptions{Logger: logger}) defer c.Close() if shutdown { c.SendMessage(ctx, &irc.Message{ Command: "ERROR", Params: []string{"Server is shutting down"}, }) return } for { msg, err := c.ReadMessage() if errors.Is(err, io.EOF) { break } else if err != nil { logger.Printf("failed to read IRC command: %v", err) break } switch msg.Command { case "CAP", "NICK", "USER", "PASS": // Ensure regular IRC clients cannot connect. This is important to // e.g. prevent unprivileged soju users from connecting to the // admin socket. c.SendMessage(ctx, &irc.Message{ Command: "ERROR", Params: []string{"This is not a regular IRC server"}, }) return case "BOUNCERSERV": if len(msg.Params) < 1 { c.SendMessage(ctx, &irc.Message{ Command: irc.ERR_NEEDMOREPARAMS, Params: []string{ "*", msg.Command, "Not enough parameters", }, }) break } err := handleServicePRIVMSG(&serviceContext{ Context: ctx, srv: s, admin: true, print: func(text string) { c.SendMessage(ctx, &irc.Message{ Prefix: s.prefix(), Command: "PRIVMSG", Params: []string{"*", text}, }) }, }, msg.Params[0]) if err != nil { c.SendMessage(ctx, &irc.Message{ Prefix: s.prefix(), Command: "FAIL", Params: []string{msg.Command, err.Error()}, }) } else { c.SendMessage(ctx, &irc.Message{ Prefix: s.prefix(), Command: msg.Command, Params: []string{"OK"}, }) } default: c.SendMessage(ctx, &irc.Message{ Prefix: s.prefix(), Command: irc.ERR_UNKNOWNCOMMAND, Params: []string{ "*", msg.Command, "Unknown command", }, }) } } } func (s *Server) Serve(ln net.Listener, handler func(ircConn)) error { ln = &retryListener{ Listener: ln, Logger: &prefixLogger{logger: s.Logger, prefix: fmt.Sprintf("listener %v: ", ln.Addr())}, } s.lock.Lock() s.listeners[ln] = struct{}{} s.lock.Unlock() s.stopWG.Add(1) defer func() { s.lock.Lock() delete(s.listeners, ln) s.lock.Unlock() s.stopWG.Done() }() for { conn, err := ln.Accept() if errors.Is(err, net.ErrClosed) { return nil } else if err != nil { return fmt.Errorf("failed to accept connection: %v", err) } go handler(newNetIRCConn(conn)) } } func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { conn, err := websocket.Accept(w, req, &websocket.AcceptOptions{ Subprotocols: []string{"text.ircv3.net"}, // non-compliant, fight me OriginPatterns: s.Config().HTTPOrigins, }) if err != nil { s.Logger.Printf("failed to serve HTTP connection: %v", err) return } isProxy := false if host, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { if ip := net.ParseIP(host); ip != nil { isProxy = s.Config().AcceptProxyIPs.Contains(ip) } } // Only trust the Forwarded header field if this is a trusted proxy IP // to prevent users from spoofing the remote address remoteAddr := req.RemoteAddr if isProxy { forwarded := parseForwarded(req.Header) if forwarded["for"] != "" { remoteAddr = forwarded["for"] } } s.Handle(newWebsocketIRCConn(conn, remoteAddr)) } func parseForwarded(h http.Header) map[string]string { forwarded := h.Get("Forwarded") if forwarded == "" { return map[string]string{ "for": h.Get("X-Forwarded-For"), "proto": h.Get("X-Forwarded-Proto"), "host": h.Get("X-Forwarded-Host"), } } // Hack to easily parse header parameters _, params, _ := mime.ParseMediaType("hack; " + forwarded) return params } type ServerStats struct { Users int Downstreams int64 Upstreams int64 } func (s *Server) Stats() *ServerStats { var stats ServerStats s.lock.Lock() stats.Users = len(s.users) s.lock.Unlock() stats.Downstreams = s.metrics.downstreams.Value() stats.Upstreams = s.metrics.upstreams.Value() return &stats } func (s *Server) disableInactiveUsersLoop() { ticker := time.NewTicker(4 * time.Hour) defer ticker.Stop() for { select { case <-s.stopCh: return case <-ticker.C: } if err := s.disableInactiveUsers(context.TODO()); err != nil { s.Logger.Printf("failed to disable inactive users: %v", err) } } } func (s *Server) disableInactiveUsers(ctx context.Context) error { delay := s.Config().DisableInactiveUsersDelay if delay == 0 { return nil } ctx, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() usernames, err := s.db.ListInactiveUsernames(ctx, time.Now().Add(-delay)) if err != nil { return fmt.Errorf("failed to list inactive users: %v", err) } else if len(usernames) == 0 { return nil } // Filter out users with active downstream connections var users []*user s.lock.Lock() for _, username := range usernames { u := s.users[username] if u == nil { // TODO: disable the user in the DB continue } if n := u.numDownstreamConns.Load(); n > 0 { continue } users = append(users, u) } s.lock.Unlock() if len(users) == 0 { return nil } s.Logger.Printf("found %v inactive users", len(users)) for _, u := range users { done := make(chan error, 1) enabled := false event := eventUserUpdate{ enabled: &enabled, done: done, } select { case <-ctx.Done(): return ctx.Err() case u.events <- event: // Event was sent, let's wait for the reply } select { case <-ctx.Done(): return ctx.Err() case err := <-done: if err != nil { return err } else { s.Logger.Printf("deleted inactive user %q", u.Username) } } } return nil } func buildWebPushHTTPClient() *http.Client { // this is a dialer that can only connect to external IP addresses dialer := &net.Dialer{ Control: func(network, address string, c syscall.RawConn) error { ip, _, err := net.SplitHostPort(address) if err != nil { return err } parsedIP, err := netip.ParseAddr(ip) if err != nil { return err } if parsedIP.IsLoopback() || parsedIP.IsMulticast() || parsedIP.IsPrivate() { return errWebPushToInternalIP } return nil }, } return &http.Client{ Transport: &userAgentHTTPTransport{ userAgent: "soju", transport: http.Transport{ DialContext: dialer.DialContext, }, }, } } type userAgentHTTPTransport struct { userAgent string transport http.Transport } func (ua *userAgentHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", ua.userAgent) return ua.transport.RoundTrip(req) } ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/server_test.go���������������������������������������������������������������������������0000664�0000000�0000000�00000017656�14770724770�0015432�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "context" "net" "os" "reflect" "testing" "time" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/database" "codeberg.org/emersion/soju/xirc" ) var testServerPrefix = &irc.Prefix{Name: "soju-test-server"} const ( testUsername = "soju-test-user" testPassword = testUsername ) type testingLogger struct { t *testing.T } func (tl testingLogger) Printf(format string, v ...interface{}) { tl.t.Logf(format, v...) } func createTempSqliteDB(t *testing.T) database.Database { if !database.SqliteEnabled { t.Skip("SQLite support is disabled") } db, err := database.OpenTempSqliteDB() if err != nil { t.Fatalf("failed to create temporary SQLite database: %v", err) } return db } func createTempPostgresDB(t *testing.T) database.Database { source, ok := os.LookupEnv("SOJU_TEST_POSTGRES") if !ok { t.Skip("set SOJU_TEST_POSTGRES to a connection string to execute PostgreSQL tests") } db, err := database.OpenTempPostgresDB(source) if err != nil { t.Fatalf("failed to create temporary PostgreSQL database: %v", err) } return db } func createTestUser(t *testing.T, db database.Database) *database.User { record := database.NewUser(testUsername) if err := record.SetPassword(testPassword); err != nil { t.Fatalf("failed to generate bcrypt hash: %v", err) } if err := db.StoreUser(context.Background(), record); err != nil { t.Fatalf("failed to store test user: %v", err) } return record } func createTestDownstream(t *testing.T, srv *Server) ircConn { c1, c2 := net.Pipe() go srv.Handle(newNetIRCConn(c1)) return newNetIRCConn(c2) } func createTestUpstream(t *testing.T, db database.Database, user *database.User) (*database.Network, net.Listener) { ln, err := net.Listen("tcp", "localhost:0") if err != nil { t.Fatalf("failed to create TCP listener: %v", err) } network := database.NewNetwork("irc+insecure://" + ln.Addr().String()) network.Name = "testnet" if err := db.StoreNetwork(context.Background(), user.ID, network); err != nil { t.Fatalf("failed to store test network: %v", err) } return network, ln } func mustAccept(t *testing.T, ln net.Listener) ircConn { c, err := ln.Accept() if err != nil { t.Fatalf("failed accepting connection: %v", err) } return newNetIRCConn(c) } func expectMessage(t *testing.T, c ircConn, cmd string) *irc.Message { msg, err := c.ReadMessage() if err != nil { t.Fatalf("failed to read IRC message (want %q): %v", cmd, err) } if msg.Command != cmd { t.Fatalf("invalid message received: want %q, got: %v", cmd, msg) } return msg } func roundtrip(t *testing.T, c ircConn) []*irc.Message { c.WriteMessage(&irc.Message{Command: "PING", Params: []string{"roundtrip"}}) var msgs []*irc.Message for { msg, err := c.ReadMessage() if err != nil { t.Fatalf("failed to read IRC message: %v", err) } if msg.Command == "PONG" { break } msgs = append(msgs, msg) } return msgs } func registerDownstreamConn(t *testing.T, c ircConn, network *database.Network) { c.WriteMessage(&irc.Message{ Command: "PASS", Params: []string{testPassword}, }) c.WriteMessage(&irc.Message{ Command: "NICK", Params: []string{testUsername}, }) c.WriteMessage(&irc.Message{ Command: "USER", Params: []string{testUsername + "/" + network.Name, "0", "*", testUsername}, }) expectMessage(t, c, irc.RPL_WELCOME) } func registerUpstreamConn(t *testing.T, c ircConn) { msg := expectMessage(t, c, "CAP") if msg.Params[0] != "LS" { t.Fatalf("invalid CAP LS: got: %v", msg) } msg = expectMessage(t, c, "NICK") nick := msg.Params[0] if nick != testUsername { t.Fatalf("invalid NICK: want %q, got: %v", testUsername, msg) } expectMessage(t, c, "USER") c.WriteMessage(&irc.Message{ Prefix: testServerPrefix, Command: irc.RPL_WELCOME, Params: []string{nick, "Welcome!"}, }) c.WriteMessage(&irc.Message{ Prefix: testServerPrefix, Command: irc.RPL_YOURHOST, Params: []string{nick, "Your host is soju-test-server"}, }) c.WriteMessage(&irc.Message{ Prefix: testServerPrefix, Command: irc.RPL_CREATED, Params: []string{nick, "Who cares when the server was created?"}, }) c.WriteMessage(&irc.Message{ Prefix: testServerPrefix, Command: irc.RPL_MYINFO, Params: []string{nick, testServerPrefix.Name, "soju", "aiwroO", "OovaimnqpsrtklbeI"}, }) c.WriteMessage(&irc.Message{ Prefix: testServerPrefix, Command: irc.ERR_NOMOTD, Params: []string{nick, "No MOTD"}, }) } func newDebugLogger(t *testing.T) *DebugLogger { l := &DebugLogger{ logger: &testingLogger{t}, } l.debug.Store(true) return l } func testBroadcast(t *testing.T, db database.Database) { user := createTestUser(t, db) network, upstream := createTestUpstream(t, db, user) defer upstream.Close() srv := NewServer(db) srv.Logger = newDebugLogger(t) if err := srv.Start(); err != nil { t.Fatalf("failed to start server: %v", err) } defer srv.Shutdown() uc := mustAccept(t, upstream) defer uc.Close() registerUpstreamConn(t, uc) dc := createTestDownstream(t, srv) defer dc.Close() registerDownstreamConn(t, dc, network) noticeText := "This is a very important server notice." uc.WriteMessage(&irc.Message{ Prefix: testServerPrefix, Command: "NOTICE", Params: []string{testUsername, noticeText}, }) var msg *irc.Message for { var err error msg, err = dc.ReadMessage() if err != nil { t.Fatalf("failed to read IRC message: %v", err) } if msg.Command == "NOTICE" { break } } if msg.Params[1] != noticeText { t.Fatalf("invalid NOTICE text: want %q, got: %v", noticeText, msg) } } func TestServer_broadcast(t *testing.T) { t.Run("sqlite", func(t *testing.T) { db := createTempSqliteDB(t) testBroadcast(t, db) }) t.Run("postgres", func(t *testing.T) { db := createTempPostgresDB(t) testBroadcast(t, db) }) } func testChatHistory(t *testing.T, msgStoreDriver, msgStorePath string) { db := createTempSqliteDB(t) user := createTestUser(t, db) network, upstream := createTestUpstream(t, db, user) defer upstream.Close() srv := NewServer(db) srv.Logger = newDebugLogger(t) cfg := *srv.Config() cfg.MsgStoreDriver = msgStoreDriver cfg.MsgStorePath = msgStorePath srv.SetConfig(&cfg) if err := srv.Start(); err != nil { t.Fatalf("failed to start server: %v", err) } defer srv.Shutdown() uc := mustAccept(t, upstream) defer uc.Close() registerUpstreamConn(t, uc) texts := []string{ "Hiya!", "How are you doing?", "Can I take a sip from your glass of soju?", } baseTime := time.Date(2023, 05, 23, 6, 0, 0, 0, time.UTC) for i, text := range texts { msgTime := baseTime.Add(time.Duration(i) * time.Second) uc.WriteMessage(&irc.Message{ Tags: irc.Tags{"time": xirc.FormatServerTime(msgTime)}, Prefix: &irc.Prefix{Name: "foo"}, Command: "PRIVMSG", Params: []string{testUsername, text}, }) } roundtrip(t, uc) dc := createTestDownstream(t, srv) defer dc.Close() registerDownstreamConn(t, dc, network) roundtrip(t, dc) // drain post-connection-registration messages testCases := []struct { Name string After time.Time Texts []string }{ { Name: "all", After: baseTime.Add(-time.Second), Texts: texts, }, { Name: "none", After: baseTime.Add(time.Duration(len(texts)-1) * time.Second), Texts: nil, }, { Name: "all_but_first", After: baseTime, Texts: texts[1:], }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { dc.WriteMessage(&irc.Message{ Command: "CHATHISTORY", Params: []string{"AFTER", "foo", "timestamp=" + xirc.FormatServerTime(tc.After), "100"}, }) var got []string for _, msg := range roundtrip(t, dc) { if msg.Command != "PRIVMSG" { t.Fatalf("unexpected reply: %v", msg) } got = append(got, msg.Params[1]) } if !reflect.DeepEqual(got, tc.Texts) { t.Errorf("got %v, want %v", got, tc.Texts) } }) } } func TestServer_chatHistory(t *testing.T) { t.Run("fs", func(t *testing.T) { testChatHistory(t, "fs", t.TempDir()) }) t.Run("db", func(t *testing.T) { testChatHistory(t, "db", "") }) } ����������������������������������������������������������������������������������soju-0.9.0/service.go�������������������������������������������������������������������������������0000664�0000000�0000000�00000117674�14770724770�0014526�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "context" "crypto/sha1" "crypto/sha256" "crypto/sha512" "encoding/hex" "flag" "fmt" "io/ioutil" "sort" "strconv" "strings" "time" "unicode" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/database" ) const serviceNick = "BouncerServ" const serviceNickCM = "bouncerserv" const serviceRealname = "soju bouncer service" // maxRSABits is the maximum number of RSA key bits used when generating a new // private key. const maxRSABits = 8192 var servicePrefix = &irc.Prefix{ Name: serviceNick, User: serviceNick, Host: serviceNick, } type serviceContext struct { context.Context nick string // optional network *network // optional user *user // optional srv *Server admin bool print func(string) } type serviceCommandSet map[string]*serviceCommand type serviceCommand struct { usage string desc string handle func(ctx *serviceContext, params []string) error children serviceCommandSet admin bool global bool } func sendServiceNOTICE(dc *downstreamConn, text string) { dc.SendMessage(context.TODO(), &irc.Message{ Prefix: servicePrefix, Command: "NOTICE", Params: []string{dc.nick, text}, }) } func sendServicePRIVMSG(dc *downstreamConn, text string) { dc.SendMessage(context.TODO(), &irc.Message{ Prefix: servicePrefix, Command: "PRIVMSG", Params: []string{dc.nick, text}, }) } func splitWords(s string) ([]string, error) { var words []string var lastWord strings.Builder escape := false prev := ' ' wordDelim := ' ' for _, r := range s { if escape { // last char was a backslash, write the byte as-is. lastWord.WriteRune(r) escape = false } else if r == '\\' { escape = true } else if wordDelim == ' ' && unicode.IsSpace(r) { // end of last word if !unicode.IsSpace(prev) { words = append(words, lastWord.String()) lastWord.Reset() } } else if r == wordDelim { // wordDelim is either " or ', switch back to // space-delimited words. wordDelim = ' ' } else if r == '"' || r == '\'' { if wordDelim == ' ' { // start of (double-)quoted word wordDelim = r } else { // either wordDelim is " and r is ' or vice-versa lastWord.WriteRune(r) } } else { lastWord.WriteRune(r) } prev = r } if !unicode.IsSpace(prev) { words = append(words, lastWord.String()) } if wordDelim != ' ' { return nil, fmt.Errorf("unterminated quoted string") } if escape { return nil, fmt.Errorf("unterminated backslash sequence") } return words, nil } func handleServicePRIVMSG(ctx *serviceContext, text string) error { words, err := splitWords(text) if err != nil { return fmt.Errorf(`failed to parse command: %v`, err) } return handleServiceCommand(ctx, words) } func handleServiceCommand(ctx *serviceContext, words []string) error { cmd, params, err := serviceCommands.Get(words) if err != nil { return fmt.Errorf(`%v (type "help" for a list of commands)`, err) } if cmd.admin && !ctx.admin { return fmt.Errorf("you must be an admin to use this command") } if !cmd.global && ctx.user == nil { return fmt.Errorf("this command must be run as a user (try running with user run)") } if cmd.handle == nil { if len(cmd.children) > 0 { var l []string appendServiceCommandSetHelp(cmd.children, words, ctx.admin, ctx.user == nil, &l) ctx.print("available commands: " + strings.Join(l, ", ")) return nil } // Pretend the command does not exist if it has neither children nor handler. // This is obviously a bug but it is better to not die anyway. var logger Logger if ctx.user != nil { logger = ctx.user.logger } else { logger = ctx.srv.Logger } logger.Printf("command without handler and subcommands invoked:", words[0]) return fmt.Errorf("command %q not found", words[0]) } if err := cmd.handle(ctx, params); err == flag.ErrHelp { name := strings.Join(words[:len(words)-len(params)], " ") return fmt.Errorf(`unsupported flag (type "help %v" for a help message)`, name) } else { return err } } func (cmds serviceCommandSet) Get(params []string) (*serviceCommand, []string, error) { if len(params) == 0 { return nil, nil, fmt.Errorf("no command specified") } name := params[0] params = params[1:] cmd, ok := cmds[name] if !ok { for k := range cmds { if !strings.HasPrefix(k, name) { continue } if cmd != nil { return nil, params, fmt.Errorf("command %q is ambiguous", name) } cmd = cmds[k] } } if cmd == nil { return nil, params, fmt.Errorf("command %q not found", name) } if len(params) == 0 || len(cmd.children) == 0 { return cmd, params, nil } return cmd.children.Get(params) } func (cmds serviceCommandSet) Names() []string { l := make([]string, 0, len(cmds)) for name := range cmds { l = append(l, name) } sort.Strings(l) return l } var serviceCommands serviceCommandSet func init() { serviceCommands = serviceCommandSet{ "help": { usage: "[command]", desc: "print help message", handle: handleServiceHelp, global: true, }, "network": { children: serviceCommandSet{ "create": { usage: "-addr <addr> [-name name] [-username username] [-pass pass] [-realname realname] [-certfp fingerprint] [-nick nick] [-auto-away auto-away] [-enabled enabled] [-ignore-limit ignore-limit] [-connect-command command]...", desc: "add a new network", handle: handleServiceNetworkCreate, }, "status": { desc: "show a list of saved networks and their current status", handle: handleServiceNetworkStatus, }, "update": { usage: "[name] [-addr addr] [-name name] [-username username] [-pass pass] [-realname realname] [-certfp fingerprint] [-nick nick] [-auto-away auto-away] [-enabled enabled] [-ignore-limit ignore-limit] [-connect-command command]...", desc: "update a network", handle: handleServiceNetworkUpdate, }, "delete": { usage: "[name]", desc: "delete a network", handle: handleServiceNetworkDelete, }, "quote": { usage: "[name] <command>", desc: "send a raw line to a network", handle: handleServiceNetworkQuote, }, }, }, "certfp": { children: serviceCommandSet{ "generate": { usage: "[-key-type rsa|ecdsa|ed25519] [-bits N] [-network name]", desc: "generate a new self-signed certificate, defaults to using RSA-3072 key", handle: handleServiceCertFPGenerate, }, "fingerprint": { usage: "[-network name]", desc: "show fingerprints of certificate", handle: handleServiceCertFPFingerprints, }, }, }, "sasl": { children: serviceCommandSet{ "status": { usage: "[-network name]", desc: "show SASL status", handle: handleServiceSASLStatus, }, "set-plain": { usage: "[-network name] <username> <password>", desc: "set SASL PLAIN credentials", handle: handleServiceSASLSetPlain, }, "reset": { usage: "[-network name]", desc: "disable SASL authentication and remove stored credentials", handle: handleServiceSASLReset, }, }, }, "user": { children: serviceCommandSet{ "status": { usage: "[username]", desc: "show a list of users and their current status", handle: handleUserStatus, admin: true, global: true, }, "create": { usage: "-username <username> -password <password> [-disable-password] [-admin true|false] [-nick <nick>] [-realname <realname>] [-enabled true|false] [-max-networks <max-networks>]", desc: "create a new soju user", handle: handleUserCreate, admin: true, global: true, }, "update": { usage: "[username] [-password <password>] [-disable-password] [-admin true|false] [-nick <nick>] [-realname <realname>] [-enabled true|false] [-max-networks <max-networks>]", desc: "update a user", handle: handleUserUpdate, global: true, }, "delete": { usage: "<username> [confirmation token]", desc: "delete a user", handle: handleUserDelete, global: true, }, "run": { usage: "<username> <command>", desc: "run a command as another user", handle: handleUserRun, admin: true, global: true, }, }, global: true, }, "channel": { children: serviceCommandSet{ "status": { usage: "[-network name]", desc: "show a list of saved channels and their current status", handle: handleServiceChannelStatus, }, "create": { usage: "<name> [-detached true|false] [-relay-detached default|none|highlight|message] [-reattach-on default|none|highlight|message] [-detach-after <duration>] [-detach-on default|none|highlight|message]", desc: "create a channel", handle: handleServiceChannelCreate, }, "update": { usage: "<name> [-detached true|false] [-relay-detached default|none|highlight|message] [-reattach-on default|none|highlight|message] [-detach-after <duration>] [-detach-on default|none|highlight|message]", desc: "update a channel", handle: handleServiceChannelUpdate, }, "delete": { usage: "<name>", desc: "delete a channel", handle: handleServiceChannelDelete, }, }, }, "server": { children: serviceCommandSet{ "status": { desc: "show server statistics", handle: handleServiceServerStatus, admin: true, global: true, }, "notice": { usage: "<notice>", desc: "broadcast a notice to all connected bouncer users", handle: handleServiceServerNotice, admin: true, global: true, }, "debug": { usage: "true|false", desc: "enable/disable debug logging to stderr (will leak sensitive information)", handle: handleServiceServerDebug, admin: true, global: true, }, }, admin: true, }, } } func appendServiceCommandSetHelp(cmds serviceCommandSet, prefix []string, admin bool, global bool, l *[]string) { for _, name := range cmds.Names() { cmd := cmds[name] if cmd.admin && !admin { continue } if !cmd.global && global { continue } words := append(prefix, name) if len(cmd.children) == 0 { s := strings.Join(words, " ") *l = append(*l, s) } else { appendServiceCommandSetHelp(cmd.children, words, admin, global, l) } } } func handleServiceHelp(ctx *serviceContext, params []string) error { if len(params) > 0 { cmd, rest, err := serviceCommands.Get(params) if err != nil { return err } words := params[:len(params)-len(rest)] if len(cmd.children) > 0 { var l []string appendServiceCommandSetHelp(cmd.children, words, ctx.admin, ctx.user == nil, &l) ctx.print("available commands: " + strings.Join(l, ", ")) } else { text := strings.Join(words, " ") if cmd.usage != "" { text += " " + cmd.usage } text += ": " + cmd.desc ctx.print(text) } } else { var l []string appendServiceCommandSetHelp(serviceCommands, nil, ctx.admin, ctx.user == nil, &l) ctx.print("available commands: " + strings.Join(l, ", ")) } return nil } func newFlagSet() *flag.FlagSet { fs := flag.NewFlagSet("", flag.ContinueOnError) fs.SetOutput(ioutil.Discard) return fs } type stringSliceFlag []string func (v *stringSliceFlag) String() string { return fmt.Sprint([]string(*v)) } func (v *stringSliceFlag) Set(s string) error { *v = append(*v, s) return nil } // stringPtrFlag is a flag value populating a string pointer. This allows to // disambiguate between a flag that hasn't been set and a flag that has been // set to an empty string. type stringPtrFlag struct { ptr **string } func (f stringPtrFlag) String() string { if f.ptr == nil || *f.ptr == nil { return "" } return **f.ptr } func (f stringPtrFlag) Set(s string) error { *f.ptr = &s return nil } type boolPtrFlag struct { ptr **bool } func (f boolPtrFlag) String() string { if f.ptr == nil || *f.ptr == nil { return "<nil>" } return strconv.FormatBool(**f.ptr) } func (f boolPtrFlag) Set(s string) error { v, err := strconv.ParseBool(s) if err != nil { return err } *f.ptr = &v return nil } type intPtrFlag struct { ptr **int } func (f intPtrFlag) String() string { if f.ptr == nil || *f.ptr == nil { return "<nil>" } return strconv.Itoa(**f.ptr) } func (f intPtrFlag) Set(s string) error { v, err := strconv.Atoi(s) if err != nil { return err } *f.ptr = &v return nil } func getNetworkFromArg(ctx *serviceContext, params []string) (*network, []string, error) { name, params := popArg(params) if name == "" { if ctx.network == nil { return nil, params, fmt.Errorf("no network selected, a name argument is required") } return ctx.network, params, nil } else { net := ctx.user.getNetwork(name) if net == nil { return nil, params, fmt.Errorf("unknown network %q", name) } return net, params, nil } } type networkFlagSet struct { *flag.FlagSet Addr, Name, Nick, Username, Pass, Realname, CertFP *string AutoAway, Enabled *bool IgnoreLimit bool ConnectCommands []string } func newNetworkFlagSet() *networkFlagSet { fs := &networkFlagSet{FlagSet: newFlagSet()} fs.Var(stringPtrFlag{&fs.Addr}, "addr", "") fs.Var(stringPtrFlag{&fs.Name}, "name", "") fs.Var(stringPtrFlag{&fs.Nick}, "nick", "") fs.Var(stringPtrFlag{&fs.Username}, "username", "") fs.Var(stringPtrFlag{&fs.Pass}, "pass", "") fs.Var(stringPtrFlag{&fs.Realname}, "realname", "") fs.Var(stringPtrFlag{&fs.CertFP}, "certfp", "") fs.Var(boolPtrFlag{&fs.AutoAway}, "auto-away", "") fs.Var(boolPtrFlag{&fs.Enabled}, "enabled", "") fs.BoolVar(&fs.IgnoreLimit, "ignore-limit", false, "") fs.Var((*stringSliceFlag)(&fs.ConnectCommands), "connect-command", "") return fs } func (fs *networkFlagSet) update(network *database.Network) error { if fs.Addr != nil { if addrParts := strings.SplitN(*fs.Addr, "://", 2); len(addrParts) == 2 { scheme := addrParts[0] switch scheme { case "ircs", "irc+insecure", "unix": default: return fmt.Errorf("unknown scheme %q (supported schemes: ircs, irc+insecure, unix)", scheme) } } network.Addr = *fs.Addr } if fs.Name != nil { if *fs.Name == "*" { return fmt.Errorf("the network name %q is reserved", *fs.Name) } network.Name = *fs.Name } if fs.Nick != nil { network.Nick = *fs.Nick } if fs.Username != nil { network.Username = *fs.Username } if fs.Pass != nil { network.Pass = *fs.Pass } if fs.Realname != nil { network.Realname = *fs.Realname } if fs.CertFP != nil { certFP := strings.ToLower(strings.ReplaceAll(*fs.CertFP, ":", "")) if _, err := hex.DecodeString(certFP); err != nil { return fmt.Errorf("the certificate fingerprint must be hex-encoded") } if len(certFP) == 0 { network.CertFP = "" } else if len(certFP) == 64 { network.CertFP = "sha-256:" + certFP } else if len(certFP) == 128 { network.CertFP = "sha-512:" + certFP } else { return fmt.Errorf("the certificate fingerprint must be a SHA256 or SHA512 hash") } } if fs.AutoAway != nil { network.AutoAway = *fs.AutoAway } if fs.Enabled != nil { network.Enabled = *fs.Enabled } if fs.ConnectCommands != nil { if len(fs.ConnectCommands) == 1 && fs.ConnectCommands[0] == "" { network.ConnectCommands = nil } else { if len(fs.ConnectCommands) > 20 { return fmt.Errorf("too many -connect-command flags supplied") } for _, command := range fs.ConnectCommands { _, err := irc.ParseMessage(command) if err != nil { return fmt.Errorf("flag -connect-command must be a valid raw irc command string: %q: %v", command, err) } } network.ConnectCommands = fs.ConnectCommands } } return nil } func handleServiceNetworkCreate(ctx *serviceContext, params []string) error { fs := newNetworkFlagSet() if err := fs.Parse(params); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } if fs.Addr == nil { return fmt.Errorf("flag -addr is required") } if fs.IgnoreLimit && !ctx.admin { return fmt.Errorf("you must be an admin to use the flag -ignore-limit") } record := database.NewNetwork(*fs.Addr) if err := fs.update(record); err != nil { return err } network, err := ctx.user.createNetwork(ctx, record, !fs.IgnoreLimit) if err != nil { return fmt.Errorf("could not create network: %v", err) } ctx.print(fmt.Sprintf("created network %q", network.GetName())) return nil } func handleServiceNetworkStatus(ctx *serviceContext, params []string) error { if len(params) != 0 { return fmt.Errorf("expected no argument") } n := 0 for _, net := range ctx.user.networks { var statuses []string var details string if uc := net.conn; uc != nil { if ctx.nick != "" && ctx.nick != uc.nick { statuses = append(statuses, "connected as "+uc.nick) } else { statuses = append(statuses, "connected") } details = fmt.Sprintf("%v channels", uc.channels.Len()) } else if !net.Enabled { statuses = append(statuses, "disabled") } else { statuses = append(statuses, "disconnected") if net.lastError != nil { details = net.lastError.Error() } } if net == ctx.network { statuses = append(statuses, "current") } name := net.GetName() if name != net.Addr { name = fmt.Sprintf("%v (%v)", name, net.Addr) } s := fmt.Sprintf("%v [%v]", name, strings.Join(statuses, ", ")) if details != "" { s += ": " + details } ctx.print(s) n++ } if n == 0 { ctx.print(`No network configured, add one with "network create".`) } return nil } func handleServiceNetworkUpdate(ctx *serviceContext, params []string) error { net, params, err := getNetworkFromArg(ctx, params) if err != nil { return err } fs := newNetworkFlagSet() if err := fs.Parse(params); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } if fs.IgnoreLimit && !ctx.admin { return fmt.Errorf("you must be an admin to use the flag -ignore-limit") } record := net.Network // copy network record because we'll mutate it wasEnabled := record.Enabled if err := fs.update(&record); err != nil { return err } network, err := ctx.user.updateNetwork(ctx, &record, !fs.IgnoreLimit && !wasEnabled) if err != nil { return fmt.Errorf("could not update network: %v", err) } ctx.print(fmt.Sprintf("updated network %q", network.GetName())) return nil } func handleServiceNetworkDelete(ctx *serviceContext, params []string) error { if len(params) != 1 { return fmt.Errorf("expected exactly one argument") } net, params, err := getNetworkFromArg(ctx, params) if err != nil { return err } if err := ctx.user.deleteNetwork(ctx, net.ID); err != nil { return err } ctx.print(fmt.Sprintf("deleted network %q", net.GetName())) return nil } func handleServiceNetworkQuote(ctx *serviceContext, params []string) error { if len(params) != 1 && len(params) != 2 { return fmt.Errorf("expected one or two arguments") } raw := params[len(params)-1] params = params[:len(params)-1] net, params, err := getNetworkFromArg(ctx, params) if err != nil { return err } uc := net.conn if uc == nil { return fmt.Errorf("network %q is not currently connected", net.GetName()) } m, err := irc.ParseMessage(raw) if err != nil { return fmt.Errorf("failed to parse command %q: %v", raw, err) } uc.SendMessage(ctx, m) ctx.print(fmt.Sprintf("sent command to %q", net.GetName())) return nil } func sendCertfpFingerprints(ctx *serviceContext, cert []byte) { sha1Sum := sha1.Sum(cert) ctx.print("SHA-1 fingerprint: " + hex.EncodeToString(sha1Sum[:])) sha256Sum := sha256.Sum256(cert) ctx.print("SHA-256 fingerprint: " + hex.EncodeToString(sha256Sum[:])) sha512Sum := sha512.Sum512(cert) ctx.print("SHA-512 fingerprint: " + hex.EncodeToString(sha512Sum[:])) } func getNetworkFromFlag(ctx *serviceContext, name string) (*network, error) { if name == "" { if ctx.network == nil { return nil, fmt.Errorf("no network selected, -network is required") } return ctx.network, nil } else { net := ctx.user.getNetwork(name) if net == nil { return nil, fmt.Errorf("unknown network %q", name) } return net, nil } } func handleServiceCertFPGenerate(ctx *serviceContext, params []string) error { fs := newFlagSet() netName := fs.String("network", "", "select a network") keyType := fs.String("key-type", "rsa", "key type to generate (rsa, ecdsa, ed25519)") bits := fs.Int("bits", 3072, "size of key to generate, meaningful only for RSA") if err := fs.Parse(params); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } if *bits <= 0 || *bits > maxRSABits { return fmt.Errorf("invalid value for -bits") } net, err := getNetworkFromFlag(ctx, *netName) if err != nil { return err } privKey, cert, err := generateCertFP(*keyType, *bits) if err != nil { return err } net.SASL.External.CertBlob = cert net.SASL.External.PrivKeyBlob = privKey net.SASL.Mechanism = "EXTERNAL" if err := ctx.srv.db.StoreNetwork(ctx, ctx.user.ID, &net.Network); err != nil { return err } ctx.print("certificate generated") sendCertfpFingerprints(ctx, cert) return nil } func handleServiceCertFPFingerprints(ctx *serviceContext, params []string) error { fs := newFlagSet() netName := fs.String("network", "", "select a network") if err := fs.Parse(params); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } net, err := getNetworkFromFlag(ctx, *netName) if err != nil { return err } if net.SASL.Mechanism != "EXTERNAL" { return fmt.Errorf("CertFP not set up") } sendCertfpFingerprints(ctx, net.SASL.External.CertBlob) return nil } func handleServiceSASLStatus(ctx *serviceContext, params []string) error { fs := newFlagSet() netName := fs.String("network", "", "select a network") if err := fs.Parse(params); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } net, err := getNetworkFromFlag(ctx, *netName) if err != nil { return err } switch net.SASL.Mechanism { case "PLAIN": ctx.print(fmt.Sprintf("SASL PLAIN enabled with username %q", net.SASL.Plain.Username)) case "EXTERNAL": ctx.print("SASL EXTERNAL (CertFP) enabled") case "": ctx.print("SASL is disabled") } if uc := net.conn; uc != nil { if uc.account != "" { ctx.print(fmt.Sprintf("Authenticated on upstream network with account %q", uc.account)) } else { ctx.print("Unauthenticated on upstream network") } } else { ctx.print("Disconnected from upstream network") } return nil } func handleServiceSASLSetPlain(ctx *serviceContext, params []string) error { fs := newFlagSet() netName := fs.String("network", "", "select a network") if err := fs.Parse(params); err != nil { return err } if fs.NArg() != 2 { return fmt.Errorf("expected exactly 2 arguments") } net, err := getNetworkFromFlag(ctx, *netName) if err != nil { return err } net.SASL.Plain.Username = fs.Arg(0) net.SASL.Plain.Password = fs.Arg(1) net.SASL.Mechanism = "PLAIN" if err := ctx.srv.db.StoreNetwork(ctx, ctx.user.ID, &net.Network); err != nil { return err } ctx.print("credentials saved") return nil } func handleServiceSASLReset(ctx *serviceContext, params []string) error { fs := newFlagSet() netName := fs.String("network", "", "select a network") if err := fs.Parse(params); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } net, err := getNetworkFromFlag(ctx, *netName) if err != nil { return err } net.SASL.Plain.Username = "" net.SASL.Plain.Password = "" net.SASL.External.CertBlob = nil net.SASL.External.PrivKeyBlob = nil net.SASL.Mechanism = "" if err := ctx.srv.db.StoreNetwork(ctx, ctx.user.ID, &net.Network); err != nil { return err } ctx.print("credentials reset") return nil } func handleUserStatus(ctx *serviceContext, params []string) error { if len(params) > 1 { return fmt.Errorf("expected 0 or 1 argument") } // Limit to a small amount of users to avoid sending // thousands of messages on large instances. users := make([]database.User, 0, 50) var n int if len(params) == 0 { ctx.srv.lock.Lock() n = len(ctx.srv.users) for _, user := range ctx.srv.users { if len(users) == cap(users) { break } users = append(users, user.User) } ctx.srv.lock.Unlock() } else { username := params[0] u := ctx.srv.getUser(username) if u == nil { return fmt.Errorf("unknown username %q", username) } users = append(users, u.User) n = 1 } for _, user := range users { var attrs []string if user.Admin { attrs = append(attrs, "admin") } if !user.Enabled { attrs = append(attrs, "disabled") } line := user.Username if len(attrs) > 0 { line += " (" + strings.Join(attrs, ", ") + ")" } networks, err := ctx.srv.db.ListNetworks(ctx, user.ID) if err != nil { return fmt.Errorf("could not get networks of user %q: %v", user.Username, err) } line += fmt.Sprintf(": %d networks", len(networks)) if user.MaxNetworks >= 0 { line += fmt.Sprintf(" (%d max)", user.MaxNetworks) } ctx.print(line) } if n > len(users) { ctx.print(fmt.Sprintf("(%d more users omitted)", n-len(users))) } return nil } func handleUserCreate(ctx *serviceContext, params []string) error { fs := newFlagSet() username := fs.String("username", "", "") password := fs.String("password", "", "") disablePassword := fs.Bool("disable-password", false, "") nick := fs.String("nick", "", "") realname := fs.String("realname", "", "") admin := fs.Bool("admin", false, "") enabled := fs.Bool("enabled", true, "") maxNetworks := fs.Int("max-networks", -1, "") if err := fs.Parse(params); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } if *username == "" { return fmt.Errorf("flag -username is required") } if *password != "" && *disablePassword { return fmt.Errorf("flags -password and -disable-password are mutually exclusive") } if *password == "" && !*disablePassword { return fmt.Errorf("flag -password is required") } user := database.NewUser(*username) user.Nick = *nick user.Realname = *realname user.Admin = *admin user.Enabled = *enabled user.MaxNetworks = *maxNetworks if !*disablePassword { if err := user.SetPassword(*password); err != nil { return err } } if _, err := ctx.srv.createUser(ctx, user); err != nil { return fmt.Errorf("could not create user: %v", err) } ctx.print(fmt.Sprintf("created user %q", *username)) return nil } func popArg(params []string) (string, []string) { if len(params) > 0 && !strings.HasPrefix(params[0], "-") { return params[0], params[1:] } return "", params } func handleUserUpdate(ctx *serviceContext, params []string) error { var password, nick, realname *string var admin, enabled *bool var disablePassword bool var maxNetworks *int fs := newFlagSet() fs.Var(stringPtrFlag{&password}, "password", "") fs.BoolVar(&disablePassword, "disable-password", false, "") fs.Var(stringPtrFlag{&nick}, "nick", "") fs.Var(stringPtrFlag{&realname}, "realname", "") fs.Var(boolPtrFlag{&admin}, "admin", "") fs.Var(boolPtrFlag{&enabled}, "enabled", "") fs.Var(intPtrFlag{&maxNetworks}, "max-networks", "") username, params := popArg(params) if err := fs.Parse(params); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } if username == "" && ctx.user == nil { return fmt.Errorf("cannot determine the user to update") } if password != nil && disablePassword { return fmt.Errorf("flags -password and -disable-password are mutually exclusive") } if username != "" && (ctx.user == nil || username != ctx.user.Username) { if !ctx.admin { return fmt.Errorf("you must be an admin to update other users") } if nick != nil { return fmt.Errorf("cannot update -nick of other user") } if realname != nil { return fmt.Errorf("cannot update -realname of other user") } var hashed *string if password != nil { var passwordRecord database.User if err := passwordRecord.SetPassword(*password); err != nil { return err } hashed = &passwordRecord.Password } if disablePassword { hashedStr := "" hashed = &hashedStr } u := ctx.srv.getUser(username) if u == nil { return fmt.Errorf("unknown username %q", username) } done := make(chan error, 1) event := eventUserUpdate{ password: hashed, admin: admin, enabled: enabled, maxNetworks: maxNetworks, done: done, } select { case <-ctx.Done(): return ctx.Err() case u.events <- event: } // TODO: send context to the other side if err := <-done; err != nil { return err } ctx.print(fmt.Sprintf("updated user %q", username)) } else { if admin != nil { return fmt.Errorf("cannot update -admin of own user") } if enabled != nil { return fmt.Errorf("cannot update -enabled of own user") } if maxNetworks != nil && !ctx.admin { return fmt.Errorf("cannot update -max-networks of own user") } err := ctx.user.updateUser(ctx, func(record *database.User) error { if password != nil { if err := record.SetPassword(*password); err != nil { return err } } if disablePassword { record.Password = "" } if nick != nil { record.Nick = *nick } if realname != nil { record.Realname = *realname } if maxNetworks != nil { record.MaxNetworks = *maxNetworks } return nil }) if err != nil { return err } ctx.print(fmt.Sprintf("updated user %q", ctx.user.Username)) } return nil } func handleUserDelete(ctx *serviceContext, params []string) error { if len(params) != 1 && len(params) != 2 { return fmt.Errorf("expected one or two arguments") } username := params[0] hashBytes := sha1.Sum([]byte(username)) hash := fmt.Sprintf("%x", hashBytes[0:3]) self := ctx.user != nil && ctx.user.Username == username if !ctx.admin && !self { return fmt.Errorf("only admins may delete other users") } u := ctx.srv.getUser(username) if u == nil { return fmt.Errorf("unknown username %q", username) } if len(params) < 2 { ctx.print(fmt.Sprintf(`To confirm user deletion, send "user delete %s %s"`, username, hash)) return nil } if token := params[1]; token != hash { return fmt.Errorf("provided confirmation token doesn't match user") } var deleteCtx context.Context = ctx if self { ctx.print(fmt.Sprintf("Goodbye %s, deleting your account. There will be no further confirmation.", username)) // We can't use ctx here, because it'll be cancelled once we close the // downstream connection deleteCtx = context.TODO() } if err := u.stop(deleteCtx); err != nil { return fmt.Errorf("failed to stop user: %v", err) } if err := ctx.srv.db.DeleteUser(deleteCtx, u.ID); err != nil { return fmt.Errorf("failed to delete user: %v", err) } if !self { ctx.print(fmt.Sprintf("deleted user %q", username)) } return nil } type userRunMsg struct { message string err error } func handleUserRun(ctx *serviceContext, params []string) error { if len(params) < 2 { return fmt.Errorf("expected at least two arguments") } username := params[0] params = params[1:] if ctx.user != nil && username == ctx.user.Username { return handleServiceCommand(ctx, params) } u := ctx.srv.getUser(username) if u == nil { return fmt.Errorf("unknown username %q", username) } msgCh := make(chan userRunMsg, 1) ev := eventUserRun{ params: params, ch: msgCh, } select { case <-ctx.Done(): return ctx.Err() case u.events <- ev: } for { select { case <-ctx.Done(): // This handles a possible race condition: // - we send ev to u.events // - the user goroutine for u stops (because of a crash or user deletion) // - we would block on printCh // Quitting on ctx.Done() prevents us from blocking indefinitely // in case the event is never processed. // TODO: Properly fix this condition by flushing the u.events queue // and running close(ev.print) in a defer return fmt.Errorf("timeout executing command") case msg := <-msgCh: if msg.message != "" { ctx.print(msg.message) } else { return msg.err } } } } func handleServiceChannelStatus(ctx *serviceContext, params []string) error { var defaultNetworkName string if ctx.network != nil { defaultNetworkName = ctx.network.GetName() } fs := newFlagSet() networkName := fs.String("network", defaultNetworkName, "") if err := fs.Parse(params); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } n := 0 sendNetwork := func(net *network) { var channels []*database.Channel net.channels.ForEach(func(_ string, ch *database.Channel) { channels = append(channels, ch) }) sort.Slice(channels, func(i, j int) bool { return strings.ReplaceAll(channels[i].Name, "#", "") < strings.ReplaceAll(channels[j].Name, "#", "") }) for _, ch := range channels { var uch *upstreamChannel if net.conn != nil { uch = net.conn.channels.Get(ch.Name) } name := ch.Name if *networkName == "" { name += "/" + net.GetName() } var status string if uch != nil { status = "joined" } else if net.conn != nil { status = "parted" } else { status = "disconnected" } if ch.Detached { status += ", detached" } s := fmt.Sprintf("%v [%v]", name, status) ctx.print(s) n++ } } if *networkName == "" { for _, net := range ctx.user.networks { sendNetwork(net) } } else { net := ctx.user.getNetwork(*networkName) if net == nil { return fmt.Errorf("unknown network %q", *networkName) } sendNetwork(net) } if n == 0 { ctx.print("No channel configured.") } return nil } func parseFilter(filter string) (database.MessageFilter, error) { switch filter { case "default": return database.FilterDefault, nil case "none": return database.FilterNone, nil case "highlight": return database.FilterHighlight, nil case "message": return database.FilterMessage, nil } return 0, fmt.Errorf("unknown filter: %q", filter) } type channelFlagSet struct { *flag.FlagSet Detached *bool RelayDetached, ReattachOn, DetachAfter, DetachOn *string } func newChannelFlagSet() *channelFlagSet { fs := &channelFlagSet{FlagSet: newFlagSet()} fs.Var(boolPtrFlag{&fs.Detached}, "detached", "") fs.Var(stringPtrFlag{&fs.RelayDetached}, "relay-detached", "") fs.Var(stringPtrFlag{&fs.ReattachOn}, "reattach-on", "") fs.Var(stringPtrFlag{&fs.DetachAfter}, "detach-after", "") fs.Var(stringPtrFlag{&fs.DetachOn}, "detach-on", "") return fs } func (fs *channelFlagSet) update(channel *database.Channel) error { if fs.RelayDetached != nil { filter, err := parseFilter(*fs.RelayDetached) if err != nil { return err } channel.RelayDetached = filter } if fs.ReattachOn != nil { filter, err := parseFilter(*fs.ReattachOn) if err != nil { return err } channel.ReattachOn = filter } if fs.DetachAfter != nil { dur, err := time.ParseDuration(*fs.DetachAfter) if err != nil || dur < 0 { return fmt.Errorf("unknown duration for -detach-after %q (duration format: 0, 300s, 22h30m, ...)", *fs.DetachAfter) } channel.DetachAfter = dur } if fs.DetachOn != nil { filter, err := parseFilter(*fs.DetachOn) if err != nil { return err } channel.DetachOn = filter } return nil } func stripNetworkSuffix(ctx *serviceContext, name string) (string, *network, error) { if ctx.network != nil { return name, ctx.network, nil } l := strings.SplitN(name, "/", 2) if len(l) != 2 { return "", nil, fmt.Errorf("missing network name") } name = l[0] netName := l[1] for _, network := range ctx.user.networks { if netName == network.GetName() { return name, network, nil } } return "", nil, fmt.Errorf("unknown network %q", netName) } func handleServiceChannelCreate(ctx *serviceContext, params []string) error { if len(params) < 1 { return fmt.Errorf("expected at least one argument") } name := params[0] fs := newChannelFlagSet() if err := fs.Parse(params[1:]); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } name, network, err := stripNetworkSuffix(ctx, name) if err != nil { return err } if name == "" || strings.ContainsAny(name, illegalChanChars) { return fmt.Errorf("invalid channel name: %v", name) } if network.conn != nil && !network.conn.isChannel(name) { return fmt.Errorf("not a channel name: %v", name) } if network.channels.Get(name) != nil { return fmt.Errorf("channel %q already exists", name) } ch := database.Channel{ Name: name, } if err := fs.update(&ch); err != nil { return err } network.channels.Set(ch.Name, &ch) if err := ctx.srv.db.StoreChannel(ctx, network.ID, &ch); err != nil { return fmt.Errorf("failed to create channel: %v", err) } if network.conn != nil { network.conn.SendMessage(ctx, &irc.Message{ Command: "JOIN", Params: []string{ch.Name}, }) } ctx.print(fmt.Sprintf("created channel %q", name)) return nil } func handleServiceChannelUpdate(ctx *serviceContext, params []string) error { if len(params) < 1 { return fmt.Errorf("expected at least one argument") } name := params[0] fs := newChannelFlagSet() if err := fs.Parse(params[1:]); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("unexpected argument: %v", fs.Arg(0)) } name, network, err := stripNetworkSuffix(ctx, name) if err != nil { return err } ch := network.channels.Get(name) if ch == nil { return fmt.Errorf("unknown channel %q", name) } if err := fs.update(ch); err != nil { return err } if fs.Detached != nil && *fs.Detached != ch.Detached { if *fs.Detached { network.detach(ch) } else { network.attach(ctx, ch) } } if network.conn != nil { network.conn.updateChannelAutoDetach(name) } if err := ctx.srv.db.StoreChannel(ctx, network.ID, ch); err != nil { return fmt.Errorf("failed to update channel: %v", err) } ctx.print(fmt.Sprintf("updated channel %q", name)) return nil } func handleServiceChannelDelete(ctx *serviceContext, params []string) error { if len(params) != 1 { return fmt.Errorf("expected exactly one argument") } name := params[0] name, network, err := stripNetworkSuffix(ctx, name) if err != nil { return err } if err := network.deleteChannel(ctx, name); err != nil { return fmt.Errorf("failed to delete channel: %v", err) } if uc := network.conn; uc != nil && uc.channels.Has(name) { uc.SendMessage(ctx, &irc.Message{ Command: "PART", Params: []string{name}, }) } ctx.print(fmt.Sprintf("deleted channel %q", name)) return nil } func handleServiceServerStatus(ctx *serviceContext, params []string) error { if len(params) != 0 { return fmt.Errorf("expected no argument") } dbStats, err := ctx.srv.db.Stats(ctx) if err != nil { return err } serverStats := ctx.srv.Stats() ctx.print(fmt.Sprintf("%v/%v users, %v downstreams, %v upstreams, %v networks, %v channels", serverStats.Users, dbStats.Users, serverStats.Downstreams, serverStats.Upstreams, dbStats.Networks, dbStats.Channels)) return nil } func handleServiceServerNotice(ctx *serviceContext, params []string) error { if len(params) != 1 { return fmt.Errorf("expected exactly one argument") } text := params[0] var logger Logger if ctx.user != nil { logger = ctx.user.logger } else { logger = ctx.srv.Logger } logger.Printf("broadcasting bouncer-wide NOTICE: %v", text) broadcastMsg := &irc.Message{ Prefix: servicePrefix, Command: "NOTICE", Params: []string{"$" + ctx.srv.Config().Hostname, text}, } var err error sent := 0 total := 0 ctx.srv.forEachUser(func(u *user) { total++ select { case <-ctx.Done(): err = ctx.Err() case u.events <- eventBroadcast{broadcastMsg}: sent++ } }) logger.Printf("broadcast bouncer-wide NOTICE to %v/%v downstreams", sent, total) ctx.print(fmt.Sprintf("sent to %v/%v downstream connections", sent, total)) return err } func handleServiceServerDebug(ctx *serviceContext, params []string) error { if len(params) != 1 { return fmt.Errorf("expected exactly one argument") } enabled, err := strconv.ParseBool(params[0]) if err != nil { return err } previous := ctx.srv.Logger.debug.Swap(enabled) if previous != enabled { if enabled { ctx.srv.Logger.Printf("enabling debug logging") } else { ctx.srv.Logger.Printf("disabling debug logging") } } return nil } ��������������������������������������������������������������������soju-0.9.0/service_test.go��������������������������������������������������������������������������0000664�0000000�0000000�00000002443�14770724770�0015550�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "testing" ) func assertSplit(t *testing.T, input string, expected []string) { actual, err := splitWords(input) if err != nil { t.Errorf("%q: %v", input, err) return } if len(actual) != len(expected) { t.Errorf("%q: expected %d words, got %d\nexpected: %v\ngot: %v", input, len(expected), len(actual), expected, actual) return } for i := 0; i < len(actual); i++ { if actual[i] != expected[i] { t.Errorf("%q: expected word #%d to be %q, got %q\nexpected: %v\ngot: %v", input, i, expected[i], actual[i], expected, actual) } } } func TestSplit(t *testing.T) { assertSplit(t, " ch 'up' #soju 'relay'-det\"ache\"d message ", []string{ "ch", "up", "#soju", "relay-detached", "message", }) assertSplit(t, "net update \\\"free\\\"node -pass 'political \"stance\" desu!' -realname '' -nick lee", []string{ "net", "update", "\"free\"node", "-pass", "political \"stance\" desu!", "-realname", "", "-nick", "lee", }) assertSplit(t, "Omedeto,\\ Yui! ''", []string{ "Omedeto, Yui!", "", }) if _, err := splitWords("end of 'file"); err == nil { t.Errorf("expected error on unterminated single quote") } if _, err := splitWords("end of backquote \\"); err == nil { t.Errorf("expected error on unterminated backquote sequence") } } �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/upstream.go������������������������������������������������������������������������������0000664�0000000�0000000�00000176620�14770724770�0014722�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "context" "crypto" "crypto/sha256" "crypto/sha512" "crypto/tls" "crypto/x509" "encoding/base64" "encoding/hex" "errors" "fmt" "io" "math/rand" "net" "strconv" "strings" "time" "github.com/emersion/go-sasl" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/database" "codeberg.org/emersion/soju/xirc" ) // permanentUpstreamCaps is the static list of upstream capabilities always // requested when supported. var permanentUpstreamCaps = map[string]bool{ "account-notify": true, "account-tag": true, "away-notify": true, "batch": true, "chghost": true, "extended-join": true, "extended-monitor": true, "invite-notify": true, "labeled-response": true, "message-tags": true, "multi-prefix": true, "sasl": true, "server-time": true, "setname": true, "draft/account-registration": true, "draft/extended-monitor": true, } // storableMessageTags is the static list of message tags that will cause // a TAGMSG to be stored. var storableMessageTags = map[string]bool{ "+draft/react": true, "+react": true, } type registrationError struct { *irc.Message } func (err registrationError) Error() string { return fmt.Sprintf("registration error (%v): %v", err.Command, err.Reason()) } func (err registrationError) Reason() string { if len(err.Params) > 0 { return err.Params[len(err.Params)-1] } return err.Command } func (err registrationError) Temporary() bool { // Only return false if we're 100% sure that fixing the error requires a // network configuration change switch err.Command { case irc.ERR_PASSWDMISMATCH, irc.ERR_ERRONEUSNICKNAME: return false case "FAIL": return err.Params[1] != "ACCOUNT_REQUIRED" default: return true } } type upstreamChannel struct { Name string conn *upstreamConn Topic string TopicWho *irc.Prefix TopicTime time.Time Status xirc.ChannelStatus modes channelModes creationTime string Members xirc.CaseMappingMap[*xirc.MembershipSet] complete bool detachTimer *time.Timer } func (uc *upstreamChannel) updateAutoDetach(dur time.Duration) { if uc.detachTimer != nil { uc.detachTimer.Stop() uc.detachTimer = nil } if dur == 0 { return } uc.detachTimer = time.AfterFunc(dur, func() { uc.conn.network.user.events <- eventChannelDetach{ uc: uc.conn, name: uc.Name, } }) } type upstreamBatch struct { Type string Params []string Outer *upstreamBatch // if not-nil, this batch is nested in Outer Label string } type upstreamUser struct { Nickname string Username string Hostname string Server string Flags string Account string Realname string } func (uu *upstreamUser) hasWHOXFields(fields string) bool { for i := 0; i < len(fields); i++ { ok := false switch fields[i] { case 'n': ok = uu.Nickname != "" case 'u': ok = uu.Username != "" case 'h': ok = uu.Hostname != "" case 's': ok = uu.Server != "" case 'f': ok = uu.Flags != "" case 'a': ok = uu.Account != "" case 'r': ok = uu.Realname != "" case 't', 'c', 'i', 'd', 'l', 'o': // we return static values for those fields, so they are always available ok = true } if !ok { return false } } return true } func (uu *upstreamUser) updateFrom(update *upstreamUser) { if update.Nickname != "" { uu.Nickname = update.Nickname } if update.Username != "" { uu.Username = update.Username } if update.Hostname != "" { uu.Hostname = update.Hostname } if update.Server != "" { uu.Server = update.Server } if update.Flags != "" { uu.Flags = update.Flags } if update.Account != "" { uu.Account = update.Account } if update.Realname != "" { uu.Realname = update.Realname } } type pendingUpstreamCommand struct { downstreamID uint64 msg *irc.Message sentAt time.Time } type upstreamConn struct { *conn network *network user *user serverPrefix *irc.Prefix serverName string availableUserModes string availableChannelModes map[byte]channelModeType availableChannelTypes string availableStatusMsg string availableMemberships []xirc.Membership isupport map[string]*string registered bool nick string username string realname string hostname string modes userModes channels xirc.CaseMappingMap[*upstreamChannel] users xirc.CaseMappingMap[*upstreamUser] caps xirc.CapRegistry batches map[string]upstreamBatch away bool account string nextLabelID uint64 monitored xirc.CaseMappingMap[bool] saslClient sasl.Client saslStarted bool // Queue of commands in progress, indexed by type. The first entry has been // sent to the server and is awaiting reply. The following entries have not // been sent yet. pendingCmds map[string][]pendingUpstreamCommand pendingRegainNick string regainNickTimer *time.Timer regainNickBackoff *backoffer gotMotd bool hasDesiredNick bool } func connectToUpstream(ctx context.Context, network *network) (*upstreamConn, error) { logger := &prefixLogger{network.user.logger, fmt.Sprintf("upstream %q: ", network.GetName())} ctx, cancel := context.WithTimeout(ctx, connectTimeout) defer cancel() u, err := network.URL() if err != nil { return nil, err } var netConn net.Conn switch u.Scheme { case "ircs": addr := u.Host host, _, err := net.SplitHostPort(u.Host) if err != nil { host = u.Host addr = u.Host + ":6697" } tlsConfig := &tls.Config{ServerName: host, NextProtos: []string{"irc"}} if network.SASL.Mechanism == "EXTERNAL" { if network.SASL.External.CertBlob == nil { return nil, fmt.Errorf("missing certificate for authentication") } if network.SASL.External.PrivKeyBlob == nil { return nil, fmt.Errorf("missing private key for authentication") } key, err := x509.ParsePKCS8PrivateKey(network.SASL.External.PrivKeyBlob) if err != nil { return nil, fmt.Errorf("failed to parse private key: %v", err) } tlsConfig.Certificates = []tls.Certificate{ { Certificate: [][]byte{network.SASL.External.CertBlob}, PrivateKey: key.(crypto.PrivateKey), }, } logger.Printf("using TLS client certificate %x", sha256.Sum256(network.SASL.External.CertBlob)) } if network.CertFP != "" { tlsConfig.InsecureSkipVerify = true tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { if len(rawCerts) == 0 { return fmt.Errorf("the server didn't present any TLS certificate") } parts := strings.SplitN(network.CertFP, ":", 2) algo, localCertFP := parts[0], parts[1] for _, rawCert := range rawCerts { var remoteCertFP string switch algo { case "sha-512": sum := sha512.Sum512(rawCert) remoteCertFP = hex.EncodeToString(sum[:]) case "sha-256": sum := sha256.Sum256(rawCert) remoteCertFP = hex.EncodeToString(sum[:]) } if remoteCertFP == localCertFP { return nil // fingerprints match } } // Fingerprints don't match, let's give the user a fingerprint // they can use to connect sum := sha512.Sum512(rawCerts[0]) remoteCertFP := hex.EncodeToString(sum[:]) return fmt.Errorf("the configured TLS certificate fingerprint doesn't match the server's - %s", remoteCertFP) } } logger.Printf("connecting to TLS server at address %q", addr) netConn, err = dialTCP(ctx, network.user, addr) if err != nil { return nil, err } // Don't do the TLS handshake immediately, because we need to register // the new connection with identd ASAP. See: // https://todo.sr.ht/~emersion/soju/69#event-41859 netConn = tls.Client(netConn, tlsConfig) case "irc+insecure": addr := u.Host if _, _, err := net.SplitHostPort(addr); err != nil { addr = u.Host + ":6667" } logger.Printf("connecting to plain-text server at address %q", addr) netConn, err = dialTCP(ctx, network.user, addr) if err != nil { return nil, err } case "irc+unix", "unix": var dialer net.Dialer logger.Printf("connecting to Unix socket at path %q", u.Path) netConn, err = dialer.DialContext(ctx, "unix", u.Path) if err != nil { return nil, fmt.Errorf("failed to connect to Unix socket %q: %v", u.Path, err) } default: return nil, fmt.Errorf("failed to dial %q: unknown scheme: %v", network.Addr, u.Scheme) } options := connOptions{ Logger: logger, RateLimitDelay: upstreamMessageDelay, RateLimitBurst: upstreamMessageBurst, } cm := stdCaseMapping uc := &upstreamConn{ conn: newConn(network.user.srv, newNetIRCConn(netConn), &options), network: network, user: network.user, channels: xirc.NewCaseMappingMap[*upstreamChannel](cm), users: xirc.NewCaseMappingMap[*upstreamUser](cm), caps: xirc.NewCapRegistry(), batches: make(map[string]upstreamBatch), serverPrefix: &irc.Prefix{Name: "*"}, availableChannelTypes: stdChannelTypes, availableStatusMsg: "", availableChannelModes: stdChannelModes, availableMemberships: stdMemberships, isupport: make(map[string]*string), pendingCmds: make(map[string][]pendingUpstreamCommand), monitored: xirc.NewCaseMappingMap[bool](cm), hasDesiredNick: true, } return uc, nil } func dialTCP(ctx context.Context, user *user, addr string) (net.Conn, error) { var dialer net.Dialer upstreamUserIPs := user.srv.Config().UpstreamUserIPs if len(upstreamUserIPs) > 0 { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, err } ipAddr, err := resolveIPAddr(ctx, host) if err != nil { return nil, fmt.Errorf("failed to resolve host %q: %v", host, err) } localAddr, err := user.localTCPAddr(ipAddr.IP) if err != nil { return nil, fmt.Errorf("failed to pick local IP for remote host %q: %v", host, err) } addr = net.JoinHostPort(ipAddr.String(), port) dialer.LocalAddr = localAddr } return dialer.DialContext(ctx, "tcp", addr) } func (uc *upstreamConn) forEachDownstream(f func(*downstreamConn)) { uc.network.forEachDownstream(f) } func (uc *upstreamConn) forEachDownstreamByID(id uint64, f func(*downstreamConn)) { uc.forEachDownstream(func(dc *downstreamConn) { if id != 0 && id != dc.id { return } f(dc) }) } func (uc *upstreamConn) downstreamByID(id uint64) *downstreamConn { for _, dc := range uc.user.downstreamConns { if dc.id == id { return dc } } return nil } func (uc *upstreamConn) getChannel(name string) (*upstreamChannel, error) { ch := uc.channels.Get(name) if ch == nil { return nil, fmt.Errorf("unknown channel %q", name) } return ch, nil } func (uc *upstreamConn) isChannel(entity string) bool { return len(entity) > 0 && strings.ContainsRune(uc.availableChannelTypes, rune(entity[0])) } func (uc *upstreamConn) isOurNick(nick string) bool { return uc.network.equalCasemap(uc.nick, nick) } func (uc *upstreamConn) forwardMessage(ctx context.Context, msg *irc.Message) { uc.forEachDownstream(func(dc *downstreamConn) { dc.SendMessage(ctx, msg) }) } func (uc *upstreamConn) forwardMsgByID(ctx context.Context, id uint64, msg *irc.Message) { uc.forEachDownstreamByID(id, func(dc *downstreamConn) { dc.SendMessage(ctx, msg) }) } func (uc *upstreamConn) abortPendingCommands() { ctx := context.TODO() for _, l := range uc.pendingCmds { for _, pendingCmd := range l { dc := uc.downstreamByID(pendingCmd.downstreamID) if dc == nil { continue } switch pendingCmd.msg.Command { case "LIST": dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_LISTEND, Params: []string{dc.nick, "Command aborted"}, }) case "WHO": mask := "*" if len(pendingCmd.msg.Params) > 0 { mask = pendingCmd.msg.Params[0] } dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFWHO, Params: []string{dc.nick, mask, "Command aborted"}, }) case "WHOIS": nick := pendingCmd.msg.Params[len(pendingCmd.msg.Params)-1] dc.SendMessage(ctx, &irc.Message{ Command: irc.RPL_ENDOFWHOIS, Params: []string{dc.nick, nick, "Command aborted"}, }) case "AUTHENTICATE": dc.endSASL(ctx, &irc.Message{ Command: irc.ERR_SASLABORTED, Params: []string{dc.nick, "SASL authentication aborted"}, }) case "REGISTER", "VERIFY": dc.SendMessage(ctx, &irc.Message{ Command: "FAIL", Params: []string{pendingCmd.msg.Command, "TEMPORARILY_UNAVAILABLE", pendingCmd.msg.Params[0], "Command aborted"}, }) default: panic(fmt.Errorf("Unsupported pending command %q", pendingCmd.msg.Command)) } } } uc.pendingCmds = make(map[string][]pendingUpstreamCommand) } func (uc *upstreamConn) sendNextPendingCommand(cmd string) { if len(uc.pendingCmds[cmd]) == 0 { return } pendingCmd := &uc.pendingCmds[cmd][0] uc.SendMessageLabeled(context.TODO(), pendingCmd.downstreamID, pendingCmd.msg) pendingCmd.sentAt = time.Now() } func (uc *upstreamConn) enqueueCommand(dc *downstreamConn, msg *irc.Message) { switch msg.Command { case "LIST", "WHO", "WHOIS", "AUTHENTICATE", "REGISTER", "VERIFY": // Supported default: panic(fmt.Errorf("Unsupported pending command %q", msg.Command)) } uc.pendingCmds[msg.Command] = append(uc.pendingCmds[msg.Command], pendingUpstreamCommand{ downstreamID: dc.id, msg: msg, }) // If we didn't get a reply after a while, just give up // TODO: consider sending an abort reply to downstream if t := uc.pendingCmds[msg.Command][0].sentAt; !t.IsZero() && time.Since(t) > 30*time.Second { copy(uc.pendingCmds[msg.Command], uc.pendingCmds[msg.Command][1:]) } if len(uc.pendingCmds[msg.Command]) == 1 { uc.sendNextPendingCommand(msg.Command) } } func (uc *upstreamConn) currentPendingCommand(cmd string) (*downstreamConn, *irc.Message) { if len(uc.pendingCmds[cmd]) == 0 { return nil, nil } pendingCmd := uc.pendingCmds[cmd][0] return uc.downstreamByID(pendingCmd.downstreamID), pendingCmd.msg } func (uc *upstreamConn) dequeueCommand(cmd string) (*downstreamConn, *irc.Message) { dc, msg := uc.currentPendingCommand(cmd) if len(uc.pendingCmds[cmd]) > 0 { copy(uc.pendingCmds[cmd], uc.pendingCmds[cmd][1:]) uc.pendingCmds[cmd] = uc.pendingCmds[cmd][:len(uc.pendingCmds[cmd])-1] } uc.sendNextPendingCommand(cmd) return dc, msg } func (uc *upstreamConn) cancelPendingCommandsByDownstreamID(downstreamID uint64) { for cmd := range uc.pendingCmds { // We can't cancel the currently running command stored in // uc.pendingCmds[cmd][0] for i := len(uc.pendingCmds[cmd]) - 1; i >= 1; i-- { if uc.pendingCmds[cmd][i].downstreamID == downstreamID { uc.pendingCmds[cmd] = append(uc.pendingCmds[cmd][:i], uc.pendingCmds[cmd][i+1:]...) } } } } func (uc *upstreamConn) parseMembershipPrefix(s string) (ms xirc.MembershipSet, nick string) { var memberships xirc.MembershipSet i := 0 for _, m := range uc.availableMemberships { if i >= len(s) { break } if s[i] == m.Prefix { memberships = append(memberships, m) i++ } } return memberships, s[i:] } func (uc *upstreamConn) handleMessage(ctx context.Context, msg *irc.Message) error { var label string if l, ok := msg.Tags["label"]; ok { label = l delete(msg.Tags, "label") } var msgBatch *upstreamBatch if batchName, ok := msg.Tags["batch"]; ok { b, ok := uc.batches[batchName] if !ok { return fmt.Errorf("unexpected batch reference: batch was not defined: %q", batchName) } msgBatch = &b if label == "" { label = msgBatch.Label } delete(msg.Tags, "batch") } var downstreamID uint64 if label != "" { var labelOffset uint64 n, err := fmt.Sscanf(label, "sd-%d-%d", &downstreamID, &labelOffset) if err == nil && n < 2 { err = errors.New("not enough arguments") } if err != nil { return fmt.Errorf("unexpected message label: invalid downstream reference for label %q: %v", label, err) } } if msg.Prefix == nil { msg.Prefix = uc.serverPrefix } if !isNumeric(msg.Command) { t, err := time.Parse(xirc.ServerTimeLayout, string(msg.Tags["time"])) if err != nil { t = time.Now() } msg.Tags["time"] = uc.user.FormatServerTime(t) } switch msg.Command { case "PING": uc.SendMessage(ctx, &irc.Message{ Command: "PONG", Params: msg.Params, }) return nil case "NOTICE", "PRIVMSG", "TAGMSG": var target, text string if msg.Command != "TAGMSG" { if err := parseMessageParams(msg, &target, &text); err != nil { return err } } else { if err := parseMessageParams(msg, &target); err != nil { return err } } // remove statusmsg sigils from target target = strings.TrimLeft(target, uc.availableStatusMsg) if uc.network.equalCasemap(msg.Prefix.Name, serviceNick) { uc.logger.Printf("skipping %v from soju's service: %v", msg.Command, msg) break } if uc.network.equalCasemap(target, serviceNick) { uc.logger.Printf("skipping %v to soju's service: %v", msg.Command, msg) break } if !uc.registered || uc.network.equalCasemap(msg.Prefix.Name, uc.serverPrefix.Name) || target == "*" || strings.HasPrefix(target, "$") { // This is a server message uc.produce("", msg, 0) break } directMessage := uc.isOurNick(target) bufferName := target if directMessage { bufferName = msg.Prefix.Name } if t, ok := msg.Tags["+draft/channel-context"]; ok { ch := uc.channels.Get(string(t)) if ch != nil && ch.Members.Has(msg.Prefix.Name) { bufferName = ch.Name directMessage = false } } self := uc.isOurNick(msg.Prefix.Name) ch := uc.network.channels.Get(bufferName) highlight := false detached := false if ch != nil && msg.Command != "TAGMSG" && !self { if ch.Detached { uc.handleDetachedMessage(ctx, ch, msg) } highlight = uc.network.isHighlight(msg) if ch.DetachOn == database.FilterMessage || ch.DetachOn == database.FilterDefault || (ch.DetachOn == database.FilterHighlight && highlight) { uc.updateChannelAutoDetach(bufferName) } if ch.Detached && ch.RelayDetached == database.FilterNone { detached = true } } if !self && !detached && msg.Command != "TAGMSG" && (highlight || directMessage) { go uc.network.broadcastWebPush(msg) if timestamp, err := time.Parse(xirc.ServerTimeLayout, string(msg.Tags["time"])); err == nil { uc.network.pushTargets.Set(bufferName, timestamp) } } uc.produce(bufferName, msg, downstreamID) case "CAP": var subCmd string if err := parseMessageParams(msg, nil, &subCmd); err != nil { return err } subCmd = strings.ToUpper(subCmd) subParams := msg.Params[2:] switch subCmd { case "LS": if len(subParams) < 1 { return newNeedMoreParamsError(msg.Command) } caps := subParams[len(subParams)-1] more := len(subParams) >= 2 && msg.Params[len(subParams)-2] == "*" uc.handleSupportedCaps(caps) if more { break // wait to receive all capabilities } uc.updateCaps(ctx) if uc.requestSASL() { break // we'll send CAP END after authentication is completed } uc.SendMessage(ctx, &irc.Message{ Command: "CAP", Params: []string{"END"}, }) case "ACK", "NAK": if len(subParams) < 1 { return newNeedMoreParamsError(msg.Command) } caps := strings.Fields(subParams[0]) for _, name := range caps { enable := subCmd == "ACK" if strings.HasPrefix(name, "-") { name = strings.TrimPrefix(name, "-") enable = false } if err := uc.handleCapAck(ctx, strings.ToLower(name), enable); err != nil { return err } } if uc.registered { uc.forEachDownstream(func(dc *downstreamConn) { dc.updateSupportedCaps(ctx) }) } case "NEW": if len(subParams) < 1 { return newNeedMoreParamsError(msg.Command) } uc.handleSupportedCaps(subParams[0]) uc.updateCaps(ctx) case "DEL": if len(subParams) < 1 { return newNeedMoreParamsError(msg.Command) } caps := strings.Fields(subParams[0]) for _, c := range caps { uc.caps.Del(c) } if uc.registered { uc.forEachDownstream(func(dc *downstreamConn) { dc.updateSupportedCaps(ctx) }) } default: uc.logger.Debugf("unhandled message: %v", msg) } case "AUTHENTICATE": if uc.saslClient == nil { return fmt.Errorf("received unexpected AUTHENTICATE message") } // TODO: if a challenge is 400 bytes long, buffer it var challengeStr string if err := parseMessageParams(msg, &challengeStr); err != nil { uc.SendMessage(ctx, &irc.Message{ Command: "AUTHENTICATE", Params: []string{"*"}, }) return err } var challenge []byte if challengeStr != "+" { var err error challenge, err = base64.StdEncoding.DecodeString(challengeStr) if err != nil { uc.SendMessage(ctx, &irc.Message{ Command: "AUTHENTICATE", Params: []string{"*"}, }) return err } } var resp []byte var err error if !uc.saslStarted { _, resp, err = uc.saslClient.Start() uc.saslStarted = true } else { resp, err = uc.saslClient.Next(challenge) } if err != nil { uc.SendMessage(ctx, &irc.Message{ Command: "AUTHENTICATE", Params: []string{"*"}, }) return err } for _, msg := range xirc.GenerateSASL(resp) { uc.SendMessage(ctx, msg) } case irc.RPL_LOGGEDIN: var rawPrefix string if err := parseMessageParams(msg, nil, &rawPrefix, &uc.account); err != nil { return err } prefix := irc.ParsePrefix(rawPrefix) uc.username = prefix.User uc.hostname = prefix.Host uc.logger.Printf("logged in with account %q", uc.account) uc.forEachDownstream(func(dc *downstreamConn) { dc.updateAccount(ctx) dc.updateHost(ctx) }) case irc.RPL_LOGGEDOUT: var rawPrefix string if err := parseMessageParams(msg, nil, &rawPrefix); err != nil { return err } uc.account = "" prefix := irc.ParsePrefix(rawPrefix) uc.username = prefix.User uc.hostname = prefix.Host uc.logger.Printf("logged out") uc.forEachDownstream(func(dc *downstreamConn) { dc.updateAccount(ctx) dc.updateHost(ctx) }) case xirc.RPL_VISIBLEHOST: var rawHost string if err := parseMessageParams(msg, nil, &rawHost); err != nil { return err } parts := strings.SplitN(rawHost, "@", 2) if len(parts) == 2 { uc.username, uc.hostname = parts[0], parts[1] } else { uc.hostname = rawHost } uc.forEachDownstream(func(dc *downstreamConn) { dc.updateHost(ctx) }) case irc.ERR_NICKLOCKED, irc.RPL_SASLSUCCESS, irc.ERR_SASLFAIL, irc.ERR_SASLTOOLONG, irc.ERR_SASLABORTED: var info string if err := parseMessageParams(msg, nil, &info); err != nil { return err } switch msg.Command { case irc.ERR_NICKLOCKED: uc.logger.Printf("invalid nick used with SASL authentication: %v", info) case irc.ERR_SASLFAIL: uc.logger.Printf("SASL authentication failed: %v", info) case irc.ERR_SASLTOOLONG: uc.logger.Printf("SASL message too long: %v", info) } uc.saslClient = nil uc.saslStarted = false if dc, _ := uc.dequeueCommand("AUTHENTICATE"); dc != nil && dc.sasl != nil { if msg.Command == irc.RPL_SASLSUCCESS { uc.network.autoSaveSASLPlain(ctx, dc.sasl.plain.Username, dc.sasl.plain.Password) } dc.endSASL(ctx, msg) } if !uc.registered { uc.SendMessage(ctx, &irc.Message{ Command: "CAP", Params: []string{"END"}, }) } case "REGISTER", "VERIFY": if dc, cmd := uc.dequeueCommand(msg.Command); dc != nil { if msg.Command == "REGISTER" { var account, password string if err := parseMessageParams(msg, nil, &account); err != nil { return err } if err := parseMessageParams(cmd, nil, nil, &password); err != nil { return err } uc.network.autoSaveSASLPlain(ctx, account, password) } dc.SendMessage(ctx, msg) } case irc.RPL_WELCOME: if err := parseMessageParams(msg, &uc.nick); err != nil { return err } uc.registered = true uc.serverPrefix = msg.Prefix uc.logger.Printf("connection registered with nick %q", uc.nick) if uc.network.channels.Len() > 0 { var channels, keys []string uc.network.channels.ForEach(func(_ string, ch *database.Channel) { channels = append(channels, ch.Name) keys = append(keys, ch.Key) }) for _, msg := range xirc.GenerateJoin(channels, keys) { uc.SendMessage(ctx, msg) } } case irc.RPL_MYINFO: if err := parseMessageParams(msg, nil, &uc.serverName, nil, &uc.availableUserModes, nil); err != nil { return err } case irc.RPL_ISUPPORT: if err := parseMessageParams(msg, nil, nil); err != nil { return err } var downstreamIsupport []string for _, token := range msg.Params[1 : len(msg.Params)-1] { parameter := token var negate, hasValue bool var value string if strings.HasPrefix(token, "-") { negate = true token = token[1:] } else if i := strings.IndexByte(token, '='); i >= 0 { parameter = token[:i] value = token[i+1:] hasValue = true } parameter = strings.ToUpper(parameter) if hasValue { uc.isupport[parameter] = &value } else if !negate { uc.isupport[parameter] = nil } else { delete(uc.isupport, parameter) } var err error switch parameter { case "CASEMAPPING": casemap := xirc.ParseCaseMapping(value) if casemap == nil { casemap = xirc.CaseMappingRFC1459 } uc.network.updateCasemapping(casemap) case "CHANMODES": if !negate { err = uc.handleChanModes(value) } else { uc.availableChannelModes = stdChannelModes } case "CHANTYPES": if !negate { uc.availableChannelTypes = value } else { uc.availableChannelTypes = stdChannelTypes } case "STATUSMSG": if !negate { uc.availableStatusMsg = value } else { uc.availableStatusMsg = "" } case "PREFIX": if !negate { err = uc.handleMemberships(value) } else { uc.availableMemberships = stdMemberships } case "SOJU.IM/SAFERATE": uc.rateLimit = negate } if err != nil { return err } if passthroughIsupport[parameter] { downstreamIsupport = append(downstreamIsupport, token) } } uc.updateMonitor() uc.forEachDownstream(func(dc *downstreamConn) { msgs := xirc.GenerateIsupport(downstreamIsupport) for _, msg := range msgs { dc.SendMessage(ctx, msg) } }) case irc.ERR_NOMOTD, irc.RPL_ENDOFMOTD: if !uc.gotMotd { // Ignore the initial MOTD upon connection, but forward // subsequent MOTD messages downstream uc.gotMotd = true // If upstream did not send any CASEMAPPING token, assume it // implements the old RFCs with rfc1459. if uc.isupport["CASEMAPPING"] == nil { uc.network.updateCasemapping(stdCaseMapping) } // If the server doesn't support MONITOR, periodically try to // regain our desired nick if _, ok := uc.isupport["MONITOR"]; !ok { uc.startRegainNickTimer() } return nil } uc.forwardMsgByID(ctx, downstreamID, msg) case "BATCH": var tag string if err := parseMessageParams(msg, &tag); err != nil { return err } if strings.HasPrefix(tag, "+") { tag = tag[1:] if _, ok := uc.batches[tag]; ok { return fmt.Errorf("unexpected BATCH reference tag: batch was already defined: %q", tag) } var batchType string if err := parseMessageParams(msg, nil, &batchType); err != nil { return err } label := label if label == "" && msgBatch != nil { label = msgBatch.Label } uc.batches[tag] = upstreamBatch{ Type: batchType, Params: msg.Params[2:], Outer: msgBatch, Label: label, } } else if strings.HasPrefix(tag, "-") { tag = tag[1:] if _, ok := uc.batches[tag]; !ok { return fmt.Errorf("unknown BATCH reference tag: %q", tag) } delete(uc.batches, tag) } else { return fmt.Errorf("unexpected BATCH reference tag: missing +/- prefix: %q", tag) } case "NICK": var newNick string if err := parseMessageParams(msg, &newNick); err != nil { return err } me := false if uc.isOurNick(msg.Prefix.Name) { uc.logger.Printf("changed nick from %q to %q", uc.nick, newNick) me = true uc.nick = newNick if uc.network.equalCasemap(uc.pendingRegainNick, newNick) { uc.pendingRegainNick = "" uc.stopRegainNickTimer() } wantNick := database.GetNick(&uc.user.User, &uc.network.Network) if uc.network.equalCasemap(wantNick, newNick) { uc.hasDesiredNick = true } } uc.channels.ForEach(func(_ string, ch *upstreamChannel) { memberships := ch.Members.Get(msg.Prefix.Name) if memberships != nil { ch.Members.Del(msg.Prefix.Name) ch.Members.Set(newNick, memberships) uc.appendLog(ch.Name, msg) } }) uc.cacheUserInfo(msg.Prefix.Name, &upstreamUser{ Nickname: newNick, }) if !me { uc.forwardMessage(ctx, msg) } else { uc.forEachDownstream(func(dc *downstreamConn) { dc.updateNick(ctx) }) uc.updateMonitor() } case "SETNAME": var newRealname string if err := parseMessageParams(msg, &newRealname); err != nil { return err } uc.cacheUserInfo(msg.Prefix.Name, &upstreamUser{ Realname: newRealname, }) // TODO: consider appending this message to logs if uc.isOurNick(msg.Prefix.Name) { uc.logger.Printf("changed realname from %q to %q", uc.realname, newRealname) uc.realname = newRealname uc.forEachDownstream(func(dc *downstreamConn) { dc.updateRealname(ctx) }) } else { uc.forwardMessage(ctx, msg) } case "CHGHOST": var newUsername, newHostname string if err := parseMessageParams(msg, &newUsername, &newHostname); err != nil { return err } newPrefix := &irc.Prefix{ Name: uc.nick, User: newUsername, Host: newHostname, } if uc.isOurNick(msg.Prefix.Name) { uc.logger.Printf("changed prefix from %q to %q", msg.Prefix.Host, newPrefix) uc.username = newUsername uc.hostname = newHostname uc.forEachDownstream(func(dc *downstreamConn) { dc.updateHost(ctx) }) } else { // TODO: add fallback with QUIT/JOIN/MODE messages uc.forwardMessage(ctx, msg) } case "JOIN": var channels string if err := parseMessageParams(msg, &channels); err != nil { return err } uu := &upstreamUser{ Username: msg.Prefix.User, Hostname: msg.Prefix.Host, } if uc.caps.IsEnabled("away-notify") { // we have enough info to build the user flags in a best-effort manner: // - the H/G flag is set to Here first, will be replaced by Gone later if the user is AWAY uu.Flags = "H" // - the B (bot mode) flag is set if the JOIN comes from a bot // note: we have no way to track the user bot mode after they have joined // (we are not notified of the bot mode updates), but this is good enough. if _, ok := msg.Tags["bot"]; ok { if bot := uc.isupport["BOT"]; bot != nil { uu.Flags += *bot } } // TODO: add the server operator flag (`*`) if the message has an oper-tag } if len(msg.Params) > 2 { // extended-join uu.Account = msg.Params[1] uu.Realname = msg.Params[2] } uc.cacheUserInfo(msg.Prefix.Name, uu) for _, ch := range strings.Split(channels, ",") { if uc.isOurNick(msg.Prefix.Name) { uc.logger.Printf("joined channel %q", ch) members := xirc.NewCaseMappingMap[*xirc.MembershipSet](uc.network.casemap) uc.channels.Set(ch, &upstreamChannel{ Name: ch, conn: uc, Members: members, }) uc.updateChannelAutoDetach(ch) uc.SendMessage(ctx, &irc.Message{ Command: "MODE", Params: []string{ch}, }) } else { ch, err := uc.getChannel(ch) if err != nil { return err } ch.Members.Set(msg.Prefix.Name, &xirc.MembershipSet{}) } chMsg := msg.Copy() chMsg.Params[0] = ch uc.produce(ch, chMsg, 0) } case "PART": var channels string if err := parseMessageParams(msg, &channels); err != nil { return err } for _, ch := range strings.Split(channels, ",") { if uc.isOurNick(msg.Prefix.Name) { uc.logger.Printf("parted channel %q", ch) if uch := uc.channels.Get(ch); uch != nil { uc.channels.Del(ch) uch.updateAutoDetach(0) uch.Members.ForEach(func(nick string, memberships *xirc.MembershipSet) { if !uc.shouldCacheUserInfo(nick) { uc.users.Del(nick) } }) } } else { ch, err := uc.getChannel(ch) if err != nil { return err } ch.Members.Del(msg.Prefix.Name) if !uc.shouldCacheUserInfo(msg.Prefix.Name) { uc.users.Del(msg.Prefix.Name) } } chMsg := msg.Copy() chMsg.Params[0] = ch uc.produce(ch, chMsg, 0) } case "KICK": var channel, user string if err := parseMessageParams(msg, &channel, &user); err != nil { return err } if uc.isOurNick(user) { uc.logger.Printf("kicked from channel %q by %s", channel, msg.Prefix.Name) if uch := uc.channels.Get(channel); uch != nil { uc.channels.Del(channel) uch.Members.ForEach(func(nick string, memberships *xirc.MembershipSet) { if !uc.shouldCacheUserInfo(nick) { uc.users.Del(nick) } }) } } else { ch, err := uc.getChannel(channel) if err != nil { return err } ch.Members.Del(user) if !uc.shouldCacheUserInfo(user) { uc.users.Del(user) } } uc.produce(channel, msg, 0) case "QUIT": if uc.isOurNick(msg.Prefix.Name) { uc.logger.Printf("quit") } uc.channels.ForEach(func(_ string, ch *upstreamChannel) { if ch.Members.Has(msg.Prefix.Name) { ch.Members.Del(msg.Prefix.Name) uc.appendLog(ch.Name, msg) } }) uc.users.Del(msg.Prefix.Name) if msg.Prefix.Name != uc.nick { uc.forwardMessage(ctx, msg) } case irc.RPL_TOPIC, irc.RPL_NOTOPIC: var name, topic string if err := parseMessageParams(msg, nil, &name, &topic); err != nil { return err } ch := uc.channels.Get(name) if ch == nil { uc.forwardMsgByID(ctx, downstreamID, msg) } else { if msg.Command == irc.RPL_TOPIC { ch.Topic = topic } else { ch.Topic = "" } } case "TOPIC": var name string if err := parseMessageParams(msg, &name); err != nil { return err } ch, err := uc.getChannel(name) if err != nil { return err } if len(msg.Params) > 1 { ch.Topic = msg.Params[1] ch.TopicWho = msg.Prefix.Copy() ch.TopicTime = time.Now() // TODO use msg.Tags["time"] } else { ch.Topic = "" } uc.produce(ch.Name, msg, 0) case "MODE": var name, modeStr string if err := parseMessageParams(msg, &name, &modeStr); err != nil { return err } if !uc.isChannel(name) { // user mode change if name != uc.nick { return fmt.Errorf("received MODE message for unknown nick %q", name) } if err := uc.modes.Apply(modeStr); err != nil { return err } uc.forwardMessage(ctx, msg) } else { // channel mode change ch, err := uc.getChannel(name) if err != nil { return err } err = applyChannelModes(ch, modeStr, msg.Params[2:]) if err != nil { return err } uc.appendLog(ch.Name, msg) c := uc.network.channels.Get(name) if c == nil || !c.Detached { uc.forwardMessage(ctx, msg) } } case irc.RPL_UMODEIS: if err := parseMessageParams(msg, nil); err != nil { return err } modeStr := "" if len(msg.Params) > 1 { modeStr = msg.Params[1] } uc.modes = "" if err := uc.modes.Apply(modeStr); err != nil { return err } uc.forwardMessage(ctx, msg) case irc.RPL_CHANNELMODEIS: var channel string if err := parseMessageParams(msg, nil, &channel); err != nil { return err } modeStr := "" var modeArgs []string if len(msg.Params) > 2 { modeStr = msg.Params[2] modeArgs = msg.Params[3:] } ch := uc.channels.Get(channel) if ch == nil { uc.forwardMsgByID(ctx, downstreamID, msg) return nil } firstMode := ch.modes == nil ch.modes = make(map[byte]string) if err := applyChannelModes(ch, modeStr, modeArgs); err != nil { return err } c := uc.network.channels.Get(channel) if firstMode && (c == nil || !c.Detached) { uc.forwardMessage(ctx, msg) } case xirc.RPL_CREATIONTIME: var channel, creationTime string if err := parseMessageParams(msg, nil, &channel, &creationTime); err != nil { return err } ch := uc.channels.Get(channel) if ch == nil { uc.forwardMsgByID(ctx, downstreamID, msg) return nil } firstCreationTime := ch.creationTime == "" ch.creationTime = creationTime c := uc.network.channels.Get(channel) if firstCreationTime && (c == nil || !c.Detached) { uc.forwardMessage(ctx, msg) } case xirc.RPL_TOPICWHOTIME: var channel, who, timeStr string if err := parseMessageParams(msg, nil, &channel, &who, &timeStr); err != nil { return err } ch := uc.channels.Get(channel) if ch == nil { uc.forwardMsgByID(ctx, downstreamID, msg) return nil } firstTopicWhoTime := ch.TopicWho == nil ch.TopicWho = irc.ParsePrefix(who) sec, err := strconv.ParseInt(timeStr, 10, 64) if err != nil { return fmt.Errorf("failed to parse topic time: %v", err) } ch.TopicTime = time.Unix(sec, 0) c := uc.network.channels.Get(channel) if firstTopicWhoTime && (c == nil || !c.Detached) { uc.forwardMessage(ctx, msg) } case irc.RPL_LISTSTART, irc.RPL_LIST: dc, cmd := uc.currentPendingCommand("LIST") if cmd == nil { return fmt.Errorf("unexpected RPL_LIST: no matching pending LIST") } else if dc == nil { return nil } dc.SendMessage(ctx, msg) case irc.RPL_LISTEND: dc, cmd := uc.dequeueCommand("LIST") if cmd == nil { return fmt.Errorf("unexpected RPL_LISTEND: no matching pending LIST") } else if dc == nil { return nil } dc.SendMessage(ctx, msg) case irc.RPL_NAMREPLY: var name, statusStr, members string if err := parseMessageParams(msg, nil, &statusStr, &name, &members); err != nil { return err } ch := uc.channels.Get(name) if ch == nil { // NAMES on a channel we have not joined, forward to downstream uc.forwardMsgByID(ctx, downstreamID, msg) return nil } status, err := xirc.ParseChannelStatus(statusStr) if err != nil { return err } ch.Status = status for _, s := range splitSpace(members) { memberships, nick := uc.parseMembershipPrefix(s) ch.Members.Set(nick, &memberships) } case irc.RPL_ENDOFNAMES: var name string if err := parseMessageParams(msg, nil, &name); err != nil { return err } ch := uc.channels.Get(name) if ch == nil { // NAMES on a channel we have not joined, forward to downstream uc.forwardMsgByID(ctx, downstreamID, msg) return nil } if ch.complete { return fmt.Errorf("received unexpected RPL_ENDOFNAMES") } ch.complete = true c := uc.network.channels.Get(name) if c == nil || !c.Detached { uc.forEachDownstream(func(dc *downstreamConn) { forwardChannel(ctx, dc, ch) }) } case irc.RPL_WHOREPLY: var username, host, server, nick, flags, trailing string if err := parseMessageParams(msg, nil, nil, &username, &host, &server, &nick, &flags, &trailing); err != nil { return err } dc, cmd := uc.currentPendingCommand("WHO") if cmd == nil { return fmt.Errorf("unexpected RPL_WHOREPLY: no matching pending WHO") } else if dc == nil { return nil } parts := strings.SplitN(trailing, " ", 2) if len(parts) != 2 { return fmt.Errorf("malformed RPL_WHOREPLY: failed to parse real name") } realname := parts[1] dc.SendMessage(ctx, msg) if uc.shouldCacheUserInfo(nick) { uc.cacheUserInfo(nick, &upstreamUser{ Username: username, Hostname: host, Server: server, Nickname: nick, Flags: stripMemberPrefixes(flags, uc), Realname: realname, }) } case xirc.RPL_WHOSPCRPL: dc, cmd := uc.currentPendingCommand("WHO") if cmd == nil { return fmt.Errorf("unexpected RPL_WHOSPCRPL: no matching pending WHO") } else if dc == nil { return nil } dc.SendMessage(ctx, msg) if len(cmd.Params) > 1 { fields, _ := xirc.ParseWHOXOptions(cmd.Params[1]) if strings.IndexByte(fields, 'n') < 0 { return nil } info, err := xirc.ParseWHOXReply(msg, fields) if err != nil { return err } if uc.shouldCacheUserInfo(info.Nickname) { uc.cacheUserInfo(info.Nickname, &upstreamUser{ Nickname: info.Nickname, Username: info.Username, Hostname: info.Hostname, Server: info.Server, Flags: stripMemberPrefixes(info.Flags, uc), Account: info.Account, Realname: info.Realname, }) } } case irc.RPL_ENDOFWHO: dc, cmd := uc.dequeueCommand("WHO") if cmd == nil { // Some servers send RPL_TRYAGAIN followed by RPL_ENDOFWHO return nil } else if dc == nil { // Downstream connection is gone return nil } dc.SendMessage(ctx, msg) case xirc.RPL_WHOISCERTFP, xirc.RPL_WHOISREGNICK, irc.RPL_WHOISUSER, irc.RPL_WHOISSERVER, irc.RPL_WHOISCHANNELS, irc.RPL_WHOISOPERATOR, irc.RPL_WHOISIDLE, xirc.RPL_WHOISSPECIAL, xirc.RPL_WHOISACCOUNT, xirc.RPL_WHOISACTUALLY, xirc.RPL_WHOISHOST, xirc.RPL_WHOISMODES, xirc.RPL_WHOISSECURE: dc, cmd := uc.currentPendingCommand("WHOIS") if cmd == nil { return fmt.Errorf("unexpected WHOIS reply %q: no matching pending WHOIS", msg.Command) } else if dc == nil { return nil } dc.SendMessage(ctx, msg) case irc.RPL_ENDOFWHOIS: dc, cmd := uc.dequeueCommand("WHOIS") if cmd == nil { return fmt.Errorf("unexpected RPL_ENDOFWHOIS: no matching pending WHOIS") } else if dc == nil { return nil } dc.SendMessage(ctx, msg) case "INVITE": var nick, channel string if err := parseMessageParams(msg, &nick, &channel); err != nil { return err } weAreInvited := uc.isOurNick(nick) if weAreInvited { joined := uc.channels.Get(channel) != nil c := uc.network.channels.Get(channel) if !joined && c != nil { // Automatically join a saved channel when we are invited for _, msg := range xirc.GenerateJoin([]string{c.Name}, []string{c.Key}) { uc.SendMessage(ctx, msg) } break } } uc.forEachDownstream(func(dc *downstreamConn) { if !weAreInvited && !dc.caps.IsEnabled("invite-notify") { return } dc.SendMessage(ctx, msg) }) if weAreInvited { go uc.network.broadcastWebPush(msg) } case irc.RPL_INVITING: var nick, channel string if err := parseMessageParams(msg, nil, &nick, &channel); err != nil { return err } uc.forwardMsgByID(ctx, downstreamID, msg) case irc.RPL_MONONLINE, irc.RPL_MONOFFLINE: var targetsStr string if err := parseMessageParams(msg, nil, &targetsStr); err != nil { return err } targets := strings.Split(targetsStr, ",") online := msg.Command == irc.RPL_MONONLINE for _, target := range targets { prefix := irc.ParsePrefix(target) uc.monitored.Set(prefix.Name, online) } // Check if the nick we want is now free wantNick := database.GetNick(&uc.user.User, &uc.network.Network) if !online && !uc.isOurNick(wantNick) && !uc.hasDesiredNick { found := false for _, target := range targets { prefix := irc.ParsePrefix(target) if uc.network.equalCasemap(prefix.Name, wantNick) { found = true break } } if found { uc.logger.Printf("desired nick %q is now available", wantNick) uc.SendMessage(ctx, &irc.Message{ Command: "NICK", Params: []string{wantNick}, }) } } uc.forEachDownstream(func(dc *downstreamConn) { for _, target := range targets { prefix := irc.ParsePrefix(target) if dc.monitored.Has(prefix.Name) { dc.SendMessage(ctx, &irc.Message{ Command: msg.Command, Params: []string{dc.nick, target}, }) } } }) case irc.ERR_MONLISTFULL: var limit, targetsStr string if err := parseMessageParams(msg, nil, &limit, &targetsStr); err != nil { return err } targets := strings.Split(targetsStr, ",") uc.forEachDownstream(func(dc *downstreamConn) { for _, target := range targets { if dc.monitored.Has(target) { dc.SendMessage(ctx, &irc.Message{ Command: msg.Command, Params: []string{dc.nick, limit, target}, }) } } }) case irc.RPL_AWAY: uc.forwardMsgByID(ctx, downstreamID, msg) case "AWAY": // Update user flags, if we already have the flags cached uu := uc.users.Get(msg.Prefix.Name) if uu != nil && uu.Flags != "" { flags := uu.Flags if isAway := len(msg.Params) > 0; isAway { flags = strings.ReplaceAll(flags, "H", "G") } else { flags = strings.ReplaceAll(flags, "G", "H") } uc.cacheUserInfo(msg.Prefix.Name, &upstreamUser{ Flags: flags, }) } uc.forwardMessage(ctx, msg) case "ACCOUNT": var account string if err := parseMessageParams(msg, &account); err != nil { return err } uc.cacheUserInfo(msg.Prefix.Name, &upstreamUser{ Account: account, }) uc.forwardMessage(ctx, msg) case irc.RPL_BANLIST, irc.RPL_INVITELIST, irc.RPL_EXCEPTLIST, irc.RPL_ENDOFBANLIST, irc.RPL_ENDOFINVITELIST, irc.RPL_ENDOFEXCEPTLIST: uc.forwardMsgByID(ctx, downstreamID, msg) case irc.ERR_NOSUCHNICK, irc.ERR_NOSUCHSERVER: // one argument WHOIS variant errors with NOSUCHNICK // two argument WHOIS variant errors with NOSUCHSERVER var nick, reason string if err := parseMessageParams(msg, nil, &nick, &reason); err != nil { return err } cm := uc.network.casemap dc, cmd := uc.currentPendingCommand("WHOIS") if cmd != nil && cm(cmd.Params[len(cmd.Params)-1]) == cm(nick) { uc.dequeueCommand("WHOIS") if dc != nil { dc.SendMessage(ctx, msg) } } else { uc.forwardMsgByID(ctx, downstreamID, msg) } case xirc.ERR_UNKNOWNERROR, irc.ERR_UNKNOWNCOMMAND, irc.ERR_NEEDMOREPARAMS, irc.RPL_TRYAGAIN: var command, reason string if err := parseMessageParams(msg, nil, &command, &reason); err != nil { return err } if dc, _ := uc.dequeueCommand(command); dc != nil && downstreamID == 0 { downstreamID = dc.id } if command == "AUTHENTICATE" { uc.saslClient = nil uc.saslStarted = false } uc.forwardMsgByID(ctx, downstreamID, msg) case "FAIL": var command, code string if err := parseMessageParams(msg, &command, &code); err != nil { return err } if !uc.registered && command == "*" && code == "ACCOUNT_REQUIRED" { return registrationError{msg} } if dc, _ := uc.dequeueCommand(command); dc != nil && downstreamID == 0 { downstreamID = dc.id } uc.forwardMsgByID(ctx, downstreamID, msg) case "ACK": // Ignore case irc.RPL_NOWAWAY, irc.RPL_UNAWAY: // Ignore case irc.RPL_YOURHOST, irc.RPL_CREATED: // Ignore case irc.RPL_LUSERCLIENT, irc.RPL_LUSEROP, irc.RPL_LUSERUNKNOWN, irc.RPL_LUSERCHANNELS, irc.RPL_LUSERME: fallthrough case irc.RPL_STATSVLINE, xirc.RPL_STATSPING, irc.RPL_STATSBLINE, irc.RPL_STATSDLINE: fallthrough case xirc.RPL_LOCALUSERS, xirc.RPL_GLOBALUSERS: fallthrough case irc.RPL_MOTDSTART, irc.RPL_MOTD: // Ignore these messages if they're part of the initial registration // message burst. Forward them if the user explicitly asked for them. if !uc.gotMotd { return nil } uc.forwardMsgByID(ctx, downstreamID, msg) case "ERROR": var text string if err := parseMessageParams(msg, &text); err != nil { return err } return fmt.Errorf("fatal server error: %v", text) case irc.ERR_NICKNAMEINUSE: // At this point, we haven't received ISUPPORT so we don't know the // maximum nickname length or whether the server supports MONITOR. Many // servers have NICKLEN=30 so let's just use that. if !uc.registered && len(uc.nick)+1 < 30 { uc.nick = uc.nick + "_" uc.hasDesiredNick = false uc.logger.Printf("desired nick is not available, falling back to %q", uc.nick) uc.SendMessage(ctx, &irc.Message{ Command: "NICK", Params: []string{uc.nick}, }) return nil } var failedNick string if err := parseMessageParams(msg, nil, &failedNick); err != nil { return err } if uc.network.equalCasemap(uc.pendingRegainNick, failedNick) { // This message comes from our own logic to try to regain our // desired nick, don't relay to downstream connections uc.pendingRegainNick = "" return nil } fallthrough case irc.ERR_PASSWDMISMATCH, irc.ERR_ERRONEUSNICKNAME, irc.ERR_NICKCOLLISION, irc.ERR_UNAVAILRESOURCE, irc.ERR_NOPERMFORHOST, irc.ERR_YOUREBANNEDCREEP: if !uc.registered { return registrationError{msg} } uc.forwardMsgByID(ctx, downstreamID, msg) default: uc.logger.Debugf("unhandled message: %v", msg) uc.forwardMsgByID(ctx, downstreamID, msg) } return nil } func (uc *upstreamConn) handleDetachedMessage(ctx context.Context, ch *database.Channel, msg *irc.Message) { if uc.network.detachedMessageNeedsRelay(ch, msg) { uc.forEachDownstream(func(dc *downstreamConn) { dc.relayDetachedMessage(uc.network, msg) }) } if ch.ReattachOn == database.FilterMessage || (ch.ReattachOn == database.FilterHighlight && uc.network.isHighlight(msg)) { uc.network.attach(ctx, ch) if err := uc.srv.db.StoreChannel(ctx, uc.network.ID, ch); err != nil { uc.logger.Printf("failed to update channel %q: %v", ch.Name, err) } } } func (uc *upstreamConn) handleChanModes(s string) error { parts := strings.SplitN(s, ",", 5) if len(parts) < 4 { return fmt.Errorf("malformed ISUPPORT CHANMODES value: %v", s) } modes := make(map[byte]channelModeType) for i, mt := range []channelModeType{modeTypeA, modeTypeB, modeTypeC, modeTypeD} { for j := 0; j < len(parts[i]); j++ { mode := parts[i][j] modes[mode] = mt } } uc.availableChannelModes = modes return nil } func (uc *upstreamConn) handleMemberships(s string) error { if s == "" { uc.availableMemberships = nil return nil } if s[0] != '(' { return fmt.Errorf("malformed ISUPPORT PREFIX value: %v", s) } sep := strings.IndexByte(s, ')') if sep < 0 || len(s) != sep*2 { return fmt.Errorf("malformed ISUPPORT PREFIX value: %v", s) } memberships := make([]xirc.Membership, len(s)/2-1) for i := range memberships { memberships[i] = xirc.Membership{ Mode: s[i+1], Prefix: s[sep+i+1], } } uc.availableMemberships = memberships return nil } func (uc *upstreamConn) handleSupportedCaps(capsStr string) { caps := strings.Fields(capsStr) for _, s := range caps { kv := strings.SplitN(s, "=", 2) k := strings.ToLower(kv[0]) var v string if len(kv) == 2 { v = kv[1] } uc.caps.Available[k] = v } } func (uc *upstreamConn) updateCaps(ctx context.Context) { var requestCaps []string for c := range permanentUpstreamCaps { if uc.caps.IsAvailable(c) && !uc.caps.IsEnabled(c) { requestCaps = append(requestCaps, c) } } echoMessage := uc.caps.IsAvailable("labeled-response") if !uc.caps.IsEnabled("echo-message") && echoMessage { requestCaps = append(requestCaps, "echo-message") } else if uc.caps.IsEnabled("echo-message") && !echoMessage { requestCaps = append(requestCaps, "-echo-message") } if len(requestCaps) == 0 { return } uc.SendMessage(ctx, &irc.Message{ Command: "CAP", Params: []string{"REQ", strings.Join(requestCaps, " ")}, }) } func (uc *upstreamConn) supportsSASL(mech string) bool { v, ok := uc.caps.Available["sasl"] if !ok { return false } if v == "" { return true } mechanisms := strings.Split(v, ",") for _, m := range mechanisms { if strings.EqualFold(m, mech) { return true } } return false } func (uc *upstreamConn) requestSASL() bool { if uc.network.SASL.Mechanism == "" { return false } return uc.supportsSASL(uc.network.SASL.Mechanism) } func (uc *upstreamConn) handleCapAck(ctx context.Context, name string, ok bool) error { uc.caps.SetEnabled(name, ok) switch name { case "sasl": if !uc.requestSASL() { return nil } if !ok { uc.logger.Printf("server refused to acknowledge the SASL capability") return nil } auth := &uc.network.SASL switch auth.Mechanism { case "PLAIN": uc.logger.Printf("starting SASL PLAIN authentication with username %q", auth.Plain.Username) uc.saslClient = sasl.NewPlainClient("", auth.Plain.Username, auth.Plain.Password) case "EXTERNAL": uc.logger.Printf("starting SASL EXTERNAL authentication") uc.saslClient = sasl.NewExternalClient("") default: return fmt.Errorf("unsupported SASL mechanism %q", name) } uc.SendMessage(ctx, &irc.Message{ Command: "AUTHENTICATE", Params: []string{auth.Mechanism}, }) case "echo-message": default: if permanentUpstreamCaps[name] { break } uc.logger.Printf("received CAP ACK/NAK for a cap we don't support: %v", name) } return nil } func splitSpace(s string) []string { return strings.FieldsFunc(s, func(r rune) bool { return r == ' ' }) } func (uc *upstreamConn) register(ctx context.Context) { uc.nick = database.GetNick(&uc.user.User, &uc.network.Network) uc.username = database.GetUsername(&uc.user.User, &uc.network.Network) uc.realname = database.GetRealname(&uc.user.User, &uc.network.Network) uc.SendMessage(ctx, &irc.Message{ Command: "CAP", Params: []string{"LS", "302"}, }) if uc.network.Pass != "" { uc.SendMessage(ctx, &irc.Message{ Command: "PASS", Params: []string{uc.network.Pass}, }) } uc.SendMessage(ctx, &irc.Message{ Command: "NICK", Params: []string{uc.nick}, }) uc.SendMessage(ctx, &irc.Message{ Command: "USER", Params: []string{uc.username, "0", "*", uc.realname}, }) } func (uc *upstreamConn) ReadMessage() (*irc.Message, error) { msg, err := uc.conn.ReadMessage() if err != nil { return nil, err } uc.srv.metrics.upstreamInMessagesTotal.Inc() return msg, nil } func (uc *upstreamConn) runUntilRegistered(ctx context.Context) error { for !uc.registered { msg, err := uc.ReadMessage() if err != nil { return fmt.Errorf("failed to read message: %v", err) } if err := uc.handleMessage(ctx, msg); err != nil { if _, ok := err.(registrationError); ok { return err } else { msg.Tags = nil // prevent message tags from cluttering logs return fmt.Errorf("failed to handle message %q: %v", msg, err) } } } for _, command := range uc.network.ConnectCommands { m, err := irc.ParseMessage(command) if err != nil { uc.logger.Printf("failed to parse connect command %q: %v", command, err) } else { uc.SendMessage(ctx, m) } } return nil } func (uc *upstreamConn) readMessages(ch chan<- event) error { for { msg, err := uc.ReadMessage() if errors.Is(err, io.EOF) { break } else if err != nil { return fmt.Errorf("failed to read IRC command: %v", err) } ch <- eventUpstreamMessage{msg, uc} } return nil } func (uc *upstreamConn) SendMessage(ctx context.Context, msg *irc.Message) { if !uc.caps.IsEnabled("message-tags") { msg = msg.Copy() msg.Tags = nil } uc.srv.metrics.upstreamOutMessagesTotal.Inc() uc.conn.SendMessage(ctx, msg) } func (uc *upstreamConn) SendMessageLabeled(ctx context.Context, downstreamID uint64, msg *irc.Message) { if uc.caps.IsEnabled("labeled-response") { if msg.Tags == nil { msg.Tags = make(irc.Tags) } msg.Tags["label"] = fmt.Sprintf("sd-%d-%d", downstreamID, uc.nextLabelID) uc.nextLabelID++ } uc.SendMessage(ctx, msg) } // appendLog appends a message to the log file. // // The internal message ID is returned. If the message isn't recorded in the // log file, an empty string is returned. func (uc *upstreamConn) appendLog(entity string, msg *irc.Message) (msgID string) { if uc.user.msgStore == nil { return "" } if msg.Command == "TAGMSG" { store := false for tag := range msg.Tags { if storableMessageTags[tag] { store = true break } } if !store { return "" } } // Don't store messages with a server mask target if strings.HasPrefix(entity, "$") { return "" } entityCM := uc.network.casemap(entity) if entityCM == "nickserv" { // The messages sent/received from NickServ may contain // security-related information (like passwords). Don't store these. return "" } if !uc.network.delivered.Empty() && !uc.network.delivered.HasTarget(entity) { // This is the first message we receive from this target. Save the last // message ID in delivery receipts, so that we can send the new message // in the backlog if an offline client reconnects. lastID, err := uc.user.msgStore.LastMsgID(&uc.network.Network, entityCM, time.Now()) if err != nil { uc.logger.Printf("failed to log message: failed to get last message ID: %v", err) return "" } uc.network.delivered.ForEachClient(func(clientName string) { uc.network.delivered.StoreID(entity, clientName, lastID) }) } msgID, err := uc.user.msgStore.Append(&uc.network.Network, entityCM, msg) if err != nil { uc.logger.Printf("failed to append message to store: %v", err) return "" } return msgID } // produce appends a message to the logs and forwards it to connected downstream // connections. // // originID is the id of the downstream (origin) that sent the message. If it is not 0 // and origin doesn't support echo-message, the message is forwarded to all // connections except origin. func (uc *upstreamConn) produce(target string, msg *irc.Message, originID uint64) { var msgID string if target != "" { msgID = uc.appendLog(target, msg) } // Don't forward messages if it's a detached channel ch := uc.network.channels.Get(target) detached := ch != nil && ch.Detached ctx := context.TODO() uc.forEachDownstream(func(dc *downstreamConn) { echo := dc.id == originID && msg.Prefix != nil && uc.isOurNick(msg.Prefix.Name) if !detached && (!echo || dc.caps.IsEnabled("echo-message")) { dc.sendMessageWithID(ctx, msg, msgID) } else { dc.advanceMessageWithID(ctx, msg, msgID) } }) } func (uc *upstreamConn) updateAway() { ctx := context.TODO() if !uc.network.AutoAway { return } away := true uc.forEachDownstream(func(dc *downstreamConn) { if dc.away == nil { away = false } }) if away == uc.away { return } if away { reason := "Auto away" if uc.caps.IsAvailable("draft/pre-away") { reason = "*" } uc.SendMessage(ctx, &irc.Message{ Command: "AWAY", Params: []string{reason}, }) } else { uc.SendMessage(ctx, &irc.Message{ Command: "AWAY", }) } uc.away = away } func (uc *upstreamConn) updateChannelAutoDetach(name string) { uch := uc.channels.Get(name) if uch == nil { return } ch := uc.network.channels.Get(name) if ch == nil || ch.Detached { return } uch.updateAutoDetach(ch.DetachAfter) } func (uc *upstreamConn) updateMonitor() { if _, ok := uc.isupport["MONITOR"]; !ok { return } ctx := context.TODO() add := make(map[string]struct{}) var addList []string seen := make(map[string]struct{}) uc.forEachDownstream(func(dc *downstreamConn) { dc.monitored.ForEach(func(target string, _ struct{}) { targetCM := uc.network.casemap(target) if targetCM == serviceNickCM { return } if !uc.monitored.Has(targetCM) { if _, ok := add[targetCM]; !ok { addList = append(addList, targetCM) add[targetCM] = struct{}{} } } else { seen[targetCM] = struct{}{} } }) }) wantNick := database.GetNick(&uc.user.User, &uc.network.Network) wantNickCM := uc.network.casemap(wantNick) if _, ok := add[wantNickCM]; !ok && !uc.monitored.Has(wantNick) && !uc.isOurNick(wantNick) && !uc.hasDesiredNick { addList = append(addList, wantNickCM) add[wantNickCM] = struct{}{} } removeAll := true var removeList []string uc.monitored.ForEach(func(nick string, online bool) { if _, ok := seen[uc.network.casemap(nick)]; ok { removeAll = false } else { removeList = append(removeList, nick) } }) // TODO: better handle the case where len(uc.monitored) + len(addList) // exceeds the limit, probably by immediately sending ERR_MONLISTFULL? if removeAll && len(addList) == 0 && len(removeList) > 0 { // Optimization when the last MONITOR-aware downstream disconnects uc.SendMessage(ctx, &irc.Message{ Command: "MONITOR", Params: []string{"C"}, }) } else { msgs := xirc.GenerateMonitor("-", removeList) msgs = append(msgs, xirc.GenerateMonitor("+", addList)...) for _, msg := range msgs { uc.SendMessage(ctx, msg) } } for _, target := range removeList { uc.monitored.Del(target) if !uc.shouldCacheUserInfo(target) { uc.users.Del(target) } } } func (uc *upstreamConn) stopRegainNickTimer() { if uc.regainNickTimer != nil { uc.regainNickTimer.Stop() // Maybe we're racing with the timer goroutine, so maybe we'll receive // an eventTryRegainNick later on, but tryRegainNick handles that case } uc.regainNickTimer = nil uc.regainNickBackoff = nil } func (uc *upstreamConn) startRegainNickTimer() { if uc.regainNickBackoff != nil || uc.regainNickTimer != nil { panic("startRegainNickTimer called twice") } wantNick := database.GetNick(&uc.user.User, &uc.network.Network) if uc.isOurNick(wantNick) { return } const ( min = 15 * time.Second max = 10 * time.Minute jitter = 10 * time.Second ) uc.regainNickBackoff = newBackoffer(min, max, jitter) uc.regainNickTimer = time.AfterFunc(uc.regainNickBackoff.Next(), func() { e := eventTryRegainNick{uc: uc, nick: wantNick} select { case uc.network.user.events <- e: // ok default: uc.logger.Printf("skipping nick regain attempt: event queue is full") } }) } func (uc *upstreamConn) tryRegainNick(nick string) { ctx := context.TODO() if uc.regainNickTimer == nil { return } // Maybe the user has updated their desired nick wantNick := database.GetNick(&uc.user.User, &uc.network.Network) if wantNick != nick || uc.isOurNick(wantNick) { uc.stopRegainNickTimer() return } uc.regainNickTimer.Reset(uc.regainNickBackoff.Next()) if uc.pendingRegainNick != "" { return } uc.SendMessage(ctx, &irc.Message{ Command: "NICK", Params: []string{wantNick}, }) uc.pendingRegainNick = wantNick } func (uc *upstreamConn) getCachedWHO(mask, fields string) (l []*upstreamUser, ok bool) { // Non-extended WHO fields if fields == "" { fields = "cuhsnfdr" } // Some extensions are required to keep our cached state in sync. We could // require setname for 'r' and chghost for 'h'/'s', but servers usually // implement a QUIT/JOIN fallback, so let's not bother. // TODO: Avoid storing fields we cannot keep up to date, instead of storing them // then failing here. eg if we don't have account-notify, avoid storing the ACCOUNT // in the first place. if strings.IndexByte(fields, 'a') >= 0 && !uc.caps.IsEnabled("account-notify") { return nil, false } if strings.IndexByte(fields, 'f') >= 0 && !uc.caps.IsEnabled("away-notify") { return nil, false } if uu := uc.users.Get(mask); uu != nil { if uu.hasWHOXFields(fields) { return []*upstreamUser{uu}, true } } else if uch := uc.channels.Get(mask); uch != nil { l = make([]*upstreamUser, 0, uch.Members.Len()) ok = true uch.Members.ForEach(func(nick string, membershipSet *xirc.MembershipSet) { if !ok { return } uu := uc.users.Get(nick) if uu == nil || !uu.hasWHOXFields(fields) { ok = false } else { l = append(l, uu) } }) if !ok { return nil, false } return l, true } return nil, false } func (uc *upstreamConn) cacheUserInfo(nick string, info *upstreamUser) { if nick == "" { panic("cacheUserInfo called with empty nickname") } uu := uc.users.Get(nick) if uu == nil { if info.Nickname != "" { nick = info.Nickname } else { info.Nickname = nick } uc.users.Set(info.Nickname, info) } else { uu.updateFrom(info) if info.Nickname != "" && nick != info.Nickname { uc.users.Del(nick) uc.users.Set(uu.Nickname, uu) } } } func (uc *upstreamConn) shouldCacheUserInfo(nick string) bool { if uc.isOurNick(nick) { return true } // keep the cached user info only if we MONITOR it, or we share a channel with them if uc.monitored.Has(nick) { return true } found := false uc.channels.ForEach(func(_ string, ch *upstreamChannel) { found = found || ch.Members.Has(nick) }) return found } // resolveIPAddr replaces the standard library's DNS resolver to randomize the // result order instead of always returning the same IP address. The bouncer // will often have bursts of connections to the same host (e.g. on startup) so // it's more important for our use-case to distribute the traffic among // available IP addresses than to find the fastest link. // // See: https://todo.sr.ht/~emersion/soju/221 func resolveIPAddr(ctx context.Context, host string) (*net.IPAddr, error) { ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } // Prefer IPv6 if available, for per-user local IP addresses ip6Addrs := make([]net.IPAddr, 0, len(ipAddrs)) for _, ipAddr := range ipAddrs { if ipAddr.IP.To4() == nil { ip6Addrs = append(ip6Addrs, ipAddr) } } if len(ip6Addrs) > 0 { ipAddrs = ip6Addrs } i := rand.Intn(len(ipAddrs)) return &ipAddrs[i], nil } ����������������������������������������������������������������������������������������������������������������soju-0.9.0/user.go����������������������������������������������������������������������������������0000664�0000000�0000000�00000101704�14770724770�0014027�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package soju import ( "context" "crypto/sha256" "encoding/binary" "encoding/hex" "errors" "fmt" "math/big" "net" "sort" "strings" "sync/atomic" "time" "codeberg.org/emersion/soju/xirc" "github.com/SherClockHolmes/webpush-go" "gopkg.in/irc.v4" "codeberg.org/emersion/soju/database" "codeberg.org/emersion/soju/msgstore" ) type UserUpdateFunc func(record *database.User) error type event interface{} type eventUpstreamMessage struct { msg *irc.Message uc *upstreamConn } type eventUpstreamConnectionError struct { net *network err error } type eventUpstreamConnected struct { uc *upstreamConn } type eventUpstreamDisconnected struct { uc *upstreamConn } type eventUpstreamError struct { uc *upstreamConn err error } type eventDownstreamMessage struct { msg *irc.Message dc *downstreamConn } type eventDownstreamConnected struct { dc *downstreamConn } type eventDownstreamDisconnected struct { dc *downstreamConn } type eventChannelDetach struct { uc *upstreamConn name string } type eventBroadcast struct { msg *irc.Message } type eventStop struct{} type eventUserUpdate struct { password *string admin *bool enabled *bool maxNetworks *int done chan error } type eventTryRegainNick struct { uc *upstreamConn nick string } type eventUserRun struct { params []string ch chan userRunMsg } type deliveredClientMap map[string]string // client name -> msg ID type deliveredStore struct { m xirc.CaseMappingMap[deliveredClientMap] } func newDeliveredStore(cm xirc.CaseMapping) deliveredStore { return deliveredStore{xirc.NewCaseMappingMap[deliveredClientMap](cm)} } func (ds deliveredStore) Empty() bool { return ds.m.Len() == 0 } func (ds deliveredStore) HasTarget(target string) bool { return ds.m.Get(target) != nil } func (ds deliveredStore) LoadID(target, clientName string) string { clients := ds.m.Get(target) if clients == nil { return "" } return clients[clientName] } func (ds deliveredStore) StoreID(target, clientName, msgID string) { clients := ds.m.Get(target) if clients == nil { clients = make(deliveredClientMap) ds.m.Set(target, clients) } clients[clientName] = msgID } func (ds deliveredStore) ForEachTarget(f func(target string)) { ds.m.ForEach(func(name string, _ deliveredClientMap) { f(name) }) } func (ds deliveredStore) ForEachClient(f func(clientName string)) { clients := make(map[string]struct{}) ds.m.ForEach(func(name string, delivered deliveredClientMap) { for clientName := range delivered { clients[clientName] = struct{}{} } }) for clientName := range clients { f(clientName) } } type network struct { database.Network user *user logger Logger stopped chan struct{} conn *upstreamConn channels xirc.CaseMappingMap[*database.Channel] delivered deliveredStore pushTargets xirc.CaseMappingMap[time.Time] lastError error casemap xirc.CaseMapping } func newNetwork(user *user, record *database.Network, channels []database.Channel) *network { logger := &prefixLogger{user.logger, fmt.Sprintf("network %q: ", record.GetName())} // Initialize maps with the most strict case-mapping to avoid collisions: // we don't know which case-mapping will be used by the upstream server yet cm := xirc.CaseMappingASCII m := xirc.NewCaseMappingMap[*database.Channel](cm) for _, ch := range channels { ch := ch m.Set(ch.Name, &ch) } return &network{ Network: *record, user: user, logger: logger, stopped: make(chan struct{}), channels: m, delivered: newDeliveredStore(cm), pushTargets: xirc.NewCaseMappingMap[time.Time](cm), casemap: stdCaseMapping, } } func (net *network) forEachDownstream(f func(*downstreamConn)) { for _, dc := range net.user.downstreamConns { if dc.network != net { continue } f(dc) } } func (net *network) isStopped() bool { select { case <-net.stopped: return true default: return false } } func (net *network) equalCasemap(a, b string) bool { return net.casemap(a) == net.casemap(b) } func userIdent(u *database.User) string { // The ident is a string we will send to upstream servers in clear-text. // For privacy reasons, make sure it doesn't expose any meaningful user // metadata. We just use the base64-encoded hashed ID, so that people don't // start relying on the string being an integer or following a pattern. var b [64]byte binary.LittleEndian.PutUint64(b[:], uint64(u.ID)) h := sha256.Sum256(b[:]) return hex.EncodeToString(h[:16]) } func (net *network) runConn(ctx context.Context) error { net.user.srv.metrics.upstreams.Add(1) defer net.user.srv.metrics.upstreams.Add(-1) ctx, cancel := context.WithCancel(ctx) defer cancel() done := ctx.Done() // This must not be subject to timeout ctx, cancelTimeout := context.WithTimeout(ctx, time.Minute) defer cancelTimeout() uc, err := connectToUpstream(ctx, net) if err != nil { return fmt.Errorf("failed to connect: %w", err) } defer uc.Close() // The context is cancelled by the caller when the network is stopped. go func() { <-done uc.Close() }() if net.user.srv.Identd != nil { net.user.srv.Identd.Store(uc.RemoteAddr().String(), uc.LocalAddr().String(), userIdent(&net.user.User)) defer net.user.srv.Identd.Delete(uc.RemoteAddr().String(), uc.LocalAddr().String()) } // TODO: this is racy, we're not running in the user goroutine yet // uc.register accesses user/network DB records uc.register(ctx) if err := uc.runUntilRegistered(ctx); err != nil { return fmt.Errorf("failed to register: %w", err) } net.user.events <- eventUpstreamConnected{uc} defer func() { net.user.events <- eventUpstreamDisconnected{uc} }() if err := uc.readMessages(net.user.events); err != nil { return fmt.Errorf("failed to handle messages: %w", err) } return nil } func (net *network) run() { if !net.user.Enabled || !net.Enabled { return } ctx, cancel := context.WithCancel(context.TODO()) go func() { <-net.stopped cancel() }() var lastTry time.Time backoff := newBackoffer(retryConnectMinDelay, retryConnectMaxDelay, retryConnectJitter) for { if net.isStopped() { return } delay := backoff.Next() - time.Now().Sub(lastTry) if delay > 0 { net.logger.Printf("waiting %v before trying to reconnect to %q", delay.Truncate(time.Second), net.Addr) time.Sleep(delay) } lastTry = time.Now() if err := net.runConn(ctx); err != nil { text := err.Error() temp := true var regErr registrationError if errors.As(err, ®Err) { text = "failed to register: " + regErr.Reason() temp = regErr.Temporary() } net.logger.Printf("connection error to %q: %v", net.Addr, text) net.user.events <- eventUpstreamConnectionError{net, fmt.Errorf("connection error: %v", err)} net.user.srv.metrics.upstreamConnectErrorsTotal.Inc() if !temp { return } } else { backoff.Reset() } } } func (net *network) stop() { if !net.isStopped() { close(net.stopped) } } func (net *network) detach(ch *database.Channel) { if ch.Detached { return } net.logger.Printf("detaching channel %q", ch.Name) ch.Detached = true if net.user.msgStore != nil { nameCM := net.casemap(ch.Name) lastID, err := net.user.msgStore.LastMsgID(&net.Network, nameCM, time.Now()) if err != nil { net.logger.Printf("failed to get last message ID for channel %q: %v", ch.Name, err) } ch.DetachedInternalMsgID = lastID } if net.conn != nil { uch := net.conn.channels.Get(ch.Name) if uch != nil { uch.updateAutoDetach(0) } } net.forEachDownstream(func(dc *downstreamConn) { dc.SendMessage(context.TODO(), &irc.Message{ Prefix: dc.prefix(), Command: "PART", Params: []string{ch.Name, "Detach"}, }) }) } func (net *network) attach(ctx context.Context, ch *database.Channel) { if !ch.Detached { return } net.logger.Printf("attaching channel %q", ch.Name) detachedMsgID := ch.DetachedInternalMsgID ch.Detached = false ch.DetachedInternalMsgID = "" var uch *upstreamChannel if net.conn != nil { uch = net.conn.channels.Get(ch.Name) net.conn.updateChannelAutoDetach(ch.Name) } net.forEachDownstream(func(dc *downstreamConn) { dc.SendMessage(ctx, &irc.Message{ Prefix: dc.prefix(), Command: "JOIN", Params: []string{ch.Name}, }) if uch != nil { forwardChannel(ctx, dc, uch) } if detachedMsgID != "" { dc.sendTargetBacklog(ctx, net, ch.Name, detachedMsgID) } }) } func (net *network) deleteChannel(ctx context.Context, name string) error { ch := net.channels.Get(name) if ch == nil { return fmt.Errorf("unknown channel %q", name) } if net.conn != nil { uch := net.conn.channels.Get(ch.Name) if uch != nil { uch.updateAutoDetach(0) } } if err := net.user.srv.db.DeleteChannel(ctx, ch.ID); err != nil { return err } net.channels.Del(name) return nil } func (net *network) updateCasemapping(newCasemap xirc.CaseMapping) { net.casemap = newCasemap net.channels.SetCaseMapping(newCasemap) net.delivered.m.SetCaseMapping(newCasemap) net.pushTargets.SetCaseMapping(newCasemap) if uc := net.conn; uc != nil { uc.channels.SetCaseMapping(newCasemap) uc.channels.ForEach(func(_ string, uch *upstreamChannel) { uch.Members.SetCaseMapping(newCasemap) }) uc.users.SetCaseMapping(newCasemap) uc.monitored.SetCaseMapping(newCasemap) } net.forEachDownstream(func(dc *downstreamConn) { dc.updateCasemapping() }) } func (net *network) storeClientDeliveryReceipts(ctx context.Context, clientName string) { if !net.user.hasPersistentMsgStore() { return } var receipts []database.DeliveryReceipt net.delivered.ForEachTarget(func(target string) { msgID := net.delivered.LoadID(target, clientName) if msgID == "" { return } receipts = append(receipts, database.DeliveryReceipt{ Target: target, InternalMsgID: msgID, }) }) if err := net.user.srv.db.StoreClientDeliveryReceipts(ctx, net.ID, clientName, receipts); err != nil { net.logger.Printf("failed to store delivery receipts for client %q: %v", clientName, err) } } func (net *network) isHighlight(msg *irc.Message) bool { if msg.Command != "PRIVMSG" && msg.Command != "NOTICE" { return false } text := msg.Params[1] nick := database.GetNick(&net.user.User, &net.Network) if net.conn != nil { nick = net.conn.nick } // TODO: use case-mapping aware comparison here return msg.Prefix.Name != nick && isHighlight(text, nick) } func (net *network) detachedMessageNeedsRelay(ch *database.Channel, msg *irc.Message) bool { highlight := net.isHighlight(msg) return ch.RelayDetached == database.FilterMessage || ((ch.RelayDetached == database.FilterHighlight || ch.RelayDetached == database.FilterDefault) && highlight) } func (net *network) autoSaveSASLPlain(ctx context.Context, username, password string) { // User may have e.g. EXTERNAL mechanism configured. We do not want to // automatically erase the key pair or any other credentials. if net.SASL.Mechanism != "" && net.SASL.Mechanism != "PLAIN" { return } net.logger.Printf("auto-saving SASL PLAIN credentials with username %q", username) net.SASL.Mechanism = "PLAIN" net.SASL.Plain.Username = username net.SASL.Plain.Password = password if err := net.user.srv.db.StoreNetwork(ctx, net.user.ID, &net.Network); err != nil { net.logger.Printf("failed to save SASL PLAIN credentials: %v", err) } } // broadcastWebPush broadcasts a Web Push message for the given IRC message. // // Broadcasting the message to all Web Push endpoints might take a while, so // callers should call this function in a new goroutine. func (net *network) broadcastWebPush(msg *irc.Message) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() subs, err := net.user.srv.db.ListWebPushSubscriptions(ctx, net.user.ID, net.ID) if err != nil { net.logger.Printf("failed to list Web push subscriptions: %v", err) return } for _, sub := range subs { err := net.user.srv.sendWebPush(ctx, &webpush.Subscription{ Endpoint: sub.Endpoint, Keys: webpush.Keys{ Auth: sub.Keys.Auth, P256dh: sub.Keys.P256DH, }, }, sub.Keys.VAPID, msg) if err == errWebPushSubscriptionExpired { if err := net.user.srv.db.DeleteWebPushSubscription(ctx, sub.ID); err != nil { net.logger.Printf("failed to delete expired Web Push subscription %q: %v", sub.Endpoint, err) } else { net.logger.Debugf("deleted expired Web Push subscription %q", sub.Endpoint) } } else if err != nil { net.logger.Printf("failed to send Web push notification to endpoint %q: %v", sub.Endpoint, err) // If it failed for any reason and is old, delete it if time.Since(sub.UpdatedAt) > webpushPruneSubscriptionDelay { if err := net.user.srv.db.DeleteWebPushSubscription(ctx, sub.ID); err != nil { net.logger.Printf("failed to delete pruned Web Push subscription %q: %v", sub.Endpoint, err) } else { net.logger.Printf("deleted pruned Web Push subscription %q", sub.Endpoint) } } } } } type user struct { database.User srv *Server logger Logger events chan event done chan struct{} numDownstreamConns atomic.Int64 networks []*network downstreamConns []*downstreamConn msgStore msgstore.Store } func newUser(srv *Server, record *database.User) *user { logger := &prefixLogger{srv.Logger, fmt.Sprintf("user %q: ", record.Username)} var msgStore msgstore.Store switch srv.Config().MsgStoreDriver { case "fs": msgStore = msgstore.NewFSStore(srv.Config().MsgStorePath, record) case "db": msgStore = msgstore.NewDBStore(srv.db) case "memory": msgStore = msgstore.NewMemoryStore() } return &user{ User: *record, srv: srv, logger: logger, events: make(chan event, 64), done: make(chan struct{}), msgStore: msgStore, } } func (u *user) forEachUpstream(f func(uc *upstreamConn)) { for _, network := range u.networks { if network.conn == nil { continue } f(network.conn) } } func (u *user) getNetwork(name string) *network { for _, network := range u.networks { if network.Addr == name { return network } if network.Name != "" && network.Name == name { return network } } return nil } func (u *user) getNetworkByID(id int64) *network { for _, net := range u.networks { if net.ID == id { return net } } return nil } func (u *user) run() { defer func() { if u.msgStore != nil { if err := u.msgStore.Close(); err != nil { u.logger.Printf("failed to close message store for user %q: %v", u.Username, err) } } close(u.done) }() networks, err := u.srv.db.ListNetworks(context.TODO(), u.ID) if err != nil { u.logger.Printf("failed to list networks for user %q: %v", u.Username, err) return } sort.Slice(networks, func(i, j int) bool { return networks[i].ID < networks[j].ID }) for _, record := range networks { record := record channels, err := u.srv.db.ListChannels(context.TODO(), record.ID) if err != nil { u.logger.Printf("failed to list channels for user %q, network %q: %v", u.Username, record.GetName(), err) continue } network := newNetwork(u, &record, channels) u.networks = append(u.networks, network) if u.hasPersistentMsgStore() { receipts, err := u.srv.db.ListDeliveryReceipts(context.TODO(), record.ID) if err != nil { u.logger.Printf("failed to load delivery receipts for user %q, network %q: %v", u.Username, network.GetName(), err) return } for _, rcpt := range receipts { network.delivered.StoreID(rcpt.Target, rcpt.Client, rcpt.InternalMsgID) } } go network.run() } for e := range u.events { switch e := e.(type) { case eventUpstreamConnected: uc := e.uc uc.network.conn = uc uc.updateAway() uc.updateMonitor() ctx := context.TODO() uc.forEachDownstream(func(dc *downstreamConn) { dc.updateSupportedCaps(ctx) if !dc.caps.IsEnabled("soju.im/bouncer-networks") { sendServiceNOTICE(dc, fmt.Sprintf("connected to %s", uc.network.GetName())) } dc.updateNick(ctx) dc.updateHost(ctx) dc.updateRealname(ctx) dc.updateAccount(ctx) dc.updateCasemapping() }) u.notifyBouncerNetworkState(uc.network.ID, irc.Tags{ "state": "connected", "error": "", }) uc.network.lastError = nil case eventUpstreamDisconnected: u.handleUpstreamDisconnected(e.uc) case eventUpstreamConnectionError: net := e.net stopped := false select { case <-net.stopped: stopped = true default: } if !stopped && (net.lastError == nil || net.lastError.Error() != e.err.Error()) { net.forEachDownstream(func(dc *downstreamConn) { sendServiceNOTICE(dc, fmt.Sprintf("failed connecting/registering to %s: %v", net.GetName(), e.err)) }) } net.lastError = e.err u.notifyBouncerNetworkState(net.ID, irc.Tags{ "error": net.lastError.Error(), }) case eventUpstreamError: uc := e.uc uc.forEachDownstream(func(dc *downstreamConn) { sendServiceNOTICE(dc, fmt.Sprintf("disconnected from %s: %v", uc.network.GetName(), e.err)) }) uc.network.lastError = e.err u.notifyBouncerNetworkState(uc.network.ID, irc.Tags{ "error": uc.network.lastError.Error(), }) case eventUpstreamMessage: msg, uc := e.msg, e.uc if uc.isClosed() { uc.logger.Printf("ignoring message on closed connection: %v", msg) break } if err := uc.handleMessage(context.TODO(), msg); err != nil { uc.logger.Printf("failed to handle message %q: %v", msg, err) } case eventChannelDetach: uc, name := e.uc, e.name c := uc.network.channels.Get(name) if c == nil || c.Detached { continue } uc.network.detach(c) if err := uc.srv.db.StoreChannel(context.TODO(), uc.network.ID, c); err != nil { u.logger.Printf("failed to store updated detached channel %q: %v", c.Name, err) } case eventDownstreamConnected: dc := e.dc ctx := context.TODO() if dc.network != nil { dc.monitored.SetCaseMapping(dc.network.casemap) } if !u.Enabled && u.srv.Config().EnableUsersOnAuth { err := u.updateUser(ctx, func(record *database.User) error { record.Enabled = true return nil }) if err != nil { dc.logger.Printf("failed to enable user after successful authentication: %v", err) } } if !u.Enabled { dc.SendMessage(ctx, &irc.Message{ Command: "ERROR", Params: []string{"This bouncer account is disabled"}, }) // TODO: close dc after the error message is sent break } if err := dc.welcome(ctx, u); err != nil { if ircErr, ok := err.(ircError); ok { msg := ircErr.Message.Copy() msg.Prefix = dc.srv.prefix() dc.SendMessage(ctx, msg) } else { dc.SendMessage(ctx, &irc.Message{ Command: "ERROR", Params: []string{"Internal server error"}, }) } dc.logger.Printf("failed to handle new registered connection: %v", err) // TODO: close dc after the error message is sent break } u.downstreamConns = append(u.downstreamConns, dc) u.numDownstreamConns.Add(1) dc.forEachNetwork(func(network *network) { if network.lastError != nil { sendServiceNOTICE(dc, fmt.Sprintf("disconnected from %s: %v", network.GetName(), network.lastError)) } }) u.forEachUpstream(func(uc *upstreamConn) { uc.updateAway() }) if !dc.impersonating { u.bumpDownstreamInteractionTime(ctx) } case eventDownstreamDisconnected: dc := e.dc ctx := context.TODO() for i := range u.downstreamConns { if u.downstreamConns[i] == dc { u.downstreamConns = append(u.downstreamConns[:i], u.downstreamConns[i+1:]...) u.numDownstreamConns.Add(-1) break } } dc.forEachNetwork(func(net *network) { net.storeClientDeliveryReceipts(ctx, dc.clientName) }) u.forEachUpstream(func(uc *upstreamConn) { uc.cancelPendingCommandsByDownstreamID(dc.id) uc.updateAway() uc.updateMonitor() }) if !dc.impersonating { u.bumpDownstreamInteractionTime(ctx) } case eventDownstreamMessage: msg, dc := e.msg, e.dc if dc.isClosed() { dc.logger.Printf("ignoring message on closed connection: %v", msg) break } err := dc.handleMessage(context.TODO(), msg) if ircErr, ok := err.(ircError); ok { ircErr.Message.Prefix = dc.srv.prefix() dc.SendMessage(context.TODO(), ircErr.Message) } else if err != nil { dc.logger.Printf("failed to handle message %q: %v", msg, err) dc.Close() } case eventBroadcast: msg := e.msg for _, dc := range u.downstreamConns { dc.SendMessage(context.TODO(), msg) } case eventUserUpdate: e.done <- u.updateUser(context.TODO(), func(record *database.User) error { if e.password != nil { record.Password = *e.password } if e.admin != nil { record.Admin = *e.admin } if e.enabled != nil { record.Enabled = *e.enabled } if e.maxNetworks != nil { record.MaxNetworks = *e.maxNetworks } return nil }) // If the password was updated, kill all downstream connections to // force them to re-authenticate with the new credentials. if e.password != nil { for _, dc := range u.downstreamConns { dc.Close() } } case eventTryRegainNick: e.uc.tryRegainNick(e.nick) case eventUserRun: ctx := context.TODO() err := handleServiceCommand(&serviceContext{ Context: ctx, user: u, srv: u.srv, // Here we are setting an admin context on any user run command // that is run. // As a reminder, user run can only be run by an admin. // This enables admins to run actions on a user with admin rights, // for example to add a network past the user limit. // Non-admin users cannot run user run, not even on themselves, // so this cannot be used to escalate privileges. admin: true, print: func(text string) { // Avoid blocking on e.print in case our context is canceled. // This is a no-op right now because we use context.TODO(), // but might be useful later when we add timeouts. select { case <-ctx.Done(): case e.ch <- userRunMsg{message: text}: } }, }, e.params) select { case <-ctx.Done(): case e.ch <- userRunMsg{err: err}: } case eventStop: for _, dc := range u.downstreamConns { dc.Close() } for _, n := range u.networks { n.stop() n.delivered.ForEachClient(func(clientName string) { n.storeClientDeliveryReceipts(context.TODO(), clientName) }) } return default: panic(fmt.Sprintf("received unknown event type: %T", e)) } } } func (u *user) handleUpstreamDisconnected(uc *upstreamConn) { uc.network.conn = nil uc.stopRegainNickTimer() uc.abortPendingCommands() uc.channels.ForEach(func(_ string, uch *upstreamChannel) { uch.updateAutoDetach(0) }) uc.forEachDownstream(func(dc *downstreamConn) { dc.updateSupportedCaps(context.TODO()) }) // If the network has been removed, don't send a state change notification found := false for _, net := range u.networks { if net == uc.network { found = true break } } if !found { return } u.notifyBouncerNetworkState(uc.network.ID, irc.Tags{"state": "disconnected"}) if uc.network.lastError == nil { uc.forEachDownstream(func(dc *downstreamConn) { if !dc.caps.IsEnabled("soju.im/bouncer-networks") { sendServiceNOTICE(dc, fmt.Sprintf("disconnected from %s", uc.network.GetName())) } }) } } func (u *user) notifyBouncerNetworkState(netID int64, attrs irc.Tags) { // Don't send state updates for removed networks found := false for _, net := range u.networks { if net.ID == netID { found = true break } } if !found { return } netIDStr := fmt.Sprintf("%v", netID) for _, dc := range u.downstreamConns { if dc.caps.IsEnabled("soju.im/bouncer-networks-notify") { dc.SendMessage(context.TODO(), &irc.Message{ Command: "BOUNCER", Params: []string{"NETWORK", netIDStr, attrs.String()}, }) } } } func (u *user) addNetwork(network *network) { u.networks = append(u.networks, network) sort.Slice(u.networks, func(i, j int) bool { return u.networks[i].ID < u.networks[j].ID }) go network.run() } func (u *user) removeNetwork(network *network) { network.stop() for _, dc := range u.downstreamConns { if dc.network != nil && dc.network == network { dc.Close() } } for i, net := range u.networks { if net == network { u.networks = append(u.networks[:i], u.networks[i+1:]...) return } } panic("tried to remove a non-existing network") } func (u *user) canEnableNewNetwork() error { max := u.MaxNetworks if max < 0 { max = u.srv.Config().MaxUserNetworks } if max < 0 { return nil } n := 0 for _, network := range u.networks { if network.Enabled { n++ } } if n >= max { return fmt.Errorf("maximum number of enabled networks reached") } return nil } func (u *user) checkNetwork(record *database.Network) error { url, err := record.URL() if err != nil { return err } if url.User != nil { return fmt.Errorf("%v:// URL must not have username and password information", url.Scheme) } if url.RawQuery != "" { return fmt.Errorf("%v:// URL must not have query values", url.Scheme) } if url.Fragment != "" { return fmt.Errorf("%v:// URL must not have a fragment", url.Scheme) } switch url.Scheme { case "ircs", "irc+insecure": if url.Host == "" { return fmt.Errorf("%v:// URL must have a host", url.Scheme) } if url.Path != "" { return fmt.Errorf("%v:// URL must not have a path", url.Scheme) } case "irc+unix", "unix": if !u.Admin { return fmt.Errorf("non-admin users cannot add networks with a unix address") } if url.Host != "" { return fmt.Errorf("%v:// URL must not have a host", url.Scheme) } if url.Path == "" { return fmt.Errorf("%v:// URL must have a path", url.Scheme) } default: return fmt.Errorf("unknown URL scheme %q", url.Scheme) } if record.GetName() == "" { return fmt.Errorf("network name cannot be empty") } if strings.HasPrefix(record.GetName(), "-") { // Can be mixed up with flags when sending commands to the service return fmt.Errorf("network name cannot start with a dash character") } for _, net := range u.networks { if net.GetName() == record.GetName() && net.ID != record.ID { return fmt.Errorf("a network with the name %q already exists", record.GetName()) } } return nil } func (u *user) createNetwork(ctx context.Context, record *database.Network, enforceLimit bool) (*network, error) { if record.ID != 0 { panic("tried creating an already-existing network") } if enforceLimit && record.Enabled { if err := u.canEnableNewNetwork(); err != nil { return nil, err } } if err := u.checkNetwork(record); err != nil { return nil, err } network := newNetwork(u, record, nil) err := u.srv.db.StoreNetwork(ctx, u.ID, &network.Network) if err != nil { return nil, err } u.addNetwork(network) attrs := getNetworkAttrs(network) u.notifyBouncerNetworkState(network.ID, attrs) return network, nil } func (u *user) updateNetwork(ctx context.Context, record *database.Network, enforceLimit bool) (*network, error) { if record.ID == 0 { panic("tried updating a new network") } if enforceLimit && record.Enabled { if err := u.canEnableNewNetwork(); err != nil { return nil, err } } // If the nickname/realname is reset to the default, just wipe the // per-network setting if record.Nick == u.Nick { record.Nick = "" } if record.Realname == u.Realname { record.Realname = "" } if err := u.checkNetwork(record); err != nil { return nil, err } network := u.getNetworkByID(record.ID) if network == nil { panic("tried updating a non-existing network") } if err := u.srv.db.StoreNetwork(ctx, u.ID, record); err != nil { return nil, err } // Most network changes require us to re-connect to the upstream server channels := make([]database.Channel, 0, network.channels.Len()) network.channels.ForEach(func(_ string, ch *database.Channel) { channels = append(channels, *ch) }) updatedNetwork := newNetwork(u, record, channels) // If we're currently connected, disconnect and perform the necessary // bookkeeping network.stop() if network.conn != nil { // Note: this will set network.conn to nil u.handleUpstreamDisconnected(network.conn) } // Patch downstream connections to use our fresh updated network for _, dc := range u.downstreamConns { if dc.network != nil && dc.network == network { dc.network = updatedNetwork } } // We need to remove the network after patching downstream connections, // otherwise they'll get closed u.removeNetwork(network) // The filesystem message store needs to be notified whenever the network // is renamed renameNetMsgStore, ok := u.msgStore.(msgstore.RenameNetworkStore) if ok && updatedNetwork.GetName() != network.GetName() { if err := renameNetMsgStore.RenameNetwork(&network.Network, &updatedNetwork.Network); err != nil { network.logger.Printf("failed to update message store network name to %q: %v", updatedNetwork.GetName(), err) } } // This will re-connect to the upstream server u.addNetwork(updatedNetwork) // TODO: only broadcast attributes that have changed attrs := getNetworkAttrs(updatedNetwork) u.notifyBouncerNetworkState(updatedNetwork.ID, attrs) return updatedNetwork, nil } func (u *user) deleteNetwork(ctx context.Context, id int64) error { network := u.getNetworkByID(id) if network == nil { panic("tried deleting a non-existing network") } if err := u.srv.db.DeleteNetwork(ctx, network.ID); err != nil { return err } u.removeNetwork(network) idStr := fmt.Sprintf("%v", network.ID) for _, dc := range u.downstreamConns { if dc.caps.IsEnabled("soju.im/bouncer-networks-notify") { dc.SendMessage(ctx, &irc.Message{ Command: "BOUNCER", Params: []string{"NETWORK", idStr, "*"}, }) } } return nil } func (u *user) updateUser(ctx context.Context, update UserUpdateFunc) error { record := u.User // copy if err := update(&record); err != nil { return err } nickUpdated := u.Nick != record.Nick realnameUpdated := u.Realname != record.Realname enabledUpdated := u.Enabled != record.Enabled if err := u.srv.db.StoreUser(ctx, &record); err != nil { return fmt.Errorf("failed to update user %q: %v", u.Username, err) } u.User = record if nickUpdated { for _, net := range u.networks { if net.Nick != "" { continue } if uc := net.conn; uc != nil { uc.SendMessage(ctx, &irc.Message{ Command: "NICK", Params: []string{database.GetNick(&u.User, &net.Network)}, }) } } } if realnameUpdated || enabledUpdated { // Re-connect to networks which use the default realname var needUpdate []database.Network for _, net := range u.networks { // If only the realname was updated, maybe we can skip the // re-connect if realnameUpdated && !enabledUpdated { // If this network has a custom realname set, no need to // re-connect: the user-wide realname remains unused if net.Realname != "" { continue } // We only need to call updateNetwork for upstreams that don't // support setname if uc := net.conn; uc != nil && uc.caps.IsEnabled("setname") { uc.SendMessage(ctx, &irc.Message{ Command: "SETNAME", Params: []string{database.GetRealname(&u.User, &net.Network)}, }) continue } } needUpdate = append(needUpdate, net.Network) } var netErr error for _, net := range needUpdate { if _, err := u.updateNetwork(ctx, &net, false); err != nil { netErr = err } } if netErr != nil { return netErr } } if !u.Enabled { // TODO: send an error message before disconnecting for _, dc := range u.downstreamConns { dc.Close() } } return nil } func (u *user) stop(ctx context.Context) error { select { case <-u.done: return nil // already stopped case u.events <- eventStop{}: // we've requested to stop, let's wait for the user goroutine to exit case <-ctx.Done(): return ctx.Err() } select { case <-u.done: return nil case <-ctx.Done(): return ctx.Err() } } func (u *user) hasPersistentMsgStore() bool { if u.msgStore == nil { return false } return !msgstore.IsMemoryStore(u.msgStore) } func (u *user) FormatServerTime(t time.Time) string { if u.msgStore != nil && msgstore.IsFSStore(u.msgStore) { // The FS message store truncates message timestamps to the second, // so truncate them here to get consistent timestamps. t = t.Truncate(time.Second) } return xirc.FormatServerTime(t) } // localTCPAddr returns the local address to use when connecting to a host. // A nil address is returned when the OS should automatically pick one. func (u *user) localTCPAddr(remoteIP net.IP) (*net.TCPAddr, error) { upstreamUserIPs := u.srv.Config().UpstreamUserIPs if len(upstreamUserIPs) == 0 { return nil, nil } wantIPv6 := remoteIP.To4() == nil var ipNet *net.IPNet for _, in := range upstreamUserIPs { if wantIPv6 == (in.IP.To4() == nil) { ipNet = in break } } if ipNet == nil { return nil, nil } var ipInt big.Int ipInt.SetBytes(ipNet.IP) ipInt.Add(&ipInt, big.NewInt(u.ID+1)) ip := net.IP(ipInt.Bytes()) if !ipNet.Contains(ip) { return nil, fmt.Errorf("IP network %v too small", ipNet) } return &net.TCPAddr{IP: ip}, nil } func (u *user) bumpDownstreamInteractionTime(ctx context.Context) { err := u.updateUser(ctx, func(record *database.User) error { record.DownstreamInteractedAt = time.Now() return nil }) if err != nil { u.logger.Printf("failed to bump downstream interaction time: %v", err) } } ������������������������������������������������������������soju-0.9.0/xirc/������������������������������������������������������������������������������������0000775�0000000�0000000�00000000000�14770724770�0013464�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/xirc/caps.go�����������������������������������������������������������������������������0000664�0000000�0000000�00000001256�14770724770�0014745�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package xirc type CapRegistry struct { Available map[string]string Enabled map[string]struct{} } func NewCapRegistry() CapRegistry { return CapRegistry{ Available: make(map[string]string), Enabled: make(map[string]struct{}), } } func (cr *CapRegistry) IsAvailable(name string) bool { _, ok := cr.Available[name] return ok } func (cr *CapRegistry) IsEnabled(name string) bool { _, ok := cr.Enabled[name] return ok } func (cr *CapRegistry) Del(name string) { delete(cr.Available, name) delete(cr.Enabled, name) } func (cr *CapRegistry) SetEnabled(name string, enabled bool) { if enabled { cr.Enabled[name] = struct{}{} } else { delete(cr.Enabled, name) } } ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/xirc/casemapping.go����������������������������������������������������������������������0000664�0000000�0000000�00000005501�14770724770�0016303�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package xirc func casemapASCII(name string) string { nameBytes := []byte(name) for i, r := range nameBytes { if 'A' <= r && r <= 'Z' { nameBytes[i] = r + 'a' - 'A' } } return string(nameBytes) } func casemapRFC1459(name string) string { nameBytes := []byte(name) for i, r := range nameBytes { if 'A' <= r && r <= 'Z' { nameBytes[i] = r + 'a' - 'A' } else if r == '{' { nameBytes[i] = '[' } else if r == '}' { nameBytes[i] = ']' } else if r == '\\' { nameBytes[i] = '|' } else if r == '~' { nameBytes[i] = '^' } } return string(nameBytes) } func casemapRFC1459Strict(name string) string { nameBytes := []byte(name) for i, r := range nameBytes { if 'A' <= r && r <= 'Z' { nameBytes[i] = r + 'a' - 'A' } else if r == '{' { nameBytes[i] = '[' } else if r == '}' { nameBytes[i] = ']' } else if r == '\\' { nameBytes[i] = '|' } } return string(nameBytes) } // CaseMapping returns the canonical representation of a name according to an // IRC case-mapping. type CaseMapping func(string) string var ( CaseMappingASCII CaseMapping = casemapASCII CaseMappingRFC1459 CaseMapping = casemapRFC1459 CaseMappingRFC1459Strict CaseMapping = casemapRFC1459Strict ) func ParseCaseMapping(s string) CaseMapping { var cm CaseMapping switch s { case "ascii": cm = CaseMappingASCII case "rfc1459": cm = CaseMappingRFC1459 case "rfc1459-strict": cm = CaseMappingRFC1459Strict } return cm } type CaseMappingMap[V interface{}] struct { m map[string]caseMappingEntry[V] casemap CaseMapping } type caseMappingEntry[V interface{}] struct { originalKey string value V } func NewCaseMappingMap[V interface{}](cm CaseMapping) CaseMappingMap[V] { return CaseMappingMap[V]{ m: make(map[string]caseMappingEntry[V]), casemap: cm, } } func (cmm *CaseMappingMap[V]) Has(name string) bool { _, ok := cmm.m[cmm.casemap(name)] return ok } func (cmm *CaseMappingMap[V]) Len() int { return len(cmm.m) } func (cmm *CaseMappingMap[V]) Get(name string) V { entry, ok := cmm.m[cmm.casemap(name)] if !ok { var v V return v } return entry.value } func (cmm *CaseMappingMap[V]) Set(name string, value V) { nameCM := cmm.casemap(name) entry, ok := cmm.m[nameCM] if !ok { cmm.m[nameCM] = caseMappingEntry[V]{ originalKey: name, value: value, } return } entry.value = value cmm.m[nameCM] = entry } func (cmm *CaseMappingMap[V]) Del(name string) { delete(cmm.m, cmm.casemap(name)) } func (cmm *CaseMappingMap[V]) ForEach(f func(string, V)) { for _, entry := range cmm.m { f(entry.originalKey, entry.value) } } func (cmm *CaseMappingMap[V]) SetCaseMapping(newCasemap CaseMapping) { cmm.casemap = newCasemap m := make(map[string]caseMappingEntry[V], len(cmm.m)) for _, entry := range cmm.m { m[cmm.casemap(entry.originalKey)] = entry } cmm.m = m } �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/xirc/genmsg.go���������������������������������������������������������������������������0000664�0000000�0000000�00000012275�14770724770�0015302�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package xirc import ( "encoding/base64" "fmt" "sort" "strings" "gopkg.in/irc.v4" ) func GenerateJoin(channels, keys []string) []*irc.Message { // Put channels with a key first js := joinSorter{channels, keys} sort.Sort(&js) // Two spaces because there are three words (JOIN, channels and keys) maxLength := maxMessageLength - (len("JOIN") + 2) var msgs []*irc.Message var channelsBuf, keysBuf strings.Builder for i, channel := range channels { key := keys[i] n := channelsBuf.Len() + keysBuf.Len() + 1 + len(channel) if key != "" { n += 1 + len(key) } if channelsBuf.Len() > 0 && n > maxLength { // No room for the new channel in this message params := []string{channelsBuf.String()} if keysBuf.Len() > 0 { params = append(params, keysBuf.String()) } msgs = append(msgs, &irc.Message{Command: "JOIN", Params: params}) channelsBuf.Reset() keysBuf.Reset() } if channelsBuf.Len() > 0 { channelsBuf.WriteByte(',') } channelsBuf.WriteString(channel) if key != "" { if keysBuf.Len() > 0 { keysBuf.WriteByte(',') } keysBuf.WriteString(key) } } if channelsBuf.Len() > 0 { params := []string{channelsBuf.String()} if keysBuf.Len() > 0 { params = append(params, keysBuf.String()) } msgs = append(msgs, &irc.Message{Command: "JOIN", Params: params}) } return msgs } type joinSorter struct { channels []string keys []string } func (js *joinSorter) Len() int { return len(js.channels) } func (js *joinSorter) Less(i, j int) bool { if (js.keys[i] != "") != (js.keys[j] != "") { // Only one of the channels has a key return js.keys[i] != "" } return js.channels[i] < js.channels[j] } func (js *joinSorter) Swap(i, j int) { js.channels[i], js.channels[j] = js.channels[j], js.channels[i] js.keys[i], js.keys[j] = js.keys[j], js.keys[i] } func GenerateIsupport(tokens []string) []*irc.Message { maxTokens := maxMessageParams - 2 // 2 reserved params: nick + text // TODO: take into account maxMessageLength as well var msgs []*irc.Message for len(tokens) > 0 { var msgTokens []string if len(tokens) > maxTokens { msgTokens = tokens[:maxTokens] tokens = tokens[maxTokens:] } else { msgTokens = tokens tokens = nil } encodedTokens := make([]string, len(msgTokens)) for i, tok := range msgTokens { encodedTokens[i] = isupportEncoder.Replace(tok) } msgs = append(msgs, &irc.Message{ Command: irc.RPL_ISUPPORT, Params: append(append([]string{"*"}, encodedTokens...), "are supported"), }) } return msgs } var isupportEncoder = strings.NewReplacer(" ", "\\x20", "\\", "\\x5C") func GenerateMOTD(motd string) []*irc.Message { var msgs []*irc.Message msgs = append(msgs, &irc.Message{ Command: irc.RPL_MOTDSTART, Params: []string{"*", fmt.Sprintf("- Message of the Day -")}, }) for _, l := range strings.Split(motd, "\n") { msgs = append(msgs, &irc.Message{ Command: irc.RPL_MOTD, Params: []string{"*", l}, }) } msgs = append(msgs, &irc.Message{ Command: irc.RPL_ENDOFMOTD, Params: []string{"*", "End of /MOTD command."}, }) return msgs } func GenerateMonitor(subcmd string, targets []string) []*irc.Message { maxLength := maxMessageLength - len("MONITOR "+subcmd+" ") var msgs []*irc.Message var buf []string n := 0 for _, target := range targets { if n+len(target)+1 > maxLength { msgs = append(msgs, &irc.Message{ Command: "MONITOR", Params: []string{subcmd, strings.Join(buf, ",")}, }) buf = buf[:0] n = 0 } buf = append(buf, target) n += len(target) + 1 } if len(buf) > 0 { msgs = append(msgs, &irc.Message{ Command: "MONITOR", Params: []string{subcmd, strings.Join(buf, ",")}, }) } return msgs } func GenerateNamesReply(channel string, status ChannelStatus, members []string) []*irc.Message { emptyNameReply := irc.Message{ Command: irc.RPL_NAMREPLY, Params: []string{"*", string(status), channel, ""}, } maxLength := maxMessageLength - len(emptyNameReply.String()) var msgs []*irc.Message var buf strings.Builder for _, s := range members { n := buf.Len() + 1 + len(s) if buf.Len() != 0 && n > maxLength { // There's not enough space for the next space + nick msgs = append(msgs, &irc.Message{ Command: irc.RPL_NAMREPLY, Params: []string{"*", string(status), channel, buf.String()}, }) buf.Reset() } if buf.Len() != 0 { buf.WriteByte(' ') } buf.WriteString(s) } if buf.Len() != 0 { msgs = append(msgs, &irc.Message{ Command: irc.RPL_NAMREPLY, Params: []string{"*", string(status), channel, buf.String()}, }) } msgs = append(msgs, &irc.Message{ Command: irc.RPL_ENDOFNAMES, Params: []string{"*", channel, "End of /NAMES list"}, }) return msgs } func GenerateSASL(resp []byte) []*irc.Message { encoded := base64.StdEncoding.EncodeToString(resp) // <= instead of < because we need to send a final empty response if // the last chunk is exactly 400 bytes long var msgs []*irc.Message for i := 0; i <= len(encoded); i += MaxSASLLength { j := i + MaxSASLLength if j > len(encoded) { j = len(encoded) } chunk := encoded[i:j] if chunk == "" { chunk = "+" } msgs = append(msgs, &irc.Message{ Command: "AUTHENTICATE", Params: []string{chunk}, }) } return msgs } �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/xirc/whox.go�����������������������������������������������������������������������������0000664�0000000�0000000�00000007306�14770724770�0015006�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������package xirc import ( "gopkg.in/irc.v4" "fmt" "strings" ) // whoxFields is the list of all WHOX field letters, by order of appearance in // RPL_WHOSPCRPL messages. var whoxFields = []byte("tcuihsnfdlaor") type WHOXInfo struct { Token string Channel string Username string Hostname string Server string Nickname string Flags string Account string Realname string } func (info *WHOXInfo) get(k byte) string { switch k { case 't': return info.Token case 'c': channel := info.Channel if channel == "" { channel = "*" } return channel case 'u': return info.Username case 'i': return "255.255.255.255" case 'h': hostname := info.Hostname if strings.HasPrefix(info.Hostname, ":") { // The hostname cannot start with a colon as this would get parsed // as a trailing parameter. IPv6 addresses such as "::1" are // prefixed with a zero to ensure this. hostname = "0" + hostname } return hostname case 's': return info.Server case 'n': return info.Nickname case 'f': return info.Flags case 'd': return "0" case 'l': // idle time return "0" case 'a': account := "0" // WHOX uses "0" to mean "no account" if info.Account != "" && info.Account != "*" { account = info.Account } return account case 'o': return "0" case 'r': return info.Realname } return "" } func (info *WHOXInfo) set(k byte, v string) { switch k { case 't': info.Token = v case 'c': info.Channel = v case 'u': info.Username = v case 'h': info.Hostname = v case 's': info.Server = v case 'n': info.Nickname = v case 'f': info.Flags = v case 'a': info.Account = v case 'r': info.Realname = v } } func GenerateWHOXReply(fields string, info *WHOXInfo) *irc.Message { if fields == "" { hostname := info.Hostname if strings.HasPrefix(info.Hostname, ":") { // The hostname cannot start with a colon as this would get parsed // as a trailing parameter. IPv6 addresses such as "::1" are // prefixed with a zero to ensure this. hostname = "0" + hostname } channel := info.Channel if channel == "" { channel = "*" } return &irc.Message{ Command: irc.RPL_WHOREPLY, Params: []string{"*", channel, info.Username, hostname, info.Server, info.Nickname, info.Flags, "0 " + info.Realname}, } } fieldSet := make(map[byte]bool) for i := 0; i < len(fields); i++ { fieldSet[fields[i]] = true } var values []string for _, field := range whoxFields { if !fieldSet[field] { continue } values = append(values, info.get(field)) } return &irc.Message{ Command: RPL_WHOSPCRPL, Params: append([]string{"*"}, values...), } } func ParseWHOXOptions(options string) (fields, whoxToken string) { optionsParts := strings.SplitN(options, "%", 2) // TODO: add support for WHOX flags in optionsParts[0] if len(optionsParts) == 2 { optionsParts := strings.SplitN(optionsParts[1], ",", 2) fields = strings.ToLower(optionsParts[0]) if len(optionsParts) == 2 && strings.Contains(fields, "t") { whoxToken = optionsParts[1] } } return fields, whoxToken } func ParseWHOXReply(msg *irc.Message, fields string) (*WHOXInfo, error) { if msg.Command != RPL_WHOSPCRPL { return nil, fmt.Errorf("invalid WHOX reply %q", msg.Command) } else if len(msg.Params) == 0 { return nil, fmt.Errorf("invalid RPL_WHOSPCRPL: no params") } fieldSet := make(map[byte]bool) for i := 0; i < len(fields); i++ { fieldSet[fields[i]] = true } var info WHOXInfo values := msg.Params[1:] for _, field := range whoxFields { if !fieldSet[field] { continue } if len(values) == 0 { return nil, fmt.Errorf("invalid RPL_WHOSPCRPL: missing value for field %q", string(field)) } info.set(field, values[0]) values = values[1:] } return &info, nil } ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������soju-0.9.0/xirc/xirc.go�����������������������������������������������������������������������������0000664�0000000�0000000�00000006114�14770724770�0014762�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������// Package xirc contains an extended IRC library. package xirc import ( "fmt" "strings" "time" "gopkg.in/irc.v4" ) const ( maxMessageLength = 512 maxMessageParams = 15 ) const MaxSASLLength = 400 const ( RPL_STATSPING = "246" RPL_LOCALUSERS = "265" RPL_GLOBALUSERS = "266" RPL_WHOISCERTFP = "276" RPL_WHOISREGNICK = "307" RPL_WHOISSPECIAL = "320" RPL_CREATIONTIME = "329" RPL_WHOISACCOUNT = "330" RPL_TOPICWHOTIME = "333" RPL_WHOISACTUALLY = "338" RPL_WHOSPCRPL = "354" RPL_WHOISHOST = "378" RPL_WHOISMODES = "379" RPL_VISIBLEHOST = "396" ERR_UNKNOWNERROR = "400" ERR_INVALIDCAPCMD = "410" RPL_WHOISSECURE = "671" // https://ircv3.net/specs/extensions/bot-mode RPL_WHOISBOT = "335" // https://ircv3.net/specs/extensions/metadata RPL_KEYVALUE = "761" RPL_METADATASUBOK = "770" RPL_METADATAUNSUBOK = "771" RPL_METADATASUBS = "772" ) // The server-time layout, as defined in the IRCv3 spec. const ServerTimeLayout = "2006-01-02T15:04:05.000Z" // FormatServerTime formats a time with the server-time layout. func FormatServerTime(t time.Time) string { return t.UTC().Format(ServerTimeLayout) } // ParseCTCPMessage parses a CTCP message. CTCP is defined in // https://tools.ietf.org/html/draft-oakley-irc-ctcp-02 func ParseCTCPMessage(msg *irc.Message) (cmd string, params string, ok bool) { if (msg.Command != "PRIVMSG" && msg.Command != "NOTICE") || len(msg.Params) < 2 { return "", "", false } text := msg.Params[1] if !strings.HasPrefix(text, "\x01") { return "", "", false } text = strings.Trim(text, "\x01") words := strings.SplitN(text, " ", 2) cmd = strings.ToUpper(words[0]) if len(words) > 1 { params = words[1] } return cmd, params, true } type ChannelStatus byte const ( ChannelPublic ChannelStatus = '=' ChannelSecret ChannelStatus = '@' ChannelPrivate ChannelStatus = '*' ) func ParseChannelStatus(s string) (ChannelStatus, error) { if len(s) > 1 { return 0, fmt.Errorf("invalid channel status %q: more than one character", s) } switch cs := ChannelStatus(s[0]); cs { case ChannelPublic, ChannelSecret, ChannelPrivate: return cs, nil default: return 0, fmt.Errorf("invalid channel status %q: unknown status", s) } } // Membership is a channel member rank. type Membership struct { Mode byte Prefix byte } // MembershipSet is a set of memberships sorted by descending rank. type MembershipSet []Membership func (ms *MembershipSet) Add(availableMemberships []Membership, newMembership Membership) { l := *ms i := 0 for _, availableMembership := range availableMemberships { if i >= len(l) { break } if l[i] == availableMembership { if availableMembership == newMembership { // we already have this membership return } i++ continue } if availableMembership == newMembership { break } } // insert newMembership at i l = append(l, Membership{}) copy(l[i+1:], l[i:]) l[i] = newMembership *ms = l } func (ms *MembershipSet) Remove(membership Membership) { l := *ms for i, m := range l { if m == membership { *ms = append(l[:i], l[i+1:]...) return } } } ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������