diff --git a/backend/.env_sample b/backend/.env_sample index 4c1252e8..fb92a190 100644 --- a/backend/.env_sample +++ b/backend/.env_sample @@ -1,4 +1,8 @@ WS_SECRET=xinfin_xdpos_hybrid_network_stats PORT=2000 ENABLE_FORENSICS=false -MONGODBURL=localhost:27017 \ No newline at end of file +MONGODBURL=localhost:27017 + +# Bootnode health (optional) +# ENABLE_BOOTNODE_HEALTH=true +# BOOTNODE_NETWORK=devnet \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 8809c4e0..5350b5bb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /build COPY go.mod go.sum ./ diff --git a/backend/README.md b/backend/README.md index 434d5d66..9b875a73 100644 --- a/backend/README.md +++ b/backend/README.md @@ -33,6 +33,21 @@ Copy `.env_sample` to `.env` and fill in the values. | `MONGODBURL` | `localhost:27017` | MongoDB host:port | | `MASTERNODE_URL` | `https://master.xinfin.network/api` | Masternode API base URL | | `VERBOSITY` | `1` | Log verbosity level | +| `ENABLE_BOOTNODE_HEALTH` | `true` | Set to `false` to disable bootnode UDP/TCP probes | +| `BOOTNODE_NETWORK` | `mainnet` | Network for bootnode probes (`mainnet`, `testnet`, or `devnet`) | +| `BOOTNODE_CHECK_INTERVAL` | `60` | Seconds between automatic bootnode health checks | +| `BOOTNODE_CHECK_TIMEOUT` | `5` | Per-check timeout in seconds | +| `BOOTNODE_CHECK_PARALLEL` | `8` | Parallel workers for bootnode probes | + +Bootnode lists default to the official XinFin-Node files on GitHub: + +- Mainnet: [mainnet/bootnodes.list](https://github.com/XinFinOrg/XinFin-Node/blob/master/mainnet/bootnodes.list) +- Testnet: [testnet/bootnodes.list](https://github.com/XinFinOrg/XinFin-Node/blob/master/testnet/bootnodes.list) +- Devnet: hardcoded placeholder in `internal/service/bootnode_load.go` (`TODO` — update IP/enode when official devnet bootnodes are available) + +The list is re-fetched from GitHub before each health check so updates in XinFin-Node are picked up automatically. + +Bootnode UDP/TCP probes depend on [XDPoSChain](https://github.com/XinFinOrg/XDPoSChain) `feat/discv4-ping-export` (pinned as `v1.6.1-0.20260621231742-90647d00de10` in `go.mod`). --- diff --git a/backend/config/config.go b/backend/config/config.go index ed188c6c..bdea589a 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -4,6 +4,7 @@ import ( "os" "strconv" "strings" + "time" ) type Config struct { @@ -14,6 +15,12 @@ type Config struct { EnableForensics bool MasterNodeURL string GeoIPDBPath string + + EnableBootnodeHealth bool + BootnodeNetwork string + BootnodeInterval time.Duration + BootnodeTimeout time.Duration + BootnodeParallel int } func Load() *Config { @@ -40,6 +47,30 @@ func Load() *Config { masternodeURL = "https://master.xinfin.network/api" } + enableBootnodeHealth := os.Getenv("ENABLE_BOOTNODE_HEALTH") != "false" + bootnodeInterval := 60 * time.Second + if v := os.Getenv("BOOTNODE_CHECK_INTERVAL"); v != "" { + if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + bootnodeInterval = time.Duration(secs) * time.Second + } + } + bootnodeTimeout := 5 * time.Second + if v := os.Getenv("BOOTNODE_CHECK_TIMEOUT"); v != "" { + if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + bootnodeTimeout = time.Duration(secs) * time.Second + } + } + bootnodeParallel := 8 + if v := os.Getenv("BOOTNODE_CHECK_PARALLEL"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + bootnodeParallel = n + } + } + bootnodeNetwork := os.Getenv("BOOTNODE_NETWORK") + if bootnodeNetwork == "" { + bootnodeNetwork = "mainnet" + } + return &Config{ Port: port, WSSecrets: secrets, @@ -48,5 +79,11 @@ func Load() *Config { EnableForensics: os.Getenv("ENABLE_FORENSICS") == "true", MasterNodeURL: masternodeURL, GeoIPDBPath: os.Getenv("GEOIP_DB_PATH"), + + EnableBootnodeHealth: enableBootnodeHealth, + BootnodeNetwork: bootnodeNetwork, + BootnodeInterval: bootnodeInterval, + BootnodeTimeout: bootnodeTimeout, + BootnodeParallel: bootnodeParallel, } } diff --git a/backend/go.mod b/backend/go.mod index 9b8d32d3..cd13714f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,8 +1,9 @@ module github.com/XinFinOrg/XDCStats/backend -go 1.23 +go 1.25 require ( + github.com/XinFinOrg/XDPoSChain v1.6.1-0.20260621231742-90647d00de10 github.com/gin-gonic/gin v1.10.0 github.com/gorilla/websocket v1.5.3 github.com/oschwald/geoip2-golang v1.11.0 @@ -10,19 +11,25 @@ require ( ) require ( + github.com/StackExchange/wmi v1.2.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-ole/go-ole v1.2.5 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang/snappy v0.0.4 // indirect + github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -30,6 +37,10 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect + github.com/tklauser/go-sysconf v0.3.14 // indirect + github.com/tklauser/numcpus v0.8.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect @@ -37,11 +48,11 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index dae9ed87..862eea31 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/XinFinOrg/XDPoSChain v1.6.1-0.20260621231742-90647d00de10 h1:2+JlE9+5h+N81povBMSwFA0FGEwrNNT89+e9HkXro3w= +github.com/XinFinOrg/XDPoSChain v1.6.1-0.20260621231742-90647d00de10/go.mod h1:SzduIe0UzwgO7Fj15CyFDq+XrT3wITnm4ncJsIEFfRs= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -9,12 +13,22 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ 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/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-ole/go-ole v1.2.5 h1:t4MGB5xEDZvXI+0rMjjsfBsD7yAgp/s9ZDkL1JndXwY= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -25,17 +39,35 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= -github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -49,6 +81,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w= github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= @@ -57,6 +98,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 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/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -67,8 +110,15 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +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= +github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= @@ -88,44 +138,77 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/api/bootnodes.go b/backend/internal/api/bootnodes.go new file mode 100644 index 00000000..36709496 --- /dev/null +++ b/backend/internal/api/bootnodes.go @@ -0,0 +1,52 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/XinFinOrg/XDCStats/backend/internal/service" +) + +// BootnodeHandler serves bootnode UDP/TCP health endpoints. +type BootnodeHandler struct { + checker *service.BootnodeChecker + adminSecret string +} + +func NewBootnodeHandler(checker *service.BootnodeChecker, adminSecret string) *BootnodeHandler { + return &BootnodeHandler{checker: checker, adminSecret: adminSecret} +} + +// Health returns the latest bootnode UDP/TCP probe results. +func (h *BootnodeHandler) Health(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, h.checker.Snapshot()) +} + +// Check triggers an immediate bootnode health check (admin only). +func (h *BootnodeHandler) Check(c *gin.Context) { + if !h.checkAuth(c) { + return + } + go h.checker.CheckNow() + c.JSON(http.StatusAccepted, gin.H{ + "status": "check started", + "message": "poll GET /v2/bootnodes/health for results", + }) +} + +func (h *BootnodeHandler) checkAuth(c *gin.Context) bool { + if h.adminSecret == "" { + return true + } + secret := c.GetHeader("x-api-secret") + if secret != h.adminSecret { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Unauthorized", + "message": "Invalid or missing API secret", + }) + return false + } + return true +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index cee9ca7e..a98f9f78 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -32,10 +32,13 @@ type Handler struct { nodes *collection.Collection adminSecret string // forensics handlers set by main when ENABLE_FORENSICS=true - ForensicsReports func(c *gin.Context) - ForensicsDetail func(c *gin.Context) - ForensicsLatest func(c *gin.Context) - ForensicsMasternode func(c *gin.Context) + ForensicsReports func(c *gin.Context) + ForensicsDetail func(c *gin.Context) + ForensicsLatest func(c *gin.Context) + ForensicsMasternode func(c *gin.Context) + // bootnode handlers set by main when ENABLE_BOOTNODE_HEALTH=true + BootnodesHealth func(c *gin.Context) + BootnodesCheck func(c *gin.Context) } func NewHandler(nodes *collection.Collection, adminSecret string) *Handler { diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index b813e279..cab26a77 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -15,6 +15,13 @@ func SetupRouter(r *gin.Engine, h *Handler) { v2.GET("/coinbase_info", h.CoinbaseInfo) v2.GET("/history", h.HistoryMetric) + v2.GET("/bootnodes/health", bootnodeGuard(h, func(c *gin.Context) { + h.BootnodesHealth(c) + })) + v2.POST("/bootnodes/check", bootnodeGuard(h, func(c *gin.Context) { + h.BootnodesCheck(c) + })) + // Forensics routes — handlers are nil if forensics disabled; middleware guards them. v2.GET("/forensics/masternode", forensicsGuard(h, func(c *gin.Context) { h.ForensicsMasternode(c) @@ -31,6 +38,16 @@ func SetupRouter(r *gin.Engine, h *Handler) { } } +func bootnodeGuard(h *Handler, next gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + if h.BootnodesHealth == nil { + c.JSON(503, gin.H{"error": "bootnode health not enabled"}) + return + } + next(c) + } +} + func forensicsGuard(h *Handler, next gin.HandlerFunc) gin.HandlerFunc { return func(c *gin.Context) { if h.ForensicsReports == nil { diff --git a/backend/internal/discv4/client.go b/backend/internal/discv4/client.go new file mode 100644 index 00000000..9138f0b2 --- /dev/null +++ b/backend/internal/discv4/client.go @@ -0,0 +1,71 @@ +package discv4 + +import ( + "net" + "time" + + "github.com/XinFinOrg/XDPoSChain/crypto" + "github.com/XinFinOrg/XDPoSChain/p2p/discover" + "github.com/XinFinOrg/XDPoSChain/p2p/enode" +) + +// ErrTimeout is returned when no pong is received in time. +var ErrTimeout = discover.ErrTimeout + +// Client sends XDC discv4 pings via the XDPoSChain discovery protocol. +type Client struct { + tab *discover.Table + conn *net.UDPConn + db *enode.DB +} + +// NewClient binds an ephemeral UDP socket for discovery probes. +func NewClient() (*Client, error) { + key, err := crypto.GenerateKey() + if err != nil { + return nil, err + } + conn, err := net.ListenPacket("udp4", "0.0.0.0:0") + if err != nil { + return nil, err + } + udpConn := conn.(*net.UDPConn) + db, err := enode.OpenDB("") + if err != nil { + conn.Close() + return nil, err + } + ln := enode.NewLocalNode(db, key) + tab, err := discover.ListenUDP(udpConn, ln, discover.Config{PrivateKey: key}) + if err != nil { + conn.Close() + db.Close() + return nil, err + } + return &Client{tab: tab, conn: udpConn, db: db}, nil +} + +// Close releases the UDP socket and discovery table. +func (c *Client) Close() error { + c.tab.Close() + c.conn.Close() + c.db.Close() + return nil +} + +// Ping sends a single pingXDC to n and returns the RTT on success. +// The internal discovery timeout (500 ms) governs how long to wait for a pong. +func (c *Client) Ping(n *Node) (time.Duration, error) { + if err := n.validate(); err != nil { + return 0, err + } + target, err := enode.ParseV4(n.Enode()) + if err != nil { + return 0, err + } + start := time.Now() + if err := c.tab.Ping(target); err != nil { + return 0, err + } + return time.Since(start), nil +} diff --git a/backend/internal/discv4/client_integration_test.go b/backend/internal/discv4/client_integration_test.go new file mode 100644 index 00000000..9fafbd54 --- /dev/null +++ b/backend/internal/discv4/client_integration_test.go @@ -0,0 +1,27 @@ +//go:build integration + +package discv4 + +import ( + "testing" + "time" +) + +func TestPingLiveBootnode(t *testing.T) { + enode := "enode://efc710050d65cda5d563fc6c9520986a69bbed81036b427a5ad7a2079bc8ba421e78d291a9388a1bd10ff5d9afdc2a095900528f5c45942104d818feda37cf7a@154.12.116.170:30301" + n, err := ParseNode(enode) + if err != nil { + t.Fatal(err) + } + client, err := NewClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + rtt, err := client.Ping(n, 5*time.Second) + if err != nil { + t.Fatal(err) + } + t.Logf("pong RTT %v", rtt) +} diff --git a/backend/internal/discv4/load.go b/backend/internal/discv4/load.go new file mode 100644 index 00000000..c92cd14d --- /dev/null +++ b/backend/internal/discv4/load.go @@ -0,0 +1,56 @@ +package discv4 + +import ( + "bufio" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + // MainnetBootnodesURL is the official XDC mainnet bootnode list in XinFin-Node. + MainnetBootnodesURL = "https://raw.githubusercontent.com/XinFinOrg/XinFin-Node/master/mainnet/bootnodes.list" + // TestnetBootnodesURL is the official XDC testnet (Apothem) bootnode list in XinFin-Node. + TestnetBootnodesURL = "https://raw.githubusercontent.com/XinFinOrg/XinFin-Node/master/testnet/bootnodes.list" +) + +// LoadNodesFromURL fetches and parses a bootnodes.list file from url. +func LoadNodesFromURL(url string) ([]*Node, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("fetch %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch %s: HTTP %s", url, resp.Status) + } + return parseNodes(resp.Body, url) +} + +func parseNodes(r io.Reader, source string) ([]*Node, error) { + var nodes []*Node + sc := bufio.NewScanner(r) + lineNo := 0 + for sc.Scan() { + lineNo++ + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + n, err := ParseNode(line) + if err != nil { + return nil, fmt.Errorf("%s:%d: %w", source, lineNo, err) + } + nodes = append(nodes, n) + } + if err := sc.Err(); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, fmt.Errorf("%s: no bootnodes found", source) + } + return nodes, nil +} diff --git a/backend/internal/discv4/node.go b/backend/internal/discv4/node.go new file mode 100644 index 00000000..55fbbcfa --- /dev/null +++ b/backend/internal/discv4/node.go @@ -0,0 +1,152 @@ +package discv4 + +import ( + "errors" + "fmt" + "net" + "net/url" + "regexp" + "strconv" +) + +// NodeID is a marshaled secp256k1 public key (512 bits). +type NodeID [64]byte + +// Node is a complete discv4 node endpoint. +type Node struct { + IP net.IP + UDP, TCP uint16 + ID NodeID +} + +func (n *Node) UDPAddr() *net.UDPAddr { + return &net.UDPAddr{IP: n.IP, Port: int(n.UDP)} +} + +func (n *Node) Endpoint() string { + if n.UDP == n.TCP { + return fmt.Sprintf("%s:%d", n.IP, n.TCP) + } + return fmt.Sprintf("%s:%d", n.IP, n.UDP) +} + +func (n *Node) TCPEndpoint() string { + return fmt.Sprintf("%s:%d", n.IP, n.TCP) +} + +func (n *Node) Enode() string { + u := url.URL{Scheme: "enode"} + addr := net.TCPAddr{IP: n.IP, Port: int(n.TCP)} + u.User = url.User(fmt.Sprintf("%x", n.ID[:])) + u.Host = addr.String() + if n.UDP != n.TCP { + u.RawQuery = "discport=" + strconv.Itoa(int(n.UDP)) + } + return u.String() +} + +func (n *Node) validate() error { + if n.IP == nil { + return errors.New("incomplete node") + } + if n.UDP == 0 || n.TCP == 0 { + return errors.New("missing port") + } + if n.IP.IsMulticast() || n.IP.IsUnspecified() { + return errors.New("invalid IP") + } + return nil +} + +var incompleteNodeURL = regexp.MustCompile(`(?i)^(?:enode://)?([0-9a-f]+)$`) + +// ParseNode parses an enode URL. +func ParseNode(rawurl string) (*Node, error) { + if m := incompleteNodeURL.FindStringSubmatch(rawurl); m != nil { + id, err := hexID(m[1]) + if err != nil { + return nil, fmt.Errorf("invalid node ID: %w", err) + } + return &Node{ID: id}, nil + } + return parseComplete(rawurl) +} + +func parseComplete(rawurl string) (*Node, error) { + u, err := url.Parse(rawurl) + if err != nil { + return nil, err + } + if u.Scheme != "enode" { + return nil, errors.New(`invalid URL scheme, want "enode"`) + } + if u.User == nil { + return nil, errors.New("does not contain node ID") + } + var id NodeID + if id, err = hexID(u.User.String()); err != nil { + return nil, fmt.Errorf("invalid node ID: %w", err) + } + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + return nil, fmt.Errorf("invalid host: %w", err) + } + ip := net.ParseIP(host) + if ip == nil { + return nil, errors.New("invalid IP address") + } + if ipv4 := ip.To4(); ipv4 != nil { + ip = ipv4 + } + tcpPort, err := strconv.ParseUint(port, 10, 16) + if err != nil { + return nil, errors.New("invalid port") + } + udpPort := tcpPort + if qv := u.Query().Get("discport"); qv != "" { + udpPort, err = strconv.ParseUint(qv, 10, 16) + if err != nil { + return nil, errors.New("invalid discport") + } + } + n := &Node{ID: id, IP: ip, UDP: uint16(udpPort), TCP: uint16(tcpPort)} + return n, n.validate() +} + +func hexID(in string) (NodeID, error) { + var id NodeID + b, err := decodeHex(in) + if err != nil { + return id, err + } + if len(b) != len(id) { + return id, fmt.Errorf("wrong length, want %d hex chars", len(id)*2) + } + copy(id[:], b) + return id, nil +} + +func decodeHex(in string) ([]byte, error) { + if len(in)%2 != 0 { + return nil, errors.New("odd length hex string") + } + out := make([]byte, len(in)/2) + for i := 0; i < len(in); i += 2 { + var b byte + for j := 0; j < 2; j++ { + c := in[i+j] + switch { + case c >= '0' && c <= '9': + b = b<<4 | (c - '0') + case c >= 'a' && c <= 'f': + b = b<<4 | (c - 'a' + 10) + case c >= 'A' && c <= 'F': + b = b<<4 | (c - 'A' + 10) + default: + return nil, errors.New("invalid hex character") + } + } + out[i/2] = b + } + return out, nil +} diff --git a/backend/internal/discv4/node_test.go b/backend/internal/discv4/node_test.go new file mode 100644 index 00000000..fbae20e7 --- /dev/null +++ b/backend/internal/discv4/node_test.go @@ -0,0 +1,20 @@ +package discv4 + +import "testing" + +func TestParseNode(t *testing.T) { + enode := "enode://efc710050d65cda5d563fc6c9520986a69bbed81036b427a5ad7a2079bc8ba421e78d291a9388a1bd10ff5d9afdc2a095900528f5c45942104d818feda37cf7a@154.12.116.170:30301" + n, err := ParseNode(enode) + if err != nil { + t.Fatal(err) + } + if n.Endpoint() != "154.12.116.170:30301" { + t.Fatalf("endpoint = %q, want 154.12.116.170:30301", n.Endpoint()) + } + if n.UDP != 30301 || n.TCP != 30301 { + t.Fatalf("ports udp=%d tcp=%d", n.UDP, n.TCP) + } + if n.TCPEndpoint() != "154.12.116.170:30301" { + t.Fatalf("tcp endpoint = %q, want 154.12.116.170:30301", n.TCPEndpoint()) + } +} diff --git a/backend/internal/probe/probe.go b/backend/internal/probe/probe.go new file mode 100644 index 00000000..5168819c --- /dev/null +++ b/backend/internal/probe/probe.go @@ -0,0 +1,112 @@ +package probe + +import "time" + +// Error kinds surfaced in API responses. +const ( + KindLocal = "local" // probe client failed (bind, init, etc.) + KindUnreachable = "unreachable" // bootnode did not respond + KindWarning = "warning" // reachable but degraded (e.g. protocol mismatch) +) + +const ( + DefaultAttempts = 3 + DefaultMinSuccess = 2 +) + +// Outcome is the result of one probe attempt. +type Outcome int + +const ( + OutcomeOK Outcome = iota + OutcomeWarning + OutcomeFail +) + +// Attempt holds one probe try. +type Attempt struct { + Outcome Outcome + RTT time.Duration + Message string +} + +// Summary is the aggregated result across multiple attempts. +type Summary struct { + Healthy bool + RTTMs int64 + Error string + ErrorKind string + Probes int + ProbeFails int +} + +// Aggregate applies a "minSuccess of attempts" rule (default: 2 of 3). +func Aggregate(attempts []Attempt, minSuccess int) Summary { + if minSuccess < 1 { + minSuccess = 1 + } + n := len(attempts) + if n == 0 { + return Summary{ErrorKind: KindUnreachable, Error: "no probe attempts"} + } + + var ok, warn, fail int + var rttSum time.Duration + for _, a := range attempts { + switch a.Outcome { + case OutcomeOK: + ok++ + rttSum += a.RTT + case OutcomeWarning: + warn++ + case OutcomeFail: + fail++ + } + } + + s := Summary{ + Probes: n, + ProbeFails: fail + warn, + } + + if ok >= minSuccess { + s.Healthy = true + if ok > 0 { + s.RTTMs = (rttSum / time.Duration(ok)).Milliseconds() + } + return s + } + + // Unhealthy — pick the most representative error message. + if warn >= minSuccess { + s.ErrorKind = KindWarning + s.Error = lastMessage(attempts, OutcomeWarning) + return s + } + s.ErrorKind = KindUnreachable + s.Error = lastMessage(attempts, OutcomeFail) + if s.Error == "" { + s.Error = lastMessage(attempts, OutcomeWarning) + } + if s.Error == "" { + s.Error = "probe failed" + } + return s +} + +// LocalSummary reports a probe infrastructure failure. +func LocalSummary(err error) Summary { + return Summary{ + ErrorKind: KindLocal, + Error: err.Error(), + } +} + +func lastMessage(attempts []Attempt, want Outcome) string { + for i := len(attempts) - 1; i >= 0; i-- { + if attempts[i].Outcome == want && attempts[i].Message != "" { + return attempts[i].Message + } + } + return "" +} diff --git a/backend/internal/probe/probe_test.go b/backend/internal/probe/probe_test.go new file mode 100644 index 00000000..a427238b --- /dev/null +++ b/backend/internal/probe/probe_test.go @@ -0,0 +1,54 @@ +package probe + +import ( + "testing" + "time" +) + +func TestAggregate_twoOfThree(t *testing.T) { + attempts := []Attempt{ + {Outcome: OutcomeFail, Message: "timeout"}, + {Outcome: OutcomeOK, RTT: 100 * time.Millisecond}, + {Outcome: OutcomeOK, RTT: 200 * time.Millisecond}, + } + s := Aggregate(attempts, 2) + if !s.Healthy { + t.Fatal("expected healthy with 2/3 successes") + } + if s.RTTMs != 150 { + t.Fatalf("rttMs = %d, want 150", s.RTTMs) + } + if s.Probes != 3 || s.ProbeFails != 1 { + t.Fatalf("probes=%d fails=%d", s.Probes, s.ProbeFails) + } +} + +func TestAggregate_twoOfThreeUnhealthy(t *testing.T) { + attempts := []Attempt{ + {Outcome: OutcomeFail, Message: "timeout"}, + {Outcome: OutcomeFail, Message: "timeout"}, + {Outcome: OutcomeOK, RTT: 50 * time.Millisecond}, + } + s := Aggregate(attempts, 2) + if s.Healthy { + t.Fatal("expected unhealthy with 2/3 failures") + } + if s.ErrorKind != KindUnreachable { + t.Fatalf("errorKind = %q", s.ErrorKind) + } +} + +func TestAggregate_warningMajority(t *testing.T) { + attempts := []Attempt{ + {Outcome: OutcomeWarning, Message: "incompatible p2p protocol version"}, + {Outcome: OutcomeWarning, Message: "incompatible p2p protocol version"}, + {Outcome: OutcomeFail, Message: "timeout"}, + } + s := Aggregate(attempts, 2) + if s.Healthy { + t.Fatal("expected unhealthy") + } + if s.ErrorKind != KindWarning { + t.Fatalf("errorKind = %q, want warning", s.ErrorKind) + } +} diff --git a/backend/internal/rlpx/classify.go b/backend/internal/rlpx/classify.go new file mode 100644 index 00000000..fce3d432 --- /dev/null +++ b/backend/internal/rlpx/classify.go @@ -0,0 +1,53 @@ +package rlpx + +import ( + "errors" + + xdp2p "github.com/XinFinOrg/XDPoSChain/p2p" +) + +// DialOutcome is the classified result of one TCP RLPx probe. +type DialOutcome struct { + Outcome Outcome + RTT int64 // milliseconds; set when OutcomeOK + Message string +} + +// Outcome classifies a single dial attempt. +type Outcome int + +const ( + OutcomeOK Outcome = iota + OutcomeWarning + OutcomeFail +) + +func classifyDial(err error) DialOutcome { + if err == nil { + return DialOutcome{Outcome: OutcomeOK} + } + + var reason xdp2p.DiscReason + if !errors.As(err, &reason) { + return DialOutcome{Outcome: OutcomeFail, Message: err.Error()} + } + + switch reason { + case xdp2p.DiscTooManyPeers, + xdp2p.DiscUselessPeer, + xdp2p.DiscRequested, + xdp2p.DiscAlreadyConnected, + xdp2p.DiscNonAllowlistedPeer, + xdp2p.DiscPairPeerStop: + return DialOutcome{Outcome: OutcomeOK, Message: reason.String()} + + case xdp2p.DiscQuitting, + xdp2p.DiscIncompatibleVersion, + xdp2p.DiscInvalidIdentity, + xdp2p.DiscUnexpectedIdentity: + return DialOutcome{Outcome: OutcomeWarning, Message: reason.String()} + + default: + return DialOutcome{Outcome: OutcomeFail, Message: reason.String()} + } +} diff --git a/backend/internal/rlpx/classify_test.go b/backend/internal/rlpx/classify_test.go new file mode 100644 index 00000000..ca78819f --- /dev/null +++ b/backend/internal/rlpx/classify_test.go @@ -0,0 +1,33 @@ +package rlpx + +import ( + "testing" + + xdp2p "github.com/XinFinOrg/XDPoSChain/p2p" +) + +func TestClassifyDial(t *testing.T) { + tests := []struct { + err error + want Outcome + contains string + }{ + {nil, OutcomeOK, ""}, + {xdp2p.DiscTooManyPeers, OutcomeOK, "too many peers"}, + {xdp2p.DiscUselessPeer, OutcomeOK, "useless peer"}, + {xdp2p.DiscQuitting, OutcomeWarning, "quitting"}, + {xdp2p.DiscIncompatibleVersion, OutcomeWarning, "incompatible"}, + {xdp2p.DiscInvalidIdentity, OutcomeWarning, "invalid node identity"}, + {xdp2p.DiscNetworkError, OutcomeFail, "network error"}, + {xdp2p.DiscReadTimeout, OutcomeFail, "read timeout"}, + } + for _, tt := range tests { + got := classifyDial(tt.err) + if got.Outcome != tt.want { + t.Fatalf("classifyDial(%v) outcome = %d, want %d", tt.err, got.Outcome, tt.want) + } + if tt.contains != "" && got.Message == "" { + t.Fatalf("classifyDial(%v) empty message", tt.err) + } + } +} diff --git a/backend/internal/rlpx/client.go b/backend/internal/rlpx/client.go new file mode 100644 index 00000000..c65d17c9 --- /dev/null +++ b/backend/internal/rlpx/client.go @@ -0,0 +1,91 @@ +package rlpx + +import ( + "errors" + "fmt" + "net" + "strconv" + "syscall" + "time" + + "github.com/XinFinOrg/XDCStats/backend/internal/discv4" + "github.com/XinFinOrg/XDPoSChain/crypto" + xdp2p "github.com/XinFinOrg/XDPoSChain/p2p" + "github.com/XinFinOrg/XDPoSChain/p2p/enode" +) + +// Client probes bootnodes over TCP using the RLPx handshake. +type Client struct { + srv *xdp2p.Server +} + +// NewClient starts a minimal p2p server used for outbound RLPx probes. +func NewClient() (*Client, error) { + key, err := crypto.GenerateKey() + if err != nil { + return nil, err + } + srv := &xdp2p.Server{ + Config: xdp2p.Config{ + PrivateKey: key, + MaxPeers: 1, + Name: "xdcstats-probe", + ListenAddr: ":0", + NoDiscovery: true, + }, + } + if err := srv.Start(); err != nil { + return nil, err + } + return &Client{srv: srv}, nil +} + +// Close stops the probe server. +func (c *Client) Close() { + c.srv.Stop() +} + +// Dial connects to n over TCP, performs an RLPx handshake probe, and classifies the result. +func (c *Client) Dial(n *discv4.Node, timeout time.Duration) DialOutcome { + target, err := enode.ParseV4(n.Enode()) + if err != nil { + return DialOutcome{Outcome: OutcomeFail, Message: err.Error()} + } + + start := time.Now() + addr := net.JoinHostPort(n.IP.String(), strconv.Itoa(int(n.TCP))) + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return DialOutcome{Outcome: OutcomeFail, Message: formatDialError(err).Error()} + } + defer conn.Close() + + remaining := timeout - time.Since(start) + if remaining <= 0 { + return DialOutcome{Outcome: OutcomeFail, Message: "timeout"} + } + _ = conn.SetDeadline(time.Now().Add(remaining)) + + err = c.srv.SetupConn(conn, 0, target) + out := classifyDial(err) + if out.Outcome == OutcomeOK { + out.RTT = time.Since(start).Milliseconds() + if err == nil { + for _, p := range c.srv.Peers() { + p.Disconnect(xdp2p.DiscQuitting) + } + } + } + return out +} + +func formatDialError(err error) error { + if errors.Is(err, syscall.ECONNREFUSED) { + return fmt.Errorf("connection refused") + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return fmt.Errorf("timeout") + } + return err +} diff --git a/backend/internal/service/bootnode_checker.go b/backend/internal/service/bootnode_checker.go new file mode 100644 index 00000000..382a9fe0 --- /dev/null +++ b/backend/internal/service/bootnode_checker.go @@ -0,0 +1,328 @@ +package service + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/XinFinOrg/XDCStats/backend/internal/discv4" + "github.com/XinFinOrg/XDCStats/backend/internal/probe" + "github.com/XinFinOrg/XDCStats/backend/internal/rlpx" +) + +// BootnodeStatus is the UDP/TCP health result for one bootnode. +type BootnodeStatus struct { + Index int `json:"index"` + Enode string `json:"enode"` + Endpoint string `json:"endpoint"` + TcpEndpoint string `json:"tcpEndpoint"` + NodeID string `json:"nodeId"` + Healthy bool `json:"healthy"` + RTTMs int64 `json:"rttMs,omitempty"` + Error string `json:"error,omitempty"` + ErrorKind string `json:"errorKind,omitempty"` + Probes int `json:"probes,omitempty"` + ProbeFails int `json:"probeFails,omitempty"` + TcpHealthy bool `json:"tcpHealthy"` + TcpRTTMs int64 `json:"tcpRttMs,omitempty"` + TcpError string `json:"tcpError,omitempty"` + TcpErrorKind string `json:"tcpErrorKind,omitempty"` + TcpProbes int `json:"tcpProbes,omitempty"` + TcpProbeFails int `json:"tcpProbeFails,omitempty"` + CheckedAt string `json:"checkedAt"` +} + +// BootnodeHealthReport is the latest bootnode probe snapshot. +type BootnodeHealthReport struct { + Total int `json:"total"` + Healthy int `json:"healthy"` + Unhealthy int `json:"unhealthy"` + TcpHealthy int `json:"tcpHealthy"` + TcpUnhealthy int `json:"tcpUnhealthy"` + CheckedAt string `json:"checkedAt"` + Duration string `json:"duration"` + Bootnodes []BootnodeStatus `json:"bootnodes"` +} + +type workerClients struct { + udp *discv4.Client + tcp *rlpx.Client + udpInit error + tcpInit error +} + +// BootnodeChecker periodically probes bootnodes over UDP discv4 and TCP RLPx. +type BootnodeChecker struct { + loadNodes func() ([]*discv4.Node, error) + timeout time.Duration + parallel int + attempts int + minSuccess int + + mu sync.RWMutex + report BootnodeHealthReport + running bool +} + +// NewBootnodeChecker creates a checker that reloads the bootnode list before each check. +func NewBootnodeChecker(loadNodes func() ([]*discv4.Node, error), timeout time.Duration, parallel int) *BootnodeChecker { + if parallel < 1 { + parallel = 1 + } + return &BootnodeChecker{ + loadNodes: loadNodes, + timeout: timeout, + parallel: parallel, + attempts: probe.DefaultAttempts, + minSuccess: probe.DefaultMinSuccess, + } +} + +// Run starts the periodic checker until ctx is cancelled. It performs an +// immediate check on startup. +func (c *BootnodeChecker) Run(ctx context.Context, interval time.Duration) { + c.checkAll() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.checkAll() + } + } +} + +// CheckNow triggers a synchronous health check. +func (c *BootnodeChecker) CheckNow() { + c.checkAll() +} + +// Snapshot returns the latest health report. +func (c *BootnodeChecker) Snapshot() BootnodeHealthReport { + c.mu.RLock() + defer c.mu.RUnlock() + return c.report +} + +func (c *BootnodeChecker) checkAll() { + c.mu.Lock() + if c.running { + c.mu.Unlock() + return + } + c.running = true + c.mu.Unlock() + + defer func() { + c.mu.Lock() + c.running = false + c.mu.Unlock() + }() + + nodes, err := c.loadNodes() + if err != nil { + slog.Error("load bootnodes failed", "err", err) + return + } + if len(nodes) == 0 { + slog.Warn("no bootnodes loaded") + return + } + + start := time.Now() + results := make([]BootnodeStatus, len(nodes)) + + type job struct { + index int + node *discv4.Node + } + jobs := make(chan job) + + clients := make([]workerClients, c.parallel) + for i := 0; i < c.parallel; i++ { + clients[i].udp, clients[i].udpInit = discv4.NewClient() + clients[i].tcp, clients[i].tcpInit = rlpx.NewClient() + } + defer func() { + for _, wc := range clients { + if wc.udp != nil { + wc.udp.Close() + } + if wc.tcp != nil { + wc.tcp.Close() + } + } + }() + + var wg sync.WaitGroup + worker := func(wc workerClients) { + defer wg.Done() + for j := range jobs { + status := BootnodeStatus{ + Index: j.index + 1, + Enode: j.node.Enode(), + Endpoint: j.node.Endpoint(), + TcpEndpoint: j.node.TCPEndpoint(), + NodeID: formatNodeID(j.node.ID), + CheckedAt: time.Now().UTC().Format(time.RFC3339), + } + + var probeWG sync.WaitGroup + probeWG.Add(2) + + go func() { + defer probeWG.Done() + applyProbeSummary(&status, probeUDP(wc, j.node, c.timeout, c.attempts, c.minSuccess)) + }() + + go func() { + defer probeWG.Done() + applyTCPProbeSummary(&status, probeTCP(wc, j.node, c.timeout, c.attempts, c.minSuccess)) + }() + + probeWG.Wait() + results[j.index] = status + } + } + + for i := 0; i < c.parallel; i++ { + wg.Add(1) + go worker(clients[i]) + } + for i, n := range nodes { + jobs <- job{index: i, node: n} + } + close(jobs) + wg.Wait() + + c.storeReport(results, start) +} + +func probeUDP(wc workerClients, n *discv4.Node, timeout time.Duration, attempts, minSuccess int) probe.Summary { + if wc.udpInit != nil { + return probe.LocalSummary(wc.udpInit) + } + + tries := make([]probe.Attempt, 0, attempts) + for i := 0; i < attempts; i++ { + if i > 0 { + time.Sleep(150 * time.Millisecond) + } + rtt, err := wc.udp.Ping(n) + if err == nil { + tries = append(tries, probe.Attempt{Outcome: probe.OutcomeOK, RTT: rtt}) + continue + } + tries = append(tries, probe.Attempt{Outcome: probe.OutcomeFail, Message: err.Error()}) + } + return probe.Aggregate(tries, minSuccess) +} + +func probeTCP(wc workerClients, n *discv4.Node, timeout time.Duration, attempts, minSuccess int) probe.Summary { + if wc.tcpInit != nil { + return probe.LocalSummary(wc.tcpInit) + } + perProbe := timeout / time.Duration(attempts) + if perProbe < time.Second { + perProbe = time.Second + } + + tries := make([]probe.Attempt, 0, attempts) + for i := 0; i < attempts; i++ { + if i > 0 { + time.Sleep(150 * time.Millisecond) + } + out := wc.tcp.Dial(n, perProbe) + switch out.Outcome { + case rlpx.OutcomeOK: + tries = append(tries, probe.Attempt{ + Outcome: probe.OutcomeOK, + RTT: time.Duration(out.RTT) * time.Millisecond, + }) + case rlpx.OutcomeWarning: + tries = append(tries, probe.Attempt{ + Outcome: probe.OutcomeWarning, + Message: out.Message, + }) + default: + tries = append(tries, probe.Attempt{ + Outcome: probe.OutcomeFail, + Message: out.Message, + }) + } + } + return probe.Aggregate(tries, minSuccess) +} + +func applyProbeSummary(status *BootnodeStatus, s probe.Summary) { + status.Healthy = s.Healthy + status.RTTMs = s.RTTMs + status.Error = s.Error + status.ErrorKind = s.ErrorKind + status.Probes = s.Probes + status.ProbeFails = s.ProbeFails +} + +func applyTCPProbeSummary(status *BootnodeStatus, s probe.Summary) { + status.TcpHealthy = s.Healthy + status.TcpRTTMs = s.RTTMs + status.TcpError = s.Error + status.TcpErrorKind = s.ErrorKind + status.TcpProbes = s.Probes + status.TcpProbeFails = s.ProbeFails +} + +func (c *BootnodeChecker) storeReport(results []BootnodeStatus, start time.Time) { + healthy := 0 + tcpHealthy := 0 + for _, r := range results { + if r.Healthy { + healthy++ + } + if r.TcpHealthy { + tcpHealthy++ + } + } + + report := BootnodeHealthReport{ + Total: len(results), + Healthy: healthy, + Unhealthy: len(results) - healthy, + TcpHealthy: tcpHealthy, + TcpUnhealthy: len(results) - tcpHealthy, + CheckedAt: time.Now().UTC().Format(time.RFC3339), + Duration: time.Since(start).Round(time.Millisecond).String(), + Bootnodes: results, + } + + c.mu.Lock() + c.report = report + c.mu.Unlock() + + slog.Info("bootnode health check complete", + "total", report.Total, + "udp_healthy", report.Healthy, + "udp_unhealthy", report.Unhealthy, + "tcp_healthy", report.TcpHealthy, + "tcp_unhealthy", report.TcpUnhealthy, + "duration", report.Duration, + ) +} + +func formatNodeID(id discv4.NodeID) string { + return fmtHex(id[:]) +} + +func fmtHex(b []byte) string { + const hexdigits = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, v := range b { + out[i*2] = hexdigits[v>>4] + out[i*2+1] = hexdigits[v&0x0f] + } + return string(out) +} diff --git a/backend/internal/service/bootnode_load.go b/backend/internal/service/bootnode_load.go new file mode 100644 index 00000000..f00115fe --- /dev/null +++ b/backend/internal/service/bootnode_load.go @@ -0,0 +1,49 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/XinFinOrg/XDCStats/backend/config" + "github.com/XinFinOrg/XDCStats/backend/internal/discv4" +) + +// BootnodesURLForNetwork returns the XinFin-Node bootnodes.list URL for a network. +func BootnodesURLForNetwork(network string) (string, error) { + switch strings.ToLower(strings.TrimSpace(network)) { + case "mainnet", "": + return discv4.MainnetBootnodesURL, nil + case "testnet": + return discv4.TestnetBootnodesURL, nil + case "devnet": + return "", fmt.Errorf("devnet uses hardcoded bootnode (see devnetBootnode in bootnode_load.go)") + default: + return "", fmt.Errorf("unknown BOOTNODE_NETWORK %q (use mainnet, testnet, or devnet)", network) + } +} + +// TODO: replace devnet bootnode IP/enode with the official devnet bootnode when available. +const devnetBootnode = "enode://efc710050d65cda5d563fc6c9520986a69bbed81036b427a5ad7a2079bc8ba421e78d291a9388a1bd10ff5d9afdc2a095900528f5c45942104d818feda37cf7a@10.0.0.1:30303" + +func loadDevnetBootnodes() ([]*discv4.Node, error) { + n, err := discv4.ParseNode(devnetBootnode) + if err != nil { + return nil, err + } + return []*discv4.Node{n}, nil +} + +// LoadBootnodes loads bootnodes from the XinFin-Node GitHub list for the configured network. +func LoadBootnodes(cfg *config.Config) ([]*discv4.Node, error) { + if !cfg.EnableBootnodeHealth { + return nil, fmt.Errorf("bootnode health disabled") + } + if strings.EqualFold(strings.TrimSpace(cfg.BootnodeNetwork), "devnet") { + return loadDevnetBootnodes() + } + url, err := BootnodesURLForNetwork(cfg.BootnodeNetwork) + if err != nil { + return nil, err + } + return discv4.LoadNodesFromURL(url) +} diff --git a/backend/internal/service/bootnode_load_test.go b/backend/internal/service/bootnode_load_test.go new file mode 100644 index 00000000..574f3910 --- /dev/null +++ b/backend/internal/service/bootnode_load_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "testing" + + "github.com/XinFinOrg/XDCStats/backend/internal/discv4" +) + +func TestBootnodesURLForNetwork(t *testing.T) { + tests := []struct { + network string + want string + }{ + {"mainnet", discv4.MainnetBootnodesURL}, + {"", discv4.MainnetBootnodesURL}, + {"testnet", discv4.TestnetBootnodesURL}, + } + for _, tt := range tests { + got, err := BootnodesURLForNetwork(tt.network) + if err != nil { + t.Fatalf("network %q: %v", tt.network, err) + } + if got != tt.want { + t.Fatalf("network %q: got %q, want %q", tt.network, got, tt.want) + } + } + if _, err := BootnodesURLForNetwork("devnet"); err == nil { + t.Fatal("expected error for devnet URL lookup") + } + + nodes, err := loadDevnetBootnodes() + if err != nil { + t.Fatal(err) + } + if len(nodes) != 1 { + t.Fatalf("devnet: got %d nodes, want 1", len(nodes)) + } + if nodes[0].Endpoint() != "10.0.0.1:30303" { + t.Fatalf("devnet endpoint: got %q, want 10.0.0.1:30303", nodes[0].Endpoint()) + } +} diff --git a/backend/main.go b/backend/main.go index 84e76746..44be52b9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -21,6 +21,7 @@ import ( "github.com/XinFinOrg/XDCStats/backend/internal/api" "github.com/XinFinOrg/XDCStats/backend/internal/collection" "github.com/XinFinOrg/XDCStats/backend/internal/db" + "github.com/XinFinOrg/XDCStats/backend/internal/discv4" "github.com/XinFinOrg/XDCStats/backend/internal/geoip" "github.com/XinFinOrg/XDCStats/backend/internal/service" "github.com/XinFinOrg/XDCStats/backend/internal/ws" @@ -61,6 +62,26 @@ func main() { handler := api.NewHandler(nodes, cfg.AdminSecret) + var bootnodeChecker *service.BootnodeChecker + if cfg.EnableBootnodeHealth { + loader := func() ([]*discv4.Node, error) { + return service.LoadBootnodes(cfg) + } + bootnodeChecker = service.NewBootnodeChecker(loader, cfg.BootnodeTimeout, cfg.BootnodeParallel) + bootCtx, bootCancel := context.WithCancel(context.Background()) + defer bootCancel() + go bootnodeChecker.Run(bootCtx, cfg.BootnodeInterval) + + bh := api.NewBootnodeHandler(bootnodeChecker, cfg.AdminSecret) + handler.BootnodesHealth = bh.Health + handler.BootnodesCheck = bh.Check + + slog.Info("bootnode health enabled", + "network", cfg.BootnodeNetwork, + "interval", cfg.BootnodeInterval, + ) + } + // Forensics (optional) if cfg.EnableForensics { client, err := db.Connect(cfg.MongoDBURL) @@ -137,7 +158,7 @@ func requestLogger() gin.HandlerFunc { func corsMiddleware() gin.HandlerFunc { return func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Methods", "GET, OPTIONS") + c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, x-api-secret") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) diff --git a/docker-compose.yml b/docker-compose.yml index 5367a05a..d30271f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,11 @@ services: MONGODBURL: ${MONGODBURL:-localhost:27017} MASTERNODE_URL: ${MASTERNODE_URL:-https://master.xinfin.network/api} LOG_LEVEL: ${LOG_LEVEL:-info} + ENABLE_BOOTNODE_HEALTH: ${ENABLE_BOOTNODE_HEALTH:-true} + BOOTNODE_NETWORK: ${BOOTNODE_NETWORK:-mainnet} + BOOTNODE_CHECK_INTERVAL: ${BOOTNODE_CHECK_INTERVAL:-60} + BOOTNODE_CHECK_TIMEOUT: ${BOOTNODE_CHECK_TIMEOUT:-5} + BOOTNODE_CHECK_PARALLEL: ${BOOTNODE_CHECK_PARALLEL:-8} frontend: image: xinfinorg/xdcstats-frontend:acbc983 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 576c10a8..6eb11c67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,8 @@ import HistoryModal, { type HistoryMetric } from './components/HistoryModal'; import SparklineChart from './components/SparklineChart'; import BlockPropagationChart from './components/BlockPropagationChart'; import NodesTable from './components/NodesTable'; +import BootnodesPanel from './components/BootnodesPanel'; +import { useBootnodePolling } from './hooks/useBootnodePolling'; const MAX_BINS = 40; const API_URL = import.meta.env.VITE_API_URL ?? ''; @@ -157,6 +159,18 @@ const App: React.FC = () => { intervalMs: 5000, }); + const { + report: bootnodeReport, + loading: bootnodeLoading, + checking: bootnodeChecking, + error: bootnodeError, + disabled: bootnodeDisabled, + triggerCheck: triggerBootnodeCheck, + } = useBootnodePolling({ + apiUrl: API_URL, + enabled: Boolean(API_URL), + }); + // ─── Pin handler ───────────────────────────────────────────────────────── const handlePin = useCallback((id: string) => { @@ -333,6 +347,15 @@ const App: React.FC = () => { /> + + {/* Nodes Table */} void; +} + +const STORAGE_KEY = 'xdcstats_bootnodes_collapsed'; + +const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ + { value: 'all', label: 'All bootnodes' }, + { value: 'healthy', label: 'Healthy only (UDP + TCP)' }, + { value: 'unhealthy', label: 'Any issue (UDP or TCP)' }, +]; + +const selectStyle: React.CSSProperties = { + fontSize: 12, + fontWeight: 600, + padding: '6px 28px 6px 10px', + borderRadius: 6, + border: '2px solid #c5d4e8', + background: '#fff', + color: '#1e2a6e', + cursor: 'pointer', + appearance: 'none', + backgroundImage: + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7c93' d='M6 8L1 3h10z'/%3E%3C/svg%3E\")", + backgroundRepeat: 'no-repeat', + backgroundPosition: 'right 8px center', +}; + +function loadCollapsed(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +function saveCollapsed(collapsed: boolean) { + try { + localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0'); + } catch { + /* ignore */ + } +} + +function isFullyHealthy(node: BootnodeStatus): boolean { + return node.healthy && node.tcpHealthy; +} + +function hasAnyIssue(node: BootnodeStatus): boolean { + return !node.healthy || !node.tcpHealthy; +} + +function statusRank(healthy: boolean, errorKind?: string): number { + if (healthy) return 0; + if (errorKind === 'warning') return 1; + if (errorKind === 'local') return 2; + return 3; +} + +function formatProbeError( + label: string, + error?: string, + kind?: string, + probes?: number, + fails?: number, +) { + if (!error) return ''; + const meta = + probes != null && fails != null ? ` (${fails}/${probes} failed)` : ''; + const prefix = kind === 'local' ? `${label} probe` : label; + return `${prefix}: ${error}${meta}`; +} + +function formatErrors(node: BootnodeStatus) { + const parts: string[] = []; + const udp = formatProbeError('UDP', node.error, node.errorKind, node.probes, node.probeFails); + const tcp = formatProbeError( + 'TCP', + node.tcpError, + node.tcpErrorKind, + node.tcpProbes, + node.tcpProbeFails, + ); + if (udp) parts.push(udp); + if (tcp) parts.push(tcp); + if (!parts.length) return '–'; + return parts.join('\n'); +} + +function formatErrorsInline(node: BootnodeStatus) { + const text = formatErrors(node); + return text === '–' ? text : text.replace(/\n/g, ' · '); +} + +function truncateEnode(enode: string, max = 48) { + if (enode.length <= max) return enode; + return `${enode.slice(0, max)}…`; +} + +function displayAddress(node: BootnodeStatus): string { + if (!node.tcpEndpoint || node.endpoint === node.tcpEndpoint) { + return node.endpoint; + } + return node.tcpEndpoint; +} + +function addressTitle(node: BootnodeStatus): string | undefined { + if (!node.tcpEndpoint || node.endpoint === node.tcpEndpoint) { + return undefined; + } + return `UDP ${node.endpoint} · TCP ${node.tcpEndpoint}`; +} + +function matchesBootnodeSearch(node: BootnodeStatus, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return ( + node.enode.toLowerCase().includes(q) || + node.nodeId.toLowerCase().includes(q) || + node.endpoint.toLowerCase().includes(q) || + (node.tcpEndpoint || '').toLowerCase().includes(q) + ); +} + +function compareBootnodes( + a: BootnodeStatus, + b: BootnodeStatus, + key: BootnodeSortKey, + dir: SortDir, +): number { + let cmp = 0; + switch (key) { + case 'index': + cmp = a.index - b.index; + break; + case 'address': + cmp = displayAddress(a).localeCompare(displayAddress(b)); + break; + case 'udpStatus': + cmp = statusRank(a.healthy, a.errorKind) - statusRank(b.healthy, b.errorKind); + break; + case 'udpRtt': + cmp = (a.rttMs ?? -1) - (b.rttMs ?? -1); + break; + case 'tcpStatus': + cmp = + statusRank(a.tcpHealthy, a.tcpErrorKind) - + statusRank(b.tcpHealthy, b.tcpErrorKind); + break; + case 'tcpRtt': + cmp = (a.tcpRttMs ?? -1) - (b.tcpRttMs ?? -1); + break; + case 'enode': + cmp = a.enode.localeCompare(b.enode); + break; + case 'errors': + cmp = formatErrorsInline(a).localeCompare(formatErrorsInline(b)); + break; + } + return dir === 'asc' ? cmp : -cmp; +} + +function statusBadge(healthy: boolean, okLabel: string, errorKind?: string) { + if (healthy) { + return ( + + {okLabel} + + ); + } + if (errorKind === 'warning') { + return ( + + Warning + + ); + } + if (errorKind === 'local') { + return ( + + Probe error + + ); + } + return ( + + Unreachable + + ); +} + +function ErrorDetailModal({ + text, + onClose, +}: { + text: string; + onClose: () => void; +}) { + const copy = () => { + void navigator.clipboard.writeText(text); + }; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="bootnode-error-detail-title" + > +
+ + Probe errors + +
+ + +
+
+
+          {text}
+        
+
+
+ ); +} + +interface BootnodeRowProps { + node: BootnodeStatus; + onShowErrors: (text: string) => void; +} + +const BootnodeRow: React.FC = ({ node, onShowErrors }) => { + const errorsFull = formatErrors(node); + const errorsInline = formatErrorsInline(node); + const hasErrors = errorsFull !== '–'; + + return ( + + {node.index} + + {displayAddress(node)} + + {statusBadge(node.healthy, 'UDP OK', node.errorKind)} + + {node.healthy && node.rttMs != null ? `${node.rttMs} ms` : '–'} + + {statusBadge(node.tcpHealthy, 'TCP OK', node.tcpErrorKind)} + + {node.tcpHealthy && node.tcpRttMs != null ? `${node.tcpRttMs} ms` : '–'} + + + + {truncateEnode(node.enode, 56)} + + + + {hasErrors ? ( + + ) : ( + + )} + + + ); +}; + +const BootnodesPanel: React.FC = ({ + report, + loading, + checking, + error, + disabled, + onCheckNow, +}) => { + const [page, setPage] = useState(0); + const [filter, setFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[0]); + const [collapsed, setCollapsed] = useState(loadCollapsed); + const [activeSort, setActiveSort] = useState({ key: 'index', dir: 'asc' }); + const [errorDetail, setErrorDetail] = useState(null); + const [tooltip, setTooltip] = useState({ + visible: false, + x: 0, + y: 0, + html: '', + }); + const tooltipTimeout = useRef | null>(null); + + const showHeaderTooltip = useCallback((e: React.MouseEvent, text: string) => { + if (tooltipTimeout.current) clearTimeout(tooltipTimeout.current); + setTooltip({ + visible: true, + x: e.clientX + 14, + y: e.clientY - 10, + html: `
${text}
`, + }); + }, []); + + const hideTooltip = useCallback(() => { + tooltipTimeout.current = setTimeout(() => { + setTooltip((t) => ({ ...t, visible: false })); + }, 100); + }, []); + + const moveTooltip = useCallback( + (e: React.MouseEvent) => { + if (tooltip.visible) { + setTooltip((t) => ({ ...t, x: e.clientX + 14, y: e.clientY - 10 })); + } + }, + [tooltip.visible], + ); + + useEffect(() => { + return () => { + if (tooltipTimeout.current) clearTimeout(tooltipTimeout.current); + }; + }, []); + + const toggleCollapsed = () => { + setCollapsed((prev) => { + const next = !prev; + saveCollapsed(next); + return next; + }); + }; + + const handleSort = useCallback((key: BootnodeSortKey) => { + setActiveSort((prev) => { + if (prev.key === key) { + return { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }; + } + return { key, dir: 'asc' }; + }); + }, []); + + const allNodes = report?.bootnodes ?? []; + + const filteredNodes = useMemo(() => { + let nodes = allNodes; + switch (filter) { + case 'healthy': + nodes = nodes.filter(isFullyHealthy); + break; + case 'unhealthy': + nodes = nodes.filter(hasAnyIssue); + break; + } + const q = searchQuery.trim(); + if (q) { + nodes = nodes.filter((node) => matchesBootnodeSearch(node, q)); + } + return [...nodes].sort((a, b) => + compareBootnodes(a, b, activeSort.key, activeSort.dir), + ); + }, [allNodes, filter, searchQuery, activeSort]); + + const totalPages = Math.max(1, Math.ceil(filteredNodes.length / pageSize)); + + useEffect(() => { + setPage(0); + }, [report?.checkedAt, filter, pageSize, searchQuery, activeSort]); + + useEffect(() => { + if (page >= totalPages) { + setPage(Math.max(0, totalPages - 1)); + } + }, [page, totalPages]); + + const pagedNodes = useMemo( + () => filteredNodes.slice(page * pageSize, (page + 1) * pageSize), + [filteredNodes, page, pageSize], + ); + + if (disabled) { + return null; + } + + const healthy = report?.healthy ?? 0; + const tcpHealthy = report?.tcpHealthy ?? 0; + const total = report?.total ?? 0; + const allUdpHealthy = total > 0 && healthy === total; + const allTcpHealthy = total > 0 && tcpHealthy === total; + const busy = checking || (loading && !report); + + const SortIcon: React.FC<{ colKey: BootnodeSortKey }> = ({ colKey }) => { + if (activeSort.key !== colKey) { + return ; + } + return {activeSort.dir === 'asc' ? '↑' : '↓'}; + }; + + return ( +
+
+
+
+

+ Bootnode Health +

+ {collapsed && !loading && report && ( + 0 || tcpHealthy > 0 + ? '#fff8e6' + : '#fdecea', + color: + allUdpHealthy && allTcpHealthy + ? '#29b348' + : healthy > 0 || tcpHealthy > 0 + ? '#f5b225' + : '#e74c3c', + }} + > + UDP {healthy}/{total} · TCP {tcpHealthy}/{total} + + )} +
+ + {!collapsed && ( + setSearchQuery(e.target.value)} + className="text-sm font-semibold px-3 py-1.5 rounded-md border-2 border-blue-400 bg-white text-blue-900 placeholder-blue-300 focus:outline-none focus:border-blue-600" + style={{ minWidth: 300 }} + aria-label="Search bootnodes" + /> + )} + +
+ {!collapsed && ( + <> + + + + + )} + + + + +
+
+ + {!collapsed && ( + <> +
+ {loading && !report ? ( + Waiting for first bootnode check… + ) : ( + <> + + UDP + 0 ? '#f5b225' : '#e74c3c' }} + > + {healthy} + + /{total} + + + TCP + 0 ? '#f5b225' : '#e74c3c', + }} + > + {tcpHealthy} + + /{total} + + {filter !== 'all' && Showing {filteredNodes.length} filtered} + {searchQuery.trim() && ( + + Search: {filteredNodes.length} match{filteredNodes.length === 1 ? '' : 'es'} + + )} + {report?.checkedAt && ( + + Last check: {new Date(report.checkedAt).toLocaleString()} + + )} + {report?.duration && ({report.duration})} + + )} + {error && {error}} +
+ + {totalPages > 1 && ( +
+ + {page * pageSize + 1}–{Math.min((page + 1) * pageSize, filteredNodes.length)} of{' '} + {filteredNodes.length} bootnodes + +
+ + + Page {page + 1} / {totalPages} + + +
+
+ )} + +
+ + + + {BOOTNODE_COLUMNS.map((col) => ( + + ))} + + + + {pagedNodes.length ? ( + pagedNodes.map((node) => ( + + )) + ) : ( + + + + )} + +
handleSort(col.key) : undefined} + onMouseEnter={(e) => showHeaderTooltip(e, col.tooltip)} + onMouseLeave={hideTooltip} + onMouseMove={moveTooltip} + > + {col.label} + {col.sortable && } +
+ {busy + ? 'Running bootnode check…' + : searchQuery.trim() + ? 'No bootnodes match your search.' + : filter === 'all' + ? 'No bootnode results yet' + : 'No bootnodes match this filter'} +
+
+ + {tooltip.visible && ( +
+ )} + + {errorDetail && ( + setErrorDetail(null)} + /> + )} + + )} +
+
+ ); +}; + +export default BootnodesPanel; diff --git a/frontend/src/hooks/useBootnodePolling.ts b/frontend/src/hooks/useBootnodePolling.ts new file mode 100644 index 00000000..32acc23c --- /dev/null +++ b/frontend/src/hooks/useBootnodePolling.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { BootnodeHealthReport } from '../types'; + +interface UseBootnodePollingOptions { + apiUrl: string; + intervalMs?: number; + enabled?: boolean; + adminSecret?: string; +} + +export function useBootnodePolling({ + apiUrl, + intervalMs = 30000, + enabled = true, + adminSecret = '', +}: UseBootnodePollingOptions) { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); + const [error, setError] = useState(null); + const [disabled, setDisabled] = useState(false); + + const reportRef = useRef(report); + reportRef.current = report; + + const fetchHealth = useCallback(async (): Promise => { + const res = await fetch(`${apiUrl}/v2/bootnodes/health`); + if (res.status === 503) { + setDisabled(true); + setError(null); + setLoading(false); + return null; + } + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const data: BootnodeHealthReport = await res.json(); + setReport(data); + setError(null); + setLoading(false); + return data; + }, [apiUrl]); + + useEffect(() => { + if (!enabled) { + setLoading(false); + return; + } + + let cancelled = false; + let inFlight = false; + + const poll = async () => { + if (inFlight || disabled) return; + inFlight = true; + try { + await fetchHealth(); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'fetch failed'); + setLoading(false); + } + } finally { + inFlight = false; + } + }; + + poll(); + const timer = setInterval(poll, intervalMs); + + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [apiUrl, intervalMs, enabled, disabled, fetchHealth]); + + const refresh = useCallback(async () => { + try { + await fetchHealth(); + } catch (err) { + setError(err instanceof Error ? err.message : 'fetch failed'); + } + }, [fetchHealth]); + + const triggerCheck = useCallback(async () => { + if (checking || disabled) return; + + const previousCheckedAt = reportRef.current?.checkedAt ?? ''; + setChecking(true); + setError(null); + + try { + const headers: HeadersInit = {}; + if (adminSecret) { + headers['x-api-secret'] = adminSecret; + } + const res = await fetch(`${apiUrl}/v2/bootnodes/check`, { + method: 'POST', + headers, + }); + if (res.status === 401) { + throw new Error('Check requires admin secret'); + } + if (!res.ok && res.status !== 202) { + throw new Error(`HTTP ${res.status}`); + } + + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)); + const data = await fetchHealth(); + if (data?.checkedAt && data.checkedAt !== previousCheckedAt) { + return; + } + } + throw new Error('Check timed out waiting for results'); + } catch (err) { + setError(err instanceof Error ? err.message : 'check failed'); + } finally { + setChecking(false); + } + }, [apiUrl, adminSecret, checking, disabled, fetchHealth]); + + return { report, loading, checking, error, disabled, refresh, triggerCheck }; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f160ae25..ad9e74c1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -83,3 +83,35 @@ export interface SortConfig { key: string; direction: SortDirection; } + +export interface BootnodeStatus { + index: number; + enode: string; + endpoint: string; + tcpEndpoint: string; + nodeId: string; + healthy: boolean; + rttMs?: number; + error?: string; + errorKind?: string; + probes?: number; + probeFails?: number; + tcpHealthy: boolean; + tcpRttMs?: number; + tcpError?: string; + tcpErrorKind?: string; + tcpProbes?: number; + tcpProbeFails?: number; + checkedAt: string; +} + +export interface BootnodeHealthReport { + total: number; + healthy: number; + unhealthy: number; + tcpHealthy: number; + tcpUnhealthy: number; + checkedAt: string; + duration: string; + bootnodes: BootnodeStatus[]; +} diff --git a/start-wizard.sh b/start-wizard.sh index e0d117c6..97bac8aa 100755 --- a/start-wizard.sh +++ b/start-wizard.sh @@ -90,6 +90,7 @@ case "$ENV_NAME" in esac export VITE_API_URL +export BOOTNODE_NETWORK="$ENV_NAME" # ── preview ─────────────────────────────────────────────────────────────────── printf "\n" @@ -106,6 +107,7 @@ printf " ${CYAN}%-24s${NC} ${GREEN}%s${NC}\n" "ADMIN_SECRET" "${ADMIN_SECR printf " ${CYAN}%-24s${NC} ${GREEN}%s${NC}\n" "ENABLE_FORENSICS" "${ENABLE_FORENSICS:-false}" printf " ${CYAN}%-24s${NC} ${GREEN}%s${NC}\n" "MONGODBURL" "${MONGODBURL:-localhost:27017}" printf " ${CYAN}%-24s${NC} ${GREEN}%s${NC}\n" "MASTERNODE_URL" "${MASTERNODE_URL:-https://master.xinfin.network/api}" +printf " ${CYAN}%-24s${NC} ${GREEN}%s${NC}\n" "BOOTNODE_NETWORK" "${BOOTNODE_NETWORK:-mainnet}" printf " ${CYAN}%-24s${NC} ${GREEN}%s${NC}\n" "LOG_LEVEL" "${LOG_LEVEL:-info}" printf "\n ${DIM}Override any value by exporting it before running this script.${NC}\n"