From ce20130c3848760c29c36026882e1992fb391181 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 2 Mar 2026 16:34:04 +0100 Subject: [PATCH 1/3] feat: auto-expire OpenVPN sessions after configurable max age Add a background goroutine in ronzinante that periodically checks for expired VPN sessions and terminates them automatically. The cleaner runs every hour and on startup. For each expired session it connects to the OpenVPN management socket and sends a kill command. If the client is still connected, the existing disconnect hook handles cleanup. If the session is orphan (client not found on any socket), the cleaner creates a history entry and removes the session directly. New configuration fields (with defaults for backward compatibility): - openvpn_sockets: list of management socket paths - session_max_age: max session age in hours (default 24) --- deploy/roles/windmill/templates/ronzinante.j2 | 9 +- ronzinante/configuration/configuration.go | 17 ++- ronzinante/main.go | 6 +- ronzinante/tasks/sessions.go | 134 ++++++++++++++++++ 4 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 ronzinante/tasks/sessions.go diff --git a/deploy/roles/windmill/templates/ronzinante.j2 b/deploy/roles/windmill/templates/ronzinante.j2 index 40e916b..c131e35 100644 --- a/deploy/roles/windmill/templates/ronzinante.j2 +++ b/deploy/roles/windmill/templates/ronzinante.j2 @@ -1,4 +1,9 @@ { "db_user": "root", - "db_password": "{{ mariadb_root_password }}" -} \ No newline at end of file + "db_password": "{{ mariadb_root_password }}", + "openvpn_sockets": [ + "/opt/windmill/openvpn/spool/windmill.socket", + "/opt/windmill/openvpn/spool/windmill-https.socket" + ], + "session_max_age": 24 +} diff --git a/ronzinante/configuration/configuration.go b/ronzinante/configuration/configuration.go index d09bdd8..9924de5 100644 --- a/ronzinante/configuration/configuration.go +++ b/ronzinante/configuration/configuration.go @@ -29,8 +29,10 @@ import ( ) type Configuration struct { - DbUser string `json:"db_user"` - DbPassword string `json:"db_password"` + DbUser string `json:"db_user"` + DbPassword string `json:"db_password"` + OpenVPNSockets []string `json:"openvpn_sockets"` + SessionMaxAge int `json:"session_max_age"` } var Config = Configuration{} @@ -45,4 +47,15 @@ func Init() { if err != nil { fmt.Println("error:", err) } + + // set defaults for optional fields + if len(Config.OpenVPNSockets) == 0 { + Config.OpenVPNSockets = []string{ + "/opt/windmill/openvpn/spool/windmill.socket", + "/opt/windmill/openvpn/spool/windmill-https.socket", + } + } + if Config.SessionMaxAge == 0 { + Config.SessionMaxAge = 24 + } } diff --git a/ronzinante/main.go b/ronzinante/main.go index d4cfa86..5c7b7d3 100644 --- a/ronzinante/main.go +++ b/ronzinante/main.go @@ -25,9 +25,10 @@ package main import ( "github.com/gin-gonic/gin" - "github.com/nethesis/windmill/ronzinante/database" "github.com/nethesis/windmill/ronzinante/configuration" + "github.com/nethesis/windmill/ronzinante/database" "github.com/nethesis/windmill/ronzinante/methods" + "github.com/nethesis/windmill/ronzinante/tasks" ) func main() { @@ -38,6 +39,9 @@ func main() { db := database.Init() defer db.Close() + // start background session cleaner + tasks.StartSessionCleaner() + // init routers router := gin.Default() diff --git a/ronzinante/tasks/sessions.go b/ronzinante/tasks/sessions.go new file mode 100644 index 0000000..9c8cad1 --- /dev/null +++ b/ronzinante/tasks/sessions.go @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2017 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * This file is part of Windmill project. + * + * WindMill is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, + * or any later version. + * + * WindMill is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with WindMill. If not, see COPYING. + */ + +package tasks + +import ( + "fmt" + "net" + "strings" + "time" + + "github.com/nethesis/windmill/ronzinante/configuration" + "github.com/nethesis/windmill/ronzinante/database" + "github.com/nethesis/windmill/ronzinante/models" +) + +func StartSessionCleaner() { + go func() { + // run immediately at startup + cleanExpiredSessions() + + ticker := time.NewTicker(1 * time.Hour) + for range ticker.C { + cleanExpiredSessions() + } + }() +} + +func cleanExpiredSessions() { + maxAge := time.Duration(configuration.Config.SessionMaxAge) * time.Hour + + db := database.Instance() + var sessions []models.Session + db.Find(&sessions) + + for _, session := range sessions { + started, err := parseStartedTime(session.Started) + if err != nil { + fmt.Printf("[session-cleaner] error parsing started time for session %s: %v\n", session.ServerId, err) + continue + } + + if time.Since(started) < maxAge { + continue + } + + fmt.Printf("[session-cleaner] session %s expired (started: %s)\n", session.ServerId, session.Started) + + // try to kill the client on all OpenVPN sockets + killed := false + for _, socketPath := range configuration.Config.OpenVPNSockets { + if killVPNClient(socketPath, session.ServerId) { + fmt.Printf("[session-cleaner] killed %s via %s\n", session.ServerId, socketPath) + killed = true + break + } + } + + if killed { + // disconnect hook (windmill-disconnect) will handle history + cleanup + continue + } + + // orphan session: client not found on any socket, cleanup directly + fmt.Printf("[session-cleaner] session %s is orphan, cleaning up directly\n", session.ServerId) + var history models.History + db.Where("session_id = ?", session.SessionId).First(&history) + if history.Id == 0 { + history.SessionId = session.SessionId + history.ServerId = session.ServerId + history.Started = time.Now().String() + db.Save(&history) + } + db.Delete(&session) + } +} + +func killVPNClient(socketPath string, serverID string) bool { + conn, err := net.DialTimeout("unix", socketPath, 5*time.Second) + if err != nil { + fmt.Printf("[session-cleaner] cannot connect to %s: %v\n", socketPath, err) + return false + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(10 * time.Second)) + + // read the initial banner + buf := make([]byte, 4096) + conn.Read(buf) + + // send kill command + _, err = fmt.Fprintf(conn, "kill %s\r\n", serverID) + if err != nil { + fmt.Printf("[session-cleaner] error sending kill to %s: %v\n", socketPath, err) + return false + } + + // read response + n, err := conn.Read(buf) + if err != nil { + fmt.Printf("[session-cleaner] error reading response from %s: %v\n", socketPath, err) + return false + } + + response := string(buf[:n]) + return strings.Contains(response, "SUCCESS") +} + +func parseStartedTime(started string) (time.Time, error) { + // time.Now().String() produces: "2006-01-02 15:04:05.999999999 -0700 MST m=+0.000000001" + // strip the monotonic clock suffix if present + if idx := strings.Index(started, " m="); idx != -1 { + started = started[:idx] + } + return time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", started) +} From 073fe4cb0defe4eaff181ac0ad7fc8e750e51bec Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 2 Mar 2026 16:46:26 +0100 Subject: [PATCH 2/3] Update ronzinante/tasks/sessions.go Co-authored-by: Giacomo Sanchietti --- ronzinante/tasks/sessions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ronzinante/tasks/sessions.go b/ronzinante/tasks/sessions.go index 9c8cad1..36e531b 100644 --- a/ronzinante/tasks/sessions.go +++ b/ronzinante/tasks/sessions.go @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 Nethesis S.r.l. + * Copyright (C) 2026 Nethesis S.r.l. * http://www.nethesis.it - info@nethesis.it * * This file is part of Windmill project. From e964cb6a1e96bb112c9ae1542ac7c6fd4752b9b0 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 2 Mar 2026 16:45:36 +0100 Subject: [PATCH 3/3] fix: avoid duplicate history entry in DeleteSession handler Same fix applied to the disconnect hook endpoint: check if a history record already exists for the session before inserting a new one. --- ronzinante/methods/sessions.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ronzinante/methods/sessions.go b/ronzinante/methods/sessions.go index 0255061..961c88d 100644 --- a/ronzinante/methods/sessions.go +++ b/ronzinante/methods/sessions.go @@ -143,11 +143,14 @@ func DeleteSession(c *gin.Context) { return } - // add to history this session - history.SessionId = session.SessionId - history.ServerId = session.ServerId - history.Started = time.Now().String() - db.Save(&history) + // add to history this session if not already present + db.Where("session_id = ?", session.SessionId).First(&history) + if history.Id == 0 { + history.SessionId = session.SessionId + history.ServerId = session.ServerId + history.Started = time.Now().String() + db.Save(&history) + } db.Delete(&session)