├── .github
└── workflows
│ ├── go.yml
│ └── golangci-lint.yml
├── README.md
├── config.yml
├── doc
└── mikrotik-capsman-ui-sample-processed.PNG
├── go.mod
├── html
└── index.html
├── http.go
├── lib.go
└── main.go
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: go-build
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 |
11 | build:
12 | name: Build
13 | runs-on: ubuntu-latest
14 | steps:
15 |
16 | - name: Set up Go 1.x
17 | uses: actions/setup-go@v2
18 | with:
19 | go-version: ^1.13
20 | id: go
21 |
22 | - name: Check out code into the Go module directory
23 | uses: actions/checkout@v2
24 |
25 | - name: Get dependencies
26 | run: |
27 | go get -v -t -d ./...
28 | if [ -f Gopkg.toml ]; then
29 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
30 | dep ensure
31 | fi
32 |
33 | - name: Build
34 | run: |
35 | mkdir out_x86-64
36 | cp config.yml out_x86-64/
37 | cp -r html out_x86-64/
38 | go build -o out_x86-64/mikrotik-capsman -v .
39 | mkdir out_arm32
40 | cp config.yml out_arm32/
41 | cp -r html out_arm32/
42 | GOARCH=arm go build -o out_arm32/mikrotik-capsman -v .
43 | mkdir out_win
44 | cp config.yml out_win/
45 | cp -r html out_win/
46 | GOARCH=386 GOOS=windows go build -o out_win/mikrotik-capsman32.exe -v .
47 | GOARCH=amd64 GOOS=windows go build -o out_win/mikrotik-capsman64.exe -v .
48 |
49 | - name: Upload Linux binary x86-64
50 | uses: actions/upload-artifact@master
51 | with:
52 | name: mikrotik-capsman_linux_x86-64
53 | path: out_x86-64
54 |
55 | - name: Upload Linux binary arm32
56 | uses: actions/upload-artifact@master
57 | with:
58 | name: mikrotik-capsman_linux_arm32
59 | path: out_arm32
60 |
61 | - name: Upload Windows binary
62 | uses: actions/upload-artifact@master
63 | with:
64 | name: mikrotik-capsman_windows
65 | path: out_win
66 |
--------------------------------------------------------------------------------
/.github/workflows/golangci-lint.yml:
--------------------------------------------------------------------------------
1 | name: golangci-lint
2 | on:
3 | push:
4 | tags:
5 | - v*
6 | branches:
7 | - master
8 | pull_request:
9 | jobs:
10 | golangci:
11 | name: lint
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: golangci-lint
16 | uses: golangci/golangci-lint-action@v1
17 | with:
18 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version.
19 | version: v1.29
20 |
21 | # Optional: working directory, useful for monorepos
22 | # working-directory: somedir
23 |
24 | # Optional: golangci-lint command line arguments.
25 | # args: --issues-exit-code=0
26 |
27 | # Optional: show only new issues if it's a pull request. The default value is `false`.
28 | # only-new-issues: true
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mikrotik-CapsMan
2 | * Web UI for Mikrotik CapsMan/WiFi interface
3 | * HTTP Notification engine for CapsMan/WiFi client changes
4 |
5 | 
6 |
7 | UI generates a dedicated and periodically updated WEB page with list of WiFi clients, that are connected to CapsMan. List is filled with extra information from Mikrotik DHCP Server.
8 |
9 | UI contains:
10 | - CapsMan interface name
11 | - SSID
12 | - Client MAC address
13 | - Client IP address (from DHCP)
14 | - Signal strength level
15 | - Hostname (from DHCP)
16 | - Comment (from DHCP)
17 |
18 | Supported configuration params:
19 | - `-config` - Name of configuration file (default: `config.yml`, example: `-config config-custom.yml`)
20 |
21 | Supported parameters in configuration file:
22 | - Router configuration
23 | - `mode` - operation mode (CapsMan / WiFi)
24 | - `address` - IP address and port of API interface (normally `8728`)
25 | - `username` - Login for API connection
26 | - `password` - Password for API connection
27 | - `interval` - Polling interval (examples: `5s`, `1m`, ...)
28 | - DHCP Server configuration (only Mikrotik DHCP server is supported), optional
29 | - `address` - IP address and port of API interface (normally `8728`)
30 | - `username` - Login for API connection
31 | - `password` - Password for API connection
32 | - `interval` - Polling interval (examples: `5s`, `1m`, ...)
33 | - Device list (personal configuration for each device)
34 | - `name` - Name, that will be displayed in interface
35 | - `mac` - MAC address of this device
36 | - `on.connect` - Action for connect event
37 | - `on.disconnect` - Action for disconnect event
38 | - `on.roaming` - Action for roaming between AP's (for CapsMan mode)
39 | - `on.level` - Action for signal level change
40 |
41 | Each `on.*` event have the following configuration fields:
42 | - `http.post` - URL for HTTP Post request with template support
43 | - `http.get` - URL for HTTP Get request with templates (will be used if there is no `http.post` line)
44 | - `http.post.content` - Content for HTTP Post request
45 | - `http.header` - List of HTTP headers, that should be added into request (can be used for authentification/configuration of content-type and so on)
46 |
47 | Supported template variables for `http.post`, `http.get` and `http.post.content` fields:
48 | - `name` - Name of device (from configuration)
49 | - `mac` - MAC address of device
50 | - `roaming.to` - During roaming, name of New AP
51 | - `roaming.from` - During roaming, name of OLD AP
52 | - `level.to` - During level change, value of new signal level
53 | - `level.from` - During level change, value of old signal level
54 |
55 |
56 | WEB UI is published at: http://`listen`/
57 |
58 | # Future plans
59 | - Add configuration file with support of human-readable names for specific MAC addresses
60 | - Add MQTT Support with publishing of WiFi client state for intergration with HomeAssistant or other smart house servers
61 |
62 | Have any ideas?
63 | Feel free to send change requests.
--------------------------------------------------------------------------------
/config.yml:
--------------------------------------------------------------------------------
1 | # Logging configuration
2 | log:
3 | level: INFO
4 |
5 | # Mikrotik CapsMan connection parameters
6 | router:
7 | # mode: "wifi"
8 | mode: "capsman"
9 | address: "192.168.1.1:8728"
10 | username: "admin"
11 | password: ""
12 | interval: 3s
13 |
14 | # Mikrotik DHCP server connection parameters
15 | dhcp:
16 | address: "192.168.1.1:8728"
17 | username: "admin"
18 | password: ""
19 | interval: 1m
20 |
21 |
22 | # Configuration file for MAC => DeviceName Name mapping
23 | devices:
24 | - name: "Device 01"
25 | mac: "5C:C0:70:A1:00:00"
26 | - name: "Device 02"
27 | mac: "5C:C0:70:A2:00:00"
28 | - name: "Device 03"
29 | mac: "5C:C0:70:A3:00:00"
30 | on.connect:
31 | http.post: "http://127.0.0.1:8006/device/{mac}/{name}/state"
32 | http.post.content: "{ \"state\": \"connect\" }"
33 | http.header:
34 | "Authorization": "Bearer HereIsPassword"
35 | "Content-Type": "application/json"
36 | on.disconnect:
37 | http.post: "http://127.0.0.1:8006/device/{mac}/{name}/state"
38 | http.post.content: "{ \"state\": \"disconnect\" }"
39 | http.header:
40 | "Authorization": "Bearer HereIsPassword"
41 | "Content-Type": "application/json"
42 | on.roaming:
43 | http.post: "http://127.0.0.1:8006/device/{mac}/{name}/AP"
44 | http.post.content: "{ \"AP\": \"{roaming.to}\", \"AP_OLD\": \"{roaming.from}\" }"
45 | http.header:
46 | "Authorization": "Bearer HereIsPassword"
47 | "Content-Type": "application/json"
48 | on.level:
49 | http.post: "http://127.0.0.1:8006/device/{mac}/{name}/level"
50 | http.post.content: "{ \"level\": \"{level.to}\" }"
51 | http.header:
52 | "Authorization": "Bearer HereIsPassword"
53 | "Content-Type": "application/json"
54 |
55 |
--------------------------------------------------------------------------------
/doc/mikrotik-capsman-ui-sample-processed.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vponomarev/mikrotik-capsman/1bf4e8f1147a7df3b097866a4a9ee495837d3ac7/doc/mikrotik-capsman-ui-sample-processed.PNG
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/vponomarev/libsmpp
2 |
3 | go 1.13
4 |
5 | require (
6 | github.com/gorilla/websocket v1.4.2 // indirect
7 | github.com/sirupsen/logrus v1.6.0 // indirect
8 | gopkg.in/routeros.v2 v2.0.0-20190905230420-1bbf141cdd91 // indirect
9 | gopkg.in/yaml.v2 v2.3.0 // indirect
10 | )
11 |
--------------------------------------------------------------------------------
/html/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | CapsMAN Connection overview
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | Interface |
18 | SSID |
19 | MAC |
20 | IP |
21 | Name |
22 | Signal |
23 | Hostname |
24 | Comment |
25 | |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
122 |
123 |
124 |
125 |
126 |
--------------------------------------------------------------------------------
/http.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "github.com/gorilla/websocket"
6 | log "github.com/sirupsen/logrus"
7 | "html/template"
8 | "io/ioutil"
9 | "net/http"
10 | "strings"
11 | "time"
12 | )
13 |
14 | func serveHTTP() {
15 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
16 | fn := "html/index.html"
17 | t, err := template.ParseFiles(fn)
18 | if err != nil {
19 | fmt.Fprint(w, "Error parsing template file:", fn, " with error:", err)
20 | return
21 | }
22 | if t.Execute(w, map[string]string{"ServerHost": r.Host}) != nil {
23 | fmt.Fprint(w, "Internal error: cannot execute template")
24 | }
25 | })
26 |
27 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
28 | conn, err := WS.Upgrade(w, r, nil)
29 | if err != nil {
30 | if _, ok := err.(websocket.HandshakeError); !ok {
31 | log.Println(err)
32 | }
33 | return
34 | }
35 |
36 | go WSwriter(conn)
37 | WSreader(conn)
38 |
39 | })
40 |
41 | log.WithFields(log.Fields{"listen": *listen}).Warn("Starting HTTP Listener")
42 | err := http.ListenAndServe(*listen, nil)
43 | log.WithFields(log.Fields{"listen": *listen}).Fatal("Received an error from HTTP Listener: ", err)
44 | }
45 |
46 | func WSwriter(ws *websocket.Conn) {
47 | pingTicker := time.NewTicker(pingPeriod)
48 | dataTicker := time.NewTicker(100 * time.Millisecond)
49 |
50 | var lastUpdate time.Time
51 |
52 | defer func() {
53 | pingTicker.Stop()
54 | dataTicker.Stop()
55 | ws.Close()
56 | }()
57 |
58 | for {
59 | select {
60 | case <-pingTicker.C:
61 | if ws.SetWriteDeadline(time.Now().Add(writeWait)) != nil {
62 | return
63 | }
64 | if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
65 | return
66 | }
67 | case <-dataTicker.C:
68 | // Check for LastUpdate
69 | broadcastData.RLock()
70 | if broadcastData.LastUpdate.After(lastUpdate) {
71 | data := broadcastData.Data
72 | lastUpdate = broadcastData.LastUpdate
73 | broadcastData.RUnlock()
74 |
75 | if ws.SetWriteDeadline(time.Now().Add(writeWait)) != nil {
76 | return
77 | }
78 | if err := ws.WriteMessage(websocket.TextMessage, []byte(data)); err != nil {
79 | return
80 | }
81 | } else {
82 | broadcastData.RUnlock()
83 | }
84 | }
85 | }
86 | }
87 |
88 | func WSreader(ws *websocket.Conn) {
89 | defer ws.Close()
90 | ws.SetReadLimit(512)
91 |
92 | if ws.SetReadDeadline(time.Now().Add(pongWait)) != nil {
93 | return
94 | }
95 | ws.SetPongHandler(func(string) error {
96 | return ws.SetReadDeadline(time.Now().Add(pongWait))
97 | })
98 | for {
99 | _, _, err := ws.ReadMessage()
100 | if err != nil {
101 | break
102 | }
103 | }
104 | }
105 |
106 | func makeRequest(event ConfigEvent, params map[string]string) {
107 |
108 | // Prepare request params
109 | method := "GET"
110 | data := ""
111 | url := event.HttpGet
112 | if len(event.HttpPost) > 0 {
113 | method = "POST"
114 | url = event.HttpPost
115 | data = event.HttpPostContent
116 | }
117 |
118 | for k, v := range params {
119 | url = strings.ReplaceAll(url, "{"+k+"}", v)
120 | data = strings.ReplaceAll(data, "{"+k+"}", v)
121 | }
122 |
123 | // Prepare request
124 | client := &http.Client{}
125 | req, err := http.NewRequest(method, url, strings.NewReader(data))
126 | if err != nil {
127 | log.WithFields(log.Fields{"action": "notify", "url": url}).Info("Error creating HTTP request: ", err)
128 | return
129 | }
130 |
131 | // Add headers
132 | for k, v := range event.HttpHeader {
133 | req.Header.Add(k, v)
134 | }
135 | resp, err := client.Do(req)
136 | if err != nil {
137 | log.WithFields(log.Fields{"action": "notify", "method": method, "url": url, "state": "fail"}).Info("Error making HTTP request: ", err)
138 | return
139 | }
140 | defer resp.Body.Close()
141 | body, err := ioutil.ReadAll(resp.Body)
142 | if err != nil {
143 | log.WithFields(log.Fields{"action": "notify", "method": method, "url": url, "state": "fail"}).Info("Error reading body of HTTP request: ", err)
144 | return
145 | }
146 | log.WithFields(log.Fields{"action": "notify", "method": method, "url": url, "state": "ok", "resp-body-len": len(body)}).Debug("HTTP Notification is sent")
147 | }
148 |
--------------------------------------------------------------------------------
/lib.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "github.com/gorilla/websocket"
7 | log "github.com/sirupsen/logrus"
8 | "gopkg.in/routeros.v2"
9 | "gopkg.in/yaml.v2"
10 | "io/ioutil"
11 | "strings"
12 | "sync"
13 | "time"
14 | )
15 |
16 | const (
17 | // Time allowed to read the next pong message from the client.
18 | pongWait = 60 * time.Second
19 |
20 | // Send pings to client with this period. Must be less than pongWait.
21 | pingPeriod = (pongWait * 9) / 10
22 |
23 | // Time allowed to write the file to the client.
24 | writeWait = 10 * time.Second
25 | )
26 |
27 | // Event types
28 | const (
29 | EVENT_CONNECT = iota
30 | EVENT_ROAMING
31 | EVENT_DISCONNECT
32 | EVENT_LEVEL
33 | )
34 |
35 | type LeaseEntry struct {
36 | IP string
37 | MAC string
38 | Server string
39 | Hostname string
40 | Comment string
41 | }
42 |
43 | type ReportEntry struct {
44 | IP string
45 | Name string
46 | Interface string
47 | SSID string
48 | MAC string
49 | Signal string
50 | Hostname string
51 | Comment string
52 | }
53 |
54 | type ReportEvent struct {
55 | EventType int
56 | Old ReportEntry
57 | New ReportEntry
58 | }
59 |
60 | var WS = websocket.Upgrader{
61 | ReadBufferSize: 1024,
62 | WriteBufferSize: 1024,
63 | }
64 |
65 | type BroadcastData struct {
66 | Report []ReportEntry
67 | ReportMap map[string]ReportEntry
68 | Data string
69 | LastUpdate time.Time
70 | sync.RWMutex
71 |
72 | ReportChan chan ReportEvent
73 | }
74 |
75 | type LeaseList struct {
76 | List []LeaseEntry
77 | sync.RWMutex
78 | }
79 |
80 | type ConfMikrotik struct {
81 | Address string `yaml:"address"`
82 | Username string `yaml:"username"`
83 | Password string `yaml:"password"`
84 | Interval time.Duration `yaml:"interval"`
85 | Mode string `yaml:"mode"`
86 | }
87 |
88 | type ConfDevice struct {
89 | Name string `yaml:"name"`
90 | MAC string `yaml:"mac"`
91 | OnConnect ConfigEvent `yaml:"on.connect"`
92 | OnDisconnect ConfigEvent `yaml:"on.disconnect"`
93 | OnRoaming ConfigEvent `yaml:"on.roaming"`
94 | OnLevel ConfigEvent `yaml:"on.level"`
95 | }
96 |
97 | type ConfigEvent struct {
98 | HttpPost string `yaml:"http.post"`
99 | HttpGet string `yaml:"http.get"`
100 | HttpPostContent string `yaml:"http.post.content"`
101 | HttpHeader map[string]string `yaml:"http.header"`
102 | }
103 |
104 | type LogInfo struct {
105 | Level log.Level `yaml:"level"`
106 | }
107 |
108 | type Config struct {
109 | Log LogInfo `yaml:"log"`
110 | Router ConfMikrotik `yaml:"router"`
111 | DHCP ConfMikrotik `yaml:"dhcp"`
112 | Devices []ConfDevice `yaml:"devices"`
113 | }
114 |
115 | // Init BroadcastData entry
116 | func (b *BroadcastData) Init() {
117 | b.ReportMap = map[string]ReportEntry{}
118 | b.ReportChan = make(chan ReportEvent)
119 | }
120 |
121 | var broadcastData BroadcastData
122 | var leaseList LeaseList
123 |
124 | var config Config
125 | var configMTX sync.RWMutex
126 |
127 | var devList map[string]ConfDevice
128 | var devListMTX sync.RWMutex
129 |
130 | func GetDHCPLeases(address, username, password string) (list []LeaseEntry, err error) {
131 | cl, err := routeros.Dial(address, username, password)
132 | if err != nil {
133 | return
134 | }
135 | defer cl.Close()
136 |
137 | reply, err := cl.Run("/ip/dhcp-server/lease/print")
138 | if err != nil {
139 | return
140 | }
141 |
142 | for _, re := range reply.Re {
143 | list = append(list, LeaseEntry{
144 | IP: re.Map["address"],
145 | MAC: re.Map["mac-address"],
146 | Server: re.Map["server"],
147 | Hostname: re.Map["host-name"],
148 | Comment: re.Map["comment"],
149 | })
150 | }
151 | return
152 | }
153 |
154 | func reloadDHCP() {
155 | ticker := time.NewTicker(config.DHCP.Interval)
156 | for { // nolint:gosimple
157 | select {
158 | case <-ticker.C:
159 | l, err := GetDHCPLeases(config.DHCP.Address, config.DHCP.Username, config.DHCP.Password)
160 | if err != nil {
161 | log.WithFields(log.Fields{"dhcp-addr": config.DHCP.Address}).Error("Error reloading DHCP Leases: ", err)
162 | return
163 | } else {
164 | leaseList.RLock()
165 | leaseList.List = l
166 | leaseList.RUnlock()
167 | log.WithFields(log.Fields{"count": len(l)}).Debug("Reloaded DHCP Leases")
168 | }
169 |
170 | }
171 | }
172 | }
173 |
174 | func FindLeaseByMAC(list []LeaseEntry, mac string) (e LeaseEntry, ok bool) {
175 | for _, e := range list {
176 | if e.MAC == mac {
177 | return e, true
178 | }
179 | }
180 | return
181 | }
182 |
183 | func RTLoop(c *routeros.Client, conf *Config) {
184 | for {
185 | cmd := "/caps-man/registration-table/print"
186 | if strings.ToLower(config.Router.Mode) == "wifi" {
187 | cmd = "/interface/wireless/registration-table/print"
188 | }
189 |
190 | reply, err := c.Run(cmd)
191 | if err != nil {
192 | log.WithFields(log.Fields{"address": config.Router.Address, "username": config.Router.Username}).Error("Error during request to CapsMan server: ", err)
193 |
194 | // Try to close connection
195 | c.Close()
196 |
197 | // Reconnect loop
198 | for {
199 | // Sleep for 5 sec
200 | time.Sleep(5 * time.Second)
201 | cNew, err := routeros.Dial(config.Router.Address, config.Router.Username, config.Router.Password)
202 | if err != nil {
203 | log.WithFields(log.Fields{"address": config.Router.Address, "username": config.Router.Username}).Error("Reconnect error to CapsMan server: ", err)
204 | continue
205 | }
206 | c = cNew
207 | log.WithFields(log.Fields{"address": config.Router.Address, "username": config.Router.Username}).Warn("Reconnected to CapsMan server")
208 | break
209 | }
210 | continue
211 | }
212 |
213 | var report []ReportEntry
214 |
215 | leaseList.RLock()
216 | for _, re := range reply.Re {
217 | var n, c, ip string
218 | if le, ok := FindLeaseByMAC(leaseList.List, re.Map["mac-address"]); ok {
219 | n = le.Hostname
220 | c = le.Comment
221 | ip = le.IP
222 | }
223 | devListMTX.RLock()
224 | rec := ReportEntry{
225 | IP: ip,
226 | Name: devList[re.Map["mac-address"]].Name,
227 | Interface: re.Map["interface"],
228 | SSID: re.Map["ssid"],
229 | MAC: re.Map["mac-address"],
230 | Signal: re.Map["rx-signal"],
231 | Hostname: n,
232 | Comment: c,
233 | }
234 |
235 | if strings.ToLower(config.Router.Mode) == "wifi" {
236 | rec.Signal = re.Map["signal-strength"]
237 | if i := strings.Index(rec.Signal, "@"); i > 0 {
238 | rec.Signal = rec.Signal[0:i]
239 | }
240 | }
241 | devListMTX.RUnlock()
242 | report = append(report, rec)
243 |
244 | // fmt.Printf("%-20s\t%-20s\t%-20s\t%-10s\t%-30s\t%-30s\n", re.Map["interface"], re.Map["ssid"], re.Map["mac-address"], re.Map["rx-signal"], n, c)
245 | }
246 | log.WithFields(log.Fields{"count": len(report)}).Debug("Reloaded CapsMan entries")
247 | leaseList.RUnlock()
248 |
249 | if err = broadcastData.reportUpdate(report); err != nil {
250 | log.WithFields(log.Fields{}).Warn("Error during reportUpdate: ", err)
251 |
252 | }
253 |
254 | time.Sleep(*interval)
255 | }
256 | }
257 |
258 | func loadConfig(configFileName string) (config Config, err error) {
259 | devListMTX.RLock()
260 | defer devListMTX.RUnlock()
261 |
262 | config = Config{}
263 | devList = make(map[string]ConfDevice)
264 |
265 | source, err := ioutil.ReadFile(configFileName)
266 | if err != nil {
267 | err = fmt.Errorf("cannot read config file [%s]", configFileName)
268 | return
269 | }
270 |
271 | if err = yaml.Unmarshal(source, &config); err != nil {
272 | err = fmt.Errorf("error parsing config file [%s]: %v", configFileName, err)
273 | return
274 | }
275 |
276 | for _, v := range config.Devices {
277 | devList[strings.ToUpper(v.MAC)] = v
278 | }
279 |
280 | return
281 | }
282 |
283 | func usage() {
284 |
285 | }
286 |
287 | // Handle report update request
288 | func (b *BroadcastData) reportUpdate(report []ReportEntry) error {
289 | output, err := json.Marshal(report)
290 | if err != nil {
291 | return err
292 | }
293 |
294 | // Lock mutex
295 | b.RLock()
296 | defer b.RUnlock()
297 |
298 | // Prepare new list of entries
299 | rm := map[string]ReportEntry{}
300 | for _, v := range report {
301 | rm[v.MAC] = v
302 | }
303 |
304 | // Scan for new entries
305 | for k := range rm {
306 | if _, ok := b.ReportMap[k]; !ok {
307 | // New entry
308 | b.ReportChan <- ReportEvent{
309 | EventType: EVENT_CONNECT,
310 | New: rm[k],
311 | }
312 | } else {
313 | // Check for roaming
314 | if rm[k].Interface != b.ReportMap[k].Interface {
315 | b.ReportChan <- ReportEvent{
316 | EventType: EVENT_ROAMING,
317 | Old: b.ReportMap[k],
318 | New: rm[k],
319 | }
320 | }
321 |
322 | // Check for signal level change
323 | if rm[k].Signal != b.ReportMap[k].Signal {
324 | b.ReportChan <- ReportEvent{
325 | EventType: EVENT_LEVEL,
326 | Old: b.ReportMap[k],
327 | New: rm[k],
328 | }
329 | }
330 | }
331 | }
332 |
333 | // Scan for deleted entries
334 | for k := range b.ReportMap {
335 | if _, ok := rm[k]; !ok {
336 | b.ReportChan <- ReportEvent{
337 | EventType: EVENT_DISCONNECT,
338 | Old: b.ReportMap[k],
339 | }
340 | }
341 | }
342 |
343 | b.ReportMap = rm
344 | b.Report = report
345 | b.Data = string(output)
346 | b.LastUpdate = time.Now()
347 |
348 | return nil
349 | }
350 |
351 | func (b *BroadcastData) EventHandler() {
352 | for { // nolint:gosimple
353 | select {
354 | case data := <-b.ReportChan:
355 | // fmt.Printf("New event received: %v\n", data)
356 | switch data.EventType {
357 | case EVENT_CONNECT:
358 | log.WithFields(log.Fields{"action": "register", "mac": data.New.MAC, "name": data.New.Name, "interface": data.New.Interface, "ssid": data.New.SSID, "hostname": data.New.Hostname, "comment": data.New.Comment, "level-to": data.New.Signal}).Info("New connection registered")
359 |
360 | // Get device info
361 | devListMTX.RLock()
362 | dev, ok := devList[data.New.MAC]
363 | devListMTX.RUnlock()
364 | if ok {
365 | if (len(dev.OnConnect.HttpPost) > 0) || (len(dev.OnConnect.HttpGet) > 0) {
366 | go makeRequest(dev.OnConnect, map[string]string{
367 | "name": dev.Name,
368 | "mac": data.New.MAC,
369 | "roaming.to": "",
370 | "roaming.from": "",
371 | "level.to": data.New.Signal,
372 | "level.from": "",
373 | })
374 | }
375 | }
376 |
377 | case EVENT_DISCONNECT:
378 | log.WithFields(log.Fields{"action": "disconnect", "mac": data.Old.MAC, "name": data.Old.Name, "interface": data.Old.Interface, "hostname": data.Old.Hostname, "comment": data.Old.Comment}).Info("Client disconnect")
379 |
380 | // Get device info
381 | devListMTX.RLock()
382 | dev, ok := devList[data.New.MAC]
383 | devListMTX.RUnlock()
384 | if ok {
385 | if (len(dev.OnDisconnect.HttpPost) > 0) || (len(dev.OnDisconnect.HttpGet) > 0) {
386 | go makeRequest(dev.OnDisconnect, map[string]string{
387 | "name": dev.Name,
388 | "mac": data.Old.MAC,
389 | "roaming.to": "",
390 | "roaming.from": "",
391 | "level.to": "",
392 | "level.from": data.Old.Signal,
393 | })
394 | }
395 | }
396 |
397 | case EVENT_ROAMING:
398 | log.WithFields(log.Fields{"action": "roaming", "mac": data.New.MAC, "name": data.New.Name, "interface-from": data.Old.Interface, "interface-to": data.New.Interface, "level-from": data.Old.Signal, "level-to": data.New.Signal}).Info("Client roaming")
399 |
400 | // Get device info
401 | devListMTX.RLock()
402 | dev, ok := devList[data.New.MAC]
403 | devListMTX.RUnlock()
404 | if ok {
405 | if (len(dev.OnRoaming.HttpPost) > 0) || (len(dev.OnRoaming.HttpGet) > 0) {
406 | go makeRequest(dev.OnRoaming, map[string]string{
407 | "name": dev.Name,
408 | "mac": data.New.MAC,
409 | "roaming.to": data.New.Interface,
410 | "roaming.from": data.Old.Interface,
411 | "level.from": data.Old.Signal,
412 | "level.to": data.New.Signal,
413 | })
414 | }
415 | }
416 |
417 | case EVENT_LEVEL:
418 | log.WithFields(log.Fields{"action": "level", "mac": data.New.MAC, "name": data.New.Name, "interface": data.New.Interface, "level-from": data.Old.Signal, "level-to": data.New.Signal}).Debug("Signal level change")
419 |
420 | // Get device info
421 | devListMTX.RLock()
422 | dev, ok := devList[data.New.MAC]
423 | devListMTX.RUnlock()
424 | if ok {
425 | if (len(dev.OnLevel.HttpPost) > 0) || (len(dev.OnLevel.HttpGet) > 0) {
426 | go makeRequest(dev.OnLevel, map[string]string{
427 | "name": dev.Name,
428 | "mac": data.Old.MAC,
429 | "roaming.to": "",
430 | "roaming.from": "",
431 | "level.from": data.Old.Signal,
432 | "level.to": data.New.Signal,
433 | })
434 | }
435 | }
436 |
437 | default:
438 |
439 | }
440 | }
441 | }
442 | }
443 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | log "github.com/sirupsen/logrus"
6 | "gopkg.in/routeros.v2"
7 | "os"
8 | "time"
9 | )
10 |
11 | var (
12 | // HTTP Listen port
13 | listen = flag.String("listen", "0.0.0.0:8080", "HTTP Listen configuration")
14 |
15 | // Polling interval
16 | interval = flag.Duration("interval", 3*time.Second, "CapsMan Polling Interval")
17 |
18 | // Optional configuration file
19 | configFileName = flag.String("config", "config.yml", "Configuration file name")
20 | )
21 |
22 | func main() {
23 | // Check for `--help` param
24 | if len(os.Args) > 1 {
25 | if os.Args[1] == "--help" {
26 | usage()
27 | return
28 | }
29 | }
30 |
31 | // Init broadcast data
32 | broadcastData.Init()
33 | go broadcastData.EventHandler()
34 |
35 | flag.Parse()
36 |
37 | log.SetLevel(log.DebugLevel)
38 | log.Warning("Starting Mikrotik CapsMan monitor daemon")
39 |
40 | // Load config if specified
41 | cfg, err := loadConfig(*configFileName)
42 | if err != nil {
43 | log.WithFields(log.Fields{"config": *configFileName}).Fatal("Error loading config file")
44 | return
45 | }
46 |
47 | // Switch log level if required
48 | if cfg.Log.Level != log.DebugLevel {
49 | log.WithFields(log.Fields{"loglevel": cfg.Log.Level}).Warn("Switching Log Level")
50 | log.SetLevel(cfg.Log.Level)
51 | }
52 |
53 | // Validate reload interval duration
54 | if cfg.Router.Interval < (2 * time.Second) {
55 | log.WithFields(log.Fields{"config": *configFileName}).Fatal("capsman.interval is too short, minimum value is 2 sec")
56 | }
57 |
58 | if (len(cfg.DHCP.Address) > 0) && cfg.DHCP.Interval < (10*time.Second) {
59 | log.WithFields(log.Fields{"config": *configFileName}).Fatal("dhcp.interval is too short, minimum value is 10 sec")
60 | }
61 |
62 | log.WithFields(log.Fields{"config": *configFileName}).Warn("Loaded config file")
63 | configMTX.RLock()
64 | config = cfg
65 | configMTX.RUnlock()
66 |
67 | if len(cfg.DHCP.Address) > 0 {
68 | l, err := GetDHCPLeases(config.DHCP.Address, config.DHCP.Username, config.DHCP.Password)
69 | if err != nil {
70 | log.WithFields(log.Fields{"dhcp-addr": config.DHCP.Address, "dhcp-username": config.DHCP.Username}).Fatal("Cannot connect to DHCP Server")
71 | }
72 |
73 | leaseList.RLock()
74 | leaseList.List = l
75 | leaseList.RUnlock()
76 | log.WithFields(log.Fields{"dhcp-addr": config.DHCP.Address, "count": len(l)}).Info("Loaded DHCP Lease list")
77 |
78 | } else {
79 | log.WithFields(log.Fields{"dhcp-addr": config.DHCP.Address}).Warn("DHCP support is disabled in configuration")
80 | }
81 |
82 | conn, err := routeros.Dial(config.Router.Address, config.Router.Username, config.Router.Password)
83 | if err != nil {
84 | log.WithFields(log.Fields{"address": config.Router.Address, "username": config.Router.Username}).Fatal("Cannot connect to CapsMan node")
85 | return
86 | }
87 | log.WithFields(log.Fields{"address": config.Router.Address}).Info("Connected to CapsMan server")
88 |
89 | // Run HTTP Server
90 | go serveHTTP()
91 |
92 | // Start DHCP periodical reload
93 | if len(cfg.DHCP.Address) > 0 {
94 | go reloadDHCP()
95 | }
96 |
97 | // Run loop : scan Registration-Table
98 | RTLoop(conn, &config)
99 | }
100 |
--------------------------------------------------------------------------------