├── .travis.yml ├── README.md ├── websocket.go ├── LICENSE.md ├── client_test.go ├── websocket_test.go ├── client.go ├── client_stats.go ├── stat.go └── stat_test.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.6 4 | before_install: 5 | - go get github.com/axw/gocov/gocov 6 | - go get github.com/mattn/goveralls 7 | - go get golang.org/x/tools/cmd/cover 8 | - go get github.com/golang/lint/golint 9 | before_script: 10 | - go get -d ./... 11 | script: 12 | - golint ./... 13 | - go vet ./... 14 | - go test -v ./... 15 | - if ! $HOME/gopath/bin/goveralls -service=travis-ci -repotoken $COVERALLS_TOKEN; then echo "Coveralls not available."; fi 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | edgemax [![GoDoc](http://godoc.org/github.com/mdlayher/edgemax?status.svg)](http://godoc.org/github.com/mdlayher/edgemax) [![Build Status](https://travis-ci.org/mdlayher/edgemax.svg?branch=master)](https://travis-ci.org/mdlayher/edgemax) [![Coverage Status](https://coveralls.io/repos/mdlayher/edgemax/badge.svg?branch=master)](https://coveralls.io/r/mdlayher/edgemax?branch=master) [![Report Card](http://goreportcard.com/badge/mdlayher/edgemax)](http://goreportcard.com/report/mdlayher/edgemax) 2 | ======= 3 | 4 | Package `edgemax` implements a client for Ubiquiti EdgeMAX devices. 5 | MIT Licensed. 6 | -------------------------------------------------------------------------------- /websocket.go: -------------------------------------------------------------------------------- 1 | package edgemax 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "strconv" 8 | ) 9 | 10 | func wsMarshal(v interface{}) ([]byte, byte, error) { 11 | b, err := json.Marshal(v) 12 | if err != nil { 13 | return nil, 0, err 14 | } 15 | 16 | blen := []byte(strconv.Itoa(len(b)) + "\n") 17 | return append(blen, b...), 0, nil 18 | } 19 | 20 | func wsUnmarshal(data []byte, _ byte, v interface{}) error { 21 | if data[0] == '{' { 22 | return json.Unmarshal(data, v) 23 | } 24 | 25 | bb := bytes.SplitN(data, []byte("\n"), 2) 26 | if l := len(bb); l != 2 { 27 | return fmt.Errorf("incorrect number of elements in websocket message: %d", l) 28 | } 29 | 30 | if len(bb[1]) == 0 { 31 | return nil 32 | } 33 | 34 | return json.Unmarshal(bb[1], v) 35 | } 36 | 37 | type wsName struct { 38 | Name StatType `json:"name"` 39 | } 40 | 41 | type wsRequest struct { 42 | Subscribe []wsName `json:"SUBSCRIBE"` 43 | Unsubscribe []wsName `json:"UNSUBSCRIBE"` 44 | SessionID string `json:"SESSION_ID"` 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | =========== 3 | 4 | Copyright (C) 2016 Matt Layher 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package edgemax 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "reflect" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestClientRetainsCookies(t *testing.T) { 12 | const cookieName = "foo" 13 | wantCookie := &http.Cookie{ 14 | Name: cookieName, 15 | Value: "bar", 16 | } 17 | 18 | var i int 19 | c, done := testClient(t, func(w http.ResponseWriter, r *http.Request) { 20 | defer func() { i++ }() 21 | 22 | switch i { 23 | case 0: 24 | http.SetCookie(w, wantCookie) 25 | case 1: 26 | c, err := r.Cookie(cookieName) 27 | if err != nil { 28 | t.Fatalf("unexpected error: %v", err) 29 | } 30 | 31 | if want, got := wantCookie, c; !reflect.DeepEqual(want, got) { 32 | t.Fatalf("unexpected cookie:\n- want: %v\n- got: %v", 33 | want, got) 34 | } 35 | } 36 | 37 | _, _ = w.Write([]byte(`{}`)) 38 | }) 39 | defer done() 40 | 41 | for i := 0; i < 2; i++ { 42 | req, err := c.newRequest(http.MethodGet, "/") 43 | if err != nil { 44 | t.Fatalf("unexpected error: %v", err) 45 | } 46 | 47 | _, err = c.do(req, nil) 48 | if err != nil { 49 | t.Fatalf("unexpected error: %v", err) 50 | } 51 | } 52 | } 53 | 54 | func TestClientLogin(t *testing.T) { 55 | const ( 56 | wantUsername = "username" 57 | wantPassword = "password" 58 | ) 59 | 60 | h := testHandler(t, http.MethodPost, "/") 61 | c, done := testClient(t, func(w http.ResponseWriter, r *http.Request) { 62 | h(w, r) 63 | 64 | username := r.PostFormValue("username") 65 | password := r.PostFormValue("password") 66 | 67 | if want, got := wantUsername, username; want != got { 68 | t.Fatalf("unexpected username:\n- want: %v\n- got: %v", want, got) 69 | } 70 | 71 | if want, got := wantPassword, password; want != got { 72 | t.Fatalf("unexpected password:\n- want: %v\n- got: %v", want, got) 73 | } 74 | }) 75 | defer done() 76 | 77 | if err := c.Login(wantUsername, wantPassword); err != nil { 78 | t.Fatalf("unexpected error from Client.Login: %v", err) 79 | } 80 | } 81 | 82 | func TestInsecureHTTPClient(t *testing.T) { 83 | timeout := 5 * time.Second 84 | c := InsecureHTTPClient(timeout) 85 | 86 | if want, got := c.Timeout, timeout; want != got { 87 | t.Fatalf("unexpected client timeout:\n- want: %v\n- got: %v", 88 | want, got) 89 | } 90 | 91 | got := c.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify 92 | if want := true; want != got { 93 | t.Fatalf("unexpected client insecure skip verify value:\n- want: %v\n- got: %v", 94 | want, got) 95 | } 96 | } 97 | 98 | func testClient(t *testing.T, fn func(w http.ResponseWriter, r *http.Request)) (*Client, func()) { 99 | s := httptest.NewServer(http.HandlerFunc(fn)) 100 | 101 | c, err := NewClient(s.URL, nil) 102 | if err != nil { 103 | t.Fatalf("error creating Client: %v", err) 104 | } 105 | 106 | return c, func() { s.Close() } 107 | } 108 | 109 | func testHandler(t *testing.T, method string, path string) http.HandlerFunc { 110 | return func(w http.ResponseWriter, r *http.Request) { 111 | if want, got := method, r.Method; want != got { 112 | t.Fatalf("unexpected HTTP method:\n- want: %v\n- got: %v", want, got) 113 | } 114 | 115 | if want, got := path, r.URL.Path; want != got { 116 | t.Fatalf("unexpected URL path:\n- want: %v\n- got: %v", want, got) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /websocket_test.go: -------------------------------------------------------------------------------- 1 | package edgemax 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func Test_wsMarshal(t *testing.T) { 11 | var tests = []struct { 12 | desc string 13 | wsr wsRequest 14 | out []byte 15 | }{ 16 | { 17 | desc: "empty request", 18 | wsr: wsRequest{}, 19 | out: append([]byte("53\n"), `{"SUBSCRIBE":null,"UNSUBSCRIBE":null,"SESSION_ID":""}`...), 20 | }, 21 | { 22 | desc: "subscribe to two streams", 23 | wsr: wsRequest{ 24 | Subscribe: []wsName{ 25 | {Name: "foo"}, 26 | {Name: "bar"}, 27 | }, 28 | SessionID: "baz", 29 | }, 30 | out: append([]byte("83\n"), `{"SUBSCRIBE":[{"name":"foo"},{"name":"bar"}],"UNSUBSCRIBE":null,"SESSION_ID":"baz"}`...), 31 | }, 32 | { 33 | desc: "unsubscribe from one stream", 34 | wsr: wsRequest{ 35 | Unsubscribe: []wsName{ 36 | {Name: "foo"}, 37 | }, 38 | SessionID: "bar", 39 | }, 40 | out: append([]byte("68\n"), `{"SUBSCRIBE":null,"UNSUBSCRIBE":[{"name":"foo"}],"SESSION_ID":"bar"}`...), 41 | }, 42 | } 43 | 44 | for i, tt := range tests { 45 | t.Logf("[%02d] test %q", i, tt.desc) 46 | 47 | out, pType, err := wsMarshal(tt.wsr) 48 | if err != nil { 49 | t.Fatalf("unexpected error: %v", err) 50 | } 51 | if want, got := byte(0), pType; want != got { 52 | t.Fatalf("unexpected payload type:\n- want: %v\n- got: %v", want, got) 53 | } 54 | 55 | if want, got := tt.out, out; !bytes.Equal(want, got) { 56 | t.Fatalf("unexpected output:\n- want: %q\n- got: %q", string(want), string(got)) 57 | } 58 | } 59 | } 60 | 61 | func Test_wsUnmarshal(t *testing.T) { 62 | var tests = []struct { 63 | desc string 64 | in []byte 65 | wsr wsRequest 66 | err error 67 | }{ 68 | { 69 | desc: "incorrect number of newlines", 70 | in: []byte("foo"), 71 | err: errors.New("incorrect number of elements in websocket message: 1"), 72 | }, 73 | { 74 | desc: "no JSON object present", 75 | in: []byte("3\n"), 76 | wsr: wsRequest{}, 77 | }, 78 | { 79 | desc: "empty request with no length", 80 | in: []byte(`{"SUBSCRIBE":null,"UNSUBSCRIBE":null,"SESSION_ID":""}`), 81 | wsr: wsRequest{}, 82 | }, 83 | { 84 | desc: "empty request", 85 | in: append([]byte("53\n"), `{"SUBSCRIBE":null,"UNSUBSCRIBE":null,"SESSION_ID":""}`...), 86 | wsr: wsRequest{}, 87 | }, 88 | { 89 | desc: "subscribe to two streams", 90 | in: append([]byte("83\n"), `{"SUBSCRIBE":[{"name":"foo"},{"name":"bar"}],"UNSUBSCRIBE":null,"SESSION_ID":"baz"}`...), 91 | wsr: wsRequest{ 92 | Subscribe: []wsName{ 93 | {Name: "foo"}, 94 | {Name: "bar"}, 95 | }, 96 | SessionID: "baz", 97 | }, 98 | }, 99 | { 100 | desc: "unsubscribe from one stream", 101 | in: append([]byte("68\n"), `{"SUBSCRIBE":null,"UNSUBSCRIBE":[{"name":"foo"}],"SESSION_ID":"bar"}`...), 102 | wsr: wsRequest{ 103 | Unsubscribe: []wsName{ 104 | {Name: "foo"}, 105 | }, 106 | SessionID: "bar", 107 | }, 108 | }, 109 | } 110 | 111 | for i, tt := range tests { 112 | t.Logf("[%02d] test %q", i, tt.desc) 113 | 114 | var wsr wsRequest 115 | err := wsUnmarshal(tt.in, 0, &wsr) 116 | if want, got := errStr(tt.err), errStr(err); want != got { 117 | t.Fatalf("unexpected error:\n- want: %v\n- got: %v", want, got) 118 | } 119 | if err != nil { 120 | continue 121 | } 122 | 123 | if want, got := tt.wsr, wsr; !reflect.DeepEqual(want, got) { 124 | t.Fatalf("unexpected wsRequest object:\n- want: %v\n- got: %v", want, got) 125 | } 126 | } 127 | } 128 | 129 | func errStr(err error) string { 130 | if err == nil { 131 | return "" 132 | } 133 | 134 | return err.Error() 135 | } 136 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Package edgemax implements a client for Ubiquiti EdgeMAX devices. 2 | package edgemax 3 | 4 | import ( 5 | "crypto/tls" 6 | "encoding/json" 7 | "net/http" 8 | "net/http/cookiejar" 9 | "net/url" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const ( 15 | // userAgent is the default user agent this package will report to the 16 | // EdgeMAX device. 17 | userAgent = "github.com/mdlayher/edgemax" 18 | ) 19 | 20 | // InsecureHTTPClient creates a *http.Client which does not verify an EdgeMAX 21 | // device's certificate chain and hostname. 22 | // 23 | // Please think carefully before using this client: it should only be used 24 | // with self-hosted, internal EdgeMAX devices. 25 | func InsecureHTTPClient(timeout time.Duration) *http.Client { 26 | return &http.Client{ 27 | Timeout: timeout, 28 | Transport: &http.Transport{ 29 | TLSClientConfig: &tls.Config{ 30 | InsecureSkipVerify: true, 31 | }, 32 | }, 33 | } 34 | } 35 | 36 | // A Client is a client for a Ubiquiti EdgeMAX device. 37 | // 38 | // Client.Login must be called and return a nil error before any additional 39 | // actions can be performed with a Client. 40 | type Client struct { 41 | UserAgent string 42 | 43 | apiURL *url.URL 44 | client *http.Client 45 | } 46 | 47 | // NewClient creates a new Client, using the input EdgeMAX device address 48 | // and an optional HTTP client. If no HTTP client is specified, a default 49 | // one will be used. 50 | // 51 | // If working with a self-hosted EdgeMAX device which does not have a valid 52 | // TLS certificate, InsecureHTTPClient can be used. 53 | // 54 | // Client.Login must be called and return a nil error before any additional 55 | // actions can be performed with a Client. 56 | func NewClient(addr string, client *http.Client) (*Client, error) { 57 | // Trim trailing slash to ensure sane path creation in other methods 58 | u, err := url.Parse(strings.TrimRight(addr, "/")) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | if client == nil { 64 | client = &http.Client{ 65 | Timeout: 10 * time.Second, 66 | } 67 | } 68 | 69 | if client.Jar == nil { 70 | jar, err := cookiejar.New(nil) 71 | if err != nil { 72 | return nil, err 73 | } 74 | client.Jar = jar 75 | } 76 | 77 | c := &Client{ 78 | UserAgent: userAgent, 79 | 80 | apiURL: u, 81 | client: client, 82 | } 83 | 84 | return c, nil 85 | } 86 | 87 | // Login authenticates against the EdgeMAX device using the specified username 88 | // and password. Login must be called and return a nil error before any 89 | // additional actions can be performed. 90 | func (c *Client) Login(username string, password string) error { 91 | v := make(url.Values, 2) 92 | v.Set("username", username) 93 | v.Set("password", password) 94 | 95 | _, err := c.client.PostForm(c.apiURL.String(), v) 96 | return err 97 | } 98 | 99 | // newRequest creates a new HTTP request, using the specified HTTP method and 100 | // API endpoint. 101 | func (c *Client) newRequest(method string, endpoint string) (*http.Request, error) { 102 | rel, err := url.Parse(endpoint) 103 | if err != nil { 104 | return nil, err 105 | } 106 | u := c.apiURL.ResolveReference(rel) 107 | 108 | req, err := http.NewRequest(method, u.String(), nil) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | // Needed to allow authentication to many HTTP endpoints 114 | req.Header.Set("Referer", c.apiURL.String()) 115 | 116 | req.Header.Add("User-Agent", c.UserAgent) 117 | 118 | return req, nil 119 | } 120 | 121 | // do performs an HTTP request using req and unmarshals the result onto v, if 122 | // v is not nil. 123 | func (c *Client) do(req *http.Request, v interface{}) (*http.Response, error) { 124 | res, err := c.client.Do(req) 125 | if err != nil { 126 | return nil, err 127 | } 128 | defer res.Body.Close() 129 | 130 | if v == nil { 131 | return res, nil 132 | } 133 | 134 | return res, json.NewDecoder(res.Body).Decode(v) 135 | } 136 | -------------------------------------------------------------------------------- /client_stats.go: -------------------------------------------------------------------------------- 1 | package edgemax 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "sync" 8 | "time" 9 | 10 | "golang.org/x/net/websocket" 11 | ) 12 | 13 | // Stats opens a websocket connection to an EdgeMAX device to retrieve 14 | // statistics which are sent using the socket. The done closure must 15 | // be invoked to clean up resources from Stats. 16 | func (c *Client) Stats(stats ...StatType) (statC chan Stat, done func() error, err error) { 17 | if stats == nil { 18 | stats = []StatType{ 19 | StatTypeDPIStats, 20 | StatTypeInterfaces, 21 | StatTypeSystemStats, 22 | } 23 | } 24 | 25 | doneC := make(chan struct{}) 26 | errC := make(chan error) 27 | wg := new(sync.WaitGroup) 28 | 29 | wg.Add(1) 30 | go func() { 31 | defer func() { 32 | close(errC) 33 | wg.Done() 34 | }() 35 | 36 | if err := c.keepalive(doneC); err != nil { 37 | errC <- err 38 | } 39 | }() 40 | 41 | statC, wsDone, err := c.initWebsocket(stats) 42 | if err != nil { 43 | return nil, nil, err 44 | } 45 | 46 | done = func() error { 47 | close(doneC) 48 | wg.Wait() 49 | 50 | if err := <-errC; err != nil { 51 | return err 52 | } 53 | 54 | if err := wsDone(); err != nil { 55 | return err 56 | } 57 | 58 | close(statC) 59 | return nil 60 | } 61 | 62 | return statC, done, nil 63 | } 64 | 65 | const ( 66 | // sessionCookie is the name of the session cookie used to authenticate 67 | // against EdgeMAX devices. 68 | sessionCookie = "PHPSESSID" 69 | ) 70 | 71 | // initWebsocket initializes the websocket used for Client.Stats, and provides 72 | // a closure which can be used to clean it up. 73 | func (c *Client) initWebsocket(stats []StatType) (chan Stat, func() error, error) { 74 | // Websocket URL is adapted from HTTP URL 75 | wsURL := *c.apiURL 76 | wsURL.Scheme = "wss" 77 | wsURL.Path = "/ws/stats" 78 | 79 | cfg, err := websocket.NewConfig(wsURL.String(), c.apiURL.String()) 80 | if err != nil { 81 | return nil, nil, err 82 | } 83 | 84 | // Copy TLS config from client if using standard *http.Transport, so that 85 | // using InsecureHTTPClient can also apply to websocket connections 86 | if tr, ok := c.client.Transport.(*http.Transport); ok { 87 | cfg.TlsConfig = tr.TLSClientConfig 88 | } 89 | 90 | // Need session ID from cookie to pass as part of websocket subscription 91 | var sessionID string 92 | for _, c := range c.client.Jar.Cookies(c.apiURL) { 93 | if c.Name == sessionCookie { 94 | sessionID = c.Value 95 | break 96 | } 97 | } 98 | 99 | wsc, err := websocket.DialConfig(cfg) 100 | if err != nil { 101 | return nil, nil, err 102 | } 103 | 104 | wsCodec := &websocket.Codec{ 105 | Marshal: wsMarshal, 106 | Unmarshal: wsUnmarshal, 107 | } 108 | 109 | wsns := make([]wsName, 0, len(stats)) 110 | for _, stat := range stats { 111 | wsns = append(wsns, wsName{Name: stat}) 112 | } 113 | 114 | // Subscribe to stats named in StatType slice, authenticate using session 115 | // cookie value 116 | sub := &wsRequest{ 117 | Subscribe: wsns, 118 | SessionID: sessionID, 119 | } 120 | 121 | if err := wsCodec.Send(wsc, sub); err != nil { 122 | return nil, nil, err 123 | } 124 | 125 | statC := make(chan Stat) 126 | doneC := make(chan struct{}) 127 | 128 | wg := new(sync.WaitGroup) 129 | 130 | // Unsubscribe and clean up websocket on completion using clsosure 131 | done := statsDone(wg, sub, wsCodec, wsc, doneC) 132 | 133 | // Collect raw stats from websocket, parse them, and send them into statC 134 | wg.Add(1) 135 | go collectStats(wg, wsCodec, wsc, statC, doneC) 136 | 137 | return statC, done, nil 138 | } 139 | 140 | // keepalive sends heartbeat requests at regular intervals to the EdgeMAX 141 | // device to keep a session active while Client.Stats is running. 142 | func (c *Client) keepalive(doneC <-chan struct{}) error { 143 | var v struct { 144 | Success bool `json:"success"` 145 | Ping bool `json:"PING"` 146 | Session bool `json:"SESSION"` 147 | } 148 | 149 | for { 150 | req, err := c.newRequest( 151 | http.MethodGet, 152 | fmt.Sprintf("/api/edge/heartbeat.json?_=%d", time.Now().UnixNano()), 153 | ) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | _, err = c.do(req, &v) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | select { 164 | case <-time.After(5 * time.Second): 165 | case <-doneC: 166 | return nil 167 | } 168 | } 169 | } 170 | 171 | // statsDone creates a closure which can be invoked to unsubscribe from the 172 | // stats websocket and close the connection gracefully. 173 | func statsDone( 174 | wg *sync.WaitGroup, 175 | sub *wsRequest, 176 | wsCodec *websocket.Codec, 177 | wsc *websocket.Conn, 178 | doneC chan<- struct{}, 179 | ) func() error { 180 | return func() error { 181 | // Unsubscribe from the same stats that were subscribed to 182 | names := make([]wsName, len(sub.Subscribe)) 183 | copy(names, sub.Subscribe) 184 | sub.Unsubscribe = names 185 | sub.Subscribe = nil 186 | 187 | if err := wsCodec.Send(wsc, sub); err != nil { 188 | return err 189 | } 190 | 191 | if err := wsc.Close(); err != nil { 192 | return err 193 | } 194 | 195 | // Halt stats collection goroutine 196 | close(doneC) 197 | wg.Wait() 198 | 199 | return nil 200 | } 201 | } 202 | 203 | // collectStats receives raw stats from a websocket and decodes them into 204 | // Stat structs of various types. 205 | func collectStats( 206 | wg *sync.WaitGroup, 207 | wsCodec *websocket.Codec, 208 | wsc *websocket.Conn, 209 | statC chan<- Stat, 210 | doneC chan struct{}, 211 | ) { 212 | for { 213 | select { 214 | case <-doneC: 215 | wg.Done() 216 | return 217 | default: 218 | } 219 | 220 | m := make(map[StatType]json.RawMessage) 221 | if err := wsCodec.Receive(wsc, &m); err != nil { 222 | continue 223 | } 224 | 225 | for k, v := range m { 226 | switch k { 227 | case StatTypeDPIStats: 228 | var ds DPIStats 229 | if err := ds.UnmarshalJSON(v); err != nil { 230 | break 231 | } 232 | 233 | statC <- ds 234 | case StatTypeInterfaces: 235 | var is Interfaces 236 | if err := is.UnmarshalJSON(v); err != nil { 237 | break 238 | } 239 | 240 | statC <- is 241 | case StatTypeSystemStats: 242 | ss := new(SystemStats) 243 | if err := ss.UnmarshalJSON(v); err != nil { 244 | break 245 | } 246 | 247 | statC <- ss 248 | } 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /stat.go: -------------------------------------------------------------------------------- 1 | package edgemax 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net" 7 | "reflect" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // A Stat is a statistic provided by an EdgeMAX device. Type assertions 15 | // can be used to determine the specific type of a Stat, and to access 16 | // a Stat's fields. 17 | type Stat interface { 18 | StatType() StatType 19 | } 20 | 21 | // A StatType is a type of Stat. StatType values can be used to retrieve 22 | // certain types of Stats from Client.Stats 23 | type StatType string 24 | 25 | const ( 26 | // StatTypeDPIStats retrieves EdgeMAX deep packet inspection statistics. 27 | StatTypeDPIStats StatType = "export" 28 | 29 | // StatTypeSystemStats retrieves EdgeMAX system statistics, including 30 | // system uptime, CPU utilization, and memory utilization. 31 | StatTypeSystemStats StatType = "system-stats" 32 | 33 | // StatTypeInterfaces retrieves EdgeMAX network interface statistics. 34 | StatTypeInterfaces StatType = "interfaces" 35 | ) 36 | 37 | // SystemStats is a Stat which contains system statistics for an EdgeMAX 38 | // device. 39 | type SystemStats struct { 40 | CPU int 41 | Uptime time.Duration 42 | Memory int 43 | } 44 | 45 | var _ Stat = &SystemStats{} 46 | 47 | // StatType implements the Stats interface. 48 | func (ss *SystemStats) StatType() StatType { 49 | return StatTypeSystemStats 50 | } 51 | 52 | // UnmarshalJSON unmarshals JSON into a SystemStats. 53 | func (ss *SystemStats) UnmarshalJSON(b []byte) error { 54 | var v struct { 55 | CPU string `json:"cpu"` 56 | Uptime string `json:"uptime"` 57 | Mem string `json:"mem"` 58 | } 59 | 60 | if err := json.Unmarshal(b, &v); err != nil { 61 | return err 62 | } 63 | 64 | cpu, err := strconv.Atoi(v.CPU) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | uptime, err := strconv.Atoi(v.Uptime) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | memory, err := strconv.Atoi(v.Mem) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | *ss = SystemStats{ 80 | CPU: cpu, 81 | Uptime: time.Duration(uptime) * time.Second, 82 | Memory: memory, 83 | } 84 | 85 | return nil 86 | } 87 | 88 | // Interfaces is a slice of Interface values, which contains information about 89 | // network interfaces for EdgeMAX devices. 90 | type Interfaces []*Interface 91 | 92 | var _ Stat = &Interfaces{} 93 | 94 | // StatType implements the Stats interface. 95 | func (i Interfaces) StatType() StatType { 96 | return StatTypeInterfaces 97 | } 98 | 99 | // An Interface is an EdgeMAX network interface. 100 | type Interface struct { 101 | Name string 102 | Up bool 103 | Autonegotiation bool 104 | Duplex string 105 | Speed int 106 | MAC net.HardwareAddr 107 | MTU int 108 | Addresses []net.IP 109 | Stats InterfaceStats 110 | } 111 | 112 | // InterfaceStats contains network interface data transmission statistics. 113 | type InterfaceStats struct { 114 | ReceivePackets int 115 | TransmitPackets int 116 | ReceiveBytes int 117 | TransmitBytes int 118 | ReceiveErrors int 119 | TransmitErrors int 120 | ReceiveDropped int 121 | TransmitDropped int 122 | Multicast int 123 | ReceiveBPS int 124 | TransmitBPS int 125 | } 126 | 127 | // UnmarshalJSON unmarshals JSON into an Interfaces. 128 | func (i *Interfaces) UnmarshalJSON(b []byte) error { 129 | var v map[string]struct { 130 | Up string `json:"up"` 131 | Autoneg string `json:"autoneg"` 132 | Duplex string `json:"duplex"` 133 | Speed string `json:"speed"` 134 | MAC string `json:"mac"` 135 | MTU string `json:"mtu"` 136 | Addresses interface{} `json:"addresses"` 137 | Stats struct { 138 | RXPackets string `json:"rx_packets"` 139 | TXPackets string `json:"tx_packets"` 140 | RXBytes string `json:"rx_bytes"` 141 | TXBytes string `json:"tx_bytes"` 142 | RXErrors string `json:"rx_errors"` 143 | TXErrors string `json:"tx_errors"` 144 | RXDropped string `json:"rx_dropped"` 145 | TXDropped string `json:"tx_dropped"` 146 | Multicast string `json:"multicast"` 147 | RXBPS string `json:"rx_bps"` 148 | TXBPS string `json:"tx_bps"` 149 | } `json:"stats"` 150 | } 151 | 152 | if err := json.Unmarshal(b, &v); err != nil { 153 | return err 154 | } 155 | 156 | is := make(Interfaces, 0, len(v)) 157 | for k, vv := range v { 158 | ss := []string{ 159 | vv.Speed, 160 | vv.MTU, 161 | vv.Stats.RXPackets, 162 | vv.Stats.TXPackets, 163 | vv.Stats.RXBytes, 164 | vv.Stats.TXBytes, 165 | vv.Stats.RXErrors, 166 | vv.Stats.TXErrors, 167 | vv.Stats.RXDropped, 168 | vv.Stats.TXDropped, 169 | vv.Stats.Multicast, 170 | vv.Stats.RXBPS, 171 | vv.Stats.TXBPS, 172 | } 173 | 174 | ints := make([]int, 0, len(ss)) 175 | for _, str := range ss { 176 | if str == "" { 177 | ints = append(ints, 0) 178 | continue 179 | } 180 | 181 | v, err := strconv.Atoi(str) 182 | if err != nil { 183 | return err 184 | } 185 | 186 | ints = append(ints, v) 187 | } 188 | 189 | var mac net.HardwareAddr 190 | if vv.MAC != "" { 191 | var err error 192 | mac, err = net.ParseMAC(vv.MAC) 193 | if err != nil { 194 | return err 195 | } 196 | } 197 | 198 | ips := make([]net.IP, 0) 199 | 200 | switch reflect.ValueOf(vv.Addresses).Kind() { 201 | case reflect.Slice: 202 | for _, ip := range vv.Addresses.([]interface{}) { 203 | ip, _, err := net.ParseCIDR(ip.(string)) 204 | if err != nil { 205 | return err 206 | } 207 | ips = append(ips, ip) 208 | } 209 | case reflect.String: 210 | v := vv.Addresses.(string) 211 | if v != "" { 212 | ip, _, err := net.ParseCIDR(v) 213 | if err != nil { 214 | return err 215 | } 216 | ips = append(ips, ip) 217 | } 218 | } 219 | 220 | is = append(is, &Interface{ 221 | Name: k, 222 | Up: vv.Up == "true", 223 | Autonegotiation: vv.Autoneg == "true", 224 | Duplex: vv.Duplex, 225 | Speed: ints[0], 226 | MAC: mac, 227 | MTU: ints[1], 228 | Addresses: ips, 229 | Stats: InterfaceStats{ 230 | ReceivePackets: ints[2], 231 | TransmitPackets: ints[3], 232 | ReceiveBytes: ints[4], 233 | TransmitBytes: ints[5], 234 | ReceiveErrors: ints[6], 235 | TransmitErrors: ints[7], 236 | ReceiveDropped: ints[8], 237 | TransmitDropped: ints[9], 238 | Multicast: ints[10], 239 | ReceiveBPS: ints[11], 240 | TransmitBPS: ints[12], 241 | }, 242 | }) 243 | } 244 | 245 | sort.Sort(byInterfaceName(is)) 246 | *i = is 247 | return nil 248 | } 249 | 250 | // byInterfaceName is used to sort Interfaces by network interface name. 251 | type byInterfaceName []*Interface 252 | 253 | func (b byInterfaceName) Len() int { return len(b) } 254 | func (b byInterfaceName) Less(i int, j int) bool { return b[i].Name < b[j].Name } 255 | func (b byInterfaceName) Swap(i int, j int) { b[i], b[j] = b[j], b[i] } 256 | 257 | // DPIStats is a slice of DPIStat values, and contains Deep Packet Inspection 258 | // stats from an EdgeMAX device. 259 | type DPIStats []*DPIStat 260 | 261 | // A DPIStat contains Deep Packet Inspection stats from an EdgeMAX device, for 262 | // an individual client and traffic type. 263 | type DPIStat struct { 264 | IP net.IP 265 | Type string 266 | Category string 267 | ReceiveBytes int 268 | ReceiveRate int 269 | TransmitBytes int 270 | TransmitRate int 271 | } 272 | 273 | // StatType implements the Stats interface. 274 | func (d DPIStats) StatType() StatType { 275 | return StatTypeDPIStats 276 | } 277 | 278 | // UnmarshalJSON unmarshals JSON into a DPIStats. 279 | func (d *DPIStats) UnmarshalJSON(b []byte) error { 280 | var v map[string]map[string]struct { 281 | RXBytes string `json:"rx_bytes"` 282 | RXRate string `json:"rx_rate"` 283 | TXBytes string `json:"tx_bytes"` 284 | TXRate string `json:"tx_rate"` 285 | } 286 | 287 | if err := json.Unmarshal(b, &v); err != nil { 288 | return err 289 | } 290 | 291 | var out DPIStats 292 | for statIP := range v { 293 | ip := net.ParseIP(statIP) 294 | if ip == nil { 295 | continue 296 | } 297 | 298 | for statType, stats := range v[statIP] { 299 | nameCat := strings.SplitN(statType, "|", 2) 300 | if len(nameCat) != 2 { 301 | return fmt.Errorf("invalid stat type: %q", statType) 302 | } 303 | 304 | rxBytes, err := strconv.Atoi(stats.RXBytes) 305 | if err != nil { 306 | return err 307 | } 308 | 309 | rxRate, err := strconv.Atoi(stats.RXRate) 310 | if err != nil { 311 | return err 312 | } 313 | 314 | txBytes, err := strconv.Atoi(stats.TXBytes) 315 | if err != nil { 316 | return err 317 | } 318 | 319 | txRate, err := strconv.Atoi(stats.TXRate) 320 | if err != nil { 321 | return err 322 | } 323 | 324 | out = append(out, &DPIStat{ 325 | IP: ip, 326 | Type: nameCat[0], 327 | Category: nameCat[1], 328 | ReceiveBytes: rxBytes, 329 | ReceiveRate: rxRate, 330 | TransmitBytes: txBytes, 331 | TransmitRate: txRate, 332 | }) 333 | } 334 | } 335 | 336 | sort.Sort(byIPAndType(out)) 337 | *d = out 338 | return nil 339 | } 340 | 341 | // byIPAndType is used to sort Interfaces by network interface name. 342 | type byIPAndType []*DPIStat 343 | 344 | func (b byIPAndType) Len() int { return len(b) } 345 | func (b byIPAndType) Less(i int, j int) bool { 346 | less := ipLess(b[i].IP, b[j].IP) 347 | if less { 348 | return true 349 | } 350 | 351 | if b[i].Type < b[j].Type { 352 | return true 353 | } 354 | 355 | return false 356 | } 357 | func (b byIPAndType) Swap(i int, j int) { b[i], b[j] = b[j], b[i] } 358 | 359 | func ipLess(a net.IP, b net.IP) bool { 360 | // Need 4-byte IPv4 representation where possible 361 | if ip4 := a.To4(); ip4 != nil { 362 | a = ip4 363 | } 364 | if ip4 := b.To4(); ip4 != nil { 365 | b = ip4 366 | } 367 | 368 | switch { 369 | // IPv4 addresses should appear before IPv6 addresses 370 | case len(a) == net.IPv4len && len(b) == net.IPv6len: 371 | return true 372 | case len(a) == net.IPv6len && len(b) == net.IPv4len: 373 | return false 374 | case a == nil && b == nil: 375 | return false 376 | } 377 | 378 | for i := range a { 379 | if a[i] < b[i] { 380 | return true 381 | } 382 | } 383 | 384 | return false 385 | } 386 | -------------------------------------------------------------------------------- /stat_test.go: -------------------------------------------------------------------------------- 1 | package edgemax 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net" 7 | "reflect" 8 | "strconv" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestSystemStatsUnmarshalJSON(t *testing.T) { 14 | var tests = []struct { 15 | desc string 16 | b []byte 17 | errType reflect.Type 18 | s *SystemStats 19 | }{ 20 | { 21 | desc: "invalid JSON", 22 | b: []byte(`foo`), 23 | errType: reflect.TypeOf(&json.SyntaxError{}), 24 | }, 25 | { 26 | desc: "invalid CPU integer", 27 | b: []byte(`{"cpu":"foo"}`), 28 | errType: reflect.TypeOf(&strconv.NumError{}), 29 | }, 30 | { 31 | desc: "invalid uptime integer", 32 | b: []byte(`{"cpu":"0","uptime":"foo"}`), 33 | errType: reflect.TypeOf(&strconv.NumError{}), 34 | }, 35 | { 36 | desc: "invalid memory integer", 37 | b: []byte(`{"cpu":"0","uptime":"1","mem":"foo"}`), 38 | errType: reflect.TypeOf(&strconv.NumError{}), 39 | }, 40 | { 41 | desc: "OK", 42 | b: []byte(`{"cpu":"10","uptime":"20","mem":"30"}`), 43 | s: &SystemStats{ 44 | CPU: 10, 45 | Uptime: 20 * time.Second, 46 | Memory: 30, 47 | }, 48 | }, 49 | } 50 | 51 | for i, tt := range tests { 52 | t.Logf("[%02d] test %q", i, tt.desc) 53 | 54 | s := new(SystemStats) 55 | err := s.UnmarshalJSON(tt.b) 56 | 57 | if want, got := tt.errType, reflect.TypeOf(err); !reflect.DeepEqual(want, got) { 58 | t.Fatalf("unexpected error type:\n- want: %v\n- got: %v", want, got) 59 | } 60 | if err != nil { 61 | continue 62 | } 63 | 64 | if want, got := tt.s, s; !reflect.DeepEqual(want, got) { 65 | t.Fatalf("unexpected SystemStats:\n- want: %v\n- got: %v", want, got) 66 | } 67 | } 68 | } 69 | 70 | func TestInterfacesUnmarshalJSON(t *testing.T) { 71 | var tests = []struct { 72 | desc string 73 | b []byte 74 | errType reflect.Type 75 | ifis Interfaces 76 | }{ 77 | { 78 | desc: "invalid JSON", 79 | b: []byte(`foo`), 80 | errType: reflect.TypeOf(&json.SyntaxError{}), 81 | }, 82 | { 83 | desc: "invalid speed", 84 | b: []byte(`{"eth0":{"speed":"foo"}}`), 85 | errType: reflect.TypeOf(&strconv.NumError{}), 86 | }, 87 | { 88 | desc: "invalid MTU", 89 | b: []byte(`{"eth0":{"mtu":"foo"}}`), 90 | errType: reflect.TypeOf(&strconv.NumError{}), 91 | }, 92 | { 93 | desc: "invalid receive packets", 94 | b: []byte(`{"eth0":{"stats":{"rx_packets":"foo"}}}`), 95 | errType: reflect.TypeOf(&strconv.NumError{}), 96 | }, 97 | { 98 | desc: "invalid transmit packets", 99 | b: []byte(`{"eth0":{"stats":{"tx_packets":"foo"}}}`), 100 | errType: reflect.TypeOf(&strconv.NumError{}), 101 | }, 102 | { 103 | desc: "invalid receive bytes", 104 | b: []byte(`{"eth0":{"stats":{"rx_bytes":"foo"}}}`), 105 | errType: reflect.TypeOf(&strconv.NumError{}), 106 | }, 107 | { 108 | desc: "invalid transmit bytes", 109 | b: []byte(`{"eth0":{"stats":{"tx_bytes":"foo"}}}`), 110 | errType: reflect.TypeOf(&strconv.NumError{}), 111 | }, 112 | { 113 | desc: "invalid receive errors", 114 | b: []byte(`{"eth0":{"stats":{"rx_errors":"foo"}}}`), 115 | errType: reflect.TypeOf(&strconv.NumError{}), 116 | }, 117 | { 118 | desc: "invalid transmit errors", 119 | b: []byte(`{"eth0":{"stats":{"tx_errors":"foo"}}}`), 120 | errType: reflect.TypeOf(&strconv.NumError{}), 121 | }, 122 | { 123 | desc: "invalid receive dropped", 124 | b: []byte(`{"eth0":{"stats":{"rx_dropped":"foo"}}}`), 125 | errType: reflect.TypeOf(&strconv.NumError{}), 126 | }, 127 | { 128 | desc: "invalid transmit dropped", 129 | b: []byte(`{"eth0":{"stats":{"tx_dropped":"foo"}}}`), 130 | errType: reflect.TypeOf(&strconv.NumError{}), 131 | }, 132 | { 133 | desc: "invalid multicast", 134 | b: []byte(`{"eth0":{"stats":{"multicast":"foo"}}}`), 135 | errType: reflect.TypeOf(&strconv.NumError{}), 136 | }, 137 | { 138 | desc: "invalid receive bps", 139 | b: []byte(`{"eth0":{"stats":{"rx_bps":"foo"}}}`), 140 | errType: reflect.TypeOf(&strconv.NumError{}), 141 | }, 142 | { 143 | desc: "invalid transmit bps", 144 | b: []byte(`{"eth0":{"stats":{"tx_bps":"foo"}}}`), 145 | errType: reflect.TypeOf(&strconv.NumError{}), 146 | }, 147 | { 148 | desc: "invalid MAC", 149 | b: []byte(`{"eth0":{"mac":"foo"}}`), 150 | errType: reflect.TypeOf(&net.AddrError{}), 151 | }, 152 | { 153 | desc: "invalid CIDR IP", 154 | b: []byte(`{"eth0":{"addresses":["foo"]}}`), 155 | errType: reflect.TypeOf(&net.ParseError{}), 156 | }, 157 | { 158 | desc: "OK one interface", 159 | b: []byte(`{"eth0":{"up":"true","autoneg":"true","duplex":"full","speed":"10","mac":"de:ad:be:ef:de:ad","mtu":"1500","addresses":["192.168.1.1/24"],"stats":{"rx_packets":"1","tx_packets":"2","rx_bytes":"3","tx_bytes":"4","rx_errors":"5","tx_errors":"6","rx_dropped":"7","tx_dropped":"8","multicast":"9","rx_bps":"10","tx_bps":"11"}}}`), 160 | ifis: Interfaces{{ 161 | Name: "eth0", 162 | Up: true, 163 | Autonegotiation: true, 164 | Duplex: "full", 165 | Speed: 10, 166 | MAC: net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad}, 167 | MTU: 1500, 168 | Addresses: []net.IP{net.IPv4(192, 168, 1, 1)}, 169 | Stats: InterfaceStats{ 170 | ReceivePackets: 1, 171 | TransmitPackets: 2, 172 | ReceiveBytes: 3, 173 | TransmitBytes: 4, 174 | ReceiveErrors: 5, 175 | TransmitErrors: 6, 176 | ReceiveDropped: 7, 177 | TransmitDropped: 8, 178 | Multicast: 9, 179 | ReceiveBPS: 10, 180 | TransmitBPS: 11, 181 | }, 182 | }}, 183 | }, 184 | { 185 | desc: "OK two interfaces", 186 | b: []byte(`{"eth1":{"mac":"ab:ad:1d:ea:ab:ad","addresses":["192.168.1.2/24"]},"eth0":{"mac":"de:ad:be:ef:de:ad","addresses":["192.168.1.1/24"]}}`), 187 | ifis: Interfaces{ 188 | { 189 | Name: "eth0", 190 | MAC: net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad}, 191 | Addresses: []net.IP{net.IPv4(192, 168, 1, 1)}, 192 | }, 193 | { 194 | Name: "eth1", 195 | MAC: net.HardwareAddr{0xab, 0xad, 0x1d, 0xea, 0xab, 0xad}, 196 | Addresses: []net.IP{net.IPv4(192, 168, 1, 2)}, 197 | }, 198 | }, 199 | }, 200 | } 201 | 202 | for i, tt := range tests { 203 | t.Logf("[%02d] test %q", i, tt.desc) 204 | 205 | var ifis Interfaces 206 | err := ifis.UnmarshalJSON(tt.b) 207 | 208 | if want, got := tt.errType, reflect.TypeOf(err); !reflect.DeepEqual(want, got) { 209 | t.Fatalf("unexpected error type:\n- want: %v\n- got: %v", want, got) 210 | } 211 | if err != nil { 212 | continue 213 | } 214 | 215 | if want, got := tt.ifis, ifis; !reflect.DeepEqual(want, got) { 216 | t.Fatalf("unexpected Interfaces:\n- want: %+v\n- got: %+v", want, got) 217 | } 218 | } 219 | } 220 | 221 | func TestDPIStatsUnmarshalJSON(t *testing.T) { 222 | var tests = []struct { 223 | desc string 224 | b []byte 225 | err error 226 | errType reflect.Type 227 | d DPIStats 228 | }{ 229 | { 230 | desc: "invalid JSON", 231 | b: []byte(`foo`), 232 | errType: reflect.TypeOf(&json.SyntaxError{}), 233 | }, 234 | { 235 | desc: "invalid stat type", 236 | b: []byte(`{"192.168.1.1":{"Foo":null}}`), 237 | err: errors.New(`invalid stat type: "Foo"`), 238 | }, 239 | { 240 | desc: "invalid receive bytes", 241 | b: []byte(`{"192.168.1.1":{"foo|foo":{"rx_bytes":"foo"}}}`), 242 | errType: reflect.TypeOf(&strconv.NumError{}), 243 | }, 244 | { 245 | desc: "invalid receive rate", 246 | b: []byte(`{"192.168.1.1":{"foo|foo":{"rx_bytes":"0","rx_rate":"foo"}}}`), 247 | errType: reflect.TypeOf(&strconv.NumError{}), 248 | }, 249 | { 250 | desc: "invalid transmit bytes", 251 | b: []byte(`{"192.168.1.1":{"foo|foo":{"rx_bytes":"0","rx_rate":"0","tx_bytes":"foo"}}}`), 252 | errType: reflect.TypeOf(&strconv.NumError{}), 253 | }, 254 | { 255 | desc: "invalid transmit rate", 256 | b: []byte(`{"192.168.1.1":{"foo|foo":{"rx_bytes":"0","rx_rate":"0","tx_bytes":"0","tx_rate":"foo"}}}`), 257 | errType: reflect.TypeOf(&strconv.NumError{}), 258 | }, 259 | { 260 | desc: "one IP, one DPI stat", 261 | b: []byte(`{"192.168.1.1":{"Web|Web - Other":{"rx_bytes":"1","rx_rate":"2","tx_bytes":"3","tx_rate":"4"}}}`), 262 | d: DPIStats{{ 263 | IP: net.ParseIP("192.168.1.1"), 264 | Type: "Web", 265 | Category: "Web - Other", 266 | ReceiveBytes: 1, 267 | ReceiveRate: 2, 268 | TransmitBytes: 3, 269 | TransmitRate: 4, 270 | }}, 271 | }, 272 | { 273 | desc: "one IP, two DPI stats", 274 | b: []byte(`{"192.168.1.1":{"Web|Web - Other":{"rx_bytes":"1","rx_rate":"2","tx_bytes":"3","tx_rate":"4"},"P2P|BitTorrent series":{"rx_bytes":"5","rx_rate":"6","tx_bytes":"7","tx_rate":"8"}}}`), 275 | d: DPIStats{ 276 | { 277 | IP: net.ParseIP("192.168.1.1"), 278 | Type: "P2P", 279 | Category: "BitTorrent series", 280 | ReceiveBytes: 5, 281 | ReceiveRate: 6, 282 | TransmitBytes: 7, 283 | TransmitRate: 8, 284 | }, 285 | { 286 | IP: net.ParseIP("192.168.1.1"), 287 | Type: "Web", 288 | Category: "Web - Other", 289 | ReceiveBytes: 1, 290 | ReceiveRate: 2, 291 | TransmitBytes: 3, 292 | TransmitRate: 4, 293 | }, 294 | }, 295 | }, 296 | { 297 | desc: "two IPs, one DPI stat each", 298 | b: []byte(`{"192.168.1.2":{"P2P|BitTorrent series":{"rx_bytes":"5","rx_rate":"6","tx_bytes":"7","tx_rate":"8"}},"192.168.1.1":{"Web|Web - Other":{"rx_bytes":"1","rx_rate":"2","tx_bytes":"3","tx_rate":"4"}}}`), 299 | d: DPIStats{ 300 | { 301 | IP: net.ParseIP("192.168.1.1"), 302 | Type: "Web", 303 | Category: "Web - Other", 304 | ReceiveBytes: 1, 305 | ReceiveRate: 2, 306 | TransmitBytes: 3, 307 | TransmitRate: 4, 308 | }, 309 | { 310 | IP: net.ParseIP("192.168.1.2"), 311 | Type: "P2P", 312 | Category: "BitTorrent series", 313 | ReceiveBytes: 5, 314 | ReceiveRate: 6, 315 | TransmitBytes: 7, 316 | TransmitRate: 8, 317 | }, 318 | }, 319 | }, 320 | } 321 | 322 | for i, tt := range tests { 323 | t.Logf("[%02d] test %q", i, tt.desc) 324 | 325 | var d DPIStats 326 | err := d.UnmarshalJSON(tt.b) 327 | 328 | if tt.err != nil { 329 | if want, got := errStr(tt.err), errStr(err); want != got { 330 | t.Fatalf("unexpected error:\n- want: %v\n- got: %v", want, got) 331 | } 332 | } else { 333 | if want, got := tt.errType, reflect.TypeOf(err); !reflect.DeepEqual(want, got) { 334 | t.Fatalf("unexpected error type:\n- want: %v\n- got: %v", want, got) 335 | } 336 | } 337 | if err != nil { 338 | continue 339 | } 340 | 341 | if want, got := tt.d, d; !reflect.DeepEqual(want, got) { 342 | t.Fatalf("unexpected DPIStats:\n- want: %+v\n- got: %+v", want, got) 343 | } 344 | } 345 | } 346 | 347 | func Test_ipLess(t *testing.T) { 348 | var tests = []struct { 349 | a net.IP 350 | b net.IP 351 | less bool 352 | }{ 353 | { 354 | less: false, 355 | }, 356 | { 357 | a: net.ParseIP("10.0.0.1"), 358 | b: net.ParseIP("10.0.0.2"), 359 | less: true, 360 | }, 361 | { 362 | a: net.ParseIP("10.0.0.2"), 363 | b: net.ParseIP("10.0.0.1"), 364 | less: false, 365 | }, 366 | { 367 | a: net.ParseIP("10.0.0.1"), 368 | b: net.ParseIP("10.1.0.1"), 369 | less: true, 370 | }, 371 | { 372 | a: net.ParseIP("10.1.0.1"), 373 | b: net.ParseIP("10.0.0.1"), 374 | less: false, 375 | }, 376 | { 377 | a: net.ParseIP("2001:db8::1"), 378 | b: net.ParseIP("10.0.0.1"), 379 | less: false, 380 | }, 381 | { 382 | a: net.ParseIP("10.0.0.1"), 383 | b: net.ParseIP("2001:db8::1"), 384 | less: true, 385 | }, 386 | { 387 | a: net.ParseIP("2001:db8::1"), 388 | b: net.ParseIP("2001:db8::2"), 389 | less: true, 390 | }, 391 | { 392 | a: net.ParseIP("2001:db8::2"), 393 | b: net.ParseIP("2001:db8::1"), 394 | less: false, 395 | }, 396 | { 397 | a: net.ParseIP("2001:db9::2"), 398 | b: net.ParseIP("2001:db8::1"), 399 | less: false, 400 | }, 401 | } 402 | 403 | for i, tt := range tests { 404 | t.Logf("[%02d] test %q < %q?", i, tt.a, tt.b) 405 | 406 | less := ipLess(tt.a, tt.b) 407 | if want, got := tt.less, less; !reflect.DeepEqual(want, got) { 408 | t.Fatalf("unexpected ipLess(%q, %q) result:\n- want: %v\n- got: %v", 409 | tt.a, tt.b, want, got) 410 | } 411 | } 412 | } 413 | --------------------------------------------------------------------------------