Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d9c2921d | ||
|
|
e2b29d3af9 | ||
|
|
d0dab09e21 | ||
|
|
18d5a40d32 | ||
|
|
628d77563c | ||
|
|
62fb5c75f4 | ||
|
|
b797602cbc | ||
|
|
6252271816 | ||
|
|
6cf6a2aeb8 | ||
|
|
cd78a3560d | ||
|
|
711f892ada | ||
|
|
bea9765caf | ||
|
|
9e7f1f395c | ||
|
|
a419c77a98 | ||
|
|
1b4c9298e1 | ||
|
|
3c2bb275ee | ||
|
|
ecbe1fc03d |
+18
-32
@@ -1,36 +1,22 @@
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
# Your root domain. This single value flows into authelia/configuration.yml
|
||||
# (via Authelia's {{ env "DOMAIN" }} template substitution) and into
|
||||
# caddy/Caddyfile (via Caddy's {env.DOMAIN} substitution).
|
||||
# No manual find-and-replace needed -- just set this.
|
||||
# ---------------------------------------------------------------------------
|
||||
DOMAIN=example.com
|
||||
|
||||
FRIGATE_RTSP_USER1=admin
|
||||
FRIGATE_RTSP_PASSWORD1=changeme
|
||||
FRIGATE_BACK_DOOR_IP=192.168.1.101
|
||||
# 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_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
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
# HANDOFF -- script to add a site behind Authelia
|
||||
|
||||
This doc is for a fresh session where you'll write a script that
|
||||
automates the "add a new site behind Authelia" workflow currently done
|
||||
by hand. Everything below is the context you need to write it
|
||||
correctly.
|
||||
|
||||
## Goal
|
||||
|
||||
One command, e.g.
|
||||
|
||||
```
|
||||
./add-site --subdomain doorbell --upstream 192.168.1.60:5555 --case 1 --policy two_factor
|
||||
```
|
||||
|
||||
does all of:
|
||||
|
||||
1. Adds the matching rule to `authelia/configuration.yml` under
|
||||
`access_control.rules:`.
|
||||
2. Adds (or modifies) the matching site block in the user's Caddyfile.
|
||||
If a site block already exists with `basic_auth { ... }`, removes
|
||||
that and inserts `import authelia` instead.
|
||||
3. Validates Authelia config (`authelia validate-config`).
|
||||
4. Validates Caddy config (`caddy validate`).
|
||||
5. Restarts Authelia, then reloads Caddy. Order matters.
|
||||
6. Optionally `curl`-tests the new URL and reports.
|
||||
|
||||
Idempotent: re-running with the same args is a no-op.
|
||||
|
||||
## Stack context (what's already running)
|
||||
|
||||
- **Authelia 4.39.19**, file backend, SQLite local storage, in-memory
|
||||
sessions, filesystem notifier. Compose project lives at
|
||||
`~/docker/authelia/`. Container name `authelia`.
|
||||
- **fail2ban** as a sidecar in the same compose project. Watches
|
||||
`./authelia/authelia.log` and `/var/log/caddy/access.log`.
|
||||
- **Caddy** is in its own compose project at `~/docker/caddy/`
|
||||
(assumption -- script should accept the path as a parameter).
|
||||
Container name `caddy`. On the external docker network `caddy_net`.
|
||||
- Both Caddy and Authelia are on `caddy_net`. Caddy reaches Authelia
|
||||
as `authelia:9091`.
|
||||
- The portal is `auth.{DOMAIN}` with `policy: bypass`.
|
||||
- `default_policy: deny` -- every gated domain MUST have a rule.
|
||||
- DOMAIN substitution:
|
||||
- Authelia uses Go templates: `'{{ env "DOMAIN" }}'`. Requires
|
||||
`X_AUTHELIA_CONFIG_FILTERS=template` env var (already set in the
|
||||
docker-compose.yml).
|
||||
- Caddy uses `{env.DOMAIN}`. The Caddy compose passes `DOMAIN`
|
||||
through to the Caddy container.
|
||||
|
||||
## Files the script touches
|
||||
|
||||
| File | What lives there | Who edits |
|
||||
|------|------------------|-----------|
|
||||
| `~/docker/authelia/authelia/configuration.yml` | `access_control.rules:` list | the script |
|
||||
| `<user's Caddyfile>` (path: parameter) | site blocks | the script |
|
||||
| `~/docker/authelia/.env` | `DOMAIN=...`, `TZ=...`, version pins | read-only (script reads DOMAIN from here OR the environment) |
|
||||
|
||||
The script does NOT touch:
|
||||
- `authelia/users_database.yml` (user management is separate)
|
||||
- `authelia/secrets/*` (manual one-time bootstrap)
|
||||
- `frigate_config/config.yml` or any other app's own config
|
||||
(case 2a/2b require app-side edits the script can't safely automate
|
||||
-- it should print instructions instead)
|
||||
- DNS, TLS, anything outside the local Caddy + Authelia configs
|
||||
|
||||
## Manual workflow the script automates
|
||||
|
||||
For reference, here's what a human does today to add `foo.example.com`
|
||||
as a case-1 site:
|
||||
|
||||
```bash
|
||||
# 1. Edit authelia/configuration.yml -- add under access_control.rules:
|
||||
# - domain: 'foo.{{ env "DOMAIN" }}'
|
||||
# policy: 'two_factor'
|
||||
|
||||
# 2. Edit your real Caddyfile -- add a new block (or modify existing):
|
||||
# foo.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.1.60:5555
|
||||
# }
|
||||
|
||||
# 3. Validate before reloading
|
||||
docker compose -f ~/docker/authelia/docker-compose.yml run --rm authelia \
|
||||
authelia validate-config --config /config/configuration.yml
|
||||
|
||||
docker compose -f ~/docker/caddy/docker-compose.yml exec caddy \
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
# 4. Restart Authelia FIRST (so the rule is live before Caddy starts
|
||||
# forwarding to it -- otherwise default_policy: deny returns 403)
|
||||
docker compose -f ~/docker/authelia/docker-compose.yml restart authelia
|
||||
|
||||
# 5. Reload Caddy (zero-downtime)
|
||||
docker compose -f ~/docker/caddy/docker-compose.yml exec caddy \
|
||||
caddy reload --config /etc/caddy/Caddyfile
|
||||
|
||||
# 6. Test
|
||||
curl -sI -o /dev/null -w "%{http_code}\n" https://foo.example.com
|
||||
# expect 302 redirect to auth.example.com
|
||||
```
|
||||
|
||||
## Cases the script must handle
|
||||
|
||||
The full case taxonomy is in `authelia/configuration.yml` and
|
||||
`caddy/snippets.caddyfile`. Summary:
|
||||
|
||||
### Case 1 -- no app auth, Authelia is the only gate
|
||||
|
||||
**Authelia rule:**
|
||||
```yaml
|
||||
- domain: 'SUBDOMAIN.{{ env "DOMAIN" }}'
|
||||
policy: 'two_factor' # or one_factor
|
||||
```
|
||||
|
||||
**Caddy block:**
|
||||
```caddyfile
|
||||
SUBDOMAIN.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy UPSTREAM_IP:UPSTREAM_PORT
|
||||
}
|
||||
```
|
||||
|
||||
Most common case. The migration path from `basic_auth` -- the script
|
||||
should handle "block exists with basic_auth, swap it for import authelia".
|
||||
|
||||
### Case 2a -- app supports trusted-header proxy auth
|
||||
|
||||
Same Caddy block as case 1. Same Authelia rule. **Plus** an app-side
|
||||
config change the script CANNOT safely automate (each app is different:
|
||||
Frigate's `auth.enabled: False`, Grafana's `[auth.proxy]` section,
|
||||
Gitea's `ENABLE_REVERSE_PROXY_AUTHENTICATION`, etc.). Script should
|
||||
print app-specific instructions from a lookup table and require
|
||||
`--ack-app-config-done` to proceed.
|
||||
|
||||
### Case 2b -- app supports OIDC
|
||||
|
||||
Same Caddy block as case 1. Same Authelia rule. **Plus** an
|
||||
`identity_providers.oidc.clients[]` entry to add to
|
||||
`configuration.yml`. Each app needs its own `client_id`,
|
||||
`client_secret`, `redirect_uris`, etc.
|
||||
|
||||
This is more involved. **Suggested**: out of scope for v1 of the script;
|
||||
print a pointer to Authelia's OIDC docs and skip.
|
||||
|
||||
### Case 3 -- app keeps its own auth, Authelia adds 2FA in front
|
||||
|
||||
Caddy block and Authelia rule are identical to case 1. The user just
|
||||
keeps logging into the app after Authelia. The script doesn't need to
|
||||
distinguish case 1 from case 3 mechanically -- the only difference is
|
||||
the user's mental model.
|
||||
|
||||
### Case 4 -- no Authelia involvement
|
||||
|
||||
```caddyfile
|
||||
SUBDOMAIN.{env.DOMAIN} {
|
||||
import accesslog # NO import authelia
|
||||
reverse_proxy UPSTREAM_IP:UPSTREAM_PORT
|
||||
}
|
||||
```
|
||||
|
||||
**No Authelia rule.** The script's job is just the Caddy edit + reload.
|
||||
Useful for things like Plex/Emby where Authelia's redirect breaks
|
||||
native clients.
|
||||
|
||||
## Validation hooks the script must run
|
||||
|
||||
In order, before any reload:
|
||||
|
||||
1. **Authelia config:**
|
||||
```bash
|
||||
docker compose -f ~/docker/authelia/docker-compose.yml run --rm authelia \
|
||||
authelia validate-config --config /config/configuration.yml
|
||||
```
|
||||
Exit 0 = good. Anything else = abort, restore the file from backup.
|
||||
|
||||
2. **Caddy config:**
|
||||
```bash
|
||||
docker compose -f ~/docker/caddy/docker-compose.yml exec caddy \
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
```
|
||||
Exit 0 = good. Anything else = abort, restore Caddyfile from backup.
|
||||
|
||||
After reload:
|
||||
|
||||
3. **HTTP probe:**
|
||||
```bash
|
||||
curl -sI -o /dev/null -w "%{http_code}\n" https://SUBDOMAIN.DOMAIN
|
||||
```
|
||||
- Case 1 / 2a / 2b / 3: expect `302` (redirect to Authelia).
|
||||
- Case 4: expect `200` or whatever the upstream returns.
|
||||
- `403` means the Caddy block has `import authelia` but the Authelia
|
||||
rule isn't in place (or wasn't picked up). Most common script bug.
|
||||
|
||||
## Reload semantics
|
||||
|
||||
**Order**: Authelia restart, THEN Caddy reload. Reverse order is briefly
|
||||
broken: Caddy starts forwarding to Authelia for a domain Authelia
|
||||
doesn't yet have a rule for, and `default_policy: deny` returns 403 to
|
||||
the user.
|
||||
|
||||
**Authelia restart**: full container restart. ~3-5 second outage on
|
||||
auth.{DOMAIN}. Acceptable for household use; if you want zero-downtime
|
||||
later, Authelia supports config reload via SIGHUP -- not used here.
|
||||
|
||||
**Caddy reload**: `caddy reload` is genuinely zero-downtime. It loads
|
||||
the new config, validates, and atomically swaps. If validation fails
|
||||
the old config keeps running.
|
||||
|
||||
**Rollback**: if anything fails, the script should revert the file
|
||||
edits from a `.bak` it took before mutating. Easiest pattern:
|
||||
|
||||
```bash
|
||||
cp authelia/configuration.yml authelia/configuration.yml.bak
|
||||
cp $CADDYFILE_PATH $CADDYFILE_PATH.bak
|
||||
# ... edits ...
|
||||
# if any validate fails:
|
||||
mv authelia/configuration.yml.bak authelia/configuration.yml
|
||||
mv $CADDYFILE_PATH.bak $CADDYFILE_PATH
|
||||
```
|
||||
|
||||
## Idempotency
|
||||
|
||||
The script must detect and short-circuit when the desired end state
|
||||
already exists:
|
||||
|
||||
- **Authelia rule already present**: parse `access_control.rules:`, look
|
||||
for an entry with matching `domain:`. If found and policy matches, skip
|
||||
the YAML edit. If found and policy differs, prompt or fail (don't
|
||||
silently overwrite).
|
||||
- **Caddy block already present with `import authelia`**: parse the
|
||||
Caddyfile, look for `SUBDOMAIN.{env.DOMAIN} {` block. If found and
|
||||
it has `import authelia`, skip the Caddy edit.
|
||||
- **Caddy block exists with `basic_auth`**: this is the migration case.
|
||||
Remove the `basic_auth { ... }` lines, add `import authelia` if
|
||||
missing. Preserve everything else (transport, header_up, etc).
|
||||
- **Caddy block exists without auth at all**: just add `import authelia`.
|
||||
|
||||
After all checks: if neither file actually changed, skip both reloads
|
||||
(important -- restarting Authelia for a no-op kicks every active
|
||||
session).
|
||||
|
||||
## Failure modes the script must handle
|
||||
|
||||
- `DOMAIN` env var not set / `.env` not present -> abort early with
|
||||
clear error.
|
||||
- `caddy_net` docker network doesn't exist -> abort.
|
||||
- Authelia container not running -> can still edit config and validate;
|
||||
reload step needs to be skipped or attempted with helpful error.
|
||||
- Caddy container not running -> same.
|
||||
- Caddyfile path doesn't exist or isn't writable -> abort.
|
||||
- The Caddyfile doesn't have `(authelia)` and `(accesslog)` snippets
|
||||
defined -> the script could either inject them at the top of the file
|
||||
(fragile) or refuse and tell the user to run a one-time setup step
|
||||
first. **Recommended**: refuse, with a clear "run `./bootstrap-caddy`
|
||||
first" message.
|
||||
- Subdomain conflicts with an existing block that's NOT just a
|
||||
`basic_auth` migration target (e.g. an entirely different upstream)
|
||||
-> prompt, don't auto-overwrite.
|
||||
- A site block exists with `basic_auth` AND something else complicated
|
||||
(custom matchers, multiple `handle` blocks) -> migration is hard.
|
||||
Recommended: detect the simple case (single `basic_auth { ... }`
|
||||
inside the block) and refuse the complex case.
|
||||
- `validate-config` or `caddy validate` fails -> rollback both files,
|
||||
report the validator's stderr, exit non-zero.
|
||||
- HTTP probe fails post-reload -> log the symptom but don't auto-revert;
|
||||
user may have DNS not pointing yet, etc.
|
||||
|
||||
## YAML editing -- preserve comments
|
||||
|
||||
`authelia/configuration.yml` has substantial comments (the
|
||||
"ALSO PASTE INTO CADDYFILE" blocks, case explanations). A naive YAML
|
||||
round-trip will eat them. Use a comment-preserving library:
|
||||
|
||||
- **Python**: `ruamel.yaml` with `YAML(typ='rt')` (round-trip mode).
|
||||
- **Go**: `gopkg.in/yaml.v3` is comment-aware.
|
||||
- **`yq`** (the Go-based one from mikefarah): preserves comments
|
||||
reasonably well for simple ops. Adding a list item:
|
||||
```bash
|
||||
yq -i '.access_control.rules += [{"domain": "foo.{{ env \"DOMAIN\" }}", "policy": "two_factor"}]' \
|
||||
authelia/configuration.yml
|
||||
```
|
||||
Note the escaping pain with `{{ env "DOMAIN" }}`. Test before
|
||||
committing.
|
||||
|
||||
The script should anchor inserts at a stable location. The least-bad
|
||||
anchor is the END of `access_control.rules:` -- always append, never
|
||||
splice in the middle.
|
||||
|
||||
## Caddyfile editing -- there is no good parser
|
||||
|
||||
Caddyfile has its own grammar; standard YAML/JSON tools won't touch it.
|
||||
Options, in order of pragmatism:
|
||||
|
||||
1. **Text-based pattern matching** (recommended for v1). Anchors:
|
||||
- Find `^SUBDOMAIN\.\{env\.DOMAIN\} \{$` to detect existing block.
|
||||
- For the basic_auth migration: use a small state machine to find
|
||||
`basic_auth {` ... `}` inside the matched block and delete those
|
||||
lines, then ensure `import authelia` and `import accesslog` lines
|
||||
exist.
|
||||
- For new-block insertion: append at end of file, separated by a
|
||||
blank line.
|
||||
|
||||
2. **`caddy adapt`**: converts Caddyfile to JSON. You could edit the
|
||||
JSON, then... there's no Caddyfile emitter. Adapt is one-way. Skip.
|
||||
|
||||
3. **`caddy fmt`**: normalizes whitespace in a Caddyfile, doesn't
|
||||
semantically edit. Useful AFTER your edits to clean up.
|
||||
|
||||
The text-based approach is fragile for arbitrary Caddyfiles but
|
||||
predictable for the conventions this stack uses (one site block per
|
||||
subdomain, snippets imported at top, no exotic matchers in gated
|
||||
sites).
|
||||
|
||||
## Suggested architecture
|
||||
|
||||
```
|
||||
add-site
|
||||
├── lib/
|
||||
│ ├── env.sh # find DOMAIN, container names, Caddyfile path
|
||||
│ ├── yaml_edit.sh # ruamel.yaml or yq wrapper for access_control
|
||||
│ ├── caddyfile_edit.sh # awk/sed-based site-block patcher
|
||||
│ ├── validate.sh # authelia + caddy validators
|
||||
│ └── reload.sh # restart authelia + reload caddy in order
|
||||
├── cases/
|
||||
│ ├── case-1.sh # no-app-auth recipe
|
||||
│ ├── case-2a.md # printable app-side instructions table
|
||||
│ ├── case-2b.md # OIDC out-of-scope notice + pointer
|
||||
│ └── case-4.sh # no-Authelia recipe
|
||||
├── add-site # main entrypoint
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Or in Python with `ruamel.yaml` and a small Caddyfile patcher class.
|
||||
Either is fine; the bash version has fewer install steps for an
|
||||
end-user.
|
||||
|
||||
## Concrete test cases the script must pass
|
||||
|
||||
Use these as fixtures.
|
||||
|
||||
### Test 1: fresh case-1 add
|
||||
|
||||
Pre-state:
|
||||
- `authelia/configuration.yml` has only the `auth.{DOMAIN}` bypass rule.
|
||||
- Caddyfile has `(authelia)` and `(accesslog)` snippets defined, no
|
||||
`foo.{env.DOMAIN}` block.
|
||||
|
||||
Invocation:
|
||||
```
|
||||
./add-site --subdomain foo --upstream 192.168.1.60:5555 --case 1
|
||||
```
|
||||
|
||||
Post-state:
|
||||
- `access_control.rules:` has new entry for `foo.{{ env "DOMAIN" }}`.
|
||||
- Caddyfile has new `foo.{env.DOMAIN} { ... }` block with `import
|
||||
authelia` and `import accesslog`.
|
||||
- `validate-config` and `caddy validate` both pass.
|
||||
- `curl -sI https://foo.example.com` returns 302.
|
||||
|
||||
### Test 2: idempotent re-run
|
||||
|
||||
Run Test 1's invocation twice. Second run: no file edits, no reloads,
|
||||
exit 0 with "already configured" message.
|
||||
|
||||
### Test 3: basic_auth migration
|
||||
|
||||
Pre-state:
|
||||
- Caddyfile has `foo.{env.DOMAIN} { basic_auth { user $2a$... }
|
||||
reverse_proxy ... }`.
|
||||
|
||||
Invocation:
|
||||
```
|
||||
./add-site --subdomain foo --case 1 --migrate-basic-auth
|
||||
```
|
||||
|
||||
Post-state:
|
||||
- The `basic_auth { ... }` lines are gone.
|
||||
- `import authelia` and `import accesslog` are present.
|
||||
- `reverse_proxy` line is unchanged.
|
||||
- Authelia rule added.
|
||||
|
||||
### Test 4: validation failure rollback
|
||||
|
||||
Pre-state: introduce a typo by hand into the YAML insert template
|
||||
(e.g. `polciy:` instead of `policy:`). Simulate by mocking the
|
||||
template.
|
||||
|
||||
Expected: `validate-config` fails, both files restored from `.bak`,
|
||||
non-zero exit, no reload attempted.
|
||||
|
||||
### Test 5: case-4 (no Authelia)
|
||||
|
||||
Invocation:
|
||||
```
|
||||
./add-site --subdomain plex --upstream 192.168.1.5:32400 --case 4
|
||||
```
|
||||
|
||||
Post-state:
|
||||
- Caddyfile has new `plex.{env.DOMAIN}` block with `import accesslog`,
|
||||
NO `import authelia`.
|
||||
- `access_control.rules:` is UNCHANGED.
|
||||
- Caddy reloads, Authelia is NOT restarted.
|
||||
|
||||
## Out of scope (tell the user, don't try to automate)
|
||||
|
||||
- DNS A-record creation. Caddy will fail to issue a cert for a domain
|
||||
that doesn't resolve. Print a "make sure DNS is pointing at this
|
||||
host" reminder when the script starts.
|
||||
- TLS / Let's Encrypt failures. Caddy auto-provisions; if it fails,
|
||||
it's usually DNS or rate-limit. The script should not try to debug.
|
||||
- App-side proxy-auth config (case 2a). Each app is different. Print
|
||||
the lookup-table snippet from `caddy/snippets.caddyfile` for that
|
||||
app and require `--ack-app-config-done` before running.
|
||||
- OIDC client setup (case 2b). Big enough that it deserves its own
|
||||
tool. Out of scope.
|
||||
- User management (`users_database.yml`).
|
||||
- Secret rotation (`authelia/secrets/*`).
|
||||
- TOTP enrollment.
|
||||
|
||||
## Quick reference: existing files
|
||||
|
||||
If you want to read what's there to understand the conventions:
|
||||
|
||||
- `docker-compose.yml` -- the auth stack compose, including the
|
||||
`X_AUTHELIA_CONFIG_FILTERS=template` and `DOMAIN=${DOMAIN}` env vars.
|
||||
- `authelia/configuration.yml` -- reference for the rule format,
|
||||
comment style, and the "ALSO PASTE INTO CADDYFILE" blocks under
|
||||
each case in `access_control.rules:`.
|
||||
- `caddy/snippets.caddyfile` -- canonical examples of Caddy site
|
||||
blocks for every case, including app-specific notes the case-2a
|
||||
table can be extracted from.
|
||||
- `.env.example` -- the shape of `.env` (DOMAIN, TZ, version pins).
|
||||
- `README.md` -- the comprehensive bootstrap walkthrough; the
|
||||
"Adding a new protected site" section is what the script
|
||||
automates.
|
||||
|
||||
## When in doubt
|
||||
|
||||
- For YAML edits, dry-run with
|
||||
`docker compose run --rm authelia authelia config template --config
|
||||
/config/configuration.yml` -- prints the rendered config so you can
|
||||
see exactly what Authelia will see.
|
||||
- For Caddyfile edits, `caddy fmt --overwrite Caddyfile` normalizes
|
||||
whitespace and `caddy validate` catches syntax errors. Run BOTH
|
||||
before any reload.
|
||||
- The single most useful debug command for "why is my site getting
|
||||
403":
|
||||
```bash
|
||||
docker compose -f ~/docker/authelia/docker-compose.yml exec authelia \
|
||||
tail -f /config/authelia.log
|
||||
```
|
||||
Then hit the URL. The log line tells you exactly which rule (or
|
||||
default_policy) made the call.
|
||||
@@ -1,273 +1,770 @@
|
||||
# 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 blocks from `caddy/snippets.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/
|
||||
└── snippets.caddyfile # per-service snippets to add to your existing Caddyfile
|
||||
```
|
||||
|
||||
## 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. |
|
||||
| **2a** | Yes | Yes (Remote-User header) | `import authelia` + disable app's own login form. Single login via headers. |
|
||||
| **2b** | Yes | Yes (OIDC) | `import authelia` + configure Authelia as OIDC provider in the app. Single login via token exchange. |
|
||||
| **3** | Yes | No | `import authelia` in Caddy. 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 2a**. Frigate 0.14+ supports
|
||||
proxy auth via `Remote-User` header. Disable Frigate's login form and let
|
||||
Authelia drive both the access gate and the role mapping (admin vs. viewer).
|
||||
- **`books.example.com`** (Audiobookshelf) -- **case 2b**. App redirects to
|
||||
Authelia, Authelia issues a JWT token, app accepts it. No password set in
|
||||
the app itself.
|
||||
- **Router admin / NAS UI** -- **case 3** if you want a 2FA gate in front,
|
||||
**case 4** if you just leave it to the app.
|
||||
|
||||
### What is OIDC?
|
||||
|
||||
OpenID Connect (OIDC) is an identity protocol layered on top of OAuth 2.0.
|
||||
The short version: instead of an app checking your password itself, it
|
||||
redirects you to Authelia, Authelia authenticates you and issues a signed
|
||||
token (JWT), and the app trusts that token. The app never handles your
|
||||
password — it only ever sees the token.
|
||||
|
||||
Authelia becomes the **identity provider** (IdP). Apps like Audiobookshelf,
|
||||
Immich, Jellyfin, and Mealie become **relying parties** — they trust Authelia's
|
||||
tokens and use them to identify users.
|
||||
|
||||
The practical difference from proxy-header auth (case 2a):
|
||||
- **Headers**: Caddy adds `Remote-User` to every request and the app reads it.
|
||||
Works silently. Requires the app to support header-based auth.
|
||||
- **OIDC**: The browser does a full redirect dance (app → Authelia → app).
|
||||
Users see the Authelia login page. Requires the app to support OIDC/OAuth.
|
||||
|
||||
Both result in the same thing: one Authelia credential covers the app.
|
||||
|
||||
OIDC requires additional setup in `authelia/configuration.yml` —
|
||||
an `identity_providers.oidc` block with a client entry per app, each with
|
||||
its own `client_id` and `client_secret`. See Authelia's OIDC docs for the
|
||||
full config. The Caddy side is identical to case 2a: `import authelia`.
|
||||
|
||||
Default policy in `configuration.yml` is `deny`, so a domain with no rule
|
||||
AND no `import authelia` in Caddy never reaches Authelia at all.
|
||||
|
||||
### Should you use Authelia at all?
|
||||
|
||||
A password manager with per-service credentials and per-service TOTP is a
|
||||
solid security posture. Authelia improves on it in specific situations:
|
||||
|
||||
| Situation | Password manager alone | Authelia |
|
||||
|-----------|----------------------|----------|
|
||||
| App has **no auth at all** (Homer, Dozzle, doorbell page) | Can't help | Gates it with 2FA, zero app changes |
|
||||
| App has auth but **no native TOTP** (Uptime Kuma, Syncthing, phpIPAM) | Password-only | Adds 2FA in front for free |
|
||||
| Multiple people need access | Change credentials in N places | Disable one account in Authelia |
|
||||
| True SSO -- log in once, reach 10 services | Still authenticates 10× (autofilled) | One session covers all gated services |
|
||||
| Consistent rate-limiting / brute-force protection | Wildly varies per app | fail2ban + regulation applied uniformly |
|
||||
|
||||
**Where Authelia is marginal for a single user:**
|
||||
- All your services already have native TOTP support → Authelia adds mostly
|
||||
friction. You're right that 2FA doesn't require Authelia: Vaultwarden,
|
||||
Nextcloud, Grafana, and Gitea all support TOTP natively. If you've already
|
||||
set that up in a manager like Bitwarden, Authelia's 2FA argument is weaker.
|
||||
- The Authelia password itself becomes keys-to-the-kingdom for everything
|
||||
gated behind it, which is why the TOTP requirement on Authelia matters more
|
||||
than on any individual service.
|
||||
|
||||
**The real sweet spot:**
|
||||
- You have services with zero auth (case 1: Homer, Prometheus, NUT web UI,
|
||||
Gatus) -- something has to gate them.
|
||||
- You have services with auth but no TOTP -- Authelia gives them 2FA without
|
||||
touching the app at all.
|
||||
- You manage access for more than one person.
|
||||
|
||||
### 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 auth", "SSO via reverse proxy".
|
||||
|
||||
| App | Case | Notes |
|
||||
|-----|------|-------|
|
||||
| **Proxy-header auth (case 2a)** | | |
|
||||
| Frigate 0.14+ | 2a | `auth.enabled: False` + `proxy:` block in config.yml |
|
||||
| Grafana | 2a | `[auth.proxy]` in grafana.ini; `GF_AUTH_PROXY_ENABLED=true` |
|
||||
| Gitea / Forgejo | 2a | `ENABLE_REVERSE_PROXY_AUTHENTICATION=true` in app.ini |
|
||||
| Nextcloud | 2a | `trusted_proxies` + `user_external` app + HTTP header auth |
|
||||
| Paperless-ngx | 2a | `PAPERLESS_ENABLE_HTTP_REMOTE_USER=true` |
|
||||
| Miniflux | 2a | `AUTH_PROXY_HEADER=Remote-User` env var |
|
||||
| Home Assistant | 2a | `trusted_networks` auth provider + header forwarding |
|
||||
| BookStack | 2a | `AUTH_METHOD=http` + `HTTP_AUTH_HEADER=Remote-User` in .env |
|
||||
| **OIDC auth (case 2b)** | | |
|
||||
| Audiobookshelf | 2b | Native OIDC; configure in Settings > Authentication |
|
||||
| Jellyfin | 2b | Requires `Jellyfin.Plugin.SSO` from Plugin Catalogue |
|
||||
| Immich | 2b | No header auth; OIDC only. Admin > OAuth settings |
|
||||
| Mealie | 2b | OIDC supported; or use case 3 as a simpler gate |
|
||||
| Portainer | 2b | OIDC in Settings > Authentication; or case 3 as simple gate |
|
||||
| **No built-in auth (case 1)** | | |
|
||||
| Homer / Heimdall | 1 | No auth at all -- Authelia is the only gate |
|
||||
| Dozzle | 1 | No auth by default -- Authelia is the only gate |
|
||||
| Prometheus | 1 | No auth built in; always gate, metrics expose internals |
|
||||
| Alertmanager | 1 | No auth built in |
|
||||
| Gatus | 1 | Status page; optional built-in OIDC but simpler to gate here |
|
||||
| WatchYourLAN | 1 | Network ARP scanner, no built-in auth |
|
||||
| NUT web UI | 1 | NUT daemon has no web UI; frontend web apps vary -- most have no auth |
|
||||
| **App keeps own auth (case 3)** | | |
|
||||
| Uptime Kuma | 3 | No proxy auth, no native TOTP -- Authelia is the only way to add 2FA |
|
||||
| qBittorrent | 3 | Web UI has own auth; no proxy headers |
|
||||
| Plex | 4 ⚠ | Do NOT use `import authelia` -- native clients go through Caddy but cannot complete browser-redirect auth; use Case 4, Plex handles its own auth |
|
||||
| Emby | 4 ⚠ | Same as Plex |
|
||||
| Gotify | 3 | Notification server; own auth |
|
||||
| ntfy | 3 | Notification server; token-based auth |
|
||||
| wg-easy | 3 | WireGuard web UI; WireGuard clients bypass Caddy (UDP 51820) |
|
||||
| Umami | 3 | Analytics; own auth |
|
||||
| phpIPAM | 3 | IP address management; own auth |
|
||||
| Checkmk | 3 | Monitoring; own auth (LDAP in enterprise edition) |
|
||||
| Snipe-IT | 3 | Asset management; own auth |
|
||||
| Zammad | 3 | Help desk / ticketing; own auth |
|
||||
| Lubelog | 3 | Vehicle maintenance; own auth |
|
||||
| UniFi | 3 | Network controller; own auth |
|
||||
| MeshCentral | 3 | Remote management; own auth; OIDC in enterprise builds |
|
||||
| Vaultwarden | 3 or 4 | Strong native auth + TOTP; many skip Authelia here entirely |
|
||||
| Router / NAS admin | 3 or 4 | Depends on firmware; case 4 is usually fine |
|
||||
| **App handles own auth (case 4)** | | |
|
||||
| Syncthing | 4 | Decent native auth; proxy auth not supported |
|
||||
|
||||
## 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
|
||||
|
||||
### 0. Clone the repo
|
||||
|
||||
The Frigate stack lives on `main`. This auth stack is on the `authelia` branch -- clone it separately into its own directory.
|
||||
|
||||
```bash
|
||||
git clone -b authelia \
|
||||
https://github.com/outis1one/frigate_w_audio.git \
|
||||
~/docker/authelia
|
||||
```
|
||||
|
||||
```bash
|
||||
cd ~/docker/authelia
|
||||
```
|
||||
|
||||
### 1. Create the external Docker network
|
||||
|
||||
Caddy must join this same network so it can reach Authelia by container name. Skip if `caddy_net` already exists.
|
||||
|
||||
```bash
|
||||
docker network create caddy_net 2>/dev/null || true
|
||||
```
|
||||
|
||||
### 2. Generate secrets
|
||||
|
||||
Authelia loads these from files so they never appear in `docker inspect` or process listings.
|
||||
|
||||
```bash
|
||||
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. Set your domain
|
||||
|
||||
`DOMAIN` is the only value you set here. It flows into `authelia/configuration.yml` via Go template substitution and into your Caddyfile via `{env.DOMAIN}` -- no find-and-replace needed anywhere else.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```bash
|
||||
$EDITOR .env
|
||||
```
|
||||
|
||||
# 2) Make mosquitto dirs writable by the container's mosquitto user (UID 1883)
|
||||
sudo chown -R 1883:1883 mosquitto/
|
||||
Set `DOMAIN=yourdomain.com` and `TZ=Your/Timezone`. Save and close.
|
||||
|
||||
# 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"
|
||||
### 4. Add access control rules
|
||||
|
||||
# 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)"
|
||||
**This step and step 8 (Caddy wiring) must be done together for every site you want to gate. Both are required -- neither alone is enough.**
|
||||
|
||||
sudo chown 1883:1883 mosquitto/config/passwd
|
||||
sudo chmod 0640 mosquitto/config/passwd
|
||||
```bash
|
||||
$EDITOR authelia/configuration.yml
|
||||
```
|
||||
|
||||
# 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
|
||||
Scroll to `access_control.rules`. Uncomment the rule for each site you want to protect and choose a policy:
|
||||
|
||||
# 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
|
||||
```yaml
|
||||
- domain: 'cam.{{ env "DOMAIN" }}'
|
||||
policy: 'two_factor'
|
||||
```
|
||||
|
||||
# 7) Bring up the rest
|
||||
#### Why both sides are required
|
||||
|
||||
Caddy and Authelia each control one half of the gate:
|
||||
|
||||
| What you configure | What it does |
|
||||
|--------------------|-------------|
|
||||
| `import authelia` in a Caddy site block | Sends that site's requests to Authelia for a decision |
|
||||
| Rule in `access_control.rules` | Tells Authelia what decision to make |
|
||||
|
||||
The default policy is `deny`. If a request reaches Authelia with no matching rule, it gets a **403 Forbidden -- no login prompt, no redirect, just blocked**. This is true even for an already-logged-in user.
|
||||
|
||||
Miss either side and here is what happens:
|
||||
|
||||
| Caddy `import authelia` | Rule in `configuration.yml` | Result |
|
||||
|------------------------|----------------------------|--------|
|
||||
| Missing | Present | Site is open -- Authelia is never consulted |
|
||||
| Present | Missing | 403 Forbidden, no login prompt |
|
||||
| Both missing | | Site is open -- Authelia is never consulted |
|
||||
| Both present | | Works correctly |
|
||||
|
||||
#### Which policy to use
|
||||
|
||||
| Policy | Requires |
|
||||
|--------|---------|
|
||||
| `bypass` | Nothing -- Authelia waves the request through. Used for the portal itself only. |
|
||||
| `one_factor` | Password only |
|
||||
| `two_factor` | Password + TOTP. Use this for everything. |
|
||||
|
||||
### 5. Create your first user
|
||||
|
||||
```bash
|
||||
cp authelia/users_database.yml.example authelia/users_database.yml
|
||||
```
|
||||
|
||||
```bash
|
||||
$EDITOR authelia/users_database.yml
|
||||
```
|
||||
|
||||
Fill in `username`, `email`, and `displayname`. Then generate the password hash:
|
||||
|
||||
```bash
|
||||
docker compose run --rm authelia \
|
||||
authelia crypto hash generate argon2
|
||||
```
|
||||
|
||||
Authelia prompts for the password and a confirmation without echoing --
|
||||
the plaintext never hits your shell history or `ps aux`. Copy the
|
||||
`Digest: $argon2id$...` line from the output and paste the digest
|
||||
(everything from `$argon2id` onward) as the `password:` value in
|
||||
`users_database.yml`.
|
||||
|
||||
### 6. Pre-create the Authelia log file
|
||||
|
||||
Docker creates a **directory** at a bind-mount path if the source file does not exist yet. That breaks fail2ban's read-only mount. Create it as an empty file first:
|
||||
|
||||
```bash
|
||||
touch authelia/authelia.log
|
||||
```
|
||||
|
||||
### 7. Validate the config
|
||||
|
||||
```bash
|
||||
docker compose run --rm authelia \
|
||||
authelia validate-config --config /config/configuration.yml
|
||||
```
|
||||
|
||||
Expect: `Configuration: validation complete` with no errors. Fix any YAML issues before continuing.
|
||||
|
||||
### 8. Wire Caddy
|
||||
|
||||
See [Wire Caddy into Authelia](#wire-caddy-into-authelia) below. Add the Caddy site block for each site alongside the rule you added in step 4.
|
||||
|
||||
### 9. Bring it up
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
## Caddy
|
||||
|
||||
On whichever host runs Caddy, copy `caddy/Caddyfile` (or merge the relevant
|
||||
site blocks into your existing one), edit IPs and domains, then:
|
||||
Confirm both services started cleanly:
|
||||
|
||||
```bash
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
sudo systemctl reload caddy
|
||||
docker compose logs -f authelia
|
||||
```
|
||||
|
||||
Until the Pi is deployed, comment out the `doorbell.yourdomain.com` block.
|
||||
|
||||
## Train face recognition
|
||||
|
||||
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".
|
||||
|
||||
Training images live on the Frigate host at
|
||||
`/media/frigate/clips/faces/<n>/`. Not version-controlled (privacy).
|
||||
|
||||
## 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:
|
||||
Expect: `Authelia is listening on ...`
|
||||
|
||||
```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 logs -f fail2ban
|
||||
```
|
||||
|
||||
## Adding a camera with a mic
|
||||
Expect: `Jail authelia is now active`
|
||||
|
||||
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.
|
||||
## Wire Caddy into Authelia
|
||||
|
||||
## Pi doorbell speaker (when you're ready)
|
||||
Open `caddy/snippets.caddyfile`. It contains copy-paste blocks for your
|
||||
existing Caddyfile, not a replacement for it:
|
||||
|
||||
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.
|
||||
- `(authelia)` and `(accesslog)` snippet definitions -- paste once near the
|
||||
top of your Caddyfile.
|
||||
- `auth.{env.DOMAIN}` -- the Authelia portal block.
|
||||
- Per-service examples for all four cases (Frigate, Grafana, Gitea,
|
||||
Uptime Kuma, Homer, etc.) with the required per-app config notes inline.
|
||||
|
||||
Copy the blocks you need into your real Caddyfile and replace `192.168.x.x`
|
||||
with real upstream IPs. The domain is already templated as `{env.DOMAIN}` --
|
||||
just make sure the `DOMAIN` environment variable is available to Caddy:
|
||||
|
||||
```bash
|
||||
# Dockerized Caddy -- add to its .env or compose environment:
|
||||
DOMAIN=yourdomain.com
|
||||
|
||||
# System Caddy -- add to /etc/caddy/caddy.env (or wherever systemd reads env):
|
||||
echo 'DOMAIN=yourdomain.com' | sudo tee -a /etc/caddy/caddy.env
|
||||
# Then make sure the systemd unit loads it:
|
||||
# EnvironmentFile=/etc/caddy/caddy.env (in the [Service] section)
|
||||
sudo systemctl daemon-reload && sudo systemctl 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 mkdir -p /var/log/caddy
|
||||
sudo chown caddy:caddy /var/log/caddy # adjust to your Caddy UID
|
||||
```
|
||||
|
||||
## Switching Frigate to Authelia (case 2)
|
||||
|
||||
Edit `frigate_config/config.yml` in your Frigate stack:
|
||||
|
||||
First, find your `caddy_net` subnet -- you need this for `trusted_proxies`:
|
||||
|
||||
```bash
|
||||
docker network inspect caddy_net | jq '.[0].IPAM.Config'
|
||||
```
|
||||
|
||||
Then edit `frigate_config/config.yml`:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
enabled: False
|
||||
trusted_proxies:
|
||||
- 172.18.0.0/16 # replace with your caddy_net subnet from above
|
||||
|
||||
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.
|
||||
# auth_secret: 'your-32-byte-hex'
|
||||
```
|
||||
|
||||
To generate the optional `auth_secret`:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Set the same value in the `header_up X-Proxy-Secret` line in your Caddy site block.
|
||||
|
||||
Add the rule to `authelia/configuration.yml` (step 4 of first-run), then restart both services:
|
||||
|
||||
```bash
|
||||
docker compose restart authelia
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose restart frigate # in your Frigate stack
|
||||
```
|
||||
|
||||
Verify in a private browser window: `https://cam.yourdomain.com` should go to Authelia and back without a Frigate login screen.
|
||||
|
||||
## First login + TOTP enrollment
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
```
|
||||
Authelia prompts for the password (no echo, not in shell history). Paste
|
||||
the printed digest 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 file:
|
||||
|
||||
```bash
|
||||
echo 'your_smtp_password' > authelia/secrets/SMTP_PASSWORD
|
||||
chmod 600 authelia/secrets/SMTP_PASSWORD
|
||||
```
|
||||
|
||||
Add to the `authelia` service `environment:` block in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
- AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/secrets/SMTP_PASSWORD
|
||||
```
|
||||
|
||||
Restart and check for a successful startup message:
|
||||
|
||||
```bash
|
||||
docker compose restart authelia && docker compose logs -f authelia
|
||||
```
|
||||
|
||||
Expect: `Notifier SMTP startup check successful`
|
||||
|
||||
## 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`
|
||||
|
||||
```bash
|
||||
docker compose exec frigate ping -c 2 <camera_ip>
|
||||
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` -- a request reached Authelia with no matching rule for that domain. Add a rule under `access_control.rules` in `authelia/configuration.yml`:
|
||||
|
||||
```yaml
|
||||
- domain: 'yoursite.{{ env "DOMAIN" }}'
|
||||
policy: 'two_factor'
|
||||
```
|
||||
|
||||
RTSP path varies by camera vendor:
|
||||
- Dahua / Amcrest: `/cam/realmonitor?channel=1&subtype=0`
|
||||
- Hikvision / Anpviz H-series: `/Streaming/Channels/101`
|
||||
|
||||
If your password contains `%`, `@`, `/`, `?`, `#`, `&`, or `+`, either
|
||||
URL-encode it or change the password to avoid those characters.
|
||||
|
||||
### Mosquitto restarts in a loop
|
||||
|
||||
Almost always permission on `mosquitto/config/passwd`:
|
||||
```bash
|
||||
sudo chown -R 1883:1883 mosquitto/
|
||||
sudo chmod 0640 mosquitto/config/passwd
|
||||
docker compose restart mosquitto
|
||||
```
|
||||
|
||||
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
|
||||
Then restart Authelia:
|
||||
|
||||
```bash
|
||||
docker compose logs frigate-notify --tail 30
|
||||
docker compose restart authelia
|
||||
```
|
||||
|
||||
- "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_`
|
||||
### Authelia container restarts forever
|
||||
|
||||
### Always see "person" instead of trained name
|
||||
```bash
|
||||
docker compose logs authelia | head -50
|
||||
```
|
||||
|
||||
- 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.
|
||||
Most often: missing/empty secret files in `authelia/secrets/`, bad YAML in
|
||||
`configuration.yml`, or an invalid argon2 hash in `users_database.yml`.
|
||||
|
||||
## Hardware reference
|
||||
### Caddy can't resolve `authelia`
|
||||
|
||||
Current:
|
||||
- NVR host: x86_64 + Docker, USB Coral
|
||||
- Cameras: Amcrest (Dahua RTSP)
|
||||
- Notifier: self-hosted ntfy
|
||||
Caddy isn't on `caddy_net`. Add this to your Caddy service in its compose file:
|
||||
|
||||
Planned:
|
||||
- Anpviz 4K camera with built-in mic (front door audio)
|
||||
- Back door + squirrel feeder cameras
|
||||
- Pi Zero W + USB speaker at the door
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
networks:
|
||||
- caddy_net
|
||||
|
||||
networks:
|
||||
caddy_net:
|
||||
external: true
|
||||
```
|
||||
|
||||
Then recreate the Caddy container:
|
||||
|
||||
```bash
|
||||
docker compose up -d caddy
|
||||
```
|
||||
|
||||
### 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
|
||||
sudo iptables -L DOCKER-USER -n
|
||||
# Should show f2b-* jump rules.
|
||||
```
|
||||
|
||||
### fail2ban: authelia jail missing / "No such file" on authelia.log
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Frigate still shows its own login after switching to proxy auth
|
||||
|
||||
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,241 @@
|
||||
---
|
||||
###############################################################################
|
||||
# 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.
|
||||
#
|
||||
# Your domain comes from the DOMAIN variable in .env -- no manual
|
||||
# find-and-replace needed. Authelia 4.38+ processes this file as a Go
|
||||
# template, so {{ env "DOMAIN" }} is substituted at startup.
|
||||
#
|
||||
# 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: '{{ env "DOMAIN" }}' # shown in your 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?
|
||||
#
|
||||
# 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.{{ env "DOMAIN" }}'
|
||||
policy: 'bypass'
|
||||
|
||||
# ===================================================================
|
||||
# CASE 1 -- No app auth. Authelia is the only gate.
|
||||
# Example: Pi doorbell PTT page (Flask server, no built-in auth).
|
||||
# two_factor is right -- this URL controls a speaker in your house.
|
||||
#
|
||||
# ALSO PASTE INTO CADDYFILE (case 1 site block):
|
||||
# doorbell.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# handle_path /frigate/* {
|
||||
# reverse_proxy 192.168.x.x:8971 # CHANGE: Frigate IP
|
||||
# }
|
||||
# handle {
|
||||
# reverse_proxy 192.168.x.x:5555 # CHANGE: Pi IP
|
||||
# }
|
||||
# }
|
||||
# ===================================================================
|
||||
# - domain: 'doorbell.{{ env "DOMAIN" }}'
|
||||
# policy: 'two_factor'
|
||||
|
||||
# ===================================================================
|
||||
# CASE 2a -- App supports trusted-header proxy auth.
|
||||
# Authelia replaces the app's login form. Single login; the app reads
|
||||
# Remote-User from the upstream request for its own role mapping.
|
||||
# Example: Frigate 0.14+.
|
||||
#
|
||||
# ALSO PASTE INTO CADDYFILE (case 2a site block):
|
||||
# cam.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:8971 { # CHANGE: Frigate IP
|
||||
# transport http { read_timeout 60s; write_timeout 60s }
|
||||
# # header_up X-Proxy-Secret "32-byte-hex" # if Frigate auth_secret set
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# ALSO EDIT frigate_config/config.yml IN THE FRIGATE REPO:
|
||||
# auth:
|
||||
# enabled: False
|
||||
# trusted_proxies: [172.18.0.0/16] # caddy_net subnet
|
||||
# proxy:
|
||||
# header_map: {user: remote-user, role: remote-groups}
|
||||
# default_role: viewer
|
||||
# separator: '|'
|
||||
# ===================================================================
|
||||
# - domain: 'cam.{{ env "DOMAIN" }}'
|
||||
# policy: 'two_factor'
|
||||
|
||||
# ===================================================================
|
||||
# CASE 2b -- App supports OIDC. Authelia is the OIDC provider.
|
||||
# Caddy block is identical to case 2a; the difference is on the app
|
||||
# side (token exchange, not header). REQUIRES additional setup of
|
||||
# identity_providers.oidc below this access_control block, with one
|
||||
# client per app -- see Authelia OIDC docs.
|
||||
# Example: Audiobookshelf.
|
||||
#
|
||||
# ALSO PASTE INTO CADDYFILE (case 2b site block):
|
||||
# books.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:13378 # CHANGE IP
|
||||
# }
|
||||
# ===================================================================
|
||||
# - domain: 'books.{{ env "DOMAIN" }}'
|
||||
# policy: 'two_factor'
|
||||
|
||||
# ===================================================================
|
||||
# CASE 3 -- App keeps its own login. Authelia adds a 2FA gate in
|
||||
# front. User authenticates with Authelia (2FA), then with the app
|
||||
# itself. Two logins, but Authelia's 2FA covers apps that don't
|
||||
# support proxy headers OR OIDC OR native TOTP.
|
||||
# Example: Uptime Kuma, Portainer (without OIDC), router admin.
|
||||
#
|
||||
# ALSO PASTE INTO CADDYFILE (case 3 site block):
|
||||
# uptime.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:3001 # CHANGE IP
|
||||
# }
|
||||
# ===================================================================
|
||||
# - domain: 'uptime.{{ env "DOMAIN" }}'
|
||||
# policy: 'two_factor'
|
||||
|
||||
# ===================================================================
|
||||
# CASE 4 -- App handles its own auth. NO rule needed here.
|
||||
# Authelia is never consulted. Caddy still imports accesslog so
|
||||
# fail2ban watches the subdomain for scanners.
|
||||
# Example: Plex/Emby (native clients break with Authelia redirects),
|
||||
# Syncthing, anything you've decided to leave alone.
|
||||
#
|
||||
# ONLY PASTE INTO CADDYFILE -- nothing here in configuration.yml:
|
||||
# plex.{env.DOMAIN} {
|
||||
# import accesslog # NO import authelia
|
||||
# reverse_proxy 192.168.x.x:32400 # CHANGE IP
|
||||
# }
|
||||
# ===================================================================
|
||||
# (no rule -- case 4 is the absence of one)
|
||||
|
||||
session:
|
||||
# secret loaded via AUTHELIA_SESSION_SECRET_FILE
|
||||
cookies:
|
||||
- name: 'authelia_session'
|
||||
domain: '{{ env "DOMAIN" }}'
|
||||
authelia_url: 'https://auth.{{ env "DOMAIN" }}'
|
||||
default_redirection_url: 'https://{{ env "DOMAIN" }}'
|
||||
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,38 @@
|
||||
---
|
||||
###############################################################################
|
||||
# Authelia users database
|
||||
#
|
||||
# Copy this to users_database.yml (gitignored) and edit. Generate each
|
||||
# password hash with:
|
||||
#
|
||||
# docker compose run --rm authelia \
|
||||
# authelia crypto hash generate argon2
|
||||
#
|
||||
# Authelia prompts for the password and a confirmation without echoing,
|
||||
# so the plaintext never hits shell history or `ps aux`. Output ends
|
||||
# with `Digest: $argon2id$v=19$m=...`. Paste the digest (everything from
|
||||
# `$argon2id` onward) as the `password:` value below.
|
||||
#
|
||||
# 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'
|
||||
@@ -1,48 +0,0 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caddyfile
|
||||
#
|
||||
# Install at /etc/caddy/Caddyfile (or merge with your existing one), edit the
|
||||
# placeholders below, then: sudo systemctl reload caddy
|
||||
#
|
||||
# Caddy auto-provisions Let's Encrypt certs for every site block. DNS for
|
||||
# both subdomains must point at this Caddy host's public IP first.
|
||||
#
|
||||
# Placeholders to replace:
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------- Frigate authenticated UI ----------
|
||||
cam.yourdomain.com {
|
||||
encode zstd gzip
|
||||
|
||||
reverse_proxy 192.168.1.50:8971 {
|
||||
transport http {
|
||||
read_timeout 60s
|
||||
write_timeout 60s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- Doorbell PTT page (Pi Zero W) ----------
|
||||
# Comment out this whole block until the Pi is deployed.
|
||||
doorbell.yourdomain.com {
|
||||
encode zstd gzip
|
||||
|
||||
# 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
# =============================================================================
|
||||
# Authelia + fail2ban -- Caddy snippets
|
||||
#
|
||||
# These are SNIPPETS TO ADD to your existing Caddyfile, not a replacement
|
||||
# for it. Copy the (authelia) and (accesslog) snippet definitions once at
|
||||
# the top of your Caddyfile, then copy whichever site blocks apply.
|
||||
#
|
||||
# DOMAIN is read from the environment -- set it wherever your Caddy reads
|
||||
# env vars (Caddy's own .env, systemd EnvironmentFile, or compose env:).
|
||||
# Only the upstream IPs need manual editing.
|
||||
#
|
||||
# Caddy v2.5.1+ required; tested on v2.11.2.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Paste these two snippet definitions once, near the top of your Caddyfile.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Gate any site block with Authelia by adding `import authelia` inside it.
|
||||
(authelia) {
|
||||
forward_auth authelia:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
}
|
||||
|
||||
# JSON access log that fail2ban's caddy-4xx jail reads.
|
||||
# Add `import accesslog` to EVERY site block (gated or not) so fail2ban
|
||||
# catches scanners hitting all your subdomains, not just the protected ones.
|
||||
(accesslog) {
|
||||
log {
|
||||
output file /var/log/caddy/access.log {
|
||||
roll_size 10MiB
|
||||
roll_keep 5
|
||||
roll_keep_for 720h
|
||||
}
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Authelia portal -- always required; never put `import authelia` here.
|
||||
# =============================================================================
|
||||
auth.{env.DOMAIN} {
|
||||
import accesslog
|
||||
reverse_proxy authelia:9091
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CASE 1 -- App has NO built-in auth. Authelia is the only gate.
|
||||
#
|
||||
# Examples: Homer, Heimdall, Dozzle, Prometheus, Alertmanager, Gatus,
|
||||
# WatchYourLAN, NUT web UI. Use two_factor in
|
||||
# authelia/configuration.yml for any of these.
|
||||
# =============================================================================
|
||||
|
||||
# Homer / Heimdall dashboard (no auth whatsoever)
|
||||
homer.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:8080 # CHANGE IP:PORT
|
||||
}
|
||||
|
||||
# Dozzle (Docker log viewer -- no auth by default)
|
||||
dozzle.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:8888 # CHANGE IP:PORT
|
||||
}
|
||||
|
||||
# --- Prometheus ---
|
||||
# No authentication built in. Always gate it -- metrics expose internal details.
|
||||
# prom.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:9090 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Alertmanager ---
|
||||
# No authentication built in.
|
||||
# alerts.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:9093 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Gatus ---
|
||||
# Status / uptime page. Has optional built-in OIDC but simpler to gate here.
|
||||
# status.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:8080 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- WatchYourLAN ---
|
||||
# Network ARP scanner. No built-in auth.
|
||||
# lan.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:8840 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- NUT (Network UPS Tools) web UI ---
|
||||
# The NUT daemon (upsd) has no web UI itself. Common frontends -- NUT-Monitor,
|
||||
# upsd-web, various Docker images -- have minimal or no auth. Gate whichever
|
||||
# you run here. Port varies by image.
|
||||
# ups.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:PORT # CHANGE IP:PORT
|
||||
# }
|
||||
|
||||
# --- Pi doorbell PTT page (Flask server, no auth) ---
|
||||
# Uncomment when the Pi is deployed.
|
||||
# doorbell.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
#
|
||||
# handle_path /frigate/* {
|
||||
# reverse_proxy 192.168.x.x:8971 # CHANGE: Frigate IP
|
||||
# }
|
||||
# handle {
|
||||
# reverse_proxy 192.168.x.x:5555 # CHANGE: Pi IP
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CASE 2a -- App supports trusted-header proxy auth. Authelia replaces its
|
||||
# own login form. Single login, app reads Remote-User for roles.
|
||||
#
|
||||
# Requires per-app config changes -- see notes in each block.
|
||||
# =============================================================================
|
||||
|
||||
# --- Frigate 0.14+ ---
|
||||
# In frigate_config/config.yml:
|
||||
# auth:
|
||||
# enabled: False
|
||||
# trusted_proxies: [172.18.0.0/16] # caddy_net subnet
|
||||
# proxy:
|
||||
# header_map: {user: remote-user, role: remote-groups}
|
||||
# default_role: viewer
|
||||
# separator: '|'
|
||||
cam.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:8971 { # CHANGE IP
|
||||
transport http { read_timeout 60s; write_timeout 60s }
|
||||
# header_up X-Proxy-Secret "32-byte-hex" # if auth_secret: set in Frigate
|
||||
}
|
||||
}
|
||||
|
||||
# --- Grafana ---
|
||||
# In grafana.ini (or GF_* env vars):
|
||||
# [auth.proxy]
|
||||
# enabled = true
|
||||
# header_name = Remote-User
|
||||
# header_property = username
|
||||
# auto_sign_up = true
|
||||
grafana.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:3000 # CHANGE IP
|
||||
}
|
||||
|
||||
# --- Gitea / Forgejo ---
|
||||
# In app.ini:
|
||||
# [service]
|
||||
# ENABLE_REVERSE_PROXY_AUTHENTICATION = true
|
||||
# REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
git.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:3000 # CHANGE IP
|
||||
}
|
||||
|
||||
# --- Nextcloud ---
|
||||
# In config/config.php:
|
||||
# 'trusted_proxies' => ['172.18.0.0/16'],
|
||||
# 'overwriteprotocol' => 'https',
|
||||
# Plus user_external app + HTTP header auth set to Remote-User.
|
||||
cloud.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:80 { # CHANGE IP
|
||||
header_up Host {upstream_hostport}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Paperless-ngx ---
|
||||
# In compose env:
|
||||
# PAPERLESS_ENABLE_HTTP_REMOTE_USER=true
|
||||
# PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=HTTP_REMOTE_USER
|
||||
paperless.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:8000 # CHANGE IP
|
||||
}
|
||||
|
||||
# --- Miniflux ---
|
||||
# In compose env:
|
||||
# AUTH_PROXY_HEADER=Remote-User
|
||||
# AUTH_PROXY_USER_CREATION=true
|
||||
miniflux.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:8080 # CHANGE IP
|
||||
}
|
||||
|
||||
# --- BookStack ---
|
||||
# In .env:
|
||||
# AUTH_METHOD=http
|
||||
# HTTP_AUTH_HEADER=Remote-User
|
||||
# HTTP_AUTH_AUTO_INITIATE=true
|
||||
# bookstack.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:80 # CHANGE IP
|
||||
# }
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CASE 2b -- App supports OIDC. Authelia acts as the OIDC provider.
|
||||
#
|
||||
# What is OIDC? OpenID Connect is an identity protocol on top of OAuth 2.0.
|
||||
# Authelia becomes the "identity provider" (IdP). Apps redirect users to
|
||||
# auth.DOMAIN, Authelia authenticates them and issues a signed token (JWT),
|
||||
# then redirects back. The app trusts the token instead of checking a password.
|
||||
#
|
||||
# The Caddy config is identical to case 2a: `import authelia` gates the request.
|
||||
# The difference is all on the app side -- it does a token exchange with
|
||||
# Authelia's OIDC endpoint rather than reading a Remote-User header.
|
||||
#
|
||||
# SETUP REQUIRED in authelia/configuration.yml:
|
||||
# Add an identity_providers.oidc block with a client entry for each app.
|
||||
# Each app gets its own client_id and client_secret.
|
||||
# See: https://www.authelia.com/configuration/identity-providers/openid-connect/
|
||||
#
|
||||
# Result: users never set a password in the app itself. After OIDC is working,
|
||||
# disable all local accounts in the app -- Authelia is the only credential.
|
||||
# =============================================================================
|
||||
|
||||
# --- Audiobookshelf ---
|
||||
# Native OIDC support. In Audiobookshelf Settings > Authentication:
|
||||
# Enable OpenID Connect SSO
|
||||
# Issuer URL: https://auth.DOMAIN
|
||||
# Client ID / Secret: from identity_providers.oidc in configuration.yml
|
||||
# Auto Register: on (creates user on first OIDC login)
|
||||
# books.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:13378 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Jellyfin ---
|
||||
# Requires the community SSO plugin (Jellyfin.Plugin.SSO).
|
||||
# Install from the Plugin Catalogue, then configure OIDC pointing at Authelia.
|
||||
# jellyfin.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:8096 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Immich ---
|
||||
# No proxy-header auth; OIDC is the only Authelia path.
|
||||
# In Immich Admin > Authentication Settings > OAuth:
|
||||
# Issuer URL: https://auth.DOMAIN
|
||||
# Client ID / Secret: from configuration.yml
|
||||
# Auto register: on
|
||||
# photos.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:2283 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Mealie ---
|
||||
# OIDC supported. Set in Mealie's admin OIDC settings.
|
||||
# Alternatively, skip OIDC and use case 3 as a simpler gate.
|
||||
# mealie.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:9000 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Portainer ---
|
||||
# Has OIDC for full SSO -- configure under Settings > Authentication.
|
||||
# Or use case 3 (below) as a simpler gate without OIDC config.
|
||||
# portainer.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:9000 # CHANGE IP (OIDC version)
|
||||
# }
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CASE 3 -- App keeps its own login. Authelia adds a 2FA gate in front.
|
||||
#
|
||||
# The user passes Authelia 2FA, then the app's own login appears.
|
||||
# Use for apps that don't support proxy auth headers or OIDC, but you still
|
||||
# want 2FA before they're even reachable.
|
||||
# =============================================================================
|
||||
|
||||
# --- Uptime Kuma ---
|
||||
# No proxy auth, no native TOTP -- Authelia is the only way to add 2FA.
|
||||
uptime.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:3001 # CHANGE IP
|
||||
}
|
||||
|
||||
# --- Portainer (simple gate, no OIDC) ---
|
||||
portainer.{env.DOMAIN} {
|
||||
import accesslog
|
||||
import authelia
|
||||
reverse_proxy 192.168.x.x:9000 # CHANGE IP
|
||||
}
|
||||
|
||||
# --- Home Assistant ---
|
||||
# Can also do case 2a via the trusted_networks auth provider + header forwarding.
|
||||
# homeassistant.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:8123 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Vaultwarden ---
|
||||
# Has its own strong auth + native TOTP. Many skip Authelia here entirely
|
||||
# and rely on Vaultwarden's own 2FA (totally valid). Or use case 3 as an
|
||||
# extra gate if you want 2FA even before the login page loads.
|
||||
# vault.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:80 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- qBittorrent ---
|
||||
# Web UI has its own auth. No proxy headers.
|
||||
# torrent.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:8080 # CHANGE IP:PORT
|
||||
# }
|
||||
|
||||
# --- Plex ---
|
||||
# DO NOT use `import authelia` with Plex/Emby. Plex is coupled to plex.tv
|
||||
# cloud auth -- Authelia cannot replace it. More importantly, native clients
|
||||
# (mobile, TV, desktop apps) connect via FQDN through Caddy but CANNOT
|
||||
# complete Authelia's browser-redirect login flow (no cookie, no TOTP prompt).
|
||||
# `import authelia` will break all native clients with a connection error.
|
||||
#
|
||||
# Correct approach: Case 4. Caddy terminates TLS and reverse proxies; Plex's
|
||||
# own token auth handles access control. `import accesslog` keeps fail2ban
|
||||
# watching the subdomain for scanners.
|
||||
#
|
||||
# plex.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# reverse_proxy 192.168.x.x:32400 # CHANGE IP -- NO import authelia
|
||||
# }
|
||||
|
||||
# --- Emby ---
|
||||
# Same situation as Plex: native clients go through Caddy but cannot handle
|
||||
# Authelia's login redirect. Use Case 4 -- Caddy + TLS, Emby's own auth.
|
||||
#
|
||||
# emby.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# reverse_proxy 192.168.x.x:8096 # CHANGE IP -- NO import authelia
|
||||
# }
|
||||
|
||||
# --- Gotify ---
|
||||
# Notification server. Own auth, no proxy headers.
|
||||
# gotify.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:8080 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- ntfy ---
|
||||
# Notification server. Token-based auth, no proxy headers.
|
||||
# ntfy.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:80 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- wg-easy ---
|
||||
# WireGuard web UI. Own password, no proxy headers.
|
||||
# NOTE: WireGuard clients connect directly to UDP 51820, not through Caddy.
|
||||
# This gate only protects the web management UI.
|
||||
# wg.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:51821 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Umami ---
|
||||
# Web analytics. Own auth, no proxy headers.
|
||||
# analytics.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:3000 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- phpIPAM ---
|
||||
# IP address management. Own auth, no proxy headers.
|
||||
# ipam.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:80 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Checkmk ---
|
||||
# Monitoring. Own auth. No proxy headers in the free (Raw) edition.
|
||||
# checkmk.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:5000 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Snipe-IT ---
|
||||
# Asset management. Own auth, no proxy headers.
|
||||
# assets.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:80 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Zammad ---
|
||||
# Help desk / ticketing. Own auth, no proxy headers.
|
||||
# tickets.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:3000 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Lubelog ---
|
||||
# Vehicle maintenance log. Own auth, no proxy headers.
|
||||
# cars.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:8080 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- UniFi Network Application ---
|
||||
# Network controller. Own auth, no proxy headers (LDAP/RADIUS in enterprise).
|
||||
# Serves HTTPS on 8443; skip TLS verify for the upstream.
|
||||
# unifi.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:8443 { # CHANGE IP
|
||||
# transport http { tls_insecure_skip_verify }
|
||||
# }
|
||||
# }
|
||||
|
||||
# --- MeshCentral ---
|
||||
# Remote management server. Own auth; has OIDC in paid/enterprise builds.
|
||||
# meshcentral.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# import authelia
|
||||
# reverse_proxy 192.168.x.x:443 { # CHANGE IP
|
||||
# transport http { tls_insecure_skip_verify }
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CASE 4 -- App handles its own auth. Authelia not involved.
|
||||
# Still import accesslog so fail2ban covers this site.
|
||||
# =============================================================================
|
||||
|
||||
# --- Syncthing ---
|
||||
# sync.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# reverse_proxy 192.168.x.x:8384 # CHANGE IP
|
||||
# }
|
||||
|
||||
# --- Router / NAS admin UI ---
|
||||
# router.{env.DOMAIN} {
|
||||
# import accesslog
|
||||
# reverse_proxy 192.168.x.x:443 {
|
||||
# transport http { tls_insecure_skip_verify }
|
||||
# }
|
||||
# }
|
||||
+74
-56
@@ -1,71 +1,89 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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}
|
||||
# Enables Go-template substitution in configuration.yml so
|
||||
# `{{ env "DOMAIN" }}` actually expands instead of being read as a
|
||||
# literal string. Without this, Authelia parses the braces as part
|
||||
# of the hostname and validate-config fails with `invalid character
|
||||
# "{" in host name`. Inherited by `docker compose run --rm authelia`,
|
||||
# so validate-config picks it up too.
|
||||
- X_AUTHELIA_CONFIG_FILTERS=template
|
||||
# Passed through so authelia/configuration.yml can use {{ env "DOMAIN" }}.
|
||||
- DOMAIN=${DOMAIN}
|
||||
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