54 Commits
Author SHA1 Message Date
lucretia d33a25bfa5 fix(server): replaces text logo with proper logo
Build & Deploy / build (push) Successful in 1m42s
2026-08-02 01:26:12 +02:00
lucretia b849cfd4b7 feat(client): file logging + llama.cpp fallback cooldown and parallel options
Build & Deploy / build (push) Successful in 2m8s
2026-08-02 01:24:09 +02:00
lucretia 44b09ffa86 fix(server): fixes favicon not showing
Build & Deploy / build (push) Successful in 1m55s
2026-08-01 22:44:04 +02:00
lucretia 2b56bc53b6 fix: recovers from dead llama.cpp containers and serves /api/version
Build & Deploy / build (push) Successful in 3m10s
2026-08-01 19:09:32 +02:00
LD50andGitHub faba272480 Merge pull request #33 from LD-Reborn/21-create-an-app-icon
Build & Deploy / build (push) Successful in 2m2s
docs: adds images
2026-07-31 17:28:17 +02:00
lucretia 9361aaf2fc docs: adds images 2026-07-31 17:27:53 +02:00
lucretia ded9030ede feat(deploy): adds uninstaller, docs: fixes wording and adds llama.cpp installation examples
Build & Deploy / build (push) Successful in 1m48s
2026-07-31 16:03:49 +02:00
lucretia afc85e4802 Merge branch 'main' of https://github.com/LD-Reborn/Ngino
Build & Deploy / build (push) Successful in 1m43s
2026-07-30 19:34:57 +02:00
lucretia 5b7ae55cc8 fix(client): fixes installer and inferencing issues
Build & Deploy / build (push) Successful in 1m45s
2026-07-30 19:33:44 +02:00
lucretia 2ca5ca2da2 feat(Client): adds llama.cpp backend
Build & Deploy / build (push) Successful in 2m11s
2026-07-29 22:52:48 +02:00
LD50andGitHub 7a76acc247 Merge pull request #31 from LD-Reborn/10-change-disable-1h-to-disable-temporary
10 change disable 1h to disable temporary
2026-07-29 21:07:28 +02:00
lucretia 0c136f86cb fix(Server): fixes temporary disable input visibility, adds reason text
Build & Deploy / build (push) Successful in 1m43s
2026-07-29 20:18:35 +02:00
lucretia 9d01eb5311 feat(server): adds temporary disabling functionality
Build & Deploy / build (push) Successful in 1m55s
2026-07-29 20:06:52 +02:00
lucretia d567966d41 fix(server): fixes link to group not working
Build & Deploy / build (push) Successful in 1m51s
2026-07-29 19:18:15 +02:00
lucretia f6091747d9 Merge branch 'main' of https://github.com/LD-Reborn/Ngino
Build & Deploy / build (push) Successful in 1m53s
2026-07-29 17:27:32 +02:00
lucretia de8c757d93 ci: fixes service not restarting
Build & Deploy / build (push) Successful in 1m48s
2026-07-29 15:38:41 +02:00
lucretia 4c2fc37929 fix(server): fixes models not being automatically loaded 2026-07-29 15:22:28 +02:00
lucretia f9004778d3 fix: sets dotnet secret id 2026-07-29 14:30:47 +02:00
lucretia ca71fafa4f feat(server): adds minimum keepalive and headroom, chore: updates packages to fix vulnerabilities 2026-07-29 13:18:19 +02:00
lucretia 1cfe53dbe2 feat(client): adds windows installer and InsecureSkipTlsVerify flag 2026-07-20 15:01:15 +02:00
lucretia 72051aae0e fix: fixes name in various files 2 2026-07-20 09:33:52 +02:00
lucretia a977b24b9b fix: fixes name in various files 2026-07-20 07:54:02 +02:00
lucretia 4061f2067c fix(installer): fixes install path 2026-07-20 07:51:56 +02:00
lucretia 746774ac99 src: renames projects 2026-07-20 07:51:36 +02:00
LD50andGitHub 1655a47608 docs: updates name in README 2026-07-20 07:27:25 +02:00
LD50andGitHub f2dc687d63 docs: adds GNU GPL v3 License
Added the GNU General Public License version 3 to the project.
2026-07-20 07:26:03 +02:00
lucretia b632489467 fix(server): fixes preflight CORS issue 2026-07-19 22:35:07 +02:00
lucretia 468dcedbc2 fix(server): fixes auth error when accessing favicon.ico 2026-07-19 22:06:50 +02:00
lucretia 5d1d03fe5a feat(server): splits API keys into user keys and client keys 2026-07-19 20:44:48 +02:00
lucretia 6c43744745 feat(server): splits user and client into separate key access 2026-07-19 14:45:04 +02:00
LD50andGitHub 289abbf095 Merge pull request #25 from LD-Reborn/1-add-asp-net-core-identity-framework-authentication
1 add asp net core identity framework authentication
2026-07-19 00:42:35 +02:00
lucretia 674bca825c docs: adds README architecture image source file 2026-07-19 00:42:16 +02:00
lucretia 2b756d8e0c feat(server): adds ASP .NET Core identity framework authentication 2026-07-19 00:41:53 +02:00
lucretia 43d627ab9d fix(server): fixes admin page gets full DOM reload every 15 seconds 2026-07-18 21:52:12 +02:00
LD50andGitHub fa08d86427 Merge pull request #24 from LD-Reborn/20-ai-disclosure
docs: adds AI disclosure
2026-07-18 20:24:20 +02:00
lucretia 52b642c02c docs: adds AI disclosure 2026-07-18 20:24:05 +02:00
LD50andGitHub 20017a0971 Merge pull request #23 from LD-Reborn/11-add-cors-settings
feat(server): adds CORS settings
2026-07-18 20:20:43 +02:00
lucretia adfd2e9d6f feat(server): adds CORS settings 2026-07-18 20:20:28 +02:00
LD50andGitHub 87e74d8afe Merge pull request #22 from LD-Reborn/18-perform-security-scan-on-repo
18 perform security scan on repo
2026-07-18 19:42:59 +02:00
lucretia 7217f29909 fix(server): fixes lax cookie SecurePolicy issue 2026-07-18 19:42:39 +02:00
lucretia 6240030dd5 fix(server): adds XFrameOptions and ContentSecurityPolicy headers 2026-07-18 19:39:53 +02:00
lucretia 31a86ed234 fix(client installer): fixes shell injection 2026-07-18 18:58:11 +02:00
lucretia 7675c9ad8e docs: add documentation regarding the safety of query-string token usage 2026-07-18 18:25:14 +02:00
lucretia 536771d2bc fix(auth): fixes possible timing attack in the token validation 2026-07-18 18:14:37 +02:00
lucretia 02e4462520 fix(server): fixes status path returns database path and error message to clients 2026-07-18 18:08:00 +02:00
lucretia ff78aaa6b4 fix(server): fixes auth errors not logging to elmah 2026-07-18 17:26:33 +02:00
lucretia 9a1048936e feat(auth): adds auth rate limiting 2026-07-18 14:37:30 +02:00
lucretia 7c6d396042 fix(auth): fixes unlimited access when no authentication is set up 2026-07-18 12:20:08 +02:00
lucretia b316804dfb fix: adds publish/** to gitignore, includes all appsettings.*.json (except Example) in gitignore 2026-07-18 11:39:30 +02:00
LD50andGitHub 1ca2eb70fd Merge pull request #17 from LD-Reborn/2-remove-web-root-page-make-it-a-redirect
fix(routing): removes "/" route - now redirects to "/admin"
2026-07-18 11:04:44 +02:00
lucretia b0bf1eeae0 fix(routing): removes "/" route - now redirects to "/admin" 2026-07-18 11:03:42 +02:00
LD50andGitHub f5c3e0954b Merge pull request #15 from LD-Reborn/4-add-rudimentary-billing
feat(server): adds billing
2026-07-18 00:29:32 +02:00
lucretia 53225fd8ae feat(server): adds billing 2026-07-18 00:29:00 +02:00
LD50andGitHub 1c5ab95488 Merge pull request #13 from LD-Reborn/3-add-group-management
3 add group management
2026-07-17 23:09:57 +02:00
77 changed files with 15215 additions and 3834 deletions
+5 -5
View File
@@ -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
+3 -2
View File
@@ -7,6 +7,7 @@
*.sqlite-shm *.sqlite-shm
*.sqlite-wal *.sqlite-wal
.vs/ .vs/
**appsettings.Development.json **/appsettings.*.json
!**/appsettings.Example.json !**/appsettings.Example.json
debug/** debug/**
publish/**
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+4 -4
View File
@@ -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
+77 -33
View File
@@ -1,8 +1,10 @@
# ReverseLlama # <img src="docs/images/Ngino_logo_symbol.png" width="25" alt="logo symbol"/> Ngino
ReverseLlama is a small outbound HTTP tunnel for running Ollama (or vLLM, etc.) on GPU workstations while exposing the API from a server that cannot reach those workstations directly. Ngino is a small outbound HTTP tunnel for running Ollama, llama.cpp, vLLM, etc. on GPU workstations while exposing the API from a server that cannot reach those workstations directly.
The client opens and maintains a WebSocket connection to the server. The server accepts normal HTTP requests and forwards them through that WebSocket to the client. The client then calls a local upstream such as `http://localhost:11434` and streams the response back. <img src="docs/images/Ngino_logo_full.png" alt="logo symbol"/>
The client opens and maintains a WebSocket connection to the server. The server accepts normal HTTP requests from users and forwards them through that WebSocket to the client. The client then calls a local upstream such as `http://localhost:11434` and streams the response back.
<table> <table>
<tr> <tr>
@@ -13,10 +15,10 @@ The client opens and maintains a WebSocket connection to the server. The server
The server provides The server provides
- An API with - An API with
- Authentication via API keys - Authentication via user keys
- Authorization (planned) - Authorization (planned)
- Load balancing (Scale your AI strategy horizontally!) - Load balancing (Scale your AI inferencing horizontally by adding more nodes!)
- (Ollama-only) Model management (install, remove, load, unload models) - (Ollama-based) GGUF Model management (install, remove, load, unload models)
- Client monitoring - Client monitoring
- Who is active - Who is active
- What models are running - What models are running
@@ -27,7 +29,7 @@ The server provides
- What clients are mapped to which groups - What clients are mapped to which groups
- Billing (planned) - Billing (planned)
- (planned) Price per model per thousand tokens - (planned) Price per model per thousand tokens
- (planned) Usage per API user - (planned) Usage per user key
- (planned) Rate limiting - (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. The client provides a persistent outbound connection to the server and forwards requests to the local Ollama (or vLLM, etc.) instance. Responses stream back through the tunnel with minimal overhead.
@@ -41,35 +43,35 @@ The client provides a persistent outbound connection to the server and forwards
## Projects ## 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
@@ -79,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
@@ -117,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.
@@ -128,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
+201
View File
@@ -0,0 +1,201 @@
#Requires -Version 5.1
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$Server,
[Parameter(Mandatory = $false)]
[string]$Token,
[string]$ClientId = $(if ($env:COMPUTERNAME) { $env:COMPUTERNAME.ToLowerInvariant() } else { "windows-client" }),
[string]$Upstream = "http://localhost:11434",
[string]$InstallDir = "$env:ProgramFiles\Ngino Client",
[string]$ServiceName = "NginoClient",
[switch]$InsecureSkipTlsVerify,
[switch]$NoOllama,
[switch]$UseLlamaCppViaDocker,
[string]$UseOllamaModelsPath = "",
[string]$LlamaCppDockerImage = "",
[int]$LlamaCppBasePort = 0
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
function Write-Info([string]$Message) { Write-Host "[INFO] $Message" -ForegroundColor Green }
function Write-Warn([string]$Message) { Write-Host "[WARN] $Message" -ForegroundColor Yellow }
function Find-DotNet {
$command = Get-Command dotnet -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
$candidate = Join-Path $env:ProgramFiles "dotnet\dotnet.exe"
if (Test-Path -LiteralPath $candidate) { return $candidate }
return $null
}
function Install-WingetPackage([string]$Id, [string]$Name) {
if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) {
throw "$Name is required but winget is unavailable. Install $Name manually and run this script again."
}
Write-Info "Installing $Name..."
& winget.exe install --id $Id --exact --accept-package-agreements --accept-source-agreements --silent
if ($LASTEXITCODE -ne 0) { throw "winget failed to install $Name (exit code $LASTEXITCODE)." }
}
if ([string]::IsNullOrWhiteSpace($Server)) {
$Server = Read-Host "Ngino server URL (e.g. http://my-server:5050)"
}
if ([string]::IsNullOrWhiteSpace($Server)) { throw "Server URL is required." }
if ([string]::IsNullOrWhiteSpace($Token)) {
$secureToken = Read-Host "Server token" -AsSecureString
$tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
try { $Token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer) }
}
if ([string]::IsNullOrWhiteSpace($Token)) { throw "Token is required." }
if ($UseLlamaCppViaDocker -and [string]::IsNullOrWhiteSpace($UseOllamaModelsPath)) {
$UseOllamaModelsPath = Read-Host "Ollama models path (e.g. C:\Users\user\.ollama\models)"
}
if ($UseLlamaCppViaDocker -and [string]::IsNullOrWhiteSpace($UseOllamaModelsPath)) {
throw "Ollama models path is required with -UseLlamaCppViaDocker."
}
if ($ServiceName -notmatch '^[A-Za-z0-9_.-]+$') { throw "ServiceName contains unsupported characters." }
$scriptDir = $PSScriptRoot
$repoRoot = Split-Path -Parent $scriptDir
$clientProject = Join-Path $repoRoot "src\Ngino.Client\Ngino.Client.csproj"
if (-not (Test-Path -LiteralPath $clientProject)) {
throw "Client source not found at $clientProject. Run this script from the repository."
}
$dotnet = Find-DotNet
$dotnetVersion = if ($dotnet) { & $dotnet --version } else { $null }
if (-not $dotnetVersion -or -not $dotnetVersion.StartsWith("10.")) {
if ($dotnetVersion) { Write-Warn "dotnet $dotnetVersion is installed, but version 10.x is required." }
Install-WingetPackage "Microsoft.DotNet.SDK.10" ".NET 10 SDK"
$dotnet = Find-DotNet
if (-not $dotnet) { throw ".NET was installed, but dotnet.exe could not be found." }
$dotnetVersion = & $dotnet --version
}
Write-Info "Using dotnet $dotnetVersion ($dotnet)."
if ($NoOllama) {
Write-Info "Skipping Ollama check (-NoOllama)."
} else {
$ollama = Get-Command ollama.exe -ErrorAction SilentlyContinue
if (-not $ollama) {
$ollamaCandidate = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe"
if (Test-Path -LiteralPath $ollamaCandidate) { $ollama = Get-Item $ollamaCandidate }
}
if (-not $ollama) {
Install-WingetPackage "Ollama.Ollama" "Ollama"
$ollamaCandidate = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe"
if (Test-Path -LiteralPath $ollamaCandidate) { $ollama = Get-Item $ollamaCandidate }
}
if (-not $ollama) { Write-Warn "Ollama was installed, but ollama.exe was not found in the current session." }
else {
$ollamaPath = if ($ollama.Source) { $ollama.Source } elseif ($ollama.FullName) { $ollama.FullName } else { $ollama.Path }
Write-Info "Ollama is installed ($ollamaPath)."
}
}
$architecture = $env:PROCESSOR_ARCHITECTURE
$runtimeId = switch ($architecture) {
{ $_ -in "AMD64", "x64" } { "win-x64"; break }
{ $_ -in "ARM64", "Arm64" } { "win-arm64"; break }
{ $_ -in "x86", "X86" } { "win-x86"; break }
default { throw "Unsupported architecture: $architecture" }
}
$buildDir = Join-Path ([IO.Path]::GetTempPath()) ("ngino-build-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $buildDir | Out-Null
try {
Write-Info "Building Ngino client (self-contained, $runtimeId)..."
& $dotnet publish $clientProject -c Release -r $runtimeId --self-contained true -o $buildDir
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed (exit code $LASTEXITCODE)." }
$executable = Join-Path $buildDir "Ngino.Client.exe"
if (-not (Test-Path -LiteralPath $executable)) { throw "Build failed: Ngino.Client.exe was not produced." }
$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existingService -and $existingService.Status -ne "Stopped") {
Write-Info "Stopping existing service $ServiceName..."
Stop-Service -Name $ServiceName -Force
(Get-Service -Name $ServiceName).WaitForStatus("Stopped", [TimeSpan]::FromSeconds(30))
}
Write-Info "Installing to $InstallDir..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Copy-Item -Path (Join-Path $buildDir "*") -Destination $InstallDir -Recurse -Force
} finally {
if (Test-Path -LiteralPath $buildDir) { Remove-Item -LiteralPath $buildDir -Recurse -Force }
}
$installedExecutable = Join-Path $InstallDir "Ngino.Client.exe"
$binaryPath = '"{0}"' -f $installedExecutable
if (-not (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue)) {
Write-Info "Creating Windows service $ServiceName..."
& sc.exe create $ServiceName "binPath=" $binaryPath "start=" "auto" "DisplayName=" "Ngino Tunnel Client"
if ($LASTEXITCODE -ne 0) { throw "Could not create Windows service $ServiceName." }
} else {
& sc.exe config $ServiceName "binPath=" $binaryPath "start=" "auto" "DisplayName=" "Ngino Tunnel Client" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not update Windows service $ServiceName." }
}
# A service-specific environment keeps the token out of the process command line.
$serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$ServiceName"
$insecureTlsValue = $InsecureSkipTlsVerify.IsPresent.ToString().ToLowerInvariant()
$serviceEnvironment = @(
"NGINO_SERVER=$Server",
"NGINO_TOKEN=$Token",
"NGINO_CLIENT_ID=$ClientId",
"NGINO_UPSTREAM=$Upstream",
"NGINO_INSECURE_SKIP_TLS_VERIFY=$insecureTlsValue",
"DOTNET_CLI_TELEMETRY_OPTOUT=1",
"DOTNET_NOLOGO=1"
)
if ($UseLlamaCppViaDocker) {
$serviceEnvironment += "NGINO_USE_LLAMA_CPP_VIA_DOCKER=true"
if (-not [string]::IsNullOrWhiteSpace($UseOllamaModelsPath)) {
$serviceEnvironment += "NGINO_USE_OLLAMA_MODELS_PATH=$UseOllamaModelsPath"
}
if (-not [string]::IsNullOrWhiteSpace($LlamaCppDockerImage)) {
$serviceEnvironment += "NGINO_LLAMA_CPP_DOCKER_IMAGE=$LlamaCppDockerImage"
}
if ($LlamaCppBasePort -gt 0) {
$serviceEnvironment += "NGINO_LLAMA_CPP_BASE_PORT=$LlamaCppBasePort"
}
}
if ($InsecureSkipTlsVerify) {
Write-Warn "Server TLS certificate validation is disabled for $ServiceName."
}
New-ItemProperty -Path $serviceRegistryPath -Name Environment -PropertyType MultiString -Value $serviceEnvironment -Force | Out-Null
& sc.exe description $ServiceName "Ngino outbound tunnel client" | Out-Null
& sc.exe failure $ServiceName "reset=" "86400" "actions=" "restart/5000/restart/5000/restart/5000" | Out-Null
Start-Service -Name $ServiceName
$service = Get-Service -Name $ServiceName
try { $service.WaitForStatus("Running", [TimeSpan]::FromSeconds(15)) } catch { }
if ($service.Status -eq "Running") { Write-Info "Service $ServiceName is running." }
else { Write-Warn "Service $ServiceName did not reach Running state. Check: Get-WinEvent -LogName Application" }
Write-Host ""
Write-Info "Installation complete."
Write-Host " Server: $Server"
Write-Host " Client ID: $ClientId"
Write-Host " Upstream: $Upstream"
Write-Host " Service: $ServiceName"
Write-Host " Install dir: $InstallDir"
if ($UseLlamaCppViaDocker) {
Write-Host " llama.cpp: enabled (models: $UseOllamaModelsPath)"
}
Write-Host ""
Write-Host " Manage: Get-Service $ServiceName | Start-Service/Stop-Service/Restart-Service"
Write-Host " Logs: Get-WinEvent -LogName Application | Where-Object ProviderName -eq NginoClient"
+104 -30
View File
@@ -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,6 +115,13 @@ 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)."
@@ -225,7 +262,7 @@ 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
@@ -238,53 +275,87 @@ case "$ARCH" in
*) die "Unsupported architecture: $ARCH" ;; *) die "Unsupported architecture: $ARCH" ;;
esac esac
info "Building ReverseLlama client (self-contained, $DOTNET_RID)..." info "Building Ngino client (self-contained, $DOTNET_RID)..."
BUILD_DIR="$(mktemp -d /tmp/reversellama-build.XXXXXX)" 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 "$DOTNET_RID" \ -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]
@@ -310,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"
+42
View File
@@ -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)"
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────────────
DEFAULT_INSTALL_DIR="/opt/ngino-client"
DEFAULT_SERVICE_NAME="ngino-client"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
SERVICE_NAME="$DEFAULT_SERVICE_NAME"
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { err "$@"; exit 1; }
# ── Usage ─────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $0 [OPTIONS]
Uninstalls the Ngino client systemd service and removes its files.
Does NOT remove .NET SDK, Ollama, or Docker.
Optional:
--install-dir <dir> Install directory; defaults to $DEFAULT_INSTALL_DIR
--service-name <n> systemd service name; defaults to $DEFAULT_SERVICE_NAME
-h, --help Show this help message
Examples:
$0
$0 --install-dir /opt/ngino-client --service-name ngino-client
EOF
exit 0
}
# ── Argument parsing ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
--service-name) SERVICE_NAME="$2"; shift 2 ;;
-h|--help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
# ── Root check ────────────────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
die "This script must be run as root (or with sudo)."
fi
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
ENV_DIR="/etc/ngino-client"
# ── Stop and disable the service ──────────────────────────────────────────────
if systemctl list-unit-files "$SERVICE_NAME.service" &>/dev/null 2>&1; then
info "Stopping service $SERVICE_NAME..."
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
info "Disabling service $SERVICE_NAME..."
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
fi
# ── Remove service unit file ──────────────────────────────────────────────────
if [[ -f "$SERVICE_FILE" ]]; then
info "Removing service unit file $SERVICE_FILE..."
rm -f "$SERVICE_FILE"
fi
systemctl daemon-reload
# ── Remove install directory ──────────────────────────────────────────────────
if [[ -d "$INSTALL_DIR" ]]; then
info "Removing install directory $INSTALL_DIR..."
rm -rf "$INSTALL_DIR"
fi
# ── Remove environment file ───────────────────────────────────────────────────
if [[ -d "$ENV_DIR" ]]; then
info "Removing environment directory $ENV_DIR..."
rm -rf "$ENV_DIR"
fi
# ── Done ──────────────────────────────────────────────────────────────────────
echo
info "Uninstall complete."
echo " Service: $SERVICE_NAME (stopped, disabled, unit removed)"
echo " Install dir: $INSTALL_DIR (removed)"
echo " Env dir: $ENV_DIR (removed)"
+2 -2
View File
@@ -2,5 +2,5 @@
![Screenshot of the backend - clients view](images/Screenshots_website_clients.png) ![Screenshot of the backend - clients view](images/Screenshots_website_clients.png)
# Models view # Models view
![Screenshot of the backend - models view](images/Screenshots_website_models.png) ![Screenshot of the backend - models view](images/Screenshots_website_models.png)
# API Keys view # User Keys view
![Screenshot of the backend - api keys view](images/Screenshots_website_apikeys.png) ![Screenshot of the backend - user keys view](images/Screenshots_website_apikeys.png)
+332
View File
@@ -0,0 +1,332 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 1280 720"
role="img"
aria-labelledby="title desc"
version="1.1"
id="svg10"
sodipodi:docname="Ngino_logo.svg"
xml:space="preserve"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
inkscape:export-filename="Ngino_logo_full_dark.png"
inkscape:export-xdpi="125.532"
inkscape:export-ydpi="125.532"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview10"
pagecolor="#000000"
bordercolor="#000000"
borderopacity="1"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#000000"
inkscape:zoom="0.7671875"
inkscape:cx="391.69043"
inkscape:cy="406.02851"
inkscape:window-width="2560"
inkscape:window-height="1412"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer4"
inkscape:export-bgcolor="#00000000" /><title
id="title">Ngino logo</title><desc
id="desc">A fully vector llama outline with one input connection branching to three AI endpoints, beside the Ngino wordmark.</desc><defs
id="defs2"><linearGradient
id="swatch181"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop181" /></linearGradient><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath15"><rect
style="fill:none;stroke:#000000;stroke-linecap:butt;stroke-linejoin:bevel;paint-order:stroke markers fill;stop-color:#000000"
id="rect15-7"
width="45.034248"
height="38.955639"
x="262.48285"
y="121.00073" /></clipPath><linearGradient
id="swatch7"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop7" /></linearGradient><linearGradient
id="linearGradient1"
inkscape:swatch="gradient"><stop
style="stop-color:#000000;stop-opacity:0;"
offset="0"
id="stop3" /><stop
style="stop-color:#000000;stop-opacity:0;"
offset="1"
id="stop4" /></linearGradient><linearGradient
id="routeGradient"
x1="95"
y1="0"
x2="430"
y2="0"
gradientUnits="userSpaceOnUse"><stop
offset="0"
stop-color="#0697b5"
id="stop1" /><stop
offset="1"
stop-color="#45d05f"
id="stop2" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#routeGradient"
id="linearGradient10"
gradientUnits="userSpaceOnUse"
x1="95"
y1="0"
x2="430"
y2="0"
gradientTransform="translate(50.835031)" /><linearGradient
inkscape:collect="always"
xlink:href="#routeGradient"
id="linearGradient24"
gradientUnits="userSpaceOnUse"
x1="95"
y1="0"
x2="430"
y2="0" /><linearGradient
inkscape:collect="always"
xlink:href="#routeGradient"
id="linearGradient25"
gradientUnits="userSpaceOnUse"
x1="95"
y1="0"
x2="430"
y2="0" /><linearGradient
inkscape:collect="always"
xlink:href="#routeGradient"
id="linearGradient26"
gradientUnits="userSpaceOnUse"
x1="95"
y1="0"
x2="430"
y2="0" /><linearGradient
inkscape:collect="always"
xlink:href="#routeGradient"
id="linearGradient27"
gradientUnits="userSpaceOnUse"
x1="95"
y1="0"
x2="430"
y2="0" /><linearGradient
inkscape:collect="always"
xlink:href="#routeGradient"
id="linearGradient28"
gradientUnits="userSpaceOnUse"
x1="95"
y1="0"
x2="430"
y2="0"
gradientTransform="translate(18.248473,-20.855397)" /><linearGradient
inkscape:collect="always"
xlink:href="#routeGradient"
id="linearGradient29"
gradientUnits="userSpaceOnUse"
x1="95"
y1="0"
x2="430"
y2="0"
gradientTransform="translate(18.248473,-20.855397)" /><linearGradient
inkscape:collect="always"
xlink:href="#routeGradient"
id="linearGradient30"
gradientUnits="userSpaceOnUse"
x1="95"
y1="0"
x2="430"
y2="0"
gradientTransform="translate(18.248473,-20.855397)" /><linearGradient
inkscape:collect="always"
xlink:href="#routeGradient"
id="linearGradient31"
gradientUnits="userSpaceOnUse"
x1="95"
y1="0"
x2="430"
y2="0"
gradientTransform="translate(18.248473,-20.855397)" /><filter
inkscape:collect="always"
style="color-interpolation-filters:sRGB"
id="filter215"
x="-0.0021624053"
y="-0.0047179751"
width="1.0043248"
height="1.009436"><feGaussianBlur
inkscape:collect="always"
stdDeviation="1.0812026"
id="feGaussianBlur215" /></filter><filter
inkscape:collect="always"
style="color-interpolation-filters:sRGB"
id="filter215-8"
x="-0.003844276"
y="-0.0074139609"
width="1.0076886"
height="1.0148279"><feGaussianBlur
inkscape:collect="always"
stdDeviation="1.0812026"
id="feGaussianBlur215-8" /></filter><filter
inkscape:collect="always"
style="color-interpolation-filters:sRGB"
id="filter215-6"
x="-0.0051897726"
y="-0.0047179751"
width="1.0103795"
height="1.009436"><feGaussianBlur
inkscape:collect="always"
stdDeviation="1.0812026"
id="feGaussianBlur215-0" /></filter></defs><!-- Transparent background; no embedded raster image. --><rect
style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:50;stroke-linecap:round;stroke-miterlimit:0.8;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter215)"
id="rect1"
width="1200"
height="550"
x="26.069246"
y="20.855396"
inkscape:label="background_full"
rx="100"
inkscape:highlight-color="#000000"
inkscape:export-filename="Ngino_logo_full.png"
inkscape:export-xdpi="153.60001"
inkscape:export-ydpi="153.60001" /><g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="text"
style="display:inline"
inkscape:export-filename="Ngino_logo_text.png"
inkscape:export-xdpi="125.532"
inkscape:export-ydpi="125.532"><rect
style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:50;stroke-linecap:round;stroke-miterlimit:0.8;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter215-8)"
id="rect1-1"
width="675"
height="350"
x="458.96759"
y="152.75374"
inkscape:label="background_text"
rx="100"
inkscape:highlight-color="#000000"
inkscape:export-filename="Ngino_logo_full.png"
inkscape:export-xdpi="153.60001"
inkscape:export-ydpi="153.60001" /><text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:37.3333px;line-height:1.25;font-family:Sans;-inkscape-font-specification:'Sans, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;display:inline;fill:#000000;fill-opacity:1"
x="503.97342"
y="430.72964"
id="text2"
inkscape:label="sub"><tspan
sodipodi:role="line"
id="tspan2"
x="503.97342"
y="430.72964">R E V E R S E P R O X Y F O R A I</tspan></text><text
xml:space="preserve"
style="font-weight:900;font-size:170.667px;line-height:1.25;font-family:Montserrat;-inkscape-font-specification:'Montserrat, Heavy';display:inline;fill:#000000;fill-opacity:1"
x="496.78931"
y="357.61456"
id="text1"
inkscape:label="main"
inkscape:export-filename="Ngino_logo_text.png"
inkscape:export-xdpi="125.532"
inkscape:export-ydpi="125.532"><tspan
sodipodi:role="line"
id="tspan1"
x="496.78931"
y="357.61456">Ngino</tspan></text></g><!-- Connection nodes --><!-- Wordmark --><rect
style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:50;stroke-linecap:round;stroke-miterlimit:0.8;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter215-6)"
id="rect1-8"
width="500"
height="550"
x="28.825022"
y="21.104044"
inkscape:label="background_logo"
rx="100"
inkscape:highlight-color="#000000"
inkscape:export-filename="Ngino_logo_full.png"
inkscape:export-xdpi="153.60001"
inkscape:export-ydpi="153.60001" /><g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Logo"
style="display:inline"
inkscape:export-filename="Ngino_Logo_Symbol.png"
inkscape:export-xdpi="125.532"
inkscape:export-ydpi="125.532"><g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Body"
style="display:inline"><path
d="m 115.82078,398.64766 c -22.393081,-46.4277 -14.055,-69.1446 2.64154,-97.02036 C 143.97964,277.26884 147,275.30346 190,275.30346 h 56 c 36,0 53.48269,-18.60692 63.48269,-46.60692 6,-16 4,-43 5,-65 0,-20 6,-37.30346 16,-52.30346 l 0.39308,-26.965381 C 333.17923,71.427699 347,53 356,48 l 2,47 c 6,-19 17,-34 31,-40 l -1.69653,38.268839 c 13,3 22.69653,17.517311 28.69653,28.517311 18,3 29,6.17923 31,22.17923 0,14 -3.78615,18.08961 -16.78615,20.08961 l -37,7.21385 c 12,24 28.91039,76.76578 36.91039,108.76578 14,58 -6.24848,146.8554 -29.24848,202.8554 l -13,29 h -47 l -8,-35 c -4,-22 -23,-38 -48,-38 h -53.50305 c -27,0 -45,16 -49,40 l -6,33 h -47.12424 z"
id="path2"
sodipodi:nodetypes="ccsscccccccccccccccccsscccc"
style="display:inline;fill:none;stroke:url(#linearGradient27)"
stroke-width="13"
inkscape:label="body" /></g><g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="Symbol"
style="display:inline"><g
fill="url(#routeGradient)"
id="g9"
style="display:inline;fill:url(#linearGradient27)"
transform="translate(18.248473,-20.855397)"
inkscape:label="Symbol_circles"><circle
cx="354"
cy="398"
r="15"
id="circle9"
style="fill:url(#linearGradient26)"
inkscape:label="ball_fork_down" /><circle
cx="354"
cy="356"
r="15"
id="circle8"
style="display:inline;fill:url(#linearGradient25)"
inkscape:label="ball_fork_mid" /><circle
cx="354"
cy="314"
r="15"
id="circle7"
style="display:inline;fill:url(#linearGradient24)"
inkscape:label="ball_fork_up" /><circle
cx="130.83504"
cy="356"
r="15"
id="circle6"
style="display:inline;fill:url(#linearGradient10)"
inkscape:label="ball_connector" /></g><g
fill="none"
stroke="url(#routeGradient)"
stroke-width="13"
stroke-linecap="round"
stroke-linejoin="round"
id="g6"
style="display:inline"
inkscape:label="Symbol_paths"><!-- Llama silhouette --><!-- One input, three routed endpoints --><path
d="m 254.24847,335.1446 c 29,0 42,42 78,42 h 40"
id="path6"
style="display:inline;stroke:url(#linearGradient31)"
inkscape:label="fork_down" /><path
d="m 254.24847,335.1446 h 118"
id="path5"
style="display:inline;stroke:url(#linearGradient28)"
inkscape:label="fork_mid" /><path
d="m 254.24847,335.1446 c 29,0 42,-42 78,-42 h 40"
id="path4"
style="display:inline;stroke:url(#linearGradient30)"
inkscape:label="fork_up" /><path
d="M 150.38696,335.1446 H 254.24847"
id="path3"
sodipodi:nodetypes="cc"
style="display:inline;stroke:url(#linearGradient29)"
inkscape:label="connector"
inkscape:export-filename="Ngino_logo_symbol.png"
inkscape:export-xdpi="153.60001"
inkscape:export-ydpi="153.60001" /></g></g></g><metadata
id="metadata215"><rdf:RDF><cc:Work
rdf:about=""><dc:title>Ngino logo</dc:title></cc:Work></rdf:RDF></metadata></svg>

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because it is too large Load Diff
+21
View File
@@ -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
{ {
+212
View File
@@ -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);
}
}
+113
View File
@@ -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);
}
}
+856
View File
@@ -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
{ {
+11
View File
@@ -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
{ {
+948
View File
@@ -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);
+155
View File
@@ -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;
+111
View File
@@ -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);
+125
View File
@@ -0,0 +1,125 @@
namespace Ngino.Server;
internal sealed class KeepaliveService : BackgroundService
{
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(10);
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(30);
private readonly TunnelHub _hub;
private readonly ManagementStore _managementStore;
private readonly ILogger<KeepaliveService> _logger;
public KeepaliveService(TunnelHub hub, ManagementStore managementStore, ILogger<KeepaliveService> logger)
{
_hub = hub;
_managementStore = managementStore;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ApplyKeepaliveAsync(stoppingToken);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Keepalive cycle failed.");
}
await Task.Delay(CheckInterval, stoppingToken);
}
}
private async Task ApplyKeepaliveAsync(CancellationToken cancellationToken)
{
var members = _managementStore.ListAllGroupClients();
if (members.Count == 0)
{
return;
}
var snapshots = _hub.ClientSnapshots;
if (snapshots.Count == 0)
{
return;
}
foreach (var member in members)
{
if (string.IsNullOrWhiteSpace(member.Model))
{
continue;
}
var matchingCandidates = snapshots
.Select(snapshot => new KeepaliveCandidate(
snapshot.Id,
HasModel(snapshot.Models, member.Model),
HasModel(snapshot.ActiveModels, member.Model)))
.ToList();
var actions = KeepaliveCoordinator.PlanActions([member], matchingCandidates);
foreach (var action in actions)
{
try
{
var connection = _hub.Get(action.ClientId);
if (connection is null)
{
continue;
}
var response = await connection.SendModelCommandAsync(
action.Command,
action.Model,
payloadJson: null,
CommandTimeout,
cancellationToken);
if (response.StatusCode is < 200 or >= 300)
{
_logger.LogWarning(
"Keepalive {Command} for model {Model} on client {ClientId} returned HTTP {StatusCode}.",
action.Command,
action.Model,
action.ClientId,
response.StatusCode);
}
}
catch (Exception exception)
{
_logger.LogWarning(exception,
"Keepalive {Command} for model {Model} on client {ClientId} failed.",
action.Command,
action.Model,
action.ClientId);
}
}
}
}
private static bool HasModel(IEnumerable<string> models, string requestedModel)
{
if (string.IsNullOrWhiteSpace(requestedModel))
{
return false;
}
var requested = requestedModel.Trim();
return models.Any(model => ModelNamesMatch(requested, model));
}
private static bool ModelNamesMatch(string requested, string available)
{
return string.Equals(requested, available, StringComparison.OrdinalIgnoreCase)
|| string.Equals(StripLatestTag(requested), StripLatestTag(available), StringComparison.OrdinalIgnoreCase);
}
private static string StripLatestTag(string model) =>
model.EndsWith(":latest", StringComparison.OrdinalIgnoreCase)
? model[..^":latest".Length]
: model;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.Identity;
namespace Ngino.Server.Models;
internal sealed class ApplicationUser : IdentityUser
{
}
+29
View File
@@ -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
{ {
+345
View File
@@ -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)
{ {
@@ -43,7 +46,21 @@ internal static class ReverseProxyEndpoint
return; return;
} }
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore); var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
if (!billingCheck.Allowed)
{
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
await context.Response.WriteAsJsonAsync(new
{
error = "Insufficient balance.",
balance = billingCheck.Balance,
currency = billingCheck.Currency,
threshold = billingCheck.Threshold
}, context.RequestAborted);
return;
}
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath); var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
if (!pathTokenRemoved) if (!pathTokenRemoved)
@@ -57,6 +74,12 @@ 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)) if (!groupAccess.IsClientAllowed(pathClientId))
@@ -76,7 +99,8 @@ internal static class ReverseProxyEndpoint
loggerFactory, loggerFactory,
embeddingCache, embeddingCache,
managementStore, managementStore,
groupAccess); groupAccess,
auth.UserKeyId);
return; return;
} }
@@ -124,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(
@@ -145,7 +170,21 @@ internal static class ReverseProxyEndpoint
return; return;
} }
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore); var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
if (!billingCheck.Allowed)
{
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
await context.Response.WriteAsJsonAsync(new
{
error = "Insufficient balance.",
balance = billingCheck.Balance,
currency = billingCheck.Currency,
threshold = billingCheck.Threshold
}, context.RequestAborted);
return;
}
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
if (!groupAccess.IsClientAllowed(clientId)) if (!groupAccess.IsClientAllowed(clientId))
{ {
context.Response.StatusCode = StatusCodes.Status403Forbidden; context.Response.StatusCode = StatusCodes.Status403Forbidden;
@@ -165,7 +204,8 @@ internal static class ReverseProxyEndpoint
loggerFactory, loggerFactory,
embeddingCache, embeddingCache,
managementStore, managementStore,
groupAccess); groupAccess,
auth.UserKeyId);
} }
private static async Task ForwardToClientAsync( private static async Task ForwardToClientAsync(
@@ -178,7 +218,8 @@ internal static class ReverseProxyEndpoint
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
EmbeddingCache embeddingCache, EmbeddingCache embeddingCache,
ManagementStore managementStore, ManagementStore managementStore,
GroupAccess? groupAccess = null) GroupAccess? groupAccess = null,
string? userKeyId = null)
{ {
var clientAccess = managementStore.GetClientAccess(clientId); var clientAccess = managementStore.GetClientAccess(clientId);
if (clientAccess.IsDisabled) if (clientAccess.IsDisabled)
@@ -220,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) =>
@@ -269,20 +311,29 @@ internal static class ReverseProxyEndpoint
return true; return true;
} }
private static GroupAccess ResolveGroupAccess(string? apiKeyId, ManagementStore managementStore) private static GroupAccess ResolveGroupAccess(string? userKeyId, ManagementStore managementStore)
{ {
if (string.IsNullOrWhiteSpace(apiKeyId)) if (string.IsNullOrWhiteSpace(userKeyId))
{ {
return GroupAccess.Unrestricted; return GroupAccess.Unrestricted;
} }
return managementStore.ResolveGroupAccess(apiKeyId); return managementStore.ResolveGroupAccess(userKeyId);
} }
private static bool IsTagsRequest(HttpRequest request, PathString proxyPath) => private static bool IsTagsRequest(HttpRequest request, PathString proxyPath) =>
HttpMethods.IsGet(request.Method) HttpMethods.IsGet(request.Method)
&& string.Equals(proxyPath.Value, "/api/tags", StringComparison.OrdinalIgnoreCase); && 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( private static async Task HandleTagsAsync(
HttpContext context, HttpContext context,
TunnelHub hub, TunnelHub hub,
@@ -339,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)
@@ -464,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;
@@ -488,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(
@@ -535,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
{ {
@@ -549,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; }
+311
View File
@@ -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,9 +1,9 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Threading; using System.Threading;
using ReverseLlama.Protocol; using Ngino.Protocol;
namespace ReverseLlama.Server; namespace Ngino.Server;
internal sealed class TunnelHub internal sealed class TunnelHub
{ {
@@ -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
} }
+15
View File
@@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"CORS": {
"AllowedOrigins": [ "*" ],
"AllowedMethods": [ "GET", "POST", "PUT", "DELETE", "PATCH" ],
"AllowedHeaders": [ "Content-Type", "Authorization" ],
"AllowCredentials": false
},
"AllowedHosts": "*"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -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 {
@@ -479,6 +512,43 @@ tr:last-child td {
overflow-wrap: anywhere; 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;
@@ -494,7 +564,7 @@ tr:last-child td {
} }
.nav { .nav {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(5, 1fr);
} }
.nav a { .nav a {
@@ -518,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;
File diff suppressed because it is too large Load Diff
@@ -3,24 +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="#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>
@@ -44,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;
}));
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

-141
View File
@@ -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";
}
-614
View File
@@ -1,614 +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.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("/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.BadRequest(new { error = exception.Message });
}
});
api.MapDelete("/api-keys/{id}", (string id, ManagementStore store) =>
store.DeleteApiKey(id)
? Results.NoContent()
: Results.NotFound(new { error = $"API 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);
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("/api-keys/groups", (ManagementStore store) =>
Results.Json(store.ListApiKeyGroups()));
api.MapPut("/api-keys/{id}/groups", (string id, SetApiKeyGroupsRequest request, ManagementStore store) =>
{
var keys = store.ListApiKeys();
if (!keys.Any(k => k.Id == id))
{
return Results.NotFound(new { error = $"API key '{id}' was not found." });
}
try
{
store.SetApiKeyGroups(id, request.GroupIds ?? []);
return Results.Ok(new { apiKeyId = id, groupIds = store.GetApiKeyGroupIds(id) });
}
catch (Exception exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
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(),
groups = store.ListGroups(),
apiKeyGroups = store.ListApiKeyGroups(),
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.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 CreateGroupRequest(string? Name);
internal sealed record UpdateGroupRequest(string Name);
internal sealed record AddGroupClientRequest(
string? ClientId,
string? Model,
string? ClientPattern);
internal sealed record SetApiKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
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);
File diff suppressed because it is too large Load Diff
-190
View File
@@ -1,190 +0,0 @@
using System.Data.SqlClient;
using System.Net.WebSockets;
using ElmahCore;
using ElmahCore.Mvc;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using ReverseLlama.Protocol;
using ReverseLlama.Server;
var builder = WebApplication.CreateBuilder(args);
var settings = ServerSettings.FromConfiguration(builder.Configuration);
builder.Services.AddSingleton(settings);
builder.Services.AddSingleton<TunnelHub>();
builder.Services.AddSingleton<EmbeddingCache>();
builder.Services.AddSingleton<ManagementStore>();
builder.Services.AddElmah<ElmahCore.MySql.MySqlErrorLog>().Configure<ElmahOptions>(
options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection"));
if (settings.Keycloak.IsConfigured)
{
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.Cookie.Name = "ReverseLlama.Admin";
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.LoginPath = "/admin/login";
options.LogoutPath = "/admin/logout";
})
.AddOpenIdConnect(options =>
{
options.Authority = settings.Keycloak.Authority;
options.ClientId = settings.Keycloak.ClientId;
options.ClientSecret = settings.Keycloak.ClientSecret;
options.RequireHttpsMetadata = settings.Keycloak.RequireHttpsMetadata;
options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseMode = OpenIdConnectResponseMode.Query;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.CorrelationCookie.SameSite = SameSiteMode.Lax;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.NonceCookie.SameSite = SameSiteMode.Lax;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Events = new OpenIdConnectEvents
{
OnRemoteFailure = context =>
{
var errorLog = context.HttpContext.RequestServices.GetService<ErrorLog>();
if (context.Failure is not null)
{
errorLog?.Log(new Error(context.Failure));
}
context.HandleResponse();
context.Response.Redirect("/admin/auth-error");
return Task.CompletedTask;
}
};
});
}
builder.Services.AddAuthorization();
var app = builder.Build();
var managementStore = app.Services.GetRequiredService<ManagementStore>();
var tunnelHub = app.Services.GetRequiredService<TunnelHub>();
managementStore.SetConnectedClientProvider(() => tunnelHub.ClientSnapshots.Select(c => c.Id));
if (settings.Keycloak.IsConfigured)
{
app.UseAuthentication();
app.UseAuthorization();
}
app.UseElmah();
app.Use(async (context, next) =>
{
context.Response.Headers.AccessControlAllowOrigin = "*";
context.Response.Headers.AccessControlAllowMethods = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
context.Response.Headers.AccessControlAllowHeaders = "Content-Type, Authorization";
if (HttpMethods.IsOptions(context.Request.Method))
{
context.Response.StatusCode = StatusCodes.Status204NoContent;
return;
}
await next();
});
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
});
app.MapAdminEndpoints(settings);
app.MapGet("/", (TunnelHub hub) =>
Results.Json(new
{
status = "ok",
connected = hub.HasClient,
pendingRequests = hub.PendingRequestCount,
clients = hub.ClientsSnapshot.Count
}));
app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) =>
{
// Query token allowed so the status page can be checked in a browser.
if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
{
return Results.Unauthorized();
}
return Results.Json(new
{
connected = hub.HasClient,
pendingRequests = hub.PendingRequestCount,
tunnelPath = serverSettings.TunnelPath,
embeddingCache = new
{
available = embeddingCache.IsAvailable,
count = embeddingCache.Count,
databasePath = embeddingCache.DatabasePath,
lastError = embeddingCache.LastError
},
management = new
{
available = managementStore.IsAvailable,
databasePath = managementStore.DatabasePath,
lastError = managementStore.LastError
},
clients = hub.ClientsSnapshot
});
});
app.Map(settings.TunnelPath, async (HttpContext context, TunnelHub hub, ServerSettings serverSettings, ManagementStore managementStore) =>
{
if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync($"Missing or invalid {ProtocolConstants.TokenHeader}.", context.RequestAborted);
return;
}
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("This endpoint only accepts WebSocket tunnel connections.", context.RequestAborted);
return;
}
var clientId = context.Request.Headers[ProtocolConstants.ClientIdHeader].FirstOrDefault();
if (string.IsNullOrWhiteSpace(clientId))
{
clientId = $"anonymous-{Guid.NewGuid():n}";
}
using var socket = await context.WebSockets.AcceptWebSocketAsync();
await hub.AcceptAsync(clientId, socket, context.RequestAborted);
});
app.Map("/clients/{clientId}/{**path}", ReverseProxyEndpoint.HandleClientAsync);
app.Map("/{**path}", ReverseProxyEndpoint.HandleRootAsync)
.WithOrder(1000);
var elmahService = app.Services.GetRequiredService<ErrorLog>();
try
{
app.Run();
}
catch (Exception exception)
{
elmahService.Log(new Error(exception));
throw;
}
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="ElmahCore" Version="2.1.2" />
<PackageReference Include="ElmahCore.MySql" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.28" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.28" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="System.Text.Encodings.Web" Version="8.0.0" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<ProjectReference Include="..\ReverseLlama.Protocol\ReverseLlama.Protocol.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -1,202 +0,0 @@
using ReverseLlama.Protocol;
namespace ReverseLlama.Server;
internal static class TokenAuthentication
{
private static readonly PathString PathTokenPrefix = new("/token");
public static AuthResult Authorize(
HttpRequest request,
ServerSettings settings,
ManagementStore managementStore,
bool allowQueryToken,
bool allowPathToken = false)
{
if (string.IsNullOrWhiteSpace(settings.Token) && !managementStore.HasApiKeys)
{
return AuthResult.Success(null);
}
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues))
{
foreach (var value in headerValues)
{
var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true);
if (result.IsAuthorized)
{
return result;
}
}
}
if (request.Headers.TryGetValue("Authorization", out var authorizationValues))
{
foreach (var value in authorizationValues)
{
if (TryGetBearerToken(value, out var bearerToken))
{
var result = AuthorizeToken(bearerToken, settings, managementStore, updateApiKeyLastUsed: true);
if (result.IsAuthorized)
{
return result;
}
}
}
}
if (allowPathToken
&& TryGetPathToken(request.Path, out var pathToken, out _))
{
var result = AuthorizeToken(pathToken, settings, managementStore, updateApiKeyLastUsed: true);
if (result.IsAuthorized)
{
return result;
}
}
if (allowQueryToken
&& request.Query.TryGetValue("token", out var queryValues))
{
foreach (var value in queryValues)
{
var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true);
if (result.IsAuthorized)
{
return result;
}
}
}
return AuthResult.Failure;
}
public static bool IsAuthorized(
HttpRequest request,
ServerSettings settings,
ManagementStore managementStore,
bool allowQueryToken,
bool allowPathToken = false) =>
Authorize(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized;
public static bool TryRemovePathToken(
PathString path,
ServerSettings settings,
ManagementStore managementStore,
out PathString remainingPath)
{
remainingPath = path;
if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath)
|| !IsTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: false))
{
return false;
}
remainingPath = string.IsNullOrEmpty(tokenRemainingPath.Value)
? new PathString("/")
: tokenRemainingPath;
return true;
}
public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) =>
TryGetBearerToken(value, out var token)
&& IsTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false);
private static AuthResult AuthorizeToken(
string? token,
ServerSettings settings,
ManagementStore managementStore,
bool updateApiKeyLastUsed)
{
if (string.IsNullOrWhiteSpace(token))
{
return AuthResult.Failure;
}
if (!string.IsNullOrWhiteSpace(settings.Token)
&& string.Equals(token, settings.Token, StringComparison.Ordinal))
{
return AuthResult.Success(null);
}
var apiKeyId = managementStore.GetApiKeyId(token);
if (apiKeyId is not null)
{
managementStore.IsApiKeyValid(token, updateApiKeyLastUsed);
return AuthResult.Success(apiKeyId);
}
return AuthResult.Failure;
}
public static bool IsTokenAuthorized(
string? token,
ServerSettings settings,
ManagementStore managementStore,
bool updateApiKeyLastUsed) =>
AuthorizeToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized;
private static bool TryGetBearerToken(string? authorization, out string token)
{
token = "";
if (string.IsNullOrWhiteSpace(authorization)
|| !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return false;
}
token = authorization["Bearer ".Length..].Trim();
return token.Length > 0;
}
private static bool TryGetPathToken(PathString path, out string pathToken, out PathString remainingPath)
{
pathToken = "";
remainingPath = PathString.Empty;
if (!path.StartsWithSegments(PathTokenPrefix, out var pathAfterPrefix))
{
return false;
}
var value = pathAfterPrefix.Value ?? "";
if (value.Length <= 1 || value[0] != '/')
{
return false;
}
var nextSlash = value.IndexOf('/', 1);
pathToken = nextSlash < 0
? value[1..]
: value[1..nextSlash];
if (string.IsNullOrEmpty(pathToken))
{
return false;
}
remainingPath = nextSlash < 0
? PathString.Empty
: new PathString(value[nextSlash..]);
return true;
}
}
internal sealed class AuthResult
{
public static AuthResult Failure { get; } = new(false, null);
public static AuthResult Success(string? apiKeyId) => new(true, apiKeyId);
public bool IsAuthorized { get; }
public string? ApiKeyId { get; }
private AuthResult(bool isAuthorized, string? apiKeyId)
{
IsAuthorized = isAuthorized;
ApiKeyId = apiKeyId;
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
File diff suppressed because it is too large Load Diff
@@ -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));
}
} }