package main import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "os" "os/signal" "runtime/debug" "strconv" "strings" "sync" "syscall" "time" "github.com/joho/godotenv" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" "godoor/hash" ) const ( wiegand_a_default = 17 wiegand_b_default = 18 wiegand_bit_timeout = time.Millisecond * 8 solenoid_default = 21 ) type card struct { UidHash string `json:"uid_hash"` } type cardList struct { AllowedUids []struct { Token card `json:"token"` } `json:"allowed_uids"` KeepOpenUntil *time.Time `json:"keep_open_until,omitempty"` } type ValidUids map[string]bool // bool has no meaning type Config struct { door string uidSalt string doorOpenTime time.Duration mock bool api struct { allowed string longpoll string swipe string key string } pins struct { wiegandA int wiegandB int solenoid int } prometheusMetricsBind string } type KeepDoorOpen struct { until time.Time timer *time.Timer } type OpenedTimestamp struct { Opened time.Time Closed *time.Time } var Commit = func() string { if info, ok := debug.ReadBuildInfo(); ok { for _, setting := range info.Settings { if setting.Key == "vcs.revision" { return setting.Value } } } return "" }() var Version string var ( config Config globalLock sync.Mutex validUids ValidUids wiegand Wiegand keepDoorOpen KeepDoorOpen ) var ( godoorBuildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "godoor_build_info", Help: "Build Information", }, []string{"version", "revision"}) lastSyncTimestamp = promauto.NewGauge(prometheus.GaugeOpts{ Name: "godoor_last_allow_list_sync_timestamp_seconds", Help: "Last time list of card hashes was pulled from the server", }) apiFailuresCount = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "godoor_api_request_failures_total", Help: "HTTP API request failures count", }, []string{"api", "endpoint"}) nrCardsInAllowList = promauto.NewGauge(prometheus.GaugeOpts{ Name: "godoor_allowed_card_hashes_total", Help: "Number of card hashes in memory that can open the door", }) doorOpenedCount = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "godoor_door_opens_total", Help: "Number of times door was opened", }, []string{"source"}) cardSwipesCount = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "godoor_swipes_total", Help: "Number of times a card has been swiped", }, []string{"status"}) ) func main() { log.Printf("GoDoor ver: %s (%s)", Version, Commit) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() loadConfig() godoorBuildInfo.WithLabelValues(Version, Commit).Set(1) go func() { setup(ctx) }() <-ctx.Done() log.Printf("Cleanup\n") // cleanup } func loadConfig() { var err error log.Println("Loading .env config") err = godotenv.Load() if err != nil { log.Println("Failed to load .env config, using internal defaults") } config.door = os.Getenv("KDOORPI_DOOR") config.api.allowed = os.Getenv("KDOORPI_API_ALLOWED") config.api.longpoll = os.Getenv("KDOORPI_API_LONGPOLL") config.api.swipe = os.Getenv("KDOORPI_API_SWIPE") config.api.key = os.Getenv("KDOORPI_API_KEY") config.uidSalt = os.Getenv("KDOORPI_UID_SALT") config.doorOpenTime = 8 * time.Second if doorOpenTimeStr, ok := os.LookupEnv("KDOORPI_OPEN_TIME"); ok { if openTime, err := time.ParseDuration(doorOpenTimeStr); err != nil { log.Printf("parsing KDOORPI_OPEN_TIME: %v, keeping default %v", err, config.doorOpenTime) } else { config.doorOpenTime = openTime } } _, config.mock = os.LookupEnv("KDOORPI_MOCK_HW") config.prometheusMetricsBind = os.Getenv("KDOORPI_PROMETHEUS_METRICS_BIND") if config.prometheusMetricsBind == "" { config.prometheusMetricsBind = ":3334" } config.pins.wiegandA, err = strconv.Atoi(os.Getenv("KDOORPI_PIN_WIEGAND_A")) if err != nil { config.pins.wiegandA = wiegand_a_default } config.pins.wiegandB, err = strconv.Atoi(os.Getenv("KDOORPI_PIN_WIEGAND_B")) if err != nil { config.pins.wiegandB = wiegand_b_default } config.pins.solenoid, err = strconv.Atoi(os.Getenv("KDOORPI_PIN_SOLENOID")) if err != nil { config.pins.solenoid = solenoid_default } } func setup(ctx context.Context) { log.Println("Started Setup") if config.mock { log.Println("MOCK mode enabled") if config.door == "" { config.door = "mockdoor" } wiegand = &WiegandMock{} } else { wiegand = WiegandSetup(config.pins.wiegandA, config.pins.wiegandB, wiegand_bit_timeout, config.pins.solenoid) } log.Println("HW Setup done") go runHttpServer() http.DefaultClient.Timeout = 120 * time.Second for { log.Println("Start initial token population") err := reloadTokens() if err == nil { break } apiFailuresCount.WithLabelValues("allowed", config.api.allowed).Inc() log.Printf("Initial token population failed. err: %v", err) log.Println("Retrying in 10 seconds...") time.Sleep(10 * time.Second) } log.Println("Initial token population success") go func() { for { err := waitEvents() if err != nil { apiFailuresCount.WithLabelValues("longpoll", config.api.longpoll).Inc() log.Printf("LongPoll for events failed: %v", err) log.Println("Will try to LongPoll again soon") } time.Sleep(1 * time.Second) go func() { err := reloadTokens() if err != nil { log.Printf("ReloadTokens failed: %q", err) apiFailuresCount.WithLabelValues("allowed", config.api.allowed).Inc() } }() } }() go listenSig1(ctx, wiegand) log.Println("Initialized longpoll event loop") go cardRunner(wiegand) log.Println("Setup completed") } func listenSig1(ctx context.Context, wiegand Wiegand) { usrSig := make(chan os.Signal) signal.Notify(usrSig, syscall.SIGUSR1) for { select { case <-usrSig: err := OpenAndCloseDoor(wiegand) log.Printf("Emergecy opening door as prompted by SIGUSR1") if err != nil { log.Printf("opening door: %v", err) } case <-ctx.Done(): return } } } func runHttpServer() { http.Handle("/metrics", promhttp.Handler()) log.Printf("Running prometheus metrics on http://%s/metrics", config.prometheusMetricsBind) log.Fatal(http.ListenAndServe(config.prometheusMetricsBind, nil)) } func OpenAndCloseDoor(w Wiegand) error { err := OpenDoor(w) if err != nil { return err } if keepDoorOpen.until.After(time.Now()) { fmt.Println("Door is already open") return nil } fmt.Println("Door is now open") time.Sleep(config.doorOpenTime) err = CloseDoor(w) if err != nil { return err } fmt.Println("Door is now closed") return nil } func OpenDoor(w Wiegand) error { open, _ := w.IsDoorOpen() if open { return nil } w.OpenDoor() return nil } func CloseDoor(w Wiegand) error { open, _ := w.IsDoorOpen() if !open { return nil } w.CloseDoor() return nil } func cardRunner(w Wiegand) { for { card, err := w.GetCardUid() if err != nil { continue } // printCardId(card) hashedHex := hash.HashCardUid(card) log.Println(hashedHex) globalLock.Lock() ok := validUids[hashedHex] globalLock.Unlock() go func() { err := sendSwipeEvent(hashedHex, ok) if err != nil { apiFailuresCount.WithLabelValues("swipe", config.api.swipe).Inc() log.Printf("Failed to send swipe event: %v", err) } }() if ok { log.Println("Opening door") err := OpenAndCloseDoor(w) cardSwipesCount.WithLabelValues("accepted").Inc() doorOpenedCount.WithLabelValues("card").Inc() if err != nil { log.Println("There was an error opening and closing the Door") } } else { cardSwipesCount.WithLabelValues("denied").Inc() log.Println("Unknown card") } } } func ParseNextMessage(r *bufio.Reader) (string, error) { var message string for { s, err := r.ReadString('\n') if err != nil { return message, err } message = message + s nextBytes, err := r.ReadByte() if err != nil { return message, err } if nextBytes == '\n' { return message, nil } r.UnreadByte() } } func waitEvents() error { req, err := http.NewRequest(http.MethodGet, config.api.longpoll, nil) if err != nil { return err } req.Header.Add("KEY", config.api.key) resp, err := http.DefaultClient.Do(req) if err != nil { return err } log.Printf("%v\n", resp) reader := bufio.NewReader(resp.Body) for { msg, err := ParseNextMessage(reader) if err != nil { if err == io.EOF { return nil } return err } for _, line := range strings.Split(msg, "\n") { data, found_data := strings.CutPrefix(line, "data:") if !found_data { continue } log.Printf("got server data: %q\n", data) if strings.TrimSpace(data) == config.door { err := OpenAndCloseDoor(wiegand) doorOpenedCount.WithLabelValues("api").Inc() if err != nil { log.Println("There was an error opening and closing the Door") } } } } } func reloadTokens() error { req, err := http.NewRequest(http.MethodGet, config.api.allowed, nil) if err != nil { return err } req.Header.Add("KEY", config.api.key) resp, err := http.DefaultClient.Do(req) if err != nil { return err } if resp.StatusCode != 200 { log.Printf("%v\n", resp) } var cl cardList body, err := io.ReadAll(resp.Body) if err != nil { return err } err = json.Unmarshal(body, &cl) if err != nil { return err } globalLock.Lock() defer globalLock.Unlock() validUids = make(ValidUids) var totalCardCount int = 0 for i, val := range cl.AllowedUids { // log.Printf("%d: %+v\n", i, val.Token.UidHash) validUids[val.Token.UidHash] = true totalCardCount = i } log.Printf("Got %d cards from server", totalCardCount) nrCardsInAllowList.Set(float64(totalCardCount)) if cl.KeepOpenUntil != nil { updateKeepOpenDoor(*cl.KeepOpenUntil) } lastSyncTimestamp.SetToCurrentTime() return nil } func updateKeepOpenDoor(newKeepOpenTime time.Time) { // is there one active? if keepDoorOpen.timer != nil { keepDoorOpen.timer.Stop() keepDoorOpen = KeepDoorOpen{} } if newKeepOpenTime.After(time.Now()) { log.Printf("Keeping door open until %v", newKeepOpenTime) OpenDoor(wiegand) timer := time.AfterFunc(time.Until(newKeepOpenTime), handleKeepDoorOpenCloseCleanup) keepDoorOpen = KeepDoorOpen{ timer: timer, until: newKeepOpenTime, } } else { CloseDoor(wiegand) } } func handleKeepDoorOpenCloseCleanup() { fmt.Println("Keep door open time is reached!") CloseDoor(wiegand) keepDoorOpen = KeepDoorOpen{} } func sendSwipeEvent(cardUidHash string, success bool) error { swipeEvent := map[string]any{ "uid_hash": cardUidHash, "door": config.door, "timestamp": time.Now().Format(time.RFC3339), "success": success, } data, err := json.Marshal(swipeEvent) if err != nil { return err } req, err := http.NewRequest(http.MethodPost, config.api.swipe, bytes.NewReader(data)) if err != nil { return err } req.Header.Add("KEY", config.api.key) req.Header.Add("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { return fmt.Errorf("Server responded with %d", resp.StatusCode) } return nil }