Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions internal/server/vpn.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"

"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/constants/vpn"
)

func newVPNHandler(ctx context.Context, looper VPNLooper,
Expand Down Expand Up @@ -50,6 +54,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)
}
Expand Down Expand Up @@ -129,3 +140,86 @@ func (h *vpnHandler) patchSettings(w http.ResponseWriter, r *http.Request) {
h.warner.Warn("writing response: " + err.Error())
}
}

// tunStats is the response for GET /v1/vpn/stats.
type tunStats struct {
Interface string `json:"interface"`
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
}

func (h *vpnHandler) getStats(w http.ResponseWriter) {
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 {
// 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 for %s: %v", iface, err), http.StatusInternalServerError)
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{
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"
}
}