From 0c85b726be481f0605b20af691b655d755b2bd32 Mon Sep 17 00:00:00 2001 From: Sugata Patra <123456gamelive@gmail.com> Date: Sat, 1 Aug 2026 21:57:08 +0530 Subject: [PATCH 1/2] feat(server): expose tun interface rx_bytes and tx_bytes via /v1/vpn/stats Implements feature request #3419 to allow clients to query bandwidth usage through the VPN tunnel for visualization and monitoring. Uses the default tun0 interface statistics from sysfs. --- internal/server/vpn.go | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/internal/server/vpn.go b/internal/server/vpn.go index 84f21c83c..81c8fe68b 100644 --- a/internal/server/vpn.go +++ b/internal/server/vpn.go @@ -3,7 +3,10 @@ package server import ( "context" "encoding/json" + "fmt" "net/http" + "os" + "strconv" "strings" "github.com/qdm12/gluetun/internal/configuration/settings" @@ -50,6 +53,13 @@ func (h *vpnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { default: errMethodNotSupported(w, r.Method) } + case "/stats": + switch r.Method { + case http.MethodGet: + h.getStats(w) + default: + errMethodNotSupported(w, r.Method) + } default: errRouteNotSupported(w, r.RequestURI) } @@ -129,3 +139,45 @@ func (h *vpnHandler) patchSettings(w http.ResponseWriter, r *http.Request) { h.warner.Warn("writing response: " + err.Error()) } } + +type tunStats struct { + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` +} + +func (h *vpnHandler) getStats(w http.ResponseWriter) { + // Default tun interface name used by Gluetun + const iface = "tun0" + rxPath := fmt.Sprintf("/sys/class/net/%s/statistics/rx_bytes", iface) + txPath := fmt.Sprintf("/sys/class/net/%s/statistics/tx_bytes", iface) + + rxData, err := os.ReadFile(rxPath) + if err != nil { + http.Error(w, fmt.Sprintf("reading rx_bytes: %v", err), http.StatusNotFound) + return + } + txData, err := os.ReadFile(txPath) + if err != nil { + http.Error(w, fmt.Sprintf("reading tx_bytes: %v", err), http.StatusNotFound) + return + } + + rxBytes, err := strconv.ParseUint(strings.TrimSpace(string(rxData)), 10, 64) + if err != nil { + http.Error(w, fmt.Sprintf("parsing rx_bytes: %v", err), http.StatusInternalServerError) + return + } + txBytes, err := strconv.ParseUint(strings.TrimSpace(string(txData)), 10, 64) + if err != nil { + http.Error(w, fmt.Sprintf("parsing tx_bytes: %v", err), http.StatusInternalServerError) + return + } + + encoder := json.NewEncoder(w) + data := tunStats{RxBytes: rxBytes, TxBytes: txBytes} + if err := encoder.Encode(data); err != nil { + h.warner.Warn(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } +} From d4b2793767a96030fa467b82105cae756a3156a1 Mon Sep 17 00:00:00 2001 From: Sugata Patra <123456gamelive@gmail.com> Date: Sat, 1 Aug 2026 22:02:13 +0530 Subject: [PATCH 2/2] feat(server): fully implement TUN rx/tx bytes endpoint for #3419 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /v1/vpn/stats returns interface name, rx_bytes and tx_bytes - Resolves the correct interface from current VPN settings (OpenVPN → OpenVPN.Interface default tun0, WireGuard/AmneziaWG → Wireguard.Interface default wg0) - Falls back to the alternate common name if the configured interface is not present - Clients can poll the endpoint to compute bandwidth rates Closes #3419 --- internal/server/vpn.go | 58 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/internal/server/vpn.go b/internal/server/vpn.go index 81c8fe68b..1a7a2c560 100644 --- a/internal/server/vpn.go +++ b/internal/server/vpn.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/qdm12/gluetun/internal/configuration/settings" + "github.com/qdm12/gluetun/internal/constants/vpn" ) func newVPNHandler(ctx context.Context, looper VPNLooper, @@ -140,25 +141,39 @@ func (h *vpnHandler) patchSettings(w http.ResponseWriter, r *http.Request) { } } +// tunStats is the response for GET /v1/vpn/stats. type tunStats struct { - RxBytes uint64 `json:"rx_bytes"` - TxBytes uint64 `json:"tx_bytes"` + Interface string `json:"interface"` + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` } func (h *vpnHandler) getStats(w http.ResponseWriter) { - // Default tun interface name used by Gluetun - const iface = "tun0" + iface := h.resolveTunInterface() rxPath := fmt.Sprintf("/sys/class/net/%s/statistics/rx_bytes", iface) txPath := fmt.Sprintf("/sys/class/net/%s/statistics/tx_bytes", iface) rxData, err := os.ReadFile(rxPath) if err != nil { - http.Error(w, fmt.Sprintf("reading rx_bytes: %v", err), http.StatusNotFound) - return + // Fallback: try the other common name if the configured one is missing + fallback := "tun0" + if iface == "tun0" { + fallback = "wg0" + } + rxPath = fmt.Sprintf("/sys/class/net/%s/statistics/rx_bytes", fallback) + txPath = fmt.Sprintf("/sys/class/net/%s/statistics/tx_bytes", fallback) + rxData, err = os.ReadFile(rxPath) + if err != nil { + http.Error(w, fmt.Sprintf("TUN interface %q (and fallback) not found or not up: %v", iface, err), + http.StatusNotFound) + return + } + iface = fallback } + txData, err := os.ReadFile(txPath) if err != nil { - http.Error(w, fmt.Sprintf("reading tx_bytes: %v", err), http.StatusNotFound) + http.Error(w, fmt.Sprintf("reading tx_bytes for %s: %v", iface, err), http.StatusInternalServerError) return } @@ -174,10 +189,37 @@ func (h *vpnHandler) getStats(w http.ResponseWriter) { } encoder := json.NewEncoder(w) - data := tunStats{RxBytes: rxBytes, TxBytes: txBytes} + data := tunStats{ + Interface: iface, + RxBytes: rxBytes, + TxBytes: txBytes, + } if err := encoder.Encode(data); err != nil { h.warner.Warn(err.Error()) w.WriteHeader(http.StatusInternalServerError) return } } + +// resolveTunInterface returns the configured TUN/WG interface name +// based on the current VPN settings. +func (h *vpnHandler) resolveTunInterface() string { + s := h.looper.GetSettings() + switch s.Type { + case vpn.OpenVPN: + if s.OpenVPN.Interface != "" { + return s.OpenVPN.Interface + } + return "tun0" + case vpn.Wireguard, vpn.AmneziaWg: + if s.Wireguard.Interface != "" { + return s.Wireguard.Interface + } + if s.Type == vpn.AmneziaWg && s.AmneziaWg.Wireguard.Interface != "" { + return s.AmneziaWg.Wireguard.Interface + } + return "wg0" + default: + return "tun0" + } +}