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/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 upstreamUpdate struct { AllowedHashes []string `json:"allowed_hashes"` KeepOpenUntil *time.Time `json:"keep_open_until,omitempty"` } type ValidHashesT 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 VALID_HASHES_LOCK sync.Mutex VALID_HASHES = make(ValidHashesT) 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() log.Printf("Door name: %s\n", config.door) godoorBuildInfo.WithLabelValues(Version, Commit).Set(1) go func() { setup(ctx) }() <-ctx.Done() log.Printf("Cleanup\n") // cleanup } func loadConfig() { var err error 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) { 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 := reloadInfo() 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") allowedPollInterval := 15 * time.Second if intervalStr, ok := os.LookupEnv("KDOORPI_ALLOWED_POLL_INTERVAL"); ok { if interval, err := time.ParseDuration(intervalStr); err != nil { log.Printf("parsing KDOORPI_ALLOWED_POLL_INTERVAL: %v, keeping default %v", err, allowedPollInterval) } else if interval <= 0 { // time.NewTicker panics on a non-positive duration, so a malformed // setting like "0" or "-1s" would crash the controller. Keep the // safe default instead. log.Printf("KDOORPI_ALLOWED_POLL_INTERVAL must be positive, got %v, keeping default %v", interval, allowedPollInterval) } else { allowedPollInterval = interval } } go func() { ticker := time.NewTicker(allowedPollInterval) defer ticker.Stop() for range ticker.C { pollAllowedOnce() } }() 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 := reloadInfo() if err != nil { log.Printf("reloadTokens failed: %q", err) apiFailuresCount.WithLabelValues("allowed", config.api.allowed).Inc() // Intentionally no cancelKeepOpenDoor() here: the hold // fail-safe is owned by the fixed-interval periodic poll // above. Cancelling from this best-effort, per-iteration // refresh too would only add door flapping. Worst-case // stuck-open after proxy loss is ~reloadInfoTimeout + one // poll interval (a hung-but-connected proxy blocks the // in-flight reloadInfo until its request timeout). } }() } }() go listenSig1(ctx, wiegand) log.Println("Initialized longpoll event loop") go cardRunner(wiegand) log.Println("Setup completed") } func pollAllowedOnce() { err := reloadInfo() if err != nil { log.Printf("Periodic reloadInfo failed: %v", err) apiFailuresCount.WithLabelValues("allowed", config.api.allowed).Inc() // Fail safe: we can no longer confirm the door should stay // held open, so close it now rather than trusting a stale // deadline (up to 6h away). A later successful poll re-opens // it if the hold is still active server-side. cancelKeepOpenDoor() } } func listenSig1(ctx context.Context, wiegand Wiegand) { usrSig := make(chan os.Signal, 1) 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 } keepDoorOpenLock.Lock() keepOpenUntil := keepDoorOpen.until keepDoorOpenLock.Unlock() if keepOpenUntil.After(time.Now()) { fmt.Println("Door is already open") return nil } fmt.Println("Door is now open") time.Sleep(config.doorOpenTime) // A hold may have been armed while we were sleeping. Re-check under the // lock and skip the close if so, holding the lock across the close decision // so an in-flight updateKeepOpenDoor cannot slip a hold in between the // check and the close. Otherwise this pulse would close a held-open door // and subsequent polls, seeing the timer as already valid, would never // reopen it, locking the door for the whole hold duration. keepDoorOpenLock.Lock() defer keepDoorOpenLock.Unlock() if keepDoorOpen.until.After(time.Now()) { fmt.Println("Door is held open, leaving open") return nil } 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 } return w.OpenDoor() } func CloseDoor(w Wiegand) error { open, _ := w.IsDoorOpen() if !open { return nil } return w.CloseDoor() } func cardRunner(w Wiegand) { for { card, err := w.GetCardUid() if err != nil { continue } // printCardId(card) hashedHex := hash.HashCardUid(card) log.Println(hashedHex) VALID_HASHES_LOCK.Lock() ok := VALID_HASHES[hashedHex] VALID_HASHES_LOCK.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") } } } } } // reloadInfoLock serializes reloadInfo so the periodic-poll and longpoll-driven // callers cannot apply /allowed responses out of order (a slow stale response // must not overwrite a newer one and, e.g., cancel a just-armed hold). var reloadInfoLock sync.Mutex // reloadInfoTimeout bounds a single /allowed fetch. The shared http client has a // 120s timeout (needed by the longpoll), but the allow-list/keep-open fetch must // be quick: a hung-but-connected proxy otherwise blocks the fail-safe close for // up to 120s. This caps the worst-case stuck-open window to ~this + one interval. const reloadInfoTimeout = 30 * time.Second func reloadInfo() error { reloadInfoLock.Lock() defer reloadInfoLock.Unlock() ctx, cancel := context.WithTimeout(context.Background(), reloadInfoTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, config.api.allowed, nil) if err != nil { return err } req.Header.Add("KEY", config.api.key) req.Header.Add("DOOR_NAME", config.door) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { log.Printf("%v\n", resp) // Treat a non-200 as a failure rather than parsing a possibly-cached or // error body: otherwise a stale future keep_open_until would defeat the // fail-safe, or a body lacking it would wrongly cancel a live hold. The // caller's error path then drives the documented fail-safe close. return fmt.Errorf("allowed endpoint returned status %d", resp.StatusCode) } var info upstreamUpdate body, err := io.ReadAll(resp.Body) if err != nil { return err } err = json.Unmarshal(body, &info) if err != nil { return err } // Update Allowed hashes validHashesPre := make(ValidHashesT) for _, hash := range info.AllowedHashes { validHashesPre[hash] = true } VALID_HASHES_LOCK.Lock() VALID_HASHES = validHashesPre VALID_HASHES_LOCK.Unlock() log.Printf("Got %d cards from server", len(info.AllowedHashes)) nrCardsInAllowList.Set(float64(len(info.AllowedHashes))) // Update Keep open if info.KeepOpenUntil != nil { updateKeepOpenDoor(*info.KeepOpenUntil) } else { cancelKeepOpenDoor() } lastSyncTimestamp.SetToCurrentTime() return nil } func sendSwipeEvent(cardUidHash string, approved bool) error { swipeEvent := map[string]any{ "uid_hash": cardUidHash, "door": config.door, "timestamp": time.Now().Format(time.RFC3339), "approved": approved, } 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 }