add easy webinterface

keep-around/16fdeb85d4d114671b208cf47667157b4d4f607c
Martin/Geno 4 years ago
parent d20e749038
commit 16fdeb85d4
No known key found for this signature in database
GPG Key ID: 9D7D3C6BFF600C6A
  1. 7
      .gitlab-ci.yml
  2. 26
      cmd/controller.go
  3. 2
      cmd/dump.go
  4. 19
      cmd/root.go
  5. 3
      config/config.go
  6. 13
      config_example.conf
  7. 16
      data/hwaddr.go
  8. 19
      data/socket_msg.go
  9. 23
      data/wifi_client.go
  10. 24
      database/client.go
  11. 3
      database/main.go
  12. 7
      web/config.go
  13. 54
      web/webserver.go
  14. 28
      web/webserver_test.go
  15. 6
      webroot/assets/get_assets.sh
  16. 3
      webroot/assets/vanilla-framework.css
  17. 2626
      webroot/assets/vue-route.js
  18. 1
      webroot/assets/vue-websocket.js
  19. 1
      webroot/assets/vue-websocket.js.map
  20. 11907
      webroot/assets/vue.js
  21. 6
      webroot/assets/vuex.js
  22. 10
      webroot/css/main.css
  23. 46
      webroot/index.html
  24. 20
      webroot/js/data.js
  25. 35
      webroot/js/main.js
  26. 104
      webroot/js/store.js
  27. 37
      webroot/js/view/accesspoint.js
  28. 42
      webroot/js/view/clients.js

@ -24,7 +24,7 @@ test-my-project:
stage: test
script:
- go get github.com/client9/misspell/cmd/misspell
- misspell -error .
- find . -type f | grep -v webroot/assets | xargs misspell -error
- ./.ci/check-gofmt
- ./.ci/check-testfiles
- go test $(go list ./... | grep -v /vendor/) -v -coverprofile .testCoverage.txt
@ -44,7 +44,10 @@ deploy:
script:
- go install "dev.sum7.eu/$CI_PROJECT_PATH"
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
- 'which rsync || ( apt-get update -y && apt-get install rsync -y )'
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
- rsync -e "ssh -6 -o StrictHostKeyChecking=no -p $SSH_PORT" -a --delete "/builds/$CI_PROJECT_PATH/webroot/" "$CI_PROJECT_NAME@$SSH_HOST":/opt/$CI_PROJECT_NAME/webroot/
- ssh -6 -o StrictHostKeyChecking=no -p $SSH_PORT "$CI_PROJECT_NAME@$SSH_HOST" sudo /usr/bin/systemctl stop $CI_PROJECT_NAME
- scp -6 -o StrictHostKeyChecking=no -P $SSH_PORT "/go/bin/$CI_PROJECT_NAME" "$CI_PROJECT_NAME@$SSH_HOST":/opt/$CI_PROJECT_NAME/bin
- ssh -6 -o StrictHostKeyChecking=no -p $SSH_PORT "$CI_PROJECT_NAME@$SSH_HOST" sudo /usr/bin/systemctl restart $CI_PROJECT_NAME
- ssh -6 -o StrictHostKeyChecking=no -p $SSH_PORT "$CI_PROJECT_NAME@$SSH_HOST" sudo /usr/bin/systemctl start $CI_PROJECT_NAME

@ -1,6 +1,7 @@
package cmd
import (
"net"
"os"
"os/signal"
"syscall"
@ -12,7 +13,9 @@ import (
"dev.sum7.eu/genofire/wifictld-analyzer/capture"
"dev.sum7.eu/genofire/wifictld-analyzer/config"
"dev.sum7.eu/genofire/wifictld-analyzer/controller"
"dev.sum7.eu/genofire/wifictld-analyzer/data"
"dev.sum7.eu/genofire/wifictld-analyzer/database"
"dev.sum7.eu/genofire/wifictld-analyzer/web"
)
// queryCmd represents the query command
@ -31,7 +34,26 @@ var controllerCmd = &cobra.Command{
ctr := controller.NewController(db)
defer ctr.Close()
coll := capture.NewCollector(ctr.Handler, configObj.Interfaces)
var handlers []data.Handler
if configObj.Webserver.Enable {
log.Infof("starting webserver on %s", configObj.Webserver.Bind)
srv := web.New(configObj.Webserver)
go srv.Start()
handlers = append(handlers, srv.Handler)
defer srv.Close()
}
coll := capture.NewCollector(func(addr *net.UDPAddr, msg *data.SocketMSG) (*data.SocketMSG, error) {
for _, a := range handlers {
a(addr, msg)
}
if !configObj.Answer {
ctr.Handler(addr, msg)
return nil, nil
}
return ctr.Handler(addr, msg)
}, configObj.Interfaces)
defer coll.Close()
ctr.Send = coll.Send
@ -47,5 +69,5 @@ var controllerCmd = &cobra.Command{
}
func init() {
RootCmd.AddCommand(controllerCmd)
RootCMD.AddCommand(controllerCmd)
}

@ -58,7 +58,7 @@ var dumpCmd = &cobra.Command{
}
func init() {
RootCmd.AddCommand(dumpCmd)
RootCMD.AddCommand(dumpCmd)
dumpCmd.Flags().IntVar(&port, "port", capture.Port, "define a port to listen (if not set or set to 0 the kernel will use a random free port at its own)")
dumpCmd.Flags().StringVar(&ipAddress, "listen", capture.MulticastAddressDefault, "")
}

@ -6,19 +6,22 @@ import (
"github.com/spf13/cobra"
)
var debug bool
var (
debug bool
timestamps bool
)
// RootCmd represents the base command when called without any subcommands
var RootCmd = &cobra.Command{
// RootCMD represents the base command when called without any subcommands
var RootCMD = &cobra.Command{
Use: "analyzer",
Short: "wifictld analyzer",
Long: `capture wifictld traffic and display thus`,
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
// This is called by main.main(). It only needs to happen once to the RootCMD.
func Execute() {
if err := RootCmd.Execute(); err != nil {
if err := RootCMD.Execute(); err != nil {
log.Error(err)
}
}
@ -27,7 +30,11 @@ func init() {
if debug {
log.SetLevel(log.DebugLevel)
}
log.SetFormatter(&log.TextFormatter{
DisableTimestamp: timestamps,
})
log.Debug("show debug")
})
RootCmd.PersistentFlags().BoolVar(&debug, "v", false, "show debug log")
RootCMD.PersistentFlags().BoolVar(&debug, "v", false, "show debug log")
RootCMD.PersistentFlags().BoolVar(&timestamps, "timestamps", false, "Enables timestamps for log output")
}

@ -2,9 +2,12 @@ package config
import (
"dev.sum7.eu/genofire/wifictld-analyzer/capture"
"dev.sum7.eu/genofire/wifictld-analyzer/web"
)
type Config struct {
StatePath string `toml:"state_path"`
Answer bool `toml:"answer"`
Webserver *web.Config `toml:"webserver"`
Interfaces []*capture.IFaceConfig `toml:"interfaces"`
}

@ -1,6 +1,13 @@
state_path = "/tmp/wifictld.json"
answer = false
[webserver]
enable = true
bind = ":8080"
webroot = "./webroot/"
[[interfaces]]
ifname = "wlp4s0"
#port = 1000
#ip_address = "ff02::31f1"
ifname = "wlp4s0"
#port = 1000
#ip_address = "ff02::31f1"

@ -0,0 +1,16 @@
package data
import "net"
type HardwareAddr struct{ net.HardwareAddr }
//MarshalJSON to bytearray
func (a HardwareAddr) MarshalText() ([]byte, error) {
return []byte(a.String()), nil
}
// UnmarshalJSON from bytearray
func (a HardwareAddr) UnmarshalText(data []byte) (err error) {
a.HardwareAddr, err = net.ParseMAC(string(data))
return
}

@ -3,7 +3,6 @@ package data
import (
"encoding/binary"
"fmt"
"net"
"time"
"github.com/bdlm/log"
@ -31,8 +30,8 @@ func (a SocketMSGType) Is(b SocketMSGType) bool {
// SocketMSG package of wifictld format
type SocketMSG struct {
Types SocketMSGType
Client *WifiClient
Types SocketMSGType `json:"types"`
Client *WifiClient `json:"client,omitempty"`
}
func NewSocketMSG(obj []byte) (*SocketMSG, error) {
@ -51,17 +50,17 @@ func (msg *SocketMSG) Marshal() ([]byte, error) {
pos += 4
if msg.Types.Is(SocketMSGTypeClient) {
obj[pos] = msg.Client.Addr[0]
obj[pos] = msg.Client.Addr.HardwareAddr[0]
pos++
obj[pos] = msg.Client.Addr[1]
obj[pos] = msg.Client.Addr.HardwareAddr[1]
pos++
obj[pos] = msg.Client.Addr[2]
obj[pos] = msg.Client.Addr.HardwareAddr[2]
pos++
obj[pos] = msg.Client.Addr[3]
obj[pos] = msg.Client.Addr.HardwareAddr[3]
pos++
obj[pos] = msg.Client.Addr[4]
obj[pos] = msg.Client.Addr.HardwareAddr[4]
pos++
obj[pos] = msg.Client.Addr[5]
obj[pos] = msg.Client.Addr.HardwareAddr[5]
pos++
binary.BigEndian.PutUint32(obj[pos:(pos+4)], uint32(msg.Client.Time.Unix()))
pos += 4
@ -100,7 +99,7 @@ func (msg *SocketMSG) Unmarshal(obj []byte) error {
if msg.Types.Is(SocketMSGTypeClient) {
msg.Client = &WifiClient{
Addr: net.HardwareAddr(obj[pos:(pos + 6)]),
Addr: HardwareAddr{HardwareAddr: obj[pos:(pos + 6)]},
Time: time.Unix(int64(binary.BigEndian.Uint32(obj[(pos+6):(pos+10)])), 0),
TryProbe: binary.BigEndian.Uint16(obj[(pos + 10):(pos + 12)]),
TryAuth: binary.BigEndian.Uint16(obj[(pos + 12):(pos + 14)]),

@ -1,9 +1,6 @@
package data
import (
"net"
"time"
)
import "time"
const (
// default multicast group used by announced
@ -18,13 +15,13 @@ const (
// WifiClient datatype of wifictld
type WifiClient struct {
Addr net.HardwareAddr
Time time.Time
TryProbe uint16
TryAuth uint16
Connected bool
Authed bool
FreqHighest uint16
SignalLowFreq int16
SignalHighFreq int16
Addr HardwareAddr `json:"addr"`
Time time.Time `json:"time"`
TryProbe uint16 `json:"try_probe"`
TryAuth uint16 `json:"try_auth"`
Connected bool `json:"connected"`
Authed bool `json:"authed"`
FreqHighest uint16 `json:"freq_highest"`
SignalLowFreq int16 `json:"signal_low_freq"`
SignalHighFreq int16 `json:"signal_high_freq"`
}

@ -11,17 +11,17 @@ import (
)
type Client struct {
AP *AP `json:"-"`
APAddr string `json:"ap"`
Addr net.HardwareAddr `json:"-"`
TryProbe uint16 `json:"try_probe"`
TryAuth uint16 `json:"try_auth"`
Connected bool `json:"connected"`
Authed bool `json:"authed"`
FreqHighest uint16 `json:"freq_highest"`
SignalLowFreq int16 `json:"signal_low_freq"`
SignalHighFreq int16 `json:"signal_high_freq"`
Lastseen jsontime.Time `json:"lastseen"`
AP *AP `json:"-"`
APAddr string `json:"ap"`
Addr data.HardwareAddr `json:"-"`
TryProbe uint16 `json:"try_probe"`
TryAuth uint16 `json:"try_auth"`
Connected bool `json:"connected"`
Authed bool `json:"authed"`
FreqHighest uint16 `json:"freq_highest"`
SignalLowFreq int16 `json:"signal_low_freq"`
SignalHighFreq int16 `json:"signal_high_freq"`
Lastseen jsontime.Time `json:"lastseen"`
}
func (db *DB) LearnClient(apIP net.IP, clientWifictl *data.WifiClient) bool {
@ -70,7 +70,7 @@ func (db *DB) LearnClient(apIP net.IP, clientWifictl *data.WifiClient) bool {
return ret
}
func (db *DB) GetClient(addr net.HardwareAddr) *data.WifiClient {
func (db *DB) GetClient(addr data.HardwareAddr) *data.WifiClient {
client, ok := db.Clients[addr.String()]
wClient := &data.WifiClient{
Addr: addr,

@ -1,7 +1,6 @@
package database
import (
"net"
"time"
"github.com/bdlm/log"
@ -25,7 +24,7 @@ func NewDB(path string) *DB {
file.ReadJSON(path, db)
for addr, client := range db.Clients {
client.Addr, _ = net.ParseMAC(addr)
client.Addr.UnmarshalText([]byte(addr))
if ap, ok := db.APs[client.APAddr]; ok {
client.AP = ap
}

@ -0,0 +1,7 @@
package web
type Config struct {
Enable bool `toml:"enable"`
Bind string `toml:"bind"`
Webroot string `toml:"webroot"`
}

@ -0,0 +1,54 @@
package web
import (
"net"
"net/http"
"github.com/NYTimes/gziphandler"
"github.com/bdlm/log"
"dev.sum7.eu/genofire/golang-lib/websocket"
"dev.sum7.eu/genofire/wifictld-analyzer/data"
)
type Server struct {
web *http.Server
ws *websocket.WebsocketHandlerService
}
// New creates a new webserver and starts it
func New(config *Config) *Server {
ws := websocket.NewWebsocketHandlerService()
ws.Listen("/ws")
http.Handle("/", gziphandler.GzipHandler(http.FileServer(http.Dir(config.Webroot))))
return &Server{
web: &http.Server{
Addr: config.Bind,
},
ws: ws,
}
}
func (srv *Server) Handler(addr *net.UDPAddr, msg *data.SocketMSG) (*data.SocketMSG, error) {
srv.ws.SendAll(&websocket.Message{
Subject: "wifictld_pkg",
Body: map[string]interface{}{
"ip": addr.IP,
"msg": msg,
},
})
return msg, nil
}
func (srv *Server) Start() {
// service connections
if err := srv.web.ListenAndServe(); err != http.ErrServerClosed {
log.Panicf("webserver crashed: %s", err)
}
}
func (srv *Server) Close() {
srv.web.Close()
srv.ws.Close()
}

@ -0,0 +1,28 @@
package web
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestWebserver(t *testing.T) {
assert := assert.New(t)
srv := New(&Config{
Bind: ":12345",
Webroot: "/tmp",
})
assert.NotNil(srv)
go srv.Start()
time.Sleep(time.Millisecond * 200)
assert.Panics(func() {
srv.Start()
}, "not allowed to listen twice")
srv.Close()
}

@ -0,0 +1,6 @@
wget -O vanilla-framework.css https://assets.ubuntu.com/v1/vanilla-framework-version-1.8.1.min.css
wget -O vue.js https://cdn.jsdelivr.net/npm/vue@2.6.7/dist/vue.js
wget -O vue-route.js https://unpkg.com/vue-router/dist/vue-router.js
wget -O vuex.js https://unpkg.com/vuex@2.0.0/dist/vuex.min.js
wget -O vue-websocket.js https://raw.githubusercontent.com/nathantsoi/vue-native-websocket/master/dist/build.js
wget -O vue-websocket.js.map https://raw.githubusercontent.com/nathantsoi/vue-native-websocket/master/dist/build.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -0,0 +1,10 @@
.p-navigation__logo {
font-size: 1.5rem;
line-height: 3rem;
}
.p-navigation .p-navigation__logo .p-navigation__link {
color: red;
}
.p-navigation .p-navigation__logo.online .p-navigation__link {
color: black;
}

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Wifictld</title>
<link rel="stylesheet" href="assets/vanilla-framework.css" />
<link rel="stylesheet" href="css/main.css" />
<script src="assets/vue.js"></script>
<script src="assets/vue-route.js"></script>
<script src="assets/vuex.js"></script>
<script src="assets/vue-websocket.js"></script>
</head>
<body role="document">
<div id="app">
<header id="navigation" class="p-navigation">
<div class="p-navigation__banner">
<navbar-logo></navbar-logo>
<a href="#navigation" class="p-navigation__toggle--open" title="menu">Menu</a>
<a href="#navigation-closed" class="p-navigation__toggle--close" title="close menu">Close menu</a>
</div>
<nav class="p-navigation__nav" role="menubar">
<span class="u-off-screen">
<a href="#main-content">Jump to main content</a>
</span>
<ul class="p-navigation__links" role="menu">
<router-link :to="{name: 'aps' }" tag="li" class="p-navigation__link" active-class="is-selected">
<a>Access Points</a>
</router-link>
<router-link :to="{name: 'clients' }" tag="li" class="p-navigation__link" active-class="is-selected">
<a>Clients</a>
</router-link>
</ul>
</nav>
</header>
<div class="content">
<router-view></router-view>
</div>
</div>
<script src="js/view/accesspoint.js"></script>
<script src="js/view/clients.js"></script>
<script src="js/store.js"></script>
<script src="js/data.js"></script>
<script src="js/main.js"></script>
</body>
</html>

@ -0,0 +1,20 @@
store.commit('setEvent', {
subject:"wifictld_pkg",
callback: (state, msg) => {
// add to ap list
const ip = msg.body.ip;
if(state.controller._ap[ip] === undefined){
state.controller.ap.push(ip)
state.controller._ap[ip] = null;
}
// add clients
const client = msg.body.msg.client;
if (state.controller.clients[client.addr] === undefined) {
state.controller._clients.push(client.addr)
}
client.ap = ip;
state.controller.clients[client.addr] = client;
}
})

@ -0,0 +1,35 @@
const router = new VueRouter({
store,
routes: [
{ path: '/ap', component: ViewAccessPoints, name: "aps" },
{ path: '/ap/clients/:ip', component: ViewClients, name: "ap.clients"},
{ path: '/clients', component: ViewClients, name: "clients" },
{ path: '/', redirect: '/ap' }
]
})
VueNativeSock.default.install(Vue, `//${location.host}${location.pathname}ws`, {
store: store,
reconnection: true,
reconnectionDelay: 5000,
format: 'json',
})
const NavbarLogo = {
template: `<div class="p-navigation__logo" v-bind:class="{ online: isOnline }">
<a class="p-navigation__link" href="/">Wifictld</a>
</div>`,
computed: {
isOnline () {
return this.$store.state.socket.isConnected
}
}
};
const app = new Vue({
el: '#app',
store,
router,
components: { NavbarLogo },
})

@ -0,0 +1,104 @@
function newUUID () {
/* eslint-disable */
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0,
v = c === 'x' ? r : r & 0x3 | 0x8;
return v.toString(16);
});
/* eslint-enable */
}
const store = new Vuex.Store({
state: {
socket: {
_session: localStorage.getItem('session'),
isConnected: false,
reconnectError: false,
eventMSGID: {},
eventTo: {},
},
controller: {
_ap : {},
ap: [],
_clients: [],
clients: {},
},
},
mutations:{
SOCKET_ONOPEN (state, event) {
state.socket.isConnected = true
},
SOCKET_ONCLOSE (state, event) {
state.socket.isConnected = false
},
SOCKET_ONERROR (state, event) {
console.error(state, event)
},
// default handler called for all methods
SOCKET_ONMESSAGE (state, msg) {
if (msg.subject === 'session_init') {
if (state.socket._session === null) {
state.socket._session = newUUID();
localStorage.setItem('session', state.socket._session);
}
msg.id = state.socket._session;
Vue.prototype.$socket.sendObj(msg);
return;
}
const msgFunc = state.socket.eventMSGID[msg.id];
if (msgFunc) {
msgFunc(state, msg);
delete state.socket.eventMSGID[msg.id];
return;
}
const eventFuncs = state.socket.eventTo[msg.subject];
if (typeof eventFuncs === 'object' && eventFuncs.length > 0) {
for (const key in eventFuncs) {
const func = eventFuncs[key];
if (func) {
func(state, msg);
}
}
return;
}
console.log(`unable to identify message: ${msg.subject}`);
},
// mutations for reconnect methods
SOCKET_RECONNECT(state, count) {
console.info(state, "reconnect:", count)
},
SOCKET_RECONNECT_ERROR(state) {
state.socket.reconnectError = true;
},
setEvent (state, data) {
state.socket.eventTo[data.subject] = [data.callback];
},
addEvent (state, data) {
if (typeof state.socket.eventTo[data.subject] !== 'object') {
state.socket.eventTo[data.subject] = [];
}
state.socket.eventTo[data.subject].push(data.callback);
},
delEvent (state, data) {
if (typeof state.socket.eventTo[data.subject] === 'object' && state.socket.eventTo[data.subject].length > 1) {
state.socket.eventTo[data.subject].pop(data.callback);
} else {
state.socket.eventTo[data.subject] = [];
}
},
call (state, data) {
if (!data.msg.id) {
data.msg.id = newUUID();
}
const ret = Vue.prototype.$socket.sendObj(data.msg);
if (typeof data.callback === 'function') {
state.socket.eventMSGID[data.msg.id] = data.callback;
}
return ret;
}
}
})

@ -0,0 +1,37 @@
const ViewAccessPoints = {
template: `<div class="row">
<p><span class="p-heading--one">AccessPoints</span></p>
<table class="p-table--mobile-card" role="grid">
<thead>
<tr role="row">
<th scope="col" role="columnheader" aria-sort="none">IP</th>
<th scope="col" role="columnheader" aria-sort="none" class="u-align--right">Users</th>
</tr>
</thead>
<tbody>
<tr role="row" v-for="item in getAPs">
<td role="rowheader" aria-label="IP">{{ item.ip.substring(21) }}</td>
<td role="gridcell" aria-label="Users" class="u-align--right">
<router-link :to="{ name: 'ap.clients', params: { ip: item.ip }}">
{{ item.clients }}
</router-link>
</td>
</tr>
</tbody>
</table>
</div>`,
computed: {
getAPs () {
const state = this.$store.state;
return state.controller.ap.map(function (ip) {
return {
ip: ip,
clients: state.controller._clients.filter(function (hwaddr) {
const client = state.controller.clients[hwaddr];
return client.ap === ip;
}).length,
};
})
},
}
}

@ -0,0 +1,42 @@
const ViewClients = {
template: `<div class="row">
<p>
<span class="p-heading--one">Clients</span>
<sub v-if="$route.params.ip">of Router {{ $route.params.ip.substring(21) }}</sub>
</p>
<table class="p-table--mobile-card" role="grid">
<thead>
<tr role="row">
<th scope="col" role="columnheader" aria-sort="none">Addr</th>
<th scope="col" role="columnheader" aria-sort="none">AP</th>
<th scope="col" role="columnheader" aria-sort="none" class="u-align--right">FreqHighs</th>
<th scope="col" role="columnheader" aria-sort="none" class="u-align--right">SignalLow</th>
<th scope="col" role="columnheader" aria-sort="none" class="u-align--right">SignalHigh</th>
</tr>
</thead>
<tbody>
<tr role="row" v-for="item in getClients">
<td role="rowheader" aria-label="Addr">{{ item.addr.substring(12) }}</td>
<td role="gridcell" aria-label="AP">
<router-link :to="{ name: 'ap.clients', params: { ip: item.ap }}">
{{ item.ap.substring(21) }}
</router-link>
</td>
<td role="gridcell" aria-label="Freq Highs" class="u-align--right">{{ item.freq_highest }}</td>
<td role="gridcell" aria-label="Signal LowF" class="u-align--right">{{ item.signal_low_freq }}</td>
<td role="gridcell" aria-label="Signal HighF" class="u-align--right">{{ item.signal_high_freq }}</td>
</tr>
</tbody>
</table>
</div>`,
computed: {
getClients () {
const state = this.$store.state,
apIP = this.$route.params.ip;
return state.controller._clients.map(function(addr) {
return state.controller.clients[addr];
}).filter((client)=> (apIP === undefined || client.ap === apIP));
},
}
}
Loading…
Cancel
Save