First basic version. Features: webUI, mqtt support (gets devices updates + parsing), reads userDb.json

This commit is contained in:
Holger Cremer
2017-11-06 22:16:19 +01:00
parent a1e4eaf0fa
commit 9b377bcb81
20 changed files with 8067 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.idea/
.vscode/
config.toml
vendor/
userDb.json
masterDb.json

117
Gopkg.lock generated Normal file
View File

@@ -0,0 +1,117 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/BurntSushi/toml"
packages = ["."]
revision = "b26d9c308763d68093482582cea63d69be07a0f0"
version = "v0.3.0"
[[projects]]
name = "github.com/davecgh/go-spew"
packages = ["spew"]
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
branch = "master"
name = "github.com/dchest/uniuri"
packages = ["."]
revision = "8902c56451e9b58ff940bbe5fec35d5f9c04584a"
[[projects]]
name = "github.com/eclipse/paho.mqtt.golang"
packages = [".","packets"]
revision = "aff15770515e3c57fc6109da73d42b0d46f7f483"
version = "v1.1.0"
[[projects]]
branch = "master"
name = "github.com/gin-contrib/gzip"
packages = ["."]
revision = "9b22cb967bcc8481c9fe3ab99f70d715f88980f0"
[[projects]]
branch = "master"
name = "github.com/gin-contrib/sse"
packages = ["."]
revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae"
[[projects]]
name = "github.com/gin-gonic/gin"
packages = [".","binding","render"]
revision = "d459835d2b077e44f7c9b453505ee29881d5d12d"
version = "v1.2"
[[projects]]
branch = "master"
name = "github.com/golang/protobuf"
packages = ["proto"]
revision = "1643683e1b54a9e88ad26d98f81400c8c9d9f4f9"
[[projects]]
name = "github.com/mattn/go-isatty"
packages = ["."]
revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39"
version = "v0.0.3"
[[projects]]
name = "github.com/pmezard/go-difflib"
packages = ["difflib"]
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0"
[[projects]]
name = "github.com/sirupsen/logrus"
packages = ["."]
revision = "f006c2ac4710855cf0f916dd6b77acf6b048dc6e"
version = "v1.0.3"
[[projects]]
name = "github.com/stretchr/testify"
packages = ["assert"]
revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0"
version = "v1.1.4"
[[projects]]
branch = "master"
name = "github.com/ugorji/go"
packages = ["codec"]
revision = "459bba837a9de7a4d9c58fa5a1171a2a3c96d0d0"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = ["ssh/terminal"]
revision = "2509b142fb2b797aa7587dad548f113b2c0f20ce"
[[projects]]
branch = "master"
name = "golang.org/x/net"
packages = ["proxy","websocket"]
revision = "c73622c77280266305273cb545f54516ced95b93"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = ["unix","windows"]
revision = "661970f62f5897bc0cd5fdca7e087ba8a98a8fa1"
[[projects]]
name = "gopkg.in/go-playground/validator.v8"
packages = ["."]
revision = "5f1438d3fca68893a817e4a66806cea46a9e4ebf"
version = "v8.18.2"
[[projects]]
branch = "v2"
name = "gopkg.in/yaml.v2"
packages = ["."]
revision = "eb3733d160e74a9c7e442f435eb3bea458e1d19f"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "13743fcc4cbd00e607d2f15ef32adb097b9e3fec858d56d2ffb0d753b998f7ee"
solver-name = "gps-cdcl"
solver-version = 1

50
Gopkg.toml Normal file
View File

@@ -0,0 +1,50 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
[[constraint]]
name = "github.com/sirupsen/logrus"
version = "1.0.3"
[[constraint]]
name = "github.com/gin-gonic/gin"
version = "1.2.0"
[[constraint]]
branch = "master"
name = "github.com/gin-contrib/gzip"
[[constraint]]
name = "github.com/BurntSushi/toml"
version = "0.3.0"
[[constraint]]
name = "github.com/eclipse/paho.mqtt.golang"
version = "1.1.0"
[[constraint]]
name = "github.com/stretchr/testify"
version = "1.1.4"
[[constraint]]
branch = "master"
name = "github.com/dchest/uniuri"

23
cmd/spaceDevices.go Normal file
View File

@@ -0,0 +1,23 @@
package main
import (
"github.com/ktt-ol/spaceDevices/conf"
"github.com/ktt-ol/spaceDevices/db"
"github.com/ktt-ol/spaceDevices/mqtt"
"github.com/ktt-ol/spaceDevices/webService"
log "github.com/sirupsen/logrus"
)
const CONFIG_FILE = "config.toml"
func main() {
log.SetLevel(log.DebugLevel)
log.SetFormatter(&log.TextFormatter{DisableColors: true})
config := conf.LoadConfig(CONFIG_FILE)
//spaceDevices.EnableMqttDebugLogging()
mqttHandler := mqtt.NewMqttHandler(config.Mqtt)
macDb := db.NewUserMacSettings(config.MacDb)
webService.StartWebService(config.Server, mqttHandler, macDb)
}

43
conf/config.go Normal file
View File

@@ -0,0 +1,43 @@
package conf
import (
"github.com/BurntSushi/toml"
log "github.com/sirupsen/logrus"
)
func LoadConfig(configFile string) TomlConfig {
log.WithField("configFile", configFile).Info("Loading config.")
config := &TomlConfig{}
if _, err := toml.DecodeFile(configFile, config); err != nil {
log.WithError(err).Fatal("Could not read config file.")
}
return *config
}
type TomlConfig struct {
Server ServerConf
MacDb MacDbConf
Mqtt MqttConf
}
type ServerConf struct {
Host string
Port int
Https bool
KeyFile string
CertFile string
}
type MacDbConf struct {
MasterFile string
UserFile string
}
type MqttConf struct {
Url string
Username string
Password string
// if empty, the system certificates are used
CertFile string
}

53
config.toml.example Normal file
View File

@@ -0,0 +1,53 @@
[server]
host = "0.0.0.0"
port = 9000
https = false
# optional if https is false
keyFile = "...your.key"
# optional if https is false
certFile = "...your.cer"
[mqtt]
url = "tls://server:8883"
# optional
certFile = "server.cert.pem"
username = "user"
password = "pass"
[macDb]
# JSON file, NOT modified by the app
# Format:
#{
# "00:01:02:03:04:05": {
# "name": "a name",
# "device-type": "a type",
# "visibility": "ignore"
# },
# "aa:bb:cc:dd:ee:ff": {
# "name": "another name",
# "device-type": "server",
# "visibility": "ignore"
# },
masterFile = "masterDb.json"
# JSON file, modified by this app
# Format:
#{
# "00:01:02:03:04:05": {
# "name": "a name",
# "visibility": "show",
# "ts": 1427737817755
# },
# "aa:bb:cc:dd:ee:ff": {
# "name": "another name",
# "visibility": "show",
# "ts": 1427737817755
# },
userFile = "userDb.json"
# mqtt: {
# server: 'tls://spacegate.mainframe.lan',
# ca: extConfFolder + '/spacegate.crt',
# topic: '/net/devices',
# username: 'devices',
# password: '6AroZF3A34E6bocd'
# },

View File

@@ -0,0 +1,13 @@
package db
import "testing"
import (
"github.com/stretchr/testify/assert"
)
func Test_IsMacLocallyAdministered(t *testing.T) {
assert.True(t, IsMacLocallyAdministered("06:00:00:00:00:00"))
assert.True(t, IsMacLocallyAdministered("62:01:0f:b5:f2:d9"))
assert.False(t, IsMacLocallyAdministered("20:c9:d0:7a:fa:31"))
}

113
db/usersMacSettings.go Normal file
View File

@@ -0,0 +1,113 @@
package db
import (
"encoding/json"
"io/ioutil"
"strconv"
"sync"
"github.com/ktt-ol/spaceDevices/conf"
log "github.com/sirupsen/logrus"
)
type UserMacSettings struct {
userMap map[string]UserMacInfo
lock sync.RWMutex
config conf.MacDbConf
}
type Visibility uint8
const (
// Not shown at all
VisibilityIgnore Visibility = iota
// don't show the name, but increments the anonymous user count
VisibilityAnon
// show the user, but not the device name(s)
VisibilityUser
// show user and the device names
VisibilityAll
)
func ParseVisibility(visibility uint8) (Visibility, bool) {
if visibility > 3 {
return 0, false
}
return Visibility(visibility), true
}
func NewUserMacSettings(config conf.MacDbConf) *UserMacSettings {
instance := &UserMacSettings{config: config}
instance.loadDb()
return instance
}
func (db *UserMacSettings) Get(mac string) (UserMacInfo, bool) {
db.lock.RLock()
value, ok := db.userMap[mac]
db.lock.RUnlock()
return value, ok
}
func (db *UserMacSettings) Set(mac string, info UserMacInfo) {
db.lock.Lock()
defer db.lock.Unlock()
db.userMap[mac] = info
db.saveDb()
}
func (db *UserMacSettings) Delete(mac string) {
db.lock.Lock()
defer db.lock.Unlock()
delete(db.userMap, mac)
}
func (db *UserMacSettings) loadDb() {
db.lock.Lock()
defer db.lock.Unlock()
file, err := ioutil.ReadFile(db.config.UserFile)
if err != nil {
log.Fatal("UserFile error: ", err)
}
var parsed entryMap
if err = json.Unmarshal(file, &parsed); err != nil {
log.Fatal("Unmarshal err: ", err)
}
db.userMap = parsed
}
func (db *UserMacSettings) saveDb() {
bytes, err := json.MarshalIndent(db.userMap, "", " ")
if err != nil {
log.Fatal("Can't marshal the userDb: ", err)
}
if err = ioutil.WriteFile(db.config.UserFile, bytes, 0644); err != nil {
log.Fatal("Can't save the userDb: ", err)
}
}
type entryMap map[string]UserMacInfo
type UserMacInfo struct {
Name string
DeviceName string
Visibility Visibility
// last change in ms
Ts int64
}
// IsMacLocallyAdministered expects the mac in the format e.g. "20:c9:d0:7a:fa:31"
// https://en.wikipedia.org/wiki/MAC_address
func IsMacLocallyAdministered(mac string) bool {
// 00000010
const mask = 1 << 1
first2chars := mac[:2]
decimal, _ := strconv.ParseInt(first2chars, 16, 8)
return (decimal & mask) == mask
}

227
mqtt/mqtt.go Normal file
View File

@@ -0,0 +1,227 @@
package mqtt
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"io/ioutil"
"time"
"github.com/eclipse/paho.mqtt.golang"
"github.com/ktt-ol/spaceDevices/conf"
log "github.com/sirupsen/logrus"
)
const CLIENT_ID = "spaceDevices2"
var logger = log.WithField("where", "mqtt")
type MqttHandler struct {
WifiSessionList []WifiSession
// more to come, e.g. LanSessions
}
type WifiSession struct {
Ip string
Mac string
Vlan string
AP int
}
//func init() {
// mqtt.ERROR.SetOutput(copyOfStdLogger(log.ErrorLevel).Writer())
// mqtt.CRITICAL.SetOutput(copyOfStdLogger(log.ErrorLevel).Writer())
// mqtt.WARN.SetOutput(copyOfStdLogger(log.WarnLevel).Writer())
// mqtt.DEBUG.SetOutput(copyOfStdLogger(log.DebugLevel).Writer())
//}
//func copyOfStdLogger(level log.Level) *log.Logger {
// logger := log.New()
// logger.Formatter = log.StandardLogger().Formatter
// logger.Out = log.StandardLogger().Out
// logger.SetLevel(level)
// return logger
//}
func EnableMqttDebugLogging() {
stdLogWriter := log.StandardLogger().Writer()
mqtt.ERROR.SetOutput(stdLogWriter)
mqtt.CRITICAL.SetOutput(stdLogWriter)
mqtt.WARN.SetOutput(stdLogWriter)
mqtt.DEBUG.SetOutput(stdLogWriter)
}
func NewMqttHandler(conf conf.MqttConf) *MqttHandler {
opts := mqtt.NewClientOptions()
opts.AddBroker(conf.Url)
if conf.Username != "" {
opts.SetUsername(conf.Username)
}
if conf.Password != "" {
opts.SetPassword(conf.Password)
}
certs := defaultCertPool(conf.CertFile)
tlsConf := &tls.Config{
RootCAs: certs,
}
opts.SetTLSConfig(tlsConf)
opts.SetClientID(CLIENT_ID)
opts.SetAutoReconnect(true)
opts.SetKeepAlive(10 * time.Second)
opts.SetMaxReconnectInterval(5 * time.Minute)
handler := MqttHandler{}
opts.SetOnConnectHandler(handler.onConnect)
opts.SetConnectionLostHandler(handler.onConnectionLost)
client := mqtt.NewClient(opts)
if tok := client.Connect(); tok.WaitTimeout(5*time.Second) && tok.Error() != nil {
logger.WithError(tok.Error()).Fatal("Could not connect to mqtt server.")
}
return &handler
}
func (h *MqttHandler) onConnect(client mqtt.Client) {
logger.Info("connected")
err := subscribe(client, "/net/wlan-sessions",
func(client mqtt.Client, message mqtt.Message) {
//logger.Debug("new wifi sessions")
mock := `{ "38134": {
"last-auth": 1509211121,
"vlan": "default",
"stats": {
"rx-multicast-pkts": 0,
"rx-unicast-pkts": 292,
"tx-unicast-pkts": 654,
"rx-unicast-bytes": 20510,
"tx-unicast-bytes": 278565,
"rx-multicast-bytes": 0
},
"ssid": "mainframe",
"ip": "::1",
"hostname": "-",
"last-snr": 47,
"last-rate-mbits": "6",
"ap": 1,
"mac": "10:68:3f:bb:bb:bb",
"radio": 2,
"userinfo": {
"name": "Holger",
"visibility": "show",
"ts": 1427737817755
},
"session-start": 1509211121,
"last-rssi-dbm": -48,
"last-activity": 1509211584
}}`
// message.Payload()
h.WifiSessionList = parseWifiSessions([]byte(mock))
})
if err != nil {
logger.WithError(err).Fatal("Could not subscribe")
}
}
func (h *MqttHandler) onConnectionLost(client mqtt.Client, err error) {
logger.WithError(err).Error("Connection lost.")
}
func (h *MqttHandler) GetByIp(ip string) (WifiSession, bool) {
for _, v := range h.WifiSessionList {
if v.Ip == ip {
return v, true
}
}
return WifiSession{}, false
}
func subscribe(client mqtt.Client, topic string, cb mqtt.MessageHandler) error {
qos := 0
tok := client.Subscribe(topic, byte(qos), cb)
tok.WaitTimeout(5 * time.Second)
return tok.Error()
}
func defaultCertPool(certFile string) *x509.CertPool {
if certFile == "" {
log.Debug("No certFile given, using system pool")
pool, err := x509.SystemCertPool()
if err != nil {
log.WithError(err).Fatal("Could not create system cert pool.")
}
return pool
}
fileData, err := ioutil.ReadFile(certFile)
if err != nil {
log.WithError(err).Fatal("Could not read given cert file.")
}
certs := x509.NewCertPool()
if !certs.AppendCertsFromPEM(fileData) {
log.Fatal("unable to add given certificate to CertPool")
}
return certs
}
func parseWifiSessions(rawData []byte) []WifiSession {
sessionsList := []WifiSession{}
// we don't use a struct here, because we are interested in a small subset, only.
var sessionData map[string]interface{}
if err := json.Unmarshal(rawData, &sessionData); err != nil {
log.WithFields(log.Fields{
"rawData": string(rawData),
"error": err,
}).Error("Unable to unmarshal wifi session json.")
return sessionsList
}
for _, v := range sessionData {
entry, ok := v.(map[string]interface{})
if !ok {
log.WithFields(log.Fields{
"data": v,
}).Error("Unable to unmarshal wifi session json. Unexpected structure")
return sessionsList
}
vlan, ok := entry["vlan"].(string)
if !ok {
logParseError("vlan", v)
return []WifiSession{}
}
ip, ok := entry["ip"].(string)
if !ok {
logParseError("ip", v)
return []WifiSession{}
}
ap, ok := entry["ap"].(float64)
if !ok {
logParseError("ap", v)
return []WifiSession{}
}
mac, ok := entry["mac"].(string)
if !ok {
logParseError("mac", v)
return []WifiSession{}
}
sessionsList = append(sessionsList, WifiSession{ip, mac, vlan, int(ap)})
}
return sessionsList
}
func logParseError(field string, data interface{}) {
logger.WithFields(log.Fields{
"field": field,
"data": data,
}).Error("Parse error for field.")
}

136
mqtt/mqtt_test.go Normal file
View File

@@ -0,0 +1,136 @@
package mqtt
import "testing"
import (
"github.com/stretchr/testify/assert"
)
func Test_parseWifiSessions(t *testing.T) {
const testData = `
{
"38126": {
"last-auth": 1509210709,
"vlan": "default",
"stats": {
"rx-multicast-pkts": 499,
"rx-unicast-pkts": 1817,
"tx-unicast-pkts": 734,
"rx-unicast-bytes": 156208,
"tx-unicast-bytes": 272461,
"rx-multicast-bytes": 76808
},
"ssid": "mainframe",
"ip": "192.168.2.127",
"hostname": "-",
"last-snr": 40,
"last-rate-mbits": "24",
"ap": 2,
"mac": "2c:0e:3d:aa:aa:aa",
"radio": 2,
"userinfo": null,
"session-start": 1509210709,
"last-rssi-dbm": -55,
"last-activity": 1509211581
},
"38134": {
"last-auth": 1509211121,
"vlan": "default",
"stats": {
"rx-multicast-pkts": 0,
"rx-unicast-pkts": 292,
"tx-unicast-pkts": 654,
"rx-unicast-bytes": 20510,
"tx-unicast-bytes": 278565,
"rx-multicast-bytes": 0
},
"ssid": "mainframe",
"ip": "192.168.2.179",
"hostname": "-",
"last-snr": 47,
"last-rate-mbits": "6",
"ap": 1,
"mac": "10:68:3f:bb:bb:bb",
"radio": 2,
"userinfo": {
"name": "Holger",
"visibility": "show",
"ts": 1427737817755
},
"session-start": 1509211121,
"last-rssi-dbm": -48,
"last-activity": 1509211584
},
"38135": {
"last-auth": 1509211163,
"vlan": "default",
"stats": {
"rx-multicast-pkts": 114,
"rx-unicast-pkts": 8119,
"tx-unicast-pkts": 12440,
"rx-unicast-bytes": 1093407,
"tx-unicast-bytes": 15083985,
"rx-multicast-bytes": 20379
},
"ssid": "mainframe",
"ip": "192.168.2.35",
"hostname": "happle",
"last-snr": 39,
"last-rate-mbits": "24",
"ap": 1,
"mac": "20:c9:d0:cc:cc:cc",
"radio": 2,
"userinfo": {
"name": "Holger",
"visibility": "show",
"ts": 1438474581580
},
"session-start": 1509211163,
"last-rssi-dbm": -56,
"last-activity": 1509211584
},
"38137": {
"last-auth": 1509211199,
"vlan": "FreiFunk",
"stats": {
"rx-multicast-pkts": 14,
"rx-unicast-pkts": 931,
"tx-unicast-pkts": 615,
"rx-unicast-bytes": 70172,
"tx-unicast-bytes": 265390,
"rx-multicast-bytes": 1574
},
"ssid": "nordwest.freifunk.net",
"ip": "10.18.159.6",
"hostname": "iPhonevineSager",
"last-snr": 13,
"last-rate-mbits": "2",
"ap": 1,
"mac": "b8:53:ac:dd:dd:dd",
"radio": 1,
"userinfo": null,
"session-start": 1509211199,
"last-rssi-dbm": -82,
"last-activity": 1509211584
}
}
`
assert := assert.New(t)
sessions := parseWifiSessions([]byte(testData))
assert.Equal(len(sessions), 4)
mustContain := [4]bool{false, false, false, false}
for _, v := range sessions {
mustContain[0] = mustContain[0] || (v.Ip == "192.168.2.127" && v.Mac == "2c:0e:3d:aa:aa:aa" && v.Vlan == "default" && v.AP == 2)
mustContain[1] = mustContain[1] || (v.Ip == "192.168.2.179" && v.Mac == "10:68:3f:bb:bb:bb" && v.Vlan == "default" && v.AP == 1)
mustContain[2] = mustContain[2] || (v.Ip == "192.168.2.35" && v.Mac == "20:c9:d0:cc:cc:cc" && v.Vlan == "default" && v.AP == 1)
mustContain[3] = mustContain[3] || (v.Ip == "10.18.159.6" && v.Mac == "b8:53:ac:dd:dd:dd" && v.Vlan == "FreiFunk" && v.AP == 1)
}
for _, v := range mustContain {
assert.True(v)
}
// don't fail for garbage
sessions = parseWifiSessions([]byte("{ totally invalid json }"))
assert.Equal(len(sessions), 0)
}

View File

@@ -0,0 +1,92 @@
package webService
import (
"runtime"
"sync"
"time"
"github.com/dchest/uniuri"
)
const cleanupAfter = 20 * time.Minute
type tokenEntry struct {
value string
ts int64 // unix nano
}
type simpleXSRFheckInternal struct {
tokens map[string]tokenEntry
lock sync.RWMutex
stopCleaner chan bool
}
// SimpleXSRFCheck creates/compares and stores the XSRF tokens for the associated ip. After cleanupAfter amount of time
// a cleanup go routine removes old entries.
type SimpleXSRFCheck struct {
// the real struct. The outer hull is a wrapper that allows the 'SetFinalizer' function to work probably.
*simpleXSRFheckInternal
}
// NewSimpleXSRFCheck creates a new instance
func NewSimpleXSRFCheck() *SimpleXSRFCheck {
obj := simpleXSRFheckInternal{tokens: make(map[string]tokenEntry), stopCleaner: make(chan bool)}
go obj.cleanup()
// we need this wrapper or the SetFinalizer would never call
wraper := &SimpleXSRFCheck{&obj}
runtime.SetFinalizer(wraper, stopCleaner)
return wraper
}
func stopCleaner(sx *SimpleXSRFCheck) {
select {
case sx.stopCleaner <- true:
break
// don't block!
default:
break
}
}
func (sx *simpleXSRFheckInternal) NewToken(ip string) string {
token := tokenEntry{value: uniuri.NewLen(10), ts: time.Now().UnixNano()}
sx.lock.Lock()
sx.tokens[ip] = token
sx.lock.Unlock()
return token.value
}
// CheckAndClearToken returns true if the given token matches for the given ip. If no entry was found for the ip, false is returned.
// At the end the ip entry is removed from the internal map.
func (sx *simpleXSRFheckInternal) CheckAndClearToken(ip string, token string) bool {
sx.lock.RLock()
defer sx.lock.RUnlock()
savedToken, ok := sx.tokens[ip]
if !ok {
return false
}
delete(sx.tokens, ip)
return savedToken.value == token
}
func (sx *simpleXSRFheckInternal) cleanup() {
ticker := time.NewTicker(cleanupAfter)
for {
select {
case <-sx.stopCleaner:
ticker.Stop()
return
case <-ticker.C:
cleanupValue := time.Now().UnixNano() - int64(cleanupAfter)
sx.lock.Lock()
for k, v := range sx.tokens {
if v.ts < cleanupValue {
delete(sx.tokens, k)
}
}
sx.lock.Unlock()
}
}
}

138
webService/web.go Normal file
View File

@@ -0,0 +1,138 @@
package webService
import (
"fmt"
"net"
"net/http"
"time"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"github.com/ktt-ol/spaceDevices/conf"
"github.com/ktt-ol/spaceDevices/db"
"github.com/ktt-ol/spaceDevices/mqtt"
"github.com/sirupsen/logrus"
)
var logger = logrus.WithField("where", "webSrv")
var mqttHandler *mqtt.MqttHandler
var macDb *db.UserMacSettings
var xsrfCheck *SimpleXSRFCheck
func StartWebService(conf conf.ServerConf, _mqttHandler *mqtt.MqttHandler, _macDb *db.UserMacSettings) {
mqttHandler = _mqttHandler
macDb = _macDb
xsrfCheck = NewSimpleXSRFCheck()
router := gin.Default()
router.Use(gzip.Gzip(gzip.DefaultCompression))
router.Static("/assets", "webUI/assets")
router.LoadHTMLGlob("webUI/templates/*.html")
router.GET("/", overviewPageHandler)
router.POST("/", changeInfoHandler)
router.GET("/help.html", func(c *gin.Context) {
c.HTML(http.StatusOK, "help.html", gin.H{})
})
addr := fmt.Sprintf("%s:%d", conf.Host, conf.Port)
if conf.Https {
router.RunTLS(addr, conf.CertFile, conf.KeyFile)
} else {
router.Run(addr)
}
}
func sendError(c *gin.Context, msg string) {
c.String(http.StatusBadRequest, "Error: "+msg)
c.Abort()
}
func overviewPageHandler(c *gin.Context) {
ip, _, _ := net.SplitHostPort(c.Request.RemoteAddr)
logger.WithField("ip", ip).Debug("Request ip.")
name := "???"
mac := "???"
deviceName := ""
visibility := db.Visibility(99)
isLocallyAdministered := false
macNotFound := false
if info, ok := mqttHandler.GetByIp(ip); ok {
if userInfo, ok := macDb.Get(info.Mac); ok {
mac = info.Mac
name = userInfo.Name
deviceName = userInfo.DeviceName
visibility = userInfo.Visibility
isLocallyAdministered = db.IsMacLocallyAdministered(mac)
}
} else {
macNotFound = true
}
c.HTML(http.StatusOK, "index.html", gin.H{
"secToken": xsrfCheck.NewToken(ip),
"name": name,
"mac": mac,
"deviceName": deviceName,
"visibility": visibility,
"isLocallyAdministered": isLocallyAdministered,
"macNotFound": macNotFound,
})
}
type changeData struct {
Action string `form:"action" binding:"required"`
SecToken string `form:"secToken" binding:"required"`
Name string `form:"name" binding:"required"`
DeviceName string `form:"deviceName"`
VisibilityNum uint8 `form:"visibility" binding:"required"`
}
func changeInfoHandler(c *gin.Context) {
ip, _, _ := net.SplitHostPort(c.Request.RemoteAddr)
info, ok := mqttHandler.GetByIp(ip)
if !ok {
logger.WithField("ip", ip).Error("No data for ip found.")
sendError(c, "No data for your ip found.")
return
}
xsrfCheck.stopCleaner <- true
logger = logger.WithField("mac", info.Mac)
var form changeData
if err := c.Bind(&form); err != nil {
logger.WithError(err).Error("Invalid binding.")
sendError(c, "Invalid binding.")
return
}
if !xsrfCheck.CheckAndClearToken(ip, form.SecToken) {
logger.WithFields(logrus.Fields{"ip": ip, "secToken": form.SecToken}).Error("Invalid secToken")
sendError(c, "Invalid secToken")
return
}
if form.Action == "delete" {
logger.WithField("user", form.Name).Info("Delete user info.")
macDb.Delete(info.Mac)
} else if form.Action == "update" {
logger.WithField("data", fmt.Sprintf("%#v", form)).Info("Change user info.")
visibility, ok := db.ParseVisibility(form.VisibilityNum)
if !ok {
logger.WithField("VisibilityNum", form.VisibilityNum).Error("Invalid visibility.")
sendError(c, "Invalid 'visibility' value")
return
}
entry := db.UserMacInfo{Name: form.Name, DeviceName: form.DeviceName, Visibility: visibility, Ts: time.Now().Unix() * 1000}
macDb.Set(info.Mac, entry)
}
c.Redirect(http.StatusSeeOther, "/")
}

6757
webUI/assets/css/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

6
webUI/assets/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

108
webUI/assets/css/custom.css Normal file
View File

@@ -0,0 +1,108 @@
.container {
max-width: 600px;
}
.footer {
text-align: center;
padding: 30px 0;
margin-top: 70px;
border-top: 1px solid #E5E5E5;
}
#banner {
border-bottom: none;
margin-top: -20px;
}
#banner h1 {
font-size: 60px;
line-height: 1;
letter-spacing: -1px;
}
.hero-unit {
position: relative;
padding: 30px 15px;
color: #F5F5F5;
text-align: center;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
background: #4393B9;
}
.hero-unit h1 small {
color: #F5F5F5;
}
.mac-form {
margin: 20px 0;
}
.info-problem .alert {
margin-top: 40px;
}
/* http://tobiasahlin.com/spinkit/ */
.spinner {
margin: 10px auto 0;
width: 70px;
text-align: center;
}
.spinner > div {
width: 18px;
height: 18px;
background-color: #333;
border-radius: 100%;
display: inline-block;
-webkit-animation: bouncedelay 1.4s infinite ease-in-out;
animation: bouncedelay 1.4s infinite ease-in-out;
/* Prevent first frame from flickering when animation starts */
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.spinner .bounce1 {
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.spinner .bounce2 {
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
@-webkit-keyframes bouncedelay {
0%, 80%, 100% { -webkit-transform: scale(0.0) }
40% { -webkit-transform: scale(1.0) }
}
@keyframes bouncedelay {
0%, 80%, 100% {
transform: scale(0.0);
-webkit-transform: scale(0.0);
} 40% {
transform: scale(1.0);
-webkit-transform: scale(1.0);
}
}
/* help page styling */
.help .screenshot {
margin: 10px 0;
}
.help img {
max-width: 500px;
}
.help .legend {
color: #c60000;
font-weight: bold;
font-size: 120%;
}
.help .back-button {
margin-top: 20px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

49
webUI/templates/help.html Normal file
View File

@@ -0,0 +1,49 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<base href="/">
<title>Space Devices</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="assets/css/bootstrap.css">
<link rel="stylesheet" href="assets/css/custom.css">
</head>
<body>
<header class="hero-unit" id="banner">
<div class="container">
<h1>Hilfe</h1>
</div>
</header>
<div class="container help">
<h2>Ein Beispiel</h2>
von der <a href="https://status.kreativitaet-trifft-technik.de">Status</a> Seite.
<div class="img-responsive screenshot">
<img src="assets/images/screenshot.png" class="img-thumbnail" />
</div>
<ul>
<li>Mit "Mit Name/Alias anzeigen." wirst du in <span class="legend">A</span> angezeigt.</li>
<li>Mit "Als anonyme Person anzeigen." wirst du in <span class="legend">B</span> angezeigt.</li>
<li>Wenn du noch gar nichts eingetragen hast oder "Eintrag löschen" gewählt hast, dann wirst du in <span class="legend">C</span> angezeigt.
</li>
<li>Mit "Gar nicht anzeigen" wirst du nirgends angezeigt.</li>
</ul>
<a href="/" class="btn btn-primary back-button">Zurück</a>
</div>
<footer class="footer">
<div class="container">
<p><a href="https://github.com/ktt-ol/spaceDevices">https://github.com/ktt-ol/spaceDevices</a></p>
</div>
</footer>
</body>
</html>

134
webUI/templates/index.html Normal file
View File

@@ -0,0 +1,134 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<base href="/">
<title>Space Devices</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="assets/css/bootstrap.min.css">
<link rel="stylesheet" href="assets/css/custom.css">
</head>
<body>
<header class="hero-unit" id="banner">
<div class="container">
<h1>
{{.name}} <small>[{{.deviceName}}]</small>
</h1>
<p class="lead">
Deine Mac Adresse lautet: {{.mac}}
</p>
</div>
</header>
{{if .noMacFound }}
<div class="container info-problem">
<div class="alert alert-warning" role="alert">
Deine Mac Adresse wurde nicht gefunden. Evt. funktioniert es in ein paar Minuten.
<button onclick="window.location.reload()" class="btn btn-primary">Neu laden</button>
</div>
</div>
{{end}}
{{if .isLocallyAdministered }}
<div class="container info-problem">
<div class="alert alert-warning" role="alert">
Deine Mac Adresse wird zufällig generiert, daher kannst du dafür keinen Namen eingeben. Bei Windows 10 kann man
das angeblich ändern.
</div>
</div>
{{end}}
{{if (not .isLocallyAdministered) and (not .noMacFound)}}
<div class="container mac-update">
<div class="row">
<div class="col-lg-12">
<h1 class="page-header">Ändern</h1>
Ändere deinen Namen auf <a href="https://status.kreativitaet-trifft-technik.de">Status</a>.
Der wird immer dann angezeigt, wenn du mit diesem Gerät im Mainframe WLAN bist.
</div>
</div>
<form class="mac-form" action="/" method="post" onsubmit="onSubmit()" id="form">
<input type="hidden" name="action" value="update" id="action" />
<input type="hidden" name="secToken" value="{{.secToken}}" />
<div class="form-group">
<label for="name">Name/Alias</label>
<input type="text" class="form-control" id="name" name="name" placeholder="Dein Name" value="{{.name}}" required>
</div>
<div class="form-group">
<label for="deviceName">Gerätename</label>
<input type="text" class="form-control" id="deviceName" name="deviceName" placeholder="Notebook" value="{{.deviceName}}">
</div>
<div class="form-group">
<label>Sichtbarkeit</label>
<a class="help-link pull-right" href="help.html">Hilfe! Was heißt das?</a>
<div class="radio">
<label>
<input type="radio" name="visibility" value="3" {{if eq .visibility 3}}checked{{end}}>
Alles anzeigen, d.h. Name/Alias und Gerätename.
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="visibility" value="2" {{if eq .visibility 2}}checked{{end}}>
Mit Name/Alias anzeigen.
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="visibility" value="1" {{if eq .visibility 1}}checked{{end}}>
Als anonyme Person anzeigen.
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="visibility" value="0" {{if eq .visibility 0}}checked{{end}}>
Gar nicht anzeigen. Die <u>wirklich</u> paranoide Option. Meistens ist der obere Punkt besser.
</label>
</div>
</div>
<div class="form-group">
<a class="btn btn-danger" onclick="deleteName()">Eintrag löschen</a>
<button class="btn btn-primary pull-right" type="submit" id="submitButton">Speichern</button>
</div>
<div class="form-group" id="waiting" style="display: none">
<div class="spinner">
<div class="bounce1"></div>
<div class="bounce2"></div>
<div class="bounce3"></div>
</div>
</div>
</form>
</div>
{{end}}
<footer class="footer">
<div class="container">
<p><a href="https://github.com/ktt-ol/spaceDevices">https://github.com/ktt-ol/spaceDevices</a></p>
</div>
</footer>
<script>
function onSubmit() {
document.getElementById("waiting").style.display = "block";
}
function deleteName() {
document.getElementById("action").value = "delete";
document.getElementById("form").submit();
}
</script>
</body>
</html>