Compare commits

..
5 Commits
Author SHA1 Message Date
Outis 2de66045dc Merge pull request #1 from outis1one/clDemoe/camera-live-view-app-dlYME
ClDemoe/camera live view app dl yme
2026-04-26 10:09:26 -04:00
Claude 09d5b28e3d Revert camera dropdown; add PEER_LINKS for sibling Pis
The dropdown allowed switching the video feed but PTT always pointed at
this Pi's local speaker, so picking a non-co-located camera could have
let a user talk into the wrong room. Each Pi is now hardcoded to the
camera at its own location via CAMERA_NAME, with optional PEER_LINKS to
render quick-jump buttons to sibling doorbell Pis at other URLs.

https://claude.ai/code/session_013XZ1vmgk78k2PEQ5DmJhF3
2026-04-25 21:23:17 +00:00
Claude b3940685c4 Add camera selector dropdown to doorbell page
Replaces the hardcoded CAMERA_NAME with a CAMERAS list. The page renders
a dropdown when more than one camera is configured and remembers the
choice in localStorage; switching tears down and reopens the WebRTC
connection.

https://claude.ai/code/session_013XZ1vmgk78k2PEQ5DmJhF3
2026-04-25 19:10:37 +00:00
Outis 8609175410 Update FN_FRIGATE__PUBLIC_URL to new domain 2026-04-25 14:47:31 -04:00
Outis 5d7df0c0c8 Initial home camera stack 2026-04-25 11:06:10 -04:00
21 changed files with 1362 additions and 1004 deletions
+33 -11
View File
@@ -1,14 +1,36 @@
# Copy to .env and fill in. DO NOT commit .env.
# Copy to .env and fill in real values. DO NOT commit .env to git.
#
# Secrets themselves live as files under authelia/secrets/ (also gitignored)
# so they can be mounted into the container without env-var leakage. This
# .env only holds non-secret tunables.
# Env var prefixes:
# FRIGATE_ - referenced via {FRIGATE_*} substitution in frigate_config/config.yml
# FN_ - read directly by frigate-notify (Viper). Note the DOUBLE underscore
# between YAML hierarchy levels: frigate.mqtt.password -> FN_FRIGATE__MQTT__PASSWORD
# Pin your image versions. Bump to current stable when you upgrade --
# check https://github.com/authelia/authelia/releases and
# https://github.com/crazy-max/docker-fail2ban/releases.
AUTHELIA_VERSION=4.39.19
FAIL2BAN_VERSION=1.1.0-r0
# ---- Camera credentials ----
FRIGATE_RTSP_USER=admin
FRIGATE_RTSP_PASSWORD=pick_a_url_safe_password
FRIGATE_FRONT_DOOR_IP=192.168.1.100
# Used by both containers for log timestamps. Set to your IANA zone.
TZ=America/New_York
FRIGATE_RTSP_USER1=admin
FRIGATE_RTSP_PASSWORD1=changeme
FRIGATE_BACK_DOOR_IP=192.168.1.101
FRIGATE_RTSP_USER2=admin
FRIGATE_RTSP_PASSWORD2=changeme
FRIGATE_SQUIRREL_IP=192.168.1.102
# Future Anpviz camera with mic
# FRIGATE_RTSP_USER3=admin
# FRIGATE_RTSP_PASSWORD3=changeme
# FRIGATE_ANPVIZ_IP=192.168.1.103
# ---- MQTT broker ----
# Used by Frigate via {FRIGATE_MQTT_*} substitution
FRIGATE_MQTT_USER=frigate
FRIGATE_MQTT_PASSWORD=pick_a_strong_mqtt_password
# ---- frigate-notify ----
# Same MQTT password as above. Note double underscores between YAML levels.
FN_FRIGATE__MQTT__PASSWORD=same_value_as_FRIGATE_MQTT_PASSWORD
FN_FRIGATE__SERVER=http://frigate:5000
FN_FRIGATE__PUBLIC_URL=https://frigate.yourdomain.com
FN_ALERTS__NTFY__SERVER=https://ntfy.yourdomain.com
+23 -17
View File
@@ -1,23 +1,26 @@
# Secrets — never commit
# Secrets & credentials
.env
authelia/secrets/JWT_SECRET
authelia/secrets/SESSION_SECRET
authelia/secrets/STORAGE_ENCRYPTION_KEY
authelia/secrets/SMTP_PASSWORD
# Authelia runtime state
authelia/users_database.yml
authelia/db.sqlite3
authelia/db.sqlite3-*
authelia/authelia.log
authelia/notifications/notification.txt
# Mosquitto runtime state
mosquitto/config/passwd
mosquitto/data/*
mosquitto/log/*
!mosquitto/data/.gitkeep
!mosquitto/log/.gitkeep
# fail2ban runtime state
fail2ban/data/db/
fail2ban/data/fail2ban.sqlite3
fail2ban/data/*.bak
fail2ban/data/jail.d/*.bak
fail2ban/data/filter.d/*.bak
# Frigate runtime state
frigate_config/frigate.db*
frigate_config/model_cache/
frigate_config/backup.db
frigate_config/backup_config.yaml
frigate_config/*.bak
frigate_config/config.yaml-b4-*
frigate_config/*.16
frigate_config/*.old
# Frigate media (way too big for git; lives on a separate volume)
media/
frigate_media/
# Editor / OS junk
*.swp
@@ -25,3 +28,6 @@ fail2ban/data/filter.d/*.bak
.DS_Store
.vscode/
.idea/
# Archives
*.tar.gz
+207 -456
View File
@@ -1,522 +1,273 @@
# Authelia + fail2ban
# Home camera stack
Self-hosted authentication portal (Authelia) with an IP-banning sidecar
(fail2ban). Sits next to your dockerized Caddy and gates every public
subdomain behind a single sign-on portal at `auth.example.com`.
Frigate NVR + Mosquitto MQTT + frigate-notify -> ntfy push notifications,
with face recognition and license plate recognition on the Frigate side.
Includes a planned Pi Zero W "doorbell speaker" stack (push-to-talk web
page fronted by Caddy) that's wired but not yet deployed.
## Architecture
```
Internet
|
v
+-------+ caddy_net (docker) +--------------------+
| Caddy |--- forward_auth -------------->| Authelia |
+---+---+ | /api/authz/... |
| reverse_proxy +--------+-----------+
| |
v v
Frigate (LAN), NAS, Pi, etc. ./authelia/db.sqlite3
./authelia/authelia.log
^
| tail
+------+--------+
| fail2ban | host net
| DOCKER-USER | + iptables
+---------------+
Cameras (RTSP) Phone / browser
| |
v v
+------------+ MQTT events +----------------+ +-------------+
| Frigate | <-------------> | Mosquitto | | Pi |
| (NVR) | | broker | | (planned) |
+-----+------+ +-------+--------+ | speaker |
| WebRTC / MSE | +------+------+
v v ^
+------------+ +------------------+ |
| Caddy |<--HTTPS--------| frigate-notify | |
| proxy | | (event consumer) | |
+------------+ +--------+---------+ |
| | |
| cam.yourdomain.com ntfy push |
| doorbell.yourdomain.com |
+-------HTTPS---------------> PTT button ------------+
```
- One docker-compose file, two services, one external network (`caddy_net`).
- File-backed users database, SQLite storage, no Redis, no external DB.
- Filesystem notifier for password reset (swap to SMTP later, one block change).
- fail2ban bans via the `DOCKER-USER` iptables chain: drops happen at the host
edge before traffic reaches any docker-published port.
- Caddy is not in this stack. Copy `caddy/Caddyfile` into your Caddy setup.
## What's deployed vs planned
| Component | Status |
|---|---|
| Frigate 0.17 | Deployed |
| Mosquitto MQTT broker | Deployed |
| frigate-notify -> ntfy | Deployed |
| Face recognition | Configured, needs training |
| License plate recognition | Configured |
| Caddy reverse proxy | Whatever your existing Caddy does |
| Pi Zero W doorbell speaker | Planned, not deployed yet |
## Repo layout
```
authelia-stack/
├── docker-compose.yml
├── .env.example # copy to .env
├── .gitignore
├── README.md
├── authelia/
├── configuration.yml # main config -- edit your domain here
│ ├── users_database.yml.example # copy to users_database.yml (gitignored)
│ ├── secrets/ # gitignored; secret files mounted as /secrets
│ └── notifications/ # filesystem notifier writes here (gitignored)
├── fail2ban/
│ └── data/ # mounted as /data in the container
│ ├── filter.d/
│ │ ├── authelia.local # matches Authelia text-log auth failures
└── caddy-4xx.local # matches Caddy JSON 4xx responses
│ └── jail.d/
│ ├── authelia.local # 3 fails/10 min -> 1 hr IP ban
│ └── caddy.local # 30 fails/2 min -> 30 min IP ban
└── caddy/
└── Caddyfile # copy/merge into your Caddy setup
home-cameras/
|-- docker-compose.yml # frigate + mosquitto + frigate-notify
|-- .env.example # template -- copy to .env, fill in
|-- .gitignore
|-- README.md # this file
|
|-- frigate_config/
| |-- config.yml # production: main stream for detect
| `-- alternatives/
| `-- config-simple.yml # lower-CPU fallback: sub-stream detect
|
|-- frigate-notify/
| `-- config.yml # MQTT in, ntfy out, face-aware templates
|
|-- mosquitto/
| |-- config/
| | `-- mosquitto.conf # broker config (allow_anonymous false)
| |-- data/.gitkeep
| `-- log/.gitkeep
|
|-- caddy/
| `-- Caddyfile # reverse proxy for both subdomains
|
`-- pi/ # runs on the Pi, NOT on the Frigate host
|-- README.md # Pi-specific setup
|-- server.py # Flask + WebSocket PTT receiver
|-- doorbell.service # systemd unit
`-- install.sh # one-shot installer
```
## Prerequisites
## First-run on the Frigate host
- Docker + docker compose v2.
- Caddy already running, in Docker, joined to an external network named
`caddy_net`. If your network is named differently, replace `caddy_net`
everywhere in this repo.
- Caddy v2.5.1 or newer (for the `forward_auth` directive; tested on v2.11.2).
- A root domain you control. DNS A records for `auth.<root>` and every
protected subdomain must point at the Caddy host's public IP.
Prerequisites:
- Docker + docker compose v2
- DNS records for any subdomains you intend to use, pointing at your Caddy
host
- An existing Caddy instance (separate from this stack) handling TLS at the
edge, OR adapt for whatever reverse proxy you use
- Coral USB stick plugged in (or adjust `detectors:` for a different accel)
- A media disk mounted on the host; update the `/media/frigate` path in
`docker-compose.yml`
## Which sites go behind Authelia?
There are four ways a site can relate to Authelia. Pick one per site.
| Case | App has built-in auth? | Supports proxy auth? | What to do |
|------|------------------------|----------------------|------------|
| **1** | No | n/a | `import authelia` in Caddy + rule in Authelia. Authelia is the only login. |
| **2** | Yes | Yes | `import authelia` in Caddy + rule in Authelia + disable app's own login form. Single login. |
| **3** | Yes | No | `import authelia` in Caddy + rule in Authelia. App auth is unchanged. User logs into Authelia then the app. Two logins. |
| **4** | Yes | — | Plain `reverse_proxy`. No `import authelia`, no rule. App handles auth. |
Concretely:
- **`doorbell.example.com`** (Pi PTT page) -- **case 1**. No app auth at all.
Authelia is the only gate. Use `two_factor` -- this URL controls a speaker.
- **`cam.example.com`** (Frigate UI) -- **case 2**. Frigate 0.14+ supports
proxy auth. Disable Frigate's login form and let Authelia drive both the
access gate and the role mapping (admin vs. viewer) via headers.
- **Router admin / NAS UI** -- **case 3** if you want a 2FA gate in front,
**case 4** if you just leave it to the app.
Default policy in `configuration.yml` is `deny`, so a domain with no rule
AND no `import authelia` in Caddy never reaches Authelia at all.
### How to tell if an app supports proxy auth (case 2)
Look for any of these in the app's docs:
- "Remote-User header", "trusted upstream", "trusted proxies"
- "Header-based authentication", "SSO via reverse proxy"
- Support for `X-Forwarded-User`, `X-Remote-User`, or `Remote-User`
| App | Proxy auth? | Notes |
|-----|-------------|-------|
| Frigate 0.14+ | Yes | `auth.enabled: False` + `proxy:` block in config.yml |
| Grafana | Yes | `[auth.proxy]` section in grafana.ini |
| Gitea / Forgejo | Yes | `REVERSE_PROXY_AUTHENTICATION_USER` in app.ini |
| Nextcloud | Yes | `TRUSTED_PROXIES` env + `overwriteprotocol = https` |
| Home Assistant | Yes | `trusted_networks` auth provider + `use_x_forwarded_for` |
| Jellyfin | Partial | Community plugin required |
| Portainer | No | Use Authelia OIDC integration instead |
| Vaultwarden | No | Use Authelia OIDC integration instead |
| Router/NAS admin | Rarely | Use case 3 (2FA gate) or case 4 |
## Getting git and authenticating to GitHub
### Set your preferred editor first
The setup steps below use `$EDITOR` to open config files. It's a standard
shell variable that points to whichever editor you like. If it isn't set,
running `$EDITOR somefile` fails because the shell tries to execute an empty
string.
Check whether it's already set:
Steps:
```bash
echo $EDITOR # blank = not set
```
git clone https://github.com/YOU/home-cameras.git
cd home-cameras
Set it for the current session, or make it permanent by adding the export
to `~/.bashrc` (bash) or `~/.zshrc` (zsh):
```bash
# Pick one -- whatever you actually have installed:
export EDITOR=nano
export EDITOR=vim
export EDITOR=micro
export EDITOR="code --wait" # VS Code (the --wait keeps the terminal paused until you close the file)
# Make it permanent:
echo 'export EDITOR=nano' >> ~/.bashrc && source ~/.bashrc
# or for zsh:
echo 'export EDITOR=nano' >> ~/.zshrc && source ~/.zshrc
```
Once set, `$EDITOR somefile` opens that file in your chosen editor every time.
### Install git
If git isn't installed on the server yet:
```bash
# Debian / Ubuntu / Raspberry Pi OS
sudo apt update && sudo apt install -y git
# Fedora / RHEL / Rocky / AlmaLinux
sudo dnf install -y git
```
The easiest way to authenticate is the **GitHub CLI** (`gh`). Install it by
adding GitHub's official apt repository (their Linux equivalent of a Homebrew
tap), then run `gh auth login` to authenticate interactively:
```bash
# Add the GitHub CLI apt repository
sudo apt install -y curl
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \
https://cli.github.com/packages stable main" \
| sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update && sudo apt install -y gh
# Authenticate -- follow the prompts (browser or paste a token)
gh auth login
```
When prompted: choose **GitHub.com**, **HTTPS**, and **Login with a web
browser** (or paste a personal access token if the server has no browser).
Once done, `gh` passes credentials to `git` automatically -- no extra config
needed.
On macOS the whole thing is two lines:
```bash
brew install git gh
gh auth login
```
## First-run setup
```bash
# 0) Clone the auth stack onto the server.
# (The Frigate stack lives on the `main` branch and is cloned separately.)
gh repo clone outis1one/frigate_w_audio -- \
--branch authelia ~/docker/authelia
cd ~/docker/authelia
# 1) Create the external docker network (Caddy must also be on this).
docker network create caddy_net 2>/dev/null || true
# 2) Bootstrap the secrets directory.
mkdir -p authelia/secrets
openssl rand -hex 32 > authelia/secrets/JWT_SECRET
openssl rand -hex 32 > authelia/secrets/SESSION_SECRET
openssl rand -hex 32 > authelia/secrets/STORAGE_ENCRYPTION_KEY
chmod 600 authelia/secrets/*
# 3) Copy and edit .env.
# 1) Configure secrets
cp .env.example .env
$EDITOR .env # set TZ; pin AUTHELIA_VERSION if you want
$EDITOR .env
# 4) Edit authelia/configuration.yml.
# Replace every `example.com` with your real root domain.
# Look for the four CHANGE comments: totp.issuer, session.cookies[].domain,
# session.cookies[].authelia_url, session.cookies[].default_redirection_url.
# Also uncomment access_control.rules entries for the sites you want to gate.
$EDITOR authelia/configuration.yml
# 2) Make mosquitto dirs writable by the container's mosquitto user (UID 1883)
sudo chown -R 1883:1883 mosquitto/
# 5) Create your first user.
cp authelia/users_database.yml.example authelia/users_database.yml
$EDITOR authelia/users_database.yml # set username, email, displayname
# 3) Bootstrap mosquitto BEFORE applying the production config.
# The committed mosquitto.conf has allow_anonymous false + password_file,
# which means we need to create the password file first OR temporarily
# flip to allow_anonymous true to start.
#
# Easiest: temporarily edit mosquitto/config/mosquitto.conf:
# allow_anonymous false -> allow_anonymous true
# comment out: password_file /mosquitto/config/passwd
# Then start:
docker compose up -d mosquitto
docker compose logs mosquitto --tail 10 # expect "running"
# Generate the password hash:
docker compose run --rm authelia \
authelia crypto hash generate argon2 --password 'your-real-password'
# Paste the $argon2id$... output into the password: field.
# 4) Create the MQTT user (use the password from your .env)
docker compose exec mosquitto mosquitto_passwd -c -b \
/mosquitto/config/passwd frigate \
"$(grep ^FRIGATE_MQTT_PASSWORD .env | cut -d= -f2)"
# 6) Pre-create the Authelia log file.
# Docker creates a DIRECTORY at the bind-mount path if the file doesn't
# exist, which breaks fail2ban's mount. Create it as an empty file first.
touch authelia/authelia.log
sudo chown 1883:1883 mosquitto/config/passwd
sudo chmod 0640 mosquitto/config/passwd
# 7) Validate config before starting.
docker compose run --rm authelia \
authelia validate-config --config /config/configuration.yml
# Expect: "Configuration: validation complete" with no errors.
# 5) Restore mosquitto.conf to its committed state:
# allow_anonymous true -> allow_anonymous false
# uncomment: password_file /mosquitto/config/passwd
git checkout mosquitto/config/mosquitto.conf
docker compose restart mosquitto
# 8) Wire up Caddy (see "Wire Caddy into Authelia" below).
# 6) Verify auth works
sudo apt install -y mosquitto-clients
mosquitto_sub -h 127.0.0.1 -u frigate \
-P "$(grep ^FRIGATE_MQTT_PASSWORD .env | cut -d= -f2)" \
-t 'test/#' -v &
mosquitto_pub -h 127.0.0.1 -u frigate \
-P "$(grep ^FRIGATE_MQTT_PASSWORD .env | cut -d= -f2)" \
-t 'test/hello' -m 'ok'
# expect: test/hello ok
kill %1
# 9) Bring it up.
# 7) Bring up the rest
docker compose up -d
docker compose logs -f authelia # expect "Authelia is listening on ..."
docker compose logs -f fail2ban # expect "Jail authelia is now active"
docker compose logs -f
```
## Wire Caddy into Authelia
Healthy startup looks like:
- Frigate: `frigate.comms.mqtt INFO : MQTT connected`
- frigate-notify: `Successfully connected to http://frigate:5000` then
`Connected to MQTT.` then `Subscribed to MQTT topic: frigate/events`
- mosquitto: incoming client connections from both
Open `caddy/Caddyfile`. It defines:
## Caddy
- `(authelia)` -- reusable snippet: add `import authelia` to any site block.
- `(accesslog)` -- writes Caddy's JSON access log to `/var/log/caddy/access.log`
so fail2ban's `caddy-4xx` jail can watch it.
- `auth.example.com` -- the Authelia portal.
- Example site blocks for all four cases (cases 1-3 active, case 4 commented).
Copy the relevant blocks into your real Caddyfile, replace `example.com` with
your domain and `192.168.x.x` with real upstream IPs, then reload Caddy.
**Every** site block should have `import accesslog` -- even case 4 sites.
fail2ban's caddy-4xx jail watches the one log file and covers all your
subdomains automatically. Scanners spray everything, not just gated sites.
### Caddy access log path
fail2ban mounts `/var/log/caddy` from the host as read-only. Your Caddy
service must write to the same path. In your Caddy compose:
```yaml
services:
caddy:
volumes:
- /var/log/caddy:/var/log/caddy
```
Create the directory before starting:
On whichever host runs Caddy, copy `caddy/Caddyfile` (or merge the relevant
site blocks into your existing one), edit IPs and domains, then:
```bash
sudo mkdir -p /var/log/caddy
sudo chown caddy:caddy /var/log/caddy # adjust to your Caddy UID
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
## Switching Frigate to Authelia (case 2)
Until the Pi is deployed, comment out the `doorbell.yourdomain.com` block.
Edit `frigate_config/config.yml` in your Frigate stack:
## Train face recognition
```yaml
auth:
enabled: False
trusted_proxies:
- 172.18.0.0/16 # the caddy_net subnet -- find it with:
# docker network inspect caddy_net | jq '.[0].IPAM.Config'
1. Let Frigate run with normal foot traffic for a day. Face crops are
captured automatically and appear under **Face Library -> Train** in
the Frigate UI.
2. For each person, label 5-10 **diverse** clear crops -- different angles,
lighting, times of day. Diversity beats quantity; 30 near-identical
frames from one event hurts recognition.
3. Walk past the camera again. Events should now show the person's name as
the sub-label, and frigate-notify's ntfy push will say their name
instead of "person".
proxy:
header_map:
user: remote-user # matches `copy_headers Remote-User` in (authelia) snippet
role: remote-groups
default_role: viewer
separator: '|'
# Optional shared secret -- prevents LAN header spoofing.
# Generate: openssl rand -hex 32
# Set the same value as `header_up X-Proxy-Secret` in caddy/Caddyfile.
# auth_secret: 'your-32-byte-hex'
```
Training images live on the Frigate host at
`/media/frigate/clips/faces/<n>/`. Not version-controlled (privacy).
Then uncomment `cam.example.com` in `authelia/configuration.yml`, restart
both services:
## Switching config profiles
This repo ships with two Frigate configs:
- `frigate_config/config.yml` -- active. Main 2688x1520 stream for both
detect and record. Better face recognition at distance, higher CPU.
- `frigate_config/alternatives/config-simple.yml` -- lower-CPU fallback.
Sub-stream 640x480 for detect, main for record only.
To switch:
```bash
docker compose restart authelia
docker compose restart frigate # in your Frigate stack
cp frigate_config/config.yml frigate_config/config.yml.bak
cp frigate_config/alternatives/config-simple.yml frigate_config/config.yml
docker compose restart frigate
```
Verify: `https://cam.example.com` in a private window goes to Authelia and
back without a Frigate login screen.
## Adding a camera with a mic
## First login + TOTP enrollment
When adding a camera with a built-in microphone (e.g. an Anpviz with mic),
see the comment block at the bottom of `frigate_config/config.yml` for the
exact edits needed to enable live audio in the Frigate UI.
1. Visit a protected subdomain in a private browser window.
2. Caddy bounces you to `https://auth.example.com` -- log in with your
username and plaintext password.
3. If the rule is `two_factor`, Authelia prompts you to register a second
factor. Pick **TOTP** and scan the QR with your authenticator app.
4. Authelia writes a confirmation link to the filesystem notifier file:
```bash
docker compose exec authelia cat /config/notifications/notification.txt
```
Click the link to confirm TOTP registration.
5. Enter the TOTP code -- you're in. The `authelia_session` cookie is scoped
to your root domain and covers every protected subdomain automatically.
## Pi doorbell speaker (when you're ready)
## User management
### Add a user
Append to `authelia/users_database.yml`, generate a hash:
```bash
docker compose run --rm authelia \
authelia crypto hash generate argon2 --password 'new-password'
```
Paste the hash as `password:`. Restart or wait 5 minutes for auto-reload.
### Disable a user
Set `disabled: true` on their entry. Takes effect at next refresh.
### Reset TOTP (force re-enrollment)
```bash
docker compose exec authelia \
authelia storage user totp delete --username USERNAME \
--config /config/configuration.yml
```
## fail2ban
fail2ban does **not** need its own separate stack or compose file. It lives
alongside Authelia in this same `docker-compose.yml`. It uses host networking
(no docker network needed) and watches two log sources:
| Jail | Log | Trigger | Ban |
|------|-----|---------|-----|
| `authelia` | `./authelia/authelia.log` | 3 failed logins in 10 min | 1 hour |
| `caddy-4xx` | `/var/log/caddy/access.log` | 30 HTTP 4xx in 2 min | 30 min |
The **caddy-4xx jail covers every site** on your Caddyfile as long as each
block has `import accesslog`. You don't need per-site jails.
Defense in depth: Authelia's `regulation` block locks the *user account*
after 3 bad passwords. fail2ban bans the *source IP* independently.
### Verify jails are active
```bash
docker compose exec fail2ban fail2ban-client status
docker compose exec fail2ban fail2ban-client status authelia
docker compose exec fail2ban fail2ban-client status caddy-4xx
```
### Test filters against real logs
```bash
docker compose exec fail2ban fail2ban-regex \
/var/log/authelia/authelia.log \
/data/filter.d/authelia.local
docker compose exec fail2ban fail2ban-regex \
/var/log/caddy/access.log \
/data/filter.d/caddy-4xx.local
```
If nothing matches: confirm `log.format: 'text'` in `authelia/configuration.yml`
and `format json` in the `(accesslog)` snippet in your Caddyfile.
### Manually unban an IP
```bash
docker compose exec fail2ban fail2ban-client set authelia unbanip 1.2.3.4
docker compose exec fail2ban fail2ban-client set caddy-4xx unbanip 1.2.3.4
```
### Tune thresholds
Edit `fail2ban/data/jail.d/*.local`, then:
```bash
docker compose restart fail2ban
```
## Day-to-day
```bash
docker compose ps # services running?
docker compose logs -f authelia # follow Authelia
docker compose logs -f fail2ban # follow fail2ban
docker compose pull && docker compose up -d # upgrade images
```
Bump `AUTHELIA_VERSION` in `.env` when upgrading Authelia. After any
upgrade, re-run `validate-config` -- the schema evolves between releases.
## Switching the notifier to SMTP
Replace `notifier:` in `authelia/configuration.yml`:
```yaml
notifier:
disable_startup_check: false
smtp:
address: 'smtps://smtp.example.com:465'
username: 'authelia@example.com'
sender: 'Authelia <authelia@example.com>'
subject: '[Authelia] {title}'
# password loaded via AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE
```
Add the secret and wire it up:
```bash
echo 'your_smtp_password' > authelia/secrets/SMTP_PASSWORD
chmod 600 authelia/secrets/SMTP_PASSWORD
```
Add to the authelia service environment in `docker-compose.yml`:
```yaml
- AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/secrets/SMTP_PASSWORD
```
Restart and look for `"Notifier SMTP startup check successful"` in logs.
See `pi/README.md`. Summary:
1. Flash Pi OS Lite, scp `pi/` to the Pi, run `install.sh`.
2. Add the `doorbell.yourdomain.com` site block in Caddy.
3. Open the URL on your phone.
## Security notes
- `.env`, `authelia/secrets/*`, `authelia/users_database.yml`, and
`authelia/db.sqlite3*` are all gitignored. Run `git status` before every
commit to confirm nothing sensitive is staged.
- Authelia is not port-mapped to the host. Only containers on `caddy_net`
can reach it; only Caddy is configured to forward_auth there.
- TOTP secrets in the SQLite DB are encrypted at rest with
`STORAGE_ENCRYPTION_KEY`. Back up both the DB and the key file -- losing
either means every user must re-enroll TOTP.
- `regulation` is per-user account lockout; fail2ban is per-IP. Both are on.
- `.env` has RTSP credentials, MQTT password, ntfy URL. Never commit it.
This repo's `.gitignore` blocks it; review `git status` before committing.
- The Pi's Flask server binds only to `127.0.0.1`. Caddy is what exposes it.
Do NOT bind `server.py` to `0.0.0.0` -- it has no auth of its own.
- Mosquitto's port 1883 is LAN-only. Use a VPN for any remote MQTT clients.
- For the doorbell page, optionally add basic auth in Caddy (`caddy
hash-password`).
## Troubleshooting
### Redirect loop between a site and `auth.example.com`
Cookie domain mismatch. The `domain:` under `session.cookies[]` must be the
bare root domain (`example.com`), and every protected site must be a subdomain
of it served over HTTPS. Mixed HTTP/HTTPS won't work; the session cookie is
`Secure`.
### "access denied" with no login prompt
`default_policy: deny` and no `access_control` rule for this domain. Add a
rule in `authelia/configuration.yml` and restart Authelia.
### Authelia container restarts forever
### Frigate can't reach cameras
```bash
docker compose logs authelia | head -50
docker compose exec frigate ping -c 2 <camera_ip>
```
Most often: missing/empty secret files in `authelia/secrets/`, bad YAML in
`configuration.yml`, or an invalid argon2 hash in `users_database.yml`.
RTSP path varies by camera vendor:
- Dahua / Amcrest: `/cam/realmonitor?channel=1&subtype=0`
- Hikvision / Anpviz H-series: `/Streaming/Channels/101`
### Caddy can't resolve `authelia`
If your password contains `%`, `@`, `/`, `?`, `#`, `&`, or `+`, either
URL-encode it or change the password to avoid those characters.
Caddy isn't on `caddy_net`. Add `networks: [caddy_net]` to your Caddy
service and `caddy_net: external: true` at the bottom of its compose, then:
### Mosquitto restarts in a loop
Almost always permission on `mosquitto/config/passwd`:
```bash
docker compose up -d caddy
sudo chown -R 1883:1883 mosquitto/
sudo chmod 0640 mosquitto/config/passwd
docker compose restart mosquitto
```
### fail2ban bans don't block traffic
Or the config file or password file simply doesn't exist yet -- see the
"First-run on the Frigate host" section above for the bootstrap flow.
### frigate-notify connects to MQTT but doesn't send pings
fail2ban is writing to the wrong iptables chain. With dockerized Caddy you
need `chain = DOCKER-USER` (already set in the shipped jail files). Verify:
```bash
sudo iptables -L DOCKER-USER -n
# Should show f2b-* jump rules.
docker compose logs frigate-notify --tail 30
```
### fail2ban: authelia jail missing / "No such file" on authelia.log
- "webapi" in logs but expecting MQTT? Check `webapi.enabled: false` and
`mqtt.enabled: true` in `frigate-notify/config.yml`.
- MQTT auth fails? Verify `FN_FRIGATE__MQTT__PASSWORD` (note DOUBLE
underscores) matches what you set with `mosquitto_passwd`.
- Confirm what reached the container:
`docker inspect frigate-notify --format '{{range .Config.Env}}{{println .}}{{end}}' | grep FN_`
Docker created a directory at `./authelia/authelia.log` instead of a file
because the file didn't exist when the container started:
```bash
docker compose down fail2ban
rm -rf authelia/authelia.log # remove the directory Docker created
touch authelia/authelia.log # create as an empty file
docker compose up -d fail2ban
```
### Always see "person" instead of trained name
### Frigate still shows its own login after switching to proxy auth
- Increase `alerts.general.recheck_delay` in `frigate-notify/config.yml`
from 10 to 15 or 20 seconds.
- Check the Frigate UI event timeline -- if the event itself doesn't
show a sub_label, the face crop was too small / too blurry / too
obscured for recognition.
1. Confirm `auth.enabled: False` in `frigate_config/config.yml`.
2. Confirm `trusted_proxies` subnet matches your `caddy_net` subnet:
```bash
docker network inspect caddy_net | grep -A2 '"Config"'
```
3. Restart Frigate: `docker compose restart frigate` (in your Frigate stack).
## Hardware reference
Current:
- NVR host: x86_64 + Docker, USB Coral
- Cameras: Amcrest (Dahua RTSP)
- Notifier: self-hosted ntfy
Planned:
- Anpviz 4K camera with built-in mic (front door audio)
- Back door + squirrel feeder cameras
- Pi Zero W + USB speaker at the door
-168
View File
@@ -1,168 +0,0 @@
---
###############################################################################
# Authelia configuration
#
# - File-based user database (no LDAP)
# - SQLite local storage (no Redis, no MySQL/Postgres)
# - Filesystem notifier (writes "emails" to /config/notifications/...)
# - Argon2id password hashing
# - Per-domain access policies under access_control.rules
#
# Secrets are NOT in this file. They are loaded from files mounted at
# /secrets via the AUTHELIA_*_FILE env vars in docker-compose.yml.
#
# After editing, validate before restarting:
# docker compose run --rm authelia authelia validate-config --config /config/configuration.yml
###############################################################################
theme: 'dark'
server:
address: 'tcp://0.0.0.0:9091'
buffers:
read: 8192
write: 8192
log:
level: 'info'
format: 'text' # fail2ban filter expects text format -- don't change to json
file_path: '/config/authelia.log'
keep_stdout: true # also log to stdout for `docker logs`
identity_validation:
reset_password:
jwt_lifespan: '5 minutes'
jwt_algorithm: 'HS256'
# jwt_secret loaded via AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE
totp:
disable: false
issuer: 'example.com' # CHANGE: your root domain (shown in authenticator app)
algorithm: 'sha1'
digits: 6
period: 30
authentication_backend:
password_change:
disable: false
password_reset:
disable: false
refresh_interval: '5 minutes'
file:
path: '/config/users_database.yml'
password:
algorithm: 'argon2'
argon2:
variant: 'argon2id'
iterations: 3
memory: 65536
parallelism: 4
key_length: 32
salt_length: 16
# ---------------------------------------------------------------------------
# Access control
#
# default_policy: deny -- every domain Caddy forward_auths here must have
# an explicit rule. Domains with no `import authelia` in Caddy never reach
# Authelia at all, so the deny doesn't apply to them.
#
# Policies:
# bypass Authelia waves the request through (used for the portal)
# one_factor password only
# two_factor password + TOTP
#
# ---------------------------------------------------------------------------
# WHICH SITES NEED A RULE HERE?
#
# There are four ways a site can relate to Authelia:
#
# CASE 1 -- App has NO built-in auth (e.g. Pi doorbell PTT page).
# -> Rule required + `import authelia` in Caddy.
# -> Authelia is the ONLY login. Use two_factor for hardware-control pages.
#
# CASE 2 -- App has built-in auth AND supports trusted-header proxy auth
# (Frigate 0.14+, Grafana, Gitea, Nextcloud, Home Assistant ...).
# -> Rule required + `import authelia` in Caddy + disable the app's login
# form in its own config (see README.md per-app instructions).
# -> Single Authelia login covers both access AND the app's user/role
# mapping via Remote-User / Remote-Groups headers.
#
# CASE 3 -- App has built-in auth and CANNOT switch to proxy auth, but you
# still want a 2FA gate in front of it (extra security layer).
# -> Rule required + `import authelia` in Caddy.
# -> User logs into Authelia (2FA) THEN into the app's own login form.
# Two separate logins -- the app auth is unchanged.
# -> Useful for: router admin pages, NAS UIs, any legacy app.
#
# CASE 4 -- App handles its own auth and you don't want Authelia involved.
# -> NO rule here + NO `import authelia` in Caddy.
# -> Traffic skips Authelia entirely; the app handles everything.
# ---------------------------------------------------------------------------
access_control:
default_policy: 'deny'
rules:
# The Authelia portal itself is always bypass.
- domain: 'auth.example.com' # CHANGE
policy: 'bypass'
# -------------------------------------------------------------------
# CASE 1: No app auth -- Authelia is the only gate.
# The Pi doorbell PTT page has no built-in authentication.
# two_factor is appropriate -- this URL controls a speaker in your house.
# -------------------------------------------------------------------
# - domain: 'doorbell.example.com' # CHANGE
# policy: 'two_factor'
# -------------------------------------------------------------------
# CASE 2: App supports trusted-header proxy auth -- replace app login.
# Frigate 0.14+: set `auth.enabled: False` and configure `proxy:` in
# frigate_config/config.yml (see README.md "Switching Frigate to Authelia").
# Single login: Authelia authenticates, Frigate reads Remote-User/Groups.
# -------------------------------------------------------------------
# - domain: 'cam.example.com' # CHANGE
# policy: 'two_factor'
# -------------------------------------------------------------------
# CASE 3: App keeps its own auth; Authelia adds a 2FA gate in front.
# The app's login form is still shown after Authelia passes the request.
# User logs into Authelia (2FA) then into the app separately.
# -------------------------------------------------------------------
# - domain: 'nas.example.com' # CHANGE/REMOVE example
# policy: 'two_factor'
# CASE 4: No rule here, no `import authelia` in Caddy. App handles auth.
session:
# secret loaded via AUTHELIA_SESSION_SECRET_FILE
cookies:
- name: 'authelia_session'
domain: 'example.com' # CHANGE: your root domain
authelia_url: 'https://auth.example.com' # CHANGE
default_redirection_url: 'https://example.com' # CHANGE
expiration: '1 hour'
inactivity: '5 minutes'
remember_me: '1 month'
same_site: 'lax'
# In-app rate limiting. Locks the user account after repeated failures.
# fail2ban is the second line of defense: it bans the source IP.
# Together: Authelia locks the *user*, fail2ban bans the *IP*.
regulation:
max_retries: 3
find_time: '2 minutes'
ban_time: '5 minutes'
storage:
# encryption_key loaded via AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
local:
path: '/config/db.sqlite3'
# Filesystem notifier -- password reset / new device emails get written to
# a file you can `tail -f`. Swap to `smtp:` when you wire up a real
# transactional sender (see README.md "Switching the notifier to SMTP").
notifier:
disable_startup_check: false
filesystem:
filename: '/config/notifications/notification.txt'
-34
View File
@@ -1,34 +0,0 @@
---
###############################################################################
# Authelia users database
#
# Copy this to users_database.yml (gitignored) and edit. Generate the
# password hash with:
#
# docker compose run --rm authelia \
# authelia crypto hash generate argon2 --password 'your-plaintext-pass'
#
# Paste the resulting `$argon2id$v=19$m=...` string as the `password:` value.
# Restart Authelia for changes to take effect (or wait refresh_interval).
###############################################################################
users:
yourname:
disabled: false
displayname: 'Your Name'
password: '$argon2id$v=19$m=65536,t=3,p=4$REPLACE_WITH_GENERATED_HASH'
email: 'you@example.com'
groups:
- 'admins'
# Add more users here. `groups` are referenced from access_control rules
# via `subject: 'group:admins'`.
#
# guest:
# disabled: false
# displayname: 'Guest'
# password: '$argon2id$v=19$m=65536,t=3,p=4$...'
# email: 'guest@example.com'
# groups:
# - 'guests'
+34 -189
View File
@@ -1,203 +1,48 @@
# =============================================================================
# Caddyfile -- Authelia + fail2ban integration
# ---------------------------------------------------------------------------
# Caddyfile
#
# Copy this file into your Caddy setup (or merge the relevant blocks into
# your existing Caddyfile), edit all placeholders, then reload:
# Install at /etc/caddy/Caddyfile (or merge with your existing one), edit the
# placeholders below, then: sudo systemctl reload caddy
#
# # System Caddy:
# sudo caddy validate --config /etc/caddy/Caddyfile
# sudo systemctl reload caddy
#
# # Dockerized Caddy:
# docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
# Caddy auto-provisions Let's Encrypt certs for every site block. DNS for
# both subdomains must point at this Caddy host's public IP first.
#
# Placeholders to replace:
# example.com -> your real root domain
# 192.168.x.x -> real upstream LAN IPs
#
# Requirements:
# - Caddy v2.5.1+ (for `forward_auth` directive; tested on v2.11.2)
# - Caddy must be on the `caddy_net` Docker network so it can resolve
# `authelia` by container name. In your Caddy compose:
# networks: [caddy_net]
# and at the bottom:
# networks:
# caddy_net:
# external: true
#
# =============================================================================
# DECISION TREE -- which sites go behind Authelia?
#
# CASE 1 -- App has NO built-in auth (e.g. Pi doorbell PTT page).
# `import authelia` + rule in authelia/configuration.yml.
# Authelia is the ONLY login. Use two_factor for hardware-control pages.
#
# CASE 2 -- App has built-in auth AND supports trusted-header proxy auth
# (Frigate 0.14+, Grafana, Gitea, Nextcloud, Home Assistant, ...).
# `import authelia` + rule in Authelia + disable the app's own login form.
# Single Authelia login: Authelia authenticates, app reads Remote-User header.
#
# CASE 3 -- App has built-in auth and CANNOT switch to proxy auth, but you
# want a 2FA gate in front anyway (router admin, legacy apps, etc.).
# `import authelia` + rule in Authelia. App auth is untouched.
# User logs into Authelia (2FA), then the app's own login form appears.
#
# CASE 4 -- App handles its own auth; Authelia not involved.
# Plain `reverse_proxy`, no `import authelia`, no Authelia rule.
# Traffic skips Authelia entirely.
#
# fail2ban coverage: import (accesslog) in EVERY site block -- gated or not.
# The caddy-4xx jail watches /var/log/caddy/access.log and bans scanners
# spraying all your subdomains, not just the Authelia-gated ones.
# =============================================================================
# yourdomain.com -> your real domain
# 192.168.1.50 -> Frigate host LAN IP
# 192.168.1.60 -> Pi Zero W LAN IP (when you set up the doorbell)
# ---------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# (authelia) -- forward_auth gate.
# Import into any site block you want gated (cases 1, 2, 3).
# On success Authelia sets Remote-User, Remote-Groups, Remote-Email,
# Remote-Name headers that the upstream app can consume for role mapping.
# -----------------------------------------------------------------------------
(authelia) {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
}
# ---------- Frigate authenticated UI ----------
cam.yourdomain.com {
encode zstd gzip
# -----------------------------------------------------------------------------
# (accesslog) -- structured JSON access log consumed by fail2ban's caddy-4xx
# jail. Import into EVERY site block so fail2ban covers your whole stack.
#
# Pre-create the log directory before starting Caddy:
# sudo mkdir -p /var/log/caddy
# sudo chown caddy:caddy /var/log/caddy # system Caddy
# # Dockerized Caddy: add volumes: ["/var/log/caddy:/var/log/caddy"] to compose
# -----------------------------------------------------------------------------
(accesslog) {
log {
output file /var/log/caddy/access.log {
roll_size 10MiB
roll_keep 5
roll_keep_for 720h
}
format json
}
}
# =============================================================================
# Authelia login portal
# Never add `import authelia` here -- the `bypass` rule in
# access_control.rules handles the portal itself. Adding forward_auth here
# would cause a redirect loop.
# =============================================================================
auth.example.com { # CHANGE
import accesslog
reverse_proxy authelia:9091
}
# =============================================================================
# CASE 1: Pi doorbell PTT page -- Authelia is the ONLY auth.
#
# The Pi's Flask server has no built-in authentication. Authelia gates it.
# two_factor is appropriate -- this URL controls a speaker in your house.
# Comment out until the Pi is deployed.
# Also add (or uncomment) the doorbell.example.com rule in configuration.yml.
# =============================================================================
# doorbell.example.com { # CHANGE
# import accesslog
# import authelia
#
# # Same-origin proxy to Frigate so WebRTC fetch works without CORS.
# handle_path /frigate/* {
# reverse_proxy 192.168.x.x:8971 {
# transport http {
# read_timeout 60s
# write_timeout 60s
# }
# }
# }
#
# handle {
# reverse_proxy 192.168.x.x:5555
# }
# }
# =============================================================================
# CASE 2: Frigate UI -- Authelia replaces Frigate's own login form.
#
# Frigate 0.14+ supports trusted-header proxy auth. Authelia authenticates
# the user (optionally with TOTP 2FA), then passes Remote-User and
# Remote-Groups headers to Frigate which maps them to admin/viewer roles.
#
# To enable proxy auth in Frigate, edit frigate_config/config.yml:
#
# auth:
# enabled: False
# trusted_proxies:
# - 172.18.0.0/16 # caddy_net subnet; find it with:
# # docker network inspect caddy_net
# proxy:
# header_map:
# user: remote-user # matches copy_headers in (authelia) snippet
# role: remote-groups
# default_role: viewer
# separator: '|'
# # Optional shared secret -- prevents LAN header spoofing.
# # Generate: openssl rand -hex 32
# # Set the same value as header_up X-Proxy-Secret below.
# # auth_secret: 'your-32-byte-hex'
#
# Then uncomment the cam.example.com rule in authelia/configuration.yml
# and restart: docker compose restart authelia (in the authelia stack)
# docker compose restart frigate (in the camera stack)
# =============================================================================
cam.example.com { # CHANGE
import accesslog
import authelia
reverse_proxy 192.168.x.x:8971 { # CHANGE IP
reverse_proxy 192.168.1.50:8971 {
transport http {
read_timeout 60s
write_timeout 60s
}
# Uncomment if you set auth_secret: in Frigate's proxy: block.
# header_up X-Proxy-Secret "your-32-byte-hex-here"
}
}
# =============================================================================
# CASE 3: App keeps its own login; Authelia adds a 2FA gate in front.
#
# Use when an app can't do proxy auth but you still want 2FA before it.
# The user authenticates with Authelia (2FA), then the app's own login
# form appears. Two separate logins -- the app's auth is untouched.
#
# Also add a rule in authelia/configuration.yml:
# - domain: 'nas.example.com'
# policy: 'two_factor'
# =============================================================================
# nas.example.com { # CHANGE/REMOVE example
# import accesslog
# import authelia
#
# reverse_proxy 192.168.x.x:PORT { # CHANGE
# transport http {
# tls_insecure_skip_verify # only if self-signed TLS
# }
# }
# }
# ---------- Doorbell PTT page (Pi Zero W) ----------
# Comment out this whole block until the Pi is deployed.
doorbell.yourdomain.com {
encode zstd gzip
# =============================================================================
# CASE 4: App handles its own auth; Authelia not involved.
#
# No `import authelia`. No access_control rule in Authelia.
# Still import accesslog so fail2ban's caddy-4xx jail covers this site.
# =============================================================================
# router.example.com { # CHANGE/REMOVE example
# import accesslog
# reverse_proxy 192.168.x.x:PORT { # CHANGE
# transport http {
# tls_insecure_skip_verify
# }
# }
# }
# Same-origin proxy to Frigate so the browser's WebRTC fetch works
# without CORS issues. /frigate/* is stripped before forwarding.
handle_path /frigate/* {
reverse_proxy 192.168.1.50:8971 {
transport http {
read_timeout 60s
write_timeout 60s
}
}
}
# Everything else (HTML page + /audio WebSocket) goes to the Pi.
handle {
reverse_proxy 192.168.1.60:5555
}
}
+62 -71
View File
@@ -1,80 +1,71 @@
# ---------------------------------------------------------------------------
# Authelia + fail2ban
# Home camera stack
# - Frigate 0.17 (NVR + face recognition + LPR + audio detection)
# - Mosquitto (MQTT broker)
# - frigate-notify (event consumer -> ntfy push notifications)
#
# Self-hosted authentication portal (Authelia) plus an IP-banning sidecar
# (fail2ban). Sits next to your dockerized Caddy on the main server and
# joins the same external `caddy_net` so Caddy reaches Authelia by
# container name (`authelia:9091`). Authelia is NOT port-mapped to the
# host -- there is no reason for anything outside the docker network to
# hit it directly.
#
# fail2ban runs in host network mode so its iptables bans drop packets
# at the host edge, which is the only place the bans actually work for
# traffic destined for docker-published ports.
#
# First-run: see README.md.
# First-run setup: see README.md.
# ---------------------------------------------------------------------------
name: authelia
services:
authelia:
container_name: authelia
image: authelia/authelia:${AUTHELIA_VERSION:-4.39.19}
frigate:
container_name: frigate
image: ghcr.io/blakeblackshear/frigate:0.17.1
restart: unless-stopped
networks:
- caddy_net
expose:
- 9091
environment:
# Secrets are loaded from files mounted at /secrets (see volumes).
# The _FILE suffix is supported for any AUTHELIA_* env var.
- AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/secrets/JWT_SECRET
- AUTHELIA_SESSION_SECRET_FILE=/secrets/SESSION_SECRET
- AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/secrets/STORAGE_ENCRYPTION_KEY
- TZ=${TZ:-UTC}
volumes:
- ./authelia:/config
- ./authelia/secrets:/secrets:ro
healthcheck:
test: ['CMD', 'authelia', 'healthcheck']
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
fail2ban:
container_name: fail2ban
image: crazymax/fail2ban:${FAIL2BAN_VERSION:-1.1.0-r0}
restart: unless-stopped
# Host networking so iptables bans take effect on the host's edge,
# including DOCKER-USER chain rules that gate traffic to containers.
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
environment:
- TZ=${TZ:-UTC}
- F2B_LOG_LEVEL=INFO
- F2B_DB_PURGE_AGE=7d
volumes:
- ./fail2ban/data:/data
# Authelia text log -- fail2ban watches this for 1FA/TOTP failures.
# Authelia writes it to /config/authelia.log = ./authelia/authelia.log.
# IMPORTANT: `touch authelia/authelia.log` before first `docker compose up`
# so Docker creates it as a file, not a directory (see README.md).
- ./authelia/authelia.log:/var/log/authelia/authelia.log:ro
# Caddy JSON access log -- covers ALL sites that import (accesslog),
# not just Authelia-gated ones. Mount the host directory read-only.
# Your Caddy must write here; see README.md "Caddy access log path".
- /var/log/caddy:/var/log/caddy:ro
# Wait for Authelia to pass its healthcheck before starting, so the
# authelia.log file exists before fail2ban tries to bind-mount it.
stop_grace_period: 30s
privileged: true # needed for USB Coral
shm_size: "512mb"
env_file: .env
depends_on:
authelia:
condition: service_healthy
- mosquitto
devices:
- /dev/bus/usb:/dev/bus/usb # USB Coral
# - /dev/apex_0:/dev/apex_0 # PCIe Coral
# - /dev/dri/renderD128 # Intel/AMD hwaccel
volumes:
- /etc/localtime:/etc/localtime:ro
- ./frigate_config:/config
- /home/user/drives/sc-games/frigate:/media/frigate
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
ports:
- "8971:8971" # authenticated UI (proxied by Caddy)
- "5001:5000" # unauthenticated UI (LAN debug only)
- "8554:8554" # RTSP restream
- "8555:8555/tcp" # WebRTC TCP
- "8555:8555/udp" # WebRTC UDP
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
networks:
caddy_net:
external: true
mosquitto:
container_name: mosquitto
hostname: mosquitto
image: eclipse-mosquitto:2
restart: unless-stopped
ports:
- "1883:1883" # MQTT (LAN ONLY -- never expose to internet)
volumes:
- ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
frigate-notify:
container_name: frigate-notify
hostname: frigate-notify
image: ghcr.io/0x2142/frigate-notify:latest
restart: unless-stopped
env_file: .env
depends_on:
mosquitto:
condition: service_started
frigate:
condition: service_healthy
volumes:
- ./frigate-notify/config.yml:/app/config.yml:ro
-15
View File
@@ -1,15 +0,0 @@
# Matches Authelia's text-format log lines for failed authentication.
# Targets Authelia 4.38+. If you change `log.format` to `json` in
# authelia/configuration.yml, this regex needs updating.
#
# Test against a real log:
# docker compose exec fail2ban fail2ban-regex \
# /var/log/authelia/authelia.log \
# /data/filter.d/authelia.local
[Definition]
failregex = ^.*Unsuccessful (1FA|TOTP|Duo|U2F) authentication attempt by user.*remote_ip"?(:|=)"?<HOST>"?.*$
^.*user not found.*path=/api/reset-password/identity/start.*remote_ip"?(:|=)"?<HOST>"?.*$
ignoreregex =
-12
View File
@@ -1,12 +0,0 @@
# Bans IPs that spray 401/403/404/429 across many requests against Caddy.
# Targets Caddy's default JSON access log shape (one JSON object per line).
# Verify against a real log:
# docker compose exec fail2ban fail2ban-regex \
# /var/log/caddy/access.log \
# /data/filter.d/caddy-4xx.local
[Definition]
failregex = ^.*"remote_ip":"<HOST>".*"status":(401|403|404|429).*$
ignoreregex = ^.*"uri":"/(favicon\.ico|robots\.txt|apple-touch-icon[^"]*)".*$
-17
View File
@@ -1,17 +0,0 @@
[authelia]
enabled = true
filter = authelia
logpath = /var/log/authelia/authelia.log
maxretry = 3
findtime = 10m
bantime = 1h
# DOCKER-USER is the chain Docker inserts before its own per-container
# rules; banning here drops packets destined for docker-published ports
# (i.e. your Caddy container's 80/443) before iptables routes them in.
chain = DOCKER-USER
banaction = iptables-allports
# Tuple-form action so we record where it came from. `port=anyport` is
# fine because chain=DOCKER-USER drops at the chain head regardless.
action = iptables-allports[name=authelia, chain=DOCKER-USER]
-14
View File
@@ -1,14 +0,0 @@
[caddy-4xx]
enabled = true
filter = caddy-4xx
# Adjust if your Caddy writes elsewhere -- this must match the host path
# mounted into the fail2ban container in docker-compose.yml.
logpath = /var/log/caddy/access.log
maxretry = 30
findtime = 2m
bantime = 30m
chain = DOCKER-USER
banaction = iptables-allports
action = iptables-allports[name=caddy-4xx, chain=DOCKER-USER]
+153
View File
@@ -0,0 +1,153 @@
## frigate-notify config
## Docs: https://frigate-notify.0x2142.com
##
## Secrets and per-deployment values come from .env via the FN_* env vars.
## frigate-notify uses Viper-style env var lookup with DOUBLE underscores
## between YAML levels:
## frigate.server -> FN_FRIGATE__SERVER
## frigate.public_url -> FN_FRIGATE__PUBLIC_URL
## frigate.mqtt.password -> FN_FRIGATE__MQTT__PASSWORD
## alerts.ntfy.server -> FN_ALERTS__NTFY__SERVER
frigate:
server: # FN_FRIGATE__SERVER
ignoressl: true
public_url: # FN_FRIGATE__PUBLIC_URL
headers:
startup_check:
attempts: 5
interval: 30
webapi:
enabled: false # MQTT below is the primary event source
interval: 5
mqtt:
enabled: true
server: mosquitto # docker DNS name; constant for this stack
port: 1883
clientid: frigate-notify
username: frigate
password: # FN_FRIGATE__MQTT__PASSWORD
topic_prefix: frigate
cameras:
exclude:
alerts:
general:
# Title shows the recognized name (face recognition or LPR) when present,
# otherwise the generic label. Camera names are auto-titlecased by
# frigate-notify.
title: 'Frigate - {{ if .SubLabel }}{{ .SubLabel }}{{ else }}{{ .Label }}{{ end }} at {{ .Camera }}'
timeformat:
nosnap: allow
snap_bbox:
snap_timestamp:
snap_crop:
# Face recognition runs slightly AFTER the initial event. Without this
# delay, .SubLabel is empty and notifications always say "person".
recheck_delay: 10
quiet:
start:
end:
zones:
unzoned: allow
allow:
block:
labels:
min_score:
allow:
block:
sublabels:
# Once face recognition reliably IDs household members, list their names
# here to silence pings on them:
# block:
# - alex
# - bob
allow:
block:
discord:
enabled: false
webhook:
template:
gotify:
enabled: false
server:
token:
ignoressl:
template:
smtp:
enabled: false
server:
port:
tls:
user:
password:
recipient:
template:
telegram:
enabled: false
chatid:
token:
template:
pushover:
enabled: false
token:
userkey:
devices:
priority:
retry:
expire:
ttl:
template:
ntfy:
enabled: true
server: # FN_ALERTS__NTFY__SERVER
topic: "frigate"
ignoressl: false
# Priority + emoji change based on whether the person is recognized.
# Recognized -> priority 3 (normal) + wave; unknown -> priority 4 + alarm.
headers:
- X-Priority: '{{ if .SubLabel }}3{{ else }}4{{ end }}'
- X-Tags: '{{ if .SubLabel }}wave{{ else }}rotating_light{{ end }}'
template: |
{{ if .SubLabel -}}
{{ .SubLabel }} at {{ .Camera }}
{{- else -}}
{{ .Label }} at {{ .Camera }}
{{- end }}
{{ if gt (len .CurrentZones) 0 }}
Zone: {{ range $i, $z := .CurrentZones }}{{ if $i }}, {{ end }}{{ $z }}{{ end }}
{{- end }}
Score: {{ printf "%.0f" (mul .TopScore 100) }}%
Time: {{ .StartTime.Format "Mon 3:04 PM" }}
webhook:
enabled: false
server:
ignoressl:
headers:
template:
monitor:
enabled: false
url:
interval:
ignoressl:
@@ -0,0 +1,164 @@
##############################################################################
# Frigate 0.17 - SIMPLE (lower-CPU) variant
#
# Use if main config.yml uses too much CPU on your hardware.
#
# Differences vs main config.yml:
# - Sub-stream (640x480) used for detect; main stream for record only
# - Lower CPU: only the small sub-stream is decoded for detection
# - Face recognition still works for close-up faces; struggles at distance
# - LPR will rarely succeed (plate area too small in 640x480)
# - face_recognition.min_area lowered to 300 to catch smaller faces
#
# To activate:
# cp frigate_config/config.yml frigate_config/config.yml.bak
# cp frigate_config/alternatives/config-simple.yml frigate_config/config.yml
# docker compose restart frigate
##############################################################################
version: 0.17-0
mqtt:
enabled: true
host: mosquitto
port: 1883
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
topic_prefix: frigate
client_id: frigate
stats_interval: 60
tls:
enabled: false
audio:
enabled: false
detectors:
coral:
type: edgetpu
device: usb
birdseye:
mode: continuous
semantic_search:
enabled: false
model_size: small
face_recognition:
enabled: true
model_size: small
min_area: 300 # smaller default since faces will be small on sub-stream
lpr:
enabled: true
model_size: small
classification:
bird:
enabled: false
objects:
track:
- person
record:
enabled: true
continuous:
days: 0
motion:
days: 10
alerts:
retain:
days: 360
mode: motion
detections:
retain:
days: 360
mode: motion
snapshots:
enabled: true
bounding_box: true
crop: true
retain:
default: 360
go2rtc:
streams:
front_door:
- rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
front_door_sub:
- rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
back_door:
- rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
back_door_sub:
- rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
squirrel:
- rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
squirrel_sub:
- rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
cameras:
front_door:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/front_door
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/front_door_sub
input_args: preset-rtsp-restream
roles:
- detect
detect:
enabled: true
width: 640
height: 480
fps: 5
motion:
mask:
- 0.582,0.426,0.582,0.476,0.989,0.534,0.994,0.467
- 0.984,0.614,0.513,0.99,0.991,0.996
- 0.001,0.163,0.085,0.165,0.095,0.255,0.003,0.263
back_door:
enabled: false
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/back_door
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/back_door_sub
input_args: preset-rtsp-restream
roles:
- detect
detect:
enabled: true
width: 640
height: 480
fps: 5
squirrel:
enabled: false
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/squirrel
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/squirrel_sub
input_args: preset-rtsp-restream
roles:
- detect
detect:
enabled: true
width: 640
height: 480
fps: 5
+202
View File
@@ -0,0 +1,202 @@
##############################################################################
# Frigate 0.17 production config
#
# - Main stream (2688x1520) used for detect + record on each camera
# (better face crops at distance, higher CPU)
# - Face recognition + LPR enabled (small models, CPU-friendly)
# - MQTT enabled for frigate-notify push events
#
# An alternate lower-CPU config that uses the sub-stream for detect lives at
# frigate_config/alternatives/config-simple.yml. To swap:
# cp frigate_config/config.yml frigate_config/config.yml.bak
# cp frigate_config/alternatives/config-simple.yml frigate_config/config.yml
# docker compose restart frigate
#
# Validate before restart:
# docker run --rm \
# -v $(pwd)/frigate_config/config.yml:/config/config.yml \
# --entrypoint python3 \
# ghcr.io/blakeblackshear/frigate:0.17.1 \
# -u -m frigate --validate-config
##############################################################################
version: 0.17-0
mqtt:
enabled: true
host: mosquitto
port: 1883
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
topic_prefix: frigate
client_id: frigate
stats_interval: 60
tls:
enabled: false
audio:
enabled: false # flip on when a mic-equipped camera arrives
detectors:
coral:
type: edgetpu
device: usb
birdseye:
mode: continuous
semantic_search:
enabled: false
model_size: small
face_recognition:
enabled: true
model_size: small
lpr:
enabled: true
model_size: small
# known_plates:
# owner:
# - "ABC-1234"
classification:
bird:
enabled: false
objects:
track:
- person
# ---------- global record defaults (0.17 schema) ----------
record:
enabled: true
continuous:
days: 0
motion:
days: 10
alerts:
retain:
days: 360
mode: motion
detections:
retain:
days: 360
mode: motion
snapshots:
enabled: true
bounding_box: true
crop: true
retain:
default: 360
# ---------- go2rtc: restream from cameras ----------
go2rtc:
streams:
front_door:
- rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
front_door_sub:
- rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
back_door:
- rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
back_door_sub:
- rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
squirrel:
- rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
squirrel_sub:
- rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
# ---------- cameras ----------
cameras:
front_door:
enabled: true
ffmpeg:
inputs:
# Main stream used for both detect and record. Single connection,
# higher resolution -> better face recognition crops.
- path: rtsp://127.0.0.1:8554/front_door
input_args: preset-rtsp-restream
roles:
- detect
- record
detect:
enabled: true
width: 2688 # adjust if your main stream is different
height: 1520
fps: 5 # detection doesn't need high fps; saves CPU
motion:
mask:
- 0.582,0.426,0.582,0.476,0.989,0.534,0.994,0.467
- 0.984,0.614,0.513,0.99,0.991,0.996
- 0.001,0.163,0.085,0.165,0.095,0.255,0.003,0.263
back_door:
enabled: false
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/back_door
input_args: preset-rtsp-restream
roles:
- detect
- record
detect:
enabled: true
width: 2688
height: 1520
fps: 5
squirrel:
enabled: false
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/squirrel
input_args: preset-rtsp-restream
roles:
- detect
- record
detect:
enabled: true
width: 2688
height: 1520
fps: 5
##############################################################################
# HOW TO ADD A CAMERA WITH A MIC (e.g., future Anpviz)
#
# 1. Set audio.enabled: true at the top of this file.
#
# 2. In go2rtc.streams, add the ffmpeg audio transcode line so live view
# has both AAC (for MSE) and opus (for WebRTC):
# anpviz:
# - rtsp://{FRIGATE_RTSP_USER3}:{FRIGATE_RTSP_PASSWORD3}@{FRIGATE_ANPVIZ_IP}:554/...#backchannel=0
# - "ffmpeg:anpviz#audio=aac#audio=opus"
#
# 3. In cameras, add the 'audio' role and set the audio-aware record preset:
# anpviz:
# enabled: true
# ffmpeg:
# output_args:
# record: preset-record-generic-audio-aac
# inputs:
# - path: rtsp://127.0.0.1:8554/anpviz
# input_args: preset-rtsp-restream
# roles:
# - detect
# - record
# - audio
#
# 4. Add FRIGATE_RTSP_USER3 / _PASSWORD3 / FRIGATE_ANPVIZ_IP to .env.
#
# 5. RTSP paths vary by vendor:
# Anpviz H-series (Hikvision OEM): /Streaming/Channels/101 (main), /102 (sub)
# Anpviz U-series (Dahua OEM): /cam/realmonitor?channel=1&subtype=0 (main)
# /cam/realmonitor?channel=1&subtype=1 (sub)
##############################################################################
+17
View File
@@ -0,0 +1,17 @@
# Mosquitto MQTT broker
#
# This config assumes the password file exists at /mosquitto/config/passwd.
# On first deployment, see README "First-run on the Frigate host" for how
# to bootstrap that file.
listener 1883 0.0.0.0
protocol mqtt
persistence true
persistence_location /mosquitto/data/
log_dest stdout
log_dest file /mosquitto/log/mosquitto.log
allow_anonymous false
password_file /mosquitto/config/passwd
+126
View File
@@ -0,0 +1,126 @@
# Pi doorbell PTT
Turns a Raspberry Pi into a network speaker so a phone hitting
`https://doorbell.yourdomain.com` can see/hear the front-door Frigate feed
and hold a button to talk through a speaker mounted at the door.
## Hardware
- Any Raspberry Pi (Zero W 1st gen is enough; Zero 2 W is better for live
two-way; Pi 3A+ has a 3.5mm jack onboard and skips the OTG adapter)
- Audio output, one of:
- USB speaker + micro-USB-to-USB-A OTG adapter (simplest)
- 3.5mm powered speaker (Pi 3A+ has the jack; Zero W does not)
- I2S DAC HAT (best quality, requires GPIO header)
- microSD card, power supply, WiFi or USB ethernet
## Install on the Pi
```bash
# From your laptop/desktop:
scp -r pi/ pi@PI_LAN_IP:~/doorbell-src
# SSH to the Pi:
ssh pi@PI_LAN_IP
cd ~/doorbell-src
chmod +x install.sh
./install.sh
```
The installer apt-installs ffmpeg + alsa-utils + Python deps, creates a
virtualenv, drops `server.py` into `~/doorbell/`, installs and enables the
systemd service, runs `speaker-test` to confirm ALSA output works, and
starts the service.
## Verify
```bash
curl http://127.0.0.1:5555/healthz # -> ok
sudo journalctl -u doorbell -f # live logs
```
## Wire it up
1. On the Caddy host, add the `doorbell.yourdomain.com` block from
`../caddy/Caddyfile` and reload Caddy.
2. DNS: point `doorbell.yourdomain.com` at the Caddy host's public IP.
3. Open `https://doorbell.yourdomain.com` on an Android phone.
4. Grant the one-time microphone permission.
5. Tap **Unmute camera** if browser autoplay swallowed the audio.
6. Hold the big green button to talk.
Add to home screen (Chrome menu -> Add to home screen) for an app-like
experience.
## Choosing the camera
Each Pi is hardcoded to one camera -- the one whose mic and speaker are
physically co-located with this Pi. The PTT button on this page only
talks to *this* Pi's speaker, so mixing cameras here would let a misclick
talk into the wrong room.
`server.py` near the top:
```python
CAMERA_NAME = "front_door"
```
The name must match a `go2rtc.streams` entry in
`frigate_config/config.yml` and the camera must be `enabled: true` in
Frigate. After editing:
```bash
sudo systemctl restart doorbell
```
## Multiple doorbell Pis
Run one copy of this app per Pi, each on its own subdomain (e.g.
`frontdoor.yourdomain.com`, `backdoor.yourdomain.com`). Add a Caddy
block per subdomain pointing at that Pi's LAN IP -- same shape as the
existing `doorbell.yourdomain.com` block in `../caddy/Caddyfile`.
To render quick-jump buttons to the other Pis at the top of the page,
fill in `PEER_LINKS` near the top of `server.py`:
```python
PEER_LINKS = [
{"label": "Back door", "url": "https://backdoor.yourdomain.com"},
{"label": "Squirrel", "url": "https://squirrel.yourdomain.com"},
]
```
Leave it as `[]` (the default) and the row is hidden. Restart with
`sudo systemctl restart doorbell` after editing.
## Audio stack
ALSA-only -- no PipeWire/PulseAudio. Lighter on the Pi Zero. If you ever
need PipeWire (e.g., to share the speaker with another app), change
`'-f', 'alsa'` to `'-f', 'pulse'` in `server.py` and install the
PipeWire/Pulse compatibility shim.
## Troubleshooting
### speaker-test fails
USB/3.5mm output isn't the default ALSA card. Check:
```bash
aplay -l
```
If your speaker isn't card 0, create `/etc/asound.conf`:
```
defaults.pcm.card 1
defaults.ctl.card 1
```
(Replace `1` with whatever card your speaker is.)
### Video plays but talk button stuck on "Disconnected"
The WebSocket isn't reaching the Pi. Most common: Caddy not proxying
`doorbell.yourdomain.com` -> Pi correctly. From the Caddy host:
```bash
curl -i http://PI_LAN_IP:5555/healthz # should return 200 ok
```
### Feedback loop when talking
The page auto-mutes the camera while the PTT button is held, so this
should not happen. If it does, increase distance between Pi speaker and
camera mic, or turn the speaker volume down.
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Doorbell PTT server
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
User=pi
Group=audio
WorkingDirectory=/home/pi/doorbell
ExecStart=/home/pi/doorbell-venv/bin/python /home/pi/doorbell/server.py
Restart=always
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Pi Zero W setup for the doorbell PTT server.
# Run as the 'pi' user after flashing Raspberry Pi OS Lite (Bookworm).
#
# Usage:
# chmod +x install.sh
# ./install.sh
set -euo pipefail
echo ">>> Installing OS packages..."
sudo apt update
sudo apt install -y ffmpeg alsa-utils python3-venv python3-pip
echo ">>> Creating project dirs..."
mkdir -p "$HOME/doorbell"
echo ">>> Creating Python virtualenv..."
python3 -m venv "$HOME/doorbell-venv"
# shellcheck disable=SC1091
source "$HOME/doorbell-venv/bin/activate"
pip install --upgrade pip
pip install flask flask-sock
echo ">>> Copying server.py..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cp "$SCRIPT_DIR/server.py" "$HOME/doorbell/server.py"
echo ">>> Installing systemd service..."
sudo cp "$SCRIPT_DIR/doorbell.service" /etc/systemd/system/doorbell.service
sudo systemctl daemon-reload
sudo systemctl enable doorbell
echo ">>> Testing audio output..."
echo "You should hear 'front left' in a moment. Ctrl-C if nothing plays."
speaker-test -D default -c 2 -t wav -l 1 || {
echo "!! speaker-test failed. Fix ALSA output before starting the service."
echo " Try: sudo raspi-config -> System Options -> Audio"
echo " Or: aplay -l and edit /etc/asound.conf"
exit 1
}
echo ">>> Starting doorbell service..."
sudo systemctl restart doorbell
sleep 2
sudo systemctl status doorbell --no-pager
echo
echo "=========================================="
echo "Done. Quick checks:"
echo " curl http://127.0.0.1:5555/healthz"
echo " sudo journalctl -u doorbell -f"
echo "=========================================="
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""
Doorbell PTT server for Pi Zero W.
Serves a single-page web app that:
* shows the Frigate WebRTC live feed (video + camera mic if present)
* provides a push-to-talk button that streams phone mic audio over a
WebSocket; this script decodes and plays it out ALSA.
Deployment:
* listens on 127.0.0.1:5555; expose publicly via Caddy reverse proxy
* runs under systemd as the 'pi' user
* requires: python3-flask, flask-sock, ffmpeg, alsa-utils
"""
import subprocess
from flask import Flask, render_template_string
from flask_sock import Sock
app = Flask(__name__)
sock = Sock(app)
# Camera this Pi corresponds to. Must match a go2rtc stream name in
# frigate_config/config.yml. The PTT button talks to the speaker physically
# attached to this Pi, so this should be the camera at the same location.
CAMERA_NAME = "front_door"
# Optional jump-links to sibling doorbell Pis (each running its own copy of
# this app, hardcoded to its own camera). Rendered as a row of buttons above
# the PTT button when non-empty. Leave empty if there are no other Pis.
PEER_LINKS = [
# {"label": "Back door", "url": "https://backdoor.yourdomain.com"},
]
PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>Doorbell</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;padding:0;height:100%;background:#000;color:#fff;
font-family:system-ui,-apple-system,sans-serif;overflow:hidden;
touch-action:none;-webkit-user-select:none;user-select:none}
#wrap{display:flex;flex-direction:column;height:100vh;height:100dvh}
#video{flex:1;min-height:0;background:#000;position:relative}
video{width:100%;height:100%;object-fit:contain;background:#000}
#controls{padding:16px;display:flex;flex-direction:column;gap:10px;
background:#111;padding-bottom:max(16px,env(safe-area-inset-bottom))}
#ptt{font-size:24px;padding:28px;border:none;border-radius:14px;
background:#2d6a2d;color:#fff;font-weight:700;touch-action:none;
transition:background .05s,transform .05s}
#ptt.active{background:#d33;transform:scale(.98)}
#ptt:disabled{background:#333;color:#666}
.row{display:flex;gap:8px}
.row button{flex:1;padding:10px;background:#333;color:#fff;border:none;
border-radius:8px;font-size:13px}
.row a{flex:1;padding:10px;background:#333;color:#fff;border-radius:8px;
font-size:13px;text-decoration:none;text-align:center;
display:flex;align-items:center;justify-content:center}
#status{font-size:12px;color:#888;text-align:center;min-height:1em}
</style>
</head>
<body>
<div id="wrap">
<div id="video">
<video id="cam" autoplay playsinline muted></video>
</div>
<div id="controls">
<div class="row" id="peers" style="display:none"></div>
<button id="ptt" disabled>Connecting...</button>
<div class="row">
<button id="unmute">Unmute camera</button>
<button id="wake">Keep screen on</button>
<button id="reload">Reconnect</button>
</div>
<div id="status"></div>
</div>
</div>
<script>
// ---- CONFIG ---------------------------------------------------------
const CAMERA_NAME = {{ camera_name|tojson }};
const PEERS = {{ peers|tojson }};
const FRIGATE_WEBRTC_URL = "/frigate/api/go2rtc/api/webrtc?src=" + encodeURIComponent(CAMERA_NAME);
// --------------------------------------------------------------------
const $ = id => document.getElementById(id);
const ptt = $('ptt'), status = $('status'), video = $('cam'),
unmute = $('unmute'), reload = $('reload'), wake = $('wake'),
peersRow = $('peers');
let ws, mediaRecorder, micStream, wakeLock = null;
const log = m => { status.textContent = m; console.log('[doorbell]', m); };
if (PEERS.length) {
for (const p of PEERS) {
const a = document.createElement('a');
a.href = p.url;
a.textContent = p.label;
peersRow.appendChild(a);
}
peersRow.style.display = 'flex';
}
async function startVideo(){
try {
const pc = new RTCPeerConnection();
pc.addTransceiver('video', {direction:'recvonly'});
pc.addTransceiver('audio', {direction:'recvonly'});
pc.ontrack = e => { video.srcObject = e.streams[0]; };
pc.oniceconnectionstatechange = () => log('ICE: ' + pc.iceConnectionState);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(FRIGATE_WEBRTC_URL, {
method: 'POST',
headers: {'Content-Type': 'application/sdp'},
body: pc.localDescription.sdp,
credentials: 'include'
});
if(!resp.ok) throw new Error('Frigate returned ' + resp.status);
const answer = await resp.text();
await pc.setRemoteDescription({type:'answer', sdp: answer});
log('Camera connected');
} catch(e){ log('Video error: ' + e.message); }
}
unmute.onclick = () => {
video.muted = !video.muted;
unmute.textContent = video.muted ? 'Unmute camera' : 'Mute camera';
if(!video.muted) video.play().catch(()=>{});
};
reload.onclick = () => location.reload();
wake.onclick = async () => {
if(!('wakeLock' in navigator)){ log('Wake lock not supported'); return; }
if(wakeLock){
wakeLock.release(); wakeLock = null;
wake.textContent = 'Keep screen on';
} else {
try {
wakeLock = await navigator.wakeLock.request('screen');
wake.textContent = 'Screen locked on';
wakeLock.addEventListener('release', () => {
wake.textContent = 'Keep screen on'; wakeLock = null;
});
} catch(e){ log('Wake lock failed: ' + e.message); }
}
};
async function setupPTT(){
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: {echoCancellation: true, noiseSuppression: true, autoGainControl: true}
});
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/audio');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
ptt.disabled = false;
ptt.textContent = 'Hold to talk';
log('Ready');
};
ws.onclose = () => {
ptt.disabled = true;
ptt.textContent = 'Disconnected';
log('WebSocket closed -- tap Reconnect');
};
ws.onerror = () => log('WebSocket error');
} catch(e){ log('Mic permission error: ' + e.message); }
}
function startTalking(e){
if(!ws || ws.readyState !== WebSocket.OPEN) return;
e.preventDefault();
ptt.classList.add('active');
ptt.textContent = 'TALKING';
video.muted = true; // prevent feedback loop
mediaRecorder = new MediaRecorder(micStream, {mimeType:'audio/webm;codecs=opus'});
mediaRecorder.ondataavailable = ev => {
if(ev.data.size > 0 && ws.readyState === WebSocket.OPEN){
ev.data.arrayBuffer().then(buf => ws.send(buf));
}
};
mediaRecorder.start(100);
}
function stopTalking(e){
e && e.preventDefault();
if(mediaRecorder && mediaRecorder.state === 'recording'){
mediaRecorder.stop();
}
ptt.classList.remove('active');
ptt.textContent = 'Hold to talk';
video.muted = false;
video.play().catch(()=>{});
}
ptt.addEventListener('touchstart', startTalking, {passive:false});
ptt.addEventListener('touchend', stopTalking, {passive:false});
ptt.addEventListener('touchcancel', stopTalking, {passive:false});
ptt.addEventListener('mousedown', startTalking);
ptt.addEventListener('mouseup', stopTalking);
ptt.addEventListener('mouseleave', stopTalking);
startVideo();
setupPTT();
</script>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(PAGE, camera_name=CAMERA_NAME, peers=PEER_LINKS)
@app.route('/healthz')
def healthz():
return 'ok'
@sock.route('/audio')
def audio(ws):
ff = subprocess.Popen(
[
'ffmpeg',
'-loglevel', 'error',
'-f', 'webm', '-i', 'pipe:0',
'-f', 'alsa', 'default',
],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
while True:
data = ws.receive()
if data is None:
break
if isinstance(data, (bytes, bytearray)):
try:
ff.stdin.write(data)
ff.stdin.flush()
except BrokenPipeError:
break
finally:
try:
ff.stdin.close()
except Exception:
pass
try:
ff.terminate()
ff.wait(timeout=2)
except Exception:
ff.kill()
if __name__ == '__main__':
# 127.0.0.1 only -- Caddy reverse-proxies from the public domain
app.run(host='127.0.0.1', port=5555, threaded=True)