diff --git a/docs/configuration/experimental/clash-api.md b/docs/configuration/experimental/clash-api.md index a8908940e..fa2aacd7d 100644 --- a/docs/configuration/experimental/clash-api.md +++ b/docs/configuration/experimental/clash-api.md @@ -1,3 +1,7 @@ +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [enable_metrics](#enable_metrics) + !!! quote "Changes in sing-box 1.10.0" :material-plus: [access_control_allow_origin](#access_control_allow_origin) @@ -25,6 +29,7 @@ "default_mode": "", "access_control_allow_origin": [], "access_control_allow_private_network": false, + "enable_metrics": false, // Deprecated @@ -119,6 +124,27 @@ Allow access from private network. To access the Clash API on a private network from a public website, `access_control_allow_private_network` must be enabled. +#### enable_metrics + +!!! question "Since sing-box 1.14.0" + +Enable the Prometheus metrics endpoint. + +When enabled, sing-box exposes metrics in the Prometheus text format at +`http://{{external-controller}}/metrics`, sharing the same authentication as the +rest of the Clash API (send `Authorization: Bearer ${secret}` when `secret` is set). + +The following metrics are exported, in addition to the standard Go runtime and +process collectors: + +| Metric | Type | Labels | Description | +|-------------------------------------------|---------|------------|----------------------------------------------------------| +| `singbox_uplink_bytes_total` | counter | | Total number of bytes uploaded through all connections. | +| `singbox_downlink_bytes_total` | counter | | Total number of bytes downloaded through all connections.| +| `singbox_active_connections` | gauge | | Number of currently active connections. | +| `singbox_active_connections_by_outbound` | gauge | `outbound` | Active connections grouped by outbound tag. | +| `singbox_outbound_delay_milliseconds` | gauge | `outbound` | Latest URL test delay of an outbound, in milliseconds. | + #### store_mode !!! failure "Deprecated in sing-box 1.8.0" diff --git a/experimental/clashapi/metrics.go b/experimental/clashapi/metrics.go new file mode 100644 index 000000000..ba6c8649c --- /dev/null +++ b/experimental/clashapi/metrics.go @@ -0,0 +1,109 @@ +package clashapi + +import ( + "net/http" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/trafficcontrol" + "github.com/sagernet/sing-box/common/urltest" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const metricsNamespace = "singbox" + +// metricsCollector implements prometheus.Collector and exposes sing-box traffic, +// connection and outbound health statistics. All values are read live on each +// scrape from the shared traffic manager, outbound manager and URL test history, +// so the collector holds no state of its own and needs no background goroutine. +type metricsCollector struct { + trafficManager *trafficcontrol.Manager + urlTestHistory *urltest.HistoryStorage + outbound adapter.OutboundManager + + uplinkBytes *prometheus.Desc + downlinkBytes *prometheus.Desc + activeConnections *prometheus.Desc + connectionsByOutbound *prometheus.Desc + outboundDelay *prometheus.Desc +} + +func newMetricsCollector(trafficManager *trafficcontrol.Manager, urlTestHistory *urltest.HistoryStorage, outbound adapter.OutboundManager) *metricsCollector { + return &metricsCollector{ + trafficManager: trafficManager, + urlTestHistory: urlTestHistory, + outbound: outbound, + uplinkBytes: prometheus.NewDesc( + prometheus.BuildFQName(metricsNamespace, "", "uplink_bytes_total"), + "Total number of bytes uploaded through all connections.", + nil, nil, + ), + downlinkBytes: prometheus.NewDesc( + prometheus.BuildFQName(metricsNamespace, "", "downlink_bytes_total"), + "Total number of bytes downloaded through all connections.", + nil, nil, + ), + activeConnections: prometheus.NewDesc( + prometheus.BuildFQName(metricsNamespace, "", "active_connections"), + "Number of currently active connections.", + nil, nil, + ), + connectionsByOutbound: prometheus.NewDesc( + prometheus.BuildFQName(metricsNamespace, "", "active_connections_by_outbound"), + "Number of currently active connections grouped by outbound tag.", + []string{"outbound"}, nil, + ), + outboundDelay: prometheus.NewDesc( + prometheus.BuildFQName(metricsNamespace, "", "outbound_delay_milliseconds"), + "Latest URL test delay of an outbound, in milliseconds.", + []string{"outbound"}, nil, + ), + } +} + +func (c *metricsCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.uplinkBytes + ch <- c.downlinkBytes + ch <- c.activeConnections + ch <- c.connectionsByOutbound + ch <- c.outboundDelay +} + +func (c *metricsCollector) Collect(ch chan<- prometheus.Metric) { + uplinkTotal, downlinkTotal := c.trafficManager.Total() + ch <- prometheus.MustNewConstMetric(c.uplinkBytes, prometheus.CounterValue, float64(uplinkTotal)) + ch <- prometheus.MustNewConstMetric(c.downlinkBytes, prometheus.CounterValue, float64(downlinkTotal)) + ch <- prometheus.MustNewConstMetric(c.activeConnections, prometheus.GaugeValue, float64(c.trafficManager.ConnectionsLen())) + + connectionsByOutbound := make(map[string]int) + for _, metadata := range c.trafficManager.Connections() { + connectionsByOutbound[metadata.Outbound]++ + } + for outbound, count := range connectionsByOutbound { + ch <- prometheus.MustNewConstMetric(c.connectionsByOutbound, prometheus.GaugeValue, float64(count), outbound) + } + + for _, outbound := range c.outbound.Outbounds() { + history := c.urlTestHistory.LoadURLTestHistory(outbound.Tag()) + if history == nil { + continue + } + ch <- prometheus.MustNewConstMetric(c.outboundDelay, prometheus.GaugeValue, float64(history.Delay), outbound.Tag()) + } +} + +// newMetricsHandler builds an HTTP handler serving the Prometheus text format. +// It uses a dedicated registry so sing-box metrics never collide with the +// default global registry, and includes the standard Go runtime and process +// collectors alongside the sing-box collector. +func newMetricsHandler(trafficManager *trafficcontrol.Manager, urlTestHistory *urltest.HistoryStorage, outbound adapter.OutboundManager) http.Handler { + registry := prometheus.NewRegistry() + registry.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + newMetricsCollector(trafficManager, urlTestHistory, outbound), + ) + return promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) +} diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index 8de33d6a6..63afa5e59 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -136,6 +136,10 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op r.Mount("/cache", cacheRouter(ctx)) r.Mount("/dns", dnsRouter(s.dnsRouter)) + if options.EnableMetrics { + r.Handle("/metrics", newMetricsHandler(trafficManager, urlTestHistory, s.outbound)) + } + s.setupMetaAPI(r) }) if options.ExternalUI != "" { diff --git a/go.mod b/go.mod index 3c5f747f5..8240ac82f 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/openai/openai-go/v3 v3.26.0 github.com/oschwald/maxminddb-golang v1.13.1 github.com/pkg/sftp v1.13.10 + github.com/prometheus/client_golang v1.23.2 github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a github.com/sagernet/cors v1.2.1 @@ -88,7 +89,9 @@ require ( github.com/anchore/go-lzo v0.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/database64128/netx-go v0.1.1 // indirect @@ -125,6 +128,7 @@ require ( github.com/libp2p/go-netroute v0.2.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pion/dtls/v3 v3.1.5 // indirect @@ -133,6 +137,9 @@ require ( github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 // indirect @@ -182,6 +189,7 @@ require ( github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/term v0.40.0 // indirect diff --git a/go.sum b/go.sum index 4afa849fa..5fdf35001 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= +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/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 h1:LYSB6VgWzKtNrcxElw3c97BP40Oc7bizKxA9K1Vi/5k= github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= @@ -135,6 +137,10 @@ github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= @@ -167,6 +173,8 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE= @@ -192,8 +200,18 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= @@ -387,6 +405,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= @@ -511,8 +531,9 @@ google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/option/experimental.go b/option/experimental.go index 2f00decfe..5c75bd20b 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -29,6 +29,7 @@ type ClashAPIOptions struct { ModeList []string `json:"-"` AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"` AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` + EnableMetrics bool `json:"enable_metrics,omitempty"` // Deprecated: migrated to global cache file CacheFile string `json:"cache_file,omitempty"`