Compare commits
7
Commits
main
..
711f892ada
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
711f892ada | ||
|
|
bea9765caf | ||
|
|
9e7f1f395c | ||
|
|
a419c77a98 | ||
|
|
1b4c9298e1 | ||
|
|
3c2bb275ee | ||
|
|
ecbe1fc03d |
+11
-33
@@ -1,36 +1,14 @@
|
||||
# Copy to .env and fill in real values. DO NOT commit .env to git.
|
||||
# Copy to .env and fill in. DO NOT commit .env.
|
||||
#
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
# ---- Camera credentials ----
|
||||
FRIGATE_RTSP_USER=admin
|
||||
FRIGATE_RTSP_PASSWORD=pick_a_url_safe_password
|
||||
FRIGATE_FRONT_DOOR_IP=192.168.1.100
|
||||
# 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
|
||||
|
||||
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
|
||||
# Used by both containers for log timestamps. Set to your IANA zone.
|
||||
TZ=America/New_York
|
||||
|
||||
+17
-23
@@ -1,26 +1,23 @@
|
||||
# Secrets & credentials
|
||||
# Secrets — never commit
|
||||
.env
|
||||
authelia/secrets/JWT_SECRET
|
||||
authelia/secrets/SESSION_SECRET
|
||||
authelia/secrets/STORAGE_ENCRYPTION_KEY
|
||||
authelia/secrets/SMTP_PASSWORD
|
||||
|
||||
# Mosquitto runtime state
|
||||
mosquitto/config/passwd
|
||||
mosquitto/data/*
|
||||
mosquitto/log/*
|
||||
!mosquitto/data/.gitkeep
|
||||
!mosquitto/log/.gitkeep
|
||||
# Authelia runtime state
|
||||
authelia/users_database.yml
|
||||
authelia/db.sqlite3
|
||||
authelia/db.sqlite3-*
|
||||
authelia/authelia.log
|
||||
authelia/notifications/notification.txt
|
||||
|
||||
# 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/
|
||||
# fail2ban runtime state
|
||||
fail2ban/data/db/
|
||||
fail2ban/data/fail2ban.sqlite3
|
||||
fail2ban/data/*.bak
|
||||
fail2ban/data/jail.d/*.bak
|
||||
fail2ban/data/filter.d/*.bak
|
||||
|
||||
# Editor / OS junk
|
||||
*.swp
|
||||
@@ -28,6 +25,3 @@ frigate_media/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Archives
|
||||
*.tar.gz
|
||||
|
||||
@@ -1,273 +1,522 @@
|
||||
# Home camera stack
|
||||
# Authelia + fail2ban
|
||||
|
||||
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
|
||||
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`.
|
||||
|
||||
```
|
||||
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 ------------+
|
||||
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
|
||||
+---------------+
|
||||
```
|
||||
|
||||
## 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 |
|
||||
- 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.
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
## First-run on the Frigate host
|
||||
## Prerequisites
|
||||
|
||||
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`
|
||||
- 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.
|
||||
|
||||
Steps:
|
||||
## 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:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOU/home-cameras.git
|
||||
cd home-cameras
|
||||
echo $EDITOR # blank = not set
|
||||
```
|
||||
|
||||
# 1) Configure secrets
|
||||
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.
|
||||
cp .env.example .env
|
||||
$EDITOR .env
|
||||
$EDITOR .env # set TZ; pin AUTHELIA_VERSION if you want
|
||||
|
||||
# 2) Make mosquitto dirs writable by the container's mosquitto user (UID 1883)
|
||||
sudo chown -R 1883:1883 mosquitto/
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
# 5) Create your first user.
|
||||
cp authelia/users_database.yml.example authelia/users_database.yml
|
||||
$EDITOR authelia/users_database.yml # set username, email, displayname
|
||||
|
||||
# 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)"
|
||||
# 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.
|
||||
|
||||
sudo chown 1883:1883 mosquitto/config/passwd
|
||||
sudo chmod 0640 mosquitto/config/passwd
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# 7) Validate config before starting.
|
||||
docker compose run --rm authelia \
|
||||
authelia validate-config --config /config/configuration.yml
|
||||
# Expect: "Configuration: validation complete" with no errors.
|
||||
|
||||
# 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
|
||||
# 8) Wire up Caddy (see "Wire Caddy into Authelia" below).
|
||||
|
||||
# 7) Bring up the rest
|
||||
# 9) Bring it up.
|
||||
docker compose up -d
|
||||
docker compose logs -f
|
||||
docker compose logs -f authelia # expect "Authelia is listening on ..."
|
||||
docker compose logs -f fail2ban # expect "Jail authelia is now active"
|
||||
```
|
||||
|
||||
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
|
||||
## Wire Caddy into Authelia
|
||||
|
||||
## Caddy
|
||||
Open `caddy/Caddyfile`. It defines:
|
||||
|
||||
On whichever host runs Caddy, copy `caddy/Caddyfile` (or merge the relevant
|
||||
site blocks into your existing one), edit IPs and domains, then:
|
||||
- `(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:
|
||||
|
||||
```bash
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
sudo systemctl reload caddy
|
||||
sudo mkdir -p /var/log/caddy
|
||||
sudo chown caddy:caddy /var/log/caddy # adjust to your Caddy UID
|
||||
```
|
||||
|
||||
Until the Pi is deployed, comment out the `doorbell.yourdomain.com` block.
|
||||
## Switching Frigate to Authelia (case 2)
|
||||
|
||||
## Train face recognition
|
||||
Edit `frigate_config/config.yml` in your Frigate stack:
|
||||
|
||||
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".
|
||||
```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'
|
||||
|
||||
Training images live on the Frigate host at
|
||||
`/media/frigate/clips/faces/<n>/`. Not version-controlled (privacy).
|
||||
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'
|
||||
```
|
||||
|
||||
## 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:
|
||||
Then uncomment `cam.example.com` in `authelia/configuration.yml`, restart
|
||||
both services:
|
||||
|
||||
```bash
|
||||
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
|
||||
docker compose restart authelia
|
||||
docker compose restart frigate # in your Frigate stack
|
||||
```
|
||||
|
||||
## Adding a camera with a mic
|
||||
Verify: `https://cam.example.com` in a private window goes to Authelia and
|
||||
back without a Frigate login screen.
|
||||
|
||||
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.
|
||||
## First login + TOTP enrollment
|
||||
|
||||
## Pi doorbell speaker (when you're ready)
|
||||
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.
|
||||
|
||||
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.
|
||||
## 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.
|
||||
|
||||
## Security notes
|
||||
|
||||
- `.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`).
|
||||
- `.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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Frigate can't reach cameras
|
||||
### 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
|
||||
|
||||
```bash
|
||||
docker compose exec frigate ping -c 2 <camera_ip>
|
||||
docker compose logs authelia | head -50
|
||||
```
|
||||
|
||||
RTSP path varies by camera vendor:
|
||||
- Dahua / Amcrest: `/cam/realmonitor?channel=1&subtype=0`
|
||||
- Hikvision / Anpviz H-series: `/Streaming/Channels/101`
|
||||
Most often: missing/empty secret files in `authelia/secrets/`, bad YAML in
|
||||
`configuration.yml`, or an invalid argon2 hash in `users_database.yml`.
|
||||
|
||||
If your password contains `%`, `@`, `/`, `?`, `#`, `&`, or `+`, either
|
||||
URL-encode it or change the password to avoid those characters.
|
||||
### Caddy can't resolve `authelia`
|
||||
|
||||
### Mosquitto restarts in a loop
|
||||
|
||||
Almost always permission on `mosquitto/config/passwd`:
|
||||
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:
|
||||
```bash
|
||||
sudo chown -R 1883:1883 mosquitto/
|
||||
sudo chmod 0640 mosquitto/config/passwd
|
||||
docker compose restart mosquitto
|
||||
docker compose up -d caddy
|
||||
```
|
||||
|
||||
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 bans don't block traffic
|
||||
|
||||
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
|
||||
docker compose logs frigate-notify --tail 30
|
||||
sudo iptables -L DOCKER-USER -n
|
||||
# Should show f2b-* jump rules.
|
||||
```
|
||||
|
||||
- "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_`
|
||||
### fail2ban: authelia jail missing / "No such file" on authelia.log
|
||||
|
||||
### Always see "person" instead of trained name
|
||||
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
|
||||
```
|
||||
|
||||
- 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.
|
||||
### Frigate still shows its own login after switching to proxy auth
|
||||
|
||||
## 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
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
###############################################################################
|
||||
# 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'
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
###############################################################################
|
||||
# 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'
|
||||
+189
-34
@@ -1,48 +1,203 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caddyfile
|
||||
# =============================================================================
|
||||
# Caddyfile -- Authelia + fail2ban integration
|
||||
#
|
||||
# Install at /etc/caddy/Caddyfile (or merge with your existing one), edit the
|
||||
# placeholders below, then: sudo systemctl reload caddy
|
||||
# Copy this file into your Caddy setup (or merge the relevant blocks into
|
||||
# your existing Caddyfile), edit all placeholders, then reload:
|
||||
#
|
||||
# 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.
|
||||
# # 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
|
||||
#
|
||||
# Placeholders to replace:
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# =============================================================================
|
||||
|
||||
# ---------- Frigate authenticated UI ----------
|
||||
cam.yourdomain.com {
|
||||
encode zstd gzip
|
||||
# -----------------------------------------------------------------------------
|
||||
# (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
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy 192.168.1.50:8971 {
|
||||
# -----------------------------------------------------------------------------
|
||||
# (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
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- Doorbell PTT page (Pi Zero W) ----------
|
||||
# Comment out this whole block until the Pi is deployed.
|
||||
doorbell.yourdomain.com {
|
||||
encode zstd gzip
|
||||
# =============================================================================
|
||||
# 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
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
# 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
|
||||
}
|
||||
}
|
||||
# =============================================================================
|
||||
# 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
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
+65
-56
@@ -1,71 +1,80 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Home camera stack
|
||||
# - Frigate 0.17 (NVR + face recognition + LPR + audio detection)
|
||||
# - Mosquitto (MQTT broker)
|
||||
# - frigate-notify (event consumer -> ntfy push notifications)
|
||||
# Authelia + fail2ban
|
||||
#
|
||||
# First-run setup: see README.md.
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
name: authelia
|
||||
|
||||
services:
|
||||
|
||||
frigate:
|
||||
container_name: frigate
|
||||
image: ghcr.io/blakeblackshear/frigate:0.17.1
|
||||
authelia:
|
||||
container_name: authelia
|
||||
image: authelia/authelia:${AUTHELIA_VERSION:-4.39.19}
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
privileged: true # needed for USB Coral
|
||||
shm_size: "512mb"
|
||||
env_file: .env
|
||||
depends_on:
|
||||
- mosquitto
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb # USB Coral
|
||||
# - /dev/apex_0:/dev/apex_0 # PCIe Coral
|
||||
# - /dev/dri/renderD128 # Intel/AMD hwaccel
|
||||
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:
|
||||
- /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
|
||||
- ./authelia:/config
|
||||
- ./authelia/secrets:/secrets:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"]
|
||||
interval: 10s
|
||||
test: ['CMD', 'authelia', 'healthcheck']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
mosquitto:
|
||||
container_name: mosquitto
|
||||
hostname: mosquitto
|
||||
image: eclipse-mosquitto:2
|
||||
fail2ban:
|
||||
container_name: fail2ban
|
||||
image: crazymax/fail2ban:${FAIL2BAN_VERSION:-1.1.0-r0}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1883:1883" # MQTT (LAN ONLY -- never expose to internet)
|
||||
# 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:
|
||||
- ./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
|
||||
- ./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.
|
||||
depends_on:
|
||||
mosquitto:
|
||||
condition: service_started
|
||||
frigate:
|
||||
authelia:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./frigate-notify/config.yml:/app/config.yml:ro
|
||||
|
||||
networks:
|
||||
caddy_net:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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 =
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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[^"]*)".*$
|
||||
@@ -0,0 +1,17 @@
|
||||
[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]
|
||||
@@ -0,0 +1,14 @@
|
||||
[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]
|
||||
@@ -1,153 +0,0 @@
|
||||
## 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:
|
||||
@@ -1,164 +0,0 @@
|
||||
##############################################################################
|
||||
# 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
|
||||
@@ -1,202 +0,0 @@
|
||||
##############################################################################
|
||||
# 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)
|
||||
##############################################################################
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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
@@ -1,126 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,19 +0,0 @@
|
||||
[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
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/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
@@ -1,269 +0,0 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user