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 10665e8 fix: only ever take a port from a previous Gateway
10665e8 is described below
commit 10665e85d42042a8c36c2395149e87c46ff066af
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 13:34:43 2026 +0800
fix: only ever take a port from a previous Gateway
---
README.md | 2 +-
README_zh.md | 2 +-
conf/app.conf | 6 +--
main.go | 25 ++++++---
scripts/install.sh | 1 +
service/proxy.go | 4 +-
util/daemon.go | 39 ++++++++++----
util/port.go | 103 +++++++++++++++++++++++++++++------
util/port_test.go | 2 +-
util/process.go | 42 +++++++++------
util/supervisor.go | 154 -----------------------------------------------------
11 files changed, 171 insertions(+), 209 deletions(-)
diff --git a/README.md b/README.md
index 0462833..28e7e9e 100644
--- a/README.md
+++ b/README.md
@@ -179,7 +179,7 @@ Gateway prints what it is actually doing when it starts, so
the result can be ch
+---------------------------------------------------------------------+
```
-If a port is taken, Gateway says which process holds it and stops, rather than
starting half-configured.
+A previous Gateway still holding one of these ports is stopped first, so a
restart never waits on it. A port held by anything else stays with that
program: Gateway names the process holding it and stops, rather than taking the
port or starting half-configured.
### Recording prompts
diff --git a/README_zh.md b/README_zh.md
index 39aafdd..957d384 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -177,7 +177,7 @@ Gateway 启动时会打印它实际在做什么,所以可以直接看结果而
+---------------------------------------------------------------------+
```
-如果端口被占用,Gateway 会指出是哪个进程占着它并停止启动,而不是半配置地跑起来。
+还占着这些端口的上一个 Gateway 会先被停掉,所以重启不用等它。被别的程序占着的端口则留给它:Gateway
会指出是哪个进程占着并停止启动,而不是抢过端口,也不会半配置地跑起来。
### 记录提示词
diff --git a/conf/app.conf b/conf/app.conf
index 2bea1d8..7ceba26 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -40,9 +40,9 @@ apiKeyEncryptionKey =
; 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. Turning it on
-; also means the two ports below are taken over on startup, killing whatever
-; holds them, so do not point them at a web server that has to keep running.
+; while it is off, but nothing is proxied until it is turned on. A previous
+; Gateway still holding one of the two ports below is stopped on startup; a
port
+; held by anything else is left to it and the proxy reports the conflict
instead.
gatewayEnabled = false
gatewayHttpPort = 80
gatewayHttpsPort = 443
diff --git a/main.go b/main.go
index 24ad4e9..62db364 100644
--- a/main.go
+++ b/main.go
@@ -15,6 +15,7 @@
package main
import (
+ "errors"
"fmt"
"os"
"time"
@@ -52,7 +53,6 @@ func main() {
return
}
- util.InitSelfGuard()
object.InitFlag()
object.InitAdapter()
object.CreateTables()
@@ -106,8 +106,8 @@ func main() {
port := conf.GetHttpPort()
addr := conf.GetHttpAddr()
- // A previous run still holding one of these ports would keep this one
from
- // starting, so it is stopped first. The gateway ports come before the
+ // A previous Gateway still holding one of these ports would keep this
one
+ // from starting, so it is stopped first. The gateway ports come before
the
// management port because service.Start() binds them first.
stopPorts := []int{}
if conf.IsGatewayEnabled() {
@@ -115,11 +115,22 @@ func main() {
}
stopPorts = append(stopPorts, port)
for _, stopPort := range stopPorts {
- if err := util.StopOldInstance(stopPort); err != nil {
- // The bind below reports the conflict in full, so a
failed kill only
- // needs a note here and never stops the startup by
itself.
- fmt.Printf("Casbin Gateway: could not free port %d:
%v\n", stopPort, err)
+ err := util.StopOldInstance(stopPort)
+ if err == nil {
+ continue
}
+
+ // A port held by something that is not Gateway stays with it.
Saying so
+ // here would only precede the bind below, which reports the
same
+ // conflict along with the remedy for that particular port.
+ var foreign *util.ForeignPortError
+ if errors.As(err, &foreign) {
+ continue
+ }
+
+ // The bind below reports the conflict in full, so a failed
kill only
+ // needs a note here and never stops the startup by itself.
+ fmt.Printf("Casbin Gateway: could not free port %d: %v\n",
stopPort, err)
}
service.PrintStartupSummary()
diff --git a/scripts/install.sh b/scripts/install.sh
index 6205071..1c0eaca 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -167,6 +167,7 @@ Type=simple
WorkingDirectory=${INSTALL_DIR}
ExecStart=${INSTALL_DIR}/casbin-gateway
Restart=on-failure
+RestartPreventExitStatus=78
[Install]
WantedBy=default.target
diff --git a/service/proxy.go b/service/proxy.go
index 4cc5b8e..1a0cd4c 100644
--- a/service/proxy.go
+++ b/service/proxy.go
@@ -469,8 +469,8 @@ func StopGateway() {
}
// 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.
+// is the normal way out; anything else means the proxy died on its own, which
+// is a failure worth crashing on rather than serving nothing in silence.
func serveGateway(err error) {
if err != nil && !errors.Is(err, http.ErrServerClosed) {
panic(err)
diff --git a/util/daemon.go b/util/daemon.go
index c5a3b75..e9d541c 100644
--- a/util/daemon.go
+++ b/util/daemon.go
@@ -15,6 +15,7 @@
package util
import (
+ "errors"
"fmt"
"net"
"os"
@@ -124,24 +125,42 @@ func stopDaemon(port int) {
return
}
- if err := StopOldInstance(port); err != nil {
- fmt.Printf("Casbin Gateway: could not stop the process on port
%d: %v\n", port, err)
+ err := StopOldInstance(port)
+ if err == nil {
+ fmt.Println("Casbin Gateway stopped")
+ return
+ }
+
+ // Answering on the port is not the same as being Gateway, so this is
where
+ // "stop" finds out that the port belongs to something else entirely.
+ var foreign *ForeignPortError
+ if errors.As(err, &foreign) {
+ fmt.Printf("Port %d is held by %s, not by Casbin Gateway, so
nothing was stopped\n", port, foreign.Holder)
os.Exit(1)
}
- fmt.Println("Casbin Gateway stopped")
+
+ fmt.Printf("Casbin Gateway: could not stop the process on port %d:
%v\n", port, err)
+ os.Exit(1)
}
func printStatus(port int) {
- if isServing(port) {
- holder := DescribePortHolder(port)
- if holder == "" {
- holder = "running"
- }
- fmt.Printf("Casbin Gateway is running on http://localhost:%d
(%s)\n", port, holder)
+ if !isServing(port) {
+ fmt.Printf("Casbin Gateway is not running on port %d\n", port)
+ return
+ }
+
+ holder := LookupPortHolder(port)
+ if holder == nil {
+ fmt.Printf("Casbin Gateway is running on
http://localhost:%d\n", port)
+ return
+ }
+
+ if !holder.Ours {
+ fmt.Printf("Port %d is held by %s, not by Casbin Gateway\n",
port, holder)
return
}
- fmt.Printf("Casbin Gateway is not running on port %d\n", port)
+ fmt.Printf("Casbin Gateway is running on http://localhost:%d (%s)\n",
port, holder)
}
// isServing reports whether something already answers on the port, which is
the
diff --git a/util/port.go b/util/port.go
index 133556e..ebee916 100644
--- a/util/port.go
+++ b/util/port.go
@@ -20,15 +20,26 @@ import (
"net"
"os"
"os/exec"
+ "path/filepath"
"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
+const (
+ // portLookupTimeout bounds the external "who owns this port" commands
so a
+ // slow lsof or netstat can never hold up startup.
+ portLookupTimeout = 2 * time.Second
+ // procNameMaxLen is how much of another process's name Linux reports,
from
+ // TASK_COMM_LEN. Anything longer comes back cut to this.
+ procNameMaxLen = 15
+ // ExitCodeFatalConfig marks an exit caused by configuration, such as a
port
+ // that is already taken. Restarting cannot fix it, so a service
manager has
+ // to be told not to try; the systemd unit the installer writes says
so. The
+ // value is the conventional sysexits.h EX_CONFIG.
+ ExitCodeFatalConfig = 78
+)
// 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
@@ -61,9 +72,9 @@ func CheckPortAvailableOn(addr string, port int) error {
}
// 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.
+// process without a stack trace. It exits with ExitCodeFatalConfig so that a
+// service manager 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`.
@@ -84,22 +95,84 @@ func FatalListenError(port int, remedy string, err error) {
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 {
+// PortHolder is the process listening on a port: its pid, its executable name
+// when that could be read, and whether that executable is this same program.
+type PortHolder struct {
+ Pid int
+ Name string
+ Ours bool
+}
+
+// String renders a holder as "pid 1234, nginx", or "pid 1234" when its name
+// could not be read.
+func (h *PortHolder) String() string {
+ if h.Name == "" {
+ return fmt.Sprintf("pid %d", h.Pid)
+ }
+
+ return fmt.Sprintf("pid %d, %s", h.Pid, h.Name)
+}
+
+// LookupPortHolder is a best-effort, read-only lookup of the process listening
+// on the port. It turns "bind failed" into something actionable such as
+// "pid 1234, nginx", and tells a previous Gateway apart from an unrelated
+// program. It returns nil when nothing is listening or the pid cannot be read.
+func LookupPortHolder(port int) *PortHolder {
pid := findListenerPid(port)
if pid <= 0 {
- return ""
+ return nil
}
name := findProcessName(pid)
- if name == "" {
- return fmt.Sprintf("pid %d", pid)
+ return &PortHolder{Pid: pid, Name: name, Ours: isSelfExecutable(name)}
+}
+
+// DescribePortHolder names the process listening on the port, or "" when there
+// is none to name. It only ever reads: a wrong guess costs an inaccurate hint,
+// never someone else's process.
+func DescribePortHolder(port int) string {
+ holder := LookupPortHolder(port)
+ if holder == nil {
+ return ""
+ }
+
+ return holder.String()
+}
+
+// isSelfExecutable reports whether an executable name is this program's own.
+// It is the check that keeps Gateway from stopping a process that is not a
+// previous Gateway, so it is deliberately conservative: a name that could not
+// be read is never a match.
+func isSelfExecutable(name string) bool {
+ self, err := os.Executable()
+ if err != nil {
+ return false
+ }
+
+ mine, other := executableBaseName(self), executableBaseName(name)
+ if mine == "" || other == "" {
+ return false
+ }
+
+ // Linux reports the name of another process cut to procNameMaxLen, so a
+ // name that long has to be compared as a prefix of this executable's.
+ if len(other) >= procNameMaxLen {
+ return strings.HasPrefix(mine, other)
+ }
+
+ return mine == other
+}
+
+// executableBaseName drops the directory and the ".exe" suffix and lowercases
+// the rest, so that "C:\Program Files\Casbin-Gateway.exe" and
+// "casbin-gateway" are recognised as the same program.
+func executableBaseName(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
}
- return fmt.Sprintf("pid %d, %s", pid, name)
+ return strings.TrimSuffix(strings.ToLower(filepath.Base(value)), ".exe")
}
// findListenerPid returns the pid of a process listening on the port, or 0
when
diff --git a/util/port_test.go b/util/port_test.go
index 5349dab..1059e7b 100644
--- a/util/port_test.go
+++ b/util/port_test.go
@@ -83,7 +83,7 @@ func TestDescribePortHolder(t *testing.T) {
// 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".
+// the exit code has to be the one a service manager treats as "do not
restart".
func TestFatalListenError(t *testing.T) {
if os.Getenv("CASBIN_GATEWAY_TEST_FATAL_LISTEN") == "1" {
listener, err := ListenTcp(0)
diff --git a/util/process.go b/util/process.go
index ea3ff0a..7c6e0ea 100644
--- a/util/process.go
+++ b/util/process.go
@@ -29,37 +29,49 @@ const (
portReleasePollInterval = 100 * time.Millisecond
)
-// StopOldInstance kills whatever process is listening on the port, so that a
-// restart never has to wait for the previous run to be shut down by hand.
+// ForeignPortError reports a port held by a process that is not Casbin
Gateway.
+// That process keeps running: the port may well be its own, and taking it by
+// force would stop something the rest of the machine depends on.
+type ForeignPortError struct {
+ Port int
+ Holder string
+}
+
+func (e *ForeignPortError) Error() string {
+ return fmt.Sprintf("port %d is held by %s, which is not Casbin
Gateway", e.Port, e.Holder)
+}
+
+// StopOldInstance stops a previous Casbin Gateway still listening on the port,
+// so that a restart never has to wait for it to be shut down by hand.
+//
+// Only another copy of this executable is ever stopped. A port held by
anything
+// else belongs to that program - an nginx on 443, a dev server on 17000 - so
it
+// is left alone and returned as a ForeignPortError; the caller reports the
+// conflict instead of taking the port by force.
//
-// This takes the port for Casbin Gateway unconditionally: an unrelated program
-// that happens to be listening there is killed just the same, so the ports in
-// conf/app.conf have to be ones this machine really is allowed to hand over.
// Only sockets in the LISTEN state count, so a process that merely holds a
// connection to a remote port with the same number is never touched.
func StopOldInstance(port int) error {
- pid := findListenerPid(port)
- if pid <= 0 || pid == os.Getpid() {
+ holder := LookupPortHolder(port)
+ if holder == nil || holder.Pid == os.Getpid() {
return nil
}
- process, err := os.FindProcess(pid)
+ if !holder.Ours {
+ return &ForeignPortError{Port: port, Holder: holder.String()}
+ }
+
+ process, err := os.FindProcess(holder.Pid)
if err != nil {
return err
}
- name := findProcessName(pid)
-
err = process.Kill()
if err != nil {
return err
}
- if name == "" {
- fmt.Printf("Casbin Gateway: stopped pid %d, which was holding
port %d\n", pid, port)
- } else {
- fmt.Printf("Casbin Gateway: stopped %s (pid %d), which was
holding port %d\n", name, pid, port)
- }
+ fmt.Printf("Casbin Gateway: stopped the previous Gateway (%s), which
was holding port %d\n", holder, port)
return waitForPortRelease(port)
}
diff --git a/util/supervisor.go b/util/supervisor.go
deleted file mode 100644
index c58c514..0000000
--- a/util/supervisor.go
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright 2025 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package util
-
-import (
- "fmt"
- "os"
- "os/exec"
- "os/signal"
- "syscall"
- "time"
-)
-
-const (
- // EnvSupervisorKey is the environment variable key to detect if
running under supervisor
- EnvSupervisorKey = "CASBIN_GATEWAY_SUPERVISED"
- // MaxRestarts is the maximum number of restarts within the restart
window
- MaxRestarts = 5
- // RestartWindow is the time window for counting restarts
- 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
-// If not already supervised, it starts a supervisor process and exits
-// If already supervised, it does nothing and returns
-func InitSelfGuard() {
- // Check if we're already supervised
- if os.Getenv(EnvSupervisorKey) == "1" {
- // Already supervised, just return and continue normal execution
- return
- }
-
- // Start as supervisor
- err := runSupervisor()
- if err != nil {
- fmt.Printf("Supervisor error: %v\n", err)
- os.Exit(1)
- }
- // If we get here, supervisor exited cleanly
- os.Exit(0)
-}
-
-// runSupervisor starts the supervisor that monitors and restarts the main
process
-func runSupervisor() error {
- fmt.Println("Starting Casbin Gateway with auto-recovery mechanism...")
-
- restartTimes := []time.Time{}
-
- for {
- // Clean up old restart times outside the window
- now := time.Now()
- validRestarts := []time.Time{}
- for _, t := range restartTimes {
- if now.Sub(t) < RestartWindow {
- validRestarts = append(validRestarts, t)
- }
- }
- restartTimes = validRestarts
-
- // Check if we've exceeded max restarts
- if len(restartTimes) >= MaxRestarts {
- return fmt.Errorf("exceeded maximum restart limit (%d
restarts in %v), stopping supervisor", MaxRestarts, RestartWindow)
- }
-
- // Start the child process
- cmd := exec.Command(os.Args[0], os.Args[1:]...)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- cmd.Stdin = os.Stdin
-
- // Set environment variable to indicate supervised process
- cmd.Env = append(os.Environ(), fmt.Sprintf("%s=1",
EnvSupervisorKey))
-
- // Start the process
- if err := cmd.Start(); err != nil {
- fmt.Printf("Failed to start process: %v\n", err)
- return err
- }
-
- processStartTime := time.Now()
- fmt.Printf("Started supervised process with PID: %d\n",
cmd.Process.Pid)
-
- // Setup signal handling to forward signals to child
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
-
- // Wait for process completion or signal
- doneChan := make(chan error, 1)
- go func() {
- doneChan <- cmd.Wait()
- }()
-
- select {
- case err := <-doneChan:
- // Process exited
- exitTime := time.Now()
- 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())
-
- fmt.Printf("Waiting %v before restarting...
(restart %d/%d)\n",
- RestartDelay, len(restartTimes),
MaxRestarts)
- time.Sleep(RestartDelay)
-
- // Continue to restart
- continue
- } else {
- // Clean exit
- fmt.Println("Process exited cleanly")
- return nil
- }
-
- case sig := <-sigChan:
- // Received shutdown signal, forward to child
- fmt.Printf("Received signal %v, forwarding to child
process...\n", sig)
- if cmd.Process != nil {
- cmd.Process.Signal(sig)
- }
- // Wait for child to exit
- <-doneChan
- return nil
- }
- }
-}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]