diff --git a/internal/locales/de.json b/internal/locales/de.json
index d140864..a196ee0 100644
--- a/internal/locales/de.json
+++ b/internal/locales/de.json
@@ -390,6 +390,9 @@
"auth.logout": "Abmelden",
"auth.user_info": "Benutzerinformationen",
"auth.session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
- "auth.login_required": "Authentifizierung erforderlich"
+ "auth.login_required": "Authentifizierung erforderlich",
+ "footer.version": "Fail2ban-UI v{version}",
+ "footer.latest": "Aktuell",
+ "footer.update_available": "Update verfügbar: v{version}"
}
\ No newline at end of file
diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json
index 9ff3d81..a3e0cfe 100644
--- a/internal/locales/de_ch.json
+++ b/internal/locales/de_ch.json
@@ -390,6 +390,9 @@
"auth.logout": "Abmäudä",
"auth.user_info": "Benutzerinformationä",
"auth.session_expired": "Ihri Sitzig isch abglaufä. Bitte mäudä di erneut a.",
- "auth.login_required": "Authentifizierig erforderlich"
+ "auth.login_required": "Authentifizierig erforderlich",
+ "footer.version": "Fail2ban-UI v{version}",
+ "footer.latest": "Aktuell",
+ "footer.update_available": "Update verfüegbar: v{version}"
}
\ No newline at end of file
diff --git a/internal/locales/en.json b/internal/locales/en.json
index 1c9861d..0471ad7 100644
--- a/internal/locales/en.json
+++ b/internal/locales/en.json
@@ -390,6 +390,9 @@
"auth.logout": "Logout",
"auth.user_info": "User Information",
"auth.session_expired": "Your session has expired. Please log in again.",
- "auth.login_required": "Authentication required"
+ "auth.login_required": "Authentication required",
+ "footer.version": "Fail2ban-UI v{version}",
+ "footer.latest": "Latest",
+ "footer.update_available": "Update available: v{version}"
}
\ No newline at end of file
diff --git a/internal/locales/es.json b/internal/locales/es.json
index b11ddd2..ca439fb 100644
--- a/internal/locales/es.json
+++ b/internal/locales/es.json
@@ -390,5 +390,8 @@
"auth.logout": "Cerrar sesión",
"auth.user_info": "Información del usuario",
"auth.session_expired": "Su sesión ha expirado. Por favor, inicie sesión nuevamente.",
- "auth.login_required": "Autenticación requerida"
+ "auth.login_required": "Autenticación requerida",
+ "footer.version": "Fail2ban-UI v{version}",
+ "footer.latest": "Actual",
+ "footer.update_available": "Actualización disponible: v{version}"
}
diff --git a/internal/locales/fr.json b/internal/locales/fr.json
index cf10373..f40c319 100644
--- a/internal/locales/fr.json
+++ b/internal/locales/fr.json
@@ -390,5 +390,8 @@
"auth.logout": "Déconnexion",
"auth.user_info": "Informations utilisateur",
"auth.session_expired": "Votre session a expiré. Veuillez vous reconnecter.",
- "auth.login_required": "Authentification requise"
+ "auth.login_required": "Authentification requise",
+ "footer.version": "Fail2ban-UI v{version}",
+ "footer.latest": "À jour",
+ "footer.update_available": "Mise à jour disponible : v{version}"
}
diff --git a/internal/locales/it.json b/internal/locales/it.json
index 0c82e12..8bbf015 100644
--- a/internal/locales/it.json
+++ b/internal/locales/it.json
@@ -390,5 +390,8 @@
"auth.logout": "Esci",
"auth.user_info": "Informazioni utente",
"auth.session_expired": "La tua sessione è scaduta. Si prega di accedere nuovamente.",
- "auth.login_required": "Autenticazione richiesta"
+ "auth.login_required": "Autenticazione richiesta",
+ "footer.version": "Fail2ban-UI v{version}",
+ "footer.latest": "Aggiornato",
+ "footer.update_available": "Aggiornamento disponibile: v{version}"
}
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 0000000..1dff2ac
--- /dev/null
+++ b/internal/version/version.go
@@ -0,0 +1,20 @@
+// 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 version
+
+// Version is the current Fail2ban-UI version (in best case it matches the latest release tag on GitHub).
+const Version = "1.3.6"
diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go
index 541d1a0..17b10c4 100644
--- a/pkg/web/handlers.go
+++ b/pkg/web/handlers.go
@@ -50,6 +50,7 @@ import (
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
"github.com/swissmakers/fail2ban-ui/internal/integrations"
"github.com/swissmakers/fail2ban-ui/internal/storage"
+ "github.com/swissmakers/fail2ban-ui/internal/version"
)
// wsHub is the global WebSocket hub instance
@@ -1141,15 +1142,97 @@ func renderIndexPage(c *gin.Context) {
}
}
+ // Update check: default true; set UPDATE_CHECK=false to disable external GitHub request
+ updateCheckEnabled := os.Getenv("UPDATE_CHECK") != "false"
+
c.HTML(http.StatusOK, "index.html", gin.H{
- "timestamp": time.Now().Format(time.RFC1123),
- "version": time.Now().Unix(),
- "disableExternalIP": disableExternalIP,
- "oidcEnabled": oidcEnabled,
- "skipLoginPage": skipLoginPage,
+ "timestamp": time.Now().Format(time.RFC1123),
+ "version": time.Now().Unix(),
+ "appVersion": version.Version,
+ "updateCheckEnabled": updateCheckEnabled,
+ "disableExternalIP": disableExternalIP,
+ "oidcEnabled": oidcEnabled,
+ "skipLoginPage": skipLoginPage,
})
}
+// githubReleaseResponse is used to parse the GitHub releases/latest API response
+type githubReleaseResponse struct {
+ TagName string `json:"tag_name"`
+}
+
+// versionLess returns true if a < b (e.g. "1.3.5" < "1.3.6")
+func versionLess(a, b string) bool {
+ parse := func(s string) []int {
+ s = strings.TrimPrefix(strings.TrimSpace(s), "v")
+ parts := strings.Split(s, ".")
+ out := make([]int, 0, len(parts))
+ for _, p := range parts {
+ n, _ := strconv.Atoi(p)
+ out = append(out, n)
+ }
+ return out
+ }
+ pa, pb := parse(a), parse(b)
+ for i := 0; i < len(pa) || i < len(pb); i++ {
+ va, vb := 0, 0
+ if i < len(pa) {
+ va = pa[i]
+ }
+ if i < len(pb) {
+ vb = pb[i]
+ }
+ if va < vb {
+ return true
+ }
+ if va > vb {
+ return false
+ }
+ }
+ return false
+}
+
+// GetVersionHandler returns the current app version and optionally the latest GitHub release.
+// UPDATE_CHECK=false disables the external request to GitHub.
+func GetVersionHandler(c *gin.Context) {
+ updateCheckEnabled := os.Getenv("UPDATE_CHECK") != "false"
+ out := gin.H{
+ "version": version.Version,
+ "update_check_enabled": updateCheckEnabled,
+ }
+ if !updateCheckEnabled {
+ c.JSON(http.StatusOK, out)
+ return
+ }
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/swissmakers/fail2ban-ui/releases/latest", nil)
+ if err != nil {
+ c.JSON(http.StatusOK, out)
+ return
+ }
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ c.JSON(http.StatusOK, out)
+ return
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ c.JSON(http.StatusOK, out)
+ return
+ }
+ var gh githubReleaseResponse
+ if err := json.NewDecoder(resp.Body).Decode(&gh); err != nil {
+ c.JSON(http.StatusOK, out)
+ return
+ }
+ latest := strings.TrimPrefix(strings.TrimSpace(gh.TagName), "v")
+ out["latest_version"] = latest
+ out["update_available"] = versionLess(version.Version, latest)
+ c.JSON(http.StatusOK, out)
+}
+
// GetJailFilterConfigHandler returns both the filter config and jail config for a given jail
func GetJailFilterConfigHandler(c *gin.Context) {
config.DebugLog("----------------------------")
diff --git a/pkg/web/routes.go b/pkg/web/routes.go
index be8017a..12884d4 100644
--- a/pkg/web/routes.go
+++ b/pkg/web/routes.go
@@ -58,6 +58,9 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
api.POST("/jails", CreateJailHandler)
api.DELETE("/jails/:jail", DeleteJailHandler)
+ // Version and update check (only on page load; UPDATE_CHECK=false disables GitHub request)
+ api.GET("/version", GetVersionHandler)
+
// Settings endpoints
api.GET("/settings", GetSettingsHandler)
api.POST("/settings", UpdateSettingsHandler)
diff --git a/pkg/web/static/js/init.js b/pkg/web/static/js/init.js
index c5129f2..47c29f7 100644
--- a/pkg/web/static/js/init.js
+++ b/pkg/web/static/js/init.js
@@ -81,6 +81,26 @@ function initializeApp() {
console.warn('Could not check LOTR on load:', err);
});
+ // Version and update check: only on page load; UPDATE_CHECK=false disables external GitHub request
+ var versionContainer = document.getElementById('version-badge-container');
+ if (versionContainer && versionContainer.getAttribute('data-update-check') === 'true') {
+ fetch('/api/version')
+ .then(function(res) { return res.json(); })
+ .then(function(data) {
+ if (!data.update_check_enabled || versionContainer.innerHTML) return;
+ var latestLabel = typeof t === 'function' ? t('footer.latest', 'Latest') : 'Latest';
+ var updateHint = (typeof t === 'function' && translations && translations['footer.update_available'])
+ ? translations['footer.update_available'].replace('{version}', data.latest_version || '')
+ : ('Update available: v' + (data.latest_version || ''));
+ if (data.update_available && data.latest_version) {
+ versionContainer.innerHTML = '' + updateHint + '';
+ } else {
+ versionContainer.innerHTML = '' + latestLabel + '';
+ }
+ })
+ .catch(function() { /* ignore; no badge on error */ });
+ }
+
Promise.all([
loadServers(),
getTranslationsSettingsOnPageload()
diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html
index 59f267f..aebd866 100644
--- a/pkg/web/templates/index.html
+++ b/pkg/web/templates/index.html
@@ -949,6 +949,8 @@