commit 2d7d249506bcc4bdd69bc6bd4cdb64fe3a35e929 Author: andatoshiki Date: Sat Aug 24 04:18:37 2024 -0700 init: init commit for compelete root soruces of project codebase diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3b777ef --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: jayofelony # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75b968a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +.idea +test-unit +test-unit.pub +build +id_rsa +id_rsa.pub +key.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..098563a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM golang:alpine as builder + +# ENV GO111MODULE=on + +LABEL maintainer="Anda Toshiki " + +RUN apk update && apk add --no-cache git + +# download, cache and install deps +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +# copy and compiled the app +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o shikigrid cmd/shikigrid/main.go + +# start a new stage from scratch +FROM alpine:latest +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +# copy the prebuilt binary and .env from the builder stage +COPY --from=builder /app/shikigrid . +COPY --from=builder /app/.env . + +EXPOSE 8666 + +CMD ["./shikigrid"] \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..54c6296 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,596 @@ +GNU GENERAL PUBLIC LICENSE +========================== + +Version 3, 29 June 2007 + +Copyright © 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 General Public License is a free, copyleft license for software and other +kinds of works. + +The licenses for most software and other practical works are designed to take away +your freedom to share and change the works. By contrast, the GNU General Public +License is 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. We, the Free +Software Foundation, use the GNU General Public License for most of our software; it +applies also to any other work released this way by its authors. You can apply it to +your programs, too. + +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. + +To protect your rights, we need to prevent others from denying you these rights or +asking you to surrender the rights. Therefore, you have certain responsibilities if +you distribute copies of the software, or if you modify it: responsibilities to +respect the freedom of others. + +For example, if you distribute copies of such a program, whether gratis or for a fee, +you must pass on to the recipients the same freedoms that you received. You must make +sure that they, too, receive or can get the source code. And you must show them these +terms so they know their rights. + +Developers that use the GNU GPL protect your rights with two steps: (1) assert +copyright on the software, and (2) offer you this License giving you legal permission +to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains that there is +no warranty for this free software. For both users' and authors' sake, the GPL +requires that modified versions be marked as changed, so that their problems will not +be attributed erroneously to authors of previous versions. + +Some devices are designed to deny users access to install or run modified versions of +the software inside them, although the manufacturer can do so. This is fundamentally +incompatible with the aim of protecting users' freedom to change the software. The +systematic pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we have designed +this version of the GPL to prohibit the practice for those products. If such problems +arise substantially in other domains, we stand ready to extend this provision to +those domains in future versions of the GPL, as needed to protect the freedom of +users. + +Finally, every program is threatened constantly by software patents. States should +not allow patents to restrict development and use of software on general-purpose +computers, but in those that do, we wish to avoid the special danger that patents +applied to a free program could make it effectively proprietary. To prevent this, the +GPL assures that patents cannot be used to render the program non-free. + +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 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. Use with the GNU Affero General Public License. + +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 Affero +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 special requirements of the GNU Affero General Public License, section +13, concerning interaction through a network will apply to the combination as such. + +### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU +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 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 +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 +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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short notice like this +when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type 'show c' for details. + +The hypothetical commands 'show w' and 'show c' should show the appropriate parts of +the General Public License. Of course, your program's commands might be different; +for a GUI interface, you would use an “about box”. + +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 GPL, see +<>. + +The GNU General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may consider it +more useful to permit linking proprietary applications with the library. If this is +what you want to do, use the GNU Lesser General Public License instead of this +License. But first, please read +<>. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ff509e --- /dev/null +++ b/Makefile @@ -0,0 +1,68 @@ +VERSION := $(shell sed -n 's/Version\s*=\s*"\([0-9.]\+\)"/\1/p' version/ver.go | tr -d '\t') + +all: clean + @mkdir build + @go build -o build/shikigrid cmd/shikigrid/*.go + @ls -la build/shikigrid + +install: + @cp build/shikigrid /usr/local/bin/ + @mkdir -p /etc/systemd/system/ + @mkdir -p /etc/shikigrid/ + @cp env.example /etc/shikigrid/shikigrid.conf + @systemctl daemon-reload + +clean: + @rm -rf build + +restart: + @service shikigrid restart + +release_files: clean cross_compile_libpcap_arm64 # cross_compile_libpcap_arm + @mkdir build + @echo building for linux/amd64 ... + @CGO_ENABLED=1 CC=x86_64-linux-gnu-gcc GOARCH=amd64 GOOS=linux go build -o build/shikigrid cmd/shikigrid/*.go + @openssl dgst -sha256 "build/shikigrid" > "build/shikigrid-amd64.sha256" + @zip -j "build/shikigrid-$(VERSION)-amd64.zip" build/shikigrid build/shikigrid-amd64.sha256 > /dev/null + @rm -rf build/shikigrid build/shikigrid-amd64.sha256 + @echo building for linux/armv6l ... + @CGO_ENABLED=1 CC=arm-linux-gnueabi-gcc GOARM=6 GOARCH=arm GOOS=linux go build -o build/shikigrid cmd/shikigrid/*.go + @openssl dgst -sha256 "build/shikigrid" > "build/shikigrid-armv6l.sha256" + @zip -j "build/shikigrid-$(VERSION)-armv6l.zip" build/shikigrid build/shikigrid-armv6l.sha256 > /dev/null + @rm -rf build/shikigrid build/shikigrid-armv6l.sha256 + @echo building for linux/aarch64 ... + @CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc GOARCH=arm64 GOOS=linux go build -o build/shikigrid cmd/shikigrid/*.go + @openssl dgst -sha256 "build/shikigrid" > "build/shikigrid-aarch64.sha256" + @zip -j "build/shikigrid-$(VERSION)-aarch64.zip" build/shikigrid build/shikigrid-aarch64.sha256 > /dev/null + @rm -rf build/shikigrid build/shikigrid-aarch64.sha256 + @ls -la build + +# requires sudo apt-get install bison flex gcc-arm-linux-gnueabi libpcap0.8 libpcap-dev +cross_compile_libpcap_arm: + @echo "Cross-compiling libpcap for armv6l..." + @wget https://www.tcpdump.org/release/libpcap-1.9.1.tar.gz + @tar -zxvf libpcap-1.9.1.tar.gz + @cd libpcap-1.9.1 && \ + export CC=arm-linux-gnueabi-gcc && \ + ./configure --host=arm-linux-gnueabi && \ + make + @echo "Copying cross-compiled libpcap to /usr/lib/arm-linux-gnueabi/" + @sudo cp libpcap-1.9.1/libpcap.a /usr/lib/arm-linux-gnueabi/ + @echo "Clean up..." + @rm -rf libpcap-1.9.1 libpcap-1.9.1.tar.gz + +# requires sudo apt-get install bison flex gcc-aarch64-linux-gnu libpcap0.8 libpcap-dev +cross_compile_libpcap_arm64: + @echo "Cross-compiling libpcap for arm64..." + @wget https://www.tcpdump.org/release/libpcap-1.9.1.tar.gz + @tar -zxvf libpcap-1.9.1.tar.gz + @cd libpcap-1.9.1 && \ + export CC=aarch64-linux-gnu-gcc && \ + ./configure --host=aarch64-linux-gnu && \ + make + @echo "Copying cross-compiled libpcap to /usr/lib/x86_64-linux-gnu/" + @sudo cp libpcap-1.9.1/libpcap.a /usr/lib/aarch64-linux-gnu/ + @echo "Clean up..." + @rm -rf libpcap-1.9.1 libpcap-1.9.1.tar.gz + +.PHONY: cross_compile_libpcap_arm cross_compile_libpcap_arm64 \ No newline at end of file diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000..1782f9a --- /dev/null +++ b/api/auth.go @@ -0,0 +1,89 @@ +package api + +import ( + "errors" + "fmt" + "github.com/evilsocket/islazy/log" + "github.com/golang-jwt/jwt/v5" + "github.com/andatoshiki/shikigrid/models" + "net/http" + "os" + "time" +) + +var ( + ErrTokenClaims = errors.New("can't extract claims from jwt token") + ErrTokenInvalid = errors.New("jwt token not valid") + ErrTokenExpired = errors.New("jwt token expired") + ErrTokenIncomplete = errors.New("jwt token is missing required fields") + ErrTokenUnauthorized = errors.New("jwt token authorized field is false (?!)") +) + +func validateToken(header string) (jwt.MapClaims, error) { + token, err := jwt.Parse(header, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(os.Getenv("API_SECRET")), nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, ErrTokenClaims + } else if !token.Valid { + return nil, ErrTokenInvalid + } + + required := []string{ + "expires_at", + "authorized", + "unit_id", + "unit_ident", + } + for _, req := range required { + if _, found := claims[req]; !found { + return nil, ErrTokenIncomplete + } + } + + log.Debug("%+v", claims) + + if expiresAt, err := time.Parse(time.RFC3339, claims["expires_at"].(string)); err != nil { + return nil, ErrTokenExpired + } else if expiresAt.Before(time.Now()) { + return nil, ErrTokenExpired + } else if claims["authorized"].(bool) != true { + return nil, ErrTokenUnauthorized + } + return claims, err +} + +func Authenticate(w http.ResponseWriter, r *http.Request) *models.Unit { + client := clientIP(r) + tokenHeader := reqToken(r) + if tokenHeader == "" { + log.Debug("unauthenticated request from %s", client) + ERROR(w, http.StatusUnauthorized, ErrUnauthorized) + return nil + } + + claims, err := validateToken(tokenHeader) + if err != nil { + log.Debug("token error for %s: %v", client, err) + ERROR(w, http.StatusUnauthorized, ErrUnauthorized) + return nil + } + + log.Debug("claims[unit_id] = %+v", claims["unit_id"]) + unit := models.FindUnit(uint(claims["unit_id"].(float64))) + if unit == nil { + log.Warning("client %s authenticated with unknown claims '%v'", client, claims) + ERROR(w, http.StatusUnauthorized, ErrUnauthorized) + return nil + } + + return unit +} diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..11026c7 --- /dev/null +++ b/api/client.go @@ -0,0 +1,263 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "sync" + "time" + + "github.com/evilsocket/islazy/log" + "github.com/andatoshiki/shikigrid/crypto" + "github.com/andatoshiki/shikigrid/models" + "github.com/andatoshiki/shikigrid/utils" +) + +var ( + ClientTimeout = 60 + ClientKeepalive = 30 + ClientTokenFile = "/tmp/shikigrid-api-enrollment.json" + Endpoint = "" +) + +//const ( +// Endpoint +//) + +type Client struct { + sync.Mutex + + cli *http.Client + keys *crypto.KeyPair + token string + tokenAt time.Time + data map[string]interface{} + hostname string +} + +func NewClient(keys *crypto.KeyPair, endpoint string, hostname string) *Client { + + t := &http.Transport{ + Dial: (&net.Dialer{ + Timeout: time.Duration(ClientTimeout) * time.Second, + KeepAlive: time.Duration(ClientKeepalive) * time.Second, + }).Dial, + TLSHandshakeTimeout: time.Duration(ClientTimeout) * time.Second, + ResponseHeaderTimeout: time.Duration(ClientTimeout) * time.Second, + ExpectContinueTimeout: 4 * time.Second, + } + + cli := &Client{ + cli: &http.Client{ + Transport: t, + Timeout: time.Duration(ClientTimeout) * time.Second, + }, + keys: keys, + data: make(map[string]interface{}), + hostname: hostname, + } + + Endpoint = endpoint + + if info, err := os.Stat(ClientTokenFile); err == nil { + if time.Since(info.ModTime()) < models.TokenTTL { + log.Debug("loading token from %s ...", ClientTokenFile) + var data map[string]interface{} + if raw, err := os.ReadFile(ClientTokenFile); err == nil { + if err := json.Unmarshal(raw, &data); err == nil { + cli.token = data["token"].(string) + cli.tokenAt = info.ModTime() + log.Debug("token: %s", cli.token) + } else { + log.Warning("error decoding %s: %v", ClientTokenFile, err) + } + } else { + log.Warning("error reading %s: %v", ClientTokenFile, err) + } + } else { + log.Debug("token in %s is expired", ClientTokenFile) + } + } + + return cli +} + +func (c *Client) enroll() error { + + hostname := c.hostname + if hostname == "" { + hostname = utils.Hostname() + } + identity := fmt.Sprintf("%s@%s", hostname, c.keys.FingerprintHex) + + log.Debug("refreshing api token as %s ...", identity) + + signature, err := c.keys.SignMessage([]byte(identity)) + if err != nil { + return err + } + + signature64 := base64.StdEncoding.EncodeToString(signature) + pubKeyPEM64 := base64.StdEncoding.EncodeToString(c.keys.PublicPEM) + + log.Debug("SIGN(%s) = %s", identity, signature64) + + enrollment := map[string]interface{}{ + "identity": identity, + "public_key": pubKeyPEM64, + "signature": signature64, + "data": c.data, + } + + obj, err := c.request("POST", "/unit/enroll", enrollment, false) + if err != nil { + return err + } + + c.tokenAt = time.Now() + c.token = obj["token"].(string) + log.Debug("new token: %s", c.token) + + if raw, err := json.Marshal(obj); err == nil { + log.Debug("saving token to %s ...", ClientTokenFile) + if err = os.WriteFile(ClientTokenFile, raw, 0644); err != nil { + log.Warning("error saving token to %s: %v", ClientTokenFile, err) + } + } else { + log.Warning("error encoding token: %v", err) + } + + return nil +} + +func (c *Client) request(method string, path string, data interface{}, auth bool) (map[string]interface{}, error) { + url := fmt.Sprintf("%s%s", Endpoint, path) + err := (error)(nil) + started := time.Now() + defer func() { + if err == nil { + log.Debug("%s %s (%s)", method, url, time.Since(started)) + } else { + log.Error("%s %s (%s) %v", method, url, time.Since(started), err) + } + }() + + buf := new(bytes.Buffer) + if data != nil { + if err = json.NewEncoder(buf).Encode(data); err != nil { + return nil, err + } + } + + req, err := http.NewRequest(method, url, buf) + if err != nil { + return nil, err + } + + if auth { + if time.Since(c.tokenAt) >= models.TokenTTL { + if err := c.enroll(); err != nil { + return nil, err + } + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.token)) + } + + res, err := c.cli.Do(req) + if err != nil { + return nil, err + } + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var obj map[string]interface{} + if err = json.Unmarshal(body, &obj); err != nil { + log.Debug(fmt.Sprintf("Error Unmarshalling json body from request: %v", body)) + return nil, err + } + + if res.StatusCode == 401 { + if err := c.enroll(); err != nil { + log.Warning("error token expired during operation: %v", err) + return nil, err + } + log.Warning("token expired, re-enroll success") + } + + if res.StatusCode != 200 { + err = fmt.Errorf("%d %s", res.StatusCode, obj["error"]) + } + + return obj, err +} + +func (c *Client) SetData(newData map[string]interface{}) map[string]interface{} { + c.Lock() + defer c.Unlock() + + for key, val := range newData { + if val == nil { + delete(c.data, key) + } else { + c.data[key] = val + } + } + + return c.data +} + +func (c *Client) Data() map[string]interface{} { + c.Lock() + defer c.Unlock() + return c.data +} + +func (c *Client) Request(method string, path string, data interface{}, auth bool) (map[string]interface{}, error) { + c.Lock() + defer c.Unlock() + return c.request(method, path, data, auth) +} + +func (c *Client) Get(path string, auth bool) (map[string]interface{}, error) { + return c.Request("GET", path, nil, auth) +} + +func (c *Client) Post(path string, what interface{}, auth bool) (map[string]interface{}, error) { + return c.Request("POST", path, what, auth) +} + +func (c *Client) PagedUnits(page int) (map[string]interface{}, error) { + return c.Get(fmt.Sprintf("/units/?p=%d", page), false) +} + +func (c *Client) Unit(fingerprint string) (map[string]interface{}, error) { + return c.Get(fmt.Sprintf("/unit/%s", fingerprint), false) +} + +func (c *Client) ReportAP(report interface{}) (map[string]interface{}, error) { + return c.Post("/unit/report/ap", report, true) +} + +func (c *Client) Inbox(page int) (map[string]interface{}, error) { + return c.Get(fmt.Sprintf("/unit/inbox/?p=%d", page), true) +} + +func (c *Client) InboxMessage(id int) (map[string]interface{}, error) { + return c.Get(fmt.Sprintf("/unit/inbox/%d", id), true) +} + +func (c *Client) MarkInboxMessage(id int, mark string) (map[string]interface{}, error) { + return c.Get(fmt.Sprintf("/unit/inbox/%d/%s", id, mark), true) +} + +func (c *Client) SendMessageTo(fingerprint string, msg Message) error { + _, err := c.Post(fmt.Sprintf("/unit/%s/inbox", fingerprint), msg, true) + return err +} diff --git a/api/cors.go b/api/cors.go new file mode 100644 index 0000000..7b3db1e --- /dev/null +++ b/api/cors.go @@ -0,0 +1,23 @@ +package api + +import ( + "github.com/go-chi/cors" + "net/http" +) + +func CORS(next http.Handler) http.Handler { + cors := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization"}, + AllowCredentials: true, + MaxAge: 300, + }) + return cors.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Frame-Options", "DENY") + w.Header().Add("X-Content-Type-Options", "nosniff") + w.Header().Add("X-XSS-Protection", "1; mode=block") + w.Header().Add("Referrer-Policy", "same-origin") + next.ServeHTTP(w, r) + })) +} diff --git a/api/message.go b/api/message.go new file mode 100644 index 0000000..c83a4c1 --- /dev/null +++ b/api/message.go @@ -0,0 +1,6 @@ +package api + +type Message struct { + Data string `json:"data"` + Signature string `json:"signature"` +} diff --git a/api/peer_data.go b/api/peer_data.go new file mode 100644 index 0000000..6fe0b0c --- /dev/null +++ b/api/peer_data.go @@ -0,0 +1,33 @@ +package api + +import ( + "encoding/json" + "github.com/evilsocket/islazy/log" + "io" + "net/http" +) + +// PeerGetData GET /api/v1/data +func (api *API) PeerGetData(w http.ResponseWriter, r *http.Request) { + JSON(w, http.StatusOK, api.Client.Data()) +} + +// PeerSetData POST /api/v1/data +func (api *API) PeerSetData(w http.ResponseWriter, r *http.Request) { + var newData map[string]interface{} + + body, err := io.ReadAll(r.Body) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + log.Debug("%s", body) + + if err = json.Unmarshal(body, &newData); err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + JSON(w, http.StatusOK, api.Client.SetData(newData)) +} diff --git a/api/peer_inbox.go b/api/peer_inbox.go new file mode 100644 index 0000000..963144f --- /dev/null +++ b/api/peer_inbox.go @@ -0,0 +1,195 @@ +package api + +import ( + "encoding/base64" + "errors" + "fmt" + "github.com/evilsocket/islazy/log" + "github.com/go-chi/chi/v5" + "github.com/andatoshiki/shikigrid/crypto" + "github.com/andatoshiki/shikigrid/models" + "io" + "net/http" + "strconv" +) + +var ( + ErrEmptyMessage = errors.New("empty message body") + ErrSenderNotFound = errors.New("sender not found") +) + +// PeerGetInbox /api/v1/inbox/ +func (api *API) PeerGetInbox(w http.ResponseWriter, r *http.Request) { + page, err := pageNum(r) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + obj, err := api.Client.Inbox(page) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + JSON(w, http.StatusOK, obj) +} + +func (api *API) InboxMessage(id int) (map[string]interface{}, int, error) { + message, err := api.Client.InboxMessage(id) + if err != nil { + return nil, http.StatusUnprocessableEntity, err + } + + sender, found := message["sender"] + if !found { + return nil, http.StatusNotFound, ErrSenderNotFound + } + + fingerprint, ok := sender.(string) + if !ok { + return nil, http.StatusUnprocessableEntity, ErrSenderNotFound + } + + unit, err := api.Client.Unit(fingerprint) + if err != nil { + return nil, http.StatusNotFound, err + } + + srcKeys, err := crypto.FromPublicPEM(unit["public_key"].(string)) + if err != nil { + return nil, http.StatusUnprocessableEntity, err + } + + data, err := base64.StdEncoding.DecodeString(message["data"].(string)) + if err != nil { + return nil, http.StatusUnprocessableEntity, err + } + + signature, err := base64.StdEncoding.DecodeString(message["signature"].(string)) + if err != nil { + return nil, http.StatusUnprocessableEntity, err + } + + log.Info("verifying message from %s ...", fingerprint) + + if err := srcKeys.VerifyMessage(data, signature); err != nil { + return nil, http.StatusUnprocessableEntity, err + } + + log.Info("decrypting message from %s ...", fingerprint) + + clearText, err := api.Keys.Decrypt(data) + if err != nil { + return nil, http.StatusUnprocessableEntity, err + } + + message["data"] = clearText + + return message, 0, nil +} + +// /api/v1/inbox/ +func (api *API) PeerGetInboxMessage(w http.ResponseWriter, r *http.Request) { + msgIDParam := chi.URLParam(r, "msg_id") + msgID, err := strconv.Atoi(msgIDParam) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + message, status, err := api.InboxMessage(msgID) + if err != nil { + ERROR(w, status, err) + return + } + + JSON(w, http.StatusOK, message) +} + +// /api/v1/inbox// +func (api *API) PeerMarkInboxMessage(w http.ResponseWriter, r *http.Request) { + markAs := chi.URLParam(r, "mark") + msgIDParam := chi.URLParam(r, "msg_id") + msgID, err := strconv.Atoi(msgIDParam) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + obj, err := api.Client.MarkInboxMessage(msgID, markAs) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + JSON(w, http.StatusOK, obj) +} + +func (api *API) SendMessage(fingerprint string, cleartext []byte) (int, error) { + unit, err := api.Client.Unit(fingerprint) + if err != nil { + return http.StatusNotFound, err + } + + unitKeys, err := crypto.FromPublicPEM(unit["public_key"].(string)) + if err != nil { + log.Error("error parsing public key of %s: %v", fingerprint, err) + return http.StatusUnprocessableEntity, err + } + + messageBody, err := api.Keys.EncryptFor(cleartext, unitKeys.Public) + if err != nil { + log.Error("error encrypting message for %s: %v", fingerprint, err) + return http.StatusUnprocessableEntity, err + } + + messageSize := len(messageBody) + if messageSize == 0 { + return http.StatusUnprocessableEntity, ErrEmptyMessage + } else if messageSize > models.MessageDataMaxSize { + err := fmt.Errorf("max message signature size is %d", models.MessageSignatureMaxSize) + return http.StatusUnprocessableEntity, err + } + + log.Info("signing encrypted message of %d bytes for %s ...", messageSize, fingerprint) + + signature, err := api.Keys.SignMessage(messageBody) + if err != nil { + log.Error("%v", err) + return http.StatusUnprocessableEntity, err + } + + msg := Message{ + Signature: base64.StdEncoding.EncodeToString(signature), + Data: base64.StdEncoding.EncodeToString(messageBody), + } + + if err := api.Client.SendMessageTo(fingerprint, msg); err != nil { + log.Error("%v", err) + return http.StatusUnprocessableEntity, err + } + + return 0, nil +} + +// POST /api/v1/unit//inbox +func (api *API) PeerSendMessageTo(w http.ResponseWriter, r *http.Request) { + cleartextMessage, err := io.ReadAll(r.Body) + if err != nil { + log.Error("error reading request body: %v", err) + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + fingerprint := chi.URLParam(r, "fingerprint") + status, err := api.SendMessage(fingerprint, cleartextMessage) + if err != nil { + ERROR(w, status, err) + return + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) +} diff --git a/api/peer_list_units.go b/api/peer_list_units.go new file mode 100644 index 0000000..327f772 --- /dev/null +++ b/api/peer_list_units.go @@ -0,0 +1,21 @@ +package api + +import ( + "net/http" +) + +func (api *API) PeerListUnits(w http.ResponseWriter, r *http.Request) { + page, err := pageNum(r) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + obj, err := api.Client.PagedUnits(page) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + JSON(w, http.StatusOK, obj) +} diff --git a/api/peer_mesh.go b/api/peer_mesh.go new file mode 100644 index 0000000..2f3978c --- /dev/null +++ b/api/peer_mesh.go @@ -0,0 +1,110 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "sort" + + "github.com/evilsocket/islazy/log" + "github.com/go-chi/chi/v5" + "github.com/andatoshiki/shikigrid/mesh" +) + +// PeerGetPeers GET /api/v1/mesh/peers +func (api *API) PeerGetPeers(w http.ResponseWriter, r *http.Request) { + peers := make([]*mesh.Peer, 0) + mesh.Peers.Range(func(key, value interface{}) bool { + peers = append(peers, value.(*mesh.Peer)) + return true + }) + + // closer first + sort.Slice(peers, func(i, j int) bool { + return peers[i].RSSI > peers[j].RSSI + }) + + JSON(w, http.StatusOK, peers) +} + +// PeerGetMemory GET /api/v1/mesh/memory +func (api *API) PeerGetMemory(w http.ResponseWriter, r *http.Request) { + peers := api.Mesh.Memory() + // higher number of encounters first + sort.Slice(peers, func(i, j int) bool { + return peers[i].Encounters > peers[j].Encounters + }) + JSON(w, http.StatusOK, peers) +} + +// PeerGetMemoryOf GET /api/v1/mesh/memory/ +func (api *API) PeerGetMemoryOf(w http.ResponseWriter, r *http.Request) { + fingerprint := chi.URLParam(r, "fingerprint") + peer := api.Mesh.MemoryOf(fingerprint) + if peer == nil { + ERROR(w, http.StatusNotFound, ErrEmpty) + return + } + JSON(w, http.StatusOK, peer) +} + +// PeerSetSignaling GET /api/v1/mesh/ +func (api *API) PeerSetSignaling(w http.ResponseWriter, r *http.Request) { + status := chi.URLParam(r, "status") + + if status == "enabled" || status == "true" { + api.Peer.Advertise(true) + } else if status == "disabled" || status == "false" { + api.Peer.Advertise(false) + } else { + ERROR(w, http.StatusNotFound, ErrEmpty) + return + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) +} + +// PeerGetMeshData GET /api/v1/mesh/data +func (api *API) PeerGetMeshData(w http.ResponseWriter, r *http.Request) { + JSON(w, http.StatusOK, api.Peer.Data()) +} + +// PeerSetMeshData POST /api/v1/mesh/data +func (api *API) PeerSetMeshData(w http.ResponseWriter, r *http.Request) { + var newData map[string]interface{} + + if api.Peer.ForceDisabled == true { + api.Peer.Advertise(false) + JSON(w, http.StatusOK, map[string]interface{}{ + "success": true, // this should be changed later when shikigotchi can handle shikigrid being force advertise disabled + }) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + log.Debug("%s", body) + + if err = json.Unmarshal(body, &newData); err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + // this makes sure that the shikigrid server receives advertisements + api.Client.SetData(map[string]interface{}{ + "advertisement": newData, + }) + + // update mesh advertisement data + api.Peer.SetData(newData) + + JSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) +} diff --git a/api/peer_report.go b/api/peer_report.go new file mode 100644 index 0000000..20313d8 --- /dev/null +++ b/api/peer_report.go @@ -0,0 +1,34 @@ +package api + +import ( + "encoding/json" + "github.com/evilsocket/islazy/log" + "io" + "net/http" +) + +// PeerReportAP POST /api/v1/report/ap +func (api *API) PeerReportAP(w http.ResponseWriter, r *http.Request) { + var report apReport + + body, err := io.ReadAll(r.Body) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + log.Debug("%s", body) + + if err = json.Unmarshal(body, &report); err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + obj, err := api.Client.ReportAP(report) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + JSON(w, http.StatusOK, obj) +} diff --git a/api/setup.go b/api/setup.go new file mode 100644 index 0000000..83b9178 --- /dev/null +++ b/api/setup.go @@ -0,0 +1,45 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/andatoshiki/shikigrid/crypto" + "github.com/andatoshiki/shikigrid/mesh" + + _ "github.com/jinzhu/gorm/dialects/mysql" + + "github.com/evilsocket/islazy/log" +) + +type API struct { + Router *chi.Mux + Keys *crypto.KeyPair + Peer *mesh.Peer + Mesh *mesh.Router + Client *Client +} + +func Setup(keys *crypto.KeyPair, peer *mesh.Peer, router *mesh.Router, Endpoint string, Hostname string) (err error, api *API) { + api = &API{ + Router: chi.NewRouter(), + Keys: keys, + Peer: peer, + Mesh: router, + Client: NewClient(keys, Endpoint, Hostname), + } + + api.Router.Use(CORS) + if api.Keys == nil { + api.setupServerRoutes() + } else { + api.setupPeerRoutes() + } + + return +} + +func (api *API) Run(addr string) { + log.Info("shikigrid api starting on %s ...", addr) + log.Fatal("%v", http.ListenAndServe(addr, api.Router)) +} diff --git a/api/setup_peer.go b/api/setup_peer.go new file mode 100644 index 0000000..bc57338 --- /dev/null +++ b/api/setup_peer.go @@ -0,0 +1,62 @@ +package api + +import ( + "github.com/evilsocket/islazy/log" + "github.com/go-chi/chi/v5" +) + +func (api *API) setupPeerRoutes() { + log.Debug("registering peer api ...") + + api.Router.Route("/api", func(r chi.Router) { + r.Route("/v1", func(r chi.Router) { + r.Route("/mesh", func(r chi.Router) { + // GET /api/v1/mesh/peers + r.Get("/peers", api.PeerGetPeers) + + r.Route("/memory", func(r chi.Router) { + // GET /api/v1/mesh/memory + r.Get("/", api.PeerGetMemory) + // GET /api/v1/mesh/memory/ + r.Get("/{fingerprint:[a-fA-F0-9]+}", api.PeerGetMemoryOf) + }) + + // GET /api/v1/mesh/ + r.Get("/{status:[a-z]+}", api.PeerSetSignaling) + + // GET /api/v1/mesh/data + r.Get("/data", api.PeerGetMeshData) + // POST /api/v1/mesh/data + r.Post("/data", api.PeerSetMeshData) + }) + + // GET /api/v1/data + r.Post("/data", api.PeerGetData) + // POST /api/v1/data + r.Post("/data", api.PeerSetData) + + r.Route("/report", func(r chi.Router) { + // POST /api/v1/report/ap + r.Post("/ap", api.PeerReportAP) + }) + r.Route("/inbox", func(r chi.Router) { + // GET /api/v1/inbox/ + r.Get("/", api.PeerGetInbox) + r.Route("/{msg_id:[0-9]+}", func(r chi.Router) { + // GET /api/v1/inbox/ + r.Get("/", api.PeerGetInboxMessage) + // GET /api/v1/inbox// + r.Get("/{mark:[a-z]+}", api.PeerMarkInboxMessage) + }) + }) + r.Route("/unit", func(r chi.Router) { + // POST /api/v1/unit//inbox + r.Post("/{fingerprint:[a-fA-F0-9]+}/inbox", api.PeerSendMessageTo) + }) + r.Route("/units", func(r chi.Router) { + // GET /api/v1/units/ + r.Get("/", api.PeerListUnits) + }) + }) + }) +} diff --git a/api/setup_server.go b/api/setup_server.go new file mode 100644 index 0000000..7ff5e9f --- /dev/null +++ b/api/setup_server.go @@ -0,0 +1,55 @@ +package api + +import ( + "fmt" + "github.com/evilsocket/islazy/log" + "github.com/go-chi/chi/v5" + "net/http" +) + +func cached(seconds int, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", fmt.Sprintf("public, max-age=%d", seconds)) + w.Header().Add("Expires", fmt.Sprintf("%d", seconds)) + next.ServeHTTP(w, r) + } +} + +func (api *API) setupServerRoutes() { + log.Debug("registering server api ...") + + api.Router.Route("/api", func(r chi.Router) { + r.Route("/v1", func(r chi.Router) { + r.Route("/units", func(r chi.Router) { + // GET /api/v1/units/ + r.Get("/", cached(600, api.ListUnits)) + // GET /api/v1/units/by_country + r.Get("/by_country", cached(600, api.UnitsByCountry)) + }) + r.Route("/unit", func(r chi.Router) { + // GET /api/v1/unit/ + r.Get("/{fingerprint:[a-fA-F0-9]+}", cached(600, api.ShowUnit)) + r.Route("/inbox", func(r chi.Router) { + // GET /api/v1/unit/inbox/ + r.Get("/", api.GetInbox) + r.Route("/{msg_id:[0-9]+}", func(r chi.Router) { + // GET /api/v1/unit/inbox/ + r.Get("/", api.GetInboxMessage) + // GET /api/v1/unit/inbox// + r.Get("/{mark:[a-z]+}", api.MarkInboxMessage) + }) + }) + // POST /api/v1/unit//inbox + r.Post("/{fingerprint:[a-fA-F0-9]+}/inbox", api.SendMessageTo) + // POST /api/v1/unit/enroll + r.Post("/enroll", api.UnitEnroll) + r.Route("/report", func(r chi.Router) { + // POST /api/v1/unit/report/ap + r.Post("/ap", api.UnitReportAP) + // POST /api/v1/unit/report/aps + r.Post("/aps", api.UnitReportMultipleAP) + }) + }) + }) + }) +} diff --git a/api/unit_enroll.go b/api/unit_enroll.go new file mode 100644 index 0000000..a6567f2 --- /dev/null +++ b/api/unit_enroll.go @@ -0,0 +1,60 @@ +package api + +import ( + "encoding/json" + "github.com/evilsocket/islazy/log" + "github.com/andatoshiki/shikigrid/models" + "io" + "net/http" +) + +func (api *API) readEnrollment(w http.ResponseWriter, r *http.Request) (error, models.EnrollmentRequest) { + var enroll models.EnrollmentRequest + + body, err := io.ReadAll(r.Body) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return err, enroll + } + + log.Debug("%s", body) + + enroll.Address = clientIP(r) + enroll.Country = r.Header.Get("CF-IPCountry") + + if err = json.Unmarshal(body, &enroll); err != nil { + log.Warning("error while reading enrollment request from %s: %v", enroll.Address, err) + log.Debug("%s", body) + ERROR(w, http.StatusUnprocessableEntity, err) + return err, enroll + } + + if err = enroll.Validate(); err != nil { + log.Warning("error while validating enrollment request from %s: %v", enroll.Address, err) + log.Debug("%s", body) + ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) + return err, enroll + } + + return nil, enroll +} + +func (api *API) UnitEnroll(w http.ResponseWriter, r *http.Request) { + err, enroll := api.readEnrollment(w, r) + if err != nil { + return + } + + err, unit := models.EnrollUnit(enroll) + if err != nil { + log.Error("%v", err) + ERROR(w, http.StatusInternalServerError, ErrEmpty) + return + } + + log.Debug("unit %s enrolled: id:%d address:%s", unit.Identity(), unit.ID, unit.Address) + + JSON(w, http.StatusOK, map[string]string{ + "token": unit.Token, + }) +} diff --git a/api/unit_inbox.go b/api/unit_inbox.go new file mode 100644 index 0000000..2245cb1 --- /dev/null +++ b/api/unit_inbox.go @@ -0,0 +1,222 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "errors" + "github.com/evilsocket/islazy/log" + "github.com/go-chi/chi/v5" + "github.com/andatoshiki/shikigrid/crypto" + "github.com/andatoshiki/shikigrid/models" + "io" + "net/http" + "strconv" + "time" +) + +var ( + ErrRecNotFound = errors.New("recipient not found") + ErrMessageNotFound = errors.New("message not found") + ErrInvalidKey = errors.New("invalid public key") + ErrInvalidSignature = errors.New("can't verify signature") + ErrDecoding = errors.New("error decoding data") +) + +func (api *API) GetInbox(w http.ResponseWriter, r *http.Request) { + unit := Authenticate(w, r) + if unit == nil { + return + } + + page, err := pageNum(r) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + messages, total, pages := unit.GetPagedInbox(page) + JSON(w, http.StatusOK, map[string]interface{}{ + "records": total, + "pages": pages, + "messages": messages, + }) +} + +// we need this because the models.Message structure doesn't not export data and signature for fast listing. +type fullMessage struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at"` + SeenAt *time.Time `json:"seen_at"` + Sender string `json:"sender"` + SenderName string `json:"sender_name"` + Data string `json:"data"` + Signature string `json:"signature"` +} + +func (api *API) GetInboxMessage(w http.ResponseWriter, r *http.Request) { + unit := Authenticate(w, r) + if unit == nil { + return + } + + msgIDParam := chi.URLParam(r, "msg_id") + msgID, err := strconv.Atoi(msgIDParam) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } else if message := unit.GetInboxMessage(msgID); message == nil { + ERROR(w, http.StatusNotFound, ErrMessageNotFound) + return + } else { + JSON(w, http.StatusOK, fullMessage{ + ID: message.ID, + CreatedAt: message.CreatedAt, + UpdatedAt: message.UpdatedAt, + DeletedAt: message.DeletedAt, + SeenAt: message.SeenAt, + Sender: message.Sender, + SenderName: message.SenderName, + Data: message.Data, + Signature: message.Signature, + }) + } +} + +func (api *API) MarkInboxMessage(w http.ResponseWriter, r *http.Request) { + unit := Authenticate(w, r) + if unit == nil { + return + } + + now := time.Now() + markAs := chi.URLParam(r, "mark") + msgIDParam := chi.URLParam(r, "msg_id") + msgID, err := strconv.Atoi(msgIDParam) + + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } else if message := unit.GetInboxMessage(msgID); message == nil { + ERROR(w, http.StatusNotFound, ErrMessageNotFound) + return + } else if markAs == "seen" { + if err := models.UpdateFields(message, map[string]interface{}{"seen_at": &now}).Error; err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + } else if markAs == "unseen" { + if err := models.UpdateFields(message, map[string]interface{}{"seen_at": nil}).Error; err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + } else if markAs == "deleted" { + if err := models.UpdateFields(message, map[string]interface{}{"deleted_at": &now}).Error; err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + } else if markAs == "restored" { + if err := models.UpdateFields(message, map[string]interface{}{"deleted_at": nil}).Error; err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + } else { + ERROR(w, http.StatusNotFound, ErrEmpty) + return + } + + JSON(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +func (api *API) SendMessageTo(w http.ResponseWriter, r *http.Request) { + // authenticate source unit + srcUnit := Authenticate(w, r) + if srcUnit == nil { + return + } + + // get dest unit by fingerprint + dstUnitFingerprint := chi.URLParam(r, "fingerprint") + dstUnit := models.FindUnitByFingerprint(dstUnitFingerprint) + if dstUnit == nil { + ERROR(w, http.StatusNotFound, ErrRecNotFound) + return + } + + // read the message and signature from the source unit + client := clientIP(r) + body, err := io.ReadAll(r.Body) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + var message Message + if err = json.Unmarshal(body, &message); err != nil { + log.Debug("error while decoding message from %s: %v", srcUnit.Identity(), err) + log.Debug("%s", body) + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + if err := models.ValidateMessage(message.Data, message.Signature); err != nil { + log.Warning("client %s sent a broken message structure: %v", srcUnit.Identity(), err) + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + // parse source unit key + srcKeys, err := crypto.FromPublicPEM(srcUnit.PublicKey) + if err != nil { + log.Warning("error decoding key from %s: %v", srcUnit.Identity(), err) + log.Debug("%s", srcUnit.PublicKey) + ERROR(w, http.StatusUnprocessableEntity, ErrInvalidKey) + return + } + + // decode data, signature and verify SIGN(SHA256(data)) + data, err := base64.StdEncoding.DecodeString(message.Data) + if err != nil { + log.Warning("error decoding message from %s: %v", srcUnit.Identity(), err) + log.Debug("%s", message.Data) + ERROR(w, http.StatusUnprocessableEntity, ErrDecoding) + return + } + + signature, err := base64.StdEncoding.DecodeString(message.Signature) + if err != nil { + log.Warning("error decoding signature from %s: %v", srcUnit.Identity(), err) + log.Debug("%s", message.Signature) + ERROR(w, http.StatusUnprocessableEntity, ErrDecoding) + return + } + + if err := srcKeys.VerifyMessage(data, signature); err != nil { + log.Warning("error verifying signature from %s: %v", srcUnit.Identity(), err) + log.Debug("%s", message.Signature) + ERROR(w, http.StatusUnprocessableEntity, ErrInvalidSignature) + return + } + + msg := models.Message{ + SenderID: srcUnit.ID, + Sender: srcUnit.Fingerprint, + SenderName: srcUnit.Name, + ReceiverID: dstUnit.ID, + Data: message.Data, + Signature: message.Signature, + } + + if err := models.Create(&msg).Error; err != nil { + log.Warning("error creating msg %v from %s: %v", msg, client, err) + ERROR(w, http.StatusInternalServerError, ErrEmpty) + return + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) +} diff --git a/api/unit_report.go b/api/unit_report.go new file mode 100644 index 0000000..3585ff4 --- /dev/null +++ b/api/unit_report.go @@ -0,0 +1,108 @@ +package api + +import ( + "encoding/json" + "fmt" + "github.com/evilsocket/islazy/log" + "github.com/andatoshiki/shikigrid/models" + "io" + "net" + "net/http" +) + +type apReport struct { + ESSID string `json:"essid"` + BSSID string `json:"bssid"` +} + +func (api *API) unitReport(client string, unit *models.Unit, ap apReport) error { + if parsed, err := net.ParseMAC(ap.BSSID); err != nil { + return fmt.Errorf("error while parsing wifi ap bssid %s from %s: %v", ap.BSSID, client, err) + } else { + // normalize + ap.BSSID = parsed.String() + } + + if existing := unit.FindAccessPoint(ap.ESSID, ap.BSSID); existing == nil { + log.Debug("unit %s (%s %s) reporting new wifi access point %v", unit.Identity(), unit.Address, + unit.Country, ap) + + newAP := models.AccessPoint{ + Name: ap.ESSID, + Mac: ap.BSSID, + UnitID: unit.ID, + } + + if err := models.Create(&newAP).Error; err != nil { + return fmt.Errorf("error creating ap %v: %v", newAP, err) + } + } else if err := models.Update(existing).Error; err != nil { + return fmt.Errorf("error updating ap %v: %v", existing, err) + } + + return nil +} + +func (api *API) UnitReportAP(w http.ResponseWriter, r *http.Request) { + unit := Authenticate(w, r) + if unit == nil { + return + } + + client := clientIP(r) + body, err := io.ReadAll(r.Body) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) + return + } + + var ap apReport + if err = json.Unmarshal(body, &ap); err != nil { + log.Warning("error while reading wifi ap from %s: %v", client, err) + ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) + return + } + + if err := api.unitReport(client, unit, ap); err != nil { + log.Warning("%v", err) + ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) + return + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) +} + +func (api *API) UnitReportMultipleAP(w http.ResponseWriter, r *http.Request) { + unit := Authenticate(w, r) + if unit == nil { + return + } + + client := clientIP(r) + body, err := io.ReadAll(r.Body) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) + return + } + + var aps []apReport + if err = json.Unmarshal(body, &aps); err != nil { + log.Warning("error while reading wifi ap list from %s: %v", client, err) + ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) + return + } + + for _, ap := range aps { + if err := api.unitReport(client, unit, ap); err != nil { + log.Warning("%v", err) + ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) + return + } + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) +} diff --git a/api/unit_show.go b/api/unit_show.go new file mode 100644 index 0000000..1c1dffa --- /dev/null +++ b/api/unit_show.go @@ -0,0 +1,17 @@ +package api + +import ( + "github.com/go-chi/chi/v5" + "github.com/andatoshiki/shikigrid/models" + "net/http" +) + +func (api *API) ShowUnit(w http.ResponseWriter, r *http.Request) { + unitFingerprint := chi.URLParam(r, "fingerprint") + if unit := models.FindUnitByFingerprint(unitFingerprint); unit == nil { + ERROR(w, http.StatusNotFound, ErrEmpty) + return + } else { + JSON(w, http.StatusOK, unit) + } +} diff --git a/api/units_list.go b/api/units_list.go new file mode 100644 index 0000000..74ef426 --- /dev/null +++ b/api/units_list.go @@ -0,0 +1,33 @@ +package api + +import ( + "github.com/evilsocket/islazy/log" + "github.com/andatoshiki/shikigrid/models" + "net/http" +) + +func (api *API) ListUnits(w http.ResponseWriter, r *http.Request) { + page, err := pageNum(r) + if err != nil { + ERROR(w, http.StatusUnprocessableEntity, err) + return + } + + units, total, pages := models.GetPagedUnits(page) + + JSON(w, http.StatusOK, map[string]interface{}{ + "records": total, + "pages": pages, + "units": units, + }) +} + +func (api *API) UnitsByCountry(w http.ResponseWriter, r *http.Request) { + if results, err := models.GetUnitsByCountry(); err != nil { + log.Warning("error getting units by country: %v", err) + ERROR(w, http.StatusInternalServerError, err) + return + } else { + JSON(w, http.StatusOK, results) + } +} diff --git a/api/utils.go b/api/utils.go new file mode 100644 index 0000000..befd9a6 --- /dev/null +++ b/api/utils.go @@ -0,0 +1,78 @@ +package api + +import ( + "encoding/json" + "errors" + "github.com/evilsocket/islazy/log" + "net/http" + "strconv" + "strings" +) + +var ( + ErrEmpty = errors.New("") + ErrUnauthorized = errors.New("unauthorized") +) + +func clientIP(r *http.Request) string { + address := strings.Split(r.RemoteAddr, ":")[0] + if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" { + address = forwardedFor + } + // https://support.cloudflare.com/hc/en-us/articles/206776727-What-is-True-Client-IP- + if trueClient := r.Header.Get("True-Client-IP"); trueClient != "" { + address = trueClient + } + // handle multiple IPs case + return strings.Trim(strings.Split(address, ",")[0], " ") +} + +func reqToken(r *http.Request) string { + keys := r.URL.Query() + token := keys.Get("token") + if token != "" { + return token + } + bearerToken := r.Header.Get("Authorization") + if parts := strings.Split(bearerToken, " "); len(parts) == 2 { + return parts[1] + } + return "" +} + +func pageNum(r *http.Request) (int, error) { + pageParam := r.URL.Query().Get("p") + if pageParam == "" { + pageParam = "1" + } + return strconv.Atoi(pageParam) +} + +func JSON(w http.ResponseWriter, statusCode int, data interface{}) { + js, err := json.Marshal(data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + if sent, err := w.Write(js); err != nil { + log.Error("error sending response: %v", err) + } else { + log.Debug("sent %d bytes of json response", sent) + } +} + +func ERROR(w http.ResponseWriter, statusCode int, err error) { + if err != nil { + JSON(w, statusCode, struct { + Error string `json:"error"` + }{ + Error: err.Error(), + }) + return + } + JSON(w, http.StatusBadRequest, nil) +} diff --git a/builder/arm_builder.sh b/builder/arm_builder.sh new file mode 100755 index 0000000..349714d --- /dev/null +++ b/builder/arm_builder.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -eu + +PROGRAM="${1}" +shift +COMMAND="${*}" + +IMAGE="https://downloads.raspberrypi.org/raspbian_lite/images/raspbian_lite-2019-07-12/2019-07-10-raspbian-buster-lite.zip" +GOLANG="https://dl.google.com/go/go1.13.1.linux-armv6l.tar.gz" + +REPO_DIR="${PWD}" +TMP_DIR="/tmp/builder" +MNT_DIR="${TMP_DIR}/mnt" + +if ! systemctl is-active systemd-binfmt.service >/dev/null 2>&1; then + mkdir -p "/lib/binfmt.d" + echo ':qemu-arm:M::\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x28\x00:\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xff\xff:/usr/bin/qemu-arm-static:F' > /lib/binfmt.d/qemu-arm-static.conf + systemctl restart systemd-binfmt.service +fi + +mkdir -p "${TMP_DIR}" +wget --show-progress -qcO "${TMP_DIR}/raspbian.zip" "${IMAGE}" +gunzip -c "${TMP_DIR}/raspbian.zip" > "${TMP_DIR}/raspbian.img" +truncate "${TMP_DIR}/raspbian.img" --size=+2G +parted --script "${TMP_DIR}/raspbian.img" resizepart 2 100% + +LOOP_PATH="$(losetup --find --partscan --show "${TMP_DIR}/raspbian.img")" +e2fsck -y -f "${LOOP_PATH}p2" +resize2fs "${LOOP_PATH}p2" +partprobe "${LOOP_PATH}" + +mkdir -p "${MNT_DIR}" +mountpoint -q "${MNT_DIR}" && umount -R "${MNT_DIR}" +mount -o rw "${LOOP_PATH}p2" "${MNT_DIR}" +mount -o rw "${LOOP_PATH}p1" "${MNT_DIR}/boot" + +mount --bind /dev "${MNT_DIR}/dev/" +mount --bind /sys "${MNT_DIR}/sys/" +mount --bind /proc "${MNT_DIR}/proc/" +mount --bind /dev/pts "${MNT_DIR}/dev/pts" +mount | grep "${MNT_DIR}" +df -h + +cp /usr/bin/qemu-arm-static "${MNT_DIR}/usr/bin" +cp /etc/resolv.conf "${MNT_DIR}/etc/resolv.conf" + +mkdir -p "${MNT_DIR}/root/src/${PROGRAM}" +mount --bind "${REPO_DIR}" "${MNT_DIR}/root/src/${PROGRAM}" + +cp "${MNT_DIR}/etc/ld.so.preload" "${MNT_DIR}/etc/_ld.so.preload" +touch "${MNT_DIR}/etc/ld.so.preload" + +chroot "${MNT_DIR}" bin/bash -x </dev/null +printf "\n\n" + +CURTAG=$(git describe --tags --abbrev=0) +OUTPUT=$(git log $CURTAG..HEAD --oneline) +IFS=$'\n' LINES=($OUTPUT) + +for LINE in "${LINES[@]}"; do + LINE=$(echo "$LINE" | sed -E "s/^[[:xdigit:]]+\s+//") + if [[ $LINE == *"new:"* ]]; then + LINE=$(echo "$LINE" | sed -E "s/^new: //") + NEW+=("$LINE") + elif [[ $LINE == *"fix:"* ]]; then + LINE=$(echo "$LINE" | sed -E "s/^fix: //") + FIXES+=("$LINE") + elif [[ $LINE != *"i did not bother commenting"* ]] && [[ $LINE != *"Merge "* ]]; then + echo " MISC LINE =$LINE" + LINE=$(echo "$LINE" | sed -E "s/^[a-z]+: //") + MISC+=("$LINE") + fi +done + +if [ -n "$NEW" ]; then + echo + echo "**New Features**" + echo + for l in "${NEW[@]}"; do + echo "* $l" + done +fi + +if [ -n "$FIXES" ]; then + echo + echo "**Fixes**" + echo + for l in "${FIXES[@]}"; do + echo "* $l" + done +fi + +if [ -n "$MISC" ]; then + echo + echo "**Misc**" + echo + for l in "${MISC[@]}"; do + echo "* $l" + done +fi + +echo diff --git a/cmd/shikigrid/inbox.go b/cmd/shikigrid/inbox.go new file mode 100644 index 0000000..6f8b5c2 --- /dev/null +++ b/cmd/shikigrid/inbox.go @@ -0,0 +1,180 @@ +package main + +import ( + "fmt" + "github.com/evilsocket/islazy/log" + "github.com/evilsocket/islazy/tui" + "github.com/andatoshiki/shikigrid/api" + "os" + "os/exec" + "runtime" + "time" +) + +func clearScreen() { + var what []string + if runtime.GOOS == "windows" { + what = []string{"cmd", "/c", "cls"} + } else { + what = []string{"clear", ""} + } + cmd := exec.Command(what[0], what[1:]...) + cmd.Stdout = os.Stdout + err := cmd.Run() + if err != nil { + return + } +} + +func showInbox(server *api.API, box map[string]interface{}) { + messages := box["messages"].([]interface{}) + numMessages := len(messages) + + if numMessages > 0 { + if clear { + log.Info("clearing %d messages", numMessages) + for _, m := range messages { + msg := m.(map[string]interface{}) + msgID := int(msg["id"].(float64)) + log.Info("deleting message %d ...", msgID) + if _, err := server.Client.MarkInboxMessage(msgID, "deleted"); err != nil { + log.Error("%v", err) + } + } + } else { + records := box["records"].(float64) + pages := box["pages"].(float64) + columns := []string{ + "ID", + "Date", + "Sender", + } + rows := [][]string{} + for _, m := range messages { + var row []string + msg := m.(map[string]interface{}) + + t, err := time.Parse(time.RFC3339, msg["created_at"].(string)) + if err != nil { + panic(err) + } + + row = []string{ + fmt.Sprintf("%d", int(msg["id"].(float64))), + t.Format("02 January 2006, 3:04 PM"), + fmt.Sprintf("%s@%s", msg["sender_name"], msg["sender"]), + } + + if msg["seen_at"] != nil { + for i := range row { + row[i] = tui.Dim(row[i]) + } + } + + rows = append(rows, row) + } + + fmt.Println() + tui.Table(os.Stdout, columns, rows) + fmt.Println() + + fmt.Printf("%d of %d (page %d of %d)", numMessages, int(records), page, int(pages)) + } + } else { + fmt.Println() + fmt.Println(tui.Dim("Inbox is empty.")) + } + + fmt.Println() +} + +func showMessage(msg map[string]interface{}) { + t, err := time.Parse(time.RFC3339, msg["created_at"].(string)) + if err != nil { + panic(err) + } + + fmt.Println() + fmt.Printf("From: %s@%s\n", msg["sender_name"], msg["sender"]) + fmt.Printf("Date: %s\n\n", t.Format("02 January 2006, 3:04 PM")) + if output == "" { + fmt.Printf("%s\n", msg["data"]) + fmt.Println() + } else if err := os.WriteFile(output, msg["data"].([]byte), os.ModePerm); err != nil { + log.Fatal("error writing to %s: %v", output, err) + } else { + log.Info("%s written", output) + } +} + +func sendMessage() { + var err error + + // send a message + var raw []byte + if message == "" { + log.Fatal("-message can not be empty") + } else if message[0] == '@' { + log.Info("reading %s ...", message[1:]) + if raw, err = os.ReadFile(message[1:]); err != nil { + log.Fatal("error reading %s: %v", message[1:], err) + } + } else { + raw = []byte(message) + } + + if status, err := server.SendMessage(receiver, raw); err != nil { + log.Fatal("%d %v", status, err) + } else { + log.Info("message sent") + } +} + +func doInbox(server *api.API) { + if receiver != "" { + sendMessage() + } else if inbox { + // just show the inbox + if id == 0 { + log.Info("fetching inbox ...") + if box, err := server.Client.Inbox(page); err != nil { + log.Fatal("%v", err) + } else { + showInbox(server, box) + } + } else if del { + log.Info("deleting message %d ...", id) + if _, err := server.Client.MarkInboxMessage(id, "deleted"); err != nil { + log.Fatal("%v", err) + } + } else if unread { + log.Info("marking message %d as unread ...", id) + if _, err := server.Client.MarkInboxMessage(id, "unseen"); err != nil { + log.Fatal("%v", err) + } + } else { + log.Info("fetching message %d ...", id) + + if msg, status, err := server.InboxMessage(id); err != nil { + log.Fatal("%d %v", status, err) + } else { + showMessage(msg) + _, _ = server.Client.MarkInboxMessage(id, "seen") + } + } + } +} + +func inboxMain() { + if inbox { + doInbox(server) + if loop { + ticker := time.NewTicker(time.Duration(loopPeriod) * time.Second) + for _ = range ticker.C { + clearScreen() + doInbox(server) + } + } + os.Exit(0) + } +} diff --git a/cmd/shikigrid/main.go b/cmd/shikigrid/main.go new file mode 100644 index 0000000..3c41329 --- /dev/null +++ b/cmd/shikigrid/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "flag" + "fmt" + "github.com/evilsocket/islazy/log" + "github.com/andatoshiki/shikigrid/version" +) + +func main() { + flag.Parse() + + setupCore() + defer cleanup() + + // just print the version and exit + if ver { + fmt.Println(version.Version) + return + } + + // from here on we need logging + if err := log.Open(); err != nil { + panic(err) + } + defer log.Close() + + // do mode related initialization + setupMode() + + // if we're in peer mode and is an inbox action + if inbox { + inboxMain() + } else { + // just start the API in either modes + server.Run(address) + } +} diff --git a/cmd/shikigrid/setup.go b/cmd/shikigrid/setup.go new file mode 100644 index 0000000..35813e3 --- /dev/null +++ b/cmd/shikigrid/setup.go @@ -0,0 +1,190 @@ +package main + +import ( + "os" + "os/signal" + "runtime/pprof" + "time" + + "github.com/evilsocket/islazy/fs" + "github.com/evilsocket/islazy/log" + "github.com/andatoshiki/shikigrid/api" + "github.com/andatoshiki/shikigrid/crypto" + "github.com/andatoshiki/shikigrid/mesh" + "github.com/andatoshiki/shikigrid/models" + "github.com/andatoshiki/shikigrid/utils" + "github.com/andatoshiki/shikigrid/version" + "github.com/joho/godotenv" +) + +func cleanup() { + if cpuProfile != "" { + log.Info("writing CPU profile to %s ...", cpuProfile) + pprof.StopCPUProfile() + } + + if memProfile != "" { + log.Info("writing memory profile to %s ...", memProfile) + f, err := os.Create(memProfile) + if err != nil { + log.Fatal("%v", err) + } + defer func() { + if err := f.Close(); err != nil { + panic(err) + } + }() + if err := pprof.WriteHeapProfile(f); err != nil { + panic(err) + } + } +} + +func setupCore() { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + go func() { + for sig := range c { + log.Warning("received signal %v", sig) + cleanup() + os.Exit(0) + } + }() + + if cpuProfile != "" { + f, err := os.Create(cpuProfile) + if err != nil { + log.Fatal("%v", err) + } + if err := pprof.StartCPUProfile(f); err != nil { + panic(err) + } + } + + if debug { + log.Level = log.DEBUG + } else { + log.Level = log.INFO + } + log.OnFatal = log.ExitOnFatal +} + +func waitForKeys() { + privPath := crypto.PrivatePath(keysPath) + for { + if !fs.Exists(privPath) { + log.Debug("waiting for %s ...", privPath) + time.Sleep(1 * time.Second) + } else { + // give it a moment to finish disk sync + time.Sleep(2 * time.Second) + log.Info("%s found", privPath) + break + } + } +} + +func setupMesh() { + var err error + peer = mesh.MakeLocalPeer(utils.Hostname(), keys, advertise) + if !advertise { + return //this probably doesn't work + } + + if err = peer.StartAdvertising(iface); err != nil { + log.Fatal("error while starting signaling: %v", err) + } + if router, err = mesh.StartRouting(iface, peersPath, peer); err != nil { + log.Fatal("%v", err) + } else { + router.OnNewPeer(func(ident string, peer *mesh.Peer) { + log.Info("detected new peer %s on channel %d", peer.ID(), peer.Channel) + }) + router.OnPeerLost(func(ident string, peer *mesh.Peer) { + log.Info("peer %s lost (inactive for %fs)", peer.ID(), peer.InactiveFor()) + }) + } + log.Info("peer %s signaling is ready", peer.ID()) +} + +func setupDB() { + if err := godotenv.Load(env); err != nil { + log.Fatal("%v", err) + } + if err := models.Setup(); err != nil { + if nodb { + log.Warning("%v", err) + } else { + log.Fatal("%v", err) + } + } +} + +func setupMode() string { + var err error + + // in case -inbox was not explicitly passed + if receiver != "" || loop == true || id > 0 { + inbox = true + } + + // for inbox actions, set the keys to the default path if empty + if (whoami || inbox) && keysPath == "" { + keysPath = "/etc/shikigotchi/" + } + + // generate keypair + if generate { + if keysPath == "" { + log.Fatal("no -keys path specified") + } else if crypto.KeysExist(keysPath) { + log.Fatal("keypair already exists in %s", keysPath) + } + + if _, err = crypto.LoadOrCreate(keysPath, 4096); err != nil { + log.Fatal("error generating RSA keypair: %v", err) + } else { + log.Info("keypair saved to %s", keysPath) + } + os.Exit(0) + } + + mode := "peer" + // if keys have been passed explicitly, or one of the inbox actions + // has been specified, we're running on the unit + // if keysPath != "" { + // mode = "peer" + // } + + log.Info("shikigrid v%s starting in %s mode ...", version.Version, mode) + + // wait for keys to be generated + if wait { + waitForKeys() + } + // load the keys + if keys, err = crypto.Load(keysPath); err != nil { + log.Fatal("error while loading keys from %s: %v", keysPath, err) + } + // print identity and exit + if whoami { + if Endpoint == "https://grid-api.toshiki.dev/api/v1" { + log.Info("https://grid.toshiki.dev/search/%s", keys.FingerprintHex) + } else { + log.Info("https://pwnagotchi.ai/pwnfile/#!%s", keys.FingerprintHex) + } + os.Exit(0) + } + // only start mesh signaling if this is not an inbox action + if !inbox { + setupMesh() + } + + // set up the proper routes for either server or peer mode + err, server = api.Setup(keys, peer, router, Endpoint, Hostname) + if err != nil { + log.Fatal("%v", err) + } + + return mode +} diff --git a/cmd/shikigrid/vars.go b/cmd/shikigrid/vars.go new file mode 100644 index 0000000..7d01d85 --- /dev/null +++ b/cmd/shikigrid/vars.go @@ -0,0 +1,83 @@ +package main + +import ( + "flag" + + "github.com/evilsocket/islazy/log" + "github.com/andatoshiki/shikigrid/api" + "github.com/andatoshiki/shikigrid/crypto" + "github.com/andatoshiki/shikigrid/mesh" +) + +var ( + debug = false + ver = false + wait = false + inbox = false + del = false + unread = false + clear = false + whoami = false + generate = false + loop = false + nodb = false + loopPeriod = 30 + receiver = "" + message = "" + output = "" + page = 1 + id = 0 + address = "0.0.0.0:8666" + env = ".env" + iface = "wlan0mon" + keysPath = "" + peersPath = "/root/peers" + keys = (*crypto.KeyPair)(nil) + router = (*mesh.Router)(nil) + peer = (*mesh.Peer)(nil) + server = (*api.API)(nil) + cpuProfile = "" + memProfile = "" + Endpoint = "https://grid-api.toshiki.dev/api/v1" + advertise = true + Hostname = "" +) + +func init() { + flag.BoolVar(&ver, "version", ver, "Print version and exit.") + flag.BoolVar(&debug, "debug", debug, "Enable debug logs.") + flag.BoolVar(&nodb, "no-db", debug, "Don't fail if database connection can't be enstablished.") + flag.StringVar(&log.Output, "log", log.Output, "Log file path or empty for standard output.") + flag.StringVar(&address, "address", address, "API address.") + flag.StringVar(&env, "env", env, "Load .env from.") + + flag.StringVar(&keysPath, "keys", keysPath, "If set, will load RSA keys from this folder and start in peer mode.") + flag.BoolVar(&generate, "generate", generate, "Generate an RSA keypair if it doesn't exist yet.") + flag.BoolVar(&wait, "wait", wait, "Wait for keys to be generated.") + flag.IntVar(&api.ClientTimeout, "client-timeout", api.ClientTimeout, "Timeout in seconds for requests to the server when in peer mode.") + flag.StringVar(&api.ClientTokenFile, "client-token", api.ClientTokenFile, "File where to store the API token.") + + flag.StringVar(&iface, "iface", iface, "Monitor interface to use for mesh advertising.") + flag.StringVar(&peersPath, "peers", peersPath, "path to save historical information of met peers.") + flag.IntVar(&mesh.SignalingPeriod, "signaling-period", mesh.SignalingPeriod, "Period in milliseconds for mesh signaling frames.") + + flag.BoolVar(&whoami, "whoami", whoami, "Prints the public key fingerprint and exit.") + flag.BoolVar(&inbox, "inbox", inbox, "Show inbox.") + flag.BoolVar(&loop, "loop", loop, "Keep refreshing and showing inbox.") + flag.IntVar(&loopPeriod, "loop-period", loopPeriod, "Period in seconds to refresh the inbox.") + flag.StringVar(&receiver, "send", receiver, "Receiver unit fingerprint.") + flag.StringVar(&message, "message", message, "Message body or file path if prefixed by @.") + flag.StringVar(&output, "output", output, "Write message body to this file instead of the standard output.") + flag.BoolVar(&del, "delete", del, "Delete the specified message.") + flag.BoolVar(&unread, "unread", unread, "Unread the specified message.") + flag.BoolVar(&clear, "clear", unread, "Delete all messages of the given page of the inbox.") + flag.IntVar(&page, "page", page, "Inbox page.") + flag.IntVar(&id, "id", id, "Message id.") + + flag.StringVar(&cpuProfile, "cpu-profile", cpuProfile, "Generate CPU profile to this file.") + flag.StringVar(&memProfile, "mem-profile", cpuProfile, "Generate memory profile to this file.") + + flag.StringVar(&Endpoint, "endpoint", Endpoint, "Pass which endpoint shikigrid should be using.") + flag.BoolVar(&advertise, "advertise", advertise, "Advertise?") + flag.StringVar(&Hostname, "hostname", Hostname, "Pass hostname to shikigrid, makes it so it wont read os.hostname()") +} diff --git a/crypto/encrypt.go b/crypto/encrypt.go new file mode 100644 index 0000000..e71e618 --- /dev/null +++ b/crypto/encrypt.go @@ -0,0 +1,120 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "encoding/binary" + "fmt" + "io" +) + +const ( + AESKEyLength = 32 + NonceLength = 12 +) + +func (pair *KeyPair) EncryptFor(cleartext []byte, pubKey *rsa.PublicKey) ([]byte, error) { + // generate a random 32 bytes long key + key := make([]byte, AESKEyLength) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, err + } + + // encrypt the key with RSA + encKey, err := pair.EncryptBlockFor(key, pubKey) + if err != nil { + return nil, err + } + + // use that key to encrypt the cleartext in AES-GCM + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + nonce := make([]byte, NonceLength) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + encrypted := gcm.Seal(nil, nonce, cleartext, nil) + + keySizeBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(keySizeBuf, uint32(len(encKey))) + + // send all + encrypted = append(encKey, encrypted...) // key enc + encrypted = append(keySizeBuf, encrypted...) // ksz key enc + encrypted = append(nonce, encrypted...) // nonce ksz key enc + + return encrypted, nil +} + +func (pair *KeyPair) EncryptBlockFor(block []byte, pubKey *rsa.PublicKey) ([]byte, error) { + return rsa.EncryptOAEP( + Hasher.New(), + rand.Reader, + pubKey, + block, + []byte("")) +} + +func (pair *KeyPair) DecryptBlock(block []byte) ([]byte, error) { + return rsa.DecryptOAEP( + Hasher.New(), + rand.Reader, + pair.Private, + block, + []byte("")) +} + +func (pair *KeyPair) Decrypt(ciphertext []byte) ([]byte, error) { + dataAvailable := len(ciphertext) + if dataAvailable < NonceLength { + return nil, fmt.Errorf("data buffer too short") + } + + nonce := ciphertext[0:NonceLength] + dataAvailable -= NonceLength + + if dataAvailable < 4 { + return nil, fmt.Errorf("data buffer too short") + } + + keySizeBuf := ciphertext[NonceLength : NonceLength+4] + keySize := binary.LittleEndian.Uint32(keySizeBuf) + dataAvailable -= 4 + + if dataAvailable < int(keySize) { + return nil, fmt.Errorf("data buffer too short") + } + + encKey := ciphertext[NonceLength+4 : NonceLength+4+keySize] + ciphertext = ciphertext[NonceLength+4+keySize:] + + // decrypt the key + key, err := pair.DecryptBlock(encKey) + if err != nil { + return nil, err + } + + // decrypt the payload + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + return gcm.Open(nil, nonce, ciphertext, nil) +} diff --git a/crypto/keypair.go b/crypto/keypair.go new file mode 100644 index 0000000..bfc2edf --- /dev/null +++ b/crypto/keypair.go @@ -0,0 +1,173 @@ +package crypto + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "github.com/evilsocket/islazy/fs" + "github.com/evilsocket/islazy/log" + "os" + "path" + "strings" +) + +type KeyPair struct { + Path string + Bits int + PrivatePath string + Private *rsa.PrivateKey + PrivatePEM []byte + PublicPath string + Public *rsa.PublicKey + PublicPEM []byte + // sha256 of PublicSSH + Fingerprint []byte + FingerprintHex string +} + +func pubKeyToPEM(key *rsa.PublicKey) ([]byte, error) { + bytes, err := x509.MarshalPKIXPublicKey(key) + if err != nil { + return nil, err + } + return pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: bytes, + }, + ), nil +} + +func FromPublicPEM(pubPEM string) (pair *KeyPair, err error) { + block, _ := pem.Decode([]byte(pubPEM)) + if block == nil { + return nil, fmt.Errorf("failed to parse PEM block containing the public key") + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + + pair = &KeyPair{} + ok := false + + if pair.Public, ok = pub.(*rsa.PublicKey); !ok { + return nil, fmt.Errorf("not an RSA key") + } + + return pair, pair.setupPublic() +} + +func PrivatePath(keysPath string) string { + return path.Join(keysPath, "id_rsa") +} + +func Load(keysPath string) (pair *KeyPair, err error) { + privFile := PrivatePath(keysPath) + pair = &KeyPair{ + Path: keysPath, + PrivatePath: privFile, + PublicPath: privFile + ".pub", + } + return pair, pair.Load() +} + +func KeysExist(keysPath string) bool { + return fs.Exists(keysPath) && fs.Exists(PrivatePath(keysPath)) +} + +func LoadOrCreate(keysPath string, bits int) (pair *KeyPair, err error) { + privFile := PrivatePath(keysPath) + pair = &KeyPair{ + Path: keysPath, + Bits: bits, + PrivatePath: privFile, + PublicPath: privFile + ".pub", + } + + if !fs.Exists(pair.PrivatePath) { + if !fs.Exists(keysPath) { + log.Debug("creating %s", keysPath) + if err := os.MkdirAll(keysPath, os.ModePerm); err != nil { + return nil, fmt.Errorf("could not create %s: %v", keysPath, err) + } + } + log.Info("%s not found, generating keypair ...", pair.PrivatePath) + + if pair.Private, err = rsa.GenerateKey(rand.Reader, bits); err != nil { + return nil, fmt.Errorf("could not generate private key: %v", err) + } + pair.Public = &pair.Private.PublicKey + + if err = pair.Save(); err != nil { + return nil, fmt.Errorf("could not save keypair: %v", err) + } + } else if err = pair.Load(); err != nil { + return nil, fmt.Errorf("could not load keypair: %v", err) + } + + return pair, nil +} + +func (pair *KeyPair) setupPublic() (err error) { + if pair.PublicPEM, err = pubKeyToPEM(pair.Public); err != nil { + return fmt.Errorf("failed converting public key to PEM: %v", err) + } + + cleanPEM := strings.TrimRight(string(pair.PublicPEM), "\n") + + hash := Hasher.New() + hash.Write([]byte(cleanPEM)) + + pair.Fingerprint = hash.Sum(nil) + pair.FingerprintHex = fmt.Sprintf("%02x", pair.Fingerprint) + + return nil +} + +func (pair *KeyPair) Save() (err error) { + prvKeyBytes := x509.MarshalPKCS1PrivateKey(pair.Private) + pair.PrivatePEM = pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: prvKeyBytes, + }, + ) + + if err = os.WriteFile(pair.PrivatePath, pair.PrivatePEM, os.ModePerm); err != nil { + return + } + + log.Debug("%s created", pair.PrivatePath) + + if err = pair.setupPublic(); err != nil { + return err + } + + err = os.WriteFile(pair.PublicPath, pair.PublicPEM, os.ModePerm) + + log.Debug("%s created", pair.PublicPath) + return +} + +func (pair *KeyPair) Load() (err error) { + log.Debug("reading %s ...", pair.PrivatePath) + if pair.PrivatePEM, err = os.ReadFile(pair.PrivatePath); err != nil { + return + } + + block, _ := pem.Decode(pair.PrivatePEM) + if block == nil { + return fmt.Errorf("failed decoding PEM from %s", pair.PrivatePath) + } + + if pair.Private, err = x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + return fmt.Errorf("failed parsing %s: %v", pair.PrivatePath, err) + } + + pair.Public = &pair.Private.PublicKey + return pair.setupPublic() +} diff --git a/crypto/sign.go b/crypto/sign.go new file mode 100644 index 0000000..22b7a5e --- /dev/null +++ b/crypto/sign.go @@ -0,0 +1,42 @@ +package crypto + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" +) + +var pssOpts = rsa.PSSOptions{ + SaltLength: 16, +} + +const Hasher = crypto.SHA256 + +func (pair *KeyPair) Sign(hash crypto.Hash, hashed []byte) ([]byte, error) { + return rsa.SignPSS(rand.Reader, pair.Private, hash, hashed, &pssOpts) +} + +func (pair *KeyPair) SignMessage(data []byte) ([]byte, error) { + hasher := Hasher.New() + hasher.Write(data) + hash := hasher.Sum(nil) + return pair.Sign(Hasher, hash) +} + +func (pair *KeyPair) Verify(signature []byte, hasher crypto.Hash, hash []byte) error { + return rsa.VerifyPSS( + pair.Public, + hasher, + hash, + signature, + &pssOpts) +} + +func (pair *KeyPair) VerifyMessage(data []byte, signature []byte) error { + hasher := Hasher.New() + hasher.Write(data) + hash := hasher.Sum(nil) + // log.Info("hash(data) = %x", hash) + // log.Info("signature = %x", signature) + return pair.Verify(signature, Hasher, hash) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99cddb2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3' +services: + app: + container_name: shikigrid_api + build: . + ports: + - 8666:8666 + restart: on-failure + volumes: + - api:/usr/src/app/ + depends_on: + - shikigrid-mysql + networks: + - shikigrid + + shikigrid-mysql: + image: mysql:5.7 + container_name: shikigrid_mysql + ports: + - 3306:3306 + environment: + - MYSQL_ROOT_HOST=${DB_HOST} + - MYSQL_USER=${DB_USER} + - MYSQL_PASSWORD=${DB_PASSWORD} + - MYSQL_DATABASE=${DB_NAME} + - MYSQL_ROOT_PASSWORD=${DB_PASSWORD} + volumes: + - database_mysql:/var/lib/mysql + networks: + - shikigrid + + phpmyadmin: + image: phpmyadmin/phpmyadmin + container_name: shikigrid_phpmyadmin + depends_on: + - shikigrid-mysql + environment: + - PMA_HOST=shikigrid-mysql + - PMA_USER=${DB_USER} + - PMA_PORT=${DB_PORT} + - PMA_PASSWORD=${DB_PASSWORD} + ports: + - 9090:80 + restart: always + networks: + - shikigrid + +volumes: + api: + database_mysql: + +networks: + shikigrid: + driver: bridge diff --git a/env.example b/env.example new file mode 100644 index 0000000..a39aec4 --- /dev/null +++ b/env.example @@ -0,0 +1,8 @@ +API_SECRET=02zygnJs5e0bBLJjaHCinWTjfRdheTYO + +DB_HOST=shikigrid-mysql +DB_DRIVER=mysql +DB_USER=shikigrid +DB_PASSWORD=shikigrid +DB_NAME=shikigrid +DB_PORT=3306 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..320c87b --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/andatoshiki/shikigrid + +go 1.21 + +require ( + github.com/biezhi/gorm-paginator/pagination v0.0.0-20190124091837-7a5c8ed20334 + github.com/evilsocket/islazy v1.11.0 + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/cors v1.2.1 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/gopacket/gopacket v1.2.0 + github.com/jinzhu/gorm v1.9.16 + github.com/joho/godotenv v1.5.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + golang.org/x/sys v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..772d575 --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/biezhi/gorm-paginator/pagination v0.0.0-20190124091837-7a5c8ed20334 h1:ptFjQ4+vPGZDiNmBuKUetQoREFiPz/WB29CfQfdfeKc= +github.com/biezhi/gorm-paginator/pagination v0.0.0-20190124091837-7a5c8ed20334/go.mod h1:Y/N4aF7p+Med/9ivVSsGBc8xOs8BGptUVBCY3k4KFCY= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/evilsocket/islazy v1.11.0 h1:B5w6uuS6ki6iDG+aH/RFeoMb8ijQh/pGabewqp2UeJ0= +github.com/evilsocket/islazy v1.11.0/go.mod h1:muYH4x5MB5YRdkxnrOtrXLIBX6LySj1uFIqys94LKdo= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +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/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/gopacket/gopacket v1.2.0 h1:eXbzFad7f73P1n2EJHQlsKuvIMJjVXK5tXoSca78I3A= +github.com/gopacket/gopacket v1.2.0/go.mod h1:BrAKEy5EOGQ76LSqh7DMAr7z0NNPdczWm2GxCG7+I8M= +github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= +github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= +github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= +github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= +github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA= +github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/mesh/hopping.go b/mesh/hopping.go new file mode 100644 index 0000000..c3dc5ab --- /dev/null +++ b/mesh/hopping.go @@ -0,0 +1,41 @@ +package mesh + +import ( + "github.com/evilsocket/islazy/log" + "github.com/evilsocket/islazy/str" + "sort" + "strconv" + "time" +) + +func ChannelHopping(iface string, chanList string, allChannels []int, hopPeriod int) { + var channels []int + for _, s := range str.Comma(chanList) { + if ch, err := strconv.Atoi(s); err != nil { + log.Fatal("%v", err) + } else { + channels = append(channels, ch) + } + } + if len(channels) == 0 { + channels = allChannels + } + sort.Ints(channels) + + go func() { + period := time.Duration(hopPeriod) * time.Millisecond + tick := time.NewTicker(period) + + log.Info("channel hopper started (period:%s channels:%v)", period, channels) + + loop := 0 + for _ = range tick.C { + ch := channels[loop%len(channels)] + // log.Debug("hopping on channel %d", ch) + if err, out := SetChannel(iface, ch); err != nil { + log.Error("%v: %s", err, out) + } + loop++ + } + }() +} diff --git a/mesh/interface.go b/mesh/interface.go new file mode 100644 index 0000000..0b0dfc9 --- /dev/null +++ b/mesh/interface.go @@ -0,0 +1,51 @@ +package mesh + +import ( + "bufio" + "fmt" + "github.com/andatoshiki/shikigrid/utils" + "regexp" + "strconv" + "strings" +) + +var chanParser = regexp.MustCompile(`^\s+Channel.([0-9]+)\s+:\s+([0-9.]+)\s+GHz.*$`) + +func ActivateInterface(name string) error { + if out, err := utils.Exec("ifconfig", []string{name, "up"}); err != nil { + return err + } else if out != "" { + return fmt.Errorf("unexpected output while activating interface %s: %s", name, out) + } + return nil +} + +func SetChannel(iface string, channel int) (error, string) { + if out, err := utils.Exec("iwconfig", []string{iface, "channel", fmt.Sprintf("%d", channel)}); err != nil { + return err, out + } else if out != "" { + return fmt.Errorf("unexpected output while setting interface %s to channel %d: %s", iface, channel, out), out + } else { + return nil, out + } +} + +func SupportedChannels(iface string) ([]int, error) { + out, err := utils.Exec("iwlist", []string{iface, "freq"}) + if err != nil { + return nil, err + } + + var channels []int + scanner := bufio.NewScanner(strings.NewReader(out)) + for scanner.Scan() { + line := scanner.Text() + if matches := chanParser.FindStringSubmatch(line); len(matches) == 3 { + if channel, err := strconv.ParseInt(matches[1], 10, 32); err == nil { + channels = append(channels, int(channel)) + } + } + } + + return channels, nil +} diff --git a/mesh/memory.go b/mesh/memory.go new file mode 100644 index 0000000..1520c33 --- /dev/null +++ b/mesh/memory.go @@ -0,0 +1,122 @@ +package mesh + +import ( + "encoding/json" + "fmt" + "github.com/evilsocket/islazy/fs" + "github.com/evilsocket/islazy/log" + "math" + "os" + "path" + "sync" + "time" +) + +type Memory struct { + sync.Mutex + path string + peers map[string]*Peer +} + +func MemoryFromPath(path string) (err error, mem *Memory) { + if path, err = fs.Expand(path); err != nil { + return err, nil + } + + mem = &Memory{ + path: path, + peers: make(map[string]*Peer), + } + + if !fs.Exists(path) { + log.Debug("creating %s ...", path) + if err = os.MkdirAll(path, os.ModePerm); err != nil { + return + } + } + + err = fs.Glob(path, "*.json", func(fileName string) error { + log.Debug("loading %s ...", fileName) + data, err := os.ReadFile(fileName) + if err != nil { + log.Error("error loading %s: %v", fileName, err) + return nil + } + + var peer jsonPeer + if err = json.Unmarshal(data, &peer); err != nil { + log.Error("error loading %s: %v", fileName, err) + return nil + } + + mem.peers[peer.Fingerprint] = peerFromJSON(peer) + return nil + }) + + log.Debug("loaded %d known peers", len(mem.peers)) + + return +} + +func (mem *Memory) Size() int { + mem.Lock() + defer mem.Unlock() + return len(mem.peers) +} + +func (mem *Memory) Of(fingerprint string) *Peer { + mem.Lock() + defer mem.Unlock() + + if peer, found := mem.peers[fingerprint]; found { + return peer + } + + return nil +} + +func (mem *Memory) List() []*Peer { + mem.Lock() + defer mem.Unlock() + + list := make([]*Peer, 0) + for _, peer := range mem.peers { + list = append(list, peer) + } + + return list +} + +func (mem *Memory) Track(fingerprint string, peer *Peer) error { + mem.Lock() + defer mem.Unlock() + + if encounter, found := mem.peers[fingerprint]; !found { + // peer first encounter + peer.Encounters = 1 + peer.MetAt = time.Now() + peer.PrevSeenAt = peer.SeenAt + } else { + // we met this peer before + if encounter.Encounters < math.MaxUint64 { + encounter.Encounters++ + } + peer.PrevSeenAt = encounter.SeenAt + peer.MetAt = encounter.MetAt + peer.Encounters = encounter.Encounters + } + + peer.SeenAt = time.Now() + + // save/update peer data in memory + mem.peers[fingerprint] = peer + // save/update peer data on disk + fileName := path.Join(mem.path, fmt.Sprintf("%s.json", fingerprint)) + if data, err := json.Marshal(peer); err != nil { + return err + } else if err := os.WriteFile(fileName, data, os.ModePerm); err != nil { + return err + } + + return nil +} diff --git a/mesh/packet_muxer.go b/mesh/packet_muxer.go new file mode 100644 index 0000000..600cd15 --- /dev/null +++ b/mesh/packet_muxer.go @@ -0,0 +1,144 @@ +package mesh + +import ( + "fmt" + "github.com/evilsocket/islazy/async" + "github.com/evilsocket/islazy/log" + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/pcap" + "strings" + "time" +) + +const ( + // ErrIfaceNotUp Ugly, but gopacket folks are not exporting pcap errors, so ... + // ref. https://github.com/gopacket/gopacket/blob/96986c90e3e5c7e01deed713ff8058e357c0c047/pcap/pcap.go#L281 + ErrIfaceNotUp = "Interface Not Up" +) + +var ( + SnapLength = 65536 + ReadTimeout = 100 +) + +type PacketCallback func(pkt gopacket.Packet) + +type PacketMuxer struct { + iface string + filter string + handle *pcap.Handle + source *gopacket.PacketSource + channel chan gopacket.Packet + queue *async.WorkQueue + stop chan struct{} + + onPacket PacketCallback +} + +func dummyPacketCallback(pkt gopacket.Packet) { + +} + +func NewPacketMuxer(iface, filter string, workers int) (mux *PacketMuxer, err error) { + mux = &PacketMuxer{ + iface: iface, + filter: filter, + stop: make(chan struct{}), + onPacket: dummyPacketCallback, + } + + for retry := 0; ; retry++ { + inactiveHandle, err := pcap.NewInactiveHandle(iface) + if err != nil { + return nil, fmt.Errorf("error while opening interface %s: %s", iface, err) + } + defer inactiveHandle.CleanUp() + + if err = inactiveHandle.SetRFMon(true); err != nil { + log.Warning("error while setting interface %s in monitor mode: %s", iface, err) + } + + if err = inactiveHandle.SetSnapLen(SnapLength); err != nil { + return nil, fmt.Errorf("error while settng span len: %s", err) + } + /* + * We don't want to pcap.BlockForever otherwise pcap_close(handle) + * could hang waiting for a timeout to expire ... + */ + readTimeout := time.Duration(ReadTimeout) * time.Millisecond + if err = inactiveHandle.SetTimeout(readTimeout); err != nil { + return nil, fmt.Errorf("error while setting timeout: %s", err) + } else if mux.handle, err = inactiveHandle.Activate(); err != nil { + if retry == 0 && err.Error() == ErrIfaceNotUp { + log.Info("interface %s is down, bringing it up ...", iface) + if err := ActivateInterface(iface); err != nil { + return nil, err + } + continue + } + return nil, fmt.Errorf("error while activating handle: %s", err) + } + + if filter != "" { + if err := mux.handle.SetBPFFilter(filter); err != nil { + return nil, fmt.Errorf("error setting BPF filter '%s': %v", filter, err) + } + } + + break + } + + mux.source = gopacket.NewPacketSource(mux.handle, mux.handle.LinkType()) + mux.channel = mux.source.Packets() + mux.queue = async.NewQueue(workers, func(arg async.Job) { + mux.onPacket(arg.(gopacket.Packet)) + }) + + return mux, nil +} + +func (mux *PacketMuxer) OnPacket(cb PacketCallback) { + mux.onPacket = cb +} + +func (mux *PacketMuxer) Write(data []byte) error { + var err error + for attempt := 0; attempt < 5; attempt++ { + if err = mux.handle.WritePacketData(data); err == nil { + return nil + } else if strings.Contains(err.Error(), "temporarily unavailable") { + log.Debug("resource temporarily unavailable when sending data") + // if it's the last attempt this will set err to nil as we can't really + // do a lot about this case, otherwise it'll wait 200ms before the next + // attempt is made. + err = nil + if attempt < 5 { + time.Sleep(200 * time.Millisecond) + } + } else { + return nil + } + } + return err +} + +func (mux *PacketMuxer) Start() { + go func() { + log.Debug("packet muxer started (iface:%s filter:%s)", mux.iface, mux.filter) + for { + select { + case packet := <-mux.channel: + mux.queue.Add(async.Job(packet)) + case <-mux.stop: + return + } + } + }() +} + +func (mux *PacketMuxer) Stop() { + log.Debug("stopping packet muxer ...") + mux.stop <- struct{}{} + mux.queue.WaitDone() + log.Debug("packet muxer stopped") +} diff --git a/mesh/peer.go b/mesh/peer.go new file mode 100644 index 0000000..296c16f --- /dev/null +++ b/mesh/peer.go @@ -0,0 +1,386 @@ +package mesh + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "regexp" + "strings" + "sync" + "time" + + "github.com/evilsocket/islazy/log" + "github.com/gopacket/gopacket/layers" + "github.com/andatoshiki/shikigrid/crypto" + "github.com/andatoshiki/shikigrid/version" + "github.com/andatoshiki/shikigrid/wifi" +) + +var ( + SignalingPeriod = 300 + + fingValidator = regexp.MustCompile("^[a-fA-F0-9]{64}$") +) + +type SessionID []byte + +type Peer struct { + sync.Mutex + + MetAt time.Time // first time met + DetectedAt time.Time // first time detected on this session + SeenAt time.Time // last time detected on this session + PrevSeenAt time.Time // if we met this unit before, this is the last time it's been seen + Encounters uint64 + Channel int + RSSI int + SessionID SessionID + SessionIDStr string + Keys *crypto.KeyPair + AdvData sync.Map + AdvPeriod int + + advEnabled bool + ForceDisabled bool + + mux *PacketMuxer + stop chan struct{} +} + +func MakeLocalPeer(name string, keys *crypto.KeyPair, advertise bool) *Peer { + now := time.Now() + peer := &Peer{ + DetectedAt: now, + SeenAt: now, + PrevSeenAt: now, + SessionID: make([]byte, 6), + Keys: keys, + AdvData: sync.Map{}, + AdvPeriod: SignalingPeriod, + stop: make(chan struct{}), + advEnabled: false, + ForceDisabled: false, + } + if !advertise { + peer.ForceDisabled = true + } + + if _, err := rand.Read(peer.SessionID); err != nil { + panic(err) + } + + parts := make([]string, 6) + for idx, byte := range peer.SessionID { + parts[idx] = fmt.Sprintf("%02x", byte) + } + peer.SessionIDStr = strings.Join(parts, ":") + + peer.AdvData.Store("name", name) + peer.AdvData.Store("identity", keys.FingerprintHex) + peer.AdvData.Store("session_id", peer.SessionIDStr) + peer.AdvData.Store("grid_version", version.Version) + + peer.AdvData.Range(func(key, value interface{}) bool { + log.Debug("local.adv.%s = %s", key, value) + return true + }) + + return peer +} + +func (peer *Peer) Advertise(enabled bool) { + peer.Lock() + defer peer.Unlock() + diff := peer.advEnabled != enabled + peer.advEnabled = enabled + if diff { + if enabled { + log.Info("peer advertisement enabled") + } else { + log.Info("peer advertisement disabled") + } + } +} + +func NewPeer(radiotap *layers.RadioTap, dot11 *layers.Dot11, adv map[string]interface{}) (peer *Peer, err error) { + now := time.Now() + peer = &Peer{ + DetectedAt: now, + SeenAt: now, + PrevSeenAt: now, + Channel: wifi.Freq2Chan(int(radiotap.ChannelFrequency)), + RSSI: int(radiotap.DBMAntennaSignal), + SessionID: SessionID(dot11.Address3), + AdvData: sync.Map{}, + } + + parts := make([]string, 6) + for idx, byte := range peer.SessionID { + parts[idx] = fmt.Sprintf("%02x", byte) + } + peer.SessionIDStr = strings.Join(parts, ":") + + // parse the fingerprint, the signature and the public key + fingerprint, found := adv["identity"].(string) + if !found { + return nil, fmt.Errorf("peer %x is not advertising any identity", peer.SessionID) + } else if !fingValidator.MatchString(fingerprint) { + return nil, fmt.Errorf("peer %x is advertising an invalid fingerprint: %s", peer.SessionID, fingerprint) + } + + if pubKey64, found := adv["public_key"]; found { + pubKey, err := base64.StdEncoding.DecodeString(pubKey64.(string)) + if err != nil { + return nil, fmt.Errorf("error decoding peer %s public key: %s", fingerprint, err) + } + + peer.Keys, err = crypto.FromPublicPEM(string(pubKey)) + if err != nil { + return nil, fmt.Errorf("error parsing peer %s public key: %s", fingerprint, err) + } + + // basic consistency check + if peer.Keys.FingerprintHex != fingerprint { + return nil, fmt.Errorf("peer %x is advertising fingerprint %s, but it should be %s", peer.SessionID, fingerprint, peer.Keys.FingerprintHex) + } + } else if !found { + log.Debug("peer %s is not advertising any public key", fingerprint) + } + + /* + No need for signature in the advertisement protocol, however: + + signature64, found := adv["signature"].(string) + if !found { + return nil, fmt.Errorf("peer %s is advertising unsigned data", fingerprint) + } + + signature, err := base64.StdEncoding.DecodeString(signature64) + if err != nil { + return nil, fmt.Errorf("error decoding peer %s signature: %s", fingerprint, err) + } + + // the signature is SIGN(advertisement), so we need to remove the signature field and convert back to json. + // NOTE: fortunately, keys will be always sorted, so we don't have to do anything in order to guarantee signature + // consistency (https://stackoverflow.com/questions/18668652/how-to-produce-json-with-sorted-keys-in-go) + signedMap := adv + delete(signedMap, "signature") + + signedData, err := json.Marshal(signedMap) + if err != nil { + return nil, fmt.Errorf("error packing data for signature verification: %v", err) + } + + // verify the signature + if err = peer.Keys.VerifyMessage(signedData, signature); err != nil { + return nil, fmt.Errorf("peer %x signature is invalid", peer.SessionID) + } + */ + + for key, value := range adv { + peer.AdvData.Store(key, value) + } + + return peer, nil +} + +func (peer *Peer) Update(radio *layers.RadioTap, dot11 *layers.Dot11, adv map[string]interface{}) (err error) { + peer.Lock() + defer peer.Unlock() + + // parse the fingerprint, the signature and the public key + fingerprint, found := adv["identity"].(string) + if !found { + return fmt.Errorf("peer %x is not advertising any identity", peer.SessionID) + } + + // basic consistency check + if peer.Keys != nil && peer.Keys.FingerprintHex != fingerprint { + return fmt.Errorf("peer %x is advertising fingerprint %s, but it should be %s", peer.SessionID, fingerprint, peer.Keys.FingerprintHex) + } + + /* + No need for signature in the advertisement protocol, however: + + signature64, found := adv["signature"].(string) + if !found { + return fmt.Errorf("peer %x is not advertising any signature", peer.SessionID) + } + + signature, err := base64.StdEncoding.DecodeString(signature64) + if err != nil { + return fmt.Errorf("error decoding peer %d signature: %s", peer.SessionID, err) + } + + // the signature is SIGN(advertisement), so we need to remove the signature field and convert back to json. + // NOTE: fortunately, keys will always be sorted, so we don't have to do anything in order to guarantee signature + // consistency (https://stackoverflow.com/questions/18668652/how-to-produce-json-with-sorted-keys-in-go) + signedMap := adv + delete(signedMap, "signature") + + signedData, err := json.Marshal(signedMap) + if err != nil { + return fmt.Errorf("error packing data for signature verification: %v", err) + } + + // verify the signature + if err = peer.Keys.VerifyMessage(signedData, signature); err != nil { + return fmt.Errorf("peer %x signature is invalid", peer.SessionID) + } + */ + + peer.Channel = wifi.Freq2Chan(int(radio.ChannelFrequency)) + peer.RSSI = int(radio.DBMAntennaSignal) + + if !bytes.Equal(peer.SessionID, dot11.Address3) { + log.Info("peer %s changed session id: %x -> %x", peer.ID(), peer.SessionIDStr, dot11.Address3) + copy(peer.SessionID, dot11.Address3) + parts := make([]string, 6) + for idx, byte := range peer.SessionID { + parts[idx] = fmt.Sprintf("%02x", byte) + } + peer.SessionIDStr = strings.Join(parts, ":") + } + + for key, value := range adv { + peer.AdvData.Store(key, value) + } + + return nil +} + +func (peer *Peer) ID() string { + name, _ := peer.AdvData.Load("name") + ident := "???" + + if peer.Keys != nil { + ident = peer.Keys.FingerprintHex + } else if _ident, found := peer.AdvData.Load("identity"); found { + ident = _ident.(string) + } + + return fmt.Sprintf("%s@%s", name, ident) +} + +func (peer *Peer) InactiveFor() float64 { + peer.Lock() + defer peer.Unlock() + return time.Since(peer.DetectedAt).Seconds() +} + +func (peer *Peer) SetData(adv map[string]interface{}) { + if peer == nil { + return + } + peer.Lock() + defer peer.Unlock() + + for key, val := range adv { + if val == nil { + peer.AdvData.Delete(key) + } else { + peer.AdvData.Store(key, val) + } + } +} + +func (peer *Peer) Data() map[string]interface{} { + peer.Lock() + defer peer.Unlock() + return peer.dataFrame() +} + +func (peer *Peer) dataFrame() map[string]interface{} { + data := map[string]interface{}{} + peer.AdvData.Range(func(key, value interface{}) bool { + data[key.(string)] = value + return true + }) + return data +} + +func (peer *Peer) advertise() { + peer.Lock() + defer peer.Unlock() + + if peer.advEnabled { + data := peer.dataFrame() + + data["timestamp"] = time.Now().Unix() + adv, err := json.Marshal(data) + if err != nil { + log.Error("could not serialize advertisement data: %v", err) + return + } + + /* + No need for signature in the advertisement protocol, however: + + // sign the advertisement + signature, err := peer.Keys.SignMessage(adv) + if err != nil { + log.Error("error signing advertisement: %v", err) + return + } + + // add the signature to the advertisement itself and encode again + data["signature"] = base64.StdEncoding.EncodeToString(signature) + adv, err = json.Marshal(data) + if err != nil { + log.Error("could not serialize signed advertisement data: %v", err) + return + } + + log.Debug("advertising:\n%+v", data) + */ + + err, raw := wifi.Pack( + net.HardwareAddr(peer.SessionID), + wifi.BroadcastAddr, + adv, + false) // set compression to true if using signature + if err != nil { + log.Error("could not encapsulate %d bytes of advertisement data: %v", len(adv), err) + return + } + + if err = peer.mux.Write(raw); err != nil { + log.Error("error sending %d bytes of advertisement frame: %v", len(raw), err) + } + } +} + +func (peer *Peer) StartAdvertising(iface string) (err error) { + if peer.mux == nil { + if peer.mux, err = NewPacketMuxer(iface, "", Workers); err != nil { + return + } + } + + go func() { + period := time.Duration(peer.AdvPeriod) * time.Millisecond + ticker := time.NewTicker(period) + + log.Debug("advertiser started with a %s period", period) + + for { + select { + case _ = <-ticker.C: + peer.advertise() + case <-peer.stop: + log.Info("advertiser stopped") + return + } + } + }() + + return nil +} + +func (peer *Peer) StopAdvertising() { + log.Debug("stopping advertiser ...") + peer.stop <- struct{}{} +} diff --git a/mesh/peer_json.go b/mesh/peer_json.go new file mode 100644 index 0000000..e2aa828 --- /dev/null +++ b/mesh/peer_json.go @@ -0,0 +1,81 @@ +package mesh + +import ( + "encoding/json" + "github.com/evilsocket/islazy/log" + "net" + "sync" + "time" +) + +type jsonPeer struct { + Fingerprint string `json:"fingerprint"` + MetAt time.Time `json:"met_at"` + DetectedAt time.Time `json:"detected_at"` + SeenAt time.Time `json:"seen_at"` + PrevSeenAt time.Time `json:"prev_seen_at"` + Encounters uint64 `json:"encounters"` + Channel int `json:"channel"` + RSSI int `json:"rssi"` + SessionID string `json:"session_id"` + Advertisement map[string]interface{} `json:"advertisement"` +} + +// creates a Peer object filled with the fields of the JSON representation +func peerFromJSON(j jsonPeer) *Peer { + peer := &Peer{ + DetectedAt: j.DetectedAt, + SeenAt: j.SeenAt, + PrevSeenAt: j.PrevSeenAt, + SessionIDStr: j.SessionID, + Encounters: j.Encounters, + Channel: j.Channel, + RSSI: j.RSSI, + AdvData: sync.Map{}, + } + + if hw, err := net.ParseMAC(j.SessionID); err == nil { + copy(peer.SessionID, hw) + } else { + log.Warning("error parsing peer session id %s: %v", j.SessionID, err) + } + + for key, val := range j.Advertisement { + peer.AdvData.Store(key, val) + } + + return peer +} + +// converts a peer into a JSON friendly representation +func (peer *Peer) json() *jsonPeer { + fingerprint := "" + if v, found := peer.AdvData.Load("identity"); found { + fingerprint = v.(string) + } + + doc := jsonPeer{ + Fingerprint: fingerprint, + MetAt: peer.MetAt, + Encounters: peer.Encounters, + PrevSeenAt: peer.PrevSeenAt, + DetectedAt: peer.DetectedAt, + SeenAt: peer.SeenAt, + Channel: peer.Channel, + RSSI: peer.RSSI, + SessionID: peer.SessionIDStr, + Advertisement: make(map[string]interface{}), + } + peer.AdvData.Range(func(key, value interface{}) bool { + doc.Advertisement[key.(string)] = value + return true + }) + + return &doc +} + +func (peer *Peer) MarshalJSON() ([]byte, error) { + peer.Lock() + defer peer.Unlock() + return json.Marshal(peer.json()) +} diff --git a/mesh/routing.go b/mesh/routing.go new file mode 100644 index 0000000..fa1aba8 --- /dev/null +++ b/mesh/routing.go @@ -0,0 +1,163 @@ +package mesh + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/evilsocket/islazy/log" + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" + "github.com/andatoshiki/shikigrid/wifi" + "sync" + "time" +) + +var ( + Workers = 0 + PeerTTL = 1800 + Peers = sync.Map{} +) + +func dummyPeerActivityCallback(ident string, peer *Peer) {} + +type PeerActivityCallback func(ident string, peer *Peer) + +type Router struct { + local *Peer + mux *PacketMuxer + onNewPeer PeerActivityCallback + onPeerLost PeerActivityCallback + memory *Memory +} + +func StartRouting(iface string, peersPath string, local *Peer) (*Router, error) { + err, memory := MemoryFromPath(peersPath) + if err != nil { + return nil, err + } + + filter := fmt.Sprintf("type mgt subtype beacon and ether src %s", wifi.SignatureAddrStr) + mux, err := NewPacketMuxer(iface, filter, Workers) + if err != nil { + return nil, err + } + + router := &Router{ + mux: mux, + local: local, + memory: memory, + onNewPeer: dummyPeerActivityCallback, + onPeerLost: dummyPeerActivityCallback, + } + mux.OnPacket(router.onPacket) + mux.Start() + + log.Info("started beacon discovery and message routing (%d known peers)", router.memory.Size()) + + go router.peersPruner() + + return router, nil +} + +func (router *Router) Memory() []*Peer { + return router.memory.List() +} + +func (router *Router) MemoryOf(fingerprint string) *Peer { + return router.memory.Of(fingerprint) +} + +func (router *Router) OnNewPeer(cb PeerActivityCallback) { + router.onNewPeer = cb +} + +func (router *Router) OnPeerLost(cb PeerActivityCallback) { + router.onPeerLost = cb +} + +func (router *Router) peersPruner() { + period := time.Duration(500) * time.Millisecond + tick := time.NewTicker(period) + + log.Debug("peers pruner started with a %s period", period) + + for range tick.C { + stale := map[string]*Peer{} + + Peers.Range(func(key, value interface{}) bool { + ident := key.(string) + peer := value.(*Peer) + inactive := peer.InactiveFor() + if int(inactive) > PeerTTL { + stale[ident] = peer + } + return true + }) + + for ident, peer := range stale { + Peers.Delete(ident) + router.onPeerLost(ident, peer) + } + } +} + +func (router *Router) newPeer(ident string, peer *Peer) { + Peers.Store(ident, peer) + router.onNewPeer(ident, peer) +} + +func (router *Router) onPeerAdvertisement(pkt gopacket.Packet, radio *layers.RadioTap, dot11 *layers.Dot11) { + err, payload := wifi.Unpack(pkt, radio, dot11) + if err != nil { + log.Debug("%v", err) + return + } + + advData := make(map[string]interface{}) + if err := json.Unmarshal(payload, &advData); err != nil { + log.Debug("error decoding payload '%s': %v", payload, err) + return + } + + ident, ok := advData["identity"] + if !ok { + log.Debug("error parsing identity from payload '%s'", payload) + return + } + + var peer *Peer + + _peer, existing := Peers.Load(ident) + if existing { + peer = _peer.(*Peer) + if err := peer.Update(radio, dot11, advData); err != nil { + log.Warning("error updating peer %s: %v", peer.ID(), err) + } else if err := router.memory.Track(ident.(string), peer); err != nil { + log.Error("error saving peer encounter for %s: %v", ident, err) + } + } else { + if peer, err = NewPeer(radio, dot11, advData); err != nil { + log.Debug("error creating peer: %v", err) + return + } else if err := router.memory.Track(ident.(string), peer); err != nil { + log.Error("error saving peer encounter for %s: %v", ident, err) + } else { + router.newPeer(ident.(string), peer) + } + } + +} + +func (router *Router) onPacket(pkt gopacket.Packet) { + if ok, radio, dot11 := wifi.Parse(pkt); ok && dot11.ChecksumValid() { + src := dot11.Address3 + dst := dot11.Address1 + if !bytes.Equal(src, router.local.SessionID) { + if bytes.Equal(dst, wifi.BroadcastAddr) { + router.onPeerAdvertisement(pkt, radio, dot11) + } else { + // log.Debug("ignoring message %x > %x", src, dst) + } + } + } +} diff --git a/models/access_point.go b/models/access_point.go new file mode 100644 index 0000000..1f81dd9 --- /dev/null +++ b/models/access_point.go @@ -0,0 +1,11 @@ +package models + +import "github.com/jinzhu/gorm" + +type AccessPoint struct { + gorm.Model + + UnitID uint `json:"-"` + Name string `gorm:"size:255;not null" json:"name"` + Mac string `gorm:"size:255;not null" json:"mac"` +} diff --git a/models/enrollment.go b/models/enrollment.go new file mode 100644 index 0000000..c1f43e4 --- /dev/null +++ b/models/enrollment.go @@ -0,0 +1,74 @@ +package models + +import ( + "encoding/base64" + "fmt" + "github.com/evilsocket/islazy/str" + "github.com/andatoshiki/shikigrid/crypto" + "regexp" + "strings" +) + +type EnrollmentRequest struct { + Identity string `json:"identity"` // name@SHA256(public_key) + PublicKey string `json:"public_key"` // BASE64(public_key.pem) + Signature string `json:"signature"` // BASE64(SIGN(identity, private_key)) + Data map[string]interface{} `json:"data"` // misc data for the unit + KeyPair *crypto.KeyPair `json:"-"` // parsed from public_key + Name string `json:"-"` + Fingerprint string `json:"-"` // SHA256(public_key) + Address string `json:"-"` + Country string `json:"-"` +} + +var ansi = regexp.MustCompile("\033\\[(?:[0-9]{1,3}(?:;[0-9]{1,3})*)?[m|K]") + +func clean(s string) string { + for _, m := range ansi.FindAllString(s, -1) { + s = strings.Replace(s, m, "", -1) + } + return str.Trim(s) +} + +func (enroll *EnrollmentRequest) Validate() error { + // split the identity into name and fingerprint + parts := strings.Split(enroll.Identity, "@") + if len(parts) != 2 { + return fmt.Errorf("error parsing the identity string: got %d parts", len(parts)) + } + + enroll.Name = clean(parts[0]) + enroll.Fingerprint = clean(parts[1]) + if len(enroll.Fingerprint) != crypto.Hasher.Size()*2 { + return fmt.Errorf("unexpected fingerprint length for %s", enroll.Fingerprint) + } + + // parse the public key as b64 pem + pubKeyPEM, err := base64.StdEncoding.DecodeString(enroll.PublicKey) + if err != nil { + return fmt.Errorf("error decoding the public key: %v", err) + } + + enroll.KeyPair, err = crypto.FromPublicPEM(string(pubKeyPEM)) + if err != nil { + return fmt.Errorf("error parsing the public key: %v", err) + } + + enroll.PublicKey = string(enroll.KeyPair.PublicPEM) + + if enroll.KeyPair.FingerprintHex != enroll.Fingerprint { + return fmt.Errorf("fingerprint mismatch: expected:%s got:%s", enroll.KeyPair.FingerprintHex, enroll.Fingerprint) + } + + data := []byte(enroll.Identity) + signature, err := base64.StdEncoding.DecodeString(enroll.Signature) + if err != nil { + return fmt.Errorf("error decoding the signature: %v", err) + } + + if err := enroll.KeyPair.VerifyMessage(data, signature); err != nil { + return fmt.Errorf("signature verification failed: %s", err) + } + + return nil +} diff --git a/models/message.go b/models/message.go new file mode 100644 index 0000000..cd48c0f --- /dev/null +++ b/models/message.go @@ -0,0 +1,35 @@ +package models + +import ( + "fmt" + "time" +) + +const ( + MessageDataMaxSize = 512000 + MessageSignatureMaxSize = 10000 +) + +type Message struct { + ID uint `gorm:"primary_key" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `sql:"index" json:"deleted_at"` + SeenAt *time.Time `json:"seen_at" sql:"index"` + SenderID uint `json:"-"` + ReceiverID uint `json:"-"` + SenderName string `gorm:"size:255" json:"sender_name"` + Sender string `gorm:"size:255;not null" json:"sender"` + Data string `gorm:"size:512000;not null" json:"-"` + Signature string `gorm:"size:10000;not null" json:"-"` +} + +func ValidateMessage(data, signature string) error { + // validate max sizes + if dataSize := len(data); dataSize > MessageDataMaxSize { + return fmt.Errorf("max message data size is %d", MessageDataMaxSize) + } else if sigSize := len(signature); sigSize > MessageSignatureMaxSize { + return fmt.Errorf("max message signature size is %d", MessageSignatureMaxSize) + } + return nil +} diff --git a/models/setup.go b/models/setup.go new file mode 100644 index 0000000..0f771e0 --- /dev/null +++ b/models/setup.go @@ -0,0 +1,38 @@ +package models + +import ( + "fmt" + "github.com/evilsocket/islazy/log" + "github.com/jinzhu/gorm" + "os" +) + +var db *gorm.DB + +func Setup() (err error) { + hostname := os.Getenv("DB_HOST") + port := os.Getenv("DB_PORT") + username := os.Getenv("DB_USER") + password := os.Getenv("DB_PASSWORD") + name := os.Getenv("DB_NAME") + + log.Info("connecting to %s:%s ...", hostname, port) + dbURL := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", username, password, hostname, port, name) + if db, err = gorm.Open("mysql", dbURL); err != nil { + return + } + db.Debug().AutoMigrate(&Unit{}, &AccessPoint{}, &Message{}) + return +} + +func Create(v interface{}) *gorm.DB { + return db.Create(v) +} + +func Update(v interface{}) *gorm.DB { + return db.Model(v).Update(v) +} + +func UpdateFields(v interface{}, fields map[string]interface{}) *gorm.DB { + return db.Model(v).Updates(fields) +} diff --git a/models/unit.go b/models/unit.go new file mode 100644 index 0000000..b228042 --- /dev/null +++ b/models/unit.go @@ -0,0 +1,181 @@ +package models + +import ( + "encoding/json" + "fmt" + "github.com/golang-jwt/jwt/v5" + "os" + "reflect" + "sync" + "time" + + "github.com/evilsocket/islazy/log" +) + +const ( + TokenTTL = time.Minute * 30 + CacheTTL = time.Minute * 120 +) + +type cachedCounter struct { + Time time.Time + Count int +} + +var ( + cache = make(map[uint]*cachedCounter) + cacheLock = sync.Mutex{} +) + +type Unit struct { + ID uint `gorm:"primary_key" json:"-"` + CreatedAt time.Time `json:"enrolled_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `sql:"index" json:"-"` + Address string `gorm:"size:50;not null" json:"-"` + Country string `gorm:"size:10" json:"country"` + Name string `gorm:"size:255;not null" json:"name"` + Fingerprint string `gorm:"size:255;not null;unique" json:"fingerprint"` + PublicKey string `gorm:"size:10000;not null" json:"public_key"` + Token string `gorm:"size:10000;not null" json:"-"` + Data string `gorm:"size:10000;not null" json:"data"` + + AccessPoints []AccessPoint `gorm:"foreignkey:UnitID" json:"-"` + + Inbox []Message `gorm:"foreignkey:ReceiverID" json:"-"` + Sent []Message `gorm:"foreignkey:SenderID" json:"-"` +} + +func (u Unit) Identity() string { + return fmt.Sprintf("%s@%s", u.Name, u.Fingerprint) +} + +func (u Unit) FindAccessPoint(essid, bssid string) *AccessPoint { + var ap AccessPoint + + if err := db.Where("unit_id = ? AND name = ? AND mac = ?", u.ID, essid, bssid).Take(&ap).Error; err != nil { + if err := db.Where("unit_id = ? AND mac = ?", u.ID, bssid).Take(&ap).Error; err != nil { + return nil + } + } + + return &ap +} + +func (u *Unit) updateToken() error { + claims := jwt.MapClaims{} + claims["authorized"] = true + claims["unit_id"] = u.ID + claims["unit_ident"] = u.Identity() + claims["expires_at"] = time.Now().Add(TokenTTL).Format(time.RFC3339) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(os.Getenv("API_SECRET"))) + if err != nil { + return err + } + u.Token = signed + return nil +} + +func (u *Unit) UpdateWith(enroll EnrollmentRequest) error { + prevData := map[string]interface{}{} + + if u.Data != "" { + if err := json.Unmarshal([]byte(u.Data), &prevData); err != nil { + log.Warning("error parsing previous data: %v", err) + log.Debug("%s", u.Data) + } + } + + // only replace sent values + for key, obj := range enroll.Data { + set := true + if key == "session" { + if session, ok := obj.(map[string]interface{}); !ok { + set = false + log.Warning("corrupted session (first level): %v", obj) + } else if epochs, found := session["epochs"]; !found { + set = false + log.Warning("corrupted session (no epochs): %v", obj) + } else if num, ok := epochs.(float64); !ok { + set = false + log.Warning("corrupted session (epochs type %v): %v", reflect.TypeOf(epochs), obj) + } else if num == 0 { + // do not update with empty sessions + set = false + } + } + + if set { + prevData[key] = obj + } + } + + newData, err := json.Marshal(prevData) + if err != nil { + return err + } + + if u.Name != enroll.Name { + log.Info("unit %s changed name: %s -> %s", u.Identity(), u.Name, enroll.Name) + } + + u.Name = enroll.Name + u.Address = enroll.Address + u.Country = enroll.Country + u.Data = string(newData) + + return db.Save(u).Error +} + +type unitJSON struct { + EnrolledAt time.Time `json:"enrolled_at"` + UpdatedAt time.Time `json:"updated_at"` + Country string `json:"country"` + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + PublicKey string `json:"public_key"` + Data map[string]interface{} `json:"data"` + Networks int `json:"networks"` +} + +func (u *Unit) apCounter() int { + cacheLock.Lock() + defer cacheLock.Unlock() + + count := -1 + if cnt, found := cache[u.ID]; found && time.Since(cnt.Time) < CacheTTL { + count = cnt.Count + } + + if count == -1 { + count = db.Model(u).Association("AccessPoints").Count() + cache[u.ID] = &cachedCounter{ + Time: time.Now(), + Count: count, + } + } + + return count +} + +func (u *Unit) MarshalJSON() ([]byte, error) { + doc := unitJSON{ + EnrolledAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + Country: u.Country, + Name: u.Name, + Fingerprint: u.Fingerprint, + PublicKey: u.PublicKey, + Data: map[string]interface{}{}, + Networks: u.apCounter(), + } + + if u.Data != "" { + if err := json.Unmarshal([]byte(u.Data), &doc.Data); err != nil { + return nil, err + } + } + + return json.Marshal(doc) +} diff --git a/models/unit_enroll.go b/models/unit_enroll.go new file mode 100644 index 0000000..d96c3fd --- /dev/null +++ b/models/unit_enroll.go @@ -0,0 +1,34 @@ +package models + +import ( + "fmt" + "github.com/evilsocket/islazy/log" +) + +func EnrollUnit(enroll EnrollmentRequest) (err error, unit *Unit) { + if unit = FindUnitByFingerprint(enroll.Fingerprint); unit == nil { + log.Info("enrolling new unit for %s (%s): %s", enroll.Address, enroll.Country, enroll.Identity) + + unit = &Unit{ + Address: enroll.Address, + Country: enroll.Country, + Name: enroll.Name, + Fingerprint: enroll.Fingerprint, + PublicKey: string(enroll.KeyPair.PublicPEM), + } + + if err := db.Create(unit).Error; err != nil { + return fmt.Errorf("error enrolling %s: %v", unit.Identity(), err), nil + } + } + + if err := unit.updateToken(); err != nil { + return fmt.Errorf("error creating token for %s: %v", unit.Identity(), err), nil + } + + if err = unit.UpdateWith(enroll); err != nil { + log.Debug("%+v", enroll) + return fmt.Errorf("error setting token for %s: %v", unit.Identity(), err), nil + } + return nil, unit +} diff --git a/models/unit_find.go b/models/unit_find.go new file mode 100644 index 0000000..5ca5801 --- /dev/null +++ b/models/unit_find.go @@ -0,0 +1,44 @@ +package models + +import "github.com/biezhi/gorm-paginator/pagination" + +type UnitsByCountry struct { + Country string `json:"country"` + Count int `json:"units"` +} + +func GetUnitsByCountry() ([]UnitsByCountry, error) { + results := make([]UnitsByCountry, 0) + if err := db.Raw("SELECT country,COUNT(id) AS count FROM units GROUP BY country ORDER BY count DESC").Scan(&results).Error; err != nil { + return nil, err + } + return results, nil +} + +func GetPagedUnits(page int) (units []Unit, total int, pages int) { + paginator := pagination.Paging(&pagination.Param{ + DB: db, + Page: page, + Limit: 25, + OrderBy: []string{"id desc"}, + }, &units) + return units, paginator.TotalRecord, paginator.TotalPage +} + +func FindUnit(id uint) *Unit { + var unit Unit + if err := db.Find(&unit, id).Error; err != nil { + return nil + } + return &unit +} + +func FindUnitByFingerprint(fingerprint string) *Unit { + var unit Unit + if fingerprint == "" { + return nil + } else if err := db.Where("fingerprint = ?", fingerprint).Take(&unit).Error; err != nil { + return nil + } + return &unit +} diff --git a/models/unit_inbox.go b/models/unit_inbox.go new file mode 100644 index 0000000..988f66e --- /dev/null +++ b/models/unit_inbox.go @@ -0,0 +1,22 @@ +package models + +import "github.com/biezhi/gorm-paginator/pagination" + +func (u *Unit) GetPagedInbox(page int) (messages []Message, total int, pages int) { + query := db.Model(Message{}).Where("receiver_id = ?", u.ID) + paginator := pagination.Paging(&pagination.Param{ + DB: query, + Page: page, + Limit: 50, + OrderBy: []string{"id desc"}, + }, &messages) + return messages, paginator.TotalRecord, paginator.TotalPage +} + +func (u *Unit) GetInboxMessage(id int) *Message { + var msg Message + if err := db.Where("receiver_id = ? AND id = ?", u.ID, id).First(&msg).Error; err != nil { + return nil + } + return &msg +} diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..9490e73 --- /dev/null +++ b/release.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# nothing to see here, just a utility i use to create new releases ^_^ + +VERSION_FILE=$(dirname "${BASH_SOURCE[0]}")/version/ver.go +echo "version file is $VERSION_FILE" +CURRENT_VERSION=$(cat "$VERSION_FILE" | grep Version | cut -d '"' -f 2) +TO_UPDATE=( + # shellcheck disable=SC2206 + $VERSION_FILE +) + +echo -n "current version is $CURRENT_VERSION, select new version: " +read NEW_VERSION +# shellcheck disable=SC2028 +echo "creating version $NEW_VERSION ...\n" + +for file in "${TO_UPDATE[@]}"; do + echo "patching $file ..." + sed -i.bak "s/$CURRENT_VERSION/$NEW_VERSION/g" "$file" + rm -rf "$file.bak" + git add "$file" +done + +git commit -m "releasing v$NEW_VERSION" +git push +git tag -a v"$NEW_VERSION" -m "release v$NEW_VERSION" +# shellcheck disable=SC2086 +git push origin v$NEW_VERSION + +echo +echo "All done, v$NEW_VERSION released ^_^" \ No newline at end of file diff --git a/shikigrid-peer.service b/shikigrid-peer.service new file mode 100644 index 0000000..5a41ac2 --- /dev/null +++ b/shikigrid-peer.service @@ -0,0 +1,14 @@ +[Unit] +Description=shikigrid peer service +Documentation=https://shikigotchi.org/ +Wants=network.target +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/shikigrid -log /var/log/shikigrid.log -peers /root/peers -address 127.0.0.1:8666 +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/shikigrid.service b/shikigrid.service new file mode 100644 index 0000000..8432cc1 --- /dev/null +++ b/shikigrid.service @@ -0,0 +1,14 @@ +[Unit] +Description=shikigrid api service +Documentation=https://shikigotchi.org/ +Wants=network.target +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/shikigrid -log /var/log/shikigrid.log -env /etc/shikigrid/shikigrid.conf -address 127.0.0.1:8666 +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/utils/exec.go b/utils/exec.go new file mode 100644 index 0000000..92619f0 --- /dev/null +++ b/utils/exec.go @@ -0,0 +1,20 @@ +package utils + +import ( + "github.com/evilsocket/islazy/str" + "os/exec" +) + +func Exec(executable string, args []string) (string, error) { + path, err := exec.LookPath(executable) + if err != nil { + return "", err + } + + raw, err := exec.Command(path, args...).CombinedOutput() + if err != nil { + return "", err + } else { + return str.Trim(string(raw)), nil + } +} diff --git a/utils/host.go b/utils/host.go new file mode 100644 index 0000000..a335df6 --- /dev/null +++ b/utils/host.go @@ -0,0 +1,21 @@ +package utils + +import ( + "github.com/evilsocket/islazy/log" + "os" + "strings" +) + +func Hostname() string { + name, err := os.Hostname() + if err != nil { + log.Warning("%v", err) + return "" + } + + if strings.HasSuffix(name, ".local") { + name = strings.Replace(name, ".local", "", -1) + } + + return name +} diff --git a/version/ver.go b/version/ver.go new file mode 100644 index 0000000..745f41a --- /dev/null +++ b/version/ver.go @@ -0,0 +1,5 @@ +package version + +const ( + Version = "0.0.1" +) diff --git a/wifi/compression.go b/wifi/compression.go new file mode 100644 index 0000000..2e39e30 --- /dev/null +++ b/wifi/compression.go @@ -0,0 +1,44 @@ +package wifi + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" +) + +func Compress(data []byte) (bool, []byte, error) { + oldSize := len(data) + buf := bytes.Buffer{} + if zw, err := gzip.NewWriterLevel(&buf, gzip.BestCompression); err != nil { + return false, nil, fmt.Errorf("error initializing payload compression: %v", err) + } else if _, err := zw.Write(data); err != nil { + return false, nil, fmt.Errorf("error during payload compression: %v", err) + } else if err = zw.Close(); err != nil { + return false, nil, fmt.Errorf("error while finalizing payload compression: %v", err) + } + + compressed := buf.Bytes() + newSize := len(compressed) + + // log.Debug("gzip: %d > %d", oldSize, newSize) + + if newSize < oldSize { + return true, compressed, nil + } + return false, data, nil +} + +func Decompress(data []byte) ([]byte, error) { + if zr, err := gzip.NewReader(bytes.NewBuffer(data)); err != nil { + return nil, fmt.Errorf("error initializing payload decompression: %v", err) + } else { + defer func(zr *gzip.Reader) { + err := zr.Close() + if err != nil { + + } + }(zr) + return io.ReadAll(zr) + } +} diff --git a/wifi/defines.go b/wifi/defines.go new file mode 100644 index 0000000..4a49ac8 --- /dev/null +++ b/wifi/defines.go @@ -0,0 +1,27 @@ +package wifi + +import ( + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" + "net" +) + +const ( + IDWhisperPayload layers.Dot11InformationElementID = 222 + IDWhisperCompression layers.Dot11InformationElementID = 223 + IDWhisperIdentity layers.Dot11InformationElementID = 224 + IDWhisperSignature layers.Dot11InformationElementID = 225 + IDWhisperStreamHeader layers.Dot11InformationElementID = 226 +) + +var SerializationOptions = gopacket.SerializeOptions{ + FixLengths: true, + ComputeChecksums: true, +} + +var ( + SignatureAddr = net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad} + SignatureAddrStr = "de:ad:be:ef:de:ad" + BroadcastAddr = net.HardwareAddr{0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + wpaFlags = 1041 +) diff --git a/wifi/pack.go b/wifi/pack.go new file mode 100644 index 0000000..8a3ded7 --- /dev/null +++ b/wifi/pack.go @@ -0,0 +1,86 @@ +package wifi + +import ( + "bytes" + "encoding/binary" + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" + "net" +) + +func Info(id layers.Dot11InformationElementID, info []byte) *layers.Dot11InformationElement { + return &layers.Dot11InformationElement{ + ID: id, + Length: uint8(len(info) & 0xff), + Info: info, + } +} + +func PackOneOf(from, to net.HardwareAddr, peerID []byte, signature []byte, streamID uint64, seqNum uint64, seqTot uint64, payload []byte, compress bool) (error, []byte) { + stack := []gopacket.SerializableLayer{ + &layers.RadioTap{}, + &layers.Dot11{ + Address1: to, + Address2: SignatureAddr, + Address3: from, + Type: layers.Dot11TypeMgmtBeacon, + }, + &layers.Dot11MgmtBeacon{ + Flags: uint16(wpaFlags), + Interval: 100, + }, + } + + if peerID != nil { + stack = append(stack, Info(IDWhisperIdentity, peerID)) + } + + if signature != nil { + stack = append(stack, Info(IDWhisperSignature, signature)) + } + + if streamID > 0 { + streamBuf := new(bytes.Buffer) + if err := binary.Write(streamBuf, binary.LittleEndian, streamID); err != nil { + return err, nil + } else if err = binary.Write(streamBuf, binary.LittleEndian, seqNum); err != nil { + return err, nil + } else if err = binary.Write(streamBuf, binary.LittleEndian, seqTot); err != nil { + return err, nil + } + stack = append(stack, Info(IDWhisperStreamHeader, streamBuf.Bytes())) + } + + if compress { + if didCompress, compressed, err := Compress(payload); err != nil { + return err, nil + } else if didCompress { + stack = append(stack, Info(IDWhisperCompression, []byte{1})) + payload = compressed + } + } + + dataSize := len(payload) + dataLeft := dataSize + dataOff := 0 + chunkSize := 0xff + + for dataLeft > 0 { + sz := chunkSize + if dataLeft < chunkSize { + sz = dataLeft + } + + chunk := payload[dataOff : dataOff+sz] + stack = append(stack, Info(IDWhisperPayload, chunk)) + + dataOff += sz + dataLeft -= sz + } + + return Serialize(stack...) +} + +func Pack(from, to net.HardwareAddr, payload []byte, compress bool) (error, []byte) { + return PackOneOf(from, to, nil, nil, 0, 0, 0, payload, compress) +} diff --git a/wifi/parse.go b/wifi/parse.go new file mode 100644 index 0000000..3166c95 --- /dev/null +++ b/wifi/parse.go @@ -0,0 +1,30 @@ +package wifi + +import ( + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" +) + +func Parse(packet gopacket.Packet) (ok bool, radio *layers.RadioTap, dot11 *layers.Dot11) { + ok = false + radio = nil + dot11 = nil + + radioLayer := packet.Layer(layers.LayerTypeRadioTap) + if radioLayer == nil { + return + } + radio, ok = radioLayer.(*layers.RadioTap) + if !ok || radio == nil { + return + } + + dot11Layer := packet.Layer(layers.LayerTypeDot11) + if dot11Layer == nil { + ok = false + return + } + + dot11, ok = dot11Layer.(*layers.Dot11) + return +} diff --git a/wifi/unpack.go b/wifi/unpack.go new file mode 100644 index 0000000..04d765b --- /dev/null +++ b/wifi/unpack.go @@ -0,0 +1,34 @@ +package wifi + +import ( + "fmt" + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" +) + +func Unpack(pkt gopacket.Packet, radio *layers.RadioTap, dot11 *layers.Dot11) (error, []byte) { + compressed := false + payload := make([]byte, 0) + + for _, layer := range pkt.Layers() { + if layer.LayerType() == layers.LayerTypeDot11InformationElement { + if info, ok := layer.(*layers.Dot11InformationElement); ok { + if info.ID == IDWhisperPayload { + payload = append(payload, info.Info...) + } else if info.ID == IDWhisperCompression { + compressed = true + } + } + } + } + + if compressed { + if decompressed, err := Decompress(payload); err != nil { + return fmt.Errorf("error decompressing payload: %v", err), nil + } else { + payload = decompressed + } + } + + return nil, payload +} diff --git a/wifi/utils.go b/wifi/utils.go new file mode 100644 index 0000000..273fca6 --- /dev/null +++ b/wifi/utils.go @@ -0,0 +1,41 @@ +package wifi + +import ( + "bytes" + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" +) + +func Serialize(layers ...gopacket.SerializableLayer) (error, []byte) { + buf := gopacket.NewSerializeBuffer() + if err := gopacket.SerializeLayers(buf, SerializationOptions, layers...); err != nil { + return err, nil + } + return nil, buf.Bytes() +} + +func IsBroadcast(dot11 *layers.Dot11) bool { + return bytes.Equal(dot11.Address1, BroadcastAddr) +} + +func Freq2Chan(freq int) int { + if freq <= 2472 { + return ((freq - 2412) / 5) + 1 + } else if freq == 2484 { + return 14 + } else if freq >= 5035 && freq <= 5885 { + return ((freq - 5035) / 5) + 7 + } + return 0 +} + +func Chan2Freq(channel int) int { + if channel <= 13 { + return ((channel - 1) * 5) + 2412 + } else if channel == 14 { + return 2484 + } else if channel <= 177 { + return ((channel - 7) * 5) + 5035 + } + return 0 +}