├── .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 | ![UI Example](https://github.com/vponomarev/mikrotik-capsman/raw/master/doc/mikrotik-capsman-ui-sample-processed.PNG) 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 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
InterfaceSSIDMACIPNameSignalHostnameComment 
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 | 


--------------------------------------------------------------------------------