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/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) diff --git a/ronzinante/tasks/sessions.go b/ronzinante/tasks/sessions.go new file mode 100644 index 0000000..36e531b --- /dev/null +++ b/ronzinante/tasks/sessions.go @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2026 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) +}