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]

Reply via email to