Compare 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 | ||
|
|
c54c897703 | ||
|
|
9176807135 | ||
|
|
d61592c239 | ||
|
|
e3a538a1fb | ||
|
|
a787c54217 | ||
|
|
f1e60ecb75 | ||
|
|
2357957eb1 | ||
|
|
477ef3e548 | ||
|
|
a01bbd368a | ||
|
|
6b18cbca0e | ||
|
|
19c5b9bce1 | ||
|
|
d69769fb28 | ||
|
|
642ad1d9e4 | ||
|
|
8eeb65b6e6 | ||
|
|
4a84ff7316 |
@@ -18,13 +18,13 @@ jobs:
|
|||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore src/ReverseLlama.Server
|
run: dotnet restore src/Ngino.Server
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build src/ReverseLlama.Server --configuration Release --no-restore
|
run: dotnet build src/Ngino.Server --configuration Release --no-restore
|
||||||
|
|
||||||
- name: Publish
|
- 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
|
- name: Copy files to server
|
||||||
uses: appleboy/scp-action@v1.0.0
|
uses: appleboy/scp-action@v1.0.0
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
password: ${{ secrets.DEPLOY_SSH_PASSWORD }}
|
password: ${{ secrets.DEPLOY_SSH_PASSWORD }}
|
||||||
port: ${{ secrets.DEPLOY_SSH_PORT }}
|
port: ${{ secrets.DEPLOY_SSH_PORT }}
|
||||||
source: "publish/*"
|
source: "publish/*"
|
||||||
target: "/var/www/ReverseLlama"
|
target: "/var/www/Ngino"
|
||||||
strip_components: 1
|
strip_components: 1
|
||||||
|
|
||||||
- name: Restart app
|
- name: Restart app
|
||||||
@@ -45,4 +45,4 @@ jobs:
|
|||||||
password: ${{ secrets.DEPLOY_SSH_PASSWORD }}
|
password: ${{ secrets.DEPLOY_SSH_PASSWORD }}
|
||||||
port: ${{ secrets.DEPLOY_SSH_PORT }}
|
port: ${{ secrets.DEPLOY_SSH_PORT }}
|
||||||
script: |
|
script: |
|
||||||
sudo systemctl restart ReverseLlama.service
|
sudo systemctl restart Ngino-server.service
|
||||||
@@ -7,5 +7,7 @@
|
|||||||
*.sqlite-shm
|
*.sqlite-shm
|
||||||
*.sqlite-wal
|
*.sqlite-wal
|
||||||
.vs/
|
.vs/
|
||||||
**appsettings.Development.json
|
**/appsettings.*.json
|
||||||
!**/appsettings.Example.json
|
!**/appsettings.Example.json
|
||||||
|
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
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
EndProject
|
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
|
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
|
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
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
EndProject
|
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
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -1,42 +1,77 @@
|
|||||||
# ReverseLlama
|
# <img src="docs/images/Ngino_logo_symbol.png" width="25" alt="logo symbol"/> Ngino
|
||||||
|
|
||||||
(Eigenentwicklung; weitesgehend vibecoded)
|
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.
|
||||||
|
|
||||||
ReverseLlama is a small outbound HTTP tunnel for testing Ollama on GPU workstations while exposing the API from a server that cannot reach those workstations directly.
|
<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>
|
||||||
|
<td><img src="docs/images/README_architecture.png" width="1400" alt="client-server architecture visualized using arrows"></td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The server provides
|
||||||
|
- An API with
|
||||||
|
- Authentication via user keys
|
||||||
|
- Authorization (planned)
|
||||||
|
- 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
|
||||||
|
- How many requests is each client processing
|
||||||
|
- How many requests has each client processed
|
||||||
|
- Group management (planned)
|
||||||
|
- Who can access which models
|
||||||
|
- What clients are mapped to which groups
|
||||||
|
- Billing (planned)
|
||||||
|
- (planned) Price per model per thousand tokens
|
||||||
|
- (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.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
[Screenshots of the app can be seen here](docs/Screenshots.md)
|
||||||
|
|
||||||
The client opens 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.
|
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
- `src/ReverseLlama.Server`: ASP.NET Core server. Exposes the public proxy endpoint and accepts the outbound client tunnel.
|
- `src/Ngino.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/Ngino.Client`: Console client. Runs on the GPU machine and forwards requests to local Ollama.
|
||||||
- `src/ReverseLlama.Protocol`: Shared tunnel message types.
|
- `src/Ngino.Protocol`: Shared tunnel message types.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
Start the server:
|
Start the server:
|
||||||
|
|
||||||
```powershell
|
```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:
|
Start the client on the GPU workstation:
|
||||||
|
|
||||||
```powershell
|
```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:
|
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
|
```powershell
|
||||||
curl.exe -H "X-Reverse-Llama-Token: change-me" http://your-server:5050/api/tags
|
curl.exe -H "X-Ngino-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/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/api/tags
|
||||||
curl.exe http://your-server:5050/token/change-me/clients/gpu-01/api/tags
|
curl.exe http://your-server:5050/token/change-me/clients/gpu-01/api/tags
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
curl.exe -H "X-Reverse-Llama-Token: change-me" `
|
curl.exe -H "X-Ngino-Token: change-me" `
|
||||||
-H "Content-Type: application/json" `
|
-H "Content-Type: application/json" `
|
||||||
-d '{"model":"llama3.1","prompt":"hello"}' `
|
-d '{"model":"llama3.1","prompt":"hello"}' `
|
||||||
http://your-server:5050/api/generate
|
http://your-server:5050/api/generate
|
||||||
@@ -46,31 +81,32 @@ curl.exe -H "X-Reverse-Llama-Token: change-me" `
|
|||||||
|
|
||||||
Server options:
|
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.
|
- `--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 `/_reverse-llama/tunnel`.
|
- `--tunnel-path <path>`: defaults to `/_ngino/tunnel`.
|
||||||
- `--status-path <path>`: defaults to `/_reverse-llama/status`.
|
- `--status-path <path>`: defaults to `/_ngino/status`.
|
||||||
- `--chunk-size <bytes>` or `REVERSE_LLAMA_CHUNK_SIZE`: defaults to `65536`.
|
- `--chunk-size <bytes>` or `NGINO_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.
|
- `--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 `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.
|
- `--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:
|
Admin UI:
|
||||||
|
|
||||||
- `GET /admin` opens the Keycloak-protected management UI.
|
- `GET /admin` opens the Keycloak-protected management UI.
|
||||||
- The temporary Keycloak settings live under `Authentication:Keycloak` in `appsettings.json`.
|
- 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`).
|
- 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:
|
Client options:
|
||||||
|
|
||||||
- `--server <url>` or `REVERSE_LLAMA_SERVER`: server base URL, for example `http://your-server:5050`.
|
- `--server <url>` or `NGINO_SERVER`: server base URL, for example `http://your-server:5050`.
|
||||||
- `--upstream <url>` or `REVERSE_LLAMA_UPSTREAM`: local Ollama URL, defaults to `http://localhost:11434`.
|
- `--upstream <url>` or `NGINO_UPSTREAM`: local Ollama URL, defaults to `http://localhost:11434`.
|
||||||
- `--token <value>` or `REVERSE_LLAMA_TOKEN`: optional shared token.
|
- `--token <value>` or `NGINO_TOKEN`: optional shared token.
|
||||||
- `--client-id <name>` or `REVERSE_LLAMA_CLIENT_ID`: identifies this machine on the server; defaults to the machine name.
|
- `--client-id <name>` or `NGINO_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`.
|
- `--tunnel-path <path>` or `NGINO_TUNNEL_PATH`: defaults to `/_ngino/tunnel`.
|
||||||
- `--reconnect-delay <seconds>` or `REVERSE_LLAMA_RECONNECT_DELAY_SECONDS`: defaults to `5`.
|
- `--reconnect-delay <seconds>` or `NGINO_RECONNECT_DELAY_SECONDS`: defaults to `5`.
|
||||||
- `--chunk-size <bytes>` or `REVERSE_LLAMA_CHUNK_SIZE`: defaults to `65536`.
|
- `--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
|
## Multiple clients
|
||||||
|
|
||||||
@@ -84,7 +120,7 @@ Any number of machines can connect at the same time; each registers under its cl
|
|||||||
|
|
||||||
## Embedding cache
|
## 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.
|
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.
|
||||||
|
|
||||||
@@ -95,12 +131,53 @@ Linux:
|
|||||||
```bash
|
```bash
|
||||||
sudo bash deploy/install-client.sh --server http://your-server:5050 --token "change-me"
|
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.
|
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
|
## Notes
|
||||||
|
|
||||||
- Request and response bodies are streamed through the tunnel, which is important for Ollama streaming responses.
|
- 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"
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||||
DEFAULT_INSTALL_DIR="/opt/reversellama-client"
|
DEFAULT_INSTALL_DIR="/opt/ngino-client"
|
||||||
DEFAULT_SERVICE_NAME="reversellama-client"
|
DEFAULT_SERVICE_NAME="ngino-client"
|
||||||
DEFAULT_UPSTREAM="http://localhost:11434"
|
DEFAULT_UPSTREAM="http://localhost:11434"
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -16,6 +16,13 @@ TOKEN=""
|
|||||||
CLIENT_ID="$(hostname -s 2>/dev/null || echo "linux-client")"
|
CLIENT_ID="$(hostname -s 2>/dev/null || echo "linux-client")"
|
||||||
UPSTREAM="$DEFAULT_UPSTREAM"
|
UPSTREAM="$DEFAULT_UPSTREAM"
|
||||||
SKIP_OLLAMA=false
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -33,10 +40,10 @@ usage() {
|
|||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $0 [OPTIONS]
|
Usage: $0 [OPTIONS]
|
||||||
|
|
||||||
Installs the ReverseLlama client as a systemd service on Linux.
|
Installs the Ngino client as a systemd service on Linux.
|
||||||
|
|
||||||
Required:
|
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
|
--token <value> Shared secret token for the server
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
@@ -45,11 +52,27 @@ Optional:
|
|||||||
--install-dir <dir> Install directory; defaults to $DEFAULT_INSTALL_DIR
|
--install-dir <dir> Install directory; defaults to $DEFAULT_INSTALL_DIR
|
||||||
--service-name <n> systemd service name; defaults to $DEFAULT_SERVICE_NAME
|
--service-name <n> systemd service name; defaults to $DEFAULT_SERVICE_NAME
|
||||||
--no-ollama Skip Ollama installation and status check
|
--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
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$0 --server http://gpu-server:5050 --token "my-secret"
|
$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" --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
|
EOF
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
@@ -57,21 +80,28 @@ EOF
|
|||||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--server) SERVER_URL="$2"; shift 2 ;;
|
--server) SERVER_URL="$2"; shift 2 ;;
|
||||||
--token) TOKEN="$2"; shift 2 ;;
|
--token) TOKEN="$2"; shift 2 ;;
|
||||||
--client-id) CLIENT_ID="$2"; shift 2 ;;
|
--client-id) CLIENT_ID="$2"; shift 2 ;;
|
||||||
--upstream) UPSTREAM="$2"; shift 2 ;;
|
--upstream) UPSTREAM="$2"; shift 2 ;;
|
||||||
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||||
--service-name) SERVICE_NAME="$2"; shift 2 ;;
|
--service-name) SERVICE_NAME="$2"; shift 2 ;;
|
||||||
--no-ollama) SKIP_OLLAMA=true; shift ;;
|
--no-ollama) SKIP_OLLAMA=true; shift ;;
|
||||||
-h|--help) usage ;;
|
--use-llama-cpp-via-docker) USE_LLAMA_CPP_VIA_DOCKER=true; shift ;;
|
||||||
*) die "Unknown option: $1" ;;
|
--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
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# ── Prompt for missing required values ───────────────────────────────────────
|
# ── Prompt for missing required values ───────────────────────────────────────
|
||||||
if [[ -z "$SERVER_URL" ]]; then
|
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
|
fi
|
||||||
if [[ -z "$SERVER_URL" ]]; then
|
if [[ -z "$SERVER_URL" ]]; then
|
||||||
die "Server URL is required."
|
die "Server URL is required."
|
||||||
@@ -85,11 +115,42 @@ if [[ -z "$TOKEN" ]]; then
|
|||||||
die "Token is required."
|
die "Token is required."
|
||||||
fi
|
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 ────────────────────────────────────────────────────────────────
|
# ── Root check ────────────────────────────────────────────────────────────────
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
die "This script must be run as root (or with sudo)."
|
die "This script must be run as root (or with sudo)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Find dotnet binary ──────────────────────────────────────────────────────
|
||||||
|
find_dotnet() {
|
||||||
|
local candidates=(
|
||||||
|
"$(command -v dotnet 2>/dev/null || true)"
|
||||||
|
/usr/share/dotnet/dotnet
|
||||||
|
/usr/bin/dotnet
|
||||||
|
/usr/local/bin/dotnet
|
||||||
|
/usr/local/share/dotnet/dotnet
|
||||||
|
"$HOME/.dotnet/dotnet"
|
||||||
|
)
|
||||||
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
||||||
|
local invoke_home
|
||||||
|
invoke_home="$(eval echo "~$SUDO_USER")"
|
||||||
|
candidates+=("$invoke_home/.dotnet/dotnet")
|
||||||
|
fi
|
||||||
|
for c in "${candidates[@]}"; do
|
||||||
|
if [[ -n "$c" && -x "$c" ]]; then
|
||||||
|
echo "$c"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# ── Check / install .NET 10 SDK ──────────────────────────────────────────────
|
# ── Check / install .NET 10 SDK ──────────────────────────────────────────────
|
||||||
install_dotnet() {
|
install_dotnet() {
|
||||||
info "Installing .NET 10 SDK..."
|
info "Installing .NET 10 SDK..."
|
||||||
@@ -120,7 +181,7 @@ install_dotnet() {
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
else
|
else
|
||||||
die "Unsupported distro. Install .NET 10 SDK manually: https://dotnet.microsoft.com/download"
|
die "Unsupported distro. Install .NET 10 SDK manually: https://dotnet.microsoft.com/download/dotnet/10.0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Add Microsoft package repository GPG key and repo
|
# Add Microsoft package repository GPG key and repo
|
||||||
@@ -149,19 +210,13 @@ REPO
|
|||||||
$PKG_MGR install -y dotnet-sdk-10.0
|
$PKG_MGR install -y dotnet-sdk-10.0
|
||||||
|
|
||||||
elif command -v pacman &>/dev/null; then
|
elif command -v pacman &>/dev/null; then
|
||||||
warn "Arch Linux detected. .NET 10 may need to be installed from the AUR or manually."
|
die "Arch Linux: install dotnet-sdk from the AUR or manually: https://dotnet.microsoft.com/download/dotnet/10.0"
|
||||||
warn "See: https://dotnet.microsoft.com/download"
|
|
||||||
die "Cannot auto-install .NET SDK on Arch. Please install dotnet-sdk-10 manually."
|
|
||||||
|
|
||||||
else
|
else
|
||||||
die "Unsupported package manager. Install .NET 10 SDK manually: https://dotnet.microsoft.com/download"
|
die "Unsupported package manager. Install .NET 10 SDK manually: https://dotnet.microsoft.com/download/dotnet/10.0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DOTNET_CMD="$(command -v dotnet 2>/dev/null || echo /usr/share/dotnet/dotnet)"
|
if ! DOTNET_CMD="$(find_dotnet)"; then
|
||||||
if [[ ! -x "$DOTNET_CMD" ]]; then
|
|
||||||
DOTNET_CMD="/usr/bin/dotnet"
|
|
||||||
fi
|
|
||||||
if [[ ! -x "$DOTNET_CMD" ]]; then
|
|
||||||
die ".NET SDK installation succeeded but dotnet binary not found. Please add it to PATH."
|
die ".NET SDK installation succeeded but dotnet binary not found. Please add it to PATH."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -170,8 +225,7 @@ REPO
|
|||||||
}
|
}
|
||||||
|
|
||||||
DOTNET_CMD=""
|
DOTNET_CMD=""
|
||||||
if command -v dotnet &>/dev/null; then
|
if DOTNET_CMD="$(find_dotnet)"; then
|
||||||
DOTNET_CMD="$(command -v dotnet)"
|
|
||||||
DOTNET_VER="$("$DOTNET_CMD" --version 2>/dev/null || true)"
|
DOTNET_VER="$("$DOTNET_CMD" --version 2>/dev/null || true)"
|
||||||
if [[ "$DOTNET_VER" == 10.* ]]; then
|
if [[ "$DOTNET_VER" == 10.* ]]; then
|
||||||
info "dotnet 10 is already installed: $DOTNET_CMD ($DOTNET_VER)"
|
info "dotnet 10 is already installed: $DOTNET_CMD ($DOTNET_VER)"
|
||||||
@@ -208,58 +262,100 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Build client from source ─────────────────────────────────────────────────
|
# ── Build client from source ─────────────────────────────────────────────────
|
||||||
CLIENT_SRC="$REPO_ROOT/src/ReverseLlama.Client"
|
CLIENT_SRC="$REPO_ROOT/src/Ngino.Client"
|
||||||
if [[ ! -d "$CLIENT_SRC" ]]; then
|
if [[ ! -d "$CLIENT_SRC" ]]; then
|
||||||
die "Client source not found at $CLIENT_SRC. Run this script from the repository or pass --install-dir."
|
die "Client source not found at $CLIENT_SRC. Run this script from the repository or pass --install-dir."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "Building ReverseLlama client (self-contained, linux-x64)..."
|
ARCH="$(uname -m)"
|
||||||
BUILD_DIR="$(mktemp -d /tmp/reversellama-build.XXXXXX)"
|
case "$ARCH" in
|
||||||
|
x86_64) DOTNET_RID="linux-x64" ;;
|
||||||
|
aarch64) DOTNET_RID="linux-arm64" ;;
|
||||||
|
armv7l) DOTNET_RID="linux-arm" ;;
|
||||||
|
*) die "Unsupported architecture: $ARCH" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
info "Building Ngino client (self-contained, $DOTNET_RID)..."
|
||||||
|
BUILD_DIR="$(mktemp -d /tmp/ngino-build.XXXXXX)"
|
||||||
trap 'rm -rf "$BUILD_DIR"' EXIT
|
trap 'rm -rf "$BUILD_DIR"' EXIT
|
||||||
|
|
||||||
"$DOTNET_CMD" publish "$CLIENT_SRC/ReverseLlama.Client.csproj" \
|
"$DOTNET_CMD" publish "$CLIENT_SRC/Ngino.Client.csproj" \
|
||||||
-c Release \
|
-c Release \
|
||||||
-r linux-x64 \
|
-r "$DOTNET_RID" \
|
||||||
--self-contained true \
|
--self-contained true \
|
||||||
-o "$BUILD_DIR"
|
-o "$BUILD_DIR"
|
||||||
|
|
||||||
if [[ ! -f "$BUILD_DIR/ReverseLlama.Client" ]]; then
|
if [[ ! -f "$BUILD_DIR/Ngino.Client" ]]; then
|
||||||
die "Build failed. ReverseLlama.Client binary not found in output."
|
die "Build failed. Ngino.Client binary not found in output."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "Build successful."
|
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 ───────────────────────────────────────────────────────────────────
|
# ── Install ───────────────────────────────────────────────────────────────────
|
||||||
info "Installing to $INSTALL_DIR..."
|
info "Installing to $INSTALL_DIR..."
|
||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
cp -a "$BUILD_DIR"/. "$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."
|
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 ───────────────────────────────────────────────────
|
# ── Create systemd service ───────────────────────────────────────────────────
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.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
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=ReverseLlama Tunnel Client
|
Description=Ngino Tunnel Client
|
||||||
After=network-online.target
|
After=network-online.target docker.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target docker.service
|
||||||
$([ "$SKIP_OLLAMA" = "false" ] && echo "After=ollama.service")
|
$([ "$SKIP_OLLAMA" = "false" ] && echo "After=ollama.service")
|
||||||
$([ "$SKIP_OLLAMA" = "false" ] && echo "Wants=ollama.service")
|
$([ "$SKIP_OLLAMA" = "false" ] && echo "Wants=ollama.service")
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
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
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||||
Environment=DOTNET_NOLOGO=1
|
Environment=DOTNET_NOLOGO=1
|
||||||
|
EnvironmentFile=$ENV_DIR/env
|
||||||
WorkingDirectory=$INSTALL_DIR
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
@@ -285,6 +381,9 @@ echo " Client ID: $CLIENT_ID"
|
|||||||
echo " Upstream: $UPSTREAM"
|
echo " Upstream: $UPSTREAM"
|
||||||
echo " Service: $SERVICE_NAME"
|
echo " Service: $SERVICE_NAME"
|
||||||
echo " Install dir: $INSTALL_DIR"
|
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
|
||||||
echo " Manage: systemctl {start|stop|restart|status} $SERVICE_NAME"
|
echo " Manage: systemctl {start|stop|restart|status} $SERVICE_NAME"
|
||||||
echo " Logs: journalctl -u $SERVICE_NAME -f"
|
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)"
|
||||||
@@ -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)"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Clients view
|
||||||
|

|
||||||
|
# Models 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 |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 164 KiB |
@@ -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,7 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
|
||||||
namespace ReverseLlama.Client;
|
namespace Ngino.Client;
|
||||||
|
|
||||||
internal sealed class ChannelHttpContent : HttpContent
|
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; }
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ReverseLlama.Protocol\ReverseLlama.Protocol.csproj" />
|
<ProjectReference Include="..\Ngino.Protocol\Ngino.Protocol.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<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.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ReverseLlama.Client;
|
using Ngino.Client;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var options = ClientOptions.Parse(args);
|
var options = ClientOptions.Parse(args);
|
||||||
|
|
||||||
Console.WriteLine("ReverseLlama client");
|
Console.WriteLine("Ngino client");
|
||||||
Console.WriteLine($" client id: {options.ClientId}");
|
Console.WriteLine($" client id: {options.ClientId}");
|
||||||
Console.WriteLine($" server tunnel: {options.TunnelUri}");
|
Console.WriteLine($" server tunnel: {options.TunnelUri}");
|
||||||
Console.WriteLine($" local upstream: {options.Upstream}");
|
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.
|
// Args are parsed by ClientOptions; keep them away from the host configuration.
|
||||||
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] });
|
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] });
|
||||||
builder.Services.AddSingleton(options);
|
builder.Services.AddSingleton(options);
|
||||||
builder.Services.AddSingleton<TunnelClient>();
|
builder.Services.AddSingleton<TunnelClient>();
|
||||||
builder.Services.AddHostedService<TunnelWorker>();
|
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.
|
// 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();
|
await builder.Build().RunAsync();
|
||||||
return 0;
|
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 System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ReverseLlama.Protocol;
|
using Ngino.Protocol;
|
||||||
|
|
||||||
namespace ReverseLlama.Client;
|
namespace Ngino.Client;
|
||||||
|
|
||||||
internal sealed class TunnelClient
|
internal sealed class TunnelClient
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan ModelRefreshInterval = TimeSpan.FromSeconds(15);
|
private static readonly TimeSpan ModelRefreshInterval = TimeSpan.FromSeconds(15);
|
||||||
private static readonly TimeSpan ModelRefreshTimeout = TimeSpan.FromSeconds(10);
|
private static readonly TimeSpan ModelRefreshTimeout = TimeSpan.FromSeconds(10);
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
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, UpstreamRequest> _activeRequests = new();
|
||||||
|
private readonly ConcurrentDictionary<string, PendingRequestBody> _pendingRequestBodies = new();
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ClientOptions _options;
|
private readonly ClientOptions _options;
|
||||||
private readonly ILogger<TunnelClient> _logger;
|
private readonly ILogger<TunnelClient> _logger;
|
||||||
private readonly object _modelSnapshotLock = new();
|
private readonly object _modelSnapshotLock = new();
|
||||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||||
|
private readonly LlamaCppManager? _llamaCppManager;
|
||||||
private List<string> _lastActiveModels = [];
|
private List<string> _lastActiveModels = [];
|
||||||
private List<string> _lastModels = [];
|
private List<string> _lastModels = [];
|
||||||
|
|
||||||
@@ -32,15 +34,51 @@ internal sealed class TunnelClient
|
|||||||
{
|
{
|
||||||
Timeout = Timeout.InfiniteTimeSpan
|
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)
|
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)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
using var socket = new ClientWebSocket();
|
using var socket = new ClientWebSocket();
|
||||||
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
|
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
if (_options.InsecureSkipTlsVerify)
|
||||||
|
{
|
||||||
|
socket.Options.RemoteCertificateValidationCallback = static (_, _, _, _) => true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_options.Token))
|
if (!string.IsNullOrWhiteSpace(_options.Token))
|
||||||
{
|
{
|
||||||
socket.Options.SetRequestHeader(ProtocolConstants.TokenHeader, _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)
|
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 request = new HttpRequestMessage(HttpMethod.Get, new Uri(_options.Upstream, "/api/tags"));
|
||||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
@@ -161,6 +209,17 @@ internal sealed class TunnelClient
|
|||||||
|
|
||||||
private async Task<List<string>> GetActiveUpstreamModelsAsync(CancellationToken cancellationToken)
|
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 request = new HttpRequestMessage(HttpMethod.Get, new Uri(_options.Upstream, "/api/ps"));
|
||||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
|
||||||
@@ -285,7 +344,8 @@ internal sealed class TunnelClient
|
|||||||
switch (message.Type)
|
switch (message.Type)
|
||||||
{
|
{
|
||||||
case TunnelMessageTypes.HttpRequest:
|
case TunnelMessageTypes.HttpRequest:
|
||||||
StartRequest(socket, message, cancellationToken);
|
_pendingRequestBodies[message.RequestId] = new PendingRequestBody();
|
||||||
|
_ = Task.Run(() => StartRequest(socket, message, cancellationToken), cancellationToken);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TunnelMessageTypes.HttpRequestBody:
|
case TunnelMessageTypes.HttpRequestBody:
|
||||||
@@ -293,6 +353,10 @@ internal sealed class TunnelClient
|
|||||||
{
|
{
|
||||||
requestWithBody.AddBody(message.Body ?? []);
|
requestWithBody.AddBody(message.Body ?? []);
|
||||||
}
|
}
|
||||||
|
else if (_pendingRequestBodies.TryGetValue(message.RequestId, out var pendingBody))
|
||||||
|
{
|
||||||
|
pendingBody.AddBody(message.Body ?? []);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TunnelMessageTypes.HttpRequestComplete:
|
case TunnelMessageTypes.HttpRequestComplete:
|
||||||
@@ -300,6 +364,10 @@ internal sealed class TunnelClient
|
|||||||
{
|
{
|
||||||
completedRequest.CompleteBody();
|
completedRequest.CompleteBody();
|
||||||
}
|
}
|
||||||
|
else if (_pendingRequestBodies.TryGetValue(message.RequestId, out var pendingBody))
|
||||||
|
{
|
||||||
|
pendingBody.Complete();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TunnelMessageTypes.Cancel:
|
case TunnelMessageTypes.Cancel:
|
||||||
@@ -307,6 +375,10 @@ internal sealed class TunnelClient
|
|||||||
{
|
{
|
||||||
cancelledRequest.Cancel();
|
cancelledRequest.Cancel();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_pendingRequestBodies.TryRemove(message.RequestId, out _);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TunnelMessageTypes.ModelCommand:
|
case TunnelMessageTypes.ModelCommand:
|
||||||
@@ -358,6 +430,11 @@ internal sealed class TunnelClient
|
|||||||
throw new InvalidOperationException("Model command is missing a model name.");
|
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 request = BuildModelCommandRequest(message);
|
||||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
@@ -374,6 +451,126 @@ internal sealed class TunnelClient
|
|||||||
return BuildModelCommandResult(message.RequestId, response, body);
|
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(
|
private static TunnelMessage BuildModelCommandResult(
|
||||||
string requestId,
|
string requestId,
|
||||||
HttpResponseMessage response,
|
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(
|
private static bool ShouldRetryModelCommandWithEmbedding(
|
||||||
string? command,
|
string? command,
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
@@ -484,15 +694,122 @@ internal sealed class TunnelClient
|
|||||||
private static StringContent JsonContent<T>(T value) =>
|
private static StringContent JsonContent<T>(T value) =>
|
||||||
new(JsonSerializer.Serialize(value, JsonOptions), Encoding.UTF8, "application/json");
|
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(
|
var request = new UpstreamRequest(
|
||||||
_options,
|
_options,
|
||||||
_httpClient,
|
_httpClient,
|
||||||
message,
|
message,
|
||||||
(response, token) => SendAsync(socket, response, token),
|
(response, token) => SendAsync(socket, response, token),
|
||||||
requestId => _activeRequests.TryRemove(requestId, out _),
|
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))
|
if (!_activeRequests.TryAdd(message.RequestId, request))
|
||||||
{
|
{
|
||||||
@@ -505,10 +822,16 @@ internal sealed class TunnelClient
|
|||||||
Error = "Duplicate request id."
|
Error = "Duplicate request id."
|
||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
_pendingRequestBodies.TryRemove(message.RequestId, out _);
|
||||||
return;
|
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)
|
private async Task SendAsync(ClientWebSocket socket, TunnelMessage message, CancellationToken cancellationToken)
|
||||||
@@ -536,5 +859,51 @@ internal sealed class TunnelClient
|
|||||||
request.Cancel();
|
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;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace ReverseLlama.Client;
|
namespace Ngino.Client;
|
||||||
|
|
||||||
internal sealed class TunnelWorker : BackgroundService
|
internal sealed class TunnelWorker : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Sockets;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using ReverseLlama.Protocol;
|
using Ngino.Protocol;
|
||||||
|
|
||||||
namespace ReverseLlama.Client;
|
namespace Ngino.Client;
|
||||||
|
|
||||||
internal sealed class UpstreamRequest
|
internal sealed class UpstreamRequest
|
||||||
{
|
{
|
||||||
@@ -19,7 +20,8 @@ internal sealed class UpstreamRequest
|
|||||||
"Trailer",
|
"Trailer",
|
||||||
"Transfer-Encoding",
|
"Transfer-Encoding",
|
||||||
"Upgrade",
|
"Upgrade",
|
||||||
ProtocolConstants.TokenHeader
|
ProtocolConstants.TokenHeader,
|
||||||
|
ProtocolConstants.ModelHeader
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||||
@@ -33,8 +35,13 @@ internal sealed class UpstreamRequest
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly TunnelMessage _initialMessage;
|
private readonly TunnelMessage _initialMessage;
|
||||||
private readonly Action<string> _onComplete;
|
private readonly Action<string> _onComplete;
|
||||||
private readonly ClientOptions _options;
|
private readonly Uri _upstream;
|
||||||
private readonly Func<TunnelMessage, CancellationToken, Task> _sendAsync;
|
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(
|
public UpstreamRequest(
|
||||||
ClientOptions options,
|
ClientOptions options,
|
||||||
@@ -42,13 +49,22 @@ internal sealed class UpstreamRequest
|
|||||||
TunnelMessage initialMessage,
|
TunnelMessage initialMessage,
|
||||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||||
Action<string> onComplete,
|
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;
|
_httpClient = httpClient;
|
||||||
_initialMessage = initialMessage;
|
_initialMessage = initialMessage;
|
||||||
_sendAsync = sendAsync;
|
_sendAsync = sendAsync;
|
||||||
_onComplete = onComplete;
|
_onComplete = onComplete;
|
||||||
|
_upstream = effectiveUpstream ?? options.Upstream;
|
||||||
|
_responseHandler = responseHandler;
|
||||||
|
_pathTransform = pathTransform;
|
||||||
|
_bodyTransform = bodyTransform;
|
||||||
|
_onConnectionRefused = onConnectionRefused;
|
||||||
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
|
||||||
if (!initialMessage.HasBody)
|
if (!initialMessage.HasBody)
|
||||||
@@ -61,12 +77,36 @@ internal sealed class UpstreamRequest
|
|||||||
{
|
{
|
||||||
if (body.Length > 0)
|
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();
|
_requestBody.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
|
||||||
public void Cancel()
|
public void Cancel()
|
||||||
{
|
{
|
||||||
@@ -84,22 +124,40 @@ internal sealed class UpstreamRequest
|
|||||||
HttpCompletionOption.ResponseHeadersRead,
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
_cancellationTokenSource.Token);
|
_cancellationTokenSource.Token);
|
||||||
|
|
||||||
await SendResponseHeadersAsync(response);
|
if (_responseHandler is not null)
|
||||||
await SendResponseBodyAsync(response);
|
{
|
||||||
|
await _responseHandler(response, _cancellationTokenSource.Token);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SendResponseHeadersAsync(response);
|
||||||
|
await SendResponseBodyAsync(response);
|
||||||
|
|
||||||
await _sendAsync(
|
await _sendAsync(
|
||||||
new TunnelMessage
|
new TunnelMessage
|
||||||
{
|
{
|
||||||
Type = TunnelMessageTypes.HttpResponseComplete,
|
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||||
RequestId = _initialMessage.RequestId
|
RequestId = _initialMessage.RequestId
|
||||||
},
|
},
|
||||||
_cancellationTokenSource.Token);
|
_cancellationTokenSource.Token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (_cancellationTokenSource.IsCancellationRequested)
|
catch (OperationCanceledException) when (_cancellationTokenSource.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
if (IsConnectionRefused(exception))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_onConnectionRefused?.Invoke();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await SendErrorAsync(exception);
|
await SendErrorAsync(exception);
|
||||||
}
|
}
|
||||||
finally
|
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()
|
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 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)
|
if (_initialMessage.HasBody)
|
||||||
{
|
{
|
||||||
@@ -185,7 +272,7 @@ internal sealed class UpstreamRequest
|
|||||||
private async Task SendResponseBodyAsync(HttpResponseMessage response)
|
private async Task SendResponseBodyAsync(HttpResponseMessage response)
|
||||||
{
|
{
|
||||||
await using var stream = await response.Content.ReadAsStreamAsync(_cancellationTokenSource.Token);
|
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)
|
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)
|
private static List<HeaderPair> CollectResponseHeaders(HttpResponseMessage response)
|
||||||
{
|
{
|
||||||
var headers = new List<HeaderPair>();
|
var headers = new List<HeaderPair>();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace ReverseLlama.Protocol;
|
namespace Ngino.Protocol;
|
||||||
|
|
||||||
public sealed class HeaderPair
|
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
|
public sealed class TunnelMessage
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace ReverseLlama.Protocol;
|
namespace Ngino.Protocol;
|
||||||
|
|
||||||
public static class TunnelMessageTypes
|
public static class TunnelMessageTypes
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace ReverseLlama.Protocol;
|
namespace Ngino.Protocol;
|
||||||
|
|
||||||
public static class WebSocketMessageTransport
|
public static class WebSocketMessageTransport
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,948 @@
|
|||||||
|
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 Ngino.Server;
|
||||||
|
|
||||||
|
internal static class AdminEndpoints
|
||||||
|
{
|
||||||
|
private static readonly FileExtensionContentTypeProvider ContentTypes = new();
|
||||||
|
|
||||||
|
public static void MapAdminEndpoints(this WebApplication app, ServerSettings settings)
|
||||||
|
{
|
||||||
|
if (settings.Keycloak.IsConfigured)
|
||||||
|
{
|
||||||
|
app.MapGet("/admin/login", (string? returnUrl) =>
|
||||||
|
Results.Challenge(
|
||||||
|
new AuthenticationProperties { RedirectUri = NormalizeLocalReturnUrl(returnUrl) },
|
||||||
|
[OpenIdConnectDefaults.AuthenticationScheme]))
|
||||||
|
.AllowAnonymous();
|
||||||
|
|
||||||
|
app.MapPost("/admin/logout", () =>
|
||||||
|
Results.SignOut(
|
||||||
|
new AuthenticationProperties { RedirectUri = "/admin" },
|
||||||
|
[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(
|
||||||
|
"Login failed while processing the Keycloak callback. The exception was written to ELMAH.",
|
||||||
|
"text/plain"))
|
||||||
|
.AllowAnonymous();
|
||||||
|
|
||||||
|
var api = app.MapGroup("/api/admin");
|
||||||
|
|
||||||
|
if (settings.Keycloak.IsConfigured)
|
||||||
|
{
|
||||||
|
api.RequireAuthorization();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
api.RequireAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
|
api.MapGet("/summary", (HttpContext context, TunnelHub hub, ManagementStore store) =>
|
||||||
|
Results.Json(BuildSummary(context.User, hub, store, settings)));
|
||||||
|
|
||||||
|
api.MapGet("/me", (HttpContext context, ManagementStore store) =>
|
||||||
|
Results.Json(new
|
||||||
|
{
|
||||||
|
authenticated = context.User.Identity?.IsAuthenticated ?? false,
|
||||||
|
name = GetUserName(context.User),
|
||||||
|
keycloakConfigured = settings.Keycloak.IsConfigured,
|
||||||
|
management = new
|
||||||
|
{
|
||||||
|
available = store.IsAvailable,
|
||||||
|
databasePath = store.DatabasePath,
|
||||||
|
lastError = store.LastError
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
api.MapPost("/clients/{clientId}/disable", (string clientId, DisableClientRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manual = string.Equals(request.Mode, "manual", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
TimeSpan? duration = manual
|
||||||
|
? null
|
||||||
|
: request.DurationMinutes is { } minutes
|
||||||
|
? TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 24 * 60))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
store.DisableClient(clientId, duration, manual, request.Reason, request.StartAtUtc, request.UntilUtc);
|
||||||
|
return Results.Ok(new { clientId, disabled = true });
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/clients/{clientId}/enable", (string clientId, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
store.EnableClient(clientId);
|
||||||
|
return Results.Ok(new { clientId, disabled = false });
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/models/detail", async (
|
||||||
|
HttpContext context,
|
||||||
|
string model,
|
||||||
|
string? clientId,
|
||||||
|
TunnelHub hub,
|
||||||
|
ManagementStore store) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(model))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Model is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelSummary = BuildModelSummaries(hub, store)
|
||||||
|
.FirstOrDefault(item => item.Name.Equals(model, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var selectedClientId = ResolveModelClientId(hub, modelSummary, model, clientId);
|
||||||
|
object? show = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(selectedClientId))
|
||||||
|
{
|
||||||
|
var connection = hub.Get(selectedClientId);
|
||||||
|
if (connection is not null)
|
||||||
|
{
|
||||||
|
show = await SendModelCommandForApiAsync(
|
||||||
|
connection,
|
||||||
|
"show",
|
||||||
|
model,
|
||||||
|
TimeSpan.FromSeconds(60),
|
||||||
|
context.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(new
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
listedClients = modelSummary?.ListedClients ?? [],
|
||||||
|
activeClients = modelSummary?.ActiveClients ?? [],
|
||||||
|
metrics = modelSummary?.Metrics ?? EmptyModelMetrics(),
|
||||||
|
selectedClientId,
|
||||||
|
show
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/models/actions", async (
|
||||||
|
HttpContext context,
|
||||||
|
ModelActionRequest request,
|
||||||
|
TunnelHub hub) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ClientId)
|
||||||
|
|| string.IsNullOrWhiteSpace(request.Model)
|
||||||
|
|| string.IsNullOrWhiteSpace(request.Action))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Client id, model, and action are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryMapModelAction(request.Action, out var command, out var timeout))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = $"Unsupported action '{request.Action}'." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var connection = hub.Get(request.ClientId);
|
||||||
|
if (connection is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Client '{request.ClientId}' is not connected." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await SendModelCommandForApiAsync(
|
||||||
|
connection,
|
||||||
|
command,
|
||||||
|
request.Model,
|
||||||
|
timeout,
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
return Results.Json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/user-keys", (ManagementStore store) =>
|
||||||
|
Results.Json(store.ListUserKeys()));
|
||||||
|
|
||||||
|
api.MapPost("/user-keys", (CreateUserKeyRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Results.Json(store.CreateUserKey(request.Name));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapDelete("/user-keys/{id}", (string id, ManagementStore store) =>
|
||||||
|
store.DeleteUserKey(id)
|
||||||
|
? Results.NoContent()
|
||||||
|
: 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()));
|
||||||
|
|
||||||
|
api.MapPost("/groups", (CreateGroupRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Results.Json(store.CreateGroup(request.Name));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
return group is not null
|
||||||
|
? Results.Json(group)
|
||||||
|
: Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPut("/groups/{id}", (string id, UpdateGroupRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Name is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.UpdateGroup(id, request.Name)
|
||||||
|
? Results.Ok(store.GetGroup(id))
|
||||||
|
: Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapDelete("/groups/{id}", (string id, ManagementStore store) =>
|
||||||
|
store.DeleteGroup(id)
|
||||||
|
? Results.NoContent()
|
||||||
|
: Results.NotFound(new { error = $"Group '{id}' was not found." }));
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}/clients", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(store.ListGroupClients(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/groups/{id}/clients", (string id, AddGroupClientRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var member = store.AddGroupClient(
|
||||||
|
id,
|
||||||
|
request.ClientId,
|
||||||
|
request.Model,
|
||||||
|
request.ClientPattern,
|
||||||
|
request.KeepaliveInstancesToKeepAlive,
|
||||||
|
request.KeepaliveMaxParallelismPerClient,
|
||||||
|
request.KeepaliveParallelismHeadroom);
|
||||||
|
return Results.Json(member);
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = $"Failed to add member: {exception.Message}" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapDelete("/groups/{groupId}/clients/{clientId:long}", (string groupId, long clientId, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(groupId);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{groupId}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.RemoveGroupClient(clientId)
|
||||||
|
? Results.NoContent()
|
||||||
|
: Results.NotFound(new { error = $"Client '{clientId}' was not found." });
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/user-keys/groups", (ManagementStore store) =>
|
||||||
|
Results.Json(store.ListUserKeyGroups()));
|
||||||
|
|
||||||
|
api.MapPut("/user-keys/{id}/groups", (string id, SetUserKeyGroupsRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var keys = store.ListUserKeys();
|
||||||
|
if (!keys.Any(k => k.Id == id))
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"User key '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
store.SetUserKeyGroups(id, request.GroupIds ?? []);
|
||||||
|
return Results.Ok(new { userKeyId = id, groupIds = store.GetUserKeyGroupIds(id) });
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}/billing", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var billing = store.GetGroupBilling(id);
|
||||||
|
return billing is not null
|
||||||
|
? Results.Json(billing)
|
||||||
|
: Results.Json(new GroupBillingInfo(id, "EUR", 0, 0, false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPut("/groups/{id}/billing", (string id, UpdateBillingRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var billing = store.UpsertGroupBilling(
|
||||||
|
id,
|
||||||
|
request.Currency ?? "EUR",
|
||||||
|
request.DefaultRatePer1k,
|
||||||
|
request.RefuseBelowBalance,
|
||||||
|
request.Enabled);
|
||||||
|
return Results.Ok(billing);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}/billing/rules", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(store.ListGroupBillingRules(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/groups/{id}/billing/rules", (string id, AddBillingRuleRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rule = store.AddBillingRule(id, request.ModelRegex, request.RatePer1k);
|
||||||
|
return Results.Json(rule);
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPut("/groups/{id}/billing/rules/{ruleId:long}", (string id, long ruleId, UpdateBillingRuleRequest request, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return store.UpdateBillingRule(ruleId, request.ModelRegex, request.RatePer1k)
|
||||||
|
? Results.Ok(new { id = ruleId })
|
||||||
|
: Results.NotFound(new { error = $"Rule '{ruleId}' was not found." });
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapDelete("/groups/{id}/billing/rules/{ruleId:long}", (string id, long ruleId, ManagementStore store) =>
|
||||||
|
store.DeleteBillingRule(ruleId)
|
||||||
|
? Results.NoContent()
|
||||||
|
: Results.NotFound(new { error = $"Rule '{ruleId}' was not found." }));
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}/billing/payments", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(store.ListGroupPayments(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/groups/{id}/billing/payments", (string id, AddPaymentRequest request, ManagementStore store, HttpContext context) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userName = GetUserName(context.User);
|
||||||
|
var payment = store.AddPayment(id, request.Amount, request.Description, userName);
|
||||||
|
return Results.Json(payment);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = exception.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapDelete("/groups/{id}/billing/payments/{paymentId:long}", (string id, long paymentId, ManagementStore store) =>
|
||||||
|
store.DeletePayment(paymentId)
|
||||||
|
? Results.NoContent()
|
||||||
|
: Results.NotFound(new { error = $"Payment '{paymentId}' was not found." }));
|
||||||
|
|
||||||
|
api.MapGet("/groups/{id}/billing/balance", (string id, ManagementStore store) =>
|
||||||
|
{
|
||||||
|
var group = store.GetGroup(id);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(store.GetGroupBalance(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/usage/tokens", (ManagementStore store) =>
|
||||||
|
Results.Json(new
|
||||||
|
{
|
||||||
|
byModel = store.GetTokenStatsByModel(),
|
||||||
|
byClient = store.GetTokenStatsByClient(),
|
||||||
|
byUserKey = store.GetTokenStatsByUserKey(),
|
||||||
|
byGroup = store.GetTokenStatsByGroup()
|
||||||
|
}));
|
||||||
|
|
||||||
|
api.MapGet("/usage/revenue", (ManagementStore store) =>
|
||||||
|
Results.Json(store.GetClientRevenue()));
|
||||||
|
|
||||||
|
var adminHome = app.MapGet("/admin", (IWebHostEnvironment environment) =>
|
||||||
|
ServeAdminAsset(environment, null));
|
||||||
|
var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) =>
|
||||||
|
ServeAdminAsset(environment, assetPath));
|
||||||
|
|
||||||
|
if (settings.Keycloak.IsConfigured)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
ManagementStore store,
|
||||||
|
ServerSettings settings) =>
|
||||||
|
new
|
||||||
|
{
|
||||||
|
generatedAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
user = new
|
||||||
|
{
|
||||||
|
name = GetUserName(user),
|
||||||
|
authenticated = user.Identity?.IsAuthenticated ?? false
|
||||||
|
},
|
||||||
|
auth = new
|
||||||
|
{
|
||||||
|
keycloakConfigured = settings.Keycloak.IsConfigured,
|
||||||
|
sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token),
|
||||||
|
clientTokenConfigured = !string.IsNullOrWhiteSpace(settings.ClientToken),
|
||||||
|
userKeysConfigured = store.HasUserKeys,
|
||||||
|
clientKeysConfigured = store.HasClientKeys
|
||||||
|
},
|
||||||
|
management = new
|
||||||
|
{
|
||||||
|
available = store.IsAvailable,
|
||||||
|
databasePath = store.DatabasePath,
|
||||||
|
lastError = store.LastError
|
||||||
|
},
|
||||||
|
clients = BuildClientSummaries(hub, store),
|
||||||
|
models = BuildModelSummaries(hub, store),
|
||||||
|
userKeys = store.ListUserKeys(),
|
||||||
|
clientKeys = store.ListClientKeys(),
|
||||||
|
groups = store.ListGroups(),
|
||||||
|
userKeyGroups = store.ListUserKeyGroups(),
|
||||||
|
clientGroups = store.ResolveClientGroups(
|
||||||
|
hub.ClientSnapshots.Select(c => c.Id).ToList())
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IReadOnlyList<ClientSummary> BuildClientSummaries(TunnelHub hub, ManagementStore store)
|
||||||
|
{
|
||||||
|
var connected = hub.ClientSnapshots.ToDictionary(client => client.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var controls = store.ListClientControls();
|
||||||
|
var stats = store.GetClientRequestStats();
|
||||||
|
var clientIds = connected.Keys
|
||||||
|
.Concat(controls.Keys)
|
||||||
|
.Concat(stats.Keys)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(clientId => clientId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<ClientSummary>();
|
||||||
|
|
||||||
|
foreach (var clientId in clientIds)
|
||||||
|
{
|
||||||
|
connected.TryGetValue(clientId, out var snapshot);
|
||||||
|
controls.TryGetValue(clientId, out var access);
|
||||||
|
stats.TryGetValue(clientId, out var requestStats);
|
||||||
|
access ??= ClientAccess.Enabled;
|
||||||
|
|
||||||
|
result.Add(new ClientSummary(
|
||||||
|
clientId,
|
||||||
|
snapshot is not null,
|
||||||
|
snapshot?.PendingRequests ?? 0,
|
||||||
|
snapshot?.Models ?? [],
|
||||||
|
snapshot?.ActiveModels ?? [],
|
||||||
|
snapshot?.ModelsUpdatedAt,
|
||||||
|
access.IsDisabled,
|
||||||
|
access.DisabledFromUtc,
|
||||||
|
access.DisabledUntilUtc,
|
||||||
|
access.DisabledManually,
|
||||||
|
access.DisabledReason,
|
||||||
|
requestStats ?? new ClientRequestStats(0, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ModelSummary> BuildModelSummaries(TunnelHub hub, ManagementStore store)
|
||||||
|
{
|
||||||
|
var listedClients = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var activeClients = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var client in hub.ClientSnapshots)
|
||||||
|
{
|
||||||
|
AddModelClients(listedClients, client.Models, client.Id);
|
||||||
|
AddModelClients(activeClients, client.ActiveModels, client.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var metrics = store.GetModelUsageStats();
|
||||||
|
var modelNames = listedClients.Keys
|
||||||
|
.Concat(activeClients.Keys)
|
||||||
|
.Concat(metrics.Keys)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(model => model, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<ModelSummary>();
|
||||||
|
|
||||||
|
foreach (var model in modelNames)
|
||||||
|
{
|
||||||
|
metrics.TryGetValue(model, out var modelMetrics);
|
||||||
|
|
||||||
|
result.Add(new ModelSummary(
|
||||||
|
model,
|
||||||
|
listedClients.TryGetValue(model, out var listed) ? listed.ToArray() : [],
|
||||||
|
activeClients.TryGetValue(model, out var active) ? active.ToArray() : [],
|
||||||
|
modelMetrics ?? EmptyModelMetrics()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddModelClients(
|
||||||
|
Dictionary<string, SortedSet<string>> target,
|
||||||
|
IEnumerable<string> models,
|
||||||
|
string clientId)
|
||||||
|
{
|
||||||
|
foreach (var model in models)
|
||||||
|
{
|
||||||
|
if (!target.TryGetValue(model, out var clients))
|
||||||
|
{
|
||||||
|
clients = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
target[model] = clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.Add(clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveModelClientId(
|
||||||
|
TunnelHub hub,
|
||||||
|
ModelSummary? modelSummary,
|
||||||
|
string model,
|
||||||
|
string? requestedClientId)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(requestedClientId)
|
||||||
|
&& hub.Get(requestedClientId) is not null)
|
||||||
|
{
|
||||||
|
return requestedClientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelSummary?.ActiveClients.FirstOrDefault(clientId => hub.Get(clientId) is not null)
|
||||||
|
?? modelSummary?.ListedClients.FirstOrDefault(clientId => hub.Get(clientId) is not null)
|
||||||
|
?? hub.SelectBest(model)?.ClientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ModelUsageStats EmptyModelMetrics() =>
|
||||||
|
new(0, 0, 0, 0, 0);
|
||||||
|
|
||||||
|
private static async Task<object> SendModelCommandForApiAsync(
|
||||||
|
TunnelConnection connection,
|
||||||
|
string command,
|
||||||
|
string model,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await connection.SendModelCommandAsync(
|
||||||
|
command,
|
||||||
|
model,
|
||||||
|
payloadJson: null,
|
||||||
|
timeout,
|
||||||
|
cancellationToken);
|
||||||
|
var body = response.Body is { Length: > 0 }
|
||||||
|
? Encoding.UTF8.GetString(response.Body)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ok = response.StatusCode is >= 200 and < 300,
|
||||||
|
statusCode = response.StatusCode,
|
||||||
|
reasonPhrase = response.ReasonPhrase,
|
||||||
|
body = ParseJsonOrText(body)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ok = false,
|
||||||
|
statusCode = StatusCodes.Status504GatewayTimeout,
|
||||||
|
reasonPhrase = "Timed out",
|
||||||
|
body = "The model command timed out."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ok = false,
|
||||||
|
statusCode = StatusCodes.Status502BadGateway,
|
||||||
|
reasonPhrase = "Command failed",
|
||||||
|
body = exception.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ParseJsonOrText(string body)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(body);
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return body.Length <= 100_000 ? body : body[..100_000];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryMapModelAction(string action, out string command, out TimeSpan timeout)
|
||||||
|
{
|
||||||
|
command = action.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"add" or "pull" => "pull",
|
||||||
|
"remove" or "delete" => "delete",
|
||||||
|
"load" => "load",
|
||||||
|
"unload" => "unload",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout = command == "pull" ? TimeSpan.FromMinutes(30) : TimeSpan.FromMinutes(2);
|
||||||
|
return command.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult ServeAdminAsset(IWebHostEnvironment environment, string? assetPath)
|
||||||
|
{
|
||||||
|
var path = string.IsNullOrWhiteSpace(assetPath) ? "index.html" : assetPath;
|
||||||
|
|
||||||
|
if (path.Contains("..", StringComparison.Ordinal)
|
||||||
|
|| path.Contains('\\'))
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = environment.WebRootFileProvider.GetFileInfo($"admin/{path}");
|
||||||
|
if (!file.Exists && !Path.HasExtension(path))
|
||||||
|
{
|
||||||
|
file = environment.WebRootFileProvider.GetFileInfo("admin/index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.Exists)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentTypes.TryGetContentType(file.Name, out var contentType);
|
||||||
|
return Results.Stream(file.CreateReadStream(), contentType ?? "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLocalReturnUrl(string? returnUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(returnUrl)
|
||||||
|
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
|| returnUrl.StartsWith("//", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "/admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetUserName(ClaimsPrincipal user) =>
|
||||||
|
user.FindFirst("preferred_username")?.Value
|
||||||
|
?? user.FindFirst(ClaimTypes.Name)?.Value
|
||||||
|
?? user.Identity?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record DisableClientRequest(
|
||||||
|
string? Mode,
|
||||||
|
int? DurationMinutes,
|
||||||
|
string? Reason,
|
||||||
|
DateTimeOffset? StartAtUtc,
|
||||||
|
DateTimeOffset? UntilUtc);
|
||||||
|
|
||||||
|
internal sealed record ModelActionRequest(
|
||||||
|
string ClientId,
|
||||||
|
string Model,
|
||||||
|
string Action);
|
||||||
|
|
||||||
|
internal sealed record CreateUserKeyRequest(string? Name);
|
||||||
|
|
||||||
|
internal sealed record CreateGroupRequest(string? Name);
|
||||||
|
|
||||||
|
internal sealed record UpdateGroupRequest(string Name);
|
||||||
|
|
||||||
|
internal sealed record AddGroupClientRequest(
|
||||||
|
string? ClientId,
|
||||||
|
string? Model,
|
||||||
|
string? ClientPattern,
|
||||||
|
int? KeepaliveInstancesToKeepAlive,
|
||||||
|
int? KeepaliveMaxParallelismPerClient,
|
||||||
|
int? KeepaliveParallelismHeadroom);
|
||||||
|
|
||||||
|
internal sealed record SetUserKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
|
||||||
|
|
||||||
|
internal sealed record UpdateBillingRequest(
|
||||||
|
string? Currency,
|
||||||
|
double DefaultRatePer1k,
|
||||||
|
double RefuseBelowBalance,
|
||||||
|
bool Enabled);
|
||||||
|
|
||||||
|
internal sealed record AddBillingRuleRequest(
|
||||||
|
string ModelRegex,
|
||||||
|
double RatePer1k);
|
||||||
|
|
||||||
|
internal sealed record UpdateBillingRuleRequest(
|
||||||
|
string ModelRegex,
|
||||||
|
double RatePer1k);
|
||||||
|
|
||||||
|
internal sealed record AddPaymentRequest(
|
||||||
|
double Amount,
|
||||||
|
string? Description);
|
||||||
|
|
||||||
|
internal sealed record ClientSummary(
|
||||||
|
string Id,
|
||||||
|
bool Connected,
|
||||||
|
int PendingRequests,
|
||||||
|
IReadOnlyList<string> Models,
|
||||||
|
IReadOnlyList<string> ActiveModels,
|
||||||
|
DateTimeOffset? ModelsUpdatedAt,
|
||||||
|
bool Disabled,
|
||||||
|
DateTimeOffset? DisabledFromUtc,
|
||||||
|
DateTimeOffset? DisabledUntilUtc,
|
||||||
|
bool DisabledManually,
|
||||||
|
string? DisabledReason,
|
||||||
|
ClientRequestStats RequestStats);
|
||||||
|
|
||||||
|
internal sealed record ModelSummary(
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> ListedClients,
|
||||||
|
IReadOnlyList<string> ActiveClients,
|
||||||
|
ModelUsageStats Metrics);
|
||||||
@@ -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 System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Http.Features;
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using ReverseLlama.Protocol;
|
using Ngino.Protocol;
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
namespace Ngino.Server;
|
||||||
|
|
||||||
internal sealed class EmbeddingCache
|
internal sealed class EmbeddingCache
|
||||||
{
|
{
|
||||||
@@ -121,7 +121,7 @@ internal sealed class EmbeddingCache
|
|||||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||||
context.Response.ContentType = JsonContentType;
|
context.Response.ContentType = JsonContentType;
|
||||||
context.Response.ContentLength = body.Length;
|
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);
|
await context.Response.Body.WriteAsync(body, context.RequestAborted);
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
@@ -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
|
internal sealed class PendingCommand
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using ReverseLlama.Protocol;
|
using Ngino.Protocol;
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
namespace Ngino.Server;
|
||||||
|
|
||||||
internal sealed class PendingProxyRequest
|
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,7 +1,7 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
namespace Ngino.Server;
|
||||||
|
|
||||||
internal sealed class ResponseTokenCounter
|
internal sealed class ResponseTokenCounter
|
||||||
{
|
{
|
||||||
@@ -21,15 +21,16 @@ internal sealed class ResponseTokenCounter
|
|||||||
_buffer.Write(chunk[..length]);
|
_buffer.Write(chunk[..length]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int CountTokens()
|
public TokenCounts CountTokens()
|
||||||
{
|
{
|
||||||
if (_buffer.Length == 0)
|
if (_buffer.Length == 0)
|
||||||
{
|
{
|
||||||
return 0;
|
return new TokenCounts(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload = Encoding.UTF8.GetString(_buffer.ToArray());
|
var payload = Encoding.UTF8.GetString(_buffer.ToArray());
|
||||||
var total = 0;
|
var totalPrompt = 0;
|
||||||
|
var totalCompletion = 0;
|
||||||
var parsedLines = false;
|
var parsedLines = false;
|
||||||
|
|
||||||
foreach (var rawLine in payload.Split('\n'))
|
foreach (var rawLine in payload.Split('\n'))
|
||||||
@@ -50,30 +51,37 @@ internal sealed class ResponseTokenCounter
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryExtractFromJson(line, out var lineTokens))
|
if (TryExtractTokenCountsFromJson(line, out var prompt, out var completion))
|
||||||
{
|
{
|
||||||
parsedLines = true;
|
parsedLines = true;
|
||||||
total += lineTokens;
|
totalPrompt += prompt;
|
||||||
|
totalCompletion += completion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedLines)
|
if (parsedLines)
|
||||||
{
|
{
|
||||||
return total;
|
return new TokenCounts(totalPrompt, totalCompletion, totalPrompt + totalCompletion);
|
||||||
}
|
}
|
||||||
|
|
||||||
return TryExtractFromJson(payload, out var tokens) ? tokens : 0;
|
if (TryExtractTokenCountsFromJson(payload, out var promptFallback, out var completionFallback))
|
||||||
|
{
|
||||||
|
return new TokenCounts(promptFallback, completionFallback, promptFallback + completionFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TokenCounts(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryExtractFromJson(string json, out int tokens)
|
private static bool TryExtractTokenCountsFromJson(string json, out int promptTokens, out int completionTokens)
|
||||||
{
|
{
|
||||||
tokens = 0;
|
promptTokens = 0;
|
||||||
|
completionTokens = 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var document = JsonDocument.Parse(json);
|
using var document = JsonDocument.Parse(json);
|
||||||
tokens = ExtractTokens(document.RootElement);
|
ExtractTokenCounts(document.RootElement, out promptTokens, out completionTokens);
|
||||||
return tokens > 0;
|
return promptTokens > 0 || completionTokens > 0;
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -81,70 +89,68 @@ internal sealed class ResponseTokenCounter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ExtractTokens(JsonElement element)
|
private static void ExtractTokenCounts(JsonElement element, out int promptTokens, out int completionTokens)
|
||||||
{
|
{
|
||||||
|
promptTokens = 0;
|
||||||
|
completionTokens = 0;
|
||||||
|
|
||||||
if (element.ValueKind == JsonValueKind.Array)
|
if (element.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
var total = 0;
|
|
||||||
foreach (var item in element.EnumerateArray())
|
foreach (var item in element.EnumerateArray())
|
||||||
{
|
{
|
||||||
total += ExtractTokens(item);
|
ExtractTokenCounts(item, out var itemPrompt, out var itemCompletion);
|
||||||
|
promptTokens += itemPrompt;
|
||||||
|
completionTokens += itemCompletion;
|
||||||
}
|
}
|
||||||
|
|
||||||
return total;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.ValueKind != JsonValueKind.Object)
|
if (element.ValueKind != JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
return 0;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
|
if (element.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
if (TryGetInt(usage, "total_tokens", out var totalTokens))
|
if (TryGetInt(usage, "prompt_tokens", out var pt))
|
||||||
{
|
{
|
||||||
return totalTokens;
|
promptTokens += pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
var usageTotal = 0;
|
if (TryGetInt(usage, "completion_tokens", out var ct))
|
||||||
if (TryGetInt(usage, "prompt_tokens", out var promptTokens))
|
|
||||||
{
|
{
|
||||||
usageTotal += promptTokens;
|
completionTokens += ct;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryGetInt(usage, "completion_tokens", out var completionTokens))
|
if (promptTokens == 0 && completionTokens == 0)
|
||||||
{
|
{
|
||||||
usageTotal += completionTokens;
|
if (TryGetInt(usage, "input_tokens", out var it))
|
||||||
|
{
|
||||||
|
promptTokens += it;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetInt(usage, "output_tokens", out var ot))
|
||||||
|
{
|
||||||
|
completionTokens += ot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryGetInt(usage, "input_tokens", out var inputTokens))
|
if (promptTokens > 0 || completionTokens > 0)
|
||||||
{
|
{
|
||||||
usageTotal += inputTokens;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
if (TryGetInt(usage, "output_tokens", out var outputTokens))
|
|
||||||
{
|
|
||||||
usageTotal += outputTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usageTotal > 0)
|
|
||||||
{
|
|
||||||
return usageTotal;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ollamaTotal = 0;
|
if (TryGetInt(element, "prompt_eval_count", out var promptEval))
|
||||||
if (TryGetInt(element, "prompt_eval_count", out var promptEvalCount))
|
|
||||||
{
|
{
|
||||||
ollamaTotal += promptEvalCount;
|
promptTokens += promptEval;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryGetInt(element, "eval_count", out var evalCount))
|
if (TryGetInt(element, "eval_count", out var eval))
|
||||||
{
|
{
|
||||||
ollamaTotal += evalCount;
|
completionTokens += eval;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ollamaTotal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetInt(JsonElement element, string propertyName, out int value)
|
private static bool TryGetInt(JsonElement element, string propertyName, out int value)
|
||||||
@@ -155,3 +161,5 @@ internal sealed class ResponseTokenCounter
|
|||||||
&& property.TryGetInt32(out value);
|
&& property.TryGetInt32(out value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record TokenCounts(int PromptTokens, int CompletionTokens, int TotalTokens);
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using ElmahCore;
|
||||||
using Microsoft.AspNetCore.Http.Features;
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
using ReverseLlama.Protocol;
|
using Ngino.Protocol;
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
namespace Ngino.Server;
|
||||||
|
|
||||||
internal static class ReverseProxyEndpoint
|
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)
|
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@@ -35,13 +38,30 @@ internal static class ReverseProxyEndpoint
|
|||||||
EmbeddingCache embeddingCache,
|
EmbeddingCache embeddingCache,
|
||||||
ManagementStore managementStore)
|
ManagementStore managementStore)
|
||||||
{
|
{
|
||||||
if (!TokenAuthentication.IsAuthorized(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true))
|
var auth = TokenAuthentication.Authorize(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true);
|
||||||
|
if (!auth.IsAuthorized)
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
|
||||||
|
if (!billingCheck.Allowed)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
|
||||||
|
await context.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
error = "Insufficient balance.",
|
||||||
|
balance = billingCheck.Balance,
|
||||||
|
currency = billingCheck.Currency,
|
||||||
|
threshold = billingCheck.Threshold
|
||||||
|
}, context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
|
||||||
|
|
||||||
var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
|
var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
|
||||||
if (!pathTokenRemoved)
|
if (!pathTokenRemoved)
|
||||||
{
|
{
|
||||||
@@ -54,8 +74,21 @@ internal static class ReverseProxyEndpoint
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsVersionRequest(context.Request, proxyPath))
|
||||||
|
{
|
||||||
|
await WriteVersionResponseAsync(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (TryGetClientAddress(proxyPath, out var pathClientId, out var clientPath))
|
if (TryGetClientAddress(proxyPath, out var pathClientId, out var clientPath))
|
||||||
{
|
{
|
||||||
|
if (!groupAccess.IsClientAllowed(pathClientId))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await context.Response.WriteAsync($"Access to client '{pathClientId}' is not permitted.", context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ForwardToClientAsync(
|
await ForwardToClientAsync(
|
||||||
context,
|
context,
|
||||||
pathClientId,
|
pathClientId,
|
||||||
@@ -65,7 +98,15 @@ internal static class ReverseProxyEndpoint
|
|||||||
settings,
|
settings,
|
||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
managementStore);
|
managementStore,
|
||||||
|
groupAccess,
|
||||||
|
auth.UserKeyId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTagsRequest(context.Request, proxyPath))
|
||||||
|
{
|
||||||
|
await HandleTagsAsync(context, hub, managementStore, groupAccess);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +120,10 @@ internal static class ReverseProxyEndpoint
|
|||||||
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, proxyPath);
|
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, proxyPath);
|
||||||
var connection = hub.SelectBest(
|
var connection = hub.SelectBest(
|
||||||
requestedModel,
|
requestedModel,
|
||||||
clientId => !managementStore.GetClientAccess(clientId).IsDisabled);
|
clientId => !managementStore.GetClientAccess(clientId).IsDisabled
|
||||||
|
&& (requestedModel is null
|
||||||
|
? groupAccess.IsClientAllowed(clientId)
|
||||||
|
: groupAccess.IsClientModelAllowed(clientId, requestedModel)));
|
||||||
if (connection is null)
|
if (connection is null)
|
||||||
{
|
{
|
||||||
if (!hub.HasClient)
|
if (!hub.HasClient)
|
||||||
@@ -104,7 +148,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
embeddingRequest,
|
embeddingRequest,
|
||||||
managementStore);
|
managementStore,
|
||||||
|
auth.UserKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task HandleClientAsync(
|
public static async Task HandleClientAsync(
|
||||||
@@ -117,13 +162,36 @@ internal static class ReverseProxyEndpoint
|
|||||||
EmbeddingCache embeddingCache,
|
EmbeddingCache embeddingCache,
|
||||||
ManagementStore managementStore)
|
ManagementStore managementStore)
|
||||||
{
|
{
|
||||||
if (!TokenAuthentication.IsAuthorized(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true))
|
var auth = TokenAuthentication.Authorize(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true);
|
||||||
|
if (!auth.IsAuthorized)
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
|
||||||
|
if (!billingCheck.Allowed)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
|
||||||
|
await context.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
error = "Insufficient balance.",
|
||||||
|
balance = billingCheck.Balance,
|
||||||
|
currency = billingCheck.Currency,
|
||||||
|
threshold = billingCheck.Threshold
|
||||||
|
}, context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
|
||||||
|
if (!groupAccess.IsClientAllowed(clientId))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await context.Response.WriteAsync($"Access to client '{clientId}' is not permitted.", context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var pathAndQuery = $"/{path}{context.Request.QueryString}";
|
var pathAndQuery = $"/{path}{context.Request.QueryString}";
|
||||||
var clientPath = new PathString($"/{path}");
|
var clientPath = new PathString($"/{path}");
|
||||||
await ForwardToClientAsync(
|
await ForwardToClientAsync(
|
||||||
@@ -135,7 +203,9 @@ internal static class ReverseProxyEndpoint
|
|||||||
settings,
|
settings,
|
||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
managementStore);
|
managementStore,
|
||||||
|
groupAccess,
|
||||||
|
auth.UserKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ForwardToClientAsync(
|
private static async Task ForwardToClientAsync(
|
||||||
@@ -147,7 +217,9 @@ internal static class ReverseProxyEndpoint
|
|||||||
ServerSettings settings,
|
ServerSettings settings,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
EmbeddingCache embeddingCache,
|
EmbeddingCache embeddingCache,
|
||||||
ManagementStore managementStore)
|
ManagementStore managementStore,
|
||||||
|
GroupAccess? groupAccess = null,
|
||||||
|
string? userKeyId = null)
|
||||||
{
|
{
|
||||||
var clientAccess = managementStore.GetClientAccess(clientId);
|
var clientAccess = managementStore.GetClientAccess(clientId);
|
||||||
if (clientAccess.IsDisabled)
|
if (clientAccess.IsDisabled)
|
||||||
@@ -173,6 +245,13 @@ internal static class ReverseProxyEndpoint
|
|||||||
}
|
}
|
||||||
|
|
||||||
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, clientPath);
|
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, clientPath);
|
||||||
|
if (requestedModel is not null && groupAccess is not null && !groupAccess.IsClientModelAllowed(clientId, requestedModel))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await context.Response.WriteAsync($"Access to model '{requestedModel}' on client '{clientId}' is not permitted.", context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ForwardAsync(
|
await ForwardAsync(
|
||||||
context,
|
context,
|
||||||
connection,
|
connection,
|
||||||
@@ -182,7 +261,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
embeddingRequest,
|
embeddingRequest,
|
||||||
managementStore);
|
managementStore,
|
||||||
|
userKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsRootPath(PathString path) =>
|
private static bool IsRootPath(PathString path) =>
|
||||||
@@ -231,6 +311,78 @@ internal static class ReverseProxyEndpoint
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static GroupAccess ResolveGroupAccess(string? userKeyId, ManagementStore managementStore)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(userKeyId))
|
||||||
|
{
|
||||||
|
return GroupAccess.Unrestricted;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
ManagementStore managementStore,
|
||||||
|
GroupAccess groupAccess)
|
||||||
|
{
|
||||||
|
var models = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var client in hub.ClientSnapshots)
|
||||||
|
{
|
||||||
|
foreach (var model in client.Models)
|
||||||
|
{
|
||||||
|
if (!models.TryGetValue(model, out var clients))
|
||||||
|
{
|
||||||
|
clients = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
models[model] = clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.Add(client.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredModels = new List<object>();
|
||||||
|
foreach (var (model, clients) in models.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var accessibleClients = clients
|
||||||
|
.Where(clientId => groupAccess.IsClientModelAllowed(clientId, model))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (accessibleClients.Count > 0)
|
||||||
|
{
|
||||||
|
filteredModels.Add(new
|
||||||
|
{
|
||||||
|
name = model,
|
||||||
|
model,
|
||||||
|
modified_at = DateTimeOffset.UtcNow,
|
||||||
|
size = 0,
|
||||||
|
digest = "",
|
||||||
|
details = new { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = new { models = filteredModels };
|
||||||
|
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
await context.Response.WriteAsJsonAsync(response, context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetNoRouteMessage(string? requestedModel) =>
|
private static string GetNoRouteMessage(string? requestedModel) =>
|
||||||
string.IsNullOrWhiteSpace(requestedModel)
|
string.IsNullOrWhiteSpace(requestedModel)
|
||||||
? "No tunnel client is available for this request."
|
? "No tunnel client is available for this request."
|
||||||
@@ -238,14 +390,26 @@ internal static class ReverseProxyEndpoint
|
|||||||
|
|
||||||
private static string GetClientDisabledMessage(string clientId, ClientAccess access)
|
private static string GetClientDisabledMessage(string clientId, ClientAccess access)
|
||||||
{
|
{
|
||||||
|
var reason = string.IsNullOrWhiteSpace(access.DisabledReason)
|
||||||
|
? ""
|
||||||
|
: $" Reason: {access.DisabledReason.Trim()}.";
|
||||||
|
|
||||||
if (access.DisabledManually)
|
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
|
if (access.DisabledUntilUtc is { } disabledUntil)
|
||||||
? $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}."
|
{
|
||||||
: $"Tunnel client '{clientId}' is disabled.";
|
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)
|
private static async Task<string?> GetRequestedModelAsync(HttpRequest request, PathString proxyPath)
|
||||||
@@ -363,9 +527,10 @@ internal static class ReverseProxyEndpoint
|
|||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
EmbeddingCache embeddingCache,
|
EmbeddingCache embeddingCache,
|
||||||
EmbeddingCacheRequest? embeddingRequest,
|
EmbeddingCacheRequest? embeddingRequest,
|
||||||
ManagementStore managementStore)
|
ManagementStore managementStore,
|
||||||
|
string? userKeyId = null)
|
||||||
{
|
{
|
||||||
var logger = loggerFactory.CreateLogger("ReverseLlama.Server.ReverseProxy");
|
var logger = loggerFactory.CreateLogger("Ngino.Server.ReverseProxy");
|
||||||
var requestId = Guid.NewGuid().ToString("n");
|
var requestId = Guid.NewGuid().ToString("n");
|
||||||
var pending = connection.RegisterPending(requestId);
|
var pending = connection.RegisterPending(requestId);
|
||||||
var startedAt = DateTimeOffset.UtcNow;
|
var startedAt = DateTimeOffset.UtcNow;
|
||||||
@@ -387,6 +552,12 @@ internal static class ReverseProxyEndpoint
|
|||||||
Headers = CollectRequestHeaders(context.Request, settings, managementStore)
|
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);
|
await connection.SendAsync(requestMessage, context.RequestAborted);
|
||||||
requestBodyTask = ForwardRequestBodyAsync(context.Request, connection, requestId, hasBody, settings, logger);
|
requestBodyTask = ForwardRequestBodyAsync(context.Request, connection, requestId, hasBody, settings, logger);
|
||||||
_ = requestBodyTask.ContinueWith(
|
_ = requestBodyTask.ContinueWith(
|
||||||
@@ -434,10 +605,16 @@ internal static class ReverseProxyEndpoint
|
|||||||
{
|
{
|
||||||
logger.LogWarning(exception, "Proxy request {RequestId} failed.", requestId);
|
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)
|
if (!context.Response.HasStarted)
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||||
await context.Response.WriteAsync(exception.Message, CancellationToken.None);
|
await context.Response.WriteAsync("Bad gateway", CancellationToken.None);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -448,13 +625,29 @@ internal static class ReverseProxyEndpoint
|
|||||||
{
|
{
|
||||||
connection.RemovePending(requestId);
|
connection.RemovePending(requestId);
|
||||||
var completedAt = DateTimeOffset.UtcNow;
|
var completedAt = DateTimeOffset.UtcNow;
|
||||||
|
var tokenCounts = tokenCounter.CountTokens();
|
||||||
|
|
||||||
|
var cost = 0.0;
|
||||||
|
if (!string.IsNullOrWhiteSpace(userKeyId) && tokenCounts.TotalTokens > 0)
|
||||||
|
{
|
||||||
|
var billing = managementStore.ResolveBillingForUserKey(userKeyId);
|
||||||
|
if (billing is not null)
|
||||||
|
{
|
||||||
|
cost = managementStore.CalculateCost(billing.GroupId, requestedModel, tokenCounts.TotalTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
managementStore.RecordRequest(new RequestMetric(
|
managementStore.RecordRequest(new RequestMetric(
|
||||||
connection.ClientId,
|
connection.ClientId,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
context.Request.Method,
|
context.Request.Method,
|
||||||
pathAndQuery,
|
pathAndQuery,
|
||||||
statusCode ?? (context.Response.HasStarted ? context.Response.StatusCode : null),
|
statusCode ?? (context.Response.HasStarted ? context.Response.StatusCode : null),
|
||||||
tokenCounter.CountTokens(),
|
tokenCounts.PromptTokens,
|
||||||
|
tokenCounts.CompletionTokens,
|
||||||
|
tokenCounts.TotalTokens,
|
||||||
|
userKeyId,
|
||||||
|
cost,
|
||||||
startedAt,
|
startedAt,
|
||||||
completedAt,
|
completedAt,
|
||||||
completedAt - startedAt));
|
completedAt - startedAt));
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using ReverseLlama.Protocol;
|
using Ngino.Protocol;
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
namespace Ngino.Server;
|
||||||
|
|
||||||
internal sealed class ServerSettings
|
internal sealed class ServerSettings
|
||||||
{
|
{
|
||||||
@@ -11,42 +11,57 @@ internal sealed class ServerSettings
|
|||||||
|
|
||||||
public string? Token { get; init; }
|
public string? Token { get; init; }
|
||||||
|
|
||||||
|
public string? ClientToken { get; init; }
|
||||||
|
|
||||||
public int ChunkSize { get; init; } = 64 * 1024;
|
public int ChunkSize { get; init; } = 64 * 1024;
|
||||||
|
|
||||||
public string? EmbeddingCachePath { get; init; }
|
public string? EmbeddingCachePath { get; init; }
|
||||||
|
|
||||||
public string? ManagementDatabasePath { get; init; }
|
public string? ManagementDatabasePath { get; init; }
|
||||||
|
|
||||||
|
public bool SecureCookies { get; init; } = true;
|
||||||
|
|
||||||
public KeycloakSettings Keycloak { get; init; } = new();
|
public KeycloakSettings Keycloak { get; init; } = new();
|
||||||
|
|
||||||
|
public CorsSettings Cors { get; init; } = new();
|
||||||
|
|
||||||
public static ServerSettings FromConfiguration(IConfiguration configuration)
|
public static ServerSettings FromConfiguration(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
return new ServerSettings
|
return new ServerSettings
|
||||||
{
|
{
|
||||||
StatusPath = NormalizePath(Read(configuration, "ReverseLlama:StatusPath", "status-path") ?? ProtocolConstants.DefaultStatusPath),
|
StatusPath = NormalizePath(Read(configuration, "Ngino:StatusPath", "status-path") ?? ProtocolConstants.DefaultStatusPath),
|
||||||
TunnelPath = NormalizePath(Read(configuration, "ReverseLlama:TunnelPath", "tunnel-path") ?? ProtocolConstants.DefaultTunnelPath),
|
TunnelPath = NormalizePath(Read(configuration, "Ngino:TunnelPath", "tunnel-path") ?? ProtocolConstants.DefaultTunnelPath),
|
||||||
Token = Read(configuration, "ReverseLlama:Token", "token") ?? Environment.GetEnvironmentVariable("REVERSE_LLAMA_TOKEN"),
|
Token = Read(configuration, "Ngino:Token", "token") ?? Environment.GetEnvironmentVariable("NGINO_TOKEN"),
|
||||||
ChunkSize = ReadInt(configuration, 64 * 1024, "ReverseLlama:ChunkSize", "chunk-size", "REVERSE_LLAMA_CHUNK_SIZE"),
|
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(
|
EmbeddingCachePath = Read(
|
||||||
configuration,
|
configuration,
|
||||||
"ReverseLlama:EmbeddingCachePath",
|
"Ngino:EmbeddingCachePath",
|
||||||
"embedding-cache-path",
|
"embedding-cache-path",
|
||||||
"REVERSE_LLAMA_EMBEDDING_CACHE_PATH"),
|
"NGINO_EMBEDDING_CACHE_PATH"),
|
||||||
ManagementDatabasePath = Read(
|
ManagementDatabasePath = Read(
|
||||||
configuration,
|
configuration,
|
||||||
"ReverseLlama:ManagementDatabasePath",
|
"Ngino:ManagementDatabasePath",
|
||||||
"management-database-path",
|
"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
|
Keycloak = new KeycloakSettings
|
||||||
{
|
{
|
||||||
Authority = Read(configuration, "Authentication:Keycloak:Authority", "REVERSE_LLAMA_KEYCLOAK_AUTHORITY"),
|
Authority = Read(configuration, "Authentication:Keycloak:Authority", "NGINO_KEYCLOAK_AUTHORITY"),
|
||||||
ClientId = Read(configuration, "Authentication:Keycloak:ClientId", "REVERSE_LLAMA_KEYCLOAK_CLIENT_ID"),
|
ClientId = Read(configuration, "Authentication:Keycloak:ClientId", "NGINO_KEYCLOAK_CLIENT_ID"),
|
||||||
ClientSecret = Read(configuration, "Authentication:Keycloak:ClientSecret", "REVERSE_LLAMA_KEYCLOAK_CLIENT_SECRET"),
|
ClientSecret = Read(configuration, "Authentication:Keycloak:ClientSecret", "NGINO_KEYCLOAK_CLIENT_SECRET"),
|
||||||
RequireHttpsMetadata = ReadBool(
|
RequireHttpsMetadata = ReadBool(
|
||||||
configuration,
|
configuration,
|
||||||
true,
|
true,
|
||||||
"Authentication:Keycloak:RequireHttpsMetadata",
|
"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;
|
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) =>
|
private static string NormalizePath(string path) =>
|
||||||
path.StartsWith('/') ? path : $"/{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
|
internal sealed class KeycloakSettings
|
||||||
{
|
{
|
||||||
public string? Authority { get; init; }
|
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.Collections.Concurrent;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using ReverseLlama.Protocol;
|
using Ngino.Protocol;
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
namespace Ngino.Server;
|
||||||
|
|
||||||
internal sealed class TunnelConnection
|
internal sealed class TunnelConnection
|
||||||
{
|
{
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using ReverseLlama.Protocol;
|
using System.Threading;
|
||||||
|
using Ngino.Protocol;
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
namespace Ngino.Server;
|
||||||
|
|
||||||
internal sealed class TunnelHub
|
internal sealed class TunnelHub
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, TunnelConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, TunnelConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly ILogger<TunnelHub> _logger;
|
private readonly ILogger<TunnelHub> _logger;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
private long _roundRobinCounter;
|
||||||
|
|
||||||
public TunnelHub(ILogger<TunnelHub> logger, ILoggerFactory loggerFactory)
|
public TunnelHub(ILogger<TunnelHub> logger, ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
@@ -40,17 +42,23 @@ internal sealed class TunnelHub
|
|||||||
var withModel = allOpen.Where(connection => connection.HasModel(model)).ToList();
|
var withModel = allOpen.Where(connection => connection.HasModel(model)).ToList();
|
||||||
if (withModel.Count > 0)
|
if (withModel.Count > 0)
|
||||||
{
|
{
|
||||||
return withModel
|
return PickBest(withModel);
|
||||||
.OrderBy(connection => connection.PendingRequestCount)
|
|
||||||
.ThenBy(connection => connection.ClientId, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.First();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allOpen
|
return PickBest(allOpen);
|
||||||
.OrderBy(connection => connection.PendingRequestCount)
|
}
|
||||||
.ThenBy(connection => connection.ClientId, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.First();
|
private TunnelConnection PickBest(List<TunnelConnection> candidates)
|
||||||
|
{
|
||||||
|
var tick = (int)(Interlocked.Increment(ref _roundRobinCounter) & 0x7FFFFFFF);
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.Select((connection, index) => (connection, index))
|
||||||
|
.OrderBy(x => x.connection.PendingRequestCount)
|
||||||
|
.ThenBy(x => (tick + x.index) % candidates.Count)
|
||||||
|
.First()
|
||||||
|
.connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The only open connection, or null when zero or more than one client is connected.</summary>
|
/// <summary>The only open connection, or null when zero or more than one client is connected.</summary>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Keycloak": {
|
"Keycloak": {
|
||||||
"Authority": "http://your-keycloak-server/realms/master",
|
"Authority": "http://your-keycloak-server/realms/master",
|
||||||
"ClientId": "ReverseLlama",
|
"ClientId": "Ngino",
|
||||||
"ClientSecret": "YOUR-Client-SECRET-GOES-HERE-AND-YES-ITS-VERY-LONG",
|
"ClientSecret": "YOUR-Client-SECRET-GOES-HERE-AND-YES-ITS-VERY-LONG",
|
||||||
"RequireHttpsMetadata": false
|
"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": "*"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 56 KiB |
@@ -58,16 +58,12 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border: 1px solid #4c5d74;
|
border: 1px solid #4c5d74;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #223044;
|
background: #223044;
|
||||||
color: #c8f2df;
|
object-fit: cover;
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand strong,
|
.brand strong,
|
||||||
@@ -167,11 +163,48 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field label {
|
.field label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
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,
|
.input,
|
||||||
.select,
|
.select,
|
||||||
.textarea {
|
.textarea {
|
||||||
@@ -281,7 +314,8 @@ textarea {
|
|||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
min-width: 760px;
|
table-layout: fixed;
|
||||||
|
min-width: 900px;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
@@ -292,6 +326,11 @@ td {
|
|||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.col-models {
|
||||||
|
width: 40%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -338,7 +377,7 @@ tr:last-child td {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
overflow-wrap: anywhere;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.good {
|
.badge.good {
|
||||||
@@ -359,6 +398,17 @@ tr:last-child td {
|
|||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge[href] {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[href]:hover {
|
||||||
|
border-color: var(--blue);
|
||||||
|
background: #edf4ff;
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||||
@@ -440,6 +490,65 @@ tr:last-child td {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-link {
|
||||||
|
color: var(--blue);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 800;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-inline {
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--panel-alt);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
display: inline-block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-current strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-current span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-detail {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -455,7 +564,7 @@ tr:last-child td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
@@ -479,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) {
|
@media (max-width: 520px) {
|
||||||
.nav {
|
.nav {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -3,23 +3,26 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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">
|
<link rel="stylesheet" href="/admin/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-mark">RL</span>
|
<img class="brand-mark" src="/admin/Ngino_logo_symbol.png" alt="Ngino">
|
||||||
<span>
|
<span>
|
||||||
<strong>ReverseLlama</strong>
|
<strong>Ngino</strong>
|
||||||
<small>Admin</small>
|
<small>Admin</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav" aria-label="Admin sections">
|
<nav class="nav" aria-label="Admin sections">
|
||||||
<a href="#clients" data-nav="clients">Clients</a>
|
<a href="#clients" data-nav="clients">Clients</a>
|
||||||
<a href="#models" data-nav="models">Models</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>
|
</nav>
|
||||||
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
|
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -43,6 +46,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/admin/morphdom-umd.js"></script>
|
||||||
<script src="/admin/app.js" defer></script>
|
<script src="/admin/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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;
|
||||||
|
|
||||||
|
}));
|
||||||
|
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,490 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
|
||||||
using Microsoft.AspNetCore.StaticFiles;
|
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
|
||||||
|
|
||||||
internal static class AdminEndpoints
|
|
||||||
{
|
|
||||||
private static readonly FileExtensionContentTypeProvider ContentTypes = new();
|
|
||||||
|
|
||||||
public static void MapAdminEndpoints(this WebApplication app, ServerSettings settings)
|
|
||||||
{
|
|
||||||
if (settings.Keycloak.IsConfigured)
|
|
||||||
{
|
|
||||||
app.MapGet("/admin/login", (string? returnUrl) =>
|
|
||||||
Results.Challenge(
|
|
||||||
new AuthenticationProperties { RedirectUri = NormalizeLocalReturnUrl(returnUrl) },
|
|
||||||
[OpenIdConnectDefaults.AuthenticationScheme]))
|
|
||||||
.AllowAnonymous();
|
|
||||||
|
|
||||||
app.MapPost("/admin/logout", () =>
|
|
||||||
Results.SignOut(
|
|
||||||
new AuthenticationProperties { RedirectUri = "/admin" },
|
|
||||||
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]))
|
|
||||||
.RequireAuthorization();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.MapGet("/admin/auth-error", () =>
|
|
||||||
Results.Text(
|
|
||||||
"Login failed while processing the Keycloak callback. The exception was written to ELMAH.",
|
|
||||||
"text/plain"))
|
|
||||||
.AllowAnonymous();
|
|
||||||
|
|
||||||
var api = app.MapGroup("/api/admin");
|
|
||||||
|
|
||||||
if (settings.Keycloak.IsConfigured)
|
|
||||||
{
|
|
||||||
api.RequireAuthorization();
|
|
||||||
}
|
|
||||||
|
|
||||||
api.MapGet("/summary", (HttpContext context, TunnelHub hub, ManagementStore store) =>
|
|
||||||
Results.Json(BuildSummary(context.User, hub, store, settings)));
|
|
||||||
|
|
||||||
api.MapGet("/me", (HttpContext context, ManagementStore store) =>
|
|
||||||
Results.Json(new
|
|
||||||
{
|
|
||||||
authenticated = context.User.Identity?.IsAuthenticated ?? false,
|
|
||||||
name = GetUserName(context.User),
|
|
||||||
keycloakConfigured = settings.Keycloak.IsConfigured,
|
|
||||||
management = new
|
|
||||||
{
|
|
||||||
available = store.IsAvailable,
|
|
||||||
databasePath = store.DatabasePath,
|
|
||||||
lastError = store.LastError
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
api.MapPost("/clients/{clientId}/disable", (string clientId, DisableClientRequest request, ManagementStore store) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var manual = string.Equals(request.Mode, "manual", StringComparison.OrdinalIgnoreCase);
|
|
||||||
TimeSpan? duration = manual
|
|
||||||
? null
|
|
||||||
: TimeSpan.FromMinutes(Math.Clamp(request.DurationMinutes ?? 60, 1, 24 * 60));
|
|
||||||
|
|
||||||
store.DisableClient(clientId, duration, manual, request.Reason);
|
|
||||||
return Results.Ok(new { clientId, disabled = true });
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
return Results.Problem(exception.Message, statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
api.MapPost("/clients/{clientId}/enable", (string clientId, ManagementStore store) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
store.EnableClient(clientId);
|
|
||||||
return Results.Ok(new { clientId, disabled = false });
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
return Results.Problem(exception.Message, statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
api.MapGet("/models/detail", async (
|
|
||||||
HttpContext context,
|
|
||||||
string model,
|
|
||||||
string? clientId,
|
|
||||||
TunnelHub hub,
|
|
||||||
ManagementStore store) =>
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(model))
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new { error = "Model is required." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var modelSummary = BuildModelSummaries(hub, store)
|
|
||||||
.FirstOrDefault(item => item.Name.Equals(model, StringComparison.OrdinalIgnoreCase));
|
|
||||||
var selectedClientId = ResolveModelClientId(hub, modelSummary, model, clientId);
|
|
||||||
object? show = null;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(selectedClientId))
|
|
||||||
{
|
|
||||||
var connection = hub.Get(selectedClientId);
|
|
||||||
if (connection is not null)
|
|
||||||
{
|
|
||||||
show = await SendModelCommandForApiAsync(
|
|
||||||
connection,
|
|
||||||
"show",
|
|
||||||
model,
|
|
||||||
TimeSpan.FromSeconds(60),
|
|
||||||
context.RequestAborted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Json(new
|
|
||||||
{
|
|
||||||
model,
|
|
||||||
listedClients = modelSummary?.ListedClients ?? [],
|
|
||||||
activeClients = modelSummary?.ActiveClients ?? [],
|
|
||||||
metrics = modelSummary?.Metrics ?? EmptyModelMetrics(),
|
|
||||||
selectedClientId,
|
|
||||||
show
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
api.MapPost("/models/actions", async (
|
|
||||||
HttpContext context,
|
|
||||||
ModelActionRequest request,
|
|
||||||
TunnelHub hub) =>
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.ClientId)
|
|
||||||
|| string.IsNullOrWhiteSpace(request.Model)
|
|
||||||
|| string.IsNullOrWhiteSpace(request.Action))
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new { error = "Client id, model, and action are required." });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!TryMapModelAction(request.Action, out var command, out var timeout))
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new { error = $"Unsupported action '{request.Action}'." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var connection = hub.Get(request.ClientId);
|
|
||||||
if (connection is null)
|
|
||||||
{
|
|
||||||
return Results.NotFound(new { error = $"Client '{request.ClientId}' is not connected." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await SendModelCommandForApiAsync(
|
|
||||||
connection,
|
|
||||||
command,
|
|
||||||
request.Model,
|
|
||||||
timeout,
|
|
||||||
context.RequestAborted);
|
|
||||||
|
|
||||||
return Results.Json(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
api.MapGet("/api-keys", (ManagementStore store) =>
|
|
||||||
Results.Json(store.ListApiKeys()));
|
|
||||||
|
|
||||||
api.MapPost("/api-keys", (CreateApiKeyRequest request, ManagementStore store) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Results.Json(store.CreateApiKey(request.Name));
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
return Results.Problem(exception.Message, statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
api.MapDelete("/api-keys/{id}", (string id, ManagementStore store) =>
|
|
||||||
store.DeleteApiKey(id)
|
|
||||||
? Results.NoContent()
|
|
||||||
: Results.NotFound(new { error = $"API key '{id}' was not found." }));
|
|
||||||
|
|
||||||
var adminHome = app.MapGet("/admin", (IWebHostEnvironment environment) =>
|
|
||||||
ServeAdminAsset(environment, null));
|
|
||||||
var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) =>
|
|
||||||
ServeAdminAsset(environment, assetPath));
|
|
||||||
|
|
||||||
if (settings.Keycloak.IsConfigured)
|
|
||||||
{
|
|
||||||
adminHome.RequireAuthorization();
|
|
||||||
adminAssets.RequireAuthorization();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object BuildSummary(
|
|
||||||
ClaimsPrincipal user,
|
|
||||||
TunnelHub hub,
|
|
||||||
ManagementStore store,
|
|
||||||
ServerSettings settings) =>
|
|
||||||
new
|
|
||||||
{
|
|
||||||
generatedAtUtc = DateTimeOffset.UtcNow,
|
|
||||||
user = new
|
|
||||||
{
|
|
||||||
name = GetUserName(user),
|
|
||||||
authenticated = user.Identity?.IsAuthenticated ?? false
|
|
||||||
},
|
|
||||||
auth = new
|
|
||||||
{
|
|
||||||
keycloakConfigured = settings.Keycloak.IsConfigured,
|
|
||||||
sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token),
|
|
||||||
apiKeysConfigured = store.HasApiKeys
|
|
||||||
},
|
|
||||||
management = new
|
|
||||||
{
|
|
||||||
available = store.IsAvailable,
|
|
||||||
databasePath = store.DatabasePath,
|
|
||||||
lastError = store.LastError
|
|
||||||
},
|
|
||||||
clients = BuildClientSummaries(hub, store),
|
|
||||||
models = BuildModelSummaries(hub, store),
|
|
||||||
apiKeys = store.ListApiKeys()
|
|
||||||
};
|
|
||||||
|
|
||||||
private static IReadOnlyList<ClientSummary> BuildClientSummaries(TunnelHub hub, ManagementStore store)
|
|
||||||
{
|
|
||||||
var connected = hub.ClientSnapshots.ToDictionary(client => client.Id, StringComparer.OrdinalIgnoreCase);
|
|
||||||
var controls = store.ListClientControls();
|
|
||||||
var stats = store.GetClientRequestStats();
|
|
||||||
var clientIds = connected.Keys
|
|
||||||
.Concat(controls.Keys)
|
|
||||||
.Concat(stats.Keys)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.OrderBy(clientId => clientId, StringComparer.OrdinalIgnoreCase);
|
|
||||||
var result = new List<ClientSummary>();
|
|
||||||
|
|
||||||
foreach (var clientId in clientIds)
|
|
||||||
{
|
|
||||||
connected.TryGetValue(clientId, out var snapshot);
|
|
||||||
controls.TryGetValue(clientId, out var access);
|
|
||||||
stats.TryGetValue(clientId, out var requestStats);
|
|
||||||
access ??= ClientAccess.Enabled;
|
|
||||||
|
|
||||||
result.Add(new ClientSummary(
|
|
||||||
clientId,
|
|
||||||
snapshot is not null,
|
|
||||||
snapshot?.PendingRequests ?? 0,
|
|
||||||
snapshot?.Models ?? [],
|
|
||||||
snapshot?.ActiveModels ?? [],
|
|
||||||
snapshot?.ModelsUpdatedAt,
|
|
||||||
access.IsDisabled,
|
|
||||||
access.DisabledUntilUtc,
|
|
||||||
access.DisabledManually,
|
|
||||||
access.DisabledReason,
|
|
||||||
requestStats ?? new ClientRequestStats(0, 0, 0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<ModelSummary> BuildModelSummaries(TunnelHub hub, ManagementStore store)
|
|
||||||
{
|
|
||||||
var listedClients = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
var activeClients = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
foreach (var client in hub.ClientSnapshots)
|
|
||||||
{
|
|
||||||
AddModelClients(listedClients, client.Models, client.Id);
|
|
||||||
AddModelClients(activeClients, client.ActiveModels, client.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
var metrics = store.GetModelUsageStats();
|
|
||||||
var modelNames = listedClients.Keys
|
|
||||||
.Concat(activeClients.Keys)
|
|
||||||
.Concat(metrics.Keys)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.OrderBy(model => model, StringComparer.OrdinalIgnoreCase);
|
|
||||||
var result = new List<ModelSummary>();
|
|
||||||
|
|
||||||
foreach (var model in modelNames)
|
|
||||||
{
|
|
||||||
metrics.TryGetValue(model, out var modelMetrics);
|
|
||||||
|
|
||||||
result.Add(new ModelSummary(
|
|
||||||
model,
|
|
||||||
listedClients.TryGetValue(model, out var listed) ? listed.ToArray() : [],
|
|
||||||
activeClients.TryGetValue(model, out var active) ? active.ToArray() : [],
|
|
||||||
modelMetrics ?? EmptyModelMetrics()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddModelClients(
|
|
||||||
Dictionary<string, SortedSet<string>> target,
|
|
||||||
IEnumerable<string> models,
|
|
||||||
string clientId)
|
|
||||||
{
|
|
||||||
foreach (var model in models)
|
|
||||||
{
|
|
||||||
if (!target.TryGetValue(model, out var clients))
|
|
||||||
{
|
|
||||||
clients = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
target[model] = clients;
|
|
||||||
}
|
|
||||||
|
|
||||||
clients.Add(clientId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ResolveModelClientId(
|
|
||||||
TunnelHub hub,
|
|
||||||
ModelSummary? modelSummary,
|
|
||||||
string model,
|
|
||||||
string? requestedClientId)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(requestedClientId)
|
|
||||||
&& hub.Get(requestedClientId) is not null)
|
|
||||||
{
|
|
||||||
return requestedClientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return modelSummary?.ActiveClients.FirstOrDefault(clientId => hub.Get(clientId) is not null)
|
|
||||||
?? modelSummary?.ListedClients.FirstOrDefault(clientId => hub.Get(clientId) is not null)
|
|
||||||
?? hub.SelectBest(model)?.ClientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ModelUsageStats EmptyModelMetrics() =>
|
|
||||||
new(0, 0, 0, 0, 0);
|
|
||||||
|
|
||||||
private static async Task<object> SendModelCommandForApiAsync(
|
|
||||||
TunnelConnection connection,
|
|
||||||
string command,
|
|
||||||
string model,
|
|
||||||
TimeSpan timeout,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await connection.SendModelCommandAsync(
|
|
||||||
command,
|
|
||||||
model,
|
|
||||||
payloadJson: null,
|
|
||||||
timeout,
|
|
||||||
cancellationToken);
|
|
||||||
var body = response.Body is { Length: > 0 }
|
|
||||||
? Encoding.UTF8.GetString(response.Body)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
ok = response.StatusCode is >= 200 and < 300,
|
|
||||||
statusCode = response.StatusCode,
|
|
||||||
reasonPhrase = response.ReasonPhrase,
|
|
||||||
body = ParseJsonOrText(body)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
ok = false,
|
|
||||||
statusCode = StatusCodes.Status504GatewayTimeout,
|
|
||||||
reasonPhrase = "Timed out",
|
|
||||||
body = "The model command timed out."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
ok = false,
|
|
||||||
statusCode = StatusCodes.Status502BadGateway,
|
|
||||||
reasonPhrase = "Command failed",
|
|
||||||
body = exception.Message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object? ParseJsonOrText(string body)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(body))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var document = JsonDocument.Parse(body);
|
|
||||||
return document.RootElement.Clone();
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return body.Length <= 100_000 ? body : body[..100_000];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryMapModelAction(string action, out string command, out TimeSpan timeout)
|
|
||||||
{
|
|
||||||
command = action.Trim().ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"add" or "pull" => "pull",
|
|
||||||
"remove" or "delete" => "delete",
|
|
||||||
"load" => "load",
|
|
||||||
"unload" => "unload",
|
|
||||||
_ => ""
|
|
||||||
};
|
|
||||||
|
|
||||||
timeout = command == "pull" ? TimeSpan.FromMinutes(30) : TimeSpan.FromMinutes(2);
|
|
||||||
return command.Length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IResult ServeAdminAsset(IWebHostEnvironment environment, string? assetPath)
|
|
||||||
{
|
|
||||||
var path = string.IsNullOrWhiteSpace(assetPath) ? "index.html" : assetPath;
|
|
||||||
|
|
||||||
if (path.Contains("..", StringComparison.Ordinal)
|
|
||||||
|| path.Contains('\\'))
|
|
||||||
{
|
|
||||||
return Results.BadRequest();
|
|
||||||
}
|
|
||||||
|
|
||||||
var file = environment.WebRootFileProvider.GetFileInfo($"admin/{path}");
|
|
||||||
if (!file.Exists && !Path.HasExtension(path))
|
|
||||||
{
|
|
||||||
file = environment.WebRootFileProvider.GetFileInfo("admin/index.html");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.Exists)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
ContentTypes.TryGetContentType(file.Name, out var contentType);
|
|
||||||
return Results.Stream(file.CreateReadStream(), contentType ?? "application/octet-stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeLocalReturnUrl(string? returnUrl)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(returnUrl)
|
|
||||||
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|
|
||||||
|| returnUrl.StartsWith("//", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return "/admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? GetUserName(ClaimsPrincipal user) =>
|
|
||||||
user.FindFirst("preferred_username")?.Value
|
|
||||||
?? user.FindFirst(ClaimTypes.Name)?.Value
|
|
||||||
?? user.Identity?.Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed record DisableClientRequest(
|
|
||||||
string? Mode,
|
|
||||||
int? DurationMinutes,
|
|
||||||
string? Reason);
|
|
||||||
|
|
||||||
internal sealed record ModelActionRequest(
|
|
||||||
string ClientId,
|
|
||||||
string Model,
|
|
||||||
string Action);
|
|
||||||
|
|
||||||
internal sealed record CreateApiKeyRequest(string? Name);
|
|
||||||
|
|
||||||
internal sealed record ClientSummary(
|
|
||||||
string Id,
|
|
||||||
bool Connected,
|
|
||||||
int PendingRequests,
|
|
||||||
IReadOnlyList<string> Models,
|
|
||||||
IReadOnlyList<string> ActiveModels,
|
|
||||||
DateTimeOffset? ModelsUpdatedAt,
|
|
||||||
bool Disabled,
|
|
||||||
DateTimeOffset? DisabledUntilUtc,
|
|
||||||
bool DisabledManually,
|
|
||||||
string? DisabledReason,
|
|
||||||
ClientRequestStats RequestStats);
|
|
||||||
|
|
||||||
internal sealed record ModelSummary(
|
|
||||||
string Name,
|
|
||||||
IReadOnlyList<string> ListedClients,
|
|
||||||
IReadOnlyList<string> ActiveClients,
|
|
||||||
ModelUsageStats Metrics);
|
|
||||||
@@ -1,700 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
|
||||||
|
|
||||||
internal sealed class ManagementStore
|
|
||||||
{
|
|
||||||
private static readonly TimeSpan ApiKeyLastUsedWriteInterval = TimeSpan.FromMinutes(1);
|
|
||||||
|
|
||||||
private readonly Dictionary<string, ApiKeyState> _apiKeysByHash = new(StringComparer.Ordinal);
|
|
||||||
private readonly string _connectionString = "";
|
|
||||||
private readonly string _databasePath = "";
|
|
||||||
private readonly object _lock = new();
|
|
||||||
private readonly ILogger<ManagementStore> _logger;
|
|
||||||
private bool _isAvailable;
|
|
||||||
private string? _lastError;
|
|
||||||
|
|
||||||
public ManagementStore(ServerSettings settings, ILogger<ManagementStore> logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_databasePath = ResolveDatabasePath(settings.ManagementDatabasePath);
|
|
||||||
_connectionString = new SqliteConnectionStringBuilder
|
|
||||||
{
|
|
||||||
DataSource = _databasePath,
|
|
||||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
|
||||||
Pooling = true
|
|
||||||
}.ToString();
|
|
||||||
|
|
||||||
Initialize();
|
|
||||||
_isAvailable = true;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
_lastError = exception.Message;
|
|
||||||
_logger.LogError(
|
|
||||||
exception,
|
|
||||||
"Management database is disabled because SQLite could not be initialized at {DatabasePath}.",
|
|
||||||
string.IsNullOrWhiteSpace(_databasePath) ? settings.ManagementDatabasePath : _databasePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string DatabasePath => _databasePath;
|
|
||||||
|
|
||||||
public bool IsAvailable => _isAvailable;
|
|
||||||
|
|
||||||
public string? LastError => _lastError;
|
|
||||||
|
|
||||||
public bool HasApiKeys
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (!_isAvailable)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _apiKeysByHash.Count > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsApiKeyValid(string apiKey, bool updateLastUsed)
|
|
||||||
{
|
|
||||||
if (!_isAvailable || string.IsNullOrWhiteSpace(apiKey))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var hash = HashApiKey(apiKey);
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (!_apiKeysByHash.TryGetValue(hash, out var key))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!updateLastUsed
|
|
||||||
|| key.LastUsedUtc is not null
|
|
||||||
&& now - key.LastUsedUtc.Value < ApiKeyLastUsedWriteInterval)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
key.LastUsedUtc = now;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = "UPDATE api_keys SET last_used_at_utc = $last_used_at_utc WHERE id = $id";
|
|
||||||
command.Parameters.AddWithValue("$last_used_at_utc", now.ToString("O"));
|
|
||||||
command.Parameters.AddWithValue("$id", key.Id);
|
|
||||||
command.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
catch (Exception exception) when (exception is SqliteException or IOException or UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(exception, "Failed to update API key last-used timestamp.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<ApiKeyInfo> ListApiKeys()
|
|
||||||
{
|
|
||||||
if (!_isAvailable)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _apiKeysByHash.Values
|
|
||||||
.OrderBy(key => key.Name, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ThenBy(key => key.CreatedAtUtc)
|
|
||||||
.Select(key => new ApiKeyInfo(
|
|
||||||
key.Id,
|
|
||||||
key.Name,
|
|
||||||
key.KeyPrefix,
|
|
||||||
key.CreatedAtUtc,
|
|
||||||
key.LastUsedUtc))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public CreatedApiKey CreateApiKey(string? name)
|
|
||||||
{
|
|
||||||
EnsureAvailable();
|
|
||||||
|
|
||||||
var apiKey = GenerateApiKey();
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var state = new ApiKeyState
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString("n"),
|
|
||||||
Name = string.IsNullOrWhiteSpace(name) ? "API key" : name.Trim(),
|
|
||||||
KeyHash = HashApiKey(apiKey),
|
|
||||||
KeyPrefix = GetKeyPrefix(apiKey),
|
|
||||||
CreatedAtUtc = now
|
|
||||||
};
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
INSERT INTO api_keys (id, name, key_hash, key_prefix, created_at_utc)
|
|
||||||
VALUES ($id, $name, $key_hash, $key_prefix, $created_at_utc)
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$id", state.Id);
|
|
||||||
command.Parameters.AddWithValue("$name", state.Name);
|
|
||||||
command.Parameters.AddWithValue("$key_hash", state.KeyHash);
|
|
||||||
command.Parameters.AddWithValue("$key_prefix", state.KeyPrefix);
|
|
||||||
command.Parameters.AddWithValue("$created_at_utc", state.CreatedAtUtc.ToString("O"));
|
|
||||||
command.ExecuteNonQuery();
|
|
||||||
|
|
||||||
_apiKeysByHash[state.KeyHash] = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CreatedApiKey(
|
|
||||||
state.Id,
|
|
||||||
state.Name,
|
|
||||||
state.KeyPrefix,
|
|
||||||
state.CreatedAtUtc,
|
|
||||||
apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool DeleteApiKey(string id)
|
|
||||||
{
|
|
||||||
if (!_isAvailable || string.IsNullOrWhiteSpace(id))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = "DELETE FROM api_keys WHERE id = $id";
|
|
||||||
command.Parameters.AddWithValue("$id", id);
|
|
||||||
var deleted = command.ExecuteNonQuery() > 0;
|
|
||||||
|
|
||||||
if (deleted)
|
|
||||||
{
|
|
||||||
foreach (var pair in _apiKeysByHash.Where(pair => pair.Value.Id == id).ToArray())
|
|
||||||
{
|
|
||||||
_apiKeysByHash.Remove(pair.Key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return deleted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ClientAccess GetClientAccess(string clientId)
|
|
||||||
{
|
|
||||||
if (!_isAvailable || string.IsNullOrWhiteSpace(clientId))
|
|
||||||
{
|
|
||||||
return ClientAccess.Enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
SELECT disabled_until_utc, disabled_manually, disabled_reason
|
|
||||||
FROM client_controls
|
|
||||||
WHERE client_id = $client_id
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$client_id", clientId);
|
|
||||||
|
|
||||||
using var reader = command.ExecuteReader();
|
|
||||||
if (!reader.Read())
|
|
||||||
{
|
|
||||||
return ClientAccess.Enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
var disabledUntil = ReadNullableDateTimeOffset(reader, 0);
|
|
||||||
var disabledManually = reader.GetInt32(1) != 0;
|
|
||||||
var reason = reader.IsDBNull(2) ? null : reader.GetString(2);
|
|
||||||
|
|
||||||
if (disabledManually)
|
|
||||||
{
|
|
||||||
return new ClientAccess(true, null, true, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (disabledUntil is { } until && until > DateTimeOffset.UtcNow)
|
|
||||||
{
|
|
||||||
return new ClientAccess(true, until, false, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ClientAccess.Enabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyDictionary<string, ClientAccess> ListClientControls()
|
|
||||||
{
|
|
||||||
if (!_isAvailable)
|
|
||||||
{
|
|
||||||
return new Dictionary<string, ClientAccess>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new Dictionary<string, ClientAccess>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = "SELECT client_id, disabled_until_utc, disabled_manually, disabled_reason FROM client_controls";
|
|
||||||
|
|
||||||
using var reader = command.ExecuteReader();
|
|
||||||
while (reader.Read())
|
|
||||||
{
|
|
||||||
var clientId = reader.GetString(0);
|
|
||||||
var disabledUntil = ReadNullableDateTimeOffset(reader, 1);
|
|
||||||
var disabledManually = reader.GetInt32(2) != 0;
|
|
||||||
var reason = reader.IsDBNull(3) ? null : reader.GetString(3);
|
|
||||||
|
|
||||||
result[clientId] = disabledManually
|
|
||||||
? new ClientAccess(true, null, true, reason)
|
|
||||||
: disabledUntil is { } until && until > DateTimeOffset.UtcNow
|
|
||||||
? new ClientAccess(true, until, false, reason)
|
|
||||||
: ClientAccess.Enabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void DisableClient(string clientId, TimeSpan? duration, bool manually, string? reason)
|
|
||||||
{
|
|
||||||
EnsureAvailable();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(clientId))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Client id is required.", nameof(clientId));
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var disabledUntil = manually ? null : now.Add(duration ?? TimeSpan.FromHours(1)).ToString("O");
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
INSERT INTO client_controls (
|
|
||||||
client_id,
|
|
||||||
disabled_until_utc,
|
|
||||||
disabled_manually,
|
|
||||||
disabled_reason,
|
|
||||||
updated_at_utc)
|
|
||||||
VALUES (
|
|
||||||
$client_id,
|
|
||||||
$disabled_until_utc,
|
|
||||||
$disabled_manually,
|
|
||||||
$disabled_reason,
|
|
||||||
$updated_at_utc)
|
|
||||||
ON CONFLICT(client_id) DO UPDATE SET
|
|
||||||
disabled_until_utc = excluded.disabled_until_utc,
|
|
||||||
disabled_manually = excluded.disabled_manually,
|
|
||||||
disabled_reason = excluded.disabled_reason,
|
|
||||||
updated_at_utc = excluded.updated_at_utc
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$client_id", clientId);
|
|
||||||
command.Parameters.AddWithValue("$disabled_until_utc", (object?)disabledUntil ?? DBNull.Value);
|
|
||||||
command.Parameters.AddWithValue("$disabled_manually", manually ? 1 : 0);
|
|
||||||
command.Parameters.AddWithValue("$disabled_reason", string.IsNullOrWhiteSpace(reason) ? DBNull.Value : reason.Trim());
|
|
||||||
command.Parameters.AddWithValue("$updated_at_utc", now.ToString("O"));
|
|
||||||
command.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void EnableClient(string clientId)
|
|
||||||
{
|
|
||||||
EnsureAvailable();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(clientId))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Client id is required.", nameof(clientId));
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
INSERT INTO client_controls (
|
|
||||||
client_id,
|
|
||||||
disabled_until_utc,
|
|
||||||
disabled_manually,
|
|
||||||
disabled_reason,
|
|
||||||
updated_at_utc)
|
|
||||||
VALUES (
|
|
||||||
$client_id,
|
|
||||||
NULL,
|
|
||||||
0,
|
|
||||||
NULL,
|
|
||||||
$updated_at_utc)
|
|
||||||
ON CONFLICT(client_id) DO UPDATE SET
|
|
||||||
disabled_until_utc = NULL,
|
|
||||||
disabled_manually = 0,
|
|
||||||
disabled_reason = NULL,
|
|
||||||
updated_at_utc = excluded.updated_at_utc
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$client_id", clientId);
|
|
||||||
command.Parameters.AddWithValue("$updated_at_utc", DateTimeOffset.UtcNow.ToString("O"));
|
|
||||||
command.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RecordRequest(RequestMetric metric)
|
|
||||||
{
|
|
||||||
if (!_isAvailable)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
INSERT INTO request_metrics (
|
|
||||||
client_id,
|
|
||||||
model,
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
status_code,
|
|
||||||
token_count,
|
|
||||||
started_at_utc,
|
|
||||||
completed_at_utc,
|
|
||||||
duration_ms)
|
|
||||||
VALUES (
|
|
||||||
$client_id,
|
|
||||||
$model,
|
|
||||||
$method,
|
|
||||||
$path,
|
|
||||||
$status_code,
|
|
||||||
$token_count,
|
|
||||||
$started_at_utc,
|
|
||||||
$completed_at_utc,
|
|
||||||
$duration_ms)
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$client_id", metric.ClientId);
|
|
||||||
command.Parameters.AddWithValue("$model", string.IsNullOrWhiteSpace(metric.Model) ? DBNull.Value : metric.Model);
|
|
||||||
command.Parameters.AddWithValue("$method", metric.Method);
|
|
||||||
command.Parameters.AddWithValue("$path", metric.Path);
|
|
||||||
command.Parameters.AddWithValue("$status_code", metric.StatusCode is null ? DBNull.Value : metric.StatusCode.Value);
|
|
||||||
command.Parameters.AddWithValue("$token_count", metric.TokenCount);
|
|
||||||
command.Parameters.AddWithValue("$started_at_utc", metric.StartedAtUtc.ToString("O"));
|
|
||||||
command.Parameters.AddWithValue("$completed_at_utc", metric.CompletedAtUtc.ToString("O"));
|
|
||||||
command.Parameters.AddWithValue("$duration_ms", metric.Duration.TotalMilliseconds);
|
|
||||||
command.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception exception) when (exception is SqliteException or IOException or UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(exception, "Failed to record request metric for client {ClientId}.", metric.ClientId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyDictionary<string, ClientRequestStats> GetClientRequestStats()
|
|
||||||
{
|
|
||||||
if (!_isAvailable)
|
|
||||||
{
|
|
||||||
return new Dictionary<string, ClientRequestStats>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var since10 = now.AddMinutes(-10).ToString("O");
|
|
||||||
var sinceHour = now.AddHours(-1).ToString("O");
|
|
||||||
var result = new Dictionary<string, ClientRequestStats>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
SELECT
|
|
||||||
client_id,
|
|
||||||
COUNT(*),
|
|
||||||
SUM(CASE WHEN started_at_utc >= $since10 THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN started_at_utc >= $sinceHour THEN 1 ELSE 0 END)
|
|
||||||
FROM request_metrics
|
|
||||||
GROUP BY client_id
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$since10", since10);
|
|
||||||
command.Parameters.AddWithValue("$sinceHour", sinceHour);
|
|
||||||
|
|
||||||
using var reader = command.ExecuteReader();
|
|
||||||
while (reader.Read())
|
|
||||||
{
|
|
||||||
result[reader.GetString(0)] = new ClientRequestStats(
|
|
||||||
reader.GetInt64(1),
|
|
||||||
reader.GetInt64(2),
|
|
||||||
reader.GetInt64(3));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyDictionary<string, ModelUsageStats> GetModelUsageStats()
|
|
||||||
{
|
|
||||||
if (!_isAvailable)
|
|
||||||
{
|
|
||||||
return new Dictionary<string, ModelUsageStats>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var since10 = now.AddMinutes(-10).ToString("O");
|
|
||||||
var sinceHour = now.AddHours(-1).ToString("O");
|
|
||||||
var result = new Dictionary<string, ModelUsageStats>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
SELECT
|
|
||||||
model,
|
|
||||||
COUNT(*),
|
|
||||||
SUM(CASE WHEN started_at_utc >= $since10 THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN started_at_utc >= $sinceHour THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN started_at_utc >= $since10 THEN token_count ELSE 0 END),
|
|
||||||
SUM(CASE WHEN started_at_utc >= $sinceHour THEN token_count ELSE 0 END)
|
|
||||||
FROM request_metrics
|
|
||||||
WHERE model IS NOT NULL AND model <> ''
|
|
||||||
GROUP BY model
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$since10", since10);
|
|
||||||
command.Parameters.AddWithValue("$sinceHour", sinceHour);
|
|
||||||
|
|
||||||
using var reader = command.ExecuteReader();
|
|
||||||
while (reader.Read())
|
|
||||||
{
|
|
||||||
result[reader.GetString(0)] = new ModelUsageStats(
|
|
||||||
reader.GetInt64(1),
|
|
||||||
reader.GetInt64(2),
|
|
||||||
reader.GetInt64(3),
|
|
||||||
reader.GetInt64(4),
|
|
||||||
reader.GetInt64(5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Initialize()
|
|
||||||
{
|
|
||||||
var directory = Path.GetDirectoryName(_databasePath);
|
|
||||||
if (!string.IsNullOrWhiteSpace(directory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var connection = OpenConnection();
|
|
||||||
|
|
||||||
using (var pragma = connection.CreateCommand())
|
|
||||||
{
|
|
||||||
pragma.CommandText = "PRAGMA journal_mode=WAL";
|
|
||||||
pragma.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var command = connection.CreateCommand())
|
|
||||||
{
|
|
||||||
command.CommandText = """
|
|
||||||
CREATE TABLE IF NOT EXISTS client_controls (
|
|
||||||
client_id TEXT NOT NULL PRIMARY KEY,
|
|
||||||
disabled_until_utc TEXT NULL,
|
|
||||||
disabled_manually INTEGER NOT NULL DEFAULT 0,
|
|
||||||
disabled_reason TEXT NULL,
|
|
||||||
updated_at_utc TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
key_hash TEXT NOT NULL UNIQUE,
|
|
||||||
key_prefix TEXT NOT NULL,
|
|
||||||
created_at_utc TEXT NOT NULL,
|
|
||||||
last_used_at_utc TEXT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS request_metrics (
|
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
client_id TEXT NOT NULL,
|
|
||||||
model TEXT NULL,
|
|
||||||
method TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
status_code INTEGER NULL,
|
|
||||||
token_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
started_at_utc TEXT NOT NULL,
|
|
||||||
completed_at_utc TEXT NOT NULL,
|
|
||||||
duration_ms REAL NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_metrics_client_started
|
|
||||||
ON request_metrics (client_id, started_at_utc);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_metrics_model_started
|
|
||||||
ON request_metrics (model, started_at_utc);
|
|
||||||
""";
|
|
||||||
command.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var command = connection.CreateCommand())
|
|
||||||
{
|
|
||||||
command.CommandText = """
|
|
||||||
SELECT id, name, key_hash, key_prefix, created_at_utc, last_used_at_utc
|
|
||||||
FROM api_keys
|
|
||||||
""";
|
|
||||||
|
|
||||||
using var reader = command.ExecuteReader();
|
|
||||||
while (reader.Read())
|
|
||||||
{
|
|
||||||
var state = new ApiKeyState
|
|
||||||
{
|
|
||||||
Id = reader.GetString(0),
|
|
||||||
Name = reader.GetString(1),
|
|
||||||
KeyHash = reader.GetString(2),
|
|
||||||
KeyPrefix = reader.GetString(3),
|
|
||||||
CreatedAtUtc = ReadDateTimeOffset(reader.GetString(4)),
|
|
||||||
LastUsedUtc = ReadNullableDateTimeOffset(reader, 5)
|
|
||||||
};
|
|
||||||
|
|
||||||
_apiKeysByHash[state.KeyHash] = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Loaded {ApiKeyCount} API key(s) from {DatabasePath}.",
|
|
||||||
_apiKeysByHash.Count,
|
|
||||||
_databasePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private SqliteConnection OpenConnection()
|
|
||||||
{
|
|
||||||
var connection = new SqliteConnection(_connectionString);
|
|
||||||
connection.Open();
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureAvailable()
|
|
||||||
{
|
|
||||||
if (!_isAvailable)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(_lastError ?? "The management database is not available.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal) =>
|
|
||||||
reader.IsDBNull(ordinal) ? null : ReadDateTimeOffset(reader.GetString(ordinal));
|
|
||||||
|
|
||||||
private static DateTimeOffset ReadDateTimeOffset(string value) =>
|
|
||||||
DateTimeOffset.TryParse(value, out var parsed) ? parsed : DateTimeOffset.MinValue;
|
|
||||||
|
|
||||||
private static string GenerateApiKey()
|
|
||||||
{
|
|
||||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
|
||||||
return $"rl_{Base64UrlEncode(bytes)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string HashApiKey(string apiKey) =>
|
|
||||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(apiKey))).ToLowerInvariant();
|
|
||||||
|
|
||||||
private static string GetKeyPrefix(string apiKey) =>
|
|
||||||
apiKey.Length <= 12 ? apiKey : apiKey[..12];
|
|
||||||
|
|
||||||
private static string Base64UrlEncode(byte[] bytes) =>
|
|
||||||
Convert.ToBase64String(bytes)
|
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
|
|
||||||
private static string ResolveDatabasePath(string? configuredPath)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(configuredPath))
|
|
||||||
{
|
|
||||||
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
|
|
||||||
return Path.IsPathRooted(expanded)
|
|
||||||
? expanded
|
|
||||||
: Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, expanded));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Path.Combine(AppContext.BaseDirectory, "App_Data", "management.sqlite");
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ApiKeyState
|
|
||||||
{
|
|
||||||
public string Id { get; init; } = "";
|
|
||||||
|
|
||||||
public string Name { get; init; } = "";
|
|
||||||
|
|
||||||
public string KeyHash { get; init; } = "";
|
|
||||||
|
|
||||||
public string KeyPrefix { get; init; } = "";
|
|
||||||
|
|
||||||
public DateTimeOffset CreatedAtUtc { get; init; }
|
|
||||||
|
|
||||||
public DateTimeOffset? LastUsedUtc { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed record ClientAccess(
|
|
||||||
bool IsDisabled,
|
|
||||||
DateTimeOffset? DisabledUntilUtc,
|
|
||||||
bool DisabledManually,
|
|
||||||
string? DisabledReason)
|
|
||||||
{
|
|
||||||
public static ClientAccess Enabled { get; } = new(false, null, false, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed record ApiKeyInfo(
|
|
||||||
string Id,
|
|
||||||
string Name,
|
|
||||||
string KeyPrefix,
|
|
||||||
DateTimeOffset CreatedAtUtc,
|
|
||||||
DateTimeOffset? LastUsedUtc);
|
|
||||||
|
|
||||||
internal sealed record CreatedApiKey(
|
|
||||||
string Id,
|
|
||||||
string Name,
|
|
||||||
string KeyPrefix,
|
|
||||||
DateTimeOffset CreatedAtUtc,
|
|
||||||
string Key);
|
|
||||||
|
|
||||||
internal sealed record RequestMetric(
|
|
||||||
string ClientId,
|
|
||||||
string? Model,
|
|
||||||
string Method,
|
|
||||||
string Path,
|
|
||||||
int? StatusCode,
|
|
||||||
int TokenCount,
|
|
||||||
DateTimeOffset StartedAtUtc,
|
|
||||||
DateTimeOffset CompletedAtUtc,
|
|
||||||
TimeSpan Duration);
|
|
||||||
|
|
||||||
internal sealed record ClientRequestStats(
|
|
||||||
long Total,
|
|
||||||
long Last10Minutes,
|
|
||||||
long LastHour);
|
|
||||||
|
|
||||||
internal sealed record ModelUsageStats(
|
|
||||||
long TotalRequests,
|
|
||||||
long RequestsLast10Minutes,
|
|
||||||
long RequestsLastHour,
|
|
||||||
long TokensLast10Minutes,
|
|
||||||
long TokensLastHour);
|
|
||||||
@@ -1,171 +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();
|
|
||||||
|
|
||||||
if (settings.Keycloak.IsConfigured)
|
|
||||||
{
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseElmah();
|
|
||||||
|
|
||||||
app.UseWebSockets(new WebSocketOptions
|
|
||||||
{
|
|
||||||
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapAdminEndpoints(settings);
|
|
||||||
|
|
||||||
app.MapGet("/", (TunnelHub hub) =>
|
|
||||||
Results.Json(new
|
|
||||||
{
|
|
||||||
status = "ok",
|
|
||||||
connected = hub.HasClient,
|
|
||||||
pendingRequests = hub.PendingRequestCount,
|
|
||||||
clients = hub.ClientsSnapshot.Count
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) =>
|
|
||||||
{
|
|
||||||
// Query token allowed so the status page can be checked in a browser.
|
|
||||||
if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Json(new
|
|
||||||
{
|
|
||||||
connected = hub.HasClient,
|
|
||||||
pendingRequests = hub.PendingRequestCount,
|
|
||||||
tunnelPath = serverSettings.TunnelPath,
|
|
||||||
embeddingCache = new
|
|
||||||
{
|
|
||||||
available = embeddingCache.IsAvailable,
|
|
||||||
count = embeddingCache.Count,
|
|
||||||
databasePath = embeddingCache.DatabasePath,
|
|
||||||
lastError = embeddingCache.LastError
|
|
||||||
},
|
|
||||||
management = new
|
|
||||||
{
|
|
||||||
available = managementStore.IsAvailable,
|
|
||||||
databasePath = managementStore.DatabasePath,
|
|
||||||
lastError = managementStore.LastError
|
|
||||||
},
|
|
||||||
clients = hub.ClientsSnapshot
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Map(settings.TunnelPath, async (HttpContext context, TunnelHub hub, ServerSettings serverSettings, ManagementStore managementStore) =>
|
|
||||||
{
|
|
||||||
if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
|
|
||||||
{
|
|
||||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
||||||
await context.Response.WriteAsync($"Missing or invalid {ProtocolConstants.TokenHeader}.", context.RequestAborted);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.WebSockets.IsWebSocketRequest)
|
|
||||||
{
|
|
||||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
||||||
await context.Response.WriteAsync("This endpoint only accepts WebSocket tunnel connections.", context.RequestAborted);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientId = context.Request.Headers[ProtocolConstants.ClientIdHeader].FirstOrDefault();
|
|
||||||
if (string.IsNullOrWhiteSpace(clientId))
|
|
||||||
{
|
|
||||||
clientId = $"anonymous-{Guid.NewGuid():n}";
|
|
||||||
}
|
|
||||||
|
|
||||||
using var socket = await context.WebSockets.AcceptWebSocketAsync();
|
|
||||||
await hub.AcceptAsync(clientId, socket, context.RequestAborted);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Map("/clients/{clientId}/{**path}", ReverseProxyEndpoint.HandleClientAsync);
|
|
||||||
|
|
||||||
app.Map("/{**path}", ReverseProxyEndpoint.HandleRootAsync)
|
|
||||||
.WithOrder(1000);
|
|
||||||
|
|
||||||
var elmahService = app.Services.GetRequiredService<ErrorLog>();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
app.Run();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
elmahService.Log(new Error(exception));
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="ElmahCore" Version="2.1.2" />
|
|
||||||
<PackageReference Include="ElmahCore.MySql" Version="2.1.2" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.28" />
|
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.28" />
|
|
||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
|
||||||
<PackageReference Include="System.Text.Encodings.Web" Version="8.0.0" />
|
|
||||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
|
||||||
<ProjectReference Include="..\ReverseLlama.Protocol\ReverseLlama.Protocol.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
using ReverseLlama.Protocol;
|
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
|
||||||
|
|
||||||
internal static class TokenAuthentication
|
|
||||||
{
|
|
||||||
private static readonly PathString PathTokenPrefix = new("/token");
|
|
||||||
|
|
||||||
public static bool IsAuthorized(
|
|
||||||
HttpRequest request,
|
|
||||||
ServerSettings settings,
|
|
||||||
ManagementStore managementStore,
|
|
||||||
bool allowQueryToken,
|
|
||||||
bool allowPathToken = false)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(settings.Token) && !managementStore.HasApiKeys)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues)
|
|
||||||
&& headerValues.Any(value => IsTokenAuthorized(value, settings, managementStore, updateApiKeyLastUsed: true)))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bearer form for OpenAI-compatible clients (e.g. n8n's OpenAI nodes pointed
|
|
||||||
// at /clients/{id}/v1) that can send an API key but no custom headers.
|
|
||||||
if (request.Headers.TryGetValue("Authorization", out var authorizationValues)
|
|
||||||
&& authorizationValues.Any(value => TryGetBearerToken(value, out var bearerToken)
|
|
||||||
&& IsTokenAuthorized(bearerToken, settings, managementStore, updateApiKeyLastUsed: true)))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowPathToken
|
|
||||||
&& TryGetPathToken(request.Path, out var pathToken, out _)
|
|
||||||
&& IsTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: true))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return allowQueryToken
|
|
||||||
&& request.Query.TryGetValue("token", out var queryValues)
|
|
||||||
&& queryValues.Any(value => IsTokenAuthorized(value, settings, managementStore, updateApiKeyLastUsed: true));
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
public static bool IsTokenAuthorized(
|
|
||||||
string? token,
|
|
||||||
ServerSettings settings,
|
|
||||||
ManagementStore managementStore,
|
|
||||||
bool updateApiKeyLastUsed)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !string.IsNullOrWhiteSpace(settings.Token)
|
|
||||||
&& string.Equals(token, settings.Token, StringComparison.Ordinal)
|
|
||||||
|| managementStore.IsApiKeyValid(token, updateApiKeyLastUsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
|
||||||
}
|
|
||||||
@@ -1,766 +0,0 @@
|
|||||||
const state = {
|
|
||||||
summary: null,
|
|
||||||
detail: null,
|
|
||||||
newKey: null,
|
|
||||||
loading: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = document.getElementById("content");
|
|
||||||
const notice = document.getElementById("notice");
|
|
||||||
const pageTitle = document.getElementById("pageTitle");
|
|
||||||
const pageSubtitle = document.getElementById("pageSubtitle");
|
|
||||||
const sidebarMeta = document.getElementById("sidebarMeta");
|
|
||||||
|
|
||||||
document.getElementById("refreshButton").addEventListener("click", () => refresh(true));
|
|
||||||
window.addEventListener("hashchange", () => renderRoute());
|
|
||||||
|
|
||||||
content.addEventListener("click", async (event) => {
|
|
||||||
const button = event.target.closest("button[data-action]");
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { action, clientId, model, keyId } = button.dataset;
|
|
||||||
|
|
||||||
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-manual") {
|
|
||||||
await api(`/clients/${encodeURIComponent(clientId)}/disable`, {
|
|
||||||
method: "POST",
|
|
||||||
body: { mode: "manual" }
|
|
||||||
});
|
|
||||||
setNotice(`Disabled ${clientId}.`);
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "enable-client") {
|
|
||||||
await api(`/clients/${encodeURIComponent(clientId)}/enable`, { method: "POST" });
|
|
||||||
setNotice(`Enabled ${clientId}.`);
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "model-command") {
|
|
||||||
await runModelCommand(clientId, model, button.dataset.modelAction);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "delete-key") {
|
|
||||||
if (!confirm("Delete this API key?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await api(`/api-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" });
|
|
||||||
setNotice("API key deleted.");
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "copy-key") {
|
|
||||||
await navigator.clipboard.writeText(button.dataset.key);
|
|
||||||
setNotice("API key copied.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setNotice(error.message, true);
|
|
||||||
} finally {
|
|
||||||
setBusy(button, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
content.addEventListener("submit", async (event) => {
|
|
||||||
const form = event.target;
|
|
||||||
if (!(form instanceof HTMLFormElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
const data = Object.fromEntries(new FormData(form).entries());
|
|
||||||
const submit = form.querySelector("button[type=submit]");
|
|
||||||
|
|
||||||
try {
|
|
||||||
setBusy(submit, true);
|
|
||||||
|
|
||||||
if (form.dataset.form === "model-action") {
|
|
||||||
await runModelCommand(data.clientId, data.model, data.action);
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.dataset.form === "model-detail") {
|
|
||||||
await loadModelDetail(data.model, data.clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.dataset.form === "api-key") {
|
|
||||||
state.newKey = await api("/api-keys", {
|
|
||||||
method: "POST",
|
|
||||||
body: { name: data.name }
|
|
||||||
});
|
|
||||||
setNotice("API key created.");
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setNotice(error.message, true);
|
|
||||||
} finally {
|
|
||||||
setBusy(submit, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function boot() {
|
|
||||||
await refresh();
|
|
||||||
setInterval(() => refresh(), 15000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh(showNotice = false, render = true) {
|
|
||||||
state.loading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
state.summary = await api("/summary");
|
|
||||||
updateShell();
|
|
||||||
if (render) {
|
|
||||||
await renderRoute();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showNotice) {
|
|
||||||
setNotice("Refreshed.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setNotice(error.message, true);
|
|
||||||
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
|
||||||
} finally {
|
|
||||||
state.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
|
||||||
const headers = options.headers ? { ...options.headers } : {};
|
|
||||||
const init = {
|
|
||||||
method: options.method || "GET",
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.body !== undefined) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
init.body = JSON.stringify(options.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/admin${path}`, init);
|
|
||||||
const contentType = response.headers.get("content-type") || "";
|
|
||||||
const body = contentType.includes("application/json")
|
|
||||||
? await response.json()
|
|
||||||
: await response.text();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const message = body?.detail || body?.error || body?.title || response.statusText;
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderRoute() {
|
|
||||||
const hash = (window.location.hash || "#clients").slice(1);
|
|
||||||
const [view, encodedModel] = hash.split("/");
|
|
||||||
|
|
||||||
document.querySelectorAll("[data-nav]").forEach((link) => {
|
|
||||||
link.classList.toggle("active", link.dataset.nav === view);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (view === "models" && encodedModel) {
|
|
||||||
const model = decodeURIComponent(encodedModel);
|
|
||||||
await loadModelDetail(model);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.detail = null;
|
|
||||||
|
|
||||||
if (view === "models") {
|
|
||||||
renderModels();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (view === "api-keys") {
|
|
||||||
renderApiKeys();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderClients();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateShell() {
|
|
||||||
const summary = state.summary;
|
|
||||||
if (!summary) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sidebarMeta.innerHTML = `
|
|
||||||
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
|
|
||||||
<div>${summary.clients.length} clients</div>
|
|
||||||
<div>${summary.models.length} models</div>
|
|
||||||
<div>${formatDate(summary.generatedAtUtc)}</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderClients() {
|
|
||||||
const clients = state.summary?.clients || [];
|
|
||||||
pageTitle.textContent = "Clients";
|
|
||||||
pageSubtitle.textContent = "Connected tunnel clients, request counts, and forwarding controls.";
|
|
||||||
|
|
||||||
content.innerHTML = `
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>Clients</h2>
|
|
||||||
<span class="badge">${clients.length} total</span>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
${clients.length ? clientsTable(clients) : emptyState("No clients have connected yet.")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clientsTable(clients) {
|
|
||||||
const rows = clients.map((client) => `
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="cell-main">${escapeHtml(client.id)}</div>
|
|
||||||
<div class="cell-sub">${client.connected ? "Connected" : "Offline"}${client.modelsUpdatedAt ? `, models ${formatDate(client.modelsUpdatedAt)}` : ""}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="badge-row">
|
|
||||||
${client.connected ? badge("Connected", "good") : badge("Offline", "")}
|
|
||||||
${client.disabled ? badge(client.disabledManually ? "Disabled manual" : "Disabled timed", "bad") : badge("Enabled", "good")}
|
|
||||||
</div>
|
|
||||||
${client.disabled ? `<div class="cell-sub">${escapeHtml(disabledText(client))}</div>` : ""}
|
|
||||||
</td>
|
|
||||||
<td>${number(client.pendingRequests)}</td>
|
|
||||||
<td>
|
|
||||||
<div class="cell-main">${number(client.requestStats.total)}</div>
|
|
||||||
<div class="cell-sub">${number(client.requestStats.last10Minutes)} in 10m, ${number(client.requestStats.lastHour)} in 1h</div>
|
|
||||||
</td>
|
|
||||||
<td>${modelBadges(client.models)}</td>
|
|
||||||
<td>${modelBadges(client.activeModels)}</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-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>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join("");
|
|
||||||
|
|
||||||
return `
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Pending</th>
|
|
||||||
<th>Requests</th>
|
|
||||||
<th>Listed models</th>
|
|
||||||
<th>Active models</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderModels() {
|
|
||||||
const models = state.summary?.models || [];
|
|
||||||
const clients = connectedClients();
|
|
||||||
pageTitle.textContent = "Models";
|
|
||||||
pageSubtitle.textContent = "Listed and active models, recent request volume, and model operations.";
|
|
||||||
|
|
||||||
content.innerHTML = `
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>Run model action</h2>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
${modelActionForm(clients)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>Models</h2>
|
|
||||||
<span class="badge">${models.length} total</span>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
${models.length ? modelsTable(models) : emptyState("No models have been reported yet.")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelActionForm(clients, selectedModel = "", selectedClient = "") {
|
|
||||||
return `
|
|
||||||
<form class="form-row" data-form="model-action">
|
|
||||||
<div class="field">
|
|
||||||
<label for="modelActionClient">Client</label>
|
|
||||||
<select class="select" id="modelActionClient" name="clientId" required>
|
|
||||||
${clientOptions(clients, selectedClient)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="modelActionModel">Model</label>
|
|
||||||
<input class="input" id="modelActionModel" name="model" value="${escapeAttr(selectedModel)}" placeholder="llama3.1" required>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="modelActionAction">Action</label>
|
|
||||||
<select class="select" id="modelActionAction" name="action" required>
|
|
||||||
<option value="add">Add</option>
|
|
||||||
<option value="load">Load</option>
|
|
||||||
<option value="unload">Unload</option>
|
|
||||||
<option value="remove">Remove</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="button" type="submit" ${clients.length ? "" : "disabled"}>Run</button>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelsTable(models) {
|
|
||||||
const rows = models.map((model) => `
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="cell-main">${escapeHtml(model.name)}</div>
|
|
||||||
<div class="cell-sub">${number(model.metrics.totalRequests)} total requests</div>
|
|
||||||
</td>
|
|
||||||
<td>${modelBadges(model.listedClients)}</td>
|
|
||||||
<td>${modelBadges(model.activeClients)}</td>
|
|
||||||
<td>
|
|
||||||
<div class="cell-main">${number(model.metrics.requestsLast10Minutes)}</div>
|
|
||||||
<div class="cell-sub">${number(model.metrics.requestsLastHour)} in last hour</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="cell-main">${number(model.metrics.tokensLast10Minutes)}</div>
|
|
||||||
<div class="cell-sub">${number(model.metrics.tokensLastHour)} in last hour</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a class="button secondary" href="#models/${encodeURIComponent(model.name)}">Details</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join("");
|
|
||||||
|
|
||||||
return `
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Model</th>
|
|
||||||
<th>Listed on</th>
|
|
||||||
<th>Active on</th>
|
|
||||||
<th>Requests 10m</th>
|
|
||||||
<th>Tokens 10m</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModelDetail(model, clientId) {
|
|
||||||
pageTitle.textContent = "Model Detail";
|
|
||||||
pageSubtitle.textContent = model;
|
|
||||||
content.innerHTML = `<div class="panel"><div class="empty">Loading model detail...</div></div>`;
|
|
||||||
|
|
||||||
const query = new URLSearchParams({ model });
|
|
||||||
if (clientId) {
|
|
||||||
query.set("clientId", clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.detail = await api(`/models/detail?${query.toString()}`);
|
|
||||||
renderModelDetail();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderModelDetail() {
|
|
||||||
const detail = state.detail;
|
|
||||||
const clients = connectedClients();
|
|
||||||
const model = detail.model;
|
|
||||||
const selectedClient = detail.selectedClientId || clients[0]?.id || "";
|
|
||||||
const metrics = detail.metrics;
|
|
||||||
const showBody = detail.show?.body === undefined ? null : detail.show.body;
|
|
||||||
|
|
||||||
pageTitle.textContent = "Model Detail";
|
|
||||||
pageSubtitle.textContent = model;
|
|
||||||
|
|
||||||
content.innerHTML = `
|
|
||||||
<div class="toolbar">
|
|
||||||
<a class="button secondary" href="#models">Back to models</a>
|
|
||||||
</div>
|
|
||||||
<div class="metric-grid">
|
|
||||||
${metric(number(metrics.requestsLast10Minutes), "Requests in 10 minutes")}
|
|
||||||
${metric(number(metrics.requestsLastHour), "Requests in 1 hour")}
|
|
||||||
${metric(number(metrics.tokensLast10Minutes), "Tokens in 10 minutes")}
|
|
||||||
${metric(number(metrics.tokensLastHour), "Tokens in 1 hour")}
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>Placement</h2>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<div class="badge-row">
|
|
||||||
${badge("Listed", detail.listedClients.length ? "good" : "")}
|
|
||||||
${modelBadges(detail.listedClients)}
|
|
||||||
</div>
|
|
||||||
<div class="badge-row" style="margin-top:8px">
|
|
||||||
${badge("Active", detail.activeClients.length ? "good" : "warn")}
|
|
||||||
${modelBadges(detail.activeClients)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>Client action</h2>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<form class="form-row" data-form="model-detail">
|
|
||||||
<input type="hidden" name="model" value="${escapeAttr(model)}">
|
|
||||||
<div class="field">
|
|
||||||
<label for="detailClient">Client</label>
|
|
||||||
<select class="select" id="detailClient" name="clientId" required>
|
|
||||||
${clientOptions(clients, selectedClient)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="button secondary" type="submit" ${clients.length ? "" : "disabled"}>Refresh detail</button>
|
|
||||||
</form>
|
|
||||||
<div class="actions" style="margin-top:10px">
|
|
||||||
<button class="button secondary" data-action="model-command" data-model-action="load" data-client-id="${escapeAttr(selectedClient)}" data-model="${escapeAttr(model)}" ${selectedClient ? "" : "disabled"}>Load</button>
|
|
||||||
<button class="button secondary" data-action="model-command" data-model-action="unload" data-client-id="${escapeAttr(selectedClient)}" data-model="${escapeAttr(model)}" ${selectedClient ? "" : "disabled"}>Unload</button>
|
|
||||||
<button class="button danger" data-action="model-command" data-model-action="remove" data-client-id="${escapeAttr(selectedClient)}" data-model="${escapeAttr(model)}" ${selectedClient ? "" : "disabled"}>Remove</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>Ollama show response</h2>
|
|
||||||
${detail.show ? badge(detail.show.ok ? "OK" : `HTTP ${detail.show.statusCode}`, detail.show.ok ? "good" : "bad") : ""}
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
${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) {
|
|
||||||
if (!clientId || !model || !action) {
|
|
||||||
throw new Error("Client, model, and action are required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "remove" && !confirm(`Remove ${model} from ${clientId}?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api("/models/actions", {
|
|
||||||
method: "POST",
|
|
||||||
body: { clientId, model, action }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
throw new Error(modelActionError(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = modelActionResultDetail(result);
|
|
||||||
const completed = `${capitalize(action)} completed for ${model} on ${clientId}${detail ? ` (${detail})` : ""}.`;
|
|
||||||
|
|
||||||
if (action === "load" || action === "unload") {
|
|
||||||
const shouldBeActive = action === "load";
|
|
||||||
setNotice(`${completed} Waiting for active model snapshot.`);
|
|
||||||
|
|
||||||
if (await waitForModelActiveState(clientId, model, shouldBeActive)) {
|
|
||||||
setNotice(completed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setNotice(`${completed} The active model snapshot has not reflected the change yet.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setNotice(completed);
|
|
||||||
await refreshAfterModelCommand(model, clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderApiKeys() {
|
|
||||||
const keys = state.summary?.apiKeys || [];
|
|
||||||
pageTitle.textContent = "API Keys";
|
|
||||||
pageSubtitle.textContent = "Keys accepted by the proxy token header, bearer auth, query token, and token path.";
|
|
||||||
|
|
||||||
content.innerHTML = `
|
|
||||||
${state.newKey ? newKeyPanel(state.newKey) : ""}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>Create API key</h2>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<form class="form-row" data-form="api-key">
|
|
||||||
<div class="field">
|
|
||||||
<label for="apiKeyName">Name</label>
|
|
||||||
<input class="input" id="apiKeyName" name="name" placeholder="e.g. openwebui_prod" required>
|
|
||||||
</div>
|
|
||||||
<button class="button" type="submit">Create</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>API 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.")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function newKeyPanel(key) {
|
|
||||||
return `
|
|
||||||
<div class="new-key">
|
|
||||||
<strong>New API 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiKeysTable(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-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 connectedClients() {
|
|
||||||
return (state.summary?.clients || []).filter((client) => client.connected);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForModelActiveState(clientId, model, shouldBeActive) {
|
|
||||||
const deadline = Date.now() + 15000;
|
|
||||||
|
|
||||||
while (Date.now() <= deadline) {
|
|
||||||
await refreshAfterModelCommand(model, clientId);
|
|
||||||
|
|
||||||
const client = (state.summary?.clients || [])
|
|
||||||
.find((item) => sameText(item.id, clientId));
|
|
||||||
|
|
||||||
if (client && modelListContains(client.activeModels, model) === shouldBeActive) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await delay(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAfterModelCommand(model, clientId) {
|
|
||||||
await refresh(false, false);
|
|
||||||
|
|
||||||
const hash = window.location.hash || "";
|
|
||||||
if (hash.startsWith(`#models/${encodeURIComponent(model)}`)) {
|
|
||||||
await loadModelDetail(model, clientId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await renderRoute();
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelActionResultDetail(result) {
|
|
||||||
const body = result?.body;
|
|
||||||
|
|
||||||
if (!body || typeof body === "string") {
|
|
||||||
return body || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.status) {
|
|
||||||
return body.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.done_reason) {
|
|
||||||
return `done: ${body.done_reason}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.done) {
|
|
||||||
return "done";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelActionError(result) {
|
|
||||||
const body = result?.body;
|
|
||||||
|
|
||||||
if (typeof body === "string" && body) {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body?.error) {
|
|
||||||
return body.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body?.message) {
|
|
||||||
return body.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Model action failed with HTTP ${result.statusCode}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelListContains(models, model) {
|
|
||||||
return (models || []).some((item) => sameModelName(item, model));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameModelName(left, right) {
|
|
||||||
return stripLatestTag(left).toLowerCase() === stripLatestTag(right).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameText(left, right) {
|
|
||||||
return String(left || "").trim().toLowerCase() === String(right || "").trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripLatestTag(model) {
|
|
||||||
const value = String(model || "").trim();
|
|
||||||
return value.toLowerCase().endsWith(":latest") ? value.slice(0, -":latest".length) : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function delay(milliseconds) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clientOptions(clients, selectedClient) {
|
|
||||||
if (!clients.length) {
|
|
||||||
return `<option value="">No connected clients</option>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return clients.map((client) => `
|
|
||||||
<option value="${escapeAttr(client.id)}" ${client.id === selectedClient ? "selected" : ""}>${escapeHtml(client.id)}</option>
|
|
||||||
`).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelBadges(items) {
|
|
||||||
if (!items || !items.length) {
|
|
||||||
return `<span class="cell-sub">None</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<div class="badge-row">${items.map((item) => badge(item, "")).join("")}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function badge(text, kind) {
|
|
||||||
return `<span class="badge ${kind || ""}">${escapeHtml(text)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function metric(value, label) {
|
|
||||||
return `
|
|
||||||
<div class="metric">
|
|
||||||
<strong>${escapeHtml(value)}</strong>
|
|
||||||
<span>${escapeHtml(label)}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyState(text) {
|
|
||||||
return `<div class="empty">${escapeHtml(text)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function disabledText(client) {
|
|
||||||
if (client.disabledManually) {
|
|
||||||
return "Until enabled manually";
|
|
||||||
}
|
|
||||||
|
|
||||||
return client.disabledUntilUtc ? `Until ${formatDate(client.disabledUntilUtc)}` : "Disabled";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value) {
|
|
||||||
if (!value) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
|
||||||
dateStyle: "short",
|
|
||||||
timeStyle: "medium"
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatJson(value) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function number(value) {
|
|
||||||
return new Intl.NumberFormat().format(value || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalize(value) {
|
|
||||||
return value ? value[0].toUpperCase() + value.slice(1) : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setNotice(message, isError = false) {
|
|
||||||
notice.hidden = false;
|
|
||||||
notice.textContent = message;
|
|
||||||
notice.classList.toggle("error", isError);
|
|
||||||
|
|
||||||
clearTimeout(setNotice.timer);
|
|
||||||
setNotice.timer = setTimeout(() => {
|
|
||||||
notice.hidden = true;
|
|
||||||
}, isError ? 7000 : 3500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBusy(element, busy) {
|
|
||||||
if (!element) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
element.disabled = busy;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value ?? "")
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAttr(value) {
|
|
||||||
return escapeHtml(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
boot();
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,16 +9,17 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -2,7 +2,7 @@ using System.Diagnostics;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ReverseLlama.Client.Tests;
|
namespace Ngino.Client.Tests;
|
||||||
|
|
||||||
public sealed class PackageAuditTests
|
public sealed class PackageAuditTests
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,7 @@ public sealed class PackageAuditTests
|
|||||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
while (directory is not null)
|
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))
|
if (File.Exists(solutionPath))
|
||||||
{
|
{
|
||||||
return solutionPath;
|
return solutionPath;
|
||||||
@@ -41,7 +41,7 @@ public sealed class PackageAuditTests
|
|||||||
directory = directory.Parent;
|
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)
|
private static async Task<CommandResult> RunDotnetPackageAuditAsync(string solutionPath)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ReverseLlama.Client;
|
using Ngino.Client;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ReverseLlama.Client.Tests;
|
namespace Ngino.Client.Tests;
|
||||||
|
|
||||||
public sealed class TunnelClientModelTests
|
public sealed class TunnelClientModelTests
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,7 @@ public sealed class TunnelClientModelTests
|
|||||||
Assert.Equal(HttpMethod.Post, request.Method);
|
Assert.Equal(HttpMethod.Post, request.Method);
|
||||||
Assert.Equal("http://localhost:11434/api/embed", request.RequestUri!.AbsoluteUri);
|
Assert.Equal("http://localhost:11434/api/embed", request.RequestUri!.AbsoluteUri);
|
||||||
Assert.Equal("bge-m3:latest", document.RootElement.GetProperty("model").GetString());
|
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());
|
Assert.Equal(expectedKeepAlive, document.RootElement.GetProperty("keep_alive").GetInt32());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
using ReverseLlama.Client;
|
using System.Net.Sockets;
|
||||||
|
using Ngino.Client;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ReverseLlama.Client.Tests;
|
namespace Ngino.Client.Tests;
|
||||||
|
|
||||||
public sealed class UpstreamRequestTests
|
public sealed class UpstreamRequestTests
|
||||||
{
|
{
|
||||||
@@ -32,4 +33,29 @@ public sealed class UpstreamRequestTests
|
|||||||
|
|
||||||
Assert.Contains("origin-form path", exception.Message);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||