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]