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 651aa2d  feat: add single-binary build via -tags embed
651aa2d is described below

commit 651aa2d6cf0a3e7ce0fa762abb22eb0b4b1f77b3
Author: Yang Luo <[email protected]>
AuthorDate: Mon Aug 17 14:01:19 2026 +0800

    feat: add single-binary build via -tags embed
---
 README.md                |  33 +++++++++++++++
 conf/conf.go             |   9 +++-
 embed.go                 |  55 +++++++++++++++++++++++++
 embedsupport/conf.go     | 105 +++++++++++++++++++++++++++++++++++++++++++++++
 embedsupport/setup.go    |  55 +++++++++++++++++++++++++
 embedsupport/web.go      |  57 +++++++++++++++++++++++++
 embedsupport/web_test.go |  98 +++++++++++++++++++++++++++++++++++++++++++
 ip/ip.go                 |  24 +++++++++--
 routers/filter.go        |  19 +++++++--
 service/startup.go       |  14 +++++++
 10 files changed, 462 insertions(+), 7 deletions(-)

diff --git a/README.md b/README.md
index 406a66b..576366d 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,7 @@ Casbin Gateway runs standalone out of the box: it stores its 
data in a local SQL
 
 - **Docker Compose**: Use the provided `docker-compose.yml` for quick local 
setup
 - **Manual Installation**: Build and run from source
+- **Single binary**: Build one self-contained executable that runs with no 
files next to it, see [Single binary](#single-binary)
 
 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`.
 
@@ -95,6 +96,7 @@ It prints a summary of what it is actually doing — ports, 
whether the reverse
 | Casbin Gateway                                                              |
 +----------------+-----------------------------------------------------------+
 | Management UI  | http://localhost:17000                                     |
+| Settings       | conf/app.conf                                              |
 | Web UI files   | web/build                                                  |
 | Reverse proxy  | enabled                                                    |
 | Gateway HTTP   | :8080                                                      |
@@ -135,6 +137,37 @@ 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.
 
+### Single binary
+
+Normally Gateway reads three things from disk when it starts: `conf/app.conf`, 
the compiled web UI in `web/build`, and the IP location database 
`ip/17monipdb.dat`. Building with the `embed` tag bakes all three into the 
executable, so it can be copied somewhere on its own and started from anywhere:
+
+```bash
+cd web && yarn install && GENERATE_SOURCEMAP=false yarn build
+```
+
+```bash
+go build -tags embed -o casbin-gateway .
+```
+
+Build the frontend first: everything under `web/build` goes into the binary, 
so `go build -tags embed` fails to compile while that directory is missing. 
Turning source maps off keeps them out of the binary, where they would cost 
several times what the code they map costs.
+
+Files on disk always win over the embedded copies, so a single binary can 
still be configured and developed against without rebuilding it:
+
+| Embedded asset | Overridden by |
+| --- | --- |
+| `conf/app.conf` | `conf/app.conf` in the working directory, or next to the 
executable |
+| `web/build` | `web/build/index.html` in the working directory, which then 
serves the whole UI |
+| `ip/17monipdb.dat` | `ip/17monipdb.dat` in the working directory |
+
+The startup summary reports which source each one came from:
+
+```
+| Settings       | embedded in the binary (put your own conf/app.conf next to 
it to override) |
+| Web UI files   | embedded in the binary                                      
               |
+```
+
+Being self-contained is about startup, not about staying read-only: a running 
Gateway still writes `./data` (the SQLite database, deployed apps, agent patch 
state), `./logs` and `./tmp` relative to its working directory. Start it from 
the directory where that state belongs.
+
 ### Necessary configuration
 
 #### Get the code
diff --git a/conf/conf.go b/conf/conf.go
index 9fb710b..7e74769 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -29,7 +29,14 @@ import (
 var WafConf string
 
 func init() {
-       // this array contains the beego configuration items that may be 
modified via env
+       ApplyEnvOverrides()
+}
+
+// ApplyEnvOverrides copies the beego configuration items that may be modified
+// via env into beego's config. It runs at init, and again whenever the config
+// is reloaded afterwards — beego.LoadAppConfig() builds a new config from the
+// file alone, so anything set here would otherwise be lost.
+func ApplyEnvOverrides() {
        presetConfigItems := []string{"httpport", "appname"}
        for _, key := range presetConfigItems {
                if value, ok := os.LookupEnv(key); ok {
diff --git a/embed.go b/embed.go
new file mode 100644
index 0000000..8cd8602
--- /dev/null
+++ b/embed.go
@@ -0,0 +1,55 @@
+// 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.
+
+//go:build embed
+
+// This file is compiled only with -tags embed. It bakes conf/app.conf and the
+// compiled web UI into the executable and hands them to embedsupport, so that
+// the binary runs on its own with no files next to it. Files on disk still win
+// over the embedded copies at runtime — see the embedsupport package.
+//
+// web/build must exist before building with this tag, or go:embed fails to
+// compile: build the frontend first with
+//
+//     cd web && yarn install && GENERATE_SOURCEMAP=false yarn build
+//
+// Source maps are worth turning off because everything under web/build ends up
+// inside the binary, and they are several times the size of the code they map.
+
+package main
+
+import (
+       "embed"
+       "io/fs"
+
+       "github.com/apache/casbin-gateway/embedsupport"
+)
+
+//go:embed conf/app.conf
+var embeddedAppConf string
+
+//go:embed web/build
+var embeddedWeb embed.FS
+
+//go:embed ip/17monipdb.dat
+var embeddedIpDb []byte
+
+func init() {
+       webFS, err := fs.Sub(embeddedWeb, "web/build")
+       if err != nil {
+               panic(err)
+       }
+
+       embedsupport.Setup(embeddedAppConf, webFS, embeddedIpDb)
+}
diff --git a/embedsupport/conf.go b/embedsupport/conf.go
new file mode 100644
index 0000000..a779f4e
--- /dev/null
+++ b/embedsupport/conf.go
@@ -0,0 +1,105 @@
+// 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 embedsupport
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+
+       "github.com/apache/casbin-gateway/conf"
+       "github.com/beego/beego"
+)
+
+// usingEmbeddedConf records that the embedded conf/app.conf was loaded because
+// no on-disk one was found, i.e. that this process runs as a single binary.
+var usingEmbeddedConf bool
+
+// IsEmbeddedConf reports whether the settings in use came from the copy of
+// conf/app.conf baked into the binary rather than from a file on disk.
+func IsEmbeddedConf() bool { return usingEmbeddedConf }
+
+// setupConf loads the embedded conf/app.conf, unless an on-disk one exists —
+// beego has already loaded that one, and disk always wins.
+//
+// beego can only read its config from a file, so the embedded copy is written
+// to a temporary file just long enough to be parsed. Nothing reads the path
+// afterwards: beego keeps the parsed values in memory.
+func setupConf(appConf string) {
+       if appConf == "" || onDiskConfPath() != "" {
+               return
+       }
+
+       tmpDir, err := os.MkdirTemp("", "casbin-gateway-conf-*")
+       if err != nil {
+               warnConf("cannot create a temporary directory for the embedded 
conf/app.conf: %v", err)
+               return
+       }
+       defer os.RemoveAll(tmpDir)
+
+       tmpConf := filepath.Join(tmpDir, "app.conf")
+       if err = os.WriteFile(tmpConf, []byte(appConf), 0o600); err != nil {
+               warnConf("cannot write the embedded conf/app.conf to %s: %v", 
tmpConf, err)
+               return
+       }
+
+       if err = beego.LoadAppConfig("ini", tmpConf); err != nil {
+               warnConf("cannot load the embedded conf/app.conf: %v", err)
+               return
+       }
+
+       // LoadAppConfig() replaces beego.AppConfig wholesale, which drops the
+       // values conf's own init() had already taken from the environment.
+       conf.ApplyEnvOverrides()
+
+       usingEmbeddedConf = true
+       fmt.Println("Using the conf/app.conf embedded in this binary. To change 
any setting, put your own conf/app.conf next to the executable.")
+}
+
+// onDiskConfPath returns the config file beego found on disk, or "" when there
+// is none. It repeats the search beego does in its own init(): 
BEEGO_CONFIG_PATH
+// first, then conf/app.conf under the working directory, then the same under
+// the executable's directory. BEEGO_RUNMODE renames the file it looks for.
+func onDiskConfPath() string {
+       filename := "app.conf"
+       if runmode := os.Getenv("BEEGO_RUNMODE"); runmode != "" {
+               filename = runmode + ".app.conf"
+       }
+
+       var candidates []string
+       if configPath := os.Getenv("BEEGO_CONFIG_PATH"); configPath != "" {
+               candidates = append(candidates, configPath)
+       }
+       candidates = append(candidates,
+               filepath.Join(beego.WorkPath, "conf", filename),
+               filepath.Join(beego.AppPath, "conf", filename),
+       )
+
+       for _, candidate := range candidates {
+               if _, err := os.Stat(candidate); err == nil {
+                       return candidate
+               }
+       }
+
+       return ""
+}
+
+// warnConf reports a failure to use the embedded config. Startup continues on
+// beego's built-in defaults, which is worth saying out loud: the ports and the
+// database it then uses are not the ones app.conf asks for.
+func warnConf(format string, args ...interface{}) {
+       fmt.Fprintf(os.Stderr, "casbin-gateway: %s\n", fmt.Sprintf(format, 
args...))
+       fmt.Fprintln(os.Stderr, "casbin-gateway: falling back to the built-in 
defaults; put a conf/app.conf next to the executable to control the settings")
+}
diff --git a/embedsupport/setup.go b/embedsupport/setup.go
new file mode 100644
index 0000000..3760ca1
--- /dev/null
+++ b/embedsupport/setup.go
@@ -0,0 +1,55 @@
+// 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 embedsupport wires up the assets that are baked into the binary when
+// it is built with -tags embed: conf/app.conf, the compiled web UI in
+// web/build, and the IP location database. Those are every file Gateway needs
+// to find on disk at startup, so embedding them turns it into a single
+// executable that runs with nothing next to it.
+//
+// The embedded copies are a fallback, never an override: whenever the matching
+// file exists on disk it wins, so an operator can drop a conf/app.conf beside
+// the binary, or keep developing against a live web/build, without rebuilding.
+//
+// A build without -tags embed never calls Setup, so both accessors report
+// "nothing embedded" and every caller keeps its original on-disk behaviour.
+package embedsupport
+
+import "io/fs"
+
+var (
+       webFS fs.FS
+       ipDb  []byte
+)
+
+// Setup must be called before any config value is read or any request is
+// served. appConf is the contents of the embedded conf/app.conf, web is the
+// embedded web/build tree, and ipDb is the embedded IP location database; each
+// of them may be empty or nil.
+func Setup(appConf string, web fs.FS, ipDatabase []byte) {
+       webFS = web
+       ipDb = ipDatabase
+       setupConf(appConf)
+}
+
+// WebFS returns the embedded web/build filesystem, or nil when the binary was
+// built without embedded web assets.
+func WebFS() fs.FS { return webFS }
+
+// HasWeb reports whether a compiled web UI is embedded in this binary.
+func HasWeb() bool { return webFS != nil }
+
+// IpDb returns the embedded IP location database, or nil when this binary
+// carries none.
+func IpDb() []byte { return ipDb }
diff --git a/embedsupport/web.go b/embedsupport/web.go
new file mode 100644
index 0000000..bcc353e
--- /dev/null
+++ b/embedsupport/web.go
@@ -0,0 +1,57 @@
+// 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 embedsupport
+
+import (
+       "bytes"
+       "io/fs"
+       "mime"
+       "net/http"
+       "path"
+       "strings"
+       "time"
+)
+
+// ServeWeb answers a request for a frontend asset from the embedded web/build
+// tree. urlPath is the request path, e.g. "/" or "/static/js/main.abc123.js".
+// Anything the tree does not contain falls back to index.html, because the web
+// UI is a single-page app whose routes ("/sites", "/rules", ...) exist only in
+// the browser.
+//
+// Callers must check HasWeb() first.
+func ServeWeb(w http.ResponseWriter, r *http.Request, urlPath string) {
+       name := path.Clean("/" + urlPath)[1:]
+       if name == "" || name == "." {
+               name = "index.html"
+       }
+
+       data, err := fs.ReadFile(webFS, name)
+       if err != nil {
+               name = "index.html"
+               data, err = fs.ReadFile(webFS, name)
+               if err != nil {
+                       http.NotFound(w, r)
+                       return
+               }
+       }
+
+       if contentType := 
mime.TypeByExtension(strings.ToLower(path.Ext(name))); contentType != "" {
+               w.Header().Set("Content-Type", contentType)
+       }
+
+       // The zero modtime keeps ServeContent from sending Last-Modified: every
+       // asset that can be cached already carries a content hash in its name.
+       http.ServeContent(w, r, path.Base(name), time.Time{}, 
bytes.NewReader(data))
+}
diff --git a/embedsupport/web_test.go b/embedsupport/web_test.go
new file mode 100644
index 0000000..51a295d
--- /dev/null
+++ b/embedsupport/web_test.go
@@ -0,0 +1,98 @@
+// 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 embedsupport
+
+import (
+       "net/http"
+       "net/http/httptest"
+       "testing"
+       "testing/fstest"
+)
+
+func TestServeWeb(t *testing.T) {
+       webFS = fstest.MapFS{
+               "index.html":              {Data: []byte("<html>index</html>")},
+               "static/js/main.abc.js":   {Data: []byte("console.log(1);")},
+               "static/css/main.abc.css": {Data: []byte("body{}")},
+       }
+       defer func() { webFS = nil }()
+
+       tests := []struct {
+               name     string
+               urlPath  string
+               wantBody string
+               wantType string
+       }{
+               {
+                       name:     "root serves index.html",
+                       urlPath:  "/",
+                       wantBody: "<html>index</html>",
+                       wantType: "text/html; charset=utf-8",
+               },
+               {
+                       name:     "asset is served from its own path",
+                       urlPath:  "/static/js/main.abc.js",
+                       wantBody: "console.log(1);",
+               },
+               {
+                       // A browser-side route of the single-page app: it has 
no file of
+                       // its own, and index.html has to answer or a reload 
404s.
+                       name:     "unknown path falls back to index.html",
+                       urlPath:  "/sites/example.com",
+                       wantBody: "<html>index</html>",
+                       wantType: "text/html; charset=utf-8",
+               },
+               {
+                       // Anything that climbs out of the tree must not reach 
the caller's
+                       // filesystem; it lands on index.html like any other 
unknown path.
+                       name:     "path traversal cannot escape the embedded 
tree",
+                       urlPath:  "/../../conf/app.conf",
+                       wantBody: "<html>index</html>",
+               },
+       }
+
+       for _, test := range tests {
+               t.Run(test.name, func(t *testing.T) {
+                       recorder := httptest.NewRecorder()
+                       ServeWeb(recorder, httptest.NewRequest(http.MethodGet, 
"http://localhost"+test.urlPath, nil), test.urlPath)
+
+                       if recorder.Code != http.StatusOK {
+                               t.Fatalf("got status %d, want %d", 
recorder.Code, http.StatusOK)
+                       }
+                       if got := recorder.Body.String(); got != test.wantBody {
+                               t.Errorf("got body %q, want %q", got, 
test.wantBody)
+                       }
+                       if test.wantType != "" {
+                               if got := 
recorder.Header().Get("Content-Type"); got != test.wantType {
+                                       t.Errorf("got Content-Type %q, want 
%q", got, test.wantType)
+                               }
+                       }
+               })
+       }
+}
+
+// TestServeWebWithoutIndex checks the one case that has no fallback: an
+// embedded tree so broken that even index.html is missing.
+func TestServeWebWithoutIndex(t *testing.T) {
+       webFS = fstest.MapFS{}
+       defer func() { webFS = nil }()
+
+       recorder := httptest.NewRecorder()
+       ServeWeb(recorder, httptest.NewRequest(http.MethodGet, 
"http://localhost/";, nil), "/")
+
+       if recorder.Code != http.StatusNotFound {
+               t.Fatalf("got status %d, want %d", recorder.Code, 
http.StatusNotFound)
+       }
+}
diff --git a/ip/ip.go b/ip/ip.go
index 57c7add..137c9bc 100644
--- a/ip/ip.go
+++ b/ip/ip.go
@@ -17,14 +17,32 @@ package ip
 import (
        "fmt"
 
+       "github.com/apache/casbin-gateway/embedsupport"
        "github.com/apache/casbin-gateway/util"
 )
 
+// ipDbPath is where the IP location database lives in the source tree.
+const ipDbPath = "ip/17monipdb.dat"
+
+// InitIpDb loads the IP location database used to tell abroad traffic apart
+// from Chinese traffic. The file on disk is used when it is there; a binary
+// built with -tags embed falls back to the copy baked into it.
 func InitIpDb() {
-       err := Init("ip/17monipdb.dat")
-       if err != nil {
-               panic(err)
+       if util.FileExist(ipDbPath) {
+               err := Init(ipDbPath)
+               if err != nil {
+                       panic(err)
+               }
+
+               return
        }
+
+       data := embedsupport.IpDb()
+       if len(data) == 0 {
+               panic(fmt.Errorf("the IP location database \"%s\" is missing", 
ipDbPath))
+       }
+
+       InitWithData(data)
 }
 
 func IsAbroadIp(ip string) bool {
diff --git a/routers/filter.go b/routers/filter.go
index 2b9fc91..16fdcb3 100644
--- a/routers/filter.go
+++ b/routers/filter.go
@@ -18,20 +18,33 @@ import (
        "net/http"
        "strings"
 
+       "github.com/apache/casbin-gateway/embedsupport"
        "github.com/apache/casbin-gateway/util"
        "github.com/beego/beego"
        "github.com/beego/beego/context"
 )
 
+// webBuildDir holds the compiled web UI. A build made with -tags embed also
+// carries a copy of it inside the binary, used only when this directory has no
+// index.html. The whole UI comes from one source or the other, so a partial
+// build on disk is never quietly completed with files from the binary.
+const webBuildDir = "web/build"
+
 func TransparentStatic(ctx *context.Context) {
        urlPath := ctx.Request.URL.Path
        if strings.HasPrefix(urlPath, "/api/") || strings.HasPrefix(urlPath, 
"/v1/") {
                return
        }
 
-       path := "web/build"
+       indexPath := webBuildDir + "/index.html"
+       if !util.FileExist(indexPath) && embedsupport.HasWeb() {
+               embedsupport.ServeWeb(ctx.ResponseWriter, ctx.Request, urlPath)
+               return
+       }
+
+       path := webBuildDir
        if urlPath == "/" {
-               path += "/index.html"
+               path = indexPath
        } else {
                path += urlPath
        }
@@ -39,7 +52,7 @@ func TransparentStatic(ctx *context.Context) {
        if util.FileExist(path) {
                http.ServeFile(ctx.ResponseWriter, ctx.Request, path)
        } else {
-               http.ServeFile(ctx.ResponseWriter, ctx.Request, 
"web/build/index.html")
+               http.ServeFile(ctx.ResponseWriter, ctx.Request, indexPath)
        }
 }
 
diff --git a/service/startup.go b/service/startup.go
index 4aa5540..728da6c 100644
--- a/service/startup.go
+++ b/service/startup.go
@@ -19,6 +19,7 @@ import (
        "strings"
 
        "github.com/apache/casbin-gateway/conf"
+       "github.com/apache/casbin-gateway/embedsupport"
        "github.com/apache/casbin-gateway/object"
        "github.com/apache/casbin-gateway/util"
 )
@@ -38,6 +39,7 @@ type summaryRow struct {
 func PrintStartupSummary() {
        rows := []summaryRow{
                {"Management UI", fmt.Sprintf("http://localhost:%d";, 
conf.GetHttpPort())},
+               {"Settings", describeConf()},
                {"Web UI files", describeWebBuild()},
                {"Reverse proxy", describeGateway()},
                {"Gateway HTTP", 
describeGatewayPort(conf.GetGatewayHttpPort())},
@@ -50,11 +52,23 @@ func PrintStartupSummary() {
        printSummaryTable("Casbin Gateway", rows)
 }
 
+func describeConf() string {
+       if embedsupport.IsEmbeddedConf() {
+               return "embedded in the binary (put your own conf/app.conf next 
to it to override)"
+       }
+
+       return "conf/app.conf"
+}
+
 func describeWebBuild() string {
        if util.FileExist(webBuildDir + "/index.html") {
                return webBuildDir
        }
 
+       if embedsupport.HasWeb() {
+               return "embedded in the binary"
+       }
+
        return fmt.Sprintf("%s is missing, run \"yarn install && yarn build\" 
in web/", webBuildDir)
 }
 


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

Reply via email to