Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de8c757d93 | ||
|
|
4c2fc37929 | ||
|
|
f9004778d3 | ||
|
|
ca71fafa4f | ||
|
|
72051aae0e | ||
|
|
a977b24b9b | ||
|
|
4061f2067c | ||
|
|
746774ac99 | ||
|
|
1655a47608 | ||
|
|
f2dc687d63 | ||
|
|
b632489467 | ||
|
|
468dcedbc2 | ||
|
|
5d1d03fe5a | ||
|
|
6c43744745 | ||
|
|
289abbf095 | ||
|
|
674bca825c | ||
|
|
2b756d8e0c | ||
|
|
43d627ab9d | ||
|
|
fa08d86427 | ||
|
|
52b642c02c | ||
|
|
20017a0971 | ||
|
|
adfd2e9d6f | ||
|
|
87e74d8afe | ||
|
|
7217f29909 | ||
|
|
6240030dd5 | ||
|
|
31a86ed234 | ||
|
|
7675c9ad8e | ||
|
|
536771d2bc | ||
|
|
02e4462520 | ||
|
|
ff78aaa6b4 | ||
|
|
9a1048936e | ||
|
|
7c6d396042 | ||
|
|
b316804dfb | ||
|
|
1ca2eb70fd | ||
|
|
b0bf1eeae0 | ||
|
|
f5c3e0954b | ||
|
|
53225fd8ae | ||
|
|
1c5ab95488 |
@@ -18,13 +18,13 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/ReverseLlama.Server
|
||||
run: dotnet restore src/Ngino.Server
|
||||
|
||||
- name: Build
|
||||
run: dotnet build src/ReverseLlama.Server --configuration Release --no-restore
|
||||
run: dotnet build src/Ngino.Server --configuration Release --no-restore
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish src/ReverseLlama.Server -c Release -o publish
|
||||
run: dotnet publish src/Ngino.Server -c Release -o publish
|
||||
|
||||
- name: Copy files to server
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
password: ${{ secrets.DEPLOY_SSH_PASSWORD }}
|
||||
port: ${{ secrets.DEPLOY_SSH_PORT }}
|
||||
source: "publish/*"
|
||||
target: "/var/www/ReverseLlama"
|
||||
target: "/var/www/Ngino"
|
||||
strip_components: 1
|
||||
|
||||
- name: Restart app
|
||||
@@ -45,4 +45,4 @@ jobs:
|
||||
password: ${{ secrets.DEPLOY_SSH_PASSWORD }}
|
||||
port: ${{ secrets.DEPLOY_SSH_PORT }}
|
||||
script: |
|
||||
sudo systemctl restart ReverseLlama.service
|
||||
sudo systemctl restart Ngino-server.service
|
||||
+3
-2
@@ -7,6 +7,7 @@
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
.vs/
|
||||
**appsettings.Development.json
|
||||
**/appsettings.*.json
|
||||
!**/appsettings.Example.json
|
||||
debug/**
|
||||
debug/**
|
||||
publish/**
|
||||
@@ -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>.
|
||||
@@ -5,15 +5,15 @@ VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseLlama.Protocol", "src\ReverseLlama.Protocol\ReverseLlama.Protocol.csproj", "{D85E1D3C-0AC3-4810-8285-16BE903EC8AB}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ngino.Protocol", "src\Ngino.Protocol\Ngino.Protocol.csproj", "{D85E1D3C-0AC3-4810-8285-16BE903EC8AB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseLlama.Server", "src\ReverseLlama.Server\ReverseLlama.Server.csproj", "{FB853CB0-5AEF-4278-82E7-3C8E506F9DFE}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ngino.Server", "src\Ngino.Server\Ngino.Server.csproj", "{FB853CB0-5AEF-4278-82E7-3C8E506F9DFE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseLlama.Client", "src\ReverseLlama.Client\ReverseLlama.Client.csproj", "{0633EAE6-B82A-4A27-851C-B5D6E11CBE03}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ngino.Client", "src\Ngino.Client\Ngino.Client.csproj", "{0633EAE6-B82A-4A27-851C-B5D6E11CBE03}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseLlama.Client.Tests", "tests\ReverseLlama.Client.Tests\ReverseLlama.Client.Tests.csproj", "{B03E7794-2888-4B56-A30F-B6C25BCFB89A}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ngino.Client.Tests", "tests\Ngino.Client.Tests\Ngino.Client.Tests.csproj", "{B03E7794-2888-4B56-A30F-B6C25BCFB89A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReverseLlama
|
||||
# Ngino
|
||||
|
||||
ReverseLlama is a small outbound HTTP tunnel for running Ollama (or vLLM, etc.) on GPU workstations while exposing the API from a server that cannot reach those workstations directly.
|
||||
Ngino is a small outbound HTTP tunnel for running Ollama (or vLLM, etc.) on GPU workstations while exposing the API from a server that cannot reach those workstations directly.
|
||||
|
||||
The client opens and maintains a WebSocket connection to the server. The server accepts normal HTTP requests and forwards them through that WebSocket to the client. The client then calls a local upstream such as `http://localhost:11434` and streams the response back.
|
||||
|
||||
@@ -13,7 +13,7 @@ The client opens and maintains a WebSocket connection to the server. The server
|
||||
|
||||
The server provides
|
||||
- An API with
|
||||
- Authentication via API keys
|
||||
- Authentication via user keys
|
||||
- Authorization (planned)
|
||||
- Load balancing (Scale your AI strategy horizontally!)
|
||||
- (Ollama-only) Model management (install, remove, load, unload models)
|
||||
@@ -27,7 +27,7 @@ The server provides
|
||||
- What clients are mapped to which groups
|
||||
- Billing (planned)
|
||||
- (planned) Price per model per thousand tokens
|
||||
- (planned) Usage per API user
|
||||
- (planned) Usage per user key
|
||||
- (planned) Rate limiting
|
||||
|
||||
The client provides a persistent outbound connection to the server and forwards requests to the local Ollama (or vLLM, etc.) instance. Responses stream back through the tunnel with minimal overhead.
|
||||
@@ -41,35 +41,35 @@ The client provides a persistent outbound connection to the server and forwards
|
||||
|
||||
## Projects
|
||||
|
||||
- `src/ReverseLlama.Server`: ASP.NET Core server. Exposes the public proxy endpoint and accepts the outbound client tunnel.
|
||||
- `src/ReverseLlama.Client`: Console client. Runs on the GPU machine and forwards requests to local Ollama.
|
||||
- `src/ReverseLlama.Protocol`: Shared tunnel message types.
|
||||
- `src/Ngino.Server`: ASP.NET Core server. Exposes the public proxy endpoint and accepts the outbound client tunnel.
|
||||
- `src/Ngino.Client`: Console client. Runs on the GPU machine and forwards requests to local Ollama.
|
||||
- `src/Ngino.Protocol`: Shared tunnel message types.
|
||||
|
||||
## Run
|
||||
|
||||
Start the server:
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ReverseLlama.Server --urls http://0.0.0.0:5050 -- --token "change-me"
|
||||
dotnet run --project src/Ngino.Server --urls http://0.0.0.0:5050 -- --token "change-me"
|
||||
```
|
||||
|
||||
Start the client on the GPU workstation:
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ReverseLlama.Client -- --server http://your-server:5050 --upstream http://localhost:11434 --token "change-me"
|
||||
dotnet run --project src/Ngino.Client -- --server http://your-server:5050 --upstream http://localhost:11434 --token "change-me"
|
||||
```
|
||||
|
||||
Call Ollama through the server. Model-bearing requests on the root path are routed to a connected client that reports that model, preferring the client with the fewest in-flight requests. You can still address one client explicitly by id:
|
||||
|
||||
```powershell
|
||||
curl.exe -H "X-Reverse-Llama-Token: change-me" http://your-server:5050/api/tags
|
||||
curl.exe -H "X-Reverse-Llama-Token: change-me" http://your-server:5050/clients/gpu-01/api/tags
|
||||
curl.exe -H "X-Ngino-Token: change-me" http://your-server:5050/api/tags
|
||||
curl.exe -H "X-Ngino-Token: change-me" http://your-server:5050/clients/gpu-01/api/tags
|
||||
curl.exe http://your-server:5050/token/change-me/api/tags
|
||||
curl.exe http://your-server:5050/token/change-me/clients/gpu-01/api/tags
|
||||
```
|
||||
|
||||
```powershell
|
||||
curl.exe -H "X-Reverse-Llama-Token: change-me" `
|
||||
curl.exe -H "X-Ngino-Token: change-me" `
|
||||
-H "Content-Type: application/json" `
|
||||
-d '{"model":"llama3.1","prompt":"hello"}' `
|
||||
http://your-server:5050/api/generate
|
||||
@@ -79,31 +79,32 @@ curl.exe -H "X-Reverse-Llama-Token: change-me" `
|
||||
|
||||
Server options:
|
||||
|
||||
- `--token <value>` or `REVERSE_LLAMA_TOKEN`: optional shared token. If set, proxy calls must authenticate with `X-Reverse-Llama-Token`, `Authorization: Bearer <token>`, or the `/token/<token>/...` path prefix.
|
||||
- `--tunnel-path <path>`: defaults to `/_reverse-llama/tunnel`.
|
||||
- `--status-path <path>`: defaults to `/_reverse-llama/status`.
|
||||
- `--chunk-size <bytes>` or `REVERSE_LLAMA_CHUNK_SIZE`: defaults to `65536`.
|
||||
- `--embedding-cache-path <path>` or `REVERSE_LLAMA_EMBEDDING_CACHE_PATH`: SQLite cache file for embedding vectors. Defaults to `App_Data\embedding-cache.sqlite` under the server app directory.
|
||||
- `--management-database-path <path>` or `REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH`: SQLite database for admin API keys, client disable state, and request/model metrics. Defaults to `App_Data\management.sqlite` under the server app directory.
|
||||
- `--token <value>` or `NGINO_TOKEN`: optional shared token. If set, proxy calls must authenticate with `X-Ngino-Token`, `Authorization: Bearer <token>`, or the `/token/<token>/...` path prefix.
|
||||
- `--tunnel-path <path>`: defaults to `/_ngino/tunnel`.
|
||||
- `--status-path <path>`: defaults to `/_ngino/status`.
|
||||
- `--chunk-size <bytes>` or `NGINO_CHUNK_SIZE`: defaults to `65536`.
|
||||
- `--embedding-cache-path <path>` or `NGINO_EMBEDDING_CACHE_PATH`: SQLite cache file for embedding vectors. Defaults to `App_Data\embedding-cache.sqlite` under the server app directory.
|
||||
- `--management-database-path <path>` or `NGINO_MANAGEMENT_DATABASE_PATH`: SQLite database for admin user keys, client keys, client disable state, and request/model metrics. Defaults to `App_Data\management.sqlite` under the server app directory.
|
||||
- `--secure-cookies` or `NGINO_SECURE_COOKIES`: set to `false` to allow admin auth cookies over plain HTTP (for local development). Defaults to `true`.
|
||||
|
||||
Admin UI:
|
||||
|
||||
- `GET /admin` opens the Keycloak-protected management UI.
|
||||
- The temporary Keycloak settings live under `Authentication:Keycloak` in `appsettings.json`.
|
||||
- API keys created in the UI are accepted anywhere the shared token is accepted: `X-Reverse-Llama-Token`, `Authorization: Bearer <key>`, `?token=...`, and `/token/<key>/...`.
|
||||
- User keys created in the UI are accepted anywhere the shared token is accepted: `X-Ngino-Token`, `Authorization: Bearer <key>`, `?token=...`, and `/token/<key>/...`.
|
||||
- Model add/remove/load/unload commands are sent through the connected tunnel client to Ollama (`/api/pull`, `/api/delete`, `/api/generate`, and `/api/show`).
|
||||
|
||||
Client options:
|
||||
|
||||
- `--server <url>` or `REVERSE_LLAMA_SERVER`: server base URL, for example `http://your-server:5050`.
|
||||
- `--upstream <url>` or `REVERSE_LLAMA_UPSTREAM`: local Ollama URL, defaults to `http://localhost:11434`.
|
||||
- `--token <value>` or `REVERSE_LLAMA_TOKEN`: optional shared token.
|
||||
- `--client-id <name>` or `REVERSE_LLAMA_CLIENT_ID`: identifies this machine on the server; defaults to the machine name.
|
||||
- `--tunnel-path <path>` or `REVERSE_LLAMA_TUNNEL_PATH`: defaults to `/_reverse-llama/tunnel`.
|
||||
- `--reconnect-delay <seconds>` or `REVERSE_LLAMA_RECONNECT_DELAY_SECONDS`: defaults to `5`.
|
||||
- `--chunk-size <bytes>` or `REVERSE_LLAMA_CHUNK_SIZE`: defaults to `65536`.
|
||||
- `--server <url>` or `NGINO_SERVER`: server base URL, for example `http://your-server:5050`.
|
||||
- `--upstream <url>` or `NGINO_UPSTREAM`: local Ollama URL, defaults to `http://localhost:11434`.
|
||||
- `--token <value>` or `NGINO_TOKEN`: optional shared token.
|
||||
- `--client-id <name>` or `NGINO_CLIENT_ID`: identifies this machine on the server; defaults to the machine name.
|
||||
- `--tunnel-path <path>` or `NGINO_TUNNEL_PATH`: defaults to `/_ngino/tunnel`.
|
||||
- `--reconnect-delay <seconds>` or `NGINO_RECONNECT_DELAY_SECONDS`: defaults to `5`.
|
||||
- `--chunk-size <bytes>` or `NGINO_CHUNK_SIZE`: defaults to `65536`.
|
||||
|
||||
The token is accepted as `X-Reverse-Llama-Token`, as `Authorization: Bearer <token>`, or as a path prefix like `/token/<token>/api/tags` or `/token/<token>/clients/{id}/v1`. The Bearer form lets OpenAI-compatible clients (e.g. n8n's OpenAI nodes pointed at `/clients/{id}/v1`) authenticate with their API-key field. The path-token form is useful for clients that cannot send custom headers. The server strips its own token header/Bearer value and removes the path prefix before forwarding; any other `Authorization` value is forwarded untouched.
|
||||
The token is accepted as `X-Ngino-Token`, as `Authorization: Bearer <token>`, or as a path prefix like `/token/<token>/api/tags` or `/token/<token>/clients/{id}/v1`. The Bearer form lets OpenAI-compatible clients (e.g. n8n's OpenAI nodes pointed at `/clients/{id}/v1`) authenticate with their API-key field. The path-token form is useful for clients that cannot send custom headers. The server strips its own token header/Bearer value and removes the path prefix before forwarding; any other `Authorization` value is forwarded untouched.
|
||||
|
||||
## Multiple clients
|
||||
|
||||
@@ -117,7 +118,7 @@ Any number of machines can connect at the same time; each registers under its cl
|
||||
|
||||
## Embedding cache
|
||||
|
||||
The server keeps an in-memory KV cache for embedding vectors and persists it to SQLite. The cache key is the requested `model` plus the exact input text. It applies to `POST /api/embed`, `POST /api/embeddings`, and `POST /v1/embeddings`; cache hits return JSON in the same endpoint family shape and include `X-Reverse-Llama-Embedding-Cache: hit`.
|
||||
The server keeps an in-memory KV cache for embedding vectors and persists it to SQLite. The cache key is the requested `model` plus the exact input text. It applies to `POST /api/embed`, `POST /api/embeddings`, and `POST /v1/embeddings`; cache hits return JSON in the same endpoint family shape and include `X-Ngino-Embedding-Cache: hit`.
|
||||
|
||||
The authenticated status endpoint reports whether the cache is available, plus the cache count and database path. If SQLite cannot be initialized, proxy traffic continues without embedding-cache writes.
|
||||
|
||||
@@ -131,9 +132,24 @@ sudo bash deploy/install-client.sh --server http://your-server:5050 --token "cha
|
||||
|
||||
Options: `--server`, `--token` (required); `--client-id`, `--upstream`, `--install-dir`, `--service-name`, `--no-ollama` (optional). Missing required values are prompted interactively.
|
||||
|
||||
The script ensures .NET 10 and Ollama are installed, builds the client self-contained, installs it to `/opt/reversellama-client`, and creates a systemd service (`reversellama-client`). Logs: `journalctl -u reversellama-client -f`.
|
||||
The script ensures .NET 10 and Ollama are installed, builds the client self-contained, installs it to `/opt/Ngino-client`, and creates a systemd service (`Ngino-client`). Logs: `journalctl -u Ngino-client -f`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Request and response bodies are streamed through the tunnel, which is important for Ollama streaming responses.
|
||||
- Use HTTPS or a private network/VPN when exposing this outside a trusted network. The token is simple shared-secret protection, not a full access-control system.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Use HTTPS or a private network/VPN when exposing this outside a trusted network.
|
||||
- **Tokens in URLs** (`/token/<token>/...` and `?token=...`) are logged by web servers (Apache, Nginx, Kestrel), reverse proxies, and browsers (history). Malicious MITM proxies can also read them. Prefer header-based auth (`X-Ngino-Token` or `Authorization: Bearer`) when your client supports it.
|
||||
- The token is simple shared-secret protection, not a full access-control system.
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
This project was architected by humans.
|
||||
The code was mostly authored by multiple AI models:
|
||||
|
||||
- Claude Fable 5
|
||||
- ChatGPT 5.5
|
||||
- OpenCode Big Pickle
|
||||
- Qwen3-coder-next:latest
|
||||
|
||||
+22
-14
@@ -2,8 +2,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
DEFAULT_INSTALL_DIR="/opt/reversellama-client"
|
||||
DEFAULT_SERVICE_NAME="reversellama-client"
|
||||
DEFAULT_INSTALL_DIR="/opt/ngino-client"
|
||||
DEFAULT_SERVICE_NAME="ngino-client"
|
||||
DEFAULT_UPSTREAM="http://localhost:11434"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -33,10 +33,10 @@ usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Installs the ReverseLlama client as a systemd service on Linux.
|
||||
Installs the Ngino client as a systemd service on Linux.
|
||||
|
||||
Required:
|
||||
--server <url> ReverseLlama server URL (e.g. http://my-server:5050)
|
||||
--server <url> Ngino server URL (e.g. http://my-server:5050)
|
||||
--token <value> Shared secret token for the server
|
||||
|
||||
Optional:
|
||||
@@ -71,7 +71,7 @@ done
|
||||
|
||||
# ── Prompt for missing required values ───────────────────────────────────────
|
||||
if [[ -z "$SERVER_URL" ]]; then
|
||||
read -rp "ReverseLlama server URL (e.g. http://my-server:5050): " SERVER_URL
|
||||
read -rp "Ngino server URL (e.g. http://my-server:5050): " SERVER_URL
|
||||
fi
|
||||
if [[ -z "$SERVER_URL" ]]; then
|
||||
die "Server URL is required."
|
||||
@@ -225,7 +225,7 @@ else
|
||||
fi
|
||||
|
||||
# ── Build client from source ─────────────────────────────────────────────────
|
||||
CLIENT_SRC="$REPO_ROOT/src/ReverseLlama.Client"
|
||||
CLIENT_SRC="$REPO_ROOT/src/Ngino.Client"
|
||||
if [[ ! -d "$CLIENT_SRC" ]]; then
|
||||
die "Client source not found at $CLIENT_SRC. Run this script from the repository or pass --install-dir."
|
||||
fi
|
||||
@@ -238,18 +238,18 @@ case "$ARCH" in
|
||||
*) die "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
info "Building ReverseLlama client (self-contained, $DOTNET_RID)..."
|
||||
BUILD_DIR="$(mktemp -d /tmp/reversellama-build.XXXXXX)"
|
||||
info "Building Ngino client (self-contained, $DOTNET_RID)..."
|
||||
BUILD_DIR="$(mktemp -d /tmp/ngino-build.XXXXXX)"
|
||||
trap 'rm -rf "$BUILD_DIR"' EXIT
|
||||
|
||||
"$DOTNET_CMD" publish "$CLIENT_SRC/ReverseLlama.Client.csproj" \
|
||||
"$DOTNET_CMD" publish "$CLIENT_SRC/Ngino.Client.csproj" \
|
||||
-c Release \
|
||||
-r "$DOTNET_RID" \
|
||||
--self-contained true \
|
||||
-o "$BUILD_DIR"
|
||||
|
||||
if [[ ! -f "$BUILD_DIR/ReverseLlama.Client" ]]; then
|
||||
die "Build failed. ReverseLlama.Client binary not found in output."
|
||||
if [[ ! -f "$BUILD_DIR/Ngino.Client" ]]; then
|
||||
die "Build failed. Ngino.Client binary not found in output."
|
||||
fi
|
||||
|
||||
info "Build successful."
|
||||
@@ -258,10 +258,17 @@ info "Build successful."
|
||||
info "Installing to $INSTALL_DIR..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp -a "$BUILD_DIR"/. "$INSTALL_DIR/"
|
||||
chmod +x "$INSTALL_DIR/ReverseLlama.Client"
|
||||
chmod +x "$INSTALL_DIR/Ngino.Client"
|
||||
|
||||
info "Client installed to $INSTALL_DIR."
|
||||
|
||||
# ── Write environment file (avoids shell injection in unit file) ─────────────
|
||||
ENV_DIR="/etc/ngino-client"
|
||||
mkdir -p "$ENV_DIR"
|
||||
printf 'NGINO_TOKEN=%s\n' "$TOKEN" > "$ENV_DIR/env"
|
||||
chmod 600 "$ENV_DIR/env"
|
||||
info "Environment file written to $ENV_DIR/env (mode 0600)."
|
||||
|
||||
# ── Create systemd service ───────────────────────────────────────────────────
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
@@ -272,7 +279,7 @@ fi
|
||||
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=ReverseLlama Tunnel Client
|
||||
Description=Ngino Tunnel Client
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
$([ "$SKIP_OLLAMA" = "false" ] && echo "After=ollama.service")
|
||||
@@ -280,11 +287,12 @@ $([ "$SKIP_OLLAMA" = "false" ] && echo "Wants=ollama.service")
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$INSTALL_DIR/ReverseLlama.Client --server "$SERVER_URL" --upstream "$UPSTREAM" --token "$TOKEN" --client-id "$CLIENT_ID"
|
||||
ExecStart=$INSTALL_DIR/Ngino.Client --server "$SERVER_URL" --upstream "$UPSTREAM" --client-id "$CLIENT_ID"
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
Environment=DOTNET_NOLOGO=1
|
||||
EnvironmentFile=$ENV_DIR/env
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
|
||||
[Install]
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||

|
||||
# Models view
|
||||

|
||||
# API Keys view
|
||||

|
||||
# User Keys view
|
||||

|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Patrick Steele-Idem <pnidem@gmail.com> (psteeleidem.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ReverseLlama.Client;
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class ChannelHttpContent : HttpContent
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Client;
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class ClientOptions
|
||||
{
|
||||
@@ -47,24 +47,24 @@ internal sealed class ClientOptions
|
||||
|
||||
return new ClientOptions
|
||||
{
|
||||
Server = ReadUri(values, "server", "REVERSE_LLAMA_SERVER", "http://localhost:5001"),
|
||||
Upstream = ReadUri(values, "upstream", "REVERSE_LLAMA_UPSTREAM", "http://localhost:11434"),
|
||||
TunnelPath = NormalizePath(Read(values, "tunnel-path", "REVERSE_LLAMA_TUNNEL_PATH") ?? ProtocolConstants.DefaultTunnelPath),
|
||||
Token = Read(values, "token", "REVERSE_LLAMA_TOKEN"),
|
||||
ClientId = Read(values, "client-id", "REVERSE_LLAMA_CLIENT_ID") ?? Environment.MachineName.ToLowerInvariant(),
|
||||
ReconnectDelay = TimeSpan.FromSeconds(ReadInt(values, 5, "reconnect-delay", "REVERSE_LLAMA_RECONNECT_DELAY_SECONDS")),
|
||||
ChunkSize = ReadInt(values, 64 * 1024, "chunk-size", "REVERSE_LLAMA_CHUNK_SIZE")
|
||||
Server = ReadUri(values, "server", "NGINO_SERVER", "http://localhost:5001"),
|
||||
Upstream = ReadUri(values, "upstream", "NGINO_UPSTREAM", "http://localhost:11434"),
|
||||
TunnelPath = NormalizePath(Read(values, "tunnel-path", "NGINO_TUNNEL_PATH") ?? ProtocolConstants.DefaultTunnelPath),
|
||||
Token = Read(values, "token", "NGINO_TOKEN"),
|
||||
ClientId = Read(values, "client-id", "NGINO_CLIENT_ID") ?? Environment.MachineName.ToLowerInvariant(),
|
||||
ReconnectDelay = TimeSpan.FromSeconds(ReadInt(values, 5, "reconnect-delay", "NGINO_RECONNECT_DELAY_SECONDS")),
|
||||
ChunkSize = ReadInt(values, 64 * 1024, "chunk-size", "NGINO_CHUNK_SIZE")
|
||||
};
|
||||
}
|
||||
|
||||
public static string Usage =>
|
||||
"""
|
||||
ReverseLlama.Client options:
|
||||
Ngino.Client options:
|
||||
--server <url> Server base URL, e.g. http://my-server:5050
|
||||
--upstream <url> Local upstream URL, e.g. http://localhost:11434
|
||||
--token <value> Optional token matching the server
|
||||
--client-id <name> Identifies this machine on the server; defaults to the machine name
|
||||
--tunnel-path <path> Defaults to /_reverse-llama/tunnel
|
||||
--tunnel-path <path> Defaults to /_ngino/tunnel
|
||||
--reconnect-delay <sec> Defaults to 5
|
||||
--chunk-size <bytes> Defaults to 65536
|
||||
""";
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ReverseLlama.Protocol\ReverseLlama.Protocol.csproj" />
|
||||
<ProjectReference Include="..\Ngino.Protocol\Ngino.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -1,13 +1,13 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ReverseLlama.Client;
|
||||
using Ngino.Client;
|
||||
|
||||
try
|
||||
{
|
||||
var options = ClientOptions.Parse(args);
|
||||
|
||||
Console.WriteLine("ReverseLlama client");
|
||||
Console.WriteLine("Ngino client");
|
||||
Console.WriteLine($" client id: {options.ClientId}");
|
||||
Console.WriteLine($" server tunnel: {options.TunnelUri}");
|
||||
Console.WriteLine($" local upstream: {options.Upstream}");
|
||||
@@ -17,9 +17,9 @@ try
|
||||
builder.Services.AddSingleton(options);
|
||||
builder.Services.AddSingleton<TunnelClient>();
|
||||
builder.Services.AddHostedService<TunnelWorker>();
|
||||
builder.Services.AddWindowsService(service => service.ServiceName = "ReverseLlamaClient");
|
||||
builder.Services.AddWindowsService(service => service.ServiceName = "NginoClient");
|
||||
// The EventLog provider defaults to Warning; connection state is worth seeing there.
|
||||
builder.Logging.AddFilter<Microsoft.Extensions.Logging.EventLog.EventLogLoggerProvider>("ReverseLlama.Client", LogLevel.Information);
|
||||
builder.Logging.AddFilter<Microsoft.Extensions.Logging.EventLog.EventLogLoggerProvider>("Ngino.Client", LogLevel.Information);
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
return 0;
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Ngino.Client.Tests")]
|
||||
[assembly: InternalsVisibleTo("ReverseLlama.Client.Tests")]
|
||||
@@ -4,16 +4,16 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Client;
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class TunnelClient
|
||||
{
|
||||
private static readonly TimeSpan ModelRefreshInterval = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan ModelRefreshTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private const string EmbeddingWarmupInput = "ReverseLlama warmup";
|
||||
private const string EmbeddingWarmupInput = "Ngino warmup";
|
||||
|
||||
private readonly ConcurrentDictionary<string, UpstreamRequest> _activeRequests = new();
|
||||
private readonly HttpClient _httpClient;
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace ReverseLlama.Client;
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class TunnelWorker : BackgroundService
|
||||
{
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Channels;
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Client;
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class UpstreamRequest
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ReverseLlama.Protocol;
|
||||
namespace Ngino.Protocol;
|
||||
|
||||
public sealed class HeaderPair
|
||||
{
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Ngino.Protocol;
|
||||
|
||||
public static class ProtocolConstants
|
||||
{
|
||||
public const string DefaultStatusPath = "/_ngino/status";
|
||||
public const string DefaultTunnelPath = "/_ngino/tunnel";
|
||||
public const string TokenHeader = "X-Ngino-Token";
|
||||
public const string ClientIdHeader = "X-Ngino-Client-Id";
|
||||
public const string ReplacedCloseDescription = "ngino-replaced";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ReverseLlama.Protocol;
|
||||
namespace Ngino.Protocol;
|
||||
|
||||
public sealed class TunnelMessage
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace ReverseLlama.Protocol;
|
||||
namespace Ngino.Protocol;
|
||||
|
||||
public static class TunnelMessageTypes
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ReverseLlama.Protocol;
|
||||
namespace Ngino.Protocol;
|
||||
|
||||
public static class WebSocketMessageTransport
|
||||
{
|
||||
@@ -1,12 +1,18 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Ngino.Server.Data;
|
||||
using Ngino.Server.Models;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal static class AdminEndpoints
|
||||
{
|
||||
@@ -28,6 +34,99 @@ internal static class AdminEndpoints
|
||||
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]))
|
||||
.RequireAuthorization();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.MapGet("/admin/login", async (HttpContext context, SignInManager<ApplicationUser> signInManager, IAntiforgery antiforgery, string? returnUrl) =>
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
return Results.Redirect(NormalizeLocalReturnUrl(returnUrl));
|
||||
|
||||
if (await signInManager.UserManager.Users.AnyAsync())
|
||||
{
|
||||
var tokens = antiforgery.GetAndStoreTokens(context);
|
||||
return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), null, tokens.RequestToken!), "text/html");
|
||||
}
|
||||
|
||||
return Results.Redirect("/admin/setup");
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost("/admin/login", async (HttpContext context, SignInManager<ApplicationUser> signInManager, IAntiforgery antiforgery, string? returnUrl, [FromForm] string? username, [FromForm] string? password) =>
|
||||
{
|
||||
if (await signInManager.UserManager.Users.AnyAsync() == false)
|
||||
return Results.Redirect("/admin/setup");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
var tokens = antiforgery.GetAndStoreTokens(context);
|
||||
return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), "Username and password are required.", tokens.RequestToken!), "text/html");
|
||||
}
|
||||
|
||||
var result = await signInManager.PasswordSignInAsync(username, password, true, true);
|
||||
if (result.Succeeded)
|
||||
return Results.Redirect(NormalizeLocalReturnUrl(returnUrl));
|
||||
|
||||
if (result.IsLockedOut)
|
||||
{
|
||||
var tokens = antiforgery.GetAndStoreTokens(context);
|
||||
return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), "Account is locked out.", tokens.RequestToken!), "text/html");
|
||||
}
|
||||
|
||||
{
|
||||
var tokens = antiforgery.GetAndStoreTokens(context);
|
||||
return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), "Invalid username or password.", tokens.RequestToken!), "text/html");
|
||||
}
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/admin/setup", async (HttpContext context, SignInManager<ApplicationUser> signInManager, IAntiforgery antiforgery) =>
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
return Results.Redirect("/admin");
|
||||
|
||||
if (await signInManager.UserManager.Users.AnyAsync())
|
||||
return Results.Redirect("/admin/login");
|
||||
|
||||
var tokens = antiforgery.GetAndStoreTokens(context);
|
||||
return Results.Content(SetupPage(null, tokens.RequestToken!), "text/html");
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost("/admin/setup", async (HttpContext context, SignInManager<ApplicationUser> signInManager, IAntiforgery antiforgery, [FromForm] string? username, [FromForm] string? email, [FromForm] string? password, [FromForm] string? confirmPassword) =>
|
||||
{
|
||||
if (await signInManager.UserManager.Users.AnyAsync())
|
||||
return Results.Redirect("/admin/login");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
var tokens = antiforgery.GetAndStoreTokens(context);
|
||||
return Results.Content(SetupPage("Username and password are required.", tokens.RequestToken!), "text/html");
|
||||
}
|
||||
|
||||
if (password != confirmPassword)
|
||||
{
|
||||
var tokens = antiforgery.GetAndStoreTokens(context);
|
||||
return Results.Content(SetupPage("Passwords do not match.", tokens.RequestToken!), "text/html");
|
||||
}
|
||||
|
||||
var user = new ApplicationUser { UserName = username, Email = email };
|
||||
var result = await signInManager.UserManager.CreateAsync(user, password);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await signInManager.SignInAsync(user, true);
|
||||
return Results.Redirect("/admin");
|
||||
}
|
||||
|
||||
var errors = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
{
|
||||
var tokens = antiforgery.GetAndStoreTokens(context);
|
||||
return Results.Content(SetupPage(errors, tokens.RequestToken!), "text/html");
|
||||
}
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost("/admin/logout", async (SignInManager<ApplicationUser> signInManager) =>
|
||||
{
|
||||
await signInManager.SignOutAsync();
|
||||
return Results.Redirect("/admin/login");
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
|
||||
app.MapGet("/admin/auth-error", () =>
|
||||
Results.Text(
|
||||
@@ -41,6 +140,10 @@ internal static class AdminEndpoints
|
||||
{
|
||||
api.RequireAuthorization();
|
||||
}
|
||||
else
|
||||
{
|
||||
api.RequireAuthorization();
|
||||
}
|
||||
|
||||
api.MapGet("/summary", (HttpContext context, TunnelHub hub, ManagementStore store) =>
|
||||
Results.Json(BuildSummary(context.User, hub, store, settings)));
|
||||
@@ -165,14 +268,14 @@ internal static class AdminEndpoints
|
||||
return Results.Json(result);
|
||||
});
|
||||
|
||||
api.MapGet("/api-keys", (ManagementStore store) =>
|
||||
Results.Json(store.ListApiKeys()));
|
||||
api.MapGet("/user-keys", (ManagementStore store) =>
|
||||
Results.Json(store.ListUserKeys()));
|
||||
|
||||
api.MapPost("/api-keys", (CreateApiKeyRequest request, ManagementStore store) =>
|
||||
api.MapPost("/user-keys", (CreateUserKeyRequest request, ManagementStore store) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Json(store.CreateApiKey(request.Name));
|
||||
return Results.Json(store.CreateUserKey(request.Name));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -180,10 +283,30 @@ internal static class AdminEndpoints
|
||||
}
|
||||
});
|
||||
|
||||
api.MapDelete("/api-keys/{id}", (string id, ManagementStore store) =>
|
||||
store.DeleteApiKey(id)
|
||||
api.MapDelete("/user-keys/{id}", (string id, ManagementStore store) =>
|
||||
store.DeleteUserKey(id)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"API key '{id}' was not found." }));
|
||||
: Results.NotFound(new { error = $"User key '{id}' was not found." }));
|
||||
|
||||
api.MapGet("/client-keys", (ManagementStore store) =>
|
||||
Results.Json(store.ListClientKeys()));
|
||||
|
||||
api.MapPost("/client-keys", (CreateUserKeyRequest request, ManagementStore store) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Json(store.CreateClientKey(request.Name));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
api.MapDelete("/client-keys/{id}", (string id, ManagementStore store) =>
|
||||
store.DeleteClientKey(id)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"Client key '{id}' was not found." }));
|
||||
|
||||
api.MapGet("/groups", (ManagementStore store) =>
|
||||
Results.Json(store.ListGroups()));
|
||||
@@ -246,7 +369,14 @@ internal static class AdminEndpoints
|
||||
|
||||
try
|
||||
{
|
||||
var member = store.AddGroupClient(id, request.ClientId, request.Model, request.ClientPattern);
|
||||
var member = store.AddGroupClient(
|
||||
id,
|
||||
request.ClientId,
|
||||
request.Model,
|
||||
request.ClientPattern,
|
||||
request.KeepaliveInstancesToKeepAlive,
|
||||
request.KeepaliveMaxParallelismPerClient,
|
||||
request.KeepaliveParallelismHeadroom);
|
||||
return Results.Json(member);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
@@ -272,21 +402,21 @@ internal static class AdminEndpoints
|
||||
: Results.NotFound(new { error = $"Client '{clientId}' was not found." });
|
||||
});
|
||||
|
||||
api.MapGet("/api-keys/groups", (ManagementStore store) =>
|
||||
Results.Json(store.ListApiKeyGroups()));
|
||||
api.MapGet("/user-keys/groups", (ManagementStore store) =>
|
||||
Results.Json(store.ListUserKeyGroups()));
|
||||
|
||||
api.MapPut("/api-keys/{id}/groups", (string id, SetApiKeyGroupsRequest request, ManagementStore store) =>
|
||||
api.MapPut("/user-keys/{id}/groups", (string id, SetUserKeyGroupsRequest request, ManagementStore store) =>
|
||||
{
|
||||
var keys = store.ListApiKeys();
|
||||
var keys = store.ListUserKeys();
|
||||
if (!keys.Any(k => k.Id == id))
|
||||
{
|
||||
return Results.NotFound(new { error = $"API key '{id}' was not found." });
|
||||
return Results.NotFound(new { error = $"User key '{id}' was not found." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
store.SetApiKeyGroups(id, request.GroupIds ?? []);
|
||||
return Results.Ok(new { apiKeyId = id, groupIds = store.GetApiKeyGroupIds(id) });
|
||||
store.SetUserKeyGroups(id, request.GroupIds ?? []);
|
||||
return Results.Ok(new { userKeyId = id, groupIds = store.GetUserKeyGroupIds(id) });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -294,6 +424,152 @@ internal static class AdminEndpoints
|
||||
}
|
||||
});
|
||||
|
||||
api.MapGet("/groups/{id}/billing", (string id, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
}
|
||||
|
||||
var billing = store.GetGroupBilling(id);
|
||||
return billing is not null
|
||||
? Results.Json(billing)
|
||||
: Results.Json(new GroupBillingInfo(id, "EUR", 0, 0, false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
|
||||
});
|
||||
|
||||
api.MapPut("/groups/{id}/billing", (string id, UpdateBillingRequest request, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var billing = store.UpsertGroupBilling(
|
||||
id,
|
||||
request.Currency ?? "EUR",
|
||||
request.DefaultRatePer1k,
|
||||
request.RefuseBelowBalance,
|
||||
request.Enabled);
|
||||
return Results.Ok(billing);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
api.MapGet("/groups/{id}/billing/rules", (string id, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
}
|
||||
|
||||
return Results.Json(store.ListGroupBillingRules(id));
|
||||
});
|
||||
|
||||
api.MapPost("/groups/{id}/billing/rules", (string id, AddBillingRuleRequest request, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rule = store.AddBillingRule(id, request.ModelRegex, request.RatePer1k);
|
||||
return Results.Json(rule);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
api.MapPut("/groups/{id}/billing/rules/{ruleId:long}", (string id, long ruleId, UpdateBillingRuleRequest request, ManagementStore store) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return store.UpdateBillingRule(ruleId, request.ModelRegex, request.RatePer1k)
|
||||
? Results.Ok(new { id = ruleId })
|
||||
: Results.NotFound(new { error = $"Rule '{ruleId}' was not found." });
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
api.MapDelete("/groups/{id}/billing/rules/{ruleId:long}", (string id, long ruleId, ManagementStore store) =>
|
||||
store.DeleteBillingRule(ruleId)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"Rule '{ruleId}' was not found." }));
|
||||
|
||||
api.MapGet("/groups/{id}/billing/payments", (string id, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
}
|
||||
|
||||
return Results.Json(store.ListGroupPayments(id));
|
||||
});
|
||||
|
||||
api.MapPost("/groups/{id}/billing/payments", (string id, AddPaymentRequest request, ManagementStore store, HttpContext context) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userName = GetUserName(context.User);
|
||||
var payment = store.AddPayment(id, request.Amount, request.Description, userName);
|
||||
return Results.Json(payment);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
api.MapDelete("/groups/{id}/billing/payments/{paymentId:long}", (string id, long paymentId, ManagementStore store) =>
|
||||
store.DeletePayment(paymentId)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"Payment '{paymentId}' was not found." }));
|
||||
|
||||
api.MapGet("/groups/{id}/billing/balance", (string id, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
}
|
||||
|
||||
return Results.Json(store.GetGroupBalance(id));
|
||||
});
|
||||
|
||||
api.MapGet("/usage/tokens", (ManagementStore store) =>
|
||||
Results.Json(new
|
||||
{
|
||||
byModel = store.GetTokenStatsByModel(),
|
||||
byClient = store.GetTokenStatsByClient(),
|
||||
byUserKey = store.GetTokenStatsByUserKey(),
|
||||
byGroup = store.GetTokenStatsByGroup()
|
||||
}));
|
||||
|
||||
api.MapGet("/usage/revenue", (ManagementStore store) =>
|
||||
Results.Json(store.GetClientRevenue()));
|
||||
|
||||
var adminHome = app.MapGet("/admin", (IWebHostEnvironment environment) =>
|
||||
ServeAdminAsset(environment, null));
|
||||
var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) =>
|
||||
@@ -304,8 +580,35 @@ internal static class AdminEndpoints
|
||||
adminHome.RequireAuthorization();
|
||||
adminAssets.RequireAuthorization();
|
||||
}
|
||||
else
|
||||
{
|
||||
adminHome.RequireAuthorization();
|
||||
adminAssets.RequireAuthorization();
|
||||
}
|
||||
}
|
||||
|
||||
private static string LoginPage(string returnUrl, string? error, string? antiforgeryToken)
|
||||
{
|
||||
var errorHtml = string.IsNullOrEmpty(error)
|
||||
? ""
|
||||
: "<div class=\"error\">" + HtmlEncode(error) + "</div>";
|
||||
var loginAction = "/admin/login" + (returnUrl != "/admin" ? "?returnUrl=" + Uri.EscapeDataString(returnUrl) : "");
|
||||
|
||||
return "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Ngino - Login</title>\n<style>\nbody{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}\n.card{background:#16213e;border:1px solid #0f3460;border-radius:12px;padding:2rem;width:100%;max-width:400px}\nh1{margin:0 0 1.5rem;font-size:1.5rem;text-align:center;color:#e94560}\nlabel{display:block;margin-bottom:.25rem;font-size:.875rem;color:#a0a0b0}\ninput{width:100%;padding:.5rem;border:1px solid #0f3460;border-radius:6px;background:#1a1a2e;color:#e0e0e0;font-size:1rem;margin-bottom:1rem;box-sizing:border-box}\ninput:focus{outline:none;border-color:#e94560}\nbutton{width:100%;padding:.625rem;border:none;border-radius:6px;background:#e94560;color:#fff;font-size:1rem;font-weight:600;cursor:pointer}\nbutton:hover{background:#c73650}\n.error{background:#3d1a1a;border:1px solid #e94560;border-radius:6px;padding:.5rem .75rem;margin-bottom:1rem;font-size:.875rem;color:#ff6b7a}\n</style>\n</head>\n<body>\n<div class=\"card\">\n<h1>Ngino Admin</h1>\n" + errorHtml + "\n<form method=\"post\" action=\"" + HtmlEncode(loginAction) + "\">\n<input type=\"hidden\" name=\"__RequestVerificationToken\" value=\"" + HtmlEncode(antiforgeryToken) + "\">\n<label for=\"username\">Username</label>\n<input type=\"text\" id=\"username\" name=\"username\" autocomplete=\"username\" required autofocus>\n<label for=\"password\">Password</label>\n<input type=\"password\" id=\"password\" name=\"password\" autocomplete=\"current-password\" required>\n<input type=\"hidden\" name=\"returnUrl\" value=\"" + HtmlEncode(returnUrl) + "\">\n<button type=\"submit\">Sign In</button>\n</form>\n</div>\n</body>\n</html>";
|
||||
}
|
||||
|
||||
private static string SetupPage(string? error, string? antiforgeryToken)
|
||||
{
|
||||
var errorHtml = string.IsNullOrEmpty(error)
|
||||
? ""
|
||||
: "<div class=\"error\">" + HtmlEncode(error) + "</div>";
|
||||
|
||||
return "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Ngino - Initial Setup</title>\n<style>\nbody{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}\n.card{background:#16213e;border:1px solid #0f3460;border-radius:12px;padding:2rem;width:100%;max-width:400px}\nh1{margin:0 0 .25rem;font-size:1.5rem;text-align:center;color:#e94560}\n.subtitle{text-align:center;color:#a0a0b0;margin-bottom:1.5rem;font-size:.875rem}\nlabel{display:block;margin-bottom:.25rem;font-size:.875rem;color:#a0a0b0}\ninput{width:100%;padding:.5rem;border:1px solid #0f3460;border-radius:6px;background:#1a1a2e;color:#e0e0e0;font-size:1rem;margin-bottom:1rem;box-sizing:border-box}\ninput:focus{outline:none;border-color:#e94560}\nbutton{width:100%;padding:.625rem;border:none;border-radius:6px;background:#e94560;color:#fff;font-size:1rem;font-weight:600;cursor:pointer}\nbutton:hover{background:#c73650}\n.error{background:#3d1a1a;border:1px solid #e94560;border-radius:6px;padding:.5rem .75rem;margin-bottom:1rem;font-size:.875rem;color:#ff6b7a}\n</style>\n</head>\n<body>\n<div class=\"card\">\n<h1>Ngino</h1>\n<p class=\"subtitle\">Initial Setup - Create Admin Account</p>\n" + errorHtml + "\n<form method=\"post\" action=\"/admin/setup\" id=\"setupForm\">\n<input type=\"hidden\" name=\"__RequestVerificationToken\" value=\"" + HtmlEncode(antiforgeryToken) + "\">\n<label for=\"username\">Username</label>\n<input type=\"text\" id=\"username\" name=\"username\" autocomplete=\"username\" required autofocus>\n<label for=\"email\">Email (optional)</label>\n<input type=\"email\" id=\"email\" name=\"email\" autocomplete=\"email\">\n<label for=\"password\">Password</label>\n<input type=\"password\" id=\"password\" name=\"password\" autocomplete=\"new-password\" required>\n<label for=\"confirmPassword\">Confirm Password</label>\n<input type=\"password\" id=\"confirmPassword\" name=\"confirmPassword\" autocomplete=\"new-password\" required>\n<button type=\"submit\">Create Account</button>\n</form>\n</div>\n<script>\ndocument.getElementById('setupForm').addEventListener('submit',function(e){\nvar p=document.getElementById('password').value;\nvar c=document.getElementById('confirmPassword').value;\nvar msg=[];\nif(p.length<8)msg.push('at least 8 characters');\nif(!/[a-z]/.test(p))msg.push('a lowercase letter');\nif(!/[A-Z]/.test(p))msg.push('an uppercase letter');\nif(!/[0-9]/.test(p))msg.push('a digit');\nif(p!==c)msg.push('passwords must match');\nif(msg.length){e.preventDefault();var d=document.querySelector('.error');if(!d){d=document.createElement('div');d.className='error';document.getElementById('setupForm').parentNode.insertBefore(d,document.getElementById('setupForm'));}d.textContent='Password needs: '+msg.join(', ')+'.';}});\n</script>\n</body>\n</html>";
|
||||
}
|
||||
|
||||
private static string? HtmlEncode(string? value) =>
|
||||
string.IsNullOrEmpty(value) ? null : System.Net.WebUtility.HtmlEncode(value);
|
||||
|
||||
private static object BuildSummary(
|
||||
ClaimsPrincipal user,
|
||||
TunnelHub hub,
|
||||
@@ -323,7 +626,9 @@ internal static class AdminEndpoints
|
||||
{
|
||||
keycloakConfigured = settings.Keycloak.IsConfigured,
|
||||
sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token),
|
||||
apiKeysConfigured = store.HasApiKeys
|
||||
clientTokenConfigured = !string.IsNullOrWhiteSpace(settings.ClientToken),
|
||||
userKeysConfigured = store.HasUserKeys,
|
||||
clientKeysConfigured = store.HasClientKeys
|
||||
},
|
||||
management = new
|
||||
{
|
||||
@@ -333,9 +638,10 @@ internal static class AdminEndpoints
|
||||
},
|
||||
clients = BuildClientSummaries(hub, store),
|
||||
models = BuildModelSummaries(hub, store),
|
||||
apiKeys = store.ListApiKeys(),
|
||||
userKeys = store.ListUserKeys(),
|
||||
clientKeys = store.ListClientKeys(),
|
||||
groups = store.ListGroups(),
|
||||
apiKeyGroups = store.ListApiKeyGroups(),
|
||||
userKeyGroups = store.ListUserKeyGroups(),
|
||||
clientGroups = store.ResolveClientGroups(
|
||||
hub.ClientSnapshots.Select(c => c.Id).ToList())
|
||||
};
|
||||
@@ -581,7 +887,7 @@ internal sealed record ModelActionRequest(
|
||||
string Model,
|
||||
string Action);
|
||||
|
||||
internal sealed record CreateApiKeyRequest(string? Name);
|
||||
internal sealed record CreateUserKeyRequest(string? Name);
|
||||
|
||||
internal sealed record CreateGroupRequest(string? Name);
|
||||
|
||||
@@ -590,9 +896,30 @@ internal sealed record UpdateGroupRequest(string Name);
|
||||
internal sealed record AddGroupClientRequest(
|
||||
string? ClientId,
|
||||
string? Model,
|
||||
string? ClientPattern);
|
||||
string? ClientPattern,
|
||||
int? KeepaliveInstancesToKeepAlive,
|
||||
int? KeepaliveMaxParallelismPerClient,
|
||||
int? KeepaliveParallelismHeadroom);
|
||||
|
||||
internal sealed record SetApiKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
|
||||
internal sealed record SetUserKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
|
||||
|
||||
internal sealed record UpdateBillingRequest(
|
||||
string? Currency,
|
||||
double DefaultRatePer1k,
|
||||
double RefuseBelowBalance,
|
||||
bool Enabled);
|
||||
|
||||
internal sealed record AddBillingRuleRequest(
|
||||
string ModelRegex,
|
||||
double RatePer1k);
|
||||
|
||||
internal sealed record UpdateBillingRuleRequest(
|
||||
string ModelRegex,
|
||||
double RatePer1k);
|
||||
|
||||
internal sealed record AddPaymentRequest(
|
||||
double Amount,
|
||||
string? Description);
|
||||
|
||||
internal sealed record ClientSummary(
|
||||
string Id,
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ElmahCore;
|
||||
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class AuthRateLimiter
|
||||
{
|
||||
private const int DecayIntervalMinutes = 144; // ~1 step per 2.4 hours
|
||||
|
||||
private readonly ConcurrentDictionary<string, AuthAttemptInfo> _attempts = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ILogger<AuthRateLimiter> _logger;
|
||||
private readonly ErrorLog _errorLog;
|
||||
|
||||
public AuthRateLimiter(ILogger<AuthRateLimiter> logger, ErrorLog errorLog)
|
||||
{
|
||||
_logger = logger;
|
||||
_errorLog = errorLog;
|
||||
}
|
||||
|
||||
public void RecordFailure(string ipAddress, string endpoint)
|
||||
{
|
||||
var info = _attempts.GetOrAdd(ipAddress, _ => new AuthAttemptInfo());
|
||||
|
||||
lock (info)
|
||||
{
|
||||
info.Count++;
|
||||
info.LastAttemptUtc = DateTime.UtcNow;
|
||||
|
||||
if (info.Count >= 20)
|
||||
{
|
||||
info.BlockedUntilUtc = DateTime.UtcNow.AddHours(48);
|
||||
_logger.LogWarning(
|
||||
"IP {IpAddress} blocked for 48 hours after {Count} failed auth attempts (last: {Endpoint})",
|
||||
ipAddress, info.Count, endpoint);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed auth attempt #{Count} from {IpAddress} on {Endpoint}",
|
||||
info.Count, ipAddress, endpoint);
|
||||
}
|
||||
|
||||
_errorLog.Log(new Error(new AuthFailureException(ipAddress, endpoint, info.Count)));
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordSuccess(string ipAddress)
|
||||
{
|
||||
if (!_attempts.TryGetValue(ipAddress, out var info))
|
||||
return;
|
||||
|
||||
lock (info)
|
||||
{
|
||||
if (info.Count > 0)
|
||||
{
|
||||
var before = info.Count;
|
||||
info.Count /= 2;
|
||||
info.LastAttemptUtc = DateTime.UtcNow;
|
||||
_logger.LogInformation(
|
||||
"Auth success from {IpAddress}: count reduced from {Before} to {After}",
|
||||
ipAddress, before, info.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public (bool Allowed, TimeSpan? RetryAfter, bool IsBlocked) CheckRateLimit(string ipAddress)
|
||||
{
|
||||
if (!_attempts.TryGetValue(ipAddress, out var info))
|
||||
{
|
||||
return (true, null, false);
|
||||
}
|
||||
|
||||
lock (info)
|
||||
{
|
||||
if (info.BlockedUntilUtc is { } blockedUntil)
|
||||
{
|
||||
if (blockedUntil > DateTime.UtcNow)
|
||||
{
|
||||
return (false, blockedUntil - DateTime.UtcNow, true);
|
||||
}
|
||||
|
||||
info.Count = 0;
|
||||
info.BlockedUntilUtc = null;
|
||||
info.LastAttemptUtc = DateTime.MinValue;
|
||||
return (true, null, false);
|
||||
}
|
||||
|
||||
if (info.Count > 0)
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - info.LastAttemptUtc;
|
||||
var decayTicks = (int)(elapsed.TotalMinutes / DecayIntervalMinutes);
|
||||
if (decayTicks > 0)
|
||||
{
|
||||
info.Count = Math.Max(0, info.Count - decayTicks);
|
||||
}
|
||||
}
|
||||
|
||||
var waitTime = CalculateWaitTime(info.Count);
|
||||
if (waitTime is { } wait)
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - info.LastAttemptUtc;
|
||||
if (elapsed < wait)
|
||||
{
|
||||
return (false, wait - elapsed, false);
|
||||
}
|
||||
}
|
||||
|
||||
return (true, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetClientIp(HttpRequest request)
|
||||
{
|
||||
if (request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
|
||||
{
|
||||
var first = forwardedFor.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(first))
|
||||
{
|
||||
var commaIndex = first.IndexOf(',');
|
||||
return commaIndex > 0 ? first[..commaIndex].Trim() : first.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue("X-Real-IP", out var realIp))
|
||||
{
|
||||
var first = realIp.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(first))
|
||||
{
|
||||
return first.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
private static TimeSpan? CalculateWaitTime(int attemptCount) =>
|
||||
attemptCount switch
|
||||
{
|
||||
< 3 => null,
|
||||
< 5 => TimeSpan.FromSeconds(5),
|
||||
< 10 => TimeSpan.FromSeconds(5 + (attemptCount - 5) * 5),
|
||||
< 20 => TimeSpan.FromMinutes(attemptCount - 9),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private sealed class AuthAttemptInfo
|
||||
{
|
||||
public int Count;
|
||||
public DateTime LastAttemptUtc;
|
||||
public DateTime? BlockedUntilUtc;
|
||||
}
|
||||
|
||||
private sealed class AuthFailureException(string ipAddress, string endpoint, int attemptCount)
|
||||
: Exception($"Failed auth attempt #{attemptCount} from {ipAddress} on {endpoint}");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Ngino.Server.Models;
|
||||
|
||||
namespace Ngino.Server.Data;
|
||||
|
||||
internal sealed class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
{
|
||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class EmbeddingCache
|
||||
{
|
||||
@@ -121,7 +121,7 @@ internal sealed class EmbeddingCache
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = JsonContentType;
|
||||
context.Response.ContentLength = body.Length;
|
||||
context.Response.Headers["X-Reverse-Llama-Embedding-Cache"] = "hit";
|
||||
context.Response.Headers["X-Ngino-Embedding-Cache"] = "hit";
|
||||
|
||||
await context.Response.Body.WriteAsync(body, context.RequestAborted);
|
||||
return true;
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal static class KeepaliveCoordinator
|
||||
{
|
||||
public static IReadOnlyList<KeepaliveAction> PlanActions(
|
||||
IEnumerable<GroupClientInfo> members,
|
||||
IEnumerable<KeepaliveCandidate> candidates)
|
||||
{
|
||||
var actions = new List<KeepaliveAction>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var candidateList = candidates.ToList();
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(member.Model))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var policy = member.KeepalivePolicy ?? GroupClientKeepalivePolicy.Default;
|
||||
var targetCount = Math.Max(0, policy.InstancesToKeepAlive);
|
||||
var matching = candidateList
|
||||
.Where(candidate => MatchesMember(candidate.ClientId, member))
|
||||
.ToList();
|
||||
|
||||
if (matching.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var active = matching.Where(candidate => candidate.HasActiveModel).ToList();
|
||||
var activeCount = active.Count;
|
||||
|
||||
if (activeCount < targetCount)
|
||||
{
|
||||
var toLoad = matching
|
||||
.Where(candidate => !candidate.HasActiveModel && candidate.HasListedModel)
|
||||
.OrderBy(candidate => candidate.ClientId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(targetCount - activeCount);
|
||||
|
||||
foreach (var candidate in toLoad)
|
||||
{
|
||||
var key = $"{candidate.ClientId}:{member.Model}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
actions.Add(new KeepaliveAction(candidate.ClientId, "load", member.Model));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (activeCount > targetCount)
|
||||
{
|
||||
var toUnload = active
|
||||
.OrderByDescending(candidate => candidate.ClientId, StringComparer.OrdinalIgnoreCase)
|
||||
.Skip(targetCount)
|
||||
.Take(activeCount - targetCount);
|
||||
|
||||
foreach (var candidate in toUnload)
|
||||
{
|
||||
var key = $"{candidate.ClientId}:{member.Model}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
actions.Add(new KeepaliveAction(candidate.ClientId, "unload", member.Model));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static bool MatchesMember(string clientId, GroupClientInfo member)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(member.ClientId)
|
||||
&& string.IsNullOrWhiteSpace(member.ClientPattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(member.ClientId)
|
||||
&& string.Equals(clientId, member.ClientId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(member.ClientPattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Regex.IsMatch(clientId, member.ClientPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
}
|
||||
catch (RegexParseException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record KeepaliveCandidate(
|
||||
string ClientId,
|
||||
bool HasListedModel,
|
||||
bool HasActiveModel);
|
||||
|
||||
internal sealed record KeepaliveAction(
|
||||
string ClientId,
|
||||
string Command,
|
||||
string Model);
|
||||
@@ -0,0 +1,125 @@
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class KeepaliveService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly TunnelHub _hub;
|
||||
private readonly ManagementStore _managementStore;
|
||||
private readonly ILogger<KeepaliveService> _logger;
|
||||
|
||||
public KeepaliveService(TunnelHub hub, ManagementStore managementStore, ILogger<KeepaliveService> logger)
|
||||
{
|
||||
_hub = hub;
|
||||
_managementStore = managementStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ApplyKeepaliveAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Keepalive cycle failed.");
|
||||
}
|
||||
|
||||
await Task.Delay(CheckInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyKeepaliveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var members = _managementStore.ListAllGroupClients();
|
||||
if (members.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshots = _hub.ClientSnapshots;
|
||||
if (snapshots.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(member.Model))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var matchingCandidates = snapshots
|
||||
.Select(snapshot => new KeepaliveCandidate(
|
||||
snapshot.Id,
|
||||
HasModel(snapshot.Models, member.Model),
|
||||
HasModel(snapshot.ActiveModels, member.Model)))
|
||||
.ToList();
|
||||
|
||||
var actions = KeepaliveCoordinator.PlanActions([member], matchingCandidates);
|
||||
foreach (var action in actions)
|
||||
{
|
||||
try
|
||||
{
|
||||
var connection = _hub.Get(action.ClientId);
|
||||
if (connection is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var response = await connection.SendModelCommandAsync(
|
||||
action.Command,
|
||||
action.Model,
|
||||
payloadJson: null,
|
||||
CommandTimeout,
|
||||
cancellationToken);
|
||||
|
||||
if (response.StatusCode is < 200 or >= 300)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Keepalive {Command} for model {Model} on client {ClientId} returned HTTP {StatusCode}.",
|
||||
action.Command,
|
||||
action.Model,
|
||||
action.ClientId,
|
||||
response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception,
|
||||
"Keepalive {Command} for model {Model} on client {ClientId} failed.",
|
||||
action.Command,
|
||||
action.Model,
|
||||
action.ClientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasModel(IEnumerable<string> models, string requestedModel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requestedModel))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var requested = requestedModel.Trim();
|
||||
return models.Any(model => ModelNamesMatch(requested, model));
|
||||
}
|
||||
|
||||
private static bool ModelNamesMatch(string requested, string available)
|
||||
{
|
||||
return string.Equals(requested, available, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(StripLatestTag(requested), StripLatestTag(available), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string StripLatestTag(string model) =>
|
||||
model.EndsWith(":latest", StringComparison.OrdinalIgnoreCase)
|
||||
? model[..^":latest".Length]
|
||||
: model;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Ngino.Server.Models;
|
||||
|
||||
internal sealed class ApplicationUser : IdentityUser
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ElmahCore" Version="2.1.2" />
|
||||
<PackageReference Include="ElmahCore.MySql" Version="2.1.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="SSH.NET" Version="2024.1.0" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||
<PackageReference Include="System.Data.SqlClient" Version="4.9.1" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="9.0.0" />
|
||||
<PackageReference Include="System.Text.Encodings.Web" Version="10.0.10" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.10" />
|
||||
<ProjectReference Include="..\Ngino.Protocol\Ngino.Protocol.csproj" />
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Ngino.Client.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>01c17520-a17c-43ae-b5e1-1fe435a232aa</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +1,6 @@
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class PendingCommand
|
||||
{
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
using System.Threading.Channels;
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class PendingProxyRequest
|
||||
{
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Data.SqlClient;
|
||||
using System.Net.WebSockets;
|
||||
using ElmahCore;
|
||||
using ElmahCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Ngino.Protocol;
|
||||
using Ngino.Server;
|
||||
using Ngino.Server.Data;
|
||||
using Ngino.Server.Models;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var settings = ServerSettings.FromConfiguration(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton(settings);
|
||||
builder.Services.AddSingleton<TunnelHub>();
|
||||
builder.Services.AddSingleton<EmbeddingCache>();
|
||||
builder.Services.AddSingleton<ManagementStore>();
|
||||
builder.Services.AddSingleton<AuthRateLimiter>();
|
||||
builder.Services.AddHostedService<KeepaliveService>();
|
||||
builder.Services.AddElmah<ElmahCore.MySql.MySqlErrorLog>().Configure<ElmahOptions>(
|
||||
options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection"));
|
||||
|
||||
if (settings.Keycloak.IsConfigured)
|
||||
{
|
||||
builder.Services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = "Ngino.Admin";
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
|
||||
options.LoginPath = "/admin/login";
|
||||
options.LogoutPath = "/admin/logout";
|
||||
})
|
||||
.AddOpenIdConnect(options =>
|
||||
{
|
||||
options.Authority = settings.Keycloak.Authority;
|
||||
options.ClientId = settings.Keycloak.ClientId;
|
||||
options.ClientSecret = settings.Keycloak.ClientSecret;
|
||||
options.RequireHttpsMetadata = settings.Keycloak.RequireHttpsMetadata;
|
||||
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||
options.ResponseMode = OpenIdConnectResponseMode.Query;
|
||||
options.SaveTokens = true;
|
||||
options.GetClaimsFromUserInfoEndpoint = true;
|
||||
options.CorrelationCookie.SameSite = SameSiteMode.Lax;
|
||||
options.CorrelationCookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
|
||||
options.NonceCookie.SameSite = SameSiteMode.Lax;
|
||||
options.NonceCookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
|
||||
options.Scope.Clear();
|
||||
options.Scope.Add("openid");
|
||||
options.Scope.Add("profile");
|
||||
options.Scope.Add("email");
|
||||
options.Events = new OpenIdConnectEvents
|
||||
{
|
||||
OnRemoteFailure = context =>
|
||||
{
|
||||
var errorLog = context.HttpContext.RequestServices.GetService<ErrorLog>();
|
||||
if (context.Failure is not null)
|
||||
{
|
||||
errorLog?.Log(new Error(context.Failure));
|
||||
}
|
||||
|
||||
context.HandleResponse();
|
||||
context.Response.Redirect("/admin/auth-error");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (!settings.Keycloak.IsConfigured)
|
||||
{
|
||||
var identityDbPath = Path.Combine(AppContext.BaseDirectory, "App_Data", "identity.sqlite");
|
||||
var identityConnectionString = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = identityDbPath,
|
||||
Mode = Microsoft.Data.Sqlite.SqliteOpenMode.ReadWriteCreate
|
||||
}.ToString();
|
||||
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||
options.UseSqlite(identityConnectionString));
|
||||
|
||||
builder.Services
|
||||
.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||
{
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
options.Password.RequiredLength = 8;
|
||||
options.User.RequireUniqueEmail = true;
|
||||
options.SignIn.RequireConfirmedAccount = false;
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = "Ngino.Admin";
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
|
||||
options.LoginPath = "/admin/login";
|
||||
options.LogoutPath = "/admin/logout";
|
||||
options.AccessDeniedPath = "/admin/login";
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddAntiforgery();
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
if (settings.Cors.AllowedOrigins.Contains("*"))
|
||||
{
|
||||
policy.AllowAnyOrigin();
|
||||
}
|
||||
else
|
||||
{
|
||||
policy.WithOrigins(settings.Cors.AllowedOrigins);
|
||||
}
|
||||
|
||||
if (settings.Cors.AllowedMethods.Contains("*"))
|
||||
{
|
||||
policy.AllowAnyMethod();
|
||||
}
|
||||
else
|
||||
{
|
||||
policy.WithMethods(settings.Cors.AllowedMethods);
|
||||
}
|
||||
|
||||
if (settings.Cors.AllowedHeaders.Contains("*"))
|
||||
{
|
||||
policy.AllowAnyHeader();
|
||||
}
|
||||
else
|
||||
{
|
||||
policy.WithHeaders(settings.Cors.AllowedHeaders);
|
||||
}
|
||||
|
||||
if (settings.Cors.AllowCredentials)
|
||||
{
|
||||
policy.AllowCredentials();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var managementStore = app.Services.GetRequiredService<ManagementStore>();
|
||||
var tunnelHub = app.Services.GetRequiredService<TunnelHub>();
|
||||
managementStore.SetConnectedClientProvider(() => tunnelHub.ClientSnapshots.Select(c => c.Id));
|
||||
|
||||
app.UseCors();
|
||||
|
||||
if (settings.Keycloak.IsConfigured)
|
||||
{
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
if (!settings.Keycloak.IsConfigured)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
dbContext.Database.EnsureCreated();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.UseElmah();
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers.XFrameOptions = "DENY";
|
||||
context.Response.Headers.ContentSecurityPolicy = "frame-ancestors 'none'";
|
||||
await next();
|
||||
});
|
||||
|
||||
var rateLimiter = app.Services.GetRequiredService<AuthRateLimiter>();
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (HttpMethods.IsOptions(context.Request.Method))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
var ip = AuthRateLimiter.GetClientIp(context.Request);
|
||||
var (allowed, retryAfter, _) = rateLimiter.CheckRateLimit(ip);
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.Response.Headers.RetryAfter = ((int)retryAfter!.Value.TotalSeconds).ToString();
|
||||
context.Response.ContentType = "application/json";
|
||||
var seconds = (int)retryAfter!.Value.TotalSeconds;
|
||||
string retryMessage;
|
||||
if (seconds >= 3600)
|
||||
{
|
||||
var hours = seconds / 3600;
|
||||
retryMessage = $"Please try again in {hours} hour{(hours == 1 ? "" : "s")}.";
|
||||
}
|
||||
else if (seconds >= 60)
|
||||
{
|
||||
var minutes = seconds / 60;
|
||||
retryMessage = $"Please try again in {minutes} minute{(minutes == 1 ? "" : "s")}.";
|
||||
}
|
||||
else
|
||||
{
|
||||
retryMessage = $"Please try again in {seconds} second{(seconds == 1 ? "" : "s")}.";
|
||||
}
|
||||
await context.Response.WriteAsJsonAsync(new { error = $"Too many requests. {retryMessage}" }, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
if (context.Response.StatusCode is StatusCodes.Status401Unauthorized)
|
||||
{
|
||||
rateLimiter.RecordFailure(ip, context.Request.Path);
|
||||
}
|
||||
else if (context.Response.StatusCode is StatusCodes.Status302Found
|
||||
&& context.Request.Path.StartsWithSegments("/api/admin"))
|
||||
{
|
||||
var location = context.Response.Headers.Location.FirstOrDefault();
|
||||
if (location is not null
|
||||
&& location.Contains("/admin/login", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
rateLimiter.RecordFailure(ip, context.Request.Path);
|
||||
}
|
||||
else if (location is not null
|
||||
&& location.StartsWith("/admin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
rateLimiter.RecordSuccess(ip);
|
||||
}
|
||||
}
|
||||
else if (context.Response.StatusCode is >= 200 and < 300
|
||||
&& context.Request.Path.StartsWithSegments("/api/admin"))
|
||||
{
|
||||
rateLimiter.RecordSuccess(ip);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapAdminEndpoints(settings);
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/admin"));
|
||||
|
||||
app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) =>
|
||||
{
|
||||
// Query token allowed so the status page can be checked in a browser.
|
||||
if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
connected = hub.HasClient,
|
||||
pendingRequests = hub.PendingRequestCount,
|
||||
tunnelPath = serverSettings.TunnelPath,
|
||||
embeddingCache = new
|
||||
{
|
||||
available = embeddingCache.IsAvailable,
|
||||
count = embeddingCache.Count
|
||||
},
|
||||
management = new
|
||||
{
|
||||
available = managementStore.IsAvailable
|
||||
},
|
||||
clients = hub.ClientsSnapshot
|
||||
});
|
||||
});
|
||||
|
||||
app.Map(settings.TunnelPath, async (HttpContext context, TunnelHub hub, ServerSettings serverSettings, ManagementStore managementStore) =>
|
||||
{
|
||||
if (!TokenAuthentication.IsClientAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsync($"Missing or invalid {ProtocolConstants.TokenHeader}.", context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsync("This endpoint only accepts WebSocket tunnel connections.", context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
var clientId = context.Request.Headers[ProtocolConstants.ClientIdHeader].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
clientId = $"anonymous-{Guid.NewGuid():n}";
|
||||
}
|
||||
|
||||
using var socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
await hub.AcceptAsync(clientId, socket, context.RequestAborted);
|
||||
});
|
||||
|
||||
app.Map("/clients/{clientId}/{**path}", ReverseProxyEndpoint.HandleClientAsync);
|
||||
|
||||
app.Map("/{**path}", ReverseProxyEndpoint.HandleRootAsync)
|
||||
.WithOrder(1000);
|
||||
|
||||
var elmahService = app.Services.GetRequiredService<ErrorLog>();
|
||||
try
|
||||
{
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
elmahService.Log(new Error(exception));
|
||||
throw;
|
||||
}
|
||||
+51
-43
@@ -1,7 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class ResponseTokenCounter
|
||||
{
|
||||
@@ -21,15 +21,16 @@ internal sealed class ResponseTokenCounter
|
||||
_buffer.Write(chunk[..length]);
|
||||
}
|
||||
|
||||
public int CountTokens()
|
||||
public TokenCounts CountTokens()
|
||||
{
|
||||
if (_buffer.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
return new TokenCounts(0, 0, 0);
|
||||
}
|
||||
|
||||
var payload = Encoding.UTF8.GetString(_buffer.ToArray());
|
||||
var total = 0;
|
||||
var totalPrompt = 0;
|
||||
var totalCompletion = 0;
|
||||
var parsedLines = false;
|
||||
|
||||
foreach (var rawLine in payload.Split('\n'))
|
||||
@@ -50,30 +51,37 @@ internal sealed class ResponseTokenCounter
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryExtractFromJson(line, out var lineTokens))
|
||||
if (TryExtractTokenCountsFromJson(line, out var prompt, out var completion))
|
||||
{
|
||||
parsedLines = true;
|
||||
total += lineTokens;
|
||||
totalPrompt += prompt;
|
||||
totalCompletion += completion;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedLines)
|
||||
{
|
||||
return total;
|
||||
return new TokenCounts(totalPrompt, totalCompletion, totalPrompt + totalCompletion);
|
||||
}
|
||||
|
||||
return TryExtractFromJson(payload, out var tokens) ? tokens : 0;
|
||||
if (TryExtractTokenCountsFromJson(payload, out var promptFallback, out var completionFallback))
|
||||
{
|
||||
return new TokenCounts(promptFallback, completionFallback, promptFallback + completionFallback);
|
||||
}
|
||||
|
||||
return new TokenCounts(0, 0, 0);
|
||||
}
|
||||
|
||||
private static bool TryExtractFromJson(string json, out int tokens)
|
||||
private static bool TryExtractTokenCountsFromJson(string json, out int promptTokens, out int completionTokens)
|
||||
{
|
||||
tokens = 0;
|
||||
promptTokens = 0;
|
||||
completionTokens = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
tokens = ExtractTokens(document.RootElement);
|
||||
return tokens > 0;
|
||||
ExtractTokenCounts(document.RootElement, out promptTokens, out completionTokens);
|
||||
return promptTokens > 0 || completionTokens > 0;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -81,70 +89,68 @@ internal sealed class ResponseTokenCounter
|
||||
}
|
||||
}
|
||||
|
||||
private static int ExtractTokens(JsonElement element)
|
||||
private static void ExtractTokenCounts(JsonElement element, out int promptTokens, out int completionTokens)
|
||||
{
|
||||
promptTokens = 0;
|
||||
completionTokens = 0;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
total += ExtractTokens(item);
|
||||
ExtractTokenCounts(item, out var itemPrompt, out var itemCompletion);
|
||||
promptTokens += itemPrompt;
|
||||
completionTokens += itemCompletion;
|
||||
}
|
||||
|
||||
return total;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (TryGetInt(usage, "total_tokens", out var totalTokens))
|
||||
if (TryGetInt(usage, "prompt_tokens", out var pt))
|
||||
{
|
||||
return totalTokens;
|
||||
promptTokens += pt;
|
||||
}
|
||||
|
||||
var usageTotal = 0;
|
||||
if (TryGetInt(usage, "prompt_tokens", out var promptTokens))
|
||||
if (TryGetInt(usage, "completion_tokens", out var ct))
|
||||
{
|
||||
usageTotal += promptTokens;
|
||||
completionTokens += ct;
|
||||
}
|
||||
|
||||
if (TryGetInt(usage, "completion_tokens", out var completionTokens))
|
||||
if (promptTokens == 0 && completionTokens == 0)
|
||||
{
|
||||
usageTotal += completionTokens;
|
||||
if (TryGetInt(usage, "input_tokens", out var it))
|
||||
{
|
||||
promptTokens += it;
|
||||
}
|
||||
|
||||
if (TryGetInt(usage, "output_tokens", out var ot))
|
||||
{
|
||||
completionTokens += ot;
|
||||
}
|
||||
}
|
||||
|
||||
if (TryGetInt(usage, "input_tokens", out var inputTokens))
|
||||
if (promptTokens > 0 || completionTokens > 0)
|
||||
{
|
||||
usageTotal += inputTokens;
|
||||
}
|
||||
|
||||
if (TryGetInt(usage, "output_tokens", out var outputTokens))
|
||||
{
|
||||
usageTotal += outputTokens;
|
||||
}
|
||||
|
||||
if (usageTotal > 0)
|
||||
{
|
||||
return usageTotal;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var ollamaTotal = 0;
|
||||
if (TryGetInt(element, "prompt_eval_count", out var promptEvalCount))
|
||||
if (TryGetInt(element, "prompt_eval_count", out var promptEval))
|
||||
{
|
||||
ollamaTotal += promptEvalCount;
|
||||
promptTokens += promptEval;
|
||||
}
|
||||
|
||||
if (TryGetInt(element, "eval_count", out var evalCount))
|
||||
if (TryGetInt(element, "eval_count", out var eval))
|
||||
{
|
||||
ollamaTotal += evalCount;
|
||||
completionTokens += eval;
|
||||
}
|
||||
|
||||
return ollamaTotal;
|
||||
}
|
||||
|
||||
private static bool TryGetInt(JsonElement element, string propertyName, out int value)
|
||||
@@ -155,3 +161,5 @@ internal sealed class ResponseTokenCounter
|
||||
&& property.TryGetInt32(out value);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record TokenCounts(int PromptTokens, int CompletionTokens, int TotalTokens);
|
||||
+74
-17
@@ -1,13 +1,14 @@
|
||||
using System.Text.Json;
|
||||
using ElmahCore;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal static class ReverseProxyEndpoint
|
||||
{
|
||||
private const string UnauthorizedMessage = "Missing or invalid ReverseLlama token.";
|
||||
private const string UnauthorizedMessage = "Missing or invalid Ngino token.";
|
||||
|
||||
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -43,7 +44,21 @@ internal static class ReverseProxyEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
||||
var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
|
||||
if (!billingCheck.Allowed)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Insufficient balance.",
|
||||
balance = billingCheck.Balance,
|
||||
currency = billingCheck.Currency,
|
||||
threshold = billingCheck.Threshold
|
||||
}, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
|
||||
|
||||
var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
|
||||
if (!pathTokenRemoved)
|
||||
@@ -76,7 +91,8 @@ internal static class ReverseProxyEndpoint
|
||||
loggerFactory,
|
||||
embeddingCache,
|
||||
managementStore,
|
||||
groupAccess);
|
||||
groupAccess,
|
||||
auth.UserKeyId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,7 +140,8 @@ internal static class ReverseProxyEndpoint
|
||||
loggerFactory,
|
||||
embeddingCache,
|
||||
embeddingRequest,
|
||||
managementStore);
|
||||
managementStore,
|
||||
auth.UserKeyId);
|
||||
}
|
||||
|
||||
public static async Task HandleClientAsync(
|
||||
@@ -145,7 +162,21 @@ internal static class ReverseProxyEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
||||
var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
|
||||
if (!billingCheck.Allowed)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Insufficient balance.",
|
||||
balance = billingCheck.Balance,
|
||||
currency = billingCheck.Currency,
|
||||
threshold = billingCheck.Threshold
|
||||
}, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
|
||||
if (!groupAccess.IsClientAllowed(clientId))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
@@ -165,7 +196,8 @@ internal static class ReverseProxyEndpoint
|
||||
loggerFactory,
|
||||
embeddingCache,
|
||||
managementStore,
|
||||
groupAccess);
|
||||
groupAccess,
|
||||
auth.UserKeyId);
|
||||
}
|
||||
|
||||
private static async Task ForwardToClientAsync(
|
||||
@@ -178,7 +210,8 @@ internal static class ReverseProxyEndpoint
|
||||
ILoggerFactory loggerFactory,
|
||||
EmbeddingCache embeddingCache,
|
||||
ManagementStore managementStore,
|
||||
GroupAccess? groupAccess = null)
|
||||
GroupAccess? groupAccess = null,
|
||||
string? userKeyId = null)
|
||||
{
|
||||
var clientAccess = managementStore.GetClientAccess(clientId);
|
||||
if (clientAccess.IsDisabled)
|
||||
@@ -220,7 +253,8 @@ internal static class ReverseProxyEndpoint
|
||||
loggerFactory,
|
||||
embeddingCache,
|
||||
embeddingRequest,
|
||||
managementStore);
|
||||
managementStore,
|
||||
userKeyId);
|
||||
}
|
||||
|
||||
private static bool IsRootPath(PathString path) =>
|
||||
@@ -269,14 +303,14 @@ internal static class ReverseProxyEndpoint
|
||||
return true;
|
||||
}
|
||||
|
||||
private static GroupAccess ResolveGroupAccess(string? apiKeyId, ManagementStore managementStore)
|
||||
private static GroupAccess ResolveGroupAccess(string? userKeyId, ManagementStore managementStore)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(apiKeyId))
|
||||
if (string.IsNullOrWhiteSpace(userKeyId))
|
||||
{
|
||||
return GroupAccess.Unrestricted;
|
||||
}
|
||||
|
||||
return managementStore.ResolveGroupAccess(apiKeyId);
|
||||
return managementStore.ResolveGroupAccess(userKeyId);
|
||||
}
|
||||
|
||||
private static bool IsTagsRequest(HttpRequest request, PathString proxyPath) =>
|
||||
@@ -464,9 +498,10 @@ internal static class ReverseProxyEndpoint
|
||||
ILoggerFactory loggerFactory,
|
||||
EmbeddingCache embeddingCache,
|
||||
EmbeddingCacheRequest? embeddingRequest,
|
||||
ManagementStore managementStore)
|
||||
ManagementStore managementStore,
|
||||
string? userKeyId = null)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("ReverseLlama.Server.ReverseProxy");
|
||||
var logger = loggerFactory.CreateLogger("Ngino.Server.ReverseProxy");
|
||||
var requestId = Guid.NewGuid().ToString("n");
|
||||
var pending = connection.RegisterPending(requestId);
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
@@ -535,10 +570,16 @@ internal static class ReverseProxyEndpoint
|
||||
{
|
||||
logger.LogWarning(exception, "Proxy request {RequestId} failed.", requestId);
|
||||
|
||||
var errorLog = context.RequestServices.GetService<ErrorLog>();
|
||||
if (errorLog is not null)
|
||||
{
|
||||
await errorLog.LogAsync(new Error(exception, context));
|
||||
}
|
||||
|
||||
if (!context.Response.HasStarted)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsync(exception.Message, CancellationToken.None);
|
||||
await context.Response.WriteAsync("Bad gateway", CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -549,13 +590,29 @@ internal static class ReverseProxyEndpoint
|
||||
{
|
||||
connection.RemovePending(requestId);
|
||||
var completedAt = DateTimeOffset.UtcNow;
|
||||
var tokenCounts = tokenCounter.CountTokens();
|
||||
|
||||
var cost = 0.0;
|
||||
if (!string.IsNullOrWhiteSpace(userKeyId) && tokenCounts.TotalTokens > 0)
|
||||
{
|
||||
var billing = managementStore.ResolveBillingForUserKey(userKeyId);
|
||||
if (billing is not null)
|
||||
{
|
||||
cost = managementStore.CalculateCost(billing.GroupId, requestedModel, tokenCounts.TotalTokens);
|
||||
}
|
||||
}
|
||||
|
||||
managementStore.RecordRequest(new RequestMetric(
|
||||
connection.ClientId,
|
||||
requestedModel,
|
||||
context.Request.Method,
|
||||
pathAndQuery,
|
||||
statusCode ?? (context.Response.HasStarted ? context.Response.StatusCode : null),
|
||||
tokenCounter.CountTokens(),
|
||||
tokenCounts.PromptTokens,
|
||||
tokenCounts.CompletionTokens,
|
||||
tokenCounts.TotalTokens,
|
||||
userKeyId,
|
||||
cost,
|
||||
startedAt,
|
||||
completedAt,
|
||||
completedAt - startedAt));
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class ServerSettings
|
||||
{
|
||||
@@ -11,42 +11,57 @@ internal sealed class ServerSettings
|
||||
|
||||
public string? Token { get; init; }
|
||||
|
||||
public string? ClientToken { get; init; }
|
||||
|
||||
public int ChunkSize { get; init; } = 64 * 1024;
|
||||
|
||||
public string? EmbeddingCachePath { get; init; }
|
||||
|
||||
public string? ManagementDatabasePath { get; init; }
|
||||
|
||||
public bool SecureCookies { get; init; } = true;
|
||||
|
||||
public KeycloakSettings Keycloak { get; init; } = new();
|
||||
|
||||
public CorsSettings Cors { get; init; } = new();
|
||||
|
||||
public static ServerSettings FromConfiguration(IConfiguration configuration)
|
||||
{
|
||||
return new ServerSettings
|
||||
{
|
||||
StatusPath = NormalizePath(Read(configuration, "ReverseLlama:StatusPath", "status-path") ?? ProtocolConstants.DefaultStatusPath),
|
||||
TunnelPath = NormalizePath(Read(configuration, "ReverseLlama:TunnelPath", "tunnel-path") ?? ProtocolConstants.DefaultTunnelPath),
|
||||
Token = Read(configuration, "ReverseLlama:Token", "token") ?? Environment.GetEnvironmentVariable("REVERSE_LLAMA_TOKEN"),
|
||||
ChunkSize = ReadInt(configuration, 64 * 1024, "ReverseLlama:ChunkSize", "chunk-size", "REVERSE_LLAMA_CHUNK_SIZE"),
|
||||
StatusPath = NormalizePath(Read(configuration, "Ngino:StatusPath", "status-path") ?? ProtocolConstants.DefaultStatusPath),
|
||||
TunnelPath = NormalizePath(Read(configuration, "Ngino:TunnelPath", "tunnel-path") ?? ProtocolConstants.DefaultTunnelPath),
|
||||
Token = Read(configuration, "Ngino:Token", "token") ?? Environment.GetEnvironmentVariable("NGINO_TOKEN"),
|
||||
ClientToken = Read(configuration, "Ngino:ClientToken", "client-token") ?? Environment.GetEnvironmentVariable("NGINO_CLIENT_TOKEN"),
|
||||
ChunkSize = ReadInt(configuration, 64 * 1024, "Ngino:ChunkSize", "chunk-size", "NGINO_CHUNK_SIZE"),
|
||||
EmbeddingCachePath = Read(
|
||||
configuration,
|
||||
"ReverseLlama:EmbeddingCachePath",
|
||||
"Ngino:EmbeddingCachePath",
|
||||
"embedding-cache-path",
|
||||
"REVERSE_LLAMA_EMBEDDING_CACHE_PATH"),
|
||||
"NGINO_EMBEDDING_CACHE_PATH"),
|
||||
ManagementDatabasePath = Read(
|
||||
configuration,
|
||||
"ReverseLlama:ManagementDatabasePath",
|
||||
"Ngino:ManagementDatabasePath",
|
||||
"management-database-path",
|
||||
"REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH"),
|
||||
"NGINO_MANAGEMENT_DATABASE_PATH"),
|
||||
SecureCookies = ReadBool(configuration, true, "Ngino:SecureCookies", "secure-cookies", "NGINO_SECURE_COOKIES"),
|
||||
Keycloak = new KeycloakSettings
|
||||
{
|
||||
Authority = Read(configuration, "Authentication:Keycloak:Authority", "REVERSE_LLAMA_KEYCLOAK_AUTHORITY"),
|
||||
ClientId = Read(configuration, "Authentication:Keycloak:ClientId", "REVERSE_LLAMA_KEYCLOAK_CLIENT_ID"),
|
||||
ClientSecret = Read(configuration, "Authentication:Keycloak:ClientSecret", "REVERSE_LLAMA_KEYCLOAK_CLIENT_SECRET"),
|
||||
Authority = Read(configuration, "Authentication:Keycloak:Authority", "NGINO_KEYCLOAK_AUTHORITY"),
|
||||
ClientId = Read(configuration, "Authentication:Keycloak:ClientId", "NGINO_KEYCLOAK_CLIENT_ID"),
|
||||
ClientSecret = Read(configuration, "Authentication:Keycloak:ClientSecret", "NGINO_KEYCLOAK_CLIENT_SECRET"),
|
||||
RequireHttpsMetadata = ReadBool(
|
||||
configuration,
|
||||
true,
|
||||
"Authentication:Keycloak:RequireHttpsMetadata",
|
||||
"REVERSE_LLAMA_KEYCLOAK_REQUIRE_HTTPS_METADATA")
|
||||
"NGINO_KEYCLOAK_REQUIRE_HTTPS_METADATA")
|
||||
},
|
||||
Cors = new CorsSettings
|
||||
{
|
||||
AllowedOrigins = ReadStringArray(configuration, ["CORS:AllowedOrigins"]),
|
||||
AllowedMethods = ReadStringArray(configuration, ["CORS:AllowedMethods"]),
|
||||
AllowedHeaders = ReadStringArray(configuration, ["CORS:AllowedHeaders"]),
|
||||
AllowCredentials = ReadBool(configuration, false, "CORS:AllowCredentials")
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -77,10 +92,39 @@ internal sealed class ServerSettings
|
||||
return bool.TryParse(value, out var parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
private static string[] ReadStringArray(IConfiguration configuration, params string[] keys)
|
||||
{
|
||||
var section = configuration.GetSection(keys[0]);
|
||||
var children = section.GetChildren().ToList();
|
||||
if (children.Count > 0)
|
||||
{
|
||||
return children.Select(c => c.Value!).Where(v => !string.IsNullOrWhiteSpace(v)).ToArray();
|
||||
}
|
||||
|
||||
var value = Read(configuration, keys);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path) =>
|
||||
path.StartsWith('/') ? path : $"/{path}";
|
||||
}
|
||||
|
||||
internal sealed class CorsSettings
|
||||
{
|
||||
public string[] AllowedOrigins { get; init; } = ["*"];
|
||||
|
||||
public string[] AllowedMethods { get; init; } = ["*"];
|
||||
|
||||
public string[] AllowedHeaders { get; init; } = ["*"];
|
||||
|
||||
public bool AllowCredentials { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class KeycloakSettings
|
||||
{
|
||||
public string? Authority { get; init; }
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal static class TokenAuthentication
|
||||
{
|
||||
private static readonly PathString PathTokenPrefix = new("/token");
|
||||
|
||||
public static AuthResult Authorize(
|
||||
HttpRequest request,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool allowQueryToken,
|
||||
bool allowPathToken = false)
|
||||
{
|
||||
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues))
|
||||
{
|
||||
foreach (var value in headerValues)
|
||||
{
|
||||
var result = AuthorizeUserToken(value, settings, managementStore, updateUserKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue("Authorization", out var authorizationValues))
|
||||
{
|
||||
foreach (var value in authorizationValues)
|
||||
{
|
||||
if (TryGetBearerToken(value, out var bearerToken))
|
||||
{
|
||||
var result = AuthorizeUserToken(bearerToken, settings, managementStore, updateUserKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path-token auth: useful for clients that cannot send headers.
|
||||
// SECURITY: the token appears in the URL and will be logged by
|
||||
// web servers, proxies, and browsers. Prefer header auth when possible.
|
||||
if (allowPathToken
|
||||
&& TryGetPathToken(request.Path, out var pathToken, out _))
|
||||
{
|
||||
var result = AuthorizeUserToken(pathToken, settings, managementStore, updateUserKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Query-string auth: needed for clients that cannot send headers
|
||||
// (e.g. browser address bar, status page).
|
||||
// SECURITY: same URL-logging risks as path-token auth above.
|
||||
if (allowQueryToken
|
||||
&& request.Query.TryGetValue("token", out var queryValues))
|
||||
{
|
||||
foreach (var value in queryValues)
|
||||
{
|
||||
var result = AuthorizeUserToken(value, settings, managementStore, updateUserKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthResult.Failure;
|
||||
}
|
||||
|
||||
public static AuthResult AuthorizeClient(
|
||||
HttpRequest request,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool allowQueryToken,
|
||||
bool allowPathToken = false)
|
||||
{
|
||||
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues))
|
||||
{
|
||||
foreach (var value in headerValues)
|
||||
{
|
||||
var result = AuthorizeClientToken(value, settings, managementStore, updateClientKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue("Authorization", out var authorizationValues))
|
||||
{
|
||||
foreach (var value in authorizationValues)
|
||||
{
|
||||
if (TryGetBearerToken(value, out var bearerToken))
|
||||
{
|
||||
var result = AuthorizeClientToken(bearerToken, settings, managementStore, updateClientKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allowPathToken
|
||||
&& TryGetPathToken(request.Path, out var pathToken, out _))
|
||||
{
|
||||
var result = AuthorizeClientToken(pathToken, settings, managementStore, updateClientKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowQueryToken
|
||||
&& request.Query.TryGetValue("token", out var queryValues))
|
||||
{
|
||||
foreach (var value in queryValues)
|
||||
{
|
||||
var result = AuthorizeClientToken(value, settings, managementStore, updateClientKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthResult.Failure;
|
||||
}
|
||||
|
||||
public static bool IsAuthorized(
|
||||
HttpRequest request,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool allowQueryToken,
|
||||
bool allowPathToken = false) =>
|
||||
Authorize(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized;
|
||||
|
||||
public static bool IsClientAuthorized(
|
||||
HttpRequest request,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool allowQueryToken,
|
||||
bool allowPathToken = false) =>
|
||||
AuthorizeClient(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized;
|
||||
|
||||
public static bool TryRemovePathToken(
|
||||
PathString path,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
out PathString remainingPath)
|
||||
{
|
||||
remainingPath = path;
|
||||
|
||||
if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath)
|
||||
|| !IsUserTokenAuthorized(pathToken, settings, managementStore, updateUserKeyLastUsed: false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
remainingPath = string.IsNullOrEmpty(tokenRemainingPath.Value)
|
||||
? new PathString("/")
|
||||
: tokenRemainingPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) =>
|
||||
TryGetBearerToken(value, out var token)
|
||||
&& IsUserTokenAuthorized(token, settings, managementStore, updateUserKeyLastUsed: false);
|
||||
|
||||
private static AuthResult AuthorizeUserToken(
|
||||
string? token,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool updateUserKeyLastUsed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return AuthResult.Failure;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Token)
|
||||
&& CryptographicOperations.FixedTimeEquals(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(token)),
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(settings.Token))))
|
||||
{
|
||||
return AuthResult.Success(null);
|
||||
}
|
||||
|
||||
var userKeyId = managementStore.GetUserKeyId(token);
|
||||
if (userKeyId is not null)
|
||||
{
|
||||
managementStore.IsUserKeyValid(token, updateUserKeyLastUsed);
|
||||
return AuthResult.Success(userKeyId);
|
||||
}
|
||||
|
||||
return AuthResult.Failure;
|
||||
}
|
||||
|
||||
private static AuthResult AuthorizeClientToken(
|
||||
string? token,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool updateClientKeyLastUsed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return AuthResult.Failure;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.ClientToken)
|
||||
&& CryptographicOperations.FixedTimeEquals(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(token)),
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(settings.ClientToken))))
|
||||
{
|
||||
return AuthResult.Success(null);
|
||||
}
|
||||
|
||||
var clientKeyId = managementStore.GetClientKeyId(token);
|
||||
if (clientKeyId is not null)
|
||||
{
|
||||
managementStore.IsClientKeyValid(token, updateClientKeyLastUsed);
|
||||
return AuthResult.Success(clientKeyId);
|
||||
}
|
||||
|
||||
return AuthResult.Failure;
|
||||
}
|
||||
|
||||
public static bool IsTokenAuthorized(
|
||||
string? token,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool updateUserKeyLastUsed) =>
|
||||
AuthorizeUserToken(token, settings, managementStore, updateUserKeyLastUsed).IsAuthorized;
|
||||
|
||||
private static bool IsUserTokenAuthorized(
|
||||
string? token,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool updateUserKeyLastUsed) =>
|
||||
AuthorizeUserToken(token, settings, managementStore, updateUserKeyLastUsed).IsAuthorized;
|
||||
|
||||
private static bool TryGetBearerToken(string? authorization, out string token)
|
||||
{
|
||||
token = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authorization)
|
||||
|| !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
token = authorization["Bearer ".Length..].Trim();
|
||||
return token.Length > 0;
|
||||
}
|
||||
|
||||
private static bool TryGetPathToken(PathString path, out string pathToken, out PathString remainingPath)
|
||||
{
|
||||
pathToken = "";
|
||||
remainingPath = PathString.Empty;
|
||||
|
||||
if (!path.StartsWithSegments(PathTokenPrefix, out var pathAfterPrefix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = pathAfterPrefix.Value ?? "";
|
||||
if (value.Length <= 1 || value[0] != '/')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var nextSlash = value.IndexOf('/', 1);
|
||||
pathToken = nextSlash < 0
|
||||
? value[1..]
|
||||
: value[1..nextSlash];
|
||||
|
||||
if (string.IsNullOrEmpty(pathToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
remainingPath = nextSlash < 0
|
||||
? PathString.Empty
|
||||
: new PathString(value[nextSlash..]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AuthResult
|
||||
{
|
||||
public static AuthResult Failure { get; } = new(false, null);
|
||||
|
||||
public static AuthResult Success(string? userKeyId) => new(true, userKeyId);
|
||||
|
||||
public bool IsAuthorized { get; }
|
||||
|
||||
public string? UserKeyId { get; }
|
||||
|
||||
private AuthResult(bool isAuthorized, string? userKeyId)
|
||||
{
|
||||
IsAuthorized = isAuthorized;
|
||||
UserKeyId = userKeyId;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class TunnelConnection
|
||||
{
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class TunnelHub
|
||||
{
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"Authentication": {
|
||||
"Keycloak": {
|
||||
"Authority": "http://your-keycloak-server/realms/master",
|
||||
"ClientId": "ReverseLlama",
|
||||
"ClientId": "Ngino",
|
||||
"ClientSecret": "YOUR-Client-SECRET-GOES-HERE-AND-YES-ITS-VERY-LONG",
|
||||
"RequireHttpsMetadata": false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"CORS": {
|
||||
"AllowedOrigins": [ "*" ],
|
||||
"AllowedMethods": [ "GET", "POST", "PUT", "DELETE", "PATCH" ],
|
||||
"AllowedHeaders": [ "Content-Type", "Authorization" ],
|
||||
"AllowCredentials": false
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
+75
-1
@@ -167,11 +167,48 @@ textarea {
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-alt);
|
||||
color: var(--blue);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.help-button.active {
|
||||
background: var(--blue);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.help-popover {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
@@ -479,6 +516,43 @@ tr:last-child td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.balance-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.balance-current strong {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.balance-current span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.balance-detail {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.billing-toggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.billing-toggle input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -494,7 +568,7 @@ tr:last-child td {
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
.nav a {
|
||||
+677
-68
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ReverseLlama Admin</title>
|
||||
<title>Ngino Admin</title>
|
||||
<link rel="stylesheet" href="/admin/app.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -12,15 +12,17 @@
|
||||
<div class="brand">
|
||||
<span class="brand-mark">RL</span>
|
||||
<span>
|
||||
<strong>ReverseLlama</strong>
|
||||
<strong>Ngino</strong>
|
||||
<small>Admin</small>
|
||||
</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="Admin sections">
|
||||
<a href="#clients" data-nav="clients">Clients</a>
|
||||
<a href="#models" data-nav="models">Models</a>
|
||||
<a href="#api-keys" data-nav="api-keys">API keys</a>
|
||||
<a href="#client-keys" data-nav="client-keys">Client keys</a>
|
||||
<a href="#user-keys" data-nav="user-keys">User keys</a>
|
||||
<a href="#groups" data-nav="groups">Groups</a>
|
||||
<a href="#usage" data-nav="usage">Usage</a>
|
||||
</nav>
|
||||
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
|
||||
</aside>
|
||||
@@ -44,6 +46,7 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/admin/morphdom-umd.js"></script>
|
||||
<script src="/admin/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,783 @@
|
||||
/*!
|
||||
* morphdom
|
||||
* Copyright (c) Patrick Steele-Idem
|
||||
* Licensed under the MIT License.
|
||||
* See /docs/licenses/LICENSE.morphdom
|
||||
*/
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
|
||||
typeof define === 'function' && define.amd ? define(factory) :
|
||||
(global = global || self, global.morphdom = factory());
|
||||
}(this, function () { 'use strict';
|
||||
|
||||
var DOCUMENT_FRAGMENT_NODE = 11;
|
||||
|
||||
function morphAttrs(fromNode, toNode) {
|
||||
var toNodeAttrs = toNode.attributes;
|
||||
var attr;
|
||||
var attrName;
|
||||
var attrNamespaceURI;
|
||||
var attrValue;
|
||||
var fromValue;
|
||||
|
||||
// document-fragments dont have attributes so lets not do anything
|
||||
if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update attributes on original DOM element
|
||||
for (var i = toNodeAttrs.length - 1; i >= 0; i--) {
|
||||
attr = toNodeAttrs[i];
|
||||
attrName = attr.name;
|
||||
attrNamespaceURI = attr.namespaceURI;
|
||||
attrValue = attr.value;
|
||||
|
||||
if (attrNamespaceURI) {
|
||||
attrName = attr.localName || attrName;
|
||||
fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName);
|
||||
|
||||
if (fromValue !== attrValue) {
|
||||
if (attr.prefix === 'xmlns'){
|
||||
attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix
|
||||
}
|
||||
fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue);
|
||||
}
|
||||
} else {
|
||||
fromValue = fromNode.getAttribute(attrName);
|
||||
|
||||
if (fromValue !== attrValue) {
|
||||
fromNode.setAttribute(attrName, attrValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any extra attributes found on the original DOM element that
|
||||
// weren't found on the target element.
|
||||
var fromNodeAttrs = fromNode.attributes;
|
||||
|
||||
for (var d = fromNodeAttrs.length - 1; d >= 0; d--) {
|
||||
attr = fromNodeAttrs[d];
|
||||
attrName = attr.name;
|
||||
attrNamespaceURI = attr.namespaceURI;
|
||||
|
||||
if (attrNamespaceURI) {
|
||||
attrName = attr.localName || attrName;
|
||||
|
||||
if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) {
|
||||
fromNode.removeAttributeNS(attrNamespaceURI, attrName);
|
||||
}
|
||||
} else {
|
||||
if (!toNode.hasAttribute(attrName)) {
|
||||
fromNode.removeAttribute(attrName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var range; // Create a range object for efficently rendering strings to elements.
|
||||
var NS_XHTML = 'http://www.w3.org/1999/xhtml';
|
||||
|
||||
var doc = typeof document === 'undefined' ? undefined : document;
|
||||
var HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template');
|
||||
var HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange();
|
||||
|
||||
function createFragmentFromTemplate(str) {
|
||||
var template = doc.createElement('template');
|
||||
template.innerHTML = str;
|
||||
return template.content.childNodes[0];
|
||||
}
|
||||
|
||||
function createFragmentFromRange(str) {
|
||||
if (!range) {
|
||||
range = doc.createRange();
|
||||
range.selectNode(doc.body);
|
||||
}
|
||||
|
||||
var fragment = range.createContextualFragment(str);
|
||||
return fragment.childNodes[0];
|
||||
}
|
||||
|
||||
function createFragmentFromWrap(str) {
|
||||
var fragment = doc.createElement('body');
|
||||
fragment.innerHTML = str;
|
||||
return fragment.childNodes[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* This is about the same
|
||||
* var html = new DOMParser().parseFromString(str, 'text/html');
|
||||
* return html.body.firstChild;
|
||||
*
|
||||
* @method toElement
|
||||
* @param {String} str
|
||||
*/
|
||||
function toElement(str) {
|
||||
str = str.trim();
|
||||
if (HAS_TEMPLATE_SUPPORT) {
|
||||
// avoid restrictions on content for things like `<tr><th>Hi</th></tr>` which
|
||||
// createContextualFragment doesn't support
|
||||
// <template> support not available in IE
|
||||
return createFragmentFromTemplate(str);
|
||||
} else if (HAS_RANGE_SUPPORT) {
|
||||
return createFragmentFromRange(str);
|
||||
}
|
||||
|
||||
return createFragmentFromWrap(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if two node's names are the same.
|
||||
*
|
||||
* NOTE: We don't bother checking `namespaceURI` because you will never find two HTML elements with the same
|
||||
* nodeName and different namespace URIs.
|
||||
*
|
||||
* @param {Element} a
|
||||
* @param {Element} b The target element
|
||||
* @return {boolean}
|
||||
*/
|
||||
function compareNodeNames(fromEl, toEl) {
|
||||
var fromNodeName = fromEl.nodeName;
|
||||
var toNodeName = toEl.nodeName;
|
||||
var fromCodeStart, toCodeStart;
|
||||
|
||||
if (fromNodeName === toNodeName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
fromCodeStart = fromNodeName.charCodeAt(0);
|
||||
toCodeStart = toNodeName.charCodeAt(0);
|
||||
|
||||
// If the target element is a virtual DOM node or SVG node then we may
|
||||
// need to normalize the tag name before comparing. Normal HTML elements that are
|
||||
// in the "http://www.w3.org/1999/xhtml"
|
||||
// are converted to upper case
|
||||
if (fromCodeStart <= 90 && toCodeStart >= 97) { // from is upper and to is lower
|
||||
return fromNodeName === toNodeName.toUpperCase();
|
||||
} else if (toCodeStart <= 90 && fromCodeStart >= 97) { // to is upper and from is lower
|
||||
return toNodeName === fromNodeName.toUpperCase();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an element, optionally with a known namespace URI.
|
||||
*
|
||||
* @param {string} name the element name, e.g. 'div' or 'svg'
|
||||
* @param {string} [namespaceURI] the element's namespace URI, i.e. the value of
|
||||
* its `xmlns` attribute or its inferred namespace.
|
||||
*
|
||||
* @return {Element}
|
||||
*/
|
||||
function createElementNS(name, namespaceURI) {
|
||||
return !namespaceURI || namespaceURI === NS_XHTML ?
|
||||
doc.createElement(name) :
|
||||
doc.createElementNS(namespaceURI, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the children of one DOM element to another DOM element
|
||||
*/
|
||||
function moveChildren(fromEl, toEl) {
|
||||
var curChild = fromEl.firstChild;
|
||||
while (curChild) {
|
||||
var nextChild = curChild.nextSibling;
|
||||
toEl.appendChild(curChild);
|
||||
curChild = nextChild;
|
||||
}
|
||||
return toEl;
|
||||
}
|
||||
|
||||
function syncBooleanAttrProp(fromEl, toEl, name) {
|
||||
if (fromEl[name] !== toEl[name]) {
|
||||
fromEl[name] = toEl[name];
|
||||
if (fromEl[name]) {
|
||||
fromEl.setAttribute(name, '');
|
||||
} else {
|
||||
fromEl.removeAttribute(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var specialElHandlers = {
|
||||
OPTION: function(fromEl, toEl) {
|
||||
var parentNode = fromEl.parentNode;
|
||||
if (parentNode) {
|
||||
var parentName = parentNode.nodeName.toUpperCase();
|
||||
if (parentName === 'OPTGROUP') {
|
||||
parentNode = parentNode.parentNode;
|
||||
parentName = parentNode && parentNode.nodeName.toUpperCase();
|
||||
}
|
||||
if (parentName === 'SELECT' && !parentNode.hasAttribute('multiple')) {
|
||||
if (fromEl.hasAttribute('selected') && !toEl.selected) {
|
||||
// Workaround for MS Edge bug where the 'selected' attribute can only be
|
||||
// removed if set to a non-empty value:
|
||||
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12087679/
|
||||
fromEl.setAttribute('selected', 'selected');
|
||||
fromEl.removeAttribute('selected');
|
||||
}
|
||||
// We have to reset select element's selectedIndex to -1, otherwise setting
|
||||
// fromEl.selected using the syncBooleanAttrProp below has no effect.
|
||||
// The correct selectedIndex will be set in the SELECT special handler below.
|
||||
parentNode.selectedIndex = -1;
|
||||
}
|
||||
}
|
||||
syncBooleanAttrProp(fromEl, toEl, 'selected');
|
||||
},
|
||||
/**
|
||||
* The "value" attribute is special for the <input> element since it sets
|
||||
* the initial value. Changing the "value" attribute without changing the
|
||||
* "value" property will have no effect since it is only used to the set the
|
||||
* initial value. Similar for the "checked" attribute, and "disabled".
|
||||
*/
|
||||
INPUT: function(fromEl, toEl) {
|
||||
syncBooleanAttrProp(fromEl, toEl, 'checked');
|
||||
syncBooleanAttrProp(fromEl, toEl, 'disabled');
|
||||
|
||||
if (fromEl.value !== toEl.value) {
|
||||
fromEl.value = toEl.value;
|
||||
}
|
||||
|
||||
if (!toEl.hasAttribute('value')) {
|
||||
fromEl.removeAttribute('value');
|
||||
}
|
||||
},
|
||||
|
||||
TEXTAREA: function(fromEl, toEl) {
|
||||
var newValue = toEl.value;
|
||||
if (fromEl.value !== newValue) {
|
||||
fromEl.value = newValue;
|
||||
}
|
||||
|
||||
var firstChild = fromEl.firstChild;
|
||||
if (firstChild) {
|
||||
// Needed for IE. Apparently IE sets the placeholder as the
|
||||
// node value and vise versa. This ignores an empty update.
|
||||
var oldValue = firstChild.nodeValue;
|
||||
|
||||
if (oldValue == newValue || (!newValue && oldValue == fromEl.placeholder)) {
|
||||
return;
|
||||
}
|
||||
|
||||
firstChild.nodeValue = newValue;
|
||||
}
|
||||
},
|
||||
SELECT: function(fromEl, toEl) {
|
||||
if (!toEl.hasAttribute('multiple')) {
|
||||
var selectedIndex = -1;
|
||||
var i = 0;
|
||||
// We have to loop through children of fromEl, not toEl since nodes can be moved
|
||||
// from toEl to fromEl directly when morphing.
|
||||
// At the time this special handler is invoked, all children have already been morphed
|
||||
// and appended to / removed from fromEl, so using fromEl here is safe and correct.
|
||||
var curChild = fromEl.firstChild;
|
||||
var optgroup;
|
||||
var nodeName;
|
||||
while(curChild) {
|
||||
nodeName = curChild.nodeName && curChild.nodeName.toUpperCase();
|
||||
if (nodeName === 'OPTGROUP') {
|
||||
optgroup = curChild;
|
||||
curChild = optgroup.firstChild;
|
||||
} else {
|
||||
if (nodeName === 'OPTION') {
|
||||
if (curChild.hasAttribute('selected')) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
curChild = curChild.nextSibling;
|
||||
if (!curChild && optgroup) {
|
||||
curChild = optgroup.nextSibling;
|
||||
optgroup = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fromEl.selectedIndex = selectedIndex;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var ELEMENT_NODE = 1;
|
||||
var DOCUMENT_FRAGMENT_NODE$1 = 11;
|
||||
var TEXT_NODE = 3;
|
||||
var COMMENT_NODE = 8;
|
||||
|
||||
function noop() {}
|
||||
|
||||
function defaultGetNodeKey(node) {
|
||||
if (node) {
|
||||
return (node.getAttribute && node.getAttribute('id')) || node.id;
|
||||
}
|
||||
}
|
||||
|
||||
function morphdomFactory(morphAttrs) {
|
||||
|
||||
return function morphdom(fromNode, toNode, options) {
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
|
||||
if (typeof toNode === 'string') {
|
||||
if (fromNode.nodeName === '#document' || fromNode.nodeName === 'HTML' || fromNode.nodeName === 'BODY') {
|
||||
var toNodeHtml = toNode;
|
||||
toNode = doc.createElement('html');
|
||||
toNode.innerHTML = toNodeHtml;
|
||||
} else {
|
||||
toNode = toElement(toNode);
|
||||
}
|
||||
} else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
|
||||
toNode = toNode.firstElementChild;
|
||||
}
|
||||
|
||||
var getNodeKey = options.getNodeKey || defaultGetNodeKey;
|
||||
var onBeforeNodeAdded = options.onBeforeNodeAdded || noop;
|
||||
var onNodeAdded = options.onNodeAdded || noop;
|
||||
var onBeforeElUpdated = options.onBeforeElUpdated || noop;
|
||||
var onElUpdated = options.onElUpdated || noop;
|
||||
var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop;
|
||||
var onNodeDiscarded = options.onNodeDiscarded || noop;
|
||||
var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop;
|
||||
var skipFromChildren = options.skipFromChildren || noop;
|
||||
var addChild = options.addChild || function(parent, child){ return parent.appendChild(child); };
|
||||
var childrenOnly = options.childrenOnly === true;
|
||||
|
||||
// This object is used as a lookup to quickly find all keyed elements in the original DOM tree.
|
||||
var fromNodesLookup = Object.create(null);
|
||||
var keyedRemovalList = [];
|
||||
|
||||
function addKeyedRemoval(key) {
|
||||
keyedRemovalList.push(key);
|
||||
}
|
||||
|
||||
function walkDiscardedChildNodes(node, skipKeyedNodes) {
|
||||
if (node.nodeType === ELEMENT_NODE) {
|
||||
var curChild = node.firstChild;
|
||||
while (curChild) {
|
||||
|
||||
var key = undefined;
|
||||
|
||||
if (skipKeyedNodes && (key = getNodeKey(curChild))) {
|
||||
// If we are skipping keyed nodes then we add the key
|
||||
// to a list so that it can be handled at the very end.
|
||||
addKeyedRemoval(key);
|
||||
} else {
|
||||
// Only report the node as discarded if it is not keyed. We do this because
|
||||
// at the end we loop through all keyed elements that were unmatched
|
||||
// and then discard them in one final pass.
|
||||
onNodeDiscarded(curChild);
|
||||
if (curChild.firstChild) {
|
||||
walkDiscardedChildNodes(curChild, skipKeyedNodes);
|
||||
}
|
||||
}
|
||||
|
||||
curChild = curChild.nextSibling;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a DOM node out of the original DOM
|
||||
*
|
||||
* @param {Node} node The node to remove
|
||||
* @param {Node} parentNode The nodes parent
|
||||
* @param {Boolean} skipKeyedNodes If true then elements with keys will be skipped and not discarded.
|
||||
* @return {undefined}
|
||||
*/
|
||||
function removeNode(node, parentNode, skipKeyedNodes) {
|
||||
if (onBeforeNodeDiscarded(node) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentNode) {
|
||||
parentNode.removeChild(node);
|
||||
}
|
||||
|
||||
onNodeDiscarded(node);
|
||||
walkDiscardedChildNodes(node, skipKeyedNodes);
|
||||
}
|
||||
|
||||
// // TreeWalker implementation is no faster, but keeping this around in case this changes in the future
|
||||
// function indexTree(root) {
|
||||
// var treeWalker = document.createTreeWalker(
|
||||
// root,
|
||||
// NodeFilter.SHOW_ELEMENT);
|
||||
//
|
||||
// var el;
|
||||
// while((el = treeWalker.nextNode())) {
|
||||
// var key = getNodeKey(el);
|
||||
// if (key) {
|
||||
// fromNodesLookup[key] = el;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // NodeIterator implementation is no faster, but keeping this around in case this changes in the future
|
||||
//
|
||||
// function indexTree(node) {
|
||||
// var nodeIterator = document.createNodeIterator(node, NodeFilter.SHOW_ELEMENT);
|
||||
// var el;
|
||||
// while((el = nodeIterator.nextNode())) {
|
||||
// var key = getNodeKey(el);
|
||||
// if (key) {
|
||||
// fromNodesLookup[key] = el;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
function indexTree(node) {
|
||||
if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
|
||||
var curChild = node.firstChild;
|
||||
while (curChild) {
|
||||
var key = getNodeKey(curChild);
|
||||
if (key) {
|
||||
fromNodesLookup[key] = curChild;
|
||||
}
|
||||
|
||||
// Walk recursively
|
||||
indexTree(curChild);
|
||||
|
||||
curChild = curChild.nextSibling;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexTree(fromNode);
|
||||
|
||||
function handleNodeAdded(el) {
|
||||
onNodeAdded(el);
|
||||
|
||||
var curChild = el.firstChild;
|
||||
while (curChild) {
|
||||
var nextSibling = curChild.nextSibling;
|
||||
|
||||
var key = getNodeKey(curChild);
|
||||
if (key) {
|
||||
var unmatchedFromEl = fromNodesLookup[key];
|
||||
// if we find a duplicate #id node in cache, replace `el` with cache value
|
||||
// and morph it to the child node.
|
||||
if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) {
|
||||
curChild.parentNode.replaceChild(unmatchedFromEl, curChild);
|
||||
morphEl(unmatchedFromEl, curChild);
|
||||
} else {
|
||||
handleNodeAdded(curChild);
|
||||
}
|
||||
} else {
|
||||
// recursively call for curChild and it's children to see if we find something in
|
||||
// fromNodesLookup
|
||||
handleNodeAdded(curChild);
|
||||
}
|
||||
|
||||
curChild = nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) {
|
||||
// We have processed all of the "to nodes". If curFromNodeChild is
|
||||
// non-null then we still have some from nodes left over that need
|
||||
// to be removed
|
||||
while (curFromNodeChild) {
|
||||
var fromNextSibling = curFromNodeChild.nextSibling;
|
||||
if ((curFromNodeKey = getNodeKey(curFromNodeChild))) {
|
||||
// Since the node is keyed it might be matched up later so we defer
|
||||
// the actual removal to later
|
||||
addKeyedRemoval(curFromNodeKey);
|
||||
} else {
|
||||
// NOTE: we skip nested keyed nodes from being removed since there is
|
||||
// still a chance they will be matched up later
|
||||
removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
|
||||
}
|
||||
curFromNodeChild = fromNextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
function morphEl(fromEl, toEl, childrenOnly) {
|
||||
var toElKey = getNodeKey(toEl);
|
||||
|
||||
if (toElKey) {
|
||||
// If an element with an ID is being morphed then it will be in the final
|
||||
// DOM so clear it out of the saved elements collection
|
||||
delete fromNodesLookup[toElKey];
|
||||
}
|
||||
|
||||
if (!childrenOnly) {
|
||||
// optional
|
||||
var beforeUpdateResult = onBeforeElUpdated(fromEl, toEl);
|
||||
if (beforeUpdateResult === false) {
|
||||
return;
|
||||
} else if (beforeUpdateResult instanceof HTMLElement) {
|
||||
fromEl = beforeUpdateResult;
|
||||
// reindex the new fromEl in case it's not in the same
|
||||
// tree as the original fromEl
|
||||
// (Phoenix LiveView sometimes returns a cloned tree,
|
||||
// but keyed lookups would still point to the original tree)
|
||||
indexTree(fromEl);
|
||||
}
|
||||
|
||||
// update attributes on original DOM element first
|
||||
morphAttrs(fromEl, toEl);
|
||||
// optional
|
||||
onElUpdated(fromEl);
|
||||
|
||||
if (onBeforeElChildrenUpdated(fromEl, toEl) === false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fromEl.nodeName !== 'TEXTAREA') {
|
||||
morphChildren(fromEl, toEl);
|
||||
} else {
|
||||
specialElHandlers.TEXTAREA(fromEl, toEl);
|
||||
}
|
||||
}
|
||||
|
||||
function morphChildren(fromEl, toEl) {
|
||||
var skipFrom = skipFromChildren(fromEl, toEl);
|
||||
var curToNodeChild = toEl.firstChild;
|
||||
var curFromNodeChild = fromEl.firstChild;
|
||||
var curToNodeKey;
|
||||
var curFromNodeKey;
|
||||
|
||||
var fromNextSibling;
|
||||
var toNextSibling;
|
||||
var matchingFromEl;
|
||||
|
||||
// walk the children
|
||||
outer: while (curToNodeChild) {
|
||||
toNextSibling = curToNodeChild.nextSibling;
|
||||
curToNodeKey = getNodeKey(curToNodeChild);
|
||||
|
||||
// walk the fromNode children all the way through
|
||||
while (!skipFrom && curFromNodeChild) {
|
||||
fromNextSibling = curFromNodeChild.nextSibling;
|
||||
|
||||
if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) {
|
||||
curToNodeChild = toNextSibling;
|
||||
curFromNodeChild = fromNextSibling;
|
||||
continue outer;
|
||||
}
|
||||
|
||||
curFromNodeKey = getNodeKey(curFromNodeChild);
|
||||
|
||||
var curFromNodeType = curFromNodeChild.nodeType;
|
||||
|
||||
// this means if the curFromNodeChild doesnt have a match with the curToNodeChild
|
||||
var isCompatible = undefined;
|
||||
|
||||
if (curFromNodeType === curToNodeChild.nodeType) {
|
||||
if (curFromNodeType === ELEMENT_NODE) {
|
||||
// Both nodes being compared are Element nodes
|
||||
|
||||
if (curToNodeKey) {
|
||||
// The target node has a key so we want to match it up with the correct element
|
||||
// in the original DOM tree
|
||||
if (curToNodeKey !== curFromNodeKey) {
|
||||
// The current element in the original DOM tree does not have a matching key so
|
||||
// let's check our lookup to see if there is a matching element in the original
|
||||
// DOM tree
|
||||
if ((matchingFromEl = fromNodesLookup[curToNodeKey])) {
|
||||
if (fromNextSibling === matchingFromEl) {
|
||||
// Special case for single element removals. To avoid removing the original
|
||||
// DOM node out of the tree (since that can break CSS transitions, etc.),
|
||||
// we will instead discard the current node and wait until the next
|
||||
// iteration to properly match up the keyed target element with its matching
|
||||
// element in the original tree
|
||||
isCompatible = false;
|
||||
} else {
|
||||
// We found a matching keyed element somewhere in the original DOM tree.
|
||||
// Let's move the original DOM node into the current position and morph
|
||||
// it.
|
||||
|
||||
// NOTE: We use insertBefore instead of replaceChild because we want to go through
|
||||
// the `removeNode()` function for the node that is being discarded so that
|
||||
// all lifecycle hooks are correctly invoked
|
||||
fromEl.insertBefore(matchingFromEl, curFromNodeChild);
|
||||
|
||||
// fromNextSibling = curFromNodeChild.nextSibling;
|
||||
|
||||
if (curFromNodeKey) {
|
||||
// Since the node is keyed it might be matched up later so we defer
|
||||
// the actual removal to later
|
||||
addKeyedRemoval(curFromNodeKey);
|
||||
} else {
|
||||
// NOTE: we skip nested keyed nodes from being removed since there is
|
||||
// still a chance they will be matched up later
|
||||
removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
|
||||
}
|
||||
|
||||
curFromNodeChild = matchingFromEl;
|
||||
curFromNodeKey = getNodeKey(curFromNodeChild);
|
||||
}
|
||||
} else {
|
||||
// The nodes are not compatible since the "to" node has a key and there
|
||||
// is no matching keyed node in the source tree
|
||||
isCompatible = false;
|
||||
}
|
||||
}
|
||||
} else if (curFromNodeKey) {
|
||||
// The original has a key
|
||||
isCompatible = false;
|
||||
}
|
||||
|
||||
isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild);
|
||||
if (isCompatible) {
|
||||
// We found compatible DOM elements so transform
|
||||
// the current "from" node to match the current
|
||||
// target DOM node.
|
||||
// MORPH
|
||||
morphEl(curFromNodeChild, curToNodeChild);
|
||||
}
|
||||
|
||||
} else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) {
|
||||
// Both nodes being compared are Text or Comment nodes
|
||||
isCompatible = true;
|
||||
// Simply update nodeValue on the original node to
|
||||
// change the text value
|
||||
if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) {
|
||||
curFromNodeChild.nodeValue = curToNodeChild.nodeValue;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompatible) {
|
||||
// Advance both the "to" child and the "from" child since we found a match
|
||||
// Nothing else to do as we already recursively called morphChildren above
|
||||
curToNodeChild = toNextSibling;
|
||||
curFromNodeChild = fromNextSibling;
|
||||
continue outer;
|
||||
}
|
||||
|
||||
// No compatible match so remove the old node from the DOM and continue trying to find a
|
||||
// match in the original DOM. However, we only do this if the from node is not keyed
|
||||
// since it is possible that a keyed node might match up with a node somewhere else in the
|
||||
// target tree and we don't want to discard it just yet since it still might find a
|
||||
// home in the final DOM tree. After everything is done we will remove any keyed nodes
|
||||
// that didn't find a home
|
||||
if (curFromNodeKey) {
|
||||
// Since the node is keyed it might be matched up later so we defer
|
||||
// the actual removal to later
|
||||
addKeyedRemoval(curFromNodeKey);
|
||||
} else {
|
||||
// NOTE: we skip nested keyed nodes from being removed since there is
|
||||
// still a chance they will be matched up later
|
||||
removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
|
||||
}
|
||||
|
||||
curFromNodeChild = fromNextSibling;
|
||||
} // END: while(curFromNodeChild) {}
|
||||
|
||||
// If we got this far then we did not find a candidate match for
|
||||
// our "to node" and we exhausted all of the children "from"
|
||||
// nodes. Therefore, we will just append the current "to" node
|
||||
// to the end
|
||||
if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) {
|
||||
// MORPH
|
||||
if(!skipFrom){ addChild(fromEl, matchingFromEl); }
|
||||
morphEl(matchingFromEl, curToNodeChild);
|
||||
} else {
|
||||
var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild);
|
||||
if (onBeforeNodeAddedResult !== false) {
|
||||
if (onBeforeNodeAddedResult) {
|
||||
curToNodeChild = onBeforeNodeAddedResult;
|
||||
}
|
||||
|
||||
if (curToNodeChild.actualize) {
|
||||
curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc);
|
||||
}
|
||||
addChild(fromEl, curToNodeChild);
|
||||
handleNodeAdded(curToNodeChild);
|
||||
}
|
||||
}
|
||||
|
||||
curToNodeChild = toNextSibling;
|
||||
curFromNodeChild = fromNextSibling;
|
||||
}
|
||||
|
||||
cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey);
|
||||
|
||||
var specialElHandler = specialElHandlers[fromEl.nodeName];
|
||||
if (specialElHandler) {
|
||||
specialElHandler(fromEl, toEl);
|
||||
}
|
||||
} // END: morphChildren(...)
|
||||
|
||||
var morphedNode = fromNode;
|
||||
var morphedNodeType = morphedNode.nodeType;
|
||||
var toNodeType = toNode.nodeType;
|
||||
|
||||
if (!childrenOnly) {
|
||||
// Handle the case where we are given two DOM nodes that are not
|
||||
// compatible (e.g. <div> --> <span> or <div> --> TEXT)
|
||||
if (morphedNodeType === ELEMENT_NODE) {
|
||||
if (toNodeType === ELEMENT_NODE) {
|
||||
if (!compareNodeNames(fromNode, toNode)) {
|
||||
onNodeDiscarded(fromNode);
|
||||
morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI));
|
||||
}
|
||||
} else {
|
||||
// Going from an element node to a text node
|
||||
morphedNode = toNode;
|
||||
}
|
||||
} else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { // Text or comment node
|
||||
if (toNodeType === morphedNodeType) {
|
||||
if (morphedNode.nodeValue !== toNode.nodeValue) {
|
||||
morphedNode.nodeValue = toNode.nodeValue;
|
||||
}
|
||||
|
||||
return morphedNode;
|
||||
} else {
|
||||
// Text node to something else
|
||||
morphedNode = toNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (morphedNode === toNode) {
|
||||
// The "to node" was not compatible with the "from node" so we had to
|
||||
// toss out the "from node" and use the "to node"
|
||||
onNodeDiscarded(fromNode);
|
||||
} else {
|
||||
if (toNode.isSameNode && toNode.isSameNode(morphedNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
morphEl(morphedNode, toNode, childrenOnly);
|
||||
|
||||
// We now need to loop over any keyed nodes that might need to be
|
||||
// removed. We only do the removal if we know that the keyed node
|
||||
// never found a match. When a keyed node is matched up we remove
|
||||
// it out of fromNodesLookup and we use fromNodesLookup to determine
|
||||
// if a keyed node has been matched up or not
|
||||
if (keyedRemovalList) {
|
||||
for (var i=0, len=keyedRemovalList.length; i<len; i++) {
|
||||
var elToRemove = fromNodesLookup[keyedRemovalList[i]];
|
||||
if (elToRemove) {
|
||||
removeNode(elToRemove, elToRemove.parentNode, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) {
|
||||
if (morphedNode.actualize) {
|
||||
morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc);
|
||||
}
|
||||
// If we had to swap out the from node with a new node because the old
|
||||
// node was not compatible with the target node then we need to
|
||||
// replace the old DOM node in the original DOM tree. This is only
|
||||
// possible if the original DOM node was part of a DOM tree which
|
||||
// we know is the case if it has a parent node.
|
||||
fromNode.parentNode.replaceChild(morphedNode, fromNode);
|
||||
}
|
||||
|
||||
return morphedNode;
|
||||
};
|
||||
}
|
||||
|
||||
var morphdom = morphdomFactory(morphAttrs);
|
||||
|
||||
return morphdom;
|
||||
|
||||
}));
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,10 +0,0 @@
|
||||
namespace ReverseLlama.Protocol;
|
||||
|
||||
public static class ProtocolConstants
|
||||
{
|
||||
public const string DefaultStatusPath = "/_reverse-llama/status";
|
||||
public const string DefaultTunnelPath = "/_reverse-llama/tunnel";
|
||||
public const string TokenHeader = "X-Reverse-Llama-Token";
|
||||
public const string ClientIdHeader = "X-Reverse-Llama-Client-Id";
|
||||
public const string ReplacedCloseDescription = "reverse-llama-replaced";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,190 +0,0 @@
|
||||
using System.Data.SqlClient;
|
||||
using System.Net.WebSockets;
|
||||
using ElmahCore;
|
||||
using ElmahCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using ReverseLlama.Protocol;
|
||||
using ReverseLlama.Server;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var settings = ServerSettings.FromConfiguration(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton(settings);
|
||||
builder.Services.AddSingleton<TunnelHub>();
|
||||
builder.Services.AddSingleton<EmbeddingCache>();
|
||||
builder.Services.AddSingleton<ManagementStore>();
|
||||
builder.Services.AddElmah<ElmahCore.MySql.MySqlErrorLog>().Configure<ElmahOptions>(
|
||||
options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection"));
|
||||
|
||||
if (settings.Keycloak.IsConfigured)
|
||||
{
|
||||
builder.Services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = "ReverseLlama.Admin";
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.LoginPath = "/admin/login";
|
||||
options.LogoutPath = "/admin/logout";
|
||||
})
|
||||
.AddOpenIdConnect(options =>
|
||||
{
|
||||
options.Authority = settings.Keycloak.Authority;
|
||||
options.ClientId = settings.Keycloak.ClientId;
|
||||
options.ClientSecret = settings.Keycloak.ClientSecret;
|
||||
options.RequireHttpsMetadata = settings.Keycloak.RequireHttpsMetadata;
|
||||
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||
options.ResponseMode = OpenIdConnectResponseMode.Query;
|
||||
options.SaveTokens = true;
|
||||
options.GetClaimsFromUserInfoEndpoint = true;
|
||||
options.CorrelationCookie.SameSite = SameSiteMode.Lax;
|
||||
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.NonceCookie.SameSite = SameSiteMode.Lax;
|
||||
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.Scope.Clear();
|
||||
options.Scope.Add("openid");
|
||||
options.Scope.Add("profile");
|
||||
options.Scope.Add("email");
|
||||
options.Events = new OpenIdConnectEvents
|
||||
{
|
||||
OnRemoteFailure = context =>
|
||||
{
|
||||
var errorLog = context.HttpContext.RequestServices.GetService<ErrorLog>();
|
||||
if (context.Failure is not null)
|
||||
{
|
||||
errorLog?.Log(new Error(context.Failure));
|
||||
}
|
||||
|
||||
context.HandleResponse();
|
||||
context.Response.Redirect("/admin/auth-error");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var managementStore = app.Services.GetRequiredService<ManagementStore>();
|
||||
var tunnelHub = app.Services.GetRequiredService<TunnelHub>();
|
||||
managementStore.SetConnectedClientProvider(() => tunnelHub.ClientSnapshots.Select(c => c.Id));
|
||||
|
||||
if (settings.Keycloak.IsConfigured)
|
||||
{
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
app.UseElmah();
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers.AccessControlAllowOrigin = "*";
|
||||
context.Response.Headers.AccessControlAllowMethods = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
||||
context.Response.Headers.AccessControlAllowHeaders = "Content-Type, Authorization";
|
||||
|
||||
if (HttpMethods.IsOptions(context.Request.Method))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status204NoContent;
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
app.MapAdminEndpoints(settings);
|
||||
|
||||
app.MapGet("/", (TunnelHub hub) =>
|
||||
Results.Json(new
|
||||
{
|
||||
status = "ok",
|
||||
connected = hub.HasClient,
|
||||
pendingRequests = hub.PendingRequestCount,
|
||||
clients = hub.ClientsSnapshot.Count
|
||||
}));
|
||||
|
||||
app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) =>
|
||||
{
|
||||
// Query token allowed so the status page can be checked in a browser.
|
||||
if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
connected = hub.HasClient,
|
||||
pendingRequests = hub.PendingRequestCount,
|
||||
tunnelPath = serverSettings.TunnelPath,
|
||||
embeddingCache = new
|
||||
{
|
||||
available = embeddingCache.IsAvailable,
|
||||
count = embeddingCache.Count,
|
||||
databasePath = embeddingCache.DatabasePath,
|
||||
lastError = embeddingCache.LastError
|
||||
},
|
||||
management = new
|
||||
{
|
||||
available = managementStore.IsAvailable,
|
||||
databasePath = managementStore.DatabasePath,
|
||||
lastError = managementStore.LastError
|
||||
},
|
||||
clients = hub.ClientsSnapshot
|
||||
});
|
||||
});
|
||||
|
||||
app.Map(settings.TunnelPath, async (HttpContext context, TunnelHub hub, ServerSettings serverSettings, ManagementStore managementStore) =>
|
||||
{
|
||||
if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsync($"Missing or invalid {ProtocolConstants.TokenHeader}.", context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsync("This endpoint only accepts WebSocket tunnel connections.", context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
var clientId = context.Request.Headers[ProtocolConstants.ClientIdHeader].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
clientId = $"anonymous-{Guid.NewGuid():n}";
|
||||
}
|
||||
|
||||
using var socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
await hub.AcceptAsync(clientId, socket, context.RequestAborted);
|
||||
});
|
||||
|
||||
app.Map("/clients/{clientId}/{**path}", ReverseProxyEndpoint.HandleClientAsync);
|
||||
|
||||
app.Map("/{**path}", ReverseProxyEndpoint.HandleRootAsync)
|
||||
.WithOrder(1000);
|
||||
|
||||
var elmahService = app.Services.GetRequiredService<ErrorLog>();
|
||||
try
|
||||
{
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
elmahService.Log(new Error(exception));
|
||||
throw;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ElmahCore" Version="2.1.2" />
|
||||
<PackageReference Include="ElmahCore.MySql" Version="2.1.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.28" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.28" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
||||
<PackageReference Include="System.Text.Encodings.Web" Version="8.0.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
<ProjectReference Include="..\ReverseLlama.Protocol\ReverseLlama.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,202 +0,0 @@
|
||||
using ReverseLlama.Protocol;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
|
||||
internal static class TokenAuthentication
|
||||
{
|
||||
private static readonly PathString PathTokenPrefix = new("/token");
|
||||
|
||||
public static AuthResult Authorize(
|
||||
HttpRequest request,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool allowQueryToken,
|
||||
bool allowPathToken = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.Token) && !managementStore.HasApiKeys)
|
||||
{
|
||||
return AuthResult.Success(null);
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues))
|
||||
{
|
||||
foreach (var value in headerValues)
|
||||
{
|
||||
var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue("Authorization", out var authorizationValues))
|
||||
{
|
||||
foreach (var value in authorizationValues)
|
||||
{
|
||||
if (TryGetBearerToken(value, out var bearerToken))
|
||||
{
|
||||
var result = AuthorizeToken(bearerToken, settings, managementStore, updateApiKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allowPathToken
|
||||
&& TryGetPathToken(request.Path, out var pathToken, out _))
|
||||
{
|
||||
var result = AuthorizeToken(pathToken, settings, managementStore, updateApiKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowQueryToken
|
||||
&& request.Query.TryGetValue("token", out var queryValues))
|
||||
{
|
||||
foreach (var value in queryValues)
|
||||
{
|
||||
var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true);
|
||||
if (result.IsAuthorized)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthResult.Failure;
|
||||
}
|
||||
|
||||
public static bool IsAuthorized(
|
||||
HttpRequest request,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool allowQueryToken,
|
||||
bool allowPathToken = false) =>
|
||||
Authorize(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized;
|
||||
|
||||
public static bool TryRemovePathToken(
|
||||
PathString path,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
out PathString remainingPath)
|
||||
{
|
||||
remainingPath = path;
|
||||
|
||||
if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath)
|
||||
|| !IsTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
remainingPath = string.IsNullOrEmpty(tokenRemainingPath.Value)
|
||||
? new PathString("/")
|
||||
: tokenRemainingPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) =>
|
||||
TryGetBearerToken(value, out var token)
|
||||
&& IsTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false);
|
||||
|
||||
private static AuthResult AuthorizeToken(
|
||||
string? token,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool updateApiKeyLastUsed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return AuthResult.Failure;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Token)
|
||||
&& string.Equals(token, settings.Token, StringComparison.Ordinal))
|
||||
{
|
||||
return AuthResult.Success(null);
|
||||
}
|
||||
|
||||
var apiKeyId = managementStore.GetApiKeyId(token);
|
||||
if (apiKeyId is not null)
|
||||
{
|
||||
managementStore.IsApiKeyValid(token, updateApiKeyLastUsed);
|
||||
return AuthResult.Success(apiKeyId);
|
||||
}
|
||||
|
||||
return AuthResult.Failure;
|
||||
}
|
||||
|
||||
public static bool IsTokenAuthorized(
|
||||
string? token,
|
||||
ServerSettings settings,
|
||||
ManagementStore managementStore,
|
||||
bool updateApiKeyLastUsed) =>
|
||||
AuthorizeToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized;
|
||||
|
||||
private static bool TryGetBearerToken(string? authorization, out string token)
|
||||
{
|
||||
token = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authorization)
|
||||
|| !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
token = authorization["Bearer ".Length..].Trim();
|
||||
return token.Length > 0;
|
||||
}
|
||||
|
||||
private static bool TryGetPathToken(PathString path, out string pathToken, out PathString remainingPath)
|
||||
{
|
||||
pathToken = "";
|
||||
remainingPath = PathString.Empty;
|
||||
|
||||
if (!path.StartsWithSegments(PathTokenPrefix, out var pathAfterPrefix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = pathAfterPrefix.Value ?? "";
|
||||
if (value.Length <= 1 || value[0] != '/')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var nextSlash = value.IndexOf('/', 1);
|
||||
pathToken = nextSlash < 0
|
||||
? value[1..]
|
||||
: value[1..nextSlash];
|
||||
|
||||
if (string.IsNullOrEmpty(pathToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
remainingPath = nextSlash < 0
|
||||
? PathString.Empty
|
||||
: new PathString(value[nextSlash..]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AuthResult
|
||||
{
|
||||
public static AuthResult Failure { get; } = new(false, null);
|
||||
|
||||
public static AuthResult Success(string? apiKeyId) => new(true, apiKeyId);
|
||||
|
||||
public bool IsAuthorized { get; }
|
||||
|
||||
public string? ApiKeyId { get; }
|
||||
|
||||
private AuthResult(bool isAuthorized, string? apiKeyId)
|
||||
{
|
||||
IsAuthorized = isAuthorized;
|
||||
ApiKeyId = apiKeyId;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Ngino.Server;
|
||||
using Xunit;
|
||||
|
||||
namespace ReverseLlama.Client.Tests;
|
||||
|
||||
public class GroupKeepalivePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void KeepalivePolicy_IsRoundTripped_ThroughGroupClientInfo()
|
||||
{
|
||||
var policy = new GroupClientKeepalivePolicy(2, 3, 4);
|
||||
var info = new GroupClientInfo(1, "group-1", "client-1", "model-a", "pattern", policy);
|
||||
|
||||
Assert.Equal(2, info.KeepalivePolicy?.InstancesToKeepAlive);
|
||||
Assert.Equal(3, info.KeepalivePolicy?.MaxParallelismPerClient);
|
||||
Assert.Equal(4, info.KeepalivePolicy?.ParallelismHeadroom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Ngino.Server;
|
||||
using Xunit;
|
||||
|
||||
namespace ReverseLlama.Client.Tests;
|
||||
|
||||
public class KeepaliveCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlanActions_LoadsMissingKeepaliveInstances()
|
||||
{
|
||||
var policy = new GroupClientKeepalivePolicy(2, 1, 1);
|
||||
var member = new GroupClientInfo(1, "group-1", "client-1", "bge-m3:latest", null, policy);
|
||||
var candidates = new[]
|
||||
{
|
||||
new KeepaliveCandidate("client-1", true, false),
|
||||
new KeepaliveCandidate("client-2", true, false),
|
||||
new KeepaliveCandidate("client-3", true, true)
|
||||
};
|
||||
|
||||
var actions = KeepaliveCoordinator.PlanActions([member], candidates);
|
||||
|
||||
Assert.Single(actions);
|
||||
Assert.Equal("client-1", actions[0].ClientId);
|
||||
Assert.Equal("load", actions[0].Command);
|
||||
Assert.Equal("bge-m3:latest", actions[0].Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanActions_UnloadsWhenTooManyInstancesAreActive()
|
||||
{
|
||||
var policy = new GroupClientKeepalivePolicy(1, 1, 1);
|
||||
var member = new GroupClientInfo(2, "group-1", null, "bge-m3:latest", null, policy);
|
||||
var candidates = new[]
|
||||
{
|
||||
new KeepaliveCandidate("client-1", true, true),
|
||||
new KeepaliveCandidate("client-2", true, true)
|
||||
};
|
||||
|
||||
var actions = KeepaliveCoordinator.PlanActions([member], candidates);
|
||||
|
||||
Assert.Single(actions);
|
||||
Assert.Equal("client-1", actions[0].ClientId);
|
||||
Assert.Equal("unload", actions[0].Command);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -9,16 +9,17 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ReverseLlama.Client\ReverseLlama.Client.csproj" />
|
||||
<ProjectReference Include="..\..\src\Ngino.Client\Ngino.Client.csproj" />
|
||||
<ProjectReference Include="..\..\src\Ngino.Server\Ngino.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+3
-3
@@ -2,7 +2,7 @@ using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace ReverseLlama.Client.Tests;
|
||||
namespace Ngino.Client.Tests;
|
||||
|
||||
public sealed class PackageAuditTests
|
||||
{
|
||||
@@ -32,7 +32,7 @@ public sealed class PackageAuditTests
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var solutionPath = Path.Combine(directory.FullName, "ReverseLlama.sln");
|
||||
var solutionPath = Path.Combine(directory.FullName, "Ngino.sln");
|
||||
if (File.Exists(solutionPath))
|
||||
{
|
||||
return solutionPath;
|
||||
@@ -41,7 +41,7 @@ public sealed class PackageAuditTests
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not find ReverseLlama.sln from the test output directory.");
|
||||
throw new InvalidOperationException("Could not find Ngino.sln from the test output directory.");
|
||||
}
|
||||
|
||||
private static async Task<CommandResult> RunDotnetPackageAuditAsync(string solutionPath)
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using ReverseLlama.Client;
|
||||
using Ngino.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace ReverseLlama.Client.Tests;
|
||||
namespace Ngino.Client.Tests;
|
||||
|
||||
public sealed class TunnelClientModelTests
|
||||
{
|
||||
@@ -56,7 +56,7 @@ public sealed class TunnelClientModelTests
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal("http://localhost:11434/api/embed", request.RequestUri!.AbsoluteUri);
|
||||
Assert.Equal("bge-m3:latest", document.RootElement.GetProperty("model").GetString());
|
||||
Assert.Equal("ReverseLlama warmup", document.RootElement.GetProperty("input").GetString());
|
||||
Assert.Equal("Ngino warmup", document.RootElement.GetProperty("input").GetString());
|
||||
Assert.Equal(expectedKeepAlive, document.RootElement.GetProperty("keep_alive").GetInt32());
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
using ReverseLlama.Client;
|
||||
using Ngino.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace ReverseLlama.Client.Tests;
|
||||
namespace Ngino.Client.Tests;
|
||||
|
||||
public sealed class UpstreamRequestTests
|
||||
{
|
||||
Reference in New Issue
Block a user