Refactor gobuild to include all assets (locales, static-files and template) into one single binary

This commit is contained in:
2026-04-08 20:17:47 +02:00
parent 1f160e8347
commit 5e51b4bab4
15 changed files with 125 additions and 36 deletions
+1 -4
View File
@@ -53,11 +53,8 @@ VOLUME ["/config"]
ENV CONTAINER=true
# Copy Fail2Ban-UI binary and templates from the build stage
# Copy Fail2Ban-UI binary (templates, locales, and static assets are embedded at compile time)
COPY --from=builder /app/fail2ban-ui /app/fail2ban-ui
COPY --from=builder /app/pkg/web/templates /app/templates
COPY --from=builder /app/internal/locales /app/locales
COPY --from=builder /app/pkg/web/static /app/static
RUN chown fail2ban:0 /app/fail2ban-ui && chmod +x /app/fail2ban-ui
EXPOSE 8080
+1 -1
View File
@@ -191,7 +191,7 @@ Documentation and deployment guidance in security tooling is never "done", and e
If you see a clearer way to describe installation steps, safer container defaults, better reverse-proxy examples, SELinux improvements, or a more practical demo environment, please contribute. Small improvements (typos, wording, examples) are just as valuable as code changes.
Want to add a new UI language? Copy `internal/locales/en.json`, translate all values, save it as `internal/locales/<lang>.json`, and open a pull request.
Want to add a new UI language? Copy `pkg/web/locales/en.json`, translate all values, save it as `pkg/web/locales/<lang>.json`, and open a pull request.
Please use a proper lowercase locale short code for `<lang>` (for example `ch`, `ch_de`, `es`, or `pt_br`).
+2 -11
View File
@@ -19,7 +19,6 @@ import (
"log"
"net"
"net/http"
"os"
"strconv"
"time"
@@ -84,16 +83,8 @@ func main() {
bindAddress, _ := config.GetBindAddressFromEnv()
serverAddr := net.JoinHostPort(bindAddress, serverPort)
// Load templates and static assets based on running in container or locally (compiled binary)
_, container := os.LookupEnv("CONTAINER")
if container {
router.LoadHTMLGlob("/app/templates/*")
router.Static("/locales", "/app/locales")
router.Static("/static", "/app/static")
} else {
router.LoadHTMLGlob("pkg/web/templates/*")
router.Static("/locales", "./internal/locales")
router.Static("/static", "./pkg/web/static")
if err := web.MountEmbeddedAssets(router); err != nil {
log.Fatalf("failed to mount embedded web assets: %v", err)
}
// Initialize WebSocket hub and console log capture
+3
View File
@@ -37,9 +37,12 @@ Clone the repository to `/opt/fail2ban-ui`:
```bash
sudo git clone https://github.com/swissmakers/fail2ban-ui.git /opt/fail2ban-ui
cd /opt/fail2ban-ui
./build-tailwind.sh
sudo go build -o fail2ban-ui ./cmd/server/main.go
```
Web UI templates, translation JSON files, and `pkg/web/static` are embedded into the binary at compile time, so you only need to ship the `fail2ban-ui` executable (plus a writable `WorkingDirectory` for the SQLite database).
### Create the fail2ban-ui.service
Save this file as `/etc/systemd/system/fail2ban-ui.service`:
For production deployments, please use a dedicated service account instead of root.
+1 -1
View File
@@ -75,7 +75,7 @@ cd /opt/fail2ban-ui
# Build static CSS assets
./build-tailwind.sh
# Build the Go binary
# Build the Go binary (embeds pkg/web/templates, pkg/web/locales, and pkg/web/static)
go build -o fail2ban-ui ./cmd/server/main.go
```
+64
View File
@@ -0,0 +1,64 @@
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// 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 web
import (
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"github.com/gin-gonic/gin"
)
//go:embed templates/*
var embeddedTemplates embed.FS
//go:embed all:static
var embeddedStatic embed.FS
//go:embed locales/*.json
var embeddedLocales embed.FS
// LocalesFS is the embedded UI translations
var LocalesFS fs.FS
func init() {
sub, err := fs.Sub(embeddedLocales, "locales")
if err != nil {
panic("web: locales embed: " + err.Error())
}
LocalesFS = sub
}
// Registers HTML templates and /static and /locales handlers using data embedded at compile time.
func MountEmbeddedAssets(r *gin.Engine) error {
tmpl, err := template.ParseFS(embeddedTemplates, "templates/*.html")
if err != nil {
return fmt.Errorf("parse HTML templates: %w", err)
}
r.SetHTMLTemplate(tmpl)
staticRoot, err := fs.Sub(embeddedStatic, "static")
if err != nil {
return fmt.Errorf("static subdirectory: %w", err)
}
r.StaticFS("/static", http.FS(staticRoot))
r.StaticFS("/locales", http.FS(LocalesFS))
return nil
}
+43
View File
@@ -0,0 +1,43 @@
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// 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 web
import (
"html/template"
"testing"
)
func TestEmbeddedTemplatesIndexName(t *testing.T) {
tmpl, err := template.ParseFS(embeddedTemplates, "templates/*.html")
if err != nil {
t.Fatal(err)
}
var names []string
for _, tp := range tmpl.Templates() {
names = append(names, tp.Name())
}
ok := false
for _, n := range names {
if n == "index.html" {
ok = true
break
}
}
if !ok {
t.Fatalf("template index.html not found; templates: %v", names)
}
}
+10 -19
View File
@@ -25,6 +25,7 @@ import (
"fmt"
"html"
"io"
"io/fs"
"log"
"net"
"net/http"
@@ -1583,11 +1584,7 @@ func renderIndexPage(c *gin.Context) {
}
func listLocaleOptions() []localeOption {
localeDir := "./internal/locales"
if _, container := os.LookupEnv("CONTAINER"); container {
localeDir = "/app/locales"
}
entries, err := os.ReadDir(localeDir)
entries, err := fs.ReadDir(LocalesFS, ".")
if err != nil {
return []localeOption{{Code: "en", Label: "en"}}
}
@@ -1604,7 +1601,11 @@ func listLocaleOptions() []localeOption {
if code == "" {
continue
}
label := localeLabelFromFile(filepath.Join(localeDir, name), code)
data, readErr := fs.ReadFile(LocalesFS, name)
label := code
if readErr == nil {
label = localeLabelFromJSON(data, code)
}
options = append(options, localeOption{
Code: code,
Label: label,
@@ -1626,9 +1627,8 @@ func listLocaleOptions() []localeOption {
return options
}
func localeLabelFromFile(path, fallback string) string {
data, err := os.ReadFile(path)
if err != nil {
func localeLabelFromJSON(data []byte, fallback string) string {
if len(data) == 0 {
return fallback
}
var translations map[string]string
@@ -2900,16 +2900,7 @@ func loadLocale(lang string) (map[string]string, error) {
}
localeCacheLock.RUnlock()
// Determines the locale file path
var localePath string
_, container := os.LookupEnv("CONTAINER")
if container {
localePath = fmt.Sprintf("/app/locales/%s.json", lang)
} else {
localePath = fmt.Sprintf("./internal/locales/%s.json", lang)
}
data, err := os.ReadFile(localePath)
data, err := fs.ReadFile(LocalesFS, lang+".json")
if err != nil {
// Falls back to English if the locale file is not found
if lang != "en" {