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 @@