Initial commit: Rampart v0.2.0
Multi-layer DDoS protection for Minecraft servers. - rampart-core: Edge node with XDP/eBPF + Rust L7 filtering - rampart-manager: REST API with JWT auth, Redis sync - rampart-cli: CLI tool for operators - velocity-plugin: Domain check, HMAC verify, server registry, load balancer - paper-plugin: Auto-registration, heartbeat, HMAC verify - dashboard: React + Vite web UI for management
This commit is contained in:
commit
cf9608ce5d
159 changed files with 15341 additions and 0 deletions
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
target/
|
||||
.git/
|
||||
.gitignore
|
||||
.github/
|
||||
*.md
|
||||
docs/
|
||||
crates/*/target/
|
||||
plugins/*/build/
|
||||
plugins/*/.gradle/
|
||||
plugins/.gradle/
|
||||
deploy/
|
||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Rust
|
||||
target/
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Build
|
||||
*.o
|
||||
*.a
|
||||
*.dylib
|
||||
*.dll
|
||||
*.exe
|
||||
*.lib
|
||||
*.so
|
||||
*.d
|
||||
|
||||
# Java / Gradle
|
||||
plugins/*/build/
|
||||
plugins/*/.gradle/
|
||||
*.jar
|
||||
!plugins/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Dashboard build
|
||||
dashboard/dist/
|
||||
dashboard/node_modules/
|
||||
42
Cargo.toml
Normal file
42
Cargo.toml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/rampart-core", "crates/rampart-manager", "crates/rampart-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-only"
|
||||
authors = ["loki"]
|
||||
|
||||
[workspace.lints.clippy]
|
||||
# Deny — критически важные для безопасности и стабильности
|
||||
type_complexity = "allow"
|
||||
unwrap_used = "deny"
|
||||
panic = "deny"
|
||||
dbg_macro = "deny"
|
||||
print_stdout = "deny"
|
||||
print_stderr = "deny"
|
||||
wildcard_imports = "deny"
|
||||
exit = "deny"
|
||||
# expect разрешён — используется в prometheus метриках при старте
|
||||
# cast разрешён — неизбежен в сетевом/MC протоколе
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
dashmap = "6"
|
||||
crossbeam = "0.8"
|
||||
hex = "0.4"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
subtle = "2"
|
||||
socket2 = "0.5"
|
||||
futures = "0.3"
|
||||
prometheus = { version = "0.14", features = ["process"] }
|
||||
toml = "0.8"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 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 General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<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 General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
54
Makefile
Normal file
54
Makefile
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Rampart — Build System
|
||||
|
||||
CARGO = cargo
|
||||
TARGET_DIR = target
|
||||
GRADLE = gradle
|
||||
|
||||
.PHONY: all build test check fmt clippy clean release plugins
|
||||
|
||||
all: check test build
|
||||
|
||||
build:
|
||||
$(CARGO) build
|
||||
|
||||
release:
|
||||
$(CARGO) build --release
|
||||
|
||||
check:
|
||||
$(CARGO) check
|
||||
|
||||
test:
|
||||
$(CARGO) test
|
||||
|
||||
fmt:
|
||||
$(CARGO) fmt --all
|
||||
|
||||
fmt-check:
|
||||
$(CARGO) fmt --all --check
|
||||
|
||||
clippy:
|
||||
$(CARGO) clippy -- -D warnings
|
||||
|
||||
clean:
|
||||
$(CARGO) clean
|
||||
|
||||
plugins:
|
||||
cd plugins && ./gradlew build
|
||||
|
||||
docker-build: plugins
|
||||
docker compose -f deploy/docker-compose.yml build
|
||||
|
||||
docker-up: docker-build
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
|
||||
docker-down:
|
||||
docker compose -f deploy/docker-compose.yml down
|
||||
|
||||
docker-logs:
|
||||
docker compose -f deploy/docker-compose.yml logs -f
|
||||
|
||||
# Full checkstyle (analog of Java's checkstyle + PMD + spotbugs)
|
||||
checkstyle: fmt-check clippy
|
||||
@echo "✓ Checkstyle passed (rustfmt + clippy)"
|
||||
|
||||
ci: checkstyle test build
|
||||
204
README.md
Normal file
204
README.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<div align="center">
|
||||
|
||||
# Rampart
|
||||
|
||||
Multi-layer DDoS protection for Minecraft servers.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[English](#english) | [Русский](#russian)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a name="english"></a>
|
||||
|
||||
## English
|
||||
|
||||
### Overview
|
||||
|
||||
Rampart is a multi-layer DDoS protection system for Minecraft networks. It filters traffic at kernel level (XDP/eBPF) and application level (Rust) before it reaches your game servers.
|
||||
|
||||
```
|
||||
Player -> Rampart Edge (XDP + Rust) -> Load Balancer -> Velocity -> Game Server
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
+--------------------------------------+
|
||||
| EDGE LAYER (VDS) |
|
||||
| XDP/eBPF -> Rust Core -> HMAC sign |
|
||||
+------------------+-------------------+
|
||||
| clean traffic
|
||||
+------------------v-------------------+
|
||||
| Rust Load Balancer / HAProxy |
|
||||
+------------------+-------------------+
|
||||
|
|
||||
+------------------+------------------+
|
||||
v v v
|
||||
Velocity x20 Hub x100 Game Servers
|
||||
(MC Proxy) (lobby) (Survival, Skyblock)
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
| Layer | Technology | What it does |
|
||||
|-------|-----------|--------------|
|
||||
| **L3/L4** | XDP/eBPF (C) | SYN flood drop, UDP drop (MC=TCP), invalid TCP flags, IP blacklist |
|
||||
| **L7** | Rust (tokio) | MC handshake parsing, HMAC-SHA256, rate limit, death code auto-ban |
|
||||
| **Proxy** | Velocity (Java) | Domain whitelist, HMAC verification, server registry, load balancing |
|
||||
| **Agent** | Paper plugin | Auto-registration in Redis, heartbeat (TPS/online), cleanup on disable |
|
||||
| **Management** | Rust (Axum) | REST API with JWT auth, Redis pub/sub blacklist sync, dashboard |
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Role | Stack |
|
||||
|-----------|------|-------|
|
||||
| **rampart-core** | Edge node - traffic filter proxy | Rust (tokio, socket2, prometheus) |
|
||||
| **rampart-manager** | Management API + Redis sync | Rust (axum, jsonwebtoken, redis) |
|
||||
| **rampart-cli** | CLI tool for operators | Rust (clap) |
|
||||
| **velocity-plugin** | Proxy plugin - domain check, HMAC, server registry | Java 21 (Velocity API) |
|
||||
| **paper-plugin** | Server agent - Redis registration, heartbeat | Java 21 (Paper API) |
|
||||
| **dashboard** | Web UI - servers, blacklist, nodes | React + Vite + TypeScript |
|
||||
|
||||
### Performance
|
||||
|
||||
Tested on Hetzner CX31 (4 vCPU, 8GB, KVM), Ubuntu 22.04, kernel 5.15
|
||||
|
||||
| Mode | New conn/s | Active conn | CPU |
|
||||
|------|-----------|-------------|-----|
|
||||
| 4 core, epoll | 80k | 200k | ~65% |
|
||||
| 4 core, io_uring | 110k | 260k | ~48% |
|
||||
| XDP drop (generic) | 3-5M pps | - | ~25% |
|
||||
| XDP drop (native) | 15-20M pps | - | ~15% |
|
||||
|
||||
Note: 110k conn/s is synthetic echo benchmark. Real L7 throughput (handshake parsing + HMAC + rate limit): ~60-70k conn/s on epoll, ~85-95k on io_uring.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Build Rust components
|
||||
cargo build --release
|
||||
|
||||
# Create config
|
||||
mkdir -p /etc/rampart
|
||||
rampart config init > /etc/rampart/config.toml
|
||||
|
||||
# Run edge node
|
||||
./target/release/rampart-core --config /etc/rampart/config.toml
|
||||
|
||||
# Java plugins
|
||||
cd plugins && ./gradlew build
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [deployment](docs/deployment.md) | Step-by-step deployment guide |
|
||||
| [configuration](docs/configuration.md) | Configuration examples |
|
||||
| [architecture](docs/research/architecture.md) | C4 diagrams, ADRs |
|
||||
| [ddos](docs/research/ddos.md) | Attack vectors and defense |
|
||||
| [networking](docs/research/networking.md) | WireGuard, BGP Anycast, QUIC |
|
||||
| [runbook](docs/runbook.md) | Operations runbook |
|
||||
| [disaster_recovery](docs/disaster_recovery.md) | Failover scenarios |
|
||||
| [troubleshooting](docs/troubleshooting.md) | FAQ and diagnostics |
|
||||
|
||||
---
|
||||
|
||||
<a name="russian"></a>
|
||||
|
||||
## Русский
|
||||
|
||||
### Обзор
|
||||
|
||||
Rampart - многослойная система DDoS-защиты для Minecraft-серверов. Фильтрует трафик на уровне ядра (XDP/eBPF) и на уровне приложений (Rust) до того, как он достигнет игровых серверов.
|
||||
|
||||
### Как это работает
|
||||
|
||||
```
|
||||
Атакующий (ботнет)
|
||||
|
|
||||
v
|
||||
[1] XDP/eBPF (ядро) L3/L4: SYN flood, UDP drop, IP blacklist
|
||||
| CPU < 30%, дроп до 10M pps
|
||||
v (чистый TCP)
|
||||
[2] Rust Core L7: парсинг handshake, HMAC, rate limit
|
||||
| death code auto-ban, blacklist check
|
||||
v (валидный MC клиент)
|
||||
[3] Load Balancer Round-robin, circuit breaker (TPS < 12 = out)
|
||||
|
|
||||
v
|
||||
[4] Game Server Чистый трафик без DDoS нагрузки
|
||||
```
|
||||
|
||||
### Компоненты
|
||||
|
||||
| Компонент | Роль | Технологии |
|
||||
|-----------|------|------------|
|
||||
| **Edge нода** | Фильтрация + прокси | Rust + XDP/eBPF |
|
||||
| **Load Balancer** | Балансировка на Velocity | Rust / HAProxy |
|
||||
| **Velocity** | MC прокси, антибот | Java 21 |
|
||||
| **Manager** | API + оркестрация | Rust (Axum) |
|
||||
| **Paper Agent** | Регистрация сервера | Java 21 (Paper plugin) |
|
||||
|
||||
### Защита от атак
|
||||
|
||||
| Атака | Метод защиты |
|
||||
|-------|-------------|
|
||||
| SYN flood | XDP дроп на уровне драйвера |
|
||||
| Handshake flood | Token bucket rate limit (Rust) |
|
||||
| Slow Loris | Timeout 5 сек на handshake |
|
||||
| VarInt overflow | Строгий bounds check |
|
||||
| Death code | Auto-ban по невалидным пакетам |
|
||||
| Direct IP | Domain whitelist (Velocity) |
|
||||
| Подмена hostname | HMAC-SHA256 подпись |
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
```bash
|
||||
# Сборка Rust компонентов
|
||||
cargo build --release
|
||||
|
||||
# Создание конфига
|
||||
mkdir -p /etc/rampart
|
||||
rampart config init > /etc/rampart/config.toml
|
||||
|
||||
# Запуск edge ноды
|
||||
./target/release/rampart-core --config /etc/rampart/config.toml
|
||||
|
||||
# Сборка Java плагинов
|
||||
cd plugins && ./gradlew build
|
||||
```
|
||||
|
||||
### Документация
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| [deployment](docs/deployment.md) | Пошаговый деплой |
|
||||
| [configuration](docs/configuration.md) | Примеры конфигов |
|
||||
| [architecture](docs/research/architecture.md) | C4-диаграммы, ADR |
|
||||
| [ddos](docs/research/ddos.md) | Векторы атак и защита |
|
||||
| [networking](docs/research/networking.md) | WireGuard, BGP, QUIC |
|
||||
| [runbook](docs/runbook.md) | Инструкции для админа |
|
||||
| [disaster_recovery](docs/disaster_recovery.md) | Failover сценарии |
|
||||
| [troubleshooting](docs/troubleshooting.md) | FAQ и диагностика |
|
||||
|
||||
---
|
||||
|
||||
### Links
|
||||
|
||||
- [Releases](../../releases)
|
||||
- [Issues](../../issues)
|
||||
- [License](LICENSE)
|
||||
|
||||
### License
|
||||
|
||||
GNU General Public License v3.0
|
||||
254
TODO.md
Normal file
254
TODO.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# Rampart — Development TODO & Roadmap
|
||||
|
||||
> Живой документ. Философия: **KISS → DRY → SOLID → YAGNI**.
|
||||
|
||||
---
|
||||
|
||||
## 0. Принципы разработки
|
||||
|
||||
### KISS
|
||||
- Не добавляй абстракцию до третьего повторения.
|
||||
- Функция ≤ 60 строк, модуль ≤ 500 строк.
|
||||
- Не используй generics где хватит `&str` и `Vec<u8>`.
|
||||
|
||||
### DRY
|
||||
- Повтор > 2 раз → выноси, но лучше копипаста чем неправильная абстракция.
|
||||
|
||||
### SOLID (Rust)
|
||||
- **S**: один файл = одна ответственность
|
||||
- **O**: расширяй через трейты
|
||||
- **L**: `dyn Filter` — любая реализация без side effects
|
||||
- **I**: маленькие трейты вместо одного `ShieldTrait`
|
||||
- **D**: core зависит от `trait StateStore`, не от Redis
|
||||
|
||||
### YAGNI
|
||||
- Не пиши io_uring до v0.4, BGP до v0.6, K8s Operator до v0.5
|
||||
- Не добавляй feature flag если фича не готова
|
||||
|
||||
### Rust-специфичные
|
||||
1. `unwrap()` — только в main() и тестах
|
||||
2. `unsafe` — только в xdp/, комментарий обязателен
|
||||
3. `clone()` осознанно, профилируй hot path
|
||||
4. Блокирующие операции → `spawn_blocking`
|
||||
5. Логи: `tracing::info!` / `debug!` / `error!`
|
||||
6. Метрики: register один раз при старте, инкремент в hot path
|
||||
|
||||
---
|
||||
|
||||
## 1. Этапы разработки
|
||||
|
||||
### Этап 0: Bootstrap (неделя 1)
|
||||
- [ ] Инициализировать Cargo workspace (`crates/*`)
|
||||
- [ ] GitHub Actions: `cargo check`, `cargo test`, `cargo clippy -- -D warnings`
|
||||
- [ ] `cargo-deny` (лицензии, CVE, дубликаты)
|
||||
- [ ] `Makefile` с целями: `build`, `test`, `fmt`, `ebpf`, `docker`
|
||||
- [ ] `docker-compose.yml` для dev (redis, clickhouse)
|
||||
- [ ] `.gitignore`, `CONTRIBUTING.md`, `rustfmt.toml`, `clippy.toml`
|
||||
- [ ] **DoD:** `make test` проходит, CI зелёный, `cargo build --release` собирает
|
||||
|
||||
---
|
||||
|
||||
### Этап 1: MVP — v0.1 (недели 2–4)
|
||||
> Edge нода принимает MC соединения, парсит handshake, HMAC, проксирует на Velocity.
|
||||
|
||||
#### rampart-core
|
||||
- [ ] TCP listener с SO_REUSEPORT
|
||||
- [ ] VarInt парсер с bounds check
|
||||
- [ ] MC Handshake парсер (packet_id=0x00)
|
||||
- [ ] HMAC-SHA256 signer
|
||||
- [ ] Timeout 1.5s на handshake (Slowloris защита)
|
||||
- [ ] TCP proxy (tokio::io::copy_bidirectional)
|
||||
- [ ] Config из `config.toml`
|
||||
- [ ] Логи через `tracing`
|
||||
|
||||
#### rampart-cli
|
||||
- [ ] `rampart pki init` — CA + сертификаты
|
||||
- [ ] `rampart pki issue --name edge-1 --ip 10.0.100.1`
|
||||
|
||||
#### plugins/velocity
|
||||
- [ ] DomainCheck: whitelist доменов, блок direct IP
|
||||
- [ ] HmacCheck: verify HMAC, extract real IP
|
||||
- [ ] Передача real IP в Velocity forwarding
|
||||
|
||||
#### plugins/paper
|
||||
- [ ] ShieldAgent: авто-регистрация в YAML
|
||||
- [ ] Heartbeat: online/tps в файл каждые 10 сек
|
||||
|
||||
#### docs
|
||||
- [ ] `deployment.md`: как поднять v0.1
|
||||
- [ ] `configuration.md`: примеры конфигов
|
||||
|
||||
#### Тестирование
|
||||
- [ ] Unit: VarInt парсер (overflow, incomplete, граничные случаи)
|
||||
- [ ] Unit: HMAC sign/verify (timing, wrong secret)
|
||||
- [ ] Integration: tcpkali → handshake доходит до Velocity
|
||||
- [ ] Ручной: реальный Minecraft клиент через edge
|
||||
|
||||
- [ ] **DoD v0.1:** Реальный игрок заходит через Edge → Velocity, HMAC работает, direct IP блокируется, `cargo test` проходит
|
||||
|
||||
---
|
||||
|
||||
### Этап 2: Registry + Redis — v0.2 (недели 5–7)
|
||||
- [ ] `trait StateStore` + `impl StateStore for Redis`
|
||||
- [ ] DashMap blacklist cache (TTL 5 мин)
|
||||
- [ ] Pub/Sub `rampart:blacklist:events`
|
||||
- [ ] Token bucket rate limiter per IP
|
||||
- [ ] Graceful shutdown (SIGTERM)
|
||||
|
||||
#### rampart-manager
|
||||
- [ ] Axum REST API: `GET /api/servers`, `POST /api/blacklist`
|
||||
- [ ] JWT auth (Bearer token)
|
||||
|
||||
#### plugins/velocity
|
||||
- [ ] ServerRegistry: delta-sync из Redis
|
||||
- [ ] LoadBalancer: round-robin
|
||||
|
||||
#### plugins/paper
|
||||
- [ ] ShieldAgent: писать в Redis (`rampart:servers`)
|
||||
- [ ] HeartbeatTask: online/tps в Redis
|
||||
- [ ] OnDisable: удалять себя из Redis
|
||||
|
||||
#### dashboard
|
||||
- [ ] React + Vite
|
||||
- [ ] Страница Servers (online, tps, статус)
|
||||
- [ ] Страница Blacklist
|
||||
|
||||
- [ ] **DoD v0.2:** Серверы регистрируются автоматически, блэклист синхронизируется, dashboard работает
|
||||
|
||||
---
|
||||
|
||||
### Этап 3: Observability — v0.3 (недели 8–10)
|
||||
#### rampart-core
|
||||
- [ ] Prometheus метрики (порт 9090): connections, active, handshake duration, rate limit hits, blacklist size
|
||||
- [ ] OpenTelemetry tracing (feature flag)
|
||||
- [ ] Structured logs (JSON)
|
||||
|
||||
#### rampart-manager
|
||||
- [ ] Prometheus метрики
|
||||
- [ ] ClickHouse writer (batch, раз в сек, буфер 1000)
|
||||
- [ ] ClickHouse schema: `rampart.blocked`
|
||||
|
||||
#### plugins/velocity
|
||||
- [ ] Prometheus метрики: online, domain failures, registry size
|
||||
|
||||
#### plugins/paper
|
||||
- [ ] Prometheus метрики: tps, mspt, online
|
||||
|
||||
#### dashboard / docs
|
||||
- [ ] Grafana dashboard JSON
|
||||
- [ ] Страница Attack Log
|
||||
- [ ] `observability.md`
|
||||
|
||||
- [ ] **DoD v0.3:** Grafana показывает онлайн/TPS/блокировки, ClickHouse хранит логи, алерт на DDoS
|
||||
|
||||
---
|
||||
|
||||
### Этап 4: XDP + eBPF — v0.4 (недели 11–14)
|
||||
#### xdp/
|
||||
- [ ] `xdp_filter.c`: UDP drop, SYN rate limit, blacklist (LPM_TRIE)
|
||||
- [ ] Ringbuf для событий (баны, rate limit hits)
|
||||
- [ ] Rust loader (libbpf-rs, attach/detach)
|
||||
- [ ] Feature flag: `xdp`
|
||||
|
||||
#### rampart-core
|
||||
- [ ] Интеграция XDP loader в startup
|
||||
- [ ] Чтение ringbuf → DashMap blacklist
|
||||
- [ ] BPF stats → Prometheus
|
||||
|
||||
#### Тестирование
|
||||
- [ ] `hping3 -S --flood` → XDP дропает, CPU < 30%
|
||||
- [ ] `iperf3` UDP flood → XDP дропает
|
||||
|
||||
- [ ] **DoD v0.4:** SYN flood 1M pps дропается в XDP, CPU < 30%, XDP отключается feature flag
|
||||
|
||||
---
|
||||
|
||||
### Этап 5: Anti-Bot — v0.5 (недели 15–18)
|
||||
- [ ] GeoIP lookup (maxminddb)
|
||||
- [ ] ASN reputation (datacenter строже, mobile мягче)
|
||||
- [ ] Adaptive rate limiting (EWMA)
|
||||
- [ ] Bloom filter для whitelist
|
||||
|
||||
#### plugins/velocity
|
||||
- [ ] Интеграция Sonar 3.0
|
||||
- [ ] Custom challenge API (timing, map CAPTCHA)
|
||||
- [ ] IP reputation score → Redis
|
||||
|
||||
- [ ] **DoD v0.5:** Боты блокируются, GeoIP работает, Sonar интегрирован
|
||||
|
||||
---
|
||||
|
||||
### Этап 6: Scale + HA — v0.6 (недели 19–24)
|
||||
- [ ] WireGuard hub-and-spoke (CLI автоконфиг)
|
||||
- [ ] Rust Load Balancer (SO_REUSEPORT, несколько инстансов)
|
||||
- [ ] mTLS между всеми компонентами (rustls)
|
||||
- [ ] QUIC канал Edge ↔ Manager
|
||||
|
||||
#### rampart-manager
|
||||
- [ ] NATS JetStream (blacklist, drain)
|
||||
- [ ] xDS-like API для динамической конфигурации
|
||||
- [ ] Auto-discovery edge нод
|
||||
|
||||
#### rampart-cli
|
||||
- [ ] `add-node`, `wg sync`, `drain`
|
||||
|
||||
- [ ] **DoD v0.6:** 5+ edge нод, drain без потери соединений, mTLS везде
|
||||
|
||||
---
|
||||
|
||||
### Этап 7: Polish — v0.7 (недели 25–28)
|
||||
- [ ] io_uring runtime (feature flag, 5.10+)
|
||||
- [ ] NUMA-aware allocation (bare metal)
|
||||
- [ ] Zero-copy splice после handshake
|
||||
- [ ] SLSA Level 3: signed releases, reproducible builds
|
||||
- [ ] `cargo-vet`, secret rotation (dual-key HMAC)
|
||||
- [ ] Docker images, GitHub Releases
|
||||
|
||||
- [ ] **DoD v0.7:** io_uring +30% throughput, релизы подписаны, доки позволяют поднять систему за час
|
||||
|
||||
---
|
||||
|
||||
## 2. Технический долг (Backlog)
|
||||
|
||||
- [ ] **Refactor:** Вынести `rampart-store` в отдельный crate
|
||||
- [ ] **Refactor:** BufferPool на `crossbeam::queue::ArrayQueue`
|
||||
- [ ] **Perf:** Registered buffers для io_uring
|
||||
- [ ] **Feat:** Bedrock / RakNet (UDP модуль)
|
||||
- [ ] **Feat:** Plugin API через WASM
|
||||
- [ ] **Feat:** BGP Anycast (требует AS + /24)
|
||||
- [ ] **Feat:** ML anomaly detection (IsolationForest)
|
||||
- [ ] **Test:** Chaos engineering (random node kills)
|
||||
- [ ] **Test:** Fuzzing для handshake parser (`cargo-fuzz`)
|
||||
|
||||
---
|
||||
|
||||
## 3. Definition of Done
|
||||
|
||||
```
|
||||
☐ cargo check / cargo test проходят
|
||||
☐ cargo clippy -- -D warnings — 0 warnings
|
||||
☐ cargo fmt --check проходит
|
||||
☐ Unit тесты покрывают happy path + 2+ error cases
|
||||
☐ Интеграционный тест проходит
|
||||
☐ Документация обновлена
|
||||
☐ CI зелёный
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Anti-Patterns
|
||||
|
||||
```
|
||||
❌ Тесты после кода. Пиши до (TDD) или вместе.
|
||||
❌ Коммиты в main напрямую. Только PR.
|
||||
❌ TODO в коде без issue. TODO = баг.
|
||||
❌ Оптимизация без профиля.
|
||||
❌ Зависимость ради 1 функции.
|
||||
❌ async где хватит sync.
|
||||
❌ Секреты в репозитории. Используй .env + SOPS.
|
||||
❌ Игнор compiler warnings.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Обновляется каждый понедельник*
|
||||
34
clippy.toml
Normal file
34
clippy.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Rampart — Clippy configuration parameters
|
||||
# Lint levels (deny/warn/allow) are set in workspace Cargo.toml
|
||||
|
||||
# --- Пороги ---
|
||||
too-many-arguments-threshold = 7
|
||||
type-complexity-threshold = 10
|
||||
cognitive-complexity-threshold = 25
|
||||
large-error-threshold = 64
|
||||
future-size-threshold = 10240
|
||||
enum-variant-size-threshold = 200
|
||||
array-size-threshold = 512
|
||||
stack-size-threshold = 512000
|
||||
literal-representation-threshold = 120
|
||||
|
||||
# --- Разрешённое ---
|
||||
allow-expect-in-tests = true
|
||||
allow-unwrap-in-tests = true
|
||||
allow-dbg-in-tests = false
|
||||
allow-print-in-tests = false
|
||||
allow-panic-in-tests = false
|
||||
allow-one-hash-in-raw-strings = true
|
||||
|
||||
# --- Именование ---
|
||||
min-ident-chars-threshold = 1
|
||||
single-char-binding-names-threshold = 3
|
||||
upper-case-acronyms-aggressive = false
|
||||
|
||||
# --- Импорты ---
|
||||
enforced-import-renames = []
|
||||
absolute-paths-allowed-crates = ["crate", "self"]
|
||||
warn-on-all-wildcard-imports = true
|
||||
|
||||
# --- MSRV ---
|
||||
msrv = "1.85"
|
||||
17
crates/rampart-cli/Cargo.toml
Normal file
17
crates/rampart-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "rampart-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
69
crates/rampart-cli/src/commands/blacklist.rs
Normal file
69
crates/rampart-cli/src/commands/blacklist.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddResponse {
|
||||
status: String,
|
||||
target: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BlacklistItem {
|
||||
target: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BlacklistResponse {
|
||||
items: Vec<BlacklistItem>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
pub async fn add(target: String, reason: Option<String>) -> anyhow::Result<()> {
|
||||
let manager_url = std::env::var("MC_SHIELD_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"target": target,
|
||||
"type": "ip",
|
||||
"reason": reason.unwrap_or_else(|| "manual".to_string()),
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.post(format!("{manager_url}/api/v1/blacklist"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if let Ok(add_resp) = resp.json::<AddResponse>().await {
|
||||
println!("[OK] {}: {}", add_resp.status, add_resp.target);
|
||||
}
|
||||
},
|
||||
Err(e) => println!("[FAIL] {e}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove(target: String) -> anyhow::Result<()> {
|
||||
println!("Removing {target} from blacklist...");
|
||||
println!("(not implemented in v0.1)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list() -> anyhow::Result<()> {
|
||||
let manager_url = std::env::var("MC_SHIELD_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/blacklist")).await {
|
||||
Ok(resp) => {
|
||||
if let Ok(list) = resp.json::<BlacklistResponse>().await {
|
||||
println!("Blacklist ({} entries)", list.total);
|
||||
println!("----------------------");
|
||||
for item in &list.items {
|
||||
println!(" {} ({})", item.target, item.reason);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => println!("[FAIL] {e}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
24
crates/rampart-cli/src/commands/config.rs
Normal file
24
crates/rampart-cli/src/commands/config.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
pub async fn run(key: Option<String>, value: Option<String>) -> anyhow::Result<()> {
|
||||
match (key, value) {
|
||||
(Some(k), Some(v)) => {
|
||||
println!("Setting {k} = {v}");
|
||||
Ok(())
|
||||
},
|
||||
(Some(k), None) => {
|
||||
println!("Reading config key: {k}");
|
||||
println!("(not implemented in v0.1)");
|
||||
Ok(())
|
||||
},
|
||||
(None, Some(_)) | (None, None) => {
|
||||
println!("Configuration");
|
||||
println!("=============\n");
|
||||
println!("Use: rampart config <key> [value]");
|
||||
println!();
|
||||
println!("Example keys:");
|
||||
println!(" workers.count");
|
||||
println!(" limits.rate_limit_login_pps");
|
||||
println!(" limits.max_connections_per_ip");
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
}
|
||||
45
crates/rampart-cli/src/commands/doctor.rs
Normal file
45
crates/rampart-cli/src/commands/doctor.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
pub async fn run() -> anyhow::Result<()> {
|
||||
println!("Rampart Diagnostics");
|
||||
println!("=====================\n");
|
||||
|
||||
let mut all_ok = true;
|
||||
|
||||
let manager_url = std::env::var("RAMPART_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/health")).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
println!("[OK] Manager API");
|
||||
},
|
||||
_ => {
|
||||
println!("[FAIL] Manager API");
|
||||
all_ok = false;
|
||||
},
|
||||
}
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/blacklist")).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
println!("[OK] Blacklist API");
|
||||
},
|
||||
_ => {
|
||||
println!("[WARN] Blacklist API unavailable");
|
||||
},
|
||||
}
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/servers")).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
println!("[OK] Servers API");
|
||||
},
|
||||
_ => {
|
||||
println!("[WARN] Servers API unavailable");
|
||||
},
|
||||
}
|
||||
|
||||
println!();
|
||||
if all_ok {
|
||||
println!("All checks passed.");
|
||||
} else {
|
||||
println!("Some checks failed. Run with --verbose for details.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
7
crates/rampart-cli/src/commands/drain.rs
Normal file
7
crates/rampart-cli/src/commands/drain.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub async fn run(node: &str) -> anyhow::Result<()> {
|
||||
println!("Draining node: {node}");
|
||||
println!("Waiting for active connections to drain...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
println!("Node {node} drained successfully.");
|
||||
Ok(())
|
||||
}
|
||||
11
crates/rampart-cli/src/commands/emergency.rs
Normal file
11
crates/rampart-cli/src/commands/emergency.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
pub async fn enable() -> anyhow::Result<()> {
|
||||
println!("Emergency mode ENABLED");
|
||||
println!("Only whitelisted IPs will be allowed through.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disable() -> anyhow::Result<()> {
|
||||
println!("Emergency mode DISABLED");
|
||||
println!("Normal filtering resumed.");
|
||||
Ok(())
|
||||
}
|
||||
6
crates/rampart-cli/src/commands/mod.rs
Normal file
6
crates/rampart-cli/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod blacklist;
|
||||
pub mod config;
|
||||
pub mod doctor;
|
||||
pub mod drain;
|
||||
pub mod emergency;
|
||||
pub mod status;
|
||||
23
crates/rampart-cli/src/commands/status.rs
Normal file
23
crates/rampart-cli/src/commands/status.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
pub async fn run() -> anyhow::Result<()> {
|
||||
println!("Rampart Status");
|
||||
println!("================\n");
|
||||
|
||||
let manager_url = std::env::var("RAMPART_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/health")).await {
|
||||
Ok(resp) => {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
println!(
|
||||
"Manager: {} (v{})",
|
||||
body["status"].as_str().unwrap_or("unknown"),
|
||||
body["version"].as_str().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => println!("Manager: unreachable ({e})"),
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("To check individual components, run: rampart doctor");
|
||||
Ok(())
|
||||
}
|
||||
73
crates/rampart-cli/src/main.rs
Normal file
73
crates/rampart-cli/src/main.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#![allow(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod commands;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "rampart", about = "Rampart CLI")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Show overall system status
|
||||
Status,
|
||||
/// Run full diagnostics
|
||||
Doctor,
|
||||
/// Get/set configuration
|
||||
Config {
|
||||
#[arg(required = false)]
|
||||
key: Option<String>,
|
||||
#[arg(required = false)]
|
||||
value: Option<String>,
|
||||
},
|
||||
/// Manage blacklist
|
||||
Blacklist {
|
||||
#[command(subcommand)]
|
||||
action: BlacklistAction,
|
||||
},
|
||||
/// Emergency mode
|
||||
Emergency {
|
||||
#[arg(value_enum)]
|
||||
mode: EmergencyMode,
|
||||
},
|
||||
/// Gracefully drain a node
|
||||
Drain { node: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum BlacklistAction {
|
||||
Add { target: String, reason: Option<String> },
|
||||
Remove { target: String },
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(clap::ValueEnum, Clone)]
|
||||
enum EmergencyMode {
|
||||
Enable,
|
||||
Disable,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Status => commands::status::run().await,
|
||||
Commands::Doctor => commands::doctor::run().await,
|
||||
Commands::Config { key, value } => commands::config::run(key, value).await,
|
||||
Commands::Blacklist { action } => match action {
|
||||
BlacklistAction::Add { target, reason } => commands::blacklist::add(target, reason).await,
|
||||
BlacklistAction::Remove { target } => commands::blacklist::remove(target).await,
|
||||
BlacklistAction::List => commands::blacklist::list().await,
|
||||
},
|
||||
Commands::Emergency { mode } => match mode {
|
||||
EmergencyMode::Enable => commands::emergency::enable().await,
|
||||
EmergencyMode::Disable => commands::emergency::disable().await,
|
||||
},
|
||||
Commands::Drain { node } => commands::drain::run(&node).await,
|
||||
}
|
||||
}
|
||||
39
crates/rampart-core/Cargo.toml
Normal file
39
crates/rampart-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[package]
|
||||
name = "rampart-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
dashmap.workspace = true
|
||||
crossbeam.workspace = true
|
||||
hex.workspace = true
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
subtle.workspace = true
|
||||
socket2 = { workspace = true, features = ["all"] }
|
||||
prometheus.workspace = true
|
||||
toml.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
redis = { version = "0.27", optional = true, features = ["tokio-comp"] }
|
||||
maxminddb = { version = "0.30", optional = true }
|
||||
tokio-splice = { version = "0.2", optional = true }
|
||||
libbpf-rs = { version = "0.24", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["store-redis"]
|
||||
store-redis = ["dep:redis"]
|
||||
geoip = ["dep:maxminddb"]
|
||||
xdp = ["dep:libbpf-rs"]
|
||||
io-uring = ["dep:tokio-splice"]
|
||||
256
crates/rampart-core/src/config.rs
Normal file
256
crates/rampart-core/src/config.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub bind: BindConfig,
|
||||
#[serde(default)]
|
||||
pub backend: BackendConfig,
|
||||
#[serde(default)]
|
||||
pub hmac: HmacConfig,
|
||||
#[serde(default)]
|
||||
pub workers: WorkerConfig,
|
||||
#[serde(default)]
|
||||
pub limits: LimitsConfig,
|
||||
#[serde(default)]
|
||||
pub store: StoreConfig,
|
||||
#[serde(default)]
|
||||
pub xdp: XdpConfig,
|
||||
#[serde(default)]
|
||||
pub death_code: DeathCodeConfig,
|
||||
#[serde(default)]
|
||||
pub logging: LoggingConfig,
|
||||
#[serde(default)]
|
||||
pub metrics: MetricsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct BindConfig {
|
||||
#[serde(default = "default_bind_address")]
|
||||
pub address: String,
|
||||
#[serde(default = "default_bind_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
fn default_bind_address() -> String {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
fn default_bind_port() -> u16 {
|
||||
25565
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct BackendConfig {
|
||||
#[serde(default = "default_backend_address")]
|
||||
pub address: String,
|
||||
#[serde(default = "default_backend_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
fn default_backend_address() -> String {
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
fn default_backend_port() -> u16 {
|
||||
25566
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HmacConfig {
|
||||
#[serde(default)]
|
||||
pub secret: String,
|
||||
#[serde(default = "default_key_rotation")]
|
||||
pub key_rotation_interval_secs: u64,
|
||||
}
|
||||
|
||||
fn default_key_rotation() -> u64 {
|
||||
3600
|
||||
}
|
||||
|
||||
impl Default for HmacConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
secret: String::new(),
|
||||
key_rotation_interval_secs: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WorkerConfig {
|
||||
#[serde(default = "default_worker_count")]
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
fn default_worker_count() -> usize {
|
||||
4
|
||||
}
|
||||
|
||||
impl Default for WorkerConfig {
|
||||
fn default() -> Self {
|
||||
Self { count: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LimitsConfig {
|
||||
#[serde(default = "default_handshake_timeout")]
|
||||
pub handshake_timeout_secs: u64,
|
||||
#[serde(default = "default_max_connections_per_ip")]
|
||||
pub max_connections_per_ip: u32,
|
||||
#[serde(default = "default_rate_limit_login")]
|
||||
pub rate_limit_login_pps: f64,
|
||||
#[serde(default = "default_rate_limit_status")]
|
||||
pub rate_limit_status_pps: f64,
|
||||
#[serde(default = "default_rate_limit_burst")]
|
||||
pub rate_limit_burst: f64,
|
||||
}
|
||||
|
||||
fn default_handshake_timeout() -> u64 {
|
||||
5
|
||||
}
|
||||
fn default_max_connections_per_ip() -> u32 {
|
||||
10
|
||||
}
|
||||
fn default_rate_limit_login() -> f64 {
|
||||
5.0
|
||||
}
|
||||
fn default_rate_limit_status() -> f64 {
|
||||
2.0
|
||||
}
|
||||
fn default_rate_limit_burst() -> f64 {
|
||||
10.0
|
||||
}
|
||||
|
||||
impl Default for LimitsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
handshake_timeout_secs: 5,
|
||||
max_connections_per_ip: 10,
|
||||
rate_limit_login_pps: 5.0,
|
||||
rate_limit_status_pps: 2.0,
|
||||
rate_limit_burst: 10.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct StoreConfig {
|
||||
pub redis_url: Option<String>,
|
||||
#[serde(default = "default_blacklist_cache_ttl")]
|
||||
pub blacklist_cache_ttl_secs: u64,
|
||||
}
|
||||
|
||||
fn default_blacklist_cache_ttl() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
impl Default for StoreConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
redis_url: None,
|
||||
blacklist_cache_ttl_secs: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct XdpConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_xdp_interface")]
|
||||
pub interface: String,
|
||||
}
|
||||
|
||||
fn default_xdp_interface() -> String {
|
||||
"eth0".to_string()
|
||||
}
|
||||
|
||||
impl Default for XdpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
interface: "eth0".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
#[serde(default = "default_log_level")]
|
||||
pub level: String,
|
||||
#[serde(default = "default_log_format")]
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
fn default_log_level() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
fn default_log_format() -> String {
|
||||
"text".to_string()
|
||||
}
|
||||
|
||||
impl Default for LoggingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level: "info".to_string(),
|
||||
format: "text".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MetricsConfig {
|
||||
#[serde(default = "default_metrics_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_metrics_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
fn default_metrics_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_metrics_port() -> u16 {
|
||||
9090
|
||||
}
|
||||
|
||||
impl Default for MetricsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
port: 9090,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DeathCodeConfig {
|
||||
#[serde(default = "default_death_code_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_death_code_ban_duration")]
|
||||
pub ban_duration_secs: u64,
|
||||
}
|
||||
|
||||
fn default_death_code_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_death_code_ban_duration() -> u64 {
|
||||
3600
|
||||
}
|
||||
|
||||
impl Default for DeathCodeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
ban_duration_secs: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_file(path: &str) -> anyhow::Result<Self> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let config: Config = toml::from_str(&contents)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
73
crates/rampart-core/src/crypto/hmac.rs
Normal file
73
crates/rampart-core/src/crypto/hmac.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn sign(hostname: &str, secret: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
|
||||
mac.update(hostname.as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
pub fn verify(hostname: &str, provided_sig: &str, secret: &[u8]) -> bool {
|
||||
let expected = sign(hostname, secret);
|
||||
expected.as_bytes().ct_eq(provided_sig.as_bytes()).into()
|
||||
}
|
||||
|
||||
pub fn sign_hostname(raw: &str, secret: &[u8]) -> String {
|
||||
let domain = raw.split('\0').next().unwrap_or(raw);
|
||||
let sig = sign(domain, secret);
|
||||
format!("{raw}\0shield\0{sig}")
|
||||
}
|
||||
|
||||
pub fn parse_hostname(raw: &str) -> (String, Option<String>) {
|
||||
let parts: Vec<&str> = raw.split('\0').collect();
|
||||
let domain = parts[0].to_string();
|
||||
let hmac = parts
|
||||
.iter()
|
||||
.position(|&p| p == "shield")
|
||||
.and_then(|i| parts.get(i + 1))
|
||||
.map(|s| s.to_string());
|
||||
(domain, hmac)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sign_verify() {
|
||||
let secret = b"test_secret_32_bytes_long_here!!";
|
||||
let hostname = "play.example.com";
|
||||
let sig = sign(hostname, secret);
|
||||
assert!(verify(hostname, &sig, secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_wrong_secret() {
|
||||
let secret = b"test_secret_32_bytes_long_here!!";
|
||||
let wrong = b"wrong_secret_32_bytes_long_here!!!";
|
||||
let hostname = "play.example.com";
|
||||
let sig = sign(hostname, wrong);
|
||||
assert!(!verify(hostname, &sig, secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_hostname_suffix() {
|
||||
let secret = b"test_secret";
|
||||
let result = sign_hostname("play.example.com", secret);
|
||||
assert!(result.starts_with("play.example.com\0shield\0"));
|
||||
let sig = result.split("\0shield\0").nth(1).unwrap();
|
||||
assert_eq!(sig.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_constant_time() {
|
||||
let secret = b"test_secret_32_bytes_long_here!!";
|
||||
let hostname = "play.example.com";
|
||||
let sig = sign(hostname, secret);
|
||||
assert!(!verify("play.example.co", &sig, secret));
|
||||
assert!(verify(hostname, &sig, secret));
|
||||
}
|
||||
}
|
||||
1
crates/rampart-core/src/crypto/mod.rs
Normal file
1
crates/rampart-core/src/crypto/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod hmac;
|
||||
98
crates/rampart-core/src/filter/blacklist.rs
Normal file
98
crates/rampart-core/src/filter/blacklist.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct BanEntry {
|
||||
expires: Instant,
|
||||
_reason: String,
|
||||
}
|
||||
|
||||
pub struct Blacklist {
|
||||
entries: Arc<DashMap<u32, BanEntry>>,
|
||||
}
|
||||
|
||||
impl Default for Blacklist {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Blacklist {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_blocked(&self, ip: u32) -> bool {
|
||||
if let Some(entry) = self.entries.get(&ip) {
|
||||
if entry.expires > Instant::now() {
|
||||
return true;
|
||||
}
|
||||
drop(entry);
|
||||
self.entries.remove(&ip);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn add(&self, ip: u32, duration: Duration, reason: &str) {
|
||||
self.entries.insert(
|
||||
ip,
|
||||
BanEntry {
|
||||
expires: Instant::now() + duration,
|
||||
_reason: reason.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn remove(&self, ip: u32) {
|
||||
self.entries.remove(&ip);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear_expired(&self) {
|
||||
self.entries.retain(|_, entry| entry.expires > Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_block() {
|
||||
let bl = Blacklist::new();
|
||||
bl.add(0x01020304, Duration::from_secs(60), "test");
|
||||
assert!(bl.is_blocked(0x01020304));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_not_blocked() {
|
||||
let bl = Blacklist::new();
|
||||
bl.add(0x01020304, Duration::from_secs(60), "test");
|
||||
assert!(!bl.is_blocked(0x05060708));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_expired() {
|
||||
let bl = Blacklist::new();
|
||||
bl.add(0x01020304, Duration::from_millis(1), "test");
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
assert!(!bl.is_blocked(0x01020304));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_remove() {
|
||||
let bl = Blacklist::new();
|
||||
bl.add(0x01020304, Duration::from_secs(60), "test");
|
||||
bl.remove(0x01020304);
|
||||
assert!(!bl.is_blocked(0x01020304));
|
||||
}
|
||||
}
|
||||
207
crates/rampart-core/src/filter/death_code.rs
Normal file
207
crates/rampart-core/src/filter/death_code.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
use crate::proxy::handshake::{read_string, read_varint};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeathCode {
|
||||
EmptyPacket,
|
||||
PacketTooShort,
|
||||
InvalidPacketId,
|
||||
NegativeProtocolVersion,
|
||||
NonCanonicalVarint,
|
||||
NullByteInHostname,
|
||||
UnprintableHostname,
|
||||
MalformedPacket,
|
||||
}
|
||||
|
||||
impl DeathCode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::EmptyPacket => "empty_packet",
|
||||
Self::PacketTooShort => "packet_too_short",
|
||||
Self::InvalidPacketId => "invalid_packet_id",
|
||||
Self::NegativeProtocolVersion => "negative_protocol_version",
|
||||
Self::NonCanonicalVarint => "non_canonical_varint",
|
||||
Self::NullByteInHostname => "null_byte_in_hostname",
|
||||
Self::UnprintableHostname => "unprintable_hostname",
|
||||
Self::MalformedPacket => "malformed_packet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect(buf: &[u8]) -> Option<DeathCode> {
|
||||
if buf.is_empty() {
|
||||
return Some(DeathCode::EmptyPacket);
|
||||
}
|
||||
|
||||
if buf.len() < 3 {
|
||||
return Some(DeathCode::PacketTooShort);
|
||||
}
|
||||
|
||||
let (packet_len, after_len) = match read_varint(buf, 0) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Some(DeathCode::MalformedPacket),
|
||||
};
|
||||
|
||||
if !is_canonical_varint(buf, 0) {
|
||||
return Some(DeathCode::NonCanonicalVarint);
|
||||
}
|
||||
|
||||
if packet_len <= 0 || (after_len + packet_len as usize) > buf.len() {
|
||||
return Some(DeathCode::MalformedPacket);
|
||||
}
|
||||
|
||||
let (packet_id, after_id) = match read_varint(buf, after_len) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Some(DeathCode::MalformedPacket),
|
||||
};
|
||||
|
||||
if !is_canonical_varint(buf, after_len) {
|
||||
return Some(DeathCode::NonCanonicalVarint);
|
||||
}
|
||||
|
||||
if packet_id != 0x00 {
|
||||
return Some(DeathCode::InvalidPacketId);
|
||||
}
|
||||
|
||||
let (_protocol_version, after_pv) = match read_varint(buf, after_id) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Some(DeathCode::MalformedPacket),
|
||||
};
|
||||
|
||||
if !is_canonical_varint(buf, after_id) {
|
||||
return Some(DeathCode::NonCanonicalVarint);
|
||||
}
|
||||
|
||||
let (server_address, _) = match read_string(buf, after_pv) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Some(DeathCode::MalformedPacket),
|
||||
};
|
||||
|
||||
if !is_canonical_varint(buf, after_pv) {
|
||||
return Some(DeathCode::NonCanonicalVarint);
|
||||
}
|
||||
|
||||
if server_address.contains('\0') {
|
||||
return Some(DeathCode::NullByteInHostname);
|
||||
}
|
||||
|
||||
if !server_address.chars().all(|c| c.is_ascii_graphic() || c == '.') {
|
||||
return Some(DeathCode::UnprintableHostname);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_canonical_varint(buf: &[u8], start: usize) -> bool {
|
||||
let mut value: u32 = 0;
|
||||
let mut shift = 0;
|
||||
let mut bytes_used = 0;
|
||||
|
||||
for (i, &byte) in buf[start..].iter().enumerate() {
|
||||
if i >= 5 {
|
||||
return false;
|
||||
}
|
||||
bytes_used = i + 1;
|
||||
value |= ((byte & 0x7F) as u32) << shift;
|
||||
shift += 7;
|
||||
if (byte & 0x80) == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if bytes_used >= 5 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let min_varint = |val: u32| -> usize {
|
||||
if val == 0 {
|
||||
return 1;
|
||||
}
|
||||
let mut bits = 32 - val.leading_zeros();
|
||||
let mut bytes = 0;
|
||||
while bits > 0 {
|
||||
bytes += 1;
|
||||
bits = bits.saturating_sub(7);
|
||||
}
|
||||
bytes.max(1)
|
||||
};
|
||||
|
||||
bytes_used == min_varint(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
|
||||
loop {
|
||||
if (value & !0x7F) == 0 {
|
||||
buf.push(value as u8);
|
||||
return;
|
||||
}
|
||||
buf.push((value as u8 & 0x7F) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
}
|
||||
|
||||
fn build_handshake_raw(hostname: &str) -> Vec<u8> {
|
||||
let addr = hostname.as_bytes();
|
||||
let mut buf = Vec::new();
|
||||
buf.push(0x00);
|
||||
write_varint(&mut buf, 765);
|
||||
write_varint(&mut buf, addr.len() as i32);
|
||||
buf.extend_from_slice(addr);
|
||||
buf.extend_from_slice(&[0x63, 0xDD]);
|
||||
buf.push(0x02);
|
||||
|
||||
let len = buf.len() as i32;
|
||||
let mut pkt = Vec::new();
|
||||
write_varint(&mut pkt, len);
|
||||
pkt.extend_from_slice(&buf);
|
||||
pkt
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_handshake() {
|
||||
let pkt = build_handshake_raw("play.example.com");
|
||||
assert_eq!(detect(&pkt), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_packet() {
|
||||
assert_eq!(detect(&[]), Some(DeathCode::EmptyPacket));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_null_byte_in_hostname() {
|
||||
let pkt = build_handshake_raw("play.example.com\0extra");
|
||||
assert_eq!(detect(&pkt), Some(DeathCode::NullByteInHostname));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_canonical_varint() {
|
||||
let pkt: Vec<u8> = vec![0x08, 0x00, 0x80, 0x00, 0x02, b'e', b'x', 0x63, 0xDD, 0x02];
|
||||
assert_eq!(detect(&pkt), Some(DeathCode::NonCanonicalVarint));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_packet_id() {
|
||||
let addr = b"play.example.com";
|
||||
let mut buf = Vec::new();
|
||||
write_varint(&mut buf, 1);
|
||||
buf.extend_from_slice(addr);
|
||||
buf.extend_from_slice(&[0x63, 0xDD]);
|
||||
buf.push(0x02);
|
||||
|
||||
let len = buf.len() as i32;
|
||||
let mut pkt = Vec::new();
|
||||
write_varint(&mut pkt, len);
|
||||
pkt.extend_from_slice(&buf);
|
||||
assert_eq!(detect(&pkt), Some(DeathCode::InvalidPacketId));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unprintable_hostname() {
|
||||
let pkt = build_handshake_raw("play\x01example.com");
|
||||
assert_eq!(detect(&pkt), Some(DeathCode::UnprintableHostname));
|
||||
}
|
||||
}
|
||||
34
crates/rampart-core/src/filter/geo.rs
Normal file
34
crates/rampart-core/src/filter/geo.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#[cfg(feature = "geoip")]
|
||||
pub struct GeoIp {
|
||||
reader: maxminddb::Reader<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "geoip")]
|
||||
impl GeoIp {
|
||||
pub fn new(db_path: &str) -> anyhow::Result<Self> {
|
||||
let reader = maxminddb::Reader::open_readfile(db_path)?;
|
||||
Ok(Self { reader })
|
||||
}
|
||||
}
|
||||
|
||||
pub enum IpCategory {
|
||||
Residential,
|
||||
Datacenter,
|
||||
Mobile,
|
||||
Vpn,
|
||||
Tor,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IpCategory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Residential => write!(f, "residential"),
|
||||
Self::Datacenter => write!(f, "datacenter"),
|
||||
Self::Mobile => write!(f, "mobile"),
|
||||
Self::Vpn => write!(f, "vpn"),
|
||||
Self::Tor => write!(f, "tor"),
|
||||
Self::Unknown => write!(f, "unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
4
crates/rampart-core/src/filter/mod.rs
Normal file
4
crates/rampart-core/src/filter/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod blacklist;
|
||||
pub mod death_code;
|
||||
pub mod geo;
|
||||
pub mod rate_limit;
|
||||
90
crates/rampart-core/src/filter/rate_limit.rs
Normal file
90
crates/rampart-core/src/filter/rate_limit.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct Bucket {
|
||||
tokens: f64,
|
||||
last_refill: Instant,
|
||||
}
|
||||
|
||||
pub struct RateLimiter {
|
||||
buckets: Arc<DashMap<u32, Bucket>>,
|
||||
max_tokens: f64,
|
||||
refill_rate: f64,
|
||||
_refill_interval: Duration,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(rate_per_sec: f64, burst: f64) -> Self {
|
||||
Self {
|
||||
buckets: Arc::new(DashMap::new()),
|
||||
max_tokens: burst,
|
||||
refill_rate: rate_per_sec,
|
||||
_refill_interval: Duration::from_secs(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check(&self, ip: u32) -> bool {
|
||||
let mut entry = self.buckets.entry(ip).or_insert_with(|| Bucket {
|
||||
tokens: self.max_tokens,
|
||||
last_refill: Instant::now(),
|
||||
});
|
||||
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(entry.last_refill);
|
||||
let refill = elapsed.as_secs_f64() * self.refill_rate;
|
||||
entry.tokens = (entry.tokens + refill).min(self.max_tokens);
|
||||
entry.last_refill = now;
|
||||
|
||||
if entry.tokens >= 1.0 {
|
||||
entry.tokens -= 1.0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.buckets.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buckets.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_under() {
|
||||
let limiter = RateLimiter::new(10.0, 10.0);
|
||||
assert!(limiter.check(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_over() {
|
||||
let limiter = RateLimiter::new(1.0, 1.0);
|
||||
assert!(limiter.check(1));
|
||||
assert!(!limiter.check(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_burst() {
|
||||
let limiter = RateLimiter::new(1.0, 5.0);
|
||||
for _ in 0..5 {
|
||||
assert!(limiter.check(2));
|
||||
}
|
||||
assert!(!limiter.check(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_refill() {
|
||||
let limiter = RateLimiter::new(100.0, 1.0);
|
||||
assert!(limiter.check(3));
|
||||
assert!(!limiter.check(3));
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
assert!(limiter.check(3));
|
||||
}
|
||||
}
|
||||
9
crates/rampart-core/src/lib.rs
Normal file
9
crates/rampart-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod filter;
|
||||
pub mod metrics;
|
||||
pub mod proxy;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(feature = "xdp")]
|
||||
pub mod xdp;
|
||||
82
crates/rampart-core/src/main.rs
Normal file
82
crates/rampart-core/src/main.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
use rampart_core::config::Config;
|
||||
use rampart_core::filter::blacklist::Blacklist;
|
||||
use rampart_core::filter::rate_limit::RateLimiter;
|
||||
use rampart_core::metrics;
|
||||
use rampart_core::proxy::listener::ProxyListener;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env().add_directive("rampart_core=info".parse()?))
|
||||
.init();
|
||||
|
||||
let config_path = std::env::var("RAMPART_CONFIG").unwrap_or_else(|_| "/etc/rampart/config.toml".to_string());
|
||||
let config = Config::from_file(&config_path)?;
|
||||
let config = Arc::new(config);
|
||||
|
||||
let rate_limiter = Arc::new(RateLimiter::new(
|
||||
config.limits.rate_limit_login_pps,
|
||||
config.limits.rate_limit_burst,
|
||||
));
|
||||
let blacklist = Arc::new(Blacklist::new());
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
let sig_tx = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
wait_for_signal().await;
|
||||
tracing::info!("shutdown signal received, draining connections...");
|
||||
let _ = sig_tx.send(true);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
tracing::info!("shutdown timeout reached, exiting");
|
||||
std::process::exit(0);
|
||||
});
|
||||
|
||||
#[cfg(feature = "store-redis")]
|
||||
if let Some(redis_url) = &config.store.redis_url
|
||||
&& !redis_url.is_empty()
|
||||
{
|
||||
let bl = blacklist.clone();
|
||||
let sd = shutdown_rx.clone();
|
||||
let url = redis_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let client = match redis::Client::open(url.as_str()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Invalid redis_url: {e}, blacklist sync disabled");
|
||||
return;
|
||||
},
|
||||
};
|
||||
rampart_core::store::start_blacklist_sync(&client, bl, sd).await;
|
||||
});
|
||||
}
|
||||
|
||||
if config.metrics.enabled {
|
||||
let metrics_addr = format!("0.0.0.0:{}", config.metrics.port);
|
||||
tracing::info!("Metrics server listening on {metrics_addr}");
|
||||
tokio::spawn(async move {
|
||||
metrics::run_metrics_server(&metrics_addr).await;
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!("Rampart edge starting on {}:{}", config.bind.address, config.bind.port);
|
||||
tracing::info!("Backend: {}:{}", config.backend.address, config.backend.port);
|
||||
|
||||
let listener = ProxyListener::new(config, rate_limiter, blacklist);
|
||||
listener.run(shutdown_rx).await
|
||||
}
|
||||
|
||||
async fn wait_for_signal() {
|
||||
let ctrl_c = tokio::signal::ctrl_c();
|
||||
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler");
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {}
|
||||
_ = term.recv() => {}
|
||||
}
|
||||
}
|
||||
63
crates/rampart-core/src/metrics.rs
Normal file
63
crates/rampart-core/src/metrics.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use prometheus::{Encoder, IntCounterVec, IntGauge, register_int_counter_vec, register_int_gauge};
|
||||
use std::sync::LazyLock;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
pub static CONNECTIONS_TOTAL: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!("rampart_connections_total", "Total connections handled", &["result"])
|
||||
.expect("CONNECTIONS_TOTAL")
|
||||
});
|
||||
|
||||
pub static RATE_LIMIT_HITS: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!("rampart_rate_limit_hits", "Rate limit hits", &["action"]).expect("RATE_LIMIT_HITS")
|
||||
});
|
||||
|
||||
pub static ACTIVE_CONNECTIONS: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!("rampart_active_connections", "Active connections").expect("ACTIVE_CONNECTIONS")
|
||||
});
|
||||
|
||||
pub static BLACKLIST_SIZE: LazyLock<IntGauge> =
|
||||
LazyLock::new(|| register_int_gauge!("rampart_blacklist_size", "Blacklist entries").expect("BLACKLIST_SIZE"));
|
||||
|
||||
pub static DEATH_CODE_BANS_TOTAL: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!("rampart_death_code_bans_total", "Death code auto-bans", &["code"])
|
||||
.expect("DEATH_CODE_BANS_TOTAL")
|
||||
});
|
||||
|
||||
pub async fn run_metrics_server(addr: &str) {
|
||||
let listener = match TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to bind metrics server: {e}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
loop {
|
||||
let (mut stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Metrics accept error: {e}");
|
||||
continue;
|
||||
},
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 1024];
|
||||
if stream.read(&mut buf).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let metric_families = prometheus::gather();
|
||||
let encoder = prometheus::TextEncoder::new();
|
||||
let mut payload = Vec::new();
|
||||
if encoder.encode(&metric_families, &mut payload).is_err() {
|
||||
return;
|
||||
}
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
payload.len()
|
||||
);
|
||||
let mut response = header.into_bytes();
|
||||
response.extend_from_slice(&payload);
|
||||
let _ = stream.write_all(&response).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
215
crates/rampart-core/src/proxy/handshake.rs
Normal file
215
crates/rampart-core/src/proxy/handshake.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ParseError {
|
||||
#[error("Incomplete packet: {0}")]
|
||||
Incomplete(&'static str),
|
||||
#[error("VarInt too big (>5 bytes)")]
|
||||
VarIntTooBig,
|
||||
#[error("VarInt overflow")]
|
||||
VarIntOverflow,
|
||||
#[error("Invalid UTF-8 in string")]
|
||||
InvalidUtf8,
|
||||
#[error("String too long: {0}")]
|
||||
StringTooLong(usize),
|
||||
#[error("Hostname too long")]
|
||||
HostnameTooLong,
|
||||
#[error("Not a handshake packet: id={0}")]
|
||||
NotHandshake(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum NextState {
|
||||
Status,
|
||||
Login,
|
||||
Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McHandshake {
|
||||
pub protocol_version: i32,
|
||||
pub server_address: String,
|
||||
pub server_port: u16,
|
||||
pub next_state: NextState,
|
||||
}
|
||||
|
||||
impl McHandshake {
|
||||
pub fn parse(buf: &[u8]) -> Result<Self, ParseError> {
|
||||
let mut pos;
|
||||
let (_, after_len) = read_varint(buf, 0)?;
|
||||
pos = after_len;
|
||||
|
||||
let (packet_id, after_id) = read_varint(buf, pos)?;
|
||||
pos = after_id;
|
||||
if packet_id != 0x00 {
|
||||
return Err(ParseError::NotHandshake(packet_id));
|
||||
}
|
||||
|
||||
let (protocol_version, after_pv) = read_varint(buf, pos)?;
|
||||
pos = after_pv;
|
||||
|
||||
let (server_address, after_addr) = read_string(buf, pos)?;
|
||||
pos = after_addr;
|
||||
|
||||
if server_address.len() > 255 {
|
||||
return Err(ParseError::HostnameTooLong);
|
||||
}
|
||||
|
||||
if pos + 2 > buf.len() {
|
||||
return Err(ParseError::Incomplete("missing port"));
|
||||
}
|
||||
let server_port = u16::from_be_bytes([buf[pos], buf[pos + 1]]);
|
||||
pos += 2;
|
||||
|
||||
let (next_state_raw, _) = read_varint(buf, pos)?;
|
||||
let next_state = match next_state_raw {
|
||||
1 => NextState::Status,
|
||||
2 => NextState::Login,
|
||||
n => NextState::Unknown(n),
|
||||
};
|
||||
|
||||
Ok(McHandshake {
|
||||
protocol_version,
|
||||
server_address,
|
||||
server_port,
|
||||
next_state,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_login(&self) -> bool {
|
||||
self.next_state == NextState::Login
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_varint(buf: &[u8], start: usize) -> Result<(i32, usize), ParseError> {
|
||||
let mut value: i32 = 0;
|
||||
let mut shift = 0;
|
||||
|
||||
for (i, &byte) in buf[start..].iter().enumerate() {
|
||||
if i >= 5 {
|
||||
return Err(ParseError::VarIntTooBig);
|
||||
}
|
||||
let segment = (byte & 0x7F) as i32;
|
||||
if shift >= 32 || (shift == 28 && segment > 0x0F) {
|
||||
return Err(ParseError::VarIntOverflow);
|
||||
}
|
||||
value |= segment << shift;
|
||||
shift += 7;
|
||||
if (byte & 0x80) == 0 {
|
||||
return Ok((value, start + i + 1));
|
||||
}
|
||||
}
|
||||
Err(ParseError::Incomplete("varint"))
|
||||
}
|
||||
|
||||
pub fn read_string(buf: &[u8], start: usize) -> Result<(String, usize), ParseError> {
|
||||
let (len, after_len) = read_varint(buf, start)?;
|
||||
if !(0..=32767).contains(&len) {
|
||||
return Err(ParseError::StringTooLong(len as usize));
|
||||
}
|
||||
let end = after_len + len as usize;
|
||||
if end > buf.len() {
|
||||
return Err(ParseError::Incomplete("string data"));
|
||||
}
|
||||
let s = std::str::from_utf8(&buf[after_len..end])
|
||||
.map_err(|_| ParseError::InvalidUtf8)?
|
||||
.to_string();
|
||||
Ok((s, end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_varint_zero() {
|
||||
let buf = vec![0x00];
|
||||
assert_eq!(read_varint(&buf, 0).unwrap(), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_varint_single() {
|
||||
let buf = vec![0x7F];
|
||||
assert_eq!(read_varint(&buf, 0).unwrap(), (127, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_varint_multi() {
|
||||
let buf = vec![0x80, 0x01];
|
||||
assert_eq!(read_varint(&buf, 0).unwrap(), (128, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_varint_max() {
|
||||
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x07];
|
||||
assert_eq!(read_varint(&buf, 0).unwrap(), (i32::MAX, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_varint_overflow() {
|
||||
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x10];
|
||||
assert!(matches!(read_varint(&buf, 0), Err(ParseError::VarIntOverflow)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_varint_incomplete() {
|
||||
let buf = vec![0x80];
|
||||
assert!(matches!(read_varint(&buf, 0), Err(ParseError::Incomplete(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handshake_login() {
|
||||
let addr = b"play.example.com";
|
||||
let mut buf = Vec::new();
|
||||
|
||||
buf.extend_from_slice(&[0x00]);
|
||||
buf.push(0x00);
|
||||
write_varint(&mut buf, 765);
|
||||
write_varint(&mut buf, addr.len() as i32);
|
||||
buf.extend_from_slice(addr);
|
||||
buf.extend_from_slice(&[0x63, 0xDD]);
|
||||
buf.push(0x02);
|
||||
|
||||
let len = (buf.len() - 1) as u8;
|
||||
buf[0] = len;
|
||||
|
||||
let hs = McHandshake::parse(&buf).unwrap();
|
||||
assert_eq!(hs.protocol_version, 765);
|
||||
assert_eq!(hs.server_address, "play.example.com");
|
||||
assert_eq!(hs.server_port, 25565);
|
||||
assert!(hs.is_login());
|
||||
}
|
||||
|
||||
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
|
||||
loop {
|
||||
if (value & !0x7F) == 0 {
|
||||
buf.push(value as u8);
|
||||
return;
|
||||
}
|
||||
buf.push((value as u8 & 0x7F) | 0x80);
|
||||
value = (value >> 7) & (i32::MAX >> 6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handshake_status() {
|
||||
let addr = b"play.example";
|
||||
let mut buf = Vec::new();
|
||||
// packet length will be set below
|
||||
buf.push(0x00);
|
||||
buf.push(0x00); // packet ID
|
||||
buf.push(0x02); // protocol version 2
|
||||
write_varint(&mut buf, addr.len() as i32);
|
||||
buf.extend_from_slice(addr);
|
||||
buf.extend_from_slice(&[0x63, 0xDD]); // port 25565
|
||||
buf.push(0x01); // next state = status
|
||||
let len = (buf.len() - 1) as u8;
|
||||
buf[0] = len;
|
||||
|
||||
let hs = McHandshake::parse(&buf).unwrap();
|
||||
assert_eq!(hs.protocol_version, 2);
|
||||
assert_eq!(hs.server_address, "play.example");
|
||||
assert_eq!(hs.server_port, 25565);
|
||||
assert_eq!(hs.next_state, NextState::Status);
|
||||
}
|
||||
}
|
||||
95
crates/rampart-core/src/proxy/listener.rs
Normal file
95
crates/rampart-core/src/proxy/listener.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use crate::config::Config;
|
||||
use crate::filter::blacklist::Blacklist;
|
||||
use crate::filter::rate_limit::RateLimiter;
|
||||
use crate::proxy::tunnel::ConnectionHandler;
|
||||
use socket2::{Domain, Socket, Type};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub struct ProxyListener {
|
||||
config: Arc<Config>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
blacklist: Arc<Blacklist>,
|
||||
}
|
||||
|
||||
impl ProxyListener {
|
||||
pub fn new(config: Arc<Config>, rate_limiter: Arc<RateLimiter>, blacklist: Arc<Blacklist>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
rate_limiter,
|
||||
blacklist,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(&self, shutdown: watch::Receiver<bool>) -> anyhow::Result<()> {
|
||||
let addr = format!("{}:{}", self.config.bind.address, self.config.bind.port).parse::<std::net::SocketAddr>()?;
|
||||
let workers = self.config.workers.count.max(1);
|
||||
|
||||
let mut handles = Vec::with_capacity(workers);
|
||||
for _ in 0..workers {
|
||||
let listener = build_listener(addr)?;
|
||||
let config = self.config.clone();
|
||||
let rate_limiter = self.rate_limiter.clone();
|
||||
let blacklist = self.blacklist.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
handles.push(tokio::spawn(accept_loop(
|
||||
listener,
|
||||
config,
|
||||
rate_limiter,
|
||||
blacklist,
|
||||
shutdown,
|
||||
)));
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
h.await??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_listener(addr: std::net::SocketAddr) -> anyhow::Result<TcpListener> {
|
||||
let socket = Socket::new(Domain::IPV4, Type::STREAM, None)?;
|
||||
socket.set_reuse_port(true)?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(65535)?;
|
||||
Ok(TcpListener::from_std(socket.into())?)
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
config: Arc<Config>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
blacklist: Arc<Blacklist>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
tracing::info!("shutdown signal received, stopping accept loop");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
result = listener.accept() => {
|
||||
let (stream, peer_addr) = match result {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
tracing::error!("accept error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let handler = ConnectionHandler::new(config.clone(), rate_limiter.clone(), blacklist.clone());
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handler.handle(stream, peer_addr).await {
|
||||
tracing::debug!("connection from {peer_addr}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
crates/rampart-core/src/proxy/mod.rs
Normal file
3
crates/rampart-core/src/proxy/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod handshake;
|
||||
pub mod listener;
|
||||
pub mod tunnel;
|
||||
204
crates/rampart-core/src/proxy/tunnel.rs
Normal file
204
crates/rampart-core/src/proxy/tunnel.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
use crate::config::Config;
|
||||
use crate::crypto::hmac;
|
||||
use crate::filter::blacklist::Blacklist;
|
||||
use crate::filter::death_code;
|
||||
use crate::filter::rate_limit::RateLimiter;
|
||||
use crate::metrics;
|
||||
use crate::proxy::handshake::{McHandshake, read_varint};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
pub struct ConnectionHandler {
|
||||
config: Arc<Config>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
blacklist: Arc<Blacklist>,
|
||||
}
|
||||
|
||||
impl ConnectionHandler {
|
||||
pub fn new(config: Arc<Config>, rate_limiter: Arc<RateLimiter>, blacklist: Arc<Blacklist>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
rate_limiter,
|
||||
blacklist,
|
||||
}
|
||||
}
|
||||
|
||||
fn ip_to_u32(addr: std::net::SocketAddr) -> u32 {
|
||||
match addr.ip() {
|
||||
std::net::IpAddr::V4(ip) => ip.to_bits(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle(&self, mut client: TcpStream, peer_addr: std::net::SocketAddr) -> anyhow::Result<()> {
|
||||
let ip_u32 = Self::ip_to_u32(peer_addr);
|
||||
|
||||
if self.blacklist.is_blocked(ip_u32) {
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.rate_limiter.check(ip_u32) {
|
||||
metrics::RATE_LIMIT_HITS.with_label_values(&["hit"]).inc();
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(self.config.limits.handshake_timeout_secs);
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let n = tokio::time::timeout(timeout, client.read(&mut buf)).await??;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let parsed = McHandshake::parse(&buf[..n]);
|
||||
match parsed {
|
||||
Ok(handshake) => {
|
||||
if !self.rate_limiter.check(ip_u32) {
|
||||
metrics::RATE_LIMIT_HITS.with_label_values(&["hit"]).inc();
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["allowed"]).inc();
|
||||
|
||||
let backend_addr = format!("{}:{}", self.config.backend.address, self.config.backend.port);
|
||||
let mut backend = TcpStream::connect(&backend_addr).await?;
|
||||
|
||||
let signed = hmac::sign_hostname(&handshake.server_address, self.config.hmac.secret.as_bytes());
|
||||
let modified = replace_hostname(&buf[..n], &handshake.server_address, &signed)?;
|
||||
backend.write_all(&modified).await?;
|
||||
|
||||
tokio::io::copy_bidirectional(&mut client, &mut backend).await?;
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::debug!("parse error from {peer_addr}: {e}");
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
|
||||
|
||||
if self.config.death_code.enabled {
|
||||
if let Some(code) = death_code::detect(&buf[..n]) {
|
||||
let duration = Duration::from_secs(self.config.death_code.ban_duration_secs);
|
||||
self.blacklist.add(ip_u32, duration, code.as_str());
|
||||
metrics::DEATH_CODE_BANS_TOTAL.with_label_values(&[code.as_str()]).inc();
|
||||
tracing::info!("death code ban {peer_addr}: {}", code.as_str());
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_hostname(original: &[u8], _old_hostname: &str, new_hostname: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let (packet_len, after_packet_len) =
|
||||
read_varint(original, 0).map_err(|_| anyhow::anyhow!("corrupt packet length"))?;
|
||||
let mut pos = after_packet_len;
|
||||
|
||||
let (_packet_id, after_id) = read_varint(original, pos).map_err(|_| anyhow::anyhow!("corrupt packet id"))?;
|
||||
pos = after_id;
|
||||
|
||||
let (_protocol_version, after_pv) =
|
||||
read_varint(original, pos).map_err(|_| anyhow::anyhow!("corrupt protocol version"))?;
|
||||
pos = after_pv;
|
||||
|
||||
let (old_host_len, host_field_start) =
|
||||
read_varint(original, pos).map_err(|_| anyhow::anyhow!("corrupt hostname length"))?;
|
||||
let host_data_end = host_field_start + old_host_len as usize;
|
||||
let old_field_size = host_data_end - pos;
|
||||
|
||||
let new_hostname_bytes = new_hostname.as_bytes();
|
||||
let new_len_field_bytes = varint_bytes(new_hostname_bytes.len() as i32);
|
||||
let new_field_size = new_len_field_bytes.len() + new_hostname_bytes.len();
|
||||
let size_diff = new_field_size as isize - old_field_size as isize;
|
||||
let new_packet_len = (packet_len as isize + size_diff) as i32;
|
||||
|
||||
let cap = original.len().wrapping_add(size_diff as usize);
|
||||
let mut result = Vec::with_capacity(cap);
|
||||
|
||||
result.extend_from_slice(&varint_bytes(new_packet_len));
|
||||
result.extend_from_slice(&original[after_packet_len..pos]);
|
||||
result.extend_from_slice(&new_len_field_bytes);
|
||||
result.extend_from_slice(new_hostname_bytes);
|
||||
result.extend_from_slice(&original[host_data_end..]);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn varint_bytes(mut value: i32) -> Vec<u8> {
|
||||
let mut result = Vec::with_capacity(5);
|
||||
loop {
|
||||
if (value & !0x7F) == 0 {
|
||||
result.push(value as u8);
|
||||
return result;
|
||||
}
|
||||
result.push((value as u8 & 0x7F) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_test_packet(hostname: &str) -> Vec<u8> {
|
||||
let addr = hostname.as_bytes();
|
||||
let mut buf = Vec::new();
|
||||
buf.push(0x00);
|
||||
buf.extend_from_slice(&varint_bytes(765));
|
||||
buf.extend_from_slice(&varint_bytes(addr.len() as i32));
|
||||
buf.extend_from_slice(addr);
|
||||
buf.extend_from_slice(&[0x63, 0xDD]);
|
||||
buf.push(0x02);
|
||||
|
||||
let len = buf.len() as i32;
|
||||
let mut pkt = varint_bytes(len);
|
||||
pkt.extend_from_slice(&buf);
|
||||
pkt
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_hostname_basic() {
|
||||
let pkt = build_test_packet("play.example.com");
|
||||
let new_hostname = "play.example.com\0shield\0abcdef1234567890";
|
||||
let modified = replace_hostname(&pkt, "play.example.com", new_hostname).unwrap();
|
||||
assert!(modified.len() > pkt.len());
|
||||
|
||||
let parsed = McHandshake::parse(&modified).unwrap();
|
||||
assert_eq!(parsed.server_address, new_hostname);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_hostname_shorter() {
|
||||
let pkt = build_test_packet("very.long.hostname.example.com");
|
||||
let new_hostname = "short.com";
|
||||
let modified = replace_hostname(&pkt, "very.long.hostname.example.com", new_hostname).unwrap();
|
||||
assert!(modified.len() < pkt.len());
|
||||
|
||||
let parsed = McHandshake::parse(&modified).unwrap();
|
||||
assert_eq!(parsed.server_address, new_hostname);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_hostname_preserves_port_and_protocol() {
|
||||
let pkt = build_test_packet("mc.example.com");
|
||||
let new_hostname = "mc.example.com\0shield\x00deadbeef";
|
||||
let modified = replace_hostname(&pkt, "mc.example.com", new_hostname).unwrap();
|
||||
|
||||
let parsed = McHandshake::parse(&modified).unwrap();
|
||||
assert_eq!(parsed.server_port, 25565);
|
||||
assert_eq!(parsed.protocol_version, 765);
|
||||
assert!(parsed.is_login());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_varint_roundtrip() {
|
||||
let cases = vec![0, 1, 127, 128, 255, 65535, 1000000, i32::MAX];
|
||||
for val in cases {
|
||||
let bytes = varint_bytes(val);
|
||||
let (decoded, _) = read_varint(&bytes, 0).unwrap();
|
||||
assert_eq!(decoded, val, "roundtrip failed for {val}");
|
||||
}
|
||||
}
|
||||
}
|
||||
29
crates/rampart-core/src/store/mod.rs
Normal file
29
crates/rampart-core/src/store/mod.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#[cfg(feature = "store-redis")]
|
||||
pub mod redis;
|
||||
#[cfg(feature = "store-redis")]
|
||||
pub use redis::start_blacklist_sync;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait StateStore: Send + Sync {
|
||||
async fn get(&self, key: &str) -> anyhow::Result<Option<String>>;
|
||||
async fn set(&self, key: &str, value: &str) -> anyhow::Result<()>;
|
||||
async fn del(&self, key: &str) -> anyhow::Result<()>;
|
||||
async fn publish(&self, channel: &str, message: &str) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
pub struct NoopStore;
|
||||
|
||||
impl StateStore for NoopStore {
|
||||
async fn get(&self, _key: &str) -> anyhow::Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn set(&self, _key: &str, _value: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn del(&self, _key: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn publish(&self, _channel: &str, _message: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
136
crates/rampart-core/src/store/redis.rs
Normal file
136
crates/rampart-core/src/store/redis.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
use crate::filter::blacklist::Blacklist;
|
||||
use crate::store::StateStore;
|
||||
use futures::StreamExt;
|
||||
use redis::AsyncCommands;
|
||||
use redis::Msg;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub struct RedisStore {
|
||||
client: redis::Client,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BlacklistEvent {
|
||||
ip: String,
|
||||
action: String,
|
||||
#[serde(default = "default_duration")]
|
||||
duration_secs: u64,
|
||||
}
|
||||
|
||||
fn default_duration() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
impl RedisStore {
|
||||
pub fn new(url: &str) -> anyhow::Result<Self> {
|
||||
let client = redis::Client::open(url)?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_blacklist_sync(
|
||||
client: &redis::Client,
|
||||
blacklist: Arc<Blacklist>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
#[allow(deprecated)]
|
||||
let conn = match client.get_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("failed to connect to Redis for blacklist sync: {e}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
let mut pubsub = conn.into_pubsub();
|
||||
if let Err(e) = pubsub.subscribe("rampart:blacklist:events").await {
|
||||
tracing::error!("failed to subscribe to blacklist events: {e}");
|
||||
return;
|
||||
}
|
||||
tracing::info!("subscribed to rampart:blacklist:events");
|
||||
|
||||
loop {
|
||||
let mut stream = pubsub.on_message();
|
||||
let msg_fut = stream.next();
|
||||
tokio::pin!(msg_fut);
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
tracing::info!("shutting down blacklist subscriber");
|
||||
return;
|
||||
}
|
||||
}
|
||||
result = &mut msg_fut => {
|
||||
match result {
|
||||
Some(msg) => {
|
||||
if let Err(e) = handle_event(&msg, &blacklist) {
|
||||
tracing::error!("blacklist event error: {e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("pubsub stream ended");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(msg: &Msg, blacklist: &Blacklist) -> anyhow::Result<()> {
|
||||
let payload: String = msg.get_payload()?;
|
||||
let event: BlacklistEvent = serde_json::from_str(&payload)?;
|
||||
|
||||
let ip_parts: Vec<&str> = event.ip.split('.').collect();
|
||||
if ip_parts.len() != 4 {
|
||||
anyhow::bail!("invalid IP: {}", event.ip);
|
||||
}
|
||||
let mut ip_u32: u32 = 0;
|
||||
for part in &ip_parts {
|
||||
let octet: u32 = part.parse()?;
|
||||
ip_u32 = (ip_u32 << 8) | octet;
|
||||
}
|
||||
|
||||
match event.action.as_str() {
|
||||
"ban" => {
|
||||
blacklist.add(ip_u32, Duration::from_secs(event.duration_secs), "redis");
|
||||
tracing::info!("blacklist add via Redis: {}", event.ip);
|
||||
},
|
||||
"unban" => {
|
||||
blacklist.remove(ip_u32);
|
||||
tracing::info!("blacklist remove via Redis: {}", event.ip);
|
||||
},
|
||||
a => anyhow::bail!("unknown action: {a}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl StateStore for RedisStore {
|
||||
async fn get(&self, key: &str) -> anyhow::Result<Option<String>> {
|
||||
let mut conn = self.client.get_multiplexed_async_connection().await?;
|
||||
Ok(conn.get(key).await?)
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &str) -> anyhow::Result<()> {
|
||||
let mut conn = self.client.get_multiplexed_async_connection().await?;
|
||||
let _: () = conn.set(key, value).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn del(&self, key: &str) -> anyhow::Result<()> {
|
||||
let mut conn = self.client.get_multiplexed_async_connection().await?;
|
||||
let _: () = conn.del(key).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish(&self, channel: &str, message: &str) -> anyhow::Result<()> {
|
||||
let mut conn = self.client.get_multiplexed_async_connection().await?;
|
||||
let _: () = conn.publish(channel, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
41
crates/rampart-core/src/xdp/mod.rs
Normal file
41
crates/rampart-core/src/xdp/mod.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#[cfg(feature = "xdp")]
|
||||
pub struct XdpFilter {
|
||||
interface: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "xdp")]
|
||||
impl XdpFilter {
|
||||
pub fn new(interface: &str) -> Self {
|
||||
Self {
|
||||
interface: interface.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self) -> anyhow::Result<()> {
|
||||
tracing::info!("XDP filter loaded on {}", self.interface);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unload(&self) -> anyhow::Result<()> {
|
||||
tracing::info!("XDP filter unloaded from {}", self.interface);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "xdp"))]
|
||||
pub struct XdpFilter;
|
||||
|
||||
#[cfg(not(feature = "xdp"))]
|
||||
impl XdpFilter {
|
||||
pub fn new(_interface: &str) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn load(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unload(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
24
crates/rampart-manager/Cargo.toml
Normal file
24
crates/rampart-manager/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "rampart-manager"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
dashmap.workspace = true
|
||||
prometheus.workspace = true
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
jsonwebtoken = "9"
|
||||
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
21
crates/rampart-manager/src/api/auth.rs
Normal file
21
crates/rampart-manager/src/api/auth.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use crate::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn login(State(state): State<Arc<AppState>>, Json(req): Json<LoginRequest>) -> Json<serde_json::Value> {
|
||||
let api_password = std::env::var("API_PASSWORD").unwrap_or_else(|_| "changeme".to_string());
|
||||
if req.password != api_password {
|
||||
return Json(serde_json::json!({"error": "invalid password"}));
|
||||
}
|
||||
|
||||
match crate::auth::create_token(&state.jwt_secret, state.jwt_expiration) {
|
||||
Ok(token) => Json(serde_json::json!({"token": token})),
|
||||
Err(_) => Json(serde_json::json!({"error": "token creation failed"})),
|
||||
}
|
||||
}
|
||||
94
crates/rampart-manager/src/api/blacklist.rs
Normal file
94
crates/rampart-manager/src/api/blacklist.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
use crate::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BlacklistEntry {
|
||||
pub target: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub reason: String,
|
||||
pub created_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BlacklistResponse {
|
||||
pub items: Vec<BlacklistEntry>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AddBlacklistRequest {
|
||||
pub target: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub reason: String,
|
||||
pub duration_secs: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn list_blacklist(State(state): State<Arc<AppState>>) -> Json<BlacklistResponse> {
|
||||
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Json(BlacklistResponse {
|
||||
items: vec![],
|
||||
total: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let members: Vec<String> = match redis::cmd("SMEMBERS")
|
||||
.arg("rampart:blacklist")
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
return Json(BlacklistResponse {
|
||||
items: vec![],
|
||||
total: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let items: Vec<BlacklistEntry> = members
|
||||
.into_iter()
|
||||
.map(|target| BlacklistEntry {
|
||||
target,
|
||||
entry_type: "ip".to_string(),
|
||||
reason: "manual".to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
expires_at: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = items.len();
|
||||
Json(BlacklistResponse { items, total })
|
||||
}
|
||||
|
||||
pub async fn add_blacklist(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AddBlacklistRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Json(serde_json::json!({"error": "redis unavailable"})),
|
||||
};
|
||||
|
||||
let _: () = redis::cmd("SADD")
|
||||
.arg("rampart:blacklist")
|
||||
.arg(&req.target)
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
tracing::info!("Added to blacklist: {} ({})", req.target, req.reason);
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "added",
|
||||
"target": req.target,
|
||||
"reason": req.reason
|
||||
}))
|
||||
}
|
||||
18
crates/rampart-manager/src/api/health.rs
Normal file
18
crates/rampart-manager/src/api/health.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
use crate::AppState;
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub async fn health_check(State(_state): State<Arc<AppState>>) -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "healthy".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
})
|
||||
}
|
||||
5
crates/rampart-manager/src/api/mod.rs
Normal file
5
crates/rampart-manager/src/api/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod auth;
|
||||
pub mod blacklist;
|
||||
pub mod health;
|
||||
pub mod nodes;
|
||||
pub mod servers;
|
||||
43
crates/rampart-manager/src/api/nodes.rs
Normal file
43
crates/rampart-manager/src/api/nodes.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use crate::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use redis::AsyncCommands;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NodeInfo {
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub ip: String,
|
||||
pub status: String,
|
||||
pub last_heartbeat: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodesResponse {
|
||||
pub nodes: Vec<NodeInfo>,
|
||||
}
|
||||
|
||||
pub async fn list_nodes(State(state): State<Arc<AppState>>) -> Json<NodesResponse> {
|
||||
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Json(NodesResponse { nodes: vec![] }),
|
||||
};
|
||||
|
||||
let keys: Vec<String> = match redis::cmd("KEYS").arg("rampart:nodes:*").query_async(&mut conn).await {
|
||||
Ok(k) => k,
|
||||
Err(_) => return Json(NodesResponse { nodes: vec![] }),
|
||||
};
|
||||
|
||||
let mut nodes = Vec::with_capacity(keys.len());
|
||||
for key in &keys {
|
||||
let raw: Option<String> = conn.get(key).await.unwrap_or(None);
|
||||
if let Some(json) = raw
|
||||
&& let Ok(node) = serde_json::from_str::<NodeInfo>(&json)
|
||||
{
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
Json(NodesResponse { nodes })
|
||||
}
|
||||
47
crates/rampart-manager/src/api/servers.rs
Normal file
47
crates/rampart-manager/src/api/servers.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use crate::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use redis::AsyncCommands;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServerEntry {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
pub ip: String,
|
||||
pub port: u16,
|
||||
pub status: String,
|
||||
pub online: u32,
|
||||
pub max_players: u32,
|
||||
pub tps: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ServersResponse {
|
||||
pub servers: Vec<ServerEntry>,
|
||||
}
|
||||
|
||||
pub async fn list_servers(State(state): State<Arc<AppState>>) -> Json<ServersResponse> {
|
||||
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Json(ServersResponse { servers: vec![] }),
|
||||
};
|
||||
|
||||
let keys: Vec<String> = match redis::cmd("KEYS").arg("rampart:servers:*").query_async(&mut conn).await {
|
||||
Ok(k) => k,
|
||||
Err(_) => return Json(ServersResponse { servers: vec![] }),
|
||||
};
|
||||
|
||||
let mut servers = Vec::with_capacity(keys.len());
|
||||
for key in &keys {
|
||||
let raw: Option<String> = conn.get(key).await.unwrap_or(None);
|
||||
if let Some(json) = raw
|
||||
&& let Ok(server) = serde_json::from_str::<ServerEntry>(&json)
|
||||
{
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
Json(ServersResponse { servers })
|
||||
}
|
||||
74
crates/rampart-manager/src/auth.rs
Normal file
74
crates/rampart-manager/src/auth.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
use axum::Json;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
}
|
||||
|
||||
pub fn create_token(secret: &str, expiration: u64) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
let now = chrono::Utc::now().timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: "rampart-admin".to_string(),
|
||||
exp: now + expiration as usize,
|
||||
iat: now,
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
pub async fn auth_middleware(
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "));
|
||||
|
||||
let token = match auth_header {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": "unauthorized"})),
|
||||
));
|
||||
},
|
||||
};
|
||||
|
||||
let state = match request.extensions().get::<Arc<crate::AppState>>() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "internal error"})),
|
||||
));
|
||||
},
|
||||
};
|
||||
|
||||
if verify_token(token, &state.jwt_secret).is_err() {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": "unauthorized"})),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
1
crates/rampart-manager/src/lib.rs
Normal file
1
crates/rampart-manager/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
66
crates/rampart-manager/src/main.rs
Normal file
66
crates/rampart-manager/src/main.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod api;
|
||||
mod auth;
|
||||
mod sync;
|
||||
|
||||
pub struct AppState {
|
||||
pub redis_client: redis::Client,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_expiration: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env().add_directive("rampart_manager=info".parse()?))
|
||||
.init();
|
||||
|
||||
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379/0".to_string());
|
||||
let redis_client = redis::Client::open(redis_url)?;
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").map_err(|_| anyhow::anyhow!("JWT_SECRET must be set"))?;
|
||||
let jwt_expiration = std::env::var("JWT_EXPIRATION_SECS")
|
||||
.unwrap_or_else(|_| "86400".to_string())
|
||||
.parse::<u64>()
|
||||
.map_err(|_| anyhow::anyhow!("JWT_EXPIRATION_SECS must be a valid u64"))?;
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
redis_client,
|
||||
jwt_secret,
|
||||
jwt_expiration,
|
||||
});
|
||||
|
||||
tokio::spawn(sync::heartbeat::start_heartbeat_check(state.clone()));
|
||||
|
||||
let public = Router::new()
|
||||
.route("/api/v1/health", get(api::health::health_check))
|
||||
.route("/api/v1/auth/login", post(api::auth::login));
|
||||
|
||||
let protected = Router::new()
|
||||
.route("/api/v1/servers", get(api::servers::list_servers))
|
||||
.route(
|
||||
"/api/v1/blacklist",
|
||||
get(api::blacklist::list_blacklist).post(api::blacklist::add_blacklist),
|
||||
)
|
||||
.route("/api/v1/nodes", get(api::nodes::list_nodes))
|
||||
.route_layer(middleware::from_fn(auth::auth_middleware));
|
||||
|
||||
let app = Router::new()
|
||||
.merge(public)
|
||||
.merge(protected)
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
let addr = "0.0.0.0:8080";
|
||||
tracing::info!("Manager API listening on {addr}");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
44
crates/rampart-manager/src/sync/heartbeat.rs
Normal file
44
crates/rampart-manager/src/sync/heartbeat.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use crate::AppState;
|
||||
use redis::AsyncCommands;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::{Duration, interval};
|
||||
|
||||
pub async fn start_heartbeat_check(state: Arc<AppState>) {
|
||||
let mut ticker = interval(Duration::from_secs(30));
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(e) = check_nodes(&state).await {
|
||||
tracing::warn!("heartbeat check failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_nodes(state: &AppState) -> anyhow::Result<()> {
|
||||
let mut conn = state.redis_client.get_multiplexed_async_connection().await?;
|
||||
let keys: Vec<String> = redis::cmd("KEYS").arg("rampart:nodes:*").query_async(&mut conn).await?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
for key in &keys {
|
||||
let raw: Option<String> = conn.get(key).await?;
|
||||
if let Some(json) = raw
|
||||
&& let Ok(mut node) = serde_json::from_str::<serde_json::Value>(&json)
|
||||
{
|
||||
let hb = node["last_heartbeat"]
|
||||
.as_str()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0);
|
||||
if now - hb > 60 {
|
||||
if let Some(obj) = node.as_object_mut() {
|
||||
obj.insert("status".to_string(), serde_json::Value::String("offline".to_string()));
|
||||
if let Ok(updated) = serde_json::to_string(&node) {
|
||||
let _: () = conn.set(key.as_str(), updated).await.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
tracing::warn!("Node {key} is offline (heartbeat expired)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
1
crates/rampart-manager/src/sync/mod.rs
Normal file
1
crates/rampart-manager/src/sync/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod heartbeat;
|
||||
12
dashboard/index.html
Normal file
12
dashboard/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rampart Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1862
dashboard/package-lock.json
generated
Normal file
1862
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
dashboard/package.json
Normal file
22
dashboard/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "rampart-dashboard",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
378
dashboard/src/App.css
Normal file
378
dashboard/src/App.css
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-card: #1f2b47;
|
||||
--bg-sidebar: #0f3460;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0b0;
|
||||
--accent: #e94560;
|
||||
--accent-hover: #ff6b81;
|
||||
--green: #2ecc71;
|
||||
--red: #e74c3c;
|
||||
--border: #2a3a5c;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Login */
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-card);
|
||||
padding: 2.5rem;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--accent);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.login-card input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.login-card button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.login-card button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.login-card button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: var(--red);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--bg-sidebar);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
color: var(--accent);
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar nav button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar nav button:hover {
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar nav button.active {
|
||||
background: rgba(233, 69, 96, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar .logout-btn {
|
||||
margin-top: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar .logout-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main-content h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
background: var(--bg-card);
|
||||
border-radius: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 6px rgba(46, 204, 113, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 6px rgba(231, 76, 60, 0.5);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.spinner::after {
|
||||
content: '';
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.error-msg {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: var(--red);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Success message */
|
||||
.success-msg {
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
color: var(--green);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Blacklist form */
|
||||
.blacklist-form {
|
||||
background: var(--bg-card);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.blacklist-form input,
|
||||
.blacklist-form select {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.blacklist-form input:focus,
|
||||
.blacklist-form select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.blacklist-form button {
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.blacklist-form button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.blacklist-form button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
.sidebar nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sidebar .logout-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
.blacklist-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
.blacklist-form input,
|
||||
.blacklist-form select,
|
||||
.blacklist-form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
17
dashboard/src/App.tsx
Normal file
17
dashboard/src/App.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useState } from 'react'
|
||||
import Login from './components/Login'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
function App() {
|
||||
const [token, setToken] = useState<string | null>(
|
||||
() => sessionStorage.getItem('rampart_token')
|
||||
)
|
||||
|
||||
if (!token) {
|
||||
return <Login onLogin={(t) => setToken(t)} />
|
||||
}
|
||||
|
||||
return <Layout />
|
||||
}
|
||||
|
||||
export default App
|
||||
122
dashboard/src/api.ts
Normal file
122
dashboard/src/api.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
const BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
function getToken(): string | null {
|
||||
return sessionStorage.getItem('rampart_token')
|
||||
}
|
||||
|
||||
function setToken(token: string): void {
|
||||
sessionStorage.setItem('rampart_token', token)
|
||||
}
|
||||
|
||||
function clearToken(): void {
|
||||
sessionStorage.removeItem('rampart_token')
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { ...options, headers })
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
window.location.reload()
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || res.statusText)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
name: string
|
||||
server_type: string
|
||||
ip: string
|
||||
port: number
|
||||
status: string
|
||||
online_players: number
|
||||
max_players: number
|
||||
tps: number
|
||||
last_heartbeat: string
|
||||
}
|
||||
|
||||
export interface BlacklistEntry {
|
||||
target: string
|
||||
type: string
|
||||
reason: string
|
||||
created: string
|
||||
expires: string
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string
|
||||
role: string
|
||||
ip: string
|
||||
status: string
|
||||
last_heartbeat: string
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<LoginResponse> {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || res.statusText)
|
||||
}
|
||||
const data: LoginResponse = await res.json()
|
||||
setToken(data.token)
|
||||
return data
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
clearToken()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
export async function fetchServers(): Promise<Server[]> {
|
||||
return apiFetch<Server[]>('/servers')
|
||||
}
|
||||
|
||||
export async function fetchBlacklist(): Promise<BlacklistEntry[]> {
|
||||
return apiFetch<BlacklistEntry[]>('/blacklist')
|
||||
}
|
||||
|
||||
export async function addBlacklist(
|
||||
target: string,
|
||||
type: string,
|
||||
reason: string,
|
||||
durationSecs: number
|
||||
): Promise<void> {
|
||||
await apiFetch('/blacklist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target, type, reason, duration_secs: durationSecs }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchNodes(): Promise<Node[]> {
|
||||
return apiFetch<Node[]>('/nodes')
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<HealthResponse> {
|
||||
return apiFetch<HealthResponse>('/health')
|
||||
}
|
||||
137
dashboard/src/components/Blacklist.tsx
Normal file
137
dashboard/src/components/Blacklist.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useState, useEffect, FormEvent } from 'react'
|
||||
import { fetchBlacklist, addBlacklist, type BlacklistEntry } from '../api'
|
||||
|
||||
function Blacklist() {
|
||||
const [entries, setEntries] = useState<BlacklistEntry[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [target, setTarget] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [duration, setDuration] = useState('3600')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState('')
|
||||
const [addSuccess, setAddSuccess] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await fetchBlacklist()
|
||||
if (!cancelled) {
|
||||
setEntries(data)
|
||||
setError('')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load blacklist')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
const interval = setInterval(load, 30000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleAdd(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setAddError('')
|
||||
setAddSuccess('')
|
||||
setAdding(true)
|
||||
|
||||
try {
|
||||
await addBlacklist(target, 'ip', reason, parseInt(duration, 10))
|
||||
setAddSuccess(`Added ${target} to blacklist`)
|
||||
setTarget('')
|
||||
setReason('')
|
||||
setDuration('3600')
|
||||
const data = await fetchBlacklist()
|
||||
setEntries(data)
|
||||
} catch (err: unknown) {
|
||||
setAddError(err instanceof Error ? err.message : 'Failed to add entry')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="spinner" />
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Blacklist</h1>
|
||||
|
||||
<form className="blacklist-form" onSubmit={handleAdd}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="IP Address"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Duration (seconds)"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={adding}>
|
||||
{adding ? 'Adding...' : 'Add to Blacklist'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{addError && <div className="error-msg">{addError}</div>}
|
||||
{addSuccess && <div className="success-msg">{addSuccess}</div>}
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target</th>
|
||||
<th>Type</th>
|
||||
<th>Reason</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
|
||||
No blacklist entries
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td>{e.target}</td>
|
||||
<td>{e.type}</td>
|
||||
<td>{e.reason}</td>
|
||||
<td>{new Date(e.created).toLocaleString()}</td>
|
||||
<td>{new Date(e.expires).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Blacklist
|
||||
53
dashboard/src/components/Layout.tsx
Normal file
53
dashboard/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useState } from 'react'
|
||||
import { logout } from '../api'
|
||||
import Servers from './Servers'
|
||||
import Blacklist from './Blacklist'
|
||||
import Nodes from './Nodes'
|
||||
|
||||
type Page = 'servers' | 'blacklist' | 'nodes'
|
||||
|
||||
const navItems: { key: Page; label: string }[] = [
|
||||
{ key: 'servers', label: 'Servers' },
|
||||
{ key: 'blacklist', label: 'Blacklist' },
|
||||
{ key: 'nodes', label: 'Nodes' },
|
||||
]
|
||||
|
||||
function Layout() {
|
||||
const [page, setPage] = useState<Page>('servers')
|
||||
|
||||
function renderPage() {
|
||||
switch (page) {
|
||||
case 'servers':
|
||||
return <Servers />
|
||||
case 'blacklist':
|
||||
return <Blacklist />
|
||||
case 'nodes':
|
||||
return <Nodes />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<aside className="sidebar">
|
||||
<h2>Rampart Manager</h2>
|
||||
<nav>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={page === item.key ? 'active' : ''}
|
||||
onClick={() => setPage(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="logout-btn" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="main-content">{renderPage()}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
48
dashboard/src/components/Login.tsx
Normal file
48
dashboard/src/components/Login.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useState, FormEvent } from 'react'
|
||||
import { login } from '../api'
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: (token: string) => void
|
||||
}
|
||||
|
||||
function Login({ onLogin }: LoginProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await login(password)
|
||||
onLogin(res.token)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<h1>Rampart</h1>
|
||||
<p>Manager Dashboard</p>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={loading || !password}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
82
dashboard/src/components/Nodes.tsx
Normal file
82
dashboard/src/components/Nodes.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { fetchNodes, type Node } from '../api'
|
||||
|
||||
function Nodes() {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await fetchNodes()
|
||||
if (!cancelled) {
|
||||
setNodes(data)
|
||||
setError('')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load nodes')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
const interval = setInterval(load, 15000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="spinner" />
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Edge Nodes</h1>
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Role</th>
|
||||
<th>IP</th>
|
||||
<th>Status</th>
|
||||
<th>Last Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
|
||||
No nodes found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td>{n.id}</td>
|
||||
<td>{n.role}</td>
|
||||
<td>{n.ip}</td>
|
||||
<td>
|
||||
<span className="status-badge">
|
||||
<span className={`status-dot ${n.status === 'online' ? 'online' : 'offline'}`} />
|
||||
{n.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(n.last_heartbeat).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Nodes
|
||||
86
dashboard/src/components/Servers.tsx
Normal file
86
dashboard/src/components/Servers.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { fetchServers, type Server } from '../api'
|
||||
|
||||
function Servers() {
|
||||
const [servers, setServers] = useState<Server[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await fetchServers()
|
||||
if (!cancelled) {
|
||||
setServers(data)
|
||||
setError('')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load servers')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
const interval = setInterval(load, 10000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="spinner" />
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Servers</h1>
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>IP:Port</th>
|
||||
<th>Status</th>
|
||||
<th>Online/Max</th>
|
||||
<th>TPS</th>
|
||||
<th>Last Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
|
||||
No servers found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{servers.map((s) => (
|
||||
<tr key={s.name}>
|
||||
<td>{s.name}</td>
|
||||
<td>{s.server_type}</td>
|
||||
<td>{s.ip}:{s.port}</td>
|
||||
<td>
|
||||
<span className="status-badge">
|
||||
<span className={`status-dot ${s.status === 'online' ? 'online' : 'offline'}`} />
|
||||
{s.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{s.online_players}/{s.max_players}</td>
|
||||
<td>{s.tps.toFixed(1)}</td>
|
||||
<td>{new Date(s.last_heartbeat).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Servers
|
||||
10
dashboard/src/main.tsx
Normal file
10
dashboard/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './App.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
1
dashboard/src/vite-env.d.ts
vendored
Normal file
1
dashboard/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
21
dashboard/tsconfig.app.json
Normal file
21
dashboard/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
dashboard/tsconfig.app.tsbuildinfo
Normal file
1
dashboard/tsconfig.app.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/Blacklist.tsx","./src/components/Layout.tsx","./src/components/Login.tsx","./src/components/Nodes.tsx","./src/components/Servers.tsx"],"version":"5.9.3"}
|
||||
7
dashboard/tsconfig.json
Normal file
7
dashboard/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
19
dashboard/tsconfig.node.json
Normal file
19
dashboard/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
dashboard/tsconfig.node.tsbuildinfo
Normal file
1
dashboard/tsconfig.node.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
6
dashboard/vite.config.ts
Normal file
6
dashboard/vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
35
deploy/config/edge.toml
Normal file
35
deploy/config/edge.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
[bind]
|
||||
address = "0.0.0.0"
|
||||
port = 25565
|
||||
|
||||
[backend]
|
||||
address = "velocity"
|
||||
port = 25577
|
||||
|
||||
[hmac]
|
||||
secret = "test_secret_32_bytes_long_for_integration_test"
|
||||
|
||||
[workers]
|
||||
count = 2
|
||||
|
||||
[limits]
|
||||
handshake_timeout_secs = 5
|
||||
max_connections_per_ip = 10
|
||||
rate_limit_status_pps = 2
|
||||
rate_limit_login_pps = 5
|
||||
rate_limit_burst = 10
|
||||
|
||||
[store]
|
||||
redis_url = "redis://redis:6379/0"
|
||||
blacklist_cache_ttl_secs = 300
|
||||
|
||||
[logging]
|
||||
level = "debug"
|
||||
format = "text"
|
||||
|
||||
[metrics]
|
||||
enabled = true
|
||||
port = 9090
|
||||
|
||||
[xdp]
|
||||
enabled = false
|
||||
2
deploy/config/paper-global.yml
Normal file
2
deploy/config/paper-global.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
velocity:
|
||||
enabled: false
|
||||
7
deploy/config/server.properties
Normal file
7
deploy/config/server.properties
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
server-port=25566
|
||||
online-mode=false
|
||||
motd=Rampart Test Server
|
||||
max-players=20
|
||||
spawn-protection=0
|
||||
difficulty=peaceful
|
||||
gamemode=creative
|
||||
12
deploy/config/velocity.toml
Normal file
12
deploy/config/velocity.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
bind = "0.0.0.0:25577"
|
||||
motd = "Rampart Test"
|
||||
online-mode = false
|
||||
show-max-players = 100
|
||||
player-info-forwarding-mode = "NONE"
|
||||
try-compressions-on-connect = false
|
||||
|
||||
[servers]
|
||||
paper = "paper:25566"
|
||||
|
||||
[forced-hosts]
|
||||
"play.example.com" = "paper"
|
||||
48
deploy/docker-compose.yml
Normal file
48
deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 3s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
|
||||
edge:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/docker/Dockerfile.edge
|
||||
ports:
|
||||
- "25565:25565"
|
||||
environment:
|
||||
RAMPART_CONFIG: /etc/rampart/config.toml
|
||||
volumes:
|
||||
- ./config/edge.toml:/etc/rampart/config.toml:ro
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
velocity:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/docker/Dockerfile.velocity
|
||||
environment:
|
||||
RAMPART_HMAC_SECRET: test_secret_32_bytes_long_for_integration_test
|
||||
RAMPART_ALLOWED_DOMAINS: play.example.com
|
||||
ports:
|
||||
- "25577:25577"
|
||||
depends_on:
|
||||
- paper
|
||||
|
||||
paper:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/docker/Dockerfile.paper
|
||||
environment:
|
||||
EULA: "true"
|
||||
RAMPART_HMAC_SECRET: test_secret_32_bytes_long_for_integration_test
|
||||
RAMPART_ALLOWED_DOMAINS: play.example.com
|
||||
ports:
|
||||
- "25566:25566"
|
||||
depends_on:
|
||||
- redis
|
||||
20
deploy/docker/Dockerfile.edge
Normal file
20
deploy/docker/Dockerfile.edge
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
FROM rust:1.84-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock rustfmt.toml ./
|
||||
COPY crates/ ./crates/
|
||||
|
||||
RUN cargo build --release --bin rampart-core && \
|
||||
cp target/release/rampart-core /app/rampart-core && \
|
||||
strip /app/rampart-core
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/rampart-core /usr/local/bin/rampart-core
|
||||
|
||||
EXPOSE 25565 9090
|
||||
|
||||
ENTRYPOINT ["rampart-core"]
|
||||
22
deploy/docker/Dockerfile.paper
Normal file
22
deploy/docker/Dockerfile.paper
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
FROM eclipse-temurin:21-jre-bookworm
|
||||
|
||||
ARG PAPER_VERSION=1.21.4
|
||||
ARG PAPER_BUILD=latest
|
||||
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Download Paper
|
||||
RUN curl -fsSLo /opt/paper.jar \
|
||||
"https://api.papermc.io/v2/projects/paper/versions/${PAPER_VERSION}/builds/${PAPER_BUILD}/downloads/paper-${PAPER_VERSION}-${PAPER_BUILD}.jar" || \
|
||||
echo "Falling back to direct download..." && \
|
||||
curl -fsSLo /opt/paper.jar \
|
||||
"https://papermc.io/api/v2/projects/paper/versions/${PAPER_VERSION}/builds/${PAPER_BUILD}/downloads/paper-${PAPER_VERSION}-${PAPER_BUILD}.jar"
|
||||
|
||||
COPY deploy/config/server.properties /opt/server.properties
|
||||
COPY deploy/config/paper-global.yml /opt/paper-global.yml
|
||||
COPY plugins/paper/build/libs/rampart-paper-*.jar /opt/plugins/
|
||||
|
||||
EXPOSE 25566
|
||||
|
||||
WORKDIR /opt
|
||||
CMD ["java", "-jar", "/opt/paper.jar", "--nogui"]
|
||||
20
deploy/docker/Dockerfile.velocity
Normal file
20
deploy/docker/Dockerfile.velocity
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
FROM eclipse-temurin:21-jre-bookworm
|
||||
|
||||
ARG VELOCITY_VERSION=3.4.0-SNAPSHOT
|
||||
ARG VELOCITY_BUILD=latest
|
||||
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Download Velocity
|
||||
RUN curl -fsSLo /opt/velocity.jar \
|
||||
"https://api.papermc.io/v2/projects/velocity/versions/${VELOCITY_VERSION}/builds/${VELOCITY_BUILD}/downloads/velocity-${VELOCITY_VERSION}-${VELOCITY_BUILD}.jar" || \
|
||||
curl -fsSLo /opt/velocity.jar \
|
||||
"https://versions.velocitypowered.com/download/${VELOCITY_VERSION}.jar"
|
||||
|
||||
COPY deploy/config/velocity.toml /opt/velocity.toml
|
||||
COPY plugins/velocity/build/libs/rampart-velocity-*.jar /opt/plugins/
|
||||
|
||||
EXPOSE 25577
|
||||
|
||||
WORKDIR /opt
|
||||
CMD ["java", "-jar", "/opt/velocity.jar", "/opt/velocity.toml"]
|
||||
80
docker-compose.yml
Normal file
80
docker-compose.yml
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
version: "3.9"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: rampart-redis
|
||||
command: redis-server --requirepass "${REDIS_PASSWORD:-rampart_dev}" --appendonly yes
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
nats:
|
||||
image: nats:2-alpine
|
||||
container_name: rampart-nats
|
||||
ports:
|
||||
- "4222:4222"
|
||||
command: -js -c /etc/nats/nats.conf
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "nats", "server", "check"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:24-alpine
|
||||
container_name: rampart-clickhouse
|
||||
ports:
|
||||
- "8123:8123" # HTTP
|
||||
- "9000:9000" # Native TCP
|
||||
volumes:
|
||||
- clickhouse-data:/var/lib/clickhouse
|
||||
- ./clickhouse-init:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8123/ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: rampart-prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
restart: unless-stopped
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: rampart-grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./dashboards:/etc/grafana/provisioning/dashboards
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
nats-data:
|
||||
clickhouse-data:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
417
docs/api.md
Normal file
417
docs/api.md
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
# API Reference - Rampart Manager
|
||||
|
||||
> REST API для управления Rampart.
|
||||
> Base URL: `https://manager.rampart.internal/api/v1`
|
||||
> Авторизация: Bearer JWT (получить через `/api/v1/auth/login`)
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
|
||||
### `POST /api/v1/auth/login`
|
||||
|
||||
Получение JWT токена.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"password": "changeme"
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
Все последующие запросы:
|
||||
```
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nodes
|
||||
|
||||
### `GET /api/v1/nodes`
|
||||
|
||||
Список всех зарегистрированных нод.
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "edge-eu-1",
|
||||
"role": "edge",
|
||||
"ip": "10.0.100.1",
|
||||
"public_ip": "45.200.10.1",
|
||||
"status": "online",
|
||||
"version": "0.4.0",
|
||||
"uptime_secs": 86400,
|
||||
"metrics": {
|
||||
"connections_per_sec": 1200,
|
||||
"active_connections": 45000,
|
||||
"cpu_percent": 45.2,
|
||||
"memory_mb": 512
|
||||
},
|
||||
"last_heartbeat": "2026-07-19T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/nodes/{id}`
|
||||
|
||||
Детальная информация о ноде.
|
||||
|
||||
### `POST /api/v1/nodes`
|
||||
|
||||
Регистрация новой ноды (или через авто-discovery).
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"name": "edge-us-2",
|
||||
"role": "edge",
|
||||
"public_ip": "45.200.20.5",
|
||||
"wg_public_key": "<BASE64_KEY>"
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"id": "edge-us-2",
|
||||
"wg_config": "https://manager/api/v1/nodes/edge-us-2/wg-config",
|
||||
"tls_cert": "https://manager/api/v1/nodes/edge-us-2/cert"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/nodes/{id}/drain`
|
||||
|
||||
Вывести ноду из ротации (graceful shutdown).
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"status": "draining",
|
||||
"active_connections_before": 45000,
|
||||
"estimated_seconds": 30
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blacklist
|
||||
|
||||
### `GET /api/v1/blacklist`
|
||||
|
||||
Список забаненных IP/ASN.
|
||||
|
||||
| Параметр | Тип | По умолчанию | Описание |
|
||||
|----------|-----|-------------|----------|
|
||||
| `page` | int | 1 | Пагинация |
|
||||
| `per_page` | int | 100 | Элементов на странице |
|
||||
| `reason` | string | - | Фильтр по причине |
|
||||
| `search` | string | - | Поиск по IP/ASN |
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"target": "1.2.3.4",
|
||||
"type": "ip", // ip | asn | cidr
|
||||
"reason": "rate_limit",
|
||||
"created_by": "admin",
|
||||
"created_at": "2026-07-19T10:00:00Z",
|
||||
"expires_at": "2026-07-20T10:00:00Z",
|
||||
"hits": 1500
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"per_page": 100
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/blacklist`
|
||||
|
||||
Добавить IP/ASN/CIDR в блэклист.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"target": "1.2.3.4",
|
||||
"type": "ip", // ip | asn | cidr
|
||||
"reason": "manual_ban",
|
||||
"duration_secs": 3600 // null = навсегда
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"status": "added",
|
||||
"target": "1.2.3.4",
|
||||
"propagated_to_nodes": 2,
|
||||
"expires_at": "2026-07-19T11:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE /api/v1/blacklist/{id}`
|
||||
|
||||
Удалить запись из блэклиста.
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"status": "removed",
|
||||
"target": "1.2.3.4"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Servers
|
||||
|
||||
### `GET /api/v1/servers`
|
||||
|
||||
Список зарегистрированных game серверов.
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"name": "survival-01",
|
||||
"type": "survival",
|
||||
"ip": "10.0.2.1",
|
||||
"port": 25565,
|
||||
"status": "online",
|
||||
"proxy": "velocity-01",
|
||||
"online": 42,
|
||||
"max_players": 100,
|
||||
"tps": 19.8,
|
||||
"mspt": 25.3,
|
||||
"ram_used_mb": 2048,
|
||||
"ram_max_mb": 8192,
|
||||
"last_heartbeat": "2026-07-19T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/servers/{name}`
|
||||
|
||||
Детальная информация о сервере.
|
||||
|
||||
### `DELETE /api/v1/servers/{name}`
|
||||
|
||||
Принудительно удалить сервер из registry.
|
||||
|
||||
---
|
||||
|
||||
## Challenges (v0.5+)
|
||||
|
||||
### `GET /api/v1/challenges/status`
|
||||
|
||||
Статус challenge системы.
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"enabled": true,
|
||||
"mode": "auto",
|
||||
"active_challenges": 15,
|
||||
"passed_last_hour": 1200,
|
||||
"failed_last_hour": 45,
|
||||
"current_type": "timing"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/challenges/rotate`
|
||||
|
||||
Принудительно сменить тип challenge.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"type": "map_captcha" // timing | map_captcha | behavioral | contextual
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"status": "rotated",
|
||||
"previous_type": "timing",
|
||||
"new_type": "map_captcha",
|
||||
"rotated_at": "2026-07-19T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
### `GET /api/v1/metrics/summary`
|
||||
|
||||
Сводка метрик за период.
|
||||
|
||||
| Параметр | Тип | По умолчанию | Описание |
|
||||
|----------|-----|-------------|----------|
|
||||
| `since` | ISO8601 | -24h | Начало периода |
|
||||
| `until` | ISO8601 | now | Конец периода |
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"total_connections": 5200000,
|
||||
"blocked": 45000,
|
||||
"allowed": 5155000,
|
||||
"active_connections": 85000,
|
||||
"top_attackers": [
|
||||
{"ip": "5.5.5.5", "hits": 12000, "country": "NL"},
|
||||
{"ip": "6.6.6.6", "hits": 8000, "country": "RU"}
|
||||
],
|
||||
"top_countries": [
|
||||
{"country": "US", "connections": 2000000},
|
||||
{"country": "DE", "connections": 1000000}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
### `GET /api/v1/health`
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"status": "healthy",
|
||||
"version": "0.4.0",
|
||||
"uptime_secs": 604800,
|
||||
"components": {
|
||||
"redis": "healthy",
|
||||
"nats": "healthy",
|
||||
"clickhouse": "healthy",
|
||||
"edge_nodes": {"online": 2, "offline": 0},
|
||||
"velocity_nodes": {"online": 3, "offline": 1},
|
||||
"game_servers": {"online": 45, "offline": 2}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
### `POST /api/v1/webhooks`
|
||||
|
||||
Настройка webhook для событий.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"url": "https://discord.com/api/webhooks/...",
|
||||
"events": ["blacklist.added", "node.down", "attack.detected"],
|
||||
"secret": "optional_hmac_secret"
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"id": "wh_abc123",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
### Payload пример (blacklist.added)
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "blacklist.added",
|
||||
"timestamp": "2026-07-19T10:30:00Z",
|
||||
"data": {
|
||||
"target": "1.2.3.4",
|
||||
"reason": "rate_limit",
|
||||
"added_by": "auto"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI Spec
|
||||
|
||||
Полная OpenAPI 3.0 спецификация: `docs/api/openapi.yaml`
|
||||
|
||||
```yaml
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: Rampart Manager API
|
||||
version: "0.4.0"
|
||||
servers:
|
||||
- url: https://manager.rampart.internal/api/v1
|
||||
paths:
|
||||
/nodes:
|
||||
get:
|
||||
summary: List all nodes
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Node list
|
||||
/blacklist:
|
||||
post:
|
||||
summary: Add to blacklist
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
target:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [ip, asn, cidr]
|
||||
reason:
|
||||
type: string
|
||||
duration_secs:
|
||||
type: integer
|
||||
responses:
|
||||
'201':
|
||||
description: Added
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
API имеет rate limiting: **60 запросов в минуту** на один JWT токен.
|
||||
|
||||
```json
|
||||
// Response 429
|
||||
{
|
||||
"error": "rate_limit_exceeded",
|
||||
"retry_after_secs": 30
|
||||
}
|
||||
```
|
||||
|
||||
Headers:
|
||||
```
|
||||
X-RateLimit-Limit: 60
|
||||
X-RateLimit-Remaining: 42
|
||||
X-RateLimit-Reset: 1626688800
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Июль 2026*
|
||||
271
docs/configuration.md
Normal file
271
docs/configuration.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# Configuration - Rampart
|
||||
|
||||
> Примеры конфигурационных файлов для всех компонентов.
|
||||
|
||||
## Как конфиги связывают компоненты
|
||||
|
||||
```
|
||||
Edge config.toml Manager (env)
|
||||
bind.address JWT_SECRET
|
||||
bind.port API_PASSWORD
|
||||
backend.address ───→ REDIS_URL
|
||||
hmac.secret ←──────┐ CLICKHOUSE_URL
|
||||
store.redis_url ────┤
|
||||
│
|
||||
Velocity (env) │ Paper (env)
|
||||
RAMPART_HMAC_SECRET┤ RAMPART_HMAC_SECRET
|
||||
RAMPART_ALLOWED_ │ RAMPART_REDIS_URL
|
||||
DOMAINS │ RAMPART_SERVER_NAME
|
||||
RAMPART_REDIS_URL ─┘ RAMPART_SERVER_IP
|
||||
```
|
||||
|
||||
HMAC secret должен быть ОДИНАКОВЫМ на Edge, Velocity и Paper.
|
||||
Redis URL - одинаковым на всех компонентах.
|
||||
|
||||
---
|
||||
|
||||
## 1. Edge Node (`config.toml`)
|
||||
|
||||
```toml
|
||||
[bind]
|
||||
address = "0.0.0.0"
|
||||
port = 25565
|
||||
|
||||
[backend]
|
||||
# Velocity нода или HAProxy
|
||||
address = "10.0.0.2"
|
||||
port = 25565
|
||||
|
||||
[hmac]
|
||||
# Минимум 32 байта. Сгенерировать: openssl rand -hex 32
|
||||
secret = "CHANGE_ME_32_BYTES_LONG_HERE_ABCDEF123456"
|
||||
|
||||
[workers]
|
||||
# Количество воркеров = количество vCPU
|
||||
count = 4
|
||||
|
||||
[xdp]
|
||||
# Опционально, требует kernel 5.10+
|
||||
enabled = false
|
||||
interface = "eth0"
|
||||
|
||||
[limits]
|
||||
# Максимум времени на получение handshake (Slowloris защита)
|
||||
handshake_timeout_secs = 5
|
||||
# Максимум одновременных соединений с одного IP
|
||||
max_connections_per_ip = 10
|
||||
# Лимит коннектов в секунду с одного IP (Status ping)
|
||||
rate_limit_status_pps = 2
|
||||
# Лимит коннектов в секунду с одного IP (Login)
|
||||
rate_limit_login_pps = 5
|
||||
# Лимит burst
|
||||
rate_limit_burst = 10
|
||||
|
||||
[store]
|
||||
# v0.2+: Redis для синхронизации блэклиста
|
||||
redis_url = "redis://:password@10.0.0.1:6379/0"
|
||||
# TTL кэша блэклиста (локально)
|
||||
blacklist_cache_ttl_secs = 300
|
||||
|
||||
[logging]
|
||||
level = "info" # trace, debug, info, warn, error
|
||||
format = "json" # json или text
|
||||
|
||||
[metrics]
|
||||
enabled = true
|
||||
port = 9090
|
||||
|
||||
[quic]
|
||||
# v0.4+: QUIC канал к Manager (опционально)
|
||||
enabled = false
|
||||
connect = "10.0.0.1:7777"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Velocity Plugin
|
||||
|
||||
Плагин конфигурируется через переменные окружения (совпадают с edge node).
|
||||
|
||||
```bash
|
||||
# Обязательно: HMAC секрет (должен совпадать с edge нодой)
|
||||
RAMPART_HMAC_SECRET="CHANGE_ME_32_BYTES_LONG_HERE_ABCDEF123456"
|
||||
|
||||
# Опционально: список разрешённых доменов (через запятую)
|
||||
RAMPART_ALLOWED_DOMAINS="play.example.com,mc.example.com,example.com"
|
||||
```
|
||||
|
||||
Установка:
|
||||
```
|
||||
# Сборка
|
||||
cd plugins && ./gradlew :velocity:build
|
||||
|
||||
# Копирование в Velocity
|
||||
cp velocity/build/libs/rampart-velocity-*.jar /opt/velocity/plugins/
|
||||
|
||||
# Рестарт
|
||||
systemctl restart velocity
|
||||
```
|
||||
|
||||
Плагин делает:
|
||||
- **DomainCheck**: блокирует direct IP-коннекты, пропускает только домены из whitelist
|
||||
- **HmacCheck**: верифицирует HMAC-SHA256 подпись в hostname (`\0shield\0<sig>`)
|
||||
|
||||
---
|
||||
|
||||
## 3. Paper Plugin
|
||||
|
||||
Конфигурация - через переменные окружения:
|
||||
|
||||
```bash
|
||||
# Обязательно: HMAC секрет (должен совпадать с edge нодой)
|
||||
RAMPART_HMAC_SECRET="CHANGE_ME_32_BYTES_LONG_HERE_ABCDEF123456"
|
||||
```
|
||||
|
||||
Установка:
|
||||
```
|
||||
cd plugins && ./gradlew :paper:build
|
||||
cp paper/build/libs/rampart-paper-*.jar /opt/paper/plugins/
|
||||
```
|
||||
|
||||
Плагин делает:
|
||||
- **HmacCheck**: резервная верификация HMAC-подписи на случай прямого коннекта (в обход Velocity)
|
||||
|
||||
---
|
||||
|
||||
## 4. Manager (`manager.toml`)
|
||||
|
||||
```toml
|
||||
[bind]
|
||||
address = "0.0.0.0"
|
||||
port = 8080
|
||||
|
||||
[tls]
|
||||
cert = "/etc/rampart/tls/manager.crt"
|
||||
key = "/etc/rampart/tls/manager.key"
|
||||
ca = "/etc/rampart/tls/ca.crt"
|
||||
|
||||
[auth]
|
||||
jwt_secret = "CHANGE_ME_JWT_SECRET_HERE"
|
||||
jwt_expiry_hours = 24
|
||||
|
||||
[redis]
|
||||
url = "redis://:password@127.0.0.1:6379/0"
|
||||
pool_size = 10
|
||||
|
||||
[nats]
|
||||
# v0.4+: NATS для критических событий
|
||||
urls = ["nats://127.0.0.1:4222"]
|
||||
|
||||
[clickhouse]
|
||||
url = "http://127.0.0.1:8123"
|
||||
db = "rampart"
|
||||
batch_size = 1000
|
||||
flush_interval_secs = 1
|
||||
|
||||
[quic]
|
||||
# v0.4+: QUIC сервер для edge нод
|
||||
bind = "0.0.0.0:7777"
|
||||
|
||||
[limits]
|
||||
api_rate_per_minute = 60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. HAProxy (`haproxy.cfg`)
|
||||
|
||||
```haproxy
|
||||
global
|
||||
maxconn 100000
|
||||
log /dev/log local0
|
||||
|
||||
defaults
|
||||
mode tcp
|
||||
timeout connect 3s
|
||||
timeout client 30s
|
||||
timeout server 30s
|
||||
option tcplog
|
||||
|
||||
frontend minecraft_in
|
||||
bind *:25565
|
||||
mode tcp
|
||||
|
||||
# Только от edge нод
|
||||
acl is_edge src 10.0.100.0/24
|
||||
tcp-request connection reject if !is_edge
|
||||
|
||||
default_backend velocity_pool
|
||||
|
||||
backend velocity_pool
|
||||
mode tcp
|
||||
balance leastconn
|
||||
option tcp-check
|
||||
|
||||
server vel1 10.0.0.2:25565 check inter 3s rise 2 fall 3
|
||||
server vel2 10.0.0.3:25565 check inter 3s rise 2 fall 3
|
||||
server vel3 10.0.0.4:25565 check inter 3s rise 2 fall 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Prometheus (`prometheus.yml`)
|
||||
|
||||
```yaml
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'rampart-edge'
|
||||
static_configs:
|
||||
- targets:
|
||||
- '10.0.100.1:9090'
|
||||
- '10.0.100.2:9090'
|
||||
|
||||
- job_name: 'rampart-manager'
|
||||
static_configs:
|
||||
- targets: ['10.0.0.1:9090']
|
||||
|
||||
- job_name: 'rampart-velocity'
|
||||
static_configs:
|
||||
- targets:
|
||||
- '10.0.0.2:9091'
|
||||
- '10.0.0.3:9091'
|
||||
|
||||
- job_name: 'paper-servers'
|
||||
file_sd_configs:
|
||||
- files: ['/etc/prometheus/game_servers.json']
|
||||
refresh_interval: 30s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. WireGuard (`wg0.conf`)
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.0.0.1/16
|
||||
PrivateKey = <MANAGER_PRIVATE_KEY>
|
||||
ListenPort = 51820
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
# Edge EU
|
||||
PublicKey = <EDGE_EU_PUBLIC_KEY>
|
||||
AllowedIPs = 10.0.100.1/32
|
||||
|
||||
[Peer]
|
||||
# Edge US
|
||||
PublicKey = <EDGE_US_PUBLIC_KEY>
|
||||
AllowedIPs = 10.0.100.2/32
|
||||
|
||||
[Peer]
|
||||
# Velocity 1
|
||||
PublicKey = <VEL1_PUBLIC_KEY>
|
||||
AllowedIPs = 10.0.0.2/32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Июль 2026*
|
||||
368
docs/deployment.md
Normal file
368
docs/deployment.md
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
# Deployment - Rampart
|
||||
|
||||
> Как поднять Rampart с нуля. v0.1-v0.7.
|
||||
|
||||
---
|
||||
|
||||
## 1. Требования
|
||||
|
||||
### Минимальные (v0.1)
|
||||
- 2 × VDS (KVM): Edge нода + Manager/Redis
|
||||
- Ubuntu 22.04+, kernel 5.10+
|
||||
- Rust toolchain (rustup)
|
||||
- Docker + docker compose (для Manager)
|
||||
|
||||
### Полный стек (v0.4+)
|
||||
- Edge: Debian 12 / Ubuntu 22.04, kernel 5.10+ (6.0+ для полного XDP)
|
||||
- Manager: любая VDS с Docker
|
||||
- Java 21 (для Velocity плагинов)
|
||||
- WireGuard (между нодами)
|
||||
|
||||
## Схема деплоя
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Edge нода │────→│ Load │────→│ Velocity │
|
||||
│ 25565/TCP │ │ Balancer │ │ кластер │
|
||||
│ XDP + Rust │ │ HAProxy │ │ x20 нод │
|
||||
└──────────────┘ └──────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌────────────────────────────┤
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Hub x100 │ │ Game │
|
||||
│ (лобби) │ │ серверы │
|
||||
│ │ │ x300+ │
|
||||
└──────────────┘ └──────────────┘
|
||||
│ │
|
||||
└──────────┬──────────────┘
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Manager нода │
|
||||
│ API :8080 │
|
||||
│ Redis │
|
||||
│ WireGuard Hub │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
Все соединения через WireGuard (10.0.0.0/16).
|
||||
Game серверы НЕ имеют публичных IP - только WG.
|
||||
Edge - единственная точка входа из интернета.
|
||||
|
||||
---
|
||||
|
||||
## 2. Быстрый старт - v0.1 (локально)
|
||||
|
||||
### Шаг 1: Edge нода
|
||||
|
||||
```bash
|
||||
# На свежей Ubuntu 22.04 VDS
|
||||
|
||||
# Установка Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source "$HOME/.cargo/env"
|
||||
rustup default stable
|
||||
|
||||
# Установка зависимостей
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential pkg-config libssl-dev
|
||||
|
||||
# Клонирование и сборка
|
||||
git clone https://github.com/yourname/rampart.git
|
||||
cd rampart
|
||||
|
||||
# Сборка edge ноды
|
||||
cargo build --release --bin rampart-core
|
||||
|
||||
# Создание конфига
|
||||
mkdir -p /etc/rampart
|
||||
cat > /etc/rampart/config.toml << 'EOF'
|
||||
[bind]
|
||||
address = "0.0.0.0"
|
||||
port = 25565
|
||||
|
||||
[backend]
|
||||
address = "127.0.0.1"
|
||||
port = 25566
|
||||
|
||||
[hmac]
|
||||
secret = "CHANGE_ME_32_BYTES_LONG_HERE"
|
||||
|
||||
[workers]
|
||||
count = 4
|
||||
|
||||
[limits]
|
||||
handshake_timeout_secs = 5
|
||||
max_connections_per_ip = 10
|
||||
EOF
|
||||
|
||||
# systemd unit
|
||||
cat > /etc/systemd/system/rampart-edge.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Rampart Edge Node
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/rampart-core --config /etc/rampart/config.toml
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
User=nobody
|
||||
Group=nogroup
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now rampart-edge
|
||||
|
||||
# Проверка
|
||||
journalctl -u rampart-edge -f
|
||||
```
|
||||
|
||||
### Шаг 2: Manager (docker compose)
|
||||
|
||||
```bash
|
||||
# На отдельной VDS или той же
|
||||
|
||||
# Установка Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Клонирование
|
||||
git clone https://github.com/yourname/rampart.git
|
||||
cd rampart
|
||||
|
||||
# Запуск инфраструктуры
|
||||
docker compose up -d
|
||||
|
||||
# Проверка
|
||||
docker compose ps
|
||||
curl http://localhost:9090/api/health
|
||||
```
|
||||
|
||||
### Шаг 3: Velocity плагин
|
||||
|
||||
```bash
|
||||
# На Velocity ноде
|
||||
# Сборка плагина
|
||||
cd plugins/velocity
|
||||
mvn clean package
|
||||
# Полученный JAR: target/rampart-velocity-*.jar
|
||||
|
||||
# Копируем в папку плагинов Velocity
|
||||
cp target/rampart-velocity-*.jar /opt/velocity/plugins/
|
||||
|
||||
# Настройка
|
||||
cat >> /opt/velocity/velocity.toml << 'EOF'
|
||||
|
||||
[rampart]
|
||||
# Включаем HMAC проверку
|
||||
hmac_secret = "CHANGE_ME_32_BYTES_LONG_HERE"
|
||||
# Домены разрешённые для подключения
|
||||
allowed_domains = ["play.example.com", "mc.example.com"]
|
||||
# Redis (опционально, для v0.2+)
|
||||
redis_url = "redis://:password@10.0.0.1:6379/0"
|
||||
EOF
|
||||
|
||||
# Рестарт Velocity
|
||||
systemctl restart velocity
|
||||
```
|
||||
|
||||
### Шаг 4: Проверка
|
||||
|
||||
```bash
|
||||
# Статус edge ноды
|
||||
rampart status
|
||||
|
||||
# Диагностика
|
||||
rampart doctor
|
||||
|
||||
# Проверка что порт слушается
|
||||
ss -tlnp | grep 25565
|
||||
|
||||
# Тест подключения Minecraft клиента
|
||||
# Открой MC → Multiplayer → play.example.com:25565
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. WireGuard сеть
|
||||
|
||||
### Hub-and-Spoke на Manager
|
||||
|
||||
```bash
|
||||
# На Manager ноде (WireGuard Hub)
|
||||
# Установка
|
||||
sudo apt-get install -y wireguard
|
||||
|
||||
# Генерация ключей
|
||||
wg genkey | tee /etc/wireguard/manager.key | wg pubkey > /etc/wireguard/manager.pub
|
||||
|
||||
# Конфиг Hub
|
||||
cat > /etc/wireguard/wg0.conf << 'EOF'
|
||||
[Interface]
|
||||
Address = 10.0.0.1/16
|
||||
PrivateKey = <MANAGER_PRIVATE_KEY>
|
||||
ListenPort = 51820
|
||||
|
||||
# Edge нода будет добавлена позже
|
||||
EOF
|
||||
|
||||
systemctl enable --now wg-quick@wg0
|
||||
```
|
||||
|
||||
### Добавление spoke ноды (через CLI)
|
||||
|
||||
```bash
|
||||
# На Manager: генерируем конфиг для edge ноды
|
||||
rampart wg add-node --role edge --name edge-eu-1 --public-ip 45.200.10.1
|
||||
|
||||
# Полученный конфиг:
|
||||
# /etc/rampart/wg-configs/edge-eu-1/wg0.conf
|
||||
|
||||
# Копируем на edge ноду
|
||||
scp /etc/rampart/wg-configs/edge-eu-1/wg0.conf root@45.200.10.1:/etc/wireguard/
|
||||
|
||||
# На edge ноде: запускаем
|
||||
ssh root@45.200.10.1 'systemctl enable --now wg-quick@wg0'
|
||||
|
||||
# Проверка
|
||||
ping 10.0.0.1 # Manager должен ответить
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Полный production deploy (v0.4+)
|
||||
|
||||
### Edge нода с XDP
|
||||
|
||||
```bash
|
||||
# Проверка совместимости
|
||||
systemd-detect-virt # должно быть kvm или none
|
||||
ethtool -i eth0 # драйвер: i40e, mlx5, virtio
|
||||
|
||||
# Установка XDP зависимостей
|
||||
sudo apt-get install -y libbpf-dev clang llvm linux-headers-$(uname -r)
|
||||
|
||||
# Сборка с XDP
|
||||
cargo build --release --features xdp
|
||||
|
||||
# Настройка sysctl для DDoS защиты
|
||||
cat > /etc/sysctl.d/99-rampart.conf << 'EOF'
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
net.ipv4.tcp_synack_retries = 2
|
||||
net.ipv4.tcp_syn_retries = 2
|
||||
net.ipv4.icmp_echo_ignore_all = 1
|
||||
net.core.somaxconn = 65535
|
||||
net.core.netdev_max_backlog = 65535
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
net.ipv4.ip_local_port_range = 1024 65535
|
||||
EOF
|
||||
sysctl -p /etc/sysctl.d/99-rampart.conf
|
||||
```
|
||||
|
||||
### Monitoring стек
|
||||
|
||||
```yaml
|
||||
# /opt/rampart/docker-compose.monitoring.yml
|
||||
# Дополнение к основному compose
|
||||
services:
|
||||
victoria-metrics:
|
||||
image: victoriametrics/victoria-metrics:latest
|
||||
ports:
|
||||
- "8428:8428" # remote_write endpoint
|
||||
command:
|
||||
- '--storageDataPath=/storage'
|
||||
- '--retentionPeriod=3'
|
||||
volumes:
|
||||
- vm-data:/storage
|
||||
|
||||
parca:
|
||||
image: ghcr.io/parca-dev/parca:latest
|
||||
ports:
|
||||
- "7070:7070"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. CI/CD pipeline
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-edge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo build --release --features xdp
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rampart-edge
|
||||
path: target/release/rampart-core
|
||||
|
||||
deploy-edge:
|
||||
needs: build-edge
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- run: |
|
||||
scp rampart-core root@${EDGE_HOST}:/usr/local/bin/
|
||||
ssh root@${EDGE_HOST} 'systemctl restart rampart-edge'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Firewall (резюме)
|
||||
|
||||
```bash
|
||||
# Быстрая настройка для edge ноды
|
||||
sudo ./scripts/firewall.sh
|
||||
|
||||
# Проверка
|
||||
sudo iptables -L -n -v
|
||||
```
|
||||
|
||||
Полные правила в [networking.md](research/networking.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Checklist после деплоя
|
||||
|
||||
```
|
||||
☐ Edge нода запущена: systemctl status rampart-edge
|
||||
☐ Порты слушаются: ss -tlnp | grep 25565
|
||||
☐ WireGuard работает: wg show
|
||||
☐ Manager API отвечает: curl http://localhost:9090/api/health
|
||||
☐ Redis доступен: redis-cli ping
|
||||
☐ ClickHouse пишет: curl http://localhost:8123/ping
|
||||
☐ Velocity плагин загружен: /plugins/rampart-velocity-*.jar
|
||||
☐ Реальный MC клиент заходит
|
||||
☐ Prometheus метрики: curl http://localhost:9090/metrics
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Troubleshooting
|
||||
|
||||
| Симптом | Причина | Решение |
|
||||
|---------|---------|---------|
|
||||
| Edge не стартует | Порт занят | `ss -tlnp \| grep 25565`, смени порт |
|
||||
| Velocity не подключается | Не совпадает HMAC secret | Проверь `config.toml` и `velocity.toml` |
|
||||
| XDP не загружается | OpenVZ / old kernel | `systemd-detect-virt`, проверь `uname -r` |
|
||||
| Redis connection refused | Не настроен firewall | `iptables -A INPUT -p tcp --dport 6379 -s 10.0.0.0/16 -j ACCEPT` |
|
||||
| ClickHouse не пишет | Нет таблицы | Выполни CREATE TABLE из `observability.md` |
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Июль 2026*
|
||||
323
docs/disaster_recovery.md
Normal file
323
docs/disaster_recovery.md
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
# Disaster Recovery - Rampart
|
||||
|
||||
> Что делать когда что-то пошло не так.
|
||||
|
||||
## Схема failover
|
||||
|
||||
```
|
||||
Redis упал:
|
||||
Edge: продолжает с локальным кэшем
|
||||
Velocity: продолжает с последним кэшем серверов
|
||||
Manager: API не работает → рестарт Redis, рестарт Manager
|
||||
|
||||
Manager упал:
|
||||
Edge: продолжает автономно
|
||||
Velocity: читает Redis напрямую
|
||||
Dashboard: недоступен → рестарт Manager
|
||||
|
||||
Edge нода упала:
|
||||
Игроки на ней теряют коннект
|
||||
При реконнекте → BGP/DNS → другая Edge нода
|
||||
Если Edge одна → все офлайн
|
||||
|
||||
Полный сбой дата-центра:
|
||||
Edge ноды в других ДЦ продолжают работу
|
||||
Игроки на живых серверах продолжают играть
|
||||
Новые регистрации/баны не синхронизируются до восстановления
|
||||
```
|
||||
|
||||
Все компоненты кроме Manager продолжают работать в degraded mode.
|
||||
Manager - единственная single point of failure (без Redis Sentinel).
|
||||
|
||||
---
|
||||
|
||||
## 1. Redis упал
|
||||
|
||||
### Симптомы
|
||||
- Velocity не видит новые серверы
|
||||
- Edge не синхронизирует блэклист
|
||||
- Manager API возвращает 500
|
||||
|
||||
### Влияние
|
||||
- **Edge:** Продолжает работать с локальным кэшем блэклиста. Новые баны не синхронизируются между нодами.
|
||||
- **Velocity:** Продолжает работать с последним кэшем server registry. Новые серверы недоступны до восстановления Redis.
|
||||
- **Manager:** API не работает.
|
||||
|
||||
### Действия
|
||||
|
||||
```bash
|
||||
# 1. Проверка Redis
|
||||
redis-cli ping
|
||||
systemctl status redis
|
||||
|
||||
# 2. Если Redis завис - рестарт
|
||||
systemctl restart redis
|
||||
|
||||
# 3. Если Redis навсегда умер - поднять новый
|
||||
# Убедись что пароль совпадает с конфигами
|
||||
docker run -d --name rampart-redis \
|
||||
-p 6379:6379 \
|
||||
redis:7-alpine redis-server --requirepass "$REDIS_PASSWORD"
|
||||
|
||||
# 4. Перезапустить Manager (он переподключится)
|
||||
systemctl restart rampart-manager
|
||||
|
||||
# 5. Edge ноды переподключатся автоматически (retry в драйвере Redis)
|
||||
# Если не переподключились - рестарт:
|
||||
systemctl restart rampart-edge
|
||||
|
||||
# 6. Velocity переподключится с задержкой до 5 сек
|
||||
```
|
||||
|
||||
### Предотвращение
|
||||
- Redis Sentinel для HA (3 ноды)
|
||||
- AOF + RDB persistence включены
|
||||
- Регулярные бэкапы: `redis-cli SAVE`
|
||||
|
||||
---
|
||||
|
||||
## 2. Manager упал
|
||||
|
||||
### Симптомы
|
||||
- API не отвечает
|
||||
- Blacklist изменения не применяются
|
||||
- Edge heartbeat пропадает
|
||||
|
||||
### Влияние
|
||||
- **Edge:** Продолжает работать автономно. Локальный блэклист активен.
|
||||
- **Velocity:** Продолжает работать. Server registry из Redis доступен.
|
||||
- **Dashboard:** Недоступен.
|
||||
|
||||
### Действия
|
||||
|
||||
```bash
|
||||
# 1. Проверка
|
||||
systemctl status rampart-manager
|
||||
journalctl -u rampart-manager -n 50 --no-pager
|
||||
|
||||
# 2. Рестарт
|
||||
systemctl restart rampart-manager
|
||||
|
||||
# 3. Если не стартует - проверить логи
|
||||
journalctl -u rampart-manager -e | grep ERROR
|
||||
|
||||
# 4. Если проблема в конфиге
|
||||
# Откатить последние изменения конфига
|
||||
git checkout HEAD~1 -- config/manager.toml
|
||||
systemctl restart rampart-manager
|
||||
```
|
||||
|
||||
### Предотвращение
|
||||
- systemd `Restart=always`
|
||||
- Мониторинг: Prometheus alert `EdgeNodeDown`
|
||||
- Два Manager в active/passive (v0.6+)
|
||||
|
||||
---
|
||||
|
||||
## 3. Edge нода упала
|
||||
|
||||
### Симптомы
|
||||
- Игроки на этой ноде теряют соединение
|
||||
- Prometheus alert: `EdgeNodeDown`
|
||||
- Метрики перестали приходить
|
||||
|
||||
### Влияние
|
||||
- Игроки, подключённые через эту ноду, дисконнектятся
|
||||
- При переподключении → попадают на другую edge ноду
|
||||
- Если edge нода одна → **все игроки офлайн**
|
||||
|
||||
### Действия
|
||||
|
||||
```bash
|
||||
# 1. Проверка
|
||||
systemctl status rampart-edge
|
||||
journalctl -u rampart-edge -n 50 --no-pager
|
||||
|
||||
# 2. Если OOM kill
|
||||
dmesg | grep -i "oom\|rampart"
|
||||
|
||||
# 3. Рестарт
|
||||
systemctl restart rampart-edge
|
||||
|
||||
# 4. Если не стартует - проверить конфиг
|
||||
rampart doctor
|
||||
|
||||
# 5. Если аппаратная проблема - переключить DNS на другую edge ноду
|
||||
# (при нескольких edge нодах)
|
||||
```
|
||||
|
||||
### Предотвращение
|
||||
- Минимум 2 edge ноды
|
||||
- DNS round-robin или BGP Anycast
|
||||
- systemd `Restart=always`
|
||||
- `rampart drain` для graceful maintenance
|
||||
|
||||
---
|
||||
|
||||
## 4. ClickHouse упал
|
||||
|
||||
### Симптомы
|
||||
- Attack log не пишется
|
||||
- Dashboard по блокировкам пустой
|
||||
|
||||
### Влияние
|
||||
- **Edge / Velocity / Manager:** Продолжают работать. Потеря аналитики.
|
||||
- Данные не теряются (буферизация в Manager на 1 секунду с батчем до 1000).
|
||||
|
||||
### Действия
|
||||
|
||||
```bash
|
||||
# 1. Проверка
|
||||
systemctl status clickhouse-server
|
||||
curl http://localhost:8123/ping
|
||||
|
||||
# 2. Рестарт
|
||||
systemctl restart clickhouse-server
|
||||
|
||||
# 3. Если долго восстанавливается - проверить диск
|
||||
df -h /var/lib/clickhouse
|
||||
|
||||
# 4. Если диск полон - почистить старые партиции
|
||||
clickhouse-client --query "ALTER TABLE rampart.blocked DROP PARTITION '2025-01'"
|
||||
```
|
||||
|
||||
### Предотвращение
|
||||
- TTL на таблицах (90 дней автоочистка)
|
||||
- ClickHouse Cloud или Cluster (v0.6+)
|
||||
- Alertmanager при заполнении диска > 80%
|
||||
|
||||
---
|
||||
|
||||
## 5. Root CA key скомпрометирован
|
||||
|
||||
### Симптомы
|
||||
- Вы знаете что ключ утек
|
||||
- Подозрительные сертификаты в сети
|
||||
|
||||
### Влияние
|
||||
- **Полная компрометация mTLS:** Атакующий может выпустить сертификаты для любой ноды
|
||||
|
||||
### Действия
|
||||
|
||||
```bash
|
||||
# 1. НЕМЕДЛЕННО: Сгенерировать новый Root CA
|
||||
rampart pki init --root-ca rampart-ca-v2 --force
|
||||
|
||||
# 2. Выпустить новые сертификаты для ВСЕХ нод
|
||||
for node in edge-eu-1 edge-us-1 vel-1 manager; do
|
||||
rampart pki issue --ca edge-ca --name "$node" \
|
||||
--ip "$(dig +short $node.rampart.internal)" \
|
||||
--san "$node.rampart.internal" \
|
||||
--output "/etc/rampart/pki/$node/"
|
||||
done
|
||||
|
||||
# 3. Разослать новые сертификаты на все ноды
|
||||
rampart pki sync --all-nodes
|
||||
|
||||
# 4. Перезапустить все сервисы (с новыми сертификатами)
|
||||
rampart restart --all
|
||||
|
||||
# 5. Отозвать старый Root CA
|
||||
rampart pki revoke --ca rampart-ca-v1
|
||||
|
||||
# 6. Расследовать утечку
|
||||
# - Проверить кто имел доступ к ключу
|
||||
# - Проверить логи доступа
|
||||
# - Сменить все пароли
|
||||
```
|
||||
|
||||
### Предотвращение
|
||||
- Root CA ключ хранить **вне серверов** (на YubiKey или в Vault)
|
||||
- Использовать Intermediate CA для повседневной работы
|
||||
- Audit лог доступа к CA ключу
|
||||
|
||||
---
|
||||
|
||||
## 6. Полный сбой инфраструктуры
|
||||
|
||||
### Ситуация
|
||||
Упали нода Manager + Redis + NATS одновременно (например, отключили дата-центр).
|
||||
|
||||
### Влияние
|
||||
- Все edge ноды продолжают работать автономно
|
||||
- Блэклист не синхронизируется
|
||||
- Server registry не обновляется
|
||||
- **Игроки продолжают играть на уже запущенных серверах**
|
||||
|
||||
### Восстановление
|
||||
|
||||
```bash
|
||||
# 1. Поднять Manager на новой VDS
|
||||
docker compose up -d
|
||||
|
||||
# 2. Восстановить Redis из бэкапа
|
||||
redis-cli --pipe < /backup/rampart-redis-$(date +%Y-%m-%d).rdb
|
||||
|
||||
# 3. Edge ноды и Velocity переподключатся автоматически
|
||||
# (они реконнектятся с экспоненциальной задержкой: 1s, 2s, 4s, 8s... max 60s)
|
||||
|
||||
# 4. Проверить что всё синхронизировалось
|
||||
rampart doctor
|
||||
```
|
||||
|
||||
### Предотвращение
|
||||
- Бэкапы Redis: ежедневно, хранить 30 дней
|
||||
- Terraform для быстрого поднятия инфраструктуры
|
||||
- DNS записи с низким TTL (60 сек)
|
||||
|
||||
---
|
||||
|
||||
## 7. DDoS на Manager/Redis
|
||||
|
||||
### Симптомы
|
||||
- Manager API не отвечает
|
||||
- Redis latency > 1 секунды
|
||||
- CPU Manager 100%
|
||||
|
||||
### Действия
|
||||
|
||||
```bash
|
||||
# 1. Изолировать Manager - закрыть все порты кроме WireGuard
|
||||
iptables -P INPUT DROP
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A INPUT -p udp --dport 51820 -j ACCEPT
|
||||
iptables -A INPUT -j DROP
|
||||
|
||||
# 2. Если DDoS идёт на публичный IP - отключить его
|
||||
# (Оставить только WireGuard туннель)
|
||||
|
||||
# 3. Edge ноды переживут без Manager несколько часов
|
||||
# (они кэшируют блэклист локально)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Cheatsheet быстрых команд
|
||||
|
||||
```bash
|
||||
# Рестарт всего
|
||||
systemctl restart rampart-edge rampart-manager redis clickhouse-server
|
||||
|
||||
# Проверка здоровья всей системы
|
||||
rampart doctor
|
||||
|
||||
# Последние 50 строк логов edge
|
||||
journalctl -u rampart-edge -n 50 -f
|
||||
|
||||
# CPU/memory edge
|
||||
htop -p $(pgrep -d',' rampart-edge)
|
||||
|
||||
# Трафик на интерфейсе
|
||||
iftop -i eth0
|
||||
|
||||
# Статистика Redis
|
||||
redis-cli info stats | grep -E "total_connections|total_commands|rejected"
|
||||
|
||||
# Активные соединения
|
||||
ss -s | grep TCP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Июль 2026*
|
||||
287
docs/migration.md
Normal file
287
docs/migration.md
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# Migration - Rampart
|
||||
|
||||
> Как обновляться между версиями без даунтайма.
|
||||
|
||||
---
|
||||
|
||||
## Общие принципы
|
||||
|
||||
1. **Читай CHANGELOG** перед обновлением
|
||||
2. **Бэкап** перед любой миграцией: Redis RDB, конфиги, сертификаты
|
||||
3. **Одна нода** сначала - тестируй на одной edge, потом на всех
|
||||
4. **Откат** - всегда сохраняй предыдущую версию бинарника
|
||||
|
||||
---
|
||||
|
||||
## v0.1 → v0.2 (Redis + Registry)
|
||||
|
||||
### Изменения
|
||||
- Edge нода начинает использовать Redis для синхронизации блэклиста
|
||||
- Paper плагин пишет в Redis вместо локального YAML
|
||||
- Velocity получает server registry из Redis
|
||||
|
||||
### Шаги
|
||||
|
||||
```bash
|
||||
# 1. Поднять Redis (если ещё нет)
|
||||
docker compose up -d redis
|
||||
|
||||
# 2. Настроить Redis пароль
|
||||
echo "requirepass НОВЫЙ_ПАРОЛЬ" >> /etc/redis/redis.conf
|
||||
systemctl restart redis
|
||||
|
||||
# 3. Обновить конфиг edge ноды
|
||||
cat >> /etc/rampart/config.toml << 'EOF'
|
||||
[store]
|
||||
redis_url = "redis://:НОВЫЙ_ПАРОЛЬ@10.0.0.1:6379/0"
|
||||
blacklist_cache_ttl_secs = 300
|
||||
EOF
|
||||
|
||||
# 4. Обновить Paper плагин (перейти с file → redis)
|
||||
sed -i 's/registration_mode: "file"/registration_mode: "redis"/' paper-global.yml
|
||||
sed -i 's|# redis_url:|redis_url: "redis://:НОВЫЙ_ПАРОЛЬ@10.0.0.1:6379/0"|' paper-global.yml
|
||||
|
||||
# 5. Обновить Velocity плагин
|
||||
# Добавить redis_url в velocity.toml
|
||||
|
||||
# 6. Рестарт по очереди (no downtime)
|
||||
systemctl restart rampart-edge # по одной edge ноде
|
||||
systemctl restart velocity # по одной velocity
|
||||
# Paper плагины - reload через /reload команду
|
||||
```
|
||||
|
||||
### Откат
|
||||
|
||||
```bash
|
||||
# Если что-то пошло не так:
|
||||
# 1. Вернуть registration_mode: "file" в paper-global.yml
|
||||
# 2. Убрать redis_url из всех конфигов
|
||||
# 3. Рестартнуть всё в обратном порядке
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.2 → v0.3 (Observability)
|
||||
|
||||
### Изменения
|
||||
- Добавляются Prometheus метрики на всех компонентах
|
||||
- ClickHouse для хранения attack log
|
||||
- Grafana дашборды
|
||||
|
||||
### Шаги
|
||||
|
||||
```bash
|
||||
# 1. Поднять стек мониторинга
|
||||
docker compose up -d clickhouse prometheus grafana
|
||||
|
||||
# 2. Создать таблицы ClickHouse
|
||||
clickhouse-client --query "
|
||||
CREATE DATABASE IF NOT EXISTS rampart;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rampart.blocked (
|
||||
ts DateTime CODEC(Delta, ZSTD),
|
||||
edge LowCardinality(String),
|
||||
src_ip IPv4,
|
||||
src_asn UInt32,
|
||||
src_country LowCardinality(FixedString(2)),
|
||||
reason LowCardinality(String),
|
||||
proto_ver Int32,
|
||||
hostname String CODEC(ZSTD)
|
||||
) ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(ts)
|
||||
ORDER BY (ts, edge, src_ip)
|
||||
TTL ts + INTERVAL 90 DAY;
|
||||
"
|
||||
|
||||
# 3. Настроить scrape targets в prometheus.yml
|
||||
# 4. Импортировать Grafana dashboard
|
||||
# 5. Ничего рестартить не нужно - метрики уже встроены
|
||||
```
|
||||
|
||||
### Проверка
|
||||
|
||||
```bash
|
||||
curl -s http://EDGE_IP:9090/metrics | grep rampart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.3 → v0.4 (XDP)
|
||||
|
||||
### Изменения
|
||||
- XDP программа на C
|
||||
- libbpf-rs для загрузки в ядро
|
||||
- Feature flag: `xdp`
|
||||
|
||||
### Шаги
|
||||
|
||||
```bash
|
||||
# 1. Проверить совместимость
|
||||
systemd-detect-virt # нужно kvm или none
|
||||
uname -r # нужно 5.10+
|
||||
ethtool -i eth0 # драйвер
|
||||
|
||||
# 2. Установить зависимости
|
||||
sudo apt-get install -y libbpf-dev clang llvm linux-headers-$(uname -r)
|
||||
|
||||
# 3. Собрать с XDP
|
||||
cargo build --release --features xdp
|
||||
|
||||
# 4. Обновить бинарник
|
||||
cp /usr/local/bin/rampart-core /usr/local/bin/rampart-core.backup
|
||||
cp target/release/rampart-core /usr/local/bin/
|
||||
|
||||
# 5. Включить XDP в конфиге
|
||||
cat >> /etc/rampart/config.toml << 'EOF'
|
||||
[xdp]
|
||||
enabled = true
|
||||
interface = "eth0"
|
||||
EOF
|
||||
|
||||
# 6. Рестарт
|
||||
systemctl restart rampart-edge
|
||||
|
||||
# 7. Проверить
|
||||
journalctl -u rampart-edge | grep XDP
|
||||
ip link show | grep xdp
|
||||
```
|
||||
|
||||
### Откат
|
||||
|
||||
```bash
|
||||
# Отключить XDP
|
||||
rampart config set xdp_enabled false
|
||||
systemctl restart rampart-edge
|
||||
# Вернуть старый бинарник
|
||||
cp /usr/local/bin/rampart-core.backup /usr/local/bin/rampart-core
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.4 → v0.5 (Anti-Bot)
|
||||
|
||||
### Изменения
|
||||
- GeoIP (MaxMind GeoLite2)
|
||||
- Sonar 3.0 интеграция
|
||||
- Challenge API
|
||||
|
||||
### Шаги
|
||||
|
||||
```bash
|
||||
# 1. Зарегистрироваться на maxmind.com, скачать GeoLite2-ASN
|
||||
# 2. Разместить базу на Manager
|
||||
mkdir -p /var/lib/rampart/geoip
|
||||
cp GeoLite2-ASN.mmdb /var/lib/rampart/geoip/
|
||||
|
||||
# 3. Обновить конфиг edge
|
||||
cat >> /etc/rampart/config.toml << 'EOF'
|
||||
[geoip]
|
||||
db_path = "/var/lib/rampart/geoip/GeoLite2-ASN.mmdb"
|
||||
# ASN с повышенным скорингом
|
||||
vpn_asns = [16276, 24940, 20473]
|
||||
datacenter_asns = [16509, 14618, 8075]
|
||||
EOF
|
||||
|
||||
# 4. Обновить Velocity плагин (с поддержкой Sonar)
|
||||
cp plugins/velocity/target/rampart-velocity-*.jar /opt/velocity/plugins/
|
||||
systemctl restart velocity
|
||||
|
||||
# 5. Проверить
|
||||
rampart geoip lookup 1.2.3.4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.5 → v0.6 (Scale + HA)
|
||||
|
||||
### Изменения
|
||||
- Rust LB вместо HAProxy
|
||||
- mTLS между всеми компонентами
|
||||
- QUIC канал Edge ↔ Manager
|
||||
- NATS JetStream
|
||||
|
||||
### Шаги
|
||||
|
||||
```bash
|
||||
# 1. Развернуть NATS
|
||||
docker compose up -d nats
|
||||
|
||||
# 2. Обновить конфиг Manager
|
||||
cat >> /etc/rampart/manager.toml << 'EOF'
|
||||
[nats]
|
||||
urls = ["nats://127.0.0.1:4222"]
|
||||
|
||||
[quic]
|
||||
bind = "0.0.0.0:7777"
|
||||
EOF
|
||||
|
||||
# 3. Сгенерировать PKI
|
||||
rampart pki init --root-ca rampart-ca
|
||||
rampart pki issue --ca edge-ca --name edge-eu-1 --ip 10.0.100.1
|
||||
rampart pki issue --ca infra-ca --name manager --ip 10.0.0.1
|
||||
|
||||
# 4. Развернуть сертификаты на все ноды
|
||||
# 5. Включить mTLS в конфигах
|
||||
# 6. Постепенно перевести трафик с HAProxy на Rust LB
|
||||
```
|
||||
|
||||
### Миграция с HAProxy
|
||||
|
||||
```bash
|
||||
# Фаза 1: Запустить Rust LB рядом с HAProxy
|
||||
# (разные порты: HAProxy :25565, Rust LB :25566)
|
||||
|
||||
# Фаза 2: Переключить edge ноды на Rust LB
|
||||
# (изменить backend.address в config.toml)
|
||||
|
||||
# Фаза 3: Остановить HAProxy
|
||||
# (когда все edge переключены)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.6 → v0.7 (Polish)
|
||||
|
||||
### Изменения
|
||||
- io_uring runtime (feature flag)
|
||||
- Zero-copy splice
|
||||
- SLSA Level 3
|
||||
|
||||
### Шаги
|
||||
|
||||
```bash
|
||||
# 1. Проверить io_uring доступность
|
||||
cat /proc/sys/kernel/io_uring_disabled # 0 = OK
|
||||
|
||||
# 2. Собрать с io_uring
|
||||
cargo build --release --features io-uring
|
||||
|
||||
# 3. Заменить бинарник
|
||||
cp /usr/local/bin/rampart-core /usr/local/bin/rampart-core.epoll.backup
|
||||
cp target/release/rampart-core /usr/local/bin/
|
||||
systemctl restart rampart-edge
|
||||
|
||||
# 4. Проверить
|
||||
journalctl -u rampart-edge | grep "io_uring"
|
||||
|
||||
# 5. Бенчмарк: сравнить производительность
|
||||
tcpkali --connections 1000 --connect-rate 5000 --duration 30s EDGE_IP:25565
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Чеклист перед любой миграцией
|
||||
|
||||
```
|
||||
☐ Прочитал CHANGELOG
|
||||
☐ Сделал бэкап Redis: redis-cli SAVE
|
||||
☐ Сделал бэкап конфигов: tar czf /backup/rampart-configs-$(date +%Y%m%d).tar.gz /etc/rampart/
|
||||
☐ Сохранил старые бинарники
|
||||
☐ Есть доступ к серверу через OOB/IPMI (на случай если сеть отвалится)
|
||||
☐ Есть откат-план
|
||||
☐ Предупредил команду в Discord
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Июль 2026*
|
||||
68
docs/research/README.md
Normal file
68
docs/research/README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Rampart - Research & Deep Dives
|
||||
|
||||
> Это исследовательская документация. Здесь живут глубокие разборы технологий,
|
||||
> эксперименты и идеи для версий v0.4+.
|
||||
>
|
||||
> Для текущей архитектуры (v0.1-v0.3) смотри `ARCHITECTURE.md` в корне репо.
|
||||
|
||||
---
|
||||
|
||||
## Структура
|
||||
|
||||
| Файл | Что внутри | Актуально с |
|
||||
|---|---|---|
|
||||
| [architecture.md](./architecture.md) | Общая архитектура, компоненты, схемы C4 | v0.1 |
|
||||
| [ddos.md](./ddos.md) | Векторы атак L3/L4/L7, методы защиты, AI-боты | v0.1 |
|
||||
| [ebpf.md](./ebpf.md) | XDP/eBPF фильтр, BPF maps, ringbuf, verifier | v0.4 |
|
||||
| [rust-performance.md](./rust-performance.md) | Zero-copy, io_uring, SO_REUSEPORT, NUMA, profiling | v0.3 |
|
||||
| [io_uring.md](./io_uring.md) | **Объединено с rust-performance.md** | v0.4 |
|
||||
| [haproxy.md](./haproxy.md) | HAProxy конфиг, mTLS, замена на Rust LB | v0.2 |
|
||||
| [envoy.md](./envoy.md) | EWMA балансировка, Circuit Breaker, xDS API | v0.5 |
|
||||
| [observability.md](./observability.md) | Prometheus, OpenTelemetry, ClickHouse, Parca | v0.3 |
|
||||
| [minecraft-protocol.md](./minecraft-protocol.md) | Handshake парсинг, VarInt, Forge, fingerprinting | v0.1 |
|
||||
| [anti-bot.md](./anti-bot.md) | Sonar, challenge системы, AI-обходы, fingerprint | v0.2 |
|
||||
| [benchmark.md](./benchmark.md) | Инструменты, методология, ожидаемые результаты | v0.3 |
|
||||
| [security.md](./security.md) | STRIDE, mTLS, Zero Trust, supply chain | v0.2 |
|
||||
| [networking.md](./networking.md) | WireGuard, BGP Anycast, QUIC, MTU | v0.3 |
|
||||
| [papers.md](./papers.md) | Ссылки на статьи, RFC, проекты для изучения | - |
|
||||
|
||||
---
|
||||
|
||||
## Как читать
|
||||
|
||||
```
|
||||
Хочу написать первую версию (v0.1)
|
||||
→ architecture.md + minecraft-protocol.md + ddos.md
|
||||
|
||||
Хочу добавить защиту от ботов
|
||||
→ anti-bot.md
|
||||
|
||||
Хочу выжать максимум производительности
|
||||
→ rust-performance.md + io_uring.md + ebpf.md
|
||||
|
||||
Хочу настроить мониторинг
|
||||
→ observability.md
|
||||
|
||||
Хочу понять безопасность системы
|
||||
→ security.md + networking.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Операционные документы (корень docs/)
|
||||
|
||||
| Файл | Описание |
|
||||
|---|---|
|
||||
| [deployment.md](../deployment.md) | Пошаговый деплой |
|
||||
| [configuration.md](../configuration.md) | Примеры конфигов |
|
||||
| [vds_compatibility.md](../vds_compatibility.md) | Таблица провайдеров |
|
||||
| [disaster_recovery.md](../disaster_recovery.md) | Failover сценарии |
|
||||
| [runbook.md](../runbook.md) | Инструкции для админа |
|
||||
| [api.md](../api.md) | REST API спецификация |
|
||||
| [testing.md](../testing.md) | Методология тестирования |
|
||||
| [troubleshooting.md](../troubleshooting.md) | FAQ |
|
||||
| [migration.md](../migration.md) | Обновление версий |
|
||||
|
||||
---
|
||||
|
||||
*Версия: 0.5-research | Июль 2026*
|
||||
282
docs/research/anti-bot.md
Normal file
282
docs/research/anti-bot.md
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
# Anti-Bot - Sonar, Challenge системы, Fingerprinting
|
||||
|
||||
> Актуально: v0.2+
|
||||
|
||||
## Путь игрока через защиту
|
||||
|
||||
```
|
||||
Новый игрок
|
||||
|
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ Edge нода │ Rate limit, Blacklist, Death code
|
||||
│ Rust │ Невалидные пакеты → бан IP
|
||||
└────────┬─────────┘
|
||||
v (валидный handshake)
|
||||
┌──────────────────┐
|
||||
│ Velocity │ DomainCheck, HmacCheck
|
||||
│ Java │ Неизвестный домен → блок
|
||||
└────────┬─────────┘
|
||||
v (подписанный HMAC)
|
||||
┌──────────────────┐
|
||||
│ Sonar Limbo │ Гравитация, Vehicle, TCP timing
|
||||
│ Java │ Не прошёл → блок IP на N мин
|
||||
└────────┬─────────┘
|
||||
v (прошёл физику)
|
||||
┌──────────────────┐
|
||||
│ Custom │ Timing challenge, Map CAPTCHA
|
||||
│ Challenge │ Не прошёл → блок IP
|
||||
└────────┬─────────┘
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ Hub / Game │ Игрок на сервере
|
||||
│ Server │ Поведенческий анализ первые 30 сек
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
Каждый слой может заблокировать игрока.
|
||||
Verified DB на Redis - прошёл один раз, не проверяется снова (TTL 24h).
|
||||
|
||||
---
|
||||
|
||||
## Слои защиты от ботов
|
||||
|
||||
```
|
||||
[1] XDP rate limit - ограничивает скорость SYN flood
|
||||
[2] Rust rate limit - ограничивает connections/сек per IP
|
||||
[3] HMAC verification - только через наш edge (криптография)
|
||||
[4] ASN reputation - датацентровые IP = строже
|
||||
[5] Sonar 3.0 (Limbo) - физическая проверка
|
||||
[6] Custom challenge - кастомная механика (нет готового обхода)
|
||||
[7] Behavioral analysis - паттерны поведения на хабе
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sonar 3.0 - базовый слой (июль 2026)
|
||||
|
||||
GitHub: `jonesdevelopment/sonar`
|
||||
Версия: 3.x, релиз 12 июля 2026
|
||||
Поддержка: Velocity 3.4-3.5.x, MC 1.8-26.2
|
||||
|
||||
### Как работает
|
||||
|
||||
```
|
||||
Игрок → Velocity → Sonar перехватывает
|
||||
↓
|
||||
Отправляет на Limbo (лёгкий фейковый сервер)
|
||||
↓
|
||||
Проверки на Limbo:
|
||||
├─ Гравитация: игрок должен падать вниз
|
||||
├─ Vehicle: правильные пакеты при взаимодействии с лодкой
|
||||
├─ TCP timing: не слишком быстрые ответы
|
||||
└─ Очередь: физически ограничивает число одновременных верификаций
|
||||
↓
|
||||
Прошёл → IP в verified DB → следующие подключения проходят мгновенно
|
||||
```
|
||||
|
||||
### Конфиг
|
||||
|
||||
```yaml
|
||||
# sonar/config.yml
|
||||
general:
|
||||
max-online-per-ip: 3
|
||||
min-players-for-attack: 8 # при N+ новых conn/сек → режим атаки
|
||||
|
||||
verification:
|
||||
timing:
|
||||
first-packet: 3500 # мс на первый пакет
|
||||
movement: 10000 # мс на проверку физики
|
||||
gravity:
|
||||
enabled: true
|
||||
captcha-on-fail: true
|
||||
vehicle:
|
||||
enabled: true
|
||||
|
||||
database:
|
||||
type: MYSQL # или POSTGRESQL, H2
|
||||
host: "10.0.0.1"
|
||||
database: "sonar"
|
||||
expiration: 5 # verified IP живёт N дней
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Кастомный challenge (поверх Sonar)
|
||||
|
||||
### Почему нужен кастомный
|
||||
|
||||
```
|
||||
Sonar открытый → атакующий читает код → пишет обход
|
||||
Кастомный → нет готового обхода → атакующий тратит время
|
||||
Меняем механику регулярно → обход устаревает
|
||||
```
|
||||
|
||||
### Идеи challenge (от простого к сложному)
|
||||
|
||||
#### 1. Timing challenge
|
||||
```java
|
||||
// Игрок должен ответить МЕЖДУ 2 и 8 секундами
|
||||
// Боты отвечают мгновенно или с постоянной задержкой
|
||||
|
||||
long sent = System.currentTimeMillis();
|
||||
// ...ждём ответ...
|
||||
long elapsed = System.currentTimeMillis() - sent;
|
||||
|
||||
if (elapsed < 2000) {
|
||||
// Слишком быстро - скрипт
|
||||
fail("Ответ слишком быстрый");
|
||||
} else if (elapsed > 8000) {
|
||||
// AFK/медленный скрипт
|
||||
fail("Время вышло");
|
||||
} else {
|
||||
pass();
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Map CAPTCHA
|
||||
```java
|
||||
// Рендерим картинку на карте Minecraft
|
||||
// Случайный шрифт из пула 50+ шрифтов
|
||||
// Игрок вводит код в чате
|
||||
|
||||
MapRenderer renderer = new CaptchaMapRenderer(challenge.getCode());
|
||||
ItemStack map = new ItemStack(Material.FILLED_MAP);
|
||||
map.setItemMeta(mapMeta);
|
||||
player.getInventory().setItemInMainHand(map);
|
||||
player.sendMessage("§eВведи код с карты в чат:");
|
||||
```
|
||||
|
||||
#### 3. Поведенческий анализ (первые 30 сек на хабе)
|
||||
```java
|
||||
// Смотрим на паттерны движения
|
||||
// Реальный игрок: случайные повороты, ускорения, паузы
|
||||
// Бот: линейное движение или полная неподвижность
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerMove(PlayerMoveEvent e) {
|
||||
BehaviorProfile profile = profiles.get(e.getPlayer().getUniqueId());
|
||||
profile.recordMovement(e.getTo());
|
||||
|
||||
if (profile.getSamples() >= 50) {
|
||||
double score = profile.calculateBotProbability();
|
||||
if (score > 0.85) {
|
||||
triggerChallenge(e.getPlayer());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Контекстный вопрос
|
||||
```java
|
||||
// Вопрос зависит от случайного события на сервере
|
||||
// Бот не знает контекст
|
||||
|
||||
String[] events = {"Последний вошедший игрок", "Текущее время на сервере"};
|
||||
// "Как зовут последнего игрока который зашёл перед тобой?"
|
||||
// Бот не знает → провал
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Репутационная система IP
|
||||
|
||||
```rust
|
||||
// Каждый IP получает score от -100 до +100
|
||||
// Хранится в Redis с TTL
|
||||
|
||||
pub struct IpReputation {
|
||||
score: i32,
|
||||
last_updated: u64,
|
||||
}
|
||||
|
||||
impl IpReputation {
|
||||
pub fn apply_event(&mut self, event: ReputationEvent) {
|
||||
let delta = match event {
|
||||
ReputationEvent::SuccessfulLogin => +10,
|
||||
ReputationEvent::HourWithoutIssues => +5,
|
||||
ReputationEvent::RateLimitHit => -20,
|
||||
ReputationEvent::InvalidPacket => -30,
|
||||
ReputationEvent::BotChallengeFailed => -50,
|
||||
ReputationEvent::BotChallengePass => +15,
|
||||
};
|
||||
self.score = (self.score + delta).clamp(-100, 100);
|
||||
}
|
||||
|
||||
pub fn get_rate_multiplier(&self) -> f64 {
|
||||
match self.score {
|
||||
s if s >= 80 => 2.0, // доверенный - больше лимит
|
||||
s if s >= 0 => 1.0, // нормальный
|
||||
s if s >= -30 => 0.5, // подозрительный
|
||||
s if s >= -60 => 0.2, // проблемный
|
||||
_ => 0.05, // почти в бане
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bloom Filter для блэклиста
|
||||
|
||||
```rust
|
||||
// Для очень больших блэклистов (миллионы IP)
|
||||
// Bloom filter: 1% false positive, но 100x меньше памяти
|
||||
|
||||
// HashSet<u32> на 1M IP: ~32 MB
|
||||
// Bloom filter на 1M IP: ~2 MB при p=0.01
|
||||
|
||||
use bloomfilter::Bloom;
|
||||
|
||||
pub struct FastBlacklist {
|
||||
bloom: Bloom<u32>, // быстрая предпроверка (может дать false positive)
|
||||
exact: DashMap<u32, BanEntry>, // точная проверка (только если bloom сказал "да")
|
||||
}
|
||||
|
||||
impl FastBlacklist {
|
||||
pub fn is_blocked(&self, ip: u32) -> bool {
|
||||
// Если bloom говорит "нет" - точно не в блэклисте (нет false negative)
|
||||
if !self.bloom.check(&ip) { return false; }
|
||||
// Bloom говорит "возможно да" - проверяем точно
|
||||
self.exact.contains_key(&ip)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VPN / Proxy детекция
|
||||
|
||||
```rust
|
||||
pub struct VpnDetector {
|
||||
// MaxMind GeoLite2-ASN + список известных VPN/proxy ASN
|
||||
asn_reader: maxminddb::Reader<Vec<u8>>,
|
||||
vpn_asns: HashSet<u32>,
|
||||
datacenter_keywords: Vec<Regex>,
|
||||
}
|
||||
|
||||
impl VpnDetector {
|
||||
pub fn classify(&self, ip: IpAddr) -> IpCategory {
|
||||
let Ok(record) = self.asn_reader.lookup::<Asn>(ip) else {
|
||||
return IpCategory::Unknown;
|
||||
};
|
||||
|
||||
if let Some(asn) = record.autonomous_system_number {
|
||||
if self.vpn_asns.contains(&asn) {
|
||||
return IpCategory::VPN;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(org) = record.autonomous_system_organization {
|
||||
if self.datacenter_keywords.iter().any(|r| r.is_match(org)) {
|
||||
return IpCategory::Datacenter;
|
||||
}
|
||||
}
|
||||
|
||||
IpCategory::Residential
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Список VPN ASN: https://github.com/X4BNet/lists_vpn (обновляется еженедельно)
|
||||
> MaxMind GeoLite2-ASN: бесплатно при регистрации на maxmind.com
|
||||
127
docs/research/architecture.md
Normal file
127
docs/research/architecture.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Architecture - Rampart
|
||||
|
||||
> Актуально: v0.1+
|
||||
> Статус: основной документ
|
||||
|
||||
---
|
||||
|
||||
## Компоненты системы
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ EDGE LAYER │
|
||||
│ XDP/eBPF (C) → Rust Core → mTLS/QUIC → Manager │
|
||||
└─────────────────────────┬───────────────────────────────────────┘
|
||||
│ чистый трафик
|
||||
┌─────────────────────────▼───────────────────────────────────────┐
|
||||
│ PROXY LAYER │
|
||||
│ Rust Load Balancer → Velocity Cluster (x20) │
|
||||
└─────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
Hub (x100) Game Servers Game Servers
|
||||
лобби Survival (x100) Skyblock (x100)
|
||||
разные VDS/дедики
|
||||
```
|
||||
|
||||
## Типы нод и требования к хостингу
|
||||
|
||||
| Нода | Роль | CPU | RAM | Тип VDS | XDP нужен |
|
||||
|---|---|---|---|---|---|
|
||||
| **Edge** | Фильтрация DDoS | 2-4 vCPU | 2-4 GB | KVM / Bare Metal | ✅ |
|
||||
| **Load Balancer** | L4 балансировка | 2 vCPU | 2 GB | KVM | ❌ |
|
||||
| **Velocity** | MC Proxy | 4 vCPU | 4-8 GB | KVM | ❌ |
|
||||
| **Manager** | API + Redis + NATS | 2-4 vCPU | 4-8 GB | KVM | ❌ |
|
||||
| **Hub** | Лобби сервер | 4-8 vCPU | 8-16 GB | KVM / Bare Metal | ❌ |
|
||||
| **Game Server** | Игровой процесс | 4-8 vCPU | 8-32 GB | KVM / Bare Metal | ❌ |
|
||||
|
||||
> ⚠️ **Важно:** XDP требует KVM или Bare Metal.
|
||||
> OpenVZ / LXC контейнеры - XDP не работает вообще.
|
||||
> Проверить тип виртуализации: `systemd-detect-virt`
|
||||
|
||||
## Sizing Guide
|
||||
|
||||
| Игроков онлайн | Edge нод | Velocity нод | Память Edge | Стоимость/мес (примерно) |
|
||||
|---|---|---|---|---|
|
||||
| до 500 | 1 | 2 | 2 GB | ~$15-30 |
|
||||
| до 2 000 | 2 | 4 | 4 GB | ~$40-80 |
|
||||
| до 10 000 | 4-6 | 8-10 | 8 GB | ~$150-300 |
|
||||
| до 50 000 | 10-15 | 15-20 | 16 GB | ~$600-1200 |
|
||||
|
||||
> Цены ориентировочные для Hetzner/Contabo/Vultr. Bare Metal дешевле при большом трафике.
|
||||
|
||||
## Выбор WireGuard решения (для v0.1-v0.3)
|
||||
|
||||
**Используем hub-and-spoke + wg-quick.** Это просто, надёжно, понятно.
|
||||
|
||||
```
|
||||
Manager нода = WireGuard Hub (10.0.0.1)
|
||||
Все остальные ноды = Spoke, пиры с Hub
|
||||
```
|
||||
|
||||
Headscale / Nebula / Tailscale - рассматриваем в v0.6+, когда нод станет 50+.
|
||||
|
||||
## Граница XDP / Rust (важно)
|
||||
|
||||
```
|
||||
XDP делает: Rust делает:
|
||||
L3: IP блэклист L7: MC handshake парсинг
|
||||
L4: SYN flood drop HMAC подпись hostname
|
||||
L4: rate limit (pps) rate limit (connections/sec)
|
||||
L4: invalid TCP flags блэклист (сложные правила)
|
||||
L4: UDP drop (MC=TCP) bot challenge
|
||||
GeoIP/ASN lookup
|
||||
```
|
||||
|
||||
XDP **не делает** HMAC, SHA256, GeoIP lookup - нет floating point до kernel 6.x,
|
||||
нет доступа к heap, нет сложной логики. Всё L7 - только в Rust userspace.
|
||||
|
||||
## C4 - Container Diagram
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Edge["Edge Layer (VDS)"]
|
||||
XDP[XDP Filter\nC/eBPF\nL3/L4 only]
|
||||
Core[Rust Core\nL7 filter + HMAC]
|
||||
end
|
||||
|
||||
subgraph Core_Infra["Core Infrastructure"]
|
||||
LB[Rust Load Balancer]
|
||||
Vel[Velocity Cluster\nJava x20]
|
||||
Mgr[Manager API\nRust + Axum]
|
||||
Redis[(Redis\nServer Registry\nBlacklist)]
|
||||
NATS[NATS JetStream\nCritical Events]
|
||||
CH[(ClickHouse\nAttack Log)]
|
||||
end
|
||||
|
||||
subgraph Backends["Game Backends (WireGuard)"]
|
||||
Hub[Hub x100]
|
||||
Game[Game Servers x300]
|
||||
end
|
||||
|
||||
XDP --> Core --> LB --> Vel --> Hub --> Game
|
||||
Core -->|blacklist events| NATS
|
||||
NATS --> Mgr
|
||||
Mgr --> Redis
|
||||
Mgr --> CH
|
||||
Vel <-->|server registry| Redis
|
||||
```
|
||||
|
||||
## ADR-001: Rust для Edge Core
|
||||
|
||||
**Решение:** Rust + tokio
|
||||
**Альтернативы:** Go (GC паузы неприемлемы), C (небезопасен), Java (память)
|
||||
**Причина:** Zero-cost abstractions, memory safety, нет GC, интеграция с libbpf-rs
|
||||
|
||||
## ADR-002: Redis как хранилище состояния
|
||||
|
||||
**Решение:** Redis + локальный кэш на edge нодах
|
||||
**Оговорка:** При падении Redis - edge работает с кэшем блэклиста, Velocity с кэшем серверов
|
||||
**Масштаб:** Redis Cluster при 1000+ серверов, Redis Sentinel для HA
|
||||
|
||||
## ADR-003: NATS для критических событий
|
||||
|
||||
**Решение:** NATS JetStream для blacklist updates, attack events, audit log
|
||||
**Причина:** Redis Pub/Sub - fire-and-forget, NATS - at-least-once delivery
|
||||
**Redis Pub/Sub оставляем для:** server registry updates, global chat (потеря допустима)
|
||||
275
docs/research/benchmark.md
Normal file
275
docs/research/benchmark.md
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
# Benchmark - Инструменты и методология
|
||||
|
||||
> Актуально: v0.3+
|
||||
|
||||
---
|
||||
|
||||
## Инструменты
|
||||
|
||||
| Инструмент | Что измеряет | Когда |
|
||||
|---|---|---|
|
||||
| **tcpkali** | TCP conn/sec, throughput | Основной benchmark |
|
||||
| **SoulFire** | Реальные MC боты (Fabric код) | Anti-bot тест |
|
||||
| **BotMark** | Быстрые MC handshake | Handshake throughput |
|
||||
| **hping3** | SYN flood | XDP тест |
|
||||
| **pktgen** | Max pps (kernel module) | XDP верхний предел |
|
||||
| **iperf3** | Bandwidth | Throughput VDS |
|
||||
| **cargo bench** | Rust unit benchmarks | Парсер, HMAC, rate limit |
|
||||
|
||||
---
|
||||
|
||||
## Методология
|
||||
|
||||
### Правила честного бенчмарка
|
||||
|
||||
```
|
||||
1. Изолированная среда - никаких фоновых процессов
|
||||
2. Прогрев (warm-up) - первые 10 сек не считаются
|
||||
3. Несколько прогонов - минимум 3, берём медиану
|
||||
4. Одна переменная - меняем одно за раз
|
||||
5. Фиксируем конфигурацию - версия ядра, CPU, RAM, NIC
|
||||
6. Не на той же машине - источник нагрузки на отдельном VDS
|
||||
```
|
||||
|
||||
### Конфигурация тестового стенда
|
||||
|
||||
```
|
||||
Тестируемый (edge нода):
|
||||
VDS: Hetzner CX31 (4 vCPU, 8GB, 1Gbps, KVM)
|
||||
OS: Ubuntu 22.04 LTS
|
||||
Kernel: 5.15.x
|
||||
NIC: virtio (XDP generic mode)
|
||||
|
||||
Источник нагрузки (отдельный VDS в той же сети):
|
||||
VDS: Hetzner CX21 (2 vCPU, 4GB, 1Gbps)
|
||||
|
||||
Измеряем:
|
||||
CPU edge ноды: htop / top
|
||||
Память: /proc/meminfo
|
||||
Connections: ss -s
|
||||
Latency: tcpkali --latency-percentiles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tcpkali - основной инструмент
|
||||
|
||||
```bash
|
||||
# Установка
|
||||
cargo install tcpkali # или apt install tcpkali
|
||||
|
||||
# Тест 1: новых соединений/сек
|
||||
tcpkali \
|
||||
--connections 1000 \
|
||||
--connect-rate 5000 \ # 5000 новых conn/сек
|
||||
--duration 30s \
|
||||
--message-rate 0 \ # без данных - только коннект
|
||||
TARGET_IP:25565
|
||||
|
||||
# Тест 2: активные соединения + трафик
|
||||
tcpkali \
|
||||
--connections 50000 \ # 50k одновременно
|
||||
--connect-rate 1000 \
|
||||
--duration 60s \
|
||||
--message-rate 1 \ # 1 msg/сек от каждого
|
||||
--message "$(cat mc_handshake.bin)" \
|
||||
TARGET_IP:25565
|
||||
|
||||
# Тест 3: latency percentiles
|
||||
tcpkali \
|
||||
--connections 1000 \
|
||||
--connect-rate 500 \
|
||||
--duration 30s \
|
||||
--latency-connect \ # измеряем latency до connect
|
||||
--latency-percentiles 50,95,99,99.9 \
|
||||
TARGET_IP:25565
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SoulFire - реальные MC боты
|
||||
|
||||
```bash
|
||||
# SoulFire запускает настоящий Fabric MC клиент
|
||||
# Боты ведут себя как реальные игроки на уровне протокола
|
||||
|
||||
# Скачать: github.com/AlexProgrammerDE/SoulFire
|
||||
java -jar SoulFire.jar \
|
||||
--target play.server.com:25565 \
|
||||
--amount 500 \ # 500 ботов
|
||||
--join-delay 100 \ # 100мс между подключениями
|
||||
--protocol-version 765 # MC 1.20.4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## hping3 - SYN flood
|
||||
|
||||
```bash
|
||||
# ТОЛЬКО для тестирования своих серверов!
|
||||
# Запускать с отдельного VDS
|
||||
|
||||
# SYN flood
|
||||
hping3 -S --flood -p 25565 TARGET_IP
|
||||
|
||||
# С рандомным src IP (проверяем uRPF)
|
||||
hping3 -S --flood -p 25565 --rand-source TARGET_IP
|
||||
|
||||
# Смотрим на XDP счётчики
|
||||
watch -n 1 'cat /sys/kernel/debug/tracing/trace_pipe'
|
||||
# или через наш /metrics endpoint
|
||||
curl http://TARGET_IP:9090/metrics | grep xdp_drops
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rust unit benchmarks
|
||||
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
[[bench]]
|
||||
name = "core_benchmarks"
|
||||
harness = false
|
||||
```
|
||||
|
||||
```rust
|
||||
// benches/core_benchmarks.rs
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
|
||||
|
||||
fn bench_handshake_parse(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("handshake_parse");
|
||||
|
||||
// Разные варианты hostname
|
||||
let cases = vec![
|
||||
("vanilla", build_handshake("play.server.com", 765, 2)),
|
||||
("forge", build_handshake("play.server.com\0FML2\0", 765, 2)),
|
||||
("hmac", build_handshake("play.server.com\0shield\0abcdef", 765, 2)),
|
||||
];
|
||||
|
||||
for (name, packet) in &cases {
|
||||
group.bench_with_input(BenchmarkId::new("parse", name), packet, |b, p| {
|
||||
b.iter(|| McHandshake::parse(black_box(p)))
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_hmac(c: &mut Criterion) {
|
||||
let secret = b"test_secret_32_bytes_long_here!!";
|
||||
let hostname = "play.server.com";
|
||||
let signed = sign_hostname(hostname, secret);
|
||||
|
||||
let mut group = c.benchmark_group("hmac");
|
||||
group.bench_function("sign", |b| {
|
||||
b.iter(|| sign_hostname(black_box(hostname), secret))
|
||||
});
|
||||
group.bench_function("verify", |b| {
|
||||
b.iter(|| verify_hostname(black_box(&signed), secret))
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_rate_limiter(c: &mut Criterion) {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let limiter = RateLimiter::new(100, 10.0);
|
||||
let ips: Vec<IpAddr> = (0..1000u32)
|
||||
.map(|i| IpAddr::V4(Ipv4Addr::from(i)))
|
||||
.collect();
|
||||
|
||||
c.bench_function("rate_limit_check", |b| {
|
||||
b.to_async(&rt).iter(|| async {
|
||||
let ip = ips[fastrand::usize(..ips.len())];
|
||||
limiter.check(black_box(ip)).await
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_handshake_parse, bench_hmac, bench_rate_limiter);
|
||||
criterion_main!(benches);
|
||||
```
|
||||
|
||||
```bash
|
||||
# Запуск
|
||||
cargo bench
|
||||
|
||||
# HTML отчёт в target/criterion/
|
||||
open target/criterion/report/index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> ⚠️ **Важное уточнение:** Цифры 110k conn/s - для **synthetic echo benchmark** (простое прокси без L7 парсинга).
|
||||
> Реальная производительность Rampart (handshake парсинг + HMAC + DashMap + rate limit) на 4 vCPU:
|
||||
> - **~60-70k conn/s** (реалистично для v0.1-v0.3 на epoll)
|
||||
> - **~85-95k conn/s** (с io_uring)
|
||||
>
|
||||
> Для простого TCP proxy без L7 логики - 110k+.
|
||||
> Для точных цифр - прогони `cargo bench` на своём железе.
|
||||
|
||||
## Ожидаемые результаты (Hetzner CX31, 4 vCPU)
|
||||
|
||||
```
|
||||
Unit benchmarks:
|
||||
handshake_parse (vanilla): ~160 ns → 6.2M парсингов/сек
|
||||
handshake_parse (forge): ~180 ns → 5.5M парсингов/сек
|
||||
hmac_sign: ~820 ns → 1.2M подписей/сек
|
||||
hmac_verify: ~840 ns → 1.2M верификаций/сек
|
||||
rate_limit_check: ~220 ns → 4.5M проверок/сек
|
||||
|
||||
Системные (epoll / tokio):
|
||||
Новых соединений/сек: ~80,000
|
||||
Активных соединений: ~200,000
|
||||
CPU при 80k conn/s: ~65%
|
||||
|
||||
Системные (io_uring):
|
||||
Новых соединений/сек: ~110,000 (+37%)
|
||||
Активных соединений: ~260,000
|
||||
CPU при 110k conn/s: ~48%
|
||||
|
||||
XDP (generic mode на virtio):
|
||||
Drop rate: ~3-5M pps
|
||||
CPU при 3M pps: ~25%
|
||||
|
||||
XDP (native, bare metal):
|
||||
Drop rate: ~15-20M pps
|
||||
CPU при 10M pps: ~15%
|
||||
```
|
||||
|
||||
### Таблица для README
|
||||
|
||||
```markdown
|
||||
## Performance
|
||||
|
||||
Tested on Hetzner CX31 (4 vCPU, 8GB, KVM), Ubuntu 22.04, kernel 5.15
|
||||
|
||||
| Mode | New conn/s | Active conn | CPU |
|
||||
|---|---|---|---|
|
||||
| 1 core, epoll | 20k | 50k | ~100% |
|
||||
| 4 core, epoll | 80k | 200k | ~65% |
|
||||
| 4 core, io_uring | 110k | 260k | ~48% |
|
||||
| XDP drop (generic) | 3-5M pps | - | ~25% |
|
||||
| XDP drop (native) | 15-20M pps | - | ~15% |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Профилирование под нагрузкой
|
||||
|
||||
```bash
|
||||
# 1. Запускаем нагрузку
|
||||
tcpkali --connections 50000 --connect-rate 5000 --duration 300s TARGET:25565 &
|
||||
|
||||
# 2. Пока идёт нагрузка - снимаем профиль CPU
|
||||
perf record -g -p $(pgrep rampart-edge) -- sleep 30
|
||||
perf report --stdio | head -100
|
||||
|
||||
# 3. Flamegraph
|
||||
cargo flamegraph --pid $(pgrep rampart-edge) --output flamegraph.svg
|
||||
open flamegraph.svg
|
||||
|
||||
# 4. tokio-console - смотрим какие async tasks тормозят
|
||||
tokio-console http://TARGET:6669
|
||||
```
|
||||
292
docs/research/ddos.md
Normal file
292
docs/research/ddos.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# DDoS - Векторы атак и защита
|
||||
|
||||
> Актуально: v0.1+
|
||||
> Это лучший раздел документации - глубокий разбор всех известных векторов.
|
||||
|
||||
---
|
||||
|
||||
## Как трафик проходит через защиту
|
||||
|
||||
```
|
||||
Атакующий (ботнет)
|
||||
|
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ 1. NIC / XDP │ L3/L4: SYN flood, UDP drop, IP blacklist
|
||||
│ (kernel, C) │ CPU < 30%, дроп до 10M pps
|
||||
└────────┬─────────┘
|
||||
v (чистый TCP)
|
||||
┌──────────────────┐
|
||||
│ 2. Rust Core │ L7: парсинг handshake, HMAC, rate limit
|
||||
│ (userspace) │ death code auto-ban, blacklist check
|
||||
└────────┬─────────┘
|
||||
v (валидный MC клиент)
|
||||
┌──────────────────┐
|
||||
│ 3. Load │ Round-robin, circuit breaker
|
||||
│ Balancer/Proxy │ TPS < 12 = server out
|
||||
└────────┬─────────┘
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ 4. Game Server │ Чистый трафик, без DDoS нагрузки
|
||||
│ (Velocity/Hub) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
Каждый слой отрабатывает и дропает до перехода к следующему.
|
||||
XDP отсекает L3/L4 флуд, Rust - L7 атаки на протокол MC.
|
||||
|
||||
---
|
||||
|
||||
## L3/L4 атаки (объёмные)
|
||||
|
||||
| Атака | Механизм | Защита | Слой |
|
||||
|---|---|---|---|
|
||||
| **UDP Flood** | Миллионы UDP пакетов | MC = TCP, UDP дропается на уровне NIC | XDP |
|
||||
| **SYN Flood** | Миллионы TCP SYN без ACK | SYN cookies в ядре Linux | XDP + sysctl |
|
||||
| **ACK Flood** | Пакеты с ACK без SYN | Stateful connection tracking | XDP |
|
||||
| **ICMP Flood** | Ping flood | Отключить ICMP ответы | sysctl |
|
||||
| **Amplification** | DNS/NTP усиление | Фильтрация у провайдера (UDP) | Upstream |
|
||||
| **Invalid flags** | TCP с мусорными флагами | XDP дроп по флагам | XDP |
|
||||
| **IP Spoof** | Поддельный src IP | BPF map проверка + uRPF | XDP |
|
||||
|
||||
### sysctl для L3/L4 защиты
|
||||
|
||||
```bash
|
||||
# SYN flood
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
net.ipv4.tcp_synack_retries = 2
|
||||
net.ipv4.tcp_syn_retries = 2
|
||||
|
||||
# ICMP
|
||||
net.ipv4.icmp_echo_ignore_all = 1
|
||||
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||
|
||||
# Общие буферы
|
||||
net.core.rmem_max = 134217728
|
||||
net.core.wmem_max = 134217728
|
||||
net.core.somaxconn = 65535
|
||||
net.core.netdev_max_backlog = 65535
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
net.ipv4.ip_local_port_range = 1024 65535
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## L7 атаки (Minecraft-специфичные)
|
||||
|
||||
### Handshake Flood
|
||||
Боты коннектятся тысячами, шлют валидный handshake, дропают.
|
||||
|
||||
```
|
||||
Детект: connections/sec с одного IP > threshold
|
||||
Защита: rate limit (token bucket) в Rust
|
||||
Параметры: max 5 conn/IP/сек, burst 10
|
||||
```
|
||||
|
||||
### Bot Join Flood
|
||||
Тысячи фейковых логинов с разных IP.
|
||||
|
||||
```
|
||||
Детект: LoginStart без предшествующего challenge
|
||||
Защита: Sonar antibot (физика на limbo) + custom challenge
|
||||
Параметры: очередь 100 одновременных верификаций
|
||||
```
|
||||
|
||||
### Ping Flood (Status Request)
|
||||
Тысячи пакетов с next_state=1 (не логин, просто пинг).
|
||||
|
||||
```
|
||||
Детект: status requests/сек > threshold с IP
|
||||
Защита: отдельный rate limit для status (next_state=1)
|
||||
Параметры: max 2 status/IP/10сек
|
||||
```
|
||||
|
||||
### Slow Loris (MC вариант)
|
||||
Открывают TCP, шлют handshake по 1 байту каждые несколько секунд - занимают слоты.
|
||||
|
||||
```
|
||||
Детект: время на handshake > 5 сек
|
||||
Защита: connection timeout (5 сек на получение полного handshake)
|
||||
Rust: tokio::time::timeout(Duration::from_secs(5), read_handshake())
|
||||
```
|
||||
|
||||
### Fragmented Handshake
|
||||
Handshake пакет разбит на несколько TCP сегментов - ломает парсеры.
|
||||
|
||||
```
|
||||
Детект: невозможно, это нормальный TCP
|
||||
Защита: robust парсер с reassembly буфером
|
||||
читаем до N байт пока не получим полный пакет
|
||||
timeout если слишком долго
|
||||
```
|
||||
|
||||
### Fake Forge Flood
|
||||
Бесконечный поток Forge handshake с мусорными mod list - ломает парсер.
|
||||
|
||||
```
|
||||
Детект: mod list длиннее разумного (> 500 модов)
|
||||
Защита: max_hostname_length = 4096, дроп при превышении
|
||||
парсер с явными bounds check на каждый VarInt
|
||||
```
|
||||
|
||||
### VarInt Overflow
|
||||
Специально сформированные VarInt которые вызывают integer overflow.
|
||||
|
||||
```
|
||||
Детект: VarInt > 5 байт (по MC протоколу)
|
||||
Защита: строгий bounds check, паника = DROP не crash
|
||||
|
||||
// Правильный парсер с защитой
|
||||
fn read_varint(buf: &[u8]) -> Result<(i32, usize), Error> {
|
||||
let mut value: i32 = 0;
|
||||
let mut position = 0;
|
||||
for (i, &byte) in buf.iter().enumerate() {
|
||||
if i >= 5 { return Err(Error::VarIntTooBig); } // MAX 5 байт
|
||||
value |= ((byte & 0x7F) as i32) << position;
|
||||
if (byte & 0x80) == 0 { return Ok((value, i + 1)); }
|
||||
position += 7;
|
||||
}
|
||||
Err(Error::Incomplete)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI-боты (2026)
|
||||
|
||||
### Проблема
|
||||
|
||||
Современные attack frameworks используют AI и базы CAPTCHA решений:
|
||||
- Боты проходят физику Sonar (реализован настоящий MC движок)
|
||||
- Боты решают математические задачи в чате
|
||||
- Боты кликают на блоки по описанию
|
||||
- LimboFilter полностью обходится
|
||||
|
||||
### Что всё ещё работает
|
||||
|
||||
```
|
||||
✓ HMAC верификация - только через наш edge (криптография)
|
||||
✓ Rate limit на edge - физически ограничивает скорость
|
||||
✓ ASN блокировка - датацентры не могут быть "жилыми" IP
|
||||
✓ Репутационная система - долго строить репутацию
|
||||
✓ Кастомный challenge - нет готового обхода
|
||||
✓ Timing analysis - боты отвечают слишком быстро или паттернами
|
||||
```
|
||||
|
||||
### Кастомный challenge - идеи которые сложно автоматизировать
|
||||
|
||||
```
|
||||
1. Timing-based: игрок должен ответить МЕЖДУ 2 и 8 секундами
|
||||
(слишком быстро = бот, слишком медленно = AFK скрипт)
|
||||
|
||||
2. Контекстный вопрос: вопрос зависит от случайного события
|
||||
на сервере в последние 5 минут (бот не знает контекст)
|
||||
|
||||
3. Изменяющаяся механика: challenge меняется каждые 6 часов
|
||||
(атакующий должен постоянно обновлять обход)
|
||||
|
||||
4. Map-based CAPTCHA: картинка рендерится на карте в инвентаре
|
||||
случайным шрифтом из пула 50+ шрифтов
|
||||
|
||||
5. Поведенческий анализ: первые 30 сек на хабе - смотрим
|
||||
на паттерны движения, мыши, взаимодействий
|
||||
```
|
||||
|
||||
### Timing Analysis
|
||||
|
||||
```rust
|
||||
// Боты часто отвечают с константной задержкой
|
||||
// Реальные игроки - с нормальным распределением
|
||||
|
||||
pub struct TimingAnalyzer {
|
||||
response_times: Vec<Duration>,
|
||||
}
|
||||
|
||||
impl TimingAnalyzer {
|
||||
pub fn is_bot_timing(&self, response_time: Duration) -> f64 {
|
||||
let ms = response_time.as_millis() as f64;
|
||||
|
||||
// Слишком быстро - скрипт
|
||||
if ms < 200.0 { return 0.9; }
|
||||
|
||||
// Слишком ровно - паттерн (variance < 10ms за 5 измерений)
|
||||
if self.response_times.len() >= 5 {
|
||||
let variance = self.calculate_variance();
|
||||
if variance < 10.0 { return 0.85; }
|
||||
}
|
||||
|
||||
// Нормальное распределение - человек
|
||||
0.1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Circuit Breaker для перегруженных серверов
|
||||
|
||||
```
|
||||
CLOSED (нормально)
|
||||
↓ TPS < 12 или timeout > 3 сек → OPEN
|
||||
OPEN (сервер выведен)
|
||||
↓ через 30 сек → HALF_OPEN (пробный трафик)
|
||||
HALF_OPEN
|
||||
↓ успешно → CLOSED
|
||||
↓ снова плохо → OPEN
|
||||
```
|
||||
|
||||
```rust
|
||||
pub enum CircuitState { Closed, Open(Instant), HalfOpen }
|
||||
|
||||
impl CircuitBreaker {
|
||||
pub fn should_route(&mut self, server: &ServerEntry) -> bool {
|
||||
match &self.state {
|
||||
CircuitState::Closed => {
|
||||
if server.tps < 12.0 { self.trip(); false }
|
||||
else { true }
|
||||
}
|
||||
CircuitState::Open(tripped_at) => {
|
||||
if tripped_at.elapsed() > Duration::from_secs(30) {
|
||||
self.state = CircuitState::HalfOpen;
|
||||
true // пробуем
|
||||
} else { false }
|
||||
}
|
||||
CircuitState::HalfOpen => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ASN Reputation
|
||||
|
||||
Разные лимиты для разных типов сетей:
|
||||
|
||||
```rust
|
||||
pub enum AsnReputation {
|
||||
Residential, // обычный провайдер → стандартные лимиты
|
||||
Datacenter, // AWS/OVH/Hetzner → строгие лимиты
|
||||
Mobile, // мобильные сети → средние лимиты (NAT!)
|
||||
Tor, // Tor exit node → максимальная строгость
|
||||
Vpn, // известный VPN → настраивается
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// rate limit множитель по типу ASN
|
||||
fn rate_limit_multiplier(rep: &AsnReputation) -> f64 {
|
||||
match rep {
|
||||
AsnReputation::Residential => 1.0,
|
||||
AsnReputation::Mobile => 0.5, // NAT - много игроков с 1 IP
|
||||
AsnReputation::Datacenter => 0.2,
|
||||
AsnReputation::Vpn => 0.3,
|
||||
AsnReputation::Tor => 0.05,
|
||||
AsnReputation::Unknown => 0.5,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ Мобильные сети используют NAT - один IP = много реальных игроков.
|
||||
> Не блокируй мобильные ASN полностью, только снижай лимит.
|
||||
340
docs/research/ebpf.md
Normal file
340
docs/research/ebpf.md
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
# eBPF / XDP - Фильтрация уровня ядра
|
||||
|
||||
> Актуально: v0.4+
|
||||
> Требует: Linux kernel 5.10+, KVM или Bare Metal (не OpenVZ/LXC)
|
||||
|
||||
---
|
||||
|
||||
## Почему XDP
|
||||
|
||||
```
|
||||
Обычный путь пакета (без XDP):
|
||||
NIC → driver → kernel TCP stack → socket buffer → userspace → решение
|
||||
|
||||
XDP путь:
|
||||
NIC driver → XDP_DROP (ещё до kernel stack)
|
||||
Никаких аллокаций, никаких копий, никаких syscall
|
||||
```
|
||||
|
||||
| Метод | Задержка дропа | CPU на 5M pps | Требует |
|
||||
|---|---|---|---|
|
||||
| iptables | ~10 мкс | ~80% | - |
|
||||
| nftables | ~8 мкс | ~70% | - |
|
||||
| Rust userspace | ~5 мкс | ~50% | - |
|
||||
| **XDP (generic)** | ~2 мкс | ~30% | любой kernel |
|
||||
| **XDP (native)** | ~0.5 мкс | ~15% | поддержка в драйвере NIC |
|
||||
| **XDP (offload)** | ~0.1 мкс | ~0% | SmartNIC |
|
||||
|
||||
Для большинства VDS - native XDP (Intel i40e, Mellanox ConnectX).
|
||||
|
||||
---
|
||||
|
||||
## Граница ответственности (критично)
|
||||
|
||||
```
|
||||
XDP МОЖЕТ: XDP НЕ МОЖЕТ:
|
||||
IP блэклист (LPM_TRIE) HMAC-SHA256 (нет floating point < kernel 6.x)
|
||||
SYN flood rate limit GeoIP lookup (нет heap allocation)
|
||||
Invalid TCP flags drop DNS resolve
|
||||
UDP drop (MC = TCP only) Сложные строковые операции
|
||||
Port whitelist Вызов userspace функций
|
||||
Per-IP packet rate Блокировать по hostname
|
||||
BPF map read/write TLS инспекция
|
||||
```
|
||||
|
||||
Всё L7 (handshake парсинг, HMAC, hostname проверка) - **только в Rust userspace**.
|
||||
|
||||
---
|
||||
|
||||
## Структура BPF Maps
|
||||
|
||||
```c
|
||||
// maps.h
|
||||
|
||||
// Блэклист IP (LPM - Longest Prefix Match, поддерживает CIDR)
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_LPM_TRIE);
|
||||
__uint(max_entries, 100000);
|
||||
__type(key, struct lpm_key); // prefixlen + ip
|
||||
__type(value, __u64); // timestamp бана
|
||||
__uint(map_flags, BPF_F_NO_PREALLOC);
|
||||
} blacklist_map SEC(".maps");
|
||||
|
||||
// Rate limit per IP (LRU - автоматически вытесняет старые)
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_LRU_PERCPU_HASH);
|
||||
__uint(max_entries, 500000);
|
||||
__type(key, __u32); // src IP
|
||||
__type(value, struct rate_entry);
|
||||
} rate_map SEC(".maps");
|
||||
|
||||
// Whitelist доверенных IP (edge нод например)
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_HASH);
|
||||
__uint(max_entries, 1000);
|
||||
__type(key, __u32);
|
||||
__type(value, __u8); // просто флаг
|
||||
} trusted_map SEC(".maps");
|
||||
|
||||
// Статистика (для Prometheus)
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
|
||||
__uint(max_entries, 16);
|
||||
__type(key, __u32); // индекс счётчика
|
||||
__type(value, __u64);
|
||||
} stats_map SEC(".maps");
|
||||
|
||||
// Ringbuf для передачи событий в userspace (быстрее perfbuf)
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_RINGBUF);
|
||||
__uint(max_entries, 1 << 24); // 16 MB
|
||||
} events SEC(".maps");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## XDP программа (C)
|
||||
|
||||
```c
|
||||
// xdp_filter.c
|
||||
|
||||
#include <linux/bpf.h>
|
||||
#include <linux/if_ether.h>
|
||||
#include <linux/ip.h>
|
||||
#include <linux/tcp.h>
|
||||
#include <bpf/bpf_helpers.h>
|
||||
#include <bpf/bpf_endian.h>
|
||||
#include "maps.h"
|
||||
|
||||
#define MC_PORT 25565
|
||||
#define RATE_LIMIT_PPS 20 // пакетов/сек с одного IP
|
||||
#define BAN_DURATION_NS 60000000000ULL // 60 сек
|
||||
|
||||
// Статистические индексы
|
||||
#define STAT_TOTAL 0
|
||||
#define STAT_BLOCKED 1
|
||||
#define STAT_RATELIM 2
|
||||
|
||||
static __always_inline void inc_stat(__u32 idx) {
|
||||
__u64 *val = bpf_map_lookup_elem(&stats_map, &idx);
|
||||
if (val) __sync_fetch_and_add(val, 1);
|
||||
}
|
||||
|
||||
SEC("xdp")
|
||||
int minecraft_xdp_filter(struct xdp_md *ctx) {
|
||||
void *data = (void *)(long)ctx->data;
|
||||
void *data_end = (void *)(long)ctx->data_end;
|
||||
|
||||
inc_stat(STAT_TOTAL);
|
||||
|
||||
// ── Парсим Ethernet ──
|
||||
struct ethhdr *eth = data;
|
||||
if ((void *)(eth + 1) > data_end) return XDP_PASS;
|
||||
if (eth->h_proto != bpf_htons(ETH_P_IP)) return XDP_PASS;
|
||||
|
||||
// ── Парсим IP ──
|
||||
struct iphdr *ip = (void *)(eth + 1);
|
||||
if ((void *)(ip + 1) > data_end) return XDP_PASS;
|
||||
if (ip->protocol != IPPROTO_TCP) return XDP_PASS; // UDP → дроп неявный (MC=TCP)
|
||||
|
||||
__u32 src_ip = ip->saddr;
|
||||
|
||||
// ── Whitelist (наши edge ноды, manager) ──
|
||||
if (bpf_map_lookup_elem(&trusted_map, &src_ip)) return XDP_PASS;
|
||||
|
||||
// ── Парсим TCP ──
|
||||
struct tcphdr *tcp = (void *)ip + (ip->ihl * 4);
|
||||
if ((void *)(tcp + 1) > data_end) return XDP_PASS;
|
||||
if (tcp->dest != bpf_htons(MC_PORT)) return XDP_PASS;
|
||||
|
||||
// ── Блэклист проверка ──
|
||||
struct lpm_key key = { .prefixlen = 32, .ip = src_ip };
|
||||
__u64 *ban_ts = bpf_map_lookup_elem(&blacklist_map, &key);
|
||||
if (ban_ts) {
|
||||
__u64 now = bpf_ktime_get_ns();
|
||||
if (now - *ban_ts < BAN_DURATION_NS) {
|
||||
inc_stat(STAT_BLOCKED);
|
||||
return XDP_DROP;
|
||||
}
|
||||
bpf_map_delete_elem(&blacklist_map, &key);
|
||||
}
|
||||
|
||||
// ── Invalid TCP flags ──
|
||||
// Дропаем пакеты с мусорными флагами (не SYN, не ACK, не PSH+ACK)
|
||||
__u8 flags = ((__u8 *)tcp)[13];
|
||||
if ((flags & 0x3F) == 0) { // нет флагов вообще
|
||||
inc_stat(STAT_BLOCKED);
|
||||
return XDP_DROP;
|
||||
}
|
||||
|
||||
// ── SYN rate limit ──
|
||||
if (tcp->syn && !tcp->ack) {
|
||||
struct rate_entry *entry = bpf_map_lookup_elem(&rate_map, &src_ip);
|
||||
__u64 now = bpf_ktime_get_ns();
|
||||
|
||||
if (entry) {
|
||||
// Простой sliding window
|
||||
if (now - entry->window_start < 1000000000ULL) { // 1 сек
|
||||
if (entry->count >= RATE_LIMIT_PPS) {
|
||||
// Баним
|
||||
__u64 ban_ts = now;
|
||||
bpf_map_update_elem(&blacklist_map, &key, &ban_ts, BPF_ANY);
|
||||
inc_stat(STAT_RATELIM);
|
||||
inc_stat(STAT_BLOCKED);
|
||||
return XDP_DROP;
|
||||
}
|
||||
__sync_fetch_and_add(&entry->count, 1);
|
||||
} else {
|
||||
// Новое окно
|
||||
entry->window_start = now;
|
||||
entry->count = 1;
|
||||
}
|
||||
} else {
|
||||
struct rate_entry new_entry = { .window_start = now, .count = 1 };
|
||||
bpf_map_update_elem(&rate_map, &src_ip, &new_entry, BPF_ANY);
|
||||
}
|
||||
}
|
||||
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
char _license[] SEC("license") = "GPL";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rust loader (libbpf-rs)
|
||||
|
||||
```rust
|
||||
// xdp/loader.rs
|
||||
|
||||
use libbpf_rs::{MapFlags, Object, ObjectBuilder};
|
||||
|
||||
pub struct XdpFilter {
|
||||
obj: Object,
|
||||
interface: String,
|
||||
}
|
||||
|
||||
impl XdpFilter {
|
||||
pub fn load(interface: &str) -> Result<Self> {
|
||||
let obj = ObjectBuilder::default()
|
||||
.open_file("/etc/rampart/xdp_filter.o")?
|
||||
.load()?;
|
||||
|
||||
// Аттачим XDP программу к интерфейсу
|
||||
let prog = obj.prog("minecraft_xdp_filter").unwrap();
|
||||
prog.attach_xdp(if_nametoindex(interface)?)?;
|
||||
|
||||
Ok(Self { obj, interface: interface.to_string() })
|
||||
}
|
||||
|
||||
// Добавляем IP в блэклист из Rust (обновляем BPF map)
|
||||
pub fn ban_ip(&self, ip: Ipv4Addr, duration: Duration) {
|
||||
let mut map = self.obj.map("blacklist_map").unwrap();
|
||||
let key = LpmKey::new(32, ip);
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos() as u64;
|
||||
map.update(&key.to_bytes(), &ts.to_le_bytes(), MapFlags::ANY).unwrap();
|
||||
}
|
||||
|
||||
// Читаем статистику
|
||||
pub fn get_stats(&self) -> XdpStats {
|
||||
let map = self.obj.map("stats_map").unwrap();
|
||||
XdpStats {
|
||||
total: read_percpu_sum(&map, 0),
|
||||
blocked: read_percpu_sum(&map, 1),
|
||||
ratelim: read_percpu_sum(&map, 2),
|
||||
}
|
||||
}
|
||||
|
||||
// Читаем события из ringbuf (атаки, баны)
|
||||
pub async fn read_events(&self, tx: mpsc::Sender<XdpEvent>) {
|
||||
let mut ringbuf = RingBuffer::new();
|
||||
ringbuf.add(self.obj.map("events").unwrap(), move |data| {
|
||||
let event: XdpEvent = unsafe { *(data.as_ptr() as *const XdpEvent) };
|
||||
let _ = tx.try_send(event);
|
||||
0
|
||||
}).unwrap();
|
||||
|
||||
loop {
|
||||
ringbuf.poll(Duration::from_millis(10)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cargo.toml для XDP компонента
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libbpf-rs = "0.23"
|
||||
libbpf-sys = "1.4"
|
||||
|
||||
[build-dependencies]
|
||||
libbpf-cargo = "0.23" # автокомпиляция .c → .o в build.rs
|
||||
```
|
||||
|
||||
```rust
|
||||
// build.rs
|
||||
use libbpf_cargo::SkeletonBuilder;
|
||||
|
||||
fn main() {
|
||||
SkeletonBuilder::new()
|
||||
.source("src/bpf/xdp_filter.c")
|
||||
.build_and_generate("src/bpf/xdp_filter.skel.rs")
|
||||
.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Требования к окружению
|
||||
|
||||
```bash
|
||||
# Проверка что XDP поддерживается
|
||||
ethtool -i eth0 | grep driver # должен быть i40e, mlx5, или virtio
|
||||
|
||||
# Проверка типа виртуализации
|
||||
systemd-detect-virt
|
||||
# kvm → XDP работает (native или generic)
|
||||
# none → bare metal → XDP native
|
||||
# openvz / lxc → XDP НЕ работает
|
||||
|
||||
# Проверка версии ядра
|
||||
uname -r
|
||||
# >= 5.10 - достаточно для нашего XDP
|
||||
# >= 6.0 - полный функционал (float в eBPF, CO-RE стабильный)
|
||||
|
||||
# Установка зависимостей (Ubuntu 22.04+)
|
||||
apt-get install -y libbpf-dev clang llvm linux-headers-$(uname -r)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ringbuf vs perfbuf
|
||||
|
||||
| | perfbuf | ringbuf (kernel 5.8+) |
|
||||
|---|---|---|
|
||||
| Тип | Per-CPU кольцевой буфер | Один разделяемый буфер |
|
||||
| Копирование | Одно | Одно |
|
||||
| Порядок событий | Не гарантирован | Гарантирован |
|
||||
| Потребление памяти | Per-CPU | Меньше |
|
||||
| **Вывод** | Устаревший | **Используй ringbuf** |
|
||||
|
||||
---
|
||||
|
||||
## Известные лимиты BPF verifier
|
||||
|
||||
```
|
||||
Максимум инструкций: 1M (kernel 5.2+, раньше 4096)
|
||||
Максимум стека: 512 байт
|
||||
Максимум вложенности: 8 уровней (loops разрешены с 5.3+)
|
||||
Циклы: разрешены, но верификатор считает итерации
|
||||
Динамический allocation: нет (только BPF maps)
|
||||
```
|
||||
|
||||
Если программа не проходит верификатор - упрости логику или разбей на несколько программ в цепочке (TC + XDP).
|
||||
240
docs/research/envoy.md
Normal file
240
docs/research/envoy.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Envoy - EWMA, Circuit Breaker, xDS API
|
||||
|
||||
> Актуально: v0.5+
|
||||
> Envoy как референс для алгоритмов балансировки.
|
||||
|
||||
---
|
||||
|
||||
## EWMA балансировщик (как в Envoy)
|
||||
|
||||
### Почему LEAST_CONN недостаточно
|
||||
|
||||
```
|
||||
Проблема:
|
||||
Сервер A: 50 игроков, TPS 20 (быстрый)
|
||||
Сервер B: 48 игроков, TPS 12 (лагающий)
|
||||
LEAST_CONN выберет B → плохо
|
||||
|
||||
EWMA учитывает реальное время ответа:
|
||||
Сервер A: быстро → высокий score → больше игроков
|
||||
Сервер B: медленно → низкий score → меньше игроков
|
||||
```
|
||||
|
||||
### Формула
|
||||
|
||||
```
|
||||
effective_load = rtt_ewma × (active_requests + 1)
|
||||
rtt_ewma_new = α × rtt_ewma_old + (1 - α) × rtt_sample
|
||||
α = 0.95 (decay, параметр сглаживания)
|
||||
```
|
||||
|
||||
### Реализация
|
||||
|
||||
```rust
|
||||
// balancer/ewma.rs
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub struct EwmaBackend {
|
||||
pub name: String,
|
||||
rtt_ewma_us: AtomicU64, // EWMA в микросекундах
|
||||
active: AtomicU64,
|
||||
}
|
||||
|
||||
impl EwmaBackend {
|
||||
pub fn new(name: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
rtt_ewma_us: AtomicU64::new(1000), // стартовое значение 1мс
|
||||
active: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_rtt(&self, rtt: Duration) {
|
||||
let sample = rtt.as_micros() as u64;
|
||||
let old = self.rtt_ewma_us.load(Ordering::Relaxed);
|
||||
// EWMA: 95% старое + 5% новое измерение
|
||||
let new = (old * 95 + sample * 5) / 100;
|
||||
self.rtt_ewma_us.store(new, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn effective_load(&self) -> u64 {
|
||||
let rtt = self.rtt_ewma_us.load(Ordering::Relaxed);
|
||||
let active = self.active.load(Ordering::Relaxed);
|
||||
rtt.saturating_mul(active + 1)
|
||||
}
|
||||
|
||||
pub fn acquire(&self) { self.active.fetch_add(1, Ordering::Relaxed); }
|
||||
pub fn release(&self) { self.active.fetch_sub(1, Ordering::Relaxed); }
|
||||
}
|
||||
|
||||
pub struct EwmaBalancer {
|
||||
backends: Vec<Arc<EwmaBackend>>,
|
||||
}
|
||||
|
||||
impl EwmaBalancer {
|
||||
pub fn select(&self) -> Option<Arc<EwmaBackend>> {
|
||||
self.backends.iter()
|
||||
.min_by_key(|b| b.effective_load())
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Circuit Breaker
|
||||
|
||||
```
|
||||
CLOSED → нормальная работа, трафик идёт
|
||||
↓ TPS < 12 или timeout > 3 сек подряд (N раз)
|
||||
OPEN → сервер выведен из ротации
|
||||
↓ через 30 сек (recovery timeout)
|
||||
HALF_OPEN → пробный трафик (1 соединение)
|
||||
↓ успешно → CLOSED
|
||||
↓ снова ошибка → OPEN (увеличиваем timeout × 2)
|
||||
```
|
||||
|
||||
```rust
|
||||
// balancer/circuit_breaker.rs
|
||||
|
||||
pub enum State {
|
||||
Closed,
|
||||
Open { tripped_at: Instant, timeout: Duration },
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
pub struct CircuitBreaker {
|
||||
state: State,
|
||||
failure_count: u32,
|
||||
failure_threshold: u32, // сколько ошибок до OPEN
|
||||
}
|
||||
|
||||
impl CircuitBreaker {
|
||||
pub fn should_route(&mut self) -> bool {
|
||||
match &self.state {
|
||||
State::Closed => true,
|
||||
|
||||
State::Open { tripped_at, timeout } => {
|
||||
if tripped_at.elapsed() >= *timeout {
|
||||
self.state = State::HalfOpen;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
State::HalfOpen => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_success(&mut self) {
|
||||
self.failure_count = 0;
|
||||
self.state = State::Closed;
|
||||
}
|
||||
|
||||
pub fn record_failure(&mut self) {
|
||||
self.failure_count += 1;
|
||||
if self.failure_count >= self.failure_threshold {
|
||||
let timeout = match &self.state {
|
||||
State::Open { timeout, .. } => *timeout * 2, // exponential backoff
|
||||
_ => Duration::from_secs(30),
|
||||
};
|
||||
self.state = State::Open {
|
||||
tripped_at: Instant::now(),
|
||||
timeout: timeout.min(Duration::from_secs(300)), // max 5 мин
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health Scoring
|
||||
|
||||
```rust
|
||||
pub fn calculate_health_score(server: &ServerEntry) -> f64 {
|
||||
let tps_score = (server.tps / 20.0).min(1.0); // 0..1
|
||||
let player_score = 1.0 - (server.online as f64 / server.max_players as f64);
|
||||
let mspt_score = (1.0 - server.mspt / 50.0).max(0.0); // 50ms MSPT = 0 score
|
||||
let ram_score = 1.0 - (server.ram_used as f64 / server.ram_max as f64);
|
||||
|
||||
// Взвешенная сумма
|
||||
tps_score * 0.40
|
||||
+ player_score * 0.30
|
||||
+ mspt_score * 0.20
|
||||
+ ram_score * 0.10
|
||||
}
|
||||
|
||||
// Балансировщик выбирает по score вместо LEAST_CONN
|
||||
pub fn select_by_score(servers: &[ServerEntry]) -> Option<&ServerEntry> {
|
||||
servers.iter()
|
||||
.filter(|s| calculate_health_score(s) > 0.3) // минимальный порог
|
||||
.max_by(|a, b| {
|
||||
calculate_health_score(a)
|
||||
.partial_cmp(&calculate_health_score(b))
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consistent Hashing (друзья на одном Hub)
|
||||
|
||||
```rust
|
||||
// Игроки с одной группой попадают на один Hub
|
||||
// При добавлении новых Hubs - минимальная миграция игроков
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub struct ConsistentHash {
|
||||
ring: BTreeMap<u64, String>, // hash → server_name
|
||||
vnodes: u32, // виртуальные ноды (больше = равномернее)
|
||||
}
|
||||
|
||||
impl ConsistentHash {
|
||||
pub fn new(vnodes: u32) -> Self {
|
||||
Self { ring: BTreeMap::new(), vnodes }
|
||||
}
|
||||
|
||||
pub fn add_server(&mut self, name: &str) {
|
||||
for i in 0..self.vnodes {
|
||||
let key = hash(&format!("{}-{}", name, i));
|
||||
self.ring.insert(key, name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_server(&self, player_uuid: &Uuid) -> Option<&str> {
|
||||
if self.ring.is_empty() { return None; }
|
||||
let hash = hash(&player_uuid.to_string());
|
||||
// Идём по кольцу вправо от hash
|
||||
self.ring.range(hash..)
|
||||
.next()
|
||||
.or_else(|| self.ring.iter().next())
|
||||
.map(|(_, name)| name.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
// Применение: для хабов, где важно чтобы друзья были рядом
|
||||
// Для game серверов - EWMA (важна нагрузка, не стабильность)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## xDS API (Envoy паттерн для динамической конфигурации)
|
||||
|
||||
> Актуально v0.6+ - если нод станет 100+
|
||||
|
||||
xDS - протокол от Envoy/Istio для динамической доставки конфигурации нодам. Вместо того чтобы каждая нода поллила Redis - Manager пушит изменения через gRPC stream.
|
||||
|
||||
```
|
||||
Manager (xDS сервер)
|
||||
↓ gRPC stream (двунаправленный)
|
||||
Edge ноды / LB ноды (xDS клиенты)
|
||||
|
||||
При изменении конфига (новый сервер, новое правило):
|
||||
Manager → push → все ноды получают обновление мгновенно
|
||||
Нет поллинга, нет задержки
|
||||
```
|
||||
183
docs/research/haproxy.md
Normal file
183
docs/research/haproxy.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# HAProxy и свой Rust Load Balancer
|
||||
|
||||
> HAProxy - хорошее начало для v0.1-v0.2.
|
||||
> Свой Rust LB - цель для v0.4 (убирает SPOF, добавляет MC-aware health check).
|
||||
|
||||
---
|
||||
|
||||
## Проблема с HAProxy
|
||||
|
||||
```
|
||||
HAProxy как единственная точка входа = Single Point of Failure
|
||||
|
||||
Если HAProxy упал:
|
||||
Все 20 Velocity нод недоступны
|
||||
Все игроки дисконнектятся
|
||||
Нет автоматического failover
|
||||
|
||||
Решение:
|
||||
v0.1-v0.2: HAProxy + keepalived (VRRP failover)
|
||||
v0.4+: Собственный Rust LB (несколько инстансов + SO_REUSEPORT)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HAProxy конфиг (v0.1)
|
||||
|
||||
```
|
||||
# /etc/haproxy/haproxy.cfg
|
||||
|
||||
global
|
||||
maxconn 100000
|
||||
log /dev/log local0
|
||||
stats socket /run/haproxy/admin.sock mode 660 level admin
|
||||
|
||||
defaults
|
||||
mode tcp
|
||||
timeout connect 3s
|
||||
timeout client 30s
|
||||
timeout server 30s
|
||||
option tcplog
|
||||
|
||||
# ── Входящие игроки (от edge нод) ──
|
||||
frontend minecraft_in
|
||||
bind *:25565
|
||||
mode tcp
|
||||
|
||||
# Принимаем только от наших edge нод
|
||||
acl is_edge_ip src 10.0.100.0/24
|
||||
tcp-request connection reject if !is_edge_ip
|
||||
|
||||
default_backend velocity_pool
|
||||
|
||||
# ── Velocity кластер ──
|
||||
backend velocity_pool
|
||||
mode tcp
|
||||
balance leastconn # наименьшее число активных соединений
|
||||
option tcp-check # проверяем что порт открыт
|
||||
|
||||
# check inter 3s - проверяем каждые 3 сек
|
||||
# rise 2 - нужно 2 успеха чтобы считать живым
|
||||
# fall 3 - 3 неудачи → выводим из ротации
|
||||
server vel1 10.0.0.2:25565 check inter 3s rise 2 fall 3
|
||||
server vel2 10.0.0.3:25565 check inter 3s rise 2 fall 3
|
||||
server vel3 10.0.0.4:25565 check inter 3s rise 2 fall 3
|
||||
# ... до vel20
|
||||
|
||||
# ── Stats страница (для Prometheus) ──
|
||||
frontend stats
|
||||
bind 10.0.0.1:8404
|
||||
stats enable
|
||||
stats uri /stats
|
||||
stats refresh 10s
|
||||
stats auth admin:${HAPROXY_STATS_PASS}
|
||||
```
|
||||
|
||||
## HAProxy + keepalived (устраняет SPOF)
|
||||
|
||||
```
|
||||
# Два HAProxy сервера, один активный (MASTER), второй резервный (BACKUP)
|
||||
# Виртуальный IP переключается автоматически при падении MASTER
|
||||
|
||||
# /etc/keepalived/keepalived.conf (на MASTER)
|
||||
vrrp_instance VI_1 {
|
||||
state MASTER
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 100 # MASTER имеет высший приоритет
|
||||
|
||||
authentication {
|
||||
auth_type PASS
|
||||
auth_pass rampart
|
||||
}
|
||||
|
||||
virtual_ipaddress {
|
||||
10.0.0.1/24 # виртуальный IP, на него смотрят edge ноды
|
||||
}
|
||||
|
||||
notify_master "/etc/keepalived/notify.sh MASTER"
|
||||
notify_backup "/etc/keepalived/notify.sh BACKUP"
|
||||
}
|
||||
|
||||
# На BACKUP: state BACKUP, priority 90
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Свой Rust Load Balancer (v0.4)
|
||||
|
||||
### Преимущества
|
||||
|
||||
```
|
||||
✓ Нет SPOF - несколько инстансов на разных машинах
|
||||
✓ SO_REUSEPORT - линейный scale по CPU
|
||||
✓ MC-aware health check (не просто TCP, а настоящий MC ping)
|
||||
✓ Hot reload без рестарта (добавить/убрать Velocity)
|
||||
✓ Нативная интеграция с Redis/NATS
|
||||
✓ Метрики в формате Prometheus из коробки
|
||||
```
|
||||
|
||||
### MC-aware Health Check
|
||||
|
||||
```rust
|
||||
// Не просто TCP connect, а настоящий MC Status ping
|
||||
async fn check_velocity_health(addr: &SocketAddr) -> bool {
|
||||
let mut stream = match tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
TcpStream::connect(addr)
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Шлём MC Handshake (next_state=1, status ping)
|
||||
let handshake = build_mc_handshake("health.check", addr.port(), 1);
|
||||
if stream.write_all(&handshake).await.is_err() { return false; }
|
||||
|
||||
// Шлём Status Request (0x00)
|
||||
let status_req = vec![0x01, 0x00];
|
||||
if stream.write_all(&status_req).await.is_err() { return false; }
|
||||
|
||||
// Ждём Status Response
|
||||
let mut buf = vec![0u8; 1024];
|
||||
match tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 5 => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hot Reload
|
||||
|
||||
```rust
|
||||
pub struct RustLoadBalancer {
|
||||
backends: Arc<ArcSwap<Vec<Backend>>>, // ArcSwap - lock-free swap
|
||||
}
|
||||
|
||||
impl RustLoadBalancer {
|
||||
// Атомарная замена списка бэкендов - без блокировки
|
||||
pub async fn reload(&self, new_backends: Vec<Backend>) {
|
||||
self.backends.store(Arc::new(new_backends));
|
||||
// Текущие соединения не прерываются
|
||||
// Новые соединения идут по новому списку
|
||||
}
|
||||
}
|
||||
|
||||
// ArcSwap из crates.io: arc-swap = "1"
|
||||
```
|
||||
|
||||
### Несколько инстансов без SPOF
|
||||
|
||||
```
|
||||
# На трёх разных машинах запускаем Rust LB
|
||||
# Edge ноды видят все три через DNS round-robin или BGP anycast
|
||||
|
||||
DNS:
|
||||
lb.internal A → 10.0.0.10 (LB1)
|
||||
lb.internal A → 10.0.0.11 (LB2)
|
||||
lb.internal A → 10.0.0.12 (LB3)
|
||||
|
||||
Если LB1 упал:
|
||||
DNS TTL = 10 сек → edge ноды переключаются на LB2/LB3
|
||||
Без keepalived, без VRRP, без единой точки отказа
|
||||
```
|
||||
139
docs/research/io_uring.md
Normal file
139
docs/research/io_uring.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# io_uring - Async I/O нового поколения
|
||||
|
||||
> Актуально: v0.4+
|
||||
> Требует: Linux 5.10+ (стабильный), 6.0+ (полный функционал)
|
||||
> Текущий код на tokio (epoll). io_uring - future optimization.
|
||||
|
||||
---
|
||||
|
||||
## epoll vs io_uring
|
||||
|
||||
```
|
||||
epoll (tokio сейчас):
|
||||
read() → syscall → копирование в userspace buf → возврат
|
||||
На каждую операцию: минимум 1 syscall + 1 копия
|
||||
|
||||
io_uring:
|
||||
Кладём запросы в submission queue (shared memory)
|
||||
Ядро обрабатывает батчем, результаты в completion queue
|
||||
Нет syscall per operation (только sq_enter раз в батч)
|
||||
Нет копирования (registered buffers)
|
||||
```
|
||||
|
||||
### Когда разница заметна
|
||||
|
||||
```
|
||||
10k соединений: epoll ≈ io_uring (разница < 5%)
|
||||
100k соединений: io_uring +15-20%
|
||||
1M соединений: io_uring +35-40%
|
||||
|
||||
Для edge ноды с 50-200k активных соединений - заметно.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Рантаймы сравнение
|
||||
|
||||
| Рантайм | Базируется на | Когда использовать |
|
||||
|---|---|---|
|
||||
| **tokio** (текущий) | epoll | v0.1-v0.3, универсально, стабильно |
|
||||
| **tokio-uring** | io_uring | v0.4+, Linux only, edge ноды |
|
||||
| **glommio** | io_uring, thread-per-core | v0.5+, высокая изоляция |
|
||||
| **monoio** | io_uring, Tencent | v0.6+, максимальная пропускная способность |
|
||||
|
||||
> **Monoio** показывает лучшие числа на синтетических echo-бенчмарках,
|
||||
> но для L7 (handshake парсинг, HMAC) разница с tokio-uring минимальна.
|
||||
> Начинай с tokio, переходи на tokio-uring если профайлер покажет I/O bottleneck.
|
||||
|
||||
---
|
||||
|
||||
## Реализация через feature flag
|
||||
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[features]
|
||||
default = []
|
||||
io-uring = ["dep:tokio-uring"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-uring = { version = "0.5", optional = true }
|
||||
```
|
||||
|
||||
```rust
|
||||
// src/runtime.rs
|
||||
|
||||
pub fn run(config: Config) {
|
||||
#[cfg(feature = "io-uring")]
|
||||
{
|
||||
println!("Запуск с io_uring runtime");
|
||||
tokio_uring::start(async { crate::edge::run(config).await });
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "io-uring"))]
|
||||
{
|
||||
println!("Запуск с epoll (tokio)");
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(num_cpus::get())
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(crate::edge::run(config));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Обычная сборка (epoll, работает везде)
|
||||
cargo build --release
|
||||
|
||||
# С io_uring (Linux 5.10+)
|
||||
cargo build --release --features io-uring
|
||||
|
||||
# Проверить версию ядра перед включением
|
||||
uname -r # должно быть 5.10+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registered Buffers (продвинутый уровень)
|
||||
|
||||
```rust
|
||||
// Регистрируем буферы один раз в ядре
|
||||
// Потом read/write используют эти буферы без копирования
|
||||
|
||||
use tokio_uring::buf::IoBuf;
|
||||
|
||||
// При старте - регистрируем пул буферов
|
||||
let buffers: Vec<Vec<u8>> = (0..1024)
|
||||
.map(|_| vec![0u8; 4096])
|
||||
.collect();
|
||||
|
||||
// io_uring читает прямо в зарегистрированный буфер
|
||||
// Нет copy_to_user, нет дополнительной аллокации
|
||||
let (result, buf) = stream.read(buf).await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ограничения io_uring
|
||||
|
||||
```
|
||||
✗ Только Linux (macOS/Windows → epoll fallback)
|
||||
✗ Требует kernel 5.10+ (stable features)
|
||||
✗ Некоторые VDS провайдеры блокируют io_uring
|
||||
(security concerns, проверь: ls /proc/sys/kernel/io_uring_*)
|
||||
✗ Не все операции имеют io_uring версии
|
||||
✗ Сложнее debug (нет привычного strace для каждой операции)
|
||||
```
|
||||
|
||||
### Проверка доступности на VDS
|
||||
|
||||
```bash
|
||||
# Проверяем что io_uring не заблокирован
|
||||
cat /proc/sys/kernel/io_uring_disabled
|
||||
# 0 = разрешён, 1 = только root, 2 = запрещён
|
||||
|
||||
# Пробуем запустить простой io_uring тест
|
||||
cargo run --example io_uring_test --features io-uring
|
||||
```
|
||||
303
docs/research/minecraft-protocol.md
Normal file
303
docs/research/minecraft-protocol.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# Minecraft Protocol - Парсинг, VarInt, Fingerprinting
|
||||
|
||||
> Актуально: v0.1+
|
||||
> Основа всей фильтрации - знание протокола.
|
||||
|
||||
---
|
||||
|
||||
## Handshake пакет (0x00) - структура
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ VarInt │ Packet Length │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ VarInt │ Packet ID = 0x00 │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ VarInt │ Protocol Version │
|
||||
│ │ 765 = 1.20.4, 769 = 1.21.4, 766 = 26.1 │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ String │ Server Address (hostname) │
|
||||
│ │ VarInt (length) + UTF-8 bytes │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ UShort │ Server Port (big-endian, 2 bytes) │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ VarInt │ Next State: 1 = Status, 2 = Login │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VarInt - строгий парсер с bounds check
|
||||
|
||||
```rust
|
||||
// minecraft/varint.rs
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum VarIntError {
|
||||
Incomplete, // данных меньше чем нужно
|
||||
TooBig, // VarInt > 5 байт (не по спецификации)
|
||||
Overflow, // значение выходит за i32
|
||||
}
|
||||
|
||||
pub fn read_varint(buf: &[u8], start: usize) -> Result<(i32, usize), VarIntError> {
|
||||
let mut value: i32 = 0;
|
||||
let mut shift = 0;
|
||||
|
||||
for (i, &byte) in buf[start..].iter().enumerate() {
|
||||
if i >= 5 {
|
||||
// MC VarInt максимум 5 байт - всё что больше: атака
|
||||
return Err(VarIntError::TooBig);
|
||||
}
|
||||
|
||||
let segment = (byte & 0x7F) as i32;
|
||||
|
||||
// Проверяем overflow до сдвига
|
||||
if shift >= 32 || (shift == 28 && segment > 0x0F) {
|
||||
return Err(VarIntError::Overflow);
|
||||
}
|
||||
|
||||
value |= segment << shift;
|
||||
shift += 7;
|
||||
|
||||
if (byte & 0x80) == 0 {
|
||||
return Ok((value, start + i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
Err(VarIntError::Incomplete)
|
||||
}
|
||||
|
||||
// VarString = VarInt (length) + UTF-8 bytes
|
||||
pub fn read_string(buf: &[u8], start: usize) -> Result<(String, usize), ParseError> {
|
||||
let (len, after_len) = read_varint(buf, start)?;
|
||||
|
||||
if len < 0 || len > 32767 {
|
||||
return Err(ParseError::StringTooLong);
|
||||
}
|
||||
|
||||
let end = after_len + len as usize;
|
||||
if end > buf.len() {
|
||||
return Err(ParseError::Incomplete);
|
||||
}
|
||||
|
||||
let s = std::str::from_utf8(&buf[after_len..end])
|
||||
.map_err(|_| ParseError::InvalidUtf8)?
|
||||
.to_string();
|
||||
|
||||
Ok((s, end))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Полный парсер handshake
|
||||
|
||||
```rust
|
||||
// minecraft/handshake.rs
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct McHandshake {
|
||||
pub protocol_version: i32,
|
||||
pub server_address: String,
|
||||
pub server_port: u16,
|
||||
pub next_state: NextState,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum NextState {
|
||||
Status, // 1 - ping
|
||||
Login, // 2 - игрок заходит
|
||||
Unknown(i32),
|
||||
}
|
||||
|
||||
impl McHandshake {
|
||||
pub fn parse(buf: &[u8]) -> Result<Self, ParseError> {
|
||||
let mut pos = 0;
|
||||
|
||||
// Packet length (игнорируем значение, просто двигаемся дальше)
|
||||
let (_, after_len) = read_varint(buf, pos)?;
|
||||
pos = after_len;
|
||||
|
||||
// Packet ID - должен быть 0x00
|
||||
let (packet_id, after_id) = read_varint(buf, pos)?;
|
||||
pos = after_id;
|
||||
if packet_id != 0x00 {
|
||||
return Err(ParseError::NotHandshake(packet_id));
|
||||
}
|
||||
|
||||
// Protocol version (не валидируем - не хардкодим версии)
|
||||
let (protocol_version, after_pv) = read_varint(buf, pos)?;
|
||||
pos = after_pv;
|
||||
|
||||
// Server address
|
||||
let (server_address, after_addr) = read_string(buf, pos)?;
|
||||
pos = after_addr;
|
||||
|
||||
// Защита от слишком длинного hostname
|
||||
if server_address.len() > 255 {
|
||||
return Err(ParseError::HostnameTooLong);
|
||||
}
|
||||
|
||||
// Server port (big-endian u16)
|
||||
if pos + 2 > buf.len() {
|
||||
return Err(ParseError::Incomplete);
|
||||
}
|
||||
let server_port = u16::from_be_bytes([buf[pos], buf[pos + 1]]);
|
||||
pos += 2;
|
||||
|
||||
// Next state
|
||||
let (next_state_raw, _) = read_varint(buf, pos)?;
|
||||
let next_state = match next_state_raw {
|
||||
1 => NextState::Status,
|
||||
2 => NextState::Login,
|
||||
n => NextState::Unknown(n),
|
||||
};
|
||||
|
||||
Ok(McHandshake {
|
||||
protocol_version,
|
||||
server_address,
|
||||
server_port,
|
||||
next_state,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_login(&self) -> bool {
|
||||
self.next_state == NextState::Login
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hostname суффиксы - Forge, FabricProxy, HMAC
|
||||
|
||||
```
|
||||
Обычный клиент: "play.server.com"
|
||||
Forge (старый): "play.server.com\0FML\0"
|
||||
NeoForge/Forge: "play.server.com\0FML2\0"
|
||||
FabricProxy-Lite: "play.server.com\0" + base64(data)
|
||||
Наш HMAC: "play.server.com\0shield\0<hex_hmac>"
|
||||
|
||||
Комбинации:
|
||||
Forge + HMAC: "play.server.com\0FML2\0\0shield\0<hex_hmac>"
|
||||
```
|
||||
|
||||
### Правильный порядок разбора
|
||||
|
||||
```rust
|
||||
// ВАЖНО: сначала убираем FML суффикс, потом проверяем HMAC
|
||||
// Если делать наоборот - HMAC подпись не совпадёт
|
||||
|
||||
pub struct ParsedHostname {
|
||||
pub domain: String, // "play.server.com"
|
||||
pub forge_marker: Option<String>, // "FML2" если Forge
|
||||
pub hmac: Option<String>, // hex HMAC если прошли через edge
|
||||
}
|
||||
|
||||
pub fn parse_hostname(raw: &str) -> ParsedHostname {
|
||||
let parts: Vec<&str> = raw.split('\0').collect();
|
||||
|
||||
// Ищем "shield" среди частей
|
||||
let shield_pos = parts.iter().position(|&p| p == "shield");
|
||||
|
||||
// Forge маркер - обычно вторая часть
|
||||
let forge_marker = parts.get(1)
|
||||
.filter(|&&p| p == "FML" || p == "FML2" || p == "FML3")
|
||||
.map(|&s| s.to_string());
|
||||
|
||||
ParsedHostname {
|
||||
domain: parts[0].to_string(),
|
||||
forge_marker,
|
||||
hmac: shield_pos.and_then(|i| parts.get(i + 1)).map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client Fingerprinting
|
||||
|
||||
### По handshake
|
||||
|
||||
```rust
|
||||
pub enum ClientType {
|
||||
Vanilla,
|
||||
NeoForge, // \0FML2\0
|
||||
Forge, // \0FML\0
|
||||
FabricProxy, // специфичный base64 суффикс
|
||||
Bot, // подозрительные паттерны
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub fn fingerprint_from_handshake(h: &McHandshake) -> ClientType {
|
||||
let addr = &h.server_address;
|
||||
|
||||
if addr.contains("\0FML2\0") { return ClientType::NeoForge; }
|
||||
if addr.contains("\0FML\0") { return ClientType::Forge; }
|
||||
|
||||
// Очень старый или нестандартный protocol_version
|
||||
if h.protocol_version < 47 || h.protocol_version > 10000 {
|
||||
return ClientType::Bot;
|
||||
}
|
||||
|
||||
ClientType::Unknown
|
||||
}
|
||||
```
|
||||
|
||||
### По plugin channels (после Login)
|
||||
|
||||
```rust
|
||||
// Lunar, Badlion, Feather регистрируют свои каналы через
|
||||
// LoginPluginRequest / PluginChannels пакет
|
||||
|
||||
pub fn fingerprint_from_channels(channels: &[String]) -> Option<ClientType> {
|
||||
for ch in channels {
|
||||
if ch.starts_with("lunarclient:") { return Some(ClientType::LunarClient); }
|
||||
if ch.starts_with("badlion:") { return Some(ClientType::BadlionClient); }
|
||||
if ch.starts_with("feather:") { return Some(ClientType::FeatherClient); }
|
||||
if ch.starts_with("pvplounge:") { return Some(ClientType::PvPLounge); }
|
||||
}
|
||||
None
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Важные нюансы протокола
|
||||
|
||||
```
|
||||
1. Один TCP коннект = один игрок. MC не мультиплексирует.
|
||||
|
||||
2. После Handshake(next_state=2) → LoginStart пакет
|
||||
Если LoginStart не пришёл за 5 сек → это бот. DROP.
|
||||
|
||||
3. Protocol version не хардкодить.
|
||||
Mojang с 2025 использует новую схему (26.1, 26.2...).
|
||||
Принимаем любой валидный VarInt в диапазоне 0..10000.
|
||||
|
||||
4. Hostname может прийти TCP-фрагментированным (несколько сегментов).
|
||||
Парсер должен уметь работать с неполными данными - читать пока
|
||||
не получим полный пакет или timeout.
|
||||
|
||||
5. Status ping (next_state=1) - не требует авторизации.
|
||||
Боты часто используют для разведки (онлайн, версия сервера).
|
||||
Rate limit status отдельно от login.
|
||||
|
||||
6. MC 1.20.2+ использует Configuration phase между Login и Play.
|
||||
Velocity обрабатывает автоматически - нам не важно для edge.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Совместимость версий (июль 2026)
|
||||
|
||||
| Версия MC | Protocol version | Схема |
|
||||
|---|---|---|
|
||||
| 1.20.4 | 765 | Старая |
|
||||
| 1.21.1 | 767 | Старая |
|
||||
| 1.21.4 | 769 | Старая |
|
||||
| 26.1 | 8xx | Новая (Mojang) |
|
||||
| 26.2 | 8xx | Новая (Mojang) |
|
||||
|
||||
Velocity 3.4+ поддерживает обе схемы прозрачно.
|
||||
Sonar 3.x поддерживает 1.8 - 26.2.
|
||||
337
docs/research/networking.md
Normal file
337
docs/research/networking.md
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
# Networking - WireGuard, BGP Anycast, QUIC, MTU
|
||||
|
||||
> Актуально: v0.1+ (WireGuard), v0.5+ (BGP), v0.4+ (QUIC)
|
||||
|
||||
---
|
||||
|
||||
## WireGuard - hub-and-spoke (v0.1-v0.3)
|
||||
|
||||
### Адресация
|
||||
|
||||
```
|
||||
10.0.0.1 Manager + Redis + NATS (главный дедик)
|
||||
10.0.0.2-21 Velocity 1-20
|
||||
10.0.1.1-100 Hub 1-100
|
||||
10.0.2.x Survival серверы
|
||||
10.0.3.x Skyblock серверы
|
||||
10.0.100.x Edge ноды (EU, US, AS...)
|
||||
```
|
||||
|
||||
### Конфиг Manager ноды (Hub)
|
||||
|
||||
```ini
|
||||
# /etc/wireguard/wg0.conf
|
||||
|
||||
[Interface]
|
||||
Address = 10.0.0.1/16
|
||||
PrivateKey = <MANAGER_PRIVATE_KEY>
|
||||
ListenPort = 51820
|
||||
|
||||
# Edge нода EU
|
||||
[Peer]
|
||||
PublicKey = <EDGE_EU_PUBLIC_KEY>
|
||||
AllowedIPs = 10.0.100.1/32
|
||||
|
||||
# Edge нода US
|
||||
[Peer]
|
||||
PublicKey = <EDGE_US_PUBLIC_KEY>
|
||||
AllowedIPs = 10.0.100.2/32
|
||||
|
||||
# Velocity 1
|
||||
[Peer]
|
||||
PublicKey = <VEL1_PUBLIC_KEY>
|
||||
AllowedIPs = 10.0.0.2/32
|
||||
|
||||
# Hub 1
|
||||
[Peer]
|
||||
PublicKey = <HUB1_PUBLIC_KEY>
|
||||
AllowedIPs = 10.0.1.1/32
|
||||
|
||||
# ... и так для каждой ноды
|
||||
```
|
||||
|
||||
### Конфиг Spoke ноды (edge, velocity, hub, game server)
|
||||
|
||||
```ini
|
||||
# /etc/wireguard/wg0.conf на любой spoke ноде
|
||||
|
||||
[Interface]
|
||||
Address = 10.0.100.1/32 # свой адрес в mesh
|
||||
PrivateKey = <THIS_NODE_PRIVATE_KEY>
|
||||
|
||||
# Только один пир - Manager (Hub)
|
||||
[Peer]
|
||||
PublicKey = <MANAGER_PUBLIC_KEY>
|
||||
Endpoint = <MANAGER_PUBLIC_IP>:51820
|
||||
AllowedIPs = 10.0.0.0/16 # весь internal диапазон через hub
|
||||
PersistentKeepalive = 25 # держим туннель через NAT
|
||||
```
|
||||
|
||||
### Авто-генерация конфигов через CLI
|
||||
|
||||
```bash
|
||||
# rampart CLI генерирует wg конфиги для всех нод
|
||||
|
||||
rampart wg init --network 10.0.0.0/16 --hub 185.200.100.1
|
||||
rampart wg add-node --role edge --name edge-eu-1 --public-ip 45.200.10.1
|
||||
rampart wg add-node --role velocity --name vel-1
|
||||
rampart wg add-node --role hub --name hub-1
|
||||
|
||||
# Генерирует файлы:
|
||||
# wg-configs/edge-eu-1/wg0.conf
|
||||
# wg-configs/vel-1/wg0.conf
|
||||
# ...
|
||||
|
||||
# Деплой на ноду
|
||||
scp wg-configs/edge-eu-1/wg0.conf root@45.200.10.1:/etc/wireguard/
|
||||
ssh root@45.200.10.1 'systemctl enable --now wg-quick@wg0'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MTU - важный нюанс
|
||||
|
||||
```
|
||||
Стандартный MTU Ethernet: 1500 байт
|
||||
WireGuard overhead: ~80 байт (заголовок + шифрование)
|
||||
Effective MTU в WG туннеле: 1420 байт
|
||||
|
||||
Если Minecraft пакет > 1420 байт → фрагментация → производительность падает.
|
||||
|
||||
Minecraft пакеты:
|
||||
Handshake: ~50-300 байт ✅ (безопасно)
|
||||
LoginStart: ~30-50 байт ✅
|
||||
Chunk Data: может быть > 1420 байт ⚠️
|
||||
|
||||
Для chunk data: MC клиент и сервер обрабатывают фрагментацию на уровне TCP.
|
||||
Для нашего edge проксирования: мы просто туннелируем TCP стрим,
|
||||
фрагментация прозрачна. Проблем нет.
|
||||
|
||||
Настройка MTU:
|
||||
```
|
||||
|
||||
```ini
|
||||
# /etc/wireguard/wg0.conf
|
||||
[Interface]
|
||||
MTU = 1420 # явно указываем чтобы не было auto-discovery проблем
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Firewall - полный набор правил
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /etc/rampart/firewall.sh
|
||||
|
||||
# ── Edge нода ──
|
||||
setup_edge_firewall() {
|
||||
iptables -F INPUT
|
||||
iptables -F FORWARD
|
||||
iptables -P INPUT DROP
|
||||
iptables -P FORWARD DROP
|
||||
|
||||
# Localhost
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
|
||||
# Established соединения
|
||||
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# WireGuard
|
||||
iptables -A INPUT -p udp --dport 51820 -j ACCEPT
|
||||
|
||||
# Minecraft от всех (мы принимаем атаки здесь и фильтруем)
|
||||
iptables -A INPUT -p tcp --dport 25565 -j ACCEPT
|
||||
|
||||
# SSH (только с нашего IP управления)
|
||||
iptables -A INPUT -p tcp --dport 22 -s ${MGMT_IP} -j ACCEPT
|
||||
|
||||
# Prometheus от Manager
|
||||
iptables -A INPUT -p tcp --dport 9090 -s 10.0.0.1 -j ACCEPT
|
||||
|
||||
# HAProxy stats (для Prometheus)
|
||||
iptables -A INPUT -p tcp --dport 8404 -s 10.0.0.0/16 -j ACCEPT
|
||||
|
||||
# Всё остальное - дроп
|
||||
iptables -A INPUT -j DROP
|
||||
}
|
||||
|
||||
# ── Velocity / HAProxy нода ──
|
||||
setup_backend_firewall() {
|
||||
iptables -F INPUT
|
||||
iptables -P INPUT DROP
|
||||
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# WireGuard
|
||||
iptables -A INPUT -p udp --dport 51820 -j ACCEPT
|
||||
|
||||
# Minecraft только от edge нод через WireGuard
|
||||
iptables -A INPUT -i wg0 -p tcp --dport 25565 -s 10.0.100.0/24 -j ACCEPT
|
||||
|
||||
# SSH
|
||||
iptables -A INPUT -p tcp --dport 22 -s ${MGMT_IP} -j ACCEPT
|
||||
|
||||
# Prometheus сервисы (внутри WG)
|
||||
iptables -A INPUT -p tcp --dport 9091 -s 10.0.0.0/16 -j ACCEPT
|
||||
|
||||
iptables -A INPUT -j DROP
|
||||
}
|
||||
|
||||
# ── Game сервер ──
|
||||
setup_game_firewall() {
|
||||
iptables -F INPUT
|
||||
iptables -P INPUT DROP
|
||||
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# WireGuard
|
||||
iptables -A INPUT -p udp --dport 51820 -j ACCEPT
|
||||
|
||||
# Minecraft только от Velocity нод
|
||||
iptables -A INPUT -i wg0 -p tcp --dport 25565 -s 10.0.0.2/28 -j ACCEPT
|
||||
|
||||
# SSH
|
||||
iptables -A INPUT -p tcp --dport 22 -s ${MGMT_IP} -j ACCEPT
|
||||
|
||||
# Prometheus метрики Paper
|
||||
iptables -A INPUT -p tcp --dport 9092 -s 10.0.0.0/16 -j ACCEPT
|
||||
|
||||
iptables -A INPUT -j DROP
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## QUIC - канал Edge ↔ Manager (v0.4+)
|
||||
|
||||
### Зачем для управляющего канала
|
||||
|
||||
```
|
||||
TCP проблема: Head-of-line blocking
|
||||
Большой blacklist update → блокирует heartbeat → edge думает что manager упал
|
||||
|
||||
QUIC решение: независимые streams
|
||||
Stream 0: heartbeat (5 сек) - не блокируется
|
||||
Stream 1: blacklist updates (push) - независимо
|
||||
Stream 2: metrics (1 сек) - независимо
|
||||
Stream 3: команды (drain/reload) - независимо
|
||||
|
||||
+ 0-RTT reconnect после разрыва (важно для мобильных VDS с нестабильным uplink)
|
||||
+ Встроенный TLS 1.3 (не нужен отдельный слой)
|
||||
```
|
||||
|
||||
### Реализация (quinn)
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
quinn = "0.11"
|
||||
```
|
||||
|
||||
```rust
|
||||
// manager/src/quic.rs
|
||||
|
||||
pub async fn start_quic_server(config: Arc<Config>) -> Result<()> {
|
||||
let tls = build_quic_server_tls(&config.tls);
|
||||
let endpoint = quinn::Endpoint::server(tls, "0.0.0.0:7777".parse()?)?;
|
||||
|
||||
while let Some(incoming) = endpoint.accept().await {
|
||||
let conn = incoming.await?;
|
||||
|
||||
// Получаем identity подключившейся edge ноды из сертификата
|
||||
let node_id = extract_node_id(&conn);
|
||||
tokio::spawn(handle_edge(conn, node_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_edge(conn: quinn::Connection, node_id: String) {
|
||||
// Открываем исходящие streams для push уведомлений
|
||||
let blacklist_tx = conn.open_uni().await.unwrap();
|
||||
|
||||
// Слушаем входящие streams (heartbeat, metrics)
|
||||
loop {
|
||||
match conn.accept_bi().await {
|
||||
Ok((tx, rx)) => {
|
||||
tokio::spawn(handle_stream(tx, rx, node_id.clone()));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("Edge нода {} отключилась", node_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## BGP Anycast (v0.6+)
|
||||
|
||||
> Только если проект вырастет до 10+ edge нод и нужен настоящий anycast.
|
||||
|
||||
### Что нужно
|
||||
|
||||
```
|
||||
1. Свой AS номер - получить через RIPE NCC (Европа) или ARIN (США)
|
||||
Стоимость: ~500€/год членский взнос в RIPE
|
||||
Плюс: купить через LIR (Local Internet Registry) - дешевле
|
||||
|
||||
2. Своя /24 подсеть - 256 IP адресов
|
||||
Получить вместе с AS через RIPE
|
||||
Стоимость: включено в RIPE членство
|
||||
|
||||
3. VDS с поддержкой BGP сессий
|
||||
Vultr, Hetzner (не все локации), Leaseweb, OVH Premium
|
||||
Проверять явно: "BGP sessions supported"
|
||||
|
||||
4. FRRouting на каждой edge ноде
|
||||
```
|
||||
|
||||
### FRRouting конфиг
|
||||
|
||||
```ini
|
||||
# /etc/frr/frr.conf на edge ноде
|
||||
|
||||
router bgp 65001
|
||||
bgp router-id 185.200.100.1
|
||||
|
||||
# BGP сессия с upstream провайдером
|
||||
neighbor 149.248.2.1 remote-as 20473
|
||||
neighbor 149.248.2.1 description "Vultr upstream"
|
||||
|
||||
address-family ipv4 unicast
|
||||
# Анонсируем свою подсеть с этой edge ноды
|
||||
network 185.200.100.0/24
|
||||
|
||||
# NO_EXPORT - не распространяем анонс дальше (только к upstream)
|
||||
neighbor 149.248.2.1 route-map SET_COMMUNITY out
|
||||
exit-address-family
|
||||
|
||||
route-map SET_COMMUNITY permit 10
|
||||
set community no-export
|
||||
|
||||
! Когда edge нода падает - FRRouting перестаёт анонсировать
|
||||
! BGP withdraw → трафик автоматически идёт на другую ноду
|
||||
! Время failover: ~30-60 сек (BGP convergence)
|
||||
```
|
||||
|
||||
### Как это работает
|
||||
|
||||
```
|
||||
play.server.com → 185.200.100.1 (один IP, твоя подсеть)
|
||||
|
||||
Игрок из Европы:
|
||||
BGP → ближайшая нода которая анонсирует 185.200.100.0/24 → edge-eu-1
|
||||
|
||||
Игрок из США:
|
||||
BGP → ближайшая нода → edge-us-1
|
||||
|
||||
edge-eu-1 упала → FRRouting делает withdraw →
|
||||
Европейский трафик → автоматически → edge-us-1 или edge-as-1
|
||||
Время: 30-60 сек
|
||||
```
|
||||
361
docs/research/observability.md
Normal file
361
docs/research/observability.md
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
# Observability - Метрики, Трейсинг, Логи
|
||||
|
||||
> Актуально: v0.3+
|
||||
|
||||
---
|
||||
|
||||
## Стек
|
||||
|
||||
```
|
||||
Метрики: Prometheus → VictoriaMetrics (долгосрочное хранение)
|
||||
Трейсинг: OpenTelemetry → Grafana Tempo
|
||||
Логи: трейсинг → Loki
|
||||
Дашборды: Grafana
|
||||
Атаки: ClickHouse (аналитика за месяцы)
|
||||
Профайлинг: Parca (continuous)
|
||||
Debug: tokio-console (async tasks)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что собираем с каждого компонента
|
||||
|
||||
### Edge нода (Rust) → порт 9090
|
||||
|
||||
```
|
||||
rampart_connections_total{node, result} - total/blocked/allowed
|
||||
rampart_active_connections{node}
|
||||
rampart_bytes_proxied_total{node, dir} - in/out
|
||||
rampart_handshake_parse_errors_total{node, reason}
|
||||
rampart_rate_limit_hits_total{node}
|
||||
rampart_blacklist_size{node}
|
||||
rampart_xdp_drops_total{node, reason} - если XDP включён
|
||||
|
||||
# Гистограммы (важны для P99)
|
||||
rampart_handshake_duration_seconds{node}
|
||||
rampart_proxy_latency_seconds{node}
|
||||
rampart_hmac_verify_duration_seconds{node}
|
||||
```
|
||||
|
||||
### Velocity нода (Java) → порт 9091
|
||||
|
||||
```
|
||||
velocity_players_online
|
||||
velocity_domain_check_failures_total{reason}
|
||||
velocity_hmac_check_failures_total
|
||||
velocity_server_registry_size{type} - hub/survival/skyblock
|
||||
velocity_balancer_decisions_total{strategy, server_type}
|
||||
velocity_redis_latency_seconds - гистограмма
|
||||
```
|
||||
|
||||
### Game сервер (Paper агент) → порт 9092
|
||||
|
||||
```
|
||||
paper_tps{server, interval} - 1m/5m/15m
|
||||
paper_mspt{server} - мс на тик
|
||||
paper_players_online{server}
|
||||
paper_chunks_loaded{server}
|
||||
paper_entities_total{server}
|
||||
paper_memory_used_bytes{server}
|
||||
paper_memory_max_bytes{server}
|
||||
paper_gc_pause_seconds{server} - GC паузы
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prometheus конфиг с авто-дискавери
|
||||
|
||||
```yaml
|
||||
# prometheus.yml
|
||||
scrape_configs:
|
||||
|
||||
- job_name: 'rampart-edge'
|
||||
static_configs:
|
||||
- targets: ['10.0.100.1:9090', '10.0.100.2:9090']
|
||||
|
||||
- job_name: 'rampart-velocity'
|
||||
static_configs:
|
||||
- targets: ['10.0.0.2:9091', '10.0.0.3:9091']
|
||||
|
||||
# Game серверы - авто-дискавери (Manager генерирует файл из Redis)
|
||||
- job_name: 'paper-servers'
|
||||
file_sd_configs:
|
||||
- files: ['/etc/prometheus/game_servers.json']
|
||||
refresh_interval: 30s
|
||||
|
||||
- job_name: 'haproxy'
|
||||
static_configs:
|
||||
- targets: ['10.0.0.1:8404']
|
||||
```
|
||||
|
||||
### Авто-генерация game_servers.json
|
||||
|
||||
```rust
|
||||
// Manager генерирует файл каждые 30 сек
|
||||
async fn generate_sd_file(redis: &Redis) {
|
||||
let servers: Vec<serde_json::Value> = redis
|
||||
.hgetall("rampart:servers").await
|
||||
.values()
|
||||
.map(|raw| {
|
||||
let s: ServerEntry = serde_json::from_str(raw).unwrap();
|
||||
serde_json::json!({
|
||||
"targets": [format!("{}:9092", s.ip)],
|
||||
"labels": { "server": s.name, "type": s.server_type }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
std::fs::write(
|
||||
"/etc/prometheus/game_servers.json",
|
||||
serde_json::to_string_pretty(&servers).unwrap()
|
||||
).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alerting правила
|
||||
|
||||
```yaml
|
||||
# alerts.yml
|
||||
groups:
|
||||
- name: rampart-critical
|
||||
rules:
|
||||
|
||||
- alert: DDoSAttack
|
||||
expr: |
|
||||
rate(rampart_connections_total{result="blocked"}[1m])
|
||||
/ rate(rampart_connections_total[1m]) > 0.8
|
||||
for: 30s
|
||||
annotations:
|
||||
summary: "DDoS атака на {{ $labels.node }}"
|
||||
|
||||
- alert: EdgeNodeDown
|
||||
expr: up{job="rampart-edge"} == 0
|
||||
for: 10s
|
||||
annotations:
|
||||
summary: "Edge нода {{ $labels.instance }} недоступна"
|
||||
|
||||
- alert: VelocityNodeDown
|
||||
expr: up{job="rampart-velocity"} == 0
|
||||
for: 15s
|
||||
|
||||
- alert: LowTPS
|
||||
expr: paper_tps{interval="1m"} < 15
|
||||
for: 2m
|
||||
annotations:
|
||||
summary: "Низкий TPS на {{ $labels.server }}: {{ $value }}"
|
||||
|
||||
- alert: HighMSPT
|
||||
expr: paper_mspt > 45
|
||||
for: 1m
|
||||
annotations:
|
||||
summary: "Высокий MSPT: {{ $value }}ms на {{ $labels.server }}"
|
||||
```
|
||||
|
||||
### Алерт в Discord
|
||||
|
||||
```yaml
|
||||
# alertmanager.yml
|
||||
receivers:
|
||||
- name: discord
|
||||
webhook_configs:
|
||||
- url: "${DISCORD_WEBHOOK}"
|
||||
send_resolved: true
|
||||
http_config:
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
title: '{{ .GroupLabels.alertname }}'
|
||||
text: |
|
||||
{{ range .Alerts }}
|
||||
**{{ .Annotations.summary }}**
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Push vs Pull
|
||||
|
||||
**Проблема:** Edge ноды - дешёвые VDS по всему миру, часто за NAT, с динамическими IP. Prometheus pull (scrape) не сработает если нода за NAT или firewall.
|
||||
|
||||
**Решение:**
|
||||
|
||||
```
|
||||
Edge ноды → vmagent (push через remote_write) или OTel Collector
|
||||
Причина: edge за NAT, динамические IP, firewall блокирует входящие
|
||||
|
||||
Manager / Velocity / HAProxy → Prometheus pull (статичные IP внутри WG сети)
|
||||
```
|
||||
|
||||
### Схема
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ VictoriaMetrics │
|
||||
│ (remote_write) │
|
||||
└───────┬──────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
vmagent Prometheus Prometheus
|
||||
(edge-eu-1) (manager) (velocity)
|
||||
push pull pull
|
||||
```
|
||||
|
||||
### Конфиг vmagent для edge ноды
|
||||
|
||||
```yaml
|
||||
# /etc/vmagent.yml
|
||||
remote_write:
|
||||
- url: "https://victoria.rampart.internal/api/v1/write"
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'rampart-edge'
|
||||
static_configs:
|
||||
- targets: ['127.0.0.1:9090'] # localhost - не требует доступа извне
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenTelemetry - distributed tracing
|
||||
|
||||
```rust
|
||||
// src/telemetry.rs
|
||||
|
||||
use opentelemetry_otlp::WithExportConfig;
|
||||
use tracing_opentelemetry::OpenTelemetryLayer;
|
||||
|
||||
pub fn init(service: &str, otlp_endpoint: &str) {
|
||||
let tracer = opentelemetry_otlp::new_pipeline()
|
||||
.tracing()
|
||||
.with_exporter(
|
||||
opentelemetry_otlp::new_exporter()
|
||||
.tonic()
|
||||
.with_endpoint(otlp_endpoint)
|
||||
)
|
||||
.install_batch(opentelemetry_sdk::runtime::Tokio)
|
||||
.unwrap();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.with(OpenTelemetryLayer::new(tracer))
|
||||
.init();
|
||||
}
|
||||
|
||||
// Использование - автоматически создаёт spans
|
||||
#[tracing::instrument(skip(stream, config))]
|
||||
pub async fn handle_connection(stream: TcpStream, config: Arc<Config>) {
|
||||
let handshake = parse_handshake(&stream).await; // child span
|
||||
filter_request(&handshake).await; // child span
|
||||
proxy_to_backend(stream).await; // child span
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ClickHouse - attack log
|
||||
|
||||
### Почему не PostgreSQL
|
||||
|
||||
```
|
||||
SELECT count() WHERE country='CN' AND ts > now()-24h
|
||||
|
||||
PostgreSQL: ~2 сек на 100M строк
|
||||
ClickHouse: ~50 мс на 100M строк
|
||||
Сжатие: PostgreSQL ~3:1, ClickHouse ~10:1
|
||||
```
|
||||
|
||||
### Схема
|
||||
|
||||
```sql
|
||||
CREATE TABLE rampart.blocked (
|
||||
ts DateTime CODEC(Delta, ZSTD),
|
||||
edge LowCardinality(String),
|
||||
src_ip IPv4,
|
||||
src_asn UInt32,
|
||||
src_country LowCardinality(FixedString(2)),
|
||||
reason LowCardinality(String),
|
||||
proto_ver Int32,
|
||||
hostname String CODEC(ZSTD)
|
||||
) ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(ts)
|
||||
ORDER BY (ts, edge, src_ip)
|
||||
TTL ts + INTERVAL 90 DAY;
|
||||
|
||||
-- Materialized View для агрегатов (не пересчитываем каждый раз)
|
||||
CREATE MATERIALIZED VIEW rampart.blocked_by_country_mv
|
||||
ENGINE = SummingMergeTree()
|
||||
ORDER BY (toDate(ts), src_country)
|
||||
AS SELECT toDate(ts) as date, src_country, count() as hits
|
||||
FROM rampart.blocked GROUP BY date, src_country;
|
||||
```
|
||||
|
||||
### Батч запись из Rust
|
||||
|
||||
```rust
|
||||
// Не пишем на каждый пакет - накапливаем и сбрасываем раз в секунду
|
||||
pub struct ClickHouseWriter {
|
||||
client: clickhouse::Client,
|
||||
buffer: Mutex<Vec<BlockedEvent>>,
|
||||
}
|
||||
|
||||
impl ClickHouseWriter {
|
||||
pub async fn flush(&self) {
|
||||
let records = { self.buffer.lock().await.drain(..).collect::<Vec<_>>() };
|
||||
if records.is_empty() { return; }
|
||||
|
||||
let mut insert = self.client.insert("rampart.blocked").unwrap();
|
||||
for r in &records { insert.write(r).await.unwrap(); }
|
||||
insert.end().await.unwrap();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parca - continuous profiling
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml дополнение
|
||||
parca:
|
||||
image: ghcr.io/parca-dev/parca:latest
|
||||
ports:
|
||||
- "7070:7070"
|
||||
volumes:
|
||||
- ./parca.yaml:/etc/parca/parca.yaml
|
||||
|
||||
# parca.yaml
|
||||
object_storage:
|
||||
bucket:
|
||||
type: FILESYSTEM
|
||||
config:
|
||||
directory: /tmp/parca
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'rampart-edge'
|
||||
scrape_interval: 10s
|
||||
targets:
|
||||
- targets: ['10.0.100.1:7071'] # pprof endpoint
|
||||
```
|
||||
|
||||
```rust
|
||||
// Включаем pprof endpoint в edge ноде
|
||||
use pprof::ProfilerGuard;
|
||||
// GET /debug/pprof/profile → CPU flame graph
|
||||
// GET /debug/pprof/heap → heap allocation graph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tokio-console - debug async tasks
|
||||
|
||||
```bash
|
||||
# Запуск edge с поддержкой tokio-console
|
||||
TOKIO_CONSOLE_BIND=10.0.100.1:6669 \
|
||||
RUST_LOG=tokio=trace \
|
||||
./rampart-edge
|
||||
|
||||
# Подключение (на своей машине)
|
||||
tokio-console http://10.0.100.1:6669
|
||||
# Видишь все async tasks, их состояние, сколько они poll'ятся
|
||||
```
|
||||
184
docs/research/papers.md
Normal file
184
docs/research/papers.md
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# Papers & References - Материалы для изучения
|
||||
|
||||
> Ссылки на статьи, RFC, проекты, инструменты которые легли в основу Rampart.
|
||||
|
||||
---
|
||||
|
||||
## Minecraft протокол
|
||||
|
||||
| Ресурс | Зачем |
|
||||
|---|---|
|
||||
| [wiki.vg/Protocol](https://wiki.vg/Protocol) | Официальная неофициальная документация MC протокола. Handshake, VarInt, все пакеты. |
|
||||
| [wiki.vg/Handshaking_sequence](https://wiki.vg/Handshaking_sequence) | Полная последовательность handshake → login → play |
|
||||
| [Velocity источник](https://github.com/PaperMC/Velocity) | Как PaperMC парсит MC протокол в Java - референс |
|
||||
| [Pumpkin-MC](https://github.com/Snowiiii/Pumpkin) | MC сервер на Rust - референс для Rust парсинга протокола |
|
||||
|
||||
---
|
||||
|
||||
## eBPF / XDP
|
||||
|
||||
| Ресурс | Зачем |
|
||||
|---|---|
|
||||
| [Outfluencer/Minecraft-XDP-eBPF](https://github.com/Outfluencer/Minecraft-XDP-eBPF) | Референс: XDP фильтр специально для Minecraft (Rust + C, 190+ stars) |
|
||||
| [xdp-project/xdp-tutorial](https://github.com/xdp-project/xdp-tutorial) | Лучший туториал по XDP - от простого к сложному |
|
||||
| [libbpf-bootstrap](https://github.com/libbpf/libbpf-bootstrap) | Шаблоны eBPF программ с современным подходом (skeleton, CO-RE) |
|
||||
| [aya-rs/aya](https://github.com/aya-rs/aya) | Альтернатива libbpf-rs - eBPF полностью на Rust (без C) |
|
||||
| [BPF Performance Tools](https://www.brendangregg.com/bpf-performance-tools-book.html) | Книга Brendan Gregg - глубокий разбор BPF/eBPF |
|
||||
| [Cloudflare: XDP введение](https://blog.cloudflare.com/l4drop-xdp-ebpf-based-ddos-mitigations/) | Как Cloudflare использует XDP для DDoS mitigation |
|
||||
| [Facebook: XDP at scale](https://engineering.fb.com/2018/05/22/open-source/open-sourcing-katran-a-scalable-network-load-balancer/) | Katran - XDP load balancer от Facebook |
|
||||
|
||||
---
|
||||
|
||||
## Rust networking
|
||||
|
||||
| Ресурс | Зачем |
|
||||
|---|---|
|
||||
| [tokio-rs/tokio](https://github.com/tokio-rs/tokio) | Async runtime - основа edge ноды |
|
||||
| [tokio-rs/tokio-uring](https://github.com/tokio-rs/tokio-uring) | io_uring runtime для tokio |
|
||||
| [bytedance/monoio](https://github.com/bytedance/monoio) | Thread-per-core io_uring runtime от ByteDance |
|
||||
| [glommio](https://github.com/DataDog/glommio) | io_uring runtime от DataDog |
|
||||
| [rustls](https://github.com/rustls/rustls) | TLS на Rust - для mTLS |
|
||||
| [quinn-rs/quinn](https://github.com/quinn-rs/quinn) | QUIC реализация на Rust |
|
||||
| [zero-copy-paxos](https://www.usenix.org/conference/osdi14/technical-sessions/presentation/ports) | Статья о zero-copy в системных сервисах |
|
||||
| [Uring и io_uring (LWN)](https://lwn.net/Articles/776703/) | Детальный разбор io_uring от автора |
|
||||
|
||||
---
|
||||
|
||||
## DDoS защита и сети
|
||||
|
||||
| Ресурс | Зачем |
|
||||
|---|---|
|
||||
| [Cloudflare Blog: DDoS](https://blog.cloudflare.com/tag/ddos/) | Статьи Cloudflare о реальных атаках и защите |
|
||||
| [Path.net технический блог](https://path.net/blog/) | Как устроена игровая DDoS защита |
|
||||
| [RFC 4271](https://datatracker.ietf.org/doc/html/rfc4271) | BGP - основа Anycast маршрутизации |
|
||||
| [RFC 9000](https://datatracker.ietf.org/doc/html/rfc9000) | QUIC протокол (официальный RFC) |
|
||||
| [WireGuard whitepaper](https://www.wireguard.com/papers/wireguard.pdf) | Технический документ WireGuard |
|
||||
| [Hping3 man page](https://linux.die.net/man/8/hping3) | Инструмент для тестирования защиты |
|
||||
| [tcpkali](https://github.com/satori-com/tcpkali) | Benchmark инструмент для TCP |
|
||||
|
||||
---
|
||||
|
||||
## Балансировка и прокси
|
||||
|
||||
| Ресурс | Зачем |
|
||||
|---|---|
|
||||
| [Envoy proxy docs](https://www.envoyproxy.io/docs/envoy/latest/) | EWMA, Circuit Breaker, xDS - референс архитектуры |
|
||||
| [HAProxy конфигурация](https://www.haproxy.org/download/2.8/doc/configuration.txt) | Полная документация HAProxy |
|
||||
| [Consistent Hashing paper](https://dl.acm.org/doi/10.1145/258533.258660) | Оригинальная статья Karger et al. 1997 |
|
||||
| [EWMA в Envoy](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/load_balancers#weighted-least-request) | Как Envoy реализует EWMA балансировку |
|
||||
| [Nginx SO_REUSEPORT](https://nginx.org/en/docs/http/ngx_http_upstream_module.html) | Как Nginx использует SO_REUSEPORT |
|
||||
|
||||
---
|
||||
|
||||
## Наблюдаемость
|
||||
|
||||
| Ресурс | Зачем |
|
||||
|---|---|
|
||||
| [OpenTelemetry](https://opentelemetry.io/docs/) | Официальная документация OTel |
|
||||
| [ClickHouse docs](https://clickhouse.com/docs) | Документация ClickHouse - схемы, запросы |
|
||||
| [VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics) | Prometheus-совместимое хранилище для долгосрочных метрик |
|
||||
| [Grafana Tempo](https://grafana.com/oss/tempo/) | Хранилище distributed traces |
|
||||
| [Parca](https://github.com/parca-dev/parca) | Continuous profiling для production |
|
||||
| [tokio-console](https://github.com/tokio-rs/console) | Debug async tokio tasks |
|
||||
| [Brendan Gregg: Flame Graphs](https://www.brendangregg.com/flamegraphs.html) | Методология профилирования через flame graphs |
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
| Ресурс | Зачем |
|
||||
|---|---|
|
||||
| [STRIDE модель](https://docs.microsoft.com/en-us/azure/security/develop/threat-modeling-tool-threats) | Методология threat modeling |
|
||||
| [subtle crate](https://docs.rs/subtle/) | Constant-time операции в Rust |
|
||||
| [HMAC RFC 2104](https://datatracker.ietf.org/doc/html/rfc2104) | Оригинальный HMAC RFC |
|
||||
| [cargo-audit](https://github.com/rustsec/rustsec) | CVE проверка Rust зависимостей |
|
||||
| [cargo-deny](https://github.com/EmbarkStudios/cargo-deny) | Политики лицензий и зависимостей |
|
||||
| [SLSA framework](https://slsa.dev/) | Supply chain security уровни |
|
||||
| [cosign](https://github.com/sigstore/cosign) | Подпись Docker образов |
|
||||
|
||||
---
|
||||
|
||||
## Смежные open-source проекты
|
||||
|
||||
| Проект | Язык | Что взять |
|
||||
|---|---|---|
|
||||
| [Velocity](https://github.com/PaperMC/Velocity) | Java | MC proxy - основа нашего плагина |
|
||||
| [Gate (Minekube)](https://github.com/minekube/gate) | Go | Высокопроизводительный MC proxy - архитектурный референс |
|
||||
| [Minecraft-XDP-eBPF](https://github.com/Outfluencer/Minecraft-XDP-eBPF) | Rust+C | XDP для Minecraft - брать за основу XDP компонента |
|
||||
| [Sonar](https://github.com/jonesdevelopment/sonar) | Java | Antibot для Velocity - интегрируем как слой |
|
||||
| [RedisBungee-Reloaded](https://github.com/ProxioDev/RedisBungee) | Java | Cross-proxy синхронизация - референс |
|
||||
| [VeloFlame](https://github.com/) | Java | Velocity форк с встроенным антиботом (июль 2026) |
|
||||
| [Pumpkin-MC](https://github.com/Snowiiii/Pumpkin) | Rust | MC сервер на Rust - референс протокола |
|
||||
| [Katran](https://github.com/facebookincubator/katran) | C++ | XDP load balancer от Facebook - архитектурный референс |
|
||||
| [NATS](https://github.com/nats-io/nats-server) | Go | Event bus - используем для критических событий |
|
||||
| [FRRouting](https://github.com/FRRouting/frr) | C | BGP routing - для Anycast в v0.6+ |
|
||||
| [headscale](https://github.com/juanfont/headscale) | Go | Self-hosted WireGuard координатор - для v0.6+ |
|
||||
|
||||
---
|
||||
|
||||
## Статьи и блоги по теме
|
||||
|
||||
| Статья | Почему стоит прочитать |
|
||||
|---|---|
|
||||
| [How TCPShield works](https://tcpshield.com/blog/) | Понять конкурента изнутри |
|
||||
| [Cloudflare: Lessons from protecting 26M HTTP RPS](https://blog.cloudflare.com/ddos-threat-report-for-2024-q4/) | Реальная статистика DDoS атак |
|
||||
| [Linux networking performance](https://talawah.io/blog/linux-kernel-vs-dpdk-http-performance-showdown/) | Kernel vs DPDK vs XDP сравнение |
|
||||
| [Tokio internals](https://tokio.rs/blog/2019-10-scheduler) | Как работает tokio scheduler |
|
||||
| [io_uring в production](https://developers.mattermost.com/blog/hands-on-iouring-go/) | Реальный опыт io_uring |
|
||||
| [eBPF maps deep dive](https://prototype-kernel.readthedocs.io/en/latest/bpf/ebpf_maps.html) | BPF map типы, когда что использовать |
|
||||
|
||||
---
|
||||
|
||||
## RFC для изучения
|
||||
|
||||
| RFC | Тема |
|
||||
|---|---|
|
||||
| RFC 793 | TCP - основа всего |
|
||||
| RFC 4271 | BGP-4 |
|
||||
| RFC 4786 | Anycast через BGP |
|
||||
| RFC 7413 | TCP Fast Open |
|
||||
| RFC 9000 | QUIC Transport |
|
||||
| RFC 9001 | QUIC + TLS 1.3 |
|
||||
| RFC 8446 | TLS 1.3 |
|
||||
| RFC 2104 | HMAC |
|
||||
| RFC 5246 | TLS 1.2 (для совместимости) |
|
||||
|
||||
---
|
||||
|
||||
## Инструменты для разработки
|
||||
|
||||
```bash
|
||||
# Анализ трафика
|
||||
wireshark # GUI пакетный анализатор
|
||||
tshark # CLI версия wireshark
|
||||
tcpdump # быстрый захват пакетов
|
||||
|
||||
# Benchmark
|
||||
tcpkali # TCP нагрузочное тестирование
|
||||
iperf3 # bandwidth тест
|
||||
hping3 # генерация специфических пакетов
|
||||
wrk # HTTP benchmark (для Manager API)
|
||||
|
||||
# eBPF отладка
|
||||
bpftool # управление BPF программами и картами
|
||||
bpftrace # скриптовый язык для eBPF
|
||||
strace # системные вызовы (для userspace)
|
||||
|
||||
# Rust
|
||||
cargo-flamegraph # flame graphs
|
||||
cargo-criterion # benchmark с HTML отчётами
|
||||
cargo-audit # CVE проверка
|
||||
cargo-deny # политики зависимостей
|
||||
tokio-console # async tasks debug
|
||||
|
||||
# Сеть
|
||||
wireguard-tools # wg, wg-quick
|
||||
frr # FRRouting (BGP)
|
||||
iptables/nftables # firewall
|
||||
|
||||
# Мониторинг
|
||||
prometheus # метрики
|
||||
grafana # дашборды
|
||||
clickhouse # attack log аналитика
|
||||
parca # continuous profiling
|
||||
```
|
||||
312
docs/research/rust-performance.md
Normal file
312
docs/research/rust-performance.md
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
# Rust Performance - Zero-Copy, SO_REUSEPORT, NUMA
|
||||
|
||||
> Актуально: v0.3+
|
||||
|
||||
---
|
||||
|
||||
## Zero-Copy проксирование
|
||||
|
||||
```
|
||||
Обычный proxy (2 копии):
|
||||
NIC → kernel buf → copy → userspace buf → copy → kernel buf → NIC
|
||||
|
||||
splice(2) zero-copy (0 копий в userspace):
|
||||
NIC → kernel pipe → NIC
|
||||
Данные никогда не покидают kernel
|
||||
```
|
||||
|
||||
### Когда применять
|
||||
|
||||
```
|
||||
Handshake фаза → обычный read() (нужно видеть байты, парсить, ставить HMAC)
|
||||
После handshake → zero-copy splice (просто проксируем стрим)
|
||||
```
|
||||
|
||||
```rust
|
||||
// src/proxy/tunnel.rs
|
||||
use tokio_splice::zero_copy_bidirectional;
|
||||
|
||||
pub async fn tunnel(mut client: TcpStream, mut backend: TcpStream) {
|
||||
// После того как handshake прочитан и HMAC добавлен -
|
||||
// всё остальное идёт через splice(2) без копий в userspace
|
||||
let _ = zero_copy_bidirectional(&mut client, &mut backend).await;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SO_REUSEPORT - линейный scale по CPU
|
||||
|
||||
```rust
|
||||
// main.rs - N воркеров, каждый слушает тот же порт
|
||||
// Ядро само балансирует входящие SYN между воркерами
|
||||
|
||||
use socket2::{Domain, Socket, Type};
|
||||
|
||||
fn build_listener(addr: SocketAddr) -> TcpListener {
|
||||
let socket = Socket::new(Domain::IPV4, Type::STREAM, None).unwrap();
|
||||
socket.set_reuse_port(true).unwrap(); // SO_REUSEPORT
|
||||
socket.set_reuse_address(true).unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
socket.bind(&addr.into()).unwrap();
|
||||
socket.listen(65535).unwrap();
|
||||
TcpListener::from_std(socket.into()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let addr: SocketAddr = "0.0.0.0:25565".parse().unwrap();
|
||||
let cpus = num_cpus::get();
|
||||
|
||||
let handles: Vec<_> = (0..cpus)
|
||||
.map(|_| tokio::spawn(accept_loop(build_listener(addr))))
|
||||
.collect();
|
||||
|
||||
futures::future::join_all(handles).await;
|
||||
}
|
||||
```
|
||||
|
||||
### Ожидаемый прирост
|
||||
|
||||
| Ядра | Без SO_REUSEPORT | С SO_REUSEPORT |
|
||||
|---|---|---|
|
||||
| 1 | 20k conn/s | 20k conn/s |
|
||||
| 4 | 22k conn/s | 78k conn/s |
|
||||
| 8 | 23k conn/s | 155k conn/s |
|
||||
|
||||
---
|
||||
|
||||
## Buffer Pool - без heap allocation на каждый пакет
|
||||
|
||||
```rust
|
||||
// src/pool.rs - пул буферов, переиспользуем вместо Vec::new()
|
||||
// ⚠ ВАЖНО: tokio::sync::Mutex блокирует async runtime в hot path.
|
||||
// Используем crossbeam::ArrayQueue - lock-free, не блокирует.
|
||||
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct BufferPool {
|
||||
pool: Arc<ArrayQueue<Vec<u8>>>,
|
||||
buf_size: usize,
|
||||
}
|
||||
|
||||
impl BufferPool {
|
||||
pub fn new(capacity: usize, buf_size: usize) -> Self {
|
||||
let pool = ArrayQueue::new(capacity);
|
||||
for _ in 0..capacity {
|
||||
pool.push(vec![0u8; buf_size]).ok();
|
||||
}
|
||||
Self { pool: Arc::new(pool), buf_size }
|
||||
}
|
||||
|
||||
// Не async! Не блокирует runtime.
|
||||
pub fn acquire(&self) -> Vec<u8> {
|
||||
self.pool.pop().unwrap_or_else(|| vec![0u8; self.buf_size])
|
||||
}
|
||||
|
||||
// Не async! Не блокирует runtime.
|
||||
pub fn release(&self, mut buf: Vec<u8>) {
|
||||
buf.clear();
|
||||
let _ = self.pool.push(buf); // игнорируем если полон
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DashMap - lock-free concurrent HashMap
|
||||
|
||||
```rust
|
||||
// Блэклист и rate limit - читаются на каждый пакет
|
||||
// RwLock<HashMap> создаёт contention под нагрузкой
|
||||
// DashMap решает это через шарды
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
pub struct Blacklist {
|
||||
// 64 шарда, каждый со своим RwLock
|
||||
// Разные IP попадают в разные шарды → нет contention
|
||||
ips: DashMap<Ipv4Addr, BanEntry>,
|
||||
}
|
||||
|
||||
impl Blacklist {
|
||||
pub fn is_blocked(&self, ip: Ipv4Addr) -> bool {
|
||||
if let Some(entry) = self.ips.get(&ip) {
|
||||
if entry.expires > Instant::now() {
|
||||
return true;
|
||||
}
|
||||
drop(entry);
|
||||
self.ips.remove(&ip); // expired
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## io_uring - async I/O нового поколения (v0.4+, future optimization)
|
||||
|
||||
> Текущий код на tokio (epoll). io_uring - future optimization для edge нод.
|
||||
|
||||
### epoll vs io_uring
|
||||
|
||||
```
|
||||
epoll (tokio сейчас):
|
||||
read() → syscall → копирование в userspace buf → возврат
|
||||
На каждую операцию: минимум 1 syscall + 1 копия
|
||||
|
||||
io_uring:
|
||||
Кладём запросы в submission queue (shared memory)
|
||||
Ядро обрабатывает батчем, результаты в completion queue
|
||||
Нет syscall per operation (только sq_enter раз в батч)
|
||||
Нет копирования (registered buffers)
|
||||
```
|
||||
|
||||
### Когда разница заметна
|
||||
|
||||
```
|
||||
10k соединений: epoll ≈ io_uring (разница < 5%)
|
||||
100k соединений: io_uring +15-20%
|
||||
1M соединений: io_uring +35-40%
|
||||
```
|
||||
|
||||
### Рантаймы сравнение
|
||||
|
||||
| Рантайм | Базируется на | Когда использовать |
|
||||
|---|---|---|
|
||||
| **tokio** (текущий) | epoll | v0.1-v0.3, универсально |
|
||||
| **tokio-uring** | io_uring | v0.4+, Linux only |
|
||||
| **glommio** | io_uring, thread-per-core | v0.5+, высокая изоляция |
|
||||
| **monoio** | io_uring, Tencent | v0.6+, максимальная пропускная способность |
|
||||
|
||||
### Реализация через feature flag
|
||||
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[features]
|
||||
default = []
|
||||
io-uring = ["dep:tokio-uring"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-uring = { version = "0.5", optional = true }
|
||||
```
|
||||
|
||||
```rust
|
||||
// src/runtime.rs
|
||||
pub fn run(config: Config) {
|
||||
#[cfg(feature = "io-uring")]
|
||||
{
|
||||
tracing::info!("Запуск с io_uring runtime");
|
||||
tokio_uring::start(async { crate::edge::run(config).await });
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "io-uring"))]
|
||||
{
|
||||
tracing::info!("Запуск с epoll (tokio)");
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(num_cpus::get())
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(crate::edge::run(config));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Обычная сборка (epoll, работает везде)
|
||||
cargo build --release
|
||||
|
||||
# С io_uring (Linux 5.10+)
|
||||
cargo build --release --features io-uring
|
||||
```
|
||||
|
||||
### Registered Buffers
|
||||
|
||||
```rust
|
||||
// Регистрируем буферы один раз в ядре
|
||||
// Потом read/write используют эти буферы без копирования
|
||||
use tokio_uring::buf::IoBuf;
|
||||
|
||||
let buffers: Vec<Vec<u8>> = (0..1024)
|
||||
.map(|_| vec![0u8; 4096])
|
||||
.collect();
|
||||
|
||||
// io_uring читает прямо в зарегистрированный буфер
|
||||
// Нет copy_to_user, нет дополнительной аллокации
|
||||
let (result, buf) = stream.read(buf).await;
|
||||
```
|
||||
|
||||
### Ограничения io_uring
|
||||
|
||||
```
|
||||
✗ Только Linux (macOS/Windows → epoll fallback)
|
||||
✗ Требует kernel 5.10+ (stable features)
|
||||
✗ Некоторые VDS провайдеры блокируют io_uring
|
||||
(проверь: cat /proc/sys/kernel/io_uring_disabled)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NUMA-aware allocation (для 2-сокетных серверов)
|
||||
|
||||
> Актуально для bare metal с 2 физическими CPU (NUMA topology)
|
||||
|
||||
```rust
|
||||
// Привязываем воркеры к NUMA нодам
|
||||
// Память аллоцируется близко к CPU который её использует
|
||||
|
||||
use nix::sched::{sched_setaffinity, CpuSet};
|
||||
|
||||
fn pin_to_numa_node(worker_id: usize, numa_node: usize) {
|
||||
let mut cpuset = CpuSet::new();
|
||||
// NUMA node 0: CPU 0-7, NUMA node 1: CPU 8-15 (пример)
|
||||
let cpu_start = numa_node * 8;
|
||||
let cpu_for_worker = cpu_start + (worker_id % 8);
|
||||
cpuset.set(cpu_for_worker).unwrap();
|
||||
sched_setaffinity(Pid::from_raw(0), &cpuset).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
Для обычных VDS (1 NUMA нода) - не нужно.
|
||||
|
||||
---
|
||||
|
||||
## Profiling в production
|
||||
|
||||
```bash
|
||||
# tokio-console - live view async tasks
|
||||
# Запускаем edge с поддержкой tokio-console
|
||||
TOKIO_CONSOLE_BIND=10.0.100.1:6669 ./rampart-edge
|
||||
|
||||
# На своей машине
|
||||
tokio-console http://10.0.100.1:6669
|
||||
|
||||
# Parca - continuous profiling (CPU flame graphs)
|
||||
docker run -p 7070:7070 ghcr.io/parca-dev/parca:latest
|
||||
# Смотрим в браузере: http://localhost:7070
|
||||
|
||||
# perf (Linux)
|
||||
perf record -g -p $(pgrep rampart-edge) -- sleep 30
|
||||
perf report --stdio | head -50
|
||||
|
||||
# Flamegraph
|
||||
cargo flamegraph --bin rampart-edge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Сводная таблица оптимизаций
|
||||
|
||||
| Техника | Прирост | Версия | Сложность |
|
||||
|---|---|---|---|
|
||||
| SO_REUSEPORT | 4x на 4 ядрах | v0.1 | Низкая |
|
||||
| DashMap вместо RwLock | 2x при contention | v0.1 | Низкая |
|
||||
| Buffer pool | -30% alloc | v0.2 | Средняя |
|
||||
| Zero-copy splice | -50% CPU на трафик | v0.2 | Средняя |
|
||||
| io_uring | +30-40% conn/s | v0.4 | Высокая |
|
||||
| XDP | 10x дроп rate | v0.4 | Высокая |
|
||||
| NUMA pinning | +10-20% на 2P сервере | v0.6 | Высокая |
|
||||
262
docs/research/security.md
Normal file
262
docs/research/security.md
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
# Security - STRIDE, mTLS, Zero Trust, Supply Chain
|
||||
|
||||
> Актуально: v0.2+
|
||||
|
||||
---
|
||||
|
||||
## STRIDE Threat Model
|
||||
|
||||
| Угроза | Конкретно | Защита |
|
||||
|---|---|---|
|
||||
| **S**poofing | Атакующий подделывает IP edge ноды | mTLS (сертификат не подделать) + WireGuard |
|
||||
| **T**ampering | Подмена HMAC в hostname | HMAC-SHA256 + constant-time compare |
|
||||
| **R**epudiation | Нет доказательств кто добавил IP в блэклист | Аудит лог (user, ts, action, IP) в ClickHouse |
|
||||
| **I**nfo Disclosure | Утечка реального IP backend | Всё за WireGuard + iptables DROP |
|
||||
| **D**oS | Перегрузка edge ноды | XDP + rate limit + challenge |
|
||||
| **E**scalation | Доступ к Manager API без авторизации | JWT + mTLS + IP whitelist + rate limit |
|
||||
|
||||
---
|
||||
|
||||
## Zero Trust - принципы
|
||||
|
||||
```
|
||||
1. Никому не доверяй по умолчанию - даже внутри WireGuard сети
|
||||
2. Проверяй каждый компонент - mTLS между всеми сервисами
|
||||
3. Минимальные привилегии - каждый компонент видит только нужное
|
||||
4. Логируй всё - аудит лог каждого действия
|
||||
|
||||
Применение в Rampart:
|
||||
Edge нода → HAProxy/LB: mTLS (сертификат edge ноды)
|
||||
LB → Velocity: mTLS (сертификат LB)
|
||||
Velocity → Redis: пароль + только WireGuard IP
|
||||
Manager API: JWT + mTLS + IP whitelist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## mTLS - схема сертификатов
|
||||
|
||||
```
|
||||
Root CA (rampart-ca)
|
||||
├── Intermediate CA (edge-ca)
|
||||
│ ├── edge-eu-1.crt
|
||||
│ ├── edge-us-1.crt
|
||||
│ └── edge-as-1.crt
|
||||
├── Intermediate CA (infra-ca)
|
||||
│ ├── haproxy.crt
|
||||
│ ├── velocity-1.crt ... velocity-20.crt
|
||||
│ ├── manager.crt
|
||||
│ └── dashboard.crt
|
||||
└── Intermediate CA (game-ca)
|
||||
├── hub-1.crt ... hub-100.crt
|
||||
└── (game серверам не нужен mTLS - они за Velocity)
|
||||
```
|
||||
|
||||
### Генерация через CLI
|
||||
|
||||
```bash
|
||||
# Инициализация PKI (один раз)
|
||||
rampart pki init \
|
||||
--root-ca rampart-ca \
|
||||
--output /etc/rampart/pki/
|
||||
|
||||
# Выпуск сертификата для новой edge ноды
|
||||
rampart pki issue \
|
||||
--ca edge-ca \
|
||||
--name edge-us-2 \
|
||||
--ip 10.0.100.5 \
|
||||
--san "edge-us-2.rampart.internal" \
|
||||
--output /etc/rampart/pki/edge-us-2/
|
||||
|
||||
# Ротация (раз в год, автоматически через cron)
|
||||
rampart pki rotate --role edge --days-before-expiry 30
|
||||
```
|
||||
|
||||
### Реализация в Rust (rustls)
|
||||
|
||||
```rust
|
||||
// tls.rs
|
||||
|
||||
use rustls::{ServerConfig, ClientConfig, RootCertStore};
|
||||
use tokio_rustls::{TlsAcceptor, TlsConnector};
|
||||
|
||||
pub fn server_config(cert: &str, key: &str, ca: &str) -> Arc<ServerConfig> {
|
||||
let mut root_store = RootCertStore::empty();
|
||||
root_store.add(load_cert(ca)).unwrap();
|
||||
|
||||
Arc::new(ServerConfig::builder()
|
||||
// Требуем клиентский сертификат (mutual)
|
||||
.with_client_cert_verifier(
|
||||
WebPkiClientVerifier::builder(Arc::new(root_store))
|
||||
.build().unwrap()
|
||||
)
|
||||
.with_single_cert(load_certs(cert), load_key(key))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
pub fn client_config(cert: &str, key: &str, ca: &str) -> Arc<ClientConfig> {
|
||||
let mut root_store = RootCertStore::empty();
|
||||
root_store.add(load_cert(ca)).unwrap();
|
||||
|
||||
Arc::new(ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_client_auth_cert(load_certs(cert), load_key(key))
|
||||
.unwrap())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HMAC - правильная реализация
|
||||
|
||||
```rust
|
||||
// hmac/signer.rs
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
// ВАЖНО: subtle для constant-time сравнения (защита от timing атак)
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn sign(hostname: &str, secret: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret)
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(hostname.as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
pub fn verify(hostname: &str, provided_sig: &str, secret: &[u8]) -> bool {
|
||||
let expected = sign(hostname, secret);
|
||||
// constant_time_eq - время сравнения не зависит от содержимого
|
||||
// Без этого атакующий может угадать HMAC по времени ответа
|
||||
expected.as_bytes().ct_eq(provided_sig.as_bytes()).into()
|
||||
}
|
||||
|
||||
// Добавляем к hostname: "play.server.com\0shield\0<hex_hmac>"
|
||||
pub fn sign_hostname(raw: &str, secret: &[u8]) -> String {
|
||||
// Берём только domain часть (без Forge суффиксов)
|
||||
let domain = raw.split('\0').next().unwrap_or(raw);
|
||||
let sig = sign(domain, secret);
|
||||
format!("{}\0shield\0{}", raw, sig) // сохраняем Forge суффикс
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Аудит лог
|
||||
|
||||
```rust
|
||||
// Каждое административное действие записывается
|
||||
|
||||
#[derive(Serialize, Deserialize, Clickhouse)]
|
||||
pub struct AuditEntry {
|
||||
pub ts: DateTime<Utc>,
|
||||
pub user: String, // кто сделал
|
||||
pub action: String, // "blacklist.add" / "server.remove" / "config.change"
|
||||
pub target: String, // "1.2.3.4" / "survival_47"
|
||||
pub details: String, // JSON с деталями
|
||||
pub src_ip: String, // откуда был запрос
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
// Вставляем в ClickHouse (не в Redis - нужна долгосрочная история)
|
||||
pub async fn audit(entry: AuditEntry) {
|
||||
clickhouse_client
|
||||
.insert("rampart.audit_log")
|
||||
.write(&entry)
|
||||
.await
|
||||
.ok(); // не прерываем основной флоу если аудит упал
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Защита Redis
|
||||
|
||||
```bash
|
||||
# redis.conf
|
||||
bind 10.0.0.1 # только WireGuard IP (не 0.0.0.0!)
|
||||
requirepass "LONG_RANDOM_PASSWORD_HERE"
|
||||
protected-mode yes
|
||||
rename-command FLUSHALL "" # запрещаем опасные команды
|
||||
rename-command FLUSHDB ""
|
||||
rename-command DEBUG ""
|
||||
rename-command CONFIG "CONFIG_RESTRICTED_CMD"
|
||||
|
||||
# Firewall - дополнительный слой
|
||||
iptables -A INPUT -p tcp --dport 6379 -s 10.0.0.0/16 -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 6379 -j DROP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supply Chain Security (v0.5+)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/supply-chain.yml
|
||||
|
||||
cargo-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo install cargo-audit
|
||||
- run: cargo audit # проверяем CVE в зависимостях
|
||||
|
||||
cargo-deny:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: EmbarkStudios/cargo-deny-action@v1
|
||||
with:
|
||||
command: check all # лицензии, дублирования, CVE
|
||||
|
||||
sbom:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: anchore/sbom-action@v0 # генерируем SBOM
|
||||
with:
|
||||
format: spdx-json
|
||||
|
||||
sign-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: sigstore/cosign-installer@v3
|
||||
- run: |
|
||||
cosign sign --yes \
|
||||
ghcr.io/yourname/rampart-core:${{ github.sha }}
|
||||
```
|
||||
|
||||
### Совместимость лицензий
|
||||
|
||||
```
|
||||
Наш код: MIT или Apache-2.0
|
||||
|
||||
Ключевые зависимости:
|
||||
tokio: MIT ✅
|
||||
rustls: MIT/Apache ✅
|
||||
libbpf-rs: LGPL-2.1 ✅ (динамическая линковка)
|
||||
libbpf-sys: LGPL-2.1 ✅
|
||||
XDP C код: GPL-2.0 ✅ (kernel module, отдельная сборка)
|
||||
|
||||
Потенциальная проблема:
|
||||
XDP .c файлы компилируются в eBPF bytecode и загружаются в ядро.
|
||||
Сам .c файл под GPL - это нормально для kernel interaction.
|
||||
Rust loader (userspace) - MIT, не загрязняется GPL.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Утечка реального IP - чеклист
|
||||
|
||||
```
|
||||
☐ DNS история очищена (проверь через SecurityTrails, Shodan)
|
||||
☐ Reverse DNS не раскрывает хостинг
|
||||
☐ Старые firewall правила удалены
|
||||
☐ game серверы не пингуют внешние ресурсы со своего IP
|
||||
(обновления плагинов, curl запросы - через proxy или не извне)
|
||||
☐ Email заголовки (если сервер шлёт письма) - проверить что не раскрывают IP
|
||||
☐ Error pages, краш репорты - не выводить IP
|
||||
☐ MC команды типа /ip - отключить или ограничить
|
||||
☐ Доступ членов команды - минимальный, только нужные люди знают IP
|
||||
☐ Pterodactyl/панель управления - закрыта за VPN или IP whitelist
|
||||
```
|
||||
284
docs/runbook.md
Normal file
284
docs/runbook.md
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
# Runbook - Rampart
|
||||
|
||||
> Пошаговые инструкции для админа в критических ситуациях.
|
||||
|
||||
---
|
||||
|
||||
## 1. DDoS атака - пошагово (3 ночи, вы сонный)
|
||||
|
||||
```bash
|
||||
# ── ШАГ 1: Подтвердить атаку ──
|
||||
|
||||
# Открыть Grafana → посмотреть алерты
|
||||
# Или в CLI:
|
||||
curl -s http://localhost:9090/api/v1/alerts | jq '.data.alerts[] | select(.state=="firing")'
|
||||
|
||||
# Проверить метрики edge ноды
|
||||
curl -s http://EDGE_IP:9090/metrics | grep -E "rampart_(connections|rate_limit|blocked)"
|
||||
|
||||
# ── ШАГ 2: Определить тип атаки ──
|
||||
|
||||
# Если CPU < 50% и много DROP → XDP работает, атака L3/L4
|
||||
# Если CPU > 80% → атака L7 (handshake flood)
|
||||
|
||||
# Проверка XDP счётчиков
|
||||
cat /sys/kernel/debug/tracing/trace_pipe | head -20
|
||||
|
||||
# ── ШАГ 3: Действия ──
|
||||
|
||||
# A) SYN flood (XDP справляется)
|
||||
# → просто наблюдаем, XDP дропает на уровне ядра
|
||||
# → проверить CPU: должен быть < 30%
|
||||
echo "Наблюдаем, XDP работает"
|
||||
|
||||
# B) Handshake flood (L7)
|
||||
# → Ужесточить rate limit на лету
|
||||
rampart config set rate_limit_login_pps 2
|
||||
rampart config set rate_limit_burst 5
|
||||
|
||||
# → Включить emergency mode (только whitelist)
|
||||
rampart emergency --enable
|
||||
# Это блокирует все IP кроме whitelist (доверенные ASN, verified players)
|
||||
|
||||
# C) Атака с датацентров
|
||||
# → Заблокировать ASN
|
||||
rampart blacklist add asn 16276 # OVH
|
||||
rampart blacklist add asn 24940 # Hetzner
|
||||
|
||||
# → Включить GeoIP фильтр (блокировать страну)
|
||||
rampart geoip block CN RU
|
||||
|
||||
# D) Атака на конкретный протокол
|
||||
# → Временно заблокировать статус пинги
|
||||
rampart config set rate_limit_status_pps 0.1
|
||||
|
||||
# → Заблокировать старые версии протокола
|
||||
rampart config set min_protocol_version 765
|
||||
|
||||
# ── ШАГ 4: Если не помогает ──
|
||||
|
||||
# Включить challenge для ВСЕХ новых подключений
|
||||
rampart challenge --mode all --type timing
|
||||
|
||||
# В крайнем случае - отключить все не-WG порты на edge
|
||||
systemctl stop rampart-edge
|
||||
iptables -A INPUT -p tcp --dport 25565 -j DROP
|
||||
# Игроки не заходят, но серверы в безопасности
|
||||
# Проверить через провайдера: возможно у них есть tools для фильтрации
|
||||
|
||||
# ── ШАГ 5: После атаки ──
|
||||
|
||||
# Выключить emergency mode
|
||||
rampart emergency --disable
|
||||
|
||||
# Проверить логи в ClickHouse
|
||||
clickhouse-client --query "
|
||||
SELECT src_country, count() as attacks
|
||||
FROM rampart.blocked
|
||||
WHERE ts > now() - INTERVAL 1 HOUR
|
||||
GROUP BY src_country
|
||||
ORDER BY attacks DESC
|
||||
LIMIT 10
|
||||
"
|
||||
|
||||
# Написать post-mortem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Edge нода не стартует
|
||||
|
||||
```bash
|
||||
# 1. Проверить статус
|
||||
systemctl status rampart-edge
|
||||
|
||||
# 2. Логи
|
||||
journalctl -u rampart-edge -n 50 --no-pager
|
||||
|
||||
# 3. Типичные причины:
|
||||
|
||||
# A) Порт занят
|
||||
ss -tlnp | grep 25565
|
||||
# Решение: сменить порт в /etc/rampart/config.toml
|
||||
|
||||
# B) Конфиг не валидный
|
||||
rampart config validate /etc/rampart/config.toml
|
||||
|
||||
# C) libbpf не найден (если собрано с XDP)
|
||||
ldd /usr/local/bin/rampart-core | grep bpf
|
||||
# Решение: apt-get install libbpf-dev
|
||||
|
||||
# D) Нет прав на BPF
|
||||
# Решение: sudo setcap cap_bpf+ep /usr/local/bin/rampart-core
|
||||
|
||||
# 4. Запуск вручную (для диагностики)
|
||||
/usr/local/bin/rampart-core --config /etc/rampart/config.toml --verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. XDP не загружается
|
||||
|
||||
```bash
|
||||
# 1. Проверить виртуализацию
|
||||
systemd-detect-virt
|
||||
# openvz/lxc → XDP не работает. Сменить провайдера.
|
||||
|
||||
# 2. Проверить версию ядра
|
||||
uname -r
|
||||
# < 5.10 → обновить ядро
|
||||
|
||||
# 3. Проверить драйвер
|
||||
ethtool -i eth0 | grep driver
|
||||
# virtio → только generic mode
|
||||
# i40e/mlx5 → native mode
|
||||
|
||||
# 4. Проверить XDP поддержку
|
||||
sudo ip link set dev eth0 xdp off 2>&1
|
||||
# "Operation not supported" → XDP не поддерживается
|
||||
|
||||
# 5. Решение: отключить XDP в config.toml
|
||||
# [xdp]
|
||||
# enabled = false
|
||||
# И перезапустить edge
|
||||
systemctl restart rampart-edge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Игроки не могут зайти
|
||||
|
||||
```bash
|
||||
# 1. Проверить edge ноду
|
||||
curl -s http://EDGE_IP:9090/metrics | grep rampart_connections
|
||||
# Если 0 → edge не принимает соединения
|
||||
|
||||
# 2. Проверить что порт открыт
|
||||
nc -zv EDGE_IP 25565
|
||||
|
||||
# 3. Проверить HMAC
|
||||
# На velocity: /logs/rampart-hmac.log
|
||||
# "HMAC mismatch" → не совпадает secret
|
||||
# "Direct IP blocked" → игрок подключился не через edge
|
||||
|
||||
# 4. Проверить firewall
|
||||
iptables -L INPUT -n -v | grep 25565
|
||||
|
||||
# 5. Проверить DNS
|
||||
dig +short play.example.com
|
||||
# Должен показывать IP edge ноды
|
||||
|
||||
# 6. Проверить rate limit
|
||||
# Если игроков много с одного IP (NAT) - превышают лимит
|
||||
rampart config set max_connections_per_ip 50 # увеличить
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Высокая нагрузка на edge
|
||||
|
||||
```bash
|
||||
# 1. Определить bottleneck
|
||||
|
||||
# CPU
|
||||
htop -p $(pgrep -d',' rampart-core)
|
||||
|
||||
# Память
|
||||
ps aux | grep rampart-core
|
||||
|
||||
# I/O (если много логов)
|
||||
iotop
|
||||
|
||||
# Сеть (pps, bandwidth)
|
||||
iftop -i eth0
|
||||
|
||||
# 2. Типичные причины:
|
||||
|
||||
# A) Не хватает воркеров
|
||||
# → Увеличить workers = vCPU
|
||||
rampart config set workers_count $(nproc)
|
||||
systemctl restart rampart-edge
|
||||
|
||||
# B) CPU > 80% от L7 парсинга
|
||||
# → Включить XDP чтобы разгрузить userspace
|
||||
# → Уменьшить rate_limit до разумных пределов
|
||||
# → Проверить что нет SQL injection или других атак (парсинг hostname!)
|
||||
|
||||
# C) Утечка памяти
|
||||
# → Проверить RSS за последние часы
|
||||
# → Если растёт - включить профилирование
|
||||
rampart debug pprof
|
||||
|
||||
# 3. Временное решение
|
||||
rampart config set max_connections 50000 # ограничить
|
||||
|
||||
# 4. Постоянное решение
|
||||
# Добавить ещё одну edge ноду
|
||||
rampart add-node --role edge --name edge-eu-2 --ip 45.200.10.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. ClickHouse переполнен
|
||||
|
||||
```bash
|
||||
# 1. Проверить дисковое пространство
|
||||
df -h /var/lib/clickhouse
|
||||
|
||||
# 2. Очистить старые партиции (> 90 дней)
|
||||
clickhouse-client --query "
|
||||
SELECT partition, formatReadableSize(bytes_on_disk)
|
||||
FROM system.parts
|
||||
WHERE table = 'blocked'
|
||||
ORDER BY partition
|
||||
"
|
||||
|
||||
# Удалить старые
|
||||
clickhouse-client --query "
|
||||
ALTER TABLE rampart.blocked DROP PARTITION '2025-01'
|
||||
"
|
||||
|
||||
# 3. Настроить TTL если не сделано
|
||||
clickhouse-client --query "
|
||||
ALTER TABLE rampart.blocked
|
||||
MODIFY TTL ts + INTERVAL 90 DAY
|
||||
"
|
||||
|
||||
# 4. Отключить логирование на время (если совсем плохо)
|
||||
rampart config set clickhouse_enabled false
|
||||
# Данные складываются в буфер, не теряются
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Краткий справочник команд
|
||||
|
||||
```bash
|
||||
rampart status # Общее состояние системы
|
||||
rampart doctor # Полная диагностика
|
||||
|
||||
rampart config get workers.count # Получить параметр
|
||||
rampart config set workers.count 4 # Установить параметр (hot reload)
|
||||
|
||||
rampart blacklist add 1.2.3.4 # Забанить IP
|
||||
rampart blacklist add asn 24940 # Забанить ASN
|
||||
rampart blacklist list # Список забаненных
|
||||
rampart blacklist remove 1.2.3.4 # Разбанить
|
||||
|
||||
rampart whitelist add 10.0.0.0/16 # Добавить в whitelist
|
||||
rampart emergency --enable # Включить emergency mode
|
||||
rampart emergency --disable # Выключить
|
||||
|
||||
rampart drain edge-eu-1 # Плавно вывести ноду
|
||||
rampart reload backend # Перезагрузить список бэкендов
|
||||
rampart pki rotate --role edge # Ротация сертификатов
|
||||
rampart wg sync # Синхронизация WireGuard
|
||||
|
||||
rampart debug pprof # CPU профиль
|
||||
rampart debug heap # Heap профиль
|
||||
rampart debug metrics # Prometheus метрики в CLI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Июль 2026*
|
||||
249
docs/testing.md
Normal file
249
docs/testing.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# Testing - Rampart
|
||||
|
||||
> Как тестировать: unit, integration, нагрузочное, DDoS simulation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Unit тесты (Rust)
|
||||
|
||||
```bash
|
||||
# Все тесты
|
||||
cargo test
|
||||
|
||||
# Конкретный модуль
|
||||
cargo test handshake
|
||||
cargo test hmac
|
||||
cargo test rate_limiter
|
||||
|
||||
# С выводом
|
||||
cargo test -- --nocapture
|
||||
|
||||
# С профилированием
|
||||
cargo test --release
|
||||
```
|
||||
|
||||
### Что тестировать
|
||||
|
||||
| Модуль | Happy path | Error cases |
|
||||
|--------|-----------|-------------|
|
||||
| VarInt parser | обычный, короткий | overflow, incomplete, >5 байт |
|
||||
| MC Handshake | vanilla, forge, hmac | truncated, invalid utf8, wrong packet id |
|
||||
| HMAC sign/verify | правильный secret | wrong secret, empty hostname, timing |
|
||||
| Rate limiter | under limit, reset | over limit, burst, concurrent |
|
||||
| Blacklist | add/check/remove | expired entry, duplicate add |
|
||||
|
||||
### Пример: VarInt
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_varint_normal() {
|
||||
let buf = vec![0x00];
|
||||
assert_eq!(read_varint(&buf, 0).unwrap(), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_varint_max() {
|
||||
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x07];
|
||||
assert_eq!(read_varint(&buf, 0).unwrap(), (i32::MAX, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_varint_overflow() {
|
||||
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x0F]; // > 5 байт
|
||||
assert!(matches!(read_varint(&buf, 0), Err(VarIntError::TooBig)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_varint_incomplete() {
|
||||
let buf = vec![0x80]; // ждём ещё байты
|
||||
assert!(matches!(read_varint(&buf, 0), Err(VarIntError::Incomplete)));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Интеграционные тесты
|
||||
|
||||
```bash
|
||||
# Требуют: docker compose up (redis, clickhouse)
|
||||
cargo test --test integration
|
||||
```
|
||||
|
||||
### Что тестируем
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_full_flow() {
|
||||
// 1. Запускаем edge ноду (test config)
|
||||
// 2. Подключаемся Minecraft клиентом (через tokio::net::TcpStream)
|
||||
// 3. Шлём валидный handshake
|
||||
// 4. Проверяем что HMAC добавлен
|
||||
// 5. Проверяем что трафик проксирован до backend
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_blacklist_sync() {
|
||||
// 1. Добавляем IP в блэклист через Redis
|
||||
// 2. Проверяем что edge нода его подхватила
|
||||
// 3. Пытаемся подключиться с забаненного IP
|
||||
// 4. Проверяем что соединение отклонено
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Fuzzing
|
||||
|
||||
```rust
|
||||
// tests/fuzz/handshake.rs
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
// Должен крашиться на любой вход
|
||||
let _ = McHandshake::parse(data);
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo install cargo-fuzz
|
||||
cargo fuzz run handshake_parser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Нагрузочное тестирование
|
||||
|
||||
### Базовый тест (tcpkali)
|
||||
|
||||
```bash
|
||||
# Установка
|
||||
cargo install tcpkali
|
||||
|
||||
# 50k новых соединений
|
||||
tcpkali \
|
||||
--connections 1000 \
|
||||
--connect-rate 5000 \
|
||||
--duration 60s \
|
||||
EDGE_IP:25565
|
||||
|
||||
# 500 активных соединений с трафиком
|
||||
tcpkali \
|
||||
--connections 500 \
|
||||
--connect-rate 100 \
|
||||
--duration 120s \
|
||||
--message-rate 1 \
|
||||
--message "$(xxd mc_handshake.bin)" \
|
||||
EDGE_IP:25565
|
||||
```
|
||||
|
||||
### SYN flood (hping3)
|
||||
|
||||
```bash
|
||||
# Только на свои серверы!
|
||||
hping3 -S --flood -p 25565 EDGE_IP
|
||||
|
||||
# С рандомным src IP
|
||||
hping3 -S --flood -p 25565 --rand-source EDGE_IP
|
||||
```
|
||||
|
||||
### Реальные Minecraft боты (SoulFire)
|
||||
|
||||
```bash
|
||||
java -jar SoulFire.jar \
|
||||
--target play.example.com:25565 \
|
||||
--amount 200 \
|
||||
--join-delay 50 \
|
||||
--protocol-version 765
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. DDoS simulation
|
||||
|
||||
```bash
|
||||
# Сценарий 1: SYN flood
|
||||
# Ожидание: XDP дропает, CPU < 30%
|
||||
hping3 -S --flood -p 25565 EDGE_IP
|
||||
|
||||
# Сценарий 2: Handshake flood
|
||||
# Ожидание: rate limit блокирует, CPU < 60%
|
||||
for i in $(seq 1 1000); do
|
||||
(echo -n "$MC_HANDSHAKE" | nc -w1 EDGE_IP 25565) &
|
||||
done
|
||||
|
||||
# Сценарий 3: Slowloris
|
||||
# Ожидание: timeout 5 сек, соединение закрывается
|
||||
while true; do
|
||||
echo -n -e '\x01' | nc -w 10 EDGE_IP 25565
|
||||
done
|
||||
|
||||
# Сценарий 4: Fragmented handshake
|
||||
# Ожидание: буферизация, успешный парсинг
|
||||
# (отправляем handshake по 1 байту с задержкой 100ms)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. CI Pipeline
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo test
|
||||
- run: cargo clippy -- -D warnings
|
||||
- run: cargo fmt --check
|
||||
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo test --test integration
|
||||
|
||||
fuzz:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo fuzz run handshake_parser -- -runs=100000
|
||||
|
||||
bench:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo bench
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Метрики качества
|
||||
|
||||
```bash
|
||||
# Покрытие кода
|
||||
cargo install cargo-tarpaulin
|
||||
cargo tarpaulin --out Html
|
||||
open tarpaulin-report.html
|
||||
|
||||
# Цели:
|
||||
# core/handshake.rs: > 95%
|
||||
# core/hmac.rs: > 90%
|
||||
# core/rate_limit: > 85%
|
||||
# xdp/: тесты в изолированной среде
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Июль 2026*
|
||||
327
docs/troubleshooting.md
Normal file
327
docs/troubleshooting.md
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
# Troubleshooting - FAQ и диагностика
|
||||
|
||||
---
|
||||
|
||||
## Edge нода
|
||||
|
||||
### "XDP не загружается"
|
||||
|
||||
```bash
|
||||
# Проверяем виртуализацию
|
||||
systemd-detect-virt
|
||||
# openvz / lxc -> XDP не работает, нужен KVM
|
||||
|
||||
# Проверяем ядро
|
||||
uname -r
|
||||
# Нужно 5.10+
|
||||
|
||||
# Проверяем зависимости
|
||||
dpkg -l | grep libbpf
|
||||
# libbpf-dev должен быть установлен
|
||||
|
||||
# Смотрим ошибку загрузки
|
||||
journalctl -u rampart-edge | grep -i "xdp\|ebpf\|bpf"
|
||||
|
||||
# Если драйвер не поддерживает native - fallback на generic
|
||||
# В конфиге:
|
||||
[xdp]
|
||||
mode = "generic" # вместо "native" или "auto"
|
||||
```
|
||||
|
||||
### "Edge не коннектится к Manager"
|
||||
|
||||
```bash
|
||||
# Проверяем WireGuard
|
||||
ping 10.0.0.1
|
||||
# Нет ответа -> WireGuard не работает
|
||||
|
||||
wg show
|
||||
# Смотрим peer Manager - есть ли last handshake?
|
||||
# Нет handshake -> проблема с ключами или firewall у Manager
|
||||
|
||||
# Проверяем firewall на Manager
|
||||
ssh root@MANAGER_IP 'iptables -L INPUT -n | grep 51820'
|
||||
# Должно быть правило ACCEPT для UDP 51820
|
||||
|
||||
# Проверяем что Manager слушает
|
||||
ssh root@MANAGER_IP 'ss -ulnp | grep 51820'
|
||||
|
||||
# Пересоздаём WireGuard handshake
|
||||
wg set wg0 peer MANAGER_PUBKEY endpoint MANAGER_IP:51820
|
||||
```
|
||||
|
||||
### "Rate limit блокирует реальных игроков"
|
||||
|
||||
```bash
|
||||
# Симптом: игроки жалуются что не могут зайти
|
||||
|
||||
# Смотрим кого блокируем
|
||||
journalctl -u rampart-edge | grep "RATE_LIMIT" | tail -50
|
||||
|
||||
# Если блокируем целые подсети мобильных операторов (NAT):
|
||||
# Увеличиваем лимит для мобильных ASN
|
||||
rampart config set rate_limit.mobile_multiplier 3.0
|
||||
|
||||
# Или поднимаем общий лимит
|
||||
rampart config set rate_limit.max_connections_per_ip 10
|
||||
rampart config reload
|
||||
```
|
||||
|
||||
### "Высокое CPU на edge ноде"
|
||||
|
||||
```bash
|
||||
# Смотрим что жрёт CPU
|
||||
top -p $(pgrep rampart-edge)
|
||||
|
||||
# Профилируем
|
||||
perf top -p $(pgrep rampart-edge)
|
||||
|
||||
# Частые причины:
|
||||
# 1. Слишком много активных соединений -> включить XDP чтобы дропать раньше
|
||||
# 2. HMAC считается для каждого пакета -> норма, так и должно быть
|
||||
# 3. GeoIP lookup медленный -> включить кэш
|
||||
[geo]
|
||||
cache_size = 100000
|
||||
cache_ttl_secs = 3600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Velocity плагин
|
||||
|
||||
### "Velocity не видит серверы"
|
||||
|
||||
```bash
|
||||
# В логах Velocity ищем:
|
||||
grep -i "rampart\|registry\|redis" /opt/velocity/logs/latest.log
|
||||
|
||||
# Частые причины:
|
||||
|
||||
# 1. Redis недоступен
|
||||
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD ping
|
||||
# Connection refused -> Redis не слушает на WireGuard IP
|
||||
|
||||
# 2. Неверный пароль Redis
|
||||
# В config.yml проверяем redis.password
|
||||
|
||||
# 3. Velocity не в WireGuard сети
|
||||
ping 10.0.0.1 # с ноды Velocity
|
||||
# Нет ответа -> настраиваем WireGuard
|
||||
|
||||
# 4. Серверы не зарегистрированы (Paper агент не запущен)
|
||||
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD keys "rampart:servers:*"
|
||||
# Пустой ответ -> Paper агент не работает
|
||||
```
|
||||
|
||||
### "Игроков не пускает - 'Подключение по IP запрещено'"
|
||||
|
||||
```bash
|
||||
# Это нормально если игрок подключается по IP, а не домену
|
||||
# Проверяем что DNS работает:
|
||||
nslookup play.yourserver.com
|
||||
# Должен вернуть IP edge ноды
|
||||
|
||||
# Если игрок подключается через домен и всё равно кикает:
|
||||
# Проверяем что edge HMAC совпадает с Velocity
|
||||
|
||||
# На Velocity смотрим логи:
|
||||
grep "HMAC\|shield" /opt/velocity/logs/latest.log
|
||||
|
||||
# Частые причины:
|
||||
# 1. Разные HMAC секреты на edge и Velocity
|
||||
# Сравниваем:
|
||||
cat /etc/rampart/config.toml | grep hmac_secret
|
||||
grep RAMPART_HMAC_SECRET /opt/velocity/velocity.conf
|
||||
|
||||
# 2. Edge нода не добавляет HMAC (add_hmac_header = false)
|
||||
# В /etc/rampart/config.toml:
|
||||
[shield]
|
||||
add_hmac_header = true
|
||||
```
|
||||
|
||||
### "Игрок попадает не на тот сервер"
|
||||
|
||||
```bash
|
||||
# Проверяем стратегию балансировщика
|
||||
grep "strategy" /opt/velocity/plugins/rampart/config.yml
|
||||
|
||||
# Смотрим онлайн по серверам
|
||||
rampart server list
|
||||
|
||||
# Если сервер переполнен но всё равно получает игроков:
|
||||
# Проверяем что Paper агент обновляет онлайн
|
||||
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD \
|
||||
GET rampart:servers:hub_1
|
||||
# В JSON смотрим "online" - должно обновляться
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paper агент
|
||||
|
||||
### "Агент не регистрирует сервер"
|
||||
|
||||
```bash
|
||||
# В логах Minecraft сервера:
|
||||
grep -i "rampart\|shield agent" /opt/minecraft/logs/latest.log
|
||||
|
||||
# Частые причины:
|
||||
|
||||
# 1. Redis недоступен с этой ноды
|
||||
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD ping
|
||||
|
||||
# 2. Неверный IP в конфиге (указан публичный вместо WireGuard)
|
||||
# Проверить env RAMPART_SERVER_IP
|
||||
# Должен быть 10.0.x.x (WireGuard IP)
|
||||
|
||||
# 3. Дублирующееся имя сервера
|
||||
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD \
|
||||
keys "rampart:servers:*"
|
||||
# Если имя уже есть - изменить RAMPART_SERVER_NAME
|
||||
|
||||
# 4. Агент не установлен
|
||||
ls /opt/minecraft/plugins/ | grep rampart-paper
|
||||
# Должен быть .jar файл
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Redis
|
||||
|
||||
### "Redis падает с OOM"
|
||||
|
||||
```bash
|
||||
# Проверяем использование памяти
|
||||
redis-cli -a $REDIS_PASSWORD INFO memory | grep used_memory_human
|
||||
|
||||
# Настраиваем eviction policy
|
||||
redis-cli -a $REDIS_PASSWORD CONFIG SET maxmemory 2gb
|
||||
redis-cli -a $REDIS_PASSWORD CONFIG SET maxmemory-policy allkeys-lru
|
||||
|
||||
# Смотрим что занимает место
|
||||
redis-cli -a $REDIS_PASSWORD --bigkeys
|
||||
```
|
||||
|
||||
### "Redis медленно отвечает"
|
||||
|
||||
```bash
|
||||
# Запускаем latency monitor
|
||||
redis-cli -a $REDIS_PASSWORD --latency-history -i 1
|
||||
|
||||
# Смотрим slowlog
|
||||
redis-cli -a $REDIS_PASSWORD SLOWLOG GET 10
|
||||
|
||||
# Частые причины:
|
||||
# 1. KEYS команда (блокирует) -> заменить на SCAN
|
||||
# 2. Нет persistent connection pool -> Jedis pool в Velocity плагине
|
||||
# 3. Сеть: проверяем пинг от Velocity до Redis через WireGuard
|
||||
ping 10.0.0.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WireGuard
|
||||
|
||||
### "Ноды не видят друг друга"
|
||||
|
||||
```bash
|
||||
# На каждой ноде
|
||||
wg show
|
||||
# Смотрим:
|
||||
# - есть ли peer с нужным PublicKey
|
||||
# - есть ли "latest handshake" (должен быть свежий)
|
||||
# - endpoint правильный
|
||||
|
||||
# Если нет handshake:
|
||||
# 1. Проверяем что Manager слушает UDP 51820
|
||||
ss -ulnp | grep 51820
|
||||
|
||||
# 2. Проверяем firewall на Manager
|
||||
iptables -L INPUT -n | grep 51820
|
||||
|
||||
# 3. Проверяем что ключи правильные
|
||||
wg pubkey < /etc/wireguard/private.key
|
||||
# Должен совпасть с PublicKey у peer на Manager
|
||||
|
||||
# Форс рестарт
|
||||
systemctl restart wg-quick@wg0
|
||||
```
|
||||
|
||||
### "Высокий пинг через WireGuard"
|
||||
|
||||
```bash
|
||||
# Измеряем
|
||||
ping 10.0.0.1
|
||||
# Нормально: < 5 мс внутри датацентра, < 50 мс между регионами
|
||||
|
||||
# Если > 200 мс -> проблема с маршрутизацией
|
||||
traceroute 10.0.0.1
|
||||
|
||||
# MTU проблема (фрагментация):
|
||||
ping -M do -s 1400 10.0.0.1
|
||||
# Если drops -> MTU слишком большой
|
||||
# В /etc/wireguard/wg0.conf добавить:
|
||||
MTU = 1380
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ClickHouse
|
||||
|
||||
### "ClickHouse не принимает данные"
|
||||
|
||||
```bash
|
||||
# Проверяем что запущен
|
||||
systemctl status clickhouse-server
|
||||
# или
|
||||
docker compose ps rampart-clickhouse
|
||||
|
||||
# Проверяем таблицы
|
||||
clickhouse-client --query "SHOW TABLES FROM rampart"
|
||||
|
||||
# Проверяем ошибки вставки в логах Manager
|
||||
journalctl -u rampart-manager | grep -i "clickhouse\|insert"
|
||||
|
||||
# Частые причины:
|
||||
# 1. Таблица не создана -> запускаем миграции
|
||||
rampart db migrate
|
||||
|
||||
# 2. Нет места на диске
|
||||
df -h
|
||||
# ClickHouse хранит в /var/lib/clickhouse/
|
||||
|
||||
# 3. Неверная схема (после обновления)
|
||||
clickhouse-client --query "DESCRIBE TABLE rampart.blocked"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Общая диагностика
|
||||
|
||||
```bash
|
||||
# Полная проверка системы одной командой
|
||||
rampart doctor
|
||||
|
||||
# Что проверяет:
|
||||
# ✅ WireGuard туннели
|
||||
# ✅ Redis доступность
|
||||
# ✅ NATS доступность
|
||||
# ✅ Manager API
|
||||
# ✅ Все edge ноды online
|
||||
# ✅ Все Velocity ноды online
|
||||
# ✅ Хотя бы один Hub онлайн
|
||||
# ✅ HMAC секрет одинаковый везде
|
||||
# ✅ Сертификаты не истекают в ближайшие 30 дней
|
||||
# ✅ Redis память < 80%
|
||||
# ✅ Место на дисках > 20%
|
||||
|
||||
# Вывод:
|
||||
# [OK] Redis: 10.0.0.1:6379
|
||||
# [OK] Manager API: /api/health
|
||||
# [WARN] Edge eu-1: last seen 45 sec ago (порог 30 сек)
|
||||
# [FAIL] Hub_5: не зарегистрирован в Redis
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Июль 2026*
|
||||
147
docs/vds_compatibility.md
Normal file
147
docs/vds_compatibility.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# VDS Compatibility - Rampart
|
||||
|
||||
> Совместимость VDS провайдеров с XDP/eBPF, io_uring и WireGuard.
|
||||
> Обновляется: Июль 2026
|
||||
|
||||
---
|
||||
|
||||
## Почему это важно
|
||||
|
||||
Некоторые провайдеры используют виртуализацию, которая **не поддерживает XDP**:
|
||||
|
||||
| Тип виртуализации | XDP Native | XDP Generic | io_uring | Рекомендация |
|
||||
|---|---|---|---|---|
|
||||
| **KVM** | ✅ (зависит от драйвера) | ✅ | ✅ | Лучший выбор |
|
||||
| **Bare Metal** | ✅ | ✅ | ✅ | Идеально для edge |
|
||||
| **VMware** | ❌ | ✅ | ✅ | Приемлемо |
|
||||
| **Hyper-V** | ❌ | ✅ | ✅ | Приемлемо |
|
||||
| **OpenVZ / LXC** | ❌ | ❌ | ❌ | **НЕ ИСПОЛЬЗОВАТЬ** для edge |
|
||||
|
||||
> ⚠️ **OpenVZ/LXC контейнеры не поддерживают XDP и io_uring.**
|
||||
> Если купите VDS за $3 у OVH - XDP не заведётся.
|
||||
|
||||
---
|
||||
|
||||
## Таблица провайдеров
|
||||
|
||||
### Edge нода (требует XDP)
|
||||
|
||||
| Провайдер | План | Виртуализация | XDP Native | XDP Generic | Цена/мес | Примечание |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Hetzner** | CX22 (2vCPU, 4GB) | KVM | ❌ (virtio) | ✅ | €4.5 | Отличный entry-level |
|
||||
| **Hetzner** | CPX21 (3vCPU, 4GB) | KVM | ✅ (i40e) | ✅ | €6.9 | Рекомендуется |
|
||||
| **Hetzner** | AX102 (8vCPU, 32GB) | Bare Metal | ✅ | ✅ | €35 | Для крупных нод |
|
||||
| **Contabo** | Cloud VPS S (4vCPU, 8GB) | KVM | ❌ | ✅ | €5.0 | Бюджетно, но CPU слабее |
|
||||
| **Vultr** | High Frequency (2vCPU, 4GB) | KVM | ✅ | ✅ | $12 | Хорошая сеть |
|
||||
| **Vultr** | Regular (2vCPU, 4GB) | KVM | ❌ (virtio) | ✅ | $6 | Базовый вариант |
|
||||
| **OVHcloud** | VPS Value (2vCPU, 4GB) | KVM | ❌ | ✅ | €3.5 | Бюджетно |
|
||||
| **OVHcloud** | VPS Elite (4vCPU, 8GB) | KVM | ✅ | ✅ | €15 | Рекомендуется |
|
||||
| **OVHcloud** | Bare Metal Game (4vCPU, 32GB) | Bare Metal | ✅ | ✅ | €30 | Для game серверов |
|
||||
| **DigitalOcean** | Premium (2vCPU, 4GB) | KVM | ❌ | ✅ | $12 | Стабильно, но дороже |
|
||||
| **Linode** | Dedicated CPU (4vCPU, 8GB) | KVM | ✅ | ✅ | $36 | Дороговато для edge |
|
||||
| **Scaleway** | DEV1-L (4vCPU, 8GB) | KVM | ❌ (virtio) | ✅ | €11 | - |
|
||||
| **AWS** | c6i.large (2vCPU, 4GB) | Nitro KVM | ✅ (ena) | ✅ | ~$24 | Дорого, сложный network |
|
||||
| **Google Cloud** | e2-standard-2 (2vCPU, 4GB) | KVM | ❌ | ✅ | ~$17 | - |
|
||||
|
||||
> ✅ = Подтверждено работает
|
||||
> ❌ = Не поддерживается драйвером
|
||||
|
||||
### Manager / Load Balancer (XDP не нужен)
|
||||
|
||||
Для Manager, HAProxy, Rust LB подойдёт **любой KVM VDS** с 2 vCPU. XDP не требуется.
|
||||
|
||||
| Провайдер | План | Цена/мес |
|
||||
|---|---|---|
|
||||
| Hetzner CX22 | 2vCPU, 4GB | €4.5 |
|
||||
| Contabo VPS S | 4vCPU, 8GB | €5.0 |
|
||||
| OVH VPS Value | 2vCPU, 4GB | €3.5 |
|
||||
|
||||
### Game серверы (Minecraft)
|
||||
|
||||
| Провайдер | План | RAM | Цена/мес | Примечание |
|
||||
|---|---|---|---|---|
|
||||
| Hetzner AX102 | Bare Metal, 8vCPU | 32GB | €35 | Лучшее соотношение |
|
||||
| OVH Game | 4vCPU | 32GB | €30 | Оптимизирован для игр |
|
||||
| Localhost | Dedicated | 64GB+ | - | Лучшая производительность |
|
||||
|
||||
---
|
||||
|
||||
## Как проверить совместимость
|
||||
|
||||
```bash
|
||||
# 1. Тип виртуализации (должно быть kvm или none)
|
||||
systemd-detect-virt
|
||||
|
||||
# 2. Драйвер сетевой карты
|
||||
ethtool -i eth0 | grep driver
|
||||
# i40e / mlx5 = XDP Native ✅
|
||||
# virtio / vmxnet3 = XDP Generic только
|
||||
|
||||
# 3. Версия ядра (нужно 5.10+)
|
||||
uname -r
|
||||
|
||||
# 4. XDP доступность
|
||||
sudo ip link set dev eth0 xdp off 2>&1 || echo "XDP не поддерживается"
|
||||
|
||||
# 5. io_uring доступность
|
||||
cat /proc/sys/kernel/io_uring_disabled
|
||||
# 0 = OK, 1 = только root, 2 = заблокирован
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Рекомендуемые конфигурации
|
||||
|
||||
### Для старта (v0.1, до 500 игроков)
|
||||
|
||||
```
|
||||
1 × Hetzner CX22 (€4.5) - Manager + Redis + NATS
|
||||
1 × Hetzner CX22 (€4.5) - Edge нода (XDP Generic)
|
||||
1 × Velocity на той же VDS что и Manager
|
||||
N × Game серверы (ваши существующие)
|
||||
Итого: ~€9/мес
|
||||
```
|
||||
|
||||
### Medium (v0.4+, до 5000 игроков)
|
||||
|
||||
```
|
||||
1 × Hetzner CPX31 (€12) - Manager + Redis + NATS + ClickHouse
|
||||
2 × Hetzner CPX21 (€6.9) - Edge ноды (XDP Native)
|
||||
2 × Hetzner CX32 (€8) - Velocity
|
||||
5 × Hetzner AX102 (€35) - Game серверы
|
||||
Итого: ~€230/мес
|
||||
```
|
||||
|
||||
### Large (v0.6+, до 50000 игроков)
|
||||
|
||||
```
|
||||
1 × Hetzner AX102 (€35) - Manager + NATS + ClickHouse
|
||||
4 × Hetzner CPX31 (€12) - Rust LB
|
||||
6 × Hetzner CPX31 (€12) - Edge ноды (XDP Native)
|
||||
15 × Hetzner CX32 (€8) - Velocity
|
||||
20 × Hetzner AX102 (€35) - Game серверы
|
||||
Итого: ~€1100/мес
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Лимиты провайдеров
|
||||
|
||||
### Hetzner
|
||||
- **Traffic:** CX/CPX - 20TB включено, далее €1/TB
|
||||
- **DDoS Protection:** Встроенная L3/L4 защита (10Gbps blackhole)
|
||||
- **BGP:** Только на выделенных серверах (AX)
|
||||
|
||||
### Contabo
|
||||
- **Traffic:** Неограничен (512Mbps)
|
||||
- **DDoS Protection:** Есть, но слабая
|
||||
- **CPU:** Старшие модели Intel Xeon, но shared
|
||||
|
||||
### OVHcloud
|
||||
- **VPS:** OpenVZ на старых тарифах - **проверяйте перед покупкой**
|
||||
- **Game серверы:** Встроенная DDoS защита (up to 1Tbps)
|
||||
- **BGP:** На Bare Metal
|
||||
|
||||
---
|
||||
|
||||
*Версия: 1.0 | Июль 2026*
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue