You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/08/23 05:34:51 UTC

(casbin-gateway) branch master updated: fix: only ever take a port from a previous Gateway

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 <hs...@gmail.com>
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: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org