33 Commits

Author SHA1 Message Date
Mykhailo Yermolenko
728939cef3 Handle keep_open_until: cancel, periodic poll, thread safety
- cancelKeepOpenDoor() closes the door when /allowed returns null
- background goroutine polls /allowed every 15s (KDOORPI_ALLOWED_POLL_INTERVAL)
- keepDoorOpenLock mutex guards shared hold state
- skip timer rebuild when the hold is unchanged (anti-thrash)
2026-06-17 16:42:54 +03:00
bd94037e29 small lint fixup 2026-06-11 22:12:33 +03:00
7bd5f4184b unify to s/success/approved/
inventory-app and doorboy-proxy uses this
2026-06-11 22:01:33 +03:00
ab5c255b28 trim space 2025-12-28 20:58:12 +02:00
9bf71b347f refactor: flatten hash updates 2025-12-28 20:58:11 +02:00
Arti Zirk
2f4005a7ba Add system architecture 2024-08-29 13:05:38 +03:00
Arti Zirk
8d4df68b95 Add note about not using godoor_server in prod 2024-08-29 12:50:40 +03:00
b2e810f0dd Cleanup CI 2024-08-15 23:18:12 +03:00
a4b42715a4 Fix conditional CI 2024-08-15 23:07:41 +03:00
fe35e0ff21 Add contiditional CI 2024-08-15 23:05:37 +03:00
c6bacca63d Use mirror.gcr.io 2024-08-15 22:54:09 +03:00
0d9bf1a3f7 Update CI 2024-08-15 22:50:44 +03:00
826df446d7 Switch to Woodpecker 2024-07-26 14:17:24 +03:00
5e64dfa04a Native builds in Docker file and multiarch build by Drone 2023-09-13 11:43:20 +03:00
aa37e8e291 Update README 2023-08-26 21:20:48 +03:00
6ad260078e Implement #5: SIGUSR1
k-space/godoor#5
2023-08-25 06:03:55 +00:00
2008c9f999 Fix Docker image 2023-08-24 22:35:23 +03:00
7e1ba5dd4d Merge pull request 'rasmus' (#4) from rasmus/godoor:rasmus into master
Reviewed-on: k-space/godoor#4
2023-08-17 17:51:37 +00:00
8c14e3bc41 use Duration for doorOpenTime + lint 2023-08-17 20:45:11 +03:00
e0dacb1e4b update deps 2023-08-17 20:11:06 +03:00
Arti Zirk
647a90c0f2 Enable Wiegand data internal pull-ups
Back Door shield needs it as it does not have external pull-ups
2023-08-11 22:09:22 +03:00
Arti Zirk
92469f11b2 Do not error on git describe if tags are missing 2023-08-11 14:35:57 +03:00
a75aeeb8f9 Add Drone config 2023-08-11 14:28:42 +03:00
Arti Zirk
d4f2624635 Fetch tokens after every longpoll close event 2023-08-11 00:57:24 +03:00
Arti Zirk
2259d3ed6b Add build dockerfile 2023-08-10 20:47:03 +03:00
Arti Zirk
8fde62955d Try to staticly build godoor 2023-08-10 20:08:23 +03:00
Arti Zirk
e03bf19427 Retry events poll right away on EOF 2023-08-10 20:07:39 +03:00
Arti Zirk
24ca04b2aa Merge pull request 'Prometheus Metrics' (#2) from prometheus-metrics into master
Reviewed-on: k-space/godoor#2
2023-08-10 14:55:47 +00:00
Arti Zirk
22487faf06 Add prometheus metrics 2023-08-07 10:46:31 +03:00
Arti Zirk
3f70299c36 Print version string on startup from git describe 2023-08-06 22:01:25 +03:00
Arti Zirk
fddac32cc7 Define a door name in example .env file 2023-08-06 21:59:45 +03:00
Arti Zirk
74cb7ba215 Add prometheus client library 2023-08-06 21:58:41 +03:00
Arti Zirk
a6b928dc0e Allow configuring more options via ENV 2023-08-06 16:48:17 +03:00
15 changed files with 629 additions and 186 deletions

1
.dockerignore Symbolic link
View File

@@ -0,0 +1 @@
./.gitignore

5
.env
View File

@@ -1,5 +0,0 @@
KDOORPI_API_ALLOWED=http://127.0.0.1:3333/allowed
KDOORPI_API_LONGPOLL=http://127.0.0.1:3333/longpoll
KDOORPI_API_SWIPE=http://127.0.0.1:3333/cardswipe
KDOORPI_API_KEY=keykey
KDOORPI_MOCK_HW=true

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@
godoor
godoor_server/godoor_server
godoor_server/keys.json
__debug_bin*
.env
.vscode

28
.woodpecker/build.yaml Normal file
View File

@@ -0,0 +1,28 @@
---
matrix:
ARCH:
- amd64
- arm64
steps:
- name: build
image: woodpeckerci/plugin-kaniko
backend_options:
kubernetes:
nodeSelector:
kubernetes.io/arch: ${ARCH}
tolerations:
- key: arch
operator: Equal
value: ${ARCH}
effect: NoSchedule
settings:
repo: ${CI_REPO}
registry: harbor.k-space.ee
tags: latest-${ARCH}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
- branch: master

30
.woodpecker/manifest.yaml Normal file
View File

@@ -0,0 +1,30 @@
---
skip_clone: true
steps:
- name: manifest
image: mirror.gcr.io/mplatform/manifest-tool:alpine-v2.1.6
secrets:
- docker_username
- docker_password
commands:
- |
cat << EOF > spec.yaml
image: "harbor.k-space.ee/${CI_REPO}:latest"
manifests:
- image: "harbor.k-space.ee/${CI_REPO}:latest-amd64"
platform:
architecture: amd64
os: linux
- image: "harbor.k-space.ee/${CI_REPO}:latest-arm64"
platform:
architecture: arm64
os: linux
EOF
- /manifest-tool --username $docker_username --password $docker_password push from-spec spec.yaml > stdout
- cat stdout
when:
- branch: master
depends_on:
- build

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM mirror.gcr.io/library/golang:1.25-alpine AS build
RUN apk add ca-certificates
WORKDIR /godoor
# pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
# fully static build (takes ~300s to build):
ENV GOBUILDLDFLAGS="-linkmode 'external' -extldflags '-static'"
RUN go build -tags netgo .
FROM scratch
WORKDIR /
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /godoor/godoor /godoor
ENTRYPOINT ["/godoor"]

View File

@@ -1,4 +1,16 @@
dev:
GOOS=linux GOARCH=arm64 go build .
.PHONY: all build build_arm64 dev
all: build
GOBUILDLDFLAGS="-X main.Version=`git describe --always --dirty`"
#GOBUILDLDFLAGS="-X main.Version=`git describe --dirty` -linkmode 'external' -extldflags '-static'"
build:
go build -tags netgo -ldflags=${GOBUILDLDFLAGS} .
build_arm64:
GOOS=linux GOARCH=arm64 go build -tags netgo -ldflags=${GOBUILDLDFLAGS} .
dev: build_arm64
scp godoor workshopdoor:/tmp/
ssh workshopdoor 'mv -f /tmp/godoor ~/ && sudo systemctl restart godoor'

View File

@@ -1,14 +1,65 @@
# GoDoor
[kdoorpi](https://git.k-space.ee/arti/kdoorpi) but in Go
Go rewrite of [kdoorpi](https://git.k-space.ee/arti/kdoorpi). Uses
[libgpiod](https://git.kernel.org/pub/scm/libs/libgpiod/libgpiod.git/about/)
native Go library to access the general purpose pins in cross platform way.
Uses Go native [libgpiod](https://git.kernel.org/pub/scm/libs/libgpiod/libgpiod.git/about/)
library to access the gpio pins in cross platform way.
# Developing
# Cross compile
Either build directly on Raspberry Pi or build locally and copy the binary
GOOS=linux GOARCH=arm64 go build
```
GOOS=linux GOARCH=arm64 go build
scp godoor rpi4b:
```
# Test deploy
# Deployment
scp godoor rpi4b:
The CI system should automatically build docker image and push it to [Harbor](https://harbor.k-space.ee)
Refer to
[Ansible playbook](https://git.k-space.ee/k-space/kube/src/branch/master/ansible-doors.yml)
on how to deploy on all door controllers
# Usage
For end users door can be opened by:
* [Web interface](https://inventory.k-space.ee/m/doorboy)
* Push button
* Enrolling keyfob and swiping keyfob at door cardreader
* Issuing `/open-...-door` command in #members channel
For emergency cases:
* Log into door controller via SSH and issue `killall -sUSR1 godoor`
# Door system architecture
* Web UI / Slack commands - https://git.k-space.ee/k-space/inventory-app
* Card List provider - https://git.k-space.ee/k-space/doorboy-proxy
```
┌──────────────┐
┌──────────────────┐ │ Card Reader │ ...
│ Slack /open-door │ └───────┬──────┘
└─────────────────┬┘ │
│ ┌──────────────┐
┌──────────────┐ │ ┌────│ RPI/w godoor │ ...
│ Web Open door│ │ │ └──────────────┘
└─────────┬────┘ │ │Swipe ▲
│ │ │Event │ Open event
▼ ▼ ▼ │ Card list
┌───────────────┐ ┌─────────┴─────┐
│ inventory-app │ │ doorboy-proxy │
│ doorboy.py │ └───────────────┘
└───────────────┘ ▲
│ │
│ │
│ ▼
│ ┌───────────┐
└──────────►│ MongoDB │
└───────────┘
```
https://asciiflow.com/ diagram

21
go.mod
View File

@@ -1,10 +1,21 @@
module godoor
go 1.18
go 1.25
require (
github.com/joho/godotenv v1.5.1 // indirect
github.com/warthog618/gpiod v0.8.0 // indirect
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 // indirect
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
github.com/prometheus/client_golang v1.16.0
github.com/warthog618/gpiod v0.8.2
golang.org/x/crypto v0.12.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect
golang.org/x/sys v0.11.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

55
go.sum
View File

@@ -1,10 +1,45 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/warthog618/gpiod v0.8.0 h1:qxH9XVvWHpTxzWFSndBcujFyNH5zVRzHM63tcmm85o4=
github.com/warthog618/gpiod v0.8.0/go.mod h1:a7Csa+IJtDBZ39++zC/6Srjo01qWejt/5velrDWuNkY=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/warthog618/go-gpiosim v0.1.0 h1:2rTMTcKUVZxpUuvRKsagnKAbKpd3Bwffp87xywEDVGI=
github.com/warthog618/go-gpiosim v0.1.0/go.mod h1:Ngx/LYI5toxHr4E+Vm6vTgCnt0of0tktsSuMUEJ2wCI=
github.com/warthog618/gpiod v0.8.2 h1:2HgQ9pNowPp7W77sXhX5ut5Tqq1WoS3t7bXYDxtYvxc=
github.com/warthog618/gpiod v0.8.2/go.mod h1:O7BNpHjCn/4YS5yFVmoFZAlY1LuYuQ8vhPf0iy/qdi4=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

434
godoor.go
View File

@@ -11,38 +11,38 @@ import (
"net/http"
"os"
"os/signal"
"runtime/debug"
"strconv"
"strings"
"sync"
"syscall"
"time"
"godoor/hash"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/joho/godotenv"
"godoor/hash"
)
const wiegand_a = 17
const wiegand_b = 18
const wiegand_bit_timeout = time.Millisecond * 8
const solenoid = 21
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"`
type upstreamUpdate struct {
AllowedHashes []string `json:"allowed_hashes"`
KeepOpenUntil *time.Time `json:"keep_open_until,omitempty"`
}
type ValidUids map[string]bool // bool has no meaning
type ValidHashesT map[string]bool // bool has no meaning
type Config struct {
door string
uidSalt string
doorOpenTime string
doorOpenTime time.Duration
mock bool
api struct {
allowed string
@@ -50,6 +50,12 @@ type Config struct {
swipe string
key string
}
pins struct {
wiegandA int
wiegandB int
solenoid int
}
prometheusMetricsBind string
}
type KeepDoorOpen struct {
@@ -62,33 +68,67 @@ type OpenedTimestamp struct {
Closed *time.Time
}
var config Config
var globalLock sync.Mutex
var validUids ValidUids
var wiegand Wiegand
var keepDoorOpen KeepDoorOpen
var Commit = func() string {
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" {
return setting.Value
}
}
}
var lastSyncedTimestamp *time.Time
var openDoorTimestamps []OpenedTimestamp
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()
godotenv.Load()
loadConfig()
log.Printf("Door name: %s\n", config.door)
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 = os.Getenv("KDOORPI_OPEN_TIME")
_, config.mock = os.LookupEnv("KDOORPI_MOCK_HW")
godoorBuildInfo.WithLabelValues(Version, Commit).Set(1)
go func() {
setup()
setup(ctx)
}()
<-ctx.Done()
@@ -97,9 +137,46 @@ func main() {
// cleanup
}
func setup() {
log.Println("Started Setup")
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 == "" {
@@ -107,68 +184,120 @@ func setup() {
}
wiegand = &WiegandMock{}
} else {
wiegand = WiegandSetup(wiegand_a, wiegand_b, wiegand_bit_timeout, solenoid)
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
go func() {
for {
err := waitEvents()
if err != nil {
log.Printf("LongPoll for events failed: %v", err)
log.Println("Will try to LongPoll again in 120 seconds")
time.Sleep(120 * time.Second)
go reloadTokens()
}
time.Sleep(1 * time.Second)
}
}()
log.Println("Initialized longpoll event loop")
for {
log.Println("Start initial token population")
err := reloadTokens()
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)
}
go runHttpServer()
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.HandleFunc("/lastsync", func(w http.ResponseWriter, r *http.Request) {
e := json.NewEncoder(w)
e.Encode(map[string]any{
"last_synced": lastSyncedTimestamp,
})
})
http.HandleFunc("/opened", func(w http.ResponseWriter, r *http.Request) {
e := json.NewEncoder(w)
e.Encode(map[string]any{
"open_timestamps": openDoorTimestamps,
})
})
http.HandleFunc("/isopen", func(w http.ResponseWriter, r *http.Request) {
e := json.NewEncoder(w)
open, _ := wiegand.IsDoorOpen()
e.Encode(map[string]any{
"open": open,
})
})
http.ListenAndServe(":3334", nil)
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 {
@@ -177,14 +306,30 @@ func OpenAndCloseDoor(w Wiegand) error {
return err
}
if keepDoorOpen.until.After(time.Now()) {
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(5 * time.Second)
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 {
@@ -200,9 +345,7 @@ func OpenDoor(w Wiegand) error {
if open {
return nil
}
w.OpenDoor()
openDoorTimestamps = append(openDoorTimestamps, OpenedTimestamp{Opened: time.Now(), Closed: nil})
return nil
return w.OpenDoor()
}
func CloseDoor(w Wiegand) error {
@@ -210,10 +353,7 @@ func CloseDoor(w Wiegand) error {
if !open {
return nil
}
w.CloseDoor()
t := time.Now()
openDoorTimestamps[len(openDoorTimestamps)-1].Closed = &t
return nil
return w.CloseDoor()
}
func cardRunner(w Wiegand) {
@@ -223,28 +363,32 @@ func cardRunner(w Wiegand) {
continue
}
printCardId(card)
// printCardId(card)
hashedHex := hash.HashCardUid(card)
log.Println(hashedHex)
globalLock.Lock()
ok := validUids[hashedHex]
globalLock.Unlock()
VALID_HASHES_LOCK.Lock()
ok := VALID_HASHES[hashedHex]
VALID_HASHES_LOCK.Unlock()
go func() {
err := sendSwipeEvent(hashedHex, ok)
if err != nil {
log.Println("Failed to send swipe event: %v", err)
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")
}
@@ -272,7 +416,6 @@ func ParseNextMessage(r *bufio.Reader) (string, error) {
r.UnreadByte()
}
}
func waitEvents() error {
@@ -291,17 +434,21 @@ func waitEvents() error {
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:")
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")
}
@@ -309,91 +456,89 @@ func waitEvents() error {
}
}
log.Printf("%v\n", resp)
return nil
}
func reloadTokens() error {
req, err := http.NewRequest(http.MethodGet, config.api.allowed, nil)
// 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 cl cardList
var info upstreamUpdate
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
err = json.Unmarshal(body, &cl)
err = json.Unmarshal(body, &info)
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)
if cl.KeepOpenUntil != nil {
updateKeepOpenDoor(*cl.KeepOpenUntil)
// Update Allowed hashes
validHashesPre := make(ValidHashesT)
for _, hash := range info.AllowedHashes {
validHashesPre[hash] = true
}
t := time.Now()
lastSyncedTimestamp = &t
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 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]string{
func sendSwipeEvent(cardUidHash string, approved bool) error {
swipeEvent := map[string]any{
"uid_hash": cardUidHash,
"door": config.door,
"timestamp": time.Now().Format(time.DateTime),
"success": fmt.Sprint(success),
"timestamp": time.Now().Format(time.RFC3339),
"approved": approved,
}
data, err := json.Marshal(swipeEvent)
@@ -406,6 +551,7 @@ func sendSwipeEvent(cardUidHash string, success bool) error {
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

7
godoor_server/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Local Development Mock server
Just for simple local testing
# NOT TO BE USED IN PROD!
Move it into a seperate repo if before making it more useful

View File

@@ -1,4 +1,6 @@
// really stupid test door server
// Only for local testing
// I WIL HUNT YOU DOWN IT YOU TRY TO USE IT IN PROD!
package main
@@ -217,7 +219,7 @@ func (doobserver *DoorBoyServer) postKeepDoorOpen(w http.ResponseWriter, r *http
rs, _ := io.ReadAll(r.Body)
parsedTime, err := time.Parse(time.RFC3339, strings.TrimSpace(string(rs)))
if err != nil {
fmt.Println("Error with parsing time: %v", err)
fmt.Printf("Error with parsing time: %v", err)
w.WriteHeader(http.StatusBadRequest)
}
keepOpenUntil = &parsedTime
@@ -229,6 +231,9 @@ func main() {
var dumpKeys []cardToken
savedKeys, err := os.ReadFile("keys.json")
if err != nil {
log.Fatalf("reading saved keys from keys.json: %v", err)
}
err = json.Unmarshal(savedKeys, &dumpKeys)
if err == nil {
@@ -268,7 +273,7 @@ func main() {
keyLock.Lock()
data, err := json.Marshal(keys)
if err == nil {
os.WriteFile("keys.json", data, 0600)
os.WriteFile("keys.json", data, 0o600)
log.Println("Saved keys successfully!")
}
keyLock.Unlock()

97
keepopen.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"fmt"
"log"
"sync"
"time"
)
var keepDoorOpenLock sync.Mutex
// keepDoorOpenGen identifies the currently armed hold timer. It is bumped (under
// keepDoorOpenLock) every time a new timer is installed so that a superseded
// timer's callback can recognise it is stale and do nothing.
var keepDoorOpenGen uint64
func updateKeepOpenDoor(newKeepOpenTime time.Time) {
keepDoorOpenLock.Lock()
defer keepDoorOpenLock.Unlock()
// Hold unchanged since the last poll: keep the existing timer rather than
// rebuilding it every poll, which would float the close time forward by up
// to one poll interval and re-pulse OpenDoor needlessly.
if keepDoorOpen.timer != nil && newKeepOpenTime.Equal(keepDoorOpen.until) {
return
}
// 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)
if err := OpenDoor(wiegand); err != nil {
// Don't commit the hold if the relay didn't actually open: leaving
// keepDoorOpen empty (timer nil) means the next poll retries instead
// of latching the Equal early-return on a door that never opened.
log.Printf("ERROR opening door for hold: %v", err)
return
}
keepDoorOpenGen++
gen := keepDoorOpenGen
timer := time.AfterFunc(time.Until(newKeepOpenTime), func() {
handleKeepDoorOpenCloseCleanup(gen)
})
keepDoorOpen = KeepDoorOpen{
timer: timer,
until: newKeepOpenTime,
}
} else {
if err := CloseDoor(wiegand); err != nil {
log.Printf("ERROR closing door: %v", err)
}
}
}
func cancelKeepOpenDoor() {
keepDoorOpenLock.Lock()
defer keepDoorOpenLock.Unlock()
if keepDoorOpen.timer == nil {
return
}
keepDoorOpen.timer.Stop()
if err := CloseDoor(wiegand); err != nil {
// Keep keepDoorOpen non-nil so the next poll's cancel retries the close;
// clearing it now would strand the door OPEN with no retry path.
log.Printf("ERROR closing door on hold cancel: %v", err)
return
}
keepDoorOpen = KeepDoorOpen{}
}
func handleKeepDoorOpenCloseCleanup(gen uint64) {
keepDoorOpenLock.Lock()
defer keepDoorOpenLock.Unlock()
// Timer.Stop() can return after this callback has already started and is
// blocked here on the lock; by the time we acquire it, updateKeepOpenDoor
// may have installed a newer hold. Only act if we are still the current
// generation, otherwise we would close the door and clear a live hold.
if gen != keepDoorOpenGen {
return
}
fmt.Println("Keep door open time is reached!")
if err := CloseDoor(wiegand); err != nil {
// Leave keepDoorOpen intact so the next poll (which will see
// keep_open_until=null and call cancelKeepOpenDoor) retries the close
// instead of stranding the door OPEN.
log.Printf("ERROR closing door at hold expiry: %v", err)
return
}
keepDoorOpen = KeepDoorOpen{}
}

View File

@@ -57,6 +57,7 @@ func (w *WiegandHW) IsDoorOpen() (bool, error) {
defer w.lock.RUnlock()
return w.isDoorOpen()
}
func (w *WiegandHW) isDoorOpen() (bool, error) {
i, err := w.solenoidLine.Value()
if err != nil {
@@ -90,19 +91,19 @@ func (w *WiegandHW) GetCardUid() (uint64, error) {
func (w *WiegandHW) wiegandAEvent(evt gpiod.LineEvent) {
w.bitTimeoutTimer.Reset(w.bitTimeout)
w.bits[w.bitNr] = true
//fmt.Printf("1")
// fmt.Printf("1")
w.bitNr += 1
}
func (w *WiegandHW) wiegandBEvent(evt gpiod.LineEvent) {
w.bitTimeoutTimer.Reset(w.bitTimeout)
w.bits[w.bitNr] = false
//fmt.Printf("0")
// fmt.Printf("0")
w.bitNr += 1
}
func WiegandSetup(a int, b int, bitTimeout time.Duration, solenoid int) *WiegandHW {
log.Printf("Wiegand GPIO-s: A:%d B:%d Solenoid:%d", a, b, solenoid)
var wiegand WiegandHW
wiegand.bitTimeout = bitTimeout
@@ -112,14 +113,14 @@ func WiegandSetup(a int, b int, bitTimeout time.Duration, solenoid int) *Wiegand
}
wa, err := gpiod.RequestLine("gpiochip0", a, gpiod.AsInput,
gpiod.WithFallingEdge, gpiod.WithEventHandler(wiegand.wiegandAEvent))
gpiod.WithFallingEdge, gpiod.LineBiasPullUp, gpiod.WithEventHandler(wiegand.wiegandAEvent))
if err != nil {
panic(err)
}
wiegand.aLine = wa
wb, err := gpiod.RequestLine("gpiochip0", b, gpiod.AsInput,
gpiod.WithFallingEdge, gpiod.WithEventHandler(wiegand.wiegandBEvent))
gpiod.WithFallingEdge, gpiod.LineBiasPullUp, gpiod.WithEventHandler(wiegand.wiegandBEvent))
if err != nil {
panic(err)
}
@@ -140,9 +141,9 @@ func (w *WiegandHW) WiegandClose() {
w.solenoidLine.Close()
}
func printCardId(card uint64) {
for i := 0; i < 7; i++ {
fmt.Printf("%02x", (card>>(8*i))&0xff)
}
fmt.Printf("\n")
}
// func printCardId(card uint64) {
// for i := 0; i < 7; i++ {
// fmt.Printf("%02x", (card>>(8*i))&0xff)
// }
// fmt.Printf("\n")
// }