This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new c39dfa9 feat: take the friction out of the first run
c39dfa9 is described below
commit c39dfa9f985f87fa40ff9ec8de13e514ea7c571b
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 11:43:10 2026 +0800
feat: take the friction out of the first run
---
README.md | 62 ++++--
README_zh.md | 58 +++--
conf/app.conf | 5 +
conf/conf.go | 33 ++-
controllers/account.go | 8 +-
controllers/agent_provider.go | 6 +-
controllers/proxy.go | 33 +++
controllers/system.go | 13 ++
main.go | 16 +-
object/provider.go | 33 +++
object/setting.go | 27 +++
routers/router.go | 1 +
scripts/install.ps1 | 31 ++-
scripts/install.sh | 68 +++++-
service/startup.go | 25 ++-
util/daemon.go | 156 +++++++++++++
util/{request.go => daemon_unix.go} | 30 +--
util/{request.go => daemon_windows.go} | 32 ++-
util/port.go | 15 +-
util/request.go | 13 ++
util/string.go | 10 +
web/src/App.tsx | 14 ++
web/src/backend/MiscBackend.ts | 5 +
web/src/components/EnvSnippet.tsx | 4 +-
web/src/components/ProviderCard.tsx | 9 +-
web/src/components/ProviderGridCard.tsx | 211 ++++++++++++++++++
web/src/components/shared/app-sidebar.tsx | 5 +-
web/src/components/shared/page-header.tsx | 23 +-
web/src/lib/providers.ts | 30 ++-
web/src/lib/relayToken.ts | 48 ++++
web/src/locales/en/data.json | 13 +-
web/src/locales/zh/data.json | 13 +-
web/src/nav.ts | 3 +
web/src/pages/ProviderListPage.tsx | 359 ++++++++++--------------------
web/src/pages/SettingPage.tsx | 6 +-
web/src/types.ts | 1 +
36 files changed, 1058 insertions(+), 361 deletions(-)
diff --git a/README.md b/README.md
index 34a2019..0462833 100644
--- a/README.md
+++ b/README.md
@@ -53,11 +53,13 @@ On Windows, in PowerShell:
irm
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/install.ps1
| iex
```
-Either one downloads the build for this machine, unpacks it into
`~/.local/share/casbin-gateway` (`%LOCALAPPDATA%\casbin-gateway` on Windows),
puts a `casbin-gateway` command on your PATH, and starts it. Then open:
+Either one downloads the build for this machine, unpacks it into
`~/.local/share/casbin-gateway` (`%LOCALAPPDATA%\casbin-gateway` on Windows),
puts a `casbin-gateway` command on your PATH, starts it in the background, and
arranges for it to start again when you log in. The terminal you installed from
is yours again straight away. Then open:
-**http://localhost:17000** — sign in as `admin` with the password `123`, and
change it from **My Account**.
+**http://localhost:17000** — no sign-in: Gateway serves this machine only and
signs the local admin in on sight.
-That is the whole installation. Gateway keeps its data in a SQLite file inside
its own directory, and signs you in against its own user table.
+That is the whole installation. Gateway keeps its data in a SQLite file inside
its own directory.
+
+The password behind that account is `admin` / `123`, and it only matters if
you open Gateway to the network — see [Serving other
machines](#serving-other-machines).
### What to do next
@@ -76,15 +78,15 @@ Agents are found by reading the user accounts, home
directories and install path
This is what fills **LLM Records**, and what lets Gateway keep the vendor key
instead of the agent.
1. **Providers** → **Add**: pick the type (OpenAI- or Anthropic-compatible),
paste the vendor base URL and API key, and list the models it serves.
-2. **Agents** → open an agent → pick that provider.
-3. Copy the environment snippet the page shows, and start the agent from a
shell that has it:
+2. **Agents** → open an agent → pick that provider. For an agent whose
configuration format Gateway knows, **Write configuration** puts it in the
agent's own file — **Preview** shows exactly what that will be first, and
**Restore** undoes it. Picking a different provider afterwards rewrites the
file on the spot, so switching from then on is one click, from either page.
+3. For any other agent, copy the environment snippet the page shows and start
the agent from a shell that has it:
```bash
export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code"
-export ANTHROPIC_AUTH_TOKEN="casbin-gateway"
+export ANTHROPIC_AUTH_TOKEN="cg-..."
```
-The token is a placeholder — the agent refuses to start without one, and
Gateway authenticates upstream with the provider's own key.
+The token is Gateway's own relay token, not a vendor key: the agent refuses to
start without something in that variable, and Gateway authenticates upstream
with the provider's key instead. The snippet on the page already has the real
value filled in.
### No API key: keep the sign-in the agent already has
@@ -96,11 +98,21 @@ Codex is the exception: its ChatGPT sign-in talks to a
different API than the ch
### Stopping, upgrading, removing
-- **Stop**: `Ctrl-C`. **Start again**: `casbin-gateway`, from any directory —
the command is a wrapper that always starts Gateway in its install directory,
where its data lives.
+- **Stop**: `casbin-gateway stop`. **Start again**: `casbin-gateway start`.
**Check**: `casbin-gateway status`. All three work from any directory — the
command is a wrapper that always starts Gateway in its install directory, where
its data lives.
+- **Run in the foreground** instead, to watch it: `casbin-gateway`, stopped
with `Ctrl-C`. In the background its console output goes to
`logs/casbin-gateway.out`.
- **Upgrade**: run the install command again. Your database and settings are
untouched.
-- **Remove**: delete `~/.local/share/casbin-gateway` and
`~/.local/bin/casbin-gateway` (on Windows, `%LOCALAPPDATA%\casbin-gateway` and
its PATH entry).
+- **Remove**: delete `~/.local/share/casbin-gateway` and
`~/.local/bin/casbin-gateway` (on Windows, `%LOCALAPPDATA%\casbin-gateway` and
its PATH entry), plus the startup entry the installer names when it finishes.
+
+Set `INSTALL_DIR` to install somewhere else, `NO_START=1` to install without
starting, or `NO_AUTOSTART=1` to install without starting at login.
+
+### Serving other machines
+
+Gateway binds `127.0.0.1` by default, because two things are wide open to
whoever can reach the port: the UI signs the local admin in without asking, and
`/v1` relays with the API keys stored here. Both are exactly what you want from
a local tool and neither should be offered to a network.
+
+To serve other machines anyway, set `httpaddr = 0.0.0.0` in `conf/app.conf`,
and then:
-Set `INSTALL_DIR` to install somewhere else, or `NO_START=1` to install
without starting.
+1. **Change the admin password** from **My Account**. The auto sign-in stops
at the first request that is not from this machine, so from then on the
password is the only thing in the way.
+2. **Send the relay token** with every request to `/v1`. Gateway generates one
on first start and shows it under **Settings → Security**; the environment
snippets on the Providers and Agents pages already carry it, and it is what
Gateway writes into the configuration of an agent it switches. Requests from
this machine never need it.
**These are nightly builds**, rebuilt from `master` on every push and
published as the
[`nightly`](https://github.com/apache/casbin-gateway/releases/tag/nightly)
pre-release. They exist so that Gateway can be tried without a Go and Node
toolchain; anything else should be built from a source release.
@@ -140,6 +152,7 @@ Everything is optional. Settings are changed on the
**Settings** page of the web
| Setting | Default | What it does |
| --- | --- | --- |
| `httpport` | `17000` | Port of the web UI and the REST API |
+| `httpaddr` | `127.0.0.1` | Interface the web UI binds to — see [Serving
other machines](#serving-other-machines) |
| `driverName` / `dataSourceName` | `sqlite` / `./data/casbin-gateway.db` |
Where data is stored |
| `gatewayEnabled` | `false` | Turns the reverse-proxy WAF on |
| `gatewayHttpPort` / `gatewayHttpsPort` | `80` / `443` | Ports the proxy
listens on |
@@ -150,19 +163,20 @@ Everything is optional. Settings are changed on the
**Settings** page of the web
Gateway prints what it is actually doing when it starts, so the result can be
checked instead of the file:
```
-+----------------------------------------------------------------------------+
-| Casbin Gateway |
-+----------------+-----------------------------------------------------------+
-| Management UI | http://localhost:17000 |
-| Settings | Settings page, seeded from conf/app.conf |
-| Web UI files | web/build |
-| Reverse proxy | enabled |
-| Gateway HTTP | :8080 |
-| Gateway HTTPS | :8443 |
-| Database | sqlite, file "./data/casbin-gateway.db" (connected) |
-| Sign-in | built-in user table, Casdoor is not configured |
-| App dir | ./data/apps |
-+----------------+-----------------------------------------------------------+
++---------------------------------------------------------------------+
+| Casbin Gateway |
++---------------------------------------------------------------------+
+| Management UI | http://localhost:17000 (this machine only) |
+| Settings | Settings page, seeded from conf/app.conf |
+| Web UI files | web/build |
+| Reverse proxy | enabled |
+| Gateway HTTP | :8080 |
+| Gateway HTTPS | :8443 |
+| Database | sqlite, file "./data/casbin-gateway.db" (connected) |
+| Sign-in | built-in user table, Casdoor is not configured |
+| Relay auth | this machine only, no token needed |
+| App dir | ./data/apps |
++---------------------------------------------------------------------+
```
If a port is taken, Gateway says which process holds it and stops, rather than
starting half-configured.
@@ -221,7 +235,7 @@ cd web && yarn install && yarn build
go run main.go
```
-Then open http://localhost:17000 and sign in as `admin` with the password
`123`, same as an installed Gateway. The SQLite database is created on first
start; there is no database server to install.
+Then open http://localhost:17000, where you are signed in as the local admin,
same as an installed Gateway. The SQLite database is created on first start;
there is no database server to install.
### Frontend development
diff --git a/README_zh.md b/README_zh.md
index bd6d839..39aafdd 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -53,9 +53,9 @@ Windows,在 PowerShell 中:
irm
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/install.ps1
| iex
```
-两者都会下载适配本机的构建产物,解压到 `~/.local/share/casbin-gateway`(Windows 上是
`%LOCALAPPDATA%\casbin-gateway`),把 `casbin-gateway` 命令加入 PATH,然后启动它。接着打开:
+两者都会下载适配本机的构建产物,解压到 `~/.local/share/casbin-gateway`(Windows 上是
`%LOCALAPPDATA%\casbin-gateway`),把 `casbin-gateway` 命令加入
PATH,在后台启动它,并设置成开机(登录)自启。安装用的那个终端窗口立刻就还给你。接着打开:
-**http://localhost:17000** —— 用 `admin` 和密码 `123` 登录,然后在 **My Account** 里改掉它。
+**http://localhost:17000** —— 不用登录:Gateway 只服务本机,本机访问会直接以管理员身份进入。
安装到此为止。Gateway 把数据存在自己目录下的一个 SQLite 文件里,登录也走它自己的用户表。
@@ -76,15 +76,15 @@ Agent 是通过读取 **Gateway 所在机器**的用户账户、home 目录和
这是 **LLM Records** 的数据来源,也是让 Gateway 而不是 Agent 持有厂商 Key 的方式。
1. **Providers** → **Add**:选类型(OpenAI 兼容或 Anthropic 兼容),填厂商的 base URL 和 API
Key,并列出它提供的模型。
-2. **Agents** → 打开一个 Agent → 选中该 Provider。
-3. 复制页面显示的环境变量片段,在设置了这些变量的 shell 里启动 Agent:
+2. **Agents** → 打开一个 Agent → 选中该 Provider。如果这个 Agent 的配置格式 Gateway 认识,点
**Write configuration** 就会写进它自己的配置文件 —— **Preview** 会先原样列出将要写入的内容,**Restore**
可以还原。写过一次之后再换 Provider,文件会立即被改写,所以之后切换只需要点一下,两个页面上都可以。
+3. 其他 Agent 则复制页面显示的环境变量片段,在设置了这些变量的 shell 里启动:
```bash
export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code"
-export ANTHROPIC_AUTH_TOKEN="casbin-gateway"
+export ANTHROPIC_AUTH_TOKEN="cg-..."
```
-这个 token 只是占位符 —— Agent 没有它就拒绝启动,而 Gateway 会用 Provider 自己的 Key 去认证上游。
+这个 token 是 Gateway 自己的中继令牌,不是厂商的 Key:Agent 没有它就拒绝启动,而 Gateway 会用 Provider 自己的
Key 去认证上游。页面上的片段里已经填好了真实的值。
### 没有 API Key:沿用 Agent 已有的登录
@@ -96,11 +96,21 @@ Codex 是例外:它的 ChatGPT 登录走的是另一套 API,不是 Gateway
### 停止、升级、卸载
-- **停止**:`Ctrl-C`。**再次启动**:在任意目录执行 `casbin-gateway` ——
这个命令是一个包装脚本,总是在安装目录(数据所在的地方)里启动 Gateway。
+- **停止**:`casbin-gateway stop`。**再次启动**:`casbin-gateway
start`。**查看状态**:`casbin-gateway status`。三条命令在任意目录都能用 ——
这个命令是一个包装脚本,总是在安装目录(数据所在的地方)里启动 Gateway。
+- **前台运行**(想盯着它跑的时候):`casbin-gateway`,用 `Ctrl-C` 停止。后台运行时控制台输出写在
`logs/casbin-gateway.out`。
- **升级**:再跑一遍安装命令。数据库和设置不受影响。
-- **卸载**:删除 `~/.local/share/casbin-gateway` 和
`~/.local/bin/casbin-gateway`(Windows 上是 `%LOCALAPPDATA%\casbin-gateway` 及其
PATH 条目)。
+- **卸载**:删除 `~/.local/share/casbin-gateway` 和
`~/.local/bin/casbin-gateway`(Windows 上是 `%LOCALAPPDATA%\casbin-gateway` 及其
PATH 条目),以及安装脚本结束时告诉你的那个自启动条目。
-设置 `INSTALL_DIR` 可以装到别的位置,`NO_START=1` 则只安装不启动。
+设置 `INSTALL_DIR` 可以装到别的位置,`NO_START=1` 只安装不启动,`NO_AUTOSTART=1` 则不设置登录自启。
+
+### 让其他机器也能访问
+
+Gateway 默认只监听 `127.0.0.1`,因为有两样东西对能连上这个端口的人是完全敞开的:Web UI 会直接把本机管理员登进去,`/v1`
会用这里存的 API Key 去转发。这两点正是本地工具该有的样子,但都不该暴露给网络。
+
+如果确实要让其他机器访问,在 `conf/app.conf` 里把 `httpaddr` 设成 `0.0.0.0`,然后:
+
+1. **改掉管理员密码**(**My Account** 里)。自动登录在遇到第一个非本机请求时就停止,从那以后密码是唯一的门槛。
+2. **每个发往 `/v1` 的请求都要带上中继令牌**。Gateway 在首次启动时生成它,显示在 **Settings → Security**
里;Providers 和 Agents 页面上的环境变量片段已经填好了它,Gateway 为 Agent 写配置时写进去的也是它。来自本机的请求永远不需要它。
**这些是 nightly 构建**,每次推送都从 `master` 重新构建,并作为
[`nightly`](https://github.com/apache/casbin-gateway/releases/tag/nightly)
预发布版本发布。它们的用途是让人不装 Go 和 Node 工具链就能试用 Gateway;其他场景都应该从源码发布版构建。
@@ -140,6 +150,7 @@ podman compose up -d
| 配置项 | 默认值 | 作用 |
| --- | --- | --- |
| `httpport` | `17000` | Web UI 和 REST API 的端口 |
+| `httpaddr` | `127.0.0.1` | Web UI 监听的网卡 —— 见[让其他机器也能访问](#让其他机器也能访问) |
| `driverName` / `dataSourceName` | `sqlite` / `./data/casbin-gateway.db` |
数据存放位置 |
| `gatewayEnabled` | `false` | 打开反向代理 WAF |
| `gatewayHttpPort` / `gatewayHttpsPort` | `80` / `443` | 代理监听的端口 |
@@ -150,19 +161,20 @@ podman compose up -d
Gateway 启动时会打印它实际在做什么,所以可以直接看结果而不用去查配置文件:
```
-+----------------------------------------------------------------------------+
-| Casbin Gateway |
-+----------------+-----------------------------------------------------------+
-| Management UI | http://localhost:17000 |
-| Settings | Settings page, seeded from conf/app.conf |
-| Web UI files | web/build |
-| Reverse proxy | enabled |
-| Gateway HTTP | :8080 |
-| Gateway HTTPS | :8443 |
-| Database | sqlite, file "./data/casbin-gateway.db" (connected) |
-| Sign-in | built-in user table, Casdoor is not configured |
-| App dir | ./data/apps |
-+----------------+-----------------------------------------------------------+
++---------------------------------------------------------------------+
+| Casbin Gateway |
++---------------------------------------------------------------------+
+| Management UI | http://localhost:17000 (this machine only) |
+| Settings | Settings page, seeded from conf/app.conf |
+| Web UI files | web/build |
+| Reverse proxy | enabled |
+| Gateway HTTP | :8080 |
+| Gateway HTTPS | :8443 |
+| Database | sqlite, file "./data/casbin-gateway.db" (connected) |
+| Sign-in | built-in user table, Casdoor is not configured |
+| Relay auth | this machine only, no token needed |
+| App dir | ./data/apps |
++---------------------------------------------------------------------+
```
如果端口被占用,Gateway 会指出是哪个进程占着它并停止启动,而不是半配置地跑起来。
@@ -221,7 +233,7 @@ cd web && yarn install && yarn build
go run main.go
```
-然后打开 http://localhost:17000,用 `admin` 和密码 `123` 登录,和安装版一样。SQLite
数据库在首次启动时创建,不需要安装数据库服务。
+然后打开 http://localhost:17000,本机访问会直接以管理员身份进入,和安装版一样。SQLite
数据库在首次启动时创建,不需要安装数据库服务。
### 前端开发
diff --git a/conf/app.conf b/conf/app.conf
index a021897..2bea1d8 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -9,6 +9,11 @@ appname = casbin-gateway
; Port of the management UI and the REST API. Whatever is already listening on
; it is killed on startup, so a previous run does not have to be stopped by
hand.
httpport = 17000
+; Interface the management UI binds to. It is loopback by default: the local
+; admin is signed in without a password and the relay hands out the provider
+; keys, so exposing both to the network is opt-in. Set it to 0.0.0.0 to serve
+; other machines, and change the admin password before you do.
+httpaddr = 127.0.0.1
runmode = dev
SessionOn = true
copyrequestbody = true
diff --git a/conf/conf.go b/conf/conf.go
index 7974809..d68639a 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -17,6 +17,7 @@ package conf
import (
_ "embed"
"fmt"
+ "net"
"os"
"runtime"
"strconv"
@@ -38,7 +39,7 @@ func init() {
// is reloaded afterwards — beego.LoadAppConfig() builds a new config from the
// file alone, so anything set here would otherwise be lost.
func ApplyEnvOverrides() {
- presetConfigItems := []string{"httpport", "appname"}
+ presetConfigItems := []string{"httpport", "httpaddr", "appname"}
for _, key := range presetConfigItems {
if value, ok := os.LookupEnv(key); ok {
err := beego.AppConfig.Set(key, value)
@@ -136,6 +137,36 @@ func GetHttpPort() int {
return GetConfigIntDefault("httpport", 17000)
}
+// GetHttpAddr is the interface the management UI and the REST API bind to. It
+// defaults to loopback: the UI signs the local admin in without a password and
+// the relay hands out the provider keys, so reaching those from the network is
+// opt-in rather than the default.
+func GetHttpAddr() string {
+ addr := strings.Trim(GetConfigString("httpaddr"), `"' `)
+ if addr == "" {
+ return "127.0.0.1"
+ }
+ return addr
+}
+
+// IsHttpAddrLoopback reports whether the management port is reachable only
from
+// this machine, which is what lets the local admin skip the sign-in form.
+func IsHttpAddrLoopback() bool {
+ addr := GetHttpAddr()
+ if addr == "localhost" {
+ return true
+ }
+ ip := net.ParseIP(addr)
+ return ip != nil && ip.IsLoopback()
+}
+
+// GetRelayToken is the token an agent sends to the local relay. It is
generated
+// on first start and stored with the settings, so it survives restarts and can
+// be rotated from the Settings page.
+func GetRelayToken() string {
+ return strings.Trim(GetConfigString("relayToken"), `"' `)
+}
+
// IsGatewayEnabled reports whether the reverse-proxy gateway should bind the
// gateway ports. It defaults to false: taking over ports 80 and 443 is opt-in,
// because those usually belong to something else on the host.
diff --git a/controllers/account.go b/controllers/account.go
index 180baed..26dfc1e 100644
--- a/controllers/account.go
+++ b/controllers/account.go
@@ -46,7 +46,7 @@ func (c *ApiController) GetSigninOptions() {
c.ResponseOk(map[string]interface{}{
"casdoorAvailable": casdoorAvailable,
"signinAvailable": signinEnabled,
- "autoSignin": signinEnabled &&
object.IsAdminUsingDefaultPassword(),
+ "autoSignin": signinEnabled &&
object.IsAdminUsingDefaultPassword() && util.IsLoopbackRequest(c.Ctx.Request),
"authConfig": map[string]string{
"serverUrl":
getUnquotedConfig("casdoorEndpoint"),
"clientId": getUnquotedConfig("clientId"),
@@ -138,7 +138,9 @@ func (c *ApiController) Signout() {
}
// autoLoginAdmin signs the built-in admin in while the seeded password is
still
-// in use, so that a fresh deployment is usable without any configuration.
+// in use, so that a fresh install is usable without any configuration. Only a
+// request from this machine is let through: the convenience is for the person
+// sitting at the keyboard, and the seeded password is public.
// It returns false when the response has already been written.
func (c *ApiController) autoLoginAdmin() bool {
user, ok, err := object.VerifyUser("admin", "123")
@@ -158,7 +160,7 @@ func (c *ApiController) autoLoginAdmin() bool {
func (c *ApiController) GetAccount() {
if c.GetSessionUser() == nil {
fromPath := c.Input().Get("fromPath")
- if object.IsSigninEnabled() && fromPath != "/signin" &&
object.IsAdminUsingDefaultPassword() {
+ if object.IsSigninEnabled() && fromPath != "/signin" &&
object.IsAdminUsingDefaultPassword() && util.IsLoopbackRequest(c.Ctx.Request) {
if !c.autoLoginAdmin() {
return
}
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index d6175e7..09155af 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -25,10 +25,6 @@ import (
"github.com/apache/casbin-gateway/object"
)
-// gatewayToken is what an agent sends to the local proxy. The proxy
authenticates
-// upstream with the provider's own key, so this value is never used for
anything.
-const gatewayToken = "casbin-gateway"
-
// PlanAgentProvider renders what a switch would write, without touching a
file.
func (c *ApiController) PlanAgentProvider() {
if c.RequireAdmin() {
@@ -140,7 +136,7 @@ func agentEndpoint(agentId string) (agentprovider.Endpoint,
error) {
// sending its own credentials: a placeholder token written into the
agent's
// configuration would replace the sign-in it already has.
if !object.UsesClientAuth(provider) {
- endpoint.ApiKey = gatewayToken
+ endpoint.ApiKey = conf.GetRelayToken()
}
return endpoint, nil
}
diff --git a/controllers/proxy.go b/controllers/proxy.go
index 663093c..157612c 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -26,6 +26,7 @@ import (
"strings"
"time"
+ "github.com/apache/casbin-gateway/conf"
"github.com/apache/casbin-gateway/object"
"github.com/apache/casbin-gateway/proxy"
"github.com/apache/casbin-gateway/util"
@@ -231,6 +232,12 @@ func (c *ApiController) proxyByAgent(target proxyTarget) {
}
func (c *ApiController) readProxyRoute(target proxyTarget) (*proxyRoute, bool)
{
+ if !c.allowRelay() {
+ c.writeProxyError(target.protocol, http.StatusUnauthorized,
"authentication_error",
+ "this relay is reachable from the network, so it needs
the token shown next to the provider in Casbin Gateway")
+ return nil, false
+ }
+
rawBody := c.Ctx.Input.RequestBody
var fields routingFields
@@ -400,6 +407,32 @@ func reportProviderStatus(provider *object.Provider,
statusCode int) {
}
}
+// allowRelay decides whether a request may use the providers stored here. A
+// request from this machine always may — that is the whole point of a local
+// gateway, and a client-auth provider carries the caller's own vendor
+// credential in the same header a token would use. Anything off-box has to
+// present the relay token instead.
+func (c *ApiController) allowRelay() bool {
+ if util.IsLoopbackRequest(c.Ctx.Request) {
+ return true
+ }
+
+ token := conf.GetRelayToken()
+ return token != "" && c.relayCredential() == token
+}
+
+// relayCredential is the token the client sent, in either of the two headers
+// the OpenAI and Anthropic clients use.
+func (c *ApiController) relayCredential() string {
+ header := c.Ctx.Request.Header
+ if key := strings.TrimSpace(header.Get("X-Api-Key")); key != "" {
+ return key
+ }
+
+ authorization := strings.TrimSpace(header.Get("Authorization"))
+ return strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
+}
+
// clientAuthHeaders are forwarded verbatim by a provider that authenticates
with
// the caller's own credentials: the credential itself, plus what the vendors
// expect beside a token issued to a CLI rather than to an API account. It is
an
diff --git a/controllers/system.go b/controllers/system.go
index 28896a8..e4621f4 100644
--- a/controllers/system.go
+++ b/controllers/system.go
@@ -35,3 +35,16 @@ func (c *ApiController) GetGatewayStatus() {
"httpsPort": conf.GetGatewayHttpsPort(),
})
}
+
+// GetRelayToken hands the web UI the token an agent has to send to the relay,
+// so the snippets it shows can be pasted as they are.
+func (c *ApiController) GetRelayToken() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ c.ResponseOk(map[string]interface{}{
+ "relayToken": conf.GetRelayToken(),
+ "localOnly": conf.IsHttpAddrLoopback(),
+ })
+}
diff --git a/main.go b/main.go
index a67561f..24ad4e9 100644
--- a/main.go
+++ b/main.go
@@ -16,6 +16,7 @@ package main
import (
"fmt"
+ "os"
"time"
"github.com/apache/casbin-gateway/agenthook"
@@ -35,12 +36,22 @@ import (
_ "github.com/beego/beego/session/redis"
)
+// daemonLogPath is where a background Gateway sends the console output nobody
+// is watching.
+const daemonLogPath = "./logs/casbin-gateway.out"
+
func main() {
// Hooks and MCP servers are launched by an agent as a short-lived child
// process. They must exit before Gateway initializes its own services.
agenthook.ServeIfInvoked()
mcpserver.ServeIfInvoked()
+ // "start", "stop" and "status" manage a Gateway running in the
background,
+ // so that using it does not mean keeping a terminal open.
+ if util.RunCommand(os.Args, conf.GetHttpPort(), daemonLogPath) {
+ return
+ }
+
util.InitSelfGuard()
object.InitFlag()
object.InitAdapter()
@@ -93,6 +104,7 @@ func main() {
beego.BConfig.WebConfig.Session.SessionGCMaxLifetime = 3600 * 24 * 365
port := conf.GetHttpPort()
+ addr := conf.GetHttpAddr()
// A previous run still holding one of these ports would keep this one
from
// starting, so it is stopped first. The gateway ports come before the
@@ -117,11 +129,11 @@ func main() {
// and before the gateway below binds anything. The gap between probe
and
// bind is unavoidable but harmless: losing the race just puts us back
to
// beego's own error.
- if err := util.CheckPortAvailable(port); err != nil {
+ if err := util.CheckPortAvailableOn(addr, port); err != nil {
util.FatalListenError(port, `change "httpport" in
conf/app.conf`, err)
}
service.Start()
- beego.Run(fmt.Sprintf(":%v", port))
+ beego.Run(fmt.Sprintf("%s:%v", addr, port))
}
diff --git a/object/provider.go b/object/provider.go
index b9e1fdf..40542b3 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -497,6 +497,17 @@ func AddProvider(provider *Provider) (bool, error) {
return false, err
}
+ name, suffix, err := freeProviderName(provider.Owner, provider.Name)
+ if err != nil {
+ return false, err
+ }
+ provider.Name = name
+ // The name the user sees follows the one that was free, so two
accounts with
+ // the same vendor are told apart in the list rather than only in the
URL.
+ if suffix > 1 && provider.DisplayName != "" {
+ provider.DisplayName = fmt.Sprintf("%s %d",
provider.DisplayName, suffix)
+ }
+
now := util.GetCurrentTime()
if provider.CreatedTime == "" {
provider.CreatedTime = now
@@ -514,6 +525,28 @@ func AddProvider(provider *Provider) (bool, error) {
return affected != 0, err
}
+// freeProviderName keeps the readable name the user asked for and appends a
+// number only when it is taken, so a second DeepSeek account becomes
"deepseek-2"
+// rather than something nobody can read.
+func freeProviderName(owner string, name string) (string, int, error) {
+ for suffix := 1; suffix < 1000; suffix++ {
+ candidate := name
+ if suffix > 1 {
+ candidate = fmt.Sprintf("%s-%d", name, suffix)
+ }
+
+ existing, err := getProvider(owner, candidate)
+ if err != nil {
+ return "", 0, err
+ }
+ if existing == nil {
+ return candidate, suffix, nil
+ }
+ }
+
+ return "", 0, fmt.Errorf("too many providers named %s", name)
+}
+
func UpdateProvider(id string, provider *Provider) (bool, error) {
owner, name := util.GetOwnerAndNameFromId(id)
stored, err := getProvider(owner, name)
diff --git a/object/setting.go b/object/setting.go
index e453b4c..0d79005 100644
--- a/object/setting.go
+++ b/object/setting.go
@@ -55,6 +55,9 @@ type Setting struct {
CasdoorApplication string `xorm:"varchar(100)"
json:"casdoorApplication"`
ApiKeyEncryptionKey string `xorm:"varchar(200)"
json:"apiKeyEncryptionKey"`
+ // RelayToken is what an agent sends to the local relay. It is
generated on
+ // first start; clearing it on the Settings page issues a new one.
+ RelayToken string `xorm:"varchar(200)" json:"relayToken"`
HttpProxy string `xorm:"varchar(200)" json:"httpProxy"`
AcmeEmail string `xorm:"varchar(200)" json:"acmeEmail"`
@@ -104,6 +107,7 @@ func SyncSettingToConf(setting *Setting) {
"casdoorApplication": setting.CasdoorApplication,
"apiKeyEncryptionKey": setting.ApiKeyEncryptionKey,
+ "relayToken": setting.RelayToken,
"httpProxy": setting.HttpProxy,
"acmeEmail": setting.AcmeEmail,
@@ -124,6 +128,14 @@ func SyncSettingToConf(setting *Setting) {
})
}
+// relayTokenLength is long enough that the token cannot be guessed and short
+// enough to stay readable in a shell snippet.
+const relayTokenLength = 32
+
+func newRelayToken() string {
+ return "cg-" + util.GenerateToken(relayTokenLength)
+}
+
func getSetting(owner string, name string) (*Setting, error) {
setting := Setting{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&setting)
@@ -155,6 +167,11 @@ func UpdateSetting(id string, setting *Setting) (bool,
error) {
}
setting.Owner, setting.Name = owner, name
+ // An empty token would leave the relay open, so clearing the field is
taken
+ // as "issue a new one" rather than "turn the check off".
+ if setting.RelayToken == "" {
+ setting.RelayToken = newRelayToken()
+ }
_, err := ormer.Engine.ID(core.PK{owner,
name}).AllCols().Update(setting)
if err != nil {
return false, err
@@ -181,6 +198,15 @@ func InitBuiltInSetting() {
}
}
+ // An installation that predates the relay token has an empty column,
so the
+ // token is issued here rather than only when the row is created.
+ if setting.RelayToken == "" {
+ setting.RelayToken = newRelayToken()
+ if _, err = ormer.Engine.ID(core.PK{setting.Owner,
setting.Name}).Cols("relay_token").Update(setting); err != nil {
+ panic(err)
+ }
+ }
+
SyncSettingToConf(setting)
}
@@ -216,6 +242,7 @@ func newSettingFromConf() *Setting {
CasdoorApplication:
conf.GetConfigStringUnquoted("casdoorApplication"),
ApiKeyEncryptionKey:
conf.GetConfigStringUnquoted("apiKeyEncryptionKey"),
+ RelayToken: newRelayToken(),
HttpProxy: conf.GetConfigStringUnquoted("httpProxy"),
AcmeEmail: conf.GetConfigStringUnquoted("acmeEmail"),
diff --git a/routers/router.go b/routers/router.go
index 7fea20d..1a2ba3c 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -39,6 +39,7 @@ func initAPI() {
beego.Router("/api/update-account", &controllers.ApiController{},
"POST:UpdateAccount")
beego.Router("/api/get-casdoor-providers",
&controllers.ApiController{}, "GET:GetCasdoorProviders")
beego.Router("/api/get-gateway-status", &controllers.ApiController{},
"GET:GetGatewayStatus")
+ beego.Router("/api/get-relay-token", &controllers.ApiController{},
"GET:GetRelayToken")
beego.Router("/api/get-setting", &controllers.ApiController{},
"GET:GetSetting")
beego.Router("/api/update-setting", &controllers.ApiController{},
"POST:UpdateSetting")
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 219c8af..9d251b4 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -100,17 +100,40 @@ if ($UserPath -notlike "*$BinDir*") {
Write-Info "Added $BinDir to your PATH, which takes effect in your next
terminal"
}
+# -- start with Windows -------------------------------------------------------
+# A shortcut in the Startup folder rather than a service: it needs no
elevation,
+# shows up in Task Manager's Startup tab, and is undone by deleting the file.
+$StartupDir = [System.Environment]::GetFolderPath('Startup')
+$StartupLink = Join-Path $StartupDir 'Casbin Gateway.lnk'
+if (-not $env:NO_AUTOSTART) {
+ try {
+ $shell = New-Object -ComObject WScript.Shell
+ $shortcut = $shell.CreateShortcut($StartupLink)
+ $shortcut.TargetPath = Join-Path $InstallDir 'casbin-gateway.exe'
+ $shortcut.Arguments = 'start'
+ $shortcut.WorkingDirectory = $InstallDir
+ $shortcut.Description = 'Casbin Gateway'
+ $shortcut.Save()
+ Write-Info 'Casbin Gateway will start with Windows.'
+ }
+ catch {
+ Write-Info "Could not add the startup shortcut: $_"
+ }
+}
+
Write-Info ''
Write-Info "Casbin Gateway is installed in $InstallDir"
Write-Info 'Its database, logs and temporary files stay in that directory.'
-Write-Info 'Sign in as "admin" with the password "123", and change it right
away.'
+Write-Info 'It serves this machine only, and signs you in there as admin
without a password.'
+Write-Info 'Stop it with "casbin-gateway stop", check on it with
"casbin-gateway status".'
+Write-Info "Remove the startup entry by deleting $StartupLink"
Write-Info ''
if ($env:NO_START) {
- Write-Info 'Start it with: casbin-gateway'
+ Write-Info 'Start it with: casbin-gateway start'
return
}
-Write-Info 'Starting Casbin Gateway, press Ctrl-C to stop it...'
+# "start" detaches and returns, so installing does not occupy this terminal.
Set-Location $InstallDir
-& (Join-Path $InstallDir 'casbin-gateway.exe')
+& (Join-Path $InstallDir 'casbin-gateway.exe') start
diff --git a/scripts/install.sh b/scripts/install.sh
index e4ff7ea..6205071 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -13,6 +13,7 @@
# BIN_DIR where the "casbin-gateway" command is placed
# (default: $HOME/.local/bin)
# NO_START set to any value to install without starting Gateway
+# NO_AUTOSTART set to any value to skip the login-time startup entry
set -euo pipefail
@@ -23,6 +24,7 @@ BASENAME="casbin-gateway-nightly"
INSTALL_DIR="${INSTALL_DIR:-${HOME}/.local/share/casbin-gateway}"
BIN_DIR="${BIN_DIR:-${HOME}/.local/bin}"
NO_START="${NO_START:-}"
+NO_AUTOSTART="${NO_AUTOSTART:-}"
info() { printf '%s\n' "$*"; }
die() { printf 'casbin-gateway: %s\n' "$*" >&2; exit 1; }
@@ -122,17 +124,75 @@ EOF
esac
fi
+# ── start at login
────────────────────────────────────────────────────────────
+# A user-level unit on Linux and a LaunchAgent on macOS: neither needs root,
and
+# both are undone by deleting one file.
+autostartNote=""
+install_autostart() {
+ if [[ -n "${NO_AUTOSTART}" ]]; then
+ return
+ fi
+
+ if [[ "${osName}" == "darwin" ]]; then
+
plist="${HOME}/Library/LaunchAgents/org.apache.casbin-gateway.plist"
+ mkdir -p "$(dirname "${plist}")" || return
+ cat > "${plist}" <<EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key><string>org.apache.casbin-gateway</string>
+ <key>ProgramArguments</key>
+ <array><string>${INSTALL_DIR}/casbin-gateway</string></array>
+ <key>WorkingDirectory</key><string>${INSTALL_DIR}</string>
+ <key>RunAtLoad</key><true/>
+ <key>KeepAlive</key><true/>
+</dict>
+</plist>
+EOF
+ launchctl unload "${plist}" >/dev/null 2>&1 || true
+ autostartNote="${plist}"
+ return
+ fi
+
+ command -v systemctl >/dev/null 2>&1 || return
+ unit="${HOME}/.config/systemd/user/casbin-gateway.service"
+ mkdir -p "$(dirname "${unit}")" || return
+ cat > "${unit}" <<EOF
+[Unit]
+Description=Casbin Gateway
+
+[Service]
+Type=simple
+WorkingDirectory=${INSTALL_DIR}
+ExecStart=${INSTALL_DIR}/casbin-gateway
+Restart=on-failure
+
+[Install]
+WantedBy=default.target
+EOF
+ systemctl --user daemon-reload >/dev/null 2>&1 || true
+ systemctl --user enable casbin-gateway.service >/dev/null 2>&1 || true
+ autostartNote="${unit}"
+}
+
+install_autostart
+
info ""
info "Casbin Gateway is installed in ${INSTALL_DIR}"
info "Its database, logs and temporary files stay in that directory."
-info "Sign in as \"admin\" with the password \"123\", and change it right
away."
+info "It serves this machine only, and signs you in there as admin without a
password."
+info "Stop it with \"casbin-gateway stop\", check on it with \"casbin-gateway
status\"."
+if [[ -n "${autostartNote}" ]]; then
+ info "It will start when you log in; remove ${autostartNote} to undo
that."
+fi
info ""
if [[ -n "${NO_START}" ]]; then
- info "Start it with: casbin-gateway"
+ info "Start it with: casbin-gateway start"
exit 0
fi
-info "Starting Casbin Gateway, press Ctrl-C to stop it..."
+# "start" detaches and returns, so installing does not occupy this terminal.
cd "${INSTALL_DIR}"
-exec "${INSTALL_DIR}/casbin-gateway"
+exec "${INSTALL_DIR}/casbin-gateway" start
diff --git a/service/startup.go b/service/startup.go
index 1c52770..ef3e1ca 100644
--- a/service/startup.go
+++ b/service/startup.go
@@ -35,7 +35,7 @@ type summaryRow struct {
// been a silent failure for someone starting Gateway for the first time.
func PrintStartupSummary() {
rows := []summaryRow{
- {"Management UI", fmt.Sprintf("http://localhost:%d",
conf.GetHttpPort())},
+ {"Management UI", describeManagementUrl()},
{"Settings", describeConf()},
{"Web UI files", describeWebBuild()},
{"Reverse proxy", describeGateway()},
@@ -43,12 +43,35 @@ func PrintStartupSummary() {
{"Gateway HTTPS",
describeGatewayPort(conf.GetGatewayHttpsPort())},
{"Database", describeDatabase()},
{"Sign-in", describeSignin()},
+ {"Relay auth", describeRelayAuth()},
{"App dir", describeAppDir()},
}
printSummaryTable("Casbin Gateway", rows)
}
+// describeManagementUrl says both where the UI is and who can reach it, since
+// the bind address decides whether the local admin is signed in automatically.
+func describeManagementUrl() string {
+ url := fmt.Sprintf("http://localhost:%d", conf.GetHttpPort())
+ if conf.IsHttpAddrLoopback() {
+ return url + " (this machine only)"
+ }
+
+ return fmt.Sprintf("%s, bound to %s", url, conf.GetHttpAddr())
+}
+
+// describeRelayAuth explains what an agent has to send to /v1. A request off
+// this machine needs the token, which is also what Gateway writes into the
+// configuration of an agent it switches.
+func describeRelayAuth() string {
+ if conf.IsHttpAddrLoopback() {
+ return "this machine only, no token needed"
+ }
+
+ return "token required off this machine, see Settings"
+}
+
// describeConf names where the settings were seeded from. They live in the
// database from the first start onwards, so this is only the origin of the
// values the Settings page now owns.
diff --git a/util/daemon.go b/util/daemon.go
new file mode 100644
index 0000000..c5a3b75
--- /dev/null
+++ b/util/daemon.go
@@ -0,0 +1,156 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package util
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+)
+
+const (
+ // EnvDaemonChildKey marks the copy started by "start", so it serves
instead
+ // of spawning another one.
+ EnvDaemonChildKey = "CASBIN_GATEWAY_DAEMON"
+ // daemonStartTimeout bounds the wait for the detached copy to answer.
+ daemonStartTimeout = 30 * time.Second
+ daemonPollInterval = 200 * time.Millisecond
+)
+
+// RunCommand handles the sub-commands that manage a background Gateway, so
that
+// running it does not have to occupy a terminal. It reports whether it handled
+// os.Args, in which case the caller must not go on to serve.
+func RunCommand(args []string, port int, logPath string) bool {
+ if os.Getenv(EnvDaemonChildKey) == "1" {
+ return false
+ }
+
+ switch commandOf(args) {
+ case "start":
+ startDetached(port, logPath)
+ case "stop":
+ stopDaemon(port)
+ case "status":
+ printStatus(port)
+ default:
+ return false
+ }
+ return true
+}
+
+func commandOf(args []string) string {
+ for _, arg := range args[1:] {
+ switch arg {
+ case "start", "--daemon", "-d":
+ return "start"
+ case "stop", "--stop":
+ return "stop"
+ case "status", "--status":
+ return "status"
+ }
+ }
+ return ""
+}
+
+// startDetached launches a copy of this executable with no terminal attached
and
+// waits until it answers, so that a failed start is reported here rather than
+// discovered later in a log nobody opened.
+func startDetached(port int, logPath string) {
+ executable, err := os.Executable()
+ if err != nil {
+ fmt.Printf("Casbin Gateway: cannot find its own executable:
%v\n", err)
+ os.Exit(1)
+ }
+
+ if isServing(port) {
+ fmt.Printf("Casbin Gateway is already running on
http://localhost:%d\n", port)
+ return
+ }
+
+ if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
+ fmt.Printf("Casbin Gateway: cannot create %s: %v\n",
filepath.Dir(logPath), err)
+ os.Exit(1)
+ }
+
+ logFile, err := os.OpenFile(logPath,
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+ if err != nil {
+ fmt.Printf("Casbin Gateway: cannot write %s: %v\n", logPath,
err)
+ os.Exit(1)
+ }
+ defer logFile.Close()
+
+ cmd := exec.Command(executable)
+ cmd.Stdout, cmd.Stderr = logFile, logFile
+ cmd.Env = append(os.Environ(), EnvDaemonChildKey+"=1")
+ detach(cmd)
+
+ if err := cmd.Start(); err != nil {
+ fmt.Printf("Casbin Gateway: could not start in the background:
%v\n", err)
+ os.Exit(1)
+ }
+
+ deadline := time.Now().Add(daemonStartTimeout)
+ for time.Now().Before(deadline) {
+ if isServing(port) {
+ fmt.Printf("Casbin Gateway is running in the background
on http://localhost:%d (pid %d)\n", port, cmd.Process.Pid)
+ fmt.Printf(" Log: %s Stop it with: casbin-gateway
stop\n", logPath)
+ return
+ }
+ time.Sleep(daemonPollInterval)
+ }
+
+ fmt.Printf("Casbin Gateway did not answer on port %d within %v. See
%s\n", port, daemonStartTimeout, logPath)
+ os.Exit(1)
+}
+
+func stopDaemon(port int) {
+ if !isServing(port) {
+ fmt.Printf("Casbin Gateway is not running on port %d\n", port)
+ return
+ }
+
+ if err := StopOldInstance(port); err != nil {
+ fmt.Printf("Casbin Gateway: could not stop the process on port
%d: %v\n", port, err)
+ os.Exit(1)
+ }
+ fmt.Println("Casbin Gateway stopped")
+}
+
+func printStatus(port int) {
+ if isServing(port) {
+ holder := DescribePortHolder(port)
+ if holder == "" {
+ holder = "running"
+ }
+ fmt.Printf("Casbin Gateway is running on http://localhost:%d
(%s)\n", port, holder)
+ return
+ }
+
+ fmt.Printf("Casbin Gateway is not running on port %d\n", port)
+}
+
+// isServing reports whether something already answers on the port, which is
the
+// only signal available to a process that did not start it.
+func isServing(port int) bool {
+ conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port),
time.Second)
+ if err != nil {
+ return false
+ }
+ conn.Close()
+ return true
+}
diff --git a/util/request.go b/util/daemon_unix.go
similarity index 55%
copy from util/request.go
copy to util/daemon_unix.go
index 63506c1..8f56931 100644
--- a/util/request.go
+++ b/util/daemon_unix.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The casbin Authors. All Rights Reserved.
+// Copyright 2026 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,29 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+//go:build !windows
+
package util
import (
- "net"
- "net/http"
- "strings"
+ "os/exec"
+ "syscall"
)
-func GetClientIp(r *http.Request) string {
- forwarded := r.Header.Get("X-Forwarded-For")
- if forwarded != "" {
- clientIP := strings.Split(forwarded, ",")[0]
- return strings.TrimSpace(clientIP)
- }
-
- realIP := r.Header.Get("X-Real-IP")
- if realIP != "" {
- return realIP
- }
-
- ip, _, err := net.SplitHostPort(r.RemoteAddr)
- if err != nil {
- return r.RemoteAddr
- }
- return ip
+// detach puts the child in its own session, so closing the terminal that
+// started it does not take it down.
+func detach(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
diff --git a/util/request.go b/util/daemon_windows.go
similarity index 56%
copy from util/request.go
copy to util/daemon_windows.go
index 63506c1..94d9010 100644
--- a/util/request.go
+++ b/util/daemon_windows.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The casbin Authors. All Rights Reserved.
+// Copyright 2026 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -15,26 +15,20 @@
package util
import (
- "net"
- "net/http"
- "strings"
+ "os/exec"
+ "syscall"
)
-func GetClientIp(r *http.Request) string {
- forwarded := r.Header.Get("X-Forwarded-For")
- if forwarded != "" {
- clientIP := strings.Split(forwarded, ",")[0]
- return strings.TrimSpace(clientIP)
- }
-
- realIP := r.Header.Get("X-Real-IP")
- if realIP != "" {
- return realIP
- }
+// detachedProcess and createNewProcessGroup keep the child alive after this
+// console closes, and without a console window of its own.
+const (
+ detachedProcess = 0x00000008
+ createNewProcessGroup = 0x00000200
+)
- ip, _, err := net.SplitHostPort(r.RemoteAddr)
- if err != nil {
- return r.RemoteAddr
+func detach(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ HideWindow: true,
+ CreationFlags: detachedProcess | createNewProcessGroup,
}
- return ip
}
diff --git a/util/port.go b/util/port.go
index c7ff651..133556e 100644
--- a/util/port.go
+++ b/util/port.go
@@ -34,14 +34,25 @@ const portLookupTimeout = 2 * time.Second
// keep the listener and serve on it, so a port that was reported free cannot
be
// taken by someone else in between the check and the bind.
func ListenTcp(port int) (net.Listener, error) {
- return net.Listen("tcp", fmt.Sprintf(":%d", port))
+ return ListenTcpAddr("", port)
+}
+
+// ListenTcpAddr binds one interface rather than all of them. An empty addr
+// means every interface, which is what the reverse proxy wants.
+func ListenTcpAddr(addr string, port int) (net.Listener, error) {
+ return net.Listen("tcp", fmt.Sprintf("%s:%d", addr, port))
}
// CheckPortAvailable binds the port and immediately releases it. Use it only
// where the port has to be handed to a server that insists on binding itself,
// such as beego.Run().
func CheckPortAvailable(port int) error {
- listener, err := ListenTcp(port)
+ return CheckPortAvailableOn("", port)
+}
+
+// CheckPortAvailableOn is CheckPortAvailable for one interface.
+func CheckPortAvailableOn(addr string, port int) error {
+ listener, err := ListenTcpAddr(addr, port)
if err != nil {
return err
}
diff --git a/util/request.go b/util/request.go
index 63506c1..682d9f6 100644
--- a/util/request.go
+++ b/util/request.go
@@ -38,3 +38,16 @@ func GetClientIp(r *http.Request) string {
}
return ip
}
+
+// IsLoopbackRequest reports whether the request came from this machine, judged
+// by the socket alone. X-Forwarded-For and X-Real-IP are deliberately ignored:
+// they are set by the client and so cannot be trusted to grant anything.
+func IsLoopbackRequest(r *http.Request) bool {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ host = r.RemoteAddr
+ }
+
+ ip := net.ParseIP(strings.Trim(host, "[]"))
+ return ip != nil && ip.IsLoopback()
+}
diff --git a/util/string.go b/util/string.go
index 1a59488..b6f78fb 100644
--- a/util/string.go
+++ b/util/string.go
@@ -142,6 +142,16 @@ func generateRandomString(length int) (string, error) {
return string(b), nil
}
+// GenerateToken returns a random opaque token, used where a value has to be
+// unguessable but is never a password anyone types.
+func GenerateToken(length int) string {
+ token, err := generateRandomString(length)
+ if err != nil {
+ panic(err)
+ }
+ return token
+}
+
func GenerateTwoUniqueRandomStrings() (string, string, error) {
len1 := 16 + int(big.NewInt(17).Int64())
len2 := 16 + int(big.NewInt(17).Int64())
diff --git a/web/src/App.tsx b/web/src/App.tsx
index ac21834..f99cb32 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -18,6 +18,7 @@ import {useTranslation} from "react-i18next";
import i18next from "i18next";
import * as AccountBackend from "@/backend/AccountBackend";
+import * as MiscBackend from "@/backend/MiscBackend";
import * as Conf from "@/Conf";
import * as Setting from "@/Setting";
import {findGroupOf, selectedKeyOf} from "@/nav";
@@ -65,6 +66,9 @@ export default function App() {
useTranslation();
// undefined while the account request is in flight, null when signed out.
const [account, setAccount] = React.useState<Account | null |
undefined>(undefined);
+ // The reverse-proxy pages are hidden until the proxy is on, so this decides
+ // whether the sidebar shows them at all.
+ const [gatewayEnabled, setGatewayEnabled] = React.useState(false);
const [themeAlgorithm, setThemeAlgorithm] =
React.useState<ThemeAlgorithm>(() => {
const stored = Setting.readThemeAlgorithm();
// Applied before the first paint so a dark-mode reload never flashes the
@@ -168,6 +172,15 @@ export default function App() {
.catch(() => getAccount());
}, [getAccount]);
+ React.useEffect(() => {
+ if (!account) {
+ return;
+ }
+ MiscBackend.getGatewayStatus()
+ .then(res => setGatewayEnabled(res.status === "ok" &&
(res.data?.gatewayEnabled ?? false)))
+ .catch(() => setGatewayEnabled(false));
+ }, [account]);
+
const signout = () => {
AccountBackend.signout().then(res => {
if (res.status === "ok") {
@@ -220,6 +233,7 @@ export default function App() {
openKeys={openKeys}
onOpenKeysChange={setOpenKeys}
isAdmin={Setting.isAdminUser(account)}
+ gatewayEnabled={gatewayEnabled}
/>
<div
diff --git a/web/src/backend/MiscBackend.ts b/web/src/backend/MiscBackend.ts
index b5fd7bd..fe1d0cb 100644
--- a/web/src/backend/MiscBackend.ts
+++ b/web/src/backend/MiscBackend.ts
@@ -27,6 +27,11 @@ export function getGatewayStatus() {
return request<GatewayStatus>("/api/get-gateway-status");
}
+/** The token an agent has to send to the relay, and whether it is needed
here. */
+export function getRelayToken() {
+ return request<{relayToken: string; localOnly:
boolean}>("/api/get-relay-token");
+}
+
export function getMetric(type: string, rangeType: string, count: number,
top?: number) {
return request<MetricPoint[]>(
`/api/get-metrics${query({type: type, rangeType: rangeType, count: count,
top: top})}`,
diff --git a/web/src/components/EnvSnippet.tsx
b/web/src/components/EnvSnippet.tsx
index c215b96..000b656 100644
--- a/web/src/components/EnvSnippet.tsx
+++ b/web/src/components/EnvSnippet.tsx
@@ -22,6 +22,7 @@ import {Button} from "@/components/ui/button";
import {SimpleTooltip} from "@/components/ui/tooltip";
import {cn} from "@/lib/utils";
import {envSnippet, shells, type Shell} from "@/lib/providers";
+import {useRelayToken} from "@/lib/relayToken";
/**
* The environment variables that point a client at one Gateway endpoint, ready
@@ -39,7 +40,8 @@ export function EnvSnippet({
includeToken?: boolean;
}) {
const [shell, setShell] = React.useState<Shell>(defaultShell);
- const snippet = envSnippet(protocol, baseUrl, shell, includeToken);
+ const token = useRelayToken();
+ const snippet = envSnippet(protocol, baseUrl, shell, includeToken, token);
return (
<div className="space-y-2">
diff --git a/web/src/components/ProviderCard.tsx
b/web/src/components/ProviderCard.tsx
index 07c89ad..1967de7 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -311,6 +311,13 @@ export function ProviderCard({
</code>
))}
+ {/* Once the file belongs to Gateway, picking another provider
+ above rewrites it on the spot, so the button stops being
the
+ thing to click and only stays for a manual rewrite. */}
+ {providerConfig.applied ? (
+ <p className="text-sm
text-muted-foreground">{i18next.t("agent:Switch takes effect at once")}</p>
+ ) : null}
+
<div className="flex flex-wrap gap-2">
<ConfirmDialog
title={i18next.t("agent:Write the configuration of
{agent}?").replace("{agent}", agent.name)}
@@ -318,7 +325,7 @@ export function ProviderCard({
confirmText={i18next.t("agent:Write configuration")}
onConfirm={() => onWrite(false)}
>
- <Button disabled={busy}>
+ <Button variant={providerConfig.applied ? "outline" :
"default"} disabled={busy}>
{providerConfig.applied
? i18next.t("agent:Rewrite configuration")
: i18next.t("agent:Write configuration")}
diff --git a/web/src/components/ProviderGridCard.tsx
b/web/src/components/ProviderGridCard.tsx
new file mode 100644
index 0000000..ca44055
--- /dev/null
+++ b/web/src/components/ProviderGridCard.tsx
@@ -0,0 +1,211 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {CircleX, Pencil, Trash2} from "lucide-react";
+import i18next from "i18next";
+
+import {ProviderIcon} from "@/components/ProviderIcon";
+import {QuotaBadge} from "@/components/ProviderQuota";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent} from "@/components/ui/card";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {providerProtocol, usesClientAuth} from "@/lib/providers";
+import {cn} from "@/lib/utils";
+import type {Agent, Provider, ProviderHealth, ProviderQuota} from "@/types";
+
+export function providerIdOf(provider: Provider) {
+ return `${provider.owner}/${provider.name}`;
+}
+
+/** How many model badges fit before the rest become a count. */
+const visibleModels = 3;
+
+/** The one mark that says whether the proxy is happy with this provider. */
+function HealthDot({health}: {health: ProviderHealth | undefined}) {
+ if (!health) {
+ return (
+ <SimpleTooltip title={i18next.t("provider:Not used yet")}>
+ <span className="bg-muted-foreground/40 size-2 shrink-0 rounded-full"
/>
+ </SimpleTooltip>
+ );
+ }
+
+ const healthy = health.healthy && health.consecutive === 0;
+ return (
+ <SimpleTooltip
+ title={
+ health.healthy
+ ? `${health.successes} / ${health.successes + health.failures}`
+ : `${health.lastError} · ${i18next.t("provider:Retried at")}
${health.retryTime}`
+ }
+ >
+ <span
+ className={cn(
+ "size-2 shrink-0 rounded-full",
+ healthy ? "bg-success" : health.healthy ? "bg-muted-foreground" :
"bg-warning",
+ )}
+ />
+ </SimpleTooltip>
+ );
+}
+
+/**
+ * One provider, as a card rather than a row: the name, where it points, what
it
+ * serves, and the agents it answers for. Switching an agent over is what
people
+ * come to this page to do, so it is one click from here.
+ */
+export function ProviderGridCard({
+ provider,
+ agents,
+ health,
+ quota,
+ busy,
+ onEdit,
+ onDelete,
+ onBind,
+}: {
+ provider: Provider;
+ agents: Agent[];
+ health: ProviderHealth | undefined;
+ quota: ProviderQuota | undefined;
+ busy: boolean;
+ onEdit: () => void;
+ onDelete: () => void;
+ onBind: (agent: Agent) => void;
+}) {
+ const id = providerIdOf(provider);
+ const protocol = providerProtocol(provider.type);
+ const models = provider.models ?? [];
+ const users = agents.filter(agent => agent.provider === id);
+ const others = agents.filter(agent => agent.provider !== id);
+
+ return (
+ <Card className="gap-0 py-0">
+ <CardContent className="flex h-full flex-col gap-3 p-4">
+ <div className="flex items-start gap-2">
+ <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl}
alt={provider.name} size={22} />
+ <div className="min-w-0 flex-1">
+ <div className="flex min-w-0 items-center gap-2">
+ <button
+ type="button"
+ onClick={onEdit}
+ className="min-w-0 truncate text-left font-medium
hover:underline"
+ >
+ {provider.displayName || provider.name}
+ </button>
+ <HealthDot health={health} />
+ </div>
+ <SimpleTooltip title={provider.baseUrl}>
+ <p className="text-muted-foreground truncate font-mono
text-xs">{provider.baseUrl || "-"}</p>
+ </SimpleTooltip>
+ </div>
+ </div>
+
+ <div className="flex flex-wrap items-center gap-1.5">
+ <Badge variant={protocol === "anthropic" ? "info" :
"success"}>{protocol}</Badge>
+ {usesClientAuth(provider) ? (
+ <Badge variant="muted">{i18next.t("provider:Caller's own
login")}</Badge>
+ ) : null}
+ {provider.status === "enabled" ? null : (
+ <Badge variant="muted">
+ <CircleX />
+ {i18next.t("provider:Disabled")}
+ </Badge>
+ )}
+ {/* A card has no column to fill, so a balance nobody can read is
left out. */}
+ {quota && (quota.error !== "" || quota.supported) ? <QuotaBadge
quota={quota} /> : null}
+ </div>
+
+ <div className="flex flex-wrap gap-1">
+ {models.length === 0 ? (
+ <span className="text-muted-foreground text-xs">
+ {usesClientAuth(provider) ? i18next.t("provider:Any model") :
"-"}
+ </span>
+ ) : (
+ <>
+ {models.slice(0, visibleModels).map(model => (
+ <Badge key={model} variant="muted">
+ {model}
+ </Badge>
+ ))}
+ {models.length > visibleModels ? (
+ <SimpleTooltip title={models.slice(visibleModels).join(", ")}>
+ <Badge variant="muted">{`+${models.length -
visibleModels}`}</Badge>
+ </SimpleTooltip>
+ ) : null}
+ </>
+ )}
+ </div>
+
+ <p className="text-muted-foreground mt-auto truncate text-xs">
+ {users.length === 0
+ ? i18next.t("provider:Used by no agent")
+ : `${i18next.t("provider:Used by")} ${users.map(agent =>
agent.name).join(", ")}`}
+ </p>
+
+ <div className="flex flex-wrap items-center gap-2 border-t pt-3">
+ {others.length === 0 ? null : (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button size="sm" variant="outline" disabled={busy}>
+ {i18next.t("provider:Switch an agent over")}
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ <DropdownMenuLabel>{i18next.t("provider:Switch
hint")}</DropdownMenuLabel>
+ {others.map(agent => (
+ <DropdownMenuItem key={`${agent.agentId}/${agent.path}`}
onSelect={() => onBind(agent)}>
+ {agent.name}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
+ <div className="ml-auto flex gap-2">
+ <SimpleTooltip title={i18next.t("general:Edit")}>
+ <Button size="icon-sm" variant="ghost"
aria-label={i18next.t("general:Edit")} onClick={onEdit}>
+ <Pencil />
+ </Button>
+ </SimpleTooltip>
+ <ConfirmDialog
+ title={i18next
+ .t("general:Sure to delete {name} ?")
+ .replace("{name}", provider.displayName || provider.name)}
+ confirmText={i18next.t("general:Delete")}
+ onConfirm={onDelete}
+ >
+ <Button
+ size="icon-sm"
+ variant="ghost"
+ className="text-destructive"
+ aria-label={i18next.t("general:Delete")}
+ >
+ <Trash2 />
+ </Button>
+ </ConfirmDialog>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/web/src/components/shared/app-sidebar.tsx
b/web/src/components/shared/app-sidebar.tsx
index 5345649..ac7aeb7 100644
--- a/web/src/components/shared/app-sidebar.tsx
+++ b/web/src/components/shared/app-sidebar.tsx
@@ -97,18 +97,21 @@ export function AppSidebar({
openKeys,
onOpenKeysChange,
isAdmin,
+ gatewayEnabled,
}: {
collapsed: boolean;
selectedKey: string;
openKeys: string[];
onOpenKeysChange: (keys: string[]) => void;
isAdmin: boolean;
+ gatewayEnabled: boolean;
}) {
const toggleGroup = (key: string) => {
onOpenKeysChange(openKeys.includes(key) ? openKeys.filter(item => item !==
key) : [...openKeys, key]);
};
- const visible = (entry: {adminOnly?: boolean}) => !entry.adminOnly ||
isAdmin;
+ const visible = (entry: {adminOnly?: boolean; gatewayOnly?: boolean}) =>
+ (!entry.adminOnly || isAdmin) && (!entry.gatewayOnly || gatewayEnabled);
const groups: NavGroup[] = navGroups
.filter(visible)
.map(group => (group.children ? {...group, children:
group.children.filter(visible)} : group))
diff --git a/web/src/components/shared/page-header.tsx
b/web/src/components/shared/page-header.tsx
index 7aee7ed..d7bbdbe 100644
--- a/web/src/components/shared/page-header.tsx
+++ b/web/src/components/shared/page-header.tsx
@@ -13,6 +13,7 @@
// limitations under the License.
import * as React from "react";
+import {ChevronDown} from "lucide-react";
import {cn} from "@/lib/utils";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from
"@/components/ui/card";
@@ -52,23 +53,41 @@ export function Section({
description,
columns = 3,
className,
+ collapsible = false,
children,
}: {
title?: React.ReactNode;
description?: React.ReactNode;
columns?: 1 | 2 | 3;
className?: string;
+ /** Starts folded, for a section most people never open. */
+ collapsible?: boolean;
children: React.ReactNode;
}) {
+ const [open, setOpen] = React.useState(!collapsible);
+
return (
<Card className={cn("gap-4 py-5", className)}>
{title || description ? (
<CardHeader className="px-5">
- {title ? <CardTitle className="text-[15px]">{title}</CardTitle> :
null}
+ {title ? (
+ collapsible ? (
+ <button
+ type="button"
+ onClick={() => setOpen(!open)}
+ className="flex w-full items-center gap-1.5 text-left"
+ >
+ <ChevronDown className={cn("size-4 transition-transform", open
&& "rotate-180")} />
+ <CardTitle className="text-[15px]">{title}</CardTitle>
+ </button>
+ ) : (
+ <CardTitle className="text-[15px]">{title}</CardTitle>
+ )
+ ) : null}
{description ? <CardDescription>{description}</CardDescription> :
null}
</CardHeader>
) : null}
- <CardContent className="px-5">
+ <CardContent className={cn("px-5", !open && "hidden")}>
<div
className={cn(
"grid gap-4",
diff --git a/web/src/lib/providers.ts b/web/src/lib/providers.ts
index d6efa3e..2ab910e 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -36,8 +36,9 @@ const protocolEnv: Record<string, {baseUrl: string; token:
string}> = {
};
/**
- * A stand-in for the key a client refuses to start without. Gateway
authenticates
- * upstream with the provider's own key, so this value is never used for
anything.
+ * What a client sends the relay while its own key stays here. Gateway issues
it
+ * on first start; this is only the fallback for a page that has not been told
+ * the real one yet.
*/
const placeholderToken = "casbin-gateway";
@@ -49,11 +50,17 @@ export type Shell = (typeof shells)[number];
* provider forwards the client's own credentials, so the token is left out
* there: setting it would replace the sign-in the client already has.
*/
-export function envSnippet(protocol: string, baseUrl: string, shell: Shell,
includeToken = true) {
+export function envSnippet(
+ protocol: string,
+ baseUrl: string,
+ shell: Shell,
+ includeToken = true,
+ token = placeholderToken,
+) {
const env = protocolEnv[protocol] ?? protocolEnv.openai;
const variables: [string, string][] = [[env.baseUrl, baseUrl]];
if (includeToken) {
- variables.push([env.token, placeholderToken]);
+ variables.push([env.token, token || placeholderToken]);
}
return variables
@@ -83,6 +90,21 @@ export function localShell(): Shell {
return navigator.userAgent.includes("Windows") ? "PowerShell" : "bash";
}
+/**
+ * The identifier stored for a provider, derived from the name the user sees so
+ * that it stays readable. The server appends a number when it is already
taken,
+ * which is why nothing random is needed here.
+ */
+export function providerSlug(label: string) {
+ const slug = label
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 60);
+
+ return slug === "" ? "provider" : slug;
+}
+
/**
* Who a vendor is, which is the thing worth knowing when picking one: the
maker
* of the models, a platform that hosts models it did not make, or a site that
diff --git a/web/src/lib/relayToken.ts b/web/src/lib/relayToken.ts
new file mode 100644
index 0000000..45f5494
--- /dev/null
+++ b/web/src/lib/relayToken.ts
@@ -0,0 +1,48 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+
+import * as MiscBackend from "@/backend/MiscBackend";
+
+/** One request per page load: the token does not change while Gateway runs. */
+let pending: Promise<string> | null = null;
+
+function fetchRelayToken() {
+ if (pending === null) {
+ pending = MiscBackend.getRelayToken()
+ .then(res => (res.status === "ok" ? (res.data?.relayToken ?? "") : ""))
+ .catch(() => "");
+ }
+ return pending;
+}
+
+/** The token the snippets have to show, so what is copied actually works. */
+export function useRelayToken() {
+ const [token, setToken] = React.useState("");
+
+ React.useEffect(() => {
+ let live = true;
+ fetchRelayToken().then(value => {
+ if (live) {
+ setToken(value);
+ }
+ });
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ return token;
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 202773a..1680ffb 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -120,9 +120,10 @@
"Stop": "Stop",
"Stop hint": "Ends every process of this installation, losing whatever it
is working on.",
"Stopped": "Stopped",
+ "Switch takes effect at once": "Picking another provider above rewrites
this file at once, so switching takes one click.",
"Target / Model": "Target / Model",
"Time": "Time",
- "Token hint": "Gateway authenticates upstream with the provider's own key,
so this token is only there because the client insists on one - any value
works.",
+ "Token hint": "Gateway authenticates upstream with the provider's own key.
This value is Gateway's own relay token, which a request from another machine
has to carry.",
"Tool": "Tool",
"Tool use ID": "Tool use ID",
"Turn on monitoring to collect activity": "Turn on monitoring to collect
activity",
@@ -332,6 +333,7 @@
"Name": "Name",
"Name cannot be empty": "Name cannot be empty",
"New Record": "New Record",
+ "Next": "Next",
"No Upgrade": "No Upgrade",
"No data": "No data",
"Nodes": "Nodes",
@@ -341,6 +343,7 @@
"Owner": "Owner",
"Password": "Password",
"Path": "Path",
+ "Previous": "Previous",
"Records": "Records",
"Refresh": "Refresh",
"Request Count Over Time": "Request Count Over Time",
@@ -531,6 +534,7 @@
"Models": "Models",
"Models from upstream": "Models from upstream",
"Models hint": "The model names this provider serves, used to route
requests that arrive by model name.",
+ "Name hint": "Whatever you will recognise this account by. A second
account with the same vendor gets a number.",
"New Provider": "New Provider",
"No balance endpoint": "No balance endpoint",
"No models hint": "With no model named, nothing that arrives by model name
will be routed here. Press Fetch models, or type the names this provider should
serve. An empty list only means \"any model\" for a provider that forwards the
caller's own login.",
@@ -568,11 +572,16 @@
"Source hint": "Where this provider gets its credentials. Picking one
fills in the type, base URL and models, so a subscription needs nothing typed
and a vendor needs only its key.",
"Status": "Status",
"Stored API key": "Stored API key",
+ "Switch an agent over": "Switch an agent over",
+ "Switch hint": "Point an agent at this provider",
+ "Switched": "Switched",
"Test Connectivity": "Test Connectivity",
"Type": "Type",
"Usage": "Usage",
"Usage hint": "Point any client that speaks this provider's API at Gateway
with these variables.",
"Used": "used",
+ "Used by": "Used by",
+ "Used by no agent": "No agent uses this provider yet",
"Used path": "Used path"
},
"rule": {
@@ -660,6 +669,8 @@
"RDS instance": "RDS instance",
"RDS region": "RDS region",
"Record mode": "Record mode",
+ "Relay token": "Relay token",
+ "Relay token hint": "What an agent sends to /v1. Requests from this
machine never need it. Clear the field and save to issue a new one.",
"Retention days": "Retention days",
"Reverse proxy": "Reverse proxy",
"Reverse proxy description": "The WAF itself. Ports 80 and 443 usually
belong to something else on the host, and binding them needs root on Linux and
macOS.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index a95a5d2..b0e7bc4 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -120,9 +120,10 @@
"Stop": "停止",
"Stop hint": "结束该安装的所有进程,正在进行的工作会丢失。",
"Stopped": "未运行",
+ "Switch takes effect at once": "在上面换一个供应商会立即改写这个文件,所以切换只需要点一下。",
"Target / Model": "目标 / 模型",
"Time": "时间",
- "Token hint": "网关是用供应商自己的密钥去认证上游的,这里的 token 只是因为客户端一定要有一个,填什么都行。",
+ "Token hint": "Gateway 是用供应商自己的密钥去认证上游的。这里的值是 Gateway
自己的中继令牌,来自其他机器的请求必须带上它。",
"Tool": "工具",
"Tool use ID": "工具调用 ID",
"Turn on monitoring to collect activity": "开启监控后才会收集活动",
@@ -332,6 +333,7 @@
"Name": "名称",
"Name cannot be empty": "名称不能为空",
"New Record": "新建记录",
+ "Next": "下一页",
"No Upgrade": "不升级",
"No data": "暂无数据",
"Nodes": "节点",
@@ -341,6 +343,7 @@
"Owner": "所有者",
"Password": "密码",
"Path": "路径",
+ "Previous": "上一页",
"Records": "日志",
"Refresh": "刷新",
"Request Count Over Time": "请求数趋势",
@@ -531,6 +534,7 @@
"Models": "模型",
"Models from upstream": "上游返回的模型",
"Models hint": "这个供应商提供的模型名,按模型名路由的请求靠它匹配。",
+ "Name hint": "你自己认得出这个账号就行。同一家厂商的第二个账号会自动加编号。",
"New Provider": "新建供应商",
"No balance endpoint": "无余额接口",
"No models hint":
"没有填写模型名,按模型名路由的请求就不会走到这个供应商。点「获取模型」,或手动填写它该服务的模型名。只有转发调用方自身登录态的供应商,留空才表示「任意模型」。",
@@ -568,11 +572,16 @@
"Source hint": "这个供应商的凭据从哪里来。选一个就会自动填好类型、base URL 和模型:订阅登录什么都不用填,厂商只用填一个
Key。",
"Status": "状态",
"Stored API key": "Gateway 保存的 API Key",
+ "Switch an agent over": "切换某个智能体",
+ "Switch hint": "把某个智能体指向这个供应商",
+ "Switched": "已切换",
"Test Connectivity": "测试连接",
"Type": "类型",
"Usage": "使用方法",
"Usage hint": "任何说这个供应商所用 API 的客户端,配上下面的变量就能走网关。",
"Used": "已用",
+ "Used by": "正在使用:",
+ "Used by no agent": "还没有智能体使用这个供应商",
"Used path": "已用字段路径"
},
"rule": {
@@ -660,6 +669,8 @@
"RDS instance": "RDS 实例",
"RDS region": "RDS 地域",
"Record mode": "记录模式",
+ "Relay token": "中继令牌",
+ "Relay token hint": "智能体访问 /v1 时携带的令牌。来自本机的请求不需要它。清空后保存即可重新生成。",
"Retention days": "保留天数",
"Reverse proxy": "反向代理",
"Reverse proxy description": "即 WAF 本身。80 和 443 端口通常已被主机上的其他程序占用,在 Linux 和
macOS 上绑定它们还需要 root 权限。",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 8f5843a..854d057 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -45,6 +45,8 @@ export interface NavGroup {
icon?: LucideIcon;
path?: string;
adminOnly?: boolean;
+ /** Hidden until the reverse proxy is turned on, since nothing under it
works before that. */
+ gatewayOnly?: boolean;
children?: NavLeaf[];
}
@@ -81,6 +83,7 @@ export const navGroups: NavGroup[] = [
{
key: "/advanced",
label: "general:Advanced",
+ gatewayOnly: true,
icon: SlidersHorizontal,
children: [
{key: "/sites", label: "general:Sites", path: "/sites", icon: Globe},
diff --git a/web/src/pages/ProviderListPage.tsx
b/web/src/pages/ProviderListPage.tsx
index 9e5f881..eef1eff 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -13,32 +13,27 @@
// limitations under the License.
import * as React from "react";
-import {Link, useNavigate} from "react-router-dom";
+import {useNavigate} from "react-router-dom";
import {
ChevronDown,
- CircleCheck,
- CircleX,
ExternalLink,
- Pencil,
Plug,
Plus,
RefreshCw,
- Trash2,
Wallet,
} from "lucide-react";
import i18next from "i18next";
+import * as AgentBackend from "@/backend/AgentBackend";
import * as ProviderBackend from "@/backend/ProviderBackend";
import * as Setting from "@/Setting";
-import {ProviderIcon, ProviderIconField} from "@/components/ProviderIcon";
+import {ProviderIconField} from "@/components/ProviderIcon";
+import {ProviderGridCard, providerIdOf} from "@/components/ProviderGridCard";
import {ProviderModelsField} from "@/components/ProviderModelsField";
-import {QuotaBadge} from "@/components/ProviderQuota";
import {ProviderSourcePicker, sourceTitle} from
"@/components/ProviderSourcePicker";
import {ProviderTestField, useProviderTest} from
"@/components/ProviderTestField";
-import {ConfirmDialog} from "@/components/shared/confirm-dialog";
-import {DataTable, type Column, type SortOrder} from
"@/components/shared/data-table";
+import {type SortOrder} from "@/components/shared/data-table";
import {Field, FormDialog} from "@/components/shared/form-dialog";
-import {CodeText} from "@/components/shared/misc";
import {PageContainer, PageHeader} from "@/components/shared/page-header";
import {PasswordInput} from "@/components/shared/password-input";
import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
@@ -47,25 +42,26 @@ import {Button} from "@/components/ui/button";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from
"@/components/ui/card";
import {Input} from "@/components/ui/input";
import {Textarea} from "@/components/ui/textarea";
-import {SimpleTooltip} from "@/components/ui/tooltip";
import {
authProvider,
authClient,
baseUrlPlaceholder,
baseUrlPresets,
customSource,
+ providerSlug,
usesClientAuth,
type ProviderSource,
} from "@/lib/providers";
import {cn} from "@/lib/utils";
-import type {Account, Provider, ProviderHealth, ProviderQuota} from "@/types";
+import type {Account, Agent, Provider, ProviderHealth, ProviderQuota} from
"@/types";
function newProvider(owner: string, label = "New Provider"): Provider {
- const randomName = Setting.getRandomName();
return {
owner: owner,
- name: `provider_${randomName}`,
- displayName: `${label} - ${randomName}`,
+ // The server appends a number when this is taken, so a second account with
+ // the same vendor keeps a name someone can read.
+ name: providerSlug(label),
+ displayName: label,
type: "openai",
status: "enabled",
models: [],
@@ -127,6 +123,8 @@ export default function ProviderListPage({account}:
{account: Account}) {
const [health, setHealth] = React.useState<ProviderHealth[]>([]);
const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
const [refreshingQuotas, setRefreshingQuotas] = React.useState(false);
+ const [agents, setAgents] = React.useState<Agent[]>([]);
+ const [binding, setBinding] = React.useState("");
const test = useProviderTest(form);
const fetchProviders = React.useCallback(
@@ -190,6 +188,22 @@ export default function ProviderListPage({account}:
{account: Account}) {
.then(() => setRefreshingQuotas(false));
}, []);
+ // Which agent a provider answers for is the thing this page is really about,
+ // so the agents are loaded next to them. A non-admin cannot list them, and
the
+ // card simply leaves that line out.
+ const loadAgents = React.useCallback(() => {
+ if (!Setting.isAdminUser(account)) {
+ return;
+ }
+ AgentBackend.getAgents()
+ .then(res => setAgents(res.status === "ok" ? (res.data ?? []) : []))
+ .catch(() => setAgents([]));
+ }, [account]);
+
+ React.useEffect(() => {
+ loadAgents();
+ }, [loadAgents]);
+
React.useEffect(() => {
ProviderBackend.getProviderQuotas()
.then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
@@ -217,7 +231,7 @@ export default function ProviderListPage({account}:
{account: Account}) {
// The upstream is probed before the provider is stored, so a key that was
// pasted wrong is caught here rather than by the first agent that uses it.
const submitProvider = () => {
- if (form.name.trim() === "") {
+ if (form.displayName.trim() === "") {
setNameError(i18next.t("general:Name cannot be empty"));
return;
}
@@ -225,9 +239,9 @@ export default function ProviderListPage({account}:
{account: Account}) {
};
const addProvider = () => {
- const name = form.name.trim();
+ const displayName = form.displayName.trim();
setAdding(true);
- ProviderBackend.addProvider({...form, name: name})
+ ProviderBackend.addProvider({...form, displayName: displayName, name:
providerSlug(displayName)})
.then(res => {
setAdding(false);
if (res.status === "error") {
@@ -244,6 +258,31 @@ export default function ProviderListPage({account}:
{account: Account}) {
});
};
+ // Switching is one call: the server rewrites the configuration of an agent
it
+ // already switched, so nothing else has to be clicked afterwards.
+ const bindAgent = (agent: Agent, provider: Provider) => {
+ setBinding(agent.agentId);
+ AgentBackend.updateAgentRouting(agent.agentId, {
+ provider: providerIdOf(provider),
+ fallbacks: [],
+ mode: agent.mode || "gateway",
+ })
+ .then(res => {
+ if (res.status === "ok") {
+ Setting.showMessage(
+ "success",
+ `${i18next.t("provider:Switched")}: ${agent.name} →
${provider.displayName || provider.name}`,
+ );
+ loadAgents();
+ } else {
+ Setting.showMessage("error", res.msg || i18next.t("agent:Failed to
update agent provider"));
+ loadAgents();
+ }
+ })
+ .catch(error => Setting.showMessage("error", `${error}`))
+ .then(() => setBinding(""));
+ };
+
const deleteProvider = (provider: Provider) => {
ProviderBackend.deleteProvider(provider)
.then(res => {
@@ -259,189 +298,6 @@ export default function ProviderListPage({account}:
{account: Account}) {
);
};
- const columns: Column<Provider>[] = [
- {
- title: i18next.t("general:Name"),
- key: "name",
- dataIndex: "name",
- width: "160px",
- // The server sorts and paginates, so sorting locally would only reorder
- // the current page.
- sorter: true,
- render: (text: string, record) => (
- <div className="flex min-w-0 items-center gap-2">
- <ProviderIcon icon={record.icon} baseUrl={record.baseUrl} alt={text}
size={18} />
- <SimpleTooltip title={text}>
- <Link
- to={`/providers/${record.owner}/${record.name}`}
- className="text-primary block truncate font-medium
hover:underline"
- >
- {text}
- </Link>
- </SimpleTooltip>
- </div>
- ),
- },
- {
- title: i18next.t("general:Display name"),
- key: "displayName",
- dataIndex: "displayName",
- width: "200px",
- sorter: true,
- render: (text: string) => (text ? <span className="block
truncate">{text}</span> : "-"),
- },
- {
- title: i18next.t("provider:Notes"),
- key: "notes",
- dataIndex: "notes",
- width: "180px",
- ellipsis: true,
- render: (text: string) =>
- text ? (
- <SimpleTooltip title={text}>
- <span className="text-muted-foreground block
truncate">{text}</span>
- </SimpleTooltip>
- ) : (
- "-"
- ),
- },
- {
- title: i18next.t("provider:Type"),
- key: "type",
- dataIndex: "type",
- width: "110px",
- render: (text: string) => <Badge variant={text === "openai" ? "success"
: "info"}>{text}</Badge>,
- },
- {
- title: i18next.t("provider:Base URL"),
- key: "baseUrl",
- dataIndex: "baseUrl",
- width: "220px",
- ellipsis: true,
- render: (text: string) =>
- text ? (
- <SimpleTooltip title={text}>
- <span className="inline-flex max-w-full">
- <CodeText>{text}</CodeText>
- </span>
- </SimpleTooltip>
- ) : (
- "-"
- ),
- },
- {
- title: i18next.t("provider:Models"),
- key: "models",
- dataIndex: "models",
- width: "220px",
- render: (models: string[], record) =>
- !models || models.length === 0 ? (
- usesClientAuth(record) ? <Badge
variant="muted">{i18next.t("provider:Any model")}</Badge> : "-"
- ) : (
- <div className="flex flex-wrap gap-1">
- {models.map(model => (
- <Badge key={model} variant="muted">
- {model}
- </Badge>
- ))}
- </div>
- ),
- },
- {
- title: i18next.t("provider:Priority"),
- key: "priority",
- dataIndex: "priority",
- width: "100px",
- sorter: true,
- },
- {
- title: i18next.t("provider:Status"),
- key: "status",
- dataIndex: "status",
- width: "120px",
- render: (text: string) =>
- text === "enabled" ? (
- <Badge variant="success">
- <CircleCheck />
- {i18next.t("provider:Enabled")}
- </Badge>
- ) : (
- <Badge variant="muted">
- <CircleX />
- {i18next.t("provider:Disabled")}
- </Badge>
- ),
- },
- {
- title: i18next.t("provider:Balance"),
- key: "quota",
- width: "140px",
- render: (_text, record) => (
- <QuotaBadge quota={quotas.find(item => item.provider ===
`${record.owner}/${record.name}`)} />
- ),
- },
- {
- title: i18next.t("provider:Health"),
- key: "health",
- width: "150px",
- render: (_text, record) => {
- const item = health.find(entry => entry.provider ===
`${record.owner}/${record.name}`);
- if (!item) {
- return <span
className="text-muted-foreground">{i18next.t("provider:Not used yet")}</span>;
- }
- // A provider that is out of its cooldown but whose last attempts
failed
- // is back in rotation without having proven anything yet.
- const badge = !item.healthy ? (
- <Badge variant="warning">
- <CircleX />
- {i18next.t("provider:Cooling down")}
- </Badge>
- ) : item.consecutive > 0 ? (
- <Badge variant="muted">{i18next.t("provider:Recovering")}</Badge>
- ) : (
- <Badge variant="success">
- <CircleCheck />
- {i18next.t("provider:Healthy")}
- </Badge>
- );
- return (
- <SimpleTooltip
- title={
- item.healthy
- ? `${item.successes} / ${item.successes + item.failures}`
- : `${item.lastError} · ${i18next.t("provider:Retried at")}
${item.retryTime}`
- }
- >
- {badge}
- </SimpleTooltip>
- );
- },
- },
- {
- title: i18next.t("general:Action"),
- key: "action",
- width: "190px",
- render: (_text, record) => (
- <div className="flex gap-2">
- <Button size="sm" variant="outline" onClick={() =>
navigate(`/providers/${record.owner}/${record.name}`)}>
- <Pencil />
- {i18next.t("general:Edit")}
- </Button>
- <ConfirmDialog
- title={i18next.t("general:Sure to delete {name}
?").replace("{name}", record.name)}
- confirmText={i18next.t("general:Delete")}
- onConfirm={() => deleteProvider(record)}
- >
- <Button size="sm" variant="outline" className="text-destructive">
- <Trash2 />
- {i18next.t("general:Delete")}
- </Button>
- </ConfirmDialog>
- </div>
- ),
- },
- ];
-
return (
<PageContainer>
<PageHeader
@@ -469,30 +325,13 @@ export default function ProviderListPage({account}:
{account: Account}) {
</CardContent>
</Card>
) : (
- <DataTable
- columns={columns}
- dataSource={data}
- // An admin sees providers across owners, where names may collide.
- rowKey={record => `${record.owner}/${record.name}`}
- loading={loading}
- onSort={(field, order) => fetchProviders(1, pageSize, {field: field,
order: order})}
- serverPagination={{
- page: page,
- pageSize: pageSize,
- total: total,
- onChange: (nextPage, nextPageSize) => fetchProviders(nextPage,
nextPageSize),
- }}
- title={i18next.t("provider:Providers")}
- description={`${total} ${i18next.t("provider:Providers")}`}
- emptyIcon={Plug}
- toolbar={
- <>
- <Button
- variant="outline"
- size="sm"
- onClick={() => loadQuotas(true)}
- loading={refreshingQuotas}
- >
+ <div className="space-y-3">
+ <div className="flex flex-wrap items-center justify-between gap-2">
+ <p className="text-muted-foreground text-sm">
+ {`${total} ${i18next.t("provider:Providers")}`}
+ </p>
+ <div className="flex flex-wrap gap-2">
+ <Button variant="outline" size="sm" onClick={() =>
loadQuotas(true)} loading={refreshingQuotas}>
<Wallet />
{i18next.t("provider:Refresh balances")}
</Button>
@@ -500,9 +339,46 @@ export default function ProviderListPage({account}:
{account: Account}) {
<RefreshCw />
{i18next.t("general:Refresh")}
</Button>
- </>
- }
- />
+ </div>
+ </div>
+
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
+ {data.map(provider => (
+ <ProviderGridCard
+ key={providerIdOf(provider)}
+ provider={provider}
+ agents={agents}
+ health={health.find(item => item.provider ===
providerIdOf(provider))}
+ quota={quotas.find(item => item.provider ===
providerIdOf(provider))}
+ busy={binding !== ""}
+ onEdit={() =>
navigate(`/providers/${provider.owner}/${provider.name}`)}
+ onDelete={() => deleteProvider(provider)}
+ onBind={agent => bindAgent(agent, provider)}
+ />
+ ))}
+ </div>
+
+ {total > pageSize ? (
+ <div className="flex justify-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={page <= 1}
+ onClick={() => fetchProviders(page - 1)}
+ >
+ {i18next.t("general:Previous")}
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={page * pageSize >= total}
+ onClick={() => fetchProviders(page + 1)}
+ >
+ {i18next.t("general:Next")}
+ </Button>
+ </div>
+ ) : null}
+ </div>
)}
<FormDialog
@@ -539,23 +415,28 @@ export default function ProviderListPage({account}:
{account: Account}) {
</Button>
</div>
</Field>
- <Field label={i18next.t("general:Name")} htmlFor="provider-name"
required error={nameError}>
+ <Field
+ label={i18next.t("general:Name")}
+ htmlFor="provider-display-name"
+ required
+ error={nameError}
+ hint={i18next.t("provider:Name hint")}
+ >
<Input
- id="provider-name"
- value={form.name}
+ id="provider-display-name"
+ value={form.displayName}
onChange={event => {
- setFormField("name", event.target.value);
+ // One field, because two names to invent is one too many.
The
+ // identifier follows what was typed until it is edited by
hand.
+ setForm(prev => ({
+ ...prev,
+ displayName: event.target.value,
+ name: providerSlug(event.target.value),
+ }));
setNameError("");
}}
/>
</Field>
- <Field label={i18next.t("general:Display name")}
htmlFor="provider-display-name">
- <Input
- id="provider-display-name"
- value={form.displayName}
- onChange={event => setFormField("displayName",
event.target.value)}
- />
- </Field>
{usesClientAuth(form) ? null : (
<Field
label={
diff --git a/web/src/pages/SettingPage.tsx b/web/src/pages/SettingPage.tsx
index 5228f59..484be03 100644
--- a/web/src/pages/SettingPage.tsx
+++ b/web/src/pages/SettingPage.tsx
@@ -169,7 +169,7 @@ export default function SettingPage({account}: {account:
Account}) {
{numberField("agentMonitorPollSeconds", i18next.t("setting:Agent poll
seconds"), undefined, 1)}
</Section>
- <Section columns={2} title={i18next.t("setting:Sign-in")}
description={i18next.t("setting:Sign-in description")}>
+ <Section columns={2} collapsible title={i18next.t("setting:Sign-in")}
description={i18next.t("setting:Sign-in description")}>
{textField("casdoorEndpoint", i18next.t("setting:Casdoor endpoint"))}
{textField("clientId", i18next.t("setting:Client ID"))}
{secretField("clientSecret", i18next.t("setting:Client secret"))}
@@ -179,9 +179,10 @@ export default function SettingPage({account}: {account:
Account}) {
<Section columns={2} title={i18next.t("setting:Security")}>
{secretField("apiKeyEncryptionKey", i18next.t("setting:API key
encryption key"), i18next.t("setting:API key encryption key hint"))}
+ {textField("relayToken", i18next.t("setting:Relay token"),
i18next.t("setting:Relay token hint"))}
</Section>
- <Section columns={2} title={i18next.t("setting:Network and
certificates")}>
+ <Section columns={2} collapsible title={i18next.t("setting:Network and
certificates")}>
{textField("httpProxy", i18next.t("setting:Outbound SOCKS5 proxy"),
i18next.t("setting:Outbound SOCKS5 proxy hint"))}
{textField("acmeEmail", i18next.t("setting:ACME email"),
i18next.t("setting:ACME email hint"))}
<Field label={i18next.t("setting:ACME private key")}
htmlFor="setting-acmePrivateKey" className="md:col-span-2">
@@ -196,6 +197,7 @@ export default function SettingPage({account}: {account:
Account}) {
<Section
columns={2}
+ collapsible
title={i18next.t("setting:Application deployment")}
description={i18next.t("setting:Application deployment description")}
>
diff --git a/web/src/types.ts b/web/src/types.ts
index dcf8b57..c361fa5 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -434,6 +434,7 @@ export interface Setting {
casdoorApplication: string;
apiKeyEncryptionKey: string;
+ relayToken: string;
httpProxy: string;
acmeEmail: string;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]