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]