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 7541b19  feat: show startup summary and explain port conflicts
7541b19 is described below

commit 7541b19be92d6b5c38388dfaf5ebbc62bf026d44
Author: Yang Luo <[email protected]>
AuthorDate: Mon Aug 17 12:02:29 2026 +0800

    feat: show startup summary and explain port conflicts
---
 README.md                        |  83 +++++++++++++++++++-
 conf/app.conf                    | 123 ++++++++++++++++++-----------
 conf/conf.go                     |  34 ++++++++
 controllers/system.go            |  34 ++++++++
 main.go                          |  13 +++-
 object/ormer.go                  |  10 +++
 routers/router.go                |   1 +
 service/proxy.go                 |  31 ++++----
 service/startup.go               | 154 ++++++++++++++++++++++++++++++++++++
 util/port.go                     | 164 +++++++++++++++++++++++++++++++++++++++
 util/port_test.go                | 140 +++++++++++++++++++++++++++++++++
 util/supervisor.go               |  14 +++-
 web/src/SiteListPage.js          |  37 ++++++++-
 web/src/backend/SystemBackend.js |  22 ++++++
 web/src/locales/en/data.json     |   4 +-
 web/src/locales/zh/data.json     |   4 +-
 16 files changed, 799 insertions(+), 69 deletions(-)

diff --git a/README.md b/README.md
index 8e3747c..392744d 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,84 @@ Casbin Gateway runs standalone out of the box: it signs 
users in against its own
 - **Docker Compose**: Use the provided `docker-compose.yml` for quick local 
setup
 - **Manual Installation**: Build and run from source
 
-The reverse-proxy gateway on ports 80 and 443 is disabled by default, so 
starting the management application does not take over those ports. Set 
`gatewayEnabled = true` in `conf/app.conf` when you are ready to use the WAF 
proxy.
+The reverse-proxy gateway on ports 80 and 443 is disabled by default, so 
starting the management application does not take over those ports. Set 
`gatewayEnabled = true` in `conf/app.conf` when you are ready to use the WAF 
proxy. On Linux and macOS those ports also need root, so for a first try it is 
easier to point `gatewayHttpPort` at a high port such as `8080`.
+
+### Quick start
+
+From nothing to a request flowing through the gateway, in five steps.
+
+#### 1. Start MySQL
+
+Any MySQL 5.7+ reachable from Gateway will do. With Docker:
+
+```bash
+docker run -d --name caswaf-db -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123 
mysql:8.0.25
+```
+
+Gateway creates the `caswaf` database itself on first start. If your MySQL is 
elsewhere, or the password is not `123`, change `dataSourceName` in 
`conf/app.conf`.
+
+#### 2. Build the web UI
+
+The backend serves the compiled frontend from `web/build`, so build it once 
before starting the backend:
+
+```bash
+cd web && yarn install && yarn build
+```
+
+For frontend development, run `yarn start` instead. That serves the UI on 
http://localhost:16001 with hot reload and proxies API calls to the backend on 
port 17000, so both have to be running.
+
+#### 3. Run the backend
+
+```bash
+go run main.go
+```
+
+It prints a summary of what it is actually doing — ports, whether the reverse 
proxy is on, whether the database answered, and which sign-in it will use:
+
+```
++----------------------------------------------------------------------------+
+| Casbin Gateway                                                              |
++----------------+-----------------------------------------------------------+
+| Management UI  | http://localhost:17000                                     |
+| Web UI files   | web/build                                                  |
+| Reverse proxy  | enabled                                                    |
+| Gateway HTTP   | :8080                                                      |
+| Gateway HTTPS  | :8443                                                      |
+| Database       | mysql, database "caswaf" (connected)                       |
+| Sign-in        | built-in user table, Casdoor is not configured             |
+| App dir        | ./data/apps                                                |
++----------------+-----------------------------------------------------------+
+```
+
+If a port is taken, Gateway says which process holds it and stops, rather than 
starting half-configured.
+
+#### 4. Sign in and add a site
+
+Open http://localhost:17000, sign in as `admin` with the password `123`, and 
change it from the "My Account" page.
+
+Then go to **Sites** → **Add**, and set:
+
+- **Domain**: the hostname clients will use, e.g. `test.example.com`
+- **Host** and **Port**: where the traffic goes, e.g. `127.0.0.1` and `8000`
+- **Mode**: `HTTP` (`HTTPS Only`, the default, redirects plain HTTP away 
before it reaches the backend)
+
+Save. Then set `gatewayEnabled = true` in `conf/app.conf` and restart the 
backend — the Sites page shows a warning while the reverse proxy is off, 
because site configurations do nothing until it is on.
+
+#### 5. Verify the forwarding
+
+Start anything on the backend port you configured, for example:
+
+```bash
+python -m http.server 8000
+```
+
+The gateway routes on the `Host` header, so no DNS or `hosts` entry is needed 
to test it:
+
+```bash
+curl -H "Host: test.example.com" http://127.0.0.1:8080/
+```
+
+You should get your backend's response. A `site not found for host` reply 
means the request reached Gateway but no site matches that `Host` value.
 
 ### Necessary configuration
 
@@ -89,8 +166,8 @@ Casbin Gateway uses XORM to connect to DB, so all DBs 
supported by XORM can also
 
 #### Run Casbin Gateway
 
-- Configure and run Casbin Gateway by yourself. If you want to learn more, see 
the [documentation](https://caswaf.org).
-- Open browser: http://localhost:16001/
+- Build the web UI once with `cd web && yarn install && yarn build`, then run 
the backend with `go run main.go`. See [Quick start](#quick-start) for the 
whole path, and the [documentation](https://caswaf.org) for everything else.
+- Open browser: http://localhost:17000/ (the backend serves the compiled UI). 
During frontend development, `yarn start` serves it on http://localhost:16001/ 
instead and proxies API calls to port 17000.
 - Sign in as `admin` with the password `123`, then change it from the "My 
Account" page.
 
 ### Optional configuration
diff --git a/conf/app.conf b/conf/app.conf
index 115f3a8..0e1b530 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -1,48 +1,77 @@
-appname = caswaf
-httpport = 17000
-runmode = dev
-SessionOn = true
-copyrequestbody = true
-driverName = mysql
-dataSourceName = root:123@tcp(localhost:3306)/
-dbName = caswaf
-redisEndpoint =
+appname = caswaf
+; Port of the management UI and the REST API.
+httpport = 17000
+runmode = dev
+SessionOn = true
+copyrequestbody = true
+
+; Database. Casbin Gateway creates "dbName" on first start if it does not 
exist.
+; XORM is used, so mysql, postgres, mssql and sqlite are all supported.
+driverName = mysql
+dataSourceName = root:123@tcp(localhost:3306)/
+dbName = caswaf
+; Optional Redis for sessions. Sessions are kept in ./tmp files when empty.
+redisEndpoint =
+
+; The reverse-proxy gateway, i.e. the WAF itself. It is off by default because
+; ports 80 and 443 usually belong to something else on the host, and binding
+; them needs root on Linux and macOS. Sites and rules can still be configured
+; while it is off, but nothing is proxied until it is turned on.
 gatewayEnabled = false
-gatewayHttpPort = 80
-gatewayHttpsPort = 443
-httpProxy = ""
-; Casdoor is optional. Leave "casdoorEndpoint" empty to sign in against 
Gateway's
-; own user table instead, with the built-in "admin" account (password "123").
-; Filling in all five values below switches sign-in over to Casdoor SSO and
-; enables the Casdoor-backed features: site SSO, CAPTCHA rules, alert providers
-; and the resource storage provider.
-casdoorEndpoint =
-; casdoorEndpoint = https://door.casdoor.com
-clientId =
-clientSecret =
-casdoorOrganization =
-casdoorApplication =
-isDemoMode = false
-appDir = "C:/github_repos"
-dbRegionId = ""
-dbAccessKeyId = ""
-dbAccessKeySecret = ""
-dbInstanceId = ""
-dbHost = ""
-dbUser = ""
-dbPass = ""
-clientIdPrefix = ""
-clientSecretPrefix = ""
-language = "en"
-appMap =
-acmeEmail = ""
-acmePrivateKey = ""
-
-; Agent patch manifests, config backups, ingest tokens, and file-tail cursors.
-; Agent activity records themselves are held only in memory.
-agentPatchStateDir = "./data/agent-patches"
-; How many agent monitoring records to keep in memory. They are never 
persisted,
-; so this is also the memory bound for the live window.
-agentRecordCapacity = 1000
-; How often to rescan the append-only logs of file-tailed agents, in seconds.
-agentMonitorPollSeconds = 5
+gatewayHttpPort = 80
+gatewayHttpsPort = 443
+
+; Optional SOCKS5 proxy ("host:port") for outbound traffic such as ACME.
+httpProxy = ""
+
+; Casdoor is optional. Leave "casdoorEndpoint" empty to sign in against 
Gateway's
+; own user table instead, with the built-in "admin" account (password "123").
+; Filling in all five values below switches sign-in over to Casdoor SSO and
+; enables the Casdoor-backed features: site SSO, CAPTCHA rules, alert providers
+; and the resource storage provider.
+casdoorEndpoint =
+; casdoorEndpoint = https://door.casdoor.com
+clientId =
+clientSecret =
+casdoorOrganization =
+casdoorApplication =
+
+; Demo mode makes the REST API read-only: only sign-in and sign-out may write.
+isDemoMode = false
+
+; Parent directory of the application source trees that Gateway deploys and
+; restarts. Only used by the "run" feature; a relative path is resolved against
+; the working directory.
+appDir = "./data/apps"
+
+; Optional Alibaba Cloud RDS credentials, used to provision a database for a
+; newly deployed application. Leave empty to skip cloud database provisioning.
+dbRegionId = ""
+dbAccessKeyId = ""
+dbAccessKeySecret = ""
+dbInstanceId = ""
+dbHost = ""
+dbUser = ""
+dbPass = ""
+
+; Prefixes prepended to the Casdoor client ID and secret that are written into 
a
+; deployed application's own app.conf.
+clientIdPrefix = ""
+clientSecretPrefix = ""
+; Default language written into a deployed application's own app.conf.
+language = "en"
+; Optional JSON object renaming deployed applications, e.g. {"casdoor": "cc"}.
+appMap =
+
+; ACME account used to issue Let's Encrypt certificates for sites.
+acmeEmail = ""
+acmePrivateKey = ""
+
+; Agent patch manifests, config backups, ingest tokens, and file-tail cursors.
+; Agent activity records themselves are held only in memory.
+agentPatchStateDir = "./data/agent-patches"
+; How many agent monitoring records to keep in memory. They are never 
persisted,
+; so this is also the memory bound for the live window.
+agentRecordCapacity = 1000
+; How often to rescan the append-only logs of file-tailed agents, in seconds.
+agentMonitorPollSeconds = 5
diff --git a/conf/conf.go b/conf/conf.go
index 856f3ce..dc84c65 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -76,6 +76,40 @@ func GetConfigInt(key string) int {
        return num
 }
 
+// GetConfigIntDefault reads an int setting, falling back to defaultValue when
+// the key is missing or unparsable. Unlike GetConfigInt it never panics, so it
+// suits settings that have a sensible built-in value.
+func GetConfigIntDefault(key string, defaultValue int) int {
+       value := strings.Trim(GetConfigString(key), `"' `)
+       num, err := strconv.Atoi(value)
+       if err != nil {
+               return defaultValue
+       }
+       return num
+}
+
+// GetHttpPort is the port serving the management UI and the REST API.
+func GetHttpPort() int {
+       return GetConfigIntDefault("httpport", 17000)
+}
+
+// IsGatewayEnabled reports whether the reverse-proxy gateway should bind the
+// gateway ports. It defaults to false: taking over ports 80 and 443 is opt-in,
+// because those usually belong to something else on the host.
+func IsGatewayEnabled() bool {
+       return 
strings.EqualFold(strings.Trim(GetConfigString("gatewayEnabled"), `"' `), 
"true")
+}
+
+// GetGatewayHttpPort is the plain-HTTP port of the reverse-proxy gateway.
+func GetGatewayHttpPort() int {
+       return GetConfigIntDefault("gatewayHttpPort", 80)
+}
+
+// GetGatewayHttpsPort is the HTTPS port of the reverse-proxy gateway.
+func GetGatewayHttpsPort() int {
+       return GetConfigIntDefault("gatewayHttpsPort", 443)
+}
+
 func GetConfigInt64(key string) (int64, error) {
        value := GetConfigString(key)
        num, err := strconv.ParseInt(value, 10, 64)
diff --git a/controllers/system.go b/controllers/system.go
new file mode 100644
index 0000000..1ccfde1
--- /dev/null
+++ b/controllers/system.go
@@ -0,0 +1,34 @@
+// 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 (
+       "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
+       }
+
+       c.ResponseOk(map[string]interface{}{
+               "gatewayEnabled": conf.IsGatewayEnabled(),
+               "httpPort":       conf.GetGatewayHttpPort(),
+               "httpsPort":      conf.GetGatewayHttpsPort(),
+       })
+}
diff --git a/main.go b/main.go
index 6e537aa..181d8ee 100644
--- a/main.go
+++ b/main.go
@@ -90,7 +90,18 @@ func main() {
        }
        beego.BConfig.WebConfig.Session.SessionGCMaxLifetime = 3600 * 24 * 365
 
-       port := beego.AppConfig.DefaultInt("httpport", 17000)
+       port := conf.GetHttpPort()
+
+       service.PrintStartupSummary()
+
+       // beego.Run() binds the management port itself and reports a conflict 
as a
+       // stack trace, so the port is probed here first to explain it in one 
line,
+       // and before the gateway below binds anything. The gap between probe 
and
+       // bind is unavoidable but harmless: losing the race just puts us back 
to
+       // beego's own error.
+       if err := util.CheckPortAvailable(port); err != nil {
+               util.FatalListenError(port, `change "httpport" in 
conf/app.conf`, err)
+       }
 
        service.Start()
 
diff --git a/object/ormer.go b/object/ormer.go
index 31a9965..7fb213b 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -88,6 +88,16 @@ func CreateTables() {
        ormer.createTable()
 }
 
+// PingDatabase reports whether the configured database answers right now. It 
is
+// used by the startup summary, so it returns the error instead of panicking.
+func PingDatabase() error {
+       if ormer == nil || ormer.Engine == nil {
+               return fmt.Errorf("the database adapter is not initialized")
+       }
+
+       return ormer.Engine.Ping()
+}
+
 // Ormer represents the MySQL adapter for policy storage.
 type Ormer struct {
        driverName     string
diff --git a/routers/router.go b/routers/router.go
index b140b74..ff6dd26 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -38,6 +38,7 @@ func initAPI() {
        beego.Router("/api/get-account", &controllers.ApiController{}, 
"GET:GetAccount")
        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/get-global-nodes", &controllers.ApiController{}, 
"GET:GetGlobalNodes")
        beego.Router("/api/get-nodes", &controllers.ApiController{}, 
"GET:GetNodes")
diff --git a/service/proxy.go b/service/proxy.go
index ea92585..0bb3b51 100644
--- a/service/proxy.go
+++ b/service/proxy.go
@@ -24,10 +24,10 @@ import (
        "path/filepath"
        "strings"
 
+       "github.com/apache/casbin-gateway/conf"
        "github.com/apache/casbin-gateway/object"
        "github.com/apache/casbin-gateway/rule"
        "github.com/apache/casbin-gateway/util"
-       "github.com/beego/beego"
 )
 
 func forwardHandler(targetUrl string, writer http.ResponseWriter, request 
*http.Request) {
@@ -327,28 +327,32 @@ func Start() {
        serverMux.HandleFunc("/caswaf-handler", handleAuthCallback)
        serverMux.HandleFunc("/caswaf-captcha-verify", handleCaptchaCallback)
 
-       gatewayEnabled, err := beego.AppConfig.Bool("gatewayEnabled")
-       if err != nil {
-               panic(err)
-       }
-       if !gatewayEnabled {
-               fmt.Printf("Casbin Gateway not enabled (gatewayEnabled == 
\"false\")\n")
+       if !conf.IsGatewayEnabled() {
+               fmt.Printf("Casbin Gateway reverse proxy is not enabled 
(gatewayEnabled = false), so site configurations will not take effect\n")
                return
        }
 
-       gatewayHttpPort, err := beego.AppConfig.Int("gatewayHttpPort")
+       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.
+       httpListener, err := util.ListenTcp(gatewayHttpPort)
        if err != nil {
-               panic(err)
+               util.FatalListenError(gatewayHttpPort, `set "gatewayEnabled = 
false" in conf/app.conf to run the management UI only`, err)
        }
 
-       gatewayHttpsPort, err := beego.AppConfig.Int("gatewayHttpsPort")
+       httpsListener, err := util.ListenTcp(gatewayHttpsPort)
        if err != nil {
-               panic(err)
+               httpListener.Close()
+               util.FatalListenError(gatewayHttpsPort, `set "gatewayEnabled = 
false" in conf/app.conf to run the management UI only`, err)
        }
 
        go func() {
                fmt.Printf("Casbin Gateway running on: http://127.0.0.1:%d\n";, 
gatewayHttpPort)
-               err := http.ListenAndServe(fmt.Sprintf(":%d", gatewayHttpPort), 
serverMux)
+               err := http.Serve(httpListener, serverMux)
                if err != nil {
                        panic(err)
                }
@@ -358,7 +362,6 @@ func Start() {
                fmt.Printf("Casbin Gateway running on: https://127.0.0.1:%d\n";, 
gatewayHttpsPort)
                server := &http.Server{
                        Handler: serverMux,
-                       Addr:    fmt.Sprintf(":%d", gatewayHttpsPort),
                        TLSConfig: &tls.Config{
                                // Minimum TLS version 1.2, TLS 1.3 is 
automatically supported
                                MinVersion: tls.VersionTLS12,
@@ -394,7 +397,7 @@ func Start() {
                        return cert, nil
                }
 
-               err := server.ListenAndServeTLS("", "")
+               err := server.ServeTLS(httpsListener, "", "")
                if err != nil {
                        panic(err)
                }
diff --git a/service/startup.go b/service/startup.go
new file mode 100644
index 0000000..9dd105f
--- /dev/null
+++ b/service/startup.go
@@ -0,0 +1,154 @@
+// 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 service
+
+import (
+       "fmt"
+       "strings"
+
+       "github.com/apache/casbin-gateway/conf"
+       "github.com/apache/casbin-gateway/object"
+       "github.com/apache/casbin-gateway/util"
+)
+
+// webBuildDir holds the compiled web UI that beego serves in production.
+const webBuildDir = "web/build"
+
+type summaryRow struct {
+       key   string
+       value string
+}
+
+// PrintStartupSummary prints one table describing what this process is 
actually
+// going to do: which ports it serves, whether the reverse proxy is on, whether
+// the database answers, and whether Casdoor is wired up. Every one of these 
has
+// been a silent failure for someone starting Gateway for the first time.
+func PrintStartupSummary() {
+       rows := []summaryRow{
+               {"Management UI", fmt.Sprintf("http://localhost:%d";, 
conf.GetHttpPort())},
+               {"Web UI files", describeWebBuild()},
+               {"Reverse proxy", describeGateway()},
+               {"Gateway HTTP", 
describeGatewayPort(conf.GetGatewayHttpPort())},
+               {"Gateway HTTPS", 
describeGatewayPort(conf.GetGatewayHttpsPort())},
+               {"Database", describeDatabase()},
+               {"Sign-in", describeSignin()},
+               {"App dir", describeAppDir()},
+       }
+
+       printSummaryTable("Casbin Gateway", rows)
+}
+
+func describeWebBuild() string {
+       if util.FileExist(webBuildDir + "/index.html") {
+               return webBuildDir
+       }
+
+       return fmt.Sprintf("%s is missing, run \"yarn install && yarn build\" 
in web/", webBuildDir)
+}
+
+func describeGateway() string {
+       if conf.IsGatewayEnabled() {
+               return "enabled"
+       }
+
+       // 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)"
+}
+
+func describeGatewayPort(port int) string {
+       if !conf.IsGatewayEnabled() {
+               return "-"
+       }
+
+       return fmt.Sprintf(":%d", port)
+}
+
+func describeDatabase() string {
+       // The connection string holds the database password, so only the 
driver and
+       // the database name are ever printed.
+       target := fmt.Sprintf("%s, database \"%s\"", 
unquote(conf.GetConfigString("driverName")), 
unquote(conf.GetConfigString("dbName")))
+
+       err := object.PingDatabase()
+       if err != nil {
+               return fmt.Sprintf("%s (unreachable: %s)", target, 
sanitizeDbError(err))
+       }
+
+       return fmt.Sprintf("%s (connected)", target)
+}
+
+func describeSignin() string {
+       if conf.IsCasdoorAvailable() {
+               return fmt.Sprintf("Casdoor SSO at %s", 
unquote(conf.GetConfigString("casdoorEndpoint")))
+       }
+
+       return "built-in user table, Casdoor is not configured"
+}
+
+func describeAppDir() string {
+       appDir := unquote(conf.GetConfigString("appDir"))
+       if appDir == "" {
+               return "-"
+       }
+
+       return appDir
+}
+
+// unquote drops the quotes beego keeps around values such as appDir = 
"./data/apps".
+func unquote(value string) string {
+       return strings.Trim(value, `"' `)
+}
+
+// sanitizeDbError keeps the password inside "dataSourceName" out of the 
console,
+// since driver errors sometimes quote the whole connection string back.
+func sanitizeDbError(err error) string {
+       msg := err.Error()
+
+       dataSourceName := conf.GetConfigDataSourceName()
+       if dataSourceName != "" {
+               msg = strings.ReplaceAll(msg, dataSourceName, 
"<dataSourceName>")
+               if i := strings.Index(dataSourceName, "@"); i > 0 {
+                       msg = strings.ReplaceAll(msg, dataSourceName[:i], 
"<credentials>")
+               }
+       }
+
+       return msg
+}
+
+func printSummaryTable(title string, rows []summaryRow) {
+       keyWidth, valueWidth := 0, len(title)
+       for _, row := range rows {
+               if len(row.key) > keyWidth {
+                       keyWidth = len(row.key)
+               }
+               if len(row.value) > valueWidth {
+                       valueWidth = len(row.value)
+               }
+       }
+
+       // The title spans both columns, so the separator has to cover them 
plus the
+       // " | " that joins them.
+       border := "+" + strings.Repeat("-", keyWidth+valueWidth+5) + "+"
+
+       fmt.Println()
+       fmt.Println(border)
+       fmt.Printf("| %-*s |\n", keyWidth+valueWidth+3, title)
+       fmt.Println(border)
+       for _, row := range rows {
+               fmt.Printf("| %-*s | %-*s |\n", keyWidth, row.key, valueWidth, 
row.value)
+       }
+       fmt.Println(border)
+       fmt.Println()
+}
diff --git a/util/port.go b/util/port.go
new file mode 100644
index 0000000..c7ff651
--- /dev/null
+++ b/util/port.go
@@ -0,0 +1,164 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package util
+
+import (
+       "context"
+       "fmt"
+       "net"
+       "os"
+       "os/exec"
+       "runtime"
+       "strconv"
+       "strings"
+       "time"
+)
+
+// portLookupTimeout bounds the external "who owns this port" commands so a 
slow
+// lsof or netstat can never hold up startup.
+const portLookupTimeout = 2 * time.Second
+
+// ListenTcp binds a TCP listener on all interfaces for the given port. Callers
+// keep the listener and serve on it, so a port that was reported free cannot 
be
+// taken by someone else in between the check and the bind.
+func ListenTcp(port int) (net.Listener, error) {
+       return net.Listen("tcp", fmt.Sprintf(":%d", port))
+}
+
+// CheckPortAvailable binds the port and immediately releases it. Use it only
+// where the port has to be handed to a server that insists on binding itself,
+// such as beego.Run().
+func CheckPortAvailable(port int) error {
+       listener, err := ListenTcp(port)
+       if err != nil {
+               return err
+       }
+
+       return listener.Close()
+}
+
+// FatalListenError explains a failed bind in plain language and stops the
+// process without a stack trace. It exits with ExitCodeFatalConfig so the
+// supervisor knows this is a configuration problem and does not restart the
+// process straight back into the same conflict.
+//
+// "remedy" completes the sentence "Free the port, or ...", for example
+// `set "gatewayEnabled = false" in conf/app.conf`.
+func FatalListenError(port int, remedy string, err error) {
+       fmt.Println()
+       if holder := DescribePortHolder(port); holder != "" {
+               fmt.Printf("Casbin Gateway: port %d is in use (%s). Free it, or 
%s.\n", port, holder, remedy)
+       } else {
+               fmt.Printf("Casbin Gateway: cannot listen on port %d: %v\n", 
port, err)
+               fmt.Printf("  Free the port, or %s.\n", remedy)
+       }
+
+       if port < 1024 && runtime.GOOS != "windows" {
+               fmt.Printf("  Ports below 1024 also need root, so \"sudo\" may 
be required.\n")
+       }
+       fmt.Println()
+
+       os.Exit(ExitCodeFatalConfig)
+}
+
+// DescribePortHolder is a best-effort, read-only lookup of the process that is
+// listening on the port, used to turn "bind failed" into something actionable
+// such as "pid 1234, nginx". It only ever reads: a wrong guess costs an
+// inaccurate hint, never someone else's process.
+func DescribePortHolder(port int) string {
+       pid := findListenerPid(port)
+       if pid <= 0 {
+               return ""
+       }
+
+       name := findProcessName(pid)
+       if name == "" {
+               return fmt.Sprintf("pid %d", pid)
+       }
+
+       return fmt.Sprintf("pid %d, %s", pid, name)
+}
+
+// findListenerPid returns the pid of a process listening on the port, or 0 
when
+// it cannot be determined. Only sockets in the LISTEN state are considered, so
+// an unrelated process that merely holds a connection to a remote port with 
the
+// same number is never reported.
+func findListenerPid(port int) int {
+       var output string
+       if runtime.GOOS == "windows" {
+               output = runLookup("netstat", "-ano", "-p", "TCP")
+               return parseNetstatPid(output, port)
+       }
+
+       output = runLookup("lsof", "-sTCP:LISTEN", "-t", "-i", 
"TCP:"+strconv.Itoa(port))
+       for _, line := range strings.Split(output, "\n") {
+               if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == 
nil {
+                       return pid
+               }
+       }
+
+       return 0
+}
+
+// parseNetstatPid picks the pid of the LISTENING row for the port out of
+// "netstat -ano" output. Rows in other states (ESTABLISHED, TIME_WAIT) are
+// skipped on purpose.
+func parseNetstatPid(output string, port int) int {
+       for _, line := range strings.Split(output, "\n") {
+               fields := strings.Fields(line)
+               if len(fields) < 5 || !strings.EqualFold(fields[0], "TCP") || 
!strings.EqualFold(fields[3], "LISTENING") {
+                       continue
+               }
+
+               _, localPort, err := net.SplitHostPort(fields[1])
+               if err != nil || localPort != strconv.Itoa(port) {
+                       continue
+               }
+
+               if pid, err := strconv.Atoi(fields[4]); err == nil {
+                       return pid
+               }
+       }
+
+       return 0
+}
+
+// findProcessName resolves a pid to an executable name, returning "" when the
+// platform command is missing or says nothing useful.
+func findProcessName(pid int) string {
+       if runtime.GOOS == "windows" {
+               output := runLookup("tasklist", "/FI", fmt.Sprintf("PID eq %d", 
pid), "/NH", "/FO", "CSV")
+               // A CSV row looks like: 
"nginx.exe","1234","Console","1","5,000 K"
+               name := strings.TrimSpace(strings.SplitN(output, ",", 2)[0])
+               return strings.Trim(name, `"`)
+       }
+
+       return strings.TrimSpace(runLookup("ps", "-p", strconv.Itoa(pid), "-o", 
"comm="))
+}
+
+// runLookup runs a short diagnostic command and returns its stdout, or "" on
+// any failure. Every caller treats the result as a hint, so errors are not
+// worth surfacing.
+func runLookup(name string, args ...string) string {
+       ctx, cancel := context.WithTimeout(context.Background(), 
portLookupTimeout)
+       defer cancel()
+
+       output, err := exec.CommandContext(ctx, name, args...).Output()
+       if err != nil {
+               return ""
+       }
+
+       return string(output)
+}
diff --git a/util/port_test.go b/util/port_test.go
new file mode 100644
index 0000000..8c11537
--- /dev/null
+++ b/util/port_test.go
@@ -0,0 +1,140 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package util
+
+import (
+       "net"
+       "os"
+       "os/exec"
+       "strings"
+       "testing"
+)
+
+// netstatSample mixes the rows that matter with the ones that have to be
+// ignored: a connection to a remote port 80, a listener on a port that merely
+// ends in "80", and an IPv6 listener on the port being looked up.
+const netstatSample = `
+Active Connections
+
+  Proto  Local Address          Foreign Address        State           PID
+  TCP    0.0.0.0:8080           0.0.0.0:0              LISTENING       1111
+  TCP    192.168.1.5:52341      93.184.216.34:80       ESTABLISHED     2222
+  TCP    0.0.0.0:80             0.0.0.0:0              LISTENING       3333
+  TCP    [::]:443               [::]:0                 LISTENING       4444
+  TCP    127.0.0.1:9000         0.0.0.0:0              TIME_WAIT       5555
+`
+
+func TestParseNetstatPid(t *testing.T) {
+       tests := []struct {
+               name string
+               port int
+               want int
+       }{
+               {"listener on the port", 80, 3333},
+               {"ipv6 listener", 443, 4444},
+               {"port that only shares a suffix", 8080, 1111},
+               {"remote port is not a listener", 25, 0},
+               {"non-listening state is skipped", 9000, 0},
+               {"nothing on the port", 1234, 0},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       got := parseNetstatPid(netstatSample, tt.port)
+                       if got != tt.want {
+                               t.Errorf("parseNetstatPid(port %d) = %d, want 
%d", tt.port, got, tt.want)
+                       }
+               })
+       }
+}
+
+// TestDescribePortHolder checks the whole lookup against a port this test is
+// holding itself. The platform commands it shells out to are not guaranteed to
+// exist, so an empty answer is tolerated: the description is only ever a hint.
+func TestDescribePortHolder(t *testing.T) {
+       listener, err := ListenTcp(0)
+       if err != nil {
+               t.Fatalf("ListenTcp() error: %v", err)
+       }
+       defer listener.Close()
+
+       port := listener.Addr().(*net.TCPAddr).Port
+       description := DescribePortHolder(port)
+       if description == "" {
+               t.Skipf("no port lookup available on this host, skipping")
+       }
+
+       if !strings.HasPrefix(description, "pid ") {
+               t.Errorf("DescribePortHolder(%d) = %q, want it to start with 
\"pid \"", port, description)
+       }
+}
+
+// TestFatalListenError runs the fatal path in a subprocess, because it ends 
the
+// process on purpose. Both halves matter: the message has to name the port, 
and
+// the exit code has to be the one the supervisor treats as "do not restart".
+func TestFatalListenError(t *testing.T) {
+       if os.Getenv("CASWAF_TEST_FATAL_LISTEN") == "1" {
+               listener, err := ListenTcp(0)
+               if err != nil {
+                       t.Fatalf("ListenTcp() error: %v", err)
+               }
+               defer listener.Close()
+
+               port := listener.Addr().(*net.TCPAddr).Port
+               _, err = ListenTcp(port)
+               if err == nil {
+                       t.Fatalf("ListenTcp(%d) succeeded twice, expected the 
port to be held", port)
+               }
+
+               FatalListenError(port, `set "gatewayEnabled = false" in 
conf/app.conf`, err)
+               return
+       }
+
+       cmd := exec.Command(os.Args[0], "-test.run=TestFatalListenError")
+       cmd.Env = append(os.Environ(), "CASWAF_TEST_FATAL_LISTEN=1")
+       output, err := cmd.CombinedOutput()
+
+       exitErr, ok := err.(*exec.ExitError)
+       if !ok {
+               t.Fatalf("FatalListenError() ended with %v, want a non-zero 
exit", err)
+       }
+       if exitErr.ExitCode() != ExitCodeFatalConfig {
+               t.Errorf("FatalListenError() exit code = %d, want %d", 
exitErr.ExitCode(), ExitCodeFatalConfig)
+       }
+
+       if !strings.Contains(string(output), "is in use") && 
!strings.Contains(string(output), "cannot listen on port") {
+               t.Errorf("FatalListenError() output = %q, want it to explain 
the port conflict", output)
+       }
+       if !strings.Contains(string(output), "gatewayEnabled") {
+               t.Errorf("FatalListenError() output = %q, want it to carry the 
remedy", output)
+       }
+}
+
+func TestCheckPortAvailable(t *testing.T) {
+       listener, err := ListenTcp(0)
+       if err != nil {
+               t.Fatalf("ListenTcp() error: %v", err)
+       }
+       port := listener.Addr().(*net.TCPAddr).Port
+
+       if err = CheckPortAvailable(port); err == nil {
+               t.Errorf("CheckPortAvailable(%d) = nil while the port is held, 
want an error", port)
+       }
+
+       listener.Close()
+       if err = CheckPortAvailable(port); err != nil {
+               t.Errorf("CheckPortAvailable(%d) error after release: %v", 
port, err)
+       }
+}
diff --git a/util/supervisor.go b/util/supervisor.go
index f005d57..e07b7ae 100644
--- a/util/supervisor.go
+++ b/util/supervisor.go
@@ -32,6 +32,11 @@ const (
        RestartWindow = 5 * time.Minute
        // RestartDelay is the delay before restarting after a crash
        RestartDelay = 2 * time.Second
+       // ExitCodeFatalConfig marks an exit caused by configuration, such as a 
port
+       // that is already taken. Restarting cannot fix it, so the supervisor 
stops
+       // instead of looping through the same failure. The value is the 
conventional
+       // sysexits.h EX_CONFIG.
+       ExitCodeFatalConfig = 78
 )
 
 // InitSelfGuard initializes the self-recovery mechanism
@@ -111,8 +116,15 @@ func runSupervisor() error {
                        uptime := exitTime.Sub(processStartTime)
                        
                        if err != nil {
+                               // A configuration failure repeats on every 
restart, so pass the
+                               // child's exit code up instead of burning 
through the restart
+                               // budget. The child has already explained 
itself.
+                               if exitErr, ok := err.(*exec.ExitError); ok && 
exitErr.ExitCode() == ExitCodeFatalConfig {
+                                       os.Exit(ExitCodeFatalConfig)
+                               }
+
                                fmt.Printf("Process crashed after %v: %v\n", 
uptime, err)
-                               
+
                                // Record this restart
                                restartTimes = append(restartTimes, time.Now())
                                
diff --git a/web/src/SiteListPage.js b/web/src/SiteListPage.js
index ac8f6a0..b2e8935 100644
--- a/web/src/SiteListPage.js
+++ b/web/src/SiteListPage.js
@@ -14,16 +14,21 @@
 
 import React from "react";
 import {Link} from "react-router-dom";
-import {Button, Popconfirm, Table, Tag, Tooltip} from "antd";
+import {Alert, Button, Popconfirm, Table, Tag, Tooltip} from "antd";
 import moment from "moment";
 import * as Setting from "./Setting";
 import * as SiteBackend from "./backend/SiteBackend";
+import * as SystemBackend from "./backend/SystemBackend";
 import i18next from "i18next";
 import BaseListPage from "./BaseListPage";
 
 class SiteListPage extends BaseListPage {
   constructor(props) {
     super(props);
+    this.state = {
+      ...this.state,
+      gatewayEnabled: null,
+    };
   }
 
   UNSAFE_componentWillMount() {
@@ -35,6 +40,35 @@ class SiteListPage extends BaseListPage {
       },
     });
     this.fetch({pagination: this.state.pagination});
+    this.getGatewayStatus();
+  }
+
+  // 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.
+  getGatewayStatus() {
+    SystemBackend.getGatewayStatus()
+      .then((res) => {
+        if (res.status === "ok") {
+          this.setState({gatewayEnabled: res.data.gatewayEnabled});
+        }
+      });
+  }
+
+  renderGatewayAlert() {
+    if (this.state.gatewayEnabled !== false) {
+      return null;
+    }
+
+    return (
+      <Alert
+        style={{marginBottom: "10px"}}
+        type="warning"
+        showIcon
+        message={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.")}
+      />
+    );
   }
 
   newSite() {
@@ -412,6 +446,7 @@ class SiteListPage extends BaseListPage {
 
     return (
       <div>
+        {this.renderGatewayAlert()}
         <Table columns={columns} dataSource={data} rowKey="name" size="middle" 
bordered pagination={this.state.pagination}
           title={() => (
             <div>
diff --git a/web/src/backend/SystemBackend.js b/web/src/backend/SystemBackend.js
new file mode 100644
index 0000000..d5ca12b
--- /dev/null
+++ b/web/src/backend/SystemBackend.js
@@ -0,0 +1,22 @@
+// 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 Setting from "../Setting";
+
+export function getGatewayStatus() {
+  return fetch(`${Setting.ServerUrl}/api/get-gateway-status`, {
+    method: "GET",
+    credentials: "include",
+  }).then(res => res.json());
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index be13f28..964cf48 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -224,7 +224,9 @@
     "Public IP": "Public IP",
     "Rules": "Rules",
     "SSL cert": "SSL cert",
-    "Status": "Status"
+    "Status": "Status",
+    "The reverse proxy is not enabled": "The reverse proxy is not enabled",
+    "The sites below will not be proxied. Set gatewayEnabled = true in 
conf/app.conf and restart Casbin Gateway to enable it.": "The sites below will 
not be proxied. Set gatewayEnabled = true in conf/app.conf and restart Casbin 
Gateway to enable it."
   },
   "usage": {
     "All": "All",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 82a4885..44a7ef1 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -224,7 +224,9 @@
     "Public IP": "公网IP",
     "Rules": "Rules",
     "SSL cert": "SSL证书",
-    "Status": "状态"
+    "Status": "状态",
+    "The reverse proxy is not enabled": "反向代理未启用",
+    "The sites below will not be proxied. Set gatewayEnabled = true in 
conf/app.conf and restart Casbin Gateway to enable it.": "下面的站点不会被代理。请在 
conf/app.conf 中设置 gatewayEnabled = true 并重启 Casbin Gateway 后生效。"
   },
   "usage": {
     "All": "全部",


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to