first commit
This commit is contained in:
commit
f2653e5747
3104
Cargo.lock
generated
Normal file
3104
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
Cargo.toml
Normal file
48
Cargo.toml
Normal file
@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "vine"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0"
|
||||
|
||||
[dependencies]
|
||||
pumpkin-protocol = { path = "../Pumpkin/crates/pumpkin-protocol" }
|
||||
pumpkin-util = { path = "../Pumpkin/crates/pumpkin-util" }
|
||||
pumpkin-command = { path = "../Pumpkin/crates/pumpkin-command" }
|
||||
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0" }
|
||||
toml = { version = "1.1", features = ["parse", "display", "serde"] }
|
||||
tracing = { version = "0.1" }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
thiserror = { version = "2.0" }
|
||||
bytes = { version = "1.12" }
|
||||
uuid = { version = "1.26", features = ["serde", "v3", "v4"] }
|
||||
rand = { version = "0.10" }
|
||||
|
||||
# Networking & Sockets
|
||||
socket2 = { version = "0.6", features = ["all"] }
|
||||
|
||||
# Security & Cryptography
|
||||
rsa = { version = "=0.10.0-rc.18", features = ["std", "encoding"] }
|
||||
sha1 = { version = "=0.11.0" }
|
||||
sha2 = { version = "=0.11.0" }
|
||||
hmac = { version = "=0.13.0" }
|
||||
num-bigint = { version = "0.5" }
|
||||
base64 = { version = "0.23" }
|
||||
ed25519-dalek = { version = "3.0", default-features = false, features = ["fast"] }
|
||||
hex = { version = "0.4", default-features = false, features = ["std"] }
|
||||
|
||||
# HTTP client for Mojang auth and telemetry
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "json", "http2"] }
|
||||
|
||||
# Telemetry & System Info
|
||||
sysinfo = { version = "0.39", default-features = false, features = ["system"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
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>.
|
||||
174
src/auth.rs
Normal file
174
src/auth.rs
Normal file
@ -0,0 +1,174 @@
|
||||
use num_bigint::BigInt;
|
||||
use pumpkin_protocol::Property;
|
||||
use rsa::{Pkcs1v15Encrypt, RsaPrivateKey, pkcs8::EncodePublicKey};
|
||||
use serde::Deserialize;
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::{net::IpAddr, time::Duration};
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AuthError {
|
||||
#[error("Failed to decrypt RSA payload: {0}")]
|
||||
DecryptError(String),
|
||||
#[error("Verify token mismatch")]
|
||||
VerifyTokenMismatch,
|
||||
#[error("Mojang authentication failed (status: {0})")]
|
||||
MojangAuthFailed(u16),
|
||||
#[error("Failed to contact Mojang session service: {0}")]
|
||||
NetworkError(#[from] reqwest::Error),
|
||||
#[error("Failed to parse Mojang response JSON: {0}")]
|
||||
JsonError(#[from] serde_json::Error),
|
||||
#[error("Invalid UUID from authentication server: {0}")]
|
||||
InvalidUuid(#[from] uuid::Error),
|
||||
}
|
||||
|
||||
pub struct KeyStore {
|
||||
private_key: RsaPrivateKey,
|
||||
public_key_der: Box<[u8]>,
|
||||
}
|
||||
|
||||
impl KeyStore {
|
||||
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let mut rng = rand::rng();
|
||||
info!("Generating 1024-bit RSA keypair for proxy encryption...");
|
||||
let private_key = RsaPrivateKey::new(&mut rng, 1024)?;
|
||||
let public_key = private_key.to_public_key();
|
||||
let public_key_der = public_key
|
||||
.to_public_key_der()
|
||||
.map(|der| der.into_vec().into_boxed_slice())?;
|
||||
|
||||
Ok(Self {
|
||||
private_key,
|
||||
public_key_der,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn public_key_der(&self) -> &[u8] {
|
||||
&self.public_key_der
|
||||
}
|
||||
|
||||
pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, AuthError> {
|
||||
self.private_key
|
||||
.decrypt(Pkcs1v15Encrypt, data)
|
||||
.map_err(|e| AuthError::DecryptError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Computes Minecraft's custom hex SHA-1 server hash
|
||||
pub fn auth_digest(&self, server_id: &str, shared_secret: &[u8]) -> String {
|
||||
let mut sha1 = Sha1::new();
|
||||
sha1.update(server_id.as_bytes());
|
||||
sha1.update(shared_secret);
|
||||
sha1.update(&self.public_key_der);
|
||||
let digest = sha1.finalize();
|
||||
|
||||
BigInt::from_signed_bytes_be(&digest).to_str_radix(16)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedPlayer {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub properties: Vec<Property>,
|
||||
}
|
||||
|
||||
impl AuthenticatedPlayer {
|
||||
pub fn offline(username: &str) -> Self {
|
||||
let id = Uuid::new_v3(&Uuid::nil(), format!("OfflinePlayer:{username}").as_bytes());
|
||||
Self {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
properties: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct MojangSessionResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
properties: Vec<MojangProperty>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct MojangProperty {
|
||||
name: String,
|
||||
value: String,
|
||||
signature: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Authenticator {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Default for Authenticator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Authenticator {
|
||||
pub fn new() -> Self {
|
||||
let client = pumpkin_util::client_builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Authenticate a player with Mojang's session servers
|
||||
pub async fn authenticate(
|
||||
&self,
|
||||
username: &str,
|
||||
server_hash: &str,
|
||||
client_ip: &IpAddr,
|
||||
prevent_proxy_connections: bool,
|
||||
) -> Result<AuthenticatedPlayer, AuthError> {
|
||||
let url = if prevent_proxy_connections {
|
||||
format!(
|
||||
"https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={server_hash}&ip={client_ip}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={server_hash}"
|
||||
)
|
||||
};
|
||||
|
||||
debug!("Querying Mojang authentication service for '{}'", username);
|
||||
let resp = self.client.get(&url).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
error!(
|
||||
"Mojang authentication failed for '{}': status {}",
|
||||
username,
|
||||
resp.status()
|
||||
);
|
||||
return Err(AuthError::MojangAuthFailed(resp.status().as_u16()));
|
||||
}
|
||||
|
||||
let body = resp.text().await?;
|
||||
let data: MojangSessionResponse = serde_json::from_str(&body)?;
|
||||
|
||||
let uuid = Uuid::parse_str(&data.id)?;
|
||||
|
||||
let properties = data
|
||||
.properties
|
||||
.into_iter()
|
||||
.map(|p| Property {
|
||||
name: p.name.into_boxed_str(),
|
||||
value: p.value.into_boxed_str(),
|
||||
signature: p.signature.map(String::into_boxed_str),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(AuthenticatedPlayer {
|
||||
id: uuid,
|
||||
username: data.name,
|
||||
properties,
|
||||
})
|
||||
}
|
||||
}
|
||||
1389
src/backend/bridge.rs
Normal file
1389
src/backend/bridge.rs
Normal file
File diff suppressed because it is too large
Load Diff
261
src/backend/forwarding.rs
Normal file
261
src/backend/forwarding.rs
Normal file
@ -0,0 +1,261 @@
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use pumpkin_protocol::codec::var_int::VarInt;
|
||||
use pumpkin_protocol::ser::{NetworkWriteExt, WritingError};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::net::IpAddr;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::auth::AuthenticatedPlayer;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub const VELOCITY_PLAYER_INFO_CHANNEL: &str = "velocity:player_info";
|
||||
pub const VELOCITY_FORWARDING_VERSION: i32 = 4;
|
||||
|
||||
pub const VINE_PLAYER_INFO_CHANNEL: &str = "vine:player_info";
|
||||
pub const VINE_FORWARDING_VERSION: i32 = 1;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ForwardingError {
|
||||
#[error("Velocity secret is required for Velocity modern forwarding")]
|
||||
MissingVelocitySecret,
|
||||
#[error("Vine secret is required for Vine modern forwarding")]
|
||||
MissingVineSecret,
|
||||
#[error("HMAC initialization failed: {0}")]
|
||||
HmacError(String),
|
||||
#[error("Serialization IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
#[error("Writing error: {0}")]
|
||||
WritingError(#[from] WritingError),
|
||||
}
|
||||
|
||||
pub struct ForwardingHelper;
|
||||
|
||||
impl ForwardingHelper {
|
||||
/// Builds the BungeeCord virtual host handshake string for Bukkit / Spigot / PaperMC:
|
||||
/// Format: `"{host}\0{client_ip}\0{uuid_without_hyphens}\0{json_properties}"`
|
||||
pub fn build_bungeecord_host(
|
||||
original_host: &str,
|
||||
client_ip: &IpAddr,
|
||||
player: &AuthenticatedPlayer,
|
||||
) -> String {
|
||||
let uuid_str = player.id.simple().to_string();
|
||||
|
||||
let json_properties =
|
||||
serde_json::to_string(&player.properties).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
format!(
|
||||
"{}\0{}\0{}\0{}",
|
||||
original_host, client_ip, uuid_str, json_properties
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the Velocity player info plugin response data for PaperMC / Purpur / Pumpkin:
|
||||
/// Contains:
|
||||
/// - 32-byte HMAC-SHA256 signature
|
||||
/// - Forwarding version (VarInt 4)
|
||||
/// - Client IP address string
|
||||
/// - Player UUID (16 raw bytes)
|
||||
/// - Player username string
|
||||
/// - Properties list (name, value, optional signature)
|
||||
pub fn build_velocity_payload(
|
||||
secret: &str,
|
||||
client_ip: &IpAddr,
|
||||
player: &AuthenticatedPlayer,
|
||||
) -> Result<Vec<u8>, ForwardingError> {
|
||||
if secret.is_empty() {
|
||||
return Err(ForwardingError::MissingVelocitySecret);
|
||||
}
|
||||
|
||||
let mut payload = Vec::new();
|
||||
|
||||
VarInt(VELOCITY_FORWARDING_VERSION).encode(&mut payload)?;
|
||||
|
||||
let ip_str = client_ip.to_string();
|
||||
payload.write_string(&ip_str)?;
|
||||
|
||||
payload.extend_from_slice(player.id.as_bytes());
|
||||
|
||||
payload.write_string(&player.username)?;
|
||||
|
||||
VarInt(player.properties.len() as i32).encode(&mut payload)?;
|
||||
for prop in &player.properties {
|
||||
payload.write_string(&prop.name)?;
|
||||
payload.write_string(&prop.value)?;
|
||||
if let Some(sig) = &prop.signature {
|
||||
payload.write_bool(true)?;
|
||||
payload.write_string(sig)?;
|
||||
} else {
|
||||
payload.write_bool(false)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||||
.map_err(|e| ForwardingError::HmacError(e.to_string()))?;
|
||||
mac.update(&payload);
|
||||
let signature = mac.finalize().into_bytes();
|
||||
|
||||
let mut full_data = Vec::with_capacity(signature.len() + payload.len());
|
||||
full_data.extend_from_slice(&signature);
|
||||
full_data.extend_from_slice(&payload);
|
||||
|
||||
Ok(full_data)
|
||||
}
|
||||
|
||||
/// Derives the 32-byte Ed25519 signing key from secret.
|
||||
/// If secret is 64 hex chars, decodes hex directly to 32-byte seed.
|
||||
/// Otherwise hashes the secret with SHA-256 to produce a 32-byte seed.
|
||||
pub fn get_vine_signing_key(secret: &str) -> Result<SigningKey, ForwardingError> {
|
||||
let secret = secret.trim();
|
||||
if secret.is_empty() {
|
||||
return Err(ForwardingError::MissingVineSecret);
|
||||
}
|
||||
let seed: [u8; 32] = if secret.len() == 64 && hex::decode(secret).is_ok() {
|
||||
let mut s = [0u8; 32];
|
||||
let decoded = hex::decode(secret).map_err(|_| ForwardingError::MissingVineSecret)?;
|
||||
s.copy_from_slice(&decoded);
|
||||
s
|
||||
} else {
|
||||
Sha256::digest(secret.as_bytes()).into()
|
||||
};
|
||||
Ok(SigningKey::from_bytes(&seed))
|
||||
}
|
||||
|
||||
/// Returns the 64-character hex Ed25519 public key corresponding to the secret.
|
||||
/// This public key can be configured directly on Pumpkin backend servers.
|
||||
pub fn get_vine_public_key_hex(secret: &str) -> Result<String, ForwardingError> {
|
||||
let signing_key = Self::get_vine_signing_key(secret)?;
|
||||
Ok(hex::encode(signing_key.verifying_key().to_bytes()))
|
||||
}
|
||||
|
||||
/// Builds the Vine player info plugin response data for Pumpkin:
|
||||
/// Contains:
|
||||
/// - 64-byte Ed25519 signature
|
||||
/// - Forwarding version (VarInt 1)
|
||||
/// - Timestamp (u64 big-endian seconds since UNIX epoch)
|
||||
/// - Challenge nonce ([u8; 16] echoed from backend request)
|
||||
/// - Client IP address string
|
||||
/// - Player UUID (16 raw bytes)
|
||||
/// - Player username string
|
||||
/// - Properties list (name, value, optional signature)
|
||||
pub fn build_vine_payload(
|
||||
secret: &str,
|
||||
client_ip: &IpAddr,
|
||||
player: &AuthenticatedPlayer,
|
||||
challenge: &[u8; 16],
|
||||
) -> Result<Vec<u8>, ForwardingError> {
|
||||
let signing_key = Self::get_vine_signing_key(secret)?;
|
||||
|
||||
let mut payload = Vec::new();
|
||||
VarInt(VINE_FORWARDING_VERSION).encode(&mut payload)?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
payload.extend_from_slice(&now.to_be_bytes());
|
||||
payload.extend_from_slice(challenge);
|
||||
|
||||
let ip_str = client_ip.to_string();
|
||||
payload.write_string(&ip_str)?;
|
||||
|
||||
payload.extend_from_slice(player.id.as_bytes());
|
||||
payload.write_string(&player.username)?;
|
||||
|
||||
VarInt(player.properties.len() as i32).encode(&mut payload)?;
|
||||
for prop in &player.properties {
|
||||
payload.write_string(&prop.name)?;
|
||||
payload.write_string(&prop.value)?;
|
||||
if let Some(sig) = &prop.signature {
|
||||
payload.write_bool(true)?;
|
||||
payload.write_string(sig)?;
|
||||
} else {
|
||||
payload.write_bool(false)?;
|
||||
}
|
||||
}
|
||||
|
||||
let signature = signing_key.sign(&payload);
|
||||
|
||||
let mut full_data = Vec::with_capacity(64 + payload.len());
|
||||
full_data.extend_from_slice(&signature.to_bytes());
|
||||
full_data.extend_from_slice(&payload);
|
||||
|
||||
Ok(full_data)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signature, Verifier};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_bungeecord_host_formatting() {
|
||||
let player = AuthenticatedPlayer {
|
||||
id: Uuid::parse_str("d8f4a1e0-0f1b-4c3a-9f2e-1a2b3c4d5e6f").unwrap(),
|
||||
username: "Steve".to_string(),
|
||||
properties: vec![],
|
||||
};
|
||||
let ip: IpAddr = "192.0.2.1".parse().unwrap();
|
||||
let host = ForwardingHelper::build_bungeecord_host("play.example.com", &ip, &player);
|
||||
|
||||
let parts: Vec<&str> = host.split('\0').collect();
|
||||
assert_eq!(parts.len(), 4);
|
||||
assert_eq!(parts[0], "play.example.com");
|
||||
assert_eq!(parts[1], "192.0.2.1");
|
||||
assert_eq!(parts[2], "d8f4a1e00f1b4c3a9f2e1a2b3c4d5e6f");
|
||||
assert_eq!(parts[3], "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_velocity_payload_generation() {
|
||||
let player = AuthenticatedPlayer {
|
||||
id: Uuid::parse_str("d8f4a1e0-0f1b-4c3a-9f2e-1a2b3c4d5e6f").unwrap(),
|
||||
username: "Alex".to_string(),
|
||||
properties: vec![],
|
||||
};
|
||||
let ip: IpAddr = "127.0.0.1".parse().unwrap();
|
||||
let secret = "test-secret-key";
|
||||
|
||||
let result = ForwardingHelper::build_velocity_payload(secret, &ip, &player);
|
||||
assert!(result.is_ok());
|
||||
let data = result.unwrap();
|
||||
|
||||
assert!(data.len() > 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vine_payload_generation_and_verification() {
|
||||
let player = AuthenticatedPlayer {
|
||||
id: Uuid::parse_str("d8f4a1e0-0f1b-4c3a-9f2e-1a2b3c4d5e6f").unwrap(),
|
||||
username: "Alex".to_string(),
|
||||
properties: vec![],
|
||||
};
|
||||
let ip: IpAddr = "127.0.0.1".parse().unwrap();
|
||||
let secret = "test-vine-secret-phrase";
|
||||
let challenge = [7u8; 16];
|
||||
|
||||
let result = ForwardingHelper::build_vine_payload(secret, &ip, &player, &challenge);
|
||||
assert!(result.is_ok());
|
||||
let data = result.unwrap();
|
||||
|
||||
// 64-byte signature + payload
|
||||
assert!(data.len() > 64);
|
||||
let (sig_bytes, payload_bytes) = data.split_at(64);
|
||||
|
||||
let signing_key = ForwardingHelper::get_vine_signing_key(secret).unwrap();
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
|
||||
let mut sig_arr = [0u8; 64];
|
||||
sig_arr.copy_from_slice(sig_bytes);
|
||||
let signature = Signature::from_bytes(&sig_arr);
|
||||
|
||||
assert!(verifying_key.verify(payload_bytes, &signature).is_ok());
|
||||
|
||||
let pub_hex = ForwardingHelper::get_vine_public_key_hex(secret).unwrap();
|
||||
assert_eq!(pub_hex.len(), 64);
|
||||
}
|
||||
}
|
||||
43
src/backend/mod.rs
Normal file
43
src/backend/mod.rs
Normal file
@ -0,0 +1,43 @@
|
||||
pub mod bridge;
|
||||
pub mod forwarding;
|
||||
|
||||
pub use bridge::{BackendBridge, BridgeError};
|
||||
pub use forwarding::{ForwardingError, ForwardingHelper};
|
||||
|
||||
use crate::config::{BackendConfig, RoutingConfig};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct Router {
|
||||
routing: RoutingConfig,
|
||||
servers: HashMap<String, BackendConfig>,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
pub fn new(routing: RoutingConfig, servers: HashMap<String, BackendConfig>) -> Self {
|
||||
Self { routing, servers }
|
||||
}
|
||||
|
||||
/// Resolves which backend server to connect to based on virtual host or default
|
||||
pub fn resolve_server(&self, virtual_host: &str) -> Option<(String, BackendConfig)> {
|
||||
if let Some(target_name) = self.routing.forced_hosts.get(virtual_host)
|
||||
&& let Some(cfg) = self.servers.get(target_name)
|
||||
{
|
||||
return Some((target_name.clone(), cfg.clone()));
|
||||
}
|
||||
|
||||
if let Some(cfg) = self.servers.get(&self.routing.default_server) {
|
||||
return Some((self.routing.default_server.clone(), cfg.clone()));
|
||||
}
|
||||
|
||||
if let Some(fallback_name) = &self.routing.fallback_server
|
||||
&& let Some(cfg) = self.servers.get(fallback_name)
|
||||
{
|
||||
return Some((fallback_name.clone(), cfg.clone()));
|
||||
}
|
||||
|
||||
self.servers
|
||||
.iter()
|
||||
.next()
|
||||
.map(|(name, cfg)| (name.clone(), cfg.clone()))
|
||||
}
|
||||
}
|
||||
47
src/bedrock/config.rs
Normal file
47
src/bedrock/config.rs
Normal file
@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration options for Bedrock Edition support.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BedrockConfig {
|
||||
/// Whether Bedrock Edition listener and status response are enabled
|
||||
pub enabled: bool,
|
||||
/// UDP address and port for the Bedrock listener (default Minecraft Bedrock port is 19132)
|
||||
pub bind_address: String,
|
||||
/// Primary MOTD line shown in Bedrock server lists
|
||||
pub motd: String,
|
||||
/// Secondary MOTD line / subtitle shown in Bedrock server lists
|
||||
pub sub_motd: String,
|
||||
/// Protocol version advertised (2169 corresponds to Bedrock 1.26.45)
|
||||
pub protocol_version: u32,
|
||||
/// Bedrock version string advertised (e.g. "1.26.45")
|
||||
pub version_name: String,
|
||||
/// Level / world name displayed in Bedrock client ping
|
||||
pub level_name: String,
|
||||
/// Game mode displayed in Bedrock client ping ("Survival", "Creative", etc.)
|
||||
pub game_mode: String,
|
||||
/// Game mode ID (0 = Survival, 1 = Creative, 2 = Adventure, 3 = Spectator)
|
||||
pub game_mode_id: u32,
|
||||
/// Maximum Bedrock players displayed in ping (0 to inherit global server max_players)
|
||||
pub max_players: u32,
|
||||
/// Server GUID for RakNet (0 automatically generates a random GUID on startup)
|
||||
pub server_guid: u64,
|
||||
}
|
||||
|
||||
impl Default for BedrockConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
bind_address: "0.0.0.0:19132".to_string(),
|
||||
motd: "§a§lVine §7» §fHigh-Performance Minecraft Proxy".to_string(),
|
||||
sub_motd: "Bedrock Support • 1.26.x".to_string(),
|
||||
protocol_version: 2169,
|
||||
version_name: "1.26.45".to_string(),
|
||||
level_name: "Vine Proxy".to_string(),
|
||||
game_mode: "Survival".to_string(),
|
||||
game_mode_id: 0,
|
||||
max_players: 100,
|
||||
server_guid: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/bedrock/listener.rs
Normal file
61
src/bedrock/listener.rs
Normal file
@ -0,0 +1,61 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, error, info, trace};
|
||||
|
||||
use crate::bedrock::config::BedrockConfig;
|
||||
use crate::bedrock::status::BedrockStatusHandler;
|
||||
|
||||
/// Runs the Bedrock UDP listener task.
|
||||
pub async fn run_bedrock_listener(
|
||||
config: BedrockConfig,
|
||||
online_players: Arc<AtomicUsize>,
|
||||
mut shutdown_rx: broadcast::Receiver<()>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let bind_addr: SocketAddr = config.bind_address.parse()?;
|
||||
let socket = UdpSocket::bind(bind_addr).await?;
|
||||
let local_addr = socket.local_addr()?;
|
||||
|
||||
info!("=====================================================");
|
||||
info!(" Vine Bedrock Listener is active on UDP {}", local_addr);
|
||||
info!(
|
||||
" Bedrock Version: {} (Protocol {})",
|
||||
config.version_name, config.protocol_version
|
||||
);
|
||||
info!("=====================================================");
|
||||
|
||||
let status_handler = BedrockStatusHandler::new(config, online_players, local_addr.port());
|
||||
let mut buffer = [0u8; 2048];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = socket.recv_from(&mut buffer) => {
|
||||
match result {
|
||||
Ok((length, peer_addr)) => {
|
||||
let packet = &buffer[..length];
|
||||
if let Some(pong_bytes) = status_handler.handle_packet(packet) {
|
||||
trace!("[{}] Received Bedrock status ping, sending pong ({} bytes)", peer_addr, pong_bytes.len());
|
||||
if let Err(e) = socket.send_to(&pong_bytes, peer_addr).await {
|
||||
debug!("[{}] Failed to send Bedrock pong: {}", peer_addr, e);
|
||||
}
|
||||
} else {
|
||||
trace!("[{}] Received unrecognized Bedrock datagram ({} bytes)", peer_addr, length);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Bedrock UDP receive error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
info!("Bedrock listener received shutdown signal, stopping...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
10
src/bedrock/mod.rs
Normal file
10
src/bedrock/mod.rs
Normal file
@ -0,0 +1,10 @@
|
||||
pub mod config;
|
||||
pub mod listener;
|
||||
pub mod status;
|
||||
|
||||
pub use config::BedrockConfig;
|
||||
pub use listener::run_bedrock_listener;
|
||||
pub use status::{
|
||||
BedrockStatusHandler, RAKNET_UNCONNECTED_PING_ID, RAKNET_UNCONNECTED_PING_OPEN_ID,
|
||||
RAKNET_UNCONNECTED_PONG_ID,
|
||||
};
|
||||
160
src/bedrock/status.rs
Normal file
160
src/bedrock/status.rs
Normal file
@ -0,0 +1,160 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use pumpkin_protocol::bedrock::status::{
|
||||
CUnconnectedPong, OFFLINE_MESSAGE_MAGIC, SUnconnectedPing, SUnconnectedPingOpenConnections,
|
||||
};
|
||||
use pumpkin_protocol::serial::{PacketRead, PacketWrite};
|
||||
|
||||
use crate::bedrock::config::BedrockConfig;
|
||||
|
||||
pub const RAKNET_UNCONNECTED_PING_ID: u8 = 0x01;
|
||||
pub const RAKNET_UNCONNECTED_PING_OPEN_ID: u8 = 0x02;
|
||||
pub const RAKNET_UNCONNECTED_PONG_ID: u8 = 0x1c;
|
||||
|
||||
/// Handler for Bedrock RakNet status pings.
|
||||
pub struct BedrockStatusHandler {
|
||||
config: BedrockConfig,
|
||||
online_players: Arc<AtomicUsize>,
|
||||
server_guid: u64,
|
||||
ipv4_port: u16,
|
||||
ipv6_port: u16,
|
||||
}
|
||||
|
||||
impl BedrockStatusHandler {
|
||||
pub fn new(config: BedrockConfig, online_players: Arc<AtomicUsize>, ipv4_port: u16) -> Self {
|
||||
let server_guid = if config.server_guid != 0 {
|
||||
config.server_guid
|
||||
} else {
|
||||
rand::random()
|
||||
};
|
||||
let ipv6_port = ipv4_port.saturating_add(1);
|
||||
|
||||
Self {
|
||||
config,
|
||||
online_players,
|
||||
server_guid,
|
||||
ipv4_port,
|
||||
ipv6_port,
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats the official Bedrock MCPE advertisement string
|
||||
pub fn build_advertisement_string(&self) -> String {
|
||||
let online = self.online_players.load(Ordering::Relaxed) as i32;
|
||||
let motd = format!("{}\n{}", self.config.motd, self.config.sub_motd);
|
||||
|
||||
format!(
|
||||
"MCPE;{};{};{};{};{};{};{};{};{};{};{};0;",
|
||||
motd,
|
||||
self.config.protocol_version,
|
||||
self.config.version_name,
|
||||
online,
|
||||
self.config.max_players,
|
||||
self.server_guid,
|
||||
self.config.level_name,
|
||||
self.config.game_mode,
|
||||
self.config.game_mode_id,
|
||||
self.ipv4_port,
|
||||
self.ipv6_port,
|
||||
)
|
||||
}
|
||||
|
||||
/// Inspects an incoming UDP datagram and, if it is an unconnected ping, builds the pong response.
|
||||
pub fn handle_packet(&self, data: &[u8]) -> Option<Vec<u8>> {
|
||||
let (&packet_id, payload) = data.split_first()?;
|
||||
|
||||
let (time, magic) = match packet_id {
|
||||
RAKNET_UNCONNECTED_PING_ID => {
|
||||
let mut cursor = Cursor::new(payload);
|
||||
let ping = SUnconnectedPing::read(&mut cursor).ok()?;
|
||||
(ping.time, ping.magic)
|
||||
}
|
||||
RAKNET_UNCONNECTED_PING_OPEN_ID => {
|
||||
let mut cursor = Cursor::new(payload);
|
||||
let ping = SUnconnectedPingOpenConnections::read(&mut cursor).ok()?;
|
||||
(ping.time, ping.magic)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if magic != OFFLINE_MESSAGE_MAGIC {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ad = self.build_advertisement_string();
|
||||
let pong = CUnconnectedPong::new(time, self.server_guid, ad);
|
||||
|
||||
let mut response = vec![RAKNET_UNCONNECTED_PONG_ID];
|
||||
pong.write(&mut response).ok()?;
|
||||
Some(response)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn server_guid(&self) -> u64 {
|
||||
self.server_guid
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_advertisement_string() {
|
||||
let config = BedrockConfig {
|
||||
enabled: true,
|
||||
bind_address: "0.0.0.0:19132".to_string(),
|
||||
motd: "Vine Server".to_string(),
|
||||
sub_motd: "Bedrock Proxy".to_string(),
|
||||
protocol_version: 2169,
|
||||
version_name: "1.26.45".to_string(),
|
||||
level_name: "Lobby".to_string(),
|
||||
game_mode: "Survival".to_string(),
|
||||
game_mode_id: 0,
|
||||
max_players: 50,
|
||||
server_guid: 12345,
|
||||
};
|
||||
|
||||
let online = Arc::new(AtomicUsize::new(3));
|
||||
let handler = BedrockStatusHandler::new(config, online, 19132);
|
||||
let ad = handler.build_advertisement_string();
|
||||
|
||||
assert!(ad.starts_with("MCPE;Vine Server\nBedrock Proxy;2169;1.26.45;3;50;12345;Lobby;Survival;0;19132;19133;0;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_unconnected_ping() {
|
||||
let config = BedrockConfig::default();
|
||||
let online = Arc::new(AtomicUsize::new(0));
|
||||
let handler = BedrockStatusHandler::new(config, online, 19132);
|
||||
|
||||
let mut packet = vec![RAKNET_UNCONNECTED_PING_ID];
|
||||
packet.extend_from_slice(&1000u64.to_be_bytes());
|
||||
packet.extend_from_slice(&OFFLINE_MESSAGE_MAGIC);
|
||||
packet.extend_from_slice(&9999u64.to_be_bytes());
|
||||
|
||||
let response = handler.handle_packet(&packet);
|
||||
assert!(response.is_some());
|
||||
let res_bytes = response.unwrap();
|
||||
assert_eq!(res_bytes[0], RAKNET_UNCONNECTED_PONG_ID);
|
||||
assert_eq!(&res_bytes[1..9], &1000u64.to_be_bytes());
|
||||
assert_eq!(&res_bytes[17..33], &OFFLINE_MESSAGE_MAGIC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_invalid_magic() {
|
||||
let config = BedrockConfig::default();
|
||||
let online = Arc::new(AtomicUsize::new(0));
|
||||
let handler = BedrockStatusHandler::new(config, online, 19132);
|
||||
|
||||
let mut packet = vec![RAKNET_UNCONNECTED_PING_ID];
|
||||
packet.extend_from_slice(&1000u64.to_be_bytes());
|
||||
packet.extend_from_slice(&[0u8; 16]);
|
||||
packet.extend_from_slice(&9999u64.to_be_bytes());
|
||||
|
||||
let response = handler.handle_packet(&packet);
|
||||
assert!(response.is_none());
|
||||
}
|
||||
}
|
||||
443
src/client.rs
Normal file
443
src/client.rs
Normal file
@ -0,0 +1,443 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::time::Duration;
|
||||
|
||||
use pumpkin_protocol::codec::var_int::VarInt;
|
||||
use pumpkin_protocol::java::client::login::{
|
||||
CEncryptionRequest, CLoginDisconnect, CSetCompression,
|
||||
};
|
||||
use pumpkin_protocol::java::packet_decoder::TCPNetworkDecoder;
|
||||
use pumpkin_protocol::java::packet_encoder::TCPNetworkEncoder;
|
||||
use pumpkin_protocol::java::server::handshake::SHandShake;
|
||||
use pumpkin_protocol::java::server::login::{SEncryptionResponse, SLoginStart};
|
||||
use pumpkin_protocol::java::server::status::SStatusPingRequest;
|
||||
use pumpkin_protocol::{ClientPacket, ConnectionState, ServerPacket};
|
||||
use pumpkin_util::version::JavaMinecraftVersion;
|
||||
use thiserror::Error;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
|
||||
use tokio::time::timeout;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::auth::{AuthenticatedPlayer, Authenticator, KeyStore};
|
||||
use crate::backend::{BackendBridge, Router};
|
||||
use crate::command::CommandDispatcher;
|
||||
use crate::config::Config;
|
||||
use crate::network::PrefixedRead;
|
||||
use crate::security::Sanitizer;
|
||||
use crate::session::SessionManager;
|
||||
use crate::status::StatusHandler;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ClientError {
|
||||
#[error("Client IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
#[error("Protocol decode error: {0}")]
|
||||
DecodeError(String),
|
||||
#[error("Protocol encode error: {0}")]
|
||||
EncodeError(String),
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthError(String),
|
||||
#[error("Bridge error: {0}")]
|
||||
BridgeError(#[from] crate::backend::BridgeError),
|
||||
#[error("Connection timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
pub struct ClientHandler {
|
||||
peer_addr: SocketAddr,
|
||||
config: Arc<Config>,
|
||||
keystore: Arc<KeyStore>,
|
||||
authenticator: Arc<Authenticator>,
|
||||
status_handler: Arc<StatusHandler>,
|
||||
router: Arc<Router>,
|
||||
online_players: Arc<AtomicUsize>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
command_dispatcher: Arc<CommandDispatcher>,
|
||||
}
|
||||
|
||||
impl ClientHandler {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
peer_addr: SocketAddr,
|
||||
config: Arc<Config>,
|
||||
keystore: Arc<KeyStore>,
|
||||
authenticator: Arc<Authenticator>,
|
||||
status_handler: Arc<StatusHandler>,
|
||||
router: Arc<Router>,
|
||||
online_players: Arc<AtomicUsize>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
command_dispatcher: Arc<CommandDispatcher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
peer_addr,
|
||||
config,
|
||||
keystore,
|
||||
authenticator,
|
||||
status_handler,
|
||||
router,
|
||||
online_players,
|
||||
session_manager,
|
||||
command_dispatcher,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
self,
|
||||
stream: TcpStream,
|
||||
leftover_bytes: Vec<u8>,
|
||||
) -> Result<(), ClientError> {
|
||||
let (read_half, write_half) = stream.into_split();
|
||||
let reader = PrefixedRead::new(read_half, leftover_bytes);
|
||||
let mut decoder = TCPNetworkDecoder::new(reader);
|
||||
let mut encoder = TCPNetworkEncoder::new(write_half);
|
||||
|
||||
let handshake_timeout =
|
||||
Duration::from_secs(self.config.network.client_handshake_timeout_secs);
|
||||
let raw_handshake = timeout(handshake_timeout, decoder.get_raw_packet())
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
.map_err(|e| ClientError::DecodeError(e.to_string()))?;
|
||||
|
||||
let client_version_guess = JavaMinecraftVersion::V_26_2;
|
||||
let mut handshake_bytes = &raw_handshake.payload[..];
|
||||
let handshake = SHandShake::read(&mut handshake_bytes, &client_version_guess)
|
||||
.map_err(|e| ClientError::DecodeError(e.to_string()))?;
|
||||
|
||||
let client_protocol = handshake.protocol_version.0 as u32;
|
||||
let client_version = JavaMinecraftVersion::from_protocol(client_protocol);
|
||||
let raw_host = &handshake.server_address;
|
||||
let clean_host = Sanitizer::sanitize_host(raw_host);
|
||||
|
||||
debug!(
|
||||
"[{}] Handshake: protocol={}, resolved_version={:?}, host='{}', next_state={:?}",
|
||||
self.peer_addr, client_protocol, client_version, clean_host, handshake.next_state
|
||||
);
|
||||
|
||||
match handshake.next_state {
|
||||
ConnectionState::Status => {
|
||||
if self.config.network.log_pings {
|
||||
info!(
|
||||
"[{}] Status ping: protocol={}, version={:?}, host='{}'",
|
||||
self.peer_addr, client_protocol, client_version, clean_host
|
||||
);
|
||||
}
|
||||
self.handle_status(&mut decoder, &mut encoder, client_version, &clean_host)
|
||||
.await
|
||||
}
|
||||
ConnectionState::Login => {
|
||||
self.handle_login(
|
||||
decoder,
|
||||
encoder,
|
||||
client_version,
|
||||
clean_host,
|
||||
self.peer_addr.ip(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
other => {
|
||||
debug!(
|
||||
"[{}] Unsupported handshake state: {:?}",
|
||||
self.peer_addr, other
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_status(
|
||||
&self,
|
||||
decoder: &mut TCPNetworkDecoder<PrefixedRead<OwnedReadHalf>>,
|
||||
encoder: &mut TCPNetworkEncoder<OwnedWriteHalf>,
|
||||
client_version: JavaMinecraftVersion,
|
||||
virtual_host: &str,
|
||||
) -> Result<(), ClientError> {
|
||||
let status_timeout = Duration::from_secs(self.config.network.client_status_timeout_secs);
|
||||
|
||||
let raw_status = timeout(status_timeout, decoder.get_raw_packet())
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
.map_err(|e| ClientError::DecodeError(e.to_string()))?;
|
||||
|
||||
if raw_status.id == 0x00 {
|
||||
let status_pkt = self
|
||||
.status_handler
|
||||
.create_status_packet(client_version, Some(virtual_host));
|
||||
let status_bytes = status_pkt
|
||||
.serialize_packet(&client_version)
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
encoder
|
||||
.write_packet(status_bytes)
|
||||
.await
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
encoder
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
}
|
||||
|
||||
if let Ok(Ok(raw_ping)) = timeout(status_timeout, decoder.get_raw_packet()).await
|
||||
&& raw_ping.id == 0x01
|
||||
{
|
||||
let mut ping_bytes = &raw_ping.payload[..];
|
||||
if let Ok(ping) = SStatusPingRequest::read(&mut ping_bytes, &client_version) {
|
||||
let pong_pkt = StatusHandler::create_pong_packet(ping.payload);
|
||||
let pong_bytes = pong_pkt
|
||||
.serialize_packet(&client_version)
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
let _ = encoder.write_packet(pong_bytes).await;
|
||||
let _ = encoder.flush().await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_login(
|
||||
&self,
|
||||
mut decoder: TCPNetworkDecoder<PrefixedRead<OwnedReadHalf>>,
|
||||
mut encoder: TCPNetworkEncoder<OwnedWriteHalf>,
|
||||
client_version: JavaMinecraftVersion,
|
||||
virtual_host: String,
|
||||
client_ip: IpAddr,
|
||||
) -> Result<(), ClientError> {
|
||||
let login_timeout = Duration::from_secs(self.config.network.client_login_timeout_secs);
|
||||
|
||||
let raw_login_start = timeout(login_timeout, decoder.get_raw_packet())
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
.map_err(|e| ClientError::DecodeError(e.to_string()))?;
|
||||
|
||||
let mut login_start_bytes = &raw_login_start.payload[..];
|
||||
let login_start = SLoginStart::read(&mut login_start_bytes, &client_version)
|
||||
.map_err(|e| ClientError::DecodeError(e.to_string()))?;
|
||||
|
||||
let username = login_start.name.to_string();
|
||||
|
||||
if self.config.security.validate_usernames && !Sanitizer::is_valid_username(&username) {
|
||||
warn!(
|
||||
"[{}] Rejecting invalid username '{}' (failed sanitization)",
|
||||
self.peer_addr, username
|
||||
);
|
||||
Self::kick(
|
||||
&mut encoder,
|
||||
client_version,
|
||||
"Invalid username characters or length (must be 2-16 alphanumeric or _)",
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
"[{}] Player '{}' initiating login (version: {:?})",
|
||||
self.peer_addr, username, client_version
|
||||
);
|
||||
|
||||
let authenticated_player = if self.config.server.online_mode {
|
||||
let verify_token: [u8; 4] = rand::random();
|
||||
let encryption_request =
|
||||
CEncryptionRequest::new("", self.keystore.public_key_der(), &verify_token, true);
|
||||
|
||||
let enc_req_bytes = encryption_request
|
||||
.serialize_packet(&client_version)
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
encoder
|
||||
.write_packet(enc_req_bytes)
|
||||
.await
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
encoder
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
|
||||
let raw_enc_resp = timeout(login_timeout, decoder.get_raw_packet())
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
.map_err(|e| ClientError::DecodeError(e.to_string()))?;
|
||||
|
||||
let mut enc_resp_bytes = &raw_enc_resp.payload[..];
|
||||
let enc_resp = SEncryptionResponse::read(&mut enc_resp_bytes, &client_version)
|
||||
.map_err(|e| ClientError::DecodeError(e.to_string()))?;
|
||||
|
||||
let decrypted_token = self
|
||||
.keystore
|
||||
.decrypt(&enc_resp.verify_token)
|
||||
.map_err(|e| ClientError::AuthError(e.to_string()))?;
|
||||
|
||||
if decrypted_token != verify_token {
|
||||
warn!(
|
||||
"[{}] Verify token mismatch for '{}'",
|
||||
self.peer_addr, username
|
||||
);
|
||||
Self::kick(
|
||||
&mut encoder,
|
||||
client_version,
|
||||
"Failed to verify encryption token",
|
||||
)
|
||||
.await?;
|
||||
return Err(ClientError::AuthError("Token mismatch".to_string()));
|
||||
}
|
||||
|
||||
let shared_secret = self
|
||||
.keystore
|
||||
.decrypt(&enc_resp.shared_secret)
|
||||
.map_err(|e| ClientError::AuthError(e.to_string()))?;
|
||||
|
||||
if shared_secret.len() != 16 {
|
||||
warn!(
|
||||
"[{}] Shared secret has invalid length ({})",
|
||||
self.peer_addr,
|
||||
shared_secret.len()
|
||||
);
|
||||
Self::kick(
|
||||
&mut encoder,
|
||||
client_version,
|
||||
"Invalid encryption key length",
|
||||
)
|
||||
.await?;
|
||||
return Err(ClientError::AuthError("Invalid key length".to_string()));
|
||||
}
|
||||
|
||||
let mut aes_key = [0u8; 16];
|
||||
aes_key.copy_from_slice(&shared_secret);
|
||||
|
||||
let server_hash = self.keystore.auth_digest("", &shared_secret);
|
||||
|
||||
let auth_player = match self
|
||||
.authenticator
|
||||
.authenticate(
|
||||
&username,
|
||||
&server_hash,
|
||||
&client_ip,
|
||||
self.config.server.prevent_proxy_connections,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(player) => player,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[{}] Mojang authentication failed for '{}': {}",
|
||||
self.peer_addr, username, e
|
||||
);
|
||||
Self::kick(
|
||||
&mut encoder,
|
||||
client_version,
|
||||
"Failed to verify username with Mojang session servers! Please try again later.",
|
||||
)
|
||||
.await?;
|
||||
return Err(ClientError::AuthError(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
decoder
|
||||
.set_encryption(&aes_key)
|
||||
.map_err(|e| ClientError::DecodeError(e.to_string()))?;
|
||||
encoder
|
||||
.set_encryption(&aes_key)
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
|
||||
debug!(
|
||||
"[{}] Stream encryption enabled for player '{}' ({})",
|
||||
self.peer_addr, auth_player.username, auth_player.id
|
||||
);
|
||||
|
||||
auth_player
|
||||
} else {
|
||||
AuthenticatedPlayer::offline(&username)
|
||||
};
|
||||
|
||||
let comp_threshold = self.config.server.compression_threshold;
|
||||
if comp_threshold > 0 {
|
||||
let set_comp = CSetCompression::new(VarInt(comp_threshold));
|
||||
let comp_bytes = set_comp
|
||||
.serialize_packet(&client_version)
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
encoder
|
||||
.write_packet(comp_bytes)
|
||||
.await
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
encoder
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
|
||||
decoder.set_compression(comp_threshold as usize);
|
||||
encoder.set_compression((
|
||||
comp_threshold as usize,
|
||||
self.config.server.compression_level as u32,
|
||||
));
|
||||
}
|
||||
|
||||
let (server_name, backend_cfg) = match self.router.resolve_server(&virtual_host) {
|
||||
Some(res) => res,
|
||||
None => {
|
||||
warn!(
|
||||
"[{}] No backend server found for host '{}'",
|
||||
self.peer_addr, virtual_host
|
||||
);
|
||||
Self::kick(
|
||||
&mut encoder,
|
||||
client_version,
|
||||
"No default or matching backend Minecraft server is configured on the proxy.",
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let fallback_server = if self.config.routing.try_fallback_on_connect_failure {
|
||||
self.config
|
||||
.routing
|
||||
.fallback_server
|
||||
.as_ref()
|
||||
.and_then(|fb_name| {
|
||||
self.config
|
||||
.servers
|
||||
.get(fb_name)
|
||||
.map(|cfg| (fb_name.clone(), cfg.clone()))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let bridge = BackendBridge::new(
|
||||
server_name,
|
||||
backend_cfg,
|
||||
fallback_server,
|
||||
client_ip,
|
||||
client_version,
|
||||
virtual_host,
|
||||
authenticated_player,
|
||||
self.config.security.max_packets_per_sec,
|
||||
self.online_players.clone(),
|
||||
Arc::new(self.config.network.clone()),
|
||||
self.config.server.compression_level,
|
||||
self.session_manager.clone(),
|
||||
self.command_dispatcher.clone(),
|
||||
self.config.clone(),
|
||||
);
|
||||
|
||||
bridge.run(decoder, encoder).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper to send formatted CLoginDisconnect packet to client
|
||||
async fn kick(
|
||||
encoder: &mut TCPNetworkEncoder<OwnedWriteHalf>,
|
||||
version: JavaMinecraftVersion,
|
||||
reason: &str,
|
||||
) -> Result<(), ClientError> {
|
||||
let json_reason = format!(
|
||||
r#"{{"text":"[Vine Proxy] {}","color":"red"}}"#,
|
||||
reason.replace('"', "\\\"")
|
||||
);
|
||||
let disconnect = CLoginDisconnect::new(json_reason);
|
||||
let bytes = disconnect
|
||||
.serialize_packet(&version)
|
||||
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
|
||||
let _ = encoder.write_packet(bytes).await;
|
||||
let _ = encoder.flush().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
194
src/command/glist.rs
Normal file
194
src/command/glist.rs
Normal file
@ -0,0 +1,194 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use pumpkin_command::CommandSource;
|
||||
use pumpkin_command::argument_builder::{ArgumentBuilder, command};
|
||||
use pumpkin_command::context::command_context::CommandContext;
|
||||
use pumpkin_command::dispatcher::CommandDispatcher as RawCommandDispatcher;
|
||||
use pumpkin_command::node::{CommandExecutor, CommandExecutorResult};
|
||||
use pumpkin_util::text::TextComponent;
|
||||
|
||||
use super::{ProxyCommandSource, command_error};
|
||||
|
||||
/// Command to list players grouped by server across the entire proxy
|
||||
pub struct GlistCommandExecutor;
|
||||
|
||||
impl CommandExecutor<ProxyCommandSource> for GlistCommandExecutor {
|
||||
fn execute(&self, context: &CommandContext<ProxyCommandSource>) -> CommandExecutorResult {
|
||||
if !context.source.has_permission("vine.command.glist") {
|
||||
return Err(command_error(
|
||||
"You do not have permission to execute this command (required: vine.command.glist).",
|
||||
));
|
||||
}
|
||||
|
||||
let sessions = context.source.session_manager.list_sessions();
|
||||
let total_players = sessions.len();
|
||||
|
||||
let mut server_players: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for server_name in context.source.config.servers.keys() {
|
||||
server_players.entry(server_name.clone()).or_default();
|
||||
}
|
||||
|
||||
for session in sessions {
|
||||
server_players
|
||||
.entry(session.current_server.clone())
|
||||
.or_default()
|
||||
.push(session.username);
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for (server_name, mut players) in server_players {
|
||||
players.sort_unstable();
|
||||
if players.is_empty() {
|
||||
lines.push(format!("[{}] (0):", server_name));
|
||||
} else {
|
||||
lines.push(format!(
|
||||
"[{}] ({}): {}",
|
||||
server_name,
|
||||
players.len(),
|
||||
players.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(format!("Total players online: {}", total_players));
|
||||
context
|
||||
.source
|
||||
.send_message(TextComponent::text(lines.join("\n")));
|
||||
Ok(total_players as i32)
|
||||
}
|
||||
}
|
||||
|
||||
use pumpkin_command::Requirement;
|
||||
|
||||
pub fn register(dispatcher: &mut RawCommandDispatcher<ProxyCommandSource>) {
|
||||
dispatcher.register(
|
||||
command(
|
||||
"glist",
|
||||
"Lists online players grouped by server across the proxy",
|
||||
)
|
||||
.requires(Requirement::from("vine.command.glist"))
|
||||
.executes(GlistCommandExecutor),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::command::{CommandDispatcher, CommandSender};
|
||||
use crate::config::{BackendConfig, Config, ForwardingMode};
|
||||
use crate::session::{PlayerAction, PlayerSession, SessionManager};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glist_empty() {
|
||||
let mut config = Config::default();
|
||||
config.servers.insert(
|
||||
"lobby".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25565".to_string(),
|
||||
forwarding: ForwardingMode::None,
|
||||
secret: None,
|
||||
motd_line1: None,
|
||||
motd_line2: None,
|
||||
icon_path: None,
|
||||
},
|
||||
);
|
||||
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let (action_tx, mut action_rx) = mpsc::unbounded_channel();
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Player {
|
||||
username: "TestPlayer".to_string(),
|
||||
action_tx,
|
||||
},
|
||||
config: Arc::new(config),
|
||||
session_manager,
|
||||
};
|
||||
|
||||
let dispatcher = CommandDispatcher::new();
|
||||
let res = dispatcher.execute_input("glist", &source);
|
||||
assert!(res.is_ok());
|
||||
|
||||
let msg = action_rx.recv().await.unwrap();
|
||||
match msg {
|
||||
PlayerAction::Message(text) => {
|
||||
let s = text.to_pretty_console();
|
||||
assert!(s.contains("[lobby] (0):"));
|
||||
assert!(s.contains("Total players online: 0"));
|
||||
}
|
||||
_ => panic!("Expected Message action"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_glist_with_players() {
|
||||
let mut config = Config::default();
|
||||
config.servers.insert(
|
||||
"lobby".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25565".to_string(),
|
||||
forwarding: ForwardingMode::None,
|
||||
secret: None,
|
||||
motd_line1: None,
|
||||
motd_line2: None,
|
||||
icon_path: None,
|
||||
},
|
||||
);
|
||||
config.servers.insert(
|
||||
"survival".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25566".to_string(),
|
||||
forwarding: ForwardingMode::None,
|
||||
secret: None,
|
||||
motd_line1: None,
|
||||
motd_line2: None,
|
||||
icon_path: None,
|
||||
},
|
||||
);
|
||||
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let (tx1, _) = mpsc::unbounded_channel();
|
||||
let (tx2, _) = mpsc::unbounded_channel();
|
||||
|
||||
session_manager.register(PlayerSession {
|
||||
username: "Steve".to_string(),
|
||||
uuid: Uuid::new_v4(),
|
||||
current_server: "lobby".to_string(),
|
||||
action_tx: tx1,
|
||||
});
|
||||
session_manager.register(PlayerSession {
|
||||
username: "Alex".to_string(),
|
||||
uuid: Uuid::new_v4(),
|
||||
current_server: "survival".to_string(),
|
||||
action_tx: tx2,
|
||||
});
|
||||
|
||||
let (action_tx, mut action_rx) = mpsc::unbounded_channel();
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Player {
|
||||
username: "Admin".to_string(),
|
||||
action_tx,
|
||||
},
|
||||
config: Arc::new(config),
|
||||
session_manager,
|
||||
};
|
||||
|
||||
let dispatcher = CommandDispatcher::new();
|
||||
let res = dispatcher.execute_input("glist", &source);
|
||||
assert!(res.is_ok());
|
||||
|
||||
let msg = action_rx.recv().await.unwrap();
|
||||
match msg {
|
||||
PlayerAction::Message(text) => {
|
||||
let s = text.to_pretty_console();
|
||||
assert!(s.contains("[lobby] (1): Steve"));
|
||||
assert!(s.contains("[survival] (1): Alex"));
|
||||
assert!(s.contains("Total players online: 2"));
|
||||
}
|
||||
_ => panic!("Expected Message action"),
|
||||
}
|
||||
}
|
||||
}
|
||||
431
src/command/mod.rs
Normal file
431
src/command/mod.rs
Normal file
@ -0,0 +1,431 @@
|
||||
pub mod glist;
|
||||
pub mod send;
|
||||
pub mod server;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
use pumpkin_command::argument_builder::{ArgumentBuilder, argument, command};
|
||||
use pumpkin_command::argument_types::core::string::StringArgumentType;
|
||||
use pumpkin_command::context::command_context::CommandContext;
|
||||
use pumpkin_command::context::string_range::StringRange;
|
||||
pub use pumpkin_command::dispatcher::CommandDispatcher as RawCommandDispatcher;
|
||||
pub use pumpkin_command::errors::command_syntax_error::CommandSyntaxError;
|
||||
pub use pumpkin_command::errors::error_types::LiteralCommandErrorType;
|
||||
pub use pumpkin_command::node::{CommandExecutor, CommandExecutorResult};
|
||||
pub use pumpkin_command::source::CommandSource;
|
||||
use pumpkin_command::suggestion::Suggestion;
|
||||
use pumpkin_command::suggestion::provider::{SuggestionProvider, SuggestionProviderResult};
|
||||
pub use pumpkin_command::suggestion::suggestions::Suggestions;
|
||||
use pumpkin_command::suggestion::suggestions::SuggestionsBuilder;
|
||||
use pumpkin_protocol::java::client::play::CommandSuggestion;
|
||||
use pumpkin_util::text::TextComponent;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::session::{PlayerAction, SessionManager};
|
||||
|
||||
pub static PROXY_COMMAND_ERROR: LiteralCommandErrorType =
|
||||
LiteralCommandErrorType::new("Proxy command execution failed");
|
||||
|
||||
pub fn command_error(msg: impl Into<String>) -> CommandSyntaxError {
|
||||
CommandSyntaxError::create_without_context(
|
||||
&PROXY_COMMAND_ERROR,
|
||||
TextComponent::text(msg.into()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Represents the origin that invoked a command
|
||||
#[derive(Clone)]
|
||||
pub enum CommandSender {
|
||||
/// Command invoked via proxy console (stdin)
|
||||
Console,
|
||||
/// Command invoked by an in-game player
|
||||
Player {
|
||||
username: String,
|
||||
action_tx: mpsc::UnboundedSender<PlayerAction>,
|
||||
},
|
||||
}
|
||||
|
||||
impl CommandSender {
|
||||
/// Sends response feedback to the command sender
|
||||
pub fn send_message(&self, message: TextComponent) {
|
||||
match self {
|
||||
Self::Console => {
|
||||
info!("{}", message.to_pretty_console());
|
||||
}
|
||||
Self::Player { action_tx, .. } => {
|
||||
let _ = action_tx.send(PlayerAction::Message(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a plain text message to the command sender
|
||||
pub fn send_text(&self, text: impl Into<String>) {
|
||||
self.send_message(TextComponent::text(text.into()));
|
||||
}
|
||||
|
||||
/// Returns the name of the sender
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Console => "CONSOLE",
|
||||
Self::Player { username, .. } => username,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execution context passed to commands
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyCommandSource {
|
||||
pub sender: CommandSender,
|
||||
pub config: Arc<Config>,
|
||||
pub session_manager: Arc<SessionManager>,
|
||||
}
|
||||
|
||||
impl ProxyCommandSource {
|
||||
/// Evaluates if the command source has the specified permission node.
|
||||
/// Console always returns `true`.
|
||||
pub fn has_permission(&self, node: &str) -> bool {
|
||||
match &self.sender {
|
||||
CommandSender::Console => true,
|
||||
CommandSender::Player { username, .. } => {
|
||||
self.config.permissions.has_permission(username, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the command sender has administrative privileges (node `vine.admin` or `*`)
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.has_permission("vine.admin")
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandSource for ProxyCommandSource {
|
||||
fn send_message(&self, message: TextComponent) {
|
||||
self.sender.send_message(message);
|
||||
}
|
||||
|
||||
fn send_error(&self, error: TextComponent) {
|
||||
self.send_message(error);
|
||||
}
|
||||
|
||||
fn has_permission(&self, permission: &str) -> bool {
|
||||
self.has_permission(permission)
|
||||
}
|
||||
}
|
||||
|
||||
/// Help command listing registered commands
|
||||
pub struct HelpCommandExecutor;
|
||||
|
||||
impl CommandExecutor<ProxyCommandSource> for HelpCommandExecutor {
|
||||
fn execute(&self, context: &CommandContext<ProxyCommandSource>) -> CommandExecutorResult {
|
||||
let text = "Available commands:\n - /server [server] : View current server or switch to another backend\n - /send <player|all> <server> : Send players to another backend (Admin)\n - /list : List connected players\n - /glist : List players grouped by server\n - /help [command] : Show this help or info about a command";
|
||||
context.source.send_message(TextComponent::text(text));
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggestion provider for `/help [command]`
|
||||
struct HelpSuggestionProvider;
|
||||
|
||||
impl SuggestionProvider<ProxyCommandSource> for HelpSuggestionProvider {
|
||||
fn suggest(
|
||||
&self,
|
||||
context: &CommandContext<ProxyCommandSource>,
|
||||
builder: SuggestionsBuilder,
|
||||
) -> SuggestionProviderResult {
|
||||
let mut builder = builder;
|
||||
let remaining = builder.remaining_lowercase().to_string();
|
||||
let is_admin = context.source.is_admin();
|
||||
|
||||
for (cmd_name, required_perm) in [
|
||||
("server", Some("vine.command.server")),
|
||||
("send", Some("vine.command.send")),
|
||||
("list", Some("vine.command.list")),
|
||||
("glist", Some("vine.command.glist")),
|
||||
("help", Some("vine.command.help")),
|
||||
] {
|
||||
if cmd_name.starts_with(&remaining) {
|
||||
let allowed = is_admin
|
||||
|| required_perm
|
||||
.map(|p| context.source.has_permission(p))
|
||||
.unwrap_or(true);
|
||||
if allowed {
|
||||
builder = builder.suggest(cmd_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailed help executor for `/help <command>`
|
||||
struct CommandSpecificHelpExecutor;
|
||||
|
||||
impl CommandExecutor<ProxyCommandSource> for CommandSpecificHelpExecutor {
|
||||
fn execute(&self, context: &CommandContext<ProxyCommandSource>) -> CommandExecutorResult {
|
||||
let cmd = StringArgumentType::get(context, "command")?;
|
||||
let msg = match cmd.to_ascii_lowercase().as_str() {
|
||||
"server" => {
|
||||
"Command /server:\n Usage: /server [server]\n Description: View your current server or switch to another backend server."
|
||||
}
|
||||
"send" => {
|
||||
"Command /send:\n Usage: /send <player|all> <server>\n Description: Send players to another backend server (Requires vine.command.send / admin)."
|
||||
}
|
||||
"list" => {
|
||||
"Command /list:\n Usage: /list\n Description: List all online players connected to the proxy."
|
||||
}
|
||||
"glist" => {
|
||||
"Command /glist:\n Usage: /glist\n Description: List online players grouped by backend server."
|
||||
}
|
||||
"help" => {
|
||||
"Command /help:\n Usage: /help [command]\n Description: Show help information for available proxy commands."
|
||||
}
|
||||
_ => "Unknown command. Use /help to see all available proxy commands.",
|
||||
};
|
||||
context.source.send_message(TextComponent::text(msg));
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// List command displaying online players
|
||||
pub struct ListCommandExecutor;
|
||||
|
||||
impl CommandExecutor<ProxyCommandSource> for ListCommandExecutor {
|
||||
fn execute(&self, context: &CommandContext<ProxyCommandSource>) -> CommandExecutorResult {
|
||||
let sessions = context.source.session_manager.list_sessions();
|
||||
if sessions.is_empty() {
|
||||
context
|
||||
.source
|
||||
.send_message(TextComponent::text("There are 0 players online."));
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let players_info: Vec<String> = sessions
|
||||
.into_iter()
|
||||
.map(|s| format!("{} ({})", s.username, s.current_server))
|
||||
.collect();
|
||||
|
||||
context.source.send_message(TextComponent::text(format!(
|
||||
"Online players ({}): {}",
|
||||
players_info.len(),
|
||||
players_info.join(", ")
|
||||
)));
|
||||
Ok(players_info.len() as i32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Central registry and dispatcher for commands using `pumpkin-command`
|
||||
pub struct CommandDispatcher {
|
||||
inner: RawCommandDispatcher<ProxyCommandSource>,
|
||||
}
|
||||
|
||||
impl Default for CommandDispatcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandDispatcher {
|
||||
/// Creates a new CommandDispatcher with default built-in commands
|
||||
pub fn new() -> Self {
|
||||
let mut dispatcher = RawCommandDispatcher::new();
|
||||
server::register(&mut dispatcher);
|
||||
send::register(&mut dispatcher);
|
||||
glist::register(&mut dispatcher);
|
||||
dispatcher.register(
|
||||
command("list", "Lists online players connected to the proxy")
|
||||
.executes(ListCommandExecutor),
|
||||
);
|
||||
dispatcher.register(
|
||||
command("help", "Displays list of available proxy commands")
|
||||
.executes(HelpCommandExecutor)
|
||||
.then(
|
||||
argument("command", StringArgumentType::SingleWord)
|
||||
.suggests(HelpSuggestionProvider)
|
||||
.executes(CommandSpecificHelpExecutor),
|
||||
),
|
||||
);
|
||||
Self { inner: dispatcher }
|
||||
}
|
||||
|
||||
/// Returns true if a command with the given name is registered
|
||||
pub fn has_command(&self, name: &str) -> bool {
|
||||
let clean = name.trim_start_matches('/').to_ascii_lowercase();
|
||||
self.inner.has_command(&clean)
|
||||
}
|
||||
|
||||
/// Dispatches a command for execution, reporting any error messages back to the source
|
||||
pub fn handle_command(&self, source: &ProxyCommandSource, input: &str) {
|
||||
self.inner.handle_command(source, input);
|
||||
}
|
||||
|
||||
/// Executes raw input directly, returning the execution result
|
||||
pub fn execute_input(
|
||||
&self,
|
||||
input: &str,
|
||||
source: &ProxyCommandSource,
|
||||
) -> Result<i32, CommandSyntaxError> {
|
||||
let trimmed = input.trim();
|
||||
let clean = trimmed.strip_prefix('/').unwrap_or(trimmed);
|
||||
self.inner.execute_input(clean, source)
|
||||
}
|
||||
|
||||
/// Returns suggestions with range information for any command input.
|
||||
/// If input contains arguments (i.e. has a space), it evaluates argument suggestions.
|
||||
/// If input is typing the root command name, it suggests permitted root commands starting with the prefix.
|
||||
pub fn suggest_command(&self, input: &str, source: &ProxyCommandSource) -> Suggestions {
|
||||
let clean = input.strip_prefix('/').unwrap_or(input);
|
||||
if clean.contains(' ') {
|
||||
let first_word = clean.split_whitespace().next().unwrap_or(clean);
|
||||
if self.has_command(first_word) {
|
||||
return self.inner.suggest_with_range(clean, source);
|
||||
}
|
||||
return Suggestions::empty();
|
||||
}
|
||||
|
||||
let clean_lower = clean.to_ascii_lowercase();
|
||||
let permitted = self.inner.get_all_permitted_commands(source);
|
||||
let mut suggestions = Vec::new();
|
||||
for (cmd_name, description) in permitted {
|
||||
if cmd_name.starts_with(&clean_lower) {
|
||||
suggestions.push(Suggestion::with_tooltip(
|
||||
StringRange::between(0, clean.len()),
|
||||
cmd_name.to_string(),
|
||||
TextComponent::text(description.to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Suggestions::new(StringRange::between(0, clean.len()), suggestions)
|
||||
}
|
||||
|
||||
/// Returns suggestions for the command string
|
||||
pub fn suggest(&self, input: &str, source: &ProxyCommandSource) -> Vec<CommandSuggestion> {
|
||||
let suggestions = self.suggest_command(input, source);
|
||||
suggestions
|
||||
.suggestions
|
||||
.into_iter()
|
||||
.map(|s| CommandSuggestion {
|
||||
suggestion: s.text.cached_text().clone(),
|
||||
tooltip: s.tooltip,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns suggestions with range information
|
||||
pub fn suggest_with_range(&self, input: &str, source: &ProxyCommandSource) -> Suggestions {
|
||||
self.suggest_command(input, source)
|
||||
}
|
||||
|
||||
/// Exposes the inner pumpkin command dispatcher
|
||||
pub fn raw(&self) -> &RawCommandDispatcher<ProxyCommandSource> {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
/// Exposes a mutable reference to the inner pumpkin command dispatcher
|
||||
pub fn raw_mut(&mut self) -> &mut RawCommandDispatcher<ProxyCommandSource> {
|
||||
&mut self.inner
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dispatcher_registration_and_dispatch() {
|
||||
let dispatcher = CommandDispatcher::new();
|
||||
assert!(dispatcher.has_command("send"));
|
||||
assert!(dispatcher.has_command("/send"));
|
||||
assert!(dispatcher.has_command("help"));
|
||||
assert!(dispatcher.has_command("list"));
|
||||
assert!(dispatcher.has_command("glist"));
|
||||
assert!(dispatcher.has_command("/glist"));
|
||||
assert!(!dispatcher.has_command("unknown"));
|
||||
|
||||
let config = Arc::new(Config::default());
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Console,
|
||||
config,
|
||||
session_manager,
|
||||
};
|
||||
|
||||
let res = dispatcher.execute_input("help", &source);
|
||||
assert!(res.is_ok());
|
||||
|
||||
let res_slash = dispatcher.execute_input("/help", &source);
|
||||
assert!(res_slash.is_ok());
|
||||
|
||||
let res_unknown = dispatcher.execute_input("unknown_cmd", &source);
|
||||
assert!(res_unknown.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_various_inputs() {
|
||||
let dispatcher = CommandDispatcher::new();
|
||||
let config = Arc::new(Config::default());
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Console,
|
||||
config,
|
||||
session_manager,
|
||||
};
|
||||
|
||||
let s_empty = dispatcher.suggest_with_range("", &source);
|
||||
let names_empty: Vec<&str> = s_empty
|
||||
.suggestions
|
||||
.iter()
|
||||
.map(|s| s.text.cached_text().as_str())
|
||||
.collect();
|
||||
assert!(names_empty.contains(&"server"));
|
||||
assert!(names_empty.contains(&"send"));
|
||||
assert!(names_empty.contains(&"glist"));
|
||||
assert!(names_empty.contains(&"list"));
|
||||
assert!(names_empty.contains(&"help"));
|
||||
|
||||
let s_s = dispatcher.suggest_with_range("s", &source);
|
||||
let names_s: Vec<&str> = s_s
|
||||
.suggestions
|
||||
.iter()
|
||||
.map(|s| s.text.cached_text().as_str())
|
||||
.collect();
|
||||
assert_eq!(names_s, vec!["send", "server"]);
|
||||
|
||||
let s_ser = dispatcher.suggest_with_range("ser", &source);
|
||||
let names_ser: Vec<&str> = s_ser
|
||||
.suggestions
|
||||
.iter()
|
||||
.map(|s| s.text.cached_text().as_str())
|
||||
.collect();
|
||||
assert_eq!(names_ser, vec!["server"]);
|
||||
|
||||
let s_server = dispatcher.suggest_with_range("server", &source);
|
||||
let names_server: Vec<&str> = s_server
|
||||
.suggestions
|
||||
.iter()
|
||||
.map(|s| s.text.cached_text().as_str())
|
||||
.collect();
|
||||
assert_eq!(names_server, vec!["server"]);
|
||||
|
||||
let s_server_space = dispatcher.suggest_with_range("server ", &source);
|
||||
let names_server_space: Vec<&str> = s_server_space
|
||||
.suggestions
|
||||
.iter()
|
||||
.map(|s| s.text.cached_text().as_str())
|
||||
.collect();
|
||||
assert!(names_server_space.contains(&"lobby"));
|
||||
|
||||
let s_help_space = dispatcher.suggest_with_range("help ", &source);
|
||||
let names_help_space: Vec<&str> = s_help_space
|
||||
.suggestions
|
||||
.iter()
|
||||
.map(|s| s.text.cached_text().as_str())
|
||||
.collect();
|
||||
assert!(names_help_space.contains(&"server"));
|
||||
assert!(names_help_space.contains(&"send"));
|
||||
assert!(names_help_space.contains(&"glist"));
|
||||
assert!(names_help_space.contains(&"list"));
|
||||
assert!(names_help_space.contains(&"help"));
|
||||
}
|
||||
}
|
||||
361
src/command/send.rs
Normal file
361
src/command/send.rs
Normal file
@ -0,0 +1,361 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use pumpkin_command::CommandSource;
|
||||
use pumpkin_command::argument_builder::{ArgumentBuilder, argument, command};
|
||||
use pumpkin_command::argument_types::core::string::StringArgumentType;
|
||||
use pumpkin_command::context::command_context::CommandContext;
|
||||
use pumpkin_command::dispatcher::CommandDispatcher as RawCommandDispatcher;
|
||||
use pumpkin_command::node::{CommandExecutor, CommandExecutorResult};
|
||||
use pumpkin_command::suggestion::provider::{SuggestionProvider, SuggestionProviderResult};
|
||||
use pumpkin_command::suggestion::suggestions::SuggestionsBuilder;
|
||||
use pumpkin_util::text::TextComponent;
|
||||
|
||||
use super::{ProxyCommandSource, command_error};
|
||||
use crate::session::PlayerAction;
|
||||
|
||||
/// Parses a host:port address string
|
||||
pub fn parse_address(addr: &str) -> Option<(String, u16)> {
|
||||
if let Ok(socket_addr) = addr.parse::<SocketAddr>() {
|
||||
return Some((socket_addr.ip().to_string(), socket_addr.port()));
|
||||
}
|
||||
if let Some((host, port_str)) = addr.rsplit_once(':')
|
||||
&& let Ok(port) = port_str.parse::<u16>()
|
||||
{
|
||||
return Some((host.to_string(), port));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
struct TargetSuggestionProvider;
|
||||
|
||||
impl SuggestionProvider<ProxyCommandSource> for TargetSuggestionProvider {
|
||||
fn suggest(
|
||||
&self,
|
||||
context: &CommandContext<ProxyCommandSource>,
|
||||
builder: SuggestionsBuilder,
|
||||
) -> SuggestionProviderResult {
|
||||
let mut builder = builder;
|
||||
let remaining = builder.remaining_lowercase().to_string();
|
||||
if "all".starts_with(&remaining) {
|
||||
builder = builder.suggest("all");
|
||||
}
|
||||
for session in context.source.session_manager.list_sessions() {
|
||||
if session
|
||||
.username
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(&remaining)
|
||||
{
|
||||
builder = builder.suggest(session.username);
|
||||
}
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerSuggestionProvider;
|
||||
|
||||
impl SuggestionProvider<ProxyCommandSource> for ServerSuggestionProvider {
|
||||
fn suggest(
|
||||
&self,
|
||||
context: &CommandContext<ProxyCommandSource>,
|
||||
builder: SuggestionsBuilder,
|
||||
) -> SuggestionProviderResult {
|
||||
let mut builder = builder;
|
||||
let remaining = builder.remaining_lowercase().to_string();
|
||||
for server in context.source.config.servers.keys() {
|
||||
if server.to_ascii_lowercase().starts_with(&remaining) {
|
||||
builder = builder.suggest(server.as_str());
|
||||
}
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
struct SendCommandExecutor;
|
||||
|
||||
impl CommandExecutor<ProxyCommandSource> for SendCommandExecutor {
|
||||
fn execute(&self, context: &CommandContext<ProxyCommandSource>) -> CommandExecutorResult {
|
||||
if !context.source.has_permission("vine.command.send") {
|
||||
return Err(command_error(
|
||||
"You do not have permission to execute this command (required: vine.command.send).",
|
||||
));
|
||||
}
|
||||
|
||||
let target = StringArgumentType::get(context, "target")?;
|
||||
let server_name = StringArgumentType::get(context, "server")?;
|
||||
|
||||
let canonical_server = match context
|
||||
.source
|
||||
.config
|
||||
.servers
|
||||
.keys()
|
||||
.find(|k| k.eq_ignore_ascii_case(server_name))
|
||||
{
|
||||
Some(name) => name.clone(),
|
||||
None => {
|
||||
let mut available: Vec<&String> = context.source.config.servers.keys().collect();
|
||||
available.sort();
|
||||
return Err(command_error(format!(
|
||||
"Unknown backend server '{}'. Available servers: [{}]",
|
||||
server_name,
|
||||
available
|
||||
.into_iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if target.eq_ignore_ascii_case("all") {
|
||||
let sessions = context.source.session_manager.list_sessions();
|
||||
if sessions.is_empty() {
|
||||
return Err(command_error("No players currently online to send."));
|
||||
}
|
||||
|
||||
let count = sessions.len();
|
||||
for session in &sessions {
|
||||
let _ = session.action_tx.send(PlayerAction::Connect {
|
||||
server_name: canonical_server.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let msg = format!("Sent {} player(s) to '{}'", count, canonical_server);
|
||||
context.source.send_message(TextComponent::text(msg));
|
||||
Ok(count as i32)
|
||||
} else {
|
||||
let session = context
|
||||
.source
|
||||
.session_manager
|
||||
.get_session(target)
|
||||
.ok_or_else(|| command_error(format!("Player '{}' is not online.", target)))?;
|
||||
|
||||
let _ = session.action_tx.send(PlayerAction::Connect {
|
||||
server_name: canonical_server.clone(),
|
||||
});
|
||||
|
||||
let msg = format!("Sent '{}' to '{}'", session.username, canonical_server);
|
||||
context.source.send_message(TextComponent::text(msg));
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(dispatcher: &mut RawCommandDispatcher<ProxyCommandSource>) {
|
||||
dispatcher.register(
|
||||
command(
|
||||
"send",
|
||||
"Sends a player or all players to another backend server",
|
||||
)
|
||||
.then(
|
||||
argument("target", StringArgumentType::SingleWord)
|
||||
.suggests(TargetSuggestionProvider)
|
||||
.then(
|
||||
argument("server", StringArgumentType::SingleWord)
|
||||
.suggests(ServerSuggestionProvider)
|
||||
.executes(SendCommandExecutor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::command::{CommandDispatcher, CommandSender};
|
||||
use crate::config::{BackendConfig, Config, ForwardingMode};
|
||||
use crate::session::{PlayerSession, SessionManager};
|
||||
|
||||
#[test]
|
||||
fn test_parse_address() {
|
||||
assert_eq!(
|
||||
parse_address("127.0.0.1:25565"),
|
||||
Some(("127.0.0.1".to_string(), 25565))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_address("mc.example.com:25577"),
|
||||
Some(("mc.example.com".to_string(), 25577))
|
||||
);
|
||||
assert_eq!(parse_address("invalid"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_command_execution() {
|
||||
let mut config = Config::default();
|
||||
config.servers.insert(
|
||||
"survival".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25567".to_string(),
|
||||
forwarding: ForwardingMode::None,
|
||||
secret: None,
|
||||
motd_line1: None,
|
||||
motd_line2: None,
|
||||
icon_path: None,
|
||||
},
|
||||
);
|
||||
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
session_manager.register(PlayerSession {
|
||||
username: "Steve".to_string(),
|
||||
uuid: Uuid::new_v4(),
|
||||
current_server: "lobby".to_string(),
|
||||
action_tx: tx,
|
||||
});
|
||||
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Console,
|
||||
config: Arc::new(config),
|
||||
session_manager,
|
||||
};
|
||||
|
||||
let dispatcher = CommandDispatcher::new();
|
||||
let res = dispatcher.execute_input("send Steve survival", &source);
|
||||
assert!(res.is_ok());
|
||||
|
||||
let action = rx.recv().await;
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(PlayerAction::Connect {
|
||||
server_name: "survival".to_string(),
|
||||
})
|
||||
);
|
||||
|
||||
// Player not in admin list is rejected
|
||||
let (non_admin_tx, _) = mpsc::unbounded_channel();
|
||||
let non_admin_source = ProxyCommandSource {
|
||||
sender: CommandSender::Player {
|
||||
username: "RegularPlayer".to_string(),
|
||||
action_tx: non_admin_tx,
|
||||
},
|
||||
config: Arc::new(Config::default()),
|
||||
session_manager: Arc::new(SessionManager::new()),
|
||||
};
|
||||
let res_denied = dispatcher.execute_input("send Steve survival", &non_admin_source);
|
||||
assert!(res_denied.is_err());
|
||||
assert!(
|
||||
res_denied
|
||||
.unwrap_err()
|
||||
.message
|
||||
.to_pretty_console()
|
||||
.contains("permission")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_command_unknown_server() {
|
||||
let config = Config::default();
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Console,
|
||||
config: Arc::new(config),
|
||||
session_manager,
|
||||
};
|
||||
|
||||
let dispatcher = CommandDispatcher::new();
|
||||
let res = dispatcher.execute_input("send Steve nonexistent", &source);
|
||||
assert!(res.is_err());
|
||||
assert!(
|
||||
res.unwrap_err()
|
||||
.message
|
||||
.to_pretty_console()
|
||||
.contains("Unknown backend server")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_command_offline_player() {
|
||||
let mut config = Config::default();
|
||||
config.servers.insert(
|
||||
"survival".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25567".to_string(),
|
||||
forwarding: ForwardingMode::None,
|
||||
secret: None,
|
||||
motd_line1: None,
|
||||
motd_line2: None,
|
||||
icon_path: None,
|
||||
},
|
||||
);
|
||||
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Console,
|
||||
config: Arc::new(config),
|
||||
session_manager,
|
||||
};
|
||||
|
||||
let dispatcher = CommandDispatcher::new();
|
||||
let res = dispatcher.execute_input("send Alex survival", &source);
|
||||
assert!(res.is_err());
|
||||
assert!(
|
||||
res.unwrap_err()
|
||||
.message
|
||||
.to_pretty_console()
|
||||
.contains("Player 'Alex' is not online")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_command_suggestions() {
|
||||
let mut config = Config::default();
|
||||
config.servers.insert(
|
||||
"survival".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25567".to_string(),
|
||||
forwarding: ForwardingMode::None,
|
||||
secret: None,
|
||||
motd_line1: None,
|
||||
motd_line2: None,
|
||||
icon_path: None,
|
||||
},
|
||||
);
|
||||
config.servers.insert(
|
||||
"lobby".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25565".to_string(),
|
||||
forwarding: ForwardingMode::None,
|
||||
secret: None,
|
||||
motd_line1: None,
|
||||
motd_line2: None,
|
||||
icon_path: None,
|
||||
},
|
||||
);
|
||||
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
session_manager.register(PlayerSession {
|
||||
username: "Steve".to_string(),
|
||||
uuid: Uuid::new_v4(),
|
||||
current_server: "lobby".to_string(),
|
||||
action_tx: tx,
|
||||
});
|
||||
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Console,
|
||||
config: Arc::new(config),
|
||||
session_manager,
|
||||
};
|
||||
|
||||
let dispatcher = CommandDispatcher::new();
|
||||
let target_suggestions = dispatcher.suggest("send ", &source);
|
||||
let names: Vec<&str> = target_suggestions
|
||||
.iter()
|
||||
.map(|s| s.suggestion.as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&"all"));
|
||||
assert!(names.contains(&"Steve"));
|
||||
|
||||
let server_suggestions = dispatcher.suggest("send Steve ", &source);
|
||||
let servers: Vec<&str> = server_suggestions
|
||||
.iter()
|
||||
.map(|s| s.suggestion.as_str())
|
||||
.collect();
|
||||
assert!(servers.contains(&"survival"));
|
||||
assert!(servers.contains(&"lobby"));
|
||||
}
|
||||
}
|
||||
224
src/command/server.rs
Normal file
224
src/command/server.rs
Normal file
@ -0,0 +1,224 @@
|
||||
use pumpkin_command::CommandSource;
|
||||
use pumpkin_command::Requirement;
|
||||
use pumpkin_command::argument_builder::{ArgumentBuilder, argument, command};
|
||||
use pumpkin_command::argument_types::core::string::StringArgumentType;
|
||||
use pumpkin_command::context::command_context::CommandContext;
|
||||
use pumpkin_command::dispatcher::CommandDispatcher as RawCommandDispatcher;
|
||||
use pumpkin_command::node::{CommandExecutor, CommandExecutorResult};
|
||||
use pumpkin_command::suggestion::provider::{SuggestionProvider, SuggestionProviderResult};
|
||||
use pumpkin_command::suggestion::suggestions::SuggestionsBuilder;
|
||||
use pumpkin_util::text::TextComponent;
|
||||
|
||||
use super::{CommandSender, ProxyCommandSource, command_error};
|
||||
use crate::session::PlayerAction;
|
||||
|
||||
struct ServerSuggestionProvider;
|
||||
|
||||
impl SuggestionProvider<ProxyCommandSource> for ServerSuggestionProvider {
|
||||
fn suggest(
|
||||
&self,
|
||||
context: &CommandContext<ProxyCommandSource>,
|
||||
builder: SuggestionsBuilder,
|
||||
) -> SuggestionProviderResult {
|
||||
let mut builder = builder;
|
||||
let remaining = builder.remaining_lowercase().to_string();
|
||||
for server in context.source.config.servers.keys() {
|
||||
if server.to_ascii_lowercase().starts_with(&remaining) {
|
||||
builder = builder.suggest(server.as_str());
|
||||
}
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
/// /server (no args): shows current server (if player) and lists all available servers
|
||||
struct ServerInfoCommandExecutor;
|
||||
|
||||
impl CommandExecutor<ProxyCommandSource> for ServerInfoCommandExecutor {
|
||||
fn execute(&self, context: &CommandContext<ProxyCommandSource>) -> CommandExecutorResult {
|
||||
if !context.source.has_permission("vine.command.server") {
|
||||
return Err(command_error(
|
||||
"You do not have permission to execute this command (required: vine.command.server).",
|
||||
));
|
||||
}
|
||||
|
||||
let mut available: Vec<&String> = context.source.config.servers.keys().collect();
|
||||
available.sort();
|
||||
let servers_str = available
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
match &context.source.sender {
|
||||
CommandSender::Player { username, .. } => {
|
||||
let current = context
|
||||
.source
|
||||
.session_manager
|
||||
.get_session(username)
|
||||
.map(|s| s.current_server)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
context.source.send_message(TextComponent::text(format!(
|
||||
"§6[Vine] §fYou are currently connected to server '§a{}§f'.\n§6[Vine] §7Available servers: §e{}",
|
||||
current, servers_str
|
||||
)));
|
||||
}
|
||||
CommandSender::Console => {
|
||||
context.source.send_message(TextComponent::text(format!(
|
||||
"Available backend servers: {}",
|
||||
servers_str
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// /server <name>: connects the player to that backend server
|
||||
struct ServerSwitchCommandExecutor;
|
||||
|
||||
impl CommandExecutor<ProxyCommandSource> for ServerSwitchCommandExecutor {
|
||||
fn execute(&self, context: &CommandContext<ProxyCommandSource>) -> CommandExecutorResult {
|
||||
if !context.source.has_permission("vine.command.server") {
|
||||
return Err(command_error(
|
||||
"You do not have permission to execute this command (required: vine.command.server).",
|
||||
));
|
||||
}
|
||||
|
||||
let server_name = StringArgumentType::get(context, "server")?;
|
||||
|
||||
let (username, action_tx) = match &context.source.sender {
|
||||
CommandSender::Player {
|
||||
username,
|
||||
action_tx,
|
||||
} => (username.clone(), action_tx.clone()),
|
||||
CommandSender::Console => {
|
||||
return Err(command_error(
|
||||
"Console cannot switch servers. Use '/send <player> <server>' instead.",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let target_server = match context
|
||||
.source
|
||||
.config
|
||||
.servers
|
||||
.keys()
|
||||
.find(|k| k.eq_ignore_ascii_case(server_name))
|
||||
{
|
||||
Some(name) => name.clone(),
|
||||
None => {
|
||||
let mut available: Vec<&String> = context.source.config.servers.keys().collect();
|
||||
available.sort();
|
||||
return Err(command_error(format!(
|
||||
"Unknown server '{}'. Available servers: [{}]",
|
||||
server_name,
|
||||
available
|
||||
.into_iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(session) = context.source.session_manager.get_session(&username)
|
||||
&& session.current_server.eq_ignore_ascii_case(&target_server)
|
||||
{
|
||||
return Err(command_error(format!(
|
||||
"You are already connected to '{}'!",
|
||||
target_server
|
||||
)));
|
||||
}
|
||||
|
||||
context.source.send_message(TextComponent::text(format!(
|
||||
"§6[Vine] §eConnecting you to '§a{}§e'...",
|
||||
target_server
|
||||
)));
|
||||
|
||||
let _ = action_tx.send(PlayerAction::Connect {
|
||||
server_name: target_server,
|
||||
});
|
||||
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(dispatcher: &mut RawCommandDispatcher<ProxyCommandSource>) {
|
||||
dispatcher.register(
|
||||
command("server", "View or connect to a backend server")
|
||||
.requires(Requirement::from("vine.command.server"))
|
||||
.executes(ServerInfoCommandExecutor)
|
||||
.then(
|
||||
argument("server", StringArgumentType::SingleWord)
|
||||
.suggests(ServerSuggestionProvider)
|
||||
.executes(ServerSwitchCommandExecutor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::session::{PlayerSession, SessionManager};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_server_command_execution() {
|
||||
let mut dispatcher = RawCommandDispatcher::new();
|
||||
register(&mut dispatcher);
|
||||
|
||||
let config = Arc::new(Config::default());
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let (action_tx, mut action_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let session = PlayerSession {
|
||||
username: "Steve".to_string(),
|
||||
uuid: Uuid::new_v4(),
|
||||
current_server: "lobby".to_string(),
|
||||
action_tx: action_tx.clone(),
|
||||
};
|
||||
session_manager.register(session);
|
||||
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Player {
|
||||
username: "Steve".to_string(),
|
||||
action_tx,
|
||||
},
|
||||
config: config.clone(),
|
||||
session_manager: session_manager.clone(),
|
||||
};
|
||||
|
||||
// 1. /server (no args) succeeds
|
||||
let res = dispatcher.execute_input("server", &source);
|
||||
assert!(res.is_ok());
|
||||
let msg_action = action_rx.try_recv();
|
||||
assert!(matches!(msg_action, Ok(PlayerAction::Message(_))));
|
||||
|
||||
// 2. /server survival requests connection to survival
|
||||
let res_switch = dispatcher.execute_input("server survival", &source);
|
||||
assert!(res_switch.is_ok());
|
||||
let switch_msg = action_rx.try_recv();
|
||||
assert!(matches!(switch_msg, Ok(PlayerAction::Message(_))));
|
||||
let action = action_rx.try_recv();
|
||||
assert_eq!(
|
||||
action.unwrap(),
|
||||
PlayerAction::Connect {
|
||||
server_name: "survival".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
// 3. /server lobby fails because player is already on lobby
|
||||
let res_already = dispatcher.execute_input("server lobby", &source);
|
||||
assert!(res_already.is_err());
|
||||
|
||||
// 4. /server unknown fails
|
||||
let res_unknown = dispatcher.execute_input("server not_a_server", &source);
|
||||
assert!(res_unknown.is_err());
|
||||
}
|
||||
}
|
||||
423
src/config.rs
Normal file
423
src/config.rs
Normal file
@ -0,0 +1,423 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_compression_level() -> i32 {
|
||||
6
|
||||
}
|
||||
|
||||
fn default_shutdown_timeout() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_tcp_keepalive() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_handshake_timeout() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_status_timeout() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_login_timeout() -> u64 {
|
||||
15
|
||||
}
|
||||
|
||||
fn default_backend_connect_timeout() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_backend_login_timeout() -> u64 {
|
||||
15
|
||||
}
|
||||
|
||||
fn default_backend_retries() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
fn default_listen_backlog() -> u32 {
|
||||
1024
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
#[serde(default)]
|
||||
pub network: NetworkConfig,
|
||||
pub motd: MotdConfig,
|
||||
pub security: SecurityConfig,
|
||||
pub routing: RoutingConfig,
|
||||
pub servers: HashMap<String, BackendConfig>,
|
||||
#[serde(default)]
|
||||
pub telemetry: TelemetryConfig,
|
||||
#[serde(default)]
|
||||
pub bedrock: crate::bedrock::BedrockConfig,
|
||||
#[serde(default)]
|
||||
pub permissions: PermissionsConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
/// Address and port for the proxy to listen on
|
||||
pub bind_address: String,
|
||||
/// Whether to verify player accounts with Mojang session servers
|
||||
pub online_mode: bool,
|
||||
/// Send player IP to Mojang during session authentication to prevent proxy hijacking
|
||||
pub prevent_proxy_connections: bool,
|
||||
/// Packet compression threshold (-1 to disable, e.g. 256 for standard compression)
|
||||
pub compression_threshold: i32,
|
||||
/// zlib / flate2 compression level (1 = fastest, 6 = default balance, 9 = best compression)
|
||||
#[serde(default = "default_compression_level")]
|
||||
pub compression_level: i32,
|
||||
/// Graceful shutdown timeout in seconds to wait for active connections to close
|
||||
#[serde(default = "default_shutdown_timeout")]
|
||||
pub shutdown_timeout_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct NetworkConfig {
|
||||
/// Enable TCP_NODELAY (disable Nagle's algorithm) on client and backend sockets.
|
||||
/// Crucial for eliminating latency spikes in Minecraft packet transmission.
|
||||
#[serde(default = "default_true")]
|
||||
pub tcp_nodelay: bool,
|
||||
|
||||
/// TCP keepalive interval in seconds on client and backend sockets (0 to disable).
|
||||
#[serde(default = "default_tcp_keepalive")]
|
||||
pub tcp_keepalive_secs: u64,
|
||||
|
||||
/// Timeout in seconds to wait for initial client Handshake packet
|
||||
#[serde(default = "default_handshake_timeout")]
|
||||
pub client_handshake_timeout_secs: u64,
|
||||
|
||||
/// Timeout in seconds to wait for client Status/Ping packets
|
||||
#[serde(default = "default_status_timeout")]
|
||||
pub client_status_timeout_secs: u64,
|
||||
|
||||
/// Timeout in seconds to wait for client LoginStart and Encryption packets
|
||||
#[serde(default = "default_login_timeout")]
|
||||
pub client_login_timeout_secs: u64,
|
||||
|
||||
/// Timeout in seconds when connecting to a backend server
|
||||
#[serde(default = "default_backend_connect_timeout")]
|
||||
pub backend_connect_timeout_secs: u64,
|
||||
|
||||
/// Timeout in seconds for backend socket reads during the login handshake phase
|
||||
#[serde(default = "default_backend_login_timeout")]
|
||||
pub backend_login_timeout_secs: u64,
|
||||
|
||||
/// Number of connection retry attempts when connecting to a backend server before failing
|
||||
#[serde(default = "default_backend_retries")]
|
||||
pub backend_connect_retry_attempts: u32,
|
||||
|
||||
/// Optional TCP receive buffer size (SO_RCVBUF) in bytes
|
||||
#[serde(default)]
|
||||
pub recv_buffer_size: Option<usize>,
|
||||
|
||||
/// Optional TCP send buffer size (SO_SNDBUF) in bytes
|
||||
#[serde(default)]
|
||||
pub send_buffer_size: Option<usize>,
|
||||
|
||||
/// TCP listen backlog queue size
|
||||
#[serde(default = "default_listen_backlog")]
|
||||
pub listen_backlog: u32,
|
||||
|
||||
/// Support HAProxy PROXY Protocol (v1 and v2) from upstream reverse proxies (e.g. Cloudflare Spectrum, HAProxy)
|
||||
#[serde(default)]
|
||||
pub proxy_protocol: bool,
|
||||
|
||||
/// Log incoming server list status pings in the console
|
||||
#[serde(default)]
|
||||
pub log_pings: bool,
|
||||
}
|
||||
|
||||
impl Default for NetworkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tcp_nodelay: default_true(),
|
||||
tcp_keepalive_secs: default_tcp_keepalive(),
|
||||
client_handshake_timeout_secs: default_handshake_timeout(),
|
||||
client_status_timeout_secs: default_status_timeout(),
|
||||
client_login_timeout_secs: default_login_timeout(),
|
||||
backend_connect_timeout_secs: default_backend_connect_timeout(),
|
||||
backend_login_timeout_secs: default_backend_login_timeout(),
|
||||
backend_connect_retry_attempts: default_backend_retries(),
|
||||
recv_buffer_size: None,
|
||||
send_buffer_size: None,
|
||||
listen_backlog: default_listen_backlog(),
|
||||
proxy_protocol: false,
|
||||
log_pings: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MotdConfig {
|
||||
/// Line 1 of the server list ping description
|
||||
pub line1: String,
|
||||
/// Line 2 of the server list ping description
|
||||
pub line2: String,
|
||||
/// Maximum players shown in the server list ping
|
||||
pub max_players: u32,
|
||||
/// Custom version display string (e.g. "Vine 1.7.x - 26.x")
|
||||
pub version_name: String,
|
||||
/// Protocol version reported to clients (776 is 26.2)
|
||||
pub protocol_version: i32,
|
||||
/// Optional sample player list shown on hover in server list
|
||||
pub sample_players: Vec<String>,
|
||||
/// Optional path to a 64x64 PNG file for the server list icon (favicons)
|
||||
#[serde(default)]
|
||||
pub icon_path: Option<String>,
|
||||
/// Whether to show the player sample list on hover in the multiplayer menu
|
||||
#[serde(default = "default_true")]
|
||||
pub show_sample_players: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SecurityConfig {
|
||||
/// Enable connection rate limiting per IP
|
||||
pub rate_limit_enabled: bool,
|
||||
/// Maximum new TCP connections per second per IP
|
||||
pub max_connections_per_sec: u32,
|
||||
/// Burst allowance for new connections
|
||||
pub connection_burst: u32,
|
||||
/// Maximum packets per second from a single client before being kicked for flooding
|
||||
pub max_packets_per_sec: u32,
|
||||
/// Maximum raw packet size allowed in bytes (default 2MB)
|
||||
pub max_packet_size: usize,
|
||||
/// Enforce strict Minecraft username validation (^[a-zA-Z0-9_]{2,16}$)
|
||||
pub validate_usernames: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RoutingConfig {
|
||||
/// Default backend server name to connect to
|
||||
pub default_server: String,
|
||||
/// Fallback server if default server is offline
|
||||
pub fallback_server: Option<String>,
|
||||
/// Virtual host routing: maps domain/hostname to backend server name
|
||||
pub forced_hosts: HashMap<String, String>,
|
||||
/// Automatically try fallback server if connection to target server fails
|
||||
#[serde(default = "default_true")]
|
||||
pub try_fallback_on_connect_failure: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ForwardingMode {
|
||||
/// Plain connection without forwarding info (for Vanilla servers in offline-mode)
|
||||
None,
|
||||
/// BungeeCord IP forwarding (for Bukkit, Spigot, PaperMC with bungeecord: true)
|
||||
BungeeCord,
|
||||
/// Velocity modern forwarding with HMAC-SHA256 secret (for PaperMC, Purpur, Pumpkin)
|
||||
Velocity,
|
||||
/// Vine modern forwarding with Ed25519 asymmetric signatures, timestamp, and challenge nonce (for Pumpkin)
|
||||
Vine,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BackendConfig {
|
||||
/// Address of backend server (e.g. "127.0.0.1:25566")
|
||||
pub address: String,
|
||||
/// Forwarding mode to use when connecting to this backend
|
||||
pub forwarding: ForwardingMode,
|
||||
/// Secret key required if forwarding mode is Velocity
|
||||
pub secret: Option<String>,
|
||||
/// Optional custom 64x64 PNG status icon path for this backend / virtual host
|
||||
#[serde(default)]
|
||||
pub icon_path: Option<String>,
|
||||
/// Optional custom MOTD Line 1 for this backend / virtual host
|
||||
#[serde(default)]
|
||||
pub motd_line1: Option<String>,
|
||||
/// Optional custom MOTD Line 2 for this backend / virtual host
|
||||
#[serde(default)]
|
||||
pub motd_line2: Option<String>,
|
||||
}
|
||||
|
||||
fn default_telemetry_interval() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_telemetry_endpoint() -> String {
|
||||
"https://market.pumpkinmc.org/api/v1/rest/telemetry/heartbeat".to_string()
|
||||
}
|
||||
|
||||
/// Telemetry configuration options.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TelemetryConfig {
|
||||
/// Whether anonymous telemetry is enabled (default: true)
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Custom telemetry backend ingestion endpoint
|
||||
#[serde(default = "default_telemetry_endpoint")]
|
||||
pub endpoint: String,
|
||||
/// Heartbeat interval in seconds (default: 300 seconds / 5 minutes). Minimum 60s.
|
||||
#[serde(default = "default_telemetry_interval")]
|
||||
pub interval_secs: u64,
|
||||
/// Whether to opt-in to displaying this proxy in the public community directory.
|
||||
#[serde(default)]
|
||||
pub public: bool,
|
||||
/// Public server/proxy name displayed on the analytics dashboard if public is true.
|
||||
#[serde(default)]
|
||||
pub server_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TelemetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
endpoint: default_telemetry_endpoint(),
|
||||
interval_secs: 300,
|
||||
public: false,
|
||||
server_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let mut servers = HashMap::new();
|
||||
servers.insert(
|
||||
"lobby".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25566".to_string(),
|
||||
forwarding: ForwardingMode::Velocity,
|
||||
secret: Some("vine-velocity-secret-change-me".to_string()),
|
||||
icon_path: None,
|
||||
motd_line1: None,
|
||||
motd_line2: None,
|
||||
},
|
||||
);
|
||||
servers.insert(
|
||||
"survival".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25567".to_string(),
|
||||
forwarding: ForwardingMode::BungeeCord,
|
||||
secret: None,
|
||||
icon_path: None,
|
||||
motd_line1: Some("§b§lVine Survival §7» §fWelcome back!".to_string()),
|
||||
motd_line2: Some("§7Custom world • Economy • Land Claims".to_string()),
|
||||
},
|
||||
);
|
||||
servers.insert(
|
||||
"vanilla".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25568".to_string(),
|
||||
forwarding: ForwardingMode::None,
|
||||
secret: None,
|
||||
icon_path: None,
|
||||
motd_line1: None,
|
||||
motd_line2: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut forced_hosts = HashMap::new();
|
||||
forced_hosts.insert("survival.example.com".to_string(), "survival".to_string());
|
||||
forced_hosts.insert("vanilla.example.com".to_string(), "vanilla".to_string());
|
||||
|
||||
Self {
|
||||
server: ServerConfig {
|
||||
bind_address: "0.0.0.0:25565".to_string(),
|
||||
online_mode: true,
|
||||
prevent_proxy_connections: true,
|
||||
compression_threshold: 256,
|
||||
compression_level: 6,
|
||||
shutdown_timeout_secs: 10,
|
||||
},
|
||||
network: NetworkConfig::default(),
|
||||
motd: MotdConfig {
|
||||
line1: "§a§lVine §7» §fHigh-Performance Minecraft Proxy".to_string(),
|
||||
line2: "§7Multi-version support §8• §e1.7.x - 26.x".to_string(),
|
||||
max_players: 100,
|
||||
version_name: "Vine 1.7.x - 26.x".to_string(),
|
||||
protocol_version: 776,
|
||||
sample_players: vec![
|
||||
"§aVine Multi-Version Proxy".to_string(),
|
||||
"§7Secure • Fast • Reliable".to_string(),
|
||||
],
|
||||
icon_path: None,
|
||||
show_sample_players: true,
|
||||
},
|
||||
security: SecurityConfig {
|
||||
rate_limit_enabled: true,
|
||||
max_connections_per_sec: 5,
|
||||
connection_burst: 10,
|
||||
max_packets_per_sec: 500,
|
||||
max_packet_size: 2 * 1024 * 1024,
|
||||
validate_usernames: true,
|
||||
},
|
||||
routing: RoutingConfig {
|
||||
default_server: "lobby".to_string(),
|
||||
fallback_server: Some("survival".to_string()),
|
||||
forced_hosts,
|
||||
try_fallback_on_connect_failure: true,
|
||||
},
|
||||
servers,
|
||||
telemetry: TelemetryConfig::default(),
|
||||
bedrock: crate::bedrock::BedrockConfig::default(),
|
||||
permissions: PermissionsConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::permissions::{GroupConfig, PermissionsConfig, UserPermissionConfig};
|
||||
|
||||
impl Config {
|
||||
pub fn load_or_create<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let path = path.as_ref();
|
||||
if path.exists() {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let config: Config = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
} else {
|
||||
let default_config = Config::default();
|
||||
let toml_string = toml::to_string_pretty(&default_config)?;
|
||||
std::fs::write(path, toml_string)?;
|
||||
Ok(default_config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_telemetry_config_defaults() {
|
||||
let default_config = TelemetryConfig::default();
|
||||
assert!(default_config.enabled);
|
||||
assert_eq!(
|
||||
default_config.endpoint,
|
||||
"https://market.pumpkinmc.org/api/v1/rest/telemetry/heartbeat"
|
||||
);
|
||||
assert_eq!(default_config.interval_secs, 300);
|
||||
assert!(!default_config.public);
|
||||
assert_eq!(default_config.server_name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telemetry_toml_deserialization() {
|
||||
let toml_str = r#"
|
||||
enabled = false
|
||||
endpoint = "http://localhost:5000/api/v1/rest/telemetry/heartbeat"
|
||||
interval_secs = 120
|
||||
public = true
|
||||
server_name = "Vine Proxy Hub"
|
||||
"#;
|
||||
|
||||
let config: TelemetryConfig = toml::from_str(toml_str).unwrap();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(
|
||||
config.endpoint,
|
||||
"http://localhost:5000/api/v1/rest/telemetry/heartbeat"
|
||||
);
|
||||
assert_eq!(config.interval_secs, 120);
|
||||
assert!(config.public);
|
||||
assert_eq!(config.server_name.as_deref(), Some("Vine Proxy Hub"));
|
||||
}
|
||||
}
|
||||
16
src/lib.rs
Normal file
16
src/lib.rs
Normal file
@ -0,0 +1,16 @@
|
||||
pub mod auth;
|
||||
pub mod backend;
|
||||
pub mod bedrock;
|
||||
pub mod client;
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod network;
|
||||
pub mod permissions;
|
||||
pub mod proxy;
|
||||
pub mod security;
|
||||
pub mod session;
|
||||
pub mod status;
|
||||
pub mod telemetry;
|
||||
|
||||
pub use config::Config;
|
||||
pub use proxy::ProxyServer;
|
||||
56
src/main.rs
Normal file
56
src/main.rs
Normal file
@ -0,0 +1,56 @@
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use vine::config::Config;
|
||||
use vine::proxy::ProxyServer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,vine=debug")),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config_path = std::env::args()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("vine.toml"));
|
||||
|
||||
info!("Loading configuration from '{}'...", config_path.display());
|
||||
let config = match Config::load_or_create(&config_path) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to load configuration file '{}': {}",
|
||||
config_path.display(),
|
||||
e
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let server = ProxyServer::new(config)?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = broadcast::channel(1);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = tokio::signal::ctrl_c().await {
|
||||
error!("Failed to listen for shutdown signal: {}", err);
|
||||
} else {
|
||||
info!("Received shutdown signal (Ctrl+C). Initiating graceful shutdown...");
|
||||
let _ = shutdown_tx.send(());
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(e) = server.run(shutdown_rx).await {
|
||||
error!("Fatal proxy error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
info!("Vine proxy shut down successfully.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
111
src/network/mod.rs
Normal file
111
src/network/mod.rs
Normal file
@ -0,0 +1,111 @@
|
||||
pub mod prefixed_stream;
|
||||
pub mod proxy_protocol;
|
||||
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::{TcpListener, TcpSocket, TcpStream};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::config::NetworkConfig;
|
||||
pub use prefixed_stream::PrefixedRead;
|
||||
pub use proxy_protocol::{ProxyHeaderResult, parse_proxy_protocol};
|
||||
|
||||
/// Creates and tunes a Tokio `TcpListener` based on `NetworkConfig`
|
||||
pub fn create_tcp_listener(
|
||||
bind_addr_str: &str,
|
||||
network: &NetworkConfig,
|
||||
) -> io::Result<TcpListener> {
|
||||
let addr: SocketAddr = bind_addr_str
|
||||
.parse()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
|
||||
let socket = if addr.is_ipv6() {
|
||||
TcpSocket::new_v6()?
|
||||
} else {
|
||||
TcpSocket::new_v4()?
|
||||
};
|
||||
|
||||
socket.set_reuseaddr(true)?;
|
||||
|
||||
#[cfg(all(unix, not(target_os = "solaris"), not(target_os = "illumos")))]
|
||||
{
|
||||
let _ = socket.set_reuseport(true);
|
||||
}
|
||||
|
||||
if let Some(buf) = network.recv_buffer_size {
|
||||
let _ = socket.set_recv_buffer_size(buf as u32);
|
||||
}
|
||||
if let Some(buf) = network.send_buffer_size {
|
||||
let _ = socket.set_send_buffer_size(buf as u32);
|
||||
}
|
||||
|
||||
socket.bind(addr)?;
|
||||
let listener = socket.listen(network.listen_backlog)?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
/// Applies socket performance options (TCP_NODELAY, KeepAlive, Buffer sizes) to a client `TcpStream`
|
||||
pub fn configure_client_socket(stream: &TcpStream, config: &NetworkConfig) -> io::Result<()> {
|
||||
if let Err(e) = stream.set_nodelay(config.tcp_nodelay) {
|
||||
warn!("Failed to set TCP_NODELAY on client socket: {}", e);
|
||||
}
|
||||
|
||||
let sock = socket2::SockRef::from(stream);
|
||||
|
||||
if config.tcp_keepalive_secs > 0 {
|
||||
let mut keepalive =
|
||||
socket2::TcpKeepalive::new().with_time(Duration::from_secs(config.tcp_keepalive_secs));
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
{
|
||||
keepalive = keepalive.with_interval(Duration::from_secs(10));
|
||||
}
|
||||
|
||||
if let Err(e) = sock.set_tcp_keepalive(&keepalive) {
|
||||
debug!("Failed to set TCP keepalive on client socket: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(buf) = config.recv_buffer_size {
|
||||
let _ = sock.set_recv_buffer_size(buf);
|
||||
}
|
||||
if let Some(buf) = config.send_buffer_size {
|
||||
let _ = sock.set_send_buffer_size(buf);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies socket performance options to a backend server `TcpStream`
|
||||
pub fn configure_backend_socket(stream: &TcpStream, config: &NetworkConfig) -> io::Result<()> {
|
||||
if let Err(e) = stream.set_nodelay(config.tcp_nodelay) {
|
||||
warn!("Failed to set TCP_NODELAY on backend socket: {}", e);
|
||||
}
|
||||
|
||||
let sock = socket2::SockRef::from(stream);
|
||||
|
||||
if config.tcp_keepalive_secs > 0 {
|
||||
let mut keepalive =
|
||||
socket2::TcpKeepalive::new().with_time(Duration::from_secs(config.tcp_keepalive_secs));
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
{
|
||||
keepalive = keepalive.with_interval(Duration::from_secs(10));
|
||||
}
|
||||
|
||||
if let Err(e) = sock.set_tcp_keepalive(&keepalive) {
|
||||
debug!("Failed to set TCP keepalive on backend socket: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(buf) = config.recv_buffer_size {
|
||||
let _ = sock.set_recv_buffer_size(buf);
|
||||
}
|
||||
if let Some(buf) = config.send_buffer_size {
|
||||
let _ = sock.set_send_buffer_size(buf);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
81
src/network/prefixed_stream.rs
Normal file
81
src/network/prefixed_stream.rs
Normal file
@ -0,0 +1,81 @@
|
||||
use std::io::{self, Cursor};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
|
||||
/// An `AsyncRead` wrapper that yields a prefix of bytes before delegating to an underlying stream.
|
||||
pub struct PrefixedRead<R> {
|
||||
prefix: Option<Cursor<Vec<u8>>>,
|
||||
inner: R,
|
||||
}
|
||||
|
||||
impl<R> PrefixedRead<R> {
|
||||
pub fn new(inner: R, prefix_bytes: Vec<u8>) -> Self {
|
||||
let prefix = if prefix_bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Cursor::new(prefix_bytes))
|
||||
};
|
||||
Self { prefix, inner }
|
||||
}
|
||||
|
||||
pub fn without_prefix(inner: R) -> Self {
|
||||
Self {
|
||||
prefix: None,
|
||||
inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> AsyncRead for PrefixedRead<R> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
if let Some(prefix) = &mut self.prefix {
|
||||
let initial_len = buf.filled().len();
|
||||
let n = std::io::Read::read(prefix, buf.initialize_unfilled())?;
|
||||
buf.advance(n);
|
||||
|
||||
if prefix.position() >= prefix.get_ref().len() as u64 {
|
||||
self.prefix = None;
|
||||
}
|
||||
|
||||
if buf.filled().len() > initial_len {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
Pin::new(&mut self.inner).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prefixed_read() {
|
||||
let prefix = b"PROXY HEADER ".to_vec();
|
||||
let inner = Cursor::new(b"MINECRAFT DATA");
|
||||
let mut stream = PrefixedRead::new(inner, prefix);
|
||||
|
||||
let mut output = Vec::new();
|
||||
stream.read_to_end(&mut output).await.unwrap();
|
||||
|
||||
assert_eq!(output, b"PROXY HEADER MINECRAFT DATA");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_prefix() {
|
||||
let inner = Cursor::new(b"ONLY DATA");
|
||||
let mut stream = PrefixedRead::without_prefix(inner);
|
||||
|
||||
let mut output = Vec::new();
|
||||
stream.read_to_end(&mut output).await.unwrap();
|
||||
|
||||
assert_eq!(output, b"ONLY DATA");
|
||||
}
|
||||
}
|
||||
298
src/network/proxy_protocol.rs
Normal file
298
src/network/proxy_protocol.rs
Normal file
@ -0,0 +1,298 @@
|
||||
use std::io;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
|
||||
pub const PROXY_V2_MAGIC: [u8; 12] = [
|
||||
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
|
||||
];
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ProxyHeaderResult {
|
||||
pub client_addr: SocketAddr,
|
||||
pub leftover_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Reads and parses HAProxy PROXY protocol (v1 or v2) from an incoming stream.
|
||||
/// Returns the client address and any leftover bytes read past the header.
|
||||
pub async fn parse_proxy_protocol<R: AsyncRead + Unpin>(
|
||||
reader: &mut R,
|
||||
fallback_addr: SocketAddr,
|
||||
) -> Result<ProxyHeaderResult, io::Error> {
|
||||
let mut initial_buf = [0u8; 512];
|
||||
let n = reader.read(&mut initial_buf).await?;
|
||||
if n == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"Stream closed before reading PROXY header",
|
||||
));
|
||||
}
|
||||
|
||||
if n >= 12 && initial_buf[..12] == PROXY_V2_MAGIC {
|
||||
return parse_proxy_v2(reader, fallback_addr, &initial_buf[..n]).await;
|
||||
}
|
||||
|
||||
if n >= 6 && &initial_buf[..6] == b"PROXY " {
|
||||
return parse_proxy_v1(reader, fallback_addr, &initial_buf[..n]).await;
|
||||
}
|
||||
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Unknown or invalid PROXY protocol header",
|
||||
))
|
||||
}
|
||||
|
||||
/// Parse PROXY protocol version 1 (text format)
|
||||
async fn parse_proxy_v1<R: AsyncRead + Unpin>(
|
||||
reader: &mut R,
|
||||
fallback_addr: SocketAddr,
|
||||
initial: &[u8],
|
||||
) -> Result<ProxyHeaderResult, io::Error> {
|
||||
let mut buffer = initial.to_vec();
|
||||
|
||||
while !buffer.windows(2).any(|w| w == b"\r\n") {
|
||||
if buffer.len() >= 108 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"PROXY v1 header exceeded maximum length (107 bytes)",
|
||||
));
|
||||
}
|
||||
let mut byte = [0u8; 1];
|
||||
let n = reader.read(&mut byte).await?;
|
||||
if n == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"EOF reached while reading PROXY v1 header",
|
||||
));
|
||||
}
|
||||
buffer.push(byte[0]);
|
||||
}
|
||||
|
||||
let crlf_pos = buffer
|
||||
.windows(2)
|
||||
.position(|w| w == b"\r\n")
|
||||
.expect("CRLf checked in loop");
|
||||
|
||||
let line = std::str::from_utf8(&buffer[..crlf_pos]).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"PROXY v1 header is not valid UTF-8",
|
||||
)
|
||||
})?;
|
||||
|
||||
let leftover_bytes = buffer[crlf_pos + 2..].to_vec();
|
||||
|
||||
let parts: Vec<&str> = line.split(' ').collect();
|
||||
if parts.is_empty() || parts[0] != "PROXY" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Invalid PROXY v1 prefix",
|
||||
));
|
||||
}
|
||||
|
||||
if parts.len() == 2 && parts[1] == "UNKNOWN" {
|
||||
return Ok(ProxyHeaderResult {
|
||||
client_addr: fallback_addr,
|
||||
leftover_bytes,
|
||||
});
|
||||
}
|
||||
|
||||
if parts.len() < 6 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Incomplete PROXY v1 header: '{}'", line),
|
||||
));
|
||||
}
|
||||
|
||||
let proto = parts[1];
|
||||
let src_ip_str = parts[2];
|
||||
let src_port_str = parts[4];
|
||||
|
||||
match proto {
|
||||
"TCP4" | "TCP6" => {
|
||||
let ip: IpAddr = src_ip_str.parse().map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Invalid PROXY v1 IP '{}': {}", src_ip_str, e),
|
||||
)
|
||||
})?;
|
||||
let port: u16 = src_port_str.parse().map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Invalid PROXY v1 port '{}': {}", src_port_str, e),
|
||||
)
|
||||
})?;
|
||||
Ok(ProxyHeaderResult {
|
||||
client_addr: SocketAddr::new(ip, port),
|
||||
leftover_bytes,
|
||||
})
|
||||
}
|
||||
"UNKNOWN" => Ok(ProxyHeaderResult {
|
||||
client_addr: fallback_addr,
|
||||
leftover_bytes,
|
||||
}),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Unsupported PROXY v1 protocol: '{}'", other),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse PROXY protocol version 2 (binary format)
|
||||
async fn parse_proxy_v2<R: AsyncRead + Unpin>(
|
||||
reader: &mut R,
|
||||
fallback_addr: SocketAddr,
|
||||
initial: &[u8],
|
||||
) -> Result<ProxyHeaderResult, io::Error> {
|
||||
let mut header = initial.to_vec();
|
||||
|
||||
while header.len() < 16 {
|
||||
let mut buf = [0u8; 16];
|
||||
let needed = 16 - header.len();
|
||||
let n = reader.read(&mut buf[..needed]).await?;
|
||||
if n == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"EOF reached while reading PROXY v2 fixed header",
|
||||
));
|
||||
}
|
||||
header.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
|
||||
let ver_cmd = header[12];
|
||||
let fam_proto = header[13];
|
||||
let addr_len = u16::from_be_bytes([header[14], header[15]]) as usize;
|
||||
|
||||
let version = ver_cmd >> 4;
|
||||
let command = ver_cmd & 0x0F;
|
||||
|
||||
if version != 2 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Unsupported PROXY v2 version: {}", version),
|
||||
));
|
||||
}
|
||||
|
||||
let total_required = 16 + addr_len;
|
||||
while header.len() < total_required {
|
||||
let mut buf = [0u8; 512];
|
||||
let needed = (total_required - header.len()).min(buf.len());
|
||||
let n = reader.read(&mut buf[..needed]).await?;
|
||||
if n == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"EOF reached while reading PROXY v2 address body",
|
||||
));
|
||||
}
|
||||
header.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
|
||||
let leftover_bytes = header[total_required..].to_vec();
|
||||
let body = &header[16..total_required];
|
||||
|
||||
if command == 0x00 {
|
||||
return Ok(ProxyHeaderResult {
|
||||
client_addr: fallback_addr,
|
||||
leftover_bytes,
|
||||
});
|
||||
}
|
||||
|
||||
if command != 0x01 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Unsupported PROXY v2 command: {}", command),
|
||||
));
|
||||
}
|
||||
|
||||
let family = fam_proto >> 4;
|
||||
let protocol = fam_proto & 0x0F;
|
||||
|
||||
if family == 0x1 && protocol == 0x1 {
|
||||
if body.len() < 12 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Truncated PROXY v2 IPv4 address block",
|
||||
));
|
||||
}
|
||||
let src_ip = Ipv4Addr::new(body[0], body[1], body[2], body[3]);
|
||||
let src_port = u16::from_be_bytes([body[8], body[9]]);
|
||||
return Ok(ProxyHeaderResult {
|
||||
client_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port),
|
||||
leftover_bytes,
|
||||
});
|
||||
}
|
||||
|
||||
if family == 0x2 && protocol == 0x1 {
|
||||
if body.len() < 36 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Truncated PROXY v2 IPv6 address block",
|
||||
));
|
||||
}
|
||||
let mut ip_bytes = [0u8; 16];
|
||||
ip_bytes.copy_from_slice(&body[0..16]);
|
||||
let src_ip = Ipv6Addr::from(ip_bytes);
|
||||
let src_port = u16::from_be_bytes([body[32], body[33]]);
|
||||
return Ok(ProxyHeaderResult {
|
||||
client_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port),
|
||||
leftover_bytes,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ProxyHeaderResult {
|
||||
client_addr: fallback_addr,
|
||||
leftover_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_v1_ipv4() {
|
||||
let mut data = Cursor::new(b"PROXY TCP4 198.51.100.22 192.0.2.1 54321 25565\r\nHello");
|
||||
let fallback = "127.0.0.1:1234".parse().unwrap();
|
||||
let res = parse_proxy_protocol(&mut data, fallback).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
res.client_addr,
|
||||
"198.51.100.22:54321".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(res.leftover_bytes, b"Hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_v1_unknown() {
|
||||
let mut data = Cursor::new(b"PROXY UNKNOWN\r\nMinecraftPayload");
|
||||
let fallback = "127.0.0.1:1234".parse().unwrap();
|
||||
let res = parse_proxy_protocol(&mut data, fallback).await.unwrap();
|
||||
|
||||
assert_eq!(res.client_addr, fallback);
|
||||
assert_eq!(res.leftover_bytes, b"MinecraftPayload");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_v2_ipv4() {
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(&PROXY_V2_MAGIC);
|
||||
payload.push(0x21);
|
||||
payload.push(0x11);
|
||||
payload.extend_from_slice(&12u16.to_be_bytes());
|
||||
payload.extend_from_slice(&[10, 0, 0, 99]);
|
||||
payload.extend_from_slice(&[192, 168, 1, 1]);
|
||||
payload.extend_from_slice(&45678u16.to_be_bytes());
|
||||
payload.extend_from_slice(&25565u16.to_be_bytes());
|
||||
payload.extend_from_slice(b"MC_PACKET");
|
||||
|
||||
let mut cursor = Cursor::new(payload);
|
||||
let fallback = "127.0.0.1:1234".parse().unwrap();
|
||||
let res = parse_proxy_protocol(&mut cursor, fallback).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
res.client_addr,
|
||||
"10.0.0.99:45678".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(res.leftover_bytes, b"MC_PACKET");
|
||||
}
|
||||
}
|
||||
322
src/permissions.rs
Normal file
322
src/permissions.rs
Normal file
@ -0,0 +1,322 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
/// Pattern matching for permission nodes.
|
||||
///
|
||||
/// Supports:
|
||||
/// - `*` matches everything
|
||||
/// - Exact match (`vine.command.send` matches `vine.command.send`)
|
||||
/// - Wildcard suffix (`vine.command.*` matches `vine.command.send` and `vine.command.server`)
|
||||
/// - Case-insensitive matching
|
||||
pub fn matches_node(pattern: &str, target: &str) -> bool {
|
||||
let p = pattern.to_ascii_lowercase();
|
||||
let t = target.to_ascii_lowercase();
|
||||
|
||||
if p == "*" || p == t {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(prefix) = p.strip_suffix(".*")
|
||||
&& t.starts_with(prefix)
|
||||
{
|
||||
// Must match boundary e.g. "vine.command.*" matches "vine.command.send" but not "vine.commander"
|
||||
if t.len() > prefix.len() && t.as_bytes()[prefix.len()] == b'.' {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Configuration for a permission group
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GroupConfig {
|
||||
/// Groups inherited by this group
|
||||
#[serde(default)]
|
||||
pub inherits: Vec<String>,
|
||||
/// List of permission nodes (e.g. `["vine.command.server", "*", "-vine.command.send"]`)
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// User assignment configuration (either a simple list of groups or detailed with direct permissions)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum UserPermissionConfig {
|
||||
/// Simple group list: e.g. `"Alex" = ["admin"]`
|
||||
Groups(Vec<String>),
|
||||
/// Detailed configuration with both groups and direct permissions
|
||||
Detailed {
|
||||
#[serde(default)]
|
||||
groups: Vec<String>,
|
||||
#[serde(default)]
|
||||
permissions: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl UserPermissionConfig {
|
||||
pub fn groups(&self) -> &[String] {
|
||||
match self {
|
||||
Self::Groups(groups) => groups,
|
||||
Self::Detailed { groups, .. } => groups,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn direct_permissions(&self) -> &[String] {
|
||||
match self {
|
||||
Self::Groups(_) => &[],
|
||||
Self::Detailed { permissions, .. } => permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_group_name() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
fn default_permission_groups() -> HashMap<String, GroupConfig> {
|
||||
let mut groups = HashMap::new();
|
||||
groups.insert(
|
||||
"default".to_string(),
|
||||
GroupConfig {
|
||||
inherits: Vec::new(),
|
||||
permissions: vec![
|
||||
"vine.command.server".to_string(),
|
||||
"vine.command.glist".to_string(),
|
||||
"vine.command.list".to_string(),
|
||||
"vine.command.help".to_string(),
|
||||
],
|
||||
},
|
||||
);
|
||||
groups.insert(
|
||||
"admin".to_string(),
|
||||
GroupConfig {
|
||||
inherits: vec!["default".to_string()],
|
||||
permissions: vec![
|
||||
"vine.command.send".to_string(),
|
||||
"vine.admin".to_string(),
|
||||
"*".to_string(),
|
||||
],
|
||||
},
|
||||
);
|
||||
groups
|
||||
}
|
||||
|
||||
/// Configuration for the proxy permission system
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PermissionsConfig {
|
||||
/// Default group applied to every player (default: "default")
|
||||
#[serde(default = "default_group_name")]
|
||||
pub default_group: String,
|
||||
/// Permission groups with inheritance and node lists
|
||||
#[serde(default = "default_permission_groups")]
|
||||
pub groups: HashMap<String, GroupConfig>,
|
||||
/// User assignments mapping username to groups or direct permissions
|
||||
#[serde(default)]
|
||||
pub users: HashMap<String, UserPermissionConfig>,
|
||||
}
|
||||
|
||||
impl Default for PermissionsConfig {
|
||||
fn default() -> Self {
|
||||
let mut users = HashMap::new();
|
||||
users.insert(
|
||||
"Alex".to_string(),
|
||||
UserPermissionConfig::Groups(vec!["admin".to_string()]),
|
||||
);
|
||||
Self {
|
||||
default_group: default_group_name(),
|
||||
groups: default_permission_groups(),
|
||||
users,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionsConfig {
|
||||
/// Collects all effective permission strings for a given username,
|
||||
/// resolving direct permissions, group inheritance, and default group.
|
||||
pub fn collect_permissions(&self, username: &str) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
let mut user_groups = Vec::new();
|
||||
|
||||
// 1. Direct user configuration (case-insensitive username lookup)
|
||||
if let Some((_, user_cfg)) = self
|
||||
.users
|
||||
.iter()
|
||||
.find(|(u, _)| u.eq_ignore_ascii_case(username))
|
||||
{
|
||||
for perm in user_cfg.direct_permissions() {
|
||||
result.push(perm.clone());
|
||||
}
|
||||
for grp in user_cfg.groups() {
|
||||
user_groups.push(grp.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Always include default_group unless explicitly overridden
|
||||
if !user_groups
|
||||
.iter()
|
||||
.any(|g| g.eq_ignore_ascii_case(&self.default_group))
|
||||
{
|
||||
user_groups.push(&self.default_group);
|
||||
}
|
||||
|
||||
// 3. Resolve groups with inheritance
|
||||
let mut visited_groups = HashSet::new();
|
||||
let mut queue = VecDeque::from(user_groups);
|
||||
|
||||
while let Some(grp_name) = queue.pop_front() {
|
||||
let grp_lower = grp_name.to_ascii_lowercase();
|
||||
if !visited_groups.insert(grp_lower.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((_, grp)) = self
|
||||
.groups
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(&grp_lower))
|
||||
{
|
||||
for perm in &grp.permissions {
|
||||
result.push(perm.clone());
|
||||
}
|
||||
for inherit in &grp.inherits {
|
||||
queue.push_back(inherit.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Evaluates whether a user has the specified permission node.
|
||||
///
|
||||
/// Evaluation rules:
|
||||
/// 1. If any effective permission starts with `-` and matches `node`, access is explicitly denied (returns `false`).
|
||||
/// 2. If any positive effective permission matches `node` (exact, wildcard `*`, or prefix `foo.*`), returns `true`.
|
||||
/// 3. Otherwise returns `false`.
|
||||
pub fn has_permission(&self, username: &str, node: &str) -> bool {
|
||||
let perms = self.collect_permissions(username);
|
||||
|
||||
// 1. Check explicit negations first
|
||||
for perm in &perms {
|
||||
if let Some(negated) = perm.strip_prefix('-')
|
||||
&& matches_node(negated, node)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check positive permissions
|
||||
for perm in &perms {
|
||||
if !perm.starts_with('-') && matches_node(perm, node) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_matches_node() {
|
||||
assert!(matches_node("*", "vine.command.send"));
|
||||
assert!(matches_node("vine.command.send", "vine.command.send"));
|
||||
assert!(matches_node("VINE.COMMAND.SEND", "vine.command.send"));
|
||||
assert!(matches_node("vine.command.*", "vine.command.send"));
|
||||
assert!(matches_node("vine.command.*", "vine.command.server"));
|
||||
assert!(!matches_node("vine.command.*", "vine.commander"));
|
||||
assert!(!matches_node("vine.command.*", "other.command.send"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_permissions() {
|
||||
let config = PermissionsConfig::default();
|
||||
|
||||
// Regular player in default group
|
||||
assert!(config.has_permission("Steve", "vine.command.server"));
|
||||
assert!(config.has_permission("Steve", "vine.command.glist"));
|
||||
assert!(config.has_permission("Steve", "vine.command.list"));
|
||||
assert!(config.has_permission("Steve", "vine.command.help"));
|
||||
assert!(!config.has_permission("Steve", "vine.command.send"));
|
||||
assert!(!config.has_permission("Steve", "vine.admin"));
|
||||
|
||||
// Alex in admin group
|
||||
assert!(config.has_permission("Alex", "vine.command.send"));
|
||||
assert!(config.has_permission("alex", "vine.command.send")); // case-insensitive
|
||||
assert!(config.has_permission("Alex", "vine.command.server"));
|
||||
assert!(config.has_permission("Alex", "any.random.node")); // * wildcard
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negated_permissions() {
|
||||
let mut config = PermissionsConfig::default();
|
||||
let mut groups = HashMap::new();
|
||||
groups.insert(
|
||||
"moderator".to_string(),
|
||||
GroupConfig {
|
||||
inherits: vec!["default".to_string()],
|
||||
permissions: vec![
|
||||
"vine.command.*".to_string(),
|
||||
"-vine.command.send".to_string(),
|
||||
],
|
||||
},
|
||||
);
|
||||
config.groups = groups;
|
||||
config.users.insert(
|
||||
"ModUser".to_string(),
|
||||
UserPermissionConfig::Groups(vec!["moderator".to_string()]),
|
||||
);
|
||||
|
||||
assert!(config.has_permission("ModUser", "vine.command.server"));
|
||||
assert!(config.has_permission("ModUser", "vine.command.glist"));
|
||||
// Explicitly negated
|
||||
assert!(!config.has_permission("ModUser", "vine.command.send"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_group_inheritance_does_not_loop() {
|
||||
let mut config = PermissionsConfig::default();
|
||||
let mut groups = HashMap::new();
|
||||
groups.insert(
|
||||
"group_a".to_string(),
|
||||
GroupConfig {
|
||||
inherits: vec!["group_b".to_string()],
|
||||
permissions: vec!["node.a".to_string()],
|
||||
},
|
||||
);
|
||||
groups.insert(
|
||||
"group_b".to_string(),
|
||||
GroupConfig {
|
||||
inherits: vec!["group_a".to_string()],
|
||||
permissions: vec!["node.b".to_string()],
|
||||
},
|
||||
);
|
||||
config.groups = groups;
|
||||
config.users.insert(
|
||||
"User1".to_string(),
|
||||
UserPermissionConfig::Groups(vec!["group_a".to_string()]),
|
||||
);
|
||||
|
||||
assert!(config.has_permission("User1", "node.a"));
|
||||
assert!(config.has_permission("User1", "node.b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detailed_user_config() {
|
||||
let mut config = PermissionsConfig::default();
|
||||
config.users.insert(
|
||||
"CustomUser".to_string(),
|
||||
UserPermissionConfig::Detailed {
|
||||
groups: vec!["default".to_string()],
|
||||
permissions: vec!["custom.perm".to_string()],
|
||||
},
|
||||
);
|
||||
|
||||
assert!(config.has_permission("CustomUser", "custom.perm"));
|
||||
assert!(config.has_permission("CustomUser", "vine.command.server"));
|
||||
assert!(!config.has_permission("CustomUser", "vine.command.send"));
|
||||
}
|
||||
}
|
||||
306
src/proxy.rs
Normal file
306
src/proxy.rs
Normal file
@ -0,0 +1,306 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::auth::{Authenticator, KeyStore};
|
||||
use crate::backend::Router;
|
||||
use crate::client::ClientHandler;
|
||||
use crate::command::{CommandDispatcher, CommandSender, ProxyCommandSource};
|
||||
use crate::config::Config;
|
||||
use crate::network;
|
||||
use crate::security::ConnectionRateLimiter;
|
||||
use crate::session::SessionManager;
|
||||
use crate::status::{StatusHandler, StatusIconManager};
|
||||
use crate::telemetry;
|
||||
|
||||
pub struct ProxyServer {
|
||||
config: Arc<Config>,
|
||||
keystore: Arc<KeyStore>,
|
||||
authenticator: Arc<Authenticator>,
|
||||
rate_limiter: Arc<ConnectionRateLimiter>,
|
||||
status_handler: Arc<StatusHandler>,
|
||||
router: Arc<Router>,
|
||||
online_players: Arc<AtomicUsize>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
command_dispatcher: Arc<CommandDispatcher>,
|
||||
}
|
||||
|
||||
impl ProxyServer {
|
||||
pub fn new(config: Config) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let config = Arc::new(config);
|
||||
let keystore = Arc::new(KeyStore::new()?);
|
||||
let authenticator = Arc::new(Authenticator::new());
|
||||
|
||||
let rate_limiter = Arc::new(ConnectionRateLimiter::new(
|
||||
config.security.rate_limit_enabled,
|
||||
config.security.max_connections_per_sec,
|
||||
config.security.connection_burst,
|
||||
));
|
||||
|
||||
let online_players = Arc::new(AtomicUsize::new(0));
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let command_dispatcher = Arc::new(CommandDispatcher::new());
|
||||
|
||||
let server_icons: HashMap<String, String> = config
|
||||
.servers
|
||||
.iter()
|
||||
.filter_map(|(name, s)| s.icon_path.as_ref().map(|p| (name.clone(), p.clone())))
|
||||
.collect();
|
||||
|
||||
let icon_manager = Arc::new(StatusIconManager::new(
|
||||
config.motd.icon_path.clone(),
|
||||
server_icons,
|
||||
config.routing.forced_hosts.clone(),
|
||||
config.routing.default_server.clone(),
|
||||
));
|
||||
|
||||
let status_handler = Arc::new(StatusHandler::new(
|
||||
config.motd.clone(),
|
||||
online_players.clone(),
|
||||
icon_manager,
|
||||
config.servers.clone(),
|
||||
config.routing.forced_hosts.clone(),
|
||||
));
|
||||
|
||||
let router = Arc::new(Router::new(config.routing.clone(), config.servers.clone()));
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
keystore,
|
||||
authenticator,
|
||||
rate_limiter,
|
||||
status_handler,
|
||||
router,
|
||||
online_players,
|
||||
session_manager,
|
||||
command_dispatcher,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the proxy listener loop
|
||||
pub async fn run(
|
||||
&self,
|
||||
mut shutdown_rx: broadcast::Receiver<()>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let bind_addr = &self.config.server.bind_address;
|
||||
let listener = network::create_tcp_listener(bind_addr, &self.config.network)?;
|
||||
|
||||
info!("=====================================================");
|
||||
info!(" Vine Minecraft Proxy is listening on {}", bind_addr);
|
||||
info!(" Online mode: {}", self.config.server.online_mode);
|
||||
info!(" Multi-version support: 1.7.2 through 1.21.x / 26.x");
|
||||
info!(" Networking options:");
|
||||
info!(" - TCP_NODELAY: {}", self.config.network.tcp_nodelay);
|
||||
info!(
|
||||
" - TCP KeepAlive: {}s",
|
||||
self.config.network.tcp_keepalive_secs
|
||||
);
|
||||
info!(
|
||||
" - PROXY Protocol v1/v2: {}",
|
||||
self.config.network.proxy_protocol
|
||||
);
|
||||
info!(
|
||||
" - Handshake timeout: {}s",
|
||||
self.config.network.client_handshake_timeout_secs
|
||||
);
|
||||
info!(
|
||||
" - Login timeout: {}s",
|
||||
self.config.network.client_login_timeout_secs
|
||||
);
|
||||
info!(
|
||||
" - Backend connect timeout: {}s",
|
||||
self.config.network.backend_connect_timeout_secs
|
||||
);
|
||||
info!(
|
||||
" - Compression level: {} (threshold: {} bytes)",
|
||||
self.config.server.compression_level, self.config.server.compression_threshold
|
||||
);
|
||||
info!(
|
||||
" Configured backend servers: {}",
|
||||
self.config.servers.len()
|
||||
);
|
||||
for (name, server) in &self.config.servers {
|
||||
let icon_info = server.icon_path.as_deref().unwrap_or("none");
|
||||
info!(
|
||||
" - [{}] {} (Forwarding: {:?}, Icon: {})",
|
||||
name, server.address, server.forwarding, icon_info
|
||||
);
|
||||
}
|
||||
info!(" Default route: '{}'", self.config.routing.default_server);
|
||||
if self.config.telemetry.enabled {
|
||||
info!(
|
||||
" Telemetry: enabled (endpoint: {}, interval: {}s)",
|
||||
self.config.telemetry.endpoint, self.config.telemetry.interval_secs
|
||||
);
|
||||
} else {
|
||||
info!(" Telemetry: disabled");
|
||||
}
|
||||
if self.config.bedrock.enabled {
|
||||
info!(
|
||||
" Bedrock Edition: enabled (listening on UDP {}, v{} / proto {})",
|
||||
self.config.bedrock.bind_address,
|
||||
self.config.bedrock.version_name,
|
||||
self.config.bedrock.protocol_version
|
||||
);
|
||||
} else {
|
||||
info!(" Bedrock Edition: disabled");
|
||||
}
|
||||
info!("=====================================================");
|
||||
|
||||
let telemetry_handle = telemetry::start_telemetry(
|
||||
self.config.clone(),
|
||||
self.online_players.clone(),
|
||||
shutdown_rx.resubscribe(),
|
||||
);
|
||||
|
||||
if self.config.bedrock.enabled {
|
||||
let bedrock_cfg = self.config.bedrock.clone();
|
||||
let online_players = self.online_players.clone();
|
||||
let bedrock_shutdown = shutdown_rx.resubscribe();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::bedrock::run_bedrock_listener(
|
||||
bedrock_cfg,
|
||||
online_players,
|
||||
bedrock_shutdown,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Bedrock listener terminated with error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let console_dispatcher = self.command_dispatcher.clone();
|
||||
let console_config = self.config.clone();
|
||||
let console_session_manager = self.session_manager.clone();
|
||||
let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("console-stdin".to_string())
|
||||
.spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
let mut line = String::new();
|
||||
while let Ok(n) = stdin.read_line(&mut line) {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
if stdin_tx.send(line.trim().to_string()).is_err() {
|
||||
break;
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
|
||||
let mut stdin_shutdown = shutdown_rx.resubscribe();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stdin_shutdown.recv() => break,
|
||||
cmd_opt = stdin_rx.recv() => {
|
||||
match cmd_opt {
|
||||
Some(trimmed) => {
|
||||
if !trimmed.is_empty() {
|
||||
let source = ProxyCommandSource {
|
||||
sender: CommandSender::Console,
|
||||
config: console_config.clone(),
|
||||
session_manager: console_session_manager.clone(),
|
||||
};
|
||||
console_dispatcher.handle_command(&source, &trimmed);
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_res = listener.accept() => {
|
||||
match accept_res {
|
||||
Ok((stream, peer_addr)) => {
|
||||
let config = self.config.clone();
|
||||
let keystore = self.keystore.clone();
|
||||
let authenticator = self.authenticator.clone();
|
||||
let status_handler = self.status_handler.clone();
|
||||
let router = self.router.clone();
|
||||
let online_players = self.online_players.clone();
|
||||
let rate_limiter = self.rate_limiter.clone();
|
||||
let session_manager = self.session_manager.clone();
|
||||
let command_dispatcher = self.command_dispatcher.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = network::configure_client_socket(&stream, &config.network) {
|
||||
debug!("[{}] Failed to configure client socket: {}", peer_addr, e);
|
||||
}
|
||||
|
||||
let mut stream = stream;
|
||||
let mut effective_addr = peer_addr;
|
||||
let mut leftover_bytes = Vec::new();
|
||||
|
||||
if config.network.proxy_protocol {
|
||||
match network::parse_proxy_protocol(&mut stream, peer_addr).await {
|
||||
Ok(res) => {
|
||||
effective_addr = res.client_addr;
|
||||
leftover_bytes = res.leftover_bytes;
|
||||
debug!("[{}] PROXY protocol resolved client IP: {}", peer_addr, effective_addr);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("[{}] Dropped connection: PROXY protocol error: {}", peer_addr, err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !rate_limiter.check_connection(&effective_addr.ip()) {
|
||||
warn!("[{}] Dropped connection: Rate limit exceeded", effective_addr);
|
||||
return;
|
||||
}
|
||||
|
||||
let handler = ClientHandler::new(
|
||||
effective_addr,
|
||||
config,
|
||||
keystore,
|
||||
authenticator,
|
||||
status_handler,
|
||||
router,
|
||||
online_players,
|
||||
session_manager,
|
||||
command_dispatcher,
|
||||
);
|
||||
|
||||
if let Err(err) = handler.handle(stream, leftover_bytes).await {
|
||||
debug!("[{}] Connection closed: {}", effective_addr, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("TCP listener accept error: {}", e);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
info!("Shutting down Vine proxy listener...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Vine proxy has stopped accepting connections.");
|
||||
|
||||
if let Some(handle) = telemetry_handle {
|
||||
let timeout_secs = self.config.server.shutdown_timeout_secs.max(1);
|
||||
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), handle).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
5
src/security/mod.rs
Normal file
5
src/security/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod rate_limiter;
|
||||
pub mod sanitizer;
|
||||
|
||||
pub use rate_limiter::{ConnectionRateLimiter, PacketRateLimiter};
|
||||
pub use sanitizer::Sanitizer;
|
||||
130
src/security/rate_limiter.rs
Normal file
130
src/security/rate_limiter.rs
Normal file
@ -0,0 +1,130 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::IpAddr,
|
||||
sync::RwLock,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
struct TokenBucket {
|
||||
tokens: f64,
|
||||
last_update: Instant,
|
||||
}
|
||||
|
||||
impl TokenBucket {
|
||||
fn new(burst: f64) -> Self {
|
||||
Self {
|
||||
tokens: burst,
|
||||
last_update: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_consume(&mut self, rate_per_sec: f64, burst: f64) -> bool {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last_update).as_secs_f64();
|
||||
self.last_update = now;
|
||||
|
||||
self.tokens = (self.tokens + elapsed * rate_per_sec).min(burst);
|
||||
|
||||
if self.tokens >= 1.0 {
|
||||
self.tokens -= 1.0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConnectionRateLimiter {
|
||||
enabled: bool,
|
||||
rate_per_sec: f64,
|
||||
burst: f64,
|
||||
buckets: RwLock<HashMap<IpAddr, TokenBucket>>,
|
||||
}
|
||||
|
||||
impl ConnectionRateLimiter {
|
||||
pub fn new(enabled: bool, rate_per_sec: u32, burst: u32) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
rate_per_sec: rate_per_sec as f64,
|
||||
burst: burst as f64,
|
||||
buckets: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if connection is allowed, false if rate limited
|
||||
pub fn check_connection(&self, ip: &IpAddr) -> bool {
|
||||
if !self.enabled {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut buckets = match self.buckets.write() {
|
||||
Ok(b) => b,
|
||||
Err(_) => return true,
|
||||
};
|
||||
|
||||
if buckets.len() > 10_000 {
|
||||
let now = Instant::now();
|
||||
buckets.retain(|_, b| now.duration_since(b.last_update) < Duration::from_secs(60));
|
||||
}
|
||||
|
||||
let bucket = buckets
|
||||
.entry(*ip)
|
||||
.or_insert_with(|| TokenBucket::new(self.burst));
|
||||
|
||||
bucket.try_consume(self.rate_per_sec, self.burst)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-connection packet rate limiter to detect and prevent packet flood attacks
|
||||
pub struct PacketRateLimiter {
|
||||
max_packets_per_sec: u32,
|
||||
packet_count: u32,
|
||||
window_start: Instant,
|
||||
}
|
||||
|
||||
impl PacketRateLimiter {
|
||||
pub fn new(max_packets_per_sec: u32) -> Self {
|
||||
Self {
|
||||
max_packets_per_sec,
|
||||
packet_count: 0,
|
||||
window_start: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a packet and return false if limit exceeded (flood detected)
|
||||
pub fn record_packet(&mut self) -> bool {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(self.window_start) >= Duration::from_secs(1) {
|
||||
self.window_start = now;
|
||||
self.packet_count = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
self.packet_count += 1;
|
||||
self.packet_count <= self.max_packets_per_sec
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_packet_rate_limiter() {
|
||||
let mut limiter = PacketRateLimiter::new(3);
|
||||
assert!(limiter.record_packet());
|
||||
assert!(limiter.record_packet());
|
||||
assert!(limiter.record_packet());
|
||||
assert!(!limiter.record_packet());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connection_rate_limiter() {
|
||||
let limiter = ConnectionRateLimiter::new(true, 2, 2);
|
||||
let ip: IpAddr = "192.168.1.100".parse().unwrap();
|
||||
|
||||
assert!(limiter.check_connection(&ip));
|
||||
assert!(limiter.check_connection(&ip));
|
||||
assert!(!limiter.check_connection(&ip));
|
||||
}
|
||||
}
|
||||
65
src/security/sanitizer.rs
Normal file
65
src/security/sanitizer.rs
Normal file
@ -0,0 +1,65 @@
|
||||
pub struct Sanitizer;
|
||||
|
||||
impl Sanitizer {
|
||||
/// Validates Minecraft username according to Mojang specifications:
|
||||
/// - Length: between 2 and 16 characters
|
||||
/// - Characters: ASCII alphanumeric (a-z, A-Z, 0-9) and underscore (_)
|
||||
pub fn is_valid_username(name: &str) -> bool {
|
||||
let len = name.len();
|
||||
if !(2..=16).contains(&len) {
|
||||
return false;
|
||||
}
|
||||
|
||||
name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
}
|
||||
|
||||
/// Sanitizes virtual host string:
|
||||
/// - Strips null bytes (`\0`) which could break BungeeCord packet format
|
||||
/// - Truncates to max 255 characters
|
||||
/// - Strips control characters
|
||||
pub fn sanitize_host(host: &str) -> String {
|
||||
let mut clean = String::with_capacity(host.len().min(255));
|
||||
for ch in host.chars() {
|
||||
if clean.len() >= 255 {
|
||||
break;
|
||||
}
|
||||
if ch != '\0' && !ch.is_control() {
|
||||
clean.push(ch);
|
||||
}
|
||||
}
|
||||
clean
|
||||
}
|
||||
|
||||
/// Validates packet size against maximum limit
|
||||
pub fn is_valid_packet_size(size: usize, max_allowed: usize) -> bool {
|
||||
size <= max_allowed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_username_validation() {
|
||||
assert!(Sanitizer::is_valid_username("Alex"));
|
||||
assert!(Sanitizer::is_valid_username("Steve_99"));
|
||||
assert!(Sanitizer::is_valid_username("a_"));
|
||||
|
||||
assert!(!Sanitizer::is_valid_username(""));
|
||||
assert!(!Sanitizer::is_valid_username("a"));
|
||||
assert!(!Sanitizer::is_valid_username("A_very_long_username_here"));
|
||||
assert!(!Sanitizer::is_valid_username("Player!@#"));
|
||||
assert!(!Sanitizer::is_valid_username("Player with space"));
|
||||
assert!(!Sanitizer::is_valid_username("Player\0Null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_host_sanitizer() {
|
||||
assert_eq!(Sanitizer::sanitize_host("mc.example.com"), "mc.example.com");
|
||||
assert_eq!(
|
||||
Sanitizer::sanitize_host("mc.example.com\0malicious_ip\0uuid"),
|
||||
"mc.example.commalicious_ipuuid"
|
||||
);
|
||||
}
|
||||
}
|
||||
148
src/session.rs
Normal file
148
src/session.rs
Normal file
@ -0,0 +1,148 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use pumpkin_util::text::TextComponent;
|
||||
|
||||
/// Actions that can be dispatched to an active player's connection
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlayerAction {
|
||||
/// Request connecting the player to a target backend server in-proxy
|
||||
Connect { server_name: String },
|
||||
/// Request transfer of the player to a target host and port
|
||||
Transfer { host: String, port: u16 },
|
||||
/// Send a system chat message to the player
|
||||
Message(TextComponent),
|
||||
}
|
||||
|
||||
/// Represents an active connected player session on the proxy
|
||||
#[derive(Clone)]
|
||||
pub struct PlayerSession {
|
||||
pub username: String,
|
||||
pub uuid: Uuid,
|
||||
pub current_server: String,
|
||||
pub action_tx: mpsc::UnboundedSender<PlayerAction>,
|
||||
}
|
||||
|
||||
/// Thread-safe registry of connected player sessions
|
||||
pub struct SessionManager {
|
||||
sessions: RwLock<HashMap<String, PlayerSession>>,
|
||||
}
|
||||
|
||||
impl Default for SessionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a new active player session
|
||||
pub fn register(&self, session: PlayerSession) {
|
||||
if let Ok(mut lock) = self.sessions.write() {
|
||||
lock.insert(session.username.to_ascii_lowercase(), session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unregisters an active session by username
|
||||
pub fn unregister(&self, username: &str) -> Option<PlayerSession> {
|
||||
if let Ok(mut lock) = self.sessions.write() {
|
||||
lock.remove(&username.to_ascii_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the current server for an active session
|
||||
pub fn update_server(&self, username: &str, new_server: &str) {
|
||||
if let Ok(mut lock) = self.sessions.write()
|
||||
&& let Some(session) = lock.get_mut(&username.to_ascii_lowercase())
|
||||
{
|
||||
session.current_server = new_server.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up an active session by username (case-insensitive)
|
||||
pub fn get_session(&self, username: &str) -> Option<PlayerSession> {
|
||||
if let Ok(lock) = self.sessions.read() {
|
||||
lock.get(&username.to_ascii_lowercase()).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a list of all active sessions
|
||||
pub fn list_sessions(&self) -> Vec<PlayerSession> {
|
||||
if let Ok(lock) = self.sessions.read() {
|
||||
lock.values().cloned().collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of currently registered sessions
|
||||
pub fn count(&self) -> usize {
|
||||
if let Ok(lock) = self.sessions.read() {
|
||||
lock.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_session_registration_and_lookup() {
|
||||
let manager = SessionManager::new();
|
||||
assert_eq!(manager.count(), 0);
|
||||
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
let session = PlayerSession {
|
||||
username: "Steve".to_string(),
|
||||
uuid: Uuid::new_v4(),
|
||||
current_server: "lobby".to_string(),
|
||||
action_tx: tx,
|
||||
};
|
||||
|
||||
manager.register(session);
|
||||
assert_eq!(manager.count(), 1);
|
||||
|
||||
let found = manager.get_session("steve");
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().username, "Steve");
|
||||
|
||||
let removed = manager.unregister("STEVE");
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(manager.count(), 0);
|
||||
assert!(manager.get_session("Steve").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_server() {
|
||||
let manager = SessionManager::new();
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
let session = PlayerSession {
|
||||
username: "Alex".to_string(),
|
||||
uuid: Uuid::new_v4(),
|
||||
current_server: "lobby".to_string(),
|
||||
action_tx: tx,
|
||||
};
|
||||
manager.register(session);
|
||||
assert_eq!(manager.get_session("Alex").unwrap().current_server, "lobby");
|
||||
|
||||
manager.update_server("alex", "survival");
|
||||
assert_eq!(
|
||||
manager.get_session("Alex").unwrap().current_server,
|
||||
"survival"
|
||||
);
|
||||
}
|
||||
}
|
||||
265
src/status.rs
Normal file
265
src/status.rs
Normal file
@ -0,0 +1,265 @@
|
||||
pub mod icon;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use pumpkin_protocol::java::client::status::{CPingResponse, CStatusResponse};
|
||||
use pumpkin_util::version::JavaMinecraftVersion;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::{BackendConfig, MotdConfig};
|
||||
pub use icon::{StatusIcon, StatusIconManager};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusJson<'a> {
|
||||
version: StatusVersion<'a>,
|
||||
players: StatusPlayers<'a>,
|
||||
description: StatusDescription,
|
||||
#[serde(rename = "enforceSecureChat")]
|
||||
enforce_secure_chat: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
favicon: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusVersion<'a> {
|
||||
name: &'a str,
|
||||
protocol: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusPlayers<'a> {
|
||||
max: u32,
|
||||
online: usize,
|
||||
sample: Vec<PlayerSample<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PlayerSample<'a> {
|
||||
name: &'a str,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusDescription {
|
||||
text: String,
|
||||
}
|
||||
|
||||
pub struct StatusHandler {
|
||||
motd: MotdConfig,
|
||||
online_players: Arc<AtomicUsize>,
|
||||
icon_manager: Arc<StatusIconManager>,
|
||||
servers: HashMap<String, BackendConfig>,
|
||||
forced_hosts: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl StatusHandler {
|
||||
pub fn new(
|
||||
motd: MotdConfig,
|
||||
online_players: Arc<AtomicUsize>,
|
||||
icon_manager: Arc<StatusIconManager>,
|
||||
servers: HashMap<String, BackendConfig>,
|
||||
forced_hosts: HashMap<String, String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
motd,
|
||||
online_players,
|
||||
icon_manager,
|
||||
servers,
|
||||
forced_hosts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the JSON status response for the given client version and optional virtual host
|
||||
pub fn build_status_response(
|
||||
&self,
|
||||
client_version: JavaMinecraftVersion,
|
||||
virtual_host: Option<&str>,
|
||||
) -> String {
|
||||
let online = self.online_players.load(Ordering::Relaxed);
|
||||
|
||||
let motd_text = self.resolve_motd_text(virtual_host);
|
||||
|
||||
let icon_uri = self.icon_manager.get_icon_for_host(virtual_host);
|
||||
|
||||
let protocol = if client_version == JavaMinecraftVersion::Unknown {
|
||||
self.motd.protocol_version
|
||||
} else {
|
||||
client_version.protocol_version()
|
||||
};
|
||||
|
||||
let sample: Vec<PlayerSample> = if self.motd.show_sample_players {
|
||||
self.motd
|
||||
.sample_players
|
||||
.iter()
|
||||
.map(|name| PlayerSample {
|
||||
name,
|
||||
id: Uuid::nil().to_string(),
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let status = StatusJson {
|
||||
version: StatusVersion {
|
||||
name: &self.motd.version_name,
|
||||
protocol,
|
||||
},
|
||||
players: StatusPlayers {
|
||||
max: self.motd.max_players,
|
||||
online,
|
||||
sample,
|
||||
},
|
||||
description: StatusDescription { text: motd_text },
|
||||
enforce_secure_chat: false,
|
||||
favicon: icon_uri.as_deref(),
|
||||
};
|
||||
|
||||
serde_json::to_string(&status).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
/// Resolves MOTD description for the virtual host or falls back to global default
|
||||
fn resolve_motd_text(&self, host: Option<&str>) -> String {
|
||||
if let Some(h) = host {
|
||||
let clean_host = h.split(':').next().unwrap_or(h).trim();
|
||||
|
||||
let target_server: Option<&str> = self
|
||||
.forced_hosts
|
||||
.get(clean_host)
|
||||
.map(|s| s.as_str())
|
||||
.or_else(|| {
|
||||
if self.servers.contains_key(clean_host) {
|
||||
Some(clean_host)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(server_name) = target_server
|
||||
&& let Some(cfg) = self.servers.get(server_name)
|
||||
&& (cfg.motd_line1.is_some() || cfg.motd_line2.is_some())
|
||||
{
|
||||
let l1 = cfg.motd_line1.as_deref().unwrap_or(&self.motd.line1);
|
||||
let l2 = cfg.motd_line2.as_deref().unwrap_or(&self.motd.line2);
|
||||
return format!("{}\n{}", l1, l2);
|
||||
}
|
||||
}
|
||||
|
||||
format!("{}\n{}", self.motd.line1, self.motd.line2)
|
||||
}
|
||||
|
||||
/// Create CStatusResponse packet
|
||||
pub fn create_status_packet(
|
||||
&self,
|
||||
client_version: JavaMinecraftVersion,
|
||||
virtual_host: Option<&str>,
|
||||
) -> CStatusResponse {
|
||||
let json = self.build_status_response(client_version, virtual_host);
|
||||
CStatusResponse::new(json)
|
||||
}
|
||||
|
||||
/// Create CPingResponse packet
|
||||
pub fn create_pong_packet(payload: i64) -> CPingResponse {
|
||||
CPingResponse::new(payload)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ForwardingMode;
|
||||
|
||||
#[test]
|
||||
fn test_status_response_with_icon_and_virtual_hosts() {
|
||||
let temp_icon_path = std::env::temp_dir().join("vine_test_status_icon.png");
|
||||
let mut png_bytes = Vec::new();
|
||||
png_bytes.extend_from_slice(&crate::status::icon::PNG_SIGNATURE);
|
||||
png_bytes.extend_from_slice(&13u32.to_be_bytes());
|
||||
png_bytes.extend_from_slice(b"IHDR");
|
||||
png_bytes.extend_from_slice(&64u32.to_be_bytes());
|
||||
png_bytes.extend_from_slice(&64u32.to_be_bytes());
|
||||
png_bytes.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
png_bytes.extend_from_slice(&[0, 0, 0, 0]);
|
||||
let _ = std::fs::write(&temp_icon_path, png_bytes);
|
||||
|
||||
let icon_path_str = temp_icon_path.to_string_lossy().to_string();
|
||||
|
||||
let mut servers = HashMap::new();
|
||||
servers.insert(
|
||||
"survival".to_string(),
|
||||
BackendConfig {
|
||||
address: "127.0.0.1:25567".to_string(),
|
||||
forwarding: ForwardingMode::None,
|
||||
secret: None,
|
||||
icon_path: None,
|
||||
motd_line1: Some("Survival Line 1".to_string()),
|
||||
motd_line2: Some("Survival Line 2".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
let mut forced_hosts = HashMap::new();
|
||||
forced_hosts.insert("mc.survival.com".to_string(), "survival".to_string());
|
||||
|
||||
let icon_mgr = Arc::new(StatusIconManager::new(
|
||||
Some(icon_path_str.clone()),
|
||||
HashMap::new(),
|
||||
forced_hosts.clone(),
|
||||
"lobby".to_string(),
|
||||
));
|
||||
|
||||
let motd = MotdConfig {
|
||||
line1: "Global Line 1".to_string(),
|
||||
line2: "Global Line 2".to_string(),
|
||||
max_players: 50,
|
||||
version_name: "Vine Proxy".to_string(),
|
||||
protocol_version: 776,
|
||||
sample_players: vec!["SamplePlayer".to_string()],
|
||||
icon_path: Some(icon_path_str),
|
||||
show_sample_players: true,
|
||||
};
|
||||
|
||||
let online = Arc::new(AtomicUsize::new(5));
|
||||
let handler = StatusHandler::new(motd, online, icon_mgr, servers, forced_hosts);
|
||||
|
||||
let global_json = handler.build_status_response(JavaMinecraftVersion::V_26_2, None);
|
||||
assert!(global_json.contains("Global Line 1\\nGlobal Line 2"));
|
||||
assert!(global_json.contains("\"online\":5"));
|
||||
assert!(global_json.contains("\"favicon\":\"data:image/png;base64,"));
|
||||
|
||||
let vhost_json =
|
||||
handler.build_status_response(JavaMinecraftVersion::V_26_2, Some("mc.survival.com"));
|
||||
assert!(vhost_json.contains("Survival Line 1\\nSurvival Line 2"));
|
||||
|
||||
let _ = std::fs::remove_file(&temp_icon_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_response_default_without_icon() {
|
||||
let icon_mgr = Arc::new(StatusIconManager::new(
|
||||
None,
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
"lobby".to_string(),
|
||||
));
|
||||
|
||||
let motd = MotdConfig {
|
||||
line1: "Vine Proxy".to_string(),
|
||||
line2: "No Icon".to_string(),
|
||||
max_players: 20,
|
||||
version_name: "Vine".to_string(),
|
||||
protocol_version: 776,
|
||||
sample_players: Vec::new(),
|
||||
icon_path: None,
|
||||
show_sample_players: false,
|
||||
};
|
||||
|
||||
let online = Arc::new(AtomicUsize::new(0));
|
||||
let handler = StatusHandler::new(motd, online, icon_mgr, HashMap::new(), HashMap::new());
|
||||
|
||||
let json = handler.build_status_response(JavaMinecraftVersion::V_26_2, None);
|
||||
assert!(!json.contains("favicon"));
|
||||
}
|
||||
}
|
||||
265
src/status/icon.rs
Normal file
265
src/status/icon.rs
Normal file
@ -0,0 +1,265 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use base64::prelude::*;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
pub const PNG_SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StatusIcon {
|
||||
pub path: PathBuf,
|
||||
pub data_uri: Arc<str>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub last_modified: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl StatusIcon {
|
||||
/// Loads, validates, and encodes a PNG file into a Minecraft status favicon data URI
|
||||
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, String> {
|
||||
let path = path.as_ref();
|
||||
let bytes = fs::read(path)
|
||||
.map_err(|e| format!("Failed to read icon file '{}': {}", path.display(), e))?;
|
||||
Self::from_bytes(bytes, path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Validates PNG bytes and builds a StatusIcon
|
||||
pub fn from_bytes(bytes: Vec<u8>, path: PathBuf) -> Result<Self, String> {
|
||||
if bytes.len() < 24 {
|
||||
return Err(format!(
|
||||
"Icon file '{}' is too small ({} bytes)",
|
||||
path.display(),
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
|
||||
if bytes[..8] != PNG_SIGNATURE {
|
||||
return Err(format!(
|
||||
"Icon file '{}' does not have a valid PNG signature (expected PNG format)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let chunk_type = &bytes[12..16];
|
||||
if chunk_type != b"IHDR" {
|
||||
return Err(format!(
|
||||
"Icon file '{}' is missing valid IHDR chunk",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
|
||||
let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
|
||||
if width != 64 || height != 64 {
|
||||
warn!(
|
||||
"Status icon '{}' dimensions are {}x{} pixels. Minecraft requires exactly 64x64 PNG images for server list icons.",
|
||||
path.display(),
|
||||
width,
|
||||
height
|
||||
);
|
||||
}
|
||||
|
||||
let b64 = BASE64_STANDARD.encode(&bytes);
|
||||
let data_uri: Arc<str> = format!("data:image/png;base64,{}", b64).into();
|
||||
|
||||
let last_modified = fs::metadata(&path).and_then(|m| m.modified()).ok();
|
||||
|
||||
info!(
|
||||
"Loaded status icon from '{}' ({}x{} pixels, {} bytes base64)",
|
||||
path.display(),
|
||||
width,
|
||||
height,
|
||||
data_uri.len()
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
data_uri,
|
||||
width,
|
||||
height,
|
||||
last_modified,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages status images for the proxy, supporting global icons, per-server icons,
|
||||
/// and automatic hot-reloading when image files on disk change.
|
||||
pub struct StatusIconManager {
|
||||
default_icon_path: Option<PathBuf>,
|
||||
server_icons: HashMap<String, PathBuf>,
|
||||
forced_hosts: HashMap<String, String>,
|
||||
default_server: String,
|
||||
cache: RwLock<HashMap<PathBuf, StatusIcon>>,
|
||||
}
|
||||
|
||||
impl StatusIconManager {
|
||||
pub fn new(
|
||||
configured_default_path: Option<String>,
|
||||
server_icons: HashMap<String, String>,
|
||||
forced_hosts: HashMap<String, String>,
|
||||
default_server: String,
|
||||
) -> Self {
|
||||
let default_icon_path = match configured_default_path {
|
||||
Some(p) if !p.trim().is_empty() => Some(PathBuf::from(p)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let server_icon_paths: HashMap<String, PathBuf> = server_icons
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, PathBuf::from(v)))
|
||||
.collect();
|
||||
|
||||
let manager = Self {
|
||||
default_icon_path,
|
||||
server_icons: server_icon_paths,
|
||||
forced_hosts,
|
||||
default_server,
|
||||
cache: RwLock::new(HashMap::new()),
|
||||
};
|
||||
|
||||
manager.preload_all();
|
||||
manager
|
||||
}
|
||||
|
||||
fn preload_all(&self) {
|
||||
if let Some(path) = &self.default_icon_path {
|
||||
self.load_or_reload(path);
|
||||
}
|
||||
for path in self.server_icons.values() {
|
||||
self.load_or_reload(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up and returns the cached base64 data URI for the given virtual host or default
|
||||
pub fn get_icon_for_host(&self, host: Option<&str>) -> Option<Arc<str>> {
|
||||
let target_path = self.resolve_icon_path(host)?;
|
||||
self.load_or_reload(&target_path)
|
||||
}
|
||||
|
||||
/// Resolves which icon path applies based on the virtual host or default
|
||||
fn resolve_icon_path(&self, host: Option<&str>) -> Option<PathBuf> {
|
||||
if let Some(h) = host {
|
||||
let clean_host = h.split(':').next().unwrap_or(h).trim();
|
||||
|
||||
if let Some(target_server) = self.forced_hosts.get(clean_host)
|
||||
&& let Some(path) = self.server_icons.get(target_server)
|
||||
{
|
||||
return Some(path.clone());
|
||||
}
|
||||
|
||||
if let Some(path) = self.server_icons.get(clean_host) {
|
||||
return Some(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = self.server_icons.get(&self.default_server) {
|
||||
return Some(path.clone());
|
||||
}
|
||||
|
||||
self.default_icon_path.clone()
|
||||
}
|
||||
|
||||
/// Retrieves cached icon or reloads from disk if file was modified
|
||||
fn load_or_reload(&self, path: &Path) -> Option<Arc<str>> {
|
||||
if let Ok(cache) = self.cache.read()
|
||||
&& let Some(cached) = cache.get(path)
|
||||
{
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
if let Ok(mtime) = meta.modified()
|
||||
&& cached.last_modified == Some(mtime)
|
||||
{
|
||||
return Some(cached.data_uri.clone());
|
||||
}
|
||||
} else {
|
||||
return Some(cached.data_uri.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match StatusIcon::load_from_file(path) {
|
||||
Ok(icon) => {
|
||||
let uri = icon.data_uri.clone();
|
||||
if let Ok(mut cache) = self.cache.write() {
|
||||
cache.insert(path.to_path_buf(), icon);
|
||||
}
|
||||
Some(uri)
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Could not load status icon '{}': {}", path.display(), e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(&PNG_SIGNATURE);
|
||||
bytes.extend_from_slice(&13u32.to_be_bytes());
|
||||
bytes.extend_from_slice(b"IHDR");
|
||||
bytes.extend_from_slice(&w.to_be_bytes());
|
||||
bytes.extend_from_slice(&h.to_be_bytes());
|
||||
bytes.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
bytes.extend_from_slice(&[0, 0, 0, 0]);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_png_parsing() {
|
||||
let png = create_test_png(64, 64);
|
||||
let icon = StatusIcon::from_bytes(png, PathBuf::from("test.png")).unwrap();
|
||||
assert_eq!(icon.width, 64);
|
||||
assert_eq!(icon.height, 64);
|
||||
assert!(icon.data_uri.starts_with("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_signature() {
|
||||
let bad_bytes = vec![0u8; 32];
|
||||
let res = StatusIcon::from_bytes(bad_bytes, PathBuf::from("test.png"));
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_icon_manager_resolution() {
|
||||
let mut server_icons = HashMap::new();
|
||||
server_icons.insert("survival".to_string(), "survival.png".to_string());
|
||||
let mut forced_hosts = HashMap::new();
|
||||
forced_hosts.insert("mc.survival.com".to_string(), "survival".to_string());
|
||||
|
||||
let manager = StatusIconManager::new(
|
||||
Some("default.png".to_string()),
|
||||
server_icons,
|
||||
forced_hosts,
|
||||
"lobby".to_string(),
|
||||
);
|
||||
|
||||
let path = manager.resolve_icon_path(Some("mc.survival.com")).unwrap();
|
||||
assert_eq!(path, PathBuf::from("survival.png"));
|
||||
|
||||
let def_path = manager.resolve_icon_path(Some("other.com")).unwrap();
|
||||
assert_eq!(def_path, PathBuf::from("default.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_icon_manager_default_none() {
|
||||
let manager =
|
||||
StatusIconManager::new(None, HashMap::new(), HashMap::new(), "lobby".to_string());
|
||||
|
||||
assert!(manager.resolve_icon_path(None).is_none());
|
||||
assert!(
|
||||
manager
|
||||
.resolve_icon_path(Some("play.example.com"))
|
||||
.is_none()
|
||||
);
|
||||
assert!(manager.get_icon_for_host(None).is_none());
|
||||
}
|
||||
}
|
||||
862
src/telemetry.rs
Normal file
862
src/telemetry.rs
Normal file
@ -0,0 +1,862 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use pumpkin_util::version::JavaMinecraftVersion;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Filename used to persist the telemetry identity key across restarts.
|
||||
pub const IDENTITY_KEY_PATH: &str = ".pumpkin/identity.key";
|
||||
|
||||
/// Fallback filename used to persist the telemetry identity key across restarts.
|
||||
pub const FALLBACK_IDENTITY_KEY_PATH: &str = "telemetry_key.bin";
|
||||
|
||||
/// Secondary fallback filename for proxy identity key.
|
||||
pub const VINE_IDENTITY_KEY_PATH: &str = ".vine/identity.key";
|
||||
|
||||
/// HTTP header name for the Ed25519 public key in lowercase hex format.
|
||||
pub const HEADER_PUBLIC_KEY: &str = "X-Telemetry-Public-Key";
|
||||
|
||||
/// HTTP header name for the Ed25519 signature in lowercase hex format.
|
||||
pub const HEADER_SIGNATURE: &str = "X-Telemetry-Signature";
|
||||
|
||||
/// HTTP header name for the request timestamp in Unix seconds.
|
||||
pub const HEADER_TIMESTAMP: &str = "X-Telemetry-Timestamp";
|
||||
|
||||
/// Maximum allowable clock drift between telemetry client and server (300 seconds / 5 minutes).
|
||||
pub const MAX_CLOCK_DRIFT_SECS: u64 = 300;
|
||||
|
||||
/// Information about an active plugin on the proxy / server.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PluginInfo {
|
||||
/// The name of the plugin.
|
||||
pub name: String,
|
||||
/// The version of the plugin.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Type alias for plugin telemetry information.
|
||||
pub type PluginTelemetryInfo = PluginInfo;
|
||||
|
||||
/// The telemetry heartbeat payload transmitted to the backend.
|
||||
/// Matches the Pumpkin Market API schema: https://market.pumpkinmc.org/api/docs/#/
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HeartbeatPayload {
|
||||
/// The server software implementation type ("vine").
|
||||
pub server_type: String,
|
||||
/// The Vine proxy software version.
|
||||
pub server_version: String,
|
||||
/// The Minecraft game version string.
|
||||
pub minecraft_version: String,
|
||||
/// The network protocol version number.
|
||||
pub protocol_version: i32,
|
||||
/// Current number of connected players.
|
||||
pub online_players: usize,
|
||||
/// Maximum number of players allowed.
|
||||
pub max_players: u32,
|
||||
/// Host operating system name.
|
||||
pub os: String,
|
||||
/// Host CPU architecture.
|
||||
pub arch: String,
|
||||
/// Number of available CPU cores.
|
||||
pub cpu_cores: usize,
|
||||
/// CPU model name or brand.
|
||||
pub cpu_model: String,
|
||||
/// RAM allocated/used by the system in MiB.
|
||||
pub ram_allocated_mb: u64,
|
||||
/// Total physical RAM available to the system in MiB.
|
||||
pub total_ram_mb: u64,
|
||||
/// List of active plugins.
|
||||
pub plugins: Vec<PluginInfo>,
|
||||
/// Whether the server has opted into public directory listing.
|
||||
pub is_public: bool,
|
||||
/// Public name of the server if opted into public directory listing.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_name: Option<String>,
|
||||
}
|
||||
|
||||
/// The response payload received from the telemetry backend.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HeartbeatResponse {
|
||||
/// Response status ("ok", "throttled", etc.).
|
||||
pub status: String,
|
||||
/// Status or error message from the backend.
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
/// Confirmed server public key acknowledged by the backend.
|
||||
#[serde(default)]
|
||||
pub server_public_key: String,
|
||||
/// Recommended seconds to wait before the next heartbeat.
|
||||
#[serde(default)]
|
||||
pub next_ping_seconds: u32,
|
||||
}
|
||||
|
||||
/// Computes the signed data byte layout: `[timestamp_ascii_bytes] + [b'.'] + [raw_json_body_bytes]`.
|
||||
#[must_use]
|
||||
pub fn compute_signed_data(timestamp_str: &str, body_bytes: &[u8]) -> Vec<u8> {
|
||||
let mut signed_data = Vec::with_capacity(timestamp_str.len() + 1 + body_bytes.len());
|
||||
signed_data.extend_from_slice(timestamp_str.as_bytes());
|
||||
signed_data.push(b'.');
|
||||
signed_data.extend_from_slice(body_bytes);
|
||||
signed_data
|
||||
}
|
||||
|
||||
/// Signs telemetry message data using an Ed25519 signing key and returns `(public_key_hex, signature_hex)`.
|
||||
#[must_use]
|
||||
pub fn sign_telemetry_payload(
|
||||
signing_key: &SigningKey,
|
||||
timestamp_str: &str,
|
||||
body_bytes: &[u8],
|
||||
) -> (String, String) {
|
||||
let signed_data = compute_signed_data(timestamp_str, body_bytes);
|
||||
let signature = signing_key.sign(&signed_data);
|
||||
let pubkey_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
||||
let sig_hex = hex::encode(signature.to_bytes());
|
||||
(pubkey_hex, sig_hex)
|
||||
}
|
||||
|
||||
/// Errors that can occur during telemetry request signature verification.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum TelemetryVerificationError {
|
||||
#[error("Invalid timestamp string format")]
|
||||
InvalidTimestamp,
|
||||
#[error(
|
||||
"Timestamp drift exceeded: drift was {drift}s (max allowed is {MAX_CLOCK_DRIFT_SECS}s)"
|
||||
)]
|
||||
ClockDriftExceeded { drift: u64 },
|
||||
#[error("Invalid public key hex encoding")]
|
||||
InvalidPublicKeyHex,
|
||||
#[error("Invalid public key bytes: {0}")]
|
||||
InvalidPublicKey(String),
|
||||
#[error("Invalid signature hex encoding")]
|
||||
InvalidSignatureHex,
|
||||
#[error("Invalid signature bytes: {0}")]
|
||||
InvalidSignature(String),
|
||||
#[error("Signature verification failed: {0}")]
|
||||
VerificationFailed(String),
|
||||
}
|
||||
|
||||
/// Verifies a signed telemetry request against an Ed25519 public key and timestamp.
|
||||
///
|
||||
/// Ensures clock drift between `current_time_secs` and `timestamp_str` does not exceed `±300` seconds.
|
||||
pub fn verify_telemetry_request(
|
||||
pubkey_hex: &str,
|
||||
sig_hex: &str,
|
||||
timestamp_str: &str,
|
||||
body_bytes: &[u8],
|
||||
current_time_secs: u64,
|
||||
) -> Result<(), TelemetryVerificationError> {
|
||||
let ts: u64 = timestamp_str
|
||||
.parse()
|
||||
.map_err(|_| TelemetryVerificationError::InvalidTimestamp)?;
|
||||
|
||||
let drift = current_time_secs.abs_diff(ts);
|
||||
if drift > MAX_CLOCK_DRIFT_SECS {
|
||||
return Err(TelemetryVerificationError::ClockDriftExceeded { drift });
|
||||
}
|
||||
|
||||
let pubkey_bytes =
|
||||
hex::decode(pubkey_hex).map_err(|_| TelemetryVerificationError::InvalidPublicKeyHex)?;
|
||||
let vk = VerifyingKey::try_from(pubkey_bytes.as_slice())
|
||||
.map_err(|e| TelemetryVerificationError::InvalidPublicKey(e.to_string()))?;
|
||||
|
||||
let sig_bytes =
|
||||
hex::decode(sig_hex).map_err(|_| TelemetryVerificationError::InvalidSignatureHex)?;
|
||||
let sig = Signature::from_slice(&sig_bytes)
|
||||
.map_err(|e| TelemetryVerificationError::InvalidSignature(e.to_string()))?;
|
||||
|
||||
let signed_data = compute_signed_data(timestamp_str, body_bytes);
|
||||
vk.verify(&signed_data, &sig)
|
||||
.map_err(|e| TelemetryVerificationError::VerificationFailed(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Client for communicating with the Pumpkin Marketplace telemetry backend.
|
||||
pub struct TelemetryClient {
|
||||
pub signing_key: SigningKey,
|
||||
pub http_client: reqwest::Client,
|
||||
pub endpoint: String,
|
||||
pub api_base_url: String,
|
||||
}
|
||||
|
||||
impl TelemetryClient {
|
||||
/// Creates a new `TelemetryClient` instance.
|
||||
#[must_use]
|
||||
pub fn new(signing_key: SigningKey, http_client: reqwest::Client, endpoint: String) -> Self {
|
||||
let api_base_url = endpoint
|
||||
.strip_suffix("/api/v1/rest/telemetry/heartbeat")
|
||||
.unwrap_or_else(|| {
|
||||
endpoint
|
||||
.strip_suffix("/heartbeat")
|
||||
.unwrap_or_else(|| endpoint.trim_end_matches('/'))
|
||||
})
|
||||
.to_string();
|
||||
|
||||
Self {
|
||||
signing_key,
|
||||
http_client,
|
||||
endpoint,
|
||||
api_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the 64-character lowercase hexadecimal representation of the client's Ed25519 public key.
|
||||
#[must_use]
|
||||
pub fn public_key_hex(&self) -> String {
|
||||
hex::encode(self.signing_key.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
/// Loads an existing Ed25519 private key/seed from the given path or generates and persists a new one.
|
||||
pub fn load_or_generate_key(key_path: &Path) -> Result<SigningKey, std::io::Error> {
|
||||
if key_path.exists() {
|
||||
let bytes = std::fs::read(key_path)?;
|
||||
if bytes.len() == 32 {
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&bytes);
|
||||
return Ok(SigningKey::from_bytes(&seed));
|
||||
}
|
||||
tracing::warn!(
|
||||
"Failed to read valid 32-byte identity key from {}, generating a new one",
|
||||
key_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
let seed: [u8; 32] = rand::random();
|
||||
let key = SigningKey::from_bytes(&seed);
|
||||
|
||||
if let Some(parent) = key_path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(key_path, seed)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Sends a signed heartbeat request to the telemetry backend.
|
||||
pub async fn send_heartbeat(
|
||||
&self,
|
||||
payload: &HeartbeatPayload,
|
||||
) -> Result<HeartbeatResponse, reqwest::Error> {
|
||||
let body_bytes = serde_json::to_vec(payload).expect("Serialization failed");
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let timestamp_str = timestamp.to_string();
|
||||
|
||||
let (pubkey_hex, sig_hex) =
|
||||
sign_telemetry_payload(&self.signing_key, ×tamp_str, &body_bytes);
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&self.endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.header(HEADER_PUBLIC_KEY, pubkey_hex)
|
||||
.header(HEADER_SIGNATURE, sig_hex)
|
||||
.header(HEADER_TIMESTAMP, timestamp_str)
|
||||
.body(body_bytes)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<HeartbeatResponse>()
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Sends a signed server shutdown notice to the telemetry backend.
|
||||
pub async fn send_shutdown(&self, reason: Option<&str>) -> Result<(), reqwest::Error> {
|
||||
let payload = serde_json::json!({
|
||||
"reason": reason.unwrap_or("server shutdown")
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&payload).expect("Serialization failed");
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let timestamp_str = timestamp.to_string();
|
||||
|
||||
let (pubkey_hex, sig_hex) =
|
||||
sign_telemetry_payload(&self.signing_key, ×tamp_str, &body_bytes);
|
||||
|
||||
self.http_client
|
||||
.post(format!(
|
||||
"{}/api/v1/rest/telemetry/shutdown",
|
||||
self.api_base_url
|
||||
))
|
||||
.header("Content-Type", "application/json")
|
||||
.header(HEADER_PUBLIC_KEY, pubkey_hex)
|
||||
.header(HEADER_SIGNATURE, sig_hex)
|
||||
.header(HEADER_TIMESTAMP, timestamp_str)
|
||||
.body(body_bytes)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the server identity key by checking default locations or generating a new one.
|
||||
#[must_use]
|
||||
pub fn resolve_identity_key() -> SigningKey {
|
||||
let primary = Path::new(IDENTITY_KEY_PATH);
|
||||
if primary.exists()
|
||||
&& let Ok(key) = TelemetryClient::load_or_generate_key(primary)
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
let vine_key = Path::new(VINE_IDENTITY_KEY_PATH);
|
||||
if vine_key.exists()
|
||||
&& let Ok(key) = TelemetryClient::load_or_generate_key(vine_key)
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
let fallback = Path::new(FALLBACK_IDENTITY_KEY_PATH);
|
||||
if fallback.exists()
|
||||
&& let Ok(key) = TelemetryClient::load_or_generate_key(fallback)
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
let data_primary = Path::new("data").join(IDENTITY_KEY_PATH);
|
||||
if data_primary.exists()
|
||||
&& let Ok(key) = TelemetryClient::load_or_generate_key(&data_primary)
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
let data_vine = Path::new("data").join(VINE_IDENTITY_KEY_PATH);
|
||||
if data_vine.exists()
|
||||
&& let Ok(key) = TelemetryClient::load_or_generate_key(&data_vine)
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
let data_fallback = Path::new("data").join(FALLBACK_IDENTITY_KEY_PATH);
|
||||
if data_fallback.exists()
|
||||
&& let Ok(key) = TelemetryClient::load_or_generate_key(&data_fallback)
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
if Path::new("data").is_dir() {
|
||||
let path = Path::new("data").join(IDENTITY_KEY_PATH);
|
||||
if let Ok(key) = TelemetryClient::load_or_generate_key(&path) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
TelemetryClient::load_or_generate_key(primary).unwrap_or_else(|err| {
|
||||
tracing::warn!(
|
||||
"Failed to persist telemetry identity key at {}: {err}, using ephemeral key",
|
||||
primary.display()
|
||||
);
|
||||
let seed: [u8; 32] = rand::random();
|
||||
SigningKey::from_bytes(&seed)
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper function to load or create an identity key at a specified path.
|
||||
#[must_use]
|
||||
pub fn get_or_create_identity_key(path: &Path) -> SigningKey {
|
||||
TelemetryClient::load_or_generate_key(path).unwrap_or_else(|err| {
|
||||
tracing::warn!(
|
||||
"Failed to save identity key to {}: {err}, using ephemeral key",
|
||||
path.display()
|
||||
);
|
||||
let seed: [u8; 32] = rand::random();
|
||||
SigningKey::from_bytes(&seed)
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the telemetry heartbeat payload from current proxy runtime state.
|
||||
#[must_use]
|
||||
pub fn build_heartbeat_payload(config: &Config, online_players: usize) -> HeartbeatPayload {
|
||||
let os = sysinfo::System::long_os_version().unwrap_or_else(|| {
|
||||
let name = sysinfo::System::name().unwrap_or_else(|| std::env::consts::OS.to_string());
|
||||
sysinfo::System::os_version().map_or(name.clone(), |ver| format!("{name} {ver}"))
|
||||
});
|
||||
let arch = std::env::consts::ARCH.to_string();
|
||||
let cpu_cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
|
||||
|
||||
let (ram_allocated_mb, total_ram_mb, cpu_model) = if sysinfo::IS_SUPPORTED_SYSTEM {
|
||||
let mut sys = sysinfo::System::new();
|
||||
sys.refresh_memory();
|
||||
sys.refresh_cpu_all();
|
||||
let ram_allocated = sys.used_memory() / (1024 * 1024);
|
||||
let total_ram = sys.total_memory() / (1024 * 1024);
|
||||
let cpu_model = sys.cpus().first().map_or_else(
|
||||
|| "Unknown".to_string(),
|
||||
|c| {
|
||||
let brand = c.brand().trim();
|
||||
if brand.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
brand.to_string()
|
||||
}
|
||||
},
|
||||
);
|
||||
(ram_allocated, total_ram, cpu_model)
|
||||
} else {
|
||||
(0, 0, "Unknown".to_string())
|
||||
};
|
||||
|
||||
let mc_ver = JavaMinecraftVersion::from_protocol(config.motd.protocol_version as u32);
|
||||
let minecraft_version = if mc_ver == JavaMinecraftVersion::Unknown {
|
||||
"26.2".to_string()
|
||||
} else {
|
||||
mc_ver.to_string()
|
||||
};
|
||||
|
||||
let is_public = config.telemetry.public;
|
||||
let public_name = if is_public {
|
||||
config.telemetry.server_name.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
HeartbeatPayload {
|
||||
server_type: "vine".to_string(),
|
||||
server_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
minecraft_version,
|
||||
protocol_version: config.motd.protocol_version,
|
||||
online_players,
|
||||
max_players: config.motd.max_players,
|
||||
os,
|
||||
arch,
|
||||
cpu_cores,
|
||||
cpu_model,
|
||||
ram_allocated_mb,
|
||||
total_ram_mb,
|
||||
plugins: Vec::new(),
|
||||
is_public,
|
||||
public_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_telemetry(
|
||||
config: Arc<Config>,
|
||||
online_players: Arc<AtomicUsize>,
|
||||
mut shutdown_rx: broadcast::Receiver<()>,
|
||||
) -> Option<tokio::task::JoinHandle<()>> {
|
||||
if !config.telemetry.enabled {
|
||||
tracing::trace!("Telemetry is disabled in configuration.");
|
||||
return None;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Anonymous server telemetry is enabled. Sending periodic heartbeats to {}",
|
||||
config.telemetry.endpoint
|
||||
);
|
||||
|
||||
let user_agent = format!("Vine-Proxy/{}", env!("CARGO_PKG_VERSION"));
|
||||
let client = match pumpkin_util::client_builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent(&user_agent)
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
tracing::debug!("Failed to create telemetry HTTP client: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let signing_key = resolve_identity_key();
|
||||
let telemetry_client = Arc::new(TelemetryClient::new(
|
||||
signing_key,
|
||||
client,
|
||||
config.telemetry.endpoint.clone(),
|
||||
));
|
||||
|
||||
let interval_secs = config.telemetry.interval_secs.max(60);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let online = online_players.load(Ordering::Relaxed);
|
||||
let payload = build_heartbeat_payload(&config, online);
|
||||
|
||||
match telemetry_client.send_heartbeat(&payload).await {
|
||||
Ok(resp) => {
|
||||
tracing::trace!(
|
||||
"Telemetry heartbeat successfully transmitted (status: {}, public_key: {})",
|
||||
resp.status,
|
||||
resp.server_public_key
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!("Telemetry heartbeat failed to send: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
tracing::trace!("Telemetry task received shutdown signal.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger shutdown telemetry on server termination / SIGTERM
|
||||
tracing::info!("Sending server shutdown notice to telemetry backend...");
|
||||
match telemetry_client
|
||||
.send_shutdown(Some("server shutdown"))
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"Telemetry shutdown notification successfully acknowledged by backend."
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Telemetry shutdown message failed to send: {err}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_key_generation_and_persistence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key_path = dir.path().join(".pumpkin/identity.key");
|
||||
|
||||
assert!(!key_path.exists());
|
||||
let key1 = TelemetryClient::load_or_generate_key(&key_path).unwrap();
|
||||
assert!(key_path.exists());
|
||||
|
||||
// Check key file has 32 bytes
|
||||
let file_bytes = std::fs::read(&key_path).unwrap();
|
||||
assert_eq!(file_bytes.len(), 32);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::metadata(&key_path).unwrap().permissions();
|
||||
assert_eq!(perms.mode() & 0o777, 0o600);
|
||||
}
|
||||
|
||||
// Reload existing key
|
||||
let key2 = TelemetryClient::load_or_generate_key(&key_path).unwrap();
|
||||
assert_eq!(
|
||||
key1.verifying_key().to_bytes(),
|
||||
key2.verifying_key().to_bytes()
|
||||
);
|
||||
|
||||
// Corrupted file test (not 32 bytes)
|
||||
std::fs::write(&key_path, b"corrupted-key-data").unwrap();
|
||||
let key3 = TelemetryClient::load_or_generate_key(&key_path).unwrap();
|
||||
assert_ne!(
|
||||
key1.verifying_key().to_bytes(),
|
||||
key3.verifying_key().to_bytes()
|
||||
);
|
||||
let new_file_bytes = std::fs::read(&key_path).unwrap();
|
||||
assert_eq!(new_file_bytes.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_payload_serialization() {
|
||||
let payload = HeartbeatPayload {
|
||||
server_type: "vine".to_string(),
|
||||
server_version: "0.1.0".to_string(),
|
||||
minecraft_version: "26.2".to_string(),
|
||||
protocol_version: 776,
|
||||
online_players: 15,
|
||||
max_players: 100,
|
||||
os: "Linux 6.8.0".to_string(),
|
||||
arch: "x86_64".to_string(),
|
||||
cpu_cores: 8,
|
||||
cpu_model: "AMD Ryzen 7".to_string(),
|
||||
ram_allocated_mb: 2048,
|
||||
total_ram_mb: 16384,
|
||||
plugins: vec![],
|
||||
is_public: true,
|
||||
public_name: Some("My Vine Proxy".to_string()),
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string_pretty(&payload).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
||||
|
||||
// server_id must be completely removed
|
||||
assert!(parsed.get("server_id").is_none());
|
||||
|
||||
assert_eq!(parsed["server_type"], "vine");
|
||||
assert_eq!(parsed["server_version"], "0.1.0");
|
||||
assert_eq!(parsed["minecraft_version"], "26.2");
|
||||
assert_eq!(parsed["protocol_version"], 776);
|
||||
assert_eq!(parsed["online_players"], 15);
|
||||
assert_eq!(parsed["max_players"], 100);
|
||||
assert_eq!(parsed["os"], "Linux 6.8.0");
|
||||
assert_eq!(parsed["arch"], "x86_64");
|
||||
assert_eq!(parsed["cpu_cores"], 8);
|
||||
assert_eq!(parsed["cpu_model"], "AMD Ryzen 7");
|
||||
assert_eq!(parsed["ram_allocated_mb"], 2048);
|
||||
assert_eq!(parsed["total_ram_mb"], 16384);
|
||||
assert_eq!(parsed["is_public"], true);
|
||||
assert_eq!(parsed["public_name"], "My Vine Proxy");
|
||||
assert_eq!(parsed["plugins"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_response_deserialization() {
|
||||
let json_data = r#"{
|
||||
"status": "ok",
|
||||
"message": "Heartbeat accepted",
|
||||
"server_public_key": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
|
||||
"next_ping_seconds": 300
|
||||
}"#;
|
||||
|
||||
let resp: HeartbeatResponse = serde_json::from_str(json_data).unwrap();
|
||||
assert_eq!(resp.status, "ok");
|
||||
assert_eq!(resp.message, "Heartbeat accepted");
|
||||
assert_eq!(
|
||||
resp.server_public_key,
|
||||
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
|
||||
);
|
||||
assert_eq!(resp.next_ping_seconds, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_signing_and_verification() {
|
||||
let seed = [42u8; 32];
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let timestamp_str = "1757185000";
|
||||
let body = br#"{"server_type":"vine","online_players":5}"#;
|
||||
|
||||
// Verify signed data format: "{timestamp}.{body}"
|
||||
let signed_data = compute_signed_data(timestamp_str, body);
|
||||
let expected_prefix = format!("{timestamp_str}.");
|
||||
assert!(signed_data.starts_with(expected_prefix.as_bytes()));
|
||||
assert_eq!(&signed_data[expected_prefix.len()..], body);
|
||||
|
||||
// Sign payload
|
||||
let (pubkey_hex, sig_hex) = sign_telemetry_payload(&signing_key, timestamp_str, body);
|
||||
assert_eq!(pubkey_hex.len(), 64);
|
||||
assert_eq!(sig_hex.len(), 128);
|
||||
|
||||
// All hex chars must be lowercase
|
||||
assert_eq!(pubkey_hex, pubkey_hex.to_lowercase());
|
||||
assert_eq!(sig_hex, sig_hex.to_lowercase());
|
||||
|
||||
// Verify with matching timestamp
|
||||
let current_time: u64 = 1757185100; // 100s drift (within 300s)
|
||||
let verify_result =
|
||||
verify_telemetry_request(&pubkey_hex, &sig_hex, timestamp_str, body, current_time);
|
||||
assert!(verify_result.is_ok());
|
||||
|
||||
// Drift exceeded (> 300s)
|
||||
let expired_time: u64 = 1757185000 + 301;
|
||||
let expired_result =
|
||||
verify_telemetry_request(&pubkey_hex, &sig_hex, timestamp_str, body, expired_time);
|
||||
assert!(matches!(
|
||||
expired_result,
|
||||
Err(TelemetryVerificationError::ClockDriftExceeded { .. })
|
||||
));
|
||||
|
||||
// Corrupted signature
|
||||
let mut corrupted_sig = sig_hex.clone();
|
||||
let flipped_char = if corrupted_sig.starts_with('0') {
|
||||
'1'
|
||||
} else {
|
||||
'0'
|
||||
};
|
||||
corrupted_sig.replace_range(0..1, &flipped_char.to_string());
|
||||
let corrupted_result = verify_telemetry_request(
|
||||
&pubkey_hex,
|
||||
&corrupted_sig,
|
||||
timestamp_str,
|
||||
body,
|
||||
current_time,
|
||||
);
|
||||
assert!(matches!(
|
||||
corrupted_result,
|
||||
Err(TelemetryVerificationError::VerificationFailed(_))
|
||||
));
|
||||
|
||||
// Corrupted body
|
||||
let tampered_body = br#"{"server_type":"vine","online_players":6}"#;
|
||||
let tampered_result = verify_telemetry_request(
|
||||
&pubkey_hex,
|
||||
&sig_hex,
|
||||
timestamp_str,
|
||||
tampered_body,
|
||||
current_time,
|
||||
);
|
||||
assert!(matches!(
|
||||
tampered_result,
|
||||
Err(TelemetryVerificationError::VerificationFailed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telemetry_client_send_heartbeat_and_shutdown() {
|
||||
use axum::Router;
|
||||
use axum::extract::Request;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::post;
|
||||
|
||||
let seed = [7u8; 32];
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let expected_pubkey = hex::encode(signing_key.verifying_key().to_bytes());
|
||||
|
||||
// Set up mock axum server
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/rest/telemetry/heartbeat",
|
||||
post(move |req: Request| async move {
|
||||
let headers = req.headers().clone();
|
||||
let pubkey = headers.get(HEADER_PUBLIC_KEY).and_then(|v| v.to_str().ok());
|
||||
let signature = headers.get(HEADER_SIGNATURE).and_then(|v| v.to_str().ok());
|
||||
let timestamp = headers.get(HEADER_TIMESTAMP).and_then(|v| v.to_str().ok());
|
||||
|
||||
let (Some(pubkey), Some(sig), Some(ts)) = (pubkey, signature, timestamp) else {
|
||||
return (StatusCode::UNAUTHORIZED, "Missing required headers")
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let body = axum::body::to_bytes(req.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
if verify_telemetry_request(pubkey, sig, ts, &body, now).is_err() {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid signature").into_response();
|
||||
}
|
||||
|
||||
let response = HeartbeatResponse {
|
||||
status: "ok".to_string(),
|
||||
message: "Heartbeat accepted".to_string(),
|
||||
server_public_key: pubkey.to_string(),
|
||||
next_ping_seconds: 300,
|
||||
};
|
||||
let json_bytes = serde_json::to_vec(&response).unwrap();
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(CONTENT_TYPE, "application/json")],
|
||||
json_bytes,
|
||||
)
|
||||
.into_response()
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/rest/telemetry/shutdown",
|
||||
post(move |req: Request| async move {
|
||||
let headers = req.headers().clone();
|
||||
let pubkey = headers.get(HEADER_PUBLIC_KEY).and_then(|v| v.to_str().ok());
|
||||
let signature = headers.get(HEADER_SIGNATURE).and_then(|v| v.to_str().ok());
|
||||
let timestamp = headers.get(HEADER_TIMESTAMP).and_then(|v| v.to_str().ok());
|
||||
|
||||
let (Some(pubkey), Some(sig), Some(ts)) = (pubkey, signature, timestamp) else {
|
||||
return (StatusCode::UNAUTHORIZED, "Missing headers").into_response();
|
||||
};
|
||||
|
||||
let body = axum::body::to_bytes(req.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
if verify_telemetry_request(pubkey, sig, ts, &body, now).is_err() {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid signature").into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, "Shutdown received").into_response()
|
||||
}),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let endpoint = format!("http://127.0.0.1:{port}/api/v1/rest/telemetry/heartbeat");
|
||||
let http_client = pumpkin_util::client();
|
||||
let client = TelemetryClient::new(signing_key, http_client, endpoint);
|
||||
|
||||
let payload = HeartbeatPayload {
|
||||
server_type: "vine".to_string(),
|
||||
server_version: "0.1.0".to_string(),
|
||||
minecraft_version: "26.2".to_string(),
|
||||
protocol_version: 776,
|
||||
online_players: 1,
|
||||
max_players: 20,
|
||||
os: "Linux".to_string(),
|
||||
arch: "x86_64".to_string(),
|
||||
cpu_cores: 4,
|
||||
cpu_model: "Test CPU".to_string(),
|
||||
ram_allocated_mb: 1024,
|
||||
total_ram_mb: 2048,
|
||||
plugins: vec![],
|
||||
is_public: false,
|
||||
public_name: None,
|
||||
};
|
||||
|
||||
// Send valid heartbeat -> 200 OK
|
||||
let resp = client.send_heartbeat(&payload).await.unwrap();
|
||||
assert_eq!(resp.status, "ok");
|
||||
assert_eq!(resp.message, "Heartbeat accepted");
|
||||
assert_eq!(resp.server_public_key, expected_pubkey);
|
||||
assert_eq!(resp.next_ping_seconds, 300);
|
||||
|
||||
// Send valid shutdown -> 200 OK
|
||||
let shutdown_res = client.send_shutdown(Some("maintenance")).await;
|
||||
assert!(shutdown_res.is_ok());
|
||||
|
||||
// Test invalid signature / unauthorized
|
||||
let body_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
let timestamp_str = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
.to_string();
|
||||
let response = pumpkin_util::client()
|
||||
.post(format!(
|
||||
"http://127.0.0.1:{port}/api/v1/rest/telemetry/heartbeat"
|
||||
))
|
||||
.header("Content-Type", "application/json")
|
||||
.header(HEADER_PUBLIC_KEY, &expected_pubkey)
|
||||
.header(HEADER_SIGNATURE, "00".repeat(64)) // invalid signature
|
||||
.header(HEADER_TIMESTAMP, timestamp_str)
|
||||
.body(body_bytes)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user