Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d33a25bfa5 | ||
|
|
b849cfd4b7 | ||
|
|
44b09ffa86 | ||
|
|
2b56bc53b6 | ||
|
|
faba272480 | ||
|
|
9361aaf2fc | ||
|
|
ded9030ede | ||
|
|
afc85e4802 | ||
|
|
5b7ae55cc8 | ||
|
|
2ca5ca2da2 | ||
|
|
7a76acc247 | ||
|
|
0c136f86cb | ||
|
|
9d01eb5311 | ||
|
|
d567966d41 | ||
|
|
f6091747d9 | ||
|
|
de8c757d93 | ||
|
|
4c2fc37929 | ||
|
|
f9004778d3 | ||
|
|
ca71fafa4f | ||
|
|
1cfe53dbe2 | ||
|
|
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,8 +1,10 @@
|
||||
# ReverseLlama
|
||||
# <img src="docs/images/Ngino_logo_symbol.png" width="25" alt="logo symbol"/> 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, llama.cpp, 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.
|
||||
<img src="docs/images/Ngino_logo_full.png" alt="logo symbol"/>
|
||||
|
||||
The client opens and maintains a WebSocket connection to the server. The server accepts normal HTTP requests from users 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.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -13,10 +15,10 @@ 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)
|
||||
- Load balancing (Scale your AI inferencing horizontally by adding more nodes!)
|
||||
- (Ollama-based) GGUF Model management (install, remove, load, unload models)
|
||||
- Client monitoring
|
||||
- Who is active
|
||||
- What models are running
|
||||
@@ -27,7 +29,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 +43,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 +81,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 +120,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.
|
||||
|
||||
@@ -128,12 +131,53 @@ Linux:
|
||||
```bash
|
||||
sudo bash deploy/install-client.sh --server http://your-server:5050 --token "change-me"
|
||||
```
|
||||
or using ollama models with llama.cpp backend using ROCm:
|
||||
```bash
|
||||
sudo bash deploy/install-client.sh --server https://ai.domain.tld --token "change-me" --use-llama-cpp-via-docker --use-ollama-models-path /usr/share/ollama/.ollama/models --llama-cpp-docker-image ghcr.io/ggml-org/llama.cpp:server-rocm --llama-cpp-base-port 8081 --llama-cpp-parallel 128
|
||||
```
|
||||
or using ollama models with llama.cpp backend using CUDA:
|
||||
```bash
|
||||
sudo bash deploy/install-client.sh --server https://ai.domain.tld --token "change-me" --use-llama-cpp-via-docker --use-ollama-models-path /usr/share/ollama/.ollama/models --llama-cpp-docker-image ghcr.io/ggml-org/llama.cpp:server-cuda --llama-cpp-base-port 8081 --llama-cpp-parallel 128
|
||||
```
|
||||
|
||||
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`.
|
||||
llama.cpp via Docker options (replaces Ollama for inferencing):
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--use-llama-cpp-via-docker` | Use llama.cpp Docker containers instead of Ollama |
|
||||
| `--use-ollama-models-path <dir>` | Path to Ollama models directory (`manifests/blobs`); required with the flag above |
|
||||
| `--llama-cpp-docker-image <img>` | Docker image; defaults to auto-detected (rocm/cuda/cpu) |
|
||||
| `--llama-cpp-base-port <num>` | Base port for containers; defaults to `8081` |
|
||||
| `--llama-cpp-parallel <num>` | llama.cpp parallel slots per container; if unset, llama.cpp's own default is used (which is `1`) |
|
||||
| `--llama-cpp-fallback-cooldown <sec>` | Seconds before llama.cpp is retried after a failed container start; defaults to `180` |
|
||||
| `--log-dir <dir>` | Directory for log files; defaults to `<app dir>/Logs` |
|
||||
|
||||
### llama.cpp fallback to Ollama
|
||||
|
||||
Models are served via llama.cpp Docker containers. If a container cannot be started for a model (for example, the model's GGUF blob is incompatible with the llama.cpp build), the client falls back to the Ollama upstream for that model. Transient start failures are remembered for `--llama-cpp-fallback-cooldown` seconds (default 3 minutes) and then retried; a container that starts but exits before becoming ready marks the model as falling back until it is unloaded. `load`/`unload` model commands and on-demand request routing are all covered; a failed container start is detected quickly by watching the container state, and the container log tail is written to the client log to aid debugging.
|
||||
|
||||
Note: some hybrid SSM/attention models (e.g. `qwen3.5-coder-next`) are converted by Ollama into a GGUF tensor layout that stock llama.cpp cannot load (`missing tensor 'blk.0.ssm_dt.bias'` and similar). Such models are served via the Ollama fallback above. If you want them to run on llama.cpp instead, use a Hugging Face-converted GGUF (e.g. `unsloth/Qwen3-Coder-Next-GGUF`) rather than the Ollama blob.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Server,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Token,
|
||||
|
||||
[string]$ClientId = $(if ($env:COMPUTERNAME) { $env:COMPUTERNAME.ToLowerInvariant() } else { "windows-client" }),
|
||||
[string]$Upstream = "http://localhost:11434",
|
||||
[string]$InstallDir = "$env:ProgramFiles\Ngino Client",
|
||||
[string]$ServiceName = "NginoClient",
|
||||
[switch]$InsecureSkipTlsVerify,
|
||||
[switch]$NoOllama,
|
||||
[switch]$UseLlamaCppViaDocker,
|
||||
[string]$UseOllamaModelsPath = "",
|
||||
[string]$LlamaCppDockerImage = "",
|
||||
[int]$LlamaCppBasePort = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Write-Info([string]$Message) { Write-Host "[INFO] $Message" -ForegroundColor Green }
|
||||
function Write-Warn([string]$Message) { Write-Host "[WARN] $Message" -ForegroundColor Yellow }
|
||||
|
||||
function Find-DotNet {
|
||||
$command = Get-Command dotnet -ErrorAction SilentlyContinue
|
||||
if ($command) { return $command.Source }
|
||||
|
||||
$candidate = Join-Path $env:ProgramFiles "dotnet\dotnet.exe"
|
||||
if (Test-Path -LiteralPath $candidate) { return $candidate }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Install-WingetPackage([string]$Id, [string]$Name) {
|
||||
if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) {
|
||||
throw "$Name is required but winget is unavailable. Install $Name manually and run this script again."
|
||||
}
|
||||
|
||||
Write-Info "Installing $Name..."
|
||||
& winget.exe install --id $Id --exact --accept-package-agreements --accept-source-agreements --silent
|
||||
if ($LASTEXITCODE -ne 0) { throw "winget failed to install $Name (exit code $LASTEXITCODE)." }
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Server)) {
|
||||
$Server = Read-Host "Ngino server URL (e.g. http://my-server:5050)"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Server)) { throw "Server URL is required." }
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Token)) {
|
||||
$secureToken = Read-Host "Server token" -AsSecureString
|
||||
$tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
|
||||
try { $Token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer) }
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Token)) { throw "Token is required." }
|
||||
|
||||
if ($UseLlamaCppViaDocker -and [string]::IsNullOrWhiteSpace($UseOllamaModelsPath)) {
|
||||
$UseOllamaModelsPath = Read-Host "Ollama models path (e.g. C:\Users\user\.ollama\models)"
|
||||
}
|
||||
if ($UseLlamaCppViaDocker -and [string]::IsNullOrWhiteSpace($UseOllamaModelsPath)) {
|
||||
throw "Ollama models path is required with -UseLlamaCppViaDocker."
|
||||
}
|
||||
|
||||
if ($ServiceName -notmatch '^[A-Za-z0-9_.-]+$') { throw "ServiceName contains unsupported characters." }
|
||||
|
||||
$scriptDir = $PSScriptRoot
|
||||
$repoRoot = Split-Path -Parent $scriptDir
|
||||
$clientProject = Join-Path $repoRoot "src\Ngino.Client\Ngino.Client.csproj"
|
||||
if (-not (Test-Path -LiteralPath $clientProject)) {
|
||||
throw "Client source not found at $clientProject. Run this script from the repository."
|
||||
}
|
||||
|
||||
$dotnet = Find-DotNet
|
||||
$dotnetVersion = if ($dotnet) { & $dotnet --version } else { $null }
|
||||
if (-not $dotnetVersion -or -not $dotnetVersion.StartsWith("10.")) {
|
||||
if ($dotnetVersion) { Write-Warn "dotnet $dotnetVersion is installed, but version 10.x is required." }
|
||||
Install-WingetPackage "Microsoft.DotNet.SDK.10" ".NET 10 SDK"
|
||||
$dotnet = Find-DotNet
|
||||
if (-not $dotnet) { throw ".NET was installed, but dotnet.exe could not be found." }
|
||||
$dotnetVersion = & $dotnet --version
|
||||
}
|
||||
Write-Info "Using dotnet $dotnetVersion ($dotnet)."
|
||||
|
||||
if ($NoOllama) {
|
||||
Write-Info "Skipping Ollama check (-NoOllama)."
|
||||
} else {
|
||||
$ollama = Get-Command ollama.exe -ErrorAction SilentlyContinue
|
||||
if (-not $ollama) {
|
||||
$ollamaCandidate = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe"
|
||||
if (Test-Path -LiteralPath $ollamaCandidate) { $ollama = Get-Item $ollamaCandidate }
|
||||
}
|
||||
if (-not $ollama) {
|
||||
Install-WingetPackage "Ollama.Ollama" "Ollama"
|
||||
$ollamaCandidate = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe"
|
||||
if (Test-Path -LiteralPath $ollamaCandidate) { $ollama = Get-Item $ollamaCandidate }
|
||||
}
|
||||
if (-not $ollama) { Write-Warn "Ollama was installed, but ollama.exe was not found in the current session." }
|
||||
else {
|
||||
$ollamaPath = if ($ollama.Source) { $ollama.Source } elseif ($ollama.FullName) { $ollama.FullName } else { $ollama.Path }
|
||||
Write-Info "Ollama is installed ($ollamaPath)."
|
||||
}
|
||||
}
|
||||
|
||||
$architecture = $env:PROCESSOR_ARCHITECTURE
|
||||
$runtimeId = switch ($architecture) {
|
||||
{ $_ -in "AMD64", "x64" } { "win-x64"; break }
|
||||
{ $_ -in "ARM64", "Arm64" } { "win-arm64"; break }
|
||||
{ $_ -in "x86", "X86" } { "win-x86"; break }
|
||||
default { throw "Unsupported architecture: $architecture" }
|
||||
}
|
||||
|
||||
$buildDir = Join-Path ([IO.Path]::GetTempPath()) ("ngino-build-" + [Guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Path $buildDir | Out-Null
|
||||
try {
|
||||
Write-Info "Building Ngino client (self-contained, $runtimeId)..."
|
||||
& $dotnet publish $clientProject -c Release -r $runtimeId --self-contained true -o $buildDir
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed (exit code $LASTEXITCODE)." }
|
||||
|
||||
$executable = Join-Path $buildDir "Ngino.Client.exe"
|
||||
if (-not (Test-Path -LiteralPath $executable)) { throw "Build failed: Ngino.Client.exe was not produced." }
|
||||
|
||||
$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($existingService -and $existingService.Status -ne "Stopped") {
|
||||
Write-Info "Stopping existing service $ServiceName..."
|
||||
Stop-Service -Name $ServiceName -Force
|
||||
(Get-Service -Name $ServiceName).WaitForStatus("Stopped", [TimeSpan]::FromSeconds(30))
|
||||
}
|
||||
|
||||
Write-Info "Installing to $InstallDir..."
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
Copy-Item -Path (Join-Path $buildDir "*") -Destination $InstallDir -Recurse -Force
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $buildDir) { Remove-Item -LiteralPath $buildDir -Recurse -Force }
|
||||
}
|
||||
|
||||
$installedExecutable = Join-Path $InstallDir "Ngino.Client.exe"
|
||||
$binaryPath = '"{0}"' -f $installedExecutable
|
||||
if (-not (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue)) {
|
||||
Write-Info "Creating Windows service $ServiceName..."
|
||||
& sc.exe create $ServiceName "binPath=" $binaryPath "start=" "auto" "DisplayName=" "Ngino Tunnel Client"
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not create Windows service $ServiceName." }
|
||||
} else {
|
||||
& sc.exe config $ServiceName "binPath=" $binaryPath "start=" "auto" "DisplayName=" "Ngino Tunnel Client" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not update Windows service $ServiceName." }
|
||||
}
|
||||
|
||||
# A service-specific environment keeps the token out of the process command line.
|
||||
$serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$ServiceName"
|
||||
$insecureTlsValue = $InsecureSkipTlsVerify.IsPresent.ToString().ToLowerInvariant()
|
||||
$serviceEnvironment = @(
|
||||
"NGINO_SERVER=$Server",
|
||||
"NGINO_TOKEN=$Token",
|
||||
"NGINO_CLIENT_ID=$ClientId",
|
||||
"NGINO_UPSTREAM=$Upstream",
|
||||
"NGINO_INSECURE_SKIP_TLS_VERIFY=$insecureTlsValue",
|
||||
"DOTNET_CLI_TELEMETRY_OPTOUT=1",
|
||||
"DOTNET_NOLOGO=1"
|
||||
)
|
||||
if ($UseLlamaCppViaDocker) {
|
||||
$serviceEnvironment += "NGINO_USE_LLAMA_CPP_VIA_DOCKER=true"
|
||||
if (-not [string]::IsNullOrWhiteSpace($UseOllamaModelsPath)) {
|
||||
$serviceEnvironment += "NGINO_USE_OLLAMA_MODELS_PATH=$UseOllamaModelsPath"
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($LlamaCppDockerImage)) {
|
||||
$serviceEnvironment += "NGINO_LLAMA_CPP_DOCKER_IMAGE=$LlamaCppDockerImage"
|
||||
}
|
||||
if ($LlamaCppBasePort -gt 0) {
|
||||
$serviceEnvironment += "NGINO_LLAMA_CPP_BASE_PORT=$LlamaCppBasePort"
|
||||
}
|
||||
}
|
||||
if ($InsecureSkipTlsVerify) {
|
||||
Write-Warn "Server TLS certificate validation is disabled for $ServiceName."
|
||||
}
|
||||
New-ItemProperty -Path $serviceRegistryPath -Name Environment -PropertyType MultiString -Value $serviceEnvironment -Force | Out-Null
|
||||
& sc.exe description $ServiceName "Ngino outbound tunnel client" | Out-Null
|
||||
& sc.exe failure $ServiceName "reset=" "86400" "actions=" "restart/5000/restart/5000/restart/5000" | Out-Null
|
||||
|
||||
Start-Service -Name $ServiceName
|
||||
$service = Get-Service -Name $ServiceName
|
||||
try { $service.WaitForStatus("Running", [TimeSpan]::FromSeconds(15)) } catch { }
|
||||
if ($service.Status -eq "Running") { Write-Info "Service $ServiceName is running." }
|
||||
else { Write-Warn "Service $ServiceName did not reach Running state. Check: Get-WinEvent -LogName Application" }
|
||||
|
||||
Write-Host ""
|
||||
Write-Info "Installation complete."
|
||||
Write-Host " Server: $Server"
|
||||
Write-Host " Client ID: $ClientId"
|
||||
Write-Host " Upstream: $Upstream"
|
||||
Write-Host " Service: $ServiceName"
|
||||
Write-Host " Install dir: $InstallDir"
|
||||
if ($UseLlamaCppViaDocker) {
|
||||
Write-Host " llama.cpp: enabled (models: $UseOllamaModelsPath)"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host " Manage: Get-Service $ServiceName | Start-Service/Stop-Service/Restart-Service"
|
||||
Write-Host " Logs: Get-WinEvent -LogName Application | Where-Object ProviderName -eq NginoClient"
|
||||
+104
-30
@@ -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)"
|
||||
@@ -16,6 +16,13 @@ TOKEN=""
|
||||
CLIENT_ID="$(hostname -s 2>/dev/null || echo "linux-client")"
|
||||
UPSTREAM="$DEFAULT_UPSTREAM"
|
||||
SKIP_OLLAMA=false
|
||||
USE_LLAMA_CPP_VIA_DOCKER=false
|
||||
USE_OLLAMA_MODELS_PATH=""
|
||||
LLAMA_CPP_DOCKER_IMAGE=""
|
||||
LLAMA_CPP_BASE_PORT=""
|
||||
LLAMA_CPP_PARALLEL=""
|
||||
LLAMA_CPP_FALLBACK_COOLDOWN=""
|
||||
LOG_DIR=""
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'
|
||||
@@ -33,10 +40,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:
|
||||
@@ -45,11 +52,27 @@ Optional:
|
||||
--install-dir <dir> Install directory; defaults to $DEFAULT_INSTALL_DIR
|
||||
--service-name <n> systemd service name; defaults to $DEFAULT_SERVICE_NAME
|
||||
--no-ollama Skip Ollama installation and status check
|
||||
--use-llama-cpp-via-docker
|
||||
Use llama.cpp via Docker for inference instead of Ollama
|
||||
--use-ollama-models-path <dir>
|
||||
Path to Ollama models directory (manifests/blobs); required with --use-llama-cpp-via-docker
|
||||
--llama-cpp-docker-image <img>
|
||||
llama.cpp Docker image; defaults to auto-detected (rocm/cuda/cpu)
|
||||
--llama-cpp-base-port <num>
|
||||
Base port for llama.cpp containers; defaults to 8081
|
||||
--llama-cpp-parallel <num>
|
||||
llama.cpp parallel slots per container; if unset, llama.cpp's own default is used
|
||||
--llama-cpp-fallback-cooldown <sec>
|
||||
Seconds before llama.cpp is retried after a failed container start; defaults to 180
|
||||
--log-dir <dir> Directory for log files; defaults to <install-dir>/Logs
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$0 --server http://gpu-server:5050 --token "my-secret"
|
||||
$0 --server http://gpu-server:5050 --token "my-secret" --no-ollama
|
||||
$0 --server http://gpu-server:5050 --token "my-secret" \\
|
||||
--use-llama-cpp-via-docker --use-ollama-models-path /usr/share/ollama/.ollama/models \\
|
||||
--llama-cpp-parallel 128
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
@@ -57,21 +80,28 @@ EOF
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--server) SERVER_URL="$2"; shift 2 ;;
|
||||
--token) TOKEN="$2"; shift 2 ;;
|
||||
--client-id) CLIENT_ID="$2"; shift 2 ;;
|
||||
--upstream) UPSTREAM="$2"; shift 2 ;;
|
||||
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||
--service-name) SERVICE_NAME="$2"; shift 2 ;;
|
||||
--no-ollama) SKIP_OLLAMA=true; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) die "Unknown option: $1" ;;
|
||||
--server) SERVER_URL="$2"; shift 2 ;;
|
||||
--token) TOKEN="$2"; shift 2 ;;
|
||||
--client-id) CLIENT_ID="$2"; shift 2 ;;
|
||||
--upstream) UPSTREAM="$2"; shift 2 ;;
|
||||
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||
--service-name) SERVICE_NAME="$2"; shift 2 ;;
|
||||
--no-ollama) SKIP_OLLAMA=true; shift ;;
|
||||
--use-llama-cpp-via-docker) USE_LLAMA_CPP_VIA_DOCKER=true; shift ;;
|
||||
--use-ollama-models-path) USE_OLLAMA_MODELS_PATH="$2"; shift 2 ;;
|
||||
--llama-cpp-docker-image) LLAMA_CPP_DOCKER_IMAGE="$2"; shift 2 ;;
|
||||
--llama-cpp-base-port) LLAMA_CPP_BASE_PORT="$2"; shift 2 ;;
|
||||
--llama-cpp-parallel) LLAMA_CPP_PARALLEL="$2"; shift 2 ;;
|
||||
--llama-cpp-fallback-cooldown) LLAMA_CPP_FALLBACK_COOLDOWN="$2"; shift 2 ;;
|
||||
--log-dir) LOG_DIR="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) die "Unknown option: $1" ;;
|
||||
esac
|
||||
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."
|
||||
@@ -85,6 +115,13 @@ if [[ -z "$TOKEN" ]]; then
|
||||
die "Token is required."
|
||||
fi
|
||||
|
||||
if [[ "$USE_LLAMA_CPP_VIA_DOCKER" == "true" && -z "$USE_OLLAMA_MODELS_PATH" ]]; then
|
||||
read -rp "Ollama models path (e.g. /usr/share/ollama/.ollama/models): " USE_OLLAMA_MODELS_PATH
|
||||
fi
|
||||
if [[ "$USE_LLAMA_CPP_VIA_DOCKER" == "true" && -z "$USE_OLLAMA_MODELS_PATH" ]]; then
|
||||
die "Ollama models path is required with --use-llama-cpp-via-docker."
|
||||
fi
|
||||
|
||||
# ── Root check ────────────────────────────────────────────────────────────────
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
die "This script must be run as root (or with sudo)."
|
||||
@@ -225,7 +262,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,53 +275,87 @@ 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."
|
||||
|
||||
# ── Stop existing service before overwriting binary ─────────────────────────
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
if systemctl list-unit-files "$SERVICE_NAME.service" &>/dev/null 2>&1 || systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
info "Stopping existing service $SERVICE_NAME..."
|
||||
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Install ───────────────────────────────────────────────────────────────────
|
||||
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"
|
||||
if [[ "$USE_LLAMA_CPP_VIA_DOCKER" == "true" ]]; then
|
||||
printf 'NGINO_USE_LLAMA_CPP_VIA_DOCKER=true\n'
|
||||
if [[ -n "$USE_OLLAMA_MODELS_PATH" ]]; then
|
||||
printf 'NGINO_USE_OLLAMA_MODELS_PATH=%s\n' "$USE_OLLAMA_MODELS_PATH"
|
||||
fi
|
||||
if [[ -n "$LLAMA_CPP_DOCKER_IMAGE" ]]; then
|
||||
printf 'NGINO_LLAMA_CPP_DOCKER_IMAGE=%s\n' "$LLAMA_CPP_DOCKER_IMAGE"
|
||||
fi
|
||||
if [[ -n "$LLAMA_CPP_BASE_PORT" ]]; then
|
||||
printf 'NGINO_LLAMA_CPP_BASE_PORT=%s\n' "$LLAMA_CPP_BASE_PORT"
|
||||
fi
|
||||
if [[ -n "$LLAMA_CPP_PARALLEL" ]]; then
|
||||
printf 'NGINO_LLAMA_CPP_PARALLEL=%s\n' "$LLAMA_CPP_PARALLEL"
|
||||
fi
|
||||
if [[ -n "$LLAMA_CPP_FALLBACK_COOLDOWN" ]]; then
|
||||
printf 'NGINO_LLAMA_CPP_FALLBACK_COOLDOWN_SECONDS=%s\n' "$LLAMA_CPP_FALLBACK_COOLDOWN"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$LOG_DIR" ]]; then
|
||||
printf 'NGINO_LOG_DIR=%s\n' "$LOG_DIR"
|
||||
fi
|
||||
} > "$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"
|
||||
|
||||
if systemctl list-unit-files "$SERVICE_NAME.service" &>/dev/null 2>&1; then
|
||||
info "Stopping existing service $SERVICE_NAME..."
|
||||
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=ReverseLlama Tunnel Client
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Description=Ngino Tunnel Client
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target docker.service
|
||||
$([ "$SKIP_OLLAMA" = "false" ] && echo "After=ollama.service")
|
||||
$([ "$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]
|
||||
@@ -310,6 +381,9 @@ echo " Client ID: $CLIENT_ID"
|
||||
echo " Upstream: $UPSTREAM"
|
||||
echo " Service: $SERVICE_NAME"
|
||||
echo " Install dir: $INSTALL_DIR"
|
||||
if [[ "$USE_LLAMA_CPP_VIA_DOCKER" == "true" ]]; then
|
||||
echo " llama.cpp: enabled (models: $USE_OLLAMA_MODELS_PATH)"
|
||||
fi
|
||||
echo
|
||||
echo " Manage: systemctl {start|stop|restart|status} $SERVICE_NAME"
|
||||
echo " Logs: journalctl -u $SERVICE_NAME -f"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$InstallDir = "$env:ProgramFiles\Ngino Client",
|
||||
[string]$ServiceName = "NginoClient"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Info([string]$Message) { Write-Host "[INFO] $Message" -ForegroundColor Green }
|
||||
|
||||
if ($ServiceName -notmatch '^[A-Za-z0-9_.-]+$') { throw "ServiceName contains unsupported characters." }
|
||||
|
||||
# ── Stop and delete the Windows service ───────────────────────────────────────
|
||||
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($service) {
|
||||
if ($service.Status -ne "Stopped") {
|
||||
Write-Info "Stopping service $ServiceName..."
|
||||
Stop-Service -Name $ServiceName -Force
|
||||
$service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(30))
|
||||
}
|
||||
Write-Info "Deleting service $ServiceName..."
|
||||
& sc.exe delete $ServiceName
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not delete Windows service $ServiceName." }
|
||||
} else {
|
||||
Write-Info "Service $ServiceName does not exist; skipping."
|
||||
}
|
||||
|
||||
# ── Remove install directory ──────────────────────────────────────────────────
|
||||
if (Test-Path -LiteralPath $InstallDir) {
|
||||
Write-Info "Removing install directory $InstallDir..."
|
||||
Remove-Item -LiteralPath $InstallDir -Recurse -Force
|
||||
} else {
|
||||
Write-Info "Install directory $InstallDir does not exist; skipping."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Info "Uninstall complete."
|
||||
Write-Host " Service: $ServiceName (stopped and deleted)"
|
||||
Write-Host " Install dir: $InstallDir (removed)"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
DEFAULT_INSTALL_DIR="/opt/ngino-client"
|
||||
DEFAULT_SERVICE_NAME="ngino-client"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
|
||||
SERVICE_NAME="$DEFAULT_SERVICE_NAME"
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
die() { err "$@"; exit 1; }
|
||||
|
||||
# ── Usage ─────────────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Uninstalls the Ngino client systemd service and removes its files.
|
||||
Does NOT remove .NET SDK, Ollama, or Docker.
|
||||
|
||||
Optional:
|
||||
--install-dir <dir> Install directory; defaults to $DEFAULT_INSTALL_DIR
|
||||
--service-name <n> systemd service name; defaults to $DEFAULT_SERVICE_NAME
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$0
|
||||
$0 --install-dir /opt/ngino-client --service-name ngino-client
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||
--service-name) SERVICE_NAME="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) die "Unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Root check ────────────────────────────────────────────────────────────────
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
die "This script must be run as root (or with sudo)."
|
||||
fi
|
||||
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
ENV_DIR="/etc/ngino-client"
|
||||
|
||||
# ── Stop and disable the service ──────────────────────────────────────────────
|
||||
if systemctl list-unit-files "$SERVICE_NAME.service" &>/dev/null 2>&1; then
|
||||
info "Stopping service $SERVICE_NAME..."
|
||||
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
info "Disabling service $SERVICE_NAME..."
|
||||
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Remove service unit file ──────────────────────────────────────────────────
|
||||
if [[ -f "$SERVICE_FILE" ]]; then
|
||||
info "Removing service unit file $SERVICE_FILE..."
|
||||
rm -f "$SERVICE_FILE"
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
# ── Remove install directory ──────────────────────────────────────────────────
|
||||
if [[ -d "$INSTALL_DIR" ]]; then
|
||||
info "Removing install directory $INSTALL_DIR..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# ── Remove environment file ───────────────────────────────────────────────────
|
||||
if [[ -d "$ENV_DIR" ]]; then
|
||||
info "Removing environment directory $ENV_DIR..."
|
||||
rm -rf "$ENV_DIR"
|
||||
fi
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
info "Uninstall complete."
|
||||
echo " Service: $SERVICE_NAME (stopped, disabled, unit removed)"
|
||||
echo " Install dir: $INSTALL_DIR (removed)"
|
||||
echo " Env dir: $ENV_DIR (removed)"
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||

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

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

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

|
||||
@@ -0,0 +1,332 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 1280 720"
|
||||
role="img"
|
||||
aria-labelledby="title desc"
|
||||
version="1.1"
|
||||
id="svg10"
|
||||
sodipodi:docname="Ngino_logo.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
inkscape:export-filename="Ngino_logo_full_dark.png"
|
||||
inkscape:export-xdpi="125.532"
|
||||
inkscape:export-ydpi="125.532"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
|
||||
id="namedview10"
|
||||
pagecolor="#000000"
|
||||
bordercolor="#000000"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#000000"
|
||||
inkscape:zoom="0.7671875"
|
||||
inkscape:cx="391.69043"
|
||||
inkscape:cy="406.02851"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1412"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer4"
|
||||
inkscape:export-bgcolor="#00000000" /><title
|
||||
id="title">Ngino logo</title><desc
|
||||
id="desc">A fully vector llama outline with one input connection branching to three AI endpoints, beside the Ngino wordmark.</desc><defs
|
||||
id="defs2"><linearGradient
|
||||
id="swatch181"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop181" /></linearGradient><clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath15"><rect
|
||||
style="fill:none;stroke:#000000;stroke-linecap:butt;stroke-linejoin:bevel;paint-order:stroke markers fill;stop-color:#000000"
|
||||
id="rect15-7"
|
||||
width="45.034248"
|
||||
height="38.955639"
|
||||
x="262.48285"
|
||||
y="121.00073" /></clipPath><linearGradient
|
||||
id="swatch7"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop7" /></linearGradient><linearGradient
|
||||
id="linearGradient1"
|
||||
inkscape:swatch="gradient"><stop
|
||||
style="stop-color:#000000;stop-opacity:0;"
|
||||
offset="0"
|
||||
id="stop3" /><stop
|
||||
style="stop-color:#000000;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop4" /></linearGradient><linearGradient
|
||||
id="routeGradient"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"><stop
|
||||
offset="0"
|
||||
stop-color="#0697b5"
|
||||
id="stop1" /><stop
|
||||
offset="1"
|
||||
stop-color="#45d05f"
|
||||
id="stop2" /></linearGradient><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#routeGradient"
|
||||
id="linearGradient10"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0"
|
||||
gradientTransform="translate(50.835031)" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#routeGradient"
|
||||
id="linearGradient24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#routeGradient"
|
||||
id="linearGradient25"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#routeGradient"
|
||||
id="linearGradient26"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#routeGradient"
|
||||
id="linearGradient27"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#routeGradient"
|
||||
id="linearGradient28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0"
|
||||
gradientTransform="translate(18.248473,-20.855397)" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#routeGradient"
|
||||
id="linearGradient29"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0"
|
||||
gradientTransform="translate(18.248473,-20.855397)" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#routeGradient"
|
||||
id="linearGradient30"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0"
|
||||
gradientTransform="translate(18.248473,-20.855397)" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#routeGradient"
|
||||
id="linearGradient31"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="95"
|
||||
y1="0"
|
||||
x2="430"
|
||||
y2="0"
|
||||
gradientTransform="translate(18.248473,-20.855397)" /><filter
|
||||
inkscape:collect="always"
|
||||
style="color-interpolation-filters:sRGB"
|
||||
id="filter215"
|
||||
x="-0.0021624053"
|
||||
y="-0.0047179751"
|
||||
width="1.0043248"
|
||||
height="1.009436"><feGaussianBlur
|
||||
inkscape:collect="always"
|
||||
stdDeviation="1.0812026"
|
||||
id="feGaussianBlur215" /></filter><filter
|
||||
inkscape:collect="always"
|
||||
style="color-interpolation-filters:sRGB"
|
||||
id="filter215-8"
|
||||
x="-0.003844276"
|
||||
y="-0.0074139609"
|
||||
width="1.0076886"
|
||||
height="1.0148279"><feGaussianBlur
|
||||
inkscape:collect="always"
|
||||
stdDeviation="1.0812026"
|
||||
id="feGaussianBlur215-8" /></filter><filter
|
||||
inkscape:collect="always"
|
||||
style="color-interpolation-filters:sRGB"
|
||||
id="filter215-6"
|
||||
x="-0.0051897726"
|
||||
y="-0.0047179751"
|
||||
width="1.0103795"
|
||||
height="1.009436"><feGaussianBlur
|
||||
inkscape:collect="always"
|
||||
stdDeviation="1.0812026"
|
||||
id="feGaussianBlur215-0" /></filter></defs><!-- Transparent background; no embedded raster image. --><rect
|
||||
style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:50;stroke-linecap:round;stroke-miterlimit:0.8;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter215)"
|
||||
id="rect1"
|
||||
width="1200"
|
||||
height="550"
|
||||
x="26.069246"
|
||||
y="20.855396"
|
||||
inkscape:label="background_full"
|
||||
rx="100"
|
||||
inkscape:highlight-color="#000000"
|
||||
inkscape:export-filename="Ngino_logo_full.png"
|
||||
inkscape:export-xdpi="153.60001"
|
||||
inkscape:export-ydpi="153.60001" /><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
inkscape:label="text"
|
||||
style="display:inline"
|
||||
inkscape:export-filename="Ngino_logo_text.png"
|
||||
inkscape:export-xdpi="125.532"
|
||||
inkscape:export-ydpi="125.532"><rect
|
||||
style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:50;stroke-linecap:round;stroke-miterlimit:0.8;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter215-8)"
|
||||
id="rect1-1"
|
||||
width="675"
|
||||
height="350"
|
||||
x="458.96759"
|
||||
y="152.75374"
|
||||
inkscape:label="background_text"
|
||||
rx="100"
|
||||
inkscape:highlight-color="#000000"
|
||||
inkscape:export-filename="Ngino_logo_full.png"
|
||||
inkscape:export-xdpi="153.60001"
|
||||
inkscape:export-ydpi="153.60001" /><text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:37.3333px;line-height:1.25;font-family:Sans;-inkscape-font-specification:'Sans, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;display:inline;fill:#000000;fill-opacity:1"
|
||||
x="503.97342"
|
||||
y="430.72964"
|
||||
id="text2"
|
||||
inkscape:label="sub"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan2"
|
||||
x="503.97342"
|
||||
y="430.72964">R E V E R S E P R O X Y F O R A I</tspan></text><text
|
||||
xml:space="preserve"
|
||||
style="font-weight:900;font-size:170.667px;line-height:1.25;font-family:Montserrat;-inkscape-font-specification:'Montserrat, Heavy';display:inline;fill:#000000;fill-opacity:1"
|
||||
x="496.78931"
|
||||
y="357.61456"
|
||||
id="text1"
|
||||
inkscape:label="main"
|
||||
inkscape:export-filename="Ngino_logo_text.png"
|
||||
inkscape:export-xdpi="125.532"
|
||||
inkscape:export-ydpi="125.532"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan1"
|
||||
x="496.78931"
|
||||
y="357.61456">Ngino</tspan></text></g><!-- Connection nodes --><!-- Wordmark --><rect
|
||||
style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:50;stroke-linecap:round;stroke-miterlimit:0.8;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter215-6)"
|
||||
id="rect1-8"
|
||||
width="500"
|
||||
height="550"
|
||||
x="28.825022"
|
||||
y="21.104044"
|
||||
inkscape:label="background_logo"
|
||||
rx="100"
|
||||
inkscape:highlight-color="#000000"
|
||||
inkscape:export-filename="Ngino_logo_full.png"
|
||||
inkscape:export-xdpi="153.60001"
|
||||
inkscape:export-ydpi="153.60001" /><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Logo"
|
||||
style="display:inline"
|
||||
inkscape:export-filename="Ngino_Logo_Symbol.png"
|
||||
inkscape:export-xdpi="125.532"
|
||||
inkscape:export-ydpi="125.532"><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer3"
|
||||
inkscape:label="Body"
|
||||
style="display:inline"><path
|
||||
d="m 115.82078,398.64766 c -22.393081,-46.4277 -14.055,-69.1446 2.64154,-97.02036 C 143.97964,277.26884 147,275.30346 190,275.30346 h 56 c 36,0 53.48269,-18.60692 63.48269,-46.60692 6,-16 4,-43 5,-65 0,-20 6,-37.30346 16,-52.30346 l 0.39308,-26.965381 C 333.17923,71.427699 347,53 356,48 l 2,47 c 6,-19 17,-34 31,-40 l -1.69653,38.268839 c 13,3 22.69653,17.517311 28.69653,28.517311 18,3 29,6.17923 31,22.17923 0,14 -3.78615,18.08961 -16.78615,20.08961 l -37,7.21385 c 12,24 28.91039,76.76578 36.91039,108.76578 14,58 -6.24848,146.8554 -29.24848,202.8554 l -13,29 h -47 l -8,-35 c -4,-22 -23,-38 -48,-38 h -53.50305 c -27,0 -45,16 -49,40 l -6,33 h -47.12424 z"
|
||||
id="path2"
|
||||
sodipodi:nodetypes="ccsscccccccccccccccccsscccc"
|
||||
style="display:inline;fill:none;stroke:url(#linearGradient27)"
|
||||
stroke-width="13"
|
||||
inkscape:label="body" /></g><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer4"
|
||||
inkscape:label="Symbol"
|
||||
style="display:inline"><g
|
||||
fill="url(#routeGradient)"
|
||||
id="g9"
|
||||
style="display:inline;fill:url(#linearGradient27)"
|
||||
transform="translate(18.248473,-20.855397)"
|
||||
inkscape:label="Symbol_circles"><circle
|
||||
cx="354"
|
||||
cy="398"
|
||||
r="15"
|
||||
id="circle9"
|
||||
style="fill:url(#linearGradient26)"
|
||||
inkscape:label="ball_fork_down" /><circle
|
||||
cx="354"
|
||||
cy="356"
|
||||
r="15"
|
||||
id="circle8"
|
||||
style="display:inline;fill:url(#linearGradient25)"
|
||||
inkscape:label="ball_fork_mid" /><circle
|
||||
cx="354"
|
||||
cy="314"
|
||||
r="15"
|
||||
id="circle7"
|
||||
style="display:inline;fill:url(#linearGradient24)"
|
||||
inkscape:label="ball_fork_up" /><circle
|
||||
cx="130.83504"
|
||||
cy="356"
|
||||
r="15"
|
||||
id="circle6"
|
||||
style="display:inline;fill:url(#linearGradient10)"
|
||||
inkscape:label="ball_connector" /></g><g
|
||||
fill="none"
|
||||
stroke="url(#routeGradient)"
|
||||
stroke-width="13"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
id="g6"
|
||||
style="display:inline"
|
||||
inkscape:label="Symbol_paths"><!-- Llama silhouette --><!-- One input, three routed endpoints --><path
|
||||
d="m 254.24847,335.1446 c 29,0 42,42 78,42 h 40"
|
||||
id="path6"
|
||||
style="display:inline;stroke:url(#linearGradient31)"
|
||||
inkscape:label="fork_down" /><path
|
||||
d="m 254.24847,335.1446 h 118"
|
||||
id="path5"
|
||||
style="display:inline;stroke:url(#linearGradient28)"
|
||||
inkscape:label="fork_mid" /><path
|
||||
d="m 254.24847,335.1446 c 29,0 42,-42 78,-42 h 40"
|
||||
id="path4"
|
||||
style="display:inline;stroke:url(#linearGradient30)"
|
||||
inkscape:label="fork_up" /><path
|
||||
d="M 150.38696,335.1446 H 254.24847"
|
||||
id="path3"
|
||||
sodipodi:nodetypes="cc"
|
||||
style="display:inline;stroke:url(#linearGradient29)"
|
||||
inkscape:label="connector"
|
||||
inkscape:export-filename="Ngino_logo_symbol.png"
|
||||
inkscape:export-xdpi="153.60001"
|
||||
inkscape:export-ydpi="153.60001" /></g></g></g><metadata
|
||||
id="metadata215"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:title>Ngino logo</dc:title></cc:Work></rdf:RDF></metadata></svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
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
|
||||
{
|
||||
@@ -0,0 +1,212 @@
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class ClientOptions
|
||||
{
|
||||
public Uri Server { get; init; } = new("http://localhost:5001");
|
||||
|
||||
public Uri Upstream { get; init; } = new("http://localhost:11434");
|
||||
|
||||
public string TunnelPath { get; init; } = ProtocolConstants.DefaultTunnelPath;
|
||||
|
||||
public string? Token { get; init; }
|
||||
|
||||
public string ClientId { get; init; } = Environment.MachineName.ToLowerInvariant();
|
||||
|
||||
public TimeSpan ReconnectDelay { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public int ChunkSize { get; init; } = 64 * 1024;
|
||||
|
||||
public bool InsecureSkipTlsVerify { get; init; }
|
||||
|
||||
public bool UseLlamaCppViaDocker { get; init; }
|
||||
|
||||
public string? UseOllamaModelsPath { get; init; }
|
||||
|
||||
public string? LlamaCppDockerImage { get; init; }
|
||||
|
||||
public int LlamaCppBasePort { get; init; } = 8081;
|
||||
|
||||
public int? LlamaCppParallel { get; init; }
|
||||
|
||||
public TimeSpan LlamaCppFallbackCooldown { get; init; } = TimeSpan.FromMinutes(3);
|
||||
|
||||
public string? LogDirectory { get; init; }
|
||||
|
||||
public Uri TunnelUri
|
||||
{
|
||||
get
|
||||
{
|
||||
var builder = new UriBuilder(Server);
|
||||
builder.Scheme = builder.Scheme.ToLowerInvariant() switch
|
||||
{
|
||||
"http" => "ws",
|
||||
"https" => "wss",
|
||||
"ws" => "ws",
|
||||
"wss" => "wss",
|
||||
var unsupported => throw new InvalidOperationException($"Unsupported server URI scheme '{unsupported}'. Use http, https, ws, or wss.")
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(builder.Path) || builder.Path == "/")
|
||||
{
|
||||
builder.Path = NormalizePath(TunnelPath);
|
||||
}
|
||||
|
||||
return builder.Uri;
|
||||
}
|
||||
}
|
||||
|
||||
public static ClientOptions Parse(string[] args)
|
||||
{
|
||||
var values = ParseArgs(args);
|
||||
|
||||
return new ClientOptions
|
||||
{
|
||||
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"),
|
||||
InsecureSkipTlsVerify = ReadBool(values, false, "insecure-skip-tls-verify", "NGINO_INSECURE_SKIP_TLS_VERIFY"),
|
||||
UseLlamaCppViaDocker = ReadBool(values, false, "use-llama-cpp-via-docker", "NGINO_USE_LLAMA_CPP_VIA_DOCKER"),
|
||||
UseOllamaModelsPath = NormalizeDirectoryPath(Read(values, "use-ollama-models-path", "NGINO_USE_OLLAMA_MODELS_PATH")),
|
||||
LlamaCppDockerImage = Read(values, "llama-cpp-docker-image", "NGINO_LLAMA_CPP_DOCKER_IMAGE"),
|
||||
LlamaCppBasePort = ReadInt(values, 8081, "llama-cpp-base-port", "NGINO_LLAMA_CPP_BASE_PORT"),
|
||||
LlamaCppParallel = ReadOptionalInt(values, "llama-cpp-parallel", "NGINO_LLAMA_CPP_PARALLEL"),
|
||||
LlamaCppFallbackCooldown = TimeSpan.FromSeconds(ReadInt(values, 180, "llama-cpp-fallback-cooldown", "NGINO_LLAMA_CPP_FALLBACK_COOLDOWN_SECONDS")),
|
||||
LogDirectory = NormalizeDirectoryPath(Read(values, "log-dir", "NGINO_LOG_DIR"))
|
||||
};
|
||||
}
|
||||
|
||||
public static string Usage =>
|
||||
"""
|
||||
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 /_ngino/tunnel
|
||||
--reconnect-delay <sec> Defaults to 5
|
||||
--chunk-size <bytes> Defaults to 65536
|
||||
--insecure-skip-tls-verify Disable server TLS certificate validation (unsafe)
|
||||
--use-llama-cpp-via-docker Use llama.cpp via Docker for inference instead of Ollama
|
||||
--use-ollama-models-path <dir> Path to Ollama models directory (manifests/blobs), required with --use-llama-cpp-via-docker
|
||||
--llama-cpp-docker-image <img> llama.cpp Docker image; defaults to auto-detected (rocm/cuda/cpu)
|
||||
--llama-cpp-base-port <num> Base port for llama.cpp containers; defaults to 8081
|
||||
--llama-cpp-parallel <num> llama.cpp parallel slots per container; if unset, llama.cpp's own default is used
|
||||
--llama-cpp-fallback-cooldown <sec>
|
||||
Seconds before llama.cpp is retried after a failed container start; defaults to 180
|
||||
--log-dir <dir> Directory for log files; defaults to <app dir>/Logs
|
||||
""";
|
||||
|
||||
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
if (!arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Unexpected argument '{arg}'.");
|
||||
}
|
||||
|
||||
var keyValue = arg[2..].Split('=', 2);
|
||||
if (keyValue.Length == 2)
|
||||
{
|
||||
values[keyValue[0]] = keyValue[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBoolFlag(keyValue[0]))
|
||||
{
|
||||
values[keyValue[0]] = "true";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Missing value for '{arg}'.");
|
||||
}
|
||||
|
||||
values[keyValue[0]] = args[++i];
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static bool IsBoolFlag(string key)
|
||||
{
|
||||
return key switch
|
||||
{
|
||||
"insecure-skip-tls-verify" => true,
|
||||
"use-llama-cpp-via-docker" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Read(Dictionary<string, string> values, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
value = Environment.GetEnvironmentVariable(key);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ReadInt(Dictionary<string, string> values, int fallback, params string[] keys)
|
||||
{
|
||||
var value = Read(values, keys);
|
||||
return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
private static int? ReadOptionalInt(Dictionary<string, string> values, params string[] keys)
|
||||
{
|
||||
var value = Read(values, keys);
|
||||
return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
private static bool ReadBool(Dictionary<string, string> values, bool fallback, params string[] keys)
|
||||
{
|
||||
var value = Read(values, keys);
|
||||
return bool.TryParse(value, out var parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
private static Uri ReadUri(Dictionary<string, string> values, string key, string envKey, string fallback)
|
||||
{
|
||||
var value = Read(values, key, envKey) ?? fallback;
|
||||
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new ArgumentException($"'{value}' is not an absolute URI.");
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path) =>
|
||||
path.StartsWith('/') ? path : $"/{path}";
|
||||
|
||||
private static string? NormalizeDirectoryPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class FileLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private const long DefaultMaxFileSizeBytes = 5L * 1024 * 1024;
|
||||
private const string LogFileName = "ngino-client.log";
|
||||
private const string RotatedLogFileName = "ngino-client.log.1";
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly long _maxFileSizeBytes;
|
||||
private readonly object _lock = new();
|
||||
private StreamWriter _writer = null!;
|
||||
private string _currentFile = null!;
|
||||
|
||||
public FileLoggerProvider(string directory, long maxFileSizeBytes = DefaultMaxFileSizeBytes)
|
||||
{
|
||||
_directory = directory;
|
||||
_maxFileSizeBytes = maxFileSizeBytes;
|
||||
Directory.CreateDirectory(directory);
|
||||
OpenFile();
|
||||
}
|
||||
|
||||
public string LogDirectory => _directory;
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName);
|
||||
|
||||
public void WriteLog(DateTime timestamp, LogLevel level, string category, string message)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var line = $"{timestamp:yyyy-MM-dd HH:mm:ss.fff} [{level}] {category}: {message}";
|
||||
if (_writer.BaseStream.Length + line.Length + 2 > _maxFileSizeBytes)
|
||||
{
|
||||
RotateFile();
|
||||
}
|
||||
|
||||
_writer.WriteLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_writer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenFile()
|
||||
{
|
||||
_currentFile = Path.Combine(_directory, LogFileName);
|
||||
_writer = new StreamWriter(
|
||||
new FileStream(_currentFile, FileMode.Append, FileAccess.Write, FileShare.ReadWrite),
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
|
||||
{
|
||||
AutoFlush = true
|
||||
};
|
||||
}
|
||||
|
||||
private void RotateFile()
|
||||
{
|
||||
_writer.Dispose();
|
||||
|
||||
var rotatedFile = Path.Combine(_directory, RotatedLogFileName);
|
||||
try
|
||||
{
|
||||
File.Delete(rotatedFile);
|
||||
if (File.Exists(_currentFile))
|
||||
{
|
||||
File.Move(_currentFile, rotatedFile);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
|
||||
OpenFile();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FileLogger(FileLoggerProvider provider, string category) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = formatter(state, exception);
|
||||
if (exception is not null)
|
||||
{
|
||||
message += Environment.NewLine + exception;
|
||||
}
|
||||
|
||||
provider.WriteLog(DateTime.Now, logLevel, category, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed partial class LlamaCppManager : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private const int DefaultBasePort = 8081;
|
||||
private const string NginoContainerLabel = "ngino-llamacpp";
|
||||
private static readonly TimeSpan DockerTimeout = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan DefaultFallbackCooldown = TimeSpan.FromMinutes(3);
|
||||
|
||||
private readonly string _blobsPath;
|
||||
private readonly string _manifestsPath;
|
||||
private readonly string _dockerImage;
|
||||
private readonly int _basePort;
|
||||
private readonly int? _parallel;
|
||||
private readonly TimeSpan _fallbackCooldown;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ConcurrentDictionary<string, int> _modelPorts = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<int, byte> _reservedPorts = new();
|
||||
private readonly ConcurrentDictionary<string, DateTime> _fallbackModels = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _modelStartLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _portAllocationLock = new();
|
||||
|
||||
public LlamaCppManager(
|
||||
string ollamaModelsPath,
|
||||
string? dockerImage,
|
||||
int? basePort,
|
||||
ILogger? logger = null,
|
||||
TimeSpan fallbackCooldown = default,
|
||||
int? parallel = null)
|
||||
{
|
||||
_manifestsPath = Path.Combine(ollamaModelsPath, "manifests");
|
||||
_blobsPath = Path.Combine(ollamaModelsPath, "blobs");
|
||||
_dockerImage = dockerImage ?? GetDefaultDockerImage();
|
||||
_basePort = basePort ?? DefaultBasePort;
|
||||
_fallbackCooldown = fallbackCooldown > TimeSpan.Zero ? fallbackCooldown : DefaultFallbackCooldown;
|
||||
_parallel = parallel is > 0 ? parallel : null;
|
||||
_logger = logger ?? NullLogger<LlamaCppManager>.Instance;
|
||||
}
|
||||
|
||||
public string DockerImage => _dockerImage;
|
||||
|
||||
public List<LlamaCppModel> DiscoverModels()
|
||||
{
|
||||
var models = new List<LlamaCppModel>();
|
||||
|
||||
if (!Directory.Exists(_manifestsPath))
|
||||
{
|
||||
_logger.LogWarning("Ollama manifests path not found: {ManifestsPath}", _manifestsPath);
|
||||
return models;
|
||||
}
|
||||
|
||||
foreach (var manifestPath in Directory.EnumerateFiles(_manifestsPath, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
try
|
||||
{
|
||||
var model = ParseManifest(manifestPath);
|
||||
if (model is not null)
|
||||
{
|
||||
models.Add(model);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse manifest: {ManifestPath}", manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
public List<LlamaCppModel> DiscoverModelsWithBlob()
|
||||
{
|
||||
return DiscoverModels().Where(m => File.Exists(m.BlobPath)).ToList();
|
||||
}
|
||||
|
||||
public bool IsModelActive(string ollamaModelName)
|
||||
{
|
||||
return _modelPorts.ContainsKey(ollamaModelName);
|
||||
}
|
||||
|
||||
public void MarkModelAsFallback(string ollamaModelName)
|
||||
{
|
||||
_fallbackModels[ollamaModelName] = DateTime.UtcNow.Add(_fallbackCooldown);
|
||||
_logger.LogWarning(
|
||||
"Model {Model} will fall back to the Ollama upstream for {Cooldown} before llama.cpp is retried.",
|
||||
ollamaModelName, _fallbackCooldown);
|
||||
}
|
||||
|
||||
public void MarkModelAsPermanentFallback(string ollamaModelName)
|
||||
{
|
||||
_fallbackModels[ollamaModelName] = DateTime.MaxValue;
|
||||
_logger.LogError(
|
||||
"Model {Model} exited its llama.cpp container before becoming ready. It will fall back to the Ollama upstream until it is unloaded.",
|
||||
ollamaModelName);
|
||||
}
|
||||
|
||||
public bool IsModelOnFallback(string ollamaModelName)
|
||||
{
|
||||
if (_fallbackModels.TryGetValue(ollamaModelName, out var expiresAt))
|
||||
{
|
||||
if (expiresAt > DateTime.UtcNow)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_fallbackModels.TryRemove(ollamaModelName, out _);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ClearModelFallback(string ollamaModelName)
|
||||
{
|
||||
if (_fallbackModels.TryRemove(ollamaModelName, out _))
|
||||
{
|
||||
_logger.LogInformation("Cleared llama.cpp fallback marker for model {Model}.", ollamaModelName);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsContainerRunningAsync(string ollamaModelName)
|
||||
{
|
||||
if (!_modelPorts.ContainsKey(ollamaModelName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var containerName = SanitizeContainerName($"ngino-llamacpp-{ollamaModelName}");
|
||||
var (exitCode, output) = await RunDockerWithOutputAsync(
|
||||
["ps", "--filter", $"name=^{containerName}$", "--format", "{{.ID}}"],
|
||||
CancellationToken.None);
|
||||
|
||||
var running = exitCode == 0 && !string.IsNullOrWhiteSpace(output);
|
||||
if (!running)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"llama.cpp container {ContainerName} is no longer running. Invalidating cached port for {Model}.",
|
||||
containerName, ollamaModelName);
|
||||
RemoveModelPort(ollamaModelName);
|
||||
}
|
||||
|
||||
return running;
|
||||
}
|
||||
|
||||
public bool RemoveModelMapping(string ollamaModelName)
|
||||
{
|
||||
if (RemoveModelPort(ollamaModelName))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Removed stale llama.cpp port mapping for {Model}.",
|
||||
ollamaModelName);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Uri? GetUpstream(string ollamaModelName)
|
||||
{
|
||||
if (_modelPorts.TryGetValue(ollamaModelName, out var port))
|
||||
{
|
||||
return new Uri($"http://localhost:{port}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> StartModelContainerAsync(LlamaCppModel model, CancellationToken cancellationToken)
|
||||
{
|
||||
var ollamaName = model.OllamaName;
|
||||
if (string.IsNullOrWhiteSpace(ollamaName))
|
||||
{
|
||||
_logger.LogWarning("Cannot start container: model has no Ollama name");
|
||||
return false;
|
||||
}
|
||||
|
||||
var startLock = _modelStartLocks.GetOrAdd(ollamaName, static _ => new SemaphoreSlim(1, 1));
|
||||
await startLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
return await StartModelContainerCoreAsync(model, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
startLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> StartModelContainerCoreAsync(LlamaCppModel model, CancellationToken cancellationToken)
|
||||
{
|
||||
var ollamaName = model.OllamaName;
|
||||
if (string.IsNullOrWhiteSpace(ollamaName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_modelPorts.ContainsKey(ollamaName))
|
||||
{
|
||||
_logger.LogInformation("Model {Model} already has a running container", ollamaName);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsModelOnFallback(ollamaName))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Model {Model} previously failed to load via llama.cpp. Skipping container start.",
|
||||
ollamaName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(model.BlobPath))
|
||||
{
|
||||
_logger.LogError("Model blob not found: {BlobPath}", model.BlobPath);
|
||||
MarkModelAsFallback(ollamaName);
|
||||
return false;
|
||||
}
|
||||
|
||||
var port = FindAvailablePort();
|
||||
var containerName = SanitizeContainerName($"ngino-llamacpp-{ollamaName}");
|
||||
|
||||
try
|
||||
{
|
||||
var existingPort = await FindExistingContainerPortAsync(containerName);
|
||||
if (existingPort.HasValue)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Reusing existing container for {Model} on port {Port}", ollamaName, existingPort.Value);
|
||||
_modelPorts[ollamaName] = existingPort.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
await RunDockerAsync(["rm", "-f", containerName], CancellationToken.None);
|
||||
|
||||
var args = BuildDockerRunArgs(containerName, model, port);
|
||||
_logger.LogInformation(
|
||||
"Starting llama.cpp container for {Model} on port {Port}: docker {Args}",
|
||||
ollamaName, port, string.Join(" ", args));
|
||||
|
||||
var (exitCode, output) = await RunDockerWithOutputAsync(args, cancellationToken);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Failed to start llama.cpp container for {Model}, exit code: {ExitCode}, output: {Output}",
|
||||
ollamaName, exitCode, output);
|
||||
MarkModelAsFallback(ollamaName);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"llama.cpp container for {Model} started on port {Port}. Waiting for it to become ready...",
|
||||
ollamaName, port);
|
||||
|
||||
var result = await WaitForServerReadyAsync("localhost", port, containerName, cancellationToken);
|
||||
if (result != ContainerStartResult.Ready)
|
||||
{
|
||||
if (result == ContainerStartResult.ContainerExited)
|
||||
{
|
||||
MarkModelAsPermanentFallback(ollamaName);
|
||||
}
|
||||
else
|
||||
{
|
||||
MarkModelAsFallback(ollamaName);
|
||||
}
|
||||
|
||||
_logger.LogError(
|
||||
"llama.cpp container for {Model} did not become ready on port {Port} within {Timeout} ({Result}). Stopping it.",
|
||||
ollamaName, port, ContainerStartTimeout, result);
|
||||
|
||||
try
|
||||
{
|
||||
await RunDockerAsync(["stop", "--time", "10", containerName], CancellationToken.None);
|
||||
await RunDockerAsync(["rm", "-f", containerName], CancellationToken.None);
|
||||
}
|
||||
catch (Exception cleanupException)
|
||||
{
|
||||
_logger.LogWarning(cleanupException, "Failed to clean up container {ContainerName}", containerName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_modelPorts[ollamaName] = port;
|
||||
_logger.LogInformation("llama.cpp container for {Model} is ready on port {Port}.", ollamaName, port);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start llama.cpp container for {Model}", ollamaName);
|
||||
MarkModelAsFallback(ollamaName);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseReservedPort(port);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> StopModelContainerAsync(string ollamaModelName, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!RemoveModelPort(ollamaModelName))
|
||||
{
|
||||
_logger.LogWarning("No running container found for model {Model}", ollamaModelName);
|
||||
return false;
|
||||
}
|
||||
|
||||
var containerName = SanitizeContainerName($"ngino-llamacpp-{ollamaModelName}");
|
||||
|
||||
_logger.LogInformation("Stopping llama.cpp container {ContainerName}", containerName);
|
||||
|
||||
try
|
||||
{
|
||||
await RunDockerAsync(["stop", "--time", "10", containerName], cancellationToken);
|
||||
await RunDockerAsync(["rm", "-f", containerName], cancellationToken);
|
||||
ClearModelFallback(ollamaModelName);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to stop container {ContainerName}", containerName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAllContainersAsync()
|
||||
{
|
||||
_logger.LogInformation("Stopping all llama.cpp containers...");
|
||||
|
||||
try
|
||||
{
|
||||
var (exitCode, output) = await RunDockerWithOutputAsync(
|
||||
["ps", "-q", "--filter", $"label={NginoContainerLabel}"],
|
||||
CancellationToken.None);
|
||||
|
||||
if (exitCode == 0 && !string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
var containerIds = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var id in containerIds)
|
||||
{
|
||||
await RunDockerAsync(["stop", "--time", "10", id], CancellationToken.None);
|
||||
await RunDockerAsync(["rm", "-f", id], CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to stop all llama.cpp containers");
|
||||
}
|
||||
|
||||
_modelPorts.Clear();
|
||||
_reservedPorts.Clear();
|
||||
_fallbackModels.Clear();
|
||||
}
|
||||
|
||||
public async Task<bool> TestDockerAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (exitCode, _) = await RunDockerWithOutputAsync(["info", "--format", "{{.ServerVersion}}"], CancellationToken.None);
|
||||
return exitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAllContainersAsync();
|
||||
}
|
||||
|
||||
private LlamaCppModel? ParseManifest(string manifestPath)
|
||||
{
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("layers", out var layers) || layers.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? modelDigest = null;
|
||||
foreach (var layer in layers.EnumerateArray())
|
||||
{
|
||||
if (layer.TryGetProperty("mediaType", out var mediaType)
|
||||
&& mediaType.GetString() == "application/vnd.ollama.image.model"
|
||||
&& layer.TryGetProperty("digest", out var digest))
|
||||
{
|
||||
modelDigest = digest.GetString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(modelDigest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var modelName = ResolveModelName(manifestPath);
|
||||
if (modelName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blobName = modelDigest.Replace(":", "-", StringComparison.Ordinal);
|
||||
var blobPath = Path.GetFullPath(Path.Combine(_blobsPath, blobName));
|
||||
|
||||
return new LlamaCppModel
|
||||
{
|
||||
OllamaName = modelName,
|
||||
BlobDigest = blobName,
|
||||
BlobPath = blobPath,
|
||||
ManifestPath = manifestPath
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveModelName(string manifestPath)
|
||||
{
|
||||
var normalizedPath = manifestPath.Replace('\\', '/');
|
||||
var parts = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var manifestIndex = Array.FindLastIndex(parts, p =>
|
||||
p.Equals("manifests", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (manifestIndex < 0 || manifestIndex >= parts.Length - 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relativeParts = parts[(manifestIndex + 1)..];
|
||||
|
||||
if (relativeParts.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var registry = relativeParts[0];
|
||||
|
||||
if (registry.Equals("registry.ollama.ai", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (relativeParts.Length < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (relativeParts[1].Equals("library", StringComparison.OrdinalIgnoreCase) && relativeParts.Length >= 4)
|
||||
{
|
||||
return $"{relativeParts[2]}:{relativeParts[3]}";
|
||||
}
|
||||
|
||||
if (relativeParts.Length == 3)
|
||||
{
|
||||
return $"{relativeParts[1]}:{relativeParts[2]}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (relativeParts.Length >= 3)
|
||||
{
|
||||
var tag = relativeParts[^1];
|
||||
var modelPath = string.Join("/", relativeParts.Take(relativeParts.Length - 1));
|
||||
return $"{modelPath}:{tag}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string[] BuildDockerRunArgs(string containerName, LlamaCppModel model, int port)
|
||||
{
|
||||
var blobsDir = Path.GetDirectoryName(Path.GetFullPath(model.BlobPath))!;
|
||||
var blobFile = Path.GetFileName(model.BlobPath);
|
||||
|
||||
var args = new List<string>
|
||||
{
|
||||
"run",
|
||||
"-d",
|
||||
"--label", $"{NginoContainerLabel}=true",
|
||||
"--name", containerName,
|
||||
"-p", $"{port}:{port}",
|
||||
"-v", $"{blobsDir}:/models/blobs:ro",
|
||||
};
|
||||
|
||||
if (HasRocmDevices())
|
||||
{
|
||||
args.Add("--device=/dev/kfd");
|
||||
args.Add("--device=/dev/dri");
|
||||
args.Add("--group-add=video");
|
||||
}
|
||||
|
||||
if (HasNvidiaGpu() && !HasRocmDevices())
|
||||
{
|
||||
args.Add("--gpus=all");
|
||||
}
|
||||
|
||||
args.Add(_dockerImage);
|
||||
args.Add("--embeddings");
|
||||
args.Add("-m");
|
||||
args.Add($"/models/blobs/{blobFile}");
|
||||
args.Add("-ngl");
|
||||
args.Add("auto");
|
||||
if (_parallel.HasValue)
|
||||
{
|
||||
args.Add("--parallel");
|
||||
args.Add(_parallel.Value.ToString());
|
||||
}
|
||||
args.Add("--host");
|
||||
args.Add("0.0.0.0");
|
||||
args.Add("--port");
|
||||
args.Add(port.ToString());
|
||||
|
||||
return [.. args];
|
||||
}
|
||||
|
||||
private async Task<int?> FindExistingContainerPortAsync(string containerName)
|
||||
{
|
||||
var (exitCode, output) = await RunDockerWithOutputAsync(
|
||||
["ps", "--filter", $"name=^{containerName}$", "--format", "{{.Ports}}"],
|
||||
CancellationToken.None);
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var port = ParseHostPort(line);
|
||||
if (port.HasValue)
|
||||
{
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? ParseHostPort(string ports)
|
||||
{
|
||||
foreach (var mapping in ports.Split(','))
|
||||
{
|
||||
var trimmed = mapping.Trim();
|
||||
var arrowIndex = trimmed.IndexOf("->", StringComparison.Ordinal);
|
||||
if (arrowIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hostPart = trimmed[..arrowIndex].Trim();
|
||||
var colonIndex = hostPart.LastIndexOf(':');
|
||||
if (colonIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (int.TryParse(hostPart[(colonIndex + 1)..], out var port))
|
||||
{
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private int FindAvailablePort()
|
||||
{
|
||||
lock (_portAllocationLock)
|
||||
{
|
||||
var usedPorts = new HashSet<int>(_modelPorts.Values);
|
||||
foreach (var reservedPort in _reservedPorts.Keys)
|
||||
{
|
||||
usedPorts.Add(reservedPort);
|
||||
}
|
||||
|
||||
var port = _basePort;
|
||||
|
||||
while (usedPorts.Contains(port))
|
||||
{
|
||||
port++;
|
||||
}
|
||||
|
||||
_reservedPorts[port] = 0;
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseReservedPort(int port)
|
||||
{
|
||||
_reservedPorts.TryRemove(port, out _);
|
||||
}
|
||||
|
||||
private bool RemoveModelPort(string ollamaModelName)
|
||||
{
|
||||
if (_modelPorts.TryRemove(ollamaModelName, out var port))
|
||||
{
|
||||
ReleaseReservedPort(port);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<ContainerStartResult> WaitForServerReadyAsync(
|
||||
string host, int port, string containerName, CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + ContainerStartTimeout;
|
||||
|
||||
var tcpResult = await WaitForTcpPortAsync(host, port, containerName, deadline, cancellationToken);
|
||||
if (tcpResult != ContainerStartResult.Ready)
|
||||
{
|
||||
return tcpResult;
|
||||
}
|
||||
|
||||
using var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectTimeout = TimeSpan.FromSeconds(3),
|
||||
UseProxy = false
|
||||
};
|
||||
using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!await IsDockerContainerRunningAsync(containerName))
|
||||
{
|
||||
await LogContainerOutputAsync(containerName);
|
||||
return ContainerStartResult.ContainerExited;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.GetAsync(
|
||||
$"http://{host}:{port}/health", cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return ContainerStartResult.Ready;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Health probe of llama.cpp container {ContainerName} failed; retrying.", containerName);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Health probe of llama.cpp container {ContainerName} failed; retrying.", containerName);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
}
|
||||
|
||||
return ContainerStartResult.TimedOut;
|
||||
}
|
||||
|
||||
private async Task<ContainerStartResult> WaitForTcpPortAsync(
|
||||
string host, int port, string containerName, DateTime deadline, CancellationToken cancellationToken)
|
||||
{
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!await IsDockerContainerRunningAsync(containerName))
|
||||
{
|
||||
await LogContainerOutputAsync(containerName);
|
||||
return ContainerStartResult.ContainerExited;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellationToken);
|
||||
return ContainerStartResult.Ready;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
}
|
||||
|
||||
return ContainerStartResult.TimedOut;
|
||||
}
|
||||
|
||||
private async Task<bool> IsDockerContainerRunningAsync(string containerName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (exitCode, output) = await RunDockerWithOutputAsync(
|
||||
["inspect", "-f", "{{.State.Running}}", containerName],
|
||||
CancellationToken.None);
|
||||
return exitCode == 0 && string.Equals(output.Trim(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LogContainerOutputAsync(string containerName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (_, output) = await RunDockerWithOutputAsync(
|
||||
["logs", "--tail", "100", containerName],
|
||||
CancellationToken.None);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
_logger.LogError(
|
||||
"llama.cpp container {ContainerName} exited before becoming ready. Last output:\n{Output}",
|
||||
containerName, output);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"llama.cpp container {ContainerName} exited before becoming ready, but produced no output.",
|
||||
containerName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read logs of container {ContainerName}", containerName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeContainerName(string name)
|
||||
{
|
||||
var sanitized = InvalidContainerNameChars().Replace(name, "_");
|
||||
return sanitized.Trim('_').ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task<int> RunDockerAsync(string[] args, CancellationToken cancellationToken)
|
||||
{
|
||||
var (exitCode, _) = await RunDockerWithOutputAsync(args, cancellationToken);
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
private async Task<(int ExitCode, string Output)> RunDockerWithOutputAsync(
|
||||
string[] args, CancellationToken cancellationToken)
|
||||
{
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = string.Join(" ", args.Select(a => a.Contains(' ') ? $"\"{a}\"" : a)),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
|
||||
var readOutput = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var readError = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
var waitTask = process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
var completed = await Task.WhenAny(waitTask, Task.Delay(DockerTimeout, cancellationToken));
|
||||
|
||||
string output;
|
||||
string error;
|
||||
|
||||
try
|
||||
{
|
||||
output = await readOutput;
|
||||
error = await readError;
|
||||
}
|
||||
catch
|
||||
{
|
||||
output = "";
|
||||
error = "timed out";
|
||||
}
|
||||
|
||||
if (completed != waitTask)
|
||||
{
|
||||
_logger.LogWarning("Docker command timed out: docker {Args}", string.Join(" ", args));
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
return (-1, error);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
output = $"{output}\n{error}".Trim();
|
||||
}
|
||||
|
||||
return (process.ExitCode, output);
|
||||
}
|
||||
|
||||
private static string GetDefaultDockerImage()
|
||||
{
|
||||
if (HasRocmDevices())
|
||||
{
|
||||
return "ghcr.io/ggml-org/llama.cpp:server-rocm";
|
||||
}
|
||||
|
||||
if (HasNvidiaGpu())
|
||||
{
|
||||
return "ghcr.io/ggml-org/llama.cpp:server-cuda";
|
||||
}
|
||||
|
||||
return "ghcr.io/ggml-org/llama.cpp:server";
|
||||
}
|
||||
|
||||
private static bool HasRocmDevices() => File.Exists("/dev/kfd") && Directory.Exists("/dev/dri");
|
||||
|
||||
private static bool HasNvidiaGpu()
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists("/proc/driver/nvidia/version")
|
||||
|| Directory.Exists("/proc/driver/nvidia/gpus");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"[^a-zA-Z0-9_.-]")]
|
||||
private static partial Regex InvalidContainerNameChars();
|
||||
|
||||
private enum ContainerStartResult
|
||||
{
|
||||
Ready,
|
||||
ContainerExited,
|
||||
TimedOut
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record LlamaCppModel
|
||||
{
|
||||
public required string OllamaName { get; init; }
|
||||
public required string BlobDigest { get; init; }
|
||||
public required string BlobPath { get; init; }
|
||||
public required string ManifestPath { get; init; }
|
||||
}
|
||||
+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>
|
||||
@@ -0,0 +1,605 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class OllamaToLlamaCppTranslator
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly string _modelName;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public OllamaToLlamaCppTranslator(string modelName, ILogger logger)
|
||||
{
|
||||
_modelName = modelName;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool TryTranslatePath(string method, string pathAndQuery, out string newPath)
|
||||
{
|
||||
var path = pathAndQuery.Split('?')[0];
|
||||
newPath = path switch
|
||||
{
|
||||
"/api/generate" => "/completion",
|
||||
"/api/chat" => "/v1/chat/completions",
|
||||
"/api/embed" or "/api/embeddings" => "/v1/embeddings",
|
||||
_ => null!
|
||||
};
|
||||
return newPath is not null;
|
||||
}
|
||||
|
||||
public byte[] TranslateBody(string pathAndQuery, byte[] body)
|
||||
{
|
||||
if (body is null || body.Length == 0)
|
||||
return body;
|
||||
|
||||
var path = pathAndQuery.Split('?')[0];
|
||||
|
||||
try
|
||||
{
|
||||
return path switch
|
||||
{
|
||||
"/api/generate" => TranslateGenerateBody(body),
|
||||
"/api/chat" => TranslateChatBody(body),
|
||||
"/api/embed" or "/api/embeddings" => TranslateEmbedBody(body),
|
||||
_ => body
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to translate request body for {Path}", path);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] TranslateGenerateBody(byte[] body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var result = new Dictionary<string, object?>();
|
||||
|
||||
if (root.TryGetProperty("prompt", out var prompt))
|
||||
result["prompt"] = prompt.GetString() ?? "";
|
||||
|
||||
result["stream"] = true;
|
||||
|
||||
CopyOptions(root, result);
|
||||
|
||||
if (!result.ContainsKey("n_predict"))
|
||||
result["n_predict"] = 2048;
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||
}
|
||||
|
||||
private byte[] TranslateChatBody(byte[] body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var result = new Dictionary<string, object?>();
|
||||
|
||||
if (root.TryGetProperty("messages", out var messages))
|
||||
result["messages"] = messages.Deserialize<object>(JsonOptions);
|
||||
|
||||
result["stream"] = true;
|
||||
|
||||
if (root.TryGetProperty("model", out var model))
|
||||
result["model"] = model.GetString();
|
||||
|
||||
CopyOptions(root, result, chat: true);
|
||||
|
||||
if (!result.ContainsKey("max_tokens"))
|
||||
result["max_tokens"] = 2048;
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||
}
|
||||
|
||||
public static bool ExtractOriginalStream(byte[] originalBody, string pathAndQuery)
|
||||
{
|
||||
var path = pathAndQuery.Split('?')[0];
|
||||
if (path is not "/api/generate" and not "/api/chat")
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(originalBody);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("stream", out var stream))
|
||||
return stream.ValueKind != JsonValueKind.False;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private byte[] TranslateEmbedBody(byte[] body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
["model"] = _modelName
|
||||
};
|
||||
|
||||
if (root.TryGetProperty("input", out var input))
|
||||
result["input"] = input.Deserialize<object>(JsonOptions);
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||
}
|
||||
|
||||
public Func<HttpResponseMessage, CancellationToken, Task> CreateResponseHandler(
|
||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||
string requestId,
|
||||
string originalPath,
|
||||
Func<bool> originalRequestedStream) =>
|
||||
async (response, ct) =>
|
||||
{
|
||||
await TranslateAndSendResponse(response, sendAsync, requestId, originalPath, originalRequestedStream(), ct);
|
||||
};
|
||||
|
||||
private async Task TranslateAndSendResponse(
|
||||
HttpResponseMessage httpResponse,
|
||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||
string requestId,
|
||||
string originalPath,
|
||||
bool originalRequestedStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = originalPath.Split('?')[0];
|
||||
|
||||
switch (path)
|
||||
{
|
||||
case "/api/tags":
|
||||
await SynthesizeTagsResponse(sendAsync, requestId, cancellationToken);
|
||||
return;
|
||||
|
||||
case "/api/ps":
|
||||
await SynthesizePsResponse(sendAsync, requestId, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
await ForwardRawResponse(httpResponse, sendAsync, requestId, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path is "/api/generate" or "/api/chat")
|
||||
{
|
||||
if (originalRequestedStream)
|
||||
{
|
||||
await TranslateStreaming(path, httpResponse, sendAsync, requestId, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await TranslateNonStreaming(path, httpResponse, sendAsync, requestId, cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await ForwardRawResponse(httpResponse, sendAsync, requestId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ForwardRawResponse(
|
||||
HttpResponseMessage httpResponse,
|
||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||
string requestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||
RequestId = requestId,
|
||||
StatusCode = (int)httpResponse.StatusCode,
|
||||
ReasonPhrase = httpResponse.ReasonPhrase
|
||||
}, cancellationToken);
|
||||
|
||||
await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var buffer = new byte[64 * 1024];
|
||||
while (true)
|
||||
{
|
||||
var bytesRead = await stream.ReadAsync(buffer, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
break;
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseBody,
|
||||
RequestId = requestId,
|
||||
Body = buffer.AsSpan(0, bytesRead).ToArray()
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||
RequestId = requestId
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SynthesizeTagsResponse(
|
||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||
string requestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var modelList = new
|
||||
{
|
||||
models = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = _modelName,
|
||||
model = _modelName,
|
||||
modified_at = DateTime.UtcNow.ToString("o"),
|
||||
size = 0L,
|
||||
digest = "sha256:" + _modelName,
|
||||
details = new
|
||||
{
|
||||
format = "gguf",
|
||||
family = "llama",
|
||||
parameter_size = "",
|
||||
quantization_level = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(modelList, JsonOptions);
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||
RequestId = requestId,
|
||||
StatusCode = 200,
|
||||
ReasonPhrase = "OK"
|
||||
}, cancellationToken);
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseBody,
|
||||
RequestId = requestId,
|
||||
Body = body
|
||||
}, cancellationToken);
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||
RequestId = requestId
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SynthesizePsResponse(
|
||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||
string requestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(new { models = Array.Empty<object>() }, JsonOptions);
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||
RequestId = requestId,
|
||||
StatusCode = 200,
|
||||
ReasonPhrase = "OK"
|
||||
}, cancellationToken);
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseBody,
|
||||
RequestId = requestId,
|
||||
Body = body
|
||||
}, cancellationToken);
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||
RequestId = requestId
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task TranslateNonStreaming(
|
||||
string path,
|
||||
HttpResponseMessage httpResponse,
|
||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||
string requestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||
RequestId = requestId,
|
||||
StatusCode = (int)httpResponse.StatusCode,
|
||||
ReasonPhrase = httpResponse.ReasonPhrase
|
||||
}, cancellationToken);
|
||||
|
||||
var body = await httpResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
byte[] translatedBody;
|
||||
|
||||
try
|
||||
{
|
||||
translatedBody = path switch
|
||||
{
|
||||
"/api/generate" => TranslateNonStreamingGenerate(body),
|
||||
"/api/chat" => TranslateNonStreamingChat(body),
|
||||
_ => body
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to translate non-streaming response");
|
||||
translatedBody = body;
|
||||
}
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseBody,
|
||||
RequestId = requestId,
|
||||
Body = translatedBody
|
||||
}, cancellationToken);
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||
RequestId = requestId
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task TranslateStreaming(
|
||||
string path,
|
||||
HttpResponseMessage httpResponse,
|
||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||
string requestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||
RequestId = requestId,
|
||||
StatusCode = 200,
|
||||
ReasonPhrase = "OK"
|
||||
}, cancellationToken);
|
||||
|
||||
await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(cancellationToken);
|
||||
if (line is null)
|
||||
break;
|
||||
|
||||
if (!line.StartsWith("data: ", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var jsonStr = line[6..];
|
||||
if (jsonStr == "[DONE]")
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
var root = doc.RootElement;
|
||||
|
||||
byte[]? chunk = path switch
|
||||
{
|
||||
"/api/generate" => TranslateGenerateStreamChunk(root),
|
||||
"/api/chat" => TranslateChatStreamChunk(root),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (chunk is not null)
|
||||
{
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseBody,
|
||||
RequestId = requestId,
|
||||
Body = chunk
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseBody,
|
||||
RequestId = requestId,
|
||||
Body = Encoding.UTF8.GetBytes(
|
||||
$"{{\"model\":\"{EscapeJson(_modelName)}\",\"response\":\"\",\"done\":true}}\n")
|
||||
}, cancellationToken);
|
||||
|
||||
await sendAsync(new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||
RequestId = requestId
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private byte[] TranslateNonStreamingGenerate(byte[] body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
["model"] = _modelName,
|
||||
["created_at"] = DateTime.UtcNow.ToString("o"),
|
||||
["response"] = root.TryGetProperty("content", out var content) ? content.GetString() : "",
|
||||
["done"] = root.TryGetProperty("stop", out var stop) && stop.GetBoolean()
|
||||
};
|
||||
|
||||
CopyTimings(root, result);
|
||||
return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||
}
|
||||
|
||||
private byte[] TranslateNonStreamingChat(byte[] body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var message = root.GetProperty("choices")[0].GetProperty("message");
|
||||
var content = message.GetProperty("content").GetString() ?? "";
|
||||
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
["model"] = _modelName,
|
||||
["created_at"] = DateTime.UtcNow.ToString("o"),
|
||||
["message"] = new Dictionary<string, object?>
|
||||
{
|
||||
["role"] = "assistant",
|
||||
["content"] = content
|
||||
},
|
||||
["done"] = true
|
||||
};
|
||||
|
||||
if (root.TryGetProperty("usage", out var usage))
|
||||
{
|
||||
if (usage.TryGetProperty("completion_tokens", out var comp))
|
||||
result["eval_count"] = comp.GetInt32();
|
||||
if (usage.TryGetProperty("prompt_tokens", out var prompt))
|
||||
result["prompt_eval_count"] = prompt.GetInt32();
|
||||
}
|
||||
|
||||
CopyTimings(root, result);
|
||||
return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||
}
|
||||
|
||||
private byte[]? TranslateGenerateStreamChunk(JsonElement root)
|
||||
{
|
||||
var done = root.TryGetProperty("stop", out var stop) && stop.GetBoolean();
|
||||
var text = root.TryGetProperty("content", out var content) ? content.GetString() ?? "" : "";
|
||||
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
["model"] = _modelName,
|
||||
["created_at"] = DateTime.UtcNow.ToString("o"),
|
||||
["response"] = text,
|
||||
["done"] = done
|
||||
};
|
||||
|
||||
if (done)
|
||||
CopyTimings(root, result);
|
||||
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||
var withNewline = new byte[bytes.Length + 1];
|
||||
bytes.CopyTo(withNewline, 0);
|
||||
withNewline[^1] = (byte)'\n';
|
||||
return withNewline;
|
||||
}
|
||||
|
||||
private byte[]? TranslateChatStreamChunk(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("choices", out var choices) || choices.GetArrayLength() == 0)
|
||||
return null;
|
||||
|
||||
var choice = choices[0];
|
||||
var delta = choice.GetProperty("delta");
|
||||
var finishReason = choice.TryGetProperty("finish_reason", out var fr) ? fr.GetString() : null;
|
||||
var done = finishReason is not null && finishReason != "null" && finishReason != "";
|
||||
|
||||
var content = delta.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
var role = delta.TryGetProperty("role", out var r) ? r.GetString() : null;
|
||||
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
["model"] = _modelName,
|
||||
["created_at"] = DateTime.UtcNow.ToString("o"),
|
||||
["message"] = new Dictionary<string, object?>
|
||||
{
|
||||
["role"] = role ?? "assistant",
|
||||
["content"] = content
|
||||
},
|
||||
["done"] = done
|
||||
};
|
||||
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||
var withNewline = new byte[bytes.Length + 1];
|
||||
bytes.CopyTo(withNewline, 0);
|
||||
withNewline[^1] = (byte)'\n';
|
||||
return withNewline;
|
||||
}
|
||||
|
||||
private static void CopyOptions(JsonElement root, Dictionary<string, object?> target, bool chat = false)
|
||||
{
|
||||
if (!root.TryGetProperty("options", out var options) || options.ValueKind != JsonValueKind.Object)
|
||||
return;
|
||||
|
||||
foreach (var opt in options.EnumerateObject())
|
||||
{
|
||||
var key = (chat ? MapChatOptionName(opt.Name) : MapGenerateOptionName(opt.Name)) ?? opt.Name;
|
||||
target[key] = ValueToObject(opt.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? MapGenerateOptionName(string name) => name switch
|
||||
{
|
||||
"num_predict" => "n_predict",
|
||||
"temperature" => "temperature",
|
||||
"top_p" => "top_p",
|
||||
"top_k" => "top_k",
|
||||
"seed" => "seed",
|
||||
"stop" => "stop",
|
||||
"repeat_penalty" => "repeat_penalty",
|
||||
"repeat_last_n" => "repeat_last_n",
|
||||
"frequency_penalty" => "frequency_penalty",
|
||||
"presence_penalty" => "presence_penalty",
|
||||
"mirostat" => "mirostat",
|
||||
"mirostat_tau" => "mirostat_tau",
|
||||
"mirostat_eta" => "mirostat_eta",
|
||||
"num_ctx" => "n_ctx",
|
||||
"num_batch" => "n_batch",
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string? MapChatOptionName(string name) => name switch
|
||||
{
|
||||
"num_predict" => "max_tokens",
|
||||
"temperature" => "temperature",
|
||||
"top_p" => "top_p",
|
||||
"seed" => "seed",
|
||||
"stop" => "stop",
|
||||
"frequency_penalty" => "frequency_penalty",
|
||||
"presence_penalty" => "presence_penalty",
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static void CopyTimings(JsonElement root, Dictionary<string, object?> target)
|
||||
{
|
||||
if (!root.TryGetProperty("timings", out var timings))
|
||||
return;
|
||||
|
||||
if (timings.TryGetProperty("predicted_n", out var predN))
|
||||
target["eval_count"] = predN.GetInt32();
|
||||
if (timings.TryGetProperty("predicted_ms", out var predMs))
|
||||
target["eval_duration"] = (long)(predMs.GetDouble() * 1_000_000);
|
||||
if (timings.TryGetProperty("prompt_n", out var promptN))
|
||||
target["prompt_eval_count"] = promptN.GetInt32();
|
||||
if (timings.TryGetProperty("prompt_ms", out var promptMs))
|
||||
target["prompt_eval_duration"] = (long)(promptMs.GetDouble() * 1_000_000);
|
||||
}
|
||||
|
||||
private static object? ValueToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l) ? (object)l : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => element.Deserialize<object>(JsonOptions)
|
||||
};
|
||||
}
|
||||
|
||||
private static string EscapeJson(string value) =>
|
||||
value.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
}
|
||||
@@ -1,25 +1,45 @@
|
||||
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}");
|
||||
if (options.InsecureSkipTlsVerify)
|
||||
{
|
||||
Console.WriteLine(" WARNING: server TLS certificate validation is disabled");
|
||||
}
|
||||
|
||||
if (options.UseLlamaCppViaDocker)
|
||||
{
|
||||
Console.WriteLine($" llama.cpp via Docker: enabled");
|
||||
Console.WriteLine($" ollama models path: {options.UseOllamaModelsPath ?? "(not set)"}");
|
||||
Console.WriteLine($" llama.cpp docker image: {options.LlamaCppDockerImage ?? "(auto)"}");
|
||||
Console.WriteLine($" llama.cpp base port: {options.LlamaCppBasePort}");
|
||||
Console.WriteLine(
|
||||
options.LlamaCppParallel.HasValue
|
||||
? $" llama.cpp parallel slots: {options.LlamaCppParallel.Value}"
|
||||
: " llama.cpp parallel slots: (llama.cpp default)");
|
||||
}
|
||||
|
||||
var logDirectory = options.LogDirectory ?? Path.Combine(AppContext.BaseDirectory, "Logs");
|
||||
Console.WriteLine($" log directory: {logDirectory}");
|
||||
|
||||
// Args are parsed by ClientOptions; keep them away from the host configuration.
|
||||
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] });
|
||||
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);
|
||||
builder.Logging.AddProvider(new FileLoggerProvider(logDirectory));
|
||||
|
||||
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,23 +4,25 @@ 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 ConcurrentDictionary<string, PendingRequestBody> _pendingRequestBodies = new();
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ClientOptions _options;
|
||||
private readonly ILogger<TunnelClient> _logger;
|
||||
private readonly object _modelSnapshotLock = new();
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private readonly LlamaCppManager? _llamaCppManager;
|
||||
private List<string> _lastActiveModels = [];
|
||||
private List<string> _lastModels = [];
|
||||
|
||||
@@ -32,15 +34,51 @@ internal sealed class TunnelClient
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
|
||||
if (_options.UseLlamaCppViaDocker)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.UseOllamaModelsPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"--use-ollama-models-path is required when --use-llama-cpp-via-docker is set.");
|
||||
}
|
||||
|
||||
_llamaCppManager = new LlamaCppManager(
|
||||
_options.UseOllamaModelsPath,
|
||||
_options.LlamaCppDockerImage,
|
||||
_options.LlamaCppBasePort,
|
||||
_logger,
|
||||
_options.LlamaCppFallbackCooldown,
|
||||
_options.LlamaCppParallel);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_llamaCppManager is not null)
|
||||
{
|
||||
_logger.LogInformation("Testing Docker availability...");
|
||||
var dockerAvailable = await _llamaCppManager.TestDockerAsync();
|
||||
if (!dockerAvailable)
|
||||
{
|
||||
_logger.LogWarning("Docker is not available. llama.cpp via Docker will not work.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Docker is available. Using llama.cpp image: {Image}", _llamaCppManager.DockerImage);
|
||||
}
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using var socket = new ClientWebSocket();
|
||||
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
if (_options.InsecureSkipTlsVerify)
|
||||
{
|
||||
socket.Options.RemoteCertificateValidationCallback = static (_, _, _, _) => true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.Token))
|
||||
{
|
||||
socket.Options.SetRequestHeader(ProtocolConstants.TokenHeader, _options.Token);
|
||||
@@ -149,6 +187,16 @@ internal sealed class TunnelClient
|
||||
|
||||
private async Task<List<string>> GetUpstreamModelsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_llamaCppManager is not null)
|
||||
{
|
||||
var models = _llamaCppManager.DiscoverModelsWithBlob();
|
||||
return models
|
||||
.Select(m => m.OllamaName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_options.Upstream, "/api/tags"));
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -161,6 +209,17 @@ internal sealed class TunnelClient
|
||||
|
||||
private async Task<List<string>> GetActiveUpstreamModelsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_llamaCppManager is not null)
|
||||
{
|
||||
var models = _llamaCppManager.DiscoverModelsWithBlob();
|
||||
return models
|
||||
.Where(m => _llamaCppManager.IsModelActive(m.OllamaName))
|
||||
.Select(m => m.OllamaName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_options.Upstream, "/api/ps"));
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
@@ -285,7 +344,8 @@ internal sealed class TunnelClient
|
||||
switch (message.Type)
|
||||
{
|
||||
case TunnelMessageTypes.HttpRequest:
|
||||
StartRequest(socket, message, cancellationToken);
|
||||
_pendingRequestBodies[message.RequestId] = new PendingRequestBody();
|
||||
_ = Task.Run(() => StartRequest(socket, message, cancellationToken), cancellationToken);
|
||||
break;
|
||||
|
||||
case TunnelMessageTypes.HttpRequestBody:
|
||||
@@ -293,6 +353,10 @@ internal sealed class TunnelClient
|
||||
{
|
||||
requestWithBody.AddBody(message.Body ?? []);
|
||||
}
|
||||
else if (_pendingRequestBodies.TryGetValue(message.RequestId, out var pendingBody))
|
||||
{
|
||||
pendingBody.AddBody(message.Body ?? []);
|
||||
}
|
||||
break;
|
||||
|
||||
case TunnelMessageTypes.HttpRequestComplete:
|
||||
@@ -300,6 +364,10 @@ internal sealed class TunnelClient
|
||||
{
|
||||
completedRequest.CompleteBody();
|
||||
}
|
||||
else if (_pendingRequestBodies.TryGetValue(message.RequestId, out var pendingBody))
|
||||
{
|
||||
pendingBody.Complete();
|
||||
}
|
||||
break;
|
||||
|
||||
case TunnelMessageTypes.Cancel:
|
||||
@@ -307,6 +375,10 @@ internal sealed class TunnelClient
|
||||
{
|
||||
cancelledRequest.Cancel();
|
||||
}
|
||||
else
|
||||
{
|
||||
_pendingRequestBodies.TryRemove(message.RequestId, out _);
|
||||
}
|
||||
break;
|
||||
|
||||
case TunnelMessageTypes.ModelCommand:
|
||||
@@ -358,6 +430,11 @@ internal sealed class TunnelClient
|
||||
throw new InvalidOperationException("Model command is missing a model name.");
|
||||
}
|
||||
|
||||
if (_llamaCppManager is not null)
|
||||
{
|
||||
return await ExecuteModelCommandWithLlamaCppAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
using var request = BuildModelCommandRequest(message);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
@@ -374,6 +451,126 @@ internal sealed class TunnelClient
|
||||
return BuildModelCommandResult(message.RequestId, response, body);
|
||||
}
|
||||
|
||||
private async Task<TunnelMessage> ExecuteModelCommandWithLlamaCppAsync(
|
||||
TunnelMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var modelName = message.Model?.Trim();
|
||||
var normalizedCommand = NormalizeModelCommand(message.Command);
|
||||
|
||||
switch (normalizedCommand)
|
||||
{
|
||||
case "load":
|
||||
{
|
||||
var models = _llamaCppManager!.DiscoverModelsWithBlob();
|
||||
var model = models.FirstOrDefault(m =>
|
||||
string.Equals(m.OllamaName, modelName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (model is null)
|
||||
{
|
||||
return new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.ModelCommandResult,
|
||||
RequestId = message.RequestId,
|
||||
StatusCode = 404,
|
||||
Error = $"Model '{modelName}' not found in Ollama models path."
|
||||
};
|
||||
}
|
||||
|
||||
var started = await _llamaCppManager.StartModelContainerAsync(model, cancellationToken);
|
||||
if (!started)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to load model '{Model}' via llama.cpp. Falling back to Ollama upstream.",
|
||||
modelName);
|
||||
|
||||
using var fallbackRequest = BuildModelCommandRequest(_options.Upstream, "load", modelName);
|
||||
using var fallbackResponse = await _httpClient.SendAsync(
|
||||
fallbackRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
var fallbackBody = await fallbackResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
|
||||
return BuildModelCommandResult(message.RequestId, fallbackResponse, fallbackBody);
|
||||
}
|
||||
|
||||
return BuildModelCommandResult(message.RequestId, 200, "OK", []);
|
||||
}
|
||||
|
||||
case "unload":
|
||||
{
|
||||
var stopped = await _llamaCppManager!.StopModelContainerAsync(modelName!, cancellationToken);
|
||||
if (!stopped)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No running llama.cpp container for model '{Model}'. Falling back to Ollama upstream to unload it.",
|
||||
modelName);
|
||||
|
||||
_llamaCppManager.ClearModelFallback(modelName!);
|
||||
|
||||
using var fallbackRequest = BuildModelCommandRequest(_options.Upstream, "unload", modelName);
|
||||
using var fallbackResponse = await _httpClient.SendAsync(
|
||||
fallbackRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
var fallbackBody = await fallbackResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
|
||||
return BuildModelCommandResult(message.RequestId, fallbackResponse, fallbackBody);
|
||||
}
|
||||
|
||||
return BuildModelCommandResult(message.RequestId, 200, "OK", []);
|
||||
}
|
||||
|
||||
case "pull":
|
||||
case "delete":
|
||||
{
|
||||
using var request = BuildModelCommandRequest(_options.Upstream, message.Command, message.Model);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
return BuildModelCommandResult(message.RequestId, response, body);
|
||||
}
|
||||
|
||||
case "show":
|
||||
{
|
||||
var models = _llamaCppManager!.DiscoverModelsWithBlob();
|
||||
var model = models.FirstOrDefault(m =>
|
||||
string.Equals(m.OllamaName, modelName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (model is null)
|
||||
{
|
||||
return new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.ModelCommandResult,
|
||||
RequestId = message.RequestId,
|
||||
StatusCode = 404,
|
||||
Error = $"Model '{modelName}' not found in Ollama models path."
|
||||
};
|
||||
}
|
||||
|
||||
var showResponse = new
|
||||
{
|
||||
modelfile = $"# llama.cpp via Docker\nFROM {model.BlobDigest}\n",
|
||||
details = new
|
||||
{
|
||||
format = "gguf",
|
||||
family = "llama",
|
||||
parameter_size = "",
|
||||
quantization_level = ""
|
||||
},
|
||||
model_info = new { }
|
||||
};
|
||||
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(showResponse, JsonOptions);
|
||||
return new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.ModelCommandResult,
|
||||
RequestId = message.RequestId,
|
||||
StatusCode = 200,
|
||||
ReasonPhrase = "OK",
|
||||
Body = body
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported model command '{message.Command}' with llama.cpp.");
|
||||
}
|
||||
}
|
||||
|
||||
private static TunnelMessage BuildModelCommandResult(
|
||||
string requestId,
|
||||
HttpResponseMessage response,
|
||||
@@ -389,6 +586,19 @@ internal sealed class TunnelClient
|
||||
};
|
||||
}
|
||||
|
||||
private static TunnelMessage BuildModelCommandResult(
|
||||
string requestId, int statusCode, string reasonPhrase, byte[] body)
|
||||
{
|
||||
return new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.ModelCommandResult,
|
||||
RequestId = requestId,
|
||||
StatusCode = statusCode,
|
||||
ReasonPhrase = reasonPhrase,
|
||||
Body = body
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ShouldRetryModelCommandWithEmbedding(
|
||||
string? command,
|
||||
HttpResponseMessage response,
|
||||
@@ -484,15 +694,122 @@ internal sealed class TunnelClient
|
||||
private static StringContent JsonContent<T>(T value) =>
|
||||
new(JsonSerializer.Serialize(value, JsonOptions), Encoding.UTF8, "application/json");
|
||||
|
||||
private void StartRequest(ClientWebSocket socket, TunnelMessage message, CancellationToken cancellationToken)
|
||||
private async Task StartRequest(ClientWebSocket socket, TunnelMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
Uri? effectiveUpstream = null;
|
||||
var modelName = UpstreamRequest.ExtractModelName(message);
|
||||
|
||||
if (_llamaCppManager is not null)
|
||||
{
|
||||
if (modelName is not null)
|
||||
{
|
||||
effectiveUpstream = _llamaCppManager.GetUpstream(modelName);
|
||||
if (effectiveUpstream is not null)
|
||||
{
|
||||
var running = await _llamaCppManager.IsContainerRunningAsync(modelName);
|
||||
if (!running)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cached llama.cpp container for model '{Model}' is not running anymore. Starting a fresh one on demand...",
|
||||
modelName);
|
||||
effectiveUpstream = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveUpstream is null)
|
||||
{
|
||||
var model = _llamaCppManager.DiscoverModelsWithBlob()
|
||||
.FirstOrDefault(m => string.Equals(m.OllamaName, modelName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (model is not null)
|
||||
{
|
||||
if (_llamaCppManager.IsModelOnFallback(modelName))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Model '{Model}' previously failed to load via llama.cpp. Routing directly to the Ollama upstream.",
|
||||
modelName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Request for model '{Model}' but no llama.cpp container is running. Starting one on demand...",
|
||||
modelName);
|
||||
|
||||
var started = await _llamaCppManager.StartModelContainerAsync(model, cancellationToken);
|
||||
if (started)
|
||||
{
|
||||
effectiveUpstream = _llamaCppManager.GetUpstream(modelName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to load model '{Model}' via llama.cpp. Falling back to the Ollama upstream.",
|
||||
modelName);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Model '{Model}' was not found in the Ollama models path. Falling back to default upstream.",
|
||||
modelName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When llama.cpp backend is active, set up request/response translation
|
||||
Func<string, string?>? pathTransform = null;
|
||||
Func<byte[], byte[]>? bodyTransform = null;
|
||||
Func<HttpResponseMessage, CancellationToken, Task>? responseHandler = null;
|
||||
string? translatorModelName = null;
|
||||
|
||||
if (_llamaCppManager is not null && effectiveUpstream is not null && modelName is not null)
|
||||
{
|
||||
var translator = new OllamaToLlamaCppTranslator(modelName, _logger);
|
||||
translatorModelName = modelName;
|
||||
bool originalRequestedStream = true;
|
||||
|
||||
pathTransform = path =>
|
||||
{
|
||||
if (translator.TryTranslatePath(message.Method ?? "GET", path, out var newPath))
|
||||
{
|
||||
return newPath;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
bodyTransform = body =>
|
||||
{
|
||||
originalRequestedStream = OllamaToLlamaCppTranslator.ExtractOriginalStream(body, message.PathAndQuery ?? "/");
|
||||
return translator.TranslateBody(message.PathAndQuery ?? "/", body);
|
||||
};
|
||||
|
||||
responseHandler = translator.CreateResponseHandler(
|
||||
(response, token) => SendAsync(socket, response, token),
|
||||
message.RequestId,
|
||||
message.PathAndQuery ?? "/",
|
||||
() => originalRequestedStream);
|
||||
}
|
||||
|
||||
var request = new UpstreamRequest(
|
||||
_options,
|
||||
_httpClient,
|
||||
message,
|
||||
(response, token) => SendAsync(socket, response, token),
|
||||
requestId => _activeRequests.TryRemove(requestId, out _),
|
||||
cancellationToken);
|
||||
cancellationToken,
|
||||
effectiveUpstream: effectiveUpstream,
|
||||
responseHandler: responseHandler,
|
||||
pathTransform: pathTransform,
|
||||
bodyTransform: bodyTransform,
|
||||
onConnectionRefused: () =>
|
||||
{
|
||||
if (_llamaCppManager is not null && modelName is not null)
|
||||
{
|
||||
_llamaCppManager.RemoveModelMapping(modelName);
|
||||
}
|
||||
});
|
||||
|
||||
if (!_activeRequests.TryAdd(message.RequestId, request))
|
||||
{
|
||||
@@ -505,10 +822,16 @@ internal sealed class TunnelClient
|
||||
Error = "Duplicate request id."
|
||||
},
|
||||
cancellationToken);
|
||||
_pendingRequestBodies.TryRemove(message.RequestId, out _);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(request.RunAsync, cancellationToken);
|
||||
if (_pendingRequestBodies.TryRemove(message.RequestId, out var pendingBody))
|
||||
{
|
||||
pendingBody.TransferTo(request);
|
||||
}
|
||||
|
||||
await request.RunAsync();
|
||||
}
|
||||
|
||||
private async Task SendAsync(ClientWebSocket socket, TunnelMessage message, CancellationToken cancellationToken)
|
||||
@@ -536,5 +859,51 @@ internal sealed class TunnelClient
|
||||
request.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
_pendingRequestBodies.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PendingRequestBody
|
||||
{
|
||||
private readonly List<byte[]> _chunks = [];
|
||||
private bool _completed;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public void AddBody(byte[] chunk)
|
||||
{
|
||||
if (chunk.Length == 0)
|
||||
return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_chunks.Add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
public void Complete()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_completed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void TransferTo(UpstreamRequest request)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var chunk in _chunks)
|
||||
{
|
||||
request.AddBody(chunk);
|
||||
}
|
||||
|
||||
_chunks.Clear();
|
||||
|
||||
if (_completed)
|
||||
{
|
||||
request.CompleteBody();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace ReverseLlama.Client;
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class TunnelWorker : BackgroundService
|
||||
{
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Channels;
|
||||
using ReverseLlama.Protocol;
|
||||
using Ngino.Protocol;
|
||||
|
||||
namespace ReverseLlama.Client;
|
||||
namespace Ngino.Client;
|
||||
|
||||
internal sealed class UpstreamRequest
|
||||
{
|
||||
@@ -19,7 +20,8 @@ internal sealed class UpstreamRequest
|
||||
"Trailer",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
ProtocolConstants.TokenHeader
|
||||
ProtocolConstants.TokenHeader,
|
||||
ProtocolConstants.ModelHeader
|
||||
};
|
||||
|
||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||
@@ -33,8 +35,13 @@ internal sealed class UpstreamRequest
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TunnelMessage _initialMessage;
|
||||
private readonly Action<string> _onComplete;
|
||||
private readonly ClientOptions _options;
|
||||
private readonly Uri _upstream;
|
||||
private readonly Func<TunnelMessage, CancellationToken, Task> _sendAsync;
|
||||
private readonly Func<HttpResponseMessage, CancellationToken, Task>? _responseHandler;
|
||||
private readonly Func<string, string?>? _pathTransform;
|
||||
private readonly Func<byte[], byte[]>? _bodyTransform;
|
||||
private readonly Action? _onConnectionRefused;
|
||||
private readonly List<byte[]> _bufferedBody = [];
|
||||
|
||||
public UpstreamRequest(
|
||||
ClientOptions options,
|
||||
@@ -42,13 +49,22 @@ internal sealed class UpstreamRequest
|
||||
TunnelMessage initialMessage,
|
||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||
Action<string> onComplete,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
Uri? effectiveUpstream = null,
|
||||
Func<HttpResponseMessage, CancellationToken, Task>? responseHandler = null,
|
||||
Func<string, string?>? pathTransform = null,
|
||||
Func<byte[], byte[]>? bodyTransform = null,
|
||||
Action? onConnectionRefused = null)
|
||||
{
|
||||
_options = options;
|
||||
_httpClient = httpClient;
|
||||
_initialMessage = initialMessage;
|
||||
_sendAsync = sendAsync;
|
||||
_onComplete = onComplete;
|
||||
_upstream = effectiveUpstream ?? options.Upstream;
|
||||
_responseHandler = responseHandler;
|
||||
_pathTransform = pathTransform;
|
||||
_bodyTransform = bodyTransform;
|
||||
_onConnectionRefused = onConnectionRefused;
|
||||
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
if (!initialMessage.HasBody)
|
||||
@@ -61,12 +77,36 @@ internal sealed class UpstreamRequest
|
||||
{
|
||||
if (body.Length > 0)
|
||||
{
|
||||
_requestBody.Writer.TryWrite(body);
|
||||
if (_bodyTransform is not null)
|
||||
{
|
||||
_bufferedBody.Add(body);
|
||||
}
|
||||
else
|
||||
{
|
||||
_requestBody.Writer.TryWrite(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteBody() =>
|
||||
public void CompleteBody()
|
||||
{
|
||||
if (_bodyTransform is not null && _bufferedBody.Count > 0)
|
||||
{
|
||||
var totalLength = _bufferedBody.Sum(b => b.Length);
|
||||
var concatenated = new byte[totalLength];
|
||||
var offset = 0;
|
||||
foreach (var chunk in _bufferedBody)
|
||||
{
|
||||
chunk.CopyTo(concatenated, offset);
|
||||
offset += chunk.Length;
|
||||
}
|
||||
|
||||
var transformed = _bodyTransform(concatenated);
|
||||
_requestBody.Writer.TryWrite(transformed);
|
||||
}
|
||||
|
||||
_requestBody.Writer.TryComplete();
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
@@ -84,22 +124,40 @@ internal sealed class UpstreamRequest
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
_cancellationTokenSource.Token);
|
||||
|
||||
await SendResponseHeadersAsync(response);
|
||||
await SendResponseBodyAsync(response);
|
||||
if (_responseHandler is not null)
|
||||
{
|
||||
await _responseHandler(response, _cancellationTokenSource.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendResponseHeadersAsync(response);
|
||||
await SendResponseBodyAsync(response);
|
||||
|
||||
await _sendAsync(
|
||||
new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||
RequestId = _initialMessage.RequestId
|
||||
},
|
||||
_cancellationTokenSource.Token);
|
||||
await _sendAsync(
|
||||
new TunnelMessage
|
||||
{
|
||||
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||
RequestId = _initialMessage.RequestId
|
||||
},
|
||||
_cancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_cancellationTokenSource.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (IsConnectionRefused(exception))
|
||||
{
|
||||
try
|
||||
{
|
||||
_onConnectionRefused?.Invoke();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
await SendErrorAsync(exception);
|
||||
}
|
||||
finally
|
||||
@@ -110,10 +168,39 @@ internal sealed class UpstreamRequest
|
||||
}
|
||||
}
|
||||
|
||||
public static string? ExtractModelName(TunnelMessage message)
|
||||
{
|
||||
if (message.Headers is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var header in message.Headers)
|
||||
{
|
||||
if (string.Equals(header.Name, ProtocolConstants.ModelHeader, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrWhiteSpace(header.Value))
|
||||
{
|
||||
return header.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildHttpRequest()
|
||||
{
|
||||
var path = _initialMessage.PathAndQuery ?? "/";
|
||||
if (_pathTransform is not null)
|
||||
{
|
||||
var transformed = _pathTransform(path);
|
||||
if (transformed is not null)
|
||||
{
|
||||
path = transformed;
|
||||
}
|
||||
}
|
||||
|
||||
var method = new HttpMethod(_initialMessage.Method ?? HttpMethod.Get.Method);
|
||||
var request = new HttpRequestMessage(method, BuildUpstreamUri(_options.Upstream, _initialMessage.PathAndQuery));
|
||||
var request = new HttpRequestMessage(method, BuildUpstreamUri(_upstream, path));
|
||||
|
||||
if (_initialMessage.HasBody)
|
||||
{
|
||||
@@ -185,7 +272,7 @@ internal sealed class UpstreamRequest
|
||||
private async Task SendResponseBodyAsync(HttpResponseMessage response)
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(_cancellationTokenSource.Token);
|
||||
var buffer = new byte[_options.ChunkSize];
|
||||
var buffer = new byte[_upstream switch { _ => 64 * 1024 }];
|
||||
|
||||
while (true)
|
||||
{
|
||||
@@ -224,6 +311,25 @@ internal sealed class UpstreamRequest
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsConnectionRefused(Exception exception)
|
||||
{
|
||||
for (var current = exception; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is SocketException socketException
|
||||
&& socketException.SocketErrorCode == SocketError.ConnectionRefused)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.Message.Contains("Connection refused", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<HeaderPair> CollectResponseHeaders(HttpResponseMessage response)
|
||||
{
|
||||
var headers = new List<HeaderPair>();
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ReverseLlama.Protocol;
|
||||
namespace Ngino.Protocol;
|
||||
|
||||
public sealed class HeaderPair
|
||||
{
|
||||
@@ -0,0 +1,11 @@
|
||||
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";
|
||||
public const string ModelHeader = "X-Ngino-Model";
|
||||
}
|
||||
@@ -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)));
|
||||
@@ -64,11 +167,14 @@ internal static class AdminEndpoints
|
||||
try
|
||||
{
|
||||
var manual = string.Equals(request.Mode, "manual", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
TimeSpan? duration = manual
|
||||
? null
|
||||
: TimeSpan.FromMinutes(Math.Clamp(request.DurationMinutes ?? 60, 1, 24 * 60));
|
||||
: request.DurationMinutes is { } minutes
|
||||
? TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 24 * 60))
|
||||
: null;
|
||||
|
||||
store.DisableClient(clientId, duration, manual, request.Reason);
|
||||
store.DisableClient(clientId, duration, manual, request.Reason, request.StartAtUtc, request.UntilUtc);
|
||||
return Results.Ok(new { clientId, disabled = true });
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -165,14 +271,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 +286,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 +372,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 +405,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)
|
||||
{
|
||||
@@ -433,7 +566,7 @@ internal static class AdminEndpoints
|
||||
{
|
||||
byModel = store.GetTokenStatsByModel(),
|
||||
byClient = store.GetTokenStatsByClient(),
|
||||
byApiKey = store.GetTokenStatsByApiKey(),
|
||||
byUserKey = store.GetTokenStatsByUserKey(),
|
||||
byGroup = store.GetTokenStatsByGroup()
|
||||
}));
|
||||
|
||||
@@ -450,8 +583,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,
|
||||
@@ -469,7 +629,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
|
||||
{
|
||||
@@ -479,9 +641,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())
|
||||
};
|
||||
@@ -513,6 +676,7 @@ internal static class AdminEndpoints
|
||||
snapshot?.ActiveModels ?? [],
|
||||
snapshot?.ModelsUpdatedAt,
|
||||
access.IsDisabled,
|
||||
access.DisabledFromUtc,
|
||||
access.DisabledUntilUtc,
|
||||
access.DisabledManually,
|
||||
access.DisabledReason,
|
||||
@@ -720,14 +884,16 @@ internal static class AdminEndpoints
|
||||
internal sealed record DisableClientRequest(
|
||||
string? Mode,
|
||||
int? DurationMinutes,
|
||||
string? Reason);
|
||||
string? Reason,
|
||||
DateTimeOffset? StartAtUtc,
|
||||
DateTimeOffset? UntilUtc);
|
||||
|
||||
internal sealed record ModelActionRequest(
|
||||
string ClientId,
|
||||
string Model,
|
||||
string Action);
|
||||
|
||||
internal sealed record CreateApiKeyRequest(string? Name);
|
||||
internal sealed record CreateUserKeyRequest(string? Name);
|
||||
|
||||
internal sealed record CreateGroupRequest(string? Name);
|
||||
|
||||
@@ -736,9 +902,12 @@ 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,
|
||||
@@ -766,6 +935,7 @@ internal sealed record ClientSummary(
|
||||
IReadOnlyList<string> ActiveModels,
|
||||
DateTimeOffset? ModelsUpdatedAt,
|
||||
bool Disabled,
|
||||
DateTimeOffset? DisabledFromUtc,
|
||||
DateTimeOffset? DisabledUntilUtc,
|
||||
bool DisabledManually,
|
||||
string? DisabledReason,
|
||||
@@ -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;
|
||||
}
|
||||
+612
-160
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,345 @@
|
||||
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("/favicon.ico", () =>
|
||||
Results.File(
|
||||
Path.Combine(app.Environment.WebRootPath, "favicon.ico"),
|
||||
"image/x-icon"));
|
||||
|
||||
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;
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
namespace Ngino.Server;
|
||||
|
||||
internal sealed class ResponseTokenCounter
|
||||
{
|
||||
+67
-25
@@ -1,13 +1,16 @@
|
||||
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 const string OllamaVersion = "0.32.5";
|
||||
|
||||
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -43,7 +46,7 @@ internal static class ReverseProxyEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
var billingCheck = managementStore.CheckBalanceForApiKey(auth.ApiKeyId);
|
||||
var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
|
||||
if (!billingCheck.Allowed)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
|
||||
@@ -57,7 +60,7 @@ internal static class ReverseProxyEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
||||
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
|
||||
|
||||
var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
|
||||
if (!pathTokenRemoved)
|
||||
@@ -71,6 +74,12 @@ internal static class ReverseProxyEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsVersionRequest(context.Request, proxyPath))
|
||||
{
|
||||
await WriteVersionResponseAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryGetClientAddress(proxyPath, out var pathClientId, out var clientPath))
|
||||
{
|
||||
if (!groupAccess.IsClientAllowed(pathClientId))
|
||||
@@ -91,7 +100,7 @@ internal static class ReverseProxyEndpoint
|
||||
embeddingCache,
|
||||
managementStore,
|
||||
groupAccess,
|
||||
auth.ApiKeyId);
|
||||
auth.UserKeyId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,7 +149,7 @@ internal static class ReverseProxyEndpoint
|
||||
embeddingCache,
|
||||
embeddingRequest,
|
||||
managementStore,
|
||||
auth.ApiKeyId);
|
||||
auth.UserKeyId);
|
||||
}
|
||||
|
||||
public static async Task HandleClientAsync(
|
||||
@@ -161,7 +170,7 @@ internal static class ReverseProxyEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
var billingCheck = managementStore.CheckBalanceForApiKey(auth.ApiKeyId);
|
||||
var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
|
||||
if (!billingCheck.Allowed)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
|
||||
@@ -175,7 +184,7 @@ internal static class ReverseProxyEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
||||
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
|
||||
if (!groupAccess.IsClientAllowed(clientId))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
@@ -196,7 +205,7 @@ internal static class ReverseProxyEndpoint
|
||||
embeddingCache,
|
||||
managementStore,
|
||||
groupAccess,
|
||||
auth.ApiKeyId);
|
||||
auth.UserKeyId);
|
||||
}
|
||||
|
||||
private static async Task ForwardToClientAsync(
|
||||
@@ -210,7 +219,7 @@ internal static class ReverseProxyEndpoint
|
||||
EmbeddingCache embeddingCache,
|
||||
ManagementStore managementStore,
|
||||
GroupAccess? groupAccess = null,
|
||||
string? apiKeyId = null)
|
||||
string? userKeyId = null)
|
||||
{
|
||||
var clientAccess = managementStore.GetClientAccess(clientId);
|
||||
if (clientAccess.IsDisabled)
|
||||
@@ -253,7 +262,7 @@ internal static class ReverseProxyEndpoint
|
||||
embeddingCache,
|
||||
embeddingRequest,
|
||||
managementStore,
|
||||
apiKeyId);
|
||||
userKeyId);
|
||||
}
|
||||
|
||||
private static bool IsRootPath(PathString path) =>
|
||||
@@ -302,20 +311,29 @@ 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) =>
|
||||
HttpMethods.IsGet(request.Method)
|
||||
&& string.Equals(proxyPath.Value, "/api/tags", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsVersionRequest(HttpRequest request, PathString proxyPath) =>
|
||||
HttpMethods.IsGet(request.Method)
|
||||
&& string.Equals(proxyPath.Value, "/api/version", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static Task WriteVersionResponseAsync(HttpContext context) =>
|
||||
context.Response.WriteAsJsonAsync(
|
||||
new { version = OllamaVersion },
|
||||
context.RequestAborted);
|
||||
|
||||
private static async Task HandleTagsAsync(
|
||||
HttpContext context,
|
||||
TunnelHub hub,
|
||||
@@ -372,14 +390,26 @@ internal static class ReverseProxyEndpoint
|
||||
|
||||
private static string GetClientDisabledMessage(string clientId, ClientAccess access)
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(access.DisabledReason)
|
||||
? ""
|
||||
: $" Reason: {access.DisabledReason.Trim()}.";
|
||||
|
||||
if (access.DisabledManually)
|
||||
{
|
||||
return $"Tunnel client '{clientId}' is disabled until it is enabled manually.";
|
||||
return $"Tunnel client '{clientId}' is disabled until it is enabled manually.{reason}";
|
||||
}
|
||||
|
||||
return access.DisabledUntilUtc is { } disabledUntil
|
||||
? $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}."
|
||||
: $"Tunnel client '{clientId}' is disabled.";
|
||||
if (access.DisabledUntilUtc is { } disabledUntil)
|
||||
{
|
||||
var from = access.DisabledFromUtc is { } fromUtc
|
||||
? $" (scheduled from {fromUtc:O})"
|
||||
: "";
|
||||
return $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}.{from}{reason}";
|
||||
}
|
||||
|
||||
return access.DisabledFromUtc is { } fromUtc2
|
||||
? $"Tunnel client '{clientId}' is disabled (scheduled from {fromUtc2:O}).{reason}"
|
||||
: $"Tunnel client '{clientId}' is disabled.{reason}";
|
||||
}
|
||||
|
||||
private static async Task<string?> GetRequestedModelAsync(HttpRequest request, PathString proxyPath)
|
||||
@@ -498,9 +528,9 @@ internal static class ReverseProxyEndpoint
|
||||
EmbeddingCache embeddingCache,
|
||||
EmbeddingCacheRequest? embeddingRequest,
|
||||
ManagementStore managementStore,
|
||||
string? apiKeyId = null)
|
||||
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;
|
||||
@@ -522,6 +552,12 @@ internal static class ReverseProxyEndpoint
|
||||
Headers = CollectRequestHeaders(context.Request, settings, managementStore)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requestedModel))
|
||||
{
|
||||
requestMessage.Headers ??= [];
|
||||
requestMessage.Headers.Add(new HeaderPair(ProtocolConstants.ModelHeader, requestedModel));
|
||||
}
|
||||
|
||||
await connection.SendAsync(requestMessage, context.RequestAborted);
|
||||
requestBodyTask = ForwardRequestBodyAsync(context.Request, connection, requestId, hasBody, settings, logger);
|
||||
_ = requestBodyTask.ContinueWith(
|
||||
@@ -569,10 +605,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
|
||||
{
|
||||
@@ -586,9 +628,9 @@ internal static class ReverseProxyEndpoint
|
||||
var tokenCounts = tokenCounter.CountTokens();
|
||||
|
||||
var cost = 0.0;
|
||||
if (!string.IsNullOrWhiteSpace(apiKeyId) && tokenCounts.TotalTokens > 0)
|
||||
if (!string.IsNullOrWhiteSpace(userKeyId) && tokenCounts.TotalTokens > 0)
|
||||
{
|
||||
var billing = managementStore.ResolveBillingForApiKey(apiKeyId);
|
||||
var billing = managementStore.ResolveBillingForUserKey(userKeyId);
|
||||
if (billing is not null)
|
||||
{
|
||||
cost = managementStore.CalculateCost(billing.GroupId, requestedModel, tokenCounts.TotalTokens);
|
||||
@@ -604,7 +646,7 @@ internal static class ReverseProxyEndpoint
|
||||
tokenCounts.PromptTokens,
|
||||
tokenCounts.CompletionTokens,
|
||||
tokenCounts.TotalTokens,
|
||||
apiKeyId,
|
||||
userKeyId,
|
||||
cost,
|
||||
startedAt,
|
||||
completedAt,
|
||||
@@ -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": "*"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
+103
-5
@@ -58,16 +58,12 @@ textarea {
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid #4c5d74;
|
||||
border-radius: 6px;
|
||||
background: #223044;
|
||||
color: #c8f2df;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
@@ -167,11 +163,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 {
|
||||
@@ -555,6 +588,71 @@ tr:last-child td {
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.modal-overlay.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-dialog h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.radio-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.radio-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.nav {
|
||||
grid-template-columns: 1fr;
|
||||
+428
-92
@@ -3,6 +3,7 @@ const state = {
|
||||
detail: null,
|
||||
groupDetail: null,
|
||||
newKey: null,
|
||||
newClientKey: null,
|
||||
loading: false,
|
||||
usageData: null
|
||||
};
|
||||
@@ -15,6 +16,66 @@ const sidebarMeta = document.getElementById("sidebarMeta");
|
||||
|
||||
document.getElementById("refreshButton").addEventListener("click", () => refresh(true));
|
||||
window.addEventListener("hashchange", () => renderRoute());
|
||||
document.addEventListener("click", (event) => {
|
||||
if (event.target.closest(".help-button") || event.target.closest(".help-popover")) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll(".help-popover").forEach((popover) => popover.remove());
|
||||
document.querySelectorAll(".help-button.active").forEach((button) => button.classList.remove("active"));
|
||||
});
|
||||
|
||||
const morphdomOptions = {
|
||||
childrenOnly: true,
|
||||
onBeforeElUpdated(fromEl, toEl) {
|
||||
if (fromEl.isEqualNode(toEl)) return false;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
function patchContent(html) {
|
||||
const temp = document.createElement("section");
|
||||
temp.innerHTML = html;
|
||||
morphdom(content, temp, morphdomOptions);
|
||||
}
|
||||
|
||||
function patchSidebar(html) {
|
||||
const temp = document.createElement("div");
|
||||
temp.innerHTML = html;
|
||||
morphdom(sidebarMeta, temp, morphdomOptions);
|
||||
}
|
||||
|
||||
function renderFieldLabel(forId, label, helpText) {
|
||||
return `
|
||||
<label for="${escapeAttr(forId)}">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<button class="help-button" type="button" data-action="toggle-help" data-help="${escapeAttr(helpText)}" aria-label="Explain ${escapeAttr(label)}">?</button>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
function toggleHelpPopover(button) {
|
||||
const field = button.closest(".field");
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPopover = field.querySelector(".help-popover");
|
||||
if (existingPopover) {
|
||||
existingPopover.remove();
|
||||
button.classList.remove("active");
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll(".help-popover").forEach((popover) => popover.remove());
|
||||
document.querySelectorAll(".help-button.active").forEach((activeButton) => activeButton.classList.remove("active"));
|
||||
|
||||
const popover = document.createElement("div");
|
||||
popover.className = "help-popover";
|
||||
popover.textContent = button.dataset.help || "";
|
||||
field.appendChild(popover);
|
||||
button.classList.add("active");
|
||||
}
|
||||
|
||||
content.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
@@ -24,16 +85,30 @@ content.addEventListener("click", async (event) => {
|
||||
|
||||
const { action, clientId, model, keyId, groupId, memberId } = button.dataset;
|
||||
|
||||
if (action === "toggle-help") {
|
||||
event.stopPropagation();
|
||||
toggleHelpPopover(button);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBusy(button, true);
|
||||
|
||||
if (action === "disable-hour") {
|
||||
await api(`/clients/${encodeURIComponent(clientId)}/disable`, {
|
||||
method: "POST",
|
||||
body: { mode: "duration", durationMinutes: 60 }
|
||||
});
|
||||
setNotice(`Disabled ${clientId} for one hour.`);
|
||||
await refresh();
|
||||
if (action === "disable-temporary") {
|
||||
setBusy(button, false);
|
||||
try {
|
||||
const body = await showDisableModal(clientId);
|
||||
setBusy(button, true);
|
||||
await api(`/clients/${encodeURIComponent(clientId)}/disable`, {
|
||||
method: "POST",
|
||||
body
|
||||
});
|
||||
setNotice(`Disabled ${clientId}.`);
|
||||
await refresh();
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "disable-manual") {
|
||||
@@ -56,18 +131,28 @@ content.addEventListener("click", async (event) => {
|
||||
}
|
||||
|
||||
if (action === "delete-key") {
|
||||
if (!confirm("Delete this API key?")) {
|
||||
if (!confirm("Delete this user key?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api(`/api-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" });
|
||||
setNotice("API key deleted.");
|
||||
await api(`/user-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" });
|
||||
setNotice("User key deleted.");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
if (action === "delete-client-key") {
|
||||
if (!confirm("Delete this client key?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api(`/client-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" });
|
||||
setNotice("Client key deleted.");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
if (action === "copy-key") {
|
||||
await navigator.clipboard.writeText(button.dataset.key);
|
||||
setNotice("API key copied.");
|
||||
setNotice("User key copied.");
|
||||
}
|
||||
|
||||
if (action === "delete-group") {
|
||||
@@ -92,7 +177,7 @@ content.addEventListener("click", async (event) => {
|
||||
}
|
||||
|
||||
if (action === "toggle-key-assignment") {
|
||||
await toggleApiKeyAssignment(groupId, keyId, button.dataset.assigned === "true");
|
||||
await toggleUserKeyAssignment(groupId, keyId, button.dataset.assigned === "true");
|
||||
}
|
||||
|
||||
if (action === "delete-billing-rule") {
|
||||
@@ -145,12 +230,21 @@ content.addEventListener("submit", async (event) => {
|
||||
await loadModelDetail(data.model, data.clientId);
|
||||
}
|
||||
|
||||
if (form.dataset.form === "api-key") {
|
||||
state.newKey = await api("/api-keys", {
|
||||
if (form.dataset.form === "user-key") {
|
||||
state.newKey = await api("/user-keys", {
|
||||
method: "POST",
|
||||
body: { name: data.name }
|
||||
});
|
||||
setNotice("API key created.");
|
||||
setNotice("User key created.");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
if (form.dataset.form === "client-key") {
|
||||
state.newClientKey = await api("/client-keys", {
|
||||
method: "POST",
|
||||
body: { name: data.name }
|
||||
});
|
||||
setNotice("Client key created.");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
@@ -188,6 +282,9 @@ content.addEventListener("submit", async (event) => {
|
||||
if (data.clientPattern && data.clientPattern.trim()) {
|
||||
body.clientPattern = data.clientPattern.trim();
|
||||
}
|
||||
body.keepaliveInstancesToKeepAlive = parseInt(data.keepaliveInstancesToKeepAlive, 10) || 1;
|
||||
body.keepaliveMaxParallelismPerClient = parseInt(data.keepaliveMaxParallelismPerClient, 10) || 1;
|
||||
body.keepaliveParallelismHeadroom = parseInt(data.keepaliveParallelismHeadroom, 10) || 1;
|
||||
|
||||
if (!body.clientId && !body.clientPattern) {
|
||||
throw new Error("Either Client ID or Client pattern is required.");
|
||||
@@ -263,7 +360,7 @@ async function refresh(showNotice = false, render = true) {
|
||||
state.summary = await api("/summary");
|
||||
updateShell();
|
||||
if (render) {
|
||||
await renderRoute();
|
||||
await renderRoute(false);
|
||||
}
|
||||
|
||||
if (showNotice) {
|
||||
@@ -271,7 +368,7 @@ async function refresh(showNotice = false, render = true) {
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error.message, true);
|
||||
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
||||
patchContent(`<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
@@ -304,7 +401,7 @@ async function api(path, options = {}) {
|
||||
return body;
|
||||
}
|
||||
|
||||
async function renderRoute() {
|
||||
async function renderRoute(showLoading = true) {
|
||||
const hash = (window.location.hash || "#clients").slice(1);
|
||||
const parts = hash.split("/");
|
||||
const view = parts[0];
|
||||
@@ -316,13 +413,13 @@ async function renderRoute() {
|
||||
|
||||
if (view === "models" && encodedParam) {
|
||||
const model = decodeURIComponent(encodedParam);
|
||||
await loadModelDetail(model);
|
||||
await loadModelDetail(model, undefined, showLoading);
|
||||
return;
|
||||
}
|
||||
|
||||
if (view === "groups" && encodedParam) {
|
||||
const groupId = decodeURIComponent(encodedParam);
|
||||
await loadGroupDetail(groupId);
|
||||
await loadGroupDetail(groupId, showLoading);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -334,8 +431,13 @@ async function renderRoute() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (view === "api-keys") {
|
||||
renderApiKeys();
|
||||
if (view === "user-keys") {
|
||||
renderUserKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
if (view === "client-keys") {
|
||||
renderClientKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -345,7 +447,7 @@ async function renderRoute() {
|
||||
}
|
||||
|
||||
if (view === "usage") {
|
||||
await loadUsage();
|
||||
await loadUsage(showLoading);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -358,13 +460,15 @@ function updateShell() {
|
||||
return;
|
||||
}
|
||||
|
||||
sidebarMeta.innerHTML = `
|
||||
patchSidebar(`
|
||||
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
|
||||
<div>${summary.clients.length} clients</div>
|
||||
<div>${summary.models.length} models</div>
|
||||
<div>${(summary.clientKeys || []).length} client keys</div>
|
||||
<div>${(summary.userKeys || []).length} user keys</div>
|
||||
<div>${(summary.groups || []).length} groups</div>
|
||||
<div>${formatDate(summary.generatedAtUtc)}</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
function renderClients() {
|
||||
@@ -372,7 +476,7 @@ function renderClients() {
|
||||
pageTitle.textContent = "Clients";
|
||||
pageSubtitle.textContent = "Connected tunnel clients, request counts, and forwarding controls.";
|
||||
|
||||
content.innerHTML = `
|
||||
patchContent(`
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Clients</h2>
|
||||
@@ -382,11 +486,12 @@ function renderClients() {
|
||||
${clients.length ? clientsTable(clients) : emptyState("No clients have connected yet.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
function clientsTable(clients) {
|
||||
const clientGroups = state.summary?.clientGroups || {};
|
||||
const groupNames = Object.fromEntries((state.summary?.groups || []).map((g) => [g.id, g.name]));
|
||||
const rows = clients.map((client) => {
|
||||
const groups = clientGroups[client.id] || [];
|
||||
return `
|
||||
@@ -401,6 +506,7 @@ function clientsTable(clients) {
|
||||
${client.disabled ? badge(client.disabledManually ? "Disabled manual" : "Disabled timed", "bad") : badge("Enabled", "good")}
|
||||
</div>
|
||||
${client.disabled ? `<div class="cell-sub">${escapeHtml(disabledText(client))}</div>` : ""}
|
||||
${isScheduled(client) ? `<div class="cell-sub">${escapeHtml(disabledText(client))}</div>` : ""}
|
||||
</td>
|
||||
<td>${number(client.pendingRequests)}</td>
|
||||
<td>
|
||||
@@ -411,12 +517,12 @@ function clientsTable(clients) {
|
||||
<td>${modelBadges(client.activeModels)}</td>
|
||||
<td>
|
||||
<div class="badge-row">
|
||||
${groups.length ? groups.map((g) => `<a class="badge" href="#groups/${encodeURIComponent(g)}">${escapeHtml(g)}</a>`).join("") : `<span class="cell-sub">None</span>`}
|
||||
${groups.length ? groups.map((g) => `<a class="badge" href="#groups/${encodeURIComponent(g)}">${escapeHtml(groupNames[g] || g)}</a>`).join("") : `<span class="cell-sub">None</span>`}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="button warning" data-action="disable-hour" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable 1h</button>
|
||||
<button class="button warning" data-action="disable-temporary" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable temporary</button>
|
||||
<button class="button warning" data-action="disable-manual" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable</button>
|
||||
<button class="button secondary" data-action="enable-client" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "" : "disabled"}>Enable</button>
|
||||
</div>
|
||||
@@ -449,7 +555,7 @@ function renderModels() {
|
||||
pageTitle.textContent = "Models";
|
||||
pageSubtitle.textContent = "Listed and active models, recent request volume, and model operations.";
|
||||
|
||||
content.innerHTML = `
|
||||
patchContent(`
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Run model action</h2>
|
||||
@@ -467,7 +573,7 @@ function renderModels() {
|
||||
${models.length ? modelsTable(models) : emptyState("No models have been reported yet.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
function modelActionForm(clients, selectedModel = "", selectedClient = "") {
|
||||
@@ -537,10 +643,12 @@ function modelsTable(models) {
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadModelDetail(model, clientId) {
|
||||
async function loadModelDetail(model, clientId, showLoading = true) {
|
||||
pageTitle.textContent = "Model Detail";
|
||||
pageSubtitle.textContent = model;
|
||||
content.innerHTML = `<div class="panel"><div class="empty">Loading model detail...</div></div>`;
|
||||
if (showLoading) {
|
||||
patchContent(`<div class="panel"><div class="empty">Loading model detail...</div></div>`);
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({ model });
|
||||
if (clientId) {
|
||||
@@ -562,7 +670,7 @@ function renderModelDetail() {
|
||||
pageTitle.textContent = "Model Detail";
|
||||
pageSubtitle.textContent = model;
|
||||
|
||||
content.innerHTML = `
|
||||
patchContent(`
|
||||
<div class="toolbar">
|
||||
<a class="button secondary" href="#models">Back to models</a>
|
||||
</div>
|
||||
@@ -618,7 +726,7 @@ function renderModelDetail() {
|
||||
${detail.show ? `<pre class="pre">${escapeHtml(formatJson(showBody))}</pre>` : emptyState("No connected client was available for details.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
async function runModelCommand(clientId, model, action) {
|
||||
@@ -659,22 +767,22 @@ async function runModelCommand(clientId, model, action) {
|
||||
await refreshAfterModelCommand(model, clientId);
|
||||
}
|
||||
|
||||
function renderApiKeys() {
|
||||
const keys = state.summary?.apiKeys || [];
|
||||
pageTitle.textContent = "API Keys";
|
||||
function renderUserKeys() {
|
||||
const keys = state.summary?.userKeys || [];
|
||||
pageTitle.textContent = "User Keys";
|
||||
pageSubtitle.textContent = "Keys accepted by the proxy token header, bearer auth, query token, and token path.";
|
||||
|
||||
content.innerHTML = `
|
||||
patchContent(`
|
||||
${state.newKey ? newKeyPanel(state.newKey) : ""}
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Create API key</h2>
|
||||
<h2>Create user key</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form class="form-row" data-form="api-key">
|
||||
<form class="form-row" data-form="user-key">
|
||||
<div class="field">
|
||||
<label for="apiKeyName">Name</label>
|
||||
<input class="input" id="apiKeyName" name="name" placeholder="e.g. openwebui_prod" required>
|
||||
<label for="userKeyName">Name</label>
|
||||
<input class="input" id="userKeyName" name="name" placeholder="e.g. openwebui_prod" required>
|
||||
</div>
|
||||
<button class="button" type="submit">Create</button>
|
||||
</form>
|
||||
@@ -682,20 +790,53 @@ function renderApiKeys() {
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>API keys</h2>
|
||||
<h2>User keys</h2>
|
||||
<span class="badge">${keys.length} total</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
${keys.length ? apiKeysTable(keys) : emptyState("No API keys have been created.")}
|
||||
${keys.length ? userKeysTable(keys) : emptyState("No user keys have been created.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
function renderClientKeys() {
|
||||
const keys = state.summary?.clientKeys || [];
|
||||
pageTitle.textContent = "Client Keys";
|
||||
pageSubtitle.textContent = "Keys accepted by GPU clients to establish tunnel connections.";
|
||||
|
||||
patchContent(`
|
||||
${state.newClientKey ? newKeyPanel(state.newClientKey) : ""}
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Create client key</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form class="form-row" data-form="client-key">
|
||||
<div class="field">
|
||||
<label for="clientKeyName">Name</label>
|
||||
<input class="input" id="clientKeyName" name="name" placeholder="e.g. gpu_workstation_1" required>
|
||||
</div>
|
||||
<button class="button" type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Client keys</h2>
|
||||
<span class="badge">${keys.length} total</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
${keys.length ? clientKeysTable(keys) : emptyState("No client keys have been created.")}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function newKeyPanel(key) {
|
||||
return `
|
||||
<div class="new-key">
|
||||
<strong>New API key</strong>
|
||||
<strong>New user key</strong>
|
||||
<code>${escapeHtml(key.key)}</code>
|
||||
<div class="actions">
|
||||
<button class="button secondary" type="button" data-action="copy-key" data-key="${escapeAttr(key.key)}">Copy</button>
|
||||
@@ -704,7 +845,7 @@ function newKeyPanel(key) {
|
||||
`;
|
||||
}
|
||||
|
||||
function apiKeysTable(keys) {
|
||||
function userKeysTable(keys) {
|
||||
const rows = keys.map((key) => `
|
||||
<tr>
|
||||
<td>
|
||||
@@ -734,12 +875,42 @@ function apiKeysTable(keys) {
|
||||
`;
|
||||
}
|
||||
|
||||
function clientKeysTable(keys) {
|
||||
const rows = keys.map((key) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="cell-main">${escapeHtml(key.name)}</div>
|
||||
<div class="cell-sub">${escapeHtml(key.keyPrefix)}...</div>
|
||||
</td>
|
||||
<td>${formatDate(key.createdAtUtc)}</td>
|
||||
<td>${key.lastUsedUtc ? formatDate(key.lastUsedUtc) : "Never"}</td>
|
||||
<td>
|
||||
<button class="button danger" data-action="delete-client-key" data-key-id="${escapeAttr(key.id)}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Last used</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
const groups = state.summary?.groups || [];
|
||||
pageTitle.textContent = "Groups";
|
||||
pageSubtitle.textContent = "Manage access groups that control which clients and models API keys can reach.";
|
||||
pageSubtitle.textContent = "Manage access groups that control which clients and models user keys can reach.";
|
||||
|
||||
content.innerHTML = `
|
||||
patchContent(`
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Create group</h2>
|
||||
@@ -763,7 +934,7 @@ function renderGroups() {
|
||||
${groups.length ? groupsTable(groups) : emptyState("No groups have been created.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
function groupsTable(groups) {
|
||||
@@ -796,42 +967,44 @@ function groupsTable(groups) {
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadGroupDetail(groupId) {
|
||||
async function loadGroupDetail(groupId, showLoading = true) {
|
||||
pageTitle.textContent = "Group Detail";
|
||||
pageSubtitle.textContent = groupId;
|
||||
content.innerHTML = `<div class="panel"><div class="empty">Loading group...</div></div>`;
|
||||
if (showLoading) {
|
||||
patchContent(`<div class="panel"><div class="empty">Loading group...</div></div>`);
|
||||
}
|
||||
|
||||
try {
|
||||
const [group, clients, apiKeyGroups, billing, rules, payments, balance] = await Promise.all([
|
||||
const [group, clients, userKeyGroups, billing, rules, payments, balance] = await Promise.all([
|
||||
api(`/groups/${encodeURIComponent(groupId)}`),
|
||||
api(`/groups/${encodeURIComponent(groupId)}/clients`),
|
||||
api("/api-keys/groups"),
|
||||
api("/user-keys/groups"),
|
||||
api(`/groups/${encodeURIComponent(groupId)}/billing`),
|
||||
api(`/groups/${encodeURIComponent(groupId)}/billing/rules`),
|
||||
api(`/groups/${encodeURIComponent(groupId)}/billing/payments`),
|
||||
api(`/groups/${encodeURIComponent(groupId)}/billing/balance`)
|
||||
]);
|
||||
|
||||
state.groupDetail = { group, clients, apiKeyGroups, billing, rules, payments, balance };
|
||||
state.groupDetail = { group, clients, userKeyGroups, billing, rules, payments, balance };
|
||||
renderGroupDetail();
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
||||
patchContent(`<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupDetail() {
|
||||
const { group, clients, apiKeyGroups, billing, rules, payments, balance } = state.groupDetail;
|
||||
const allApiKeys = state.summary?.apiKeys || [];
|
||||
const { group, clients, userKeyGroups, billing, rules, payments, balance } = state.groupDetail;
|
||||
const allUserKeys = state.summary?.userKeys || [];
|
||||
const assignedKeyIds = new Set(
|
||||
apiKeyGroups
|
||||
.filter((akg) => akg.apiKeyId && (akg.groupIds || []).includes(group.id))
|
||||
.map((akg) => akg.apiKeyId)
|
||||
userKeyGroups
|
||||
.filter((akg) => akg.userKeyId && (akg.groupIds || []).includes(group.id))
|
||||
.map((akg) => akg.userKeyId)
|
||||
);
|
||||
|
||||
pageTitle.textContent = "Group Detail";
|
||||
pageSubtitle.textContent = group.name;
|
||||
|
||||
content.innerHTML = `
|
||||
patchContent(`
|
||||
<div class="toolbar">
|
||||
<a class="button secondary" href="#groups">Back to groups</a>
|
||||
</div>
|
||||
@@ -867,9 +1040,21 @@ function renderGroupDetail() {
|
||||
<label for="addClientPattern">Client pattern (regex, optional)</label>
|
||||
<input class="input" id="addClientPattern" name="clientPattern" placeholder="GPU_[0-9]*">
|
||||
</div>
|
||||
<div class="field">
|
||||
${renderFieldLabel("addClientKeepaliveInstances", "Min always loaded instances", "The minimum number of warm model instances that should stay loaded and ready so requests do not wait for a cold start.")}
|
||||
<input class="input" id="addClientKeepaliveInstances" name="keepaliveInstancesToKeepAlive" type="number" min="1" step="1" value="1">
|
||||
</div>
|
||||
<div class="field">
|
||||
${renderFieldLabel("addClientKeepaliveMaxParallelism", "Max parallelism per client", "The maximum number of concurrent requests this client can handle at once. Higher values let one client absorb more traffic, but too many can overload the GPU.")}
|
||||
<input class="input" id="addClientKeepaliveMaxParallelism" name="keepaliveMaxParallelismPerClient" type="number" min="1" step="1" value="1">
|
||||
</div>
|
||||
<div class="field">
|
||||
${renderFieldLabel("addClientKeepaliveHeadroom", "Parallelism headroom", "How much spare parallelism to leave unused so traffic spikes can be absorbed without saturating the GPU. A larger headroom makes routing more conservative.")}
|
||||
<input class="input" id="addClientKeepaliveHeadroom" name="keepaliveParallelismHeadroom" type="number" min="1" step="1" value="1">
|
||||
</div>
|
||||
<button class="button" type="submit">Add</button>
|
||||
</form>
|
||||
<div class="cell-sub" style="margin-top:8px">Provide either a Client ID (for explicit client access) or a Client pattern (for regex-based matching). Model is optional to restrict to a specific model.</div>
|
||||
<div class="cell-sub" style="margin-top:8px">Provide either a Client ID (for explicit client access) or a Client pattern (for regex-based matching). Model is optional to restrict to a specific model. Keepalive defaults to 1 instance, 1 parallelism, and 1 headroom.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
@@ -883,10 +1068,10 @@ function renderGroupDetail() {
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>API key assignments</h2>
|
||||
<h2>User key assignments</h2>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
${allApiKeys.length ? apiKeyAssignmentTable(allApiKeys, group.id, assignedKeyIds) : emptyState("No API keys have been created.")}
|
||||
${allUserKeys.length ? userKeyAssignmentTable(allUserKeys, group.id, assignedKeyIds) : emptyState("No user keys have been created.")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
@@ -979,6 +1164,17 @@ function renderGroupDetail() {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function formatKeepalivePolicy(policy) {
|
||||
if (!policy) {
|
||||
return `<div class="cell-sub">Default (1 / 1 / 1)</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="cell-main">${policy.instancesToKeepAlive} instance${policy.instancesToKeepAlive === 1 ? "" : "s"}</div>
|
||||
<div class="cell-sub">Parallelism ${policy.maxParallelismPerClient} · headroom ${policy.parallelismHeadroom}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1003,6 +1199,7 @@ function groupClientsTable(clients, groupId) {
|
||||
: `<div class="cell-sub">-</div>`
|
||||
}
|
||||
</td>
|
||||
<td>${formatKeepalivePolicy(client.keepalivePolicy)}</td>
|
||||
<td>
|
||||
<button class="button danger" data-action="remove-client" data-group-id="${escapeAttr(groupId)}" data-member-id="${client.id}">Remove</button>
|
||||
</td>
|
||||
@@ -1016,6 +1213,7 @@ function groupClientsTable(clients, groupId) {
|
||||
<th>Client ID</th>
|
||||
<th>Model</th>
|
||||
<th>Client pattern</th>
|
||||
<th>Keepalive policy</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1024,8 +1222,8 @@ function groupClientsTable(clients, groupId) {
|
||||
`;
|
||||
}
|
||||
|
||||
function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) {
|
||||
const rows = apiKeys.map((key) => {
|
||||
function userKeyAssignmentTable(userKeys, groupId, assignedKeyIds) {
|
||||
const rows = userKeys.map((key) => {
|
||||
const isAssigned = assignedKeyIds.has(key.id);
|
||||
return `
|
||||
<tr>
|
||||
@@ -1048,7 +1246,7 @@ function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>API Key</th>
|
||||
<th>User key</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1057,11 +1255,11 @@ function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) {
|
||||
`;
|
||||
}
|
||||
|
||||
async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) {
|
||||
const apiKeyGroups = state.groupDetail?.apiKeyGroups || [];
|
||||
const allApiKeys = state.summary?.apiKeys || [];
|
||||
async function toggleUserKeyAssignment(groupId, keyId, currentlyAssigned) {
|
||||
const userKeyGroups = state.groupDetail?.userKeyGroups || [];
|
||||
const allUserKeys = state.summary?.userKeys || [];
|
||||
|
||||
const keyGroups = apiKeyGroups.find((akg) => akg.apiKeyId === keyId);
|
||||
const keyGroups = userKeyGroups.find((ukg) => ukg.userKeyId === keyId);
|
||||
const currentGroupIds = keyGroups ? [...keyGroups.groupIds] : [];
|
||||
|
||||
let newGroupIds;
|
||||
@@ -1071,12 +1269,12 @@ async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) {
|
||||
newGroupIds = [...currentGroupIds, groupId];
|
||||
}
|
||||
|
||||
await api(`/api-keys/${encodeURIComponent(keyId)}/groups`, {
|
||||
await api(`/user-keys/${encodeURIComponent(keyId)}/groups`, {
|
||||
method: "PUT",
|
||||
body: { groupIds: newGroupIds }
|
||||
});
|
||||
|
||||
setNotice(currentlyAssigned ? "API key unassigned from group." : "API key assigned to group.");
|
||||
setNotice(currentlyAssigned ? "User key unassigned from group." : "User key assigned to group.");
|
||||
await loadGroupDetail(groupId);
|
||||
}
|
||||
|
||||
@@ -1143,10 +1341,12 @@ function formatCurrency(value, currency) {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
async function loadUsage() {
|
||||
async function loadUsage(showLoading = true) {
|
||||
pageTitle.textContent = "Usage";
|
||||
pageSubtitle.textContent = "Token usage and revenue statistics.";
|
||||
content.innerHTML = `<div class="panel"><div class="empty">Loading usage data...</div></div>`;
|
||||
if (showLoading) {
|
||||
patchContent(`<div class="panel"><div class="empty">Loading usage data...</div></div>`);
|
||||
}
|
||||
|
||||
try {
|
||||
const [usage, revenue] = await Promise.all([
|
||||
@@ -1157,7 +1357,7 @@ async function loadUsage() {
|
||||
state.usageData = { usage, revenue };
|
||||
renderUsage();
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
||||
patchContent(`<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1170,7 +1370,7 @@ function renderUsage() {
|
||||
|
||||
const byModel = usage.byModel || [];
|
||||
const byClient = usage.byClient || [];
|
||||
const byApiKey = usage.byApiKey || [];
|
||||
const byUserKey = usage.byUserKey || [];
|
||||
const byGroup = usage.byGroup || [];
|
||||
const clientRevenue = revenue || [];
|
||||
|
||||
@@ -1179,7 +1379,7 @@ function renderUsage() {
|
||||
revenueMap[r.clientId] = r;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
patchContent(`
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Tokens by model</h2>
|
||||
@@ -1200,11 +1400,11 @@ function renderUsage() {
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Tokens by API key</h2>
|
||||
<span class="badge">${byApiKey.length} keys</span>
|
||||
<h2>Tokens by user key</h2>
|
||||
<span class="badge">${byUserKey.length} keys</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
${byApiKey.length ? tokenStatsApiKeyTable(byApiKey) : emptyState("No token data yet.")}
|
||||
${byUserKey.length ? tokenStatsUserKeyTable(byUserKey) : emptyState("No token data yet.")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
@@ -1216,7 +1416,7 @@ function renderUsage() {
|
||||
${byGroup.length ? tokenStatsGroupTable(byGroup) : emptyState("No token data yet.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
function tokenStatsModelTable(stats) {
|
||||
@@ -1277,12 +1477,12 @@ function tokenStatsClientTable(stats, revenueMap) {
|
||||
`;
|
||||
}
|
||||
|
||||
function tokenStatsApiKeyTable(stats) {
|
||||
function tokenStatsUserKeyTable(stats) {
|
||||
const rows = stats.map((s) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="cell-main">${escapeHtml(s.apiKeyName)}</div>
|
||||
<div class="cell-sub">${escapeHtml(s.apiKeyPrefix)}...</div>
|
||||
<div class="cell-main">${escapeHtml(s.userKeyName)}</div>
|
||||
<div class="cell-sub">${escapeHtml(s.userKeyPrefix)}...</div>
|
||||
</td>
|
||||
<td>${number(s.promptTokens)}</td>
|
||||
<td>${number(s.completionTokens)}</td>
|
||||
@@ -1295,7 +1495,7 @@ function tokenStatsApiKeyTable(stats) {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>API key</th>
|
||||
<th>User key</th>
|
||||
<th>Prompt tokens</th>
|
||||
<th>Completion tokens</th>
|
||||
<th>Total tokens</th>
|
||||
@@ -1362,11 +1562,11 @@ async function refreshAfterModelCommand(model, clientId) {
|
||||
|
||||
const hash = window.location.hash || "";
|
||||
if (hash.startsWith(`#models/${encodeURIComponent(model)}`)) {
|
||||
await loadModelDetail(model, clientId);
|
||||
await loadModelDetail(model, clientId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
await renderRoute();
|
||||
await renderRoute(false);
|
||||
}
|
||||
|
||||
function modelActionResultDetail(result) {
|
||||
@@ -1466,11 +1666,24 @@ function emptyState(text) {
|
||||
}
|
||||
|
||||
function disabledText(client) {
|
||||
var text = "";
|
||||
if (client.disabledManually) {
|
||||
return "Until enabled manually";
|
||||
text = "Until enabled manually";
|
||||
} else if (client.disabledUntilUtc) {
|
||||
text = `Until ${formatDate(client.disabledUntilUtc)}`;
|
||||
} else if (isScheduled(client)) {
|
||||
text = `Scheduled from ${formatDate(client.disabledFromUtc)}`;
|
||||
} else {
|
||||
text = "Disabled";
|
||||
}
|
||||
if (client.disabledReason) {
|
||||
text += ` — ${escapeHtml(client.disabledReason)}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
return client.disabledUntilUtc ? `Until ${formatDate(client.disabledUntilUtc)}` : "Disabled";
|
||||
function isScheduled(client) {
|
||||
return client.disabledFromUtc && new Date(client.disabledFromUtc) > new Date();
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
@@ -1532,4 +1745,127 @@ function escapeAttr(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function showDisableModal(clientId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-dialog">
|
||||
<h3>Disable client "${escapeHtml(clientId)}"</h3>
|
||||
<div class="field">
|
||||
<span class="field-label">When to disable</span>
|
||||
<div class="radio-row">
|
||||
<label><input type="radio" name="d-when" value="now" checked> Now</label>
|
||||
<label><input type="radio" name="d-when" value="later"> Later</label>
|
||||
</div>
|
||||
<input type="datetime-local" id="d-from" class="input" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">For how long</span>
|
||||
<div class="radio-row">
|
||||
<label><input type="radio" name="d-for" value="timespan" checked> Timespan</label>
|
||||
<label><input type="radio" name="d-for" value="until"> Until</label>
|
||||
</div>
|
||||
<div id="d-timespan-group" class="field-row">
|
||||
<input type="number" id="d-duration" class="input" value="1" min="1" style="width:100px">
|
||||
<select id="d-unit" class="select" style="width:auto">
|
||||
<option value="1">minute(s)</option>
|
||||
<option value="60" selected>hour(s)</option>
|
||||
<option value="1440">day(s)</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="datetime-local" id="d-until" class="input" style="display:none" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="d-reason">Reason (optional)</label>
|
||||
<input type="text" id="d-reason" class="input" placeholder="e.g. maintenance">
|
||||
</div>
|
||||
<div class="form-row modal-actions">
|
||||
<button class="button secondary" id="d-cancel" type="button">Cancel</button>
|
||||
<button class="button warning" id="d-confirm" type="button">Disable</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
requestAnimationFrame(() => overlay.classList.add("visible"));
|
||||
|
||||
const whenRadios = overlay.querySelectorAll('input[name="d-when"]');
|
||||
const forRadios = overlay.querySelectorAll('input[name="d-for"]');
|
||||
const fromInput = overlay.querySelector("#d-from");
|
||||
const timespanGroup = overlay.querySelector("#d-timespan-group");
|
||||
const untilInput = overlay.querySelector("#d-until");
|
||||
|
||||
function updateWhen() {
|
||||
const val = overlay.querySelector('input[name="d-when"]:checked').value;
|
||||
fromInput.style.display = val === "later" ? "" : "none";
|
||||
fromInput.disabled = val !== "later";
|
||||
if (val === "now") fromInput.value = "";
|
||||
}
|
||||
|
||||
function updateFor() {
|
||||
const val = overlay.querySelector('input[name="d-for"]:checked').value;
|
||||
timespanGroup.style.display = val === "timespan" ? "flex" : "none";
|
||||
untilInput.style.display = val === "until" ? "" : "none";
|
||||
untilInput.disabled = val !== "until";
|
||||
if (val === "timespan") untilInput.value = "";
|
||||
}
|
||||
|
||||
whenRadios.forEach(r => r.addEventListener("change", updateWhen));
|
||||
forRadios.forEach(r => r.addEventListener("change", updateFor));
|
||||
updateFor();
|
||||
|
||||
overlay.querySelector("#d-confirm").addEventListener("click", () => {
|
||||
const when = overlay.querySelector('input[name="d-when"]:checked').value;
|
||||
const forVal = overlay.querySelector('input[name="d-for"]:checked').value;
|
||||
const reason = overlay.querySelector("#d-reason").value.trim() || null;
|
||||
|
||||
if (when === "later" && !fromInput.value) {
|
||||
setNotice("Please select a date and time for the disable.", true);
|
||||
return;
|
||||
}
|
||||
if (forVal === "until" && !untilInput.value) {
|
||||
setNotice("Please select a date and time for the end.", true);
|
||||
return;
|
||||
}
|
||||
if (forVal === "timespan") {
|
||||
const val = parseInt(overlay.querySelector("#d-duration").value);
|
||||
if (!val || val < 1) {
|
||||
setNotice("Please enter a valid duration.", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const body = { reason };
|
||||
|
||||
if (when === "later") {
|
||||
body.startAtUtc = new Date(fromInput.value).toISOString();
|
||||
}
|
||||
|
||||
if (forVal === "timespan") {
|
||||
const val = parseInt(overlay.querySelector("#d-duration").value) || 1;
|
||||
const unit = parseInt(overlay.querySelector("#d-unit").value);
|
||||
body.durationMinutes = val * unit;
|
||||
} else {
|
||||
body.untilUtc = new Date(untilInput.value).toISOString();
|
||||
}
|
||||
|
||||
overlay.remove();
|
||||
resolve(body);
|
||||
});
|
||||
|
||||
overlay.querySelector("#d-cancel").addEventListener("click", () => {
|
||||
overlay.remove();
|
||||
reject(new Error("Cancelled"));
|
||||
});
|
||||
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) {
|
||||
overlay.remove();
|
||||
reject(new Error("Cancelled"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
boot();
|
||||
+6
-4
@@ -3,23 +3,24 @@
|
||||
<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>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">RL</span>
|
||||
<img class="brand-mark" src="/admin/Ngino_logo_symbol.png" alt="Ngino">
|
||||
<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>
|
||||
@@ -45,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: 4.2 KiB |
@@ -1,141 +0,0 @@
|
||||
using ReverseLlama.Protocol;
|
||||
|
||||
namespace ReverseLlama.Client;
|
||||
|
||||
internal sealed class ClientOptions
|
||||
{
|
||||
public Uri Server { get; init; } = new("http://localhost:5001");
|
||||
|
||||
public Uri Upstream { get; init; } = new("http://localhost:11434");
|
||||
|
||||
public string TunnelPath { get; init; } = ProtocolConstants.DefaultTunnelPath;
|
||||
|
||||
public string? Token { get; init; }
|
||||
|
||||
public string ClientId { get; init; } = Environment.MachineName.ToLowerInvariant();
|
||||
|
||||
public TimeSpan ReconnectDelay { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public int ChunkSize { get; init; } = 64 * 1024;
|
||||
|
||||
public Uri TunnelUri
|
||||
{
|
||||
get
|
||||
{
|
||||
var builder = new UriBuilder(Server);
|
||||
builder.Scheme = builder.Scheme.ToLowerInvariant() switch
|
||||
{
|
||||
"http" => "ws",
|
||||
"https" => "wss",
|
||||
"ws" => "ws",
|
||||
"wss" => "wss",
|
||||
var unsupported => throw new InvalidOperationException($"Unsupported server URI scheme '{unsupported}'. Use http, https, ws, or wss.")
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(builder.Path) || builder.Path == "/")
|
||||
{
|
||||
builder.Path = NormalizePath(TunnelPath);
|
||||
}
|
||||
|
||||
return builder.Uri;
|
||||
}
|
||||
}
|
||||
|
||||
public static ClientOptions Parse(string[] args)
|
||||
{
|
||||
var values = ParseArgs(args);
|
||||
|
||||
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")
|
||||
};
|
||||
}
|
||||
|
||||
public static string Usage =>
|
||||
"""
|
||||
ReverseLlama.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
|
||||
--reconnect-delay <sec> Defaults to 5
|
||||
--chunk-size <bytes> Defaults to 65536
|
||||
""";
|
||||
|
||||
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
if (!arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Unexpected argument '{arg}'.");
|
||||
}
|
||||
|
||||
var keyValue = arg[2..].Split('=', 2);
|
||||
if (keyValue.Length == 2)
|
||||
{
|
||||
values[keyValue[0]] = keyValue[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Missing value for '{arg}'.");
|
||||
}
|
||||
|
||||
values[keyValue[0]] = args[++i];
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static string? Read(Dictionary<string, string> values, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
value = Environment.GetEnvironmentVariable(key);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ReadInt(Dictionary<string, string> values, int fallback, params string[] keys)
|
||||
{
|
||||
var value = Read(values, keys);
|
||||
return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
private static Uri ReadUri(Dictionary<string, string> values, string key, string envKey, string fallback)
|
||||
{
|
||||
var value = Read(values, key, envKey) ?? fallback;
|
||||
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new ArgumentException($"'{value}' is not an absolute URI.");
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path) =>
|
||||
path.StartsWith('/') ? path : $"/{path}";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -1,183 +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("/", () => 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,
|
||||
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,31 @@
|
||||
using Ngino.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace Ngino.Client.Tests;
|
||||
|
||||
public sealed class ClientOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void InsecureTlsIsDisabledByDefault()
|
||||
{
|
||||
var options = ClientOptions.Parse([]);
|
||||
|
||||
Assert.False(options.InsecureSkipTlsVerify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsecureTlsCanBeEnabledWithFlag()
|
||||
{
|
||||
var options = ClientOptions.Parse(["--insecure-skip-tls-verify"]);
|
||||
|
||||
Assert.True(options.InsecureSkipTlsVerify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsecureTlsCanBeEnabledWithEnvironmentStyleValue()
|
||||
{
|
||||
var options = ClientOptions.Parse(["--insecure-skip-tls-verify=true"]);
|
||||
|
||||
Assert.True(options.InsecureSkipTlsVerify);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -1,7 +1,8 @@
|
||||
using ReverseLlama.Client;
|
||||
using System.Net.Sockets;
|
||||
using Ngino.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace ReverseLlama.Client.Tests;
|
||||
namespace Ngino.Client.Tests;
|
||||
|
||||
public sealed class UpstreamRequestTests
|
||||
{
|
||||
@@ -32,4 +33,29 @@ public sealed class UpstreamRequestTests
|
||||
|
||||
Assert.Contains("origin-form path", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConnectionRefused_DetectsSocketConnectionRefused()
|
||||
{
|
||||
var socketException = new SocketException((int)SocketError.ConnectionRefused);
|
||||
var exception = new HttpRequestException("Connection refused", socketException);
|
||||
|
||||
Assert.True(UpstreamRequest.IsConnectionRefused(exception));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConnectionRefused_DetectsConnectionRefusedByMessage()
|
||||
{
|
||||
var exception = new HttpRequestException("Connection refused (localhost:8081)");
|
||||
|
||||
Assert.True(UpstreamRequest.IsConnectionRefused(exception));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConnectionRefused_IgnoresUnrelatedFailures()
|
||||
{
|
||||
var exception = new HttpRequestException("Connection reset by peer");
|
||||
|
||||
Assert.False(UpstreamRequest.IsConnectionRefused(exception));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user