From 486127f76a00d23326b2d287b30d0bc3f46cf30f Mon Sep 17 00:00:00 2001 From: shinya Date: Sun, 10 Aug 2025 11:27:25 +0330 Subject: [PATCH 1/2] [add] support for prometheus exporter [refactor] isolate metrics states in module global state --- cmd/cmd.go | 5 + cmd/defaults.go | 9 + config/config.go | 10 + go.mod | 12 +- go.sum | 38 +- internal/client/transport/accept_udp.go | 20 +- internal/client/transport/tcp.go | 22 +- internal/client/transport/tcpmux.go | 21 +- internal/client/transport/udp.go | 20 +- internal/client/transport/ws.go | 21 +- internal/client/transport/wsmux.go | 20 +- internal/server/transport/accept_udp.go | 22 +- internal/server/transport/tcp.go | 47 ++- internal/server/transport/tcpmux.go | 18 +- internal/server/transport/udp.go | 37 +- internal/server/transport/ws.go | 47 +-- internal/server/transport/wsmux.go | 21 +- internal/stats/stats.go | 208 +++++++++++ internal/utils/handlers/tcp_handler.go | 15 +- internal/utils/handlers/ws_handler.go | 20 +- internal/web/{ => metrics}/index.html | 0 internal/web/metrics/legacy.go | 267 ++++++++++++++ internal/web/metrics/metrics.go | 109 ++++++ internal/web/metrics/prometheus.go | 111 ++++++ internal/web/sniffer.go | 447 ------------------------ 25 files changed, 887 insertions(+), 680 deletions(-) create mode 100644 internal/stats/stats.go rename internal/web/{ => metrics}/index.html (100%) create mode 100644 internal/web/metrics/legacy.go create mode 100644 internal/web/metrics/metrics.go create mode 100644 internal/web/metrics/prometheus.go delete mode 100644 internal/web/sniffer.go diff --git a/cmd/cmd.go b/cmd/cmd.go index ef6d799..5dc5670 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -5,6 +5,7 @@ import ( "github.com/musix/backhaul/config" "github.com/musix/backhaul/internal/client" + "github.com/musix/backhaul/internal/web/metrics" "github.com/musix/backhaul/internal/server" "github.com/musix/backhaul/internal/utils" @@ -26,6 +27,8 @@ func Run(configPath string, ctx context.Context) { // Apply default values to the configuration applyDefaults(cfg) + metricHandler := metrics.NewMetricsHandler(ctx, logger, *cfg) + configType := "" if cfg.Server.BindAddr != "" { configType = "server" @@ -45,6 +48,7 @@ func Run(configPath string, ctx context.Context) { srv := server.NewServer(&cfg.Server, ctx) // server go srv.Start() + go metricHandler.Monitor() // Wait for shutdown signal <-ctx.Done() @@ -58,6 +62,7 @@ func Run(configPath string, ctx context.Context) { clnt := client.NewClient(&cfg.Client, ctx) // client go clnt.Start() + go metricHandler.Monitor() // Wait for shutdown signal <-ctx.Done() diff --git a/cmd/defaults.go b/cmd/defaults.go index d6c6980..2d34633 100644 --- a/cmd/defaults.go +++ b/cmd/defaults.go @@ -129,4 +129,13 @@ func applyDefaults(cfg *config.Config) { if cfg.Server.MuxCon < 1 { cfg.Server.MuxCon = defaultMuxCon } + + // keep legacy handler + if len(cfg.Client.MetricCollectors) == 0 { + cfg.Client.MetricCollectors = append(cfg.Client.MetricCollectors, "default") + } + + if len(cfg.Server.MetricCollectors) == 0 { + cfg.Server.MetricCollectors = append(cfg.Server.MetricCollectors, "default") + } } diff --git a/config/config.go b/config/config.go index 14a8672..605a4a6 100644 --- a/config/config.go +++ b/config/config.go @@ -41,6 +41,7 @@ type ServerConfig struct { MSS int `toml:"mss"` SO_RCVBUF int `toml:"so_rcvbuf"` SO_SNDBUF int `toml:"so_sndbuf"` + MetricCollectors []string `toml:"metrics"` } // ClientConfig represents the configuration for the client. @@ -69,6 +70,7 @@ type ClientConfig struct { MSS int `toml:"mss"` SO_RCVBUF int `toml:"so_rcvbuf"` SO_SNDBUF int `toml:"so_sndbuf"` + MetricCollectors []string `toml:"metrics"` } // Config represents the complete configuration, including both server and client settings. @@ -76,3 +78,11 @@ type Config struct { Server ServerConfig `toml:"server"` Client ClientConfig `toml:"client"` } + +func (c *Config) IsServerConfig() bool { + if c.Client.RemoteAddr != "" { + return false + } + + return true +} diff --git a/go.mod b/go.mod index c678e54..364303a 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,26 @@ go 1.23.1 require ( github.com/BurntSushi/toml v1.4.0 github.com/gorilla/websocket v1.5.3 + github.com/prometheus/client_golang v1.23.0 github.com/shirou/gopsutil/v4 v4.24.8 github.com/sirupsen/logrus v1.9.3 github.com/xtaci/smux v1.5.27 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/numcpus v0.8.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.25.0 // indirect + golang.org/x/sys v0.33.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/go.sum b/go.sum index 20b1cee..c72e329 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,39 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/shirou/gopsutil/v4 v4.24.8 h1:pVQjIenQkIhqO81mwTaXjTzOMT7d3TZkf43PlVFHENI= github.com/shirou/gopsutil/v4 v4.24.8/go.mod h1:wE0OrJtj4dG+hYkxqDH3QiBICdKSf04/npcvLLc/oRg= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= @@ -26,8 +44,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= @@ -36,12 +54,16 @@ github.com/xtaci/smux v1.5.27 h1:uIU1dpJQQWUCmGxXBgajLfc8cMMb13hCitj+HC5yC/Q= github.com/xtaci/smux v1.5.27/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/client/transport/accept_udp.go b/internal/client/transport/accept_udp.go index e1f777d..a85bcd5 100644 --- a/internal/client/transport/accept_udp.go +++ b/internal/client/transport/accept_udp.go @@ -7,13 +7,13 @@ import ( "net" "time" - "github.com/musix/backhaul/internal/web" + "github.com/musix/backhaul/internal/stats" "github.com/sirupsen/logrus" ) const BufferSize = 16 * 1024 -func UDPDialer(tcp net.Conn, remoteAddr string, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { +func UDPDialer(tcp net.Conn, remoteAddr string, logger *logrus.Logger, remotePort int) { remoteUDPAddr, err := net.ResolveUDPAddr("udp", remoteAddr) if err != nil { logger.Fatalf("failed to resolve remote address: %v", err) @@ -30,16 +30,16 @@ func UDPDialer(tcp net.Conn, remoteAddr string, logger *logrus.Logger, usage *we done := make(chan struct{}) go func() { - go tcpToUDP(tcp, remoteConn, logger, usage, remotePort, sniffer) + go tcpToUDP(tcp, remoteConn, logger, remotePort) done <- struct{}{} }() - udpToTCP(tcp, remoteConn, logger, usage, remotePort, sniffer) + udpToTCP(tcp, remoteConn, logger, remotePort) <-done } -func tcpToUDP(tcp net.Conn, udp *net.UDPConn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { +func tcpToUDP(tcp net.Conn, udp *net.UDPConn, logger *logrus.Logger, remotePort int) { buf := make([]byte, BufferSize) lenBuf := make([]byte, 2) // 2-byte header for packet size @@ -83,13 +83,11 @@ func tcpToUDP(tcp net.Conn, udp *net.UDPConn, logger *logrus.Logger, usage *web. logger.Tracef("read %d bytes from TCP, wrote %d bytes to UDP", packetSize, totalWritten) - if sniffer { - usage.AddOrUpdatePort(remotePort, uint64(totalWritten)) - } + stats.RecordPortUsage(remotePort, uint64(totalWritten)) } } -func udpToTCP(tcp net.Conn, udp *net.UDPConn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { +func udpToTCP(tcp net.Conn, udp *net.UDPConn, logger *logrus.Logger, remotePort int) { buf := make([]byte, BufferSize-6) // reserved for 5 bytes header // Pre-allocate headers @@ -146,8 +144,6 @@ func udpToTCP(tcp net.Conn, udp *net.UDPConn, logger *logrus.Logger, usage *web. logger.Tracef("read %d bytes from UDP, wrote %d bytes to TCP", r, totalWritten) - if sniffer { - usage.AddOrUpdatePort(remotePort, uint64(totalWritten)) - } + stats.RecordPortUsage(remotePort, uint64(totalWritten)) } } diff --git a/internal/client/transport/tcp.go b/internal/client/transport/tcp.go index 81811ba..bbfed3e 100644 --- a/internal/client/transport/tcp.go +++ b/internal/client/transport/tcp.go @@ -2,17 +2,16 @@ package transport import ( "context" - "fmt" "net" "strings" "sync" "sync/atomic" "time" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" "github.com/musix/backhaul/internal/utils/handlers" "github.com/musix/backhaul/internal/utils/network" - "github.com/musix/backhaul/internal/web" "github.com/sirupsen/logrus" ) @@ -24,7 +23,6 @@ type TcpTransport struct { cancel context.CancelFunc logger *logrus.Logger controlChannel net.Conn - usageMonitor *web.Usage restartMutex sync.Mutex poolConnections int32 loadConnections int32 @@ -34,7 +32,6 @@ type TcpConfig struct { RemoteAddr string Token string SnifferLog string - TunnelStatus string KeepAlive time.Duration RetryInterval time.Duration DialTimeOut time.Duration @@ -60,7 +57,6 @@ func NewTCPClient(parentCtx context.Context, config *TcpConfig, logger *logrus.L cancel: cancel, logger: logger, controlChannel: nil, // will be set when a control connection is established - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), poolConnections: 0, loadConnections: 0, controlFlow: make(chan struct{}, 100), @@ -70,11 +66,7 @@ func NewTCPClient(parentCtx context.Context, config *TcpConfig, logger *logrus.L } func (c *TcpTransport) Start() { - if c.config.WebPort > 0 { - go c.usageMonitor.Monitor() - } - - c.config.TunnelStatus = "Disconnected (TCP)" + stats.SetDown() go c.channelDialer() } @@ -108,8 +100,6 @@ func (c *TcpTransport) Restart() { // Re-initialize variables c.controlChannel = nil - c.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", c.config.WebPort), ctx, c.config.SnifferLog, c.config.Sniffer, &c.config.TunnelStatus, c.logger) - c.config.TunnelStatus = "" c.poolConnections = 0 c.loadConnections = 0 c.controlFlow = make(chan struct{}, 100) @@ -117,6 +107,8 @@ func (c *TcpTransport) Restart() { // set the log level again c.logger.SetLevel(level) + stats.SetDown() + go c.Start() } @@ -170,7 +162,7 @@ func (c *TcpTransport) channelDialer() { c.controlChannel = tunnelTCPConn c.logger.Info("control channel established successfully") - c.config.TunnelStatus = "Connected (TCP)" + stats.SetUp() go c.poolMaintainer() go c.channelHandler() @@ -360,7 +352,7 @@ func (c *TcpTransport) tunnelDialer() { c.localDialer(tcpConn, resolvedAddr, port) case utils.SG_UDP: - UDPDialer(tcpConn, resolvedAddr, c.logger, c.usageMonitor, port, c.config.Sniffer) + UDPDialer(tcpConn, resolvedAddr, c.logger, port) default: c.logger.Error("undefined transport. close the connection.") @@ -390,5 +382,5 @@ func (c *TcpTransport) localDialer(tcpConn net.Conn, resolvedAddr string, port i c.logger.Debugf("connected to local address %s successfully", resolvedAddr) - handlers.TCPConnectionHandler(c.ctx, tcpConn, localConnection, c.logger, c.usageMonitor, port, c.config.Sniffer) + handlers.TCPConnectionHandler(c.ctx, tcpConn, localConnection, c.logger, port) } diff --git a/internal/client/transport/tcpmux.go b/internal/client/transport/tcpmux.go index 829e673..53dce55 100644 --- a/internal/client/transport/tcpmux.go +++ b/internal/client/transport/tcpmux.go @@ -2,18 +2,16 @@ package transport import ( "context" - "fmt" "net" "strings" "sync" "sync/atomic" "time" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" "github.com/musix/backhaul/internal/utils/handlers" "github.com/musix/backhaul/internal/utils/network" - "github.com/musix/backhaul/internal/web" - "github.com/sirupsen/logrus" "github.com/xtaci/smux" ) @@ -26,7 +24,6 @@ type TcpMuxTransport struct { cancel context.CancelFunc logger *logrus.Logger controlChannel net.Conn - usageMonitor *web.Usage restartMutex sync.Mutex poolConnections int32 loadConnections int32 @@ -37,7 +34,6 @@ type TcpMuxConfig struct { RemoteAddr string Token string SnifferLog string - TunnelStatus string Nodelay bool Sniffer bool KeepAlive time.Duration @@ -75,7 +71,6 @@ func NewMuxClient(parentCtx context.Context, config *TcpMuxConfig, logger *logru cancel: cancel, logger: logger, controlChannel: nil, // will be set when a control connection is established - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), poolConnections: 0, loadConnections: 0, controlFlow: make(chan struct{}, 100), @@ -85,11 +80,7 @@ func NewMuxClient(parentCtx context.Context, config *TcpMuxConfig, logger *logru } func (c *TcpMuxTransport) Start() { - if c.config.WebPort > 0 { - go c.usageMonitor.Monitor() - } - - c.config.TunnelStatus = "Disconnected (TCPMUX)" + stats.SetDown() go c.channelDialer() } @@ -124,8 +115,6 @@ func (c *TcpMuxTransport) Restart() { // Re-initialize variables c.controlChannel = nil - c.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", c.config.WebPort), ctx, c.config.SnifferLog, c.config.Sniffer, &c.config.TunnelStatus, c.logger) - c.config.TunnelStatus = "" c.poolConnections = 0 c.loadConnections = 0 c.controlFlow = make(chan struct{}, 100) @@ -133,6 +122,8 @@ func (c *TcpMuxTransport) Restart() { // set the log level again c.logger.SetLevel(level) + stats.SetDown() + go c.Start() } @@ -185,7 +176,7 @@ func (c *TcpMuxTransport) channelDialer() { c.controlChannel = tunnelConn c.logger.Info("control channel established successfully") - c.config.TunnelStatus = "Connected (TCPMux)" + stats.SetUp() go c.poolMaintainer() go c.channelHandler() @@ -411,5 +402,5 @@ func (c *TcpMuxTransport) localDialer(stream *smux.Stream, remoteAddr string) { c.logger.Debugf("connected to local address %s successfully", remoteAddr) - handlers.TCPConnectionHandler(c.ctx, stream, localConnection, c.logger, c.usageMonitor, int(port), c.config.Sniffer) + handlers.TCPConnectionHandler(c.ctx, stream, localConnection, c.logger, int(port)) } diff --git a/internal/client/transport/udp.go b/internal/client/transport/udp.go index cd16c72..a269c97 100644 --- a/internal/client/transport/udp.go +++ b/internal/client/transport/udp.go @@ -2,15 +2,14 @@ package transport import ( "context" - "fmt" "net" "sync" "sync/atomic" "time" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" "github.com/musix/backhaul/internal/utils/network" - "github.com/musix/backhaul/internal/web" "github.com/sirupsen/logrus" ) @@ -21,7 +20,6 @@ type UdpTransport struct { cancel context.CancelFunc logger *logrus.Logger controlChannel net.Conn - usageMonitor *web.Usage restartMutex sync.Mutex poolConnections int32 loadConnections int32 @@ -31,7 +29,6 @@ type UdpConfig struct { RemoteAddr string Token string SnifferLog string - TunnelStatus string RetryInterval time.Duration DialTimeOut time.Duration ConnPoolSize int @@ -52,7 +49,6 @@ func NewUDPClient(parentCtx context.Context, config *UdpConfig, logger *logrus.L cancel: cancel, logger: logger, controlChannel: nil, // will be set when a control connection is established - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), poolConnections: 0, loadConnections: 0, controlFlow: make(chan struct{}, 100), @@ -62,11 +58,7 @@ func NewUDPClient(parentCtx context.Context, config *UdpConfig, logger *logrus.L } func (c *UdpTransport) Start() { - if c.config.WebPort > 0 { - go c.usageMonitor.Monitor() - } - - c.config.TunnelStatus = "Disconnected (UDP)" + stats.SetDown() go c.channelDialer() } @@ -101,8 +93,6 @@ func (c *UdpTransport) Restart() { // Re-initialize variables c.controlChannel = nil - c.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", c.config.WebPort), ctx, c.config.SnifferLog, c.config.Sniffer, &c.config.TunnelStatus, c.logger) - c.config.TunnelStatus = "" c.poolConnections = 0 c.loadConnections = 0 c.controlFlow = make(chan struct{}, 100) @@ -110,6 +100,8 @@ func (c *UdpTransport) Restart() { // set the log level again c.logger.SetLevel(level) + stats.SetDown() + go c.Start() } @@ -163,7 +155,7 @@ func (c *UdpTransport) channelDialer() { c.controlChannel = tunnelTCPConn c.logger.Info("control channel established successfully") - c.config.TunnelStatus = "Connected (UDP)" + stats.SetUp() go c.poolMaintainer() go c.channelHandler() @@ -456,7 +448,7 @@ func (c *UdpTransport) udpCopy(srcConn, dstConn *net.UDPConn, port int) { // Optionally update the port usage stats if sniffing is enabled if c.config.Sniffer { - c.usageMonitor.AddOrUpdatePort(port, uint64(totalWritten)) + stats.RecordPortUsage(port, uint64(totalWritten)) } c.logger.Debugf("forwarded %d bytes from %s to %s", n, srcConn.LocalAddr().String(), dstConn.RemoteAddr().String()) diff --git a/internal/client/transport/ws.go b/internal/client/transport/ws.go index 6864925..596debf 100644 --- a/internal/client/transport/ws.go +++ b/internal/client/transport/ws.go @@ -3,17 +3,16 @@ package transport import ( "bytes" "context" - "fmt" "strings" "sync" "sync/atomic" "time" "github.com/musix/backhaul/config" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" "github.com/musix/backhaul/internal/utils/handlers" "github.com/musix/backhaul/internal/utils/network" - "github.com/musix/backhaul/internal/web" "github.com/gorilla/websocket" "github.com/sirupsen/logrus" @@ -27,7 +26,6 @@ type WsTransport struct { logger *logrus.Logger controlChannel *websocket.Conn restartMutex sync.Mutex - usageMonitor *web.Usage poolConnections int32 loadConnections int32 controlFlow chan struct{} @@ -36,7 +34,6 @@ type WsConfig struct { RemoteAddr string Token string SnifferLog string - TunnelStatus string Nodelay bool Sniffer bool KeepAlive time.Duration @@ -61,7 +58,6 @@ func NewWSClient(parentCtx context.Context, config *WsConfig, logger *logrus.Log cancel: cancel, logger: logger, controlChannel: nil, // will be set when a control connection is established - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), poolConnections: 0, loadConnections: 0, controlFlow: make(chan struct{}, 100), @@ -71,12 +67,7 @@ func NewWSClient(parentCtx context.Context, config *WsConfig, logger *logrus.Log } func (c *WsTransport) Start() { - // for webui - if c.config.WebPort > 0 { - go c.usageMonitor.Monitor() - } - - c.config.TunnelStatus = fmt.Sprintf("Disconnected (%s)", c.config.Mode) + stats.SetDown() go c.channelDialer() @@ -111,8 +102,6 @@ func (c *WsTransport) Restart() { // Re-initialize variables c.controlChannel = nil - c.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", c.config.WebPort), ctx, c.config.SnifferLog, c.config.Sniffer, &c.config.TunnelStatus, c.logger) - c.config.TunnelStatus = "" c.poolConnections = 0 c.loadConnections = 0 c.controlFlow = make(chan struct{}, 100) @@ -120,6 +109,8 @@ func (c *WsTransport) Restart() { // set the log level again c.logger.SetLevel(level) + stats.SetDown() + go c.Start() } @@ -140,7 +131,7 @@ func (c *WsTransport) channelDialer() { c.controlChannel = tunnelWSConn c.logger.Info("control channel established successfully") - c.config.TunnelStatus = fmt.Sprintf("Connected (%s)", c.config.Mode) + stats.SetUp() go c.poolMaintainer() go c.channelHandler() @@ -359,5 +350,5 @@ func (c *WsTransport) localDialer(tunnelCon *websocket.Conn, remoteAddr string, } c.logger.Debugf("connected to local address %s successfully", remoteAddr) - handlers.WSConnectionHandler(c.ctx, tunnelCon, localConnection, c.logger, c.usageMonitor, int(port), c.config.Sniffer) + handlers.WSConnectionHandler(c.ctx, tunnelCon, localConnection, c.logger, int(port)) } diff --git a/internal/client/transport/wsmux.go b/internal/client/transport/wsmux.go index 9633836..36397ff 100644 --- a/internal/client/transport/wsmux.go +++ b/internal/client/transport/wsmux.go @@ -2,17 +2,16 @@ package transport import ( "context" - "fmt" "strings" "sync" "sync/atomic" "time" "github.com/musix/backhaul/config" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" "github.com/musix/backhaul/internal/utils/handlers" "github.com/musix/backhaul/internal/utils/network" - "github.com/musix/backhaul/internal/web" "github.com/xtaci/smux" "github.com/gorilla/websocket" @@ -27,7 +26,6 @@ type WsMuxTransport struct { cancel context.CancelFunc logger *logrus.Logger controlChannel *websocket.Conn - usageMonitor *web.Usage restartMutex sync.Mutex poolConnections int32 loadConnections int32 @@ -37,7 +35,6 @@ type WsMuxConfig struct { RemoteAddr string Token string SnifferLog string - TunnelStatus string Nodelay bool Sniffer bool KeepAlive time.Duration @@ -74,7 +71,6 @@ func NewWSMuxClient(parentCtx context.Context, config *WsMuxConfig, logger *logr cancel: cancel, logger: logger, controlChannel: nil, // will be set when a control connection is established - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), poolConnections: 0, loadConnections: 0, controlFlow: make(chan struct{}, 100), @@ -84,11 +80,7 @@ func NewWSMuxClient(parentCtx context.Context, config *WsMuxConfig, logger *logr } func (c *WsMuxTransport) Start() { - if c.config.WebPort > 0 { - go c.usageMonitor.Monitor() - } - - c.config.TunnelStatus = fmt.Sprintf("Disconnected (%s)", c.config.Mode) + stats.SetDown() go c.channelDialer() } @@ -123,8 +115,6 @@ func (c *WsMuxTransport) Restart() { // Re-initialize variables c.controlChannel = nil - c.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", c.config.WebPort), ctx, c.config.SnifferLog, c.config.Sniffer, &c.config.TunnelStatus, c.logger) - c.config.TunnelStatus = "" c.poolConnections = 0 c.loadConnections = 0 c.controlFlow = make(chan struct{}, 100) @@ -132,6 +122,8 @@ func (c *WsMuxTransport) Restart() { // set the log level again c.logger.SetLevel(level) + stats.SetDown() + go c.Start() } @@ -153,7 +145,7 @@ func (c *WsMuxTransport) channelDialer() { c.controlChannel = tunnelWSConn c.logger.Info("control channel established successfully") - c.config.TunnelStatus = fmt.Sprintf("Connected (%s)", c.config.Mode) + stats.SetUp() go c.poolMaintainer() go c.channelHandler() @@ -378,5 +370,5 @@ func (c *WsMuxTransport) localDialer(stream *smux.Stream, remoteAddr string) { c.logger.Debugf("connected to local address %s successfully", remoteAddr) - handlers.TCPConnectionHandler(c.ctx, stream, localConnection, c.logger, c.usageMonitor, int(port), c.config.Sniffer) + handlers.TCPConnectionHandler(c.ctx, stream, localConnection, c.logger, int(port)) } diff --git a/internal/server/transport/accept_udp.go b/internal/server/transport/accept_udp.go index 607e985..41a5142 100644 --- a/internal/server/transport/accept_udp.go +++ b/internal/server/transport/accept_udp.go @@ -7,8 +7,8 @@ import ( "sync" "time" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" - "github.com/musix/backhaul/internal/web" "github.com/sirupsen/logrus" ) @@ -146,7 +146,7 @@ func (s *TcpTransport) handleUDPLoop(udpChan chan *LocalAcceptUDPConn, activeCon } // Handle data exchange between connections - go UDPConnectionHandler(localConn, tunnelConn, s.logger, s.usageMonitor, localConn.listener.LocalAddr().(*net.UDPAddr).Port, s.config.Sniffer, s.rtt, activeConnections, mu) + go UDPConnectionHandler(localConn, tunnelConn, s.logger, localConn.listener.LocalAddr().(*net.UDPAddr).Port, s.rtt, activeConnections, mu) s.logger.Debugf("initiate new handler for connection %s with timestamp %d", localConn.clientAddr.String(), localConn.timeCreated) break loop @@ -156,7 +156,7 @@ func (s *TcpTransport) handleUDPLoop(udpChan chan *LocalAcceptUDPConn, activeCon } } -func UDPConnectionHandler(udp *LocalAcceptUDPConn, tcp net.Conn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool, rtt int64, activeConnections *map[string]*LocalAcceptUDPConn, mu *sync.Mutex) { +func UDPConnectionHandler(udp *LocalAcceptUDPConn, tcp net.Conn, logger *logrus.Logger, remotePort int, rtt int64, activeConnections *map[string]*LocalAcceptUDPConn, mu *sync.Mutex) { done := make(chan struct{}) if rtt == 0 { @@ -167,12 +167,12 @@ func UDPConnectionHandler(udp *LocalAcceptUDPConn, tcp net.Conn, logger *logrus. } go func() { - udpToTCP(tcp, udp, logger, usage, remotePort, sniffer) + udpToTCP(tcp, udp, logger, remotePort) tcp.Close() done <- struct{}{} }() - tcpToUDP(tcp, udp, logger, usage, remotePort, sniffer, rtt) + tcpToUDP(tcp, udp, logger, remotePort, rtt) tcp.Close() <-done @@ -186,7 +186,7 @@ func UDPConnectionHandler(udp *LocalAcceptUDPConn, tcp net.Conn, logger *logrus. mu.Unlock() } -func udpToTCP(tcp net.Conn, udp *LocalAcceptUDPConn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { +func udpToTCP(tcp net.Conn, udp *LocalAcceptUDPConn, logger *logrus.Logger, remotePort int) { // Create a header (2 bytes) to hold the size of the data header := make([]byte, 2) @@ -224,9 +224,7 @@ func udpToTCP(tcp net.Conn, udp *LocalAcceptUDPConn, logger *logrus.Logger, usag logger.Tracef("received %d bytes, forwarded %d bytes from UDP to TCP", packetSize, totalWritten-2) - if sniffer { - usage.AddOrUpdatePort(remotePort, uint64(totalWritten)) - } + stats.RecordPortUsage(remotePort, uint64(totalWritten)) case <-time.After(inactivityTimeout): // Timeout after 30 seconds of inactivity logger.Debugf("connection with timestamp %d and address %s idle for 60 seconds, closing", udp.timeCreated, udp.clientAddr.String()) @@ -235,7 +233,7 @@ func udpToTCP(tcp net.Conn, udp *LocalAcceptUDPConn, logger *logrus.Logger, usag } } -func tcpToUDP(tcp net.Conn, udp *LocalAcceptUDPConn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool, rtt int64) { +func tcpToUDP(tcp net.Conn, udp *LocalAcceptUDPConn, logger *logrus.Logger, remotePort int, rtt int64) { buf := make([]byte, BufferSize) lenBuf := make([]byte, 2) // Buffer to store the 2-byte packet length timestampBuf := make([]byte, 4) // Buffer for timestamp (4 bytes) @@ -310,9 +308,7 @@ func tcpToUDP(tcp net.Conn, udp *LocalAcceptUDPConn, logger *logrus.Logger, usag totalWritten += w } - if sniffer { - usage.AddOrUpdatePort(remotePort, uint64(totalWritten)) - } + stats.RecordPortUsage(remotePort, uint64(totalWritten)) logger.Tracef("read %d bytes from TCP, forwarded %d bytes to UDP", packetSize, totalWritten) } diff --git a/internal/server/transport/tcp.go b/internal/server/transport/tcp.go index 8c0862c..921e13e 100644 --- a/internal/server/transport/tcp.go +++ b/internal/server/transport/tcp.go @@ -10,10 +10,10 @@ import ( "sync" "time" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" "github.com/musix/backhaul/internal/utils/handlers" "github.com/musix/backhaul/internal/utils/network" - "github.com/musix/backhaul/internal/web" "github.com/sirupsen/logrus" ) @@ -29,26 +29,24 @@ type TcpTransport struct { reqNewConnChan chan struct{} controlChannel net.Conn restartMutex sync.Mutex - usageMonitor *web.Usage rtt int64 // in ms, for UDP } type TcpConfig struct { - BindAddr string - Token string - SnifferLog string - TunnelStatus string - Ports []string - Nodelay bool - Sniffer bool - KeepAlive time.Duration - Heartbeat time.Duration // in seconds - ChannelSize int - WebPort int - AcceptUDP bool - MSS int - SO_RCVBUF int - SO_SNDBUF int + BindAddr string + Token string + SnifferLog string + Ports []string + Nodelay bool + Sniffer bool + KeepAlive time.Duration + Heartbeat time.Duration // in seconds + ChannelSize int + WebPort int + AcceptUDP bool + MSS int + SO_RCVBUF int + SO_SNDBUF int } func NewTCPServer(parentCtx context.Context, config *TcpConfig, logger *logrus.Logger) *TcpTransport { @@ -66,7 +64,6 @@ func NewTCPServer(parentCtx context.Context, config *TcpConfig, logger *logrus.L localChannel: make(chan LocalTCPConn, config.ChannelSize), reqNewConnChan: make(chan struct{}, config.ChannelSize), controlChannel: nil, // will be set when a control connection is established - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), rtt: 0, } @@ -74,18 +71,14 @@ func NewTCPServer(parentCtx context.Context, config *TcpConfig, logger *logrus.L } func (s *TcpTransport) Start() { - s.config.TunnelStatus = "Disconnected (TCP)" - - if s.config.WebPort > 0 { - go s.usageMonitor.Monitor() - } + stats.SetDown() go s.tunnelListener() s.channelHandshake() if s.controlChannel != nil { - s.config.TunnelStatus = "Connected (TCP)" + stats.SetUp() numCPU := runtime.NumCPU() if numCPU > 4 { @@ -134,13 +127,13 @@ func (s *TcpTransport) Restart() { s.tunnelChannel = make(chan net.Conn, s.config.ChannelSize) s.localChannel = make(chan LocalTCPConn, s.config.ChannelSize) s.reqNewConnChan = make(chan struct{}, s.config.ChannelSize) - s.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", s.config.WebPort), ctx, s.config.SnifferLog, s.config.Sniffer, &s.config.TunnelStatus, s.logger) - s.config.TunnelStatus = "" s.controlChannel = nil // set the log level again s.logger.SetLevel(level) + stats.SetDown() + go s.Start() } @@ -553,7 +546,7 @@ func (s *TcpTransport) handleLoop() { } // Handle data exchange between connections - go handlers.TCPConnectionHandler(s.ctx, localConn.conn, tunnelConn, s.logger, s.usageMonitor, localConn.conn.LocalAddr().(*net.TCPAddr).Port, s.config.Sniffer) + go handlers.TCPConnectionHandler(s.ctx, localConn.conn, tunnelConn, s.logger, localConn.conn.LocalAddr().(*net.TCPAddr).Port) break loop } diff --git a/internal/server/transport/tcpmux.go b/internal/server/transport/tcpmux.go index fd3abcd..8d453e0 100644 --- a/internal/server/transport/tcpmux.go +++ b/internal/server/transport/tcpmux.go @@ -11,10 +11,10 @@ import ( "sync/atomic" "time" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" "github.com/musix/backhaul/internal/utils/handlers" "github.com/musix/backhaul/internal/utils/network" - "github.com/musix/backhaul/internal/web" "github.com/sirupsen/logrus" "github.com/xtaci/smux" @@ -32,7 +32,6 @@ type TcpMuxTransport struct { localChannel chan LocalTCPConn reqNewConnChan chan struct{} controlChannel net.Conn - usageMonitor *web.Usage restartMutex sync.Mutex streamCounter int32 sessionCounter int32 @@ -40,7 +39,6 @@ type TcpMuxTransport struct { type TcpMuxConfig struct { BindAddr string - TunnelStatus string SnifferLog string Token string Ports []string @@ -86,24 +84,20 @@ func NewTcpMuxServer(parentCtx context.Context, config *TcpMuxConfig, logger *lo controlChannel: nil, // will be set when a control connection is established streamCounter: 0, sessionCounter: 0, - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), } return server } func (s *TcpMuxTransport) Start() { - if s.config.WebPort > 0 { - go s.usageMonitor.Monitor() - } - s.config.TunnelStatus = "Disconnected (TCPMux)" + stats.SetDown() go s.tunnelListener() s.channelHandshake() if s.controlChannel != nil { - s.config.TunnelStatus = "Connected (TCPMux)" + stats.SetUp() numCPU := runtime.NumCPU() if numCPU > 4 { @@ -155,14 +149,14 @@ func (s *TcpMuxTransport) Restart() { s.reqNewConnChan = make(chan struct{}, s.config.ChannelSize) s.handshakeChannel = make(chan net.Conn) s.controlChannel = nil - s.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", s.config.WebPort), ctx, s.config.SnifferLog, s.config.Sniffer, &s.config.TunnelStatus, s.logger) - s.config.TunnelStatus = "" s.streamCounter = 0 s.sessionCounter = 0 // set the log level again s.logger.SetLevel(level) + stats.SetDown() + go s.Start() } @@ -605,7 +599,7 @@ func (s *TcpMuxTransport) handleSession(session *smux.Session) { // Handle data exchange between connections go func() { - handlers.TCPConnectionHandler(s.ctx, stream, incomingConn.conn, s.logger, s.usageMonitor, incomingConn.conn.LocalAddr().(*net.TCPAddr).Port, s.config.Sniffer) + handlers.TCPConnectionHandler(s.ctx, stream, incomingConn.conn, s.logger, incomingConn.conn.LocalAddr().(*net.TCPAddr).Port) atomic.AddInt32(&s.streamCounter, -1) <-counter // read signal from the channel }() diff --git a/internal/server/transport/udp.go b/internal/server/transport/udp.go index f938696..020edcd 100644 --- a/internal/server/transport/udp.go +++ b/internal/server/transport/udp.go @@ -9,8 +9,8 @@ import ( "sync" "time" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" - "github.com/musix/backhaul/internal/web" "github.com/sirupsen/logrus" ) @@ -26,20 +26,18 @@ type UdpTransport struct { reqNewConnChan chan struct{} controlChannel net.Conn restartMutex sync.Mutex - usageMonitor *web.Usage rtt int64 // for Fun! } type UdpConfig struct { - BindAddr string - Token string - SnifferLog string - TunnelStatus string - Ports []string - Sniffer bool - Heartbeat time.Duration // in seconds, for udp conn and control channel - ChannelSize int - WebPort int + BindAddr string + Token string + SnifferLog string + Ports []string + Sniffer bool + Heartbeat time.Duration // in seconds, for udp conn and control channel + ChannelSize int + WebPort int } func NewUDPServer(parentCtx context.Context, config *UdpConfig, logger *logrus.Logger) *UdpTransport { @@ -58,19 +56,13 @@ func NewUDPServer(parentCtx context.Context, config *UdpConfig, logger *logrus.L activeMu: sync.Mutex{}, reqNewConnChan: make(chan struct{}, config.ChannelSize), controlChannel: nil, // will be set when a control connection is established - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), rtt: 0, } return server } func (s *UdpTransport) Start() { - s.config.TunnelStatus = "Disconnected (UDP)" - - if s.config.WebPort > 0 { - go s.usageMonitor.Monitor() - } - + stats.SetDown() go s.channelHandshake() } @@ -105,8 +97,6 @@ func (s *UdpTransport) Restart() { // Re-initialize variables s.tunnelChannel = make(chan *TunnelUDPConn, s.config.ChannelSize) s.reqNewConnChan = make(chan struct{}, s.config.ChannelSize) - s.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", s.config.WebPort), ctx, s.config.SnifferLog, s.config.Sniffer, &s.config.TunnelStatus, s.logger) - s.config.TunnelStatus = "" s.controlChannel = nil s.activeConnections = map[string]*TunnelUDPConn{} s.activeMu = sync.Mutex{} @@ -114,6 +104,8 @@ func (s *UdpTransport) Restart() { // set the log level again s.logger.SetLevel(level) + stats.SetDown() + go s.Start() } @@ -182,6 +174,7 @@ loop: s.controlChannel = conn s.logger.Info("control channel successfully established.") + stats.SetUp() break loop } @@ -653,7 +646,7 @@ func (s *UdpTransport) udpLocalCopy(from *LocalUDPConn, to *TunnelUDPConn) { } if s.config.Sniffer { - s.usageMonitor.AddOrUpdatePort(from.listener.LocalAddr().(*net.UDPAddr).Port, uint64(totalWritten)) + stats.RecordPortUsage(from.listener.LocalAddr().(*net.UDPAddr).Port, uint64(totalWritten)) } s.logger.Debugf("forwarded %d bytes from local connection %s to tunnel", packetSize, from.addr.String()) @@ -689,7 +682,7 @@ func (s *UdpTransport) udpTunnelCopy(from *TunnelUDPConn, to *LocalUDPConn) { } if s.config.Sniffer { - s.usageMonitor.AddOrUpdatePort(to.listener.LocalAddr().(*net.UDPAddr).Port, uint64(totalWritten)) + stats.RecordPortUsage(from.listener.LocalAddr().(*net.UDPAddr).Port, uint64(totalWritten)) } s.logger.Debugf("forwarded %d bytes from local connection %s to tunnel", packetSize, from.addr.String()) diff --git a/internal/server/transport/ws.go b/internal/server/transport/ws.go index 84953dc..cded8e7 100644 --- a/internal/server/transport/ws.go +++ b/internal/server/transport/ws.go @@ -12,9 +12,9 @@ import ( "time" "github.com/musix/backhaul/config" + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" "github.com/musix/backhaul/internal/utils/handlers" - "github.com/musix/backhaul/internal/web" "github.com/gorilla/websocket" "github.com/sirupsen/logrus" @@ -31,24 +31,22 @@ type WsTransport struct { reqNewConnChan chan struct{} controlChannel *websocket.Conn restartMutex sync.Mutex - usageMonitor *web.Usage } type WsConfig struct { - BindAddr string - SnifferLog string - TLSCertFile string // Path to the TLS certificate file - TLSKeyFile string // Path to the TLS key file - TunnelStatus string - Token string - Ports []string - Nodelay bool - Sniffer bool - KeepAlive time.Duration - Heartbeat time.Duration // in seconds - ChannelSize int - WebPort int - Mode config.TransportType // ws or wss + BindAddr string + SnifferLog string + TLSCertFile string // Path to the TLS certificate file + TLSKeyFile string // Path to the TLS key file + Token string + Ports []string + Nodelay bool + Sniffer bool + KeepAlive time.Duration + Heartbeat time.Duration // in seconds + ChannelSize int + WebPort int + Mode config.TransportType // ws or wss } @@ -67,19 +65,13 @@ func NewWSServer(parentCtx context.Context, config *WsConfig, logger *logrus.Log localChannel: make(chan LocalTCPConn, config.ChannelSize), reqNewConnChan: make(chan struct{}, config.ChannelSize), controlChannel: nil, // will be set when a control connection is established - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), } return server } func (s *WsTransport) Start() { - // for webui - if s.config.WebPort > 0 { - go s.usageMonitor.Monitor() - } - - s.config.TunnelStatus = fmt.Sprintf("Disconnected (%s)", s.config.Mode) + stats.SetDown() go s.tunnelListener() @@ -116,12 +108,12 @@ func (s *WsTransport) Restart() { s.localChannel = make(chan LocalTCPConn, s.config.ChannelSize) s.reqNewConnChan = make(chan struct{}, s.config.ChannelSize) s.controlChannel = nil - s.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", s.config.WebPort), ctx, s.config.SnifferLog, s.config.Sniffer, &s.config.TunnelStatus, s.logger) - s.config.TunnelStatus = "" // set the log level again s.logger.SetLevel(level) + stats.SetDown() + go s.Start() } @@ -258,8 +250,7 @@ func (s *WsTransport) tunnelListener() { go s.handleLoop() } - s.config.TunnelStatus = fmt.Sprintf("Connected (%s)", s.config.Mode) - + stats.SetUp() } else if strings.HasPrefix(r.URL.Path, "/tunnel") { wsConn := TunnelChannel{ conn: conn, @@ -510,7 +501,7 @@ func (s *WsTransport) handleLoop() { continue loop } // Handle data exchange between connections - go handlers.WSConnectionHandler(s.ctx, tunnelConnection.conn, localConn.conn, s.logger, s.usageMonitor, localConn.conn.LocalAddr().(*net.TCPAddr).Port, s.config.Sniffer) + go handlers.WSConnectionHandler(s.ctx, tunnelConnection.conn, localConn.conn, s.logger, localConn.conn.LocalAddr().(*net.TCPAddr).Port) break loop } } diff --git a/internal/server/transport/wsmux.go b/internal/server/transport/wsmux.go index d77e8e9..f799f40 100644 --- a/internal/server/transport/wsmux.go +++ b/internal/server/transport/wsmux.go @@ -13,9 +13,9 @@ import ( "time" "github.com/musix/backhaul/config" // for mode + "github.com/musix/backhaul/internal/stats" "github.com/musix/backhaul/internal/utils" "github.com/musix/backhaul/internal/utils/handlers" - "github.com/musix/backhaul/internal/web" "github.com/xtaci/smux" "github.com/gorilla/websocket" @@ -33,7 +33,6 @@ type WsMuxTransport struct { localChannel chan LocalTCPConn reqNewConnChan chan struct{} controlChannel *websocket.Conn - usageMonitor *web.Usage restartMutex sync.Mutex streamCounter int32 sessionCounter int32 @@ -45,7 +44,6 @@ type WsMuxConfig struct { SnifferLog string TLSCertFile string // Path to the TLS certificate file TLSKeyFile string // Path to the TLS key file - TunnelStatus string Ports []string Nodelay bool Sniffer bool @@ -87,19 +85,13 @@ func NewWSMuxServer(parentCtx context.Context, config *WsMuxConfig, logger *logr streamCounter: 0, sessionCounter: 0, controlChannel: nil, // will be set when a control connection is established - usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), } return server } func (s *WsMuxTransport) Start() { - // for webui - if s.config.WebPort > 0 { - go s.usageMonitor.Monitor() - } - - s.config.TunnelStatus = fmt.Sprintf("Disconnected (%s)", s.config.Mode) + stats.SetDown() go s.tunnelListener() @@ -138,14 +130,14 @@ func (s *WsMuxTransport) Restart() { s.localChannel = make(chan LocalTCPConn, s.config.ChannelSize) s.reqNewConnChan = make(chan struct{}, s.config.ChannelSize) s.controlChannel = nil - s.usageMonitor = web.NewDataStore(fmt.Sprintf(":%v", s.config.WebPort), ctx, s.config.SnifferLog, s.config.Sniffer, &s.config.TunnelStatus, s.logger) - s.config.TunnelStatus = "" s.streamCounter = 0 s.sessionCounter = 0 // set the log level again s.logger.SetLevel(level) + stats.SetDown() + go s.Start() } @@ -283,8 +275,7 @@ func (s *WsMuxTransport) tunnelListener() { go s.handleLoop() } - s.config.TunnelStatus = fmt.Sprintf("Connected (%s)", s.config.Mode) - + stats.SetUp() } else if strings.HasPrefix(r.URL.Path, "/tunnel") { session, err := smux.Client(conn.NetConn(), s.smuxConfig) if err != nil { @@ -567,7 +558,7 @@ func (s *WsMuxTransport) handleSession(session *smux.Session) { // Handle data exchange between connections go func() { - handlers.TCPConnectionHandler(s.ctx, stream, incomingConn.conn, s.logger, s.usageMonitor, incomingConn.conn.LocalAddr().(*net.TCPAddr).Port, s.config.Sniffer) + handlers.TCPConnectionHandler(s.ctx, stream, incomingConn.conn, s.logger, incomingConn.conn.LocalAddr().(*net.TCPAddr).Port) atomic.AddInt32(&s.streamCounter, -1) <-counter // read signal from the channel }() diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..c28028e --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,208 @@ +package stats + +import ( + "context" + "encoding/json" + "os" + "sync" + "time" + + "github.com/musix/backhaul/config" + "github.com/sirupsen/logrus" +) + +type TunnelSide string + +var ( + ServerSide TunnelSide = "server" + ClientSide TunnelSide = "client" +) + +type statsStorage struct { + ctx context.Context + + side TunnelSide + transport config.TransportType + + location string + logger *logrus.Logger + + stats *stats + up bool +} + +type stats struct { + mu sync.Mutex + PortUsages PortUsages `json:"port_usages"` + TotalUsage uint64 `json:"total_usage"` +} + +type PortUsages map[int]uint64 + +var instance *statsStorage + +func InitClientStats(ctx context.Context, logger *logrus.Logger, cfg config.ClientConfig) { + instance = &statsStorage{ + ctx: ctx, + side: ClientSide, + transport: cfg.Transport, + location: cfg.SnifferLog, + logger: logger, + stats: nil, + } + + if cfg.Sniffer { + instance.init() + } +} + +func InitServerStats(ctx context.Context, logger *logrus.Logger, cfg config.ServerConfig) { + instance = &statsStorage{ + ctx: ctx, + side: ServerSide, + transport: cfg.Transport, + location: cfg.SnifferLog, + logger: logger, + stats: nil, + } + + if cfg.Sniffer { + instance.init() + } +} + +func (s *statsStorage) init() { + s.load() + + go func() { + ticker := time.NewTicker(15 * time.Second) // every 15 seconds + defer ticker.Stop() + + for { + select { + case <-ticker.C: + go s.save() + case <-s.ctx.Done(): + return + } + } + }() +} + +func (s *statsStorage) save() { + if s.stats == nil { + return + } + + s.stats.mu.Lock() + defer s.stats.mu.Unlock() + + data, err := json.MarshalIndent(s.stats, "", " ") + if err != nil { + s.logger.Errorf("Failed to marshal stats: %v", err) + return + } + + if err := os.WriteFile(s.location, data, 0644); err != nil { + s.logger.Errorf("Failed to save stats to file: %v", err) + } +} + +func (s *statsStorage) load() { + data, err := os.ReadFile(s.location) + if err != nil { + if !os.IsNotExist(err) { + s.logger.Errorf("Failed to read stats file: %v", err) + } + + s.stats = &stats{ + PortUsages: make(PortUsages), + TotalUsage: 0, + } + return + } + + var loadedStats stats + if err := json.Unmarshal(data, &loadedStats); err != nil { + s.logger.Errorf("Failed to unmarshal stats: %v", err) + s.stats = &stats{ + PortUsages: make(PortUsages), + TotalUsage: 0, + } + return + } + + s.stats = &loadedStats + if s.stats.PortUsages == nil { + s.stats.PortUsages = make(PortUsages) + } +} + +func RecordPortUsage(port int, usage uint64) { + if instance == nil { + panic("attempt to record usage before initiating stat storage") + } + + if instance.stats == nil { + return + } + + instance.stats.mu.Lock() + defer instance.stats.mu.Unlock() + + existing, exists := instance.stats.PortUsages[port] + if exists { + usage = usage + existing + } + + instance.stats.PortUsages[port] = usage + instance.stats.TotalUsage += usage +} + +func GetPortUsages() PortUsages { + if instance == nil { + return map[int]uint64{} + } + + if instance.stats == nil { + return map[int]uint64{} + } + + return instance.stats.PortUsages +} + +func GetTotalUsage() uint64 { + if instance == nil { + return 0 + } + + if instance.stats == nil { + return 0 + } + + return instance.stats.TotalUsage +} + +func SetDown() { + if instance == nil { + return + } + + instance.up = false +} + +func SetUp() { + if instance == nil { + return + } + + instance.up = true +} + +func IsUp() bool { + if instance == nil { + return false + } + + return instance.up +} diff --git a/internal/utils/handlers/tcp_handler.go b/internal/utils/handlers/tcp_handler.go index 9414af5..3dbfb0a 100644 --- a/internal/utils/handlers/tcp_handler.go +++ b/internal/utils/handlers/tcp_handler.go @@ -6,19 +6,19 @@ import ( "io" "net" - "github.com/musix/backhaul/internal/web" + "github.com/musix/backhaul/internal/stats" "github.com/sirupsen/logrus" ) -func TCPConnectionHandler(ctx context.Context, from net.Conn, to net.Conn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { +func TCPConnectionHandler(ctx context.Context, from net.Conn, to net.Conn, logger *logrus.Logger, remotePort int) { done := make(chan struct{}) go func() { defer close(done) - transferData(from, to, logger, usage, remotePort, sniffer) + transferData(from, to, logger, remotePort) }() - transferData(to, from, logger, usage, remotePort, sniffer) + transferData(to, from, logger, remotePort) select { case <-ctx.Done(): @@ -30,7 +30,7 @@ func TCPConnectionHandler(ctx context.Context, from net.Conn, to net.Conn, logge } // Using direct Read and Write for transferring data -func transferData(from net.Conn, to net.Conn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { +func transferData(from net.Conn, to net.Conn, logger *logrus.Logger, remotePort int) { buf := make([]byte, 16*1024) // 16K for { // Read data from the source connection @@ -65,9 +65,6 @@ func transferData(from net.Conn, to net.Conn, logger *logrus.Logger, usage *web. } logger.Tracef("read data: %d bytes, written data: %d bytes", r, totalWritten) - if sniffer { - usage.AddOrUpdatePort(remotePort, uint64(totalWritten)) - } + stats.RecordPortUsage(remotePort, uint64(totalWritten)) } - } diff --git a/internal/utils/handlers/ws_handler.go b/internal/utils/handlers/ws_handler.go index e4e9e08..8b6158e 100644 --- a/internal/utils/handlers/ws_handler.go +++ b/internal/utils/handlers/ws_handler.go @@ -7,20 +7,20 @@ import ( "net" "github.com/gorilla/websocket" - "github.com/musix/backhaul/internal/web" + "github.com/musix/backhaul/internal/stats" "github.com/sirupsen/logrus" ) // WebSocketToTCPConnectionHandler handles data transfer between a WebSocket and a TCP connection -func WSConnectionHandler(ctx context.Context, wsConn *websocket.Conn, tcpConn net.Conn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { +func WSConnectionHandler(ctx context.Context, wsConn *websocket.Conn, tcpConn net.Conn, logger *logrus.Logger, remotePort int) { done := make(chan struct{}) go func() { defer close(done) - transferWebSocketToTCP(wsConn, tcpConn, logger, usage, remotePort, sniffer) + transferWebSocketToTCP(wsConn, tcpConn, logger, remotePort) }() - transferTCPToWebSocket(tcpConn, wsConn, logger, usage, remotePort, sniffer) + transferTCPToWebSocket(tcpConn, wsConn, logger, remotePort) select { case <-ctx.Done(): @@ -32,7 +32,7 @@ func WSConnectionHandler(ctx context.Context, wsConn *websocket.Conn, tcpConn ne } // transferWebSocketToTCP transfers data from a WebSocket connection to a TCP connection -func transferWebSocketToTCP(wsConn *websocket.Conn, tcpConn net.Conn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { +func transferWebSocketToTCP(wsConn *websocket.Conn, tcpConn net.Conn, logger *logrus.Logger, remotePort int) { for { // Read message from the WebSocket connection messageType, message, err := wsConn.ReadMessage() @@ -58,15 +58,13 @@ func transferWebSocketToTCP(wsConn *websocket.Conn, tcpConn net.Conn, logger *lo return } logger.Tracef("transferred data from WebSocket to TCP: %d bytes", w) - if sniffer { - usage.AddOrUpdatePort(remotePort, uint64(w)) - } + stats.RecordPortUsage(remotePort, uint64(w)) } } } // transferTCPToWebSocket transfers data from a TCP connection to a WebSocket connection -func transferTCPToWebSocket(tcpConn net.Conn, wsConn *websocket.Conn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { +func transferTCPToWebSocket(tcpConn net.Conn, wsConn *websocket.Conn, logger *logrus.Logger, remotePort int) { buf := make([]byte, 16*1024) // 16K buffer size for { // Read data from the TCP connection @@ -96,8 +94,6 @@ func transferTCPToWebSocket(tcpConn net.Conn, wsConn *websocket.Conn, logger *lo } logger.Tracef("transferred data from TCP to WebSocket: %d bytes", n) - if sniffer { - usage.AddOrUpdatePort(remotePort, uint64(n)) - } + stats.RecordPortUsage(remotePort, uint64(n)) } } diff --git a/internal/web/index.html b/internal/web/metrics/index.html similarity index 100% rename from internal/web/index.html rename to internal/web/metrics/index.html diff --git a/internal/web/metrics/legacy.go b/internal/web/metrics/legacy.go new file mode 100644 index 0000000..1db7435 --- /dev/null +++ b/internal/web/metrics/legacy.go @@ -0,0 +1,267 @@ +package metrics + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "html/template" + "net/http" + "time" + + "github.com/musix/backhaul/config" + "github.com/musix/backhaul/internal/stats" + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/mem" + "github.com/shirou/gopsutil/v4/net" + "github.com/sirupsen/logrus" +) + +type SystemStats struct { + TunnelStatus string `json:"tunnelStatus"` + CPUUsage string `json:"cpuUsage"` + RAMUsage string `json:"ramUsage"` + DiskUsage string `json:"diskUsage"` + SwapUsage string `json:"swapUsage"` + NetworkTraffic string `json:"networkTraffic"` + UploadSpeed string `json:"uploadSpeed"` + DownloadSpeed string `json:"downloadSpeed"` + BackhaulTraffic string `json:"backhaulTraffic"` + Sniffer string `json:"sniffer"` + AllConnections string `json:"allConnections"` +} + +func init() { + RegisterCollector("default", func(ctx context.Context, log *logrus.Logger, cfg config.Config) Collector { + var transport config.TransportType + var sniffer bool + if cfg.IsServerConfig() { + transport = cfg.Server.Transport + sniffer = cfg.Server.Sniffer + } else { + transport = cfg.Client.Transport + sniffer = cfg.Client.Sniffer + } + + return &LegacyCollector{ + ctx: ctx, + logger: log, + transport: transport, + portUsageEnabled: sniffer, + } + }) +} + +// LegacyCollector implements Handler +type LegacyCollector struct { + ctx context.Context + logger *logrus.Logger + transport config.TransportType + status *string + portUsageEnabled bool +} + +func (c *LegacyCollector) Bind(srv *http.ServeMux) { + srv.HandleFunc("/", c.handleIndex) // handle index + srv.HandleFunc("/stats", c.statsHandler) + if c.portUsageEnabled { + srv.HandleFunc("/data", c.handleData) // New route for JSON data + } +} + +//go:embed index.html +var indexHTML embed.FS + +func (c *LegacyCollector) handleIndex(w http.ResponseWriter, r *http.Request) { + usageData := stats.GetPortUsages() + readableData := c.usageDataWithReadableUsage(usageData) + + tmpl, err := template.ParseFS(indexHTML, "index.html") + if err != nil { + c.logger.Errorf("error parsing template: %v", err) + return + } + + err = tmpl.Execute(w, readableData) + if err != nil { + c.logger.Errorf("error executing template: %v", err) + } +} + +func (c *LegacyCollector) handleData(w http.ResponseWriter, r *http.Request) { + usageData := stats.GetPortUsages() + readableData := c.usageDataWithReadableUsage(usageData) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(readableData); err != nil { + c.logger.Errorf("error encoding JSON response: %v", err) + } +} + +func (c *LegacyCollector) statsHandler(w http.ResponseWriter, r *http.Request) { + systemStats, err := c.getSystemStats() + if err != nil { + c.logger.Error("Error fetching system stats:", err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(systemStats); err != nil { + c.logger.Error("Error encoding JSON:", err) + } +} + +// converts the byte usage to a human-readable format +func (c *LegacyCollector) usageDataWithReadableUsage(usageData stats.PortUsages) []struct { + Port int + ReadableUsage string +} { + var result []struct { + Port int + ReadableUsage string + } + + for port, portUsage := range usageData { + result = append(result, struct { + Port int + ReadableUsage string + }{ + Port: port, + ReadableUsage: c.convertBytesToReadable(portUsage), + }) + } + + return result +} + +// ConvertBytesToReadable converts bytes into a human-readable format (KB, MB, GB) +func (c *LegacyCollector) convertBytesToReadable(bytes uint64) string { + const ( + KB = 1 << (10 * 1) // 1024 bytes + MB = 1 << (10 * 2) // 1024 KB + GB = 1 << (10 * 3) // 1024 MB + TB = 1 << (10 * 4) // 1024 TB + ) + + switch { + case bytes >= TB: + return fmt.Sprintf("%.2f TB", float64(bytes)/float64(TB)) + case bytes >= GB: + return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) // Bytes + } +} + +func (c *LegacyCollector) getSystemStats() (*SystemStats, error) { + + // Get initial network stats + initialStats, err := c.getNetworkStats() + if err != nil { + return nil, err + } + + // Wait for 1 second + time.Sleep(1 * time.Second) + + // Get updated network stats + finalStats, err := c.getNetworkStats() + if err != nil { + return nil, err + } + + // Get CPU usage + cpuPercent, err := cpu.Percent(0, false) + if err != nil { + return nil, err + } + + // Get RAM usage + memStats, err := mem.VirtualMemory() + if err != nil { + return nil, err + } + + // Get Disk usage + diskStats, err := disk.Usage("/") + if err != nil { + return nil, err + } + + // Get Swap usage + swapStats, err := mem.SwapMemory() + if err != nil { + return nil, err + } + + // Get Network traffic + netStats, err := net.IOCounters(false) + if err != nil { + return nil, err + } + + // Get all active network connections (TCP, UDP, etc.) + connections, err := net.Connections("all") + if err != nil { + return nil, err + } + + // Calculate upload and download speeds + uploadSpeed := float64(finalStats.BytesSent - initialStats.BytesSent) + downloadSpeed := float64(finalStats.BytesRecv - initialStats.BytesRecv) + + var tunnelStatus string + if stats.IsUp() { + tunnelStatus = fmt.Sprintf("Connected (%s)", c.transport) + } else { + + tunnelStatus = fmt.Sprintf("Disconnected (%s)", c.transport) + } + + systemStats := &SystemStats{ + TunnelStatus: tunnelStatus, + CPUUsage: c.formatFloat(cpuPercent[0]), + RAMUsage: c.convertBytesToReadable(memStats.Used), + DiskUsage: c.convertBytesToReadable(diskStats.Used), + SwapUsage: c.convertBytesToReadable(swapStats.Used), + NetworkTraffic: c.convertBytesToReadable(netStats[0].BytesSent + netStats[0].BytesRecv), + DownloadSpeed: c.formatSpeed(downloadSpeed), + UploadSpeed: c.formatSpeed(uploadSpeed), + BackhaulTraffic: c.convertBytesToReadable(stats.GetTotalUsage()), + Sniffer: map[bool]string{true: "Running", false: "Not running"}[c.portUsageEnabled], + AllConnections: fmt.Sprintf("%d", len(connections)), + } + + return systemStats, nil +} + +func (c *LegacyCollector) formatSpeed(bytesPerSec float64) string { + if bytesPerSec >= 1e9 { + return fmt.Sprintf("%.2f GB/s", bytesPerSec/1e9) + } else if bytesPerSec >= 1e6 { + return fmt.Sprintf("%.2f MB/s", bytesPerSec/1e6) + } else if bytesPerSec >= 1e3 { + return fmt.Sprintf("%.2f KB/s", bytesPerSec/1e3) + } + return fmt.Sprintf("%.2f B/s", bytesPerSec) +} + +func (c *LegacyCollector) formatFloat(value float64) string { + return fmt.Sprintf("%.2f%%", value) +} + +func (c *LegacyCollector) getNetworkStats() (*net.IOCountersStat, error) { + ioCounters, err := net.IOCounters(false) + if err != nil { + return nil, err + } + if len(ioCounters) == 0 { + return nil, fmt.Errorf("no network IO counters found") + } + return &ioCounters[0], nil +} diff --git a/internal/web/metrics/metrics.go b/internal/web/metrics/metrics.go new file mode 100644 index 0000000..ec0cc32 --- /dev/null +++ b/internal/web/metrics/metrics.go @@ -0,0 +1,109 @@ +package metrics + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/musix/backhaul/config" + "github.com/musix/backhaul/internal/stats" + "github.com/sirupsen/logrus" +) + +type CollectorFactory func(ctx context.Context, log *logrus.Logger, cfg config.Config) Collector +type Collector interface { + Bind(mux *http.ServeMux) +} + +type Handler struct { + ctx context.Context + srv *http.Server + log *logrus.Logger + + cfg config.Config + + collectors map[string]Collector +} + +var factories = make(map[string]CollectorFactory) + +func NewMetricsHandler(ctx context.Context, log *logrus.Logger, cfg config.Config) *Handler { + var includedCollectors []string + if cfg.IsServerConfig() { + stats.InitServerStats(ctx, log, cfg.Server) + includedCollectors = cfg.Server.MetricCollectors + } else { + stats.InitClientStats(ctx, log, cfg.Client) + includedCollectors = cfg.Client.MetricCollectors + } + + collectors := map[string]Collector{} + + for _, name := range includedCollectors { + factory, ok := factories[name] + if !ok { + log.Errorf("unknown metrics handler: %s", name) + continue + } + + collectors[name] = factory(ctx, log, cfg) + } + + return &Handler{ + ctx: ctx, + log: log, + cfg: cfg, + collectors: collectors, + } +} + +func (m *Handler) Monitor() { + var port int + if m.cfg.IsServerConfig() { + port = m.cfg.Server.WebPort + } else { + port = m.cfg.Client.WebPort + } + + if port <= 0 { + return + } + + srv := http.NewServeMux() + m.bindCollectors(srv) + + bindAddr := fmt.Sprintf(":%d", port) + m.srv = &http.Server{ + Addr: bindAddr, + Handler: srv, + } + + go func() { + <-m.ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := m.srv.Shutdown(shutdownCtx); err != nil { + m.log.Errorf("sniffer server shutdown error: %v", err) + } + }() + + // Start the server + m.log.Info("collector service listening on port: ", bindAddr) + if err := m.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + m.log.Errorf("collector server error: %v", err) + } +} + +func (m *Handler) bindCollectors(srv *http.ServeMux) { + for _, collectorImpl := range m.collectors { + collectorImpl.Bind(srv) + } +} + +func RegisterCollector(name string, factory CollectorFactory) { + factories[name] = factory +} diff --git a/internal/web/metrics/prometheus.go b/internal/web/metrics/prometheus.go new file mode 100644 index 0000000..2fd6d20 --- /dev/null +++ b/internal/web/metrics/prometheus.go @@ -0,0 +1,111 @@ +package metrics + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/musix/backhaul/config" + "github.com/musix/backhaul/internal/stats" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/sirupsen/logrus" +) + +// PrometheusCollector implements Collector +type PrometheusCollector struct { + ctx context.Context + transport config.TransportType + labels prometheus.Labels + + reg *prometheus.Registry +} + +func init() { + RegisterCollector("prometheus", NewPrometheusCollector) +} + +func NewPrometheusCollector(ctx context.Context, log *logrus.Logger, cfg config.Config) Collector { + var transport config.TransportType + var labels prometheus.Labels + if cfg.IsServerConfig() { + transport = cfg.Server.Transport + labels = prometheus.Labels{ + "transport": fmt.Sprintf("%s", transport), + "side": "server", + } + } else { + transport = cfg.Client.Transport + labels = prometheus.Labels{ + "transport": fmt.Sprintf("%s", transport), + "side": "client", + } + } + + instance := &PrometheusCollector{ + ctx: ctx, + transport: transport, + labels: labels, + reg: prometheus.NewRegistry(), + } + + // default collectors + instance.reg.MustRegister(collectors.NewGoCollector()) + instance.reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + + // backhaul tunnel status + instance.customCollectors() + + return instance +} + +func (p *PrometheusCollector) Bind(mux *http.ServeMux) { + mux.Handle("/metrics", promhttp.HandlerFor(p.reg, promhttp.HandlerOpts{EnableOpenMetrics: true})) +} + +func (p *PrometheusCollector) customCollectors() { + statusGauge := promauto.With(p.reg).NewGauge( + prometheus.GaugeOpts{ + Namespace: "backhaul", + Subsystem: "tunnel", + Name: "status", + Help: "is Backhaul tunnel up", + ConstLabels: p.labels, + }, + ) + + usageGauge := promauto.With(p.reg).NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "backhaul", + Subsystem: "tunnel", + Name: "usage", + Help: "backhaul usage per port", + ConstLabels: p.labels, + }, + []string{"port"}, + ) + + go func() { + tick := time.NewTicker(10 * time.Second) + defer tick.Stop() + for { + select { + case <-tick.C: + var status float64 + if stats.IsUp() { + status = 1 + } + statusGauge.Set(status) + + for i, u := range stats.GetPortUsages() { + usageGauge.With(prometheus.Labels{"port": fmt.Sprintf("%d", i)}).Set(float64(u)) + } + case <-p.ctx.Done(): + return + } + } + }() +} diff --git a/internal/web/sniffer.go b/internal/web/sniffer.go deleted file mode 100644 index f5dac22..0000000 --- a/internal/web/sniffer.go +++ /dev/null @@ -1,447 +0,0 @@ -package web - -import ( - "context" - "embed" - "encoding/json" - "fmt" - "html/template" - "net/http" - "os" - "sort" - "sync" - "time" - - "github.com/shirou/gopsutil/v4/cpu" - "github.com/shirou/gopsutil/v4/disk" - "github.com/shirou/gopsutil/v4/mem" - "github.com/shirou/gopsutil/v4/net" - - "github.com/sirupsen/logrus" -) - -type Usage struct { - dataStore sync.Map - listenAddr string - shutdownCtx context.Context - cancelFunc context.CancelFunc - server *http.Server - logger *logrus.Logger - sniffer bool - snifferLog string - mu sync.Mutex - totalTraffic uint64 - tunnelStatus *string -} - -type PortUsage struct { - Port int - Usage uint64 -} - -type SystemStats struct { - TunnelStatus string `json:"tunnelStatus"` - CPUUsage string `json:"cpuUsage"` - RAMUsage string `json:"ramUsage"` - DiskUsage string `json:"diskUsage"` - SwapUsage string `json:"swapUsage"` - NetworkTraffic string `json:"networkTraffic"` - UploadSpeed string `json:"uploadSpeed"` - DownloadSpeed string `json:"downloadSpeed"` - BackhaulTraffic string `json:"backhaulTraffic"` - Sniffer string `json:"sniffer"` - AllConnections string `json:"allConnections"` -} - -func NewDataStore(listenAddr string, shutdownCtx context.Context, snifferLog string, sniffer bool, tunnelStatus *string, logger *logrus.Logger) *Usage { - ctx, cancel := context.WithCancel(shutdownCtx) - u := &Usage{ - listenAddr: listenAddr, - shutdownCtx: ctx, - cancelFunc: cancel, - logger: logger, - sniffer: sniffer, - snifferLog: snifferLog, - tunnelStatus: tunnelStatus, - mu: sync.Mutex{}, - totalTraffic: 0, - } - return u -} - -func (m *Usage) Monitor() { - mux := http.NewServeMux() - mux.HandleFunc("/", m.handleIndex) // handle index - mux.HandleFunc("/stats", m.statsHandler) - if m.sniffer { - mux.HandleFunc("/data", m.handleData) // New route for JSON data - } - m.server = &http.Server{ - Addr: m.listenAddr, - Handler: mux, - } - - go func() { - <-m.shutdownCtx.Done() - - shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - // Attempt to gracefully shut down the server - if err := m.server.Shutdown(shutdownCtx); err != nil { - m.logger.Errorf("sniffer server shutdown error: %v", err) - } - }() - - // start save data - if m.sniffer { - go func() { - ticker := time.NewTicker(15 * time.Second) // every 5 seconds - defer ticker.Stop() - - for { - select { - case <-ticker.C: - go m.saveUsageData() - case <-m.shutdownCtx.Done(): - return - } - } - }() - } - // Start the server - m.logger.Info("sniffer service listening on port: ", m.listenAddr) - if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - m.logger.Errorf("sniffer server error: %v", err) - } -} - -//go:embed index.html -var indexHTML embed.FS - -func (m *Usage) handleIndex(w http.ResponseWriter, r *http.Request) { - usageData := m.getUsageFromFile() - readableData := m.usageDataWithReadableUsage(usageData) - - tmpl, err := template.ParseFS(indexHTML, "index.html") - if err != nil { - m.logger.Errorf("error parsing template: %v", err) - return - } - - err = tmpl.Execute(w, readableData) - if err != nil { - m.logger.Errorf("error executing template: %v", err) - } -} - -func (m *Usage) handleData(w http.ResponseWriter, r *http.Request) { - usageData := m.getUsageFromFile() - readableData := m.usageDataWithReadableUsage(usageData) - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(readableData); err != nil { - m.logger.Errorf("error encoding JSON response: %v", err) - } -} - -func (m *Usage) statsHandler(w http.ResponseWriter, r *http.Request) { - stats, err := m.getSystemStats() - if err != nil { - m.logger.Error("Error fetching system stats:", err) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(stats); err != nil { - m.logger.Error("Error encoding JSON:", err) - } -} - -func (m *Usage) AddOrUpdatePort(port int, usage uint64) { - m.mu.Lock() - defer m.mu.Unlock() - - // Retrieve current usage data for the port - value, ok := m.dataStore.Load(port) - if ok { - // Port exists, update usage - portUsage := value.(PortUsage) - portUsage.Usage += usage - m.dataStore.Store(port, portUsage) - } else { - // Port does not exist, create new entry - m.dataStore.Store(port, PortUsage{Port: port, Usage: usage}) - } -} - -func (m *Usage) saveUsageData() { - // Step 1: Load existing usage data from the JSON file - var existingUsageData []PortUsage - file, err := os.Open(m.snifferLog) - if err == nil { - // If the file exists, decode the JSON data into existingUsageData - defer file.Close() - err = json.NewDecoder(file).Decode(&existingUsageData) - if err != nil { - m.logger.Errorf("error decoding JSON data: %v", err) - return - } - } else if !os.IsNotExist(err) { - // Log any error except file not existing - m.logger.Errorf("error opening JSON file: %v", err) - return - } - - // Step 2: Get current usage data from sync.Map - currentUsageData := m.collectUsageDataFromSyncMap() - - // Step 3: Merge the existing and current usage data into a map to avoid duplicates - usageMap := make(map[int]PortUsage) - - // Add existing usage data to the map - for _, usage := range existingUsageData { - usageMap[usage.Port] = usage - } - - // Append or update current usage data in the map - for _, usage := range currentUsageData { - if existing, exists := usageMap[usage.Port]; exists { - // Update existing port usage - existing.Usage += usage.Usage - usageMap[usage.Port] = existing - } else { - // Add new port usage - usageMap[usage.Port] = usage - } - } - - m.totalTraffic = 0 - - // Step 4: Convert the map back to a slice - var mergedUsageData []PortUsage - for _, usage := range usageMap { - mergedUsageData = append(mergedUsageData, usage) - m.totalTraffic += usage.Usage - } - - // Step 5: Convert merged data to JSON - data, err := json.MarshalIndent(mergedUsageData, "", " ") - if err != nil { - m.logger.Errorf("error marshalling usage data: %v", err) - return - } - - // Step 6: Write JSON data to file - err = os.WriteFile(m.snifferLog, data, 0644) - if err != nil { - m.logger.Errorf("error writing usage data to file: %v", err) - } -} - -func (m *Usage) getUsageFromFile() []PortUsage { - // Check if the file exists - if _, err := os.Stat(m.snifferLog); os.IsNotExist(err) { - // If the file does not exist, create it and write "null" - file, err := os.OpenFile(m.snifferLog, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) - if err != nil { - m.logger.Errorf("error creating file: %v", err) - return nil - } - - // Write "null" to the new file - if _, err := file.Write([]byte("null")); err != nil { - m.logger.Errorf("error writing 'null' to the file: %v", err) - file.Close() - return nil - } - - return nil - } - - var usageData []PortUsage - - // Open the JSON file - file, err := os.Open(m.snifferLog) - if err != nil { - m.logger.Errorf("error opening JSON file: %v", err) - return nil - } - defer file.Close() - - // Decode the JSON file into the usageData slice - err = json.NewDecoder(file).Decode(&usageData) - if err != nil { - m.logger.Errorf("error decoding JSON data: %v", err) - return nil - } - - // Sort usageData by Port in ascending order - sort.Slice(usageData, func(i, j int) bool { - return usageData[i].Port < usageData[j].Port - }) - - return usageData -} - -// converts the byte usage to a human-readable format -func (m *Usage) usageDataWithReadableUsage(usageData []PortUsage) []struct { - Port int - ReadableUsage string -} { - var result []struct { - Port int - ReadableUsage string - } - - for _, portUsage := range usageData { - result = append(result, struct { - Port int - ReadableUsage string - }{ - Port: portUsage.Port, - ReadableUsage: m.convertBytesToReadable(portUsage.Usage), - }) - } - - return result -} - -// collectUsageDataFromSyncMap gathers data from sync.Map -func (m *Usage) collectUsageDataFromSyncMap() []PortUsage { - m.mu.Lock() - defer m.mu.Unlock() - - var usageData []PortUsage - m.dataStore.Range(func(key, value interface{}) bool { - if portUsage, ok := value.(PortUsage); ok { - usageData = append(usageData, portUsage) - m.dataStore.Delete(key) - } - return true - }) - return usageData -} - -// ConvertBytesToReadable converts bytes into a human-readable format (KB, MB, GB) -func (m *Usage) convertBytesToReadable(bytes uint64) string { - const ( - KB = 1 << (10 * 1) // 1024 bytes - MB = 1 << (10 * 2) // 1024 KB - GB = 1 << (10 * 3) // 1024 MB - TB = 1 << (10 * 4) // 1024 TB - ) - - switch { - case bytes >= TB: - return fmt.Sprintf("%.2f TB", float64(bytes)/float64(TB)) - case bytes >= GB: - return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB)) - case bytes >= MB: - return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB)) - case bytes >= KB: - return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB)) - default: - return fmt.Sprintf("%d B", bytes) // Bytes - } -} - -func (m *Usage) getSystemStats() (*SystemStats, error) { - - // Get initial network stats - initialStats, err := m.getNetworkStats() - if err != nil { - return nil, err - } - - // Wait for 1 second - time.Sleep(1 * time.Second) - - // Get updated network stats - finalStats, err := m.getNetworkStats() - if err != nil { - return nil, err - } - - // Get CPU usage - cpuPercent, err := cpu.Percent(0, false) - if err != nil { - return nil, err - } - - // Get RAM usage - memStats, err := mem.VirtualMemory() - if err != nil { - return nil, err - } - - // Get Disk usage - diskStats, err := disk.Usage("/") - if err != nil { - return nil, err - } - - // Get Swap usage - swapStats, err := mem.SwapMemory() - if err != nil { - return nil, err - } - - // Get Network traffic - netStats, err := net.IOCounters(false) - if err != nil { - return nil, err - } - - // Get all active network connections (TCP, UDP, etc.) - connections, err := net.Connections("all") - if err != nil { - return nil, err - } - - // Calculate upload and download speeds - uploadSpeed := float64(finalStats.BytesSent - initialStats.BytesSent) - downloadSpeed := float64(finalStats.BytesRecv - initialStats.BytesRecv) - - stats := &SystemStats{ - TunnelStatus: *m.tunnelStatus, - CPUUsage: m.formatFloat(cpuPercent[0]), - RAMUsage: m.convertBytesToReadable(memStats.Used), - DiskUsage: m.convertBytesToReadable(diskStats.Used), - SwapUsage: m.convertBytesToReadable(swapStats.Used), - NetworkTraffic: m.convertBytesToReadable(netStats[0].BytesSent + netStats[0].BytesRecv), - DownloadSpeed: m.formatSpeed(downloadSpeed), - UploadSpeed: m.formatSpeed(uploadSpeed), - BackhaulTraffic: m.convertBytesToReadable(m.totalTraffic), - Sniffer: map[bool]string{true: "Running", false: "Not running"}[m.sniffer], - AllConnections: fmt.Sprintf("%d", len(connections)), - } - - return stats, nil -} - -func (m *Usage) formatSpeed(bytesPerSec float64) string { - if bytesPerSec >= 1e9 { - return fmt.Sprintf("%.2f GB/s", bytesPerSec/1e9) - } else if bytesPerSec >= 1e6 { - return fmt.Sprintf("%.2f MB/s", bytesPerSec/1e6) - } else if bytesPerSec >= 1e3 { - return fmt.Sprintf("%.2f KB/s", bytesPerSec/1e3) - } - return fmt.Sprintf("%.2f B/s", bytesPerSec) -} - -func (m *Usage) formatFloat(value float64) string { - return fmt.Sprintf("%.2f%%", value) -} - -func (m *Usage) getNetworkStats() (*net.IOCountersStat, error) { - ioCounters, err := net.IOCounters(false) - if err != nil { - return nil, err - } - if len(ioCounters) == 0 { - return nil, fmt.Errorf("no network IO counters found") - } - return &ioCounters[0], nil -} From c304758f619545efa493cf29986bcba74a3a6213 Mon Sep 17 00:00:00 2001 From: shinya Date: Sun, 10 Aug 2025 13:56:52 +0330 Subject: [PATCH 2/2] [add] example dashboard & example prometheus/grafana setup [add] usage example in README.md --- README.md | 44 +++++- monitoring/backhaul.json | 284 ++++++++++++++++++++++++++++++++++ monitoring/dashboard.jpg | Bin 0 -> 118335 bytes monitoring/docker-compose.yml | 18 +++ monitoring/prometheus.yml | 10 ++ 5 files changed, 350 insertions(+), 6 deletions(-) create mode 100644 monitoring/backhaul.json create mode 100644 monitoring/dashboard.jpg create mode 100644 monitoring/docker-compose.yml create mode 100644 monitoring/prometheus.yml diff --git a/README.md b/README.md index d451d01..fa6d42a 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,11 @@ Welcome to the **`Backhaul`** project! This project provides a high-performance - [WSS Multiplexing Configuration](#wss-multiplexing-configuration) 5. [Generating a Self-Signed TLS Certificate with OpenSSL](#generating-a-self-signed-tls-certificate-with-openssl) 6. [Running backhaul as a service](#running-backhaul-as-a-service) -7. [FAQ](#faq) -8. [Benchmark](#benchmark) -9. [License](#license) -10. [Donation](#donation) +7. [Monitoring with prometheus/grafana](#monitoring) +8. [FAQ](#faq) +9. [Benchmark](#benchmark) +10. [License](#license) +11. [Donation](#donation) --- @@ -106,8 +107,8 @@ To start using the solution, you'll need to configure both server and client com mss = 1360 # TCP/TCPMux: Maximum Segment Size in bytes; controls max TCP payload size to avoid fragmentation. (default: system-defined) so_rcvbuf = 4194304 # TCP/TCPMux: Socket receive buffer size (bytes); larger buffer allows higher throughput on receive side. (default: system-defined) so_sndbuf = 1048576 # TCP/TCPMux: Socket send buffer size (bytes); controls send queue size to manage outgoing data flow. (default: system-defined) - - + + metrics = ["default", "prometheus"] # metrics exposed via web_port. only the legacy json ("default") and prometheus ("prometheus") are supported. to achieve backward compatibility without changing the config file, legacy json is enabled by default. ports = [ "443-600", # Listen on all ports in the range 443 to 600 @@ -154,6 +155,8 @@ To start using the solution, you'll need to configure both server and client com mss = 1360 # TCP/TCPMux: Maximum Segment Size in bytes; controls max TCP payload size to avoid fragmentation. (default: system-defined) so_rcvbuf = 1048576 # TCP/TCPMux: Socket receive buffer size (bytes); larger buffer allows higher throughput on receive side. (default: system-defined) so_sndbuf = 4194304 # TCP/TCPMux: Socket send buffer size (bytes); controls send queue size to manage outgoing data flow. (default: system-defined) + + metrics = ["default", "prometheus"] # metrics exposed via web_port. only the legacy json ("default") and prometheus ("prometheus") are supported. to achieve backward compatibility without changing the config file, legacy json is enabled by default. ``` To start the `client`: @@ -571,6 +574,35 @@ sudo systemctl status backhaul.service journalctl -u backhaul.service -e -f ``` +## Monitoring + +setting `web_port` to a non-zero value will enable the monitoring interface. + +you can choose which monitoring interface you want to use by setting `metrics` to `prometheus`, `default` or both. empty array will fallback to `["default"]` + +### Basic Monitoring Setup +you can set up a basic monitoring setup using prometheus and grafana. +![grafana dashboard](monitoring/dashboard.jpg) + +(while not necessary, it is recommended to use docker) +1. install docker, docker-compose + ```bash + sudo apt update && sudo apt install docker.io docker-compose-v2 + # to run docker without sudo + sudo groupadd docker + sudo usermod -aG docker $USER + # If you're running Linux in a virtual machine, it may be necessary to restart the virtual machine for changes to take effect. + ``` +2. create a `docker-compose.yml` like [this example](monitoring/docker-compose.yml) +3. create a prometheus.yaml like [this example](monitoring/prometheus.yml) +4. run the docker-compose file + ```bash + docker compose up -d + ``` +5. visit grafana dashboard at `http://SERVER_IP:3000` (default username/password is `admin`) +6. create a new datasource, choose prometheus, and enter the url `http://prometheus:9090` +7. via `Dashboards > New > import` import the [example dashboard](monitoring/dashboard.jpg) or [create your own](https://grafana.com/tutorials/) + ## FAQ **Q: How do I decide which transport protocol to use?** diff --git a/monitoring/backhaul.json b/monitoring/backhaul.json new file mode 100644 index 0000000..31bf679 --- /dev/null +++ b/monitoring/backhaul.json @@ -0,0 +1,284 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "12.2.0-16818804881" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "state-timeline", + "name": "State timeline", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "index": 0, + "text": "Down" + }, + "1": { + "color": "green", + "index": 1, + "text": "Up" + } + }, + "type": "value" + }, + { + "options": { + "match": "null+nan", + "result": { + "color": "yellow", + "index": 2, + "text": "No data" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0-16818804881", + "targets": [ + { + "editorMode": "code", + "exemplar": false, + "expr": "backhaul_tunnel_status", + "instant": false, + "legendFormat": "[{{side}}] {{instance}} {{transport}}", + "range": true, + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + } + } + ], + "title": "Tunnel Status", + "type": "state-timeline" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisGridShow": true, + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 44, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.2.0-16818804881", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "backhaul_tunnel_usage", + "format": "heatmap", + "instant": false, + "legendFormat": "[{{side}}] {{instance}} {{transport}}:{{port}}", + "range": true, + "refId": "A" + } + ], + "title": "Network Usage", + "type": "timeseries" + } + ], + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Backhaul", + "uid": "5d38542f-078b-4d23-953e-fd81ef9955c0", + "version": 4, + "weekStart": "" +} \ No newline at end of file diff --git a/monitoring/dashboard.jpg b/monitoring/dashboard.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f96c00c9b11d669c652b00fc2cb05bb58132f9f1 GIT binary patch literal 118335 zcmeFa1zc9k)-b;57C}N9rMr<173uCq>F$n)5LCKRKtQ^c?oer@ySuxk9{vy3(R1&8 z?>XQ1-uu7r_a6A|{mkBb_N+B)X4b4(G5c!tY8JRD@ksm;00jjF=t2I0s|nx%fCvwd z01t?LBL9o3>_}d>8Gz=^pJOUyTG72O^*-Zc%3I+xm76uLu78a7*6Y?B@#el=S!y*iS zOa3v!U0W0&!M%-#PeDmVO+(Ah!Fi91o9Dqpkw>Cp z;u4BV$||aA>Kgh6hDOFFre^jIj!w=lu5Nx${R5soe-RiN6&({B_wrSIYFc_mW>$7i zZgELzS$RceRrQCDP0cN>ZS5WX1A{}uBco&EbMp&}OUo;(YwLUa2Zu+;C!bHxuImK_ zz7~!rftk#}t-Fcx-#?E~^hB*8PY#MemWw z*c5iLpV;*w?6z3)+EfATybMNkVWoVjVr?JN zpx*BcjMuK0=}X+O8&z|=^;TXnU~}c6Rah6n0 z$DXge*|a}H?raE>t{wPPZCRozYb7a*QbeybYhbVs1$K;PL2Gbhpenfn1X@L;2=z&v zQZ22@E{O6!(Nd>Yhu%yr%jXm1-wLmd-R|y~8ckQw?kwqgJV*^h1qUbj@tY;3WlgTB z?vG8Sh^LWpk@_OGk%p%DBC?$ ziC_WpfA}z6b(3_$M>KifyWWG2-Q`{Q z62Y5|Ep`0mDB=i5RGX=wY-1m)PpTwQh9894$k;v-HohUZbVTI|t-PBn z2PJ~_?ZB?Fo{LM6DXH;Pm9DsF)`iN}vAzity-{JZB&#TNTS;lesbqAj)7<`+2%nUE zU$rkSK0uvkn$<}e#hHr7_tHpAU7R~{)^Ggy>{j6qXscajYg@xd-l#Y*!wK6{5kt2QHF%c#o&ULaV2m1x&HQ4{@ z$0AVFZ39I`{E>a!!Bg|H3VClDz5dlgVk^``#~jQnp|lvn>GD*tOitnHw_Ahct>8ENq5=>7W?B#+

Lp!&b1K$`!7>w=lQ?kRKhxdz1h4 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 literal 0 HcmV?d00001 diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000..51b9b23 --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,18 @@ +services: + prometheus: + image: prom/prometheus:latest + container_name: prometheus + volumes: + - prometheus-data:/prometheus + - ./prometheus.yml:/etc/prometheus/prometheus.yml + grafana: + image: grafana/grafana:latest + container_name: grafana + volumes: + - grafana-data:/var/lib/grafana + ports: + - "3000:3000" + +volumes: + prometheus-data: + grafana-data: \ No newline at end of file diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 0000000..dbea3a2 --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 30s # Set the scrape interval to every 15 seconds. Default is every 1 minute. + +scrape_configs: + - job_name: 'backhaul' + static_configs: + - targets: ['BACKHAUL_SERVER_IP:WEB_PORT', 'other servers...'] + +alerting: + # you can read more about alerting here: https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/