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 9dc664d feat(setting): edit the settings in the web UI, not
conf/app.conf
9dc664d is described below
commit 9dc664de9f6f176bdf46c6256bfe26cea85f4286
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 21:19:05 2026 +0800
feat(setting): edit the settings in the web UI, not conf/app.conf
---
conf/app.conf | 7 +
conf/conf.go | 36 ++++
controllers/setting.go | 168 +++++++++++++++
controllers/system.go | 7 +-
embedsupport/conf.go | 2 +-
main.go | 3 +
object/llm_pricing.go | 60 +++++-
object/ormer.go | 6 +
object/setting.go | 237 +++++++++++++++++++++
object/site_cert_account.go | 6 +-
proxy/proxy.go | 4 +-
routers/router.go | 3 +-
run/database.go | 4 +-
run/database_cloud.go | 14 +-
run/run.go | 4 +-
run/util.go | 29 +--
run/util_path.go | 8 +-
service/proxy.go | 209 ++++++++++++++----
service/startup.go | 9 +-
web/src/App.tsx | 2 +
.../system.go => web/src/backend/SettingBackend.ts | 24 +--
web/src/locales/en/data.json | 64 +++++-
web/src/locales/zh/data.json | 64 +++++-
web/src/nav.ts | 4 +-
web/src/pages/LlmRecordsPage.tsx | 78 ++++++-
web/src/pages/SettingPage.tsx | 217 +++++++++++++++++++
web/src/pages/SiteListPage.tsx | 89 ++++++--
web/src/types.ts | 50 +++++
28 files changed, 1268 insertions(+), 140 deletions(-)
diff --git a/conf/app.conf b/conf/app.conf
index e51360b..69b7234 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -1,3 +1,10 @@
+; This file seeds the settings on the very first start. Casbin Gateway then
+; keeps them in its database and reads them from there, so a line changed below
+; afterwards has no effect — use the Settings page of the web UI instead. The
+; exceptions are the keys read before the database is open: "appname",
+; "httpport", "runmode", "driverName", "dataSourceName", "dbName" and
+; "redisEndpoint". Those are only ever read from this file.
+
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.
diff --git a/conf/conf.go b/conf/conf.go
index fc09bef..7974809 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -21,6 +21,7 @@ import (
"runtime"
"strconv"
"strings"
+ "sync"
"github.com/beego/beego"
)
@@ -48,11 +49,40 @@ func ApplyEnvOverrides() {
}
}
+var (
+ settingOverrides = map[string]string{}
+ settingMutex sync.RWMutex
+)
+
+// SetSettingOverrides hands over the settings held in the database. The
+// built-in Setting row is the source of truth for everything the web UI can
+// change, so a key it carries wins over conf/app.conf even when it is empty:
+// the row was seeded from the file, and clearing a value in the UI has to
+// clear it for good rather than fall back to what the file still says.
+func SetSettingOverrides(overrides map[string]string) {
+ settingMutex.Lock()
+ defer settingMutex.Unlock()
+
+ settingOverrides = overrides
+}
+
+func getSettingOverride(key string) (string, bool) {
+ settingMutex.RLock()
+ defer settingMutex.RUnlock()
+
+ value, ok := settingOverrides[key]
+ return value, ok
+}
+
func GetConfigString(key string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
+ if value, ok := getSettingOverride(key); ok {
+ return value
+ }
+
res := beego.AppConfig.String(key)
if res == "" {
if key == "staticBaseUrl" {
@@ -65,6 +95,12 @@ func GetConfigString(key string) string {
return res
}
+// GetConfigStringUnquoted drops the quotes conf/app.conf writes around a
value.
+// Settings stored in the database never carry them.
+func GetConfigStringUnquoted(key string) string {
+ return strings.Trim(GetConfigString(key), `"' `)
+}
+
func GetConfigBool(key string) bool {
value := GetConfigString(key)
if value == "true" {
diff --git a/controllers/setting.go b/controllers/setting.go
new file mode 100644
index 0000000..62f7961
--- /dev/null
+++ b/controllers/setting.go
@@ -0,0 +1,168 @@
+// 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 controllers
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/apache/casbin-gateway/agentmonitor"
+ "github.com/apache/casbin-gateway/casdoor"
+ "github.com/apache/casbin-gateway/conf"
+ "github.com/apache/casbin-gateway/object"
+ "github.com/apache/casbin-gateway/proxy"
+ "github.com/apache/casbin-gateway/run"
+ "github.com/apache/casbin-gateway/service"
+)
+
+func (c *ApiController) GetSetting() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ setting, err := object.GetBuiltInSetting()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(setting)
+}
+
+// UpdateSetting stores the settings and applies them to the running Gateway,
so
+// the Settings page is all there is to it: no file to edit, and nothing to
+// restart afterwards.
+func (c *ApiController) UpdateSetting() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var setting object.Setting
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &setting); err != nil
{
+ c.ResponseError(err.Error())
+ return
+ }
+
+ if err := validateSetting(&setting); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ previous, err := object.GetBuiltInSetting()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if previous == nil {
+ c.ResponseError("the built-in setting does not exist")
+ return
+ }
+
+ affected, err := object.UpdateSetting(object.BuiltInSettingId, &setting)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if !affected {
+ c.ResponseError("the built-in setting could not be updated")
+ return
+ }
+
+ // Everything is saved by now, so a subsystem that refuses the new
setting is
+ // reported without losing it: the admin can correct the value, or start
+ // Gateway again with the privileges it turned out to need.
+ if err = applySetting(previous, &setting); err != nil {
+ c.ResponseError(fmt.Sprintf("the settings were saved, but they
could not all be applied: %s", err.Error()))
+ return
+ }
+
+ c.ResponseOk(setting)
+}
+
+func validateSetting(setting *object.Setting) error {
+ switch setting.LlmRecordMode {
+ case conf.LlmRecordOff, conf.LlmRecordMetadata, conf.LlmRecordFull:
+ default:
+ return fmt.Errorf("llmRecordMode must be one of \"off\",
\"metadata\" or \"full\"")
+ }
+
+ for _, port := range []int{setting.GatewayHttpPort,
setting.GatewayHttpsPort} {
+ if port < 1 || port > 65535 {
+ return fmt.Errorf("the gateway ports must be between 1
and 65535")
+ }
+ }
+ if setting.GatewayHttpPort == setting.GatewayHttpsPort {
+ return fmt.Errorf("the two gateway ports must differ")
+ }
+
+ // run.InitAppMap() panics on a malformed value, so it is rejected here
+ // rather than taking the process down after it has been stored.
+ if appMap := strings.TrimSpace(setting.AppMap); appMap != "" {
+ if err := json.Unmarshal([]byte(appMap), &map[string]string{});
err != nil {
+ return fmt.Errorf("appMap must be a JSON object, e.g.
{\"casdoor\": \"cc\"}: %s", err.Error())
+ }
+ }
+
+ return nil
+}
+
+// applySetting hands the stored settings to the subsystems that hold on to
+// them. conf already answers with the new values, so each call below is the
+// same one main() makes at startup.
+func applySetting(previous *object.Setting, setting *object.Setting) error {
+ casdoor.InitCasdoorConfig()
+ proxy.InitHttpClient()
+ run.InitAppMap()
+ object.ReloadLlmPrices()
+ agentmonitor.Configure(
+ conf.GetAgentPatchStateDir(),
+ time.Duration(conf.GetAgentMonitorPollSeconds())*time.Second,
+ conf.GetAgentRecordCapacity(),
+ )
+
+ if setting.LlmRecordMode == conf.LlmRecordOff {
+ object.StopLlmRecordWriter()
+ } else {
+ // The queue is sized when the writer starts, so a new capacity
only
+ // takes hold once it has been through a stop.
+ if previous.LlmRecordQueueCapacity !=
setting.LlmRecordQueueCapacity {
+ object.StopLlmRecordWriter()
+ }
+ object.StartLlmRecordWriter()
+ }
+
+ return applyGatewaySetting(previous, setting)
+}
+
+func applyGatewaySetting(previous *object.Setting, setting *object.Setting)
error {
+ if !setting.GatewayEnabled {
+ service.StopGateway()
+ return nil
+ }
+
+ portsChanged := previous.GatewayHttpPort != setting.GatewayHttpPort ||
+ previous.GatewayHttpsPort != setting.GatewayHttpsPort
+ if portsChanged && service.IsGatewayRunning() {
+ service.StopGateway()
+ }
+
+ if err := service.StartGateway(); err != nil {
+ return fmt.Errorf("the reverse proxy could not start: %s",
err.Error())
+ }
+
+ return nil
+}
diff --git a/controllers/system.go b/controllers/system.go
index 1ccfde1..28896a8 100644
--- a/controllers/system.go
+++ b/controllers/system.go
@@ -16,11 +16,12 @@ package controllers
import (
"github.com/apache/casbin-gateway/conf"
+ "github.com/apache/casbin-gateway/service"
)
// GetGatewayStatus tells the web UI whether the reverse-proxy gateway is
-// actually running. Without it, a Gateway started with "gatewayEnabled =
false"
-// accepts every site and rule and silently proxies nothing.
+// actually running. Without it, a Gateway whose reverse proxy is off accepts
+// every site and rule and silently proxies nothing.
func (c *ApiController) GetGatewayStatus() {
if c.RequireSignedIn() {
return
@@ -28,6 +29,8 @@ func (c *ApiController) GetGatewayStatus() {
c.ResponseOk(map[string]interface{}{
"gatewayEnabled": conf.IsGatewayEnabled(),
+ "gatewayRunning": service.IsGatewayRunning(),
+ "gatewayError": service.GatewayError(),
"httpPort": conf.GetGatewayHttpPort(),
"httpsPort": conf.GetGatewayHttpsPort(),
})
diff --git a/embedsupport/conf.go b/embedsupport/conf.go
index a779f4e..83bc9cb 100644
--- a/embedsupport/conf.go
+++ b/embedsupport/conf.go
@@ -65,7 +65,7 @@ func setupConf(appConf string) {
conf.ApplyEnvOverrides()
usingEmbeddedConf = true
- fmt.Println("Using the conf/app.conf embedded in this binary. To change
any setting, put your own conf/app.conf next to the executable.")
+ fmt.Println("Using the conf/app.conf embedded in this binary. Settings
can be changed on the Settings page of the web UI.")
}
// onDiskConfPath returns the config file beego found on disk, or "" when there
diff --git a/main.go b/main.go
index e445e90..fd44b2f 100644
--- a/main.go
+++ b/main.go
@@ -45,6 +45,9 @@ func main() {
object.InitFlag()
object.InitAdapter()
object.CreateTables()
+ // The built-in Setting row answers conf from here on, so it has to be
loaded
+ // before anything reads a setting out of conf.
+ object.InitBuiltInSetting()
object.StartLlmRecordWriter()
defer object.StopLlmRecordWriter()
casdoor.InitCasdoorConfig()
diff --git a/object/llm_pricing.go b/object/llm_pricing.go
index b671f6d..4937c0c 100644
--- a/object/llm_pricing.go
+++ b/object/llm_pricing.go
@@ -35,9 +35,9 @@ type LlmPrice struct {
CacheRead float64 `json:"cacheRead"`
}
-// llmPrices are published list prices, keyed by the part of a model name that
+// builtInLlmPrices are published list prices, keyed by the part of a model
name that
// identifies the model. They go stale, so llmPricingFile overrides them.
-var llmPrices = map[string]LlmPrice{
+var builtInLlmPrices = map[string]LlmPrice{
"claude-opus-4": {15, 75, 18.75, 1.5},
"claude-3-opus": {15, 75, 18.75, 1.5},
"claude-sonnet-4": {3, 15, 3.75, 0.3},
@@ -61,13 +61,46 @@ var llmPrices = map[string]LlmPrice{
}
var (
- pricingOnce sync.Once
- pricingKeys []string
+ pricingMutex sync.RWMutex
+ pricingLoaded bool
+ pricingKeys []string
+ llmPrices map[string]LlmPrice
)
+// ReloadLlmPrices re-reads the override file, so changing "llmPricingFile" on
+// the Settings page costs the next request rather than the next restart.
+func ReloadLlmPrices() {
+ pricingMutex.Lock()
+ defer pricingMutex.Unlock()
+
+ loadLlmPrices()
+}
+
+func ensureLlmPrices() {
+ pricingMutex.RLock()
+ loaded := pricingLoaded
+ pricingMutex.RUnlock()
+ if loaded {
+ return
+ }
+
+ pricingMutex.Lock()
+ defer pricingMutex.Unlock()
+ if !pricingLoaded {
+ loadLlmPrices()
+ }
+}
+
// loadLlmPrices merges the override file over the built-in table, longest key
-// first so a specific model wins over the family it belongs to.
+// first so a specific model wins over the family it belongs to. It rebuilds
the
+// table from the built-in one, so a price dropped from the file is dropped
here
+// too. The caller holds the write lock.
func loadLlmPrices() {
+ prices := make(map[string]LlmPrice, len(builtInLlmPrices))
+ for model, price := range builtInLlmPrices {
+ prices[model] = price
+ }
+
path := conf.GetLlmPricingFile()
if path != "" {
if data, err := os.ReadFile(path); err != nil {
@@ -80,24 +113,29 @@ func loadLlmPrices() {
beego.Error("LLM pricing file is not valid
JSON:", err)
} else {
for model, price := range overrides {
- llmPrices[strings.ToLower(model)] =
price
+ prices[strings.ToLower(model)] = price
}
}
}
}
- pricingKeys = make([]string, 0, len(llmPrices))
- for key := range llmPrices {
- pricingKeys = append(pricingKeys, key)
+ keys := make([]string, 0, len(prices))
+ for key := range prices {
+ keys = append(keys, key)
}
- sort.Slice(pricingKeys, func(i, j int) bool { return
len(pricingKeys[i]) > len(pricingKeys[j]) })
+ sort.Slice(keys, func(i, j int) bool { return len(keys[i]) >
len(keys[j]) })
+
+ llmPrices, pricingKeys, pricingLoaded = prices, keys, true
}
// GetLlmPrice matches a model name in any of the shapes it arrives in, e.g.
// "claude-sonnet-4-20250514" or "us.anthropic.claude-sonnet-4-v1:0". It
reports
// false when nothing matches, so an unpriced model is not costed at zero.
func GetLlmPrice(model string) (LlmPrice, bool) {
- pricingOnce.Do(loadLlmPrices)
+ ensureLlmPrices()
+
+ pricingMutex.RLock()
+ defer pricingMutex.RUnlock()
name := strings.ToLower(strings.TrimSpace(model))
for _, key := range pricingKeys {
diff --git a/object/ormer.go b/object/ormer.go
index 1801e1b..f5e56f5 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -251,6 +251,12 @@ func (a *Ormer) createTable() {
panic(err)
}
+ // The settings the web UI can change, held in one built-in row.
+ err = a.Engine.Sync2(new(Setting))
+ if err != nil {
+ panic(err)
+ }
+
// Local users, used for sign-in when no Casdoor is configured.
err = a.Engine.Sync2(new(User))
if err != nil {
diff --git a/object/setting.go b/object/setting.go
new file mode 100644
index 0000000..e453b4c
--- /dev/null
+++ b/object/setting.go
@@ -0,0 +1,237 @@
+// 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 object
+
+import (
+ "strconv"
+
+ "github.com/apache/casbin-gateway/conf"
+ "github.com/apache/casbin-gateway/util"
+ "github.com/xorm-io/core"
+)
+
+// BuiltInSettingId is the one row this table holds. Settings are global, so
+// there is nothing to add or delete: the row is created on first start from
+// conf/app.conf and edited from the Settings page afterwards.
+const BuiltInSettingId = "admin/setting-built-in"
+
+type Setting struct {
+ Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
+ Name string `xorm:"varchar(100) notnull pk" json:"name"`
+ CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+ DisplayName string `xorm:"varchar(100)" json:"displayName"`
+
+ GatewayEnabled bool `xorm:"bool" json:"gatewayEnabled"`
+ GatewayHttpPort int `xorm:"int" json:"gatewayHttpPort"`
+ GatewayHttpsPort int `xorm:"int" json:"gatewayHttpsPort"`
+
+ LlmRecordMode string `xorm:"varchar(100)"
json:"llmRecordMode"`
+ LlmRecordQueueCapacity int `xorm:"int"
json:"llmRecordQueueCapacity"`
+ LlmRecordRetentionDays int `xorm:"int"
json:"llmRecordRetentionDays"`
+ LlmRecordMaxRecords int `xorm:"int" json:"llmRecordMaxRecords"`
+ LlmRecordMaxPayloadBytes int `xorm:"int"
json:"llmRecordMaxPayloadBytes"`
+ LlmPricingFile string `xorm:"varchar(500)"
json:"llmPricingFile"`
+
+ AgentPatchStateDir string `xorm:"varchar(500)"
json:"agentPatchStateDir"`
+ AgentRecordCapacity int `xorm:"int" json:"agentRecordCapacity"`
+ AgentMonitorPollSeconds int `xorm:"int"
json:"agentMonitorPollSeconds"`
+
+ CasdoorEndpoint string `xorm:"varchar(500)" json:"casdoorEndpoint"`
+ ClientId string `xorm:"varchar(100)" json:"clientId"`
+ ClientSecret string `xorm:"varchar(200)" json:"clientSecret"`
+ CasdoorOrganization string `xorm:"varchar(100)"
json:"casdoorOrganization"`
+ CasdoorApplication string `xorm:"varchar(100)"
json:"casdoorApplication"`
+
+ ApiKeyEncryptionKey string `xorm:"varchar(200)"
json:"apiKeyEncryptionKey"`
+
+ HttpProxy string `xorm:"varchar(200)" json:"httpProxy"`
+ AcmeEmail string `xorm:"varchar(200)" json:"acmeEmail"`
+ AcmePrivateKey string `xorm:"mediumtext" json:"acmePrivateKey"`
+
+ AppDir string `xorm:"varchar(500)" json:"appDir"`
+ Language string `xorm:"varchar(100)" json:"language"`
+ AppMap string `xorm:"varchar(1000)" json:"appMap"`
+ ClientIdPrefix string `xorm:"varchar(100)" json:"clientIdPrefix"`
+ ClientSecretPrefix string `xorm:"varchar(100)"
json:"clientSecretPrefix"`
+ DbRegionId string `xorm:"varchar(100)" json:"dbRegionId"`
+ DbAccessKeyId string `xorm:"varchar(200)" json:"dbAccessKeyId"`
+ DbAccessKeySecret string `xorm:"varchar(200)" json:"dbAccessKeySecret"`
+ DbInstanceId string `xorm:"varchar(100)" json:"dbInstanceId"`
+ DbHost string `xorm:"varchar(200)" json:"dbHost"`
+ DbUser string `xorm:"varchar(100)" json:"dbUser"`
+ DbPass string `xorm:"varchar(200)" json:"dbPass"`
+}
+
+// SyncSettingToConf makes the row the answer conf.GetConfigString() gives, so
+// every existing caller of conf keeps working and none of them has to know
that
+// the value now comes from the database. Keys not listed here stay in
+// conf/app.conf: the database connection and the management port are read
+// before this row can be, and "isDemoMode" turns the API read-only, which
would
+// take the Settings page that turns it off down with it.
+func SyncSettingToConf(setting *Setting) {
+ conf.SetSettingOverrides(map[string]string{
+ "gatewayEnabled": strconv.FormatBool(setting.GatewayEnabled),
+ "gatewayHttpPort": strconv.Itoa(setting.GatewayHttpPort),
+ "gatewayHttpsPort": strconv.Itoa(setting.GatewayHttpsPort),
+
+ "llmRecordMode": setting.LlmRecordMode,
+ "llmRecordQueueCapacity":
strconv.Itoa(setting.LlmRecordQueueCapacity),
+ "llmRecordRetentionDays":
strconv.Itoa(setting.LlmRecordRetentionDays),
+ "llmRecordMaxRecords":
strconv.Itoa(setting.LlmRecordMaxRecords),
+ "llmRecordMaxPayloadBytes":
strconv.Itoa(setting.LlmRecordMaxPayloadBytes),
+ "llmPricingFile": setting.LlmPricingFile,
+
+ "agentPatchStateDir": setting.AgentPatchStateDir,
+ "agentRecordCapacity":
strconv.Itoa(setting.AgentRecordCapacity),
+ "agentMonitorPollSeconds":
strconv.Itoa(setting.AgentMonitorPollSeconds),
+
+ "casdoorEndpoint": setting.CasdoorEndpoint,
+ "clientId": setting.ClientId,
+ "clientSecret": setting.ClientSecret,
+ "casdoorOrganization": setting.CasdoorOrganization,
+ "casdoorApplication": setting.CasdoorApplication,
+
+ "apiKeyEncryptionKey": setting.ApiKeyEncryptionKey,
+
+ "httpProxy": setting.HttpProxy,
+ "acmeEmail": setting.AcmeEmail,
+ "acmePrivateKey": setting.AcmePrivateKey,
+
+ "appDir": setting.AppDir,
+ "language": setting.Language,
+ "appMap": setting.AppMap,
+ "clientIdPrefix": setting.ClientIdPrefix,
+ "clientSecretPrefix": setting.ClientSecretPrefix,
+ "dbRegionId": setting.DbRegionId,
+ "dbAccessKeyId": setting.DbAccessKeyId,
+ "dbAccessKeySecret": setting.DbAccessKeySecret,
+ "dbInstanceId": setting.DbInstanceId,
+ "dbHost": setting.DbHost,
+ "dbUser": setting.DbUser,
+ "dbPass": setting.DbPass,
+ })
+}
+
+func getSetting(owner string, name string) (*Setting, error) {
+ setting := Setting{Owner: owner, Name: name}
+ existed, err := ormer.Engine.Get(&setting)
+ if err != nil {
+ return nil, err
+ }
+
+ if existed {
+ return &setting, nil
+ }
+ return nil, nil
+}
+
+func GetSetting(id string) (*Setting, error) {
+ owner, name := util.GetOwnerAndNameFromId(id)
+ return getSetting(owner, name)
+}
+
+func GetBuiltInSetting() (*Setting, error) {
+ return GetSetting(BuiltInSettingId)
+}
+
+func UpdateSetting(id string, setting *Setting) (bool, error) {
+ owner, name := util.GetOwnerAndNameFromId(id)
+ if s, err := getSetting(owner, name); err != nil {
+ return false, err
+ } else if s == nil {
+ return false, nil
+ }
+
+ setting.Owner, setting.Name = owner, name
+ _, err := ormer.Engine.ID(core.PK{owner,
name}).AllCols().Update(setting)
+ if err != nil {
+ return false, err
+ }
+
+ SyncSettingToConf(setting)
+ return true, nil
+}
+
+// InitBuiltInSetting creates the row on first start, seeded from conf/app.conf
+// so an existing installation keeps the settings it already had, and loads it
+// into conf. It runs before anything else reads conf, because from here on the
+// row is what conf answers with.
+func InitBuiltInSetting() {
+ setting, err := GetBuiltInSetting()
+ if err != nil {
+ panic(err)
+ }
+
+ if setting == nil {
+ setting = newSettingFromConf()
+ if _, err = ormer.Engine.Insert(setting); err != nil {
+ panic(err)
+ }
+ }
+
+ SyncSettingToConf(setting)
+}
+
+// newSettingFromConf reads the defaults through conf's own getters, so a key
+// that conf/app.conf never mentioned is stored as the default the code would
+// have used anyway rather than as an empty value.
+func newSettingFromConf() *Setting {
+ return &Setting{
+ Owner: "admin",
+ Name: "setting-built-in",
+ CreatedTime: util.GetCurrentTime(),
+ DisplayName: "Built-in Setting",
+
+ GatewayEnabled: conf.IsGatewayEnabled(),
+ GatewayHttpPort: conf.GetGatewayHttpPort(),
+ GatewayHttpsPort: conf.GetGatewayHttpsPort(),
+
+ LlmRecordMode: conf.GetLlmRecordMode(),
+ LlmRecordQueueCapacity: conf.GetLlmRecordQueueCapacity(),
+ LlmRecordRetentionDays: conf.GetLlmRecordRetentionDays(),
+ LlmRecordMaxRecords: conf.GetLlmRecordMaxRecords(),
+ LlmRecordMaxPayloadBytes: conf.GetLlmRecordMaxPayloadBytes(),
+ LlmPricingFile: conf.GetLlmPricingFile(),
+
+ AgentPatchStateDir: conf.GetAgentPatchStateDir(),
+ AgentRecordCapacity: conf.GetAgentRecordCapacity(),
+ AgentMonitorPollSeconds: conf.GetAgentMonitorPollSeconds(),
+
+ CasdoorEndpoint:
conf.GetConfigStringUnquoted("casdoorEndpoint"),
+ ClientId: conf.GetConfigStringUnquoted("clientId"),
+ ClientSecret:
conf.GetConfigStringUnquoted("clientSecret"),
+ CasdoorOrganization:
conf.GetConfigStringUnquoted("casdoorOrganization"),
+ CasdoorApplication:
conf.GetConfigStringUnquoted("casdoorApplication"),
+
+ ApiKeyEncryptionKey:
conf.GetConfigStringUnquoted("apiKeyEncryptionKey"),
+
+ HttpProxy: conf.GetConfigStringUnquoted("httpProxy"),
+ AcmeEmail: conf.GetConfigStringUnquoted("acmeEmail"),
+ AcmePrivateKey: conf.GetConfigStringUnquoted("acmePrivateKey"),
+
+ AppDir: conf.GetConfigStringUnquoted("appDir"),
+ Language: conf.GetConfigStringUnquoted("language"),
+ AppMap: conf.GetConfigStringUnquoted("appMap"),
+ ClientIdPrefix:
conf.GetConfigStringUnquoted("clientIdPrefix"),
+ ClientSecretPrefix:
conf.GetConfigStringUnquoted("clientSecretPrefix"),
+ DbRegionId: conf.GetConfigStringUnquoted("dbRegionId"),
+ DbAccessKeyId:
conf.GetConfigStringUnquoted("dbAccessKeyId"),
+ DbAccessKeySecret:
conf.GetConfigStringUnquoted("dbAccessKeySecret"),
+ DbInstanceId:
conf.GetConfigStringUnquoted("dbInstanceId"),
+ DbHost: conf.GetConfigStringUnquoted("dbHost"),
+ DbUser: conf.GetConfigStringUnquoted("dbUser"),
+ DbPass: conf.GetConfigStringUnquoted("dbPass"),
+ }
+}
diff --git a/object/site_cert_account.go b/object/site_cert_account.go
index 5a1f546..a5125a7 100644
--- a/object/site_cert_account.go
+++ b/object/site_cert_account.go
@@ -19,8 +19,8 @@ import (
"fmt"
"net/http"
+ "github.com/apache/casbin-gateway/conf"
"github.com/apache/casbin-gateway/proxy"
- "github.com/beego/beego"
"github.com/casbin/lego/v4/acme"
"github.com/casbin/lego/v4/certcrypto"
"github.com/casbin/lego/v4/lego"
@@ -112,8 +112,8 @@ func getAcmeClient(email string, privateKey string, devMode
bool, useProxy bool)
}
func GetAcmeClient(useProxy bool) (*lego.Client, error) {
- acmeEmail := beego.AppConfig.String("acmeEmail")
- acmePrivateKey := beego.AppConfig.String("acmePrivateKey")
+ acmeEmail := conf.GetConfigStringUnquoted("acmeEmail")
+ acmePrivateKey := conf.GetConfigStringUnquoted("acmePrivateKey")
if acmeEmail == "" {
return nil, fmt.Errorf("acmeEmail should not be empty")
}
diff --git a/proxy/proxy.go b/proxy/proxy.go
index ff955fb..10cfbfa 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -24,7 +24,7 @@ import (
"strings"
"sync"
- "github.com/beego/beego"
+ "github.com/apache/casbin-gateway/conf"
)
// ProxyHttpClient sends its requests through the configured proxy.
@@ -71,7 +71,7 @@ func Transport() *http.Transport {
// "socks5h://", "http://" and "https://" addresses are honoured as written,
and
// may carry credentials.
func initProxyUrl() {
- httpProxy := strings.TrimSpace(beego.AppConfig.String("httpProxy"))
+ httpProxy :=
strings.TrimSpace(conf.GetConfigStringUnquoted("httpProxy"))
if httpProxy == "" {
return
}
diff --git a/routers/router.go b/routers/router.go
index 4ca6fe7..2b99a8d 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -39,7 +39,8 @@ func initAPI() {
beego.Router("/api/update-account", &controllers.ApiController{},
"POST:UpdateAccount")
beego.Router("/api/get-providers", &controllers.ApiController{},
"GET:GetProviders")
beego.Router("/api/get-gateway-status", &controllers.ApiController{},
"GET:GetGatewayStatus")
- beego.Router("/api/update-system-settings",
&controllers.ApiController{}, "POST:UpdateSystemSettings")
+ beego.Router("/api/get-setting", &controllers.ApiController{},
"GET:GetSetting")
+ beego.Router("/api/update-setting", &controllers.ApiController{},
"POST:UpdateSetting")
beego.Router("/api/get-global-nodes", &controllers.ApiController{},
"GET:GetGlobalNodes")
beego.Router("/api/get-nodes", &controllers.ApiController{},
"GET:GetNodes")
diff --git a/run/database.go b/run/database.go
index 4d6b10a..b93d0cb 100644
--- a/run/database.go
+++ b/run/database.go
@@ -18,7 +18,7 @@ import (
"fmt"
"strings"
- "github.com/beego/beego"
+ "github.com/apache/casbin-gateway/conf"
"github.com/xorm-io/xorm"
)
@@ -27,7 +27,7 @@ func gitCreateDatabase(name string) (bool, error) {
name = strings.Replace(name, "_00", "_", 1)
driverName := "mysql"
- dataSourceName := fmt.Sprintf("root:%s@tcp(localhost:3306)/",
beego.AppConfig.String("dbPass"))
+ dataSourceName := fmt.Sprintf("root:%s@tcp(localhost:3306)/",
conf.GetConfigStringUnquoted("dbPass"))
engine, err := xorm.NewEngine(driverName, dataSourceName)
if err != nil {
return false, err
diff --git a/run/database_cloud.go b/run/database_cloud.go
index 8aa3e1f..dfa50c6 100644
--- a/run/database_cloud.go
+++ b/run/database_cloud.go
@@ -21,15 +21,15 @@ import (
"github.com/aliyun/alibaba-cloud-sdk-go/sdk"
"github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials"
"github.com/aliyun/alibaba-cloud-sdk-go/services/rds"
- "github.com/beego/beego"
+ "github.com/apache/casbin-gateway/conf"
)
var rdsClient *rds.Client
func InitRdsClient() {
- dbRegionId := beego.AppConfig.String("dbRegionId")
- dbAccessKeyId := beego.AppConfig.String("dbAccessKeyId")
- dbAccessKeySecret := beego.AppConfig.String("dbAccessKeySecret")
+ dbRegionId := conf.GetConfigStringUnquoted("dbRegionId")
+ dbAccessKeyId := conf.GetConfigStringUnquoted("dbAccessKeyId")
+ dbAccessKeySecret := conf.GetConfigStringUnquoted("dbAccessKeySecret")
if dbRegionId == "" || dbAccessKeyId == "" || dbAccessKeySecret == "" {
return
@@ -48,7 +48,7 @@ func InitRdsClient() {
func gitCreateDatabaseCloud(name string) (bool, error) {
fmt.Printf("gitCreateDatabaseCloud(): [%s]\n", name)
- dbInstanceId := beego.AppConfig.String("dbInstanceId")
+ dbInstanceId := conf.GetConfigStringUnquoted("dbInstanceId")
// https://help.aliyun.com/document_detail/26258.htm
r := rds.CreateCreateDatabaseRequest()
@@ -72,8 +72,8 @@ func gitCreateDatabaseCloud(name string) (bool, error) {
}
func addDatabaseUser(dbName string) error {
- dbInstanceId := beego.AppConfig.String("dbInstanceId")
- dbUser := beego.AppConfig.String("dbUser")
+ dbInstanceId := conf.GetConfigStringUnquoted("dbInstanceId")
+ dbUser := conf.GetConfigStringUnquoted("dbUser")
// https://help.aliyun.com/document_detail/26266.html
r := rds.CreateGrantAccountPrivilegeRequest()
diff --git a/run/run.go b/run/run.go
index b0abbd0..37064e1 100644
--- a/run/run.go
+++ b/run/run.go
@@ -19,8 +19,8 @@ import (
"path/filepath"
"strings"
+ "github.com/apache/casbin-gateway/conf"
"github.com/apache/casbin-gateway/util"
- "github.com/beego/beego"
)
func isTargetRepo(siteName string) bool {
@@ -52,7 +52,7 @@ func CreateRepo(siteName string, needStart bool, diff string,
providerName strin
return wrapRepoError("gitClone", path, err)
}
- dbInstanceId := beego.AppConfig.String("dbInstanceId")
+ dbInstanceId := conf.GetConfigStringUnquoted("dbInstanceId")
if dbInstanceId == "" {
_, err = gitCreateDatabase(siteName)
if err != nil {
diff --git a/run/util.go b/run/util.go
index b6bf752..4319861 100644
--- a/run/util.go
+++ b/run/util.go
@@ -21,6 +21,7 @@ import (
"path/filepath"
"strings"
+ "github.com/apache/casbin-gateway/conf"
"github.com/apache/casbin-gateway/util"
"github.com/beego/beego"
)
@@ -65,7 +66,7 @@ func getRepoUrl(name string) string {
func getShortcut() string {
res := "Shortcut"
- language := beego.AppConfig.String("language")
+ language := conf.GetConfigStringUnquoted("language")
if language != "en" {
res = "快捷方式"
}
@@ -91,15 +92,15 @@ func updateAppConfFile(name string, i int, orgName string) {
}
content = strings.ReplaceAll(content, "httpport = 14000",
fmt.Sprintf("httpport = %d", 40000+i))
- content = strings.ReplaceAll(content, "root",
beego.AppConfig.String("dbUser"))
- content = strings.ReplaceAll(content, "123456",
beego.AppConfig.String("dbPass"))
- content = strings.ReplaceAll(content, "localhost:3306",
fmt.Sprintf("%s:3306", beego.AppConfig.String("dbHost")))
+ content = strings.ReplaceAll(content, "root",
conf.GetConfigStringUnquoted("dbUser"))
+ content = strings.ReplaceAll(content, "123456",
conf.GetConfigStringUnquoted("dbPass"))
+ content = strings.ReplaceAll(content, "localhost:3306",
fmt.Sprintf("%s:3306", conf.GetConfigStringUnquoted("dbHost")))
content = strings.ReplaceAll(content, "dbName = casibase",
fmt.Sprintf("dbName = %s", name))
content = strings.ReplaceAll(content, "redisEndpoint =",
fmt.Sprintf("redisEndpoint = \"%s\"", beego.AppConfig.String("redisEndpoint")))
content = strings.ReplaceAll(content, "disablePreviewMode =
false", "disablePreviewMode = true")
- content = strings.ReplaceAll(content, "casdoorEndpoint =
https://door.casdoor.com", fmt.Sprintf("casdoorEndpoint = %s",
strings.ReplaceAll(beego.AppConfig.String("casdoorEndpoint"), "my.", "cbc.")))
- content = strings.ReplaceAll(content, "clientId =
af6b5aa958822fb9dc33", fmt.Sprintf("clientId = %s",
beego.AppConfig.String("clientIdPrefix")+shortName))
- content = strings.ReplaceAll(content, "clientSecret =
8bc3010c1c951c8d876b1f311a901ff8deeb93bc", fmt.Sprintf("clientSecret = %s",
beego.AppConfig.String("clientSecretPrefix")+shortName))
+ content = strings.ReplaceAll(content, "casdoorEndpoint =
https://door.casdoor.com", fmt.Sprintf("casdoorEndpoint = %s",
strings.ReplaceAll(conf.GetConfigStringUnquoted("casdoorEndpoint"), "my.",
"cbc.")))
+ content = strings.ReplaceAll(content, "clientId =
af6b5aa958822fb9dc33", fmt.Sprintf("clientId = %s",
conf.GetConfigStringUnquoted("clientIdPrefix")+shortName))
+ content = strings.ReplaceAll(content, "clientSecret =
8bc3010c1c951c8d876b1f311a901ff8deeb93bc", fmt.Sprintf("clientSecret = %s",
conf.GetConfigStringUnquoted("clientSecretPrefix")+shortName))
content = strings.ReplaceAll(content, "casdoorOrganization =
\"casbin\"", fmt.Sprintf("casdoorOrganization = \"%s\"", shortName))
content = strings.ReplaceAll(content, "casdoorApplication =
\"app-casibase\"", fmt.Sprintf("casdoorApplication = \"%s\"",
fmt.Sprintf("app-%s", shortName)))
content = strings.ReplaceAll(content, "isLocalIpDb = false",
"isLocalIpDb = true")
@@ -111,19 +112,19 @@ func updateAppConfFile(name string, i int, orgName
string) {
}
content = strings.ReplaceAll(content, "httpport = 14000",
fmt.Sprintf("httpport = %d", 50000+i))
- content = strings.ReplaceAll(content, "root",
beego.AppConfig.String("dbUser"))
- content = strings.ReplaceAll(content, "123456",
beego.AppConfig.String("dbPass"))
- content = strings.ReplaceAll(content, "localhost:3306",
fmt.Sprintf("%s:3306", beego.AppConfig.String("dbHost")))
+ content = strings.ReplaceAll(content, "root",
conf.GetConfigStringUnquoted("dbUser"))
+ content = strings.ReplaceAll(content, "123456",
conf.GetConfigStringUnquoted("dbPass"))
+ content = strings.ReplaceAll(content, "localhost:3306",
fmt.Sprintf("%s:3306", conf.GetConfigStringUnquoted("dbHost")))
content = strings.ReplaceAll(content, "dbName = opendata",
fmt.Sprintf("dbName = %s", name))
content = strings.ReplaceAll(content, "redisEndpoint =",
fmt.Sprintf("redisEndpoint = \"%s\"", beego.AppConfig.String("redisEndpoint")))
- content = strings.ReplaceAll(content, "casdoorEndpoint =
https://door.casdoor.com", fmt.Sprintf("casdoorEndpoint = %s",
strings.ReplaceAll(beego.AppConfig.String("casdoorEndpoint"), "my.", "od.")))
- content = strings.ReplaceAll(content, "clientId =
af6b5aa958822fb9dc33", fmt.Sprintf("clientId = %s",
beego.AppConfig.String("clientIdPrefix")+shortName))
- content = strings.ReplaceAll(content, "clientSecret =
8bc3010c1c951c8d876b1f311a901ff8deeb93bc", fmt.Sprintf("clientSecret = %s",
beego.AppConfig.String("clientSecretPrefix")+shortName))
+ content = strings.ReplaceAll(content, "casdoorEndpoint =
https://door.casdoor.com", fmt.Sprintf("casdoorEndpoint = %s",
strings.ReplaceAll(conf.GetConfigStringUnquoted("casdoorEndpoint"), "my.",
"od.")))
+ content = strings.ReplaceAll(content, "clientId =
af6b5aa958822fb9dc33", fmt.Sprintf("clientId = %s",
conf.GetConfigStringUnquoted("clientIdPrefix")+shortName))
+ content = strings.ReplaceAll(content, "clientSecret =
8bc3010c1c951c8d876b1f311a901ff8deeb93bc", fmt.Sprintf("clientSecret = %s",
conf.GetConfigStringUnquoted("clientSecretPrefix")+shortName))
content = strings.ReplaceAll(content, "casdoorOrganization =
\"casbin\"", fmt.Sprintf("casdoorOrganization = \"%s\"", shortName))
content = strings.ReplaceAll(content, "casdoorApplication =
\"app-opendata\"", fmt.Sprintf("casdoorApplication = \"%s\"",
fmt.Sprintf("app-%s", shortName)))
} else {
content = strings.ReplaceAll(content, "httpport = 8000",
fmt.Sprintf("httpport = %d", 30000+i))
- content = strings.ReplaceAll(content, "123456",
beego.AppConfig.String("dbPass"))
+ content = strings.ReplaceAll(content, "123456",
conf.GetConfigStringUnquoted("dbPass"))
content = strings.ReplaceAll(content, "dbName = casdoor",
fmt.Sprintf("dbName = %s", strings.Replace(name, "_00", "_", 1)))
content = strings.ReplaceAll(content, "redisEndpoint =",
"redisEndpoint = \"localhost:6379\"")
content = strings.ReplaceAll(content, "socks5Proxy =
\"127.0.0.1:10808\"", "socks5Proxy =")
diff --git a/run/util_path.go b/run/util_path.go
index d1be391..9934cab 100644
--- a/run/util_path.go
+++ b/run/util_path.go
@@ -21,7 +21,7 @@ import (
"path/filepath"
"strings"
- "github.com/beego/beego"
+ "github.com/apache/casbin-gateway/conf"
)
var username string
@@ -41,7 +41,7 @@ func init() {
}
func InitAppMap() {
- res := beego.AppConfig.String("appMap")
+ res := conf.GetConfigStringUnquoted("appMap")
if res != "" {
err := json.Unmarshal([]byte(res), &appMap)
if err != nil {
@@ -70,14 +70,14 @@ func getMappedName(name string) string {
func GetRepoPath(name string) string {
name = getMappedName(name)
- appDir := beego.AppConfig.String("appDir")
+ appDir := conf.GetConfigStringUnquoted("appDir")
res := filepath.Join(appDir, name)
return res
}
func getCodeAppConfPath(name string) string {
name = getMappedName(name)
- appDir := beego.AppConfig.String("appDir")
+ appDir := conf.GetConfigStringUnquoted("appDir")
return fmt.Sprintf("%s/%s/conf/app.conf", appDir, name)
}
diff --git a/service/proxy.go b/service/proxy.go
index f43bfed..4cc5b8e 100644
--- a/service/proxy.go
+++ b/service/proxy.go
@@ -16,13 +16,16 @@ package service
import (
"crypto/tls"
+ "errors"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"path/filepath"
+ "runtime"
"strings"
+ "sync"
"github.com/apache/casbin-gateway/conf"
"github.com/apache/casbin-gateway/object"
@@ -320,85 +323,197 @@ func nextHandle(w http.ResponseWriter, r *http.Request) {
}
}
-func Start() {
+// The reverse proxy can be switched on and off while Gateway runs, so nobody
+// has to edit conf/app.conf and restart to get their sites proxied. Both
+// servers are held here; an empty slice means the proxy is off.
+var (
+ gatewayMutex sync.Mutex
+ gatewayServers []*http.Server
+ gatewayError string
+)
+
+// ListenError names the port that could not be bound. It is reported to the
+// browser rather than being fatal, so a port conflict leaves the management UI
+// reachable and the proxy can be turned off there.
+type ListenError struct {
+ Port int
+ Err error
+}
+
+func (e *ListenError) Error() string {
+ if holder := util.DescribePortHolder(e.Port); holder != "" {
+ return fmt.Sprintf("port %d is already in use by %s", e.Port,
holder)
+ }
+
+ if e.Port < 1024 && runtime.GOOS != "windows" {
+ return fmt.Sprintf("cannot listen on port %d: %v (ports below
1024 need root, so Gateway has to be started with sudo)", e.Port, e.Err)
+ }
+
+ return fmt.Sprintf("cannot listen on port %d: %v", e.Port, e.Err)
+}
+
+func newGatewayMux() *http.ServeMux {
serverMux := http.NewServeMux()
serverMux.HandleFunc("/", handleRequest)
serverMux.HandleFunc("/_sso-callback", handleAuthCallback)
serverMux.HandleFunc("/_captcha-verify", handleCaptchaCallback)
+ return serverMux
+}
+func Start() {
if !conf.IsGatewayEnabled() {
fmt.Printf("Casbin Gateway reverse proxy is not enabled
(gatewayEnabled = false), so site configurations will not take effect\n")
return
}
+ // A port conflict here does not end the process: the management UI is
what
+ // the reverse proxy is turned off in, so exiting would leave editing
+ // conf/app.conf by hand as the only way out.
+ if err := StartGateway(); err != nil {
+ var listenErr *ListenError
+ if !errors.As(err, &listenErr) {
+ panic(err)
+ }
+
+ fmt.Printf("Casbin Gateway reverse proxy could not start:
%s\n", listenErr.Error())
+ fmt.Printf(" Free the port, or turn the reverse proxy off on
the Sites page to run the management UI only.\n")
+ }
+}
+
+// GatewayError explains why the proxy is not running although it is enabled.
+// It is empty when there is nothing to explain.
+func GatewayError() string {
+ gatewayMutex.Lock()
+ defer gatewayMutex.Unlock()
+
+ return gatewayError
+}
+
+// IsGatewayRunning reports whether the proxy is serving right now, which is
not
+// the same as "gatewayEnabled = true": the ports may have refused to bind.
+func IsGatewayRunning() bool {
+ gatewayMutex.Lock()
+ defer gatewayMutex.Unlock()
+
+ return len(gatewayServers) > 0
+}
+
+// StartGateway binds the two gateway ports and serves on them. It is a no-op
+// when the proxy already runs, and leaves nothing bound when it fails.
+func StartGateway() error {
+ gatewayMutex.Lock()
+ defer gatewayMutex.Unlock()
+
+ if len(gatewayServers) > 0 {
+ return nil
+ }
+
+ gatewayError = ""
gatewayHttpPort := conf.GetGatewayHttpPort()
gatewayHttpsPort := conf.GetGatewayHttpsPort()
// Both ports are bound up front and the listeners are handed to the
servers
- // below. Binding here turns a port conflict into one readable line
before
- // anything else starts, and holding on to the listeners means nothing
can
- // take the port between this check and the server that serves on it.
+ // below. Binding here turns a port conflict into one readable error
before
+ // anything serves, and holding on to the listeners means nothing can
take
+ // the port between this check and the server that serves on it.
httpListener, err := util.ListenTcp(gatewayHttpPort)
if err != nil {
- util.FatalListenError(gatewayHttpPort, `set "gatewayEnabled =
false" in conf/app.conf to run the management UI only`, err)
+ return failedToListen(gatewayHttpPort, err)
}
httpsListener, err := util.ListenTcp(gatewayHttpsPort)
if err != nil {
httpListener.Close()
- util.FatalListenError(gatewayHttpsPort, `set "gatewayEnabled =
false" in conf/app.conf to run the management UI only`, err)
+ return failedToListen(gatewayHttpsPort, err)
}
+ serverMux := newGatewayMux()
+ httpServer := &http.Server{Handler: serverMux}
+ httpsServer := newHttpsServer(serverMux)
+ gatewayServers = []*http.Server{httpServer, httpsServer}
+
go func() {
fmt.Printf("Casbin Gateway running on: http://127.0.0.1:%d\n",
gatewayHttpPort)
- err := http.Serve(httpListener, serverMux)
- if err != nil {
- panic(err)
- }
+ serveGateway(httpServer.Serve(httpListener))
}()
go func() {
fmt.Printf("Casbin Gateway running on: https://127.0.0.1:%d\n",
gatewayHttpsPort)
- server := &http.Server{
- Handler: serverMux,
- TLSConfig: &tls.Config{
- // Minimum TLS version 1.2, TLS 1.3 is
automatically supported
- MinVersion: tls.VersionTLS12,
- // Prefer server's cipher suite order for
better security
- PreferServerCipherSuites: true,
- // Secure cipher suites for TLS 1.2 (excluding
3DES to prevent Sweet32 attack)
- // TLS 1.3 cipher suites are automatically
configured by Go
- CipherSuites: []uint16{
-
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
-
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
-
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
-
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
-
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
-
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
- },
- // Prefer strong elliptic curves
- CurvePreferences: []tls.CurveID{
- tls.X25519,
- tls.CurveP256,
- tls.CurveP384,
- },
- },
- }
+ serveGateway(httpsServer.ServeTLS(httpsListener, "", ""))
+ }()
- // start https server and set how to get certificate
- server.TLSConfig.GetCertificate = func(info
*tls.ClientHelloInfo) (*tls.Certificate, error) {
- domain := info.ServerName
- cert, err := getX509CertByDomain(domain)
- if err != nil {
- return nil, err
- }
+ return nil
+}
- return cert, nil
- }
+// failedToListen remembers why the proxy is not running, so the Sites page can
+// say so instead of only showing it to whoever pressed the button.
+func failedToListen(port int, err error) error {
+ listenErr := &ListenError{Port: port, Err: err}
+ gatewayError = listenErr.Error()
+ return listenErr
+}
+
+// StopGateway releases the two gateway ports. Sites stay configured; nothing
is
+// proxied until StartGateway() is called again.
+func StopGateway() {
+ gatewayMutex.Lock()
+ defer gatewayMutex.Unlock()
- err := server.ServeTLS(httpsListener, "", "")
+ for _, server := range gatewayServers {
+ server.Close()
+ }
+ gatewayServers = nil
+ gatewayError = ""
+
+ fmt.Printf("Casbin Gateway reverse proxy stopped, site configurations
no longer take effect\n")
+}
+
+// serveGateway handles what a gateway server returned. StopGateway() closing
it
+// is the normal way out; anything else is a failure worth crashing on, so the
+// supervisor restarts a proxy that died on its own.
+func serveGateway(err error) {
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ panic(err)
+ }
+}
+
+func newHttpsServer(serverMux *http.ServeMux) *http.Server {
+ server := &http.Server{
+ Handler: serverMux,
+ TLSConfig: &tls.Config{
+ // Minimum TLS version 1.2, TLS 1.3 is automatically
supported
+ MinVersion: tls.VersionTLS12,
+ // Prefer server's cipher suite order for better
security
+ PreferServerCipherSuites: true,
+ // Secure cipher suites for TLS 1.2 (excluding 3DES to
prevent Sweet32 attack)
+ // TLS 1.3 cipher suites are automatically configured
by Go
+ CipherSuites: []uint16{
+ tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+ tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+ tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
+ tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
+ },
+ // Prefer strong elliptic curves
+ CurvePreferences: []tls.CurveID{
+ tls.X25519,
+ tls.CurveP256,
+ tls.CurveP384,
+ },
+ },
+ }
+
+ // start https server and set how to get certificate
+ server.TLSConfig.GetCertificate = func(info *tls.ClientHelloInfo)
(*tls.Certificate, error) {
+ domain := info.ServerName
+ cert, err := getX509CertByDomain(domain)
if err != nil {
- panic(err)
+ return nil, err
}
- }()
+
+ return cert, nil
+ }
+
+ return server
}
diff --git a/service/startup.go b/service/startup.go
index e02b14e..1c52770 100644
--- a/service/startup.go
+++ b/service/startup.go
@@ -49,12 +49,15 @@ func PrintStartupSummary() {
printSummaryTable("Casbin Gateway", rows)
}
+// 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.
func describeConf() string {
if embedsupport.IsEmbeddedConf() {
- return "embedded in the binary (put your own conf/app.conf next
to it to override)"
+ return "Settings page, seeded from the conf/app.conf embedded
in this binary"
}
- return "conf/app.conf"
+ return "Settings page, seeded from conf/app.conf"
}
func describeWebBuild() string {
@@ -76,7 +79,7 @@ func describeGateway() string {
// Start() prints the consequence right after this table, so the row
itself
// only has to say how to turn the proxy on.
- return "disabled (set \"gatewayEnabled = true\" in conf/app.conf)"
+ return "disabled (turn it on on the Sites or Settings page of the web
UI)"
}
func describeGatewayPort(port int) string {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index c65cfaf..60e8f2d 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -45,6 +45,7 @@ import RecordEditPage from "@/pages/RecordEditPage";
import RecordListPage from "@/pages/RecordListPage";
import RuleEditPage from "@/pages/RuleEditPage";
import RuleListPage from "@/pages/RuleListPage";
+import SettingPage from "@/pages/SettingPage";
import SigninPage from "@/pages/SigninPage";
import SiteEditPage from "@/pages/SiteEditPage";
import SiteListPage from "@/pages/SiteListPage";
@@ -274,6 +275,7 @@ export default function App() {
/>
<Route path="/nodes" element={requireSignin(user =>
<NodeListPage account={user} />)} />
<Route path="/nodes/:owner/:nodeName"
element={requireSignin(() => <NodeEditPage />)} />
+ <Route path="/settings" element={requireSignin(user =>
<SettingPage account={user} />)} />
<Route path="/sites" element={requireSignin(user =>
<SiteListPage account={user} />)} />
<Route
path="/sites/:owner/:siteName"
diff --git a/controllers/system.go b/web/src/backend/SettingBackend.ts
similarity index 52%
copy from controllers/system.go
copy to web/src/backend/SettingBackend.ts
index 1ccfde1..e8a153a 100644
--- a/controllers/system.go
+++ b/web/src/backend/SettingBackend.ts
@@ -12,23 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package controllers
+import {request} from "@/backend/request";
+import type {Setting} from "@/types";
-import (
- "github.com/apache/casbin-gateway/conf"
-)
-
-// GetGatewayStatus tells the web UI whether the reverse-proxy gateway is
-// actually running. Without it, a Gateway started with "gatewayEnabled =
false"
-// accepts every site and rule and silently proxies nothing.
-func (c *ApiController) GetGatewayStatus() {
- if c.RequireSignedIn() {
- return
- }
+export function getSetting() {
+ return request<Setting>("/api/get-setting");
+}
- c.ResponseOk(map[string]interface{}{
- "gatewayEnabled": conf.IsGatewayEnabled(),
- "httpPort": conf.GetGatewayHttpPort(),
- "httpsPort": conf.GetGatewayHttpsPort(),
- })
+export function updateSetting(setting: Setting) {
+ return request<Setting>("/api/update-setting", "POST", setting);
}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index f2bb7af..1525353 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -458,6 +458,7 @@
"Recording is off": "Recording is off",
"Recording is off detail": "Nothing is recorded until you ask for it,
because prompts can carry sensitive data. Metadata is who called which model
with which outcome; bodies are the prompts themselves.",
"Recording is on": "Recording is on",
+ "Recording off": "Recording off",
"Records were dropped": "{count} records were dropped because the write
queue was full. Raise llmRecordQueueCapacity if this keeps happening.",
"Redacted values": "Redacted values",
"Request": "Request",
@@ -534,6 +535,62 @@
"or": "or",
"regex match": "regex match"
},
+ "setting": {
+ "ACME email": "ACME email",
+ "ACME email hint": "The Let's Encrypt account that issues the certificates
for your sites.",
+ "ACME private key": "ACME private key",
+ "API key encryption key": "API key encryption key",
+ "API key encryption key hint": "Encrypts the channel API keys at rest, any
length. Back it up with the database: losing it makes already-encrypted keys
unreadable.",
+ "Agent poll seconds": "Agent poll interval, in seconds",
+ "Agent record capacity": "Agent records kept",
+ "Agent record capacity hint": "Agent records are never written to disk, so
this also bounds the memory they use.",
+ "Agent state dir": "Agent state directory",
+ "Agent state dir hint": "Patch manifests, config backups, ingest tokens
and file-tail cursors.",
+ "Agents": "Agents",
+ "App dir": "Application directory",
+ "App map": "Application name map",
+ "App map hint": "Optional JSON object renaming deployed applications, e.g.
{\"casdoor\": \"cc\"}.",
+ "Application deployment": "Application deployment",
+ "Application deployment description": "Used only by the feature that
deploys and restarts application source trees.",
+ "Casdoor application": "Casdoor application",
+ "Casdoor endpoint": "Casdoor endpoint",
+ "Casdoor organization": "Casdoor organization",
+ "Client ID": "Client ID",
+ "Client ID prefix": "Client ID prefix",
+ "Client secret": "Client secret",
+ "Client secret prefix": "Client secret prefix",
+ "Database host": "Database host",
+ "Database password": "Database password",
+ "Database user": "Database user",
+ "Deployed app language": "Deployed application language",
+ "Enable the reverse proxy": "Enable the reverse proxy",
+ "Gateway HTTP port": "Gateway HTTP port",
+ "Gateway HTTPS port": "Gateway HTTPS port",
+ "LLM records": "LLM records",
+ "LLM records description": "Prompts can carry sensitive data, so anything
beyond metadata is opt-in.",
+ "Max payload bytes": "Max stored body, in bytes",
+ "Max records": "Max records",
+ "Network and certificates": "Network and certificates",
+ "Outbound SOCKS5 proxy": "Outbound SOCKS5 proxy",
+ "Outbound SOCKS5 proxy hint": "\"host:port\", used for outbound traffic
such as ACME.",
+ "Page description": "Everything Casbin Gateway reads out of its
configuration. Changes are stored in the database and applied at once.",
+ "Pricing file": "Pricing file",
+ "Pricing file hint": "Optional JSON file of token prices in US dollars per
million, overriding the built-in list prices.",
+ "Queue capacity": "Write queue capacity",
+ "Queue capacity hint": "A full queue drops records rather than slowing the
proxy down.",
+ "RDS access key ID": "RDS access key ID",
+ "RDS access key secret": "RDS access key secret",
+ "RDS instance": "RDS instance",
+ "RDS region": "RDS region",
+ "Record mode": "Record mode",
+ "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.",
+ "Security": "Security",
+ "Settings": "Settings",
+ "Sign-in": "Sign-in",
+ "Sign-in description": "Casdoor is optional: leave the endpoint empty to
sign in against Gateway's own user table."
+ },
"site": {
"Alert": "Alert",
"Alert interval": "Alert interval",
@@ -545,6 +602,7 @@
"Domain": "Domain",
"Edit Site": "Edit Site",
"Enable alert": "Enable alert",
+ "Free the port and switch it on again, or leave it off to run the
management UI only.": "Free the port and switch it on again, or leave it off to
run the management UI only.",
"Host": "Host",
"Hosts": "Hosts",
"Mode": "Mode",
@@ -555,14 +613,16 @@
"Other domains": "Other domains",
"Port": "Port",
"Public IP": "Public IP",
+ "Reverse proxy": "Reverse proxy",
"Rules": "Rules",
"SSL cert": "SSL cert",
"Site": "Site",
"Status": "Status",
+ "The reverse proxy could not start": "The reverse proxy could not start",
"The reverse proxy is not enabled": "The reverse proxy is not enabled",
"The reverse proxy is now running": "The reverse proxy is now running",
- "The sites below will not be proxied until it is turned on.": "The sites
below will not be proxied until it is turned on.",
- "Turn on the reverse proxy": "Turn on the reverse proxy"
+ "The reverse proxy is now stopped": "The reverse proxy is now stopped",
+ "The sites below will not be proxied until it is turned on.": "The sites
below will not be proxied until it is turned on."
},
"usage": {
"All": "All",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 3443e31..a2e35b4 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -458,6 +458,7 @@
"Recording is off": "记录功能已关闭",
"Recording is off detail":
"除非你主动开启,否则不会记录任何内容,因为提示词可能包含敏感数据。元数据是谁调用了哪个模型、结果如何;正文则是提示词本身。",
"Recording is on": "已开启记录",
+ "Recording off": "关闭记录",
"Records were dropped": "有 {count} 条记录因写入队列已满而被丢弃。如持续出现,请调大
llmRecordQueueCapacity。",
"Redacted values": "已脱敏字段数",
"Request": "请求内容",
@@ -534,6 +535,62 @@
"or": "或",
"regex match": "正则表达式匹配"
},
+ "setting": {
+ "ACME email": "ACME 邮箱",
+ "ACME email hint": "用于为站点签发 Let's Encrypt 证书的账号。",
+ "ACME private key": "ACME 私钥",
+ "API key encryption key": "API Key 加密密钥",
+ "API key encryption key hint": "用于加密存储的通道 API
Key,长度不限。请与数据库一起备份:一旦丢失,已加密的密钥将无法读取。",
+ "Agent poll seconds": "Agent 轮询间隔(秒)",
+ "Agent record capacity": "Agent 记录条数",
+ "Agent record capacity hint": "Agent 记录不会落盘,因此这个值也决定了它们占用的内存上限。",
+ "Agent state dir": "Agent 状态目录",
+ "Agent state dir hint": "补丁清单、配置备份、上报令牌和文件读取位置。",
+ "Agents": "Agent",
+ "App dir": "应用目录",
+ "App map": "应用名映射",
+ "App map hint": "可选的 JSON 对象,用于重命名部署的应用,例如 {\"casdoor\": \"cc\"}。",
+ "Application deployment": "应用部署",
+ "Application deployment description": "仅供部署并重启应用源码目录的功能使用。",
+ "Casdoor application": "Casdoor 应用",
+ "Casdoor endpoint": "Casdoor 地址",
+ "Casdoor organization": "Casdoor 组织",
+ "Client ID": "Client ID",
+ "Client ID prefix": "Client ID 前缀",
+ "Client secret": "Client Secret",
+ "Client secret prefix": "Client Secret 前缀",
+ "Database host": "数据库主机",
+ "Database password": "数据库密码",
+ "Database user": "数据库用户",
+ "Deployed app language": "部署应用的语言",
+ "Enable the reverse proxy": "启用反向代理",
+ "Gateway HTTP port": "网关 HTTP 端口",
+ "Gateway HTTPS port": "网关 HTTPS 端口",
+ "LLM records": "LLM 记录",
+ "LLM records description": "提示词可能包含敏感数据,因此元数据之外的内容都需要主动开启。",
+ "Max payload bytes": "单条正文上限(字节)",
+ "Max records": "最大记录条数",
+ "Network and certificates": "网络与证书",
+ "Outbound SOCKS5 proxy": "出站 SOCKS5 代理",
+ "Outbound SOCKS5 proxy hint": "形如 \"host:port\",用于 ACME 等出站流量。",
+ "Page description": "Casbin Gateway 的全部配置项。修改会保存到数据库并立即生效。",
+ "Pricing file": "价格文件",
+ "Pricing file hint": "可选的 JSON 文件,按每百万 Token 的美元价格覆盖内置价目表。",
+ "Queue capacity": "写入队列容量",
+ "Queue capacity hint": "队列写满时会丢弃记录,而不是拖慢代理。",
+ "RDS access key ID": "RDS AccessKey ID",
+ "RDS access key secret": "RDS AccessKey Secret",
+ "RDS instance": "RDS 实例",
+ "RDS region": "RDS 地域",
+ "Record mode": "记录模式",
+ "Retention days": "保留天数",
+ "Reverse proxy": "反向代理",
+ "Reverse proxy description": "即 WAF 本身。80 和 443 端口通常已被主机上的其他程序占用,在 Linux 和
macOS 上绑定它们还需要 root 权限。",
+ "Security": "安全",
+ "Settings": "设置",
+ "Sign-in": "登录",
+ "Sign-in description": "Casdoor 是可选的:地址留空则使用 Gateway 自带的用户表登录。"
+ },
"site": {
"Alert": "告警",
"Alert interval": "告警间隔",
@@ -545,6 +602,7 @@
"Domain": "域名",
"Edit Site": "编辑站点",
"Enable alert": "启用告警",
+ "Free the port and switch it on again, or leave it off to run the
management UI only.": "请释放该端口后重新开启;也可以保持关闭,只运行管理界面。",
"Host": "主机",
"Hosts": "主机列表",
"Mode": "模式",
@@ -555,14 +613,16 @@
"Other domains": "其他域名",
"Port": "端口",
"Public IP": "公网IP",
+ "Reverse proxy": "反向代理",
"Rules": "规则",
"SSL cert": "SSL证书",
"Site": "站点",
"Status": "状态",
+ "The reverse proxy could not start": "反向代理启动失败",
"The reverse proxy is not enabled": "反向代理未启用",
"The reverse proxy is now running": "反向代理已启动",
- "The sites below will not be proxied until it is turned on.":
"在开启之前,下面的站点不会被代理。",
- "Turn on the reverse proxy": "开启反向代理"
+ "The reverse proxy is now stopped": "反向代理已停止",
+ "The sites below will not be proxied until it is turned on.":
"在开启之前,下面的站点不会被代理。"
},
"usage": {
"All": "全部",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index cf7e07a..d6aa2fa 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -26,6 +26,7 @@ import {
ScrollText,
Server,
Settings,
+ SlidersHorizontal,
ShieldCheck,
type LucideIcon,
} from "lucide-react";
@@ -80,7 +81,7 @@ export const navGroups: NavGroup[] = [
{
key: "/advanced",
label: "general:Advanced",
- icon: Settings,
+ icon: SlidersHorizontal,
children: [
{key: "/sites", label: "general:Sites", path: "/sites", icon: Globe},
{key: "/certs", label: "general:Certs", path: "/certs", icon:
ShieldCheck},
@@ -90,6 +91,7 @@ export const navGroups: NavGroup[] = [
{key: "/dashboard", label: "general:Gateway Analytics", path:
"/dashboard", icon: ChartColumn},
],
},
+ {key: "/settings", label: "setting:Settings", icon: Settings, path:
"/settings", adminOnly: true},
];
/** All leaf entries, flattened, for lookups by first path segment. */
diff --git a/web/src/pages/LlmRecordsPage.tsx b/web/src/pages/LlmRecordsPage.tsx
index fd5e7ac..f5747e0 100644
--- a/web/src/pages/LlmRecordsPage.tsx
+++ b/web/src/pages/LlmRecordsPage.tsx
@@ -13,10 +13,11 @@
// limitations under the License.
import * as React from "react";
-import {Activity, CircleDollarSign, Database, Hash, Logs, RefreshCw, Trash2}
from "lucide-react";
+import {Activity, CircleDollarSign, Database, Hash, Logs, Play, RefreshCw,
Trash2} from "lucide-react";
import i18next from "i18next";
import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
+import * as SettingBackend from "@/backend/SettingBackend";
import * as Setting from "@/Setting";
import {RequestInspector} from "@/components/llm/request-inspector";
import {DataTable, type Column} from "@/components/shared/data-table";
@@ -151,6 +152,7 @@ export default function LlmRecordsPage({account}: {account:
Account}) {
const isAdmin = Setting.isAdminUser(account);
const [records, setRecords] = React.useState<LlmRecord[]>([]);
const [status, setStatus] = React.useState<LlmRecordStatus | null>(null);
+ const [changingMode, setChangingMode] = React.useState(false);
const [stats, setStats] = React.useState<LlmRecordStats | null>(null);
const [total, setTotal] = React.useState(0);
const [page, setPage] = React.useState(1);
@@ -228,6 +230,33 @@ export default function LlmRecordsPage({account}:
{account: Account}) {
});
}, [isAdmin]);
+ // Recording is off until someone asks for it. The Settings page holds the
+ // same field; changing it here is the shortcut, and it applies to the next
+ // request rather than the next restart.
+ const setRecordMode = (mode: string) => {
+ setChangingMode(true);
+ SettingBackend.getSetting()
+ .then(res => {
+ if (res.status !== "ok") {
+ throw new Error(res.msg);
+ }
+ return SettingBackend.updateSetting({...res.data, llmRecordMode: mode
as LlmRecordStatus["mode"]});
+ })
+ .then(res => {
+ setChangingMode(false);
+ if (res.status === "ok") {
+ loadStatus();
+ Setting.showMessage("success", mode === "off" ?
i18next.t("llm:Recording is off") : i18next.t("llm:Recording is on"));
+ } else {
+ Setting.showMessage("error", res.msg);
+ }
+ })
+ .catch(error => {
+ setChangingMode(false);
+ Setting.showMessage("error", `${error}`);
+ });
+ };
+
React.useEffect(() => {
load(1, pageSize);
loadStats();
@@ -443,6 +472,19 @@ export default function LlmRecordsPage({account}:
{account: Account}) {
description={description}
actions={
<>
+ {isAdmin && status ? (
+ <SimpleSelect
+ className="w-[220px]"
+ value={status.mode}
+ disabled={changingMode}
+ onChange={setRecordMode}
+ options={[
+ {label: i18next.t("llm:Recording off"), value: "off"},
+ {label: i18next.t("llm:Record metadata"), value: "metadata"},
+ {label: i18next.t("llm:Record metadata and bodies"), value:
"full"},
+ ]}
+ />
+ ) : null}
<SimpleSelect
className="w-[150px]"
value={windowHours}
@@ -480,8 +522,38 @@ export default function LlmRecordsPage({account}:
{account: Account}) {
}
/>
- {modeOff ? <MessageAlert variant="info" title={i18next.t("llm:Recording
is off")} description={i18next.t("llm:Recording is off detail")} /> : null}
- {modeMetadata ? <MessageAlert variant="info"
title={i18next.t("llm:Bodies are not stored")}
description={i18next.t("llm:Bodies are not stored detail")} /> : null}
+ {modeOff ? (
+ <MessageAlert
+ variant="info"
+ title={i18next.t("llm:Recording is off")}
+ description={i18next.t("llm:Recording is off detail")}
+ action={
+ <div className="flex gap-2">
+ <Button size="sm" variant="outline" onClick={() =>
setRecordMode("metadata")} loading={changingMode}>
+ <Play />
+ {i18next.t("llm:Record metadata")}
+ </Button>
+ <Button size="sm" variant="outline" onClick={() =>
setRecordMode("full")} loading={changingMode}>
+ <Play />
+ {i18next.t("llm:Record metadata and bodies")}
+ </Button>
+ </div>
+ }
+ />
+ ) : null}
+ {modeMetadata ? (
+ <MessageAlert
+ variant="info"
+ title={i18next.t("llm:Bodies are not stored")}
+ description={i18next.t("llm:Bodies are not stored detail")}
+ action={
+ <Button size="sm" variant="outline" onClick={() =>
setRecordMode("full")} loading={changingMode}>
+ <Play />
+ {i18next.t("llm:Record metadata and bodies")}
+ </Button>
+ }
+ />
+ ) : null}
{status && status.dropped > 0 ? (
<MessageAlert
variant="warning"
diff --git a/web/src/pages/SettingPage.tsx b/web/src/pages/SettingPage.tsx
new file mode 100644
index 0000000..5228f59
--- /dev/null
+++ b/web/src/pages/SettingPage.tsx
@@ -0,0 +1,217 @@
+// 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 i18next from "i18next";
+
+import * as SettingBackend from "@/backend/SettingBackend";
+import * as Setting from "@/Setting";
+import {Field} from "@/components/shared/form-dialog";
+import {Loading} from "@/components/shared/loading";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {NumberInput} from "@/components/shared/number-input";
+import {PageContainer, PageHeader, Section} from
"@/components/shared/page-header";
+import {PasswordInput} from "@/components/shared/password-input";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {Switch} from "@/components/ui/switch";
+import {Textarea} from "@/components/ui/textarea";
+import type {Account, Setting as SettingType} from "@/types";
+
+type KeysOfType<T> = {[K in keyof SettingType]: SettingType[K] extends T ? K :
never}[keyof SettingType];
+type StringKey = KeysOfType<string>;
+type NumberKey = KeysOfType<number>;
+type BooleanKey = KeysOfType<boolean>;
+
+export default function SettingPage({account}: {account: Account}) {
+ const isAdmin = Setting.isAdminUser(account);
+ const [setting, setSetting] = React.useState<SettingType | null>(null);
+ const [saving, setSaving] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!isAdmin) {
+ return;
+ }
+
+ SettingBackend.getSetting().then(res => {
+ if (res.status === "ok") {
+ setSetting(res.data);
+ } else {
+ Setting.showMessage("error", `${i18next.t("general:Failed to get
data")}: ${res.msg}`);
+ }
+ });
+ }, [isAdmin]);
+
+ if (!isAdmin) {
+ return <UnauthorizedResult />;
+ }
+
+ if (setting === null) {
+ return <Loading type="page" />;
+ }
+
+ const updateField = <K extends keyof SettingType>(key: K, value:
SettingType[K]) => {
+ setSetting(current => (current === null ? current : {...current, [key]:
value}));
+ };
+
+ // The backend applies what it stores, so a port it cannot bind or a value a
+ // subsystem refuses comes back as an error while the setting stays saved.
+ const save = () => {
+ setSaving(true);
+ SettingBackend.updateSetting(Setting.deepCopy(setting))
+ .then(res => {
+ setSaving(false);
+ if (res.status === "ok") {
+ setSetting(res.data);
+ Setting.showMessage("success", i18next.t("general:Successfully
saved"));
+ } else {
+ Setting.showMessage("error", `${i18next.t("general:Failed to
save")}: ${res.msg}`);
+ }
+ })
+ .catch(error => {
+ setSaving(false);
+ Setting.showMessage("error", `${i18next.t("general:Failed to save")}:
${error}`);
+ });
+ };
+
+ const textField = (key: StringKey, label: string, hint?: string) => (
+ <Field label={label} htmlFor={`setting-${key}`} hint={hint}>
+ <Input
+ id={`setting-${key}`}
+ value={setting[key]}
+ onChange={event => updateField(key, event.target.value)}
+ />
+ </Field>
+ );
+
+ const secretField = (key: StringKey, label: string, hint?: string) => (
+ <Field label={label} htmlFor={`setting-${key}`} hint={hint}>
+ <PasswordInput
+ id={`setting-${key}`}
+ value={setting[key]}
+ onChange={event => updateField(key, event.target.value)}
+ />
+ </Field>
+ );
+
+ const numberField = (key: NumberKey, label: string, hint?: string, min = 0,
max?: number) => (
+ <Field label={label} htmlFor={`setting-${key}`} hint={hint}>
+ <NumberInput
+ id={`setting-${key}`}
+ value={setting[key]}
+ onChange={value => updateField(key, value)}
+ min={min}
+ max={max}
+ />
+ </Field>
+ );
+
+ const switchField = (key: BooleanKey, label: string, hint?: string) => (
+ <Field label={label} hint={hint}>
+ <div className="flex h-9 items-center">
+ <Switch checked={setting[key]} onCheckedChange={value =>
updateField(key, value)} />
+ </div>
+ </Field>
+ );
+
+ return (
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("setting:Settings")}
+ description={i18next.t("setting:Page description")}
+ actions={
+ <Button onClick={save} loading={saving}>
+ {i18next.t("general:Save")}
+ </Button>
+ }
+ />
+
+ <Section columns={2} title={i18next.t("setting:Reverse proxy")}
description={i18next.t("setting:Reverse proxy description")}>
+ {switchField("gatewayEnabled", i18next.t("setting:Enable the reverse
proxy"))}
+ {numberField("gatewayHttpPort", i18next.t("setting:Gateway HTTP
port"), undefined, 1, 65535)}
+ {numberField("gatewayHttpsPort", i18next.t("setting:Gateway HTTPS
port"), undefined, 1, 65535)}
+ </Section>
+
+ <Section columns={2} title={i18next.t("setting:LLM records")}
description={i18next.t("setting:LLM records description")}>
+ <Field label={i18next.t("setting:Record mode")}>
+ <SimpleSelect
+ value={setting.llmRecordMode}
+ onChange={value => updateField("llmRecordMode", value as
SettingType["llmRecordMode"])}
+ options={[
+ {label: i18next.t("llm:Recording off"), value: "off"},
+ {label: i18next.t("llm:Record metadata"), value: "metadata"},
+ {label: i18next.t("llm:Record metadata and bodies"), value:
"full"},
+ ]}
+ />
+ </Field>
+ {numberField("llmRecordRetentionDays", i18next.t("setting:Retention
days"), undefined, 1)}
+ {numberField("llmRecordMaxRecords", i18next.t("setting:Max records"),
undefined, 1)}
+ {numberField("llmRecordQueueCapacity", i18next.t("setting:Queue
capacity"), i18next.t("setting:Queue capacity hint"), 1)}
+ {numberField("llmRecordMaxPayloadBytes", i18next.t("setting:Max
payload bytes"), undefined, 65536, 33554432)}
+ {textField("llmPricingFile", i18next.t("setting:Pricing file"),
i18next.t("setting:Pricing file hint"))}
+ </Section>
+
+ <Section columns={2} title={i18next.t("setting:Agents")}>
+ {textField("agentPatchStateDir", i18next.t("setting:Agent state dir"),
i18next.t("setting:Agent state dir hint"))}
+ {numberField("agentRecordCapacity", i18next.t("setting:Agent record
capacity"), i18next.t("setting:Agent record capacity hint"), 1)}
+ {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")}>
+ {textField("casdoorEndpoint", i18next.t("setting:Casdoor endpoint"))}
+ {textField("clientId", i18next.t("setting:Client ID"))}
+ {secretField("clientSecret", i18next.t("setting:Client secret"))}
+ {textField("casdoorOrganization", i18next.t("setting:Casdoor
organization"))}
+ {textField("casdoorApplication", i18next.t("setting:Casdoor
application"))}
+ </Section>
+
+ <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"))}
+ </Section>
+
+ <Section columns={2} 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">
+ <Textarea
+ id="setting-acmePrivateKey"
+ rows={4}
+ value={setting.acmePrivateKey}
+ onChange={event => updateField("acmePrivateKey",
event.target.value)}
+ />
+ </Field>
+ </Section>
+
+ <Section
+ columns={2}
+ title={i18next.t("setting:Application deployment")}
+ description={i18next.t("setting:Application deployment description")}
+ >
+ {textField("appDir", i18next.t("setting:App dir"))}
+ {textField("language", i18next.t("setting:Deployed app language"))}
+ {textField("appMap", i18next.t("setting:App map"),
i18next.t("setting:App map hint"))}
+ {textField("clientIdPrefix", i18next.t("setting:Client ID prefix"))}
+ {textField("clientSecretPrefix", i18next.t("setting:Client secret
prefix"))}
+ {textField("dbRegionId", i18next.t("setting:RDS region"))}
+ {textField("dbAccessKeyId", i18next.t("setting:RDS access key ID"))}
+ {secretField("dbAccessKeySecret", i18next.t("setting:RDS access key
secret"))}
+ {textField("dbInstanceId", i18next.t("setting:RDS instance"))}
+ {textField("dbHost", i18next.t("setting:Database host"))}
+ {textField("dbUser", i18next.t("setting:Database user"))}
+ {secretField("dbPass", i18next.t("setting:Database password"))}
+ </Section>
+ </PageContainer>
+ );
+}
diff --git a/web/src/pages/SiteListPage.tsx b/web/src/pages/SiteListPage.tsx
index bc17915..5f4dca2 100644
--- a/web/src/pages/SiteListPage.tsx
+++ b/web/src/pages/SiteListPage.tsx
@@ -18,6 +18,7 @@ import {Globe, Pencil, Plus, RefreshCw, Trash2} from
"lucide-react";
import i18next from "i18next";
import * as MiscBackend from "@/backend/MiscBackend";
+import * as SettingBackend from "@/backend/SettingBackend";
import * as SiteBackend from "@/backend/SiteBackend";
import * as Setting from "@/Setting";
import {DataTable, type Column} from "@/components/shared/data-table";
@@ -27,10 +28,12 @@ import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {Input} from "@/components/ui/input";
+import {Label} from "@/components/ui/label";
import {NumberInput} from "@/components/shared/number-input";
import {PageContainer, PageHeader} from "@/components/shared/page-header";
import {SimpleTooltip} from "@/components/ui/tooltip";
-import type {Account, Site} from "@/types";
+import {Switch} from "@/components/ui/switch";
+import type {Account, GatewayStatus, Site} from "@/types";
function newSite(owner: string): Site {
const randomName = Setting.getRandomName();
@@ -67,7 +70,8 @@ export default function SiteListPage({account}: {account:
Account}) {
const navigate = useNavigate();
const [data, setData] = React.useState<Site[]>([]);
const [loading, setLoading] = React.useState(false);
- const [gatewayEnabled, setGatewayEnabled] = React.useState<boolean |
null>(null);
+ const [gatewayStatus, setGatewayStatus] = React.useState<GatewayStatus |
null>(null);
+ const [switchingGateway, setSwitchingGateway] = React.useState(false);
const [addOpen, setAddOpen] = React.useState(false);
const [adding, setAdding] = React.useState(false);
const [form, setForm] = React.useState<Site>(() => newSite(account.name));
@@ -85,17 +89,54 @@ export default function SiteListPage({account}: {account:
Account}) {
});
}, [account.name]);
+ const loadGatewayStatus = React.useCallback(() => {
+ MiscBackend.getGatewayStatus().then(res => {
+ if (res.status === "ok") {
+ setGatewayStatus(res.data);
+ }
+ });
+ }, []);
+
React.useEffect(() => {
fetchSites();
// The sites below only do something when the reverse proxy is running, so
// the page has to say when it is off. Otherwise every site here looks
// configured and nothing is actually proxied.
- MiscBackend.getGatewayStatus().then(res => {
- if (res.status === "ok") {
- setGatewayEnabled(res.data.gatewayEnabled);
- }
- });
- }, [fetchSites]);
+ loadGatewayStatus();
+ }, [fetchSites, loadGatewayStatus]);
+
+ // A shortcut for the one setting this page is about: the Settings page holds
+ // the same field, and the backend stores it and takes the two gateway ports
+ // straight away, or gives them back.
+ const switchGateway = (enabled: boolean) => {
+ setSwitchingGateway(true);
+ SettingBackend.getSetting()
+ .then(res => {
+ if (res.status !== "ok") {
+ throw new Error(res.msg);
+ }
+ return SettingBackend.updateSetting({...res.data, gatewayEnabled:
enabled});
+ })
+ .then(res => {
+ setSwitchingGateway(false);
+ // A port that refuses to bind still leaves the setting saved, so the
+ // banner below is told what the state really is either way.
+ loadGatewayStatus();
+ if (res.status === "ok") {
+ Setting.showMessage(
+ "success",
+ enabled ? i18next.t("site:The reverse proxy is now running") :
i18next.t("site:The reverse proxy is now stopped"),
+ );
+ } else {
+ Setting.showMessage("error", res.msg);
+ }
+ })
+ .catch(error => {
+ setSwitchingGateway(false);
+ loadGatewayStatus();
+ Setting.showMessage("error", `${error}`);
+ });
+ };
const openAddDialog = () => {
setForm(newSite(account.name));
@@ -333,20 +374,36 @@ export default function SiteListPage({account}: {account:
Account}) {
<PageHeader
title={i18next.t("general:Sites")}
actions={
- <Button onClick={openAddDialog}>
- <Plus />
- {i18next.t("general:Add")}
- </Button>
+ <>
+ {account.isAdmin && gatewayStatus !== null ? (
+ <Label className="text-sm font-normal">
+ <Switch checked={gatewayStatus.gatewayEnabled}
disabled={switchingGateway} onCheckedChange={switchGateway} />
+ {i18next.t("site:Reverse proxy")}
+ </Label>
+ ) : null}
+ <Button onClick={openAddDialog}>
+ <Plus />
+ {i18next.t("general:Add")}
+ </Button>
+ </>
}
/>
- {gatewayEnabled === false && (
+ {gatewayStatus?.gatewayEnabled === false && (
<MessageAlert
variant="warning"
title={i18next.t("site:The reverse proxy is not enabled")}
- description={i18next.t(
- "site:The sites below will not be proxied. Set gatewayEnabled =
true in conf/app.conf and restart Casbin Gateway to enable it.",
- )}
+ description={i18next.t("site:The sites below will not be proxied
until it is turned on.")}
+ />
+ )}
+
+ {gatewayStatus?.gatewayEnabled && !gatewayStatus.gatewayRunning && (
+ <MessageAlert
+ variant="destructive"
+ title={i18next.t("site:The reverse proxy could not start")}
+ description={[gatewayStatus.gatewayError, i18next.t("site:Free the
port and switch it on again, or leave it off to run the management UI only.")]
+ .filter(Boolean)
+ .join(" ")}
/>
)}
diff --git a/web/src/types.ts b/web/src/types.ts
index 745c45a..4cb8adc 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -350,7 +350,57 @@ export interface GatewayStatus {
/** False while enabled means the ports refused to bind; "gatewayError" says
why. */
gatewayRunning: boolean;
gatewayError: string;
+}
+
+/**
+ * The one built-in row of the Setting table, holding everything that used to
be
+ * hand-edited in conf/app.conf. The file only seeds it on the first start.
+ */
+export interface Setting {
+ owner: string;
+ name: string;
+ createdTime: string;
+ displayName: string;
+
+ gatewayEnabled: boolean;
+ gatewayHttpPort: number;
+ gatewayHttpsPort: number;
+
llmRecordMode: "off" | "metadata" | "full";
+ llmRecordQueueCapacity: number;
+ llmRecordRetentionDays: number;
+ llmRecordMaxRecords: number;
+ llmRecordMaxPayloadBytes: number;
+ llmPricingFile: string;
+
+ agentPatchStateDir: string;
+ agentRecordCapacity: number;
+ agentMonitorPollSeconds: number;
+
+ casdoorEndpoint: string;
+ clientId: string;
+ clientSecret: string;
+ casdoorOrganization: string;
+ casdoorApplication: string;
+
+ apiKeyEncryptionKey: string;
+
+ httpProxy: string;
+ acmeEmail: string;
+ acmePrivateKey: string;
+
+ appDir: string;
+ language: string;
+ appMap: string;
+ clientIdPrefix: string;
+ clientSecretPrefix: string;
+ dbRegionId: string;
+ dbAccessKeyId: string;
+ dbAccessKeySecret: string;
+ dbInstanceId: string;
+ dbHost: string;
+ dbUser: string;
+ dbPass: string;
}
/** The two kinds of configuration the Skills & MCP page manages. */
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]