- Go 70.3%
- Nix 17.8%
- HTML 11.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Populate the scaffold with the Go app: auth/serve/doors subcommands, the go-butterflymx client wrapper with refresh-token persistence, in-app pocket-id OIDC gating on a door_users group, the web UI, and Nix packaging (flake + buildGoModule with go_1_25/GOEXPERIMENT=jsonv2 + a hardened NixOS module). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
| internal | ||
| nix | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| main.go | ||
| README.md | ||
pepela
პეპელა (Georgian for butterfly) — a small self-hosted web app to open ButterflyMX doors, gated behind pocket-id OIDC.
It's a spiritual clone of ButterflyMX-444,
built on the reverse-engineered Go client
go-butterflymx instead of
the Python library. A logged-in, authorized user sees the building's access
points and taps one to release the door.
Unofficial and unsupported: it drives reverse-engineered ButterflyMX endpoints and can break whenever they change. Use at your own risk.
How it works
- Holds a ButterflyMX OAuth2 refresh token (never your password) and re-mints the short-lived Rails API token on demand.
- Enforces login in-app as an OIDC client of pocket-id, requiring a
configurable group (
door_usersby default) in thegroupsclaim. - Stateless sessions (in-memory), CSRF-protected unlock, audit-logged opens.
Build
Requires Go 1.25 with GOEXPERIMENT=jsonv2 (go-butterflymx uses
encoding/json/v2).
GOEXPERIMENT=jsonv2 go build .
# or:
nix build
Commands
pepela auth — one-time login
Runs the interactive ButterflyMX OAuth2 + PKCE flow (handles 2FA via the real login page) and prints the token JSON, including the refresh token:
pepela auth > token.json
Follow the printed URL, log in, and paste back the com.butterflymx.oauth://…
URL you're redirected to. Store token.json as the app's token file (e.g. in
agenix).
pepela doors — verify the token
Lists the account's access points using only BUTTERFLYMX_TOKEN_FILE (no OIDC,
no persistence). A quick way to confirm pepela auth produced a working token —
it forces a live API call, so it also exercises the refresh path:
BUTTERFLYMX_TOKEN_FILE=~/pepela-token.json pepela doors
pepela serve — run the server
Configured entirely via environment variables:
| Variable | Required | Default | Meaning |
|---|---|---|---|
PEPELA_BASE_URL |
yes | — | Public URL, used to build the OIDC redirect (<base>/auth/callback) |
BUTTERFLYMX_TOKEN_FILE |
yes | — | Seed token JSON from pepela auth |
OIDC_ISSUER |
yes | — | pocket-id issuer URL |
OIDC_CLIENT_ID |
yes | — | OIDC client ID |
OIDC_CLIENT_SECRET_FILE |
yes | — | File containing the OIDC client secret |
OIDC_REQUIRED_GROUP |
no | door_users |
Group required to open doors |
PEPELA_LISTEN |
no | 127.0.0.1:3005 |
Bind address |
STATE_DIRECTORY |
no | . |
Where the rotated token is cached (set by systemd) |
The seed token file is read-only; rotated tokens are cached to
$STATE_DIRECTORY/token.json so refresh-token rotation survives restarts. Bind
to loopback and only expose the app through a TLS-terminating reverse proxy.
NixOS
This flake ships nixosModules.default (services.pepela) and
packages.default. Example:
services.pepela = {
enable = true;
baseUrl = "https://door.example.com";
tokenFile = config.age.secrets.butterflymx-oauth-token.path;
oidc = {
issuer = "https://id.example.com";
clientId = "…"; # from the pocket-id admin UI
clientSecretFile = config.age.secrets.pocket-id-oauth2-pepela-secret.path;
requiredGroup = "door_users";
};
};
Create the OIDC client in pocket-id with redirect
https://door.example.com/auth/callback, and put your users in the
door_users group.
License
GPL-3.0-only (go-butterflymx is GPL-3.0).