package main import ( "fmt" "net/http" "github.com/mattermost/mattermost-server/plugin" "github.com/mattermost/mattermost-server/model" "github.com/pkg/errors" "sync" "reflect" _ "github.com/google/go-cmp/cmp" "os" _ "github.com/fatih/structs" "github.com/hashicorp/go-multierror" _ "github.com/kr/pretty" "strings" "regexp" "strconv" "time" "github.com/fsnotify/fsnotify" "io/ioutil" "html" "encoding/json" "encoding/pem" "crypto/rsa" "crypto/x509" "crypto/rand" "crypto/sha1" "path" "encoding/base64" ) const DO_LOGGING = false var userSplit *regexp.Regexp = regexp.MustCompile(`\s+(^|[^,])|\s*,\s*`) 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 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 WatchPath string AdminUsers string adminUsers []string KeyPath string } type BathroomMonitorPlugin struct { plugin.MattermostPlugin config *Config configLock sync.RWMutex configChanged chan struct{} configUpdates int doorLock sync.RWMutex doors map[uint8]*Door lastReport *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 //p.postAdminChannel(fmt.Sprintf("STATUS: door %d = %s", id, statusStr)) p.API.PublishWebSocketEvent("updated", map[string]interface{}{}, &model.WebsocketBroadcast{}) } } else { p.log(fmt.Sprintf("Asked to change status from %d to %d ?", p.doors[id - 1].status, status)) } return nil } 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 (p *BathroomMonitorPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { p.log(fmt.Sprintf("Requested path: %s %s", r.URL.Path, c.IpAddress)) 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 == "/status-update" { 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() 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.lastReport != nil && *p.lastReport == text { return } p.lastReport = &text var users []*model.User = make([]*model.User, 0, len(p.config.adminUsers)) for _, un := range(p.config.adminUsers) { u, _ := p.API.GetUserByUsername(un) if u != nil { users = append(users, u) } } if len(users) > 0 { admin := users[0] bots, _ := p.API.GetBots(&model.BotGetOptions{Page:0, PerPage:1000}) var bathroom_bot *model.Bot = nil if bots != nil { for _, b := range(bots) { if b.Username == "bathroom-bot" { bathroom_bot = b break } } } if bathroom_bot == nil { created_bathroom_bot, err := p.API.CreateBot(&model.Bot{Username:"bathroom-bot", OwnerId:admin.Id, DisplayName:"Bathroom Bot", Description:"Tracks Bathroom Status"}) if err != nil { p.API.LogError(errors.Wrap(err, "Couldn't create bathroom-bot bot").Error()) return } bathroom_bot = created_bathroom_bot } if bathroom_bot == nil { p.API.LogError("Really couldn't create bathroom-bot bot") return } for _, u := range(users) { channel, err := p.API.GetDirectChannel(bathroom_bot.UserId, 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: bathroom_bot.UserId, ChannelId:channel.Id, Message:text, MessageSource:text}) } } else { p.API.LogError("No admin users?") } } 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) } newConfig.adminUsers = make([]string, 0, 4) split := userSplit.Split(newConfig.AdminUsers, -1) for _, un := range split { trimmed := strings.Trim(un, ", \t\n") if trimmed != "" { newConfig.adminUsers = append(newConfig.adminUsers, trimmed) } } 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) { doorFile, err := regexp.Compile(`(^|/)door(\d+)$`) if err != nil { p.API.LogError(errors.Wrap(err, "Couldn't compile regex").Error()) return } 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 := doorFile.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) OnActivate() error { if USE_FSNOTIFY { go fileNotifyBasedLoop(p) } else { } return nil } func main() { plugin.ClientMain((&BathroomMonitorPlugin{}).init()) }