diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57a0d2a7..c89445e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: strategy: fail-fast: false matrix: - goversion: ["1.21", "1.22", "1.23"] + goversion: ["1.22", "1.23", "1.24"] name: Build & Test (Linux, Go ${{ matrix.goversion }}) needs: [lint] @@ -75,7 +75,7 @@ jobs: strategy: fail-fast: false matrix: - goversion: ["1.21", "1.22", "1.23"] + goversion: ["1.22", "1.23", "1.24"] name: Build & Test (Windows, Go ${{ matrix.goversion }}) needs: [lint] @@ -99,7 +99,7 @@ jobs: strategy: fail-fast: false matrix: - goversion: ["1.21", "1.22", "1.23"] + goversion: ["1.22", "1.23", "1.24"] name: Build & Test (macOS, Go ${{ matrix.goversion }}) needs: [lint] @@ -123,7 +123,7 @@ jobs: strategy: fail-fast: false matrix: - goversion: ["1.21", "1.22", "1.23"] + goversion: ["1.22", "1.23", "1.24"] goos: - freebsd - openbsd diff --git a/CHANGELOG.md b/CHANGELOG.md index 8169936a..c7ac1d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - in case of vulnerabilities. --> +## [0.5.12] - 2024-12-18 + +* Go 1.22 is now required to build Yggdrasil + +### Changed + +* The `latency_ms` field in the admin socket `getPeers` response has been renamed to `latency` + +### Fixed + +* A timing regression which causes a higher level of idle protocol traffic on each peering has been fixed +* The `-user` flag now correctly detects an empty user/group specification + ## [0.5.11] - 2024-12-12 ### Added diff --git a/README.md b/README.md index 8449f073..ea0e1a38 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ or tools in the `contrib` folder. If you want to build from source, as opposed to installing one of the pre-built packages: -1. Install [Go](https://golang.org) (requires Go 1.21 or later) +1. Install [Go](https://golang.org) (requires Go 1.22 or later) 2. Clone this repository 2. Run `./build` diff --git a/cmd/genkeys/main.go b/cmd/genkeys/main.go index d1a15346..2d007cb8 100644 --- a/cmd/genkeys/main.go +++ b/cmd/genkeys/main.go @@ -26,6 +26,7 @@ import ( type keySet struct { priv ed25519.PrivateKey pub ed25519.PublicKey + count uint64 } func main() { @@ -36,6 +37,8 @@ func main() { threads := runtime.GOMAXPROCS(0) fmt.Println("Threads:", threads) start := time.Now() + var totalKeys uint64 + totalKeys = 0 var currentBest ed25519.PublicKey newKeys := make(chan keySet, threads) for i := 0; i < threads; i++ { @@ -44,8 +47,9 @@ func main() { for { newKey := <-newKeys if isBetter(currentBest, newKey.pub) || len(currentBest) == 0 { + totalKeys += newKey.count currentBest = newKey.pub - fmt.Println("-----", time.Since(start)) + fmt.Println("-----", time.Since(start), "---", totalKeys, "keys tried") fmt.Println("Priv:", hex.EncodeToString(newKey.priv)) fmt.Println("Pub:", hex.EncodeToString(newKey.pub)) addr := address.AddrForKey(newKey.pub) @@ -68,11 +72,14 @@ func isBetter(oldPub, newPub ed25519.PublicKey) bool { func doKeys(out chan<- keySet) { bestKey := make(ed25519.PublicKey, ed25519.PublicKeySize) + var count uint64 + count = 0 for idx := range bestKey { bestKey[idx] = 0xff } for { pub, priv, err := ed25519.GenerateKey(nil) + count++ if err != nil { panic(err) } @@ -80,6 +87,7 @@ func doKeys(out chan<- keySet) { continue } bestKey = pub - out <- keySet{priv, pub} + out <- keySet{priv, pub, count} + count = 0 } } diff --git a/cmd/yggdrasil/chuser_unix.go b/cmd/yggdrasil/chuser_unix.go index fc3e5c2c..24a706df 100644 --- a/cmd/yggdrasil/chuser_unix.go +++ b/cmd/yggdrasil/chuser_unix.go @@ -14,6 +14,12 @@ import ( func chuser(input string) error { givenUser, givenGroup, _ := strings.Cut(input, ":") + if givenUser == "" { + return fmt.Errorf("user is empty") + } + if strings.Contains(input, ":") && givenGroup == "" { + return fmt.Errorf("group is empty") + } var ( err error diff --git a/cmd/yggdrasil/chuser_unix_test.go b/cmd/yggdrasil/chuser_unix_test.go index ad2e3517..fc624ac2 100644 --- a/cmd/yggdrasil/chuser_unix_test.go +++ b/cmd/yggdrasil/chuser_unix_test.go @@ -4,33 +4,33 @@ package main import ( - "testing" "os/user" + "testing" ) // Usernames must not contain a number sign. -func TestEmptyString (t *testing.T) { +func TestEmptyString(t *testing.T) { if chuser("") == nil { t.Fatal("the empty string is not a valid user") } } // Either omit delimiter and group, or omit both. -func TestEmptyGroup (t *testing.T) { +func TestEmptyGroup(t *testing.T) { if chuser("0:") == nil { t.Fatal("the empty group is not allowed") } } // Either user only or user and group. -func TestGroupOnly (t *testing.T) { +func TestGroupOnly(t *testing.T) { if chuser(":0") == nil { t.Fatal("group only is not allowed") } } // Usenames must not contain the number sign. -func TestInvalidUsername (t *testing.T) { +func TestInvalidUsername(t *testing.T) { const username = "#user" if chuser(username) == nil { t.Fatalf("'%s' is not a valid username", username) @@ -38,14 +38,14 @@ func TestInvalidUsername (t *testing.T) { } // User IDs must be non-negative. -func TestInvalidUserid (t *testing.T) { +func TestInvalidUserid(t *testing.T) { if chuser("-1") == nil { t.Fatal("User ID cannot be negative") } } // Change to the current user by ID. -func TestCurrentUserid (t *testing.T) { +func TestCurrentUserid(t *testing.T) { usr, err := user.Current() if err != nil { t.Fatal(err) @@ -61,7 +61,7 @@ func TestCurrentUserid (t *testing.T) { } // Change to a common user by name. -func TestCommonUsername (t *testing.T) { +func TestCommonUsername(t *testing.T) { usr, err := user.Current() if err != nil { t.Fatal(err) diff --git a/cmd/yggdrasil/main.go b/cmd/yggdrasil/main.go index b3cbecf0..b3c9151d 100644 --- a/cmd/yggdrasil/main.go +++ b/cmd/yggdrasil/main.go @@ -312,6 +312,21 @@ func main() { } } + // Promise final modes of operation. At this point, if at all: + // - raw socket is created/open + // - admin socket is created/open + // - privileges are dropped to non-root user + // + // Peers, InterfacePeers, Listen can be UNIX sockets; + // Go's net.Listen.Close() deletes files on shutdown. + promises := []string{"stdio", "cpath", "inet", "unix", "dns"} + if len(cfg.MulticastInterfaces) > 0 { + promises = append(promises, "mcast") + } + if err := protect.Pledge(strings.Join(promises, " ")); err != nil { + panic(fmt.Sprintf("pledge: %v: %v", promises, err)) + } + // Block until we are told to shut down. <-ctx.Done() diff --git a/contrib/apparmor/usr.bin.yggdrasilctl b/contrib/apparmor/usr.bin.yggdrasilctl new file mode 100644 index 00000000..2f2c8366 --- /dev/null +++ b/contrib/apparmor/usr.bin.yggdrasilctl @@ -0,0 +1,11 @@ +# Last Modified: Mon Feb 3 22:19:45 2025 +include + +/usr/bin/yggdrasilctl { + include + + /etc/yggdrasil.conf rw, + /run/yggdrasil.sock rw, + owner /sys/kernel/mm/transparent_hugepage/hpage_pmd_size r, + +} diff --git a/contrib/mobile/mobile.go b/contrib/mobile/mobile.go index abc89f1c..72ea7d68 100644 --- a/contrib/mobile/mobile.go +++ b/contrib/mobile/mobile.go @@ -1,6 +1,7 @@ package mobile import ( + "crypto/ed25519" "encoding/hex" "encoding/json" "net" @@ -273,3 +274,28 @@ func (m *Yggdrasil) GetMTU() int { func GetVersion() string { return version.BuildVersion() } + +type ConfigSummary struct { + PublicKey string + IPv6Address string + IPv6Subnet string +} + +func SummaryForConfig(b []byte) *ConfigSummary { + cfg := config.GenerateConfig() + if err := cfg.UnmarshalHJSON(b); err != nil { + return nil + } + pub := ed25519.PrivateKey(cfg.PrivateKey).Public().(ed25519.PublicKey) + hpub := hex.EncodeToString(pub) + addr := net.IP(address.AddrForKey(pub)[:]) + snet := net.IPNet{ + IP: append(address.SubnetForKey(pub)[:], 0, 0, 0, 0, 0, 0, 0, 0), + Mask: net.CIDRMask(64, 128), + } + return &ConfigSummary{ + PublicKey: hpub, + IPv6Address: addr.String(), + IPv6Subnet: snet.String(), + } +} diff --git a/contrib/openrc/yggdrasil b/contrib/openrc/yggdrasil index 4a2e0a13..aece8ecb 100755 --- a/contrib/openrc/yggdrasil +++ b/contrib/openrc/yggdrasil @@ -6,7 +6,6 @@ CONFFILE="/etc/yggdrasil.conf" pidfile="/run/${RC_SVCNAME}.pid" command="/usr/bin/yggdrasil" -extra_started_commands="reload" depend() { use net dns logger @@ -42,12 +41,6 @@ start() { eend $? } -reload() { - ebegin "Reloading ${RC_SVCNAME}" - start-stop-daemon --signal HUP --pidfile "${pidfile}" - eend $? -} - stop() { ebegin "Stopping ${RC_SVCNAME}" start-stop-daemon --stop --pidfile "${pidfile}" --exec "${command}" diff --git a/go.mod b/go.mod index a77ca47d..672edd6e 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module github.com/yggdrasil-network/yggdrasil-go -go 1.21 +go 1.22 require ( - github.com/Arceliar/ironwood v0.0.0-20241210120540-9deb08d9f8f9 + github.com/Arceliar/ironwood v0.0.0-20241213013129-743fe2fccbd3 github.com/Arceliar/phony v0.0.0-20220903101357-530938a4b13d github.com/cheggaaa/pb/v3 v3.1.5 github.com/coder/websocket v1.8.12 @@ -11,13 +11,13 @@ require ( github.com/hashicorp/go-syslog v1.0.0 github.com/hjson/hjson-go/v4 v4.4.0 github.com/kardianos/minwinsvc v1.0.2 - github.com/quic-go/quic-go v0.46.0 + github.com/quic-go/quic-go v0.48.2 github.com/vishvananda/netlink v1.3.0 github.com/wlynxg/anet v0.0.5 - golang.org/x/crypto v0.31.0 - golang.org/x/net v0.32.0 - golang.org/x/sys v0.28.0 - golang.org/x/text v0.21.0 + golang.org/x/crypto v0.33.0 + golang.org/x/net v0.35.0 + golang.org/x/sys v0.30.0 + golang.org/x/text v0.22.0 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -31,10 +31,10 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/rivo/uniseg v0.2.0 // indirect - go.uber.org/mock v0.4.0 // indirect + go.uber.org/mock v0.5.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/mod v0.19.0 // indirect - golang.org/x/sync v0.10.0 // indirect + golang.org/x/sync v0.11.0 // indirect golang.org/x/tools v0.23.0 // indirect ) diff --git a/go.sum b/go.sum index 8aae0a77..d843af40 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Arceliar/ironwood v0.0.0-20241210120540-9deb08d9f8f9 h1:myI8fs7+Iw6g/ywvY9QNQOEzny51AklMz4sF0ErtTm8= -github.com/Arceliar/ironwood v0.0.0-20241210120540-9deb08d9f8f9/go.mod h1:SrrElc3FFMpYCODSr11jWbLFeOM8WsY+DbDY/l2AXF0= +github.com/Arceliar/ironwood v0.0.0-20241213013129-743fe2fccbd3 h1:d8N0z+udAnbU5PdjpLSNPTWlqeU/nnYsQ42B6+879aw= +github.com/Arceliar/ironwood v0.0.0-20241213013129-743fe2fccbd3/go.mod h1:SrrElc3FFMpYCODSr11jWbLFeOM8WsY+DbDY/l2AXF0= github.com/Arceliar/phony v0.0.0-20220903101357-530938a4b13d h1:UK9fsWbWqwIQkMCz1CP+v5pGbsGoWAw6g4AyvMpm1EM= github.com/Arceliar/phony v0.0.0-20220903101357-530938a4b13d/go.mod h1:BCnxhRf47C/dy/e/D2pmB8NkB3dQVIrkD98b220rx5Q= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= @@ -58,13 +58,14 @@ github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 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/quic-go/quic-go v0.46.0 h1:uuwLClEEyk1DNvchH8uCByQVjo3yKL9opKulExNDs7Y= -github.com/quic-go/quic-go v0.46.0/go.mod h1:1dLehS7TIR64+vxGR70GDcatWTOtMX2PUtnKsjbTurI= +github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE= +github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg= github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= @@ -74,28 +75,28 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= -golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= -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/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -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/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= diff --git a/src/address/address.go b/src/address/address.go index d56be80d..c9581c70 100644 --- a/src/address/address.go +++ b/src/address/address.go @@ -113,7 +113,7 @@ func SubnetForKey(publicKey ed25519.PublicKey) *Subnet { return &snet } -// GetKet returns the partial ed25519.PublicKey for the Address. +// GetKey returns the partial ed25519.PublicKey for the Address. // This is used for key lookup. func (a *Address) GetKey() ed25519.PublicKey { var key [ed25519.PublicKeySize]byte @@ -141,7 +141,7 @@ func (a *Address) GetKey() ed25519.PublicKey { return ed25519.PublicKey(key[:]) } -// GetKet returns the partial ed25519.PublicKey for the Subnet. +// GetKey returns the partial ed25519.PublicKey for the Subnet. // This is used for key lookup. func (s *Subnet) GetKey() ed25519.PublicKey { var addr Address diff --git a/src/admin/getpeers.go b/src/admin/getpeers.go index 34eca243..0384b792 100644 --- a/src/admin/getpeers.go +++ b/src/admin/getpeers.go @@ -31,7 +31,7 @@ type PeerEntry struct { RXRate DataUnit `json:"rate_recvd,omitempty"` TXRate DataUnit `json:"rate_sent,omitempty"` Uptime float64 `json:"uptime,omitempty"` - Latency time.Duration `json:"latency_ms,omitempty"` + Latency time.Duration `json:"latency,omitempty"` LastErrorTime time.Duration `json:"last_error_time,omitempty"` LastError string `json:"last_error,omitempty"` }