package main import ( "crypto/rand" "crypto/rsa" "crypto/sha1" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "fmt" "html" "io/ioutil" _ "math" "net/http" "os" "path" "reflect" "regexp" "strconv" "strings" "sync" "time" _ "github.com/fatih/structs" "github.com/fsnotify/fsnotify" _ "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-multierror" "github.com/kr/pretty" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/pkg/errors" ) const POST_STATUS_TO_ADMIN = false const DO_LOGGING = false var splitWhitespaceOrComma *regexp.Regexp = regexp.MustCompile(`\s+(^|[^,])|\s*,\s*`) var doorWatchFile *regexp.Regexp = regexp.MustCompile(`(^|/)door(\d+)$`) const ( Unknown = uint(iota) Open Closed ) func statusName(status uint) (string, error) { switch status { case Unknown: return "unknown", nil case Open: return "open", nil case Closed: return "closed", nil } return "", errors.New(fmt.Sprintf("Invalid status %d", status)) } type UserOptions struct { ShowWidget bool `json:"show_widget"` } func defaultUserOptions() *UserOptions { return &UserOptions{ ShowWidget: true, } } type DoorRequest struct { ip string time int64 verify string status uint } type Door struct { id uint8 status uint pubKey *rsa.PublicKey lastRequest *DoorRequest } type Config struct { NumDoors string numDoors uint8 PingInterval string pingInterval int WatchPath string AdminUsers string adminUsers []*model.User KeyPath string DoorNames string doorNames []string InfoIcon string UnknownIcons string OpenIcons string ClosedIcons string unknownIcons []string openIcons []string closedIcons []string DoorOrder string doorOrder []uint8 DoorPasswords string doorPasswords []string settingsJson map[string]interface{} } type BathroomMonitorPlugin struct { plugin.MattermostPlugin config *Config configLock sync.RWMutex configChanged chan struct{} configUpdates int doorLock sync.RWMutex doors map[uint8]*Door bot *string } func (p *BathroomMonitorPlugin) getKeyFile(id uint8) (*rsa.PublicKey, error) { keyFile, err := ioutil.ReadFile(path.Join(p.config.KeyPath, fmt.Sprintf("public%d.pem", id))) if err != nil { return nil, err } data, _ := pem.Decode(keyFile) if data == nil { return nil, errors.New("No PEM formatted data in file") } key, err := x509.ParsePKIXPublicKey(data.Bytes) if err != nil { return nil, err } switch pub := key.(type) { case *rsa.PublicKey: return pub, nil } return nil, errors.New(fmt.Sprintf("Keyfile wasn't a public RSA PKIX key, it was %T", key)) } func (p *BathroomMonitorPlugin) initDoors() { p.doorLock.Lock() defer p.doorLock.Unlock() p.doors = make(map[uint8]*Door) for i := uint8(0); i < p.config.numDoors; i++ { id := i + 1 var pub *rsa.PublicKey = nil if !USE_FSNOTIFY { var err error pub, err = p.getKeyFile(id) if err != nil { p.API.LogError(errors.Wrap(err, fmt.Sprintf("Unable to load key file for id %d", id)).Error()) pub = nil } } p.doors[i] = &Door{ id: id, status: Unknown, pubKey: pub, } } } func (p *BathroomMonitorPlugin) log(log string) { if DO_LOGGING { p.API.LogInfo(log) } } func (p *BathroomMonitorPlugin) setDoorStatus(id uint8, status uint, report bool) error { if id < 1 || id > p.config.numDoors { return errors.New(fmt.Sprintf("Invalid door id %d", id)) } if status < Unknown || status > Closed { return errors.New(fmt.Sprintf("Invalid door status %d", status)) } if p.doors == nil || len(p.doors) < int(p.config.numDoors) { return errors.New("Doors not inited yet") } if p.doors[id-1].status != status { p.doors[id-1].status = status if report { statusStr, _ := statusName(status) _ = statusStr if POST_STATUS_TO_ADMIN { p.postAdminChannel(fmt.Sprintf("STATUS: door %d = %s", id, statusStr)) } p.sendUpdate() go p.resendUpdate(10 * time.Second) } } else { p.log(fmt.Sprintf("Asked to change status from %d to %d ?", p.doors[id-1].status, status)) } return nil } func (p *BathroomMonitorPlugin) sendUpdate() { p.API.PublishWebSocketEvent("updated", map[string]interface{}{}, &model.WebsocketBroadcast{}) } func (p *BathroomMonitorPlugin) resendUpdate(wait time.Duration) { timer := time.NewTimer(wait) <-timer.C p.sendUpdate() } func (p *BathroomMonitorPlugin) init() *BathroomMonitorPlugin { p.config = &Config{ NumDoors: "1", numDoors: 1, WatchPath: "./", KeyPath: "./", AdminUsers: "", } p.configChanged = make(chan struct{}) return p } func (p *BathroomMonitorPlugin) validateRequestDoorId(r *http.Request) (uint8, error) { p.configLock.Lock() numDoors := p.config.numDoors p.configLock.Unlock() doorStr, ok := r.Form["door_id"] if !ok || len(doorStr) == 0 { return 0, errors.New("Please send door id") } doorId, err := strconv.ParseUint(doorStr[0], 10, 8) if err != nil { return 0, errors.New(fmt.Sprintf("Couldn't parse door_id: %s", err.Error())) } doorId8 := uint8(doorId) if doorId8 < 1 || doorId8 > numDoors { return 0, errors.New("Invalid door id") } return doorId8, nil } func SliceIndex(limit int, predicate func(i int) bool) int { for i := 0; i < limit; i++ { if predicate(i) { return i } } return -1 } func (p *BathroomMonitorPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { auth, authOk := r.Header["Mattermost-User-Id"] var authUser string = "" if authOk && len(auth) > 0 { authUser = auth[0] } p.log(fmt.Sprintf("Requested path: %s %s AUTH: %s (%t)", r.URL.Path, c.IPAddress, authUser, authOk)) if r.URL.Path == "/settings" { p.configLock.Lock() defer p.configLock.Unlock() retMap := make(map[string]interface{}) for k, v := range p.config.settingsJson { retMap[k] = v } userOptions := defaultUserOptions() if authOk { p.KVGetJSON("settings_"+authUser, userOptions) } retMap["user"] = userOptions bytes, err := json.Marshal(retMap) if err == nil { fmt.Fprint(w, string(bytes)) } else { p.API.LogError(fmt.Sprintf("Unable to json Marshal settings %s", err.Error())) } return } if r.URL.Path == "/status" { p.doorLock.Lock() defer p.doorLock.Unlock() var write map[string]string = make(map[string]string) for d, s := range p.doors { statusStr, _ := statusName(s.status) write[fmt.Sprintf("%d", d+1)] = statusStr } if output, err := json.Marshal(write); err == nil { fmt.Fprint(w, string(output)) } else { fmt.Fprint(w, html.EscapeString(err.Error())) } return } if r.URL.Path == "/admin-update-status-old" && authOk { r.ParseForm() p.configLock.Lock() p.doorLock.Lock() defer p.doorLock.Unlock() defer p.configLock.Unlock() if SliceIndex(len(p.config.adminUsers), func(i int) bool { return p.config.adminUsers[i].Id == authUser }) != -1 { for n, vs := range r.Form { matches := doorWatchFile.FindStringSubmatch(n) if matches != nil && len(matches) >= 3 { doorId64, err := strconv.ParseUint(matches[2], 10, 8) doorId := uint8(doorId64) if err == nil && doorId >= 1 && doorId <= p.config.numDoors && len(vs) > 0 { status64, err := strconv.ParseUint(vs[0], 10, 8) status := uint(status64) if err == nil && status >= Unknown && status <= Closed { p.setDoorStatus(uint8(doorId), status, true) } else { fmt.Fprintf(w, "Not a valid status: %d", status) } } else { fmt.Fprintf(w, "Not a valid door: %# v, %d, %# v", matches, doorId, pretty.Formatter(vs)) } } else { fmt.Fprintf(w, "Not a valid door key: %s", n) } } } else { fmt.Fprintf(w, "Not authorized: %s", authUser) } return } if r.URL.Path == "/status-update" || (r.URL.Path == "/admin-status-update" && authOk && SliceIndex(len(p.config.adminUsers), func(i int) bool { return p.config.adminUsers[i].Id == authUser }) != -1) { r.ParseForm() p.log("Contacted by " + r.RemoteAddr) doorId8, err := p.validateRequestDoorId(r) if err != nil { fmt.Fprint(w, err.Error()) return } statusStr, ok := r.Form["status"] if !ok || len(statusStr) == 0 { fmt.Fprint(w, "Please send door status") return } status, err := strconv.ParseUint(statusStr[0], 10, 8) if err != nil { fmt.Fprintf(w, "Couldn't parse status: %s", err.Error()) return } if status != 0 && status != 1 { fmt.Fprint(w, "Invalid status") return } statusVal := Open if status == 1 { statusVal = Closed } p.doorLock.Lock() defer p.doorLock.Unlock() requireVerification := true if r.URL.Path == "/admin-status-update" { requireVerification = false } password, ok := r.Form["password"] if ok && len(password) > 0 && int(doorId8) <= len(p.config.doorPasswords) && password[0] == p.config.doorPasswords[doorId8-1] { requireVerification = false } if !requireVerification { err = p.setDoorStatus(doorId8, statusVal, true) fmt.Fprintf(w, "Door %d set to status %d", doorId8, statusVal) return } if p.doors[doorId8-1].pubKey == nil { fmt.Fprintf(w, "No public key found for door %d", doorId8) return } p.log("Getting random bytes") var verifyBytes [50]byte rand.Read(verifyBytes[:]) p.log("Encoding random bytes") verifyB64 := base64.StdEncoding.EncodeToString(verifyBytes[:]) p.log("Encryping random bytes") encrypted, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, p.doors[doorId8-1].pubKey, verifyBytes[:], []byte{}) if err != nil { fmt.Fprintf(w, "Couldn't encrypt verification: %s", err.Error()) return } p.log("Encoding encrypted bytes") encryptedB64 := base64.StdEncoding.EncodeToString(encrypted) req := &DoorRequest{ ip: c.IPAddress, time: time.Now().Unix(), verify: verifyB64, status: statusVal, } p.doors[doorId8-1].lastRequest = req p.log(fmt.Sprintf("NEW REQUEST SAVED: %s %d %s", req.ip, req.time, req.verify)) fmt.Fprint(w, encryptedB64) return } if r.URL.Path == "/verify-status" { r.ParseForm() p.log("Contacted by " + r.RemoteAddr) doorId8, err := p.validateRequestDoorId(r) if err != nil { fmt.Fprint(w, err.Error()) return } verifyB64, ok := r.Form["verify"] if !ok || len(verifyB64) <= 0 { fmt.Fprint(w, "Please send the verification code") return } p.log("Verify locking") p.doorLock.Lock() defer p.doorLock.Unlock() p.log("Verify done locking") if p.doors[doorId8-1].lastRequest == nil { fmt.Fprint(w, "Invalid request") return } req := p.doors[doorId8-1].lastRequest if req.ip != c.IPAddress { fmt.Fprintf(w, "Not your request %s %s", req.ip, r.RemoteAddr) return } diff := time.Now().Unix() - req.time if diff < 0 || diff > 10 { fmt.Fprint(w, "Request expired") return } if req.verify != verifyB64[0] { p.log(fmt.Sprintf("Failed verification %s %s", req.verify, verifyB64[0])) fmt.Fprint(w, "Unauthorized request") return } p.doors[doorId8-1].lastRequest = nil p.log("Changing") err = p.setDoorStatus(doorId8, req.status, true) p.log("Changed") if err != nil { fmt.Fprintf(w, "Couldn't set status %d %d: %s", doorId8, req.status, err.Error()) } return } http.NotFound(w, r) } func (p *BathroomMonitorPlugin) confChangedEvent() { select { case p.configChanged <- struct{}{}: default: } } func (p *BathroomMonitorPlugin) postAdminChannel(text string) { p.API.LogError(text) if p.bot != nil { for _, u := range p.config.adminUsers { channel, err := p.API.GetDirectChannel(*p.bot, u.Id) if err != nil { p.API.LogError(errors.Wrap(err, fmt.Sprintf("Couldn't get direct channel to user %s", u.Username)).Error()) continue } p.API.CreatePost(&model.Post{UserId: *p.bot, ChannelId: channel.Id, Message: text, MessageSource: text}) } } } func (p *BathroomMonitorPlugin) OnConfigurationChange() error { p.configLock.Lock() defer p.configLock.Unlock() var newConfig *Config = new(Config) if err := p.API.LoadPluginConfiguration(newConfig); err != nil { newErr := errors.Wrap(err, fmt.Sprintf("%d: Failed to load configuration", p.configUpdates)) p.postAdminChannel(newErr.Error()) return newErr } if newConfig == nil || newConfig == p.config || reflect.ValueOf(*newConfig).NumField() == 0 { p.API.LogInfo("Passed same config, or empty?") return nil } var configErr error = nil if USE_FSNOTIFY { if info, err := os.Stat(newConfig.WatchPath); err != nil || info.Mode()&os.ModeDir != os.ModeDir { newConfig.WatchPath = "./" configErr = multierror.Append(configErr, errors.Wrap(err, "Invalid watch path")) } } else { if info, err := os.Stat(newConfig.KeyPath); err != nil || info.Mode()&os.ModeDir != os.ModeDir { newConfig.KeyPath = "./" configErr = multierror.Append(configErr, errors.Wrap(err, "Invalid key path")) } } numDoors, err := strconv.ParseUint(newConfig.NumDoors, 10, 8) if err != nil { newConfig.NumDoors = "1" newConfig.numDoors = 1 configErr = multierror.Append(configErr, errors.Wrap(err, "Invalid number of doors")) } else { newConfig.numDoors = uint8(numDoors) } if strings.Trim(newConfig.PingInterval, " \n\t") == "" { newConfig.pingInterval = -1 } else { pingInterval, err := strconv.ParseInt(newConfig.PingInterval, 10, 32) if err != nil { newConfig.PingInterval = "" newConfig.pingInterval = -1 configErr = multierror.Append(configErr, errors.Wrap(err, "Invalid ping interval")) } else { newConfig.pingInterval = int(pingInterval) } } newConfig.adminUsers = make([]*model.User, 0, 4) split := splitWhitespaceOrComma.Split(newConfig.AdminUsers, -1) for _, un := range split { trimmed := strings.Trim(un, ", \t\n") if trimmed != "" { u, _ := p.API.GetUserByUsername(trimmed) if u != nil { newConfig.adminUsers = append(newConfig.adminUsers, u) } } } newConfig.doorNames = make([]string, 0, 4) split = strings.Split(newConfig.DoorNames, "|") for _, un := range split { trimmed := strings.Trim(un, ", \t\n") if trimmed != "" { newConfig.doorNames = append(newConfig.doorNames, trimmed) } } newConfig.doorPasswords = make([]string, 0, 4) split = strings.Split(newConfig.DoorPasswords, "|") for _, un := range split { trimmed := strings.Trim(un, ", \t\n") if trimmed != "" { newConfig.doorPasswords = append(newConfig.doorPasswords, trimmed) } } newConfig.unknownIcons = make([]string, 0, 4) split = strings.Split(newConfig.UnknownIcons, ",") for _, un := range split { trimmed := strings.Trim(un, ", \t\n") if trimmed != "" { newConfig.unknownIcons = append(newConfig.unknownIcons, trimmed) } } newConfig.openIcons = make([]string, 0, 4) split = strings.Split(newConfig.OpenIcons, ",") for _, un := range split { trimmed := strings.Trim(un, ", \t\n") if trimmed != "" { newConfig.openIcons = append(newConfig.openIcons, trimmed) } } newConfig.closedIcons = make([]string, 0, 4) split = strings.Split(newConfig.ClosedIcons, ",") for _, un := range split { trimmed := strings.Trim(un, ", \t\n") if trimmed != "" { newConfig.closedIcons = append(newConfig.closedIcons, trimmed) } } doorOrder := make([]uint8, 0, 4) split = strings.Split(newConfig.DoorOrder, ",") for _, un := range split { trimmed := strings.Trim(un, ", \t\n") if trimmed != "" { id, err := strconv.ParseUint(trimmed, 10, 8) if err != nil { configErr = multierror.Append(configErr, errors.Wrap(err, "Invalid door order")) doorOrder = nil break } doorOrder = append(doorOrder, uint8(id)) } } newConfig.doorOrder = doorOrder min := func(a uint8, b uint8) uint8 { if a < b { return a } return b } doorsJson := make(map[string]string) for i := uint8(0); i < min(uint8(len(newConfig.doorNames)), newConfig.numDoors); i++ { doorsJson[fmt.Sprintf("%d", i+1)] = newConfig.doorNames[i] } doorOrderJson := make([]int, len(newConfig.doorOrder)) for i, o := range newConfig.doorOrder { doorOrderJson[i] = int(o) } newConfig.settingsJson = map[string]interface{}{ "info_icon": newConfig.InfoIcon, "unknown_icons": newConfig.unknownIcons, "open_icons": newConfig.openIcons, "closed_icons": newConfig.closedIcons, "doors": doorsJson, "door_order": doorOrderJson, } p.config = newConfig p.configUpdates++ p.initDoors() p.log(fmt.Sprintf("%d: Config: %d %s", p.configUpdates, p.config.numDoors, p.config.WatchPath)) if configErr != nil { p.postAdminChannel(configErr.Error()) } p.confChangedEvent() return configErr } func fileNotifyBasedLoop(p *BathroomMonitorPlugin) { for { p.configLock.Lock() numDoors := p.config.numDoors watchPath := p.config.WatchPath p.configLock.Unlock() _ = numDoors watcher, err := fsnotify.NewWatcher() if err != nil { p.API.LogError(errors.Wrap(err, "Couldn't make watcher").Error()) time.Sleep(60) continue } err = watcher.Add(watchPath) if err != nil { p.API.LogError(errors.Wrap(err, "Couldn't watch WatchPath").Error()) } run := true for run { select { case event, ok := <-watcher.Events: if !ok { p.API.LogError(errors.Wrap(err, "Couldn't get fsnotify event").Error()) time.Sleep(60) run = false } else { match := doorWatchFile.FindStringSubmatch(event.Name) if match == nil { continue } id64, err := strconv.ParseUint(match[2], 10, 8) if err != nil { p.API.LogError(errors.Wrap(err, "ParseUint error").Error()) continue } id := uint8(id64) if id < 1 || id > numDoors { continue } if event.Op&fsnotify.Write != fsnotify.Write && event.Op&fsnotify.Create != fsnotify.Create { continue } statusBytes, err := ioutil.ReadFile(event.Name) if err != nil { p.API.LogError(errors.Wrap(err, "Couldn't read door file").Error()) } status := strings.Trim(string(statusBytes), " \n\t") if len(status) <= 0 { continue } statusInt, err := strconv.ParseUint(status, 10, 32) if err != nil { p.API.LogError(errors.Wrap(err, "Status invalid "+status).Error()) continue } newStatus := Open if statusInt == 1 { newStatus = Closed } p.doorLock.Lock() err = p.setDoorStatus(id, newStatus, true) p.doorLock.Unlock() if err != nil { p.API.LogError(errors.Wrap(err, fmt.Sprintf("Couldn't set door status %d %d", id, statusInt)).Error()) continue } } case <-p.configChanged: p.log(fmt.Sprintf("CONFIG CHANGED, RESTARTING %d", p.configUpdates)) run = false } } watcher.Close() } } func (p *BathroomMonitorPlugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { userOptions := defaultUserOptions() _ = userOptions return nil, nil } func (p *BathroomMonitorPlugin) OnActivate() error { p.API.RegisterCommand(&model.Command{ Trigger: "bathrooms", AutoComplete: true, AutoCompleteDesc: "Turns the bathroom widget \"on\" or \"off\" (\"status\" or no option prints bathroom statuses)", AutoCompleteHint: "(on|off|status)", }) if p.bot == nil { botUser, err := p.API.EnsureBotUser(&model.Bot{Username: "bathroom-bot", DisplayName: "Bathroom Bot", Description: "Tracks Bathroom Status"}) if err == nil { p.bot = &botUser } else { p.bot = nil } } if USE_FSNOTIFY { go fileNotifyBasedLoop(p) } else { } go p.pingLoop() return nil } func (p *BathroomMonitorPlugin) pingLoop() { for { var tickChan <-chan time.Time = nil var ticker *time.Ticker = nil p.configLock.Lock() p.log(fmt.Sprintf("Setting pingLoop with interval %d", p.config.pingInterval)) if p.config.pingInterval > 0 { ticker = time.NewTicker(time.Duration(p.config.pingInterval) * time.Second) tickChan = ticker.C } p.configLock.Unlock() run := true for run { select { case <-tickChan: p.log(fmt.Sprintf("Sending ping")) p.API.PublishWebSocketEvent("ping", map[string]interface{}{}, &model.WebsocketBroadcast{}) case <-p.configChanged: run = false } } if ticker != nil { ticker.Stop() } } } func main() { plugin.ClientMain((&BathroomMonitorPlugin{}).init()) } func (p *BathroomMonitorPlugin) KVGetJSON(key string, value interface{}) (bool, error) { data, appErr := p.API.KVGet(key) if appErr != nil { return false, appErr } if data == nil { return false, nil } err := json.Unmarshal(data, value) if err != nil { return false, err } return true, nil }