Compare commits
229
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
102b0405b4 | ||
|
|
9c7a054d97 | ||
|
|
c63237a3db | ||
|
|
575c4ac185 | ||
|
|
a339d5fbb0 | ||
|
|
c2cf5bfe69 | ||
|
|
92f8503d48 | ||
|
|
d95bd7fd3c | ||
|
|
3cd9a1ece3 | ||
|
|
5c13054cdd | ||
|
|
7d5674aad8 | ||
|
|
2d82b2b278 | ||
|
|
08a2617b06 | ||
|
|
39387b5e0f | ||
|
|
e67ac50c61 | ||
|
|
3ebbeba672 | ||
|
|
07394769ca | ||
|
|
ae939c4085 | ||
|
|
28996eff57 | ||
|
|
93efe0d607 | ||
|
|
79861c72f3 | ||
|
|
2422ce1385 | ||
|
|
6fc6c3b84d | ||
|
|
e6522eadec | ||
|
|
2dcfaafc87 | ||
|
|
33e4f64d69 | ||
|
|
cbfc28c2dd | ||
|
|
a212be09c3 | ||
|
|
8a9241f0ff | ||
|
|
5893346625 | ||
|
|
5847b81dfd | ||
|
|
52207598b9 | ||
|
|
c57f760fdc | ||
|
|
57fd74f5af | ||
|
|
4ed7a9d2d5 | ||
|
|
9ba1d7e9db | ||
|
|
4b5ca9f6ea | ||
|
|
2517b31336 | ||
|
|
b671c1b2ec | ||
|
|
d0c444e63f | ||
|
|
0f89a3d534 | ||
|
|
d84b958937 | ||
|
|
866d895357 | ||
|
|
2005534b12 | ||
|
|
a3642c5159 | ||
|
|
8aaaf993ac | ||
|
|
09a24c2f16 | ||
|
|
0be27b15e9 | ||
|
|
9714bfca2e | ||
|
|
93288be7e2 | ||
|
|
e3874b2ebe | ||
|
|
343c2ef68b | ||
|
|
164d5e8891 | ||
|
|
24fe5a3177 | ||
|
|
70f3bfbd85 | ||
|
|
2c93a2c7fd | ||
|
|
5c3a38b9f0 | ||
|
|
24b62b7415 | ||
|
|
52604b3ba6 | ||
|
|
3d9df0793a | ||
|
|
14541062fc | ||
|
|
d954c605b0 | ||
|
|
5edfed7735 | ||
|
|
910a49f12f | ||
|
|
22990b6583 | ||
|
|
b4ac5a4de0 | ||
|
|
daf0ed11e4 | ||
|
|
0fe0c74235 | ||
|
|
2e7e073b63 | ||
|
|
f27fdad711 | ||
|
|
e965c2bd76 | ||
|
|
c7cc7176f0 | ||
|
|
ad5440a58b | ||
|
|
a55f6c430a | ||
|
|
3b0689dfd9 | ||
|
|
83d62b05fd | ||
|
|
423b295acf | ||
|
|
25dc4251de | ||
|
|
0a32ab7844 | ||
|
|
24e59a280c | ||
|
|
253ee7587b | ||
|
|
505a342417 | ||
|
|
ddfae32987 | ||
|
|
1b4036a0c2 | ||
|
|
a49f8c3533 | ||
|
|
c9d1eac7f2 | ||
|
|
7ed376e9b7 | ||
|
|
435992a4f0 | ||
|
|
9d3801494a | ||
|
|
eb794e61f9 | ||
|
|
5ace213bd4 | ||
|
|
1d386e6a58 | ||
|
|
778d06b0b8 | ||
|
|
63faa9b1bd | ||
|
|
4199f42f70 | ||
|
|
2c51ab5faa | ||
|
|
6dff335ac7 | ||
|
|
85b13d07a7 | ||
|
|
ce9a8e8c5b | ||
|
|
bef88e654d | ||
|
|
f087984526 | ||
|
|
1ed7fe89ea | ||
|
|
7dfcb8272a | ||
|
|
0ba83212d1 | ||
|
|
d72c638337 | ||
|
|
ed939e5826 | ||
|
|
bc7b9c6bb0 | ||
|
|
c1a97d8945 | ||
|
|
0d8d87794d | ||
|
|
76a494bcbf | ||
|
|
3ffcde7294 | ||
|
|
a33fb0fd84 | ||
|
|
f8bfce87d9 | ||
|
|
c584bc45cd | ||
|
|
591bdd0e79 | ||
|
|
8a298d161a | ||
|
|
63eab19e9c | ||
|
|
5e281e3d11 | ||
|
|
d0953890e6 | ||
|
|
a4d33f6afd | ||
|
|
0f0740a322 | ||
|
|
28d6d8faf4 | ||
|
|
42072387f8 | ||
|
|
165f3d3ecd | ||
|
|
697ee95461 | ||
|
|
07a6786ea5 | ||
|
|
fe9ff46081 | ||
|
|
b427127200 | ||
|
|
6f8a703004 | ||
|
|
09f46f96c1 | ||
|
|
dad93dd646 | ||
|
|
fc9733f937 | ||
|
|
bec9228c55 | ||
|
|
31ba6678d7 | ||
|
|
fce2e7caf1 | ||
|
|
74b5a0dc7a | ||
|
|
493ee30916 | ||
|
|
1e625b9955 | ||
|
|
e9286c8360 | ||
|
|
7209a32d43 | ||
|
|
294e935ffd | ||
|
|
49b965bd1d | ||
|
|
879cb24d3b | ||
|
|
8e7ffc5185 | ||
|
|
8745f5ad01 | ||
|
|
2d2e6aae88 | ||
|
|
8808de0dbb | ||
|
|
5747eebf86 | ||
|
|
8aea505541 | ||
|
|
753a8fdd43 | ||
|
|
ee7d40b0ce | ||
|
|
9a7989b31d | ||
|
|
bd5aa223fb | ||
|
|
6995afdc66 | ||
|
|
b57c266b84 | ||
|
|
65a8de0ab9 | ||
|
|
a5085cc65b | ||
|
|
ad4758e331 | ||
|
|
8bd0c0f66d | ||
|
|
23592a43bf | ||
|
|
b2b83948d8 | ||
|
|
098bb833cf | ||
|
|
ae77992869 | ||
|
|
845e06e079 | ||
|
|
cd25903865 | ||
|
|
d3553ee168 | ||
|
|
5fbc1f2ec5 | ||
|
|
758adf744e | ||
|
|
560c33f737 | ||
|
|
b0c9d73030 | ||
|
|
3e9f9dbfed | ||
|
|
b7411b236e | ||
|
|
f52713b67d | ||
|
|
3614342807 | ||
|
|
cfee4b292b | ||
|
|
92ebbdf9ef | ||
|
|
c703b1c4b4 | ||
|
|
7f23860078 | ||
|
|
503945e7c3 | ||
|
|
f5a23b06ac | ||
|
|
a595e45294 | ||
|
|
2b69cfac1d | ||
|
|
e54c7827de | ||
|
|
90da2f5a91 | ||
|
|
47c04ebeb3 | ||
|
|
953744b64c | ||
|
|
4eda82acce | ||
|
|
d1a3c4b0be | ||
|
|
85f3a89ef5 | ||
|
|
b7691e6c1b | ||
|
|
da59b65ceb | ||
|
|
b0d51f0344 | ||
|
|
be60f475c3 | ||
|
|
cd33b7ce71 | ||
|
|
afc59613fc | ||
|
|
3b4c238e1d | ||
|
|
596bd4a9d5 | ||
|
|
88d48e0e74 | ||
|
|
1018dd8c9e | ||
|
|
ed9251580a | ||
|
|
1ff12d6643 | ||
|
|
175e1679bf | ||
|
|
9bf49b5daa | ||
|
|
45f5c6c1e1 | ||
|
|
b4eccbd02d | ||
|
|
4399e8db71 | ||
|
|
22d213025d | ||
|
|
ac76ef5181 | ||
|
|
0a798d5ec9 | ||
|
|
ebe8ea3245 | ||
|
|
7a8b8b001b | ||
|
|
7aef571b27 | ||
|
|
9ec4ee7963 | ||
|
|
86331b541d | ||
|
|
96741c4f32 | ||
|
|
fc3b85f970 | ||
|
|
9ecf37c56f | ||
|
|
4b0f453952 | ||
|
|
c3133711cc | ||
|
|
31cbe435bc | ||
|
|
040b006457 | ||
|
|
accfd7a5bb | ||
|
|
0d605cc8c7 | ||
|
|
15cef3ab1d | ||
|
|
60b5dd5f29 | ||
|
|
2b6c06e060 | ||
|
|
393f97d164 | ||
|
|
a12aae7819 | ||
|
|
d1a67766d7 |
@@ -44,11 +44,26 @@ public-FQDN-only flow, hand-built Caddy site block, remote Authelia, Cloud
|
||||
Firewall — behind that one answer. Two lessons worth reusing:
|
||||
|
||||
- **Don't rename a live install's directory or containers.** New installs
|
||||
land in `~/docker/asterisk` with `easy-asterisk`; a pre-merge droplet keeps
|
||||
`~/docker/asterisk-digital-ocean` and `easy-asterisk-do`, because its
|
||||
Caddyfile block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN
|
||||
trunk all name those exact paths. `_asterisk_resolve_layout()` picks
|
||||
whichever exists, and every sibling service probes both.
|
||||
land in `~/docker/asterisk` with a container named `asterisk`; a pre-merge
|
||||
droplet keeps `~/docker/asterisk-digital-ocean` and `easy-asterisk-do`,
|
||||
because its Caddyfile block, UFW rules, Cloud Firewall, CrowdSec
|
||||
acquisition and PSTN trunk all name those exact paths.
|
||||
`_asterisk_resolve_layout()` picks whichever directory exists, and every
|
||||
sibling service probes both. The plain container name was itself renamed
|
||||
once already — from `easy-asterisk` (this repo's original choice, reusing
|
||||
the vendor CLI tool's own name, `/usr/local/bin/easy-asterisk` inside the
|
||||
container — unrelated, never renamed) to plain `asterisk`, matching every
|
||||
other service's own `container_name == service name` convention. The same
|
||||
"don't rename under a running deployment" rule applied: every resolver
|
||||
(`_asterisk_resolve_layout()` and the duplicated copies in
|
||||
`security-dashboard.sh`, `sms-inbound.sh`, `pstn-trunk.sh`,
|
||||
`tools/pstn-test-check.sh`) reads the container name out of the box's own
|
||||
`docker-compose.yml` instead of assuming it, so an existing `easy-asterisk`
|
||||
install keeps working unchanged. Migrating one to the new name is a
|
||||
deliberate, one-time action on that box (edit `docker-compose.yml`'s
|
||||
`container_name:` for both Asterisk and its coturn sidecar, `docker compose
|
||||
down` + `up -d`) — once done, every sibling service re-reads it from that
|
||||
same file and follows automatically.
|
||||
- **Check whether a "flavor-specific" behavior was actually flavor-specific.**
|
||||
The Asterisk security-logging patch and the `logs/full` logrotate config
|
||||
were droplet-only purely because that's where they got written first — the
|
||||
@@ -191,13 +206,25 @@ pip_user_install PACKAGE... # pip3 --user with --break-system-packages o
|
||||
### Caddy reverse proxy
|
||||
|
||||
```bash
|
||||
configure_caddy_for_service "Display Name" "PORT" "default-subdomain" ["extra-block"]
|
||||
configure_caddy_for_service "Display Name" "PORT" "default-subdomain" ["extra-block"] ["reverse_proxy-extra"]
|
||||
```
|
||||
|
||||
Prompts the user for a domain, appends a site block to the Caddyfile, and
|
||||
reloads Caddy. No-ops silently if Caddy isn't installed. The fourth argument
|
||||
is an optional string inserted verbatim inside the Caddy site block (use it
|
||||
for `import authelia` or custom matchers).
|
||||
is an optional string inserted verbatim inside the Caddy site block, before
|
||||
`reverse_proxy` (use it for `import authelia` or custom matchers). The fifth
|
||||
argument is a different thing — an optional string inserted **inside** the
|
||||
`reverse_proxy` block itself, as sub-directives (e.g.
|
||||
`" header_up X-Proxy-Secret abc123"`), for a backend that needs a
|
||||
header only `reverse_proxy`'s own `header_up` can set — the fourth
|
||||
argument's block runs *before* `reverse_proxy` and can't reach into it.
|
||||
`services/frigate.sh` is the reference caller: Frigate's `proxy` auth mode
|
||||
trusts `Remote-User`/`Remote-Groups` headers from Authelia's forward_auth,
|
||||
but only if a matching `X-Proxy-Secret` header is also present — otherwise
|
||||
those headers could be spoofed by a request that reaches Frigate's
|
||||
published host port directly, bypassing Caddy/Authelia entirely. Omit the
|
||||
fifth argument and the generated `reverse_proxy` line is the same bare form
|
||||
as before — every other caller is unaffected.
|
||||
|
||||
The function places that block **before** `reverse_proxy` in the generated
|
||||
site block — don't reorder this. `forward_auth` (what `import authelia`
|
||||
@@ -245,14 +272,19 @@ forward_auth https://auth.example.com {
|
||||
This only affects the remote-Authelia path — same-machine `authelia:9091`
|
||||
snippets (`services/authelia.sh`) are a single hop and don't need it.
|
||||
|
||||
Sets two out-params (not `local` — read them after the call returns) so the
|
||||
caller can tell whether Caddy actually ended up fronting the service:
|
||||
Sets three out-params (not `local` — read them after the call returns) so
|
||||
the caller can tell whether Caddy actually ended up fronting the service:
|
||||
|
||||
```bash
|
||||
CADDY_SERVICE_CONFIGURED # true/false
|
||||
CADDY_SERVICE_MODE # "local" or "remote" (only meaningful if configured)
|
||||
CADDY_SERVICE_DOMAIN # the domain actually configured (only meaningful if configured)
|
||||
```
|
||||
|
||||
`CADDY_SERVICE_DOMAIN` is what `_authelia_scope_access()` (see below) wants
|
||||
as its `DOMAIN` argument — read it right after the call instead of
|
||||
recomputing/guessing the domain a second time.
|
||||
|
||||
Use this to skip opening a host firewall port for a service Caddy already
|
||||
fronts *locally* (it reaches the service over `host.docker.internal`, not
|
||||
the network) — but still open it when `CADDY_SERVICE_MODE` is `"remote"`,
|
||||
@@ -390,22 +422,49 @@ existing login page. Reuse `_authelia_provision_oidc_client()` (guarded by
|
||||
instead of duplicating Authelia's client-secret-generation/config-patching
|
||||
logic again.
|
||||
|
||||
**Scoping a domain to specific users instead of every Authelia user.**
|
||||
By default, any domain with an `access_control` rule at all is reachable by
|
||||
every Authelia user (the existing catch-all `*.${AUTHELIA_DOMAIN}` rule).
|
||||
`services/authelia.sh`'s `_authelia_scope_access(SERVICE_ID, DOMAIN)` is a
|
||||
generic, reusable opt-in on top of that — call it right after *any* service
|
||||
finishes being protected by Authelia, forward_auth gate or native OIDC
|
||||
alike (it only cares about the domain, not the gating mechanism; see
|
||||
`_gitea_offer_authelia_sso()` for the reference caller). Asks whether
|
||||
access should stay universal or be scoped to specific usernames; if scoped,
|
||||
creates a dedicated `<service_id>-only` group, adds every listed username
|
||||
to it (creating accounts on the fly via
|
||||
**Internal vs. outside access — named, reusable groups, not one group per
|
||||
service.** By default, any domain with an `access_control` rule at all is
|
||||
"internal": reachable by every Authelia user (the existing catch-all
|
||||
`*.${AUTHELIA_DOMAIN}` rule) — admins included automatically, since the
|
||||
admin-bypass rule (below) always outranks it anyway. `services/authelia.sh`'s
|
||||
`_authelia_scope_access(SERVICE_ID, DOMAIN)` is the generic, reusable opt-in
|
||||
on top of that for "outside access" — call it right after *any* service
|
||||
finishes being protected by Authelia, forward_auth gate or native OIDC alike
|
||||
(it only cares about the domain, not the gating mechanism; see
|
||||
`_gitea_offer_authelia_sso()` for the reference caller). Asks "Internal
|
||||
(default) or Outside access", and if outside access, lets the admin pick an
|
||||
*existing* named group (by number, so e.g. "customer1" can be attached to a
|
||||
second, third, unrelated site later) or type a new one — `service_id` is
|
||||
only the suggested default name, never forced. Creates the group if new
|
||||
(adding every listed username to it, creating accounts on the fly via
|
||||
`_authelia_create_user_noninteractive()` for names that don't exist yet,
|
||||
printing their temp password), and inserts two rules *above* the general
|
||||
catch-all — allow that group on this domain, deny that group on every
|
||||
other protected domain. Idempotent: reruns against an already-scoped
|
||||
domain just report the existing group instead of duplicating rules.
|
||||
printing their temp password) and inserts two rules *above* the general
|
||||
catch-all but *below* the admin-bypass rule — allow that group on this
|
||||
domain, deny that group on every other protected domain. The already-scoped
|
||||
check is keyed to the (domain, group) pair, not the group name alone, so
|
||||
reusing a group on a second site correctly adds that site's own rule instead
|
||||
of a false "already scoped" no-op (a real bug in an earlier version of this
|
||||
function, since fixed).
|
||||
|
||||
Three more menu options round this out: **13** backfills the admin-bypass
|
||||
rule (below) onto any apex domain missing it; **14** renames a group
|
||||
everywhere it's referenced (rules + every member); **15** lists every
|
||||
group's sites and members in one place; **16** is the reverse of the
|
||||
scoping prompt's own member-picker — pick a group first, then toggle which
|
||||
users are in it, for adding members without re-touching a site.
|
||||
|
||||
**Admins always match first, on every domain — old sites and new.**
|
||||
`_authelia_ensure_admin_bypass(config_file, domain)` inserts
|
||||
`- domain: "*.${domain}" / subject: "group:admins" / policy: two_factor` as
|
||||
literally the first rule under `rules:`, and every insertion point that
|
||||
adds new rules (`add_authelia_domain`, `_authelia_scope_access`) inserts
|
||||
*after* this block rather than at the literal top of `rules:`, so a later
|
||||
scoping action can never accidentally outrank it. Without this, a group's
|
||||
deny-elsewhere rule (above) would deny an admin who's ever added to that
|
||||
group on every OTHER domain — this rule exists specifically so that can't
|
||||
happen. `install_authelia()`/`add_authelia_domain()` bake it in for
|
||||
anything created from here on; menu option 13 backfills it onto an
|
||||
instance that predates the feature.
|
||||
Guard every cross-file call with `declare -F`, same convention as the OIDC
|
||||
helper above — a service can run standalone with authelia.sh never sourced.
|
||||
|
||||
@@ -434,13 +493,16 @@ right (Portainer, ntfy), not general familiarity with the product:
|
||||
| Service | Native OIDC? | Notes |
|
||||
|---|---|---|
|
||||
| `mealie` | Yes — wired up | Pure env vars (`OIDC_AUTH_ENABLED`, `OIDC_CLIENT_ID/SECRET`, `OIDC_CONFIGURATION_URL`), see `_mealie_offer_authelia_oidc()`. Redirect URI is `<BASE_URL>/login`. Needs a `--forwarded-allow-ips` entrypoint override when Caddy-fronted, or the generated redirect URI comes out `http://` even when actually served over `https://` — see the function's own comment. |
|
||||
| `homebox` | Yes — wired up | Pure env vars (`HBOX_OIDC_ENABLED`, `HBOX_OIDC_ISSUER_URL`, `HBOX_OIDC_CLIENT_ID/SECRET`, `HBOX_OIDC_SCOPE`), see `_homebox_offer_authelia_oidc()`. Confirmed against homebox.software's own OIDC docs and authelia.com's Homebox integration page — needs PKCE (unlike Mealie/ActualBudget). Redirect path is `/api/v1/users/login/oidc/callback`; issuer URL is reportedly sensitive to a trailing slash (a real upstream bug), so it's written from this repo's own portal-URL value as-is, never with one appended. The stock compose template didn't have `env_file: .env` (vars were listed individually in `environment:` instead) — added to the template, and patched onto any pre-existing install's compose file the first time this offer runs, or the written `.env` additions would silently never reach the container. `HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false`/`HBOX_OIDC_AUTO_REDIRECT=true` are real, documented env vars for fully replacing local login, offered as a separate step gated behind the same "have you tested the button first" confirmation as Mealie/Beszel. Unlike every other native-OIDC integration in this table, `HBOX_OIDC_SCOPE` needs a fourth scope, `groups`, alongside the usual `openid profile email` — Authelia's own Homebox integration page documents this. Confirmed live: requesting it without also granting it broke login outright (`invalid_scope: "The OAuth 2.0 Client is not allowed to request scope 'groups'"`), because Authelia enforces a per-client scopes allowlist independent of what the server supports overall — `_authelia_provision_oidc_client()` used to hardcode `openid`/`profile`/`email` for every caller with no way to add more. Fixed by giving it a 6th positional arg, `EXTRA_SCOPES` (space-separated, inserted right after `REQUIRE_PKCE`), that every other existing caller passes as `""` — Homebox's is the only caller that passes `"groups"`. Separately, some Homebox collections hit an unrelated upstream bug (sysadminsmedia/homebox#1593): the default `Location`/`Item` entity types never get seeded for that collection, so the Create dialog's type dropdown comes up empty and creation fails with "Please select an entity type" regardless of Authelia. `_homebox_offer_entity_type_fix()` is the opt-in repair: entity types are scoped per collection with no unauthenticated read/write (confirmed against Homebox's own swagger doc — `GET`/`POST /v1/entity-types` both require a bearer token, and there's no documented endpoint to switch a token between a user's collections), so rather than baking in or storing any credential it prompts for a pasted API token at the moment it runs — same one-time, never-persisted trust model as `_immich_offer_authelia_oidc()`'s own admin-API-key prompt — checks for an existing `isLocation:true` type, and POSTs the two defaults only if none exist. Explicitly told upfront that fixing it only covers the one collection that token's account belongs to; a multi-collection user has to repeat the step once per collection. |
|
||||
| `actualbudget` | Yes — wired up | Pure env vars (`ACTUAL_OPENID_DISCOVERY_URL`, `ACTUAL_OPENID_CLIENT_ID/SECRET`, `ACTUAL_OPENID_SERVER_HOSTNAME`), see `_actualbudget_offer_authelia_oidc()`. Redirect path `/openid/callback` (matches the existing preset in `_authelia_add_oidc_client()`'s menu). First OIDC login becomes the server owner if none is set yet — Actual's own behavior. |
|
||||
| `immich` | Yes, not yet wired up | Real OAuth2/OIDC settings under Administration → Settings, backed by a `system-config` API (GET/PUT) — confirmed the API exists, but didn't confirm the exact request payload shape needed to set OAuth fields specifically. Needs one more verification pass against the live OpenAPI spec before automating; don't guess the payload. |
|
||||
| `immich` | Yes — wired up | Real OAuth2/OIDC settings under Administration → Settings, backed by `GET`/`PUT /api/system-config` — confirmed the exact JSON field names against Immich's own `config-file.md` and source directly (the `oauth` sub-object: `enabled`/`issuerUrl`/`clientId`/`clientSecret`/`scope`/`buttonText`, etc.), not guessed. See `_immich_offer_authelia_oidc()`. GET/PUT exchange the *whole* config object (no partial-patch endpoint), so it round-trips everything else — storage template, library settings — completely unchanged; the same shape already proven by `import-photos.sh`'s own storage-template step in this file. Needs an admin API key, which doesn't exist until the user creates their account on first web visit — this offer runs from both the fresh-install path (usually a no-op that first time) and the "update" rerun path, which is the realistic way most people finish this. |
|
||||
| `audiobookshelf` | Yes — wired up (Authelia side only) | Checked against audiobookshelf.org's own OIDC docs: config is UI-only (Settings → Authentication), no env var or config API — so `_audiobookshelf_offer_authelia_oidc()` registers the Authelia client (needs PKCE, confirmed via authelia.com's own integration page for it) and prints the exact individual-endpoint values to paste in, since Audiobookshelf wants those rather than a discovery URL. Three redirect URIs: web callback, mobile-redirect, and the `audiobookshelf://oauth` app-scheme callback. |
|
||||
| `beszel` | Yes — wired up (Authelia side only) | PocketBase-based; its OAuth2 provider is a PocketBase admin-UI setting (Settings → Auth providers), not an API — checked against beszel.dev directly. `_beszel_offer_authelia_oidc()` registers the Authelia client (also needs PKCE, per authelia.com's Beszel integration page) and prints the paste-in values. Separately offers the real, documented `DISABLE_PASSWORD_AUTH`/`USER_CREATION` env vars to fully replace Beszel's own login — gated behind an explicit warning to register a working account first, since Beszel has no default account and no signup-fallback if that hasn't happened yet. |
|
||||
| `jellyfin` | Only via a third-party plugin | No official native OIDC. Community plugins exist (`jellyfin-plugin-sso`, `jellyfin-plugin-oidc`) but are web-UI-only — native mobile/desktop Jellyfin clients can't use them. A bigger lift than an env-var toggle (plugin install via Jellyfin's own plugin repo system); hold off until that's worth doing deliberately. |
|
||||
| `homeassistant` | Only via a third-party HACS integration | No native core OIDC as of 2026 (open community discussion asking for it, not shipped). `hass-oidc-auth`/`hass-openid` exist as HACS-installed integrations — same "bigger lift" caveat as Jellyfin. |
|
||||
| `portainer` | No (CE) | OAuth/OIDC is a **Business Edition** feature — this repo installs `portainer-ce` (confirmed in `services/portainer.sh`), which doesn't have it. CE's documented path is fronting it with `oauth2-proxy`, i.e. no different from the forward_auth pattern any no-built-in-auth service already uses — not "native OIDC" in the sense this section means. |
|
||||
| `ntfy` | No | Checked ntfy's own config docs directly — no `auth-oauth2-*` keys exist. Only basic auth + access tokens + ACLs. (Worth a re-check on a future ntfy release if this matters to you — this class of feature does get added to self-hosted tools over time.) |
|
||||
| `emby`, `audiobookshelf`, `meshcentral`, `traccar`, `uptimekuma`, `filebrowser`, `wg-easy` | Not individually re-verified | High-confidence no, based on general familiarity with each product rather than a fresh doc check this pass (unlike everything above, which was actually checked and in two cases contradicted assumption). Verify before wiring any of these in, the same way the checked ones were — don't extrapolate from this table's pattern.
|
||||
| `emby`, `meshcentral`, `traccar`, `uptimekuma`, `filebrowser`, `wg-easy` | Not individually re-verified | High-confidence no, based on general familiarity with each product rather than a fresh doc check this pass (unlike everything above, which was actually checked and in two cases contradicted assumption). Verify before wiring any of these in, the same way the checked ones were — don't extrapolate from this table's pattern.
|
||||
|
||||
**No built-in auth — should be protected:**
|
||||
`magicmirror`, `wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`,
|
||||
@@ -461,6 +523,80 @@ for Authelia to protect. Removed from this list; if it grows a web UI in
|
||||
the future, add it back and wire up the same prompt other services here
|
||||
use.
|
||||
|
||||
**`frigate` — a third pattern, neither of the two above.** Frigate *does*
|
||||
have built-in auth (username/password, `admin`/`viewer` roles, on by
|
||||
default) so it isn't "no built-in auth" — but unlike the has-built-in-auth
|
||||
list, that auth is designed to be handed off to an upstream proxy instead
|
||||
of just living alongside it. Frigate has its own `proxy` auth mode built
|
||||
specifically for Authelia/Authentik/oauth2_proxy/traefik-forward-auth:
|
||||
given trusted `Remote-User`/`Remote-Groups` headers it can skip its own
|
||||
login screen entirely (`auth.enabled: False`), rather than showing a
|
||||
second, independently-expiring login *after* Authelia's. `services/frigate.sh`
|
||||
wires this up: `import authelia` (fourth arg) plus a
|
||||
`header_up X-Proxy-Secret <secret>` (fifth arg, see
|
||||
`configure_caddy_for_service` above) into the reverse_proxy block, with
|
||||
the matching `proxy.auth_secret`/`header_map`/`default_role: admin` block
|
||||
written into `config/config.yml` — and only written at all once
|
||||
`CADDY_SERVICE_CONFIGURED` confirms Caddy actually ended up fronting the
|
||||
domain, so Frigate's own login is never disabled with nothing else in
|
||||
front of it. `default_role: admin` (default in this repo's install) means
|
||||
anyone who passes Authelia gets full access, same as the login it
|
||||
replaces; use `proxy.role_map`/Authelia groups instead if some users
|
||||
should be view-only. Reuses the same `FRIGATE_PROXY_AUTH_SECRET` on
|
||||
reinstall (from `.env` via `ENV_MAP`, the same array `_frigate_parse_existing`
|
||||
already builds) rather than rotating it and breaking the existing Caddy
|
||||
pairing.
|
||||
|
||||
**`gitea` and `uptimekuma` — two more "disable/bypass built-in login,
|
||||
Authelia is the only gate" integrations, each with its own trust model.**
|
||||
Both are opt-in extras layered on top of the has-built-in-auth entries
|
||||
those services already had; neither replaces the existing behavior for
|
||||
anyone who doesn't ask for it.
|
||||
|
||||
- `gitea`'s `_gitea_offer_reverse_proxy_auth()` is a *second*, stronger
|
||||
Authelia integration alongside the OIDC "Sign in with Authelia" button
|
||||
(`_gitea_offer_authelia_sso()`, unchanged): Gitea's own
|
||||
`ENABLE_REVERSE_PROXY_AUTHENTICATION` mode auto-logs in as whatever
|
||||
username arrives in a trusted header — no click, no separate Gitea
|
||||
session with its own expiry. Unlike Frigate, Gitea's own login page
|
||||
isn't disabled — it stays as a fallback for anyone not arriving through
|
||||
the trusted path, so there's no "native login off with nothing gating
|
||||
it" failure mode to guard against here. The trust boundary is
|
||||
`REVERSE_PROXY_TRUSTED_PROXIES` (an IP range), not a shared secret —
|
||||
Gitea's own Docker image has shipped this wildcarded before (a real CVE,
|
||||
GHSA-f75j-4cw6-rmx4: any source IP could set `X-WEBAUTH-USER` and log in
|
||||
as anyone), so this always computes the range from caddy_net's actual
|
||||
subnet (`docker network inspect ... --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'`,
|
||||
the same lookup `ufw_allow_from_caddy_net` uses) and refuses to enable
|
||||
the feature at all if that can't be determined — never falls back to a
|
||||
permissive default. `REVERSE_PROXY_AUTHENTICATION_USER`/`_EMAIL` are set
|
||||
to `Remote-User`/`Remote-Email` to match Authelia's `import authelia`
|
||||
snippet's own `copy_headers` output directly, rather than renaming
|
||||
headers in Caddy to match Gitea's own `X-WEBAUTH-USER` default. Gitea
|
||||
currently reaches Caddy over its published host port
|
||||
(`host.docker.internal:PORT`), not caddy_net, because it predates this
|
||||
feature — enabling it rewires Gitea onto caddy_net (like every other
|
||||
locally-Caddy-fronted service) and re-points Caddy's upstream at
|
||||
`gitea:3000`, replacing the old site block via
|
||||
`configure_caddy_for_service`'s own existing "already exists —
|
||||
overwrite?" prompt. Local Caddy only; a remote Caddy machine's source
|
||||
address isn't a stable, narrowly-scopeable range the way caddy_net's
|
||||
bridge subnet is.
|
||||
- `uptimekuma`'s equivalent is much simpler: Uptime Kuma's `DISABLE_AUTH=true`
|
||||
env var turns its own login off *completely*, with no IP-range or secret
|
||||
check left at all — once set, anything that can reach its port is in, no
|
||||
questions asked. That makes it the one of these three where getting the
|
||||
ordering wrong is worst: `services/uptimekuma.sh` only ever sets
|
||||
`DISABLE_AUTH=true` after `configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" " import authelia"`
|
||||
confirms `CADDY_SERVICE_CONFIGURED` — the same never-disable-native-auth-
|
||||
without-a-confirmed-gate rule Frigate follows. Uptime Kuma already joined
|
||||
caddy_net unconditionally before this (see its own `_CADDY_NET_BLOCK`),
|
||||
so no networking change was needed here, just the env var and the
|
||||
Authelia-gated Caddy call happening earlier (before `docker-compose.yml`
|
||||
is written) instead of the plain unconditional call this file already
|
||||
had at the end — which now only runs as a fallback when the Authelia
|
||||
path wasn't used or wasn't completed.
|
||||
|
||||
For services without built-in auth, prompt the user before calling
|
||||
`configure_caddy_for_service` and pass `import authelia` as the extra block
|
||||
if Authelia is installed and the user wants SSO protection:
|
||||
@@ -476,22 +612,64 @@ configure_caddy_for_service "MagicMirror" "8081" "mirror" "$EXTRA_BLOCK"
|
||||
```
|
||||
|
||||
**Authelia "stay logged in" / kiosk mode:**
|
||||
Edit `~/docker/authelia/config/configuration.yml` and set a long
|
||||
`remember_me_duration`. Users then check "Remember me" once on login and
|
||||
the session persists through reboots (Redis stores the session in a volume):
|
||||
`install_authelia()` already writes `remember_me: 7d` into
|
||||
`configuration.yml` at install time — the checkbox is on the login form
|
||||
from day one, this is only about how long checking it actually lasts.
|
||||
To change the duration later, use the menu instead of hand-editing the
|
||||
file: re-run `sudo ./setup.sh authelia` against an existing install and
|
||||
pick **"Change 'remember me' session duration"** (`_authelia_set_remember_me()`
|
||||
in `services/authelia.sh`) — prompts for a new duration (`12h`, `7d`,
|
||||
`1M`, `1y`, or `-1` to disable Remember Me entirely) and restarts.
|
||||
Sessions persist through reboots regardless of duration (Redis stores
|
||||
session state in a volume).
|
||||
|
||||
**`inactivity` must track `remember_me`, or a long remember_me is a lie.**
|
||||
`inactivity` is a separate session field — how long a session can sit idle
|
||||
before Authelia ends it — and it is NOT extended or bypassed by the
|
||||
"Remember me" checkbox; the two are independent. Confirmed live: a user
|
||||
set `remember_me: 1y` expecting "won't be asked to log in again for a
|
||||
year," but the install default left `inactivity` at a much shorter value
|
||||
(2h at the time), so ordinary daily gaps between visits (overnight, a
|
||||
workday) ended the session on inactivity grounds well before remember_me
|
||||
ever came into play — the 1y setting was doing nothing. Fixed at both ends
|
||||
so this can't recur silently: `install_authelia()`'s own template now sets
|
||||
`inactivity: 7d`, matching its `remember_me: 7d` default instead of a
|
||||
shorter one, and `_authelia_set_remember_me()` now writes the SAME new
|
||||
duration into both keys on every change, not just `remember_me` alone. If
|
||||
you ever hand-edit `session:` instead of using the menu option, keep
|
||||
`inactivity` and `remember_me` equal — a mismatch here is exactly the bug
|
||||
above, not a valid intentional configuration. `expiration` (the cap for a
|
||||
session that never checked "Remember me") is a legitimately different,
|
||||
shorter-by-design setting and is untouched by any of this.
|
||||
|
||||
**The config key is `remember_me`, not `remember_me_duration`.** Authelia
|
||||
renamed it in 4.38; this repo pins `4.39.20`. A stale `remember_me_duration`
|
||||
key doesn't error, Authelia just silently ignores it — confirmed against
|
||||
Authelia's own docs/changelog after this file's own example used the old
|
||||
name for a while without anyone noticing, since nothing here actually
|
||||
reads it back to verify the write took effect. If you ever do need to
|
||||
touch this by hand instead of the menu option, the current schema is:
|
||||
|
||||
```yaml
|
||||
session:
|
||||
secret: 'your-existing-secret'
|
||||
remember_me_duration: 1y # add or update this line
|
||||
expiration: 1h
|
||||
inactivity: 5m
|
||||
inactivity: 1y
|
||||
remember_me: 1y
|
||||
cookies:
|
||||
- domain: 'example.com'
|
||||
authelia_url: 'https://auth.example.com'
|
||||
```
|
||||
|
||||
After editing: `docker compose -f ~/docker/authelia/docker-compose.yml restart`
|
||||
**This only covers Authelia's own session.** A native-OIDC app
|
||||
(`gitea`/`mealie`/`actualbudget`) issues its own separate session/token
|
||||
after logging in via Authelia, with its own independent expiry — a long
|
||||
`remember_me` makes re-authenticating to Authelia itself instant/silent
|
||||
whenever that app's own session expires and bounces you back through the
|
||||
OIDC flow, but it doesn't stop that app's session from expiring on its
|
||||
own schedule. If a native-OIDC app logs users out sooner than expected,
|
||||
that app's own session-length setting (if it exposes one) is the other
|
||||
thing to check, not this one.
|
||||
|
||||
## Non-Docker services
|
||||
|
||||
|
||||
@@ -186,11 +186,11 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
|
||||
|-------|---------|
|
||||
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
|
||||
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
|
||||
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
|
||||
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `anki-progress` (read-only study-progress dashboard for an `anki-sync-server` instance — reviews/accuracy/streak per account, plus an ntfy notification once a study session has been going for a configurable number of minutes; reads collection files with SQLite's read-only mode so it can't interfere with the live sync server), `anki-sync-server` (self-hosted sync backend for the Anki flashcard app — spaced-repetition scheduling stays in the Anki client, this just syncs collections across devices without AnkiWeb; supports multiple independent accounts per instance), `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
|
||||
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
||||
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
|
||||
| `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `ssh-key-import` (import SSH public keys from GitHub/Launchpad, optionally lock down password auth — same step base.sh's required setup runs, re-runnable on its own), `sync-cc` |
|
||||
| `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `ssh-key-import` (import SSH public keys from GitHub/Launchpad, optionally lock down password auth — same step base.sh's required setup runs, re-runnable on its own), `sync-cc`, `claude-cli` (Claude Code CLI — dual-account work/personal setup, model/effort defaults, shared global CLAUDE.md) |
|
||||
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency), optional offsite mirror (`kopia repository sync-to`), plus `dr_bringup.sh` — unattended restore-everything-and-start for standing up a cold spare box; `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
|
||||
|
||||
Run `./setup.sh --list` to see descriptions.
|
||||
@@ -240,6 +240,7 @@ utilities
|
||||
onlyoffice
|
||||
paintplus
|
||||
portainer
|
||||
pressbooks
|
||||
rustdesk
|
||||
stirling-pdf
|
||||
syncthing
|
||||
@@ -282,6 +283,7 @@ extras
|
||||
ssh-config
|
||||
ssh-key-import
|
||||
sync-cc
|
||||
claude-cli
|
||||
|
||||
backup
|
||||
backup
|
||||
@@ -291,6 +293,28 @@ backup
|
||||
|
||||
</details>
|
||||
|
||||
## Generating Anki decks (tools/anki-deck-*.py)
|
||||
|
||||
`anki-sync-server` gives you a self-hosted sync backend, but a fresh
|
||||
account has no content — `tools/anki-deck-math.py`,
|
||||
`tools/anki-deck-periodic.py`, and `tools/anki-deck-visual.py` generate
|
||||
ready-to-import `.apkg` decks (multiplication/division/addition/
|
||||
subtraction/fractions/decimals, the periodic table, and shapes/clocks/
|
||||
coin-counting) with Anki's built-in type-the-answer input and offline
|
||||
neural TTS audio (Piper) on every card. All three are standalone Python
|
||||
scripts, unrelated to the `services/*.sh` installer framework — run them
|
||||
on any machine with Python, not necessarily the server itself. Full setup
|
||||
(a venv, `genanki` + `piper-tts`, downloading a voice) and every deck's
|
||||
exact usage is documented in `tools/anki-deck-math.py`'s own header
|
||||
docstring; the other two scripts point back to it rather than repeating
|
||||
the same instructions three times.
|
||||
|
||||
Shapes, clocks, and coin images are drawn programmatically (SVG) rather
|
||||
than AI-generated — image generation is a poor fit for content that has
|
||||
to be exactly correct (an exact clock time, an exact side count), not
|
||||
just plausible-looking; see `tools/anki-deck-visual.py`'s own docstring
|
||||
for more on that tradeoff.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
|
||||
+92
-3
@@ -416,6 +416,42 @@ _remove_caddy_site_block() {
|
||||
' "$caddy_file"
|
||||
}
|
||||
|
||||
# Read-only counterpart to _remove_caddy_site_block: returns (on stdout) the
|
||||
# domain of the local Caddy site block whose body contains
|
||||
# "reverse_proxy <upstream>" (same substring-match convention), or nothing
|
||||
# if there's no local Caddy, no Caddyfile, or no matching block. Never
|
||||
# modifies the Caddyfile — for services/asterisk.sh's stack health check
|
||||
# (and any future caller) to answer "is X actually wired into Caddy?"
|
||||
# without needing to already know the domain, since several services here
|
||||
# (security-dashboard, sms-inbound) never persist the domain they were
|
||||
# configured with anywhere — the Caddyfile is the only record of it.
|
||||
caddy_domain_for_upstream() {
|
||||
local upstream="$1"
|
||||
local caddyfile="$DOCKER_DIR/caddy/Caddyfile"
|
||||
[ -f "$caddyfile" ] || return 0
|
||||
awk -v upstream="$upstream" '
|
||||
BEGIN { depth = 0; candidate = ""; domain = ""; found = 0 }
|
||||
{
|
||||
line = $0
|
||||
opens = gsub(/\{/, "{", line)
|
||||
closes = gsub(/\}/, "}", line)
|
||||
if (depth == 0 && opens > 0) {
|
||||
header = $0
|
||||
sub(/[[:space:]]*\{.*$/, "", header)
|
||||
candidate = header
|
||||
depth += opens - closes
|
||||
next
|
||||
}
|
||||
if (depth > 0) {
|
||||
if (index($0, "reverse_proxy " upstream) > 0) { found = 1; domain = candidate }
|
||||
depth += opens - closes
|
||||
next
|
||||
}
|
||||
}
|
||||
END { if (found) print domain }
|
||||
' "$caddyfile"
|
||||
}
|
||||
|
||||
# Generic per-service removal: stops/removes its containers, its Caddy site
|
||||
# block (if any), any UFW rule tagged with its name, and optionally its
|
||||
# ~/docker/<name> directory. Scoped to the common case (a Docker service
|
||||
@@ -766,6 +802,32 @@ write_readme() {
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Copies FILE to FILE.bak.<timestamp> if it already exists, right before a
|
||||
# caller is about to overwrite it with a fresh `cat > FILE` heredoc. No-ops
|
||||
# in DRY_RUN and silently no-ops if FILE doesn't exist yet (first install,
|
||||
# nothing to save) — safe to call unconditionally right before every such
|
||||
# write, fresh install or not.
|
||||
#
|
||||
# Confirmed live: install_frigate()'s fresh-install path overwrote a
|
||||
# working, hand-crafted multi-container docker-compose.yml (Frigate +
|
||||
# mosquitto + frigate-notify) with zero backup, because that file's shape
|
||||
# didn't match what the service's own "existing install" detection knew
|
||||
# how to recognize. Every service's own detection logic is a judgment call
|
||||
# about what counts as "already installed" and can miss a real setup built
|
||||
# outside this repo's own conventions — this exists as the safety net
|
||||
# underneath that judgment call, not a replacement for it: call it right
|
||||
# before any `cat > FILE` that could clobber something a user already has,
|
||||
# so a wrong detection costs a `.bak` file to restore from instead of the
|
||||
# original silently disappearing.
|
||||
backup_if_exists() {
|
||||
local file="$1"
|
||||
[ "$DRY_RUN" = true ] && return 0
|
||||
[ -f "$file" ] || return 0
|
||||
local backup="${file}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
cp -p "$file" "$backup" 2>/dev/null \
|
||||
&& log_info "Backed up existing $(basename "$file") to $(basename "$backup")"
|
||||
}
|
||||
|
||||
# ── Host port collision avoidance (shared by every service that publishes a
|
||||
# fixed host port) ────────────────────────────────────────────────────────────
|
||||
# With 70+ services in this repo, several ship the same default port (e.g.
|
||||
@@ -841,11 +903,19 @@ find_free_coturn_range() {
|
||||
}
|
||||
|
||||
# ── Caddy reverse-proxy wiring (shared by every web service) ─────────────────
|
||||
# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"]
|
||||
# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] ["reverse_proxy-extra"]
|
||||
# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"),
|
||||
# or plain port number for localhost fallback (e.g. "8085").
|
||||
# The optional 5th arg is inserted as sub-directives *inside* the
|
||||
# reverse_proxy block itself (e.g. " header_up X-Proxy-Secret abc123")
|
||||
# — for the rare case a backend needs a header only reverse_proxy's own
|
||||
# header_up can set, as opposed to EXTRA_CONFIG's auth-gate directives that
|
||||
# run before reverse_proxy entirely. See services/frigate.sh's Authelia
|
||||
# integration for the reference caller (pins X-Proxy-Secret so Frigate's
|
||||
# proxy-auth trust can't be spoofed by a request that reaches it directly,
|
||||
# bypassing Caddy/Authelia).
|
||||
configure_caddy_for_service() {
|
||||
local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}"
|
||||
local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" REVERSE_PROXY_EXTRA="${5:-}"
|
||||
|
||||
# Out-params (not `local` — callers read these after the call returns) so
|
||||
# a caller can tell whether Caddy actually ended up fronting the service
|
||||
@@ -856,6 +926,7 @@ configure_caddy_for_service() {
|
||||
# already the only intended way in, instead of leaving both routes open.
|
||||
CADDY_SERVICE_CONFIGURED=false
|
||||
CADDY_SERVICE_MODE=""
|
||||
CADDY_SERVICE_DOMAIN=""
|
||||
|
||||
# Derive the proxy upstream and a port number for display messages.
|
||||
# Plain number → host.docker.internal:PORT (host-network or legacy
|
||||
@@ -916,6 +987,15 @@ configure_caddy_for_service() {
|
||||
if [ -z "$SERVICE_DOMAIN" ]; then
|
||||
echo " ⚠ No domain provided, skipping Caddy configuration."; return 0
|
||||
fi
|
||||
# Set as soon as we know a domain was actually accepted — every path below
|
||||
# this point that returns 0 without configuring Caddy is a genuine failure
|
||||
# (write/reload error), not "no domain chosen", so leaving this set is
|
||||
# correct: the caller can tell CADDY_SERVICE_CONFIGURED apart from whether
|
||||
# a domain was entered at all. Callers that pre-compute their own default
|
||||
# URL/domain before calling this (e.g. services/mealie.sh's BASE_URL) need
|
||||
# this to reconcile against whatever the user actually typed here, which
|
||||
# can differ from that pre-computed default.
|
||||
CADDY_SERVICE_DOMAIN="$SERVICE_DOMAIN"
|
||||
|
||||
# Build the site block — upstream differs by mode
|
||||
local _BLOCK_UPSTREAM="$_UPSTREAM"
|
||||
@@ -931,6 +1011,15 @@ configure_caddy_for_service() {
|
||||
_BLOCK_UPSTREAM="${_THIS_IP}:${_DISPLAY_PORT}"
|
||||
fi
|
||||
|
||||
# Bare "reverse_proxy upstream" unless a caller needs sub-directives
|
||||
# (header_up, etc.) inside it — see the REVERSE_PROXY_EXTRA comment above.
|
||||
local _REVERSE_PROXY_LINE="reverse_proxy ${_BLOCK_UPSTREAM}"
|
||||
if [ -n "$REVERSE_PROXY_EXTRA" ]; then
|
||||
_REVERSE_PROXY_LINE="reverse_proxy ${_BLOCK_UPSTREAM} {
|
||||
${REVERSE_PROXY_EXTRA}
|
||||
}"
|
||||
fi
|
||||
|
||||
local _SITE_BLOCK
|
||||
_SITE_BLOCK="$(cat << CADDY_BLOCK
|
||||
|
||||
@@ -944,7 +1033,7 @@ ${SERVICE_DOMAIN} {
|
||||
# after it would be dead code that never runs — full bypass regardless
|
||||
# of what the auth server's own rules say.
|
||||
${EXTRA_CONFIG}
|
||||
reverse_proxy ${_BLOCK_UPSTREAM}
|
||||
${_REVERSE_PROXY_LINE}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
|
||||
@@ -180,6 +180,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -219,7 +224,20 @@ _actualbudget_offer_authelia_oidc() {
|
||||
|
||||
[ -d "$DOCKER_DIR/authelia" ] || return 0
|
||||
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
|
||||
grep -q '^ACTUAL_OPENID_DISCOVERY_URL=' "$DIR/.env" 2>/dev/null && return 0
|
||||
|
||||
# Confirmed live: a silent skip here (just `return 0`, no output) looked
|
||||
# indistinguishable from the whole SSO step not running at all — a rerun
|
||||
# against an .env that already had these vars (even from an earlier
|
||||
# attempt that didn't fully complete) produced zero output, no prompt,
|
||||
# nothing. Always say something instead, and offer to redo it.
|
||||
if grep -q '^ACTUAL_OPENID_DISCOVERY_URL=' "$DIR/.env" 2>/dev/null; then
|
||||
echo ""
|
||||
log_info "Authelia SSO is already configured for Actual Budget (ACTUAL_OPENID_* already set in $DIR/.env)."
|
||||
local RECONFIGURE=""
|
||||
prompt_yn " Reconfigure it (registers a fresh Authelia client + secret)? (y/n):" "n" RECONFIGURE
|
||||
[[ "$RECONFIGURE" =~ ^[Yy]$ ]] || return 0
|
||||
sed -i '/^ACTUAL_OPENID_/d' "$DIR/.env"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local USE_SSO=""
|
||||
@@ -239,13 +257,13 @@ _actualbudget_offer_authelia_oidc() {
|
||||
prompt_yn " Require two-factor for Actual Budget logins via Authelia too? (y/n):" "y" _2fa
|
||||
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
|
||||
|
||||
if ! _authelia_provision_oidc_client "ActualBudget" "actualbudget" "$AUTH_POLICY" "y" \
|
||||
if ! _authelia_provision_oidc_client "ActualBudget" "actualbudget" "$AUTH_POLICY" "y" "n" "" \
|
||||
"https://${AB_OIDC_DOMAIN}/openid/callback"; then
|
||||
log_warning "Couldn't register Actual Budget as an OIDC client in Authelia — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _discovery_url="https://auth.${OIDC_AUTHELIA_DOMAIN}/.well-known/openid-configuration"
|
||||
local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
|
||||
cat >> "$DIR/.env" << ENV
|
||||
|
||||
# Written by services/actualbudget.sh's Authelia SSO step. The first OIDC
|
||||
@@ -375,6 +393,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << AB_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -392,6 +411,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
AB_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << AB_ENV
|
||||
TZ=$TZ_VAL
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -170,6 +170,11 @@ CBLOCK
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
@@ -368,6 +373,7 @@ install_ai-gpu() {
|
||||
sed -i "s|America/New_York|$TZ_VAL|g" {} \;
|
||||
fi
|
||||
|
||||
backup_if_exists "$IMAGE_GEN_DIR/.env"
|
||||
cat > "$IMAGE_GEN_DIR/.env" << IMGENV
|
||||
# InvokeAI — image generation
|
||||
TZ=${TZ_VAL}
|
||||
@@ -407,6 +413,7 @@ IMGENV
|
||||
local WEBUI_SECRET
|
||||
WEBUI_SECRET="$(generate_password 32)"
|
||||
|
||||
backup_if_exists "$LLM_DIR/.env"
|
||||
cat > "$LLM_DIR/.env" << LLMENV
|
||||
# Ollama + Open WebUI + SearXNG
|
||||
TZ=${TZ_VAL}
|
||||
@@ -441,6 +448,7 @@ LLMENV
|
||||
fi
|
||||
fi
|
||||
|
||||
backup_if_exists "$PORTAL_DIR/.env"
|
||||
cat > "$PORTAL_DIR/.env" << PORTALENV
|
||||
# AI Portal — GPU stack swap controller
|
||||
TZ=${TZ_VAL}
|
||||
|
||||
@@ -10,6 +10,62 @@
|
||||
less text (saves tokens), for both local and cloud models.
|
||||
- Web search uses **DuckDuckGo** (no SearXNG in this build).
|
||||
|
||||
## Hybrid workflow — local coding model + Claude Code
|
||||
Split coding work by size, not by tool preference. This stack's local Ollama
|
||||
coder model (the GPU generations table below has sizing per card) handles
|
||||
fast, in-loop iteration — autocomplete, boilerplate, single-file refactors,
|
||||
private/offline drafting, zero token cost. Claude Code (cloud) handles the
|
||||
bigger, longer, cross-file work — architectural refactors, anything needing
|
||||
full-repo context or stronger judgment — driven against this stack's Gitea
|
||||
(or GitHub, via the `gitea-github-sync.sh` mirror in Roles above).
|
||||
|
||||
### Where to put instructions for each side
|
||||
Claude Code loads `CLAUDE.md` in four tiers, concatenated broadest to most
|
||||
specific — later tiers add to earlier ones, they don't replace them:
|
||||
|
||||
| Tier | Path | Put here |
|
||||
|---|---|---|
|
||||
| User | `~/.claude/CLAUDE.md` | Your personal conventions, true on *every* project — e.g. "CLI menus are numbered, `0` is always exit," "verify UI changes with Playwright," your code-style rules |
|
||||
| Project | `./CLAUDE.md` or `./.claude/CLAUDE.md` | This codebase's own architecture/conventions, shared with collaborators via git (this file is the reference example) |
|
||||
| Local | `./CLAUDE.local.md` (gitignored) | Your personal per-project notes — sandbox URLs, test data |
|
||||
| One-off task | The prompt itself, handed over when you say "go" | The specific feature/idea for *this* build — never durable, don't put it in `CLAUDE.md` |
|
||||
|
||||
Write cross-project quirks into `~/.claude/CLAUDE.md` once — every project
|
||||
inherits them automatically, no per-repo duplication needed. If it grows
|
||||
past ~200 lines, split it into `~/.claude/rules/*.md` (still user-level,
|
||||
loads before project-level rules).
|
||||
|
||||
### Claude Code reading from self-hosted Gitea
|
||||
Two levels, depending on what you need:
|
||||
- **Plain git — works today, nothing to install.** Claude Code's git
|
||||
operations are shell `git` commands, not a GitHub-specific code path —
|
||||
clone/push/pull against this stack's Gitea over SSH or an HTTPS token
|
||||
exactly like any other remote. This only applies to a locally-run Claude
|
||||
Code CLI against your own machine; a cloud/remote Claude Code session
|
||||
(like the one used to write this doc) is scoped to whichever provider —
|
||||
typically GitHub — it was attached to at session start, and can't reach
|
||||
an arbitrary self-hosted Gitea on your LAN.
|
||||
- **PR/issue/CI-level integration (optional).** Reading/commenting on Gitea
|
||||
PRs and issues the way a GitHub MCP server does for GitHub needs an MCP
|
||||
server that speaks Gitea's REST API. Gitea's own project publishes one —
|
||||
`gitea/gitea-mcp` (gitea.com/gitea/gitea-mcp) — as a binary release, a
|
||||
Docker image (`docker.gitea.com/gitea-mcp-server`), or `go run
|
||||
gitea.com/gitea/gitea-mcp@latest`; it supports both stdio and HTTP
|
||||
transport. Generate a token first — this stack's Gitea → profile →
|
||||
Settings → Applications → Generate New Token (repo/api scopes) — then:
|
||||
```bash
|
||||
# stdio — simplest, one Claude Code CLI on this box
|
||||
claude mcp add gitea --env GITEA_HOST=http://localhost:3001 \
|
||||
--env GITEA_ACCESS_TOKEN=<token> -- gitea-mcp -t stdio
|
||||
|
||||
# or HTTP — one server, shared by multiple Claude Code clients
|
||||
gitea-mcp -t http --port 8090 & # run once, e.g. alongside the stack
|
||||
claude mcp add gitea http://localhost:8090/mcp \
|
||||
--header "Authorization: Bearer <token>"
|
||||
```
|
||||
Not bundled by default — this stack's Gitea has no built-in Claude
|
||||
integration out of the box; this is you adding it.
|
||||
|
||||
## GPU switcher (small local GPU only)
|
||||
One small GPU can't run local chat and local image-gen at once. Swap it:
|
||||
```bash
|
||||
@@ -39,6 +95,113 @@ bash pull-models.sh # pull Ollama models (run once after first install)
|
||||
```
|
||||
Also a systemd unit: `sudo systemctl {start,stop,status} local-ai`
|
||||
|
||||
## Vision models (image understanding)
|
||||
None of the tier-selected chat/code models above can read an image. `pull-models.sh`
|
||||
offers one optional vision model at the end — pick it there, or pull one manually
|
||||
any time:
|
||||
```bash
|
||||
docker exec ollama ollama pull moondream # or llava:7b / qwen2.5vl:7b / llama3.2-vision:11b
|
||||
```
|
||||
| Model | Size | Notes |
|
||||
|-------|------|-------|
|
||||
| `moondream` | ~1.7 GB | By Moondream AI — tiny, built for CPU-only or weak/old-GPU hardware. Best default if you don't have a real GPU. |
|
||||
| `llava:7b` | ~4.7 GB | General-purpose vision, moderate resources. |
|
||||
| `qwen2.5vl:7b` | ~6 GB | Stronger accuracy, needs more RAM/VRAM. |
|
||||
| `llama3.2-vision:11b` | ~7.9 GB | Meta's vision model — heaviest of these four. |
|
||||
|
||||
Point any OpenAI-compatible app's vision/image-import feature at this stack's
|
||||
Ollama endpoint with the pulled model. For Mealie's "import recipe from
|
||||
photo" specifically — checked against docs.mealie.io directly, since Mealie
|
||||
moved this off env vars at some point and old `OPENAI_*` env var guidance
|
||||
for it is now stale: it's configured live in the UI, not `.env` —
|
||||
**Group Settings → AI Providers** in Mealie itself, not this stack's
|
||||
`.env` or `docker-compose.yml`. Add a provider with:
|
||||
- `base_url`: `http://host.docker.internal:11434/v1` (Ollama publishes on
|
||||
the host at `0.0.0.0:11434`, and Mealie is a separate compose project
|
||||
not sharing a network with this stack, so it has to be reached over the
|
||||
host the same way Caddy reaches bridge-mode services — see Mealie's own
|
||||
compose: add `extra_hosts: ["host.docker.internal:host-gateway"]` to its
|
||||
`mealie:` service if that hostname doesn't already resolve there. The
|
||||
host's real LAN IP works too with no compose edit, just less stable
|
||||
across DHCP renewals.)
|
||||
- `api_key`: any non-empty placeholder — required by Mealie's form, ignored
|
||||
by Ollama.
|
||||
- model: the vision model just pulled (e.g. `moondream`).
|
||||
|
||||
Then mark that provider as the one used for image recognition (a separate
|
||||
toggle from the general default-provider setting) — that's what actually
|
||||
turns on the photo-import feature. No Mealie container restart needed, it
|
||||
applies live. See Open WebUI → Settings → Connections if you'd rather
|
||||
confirm the local base URL/model name there first.
|
||||
|
||||
## NVIDIA server-GPU generations — capability reference
|
||||
What a given datacenter GPU generation can actually run through this stack
|
||||
(Ollama for chat/code, ComfyUI/InvokeAI for images), since it's VRAM- and
|
||||
tensor-core-bound per generation. Only Ampere and newer have native BF16
|
||||
tensor cores; llama.cpp/Ollama's CUDA backend supports Pascal (compute
|
||||
capability 6.0) and up, so quantized chat/coding model size mostly comes
|
||||
down to VRAM capacity — older cards just run slower per token, with no
|
||||
flash-attention-class kernel path.
|
||||
|
||||
| Generation | Example server cards | VRAM | Flux 2 (32B DiT) | Flux.1 / SDXL | Chat (GGUF, Ollama) | Coding (GGUF, Ollama) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Blackwell (2024-25) | B100 / B200 / GB200 | 180-192GB HBM3e | Yes — FP8 fast, native | Yes, fast | 70B+ at high precision, easily | Any coder model, full precision |
|
||||
| Hopper (2022) | H100 / H200 | 80-141GB HBM3 | Yes — FP8 native tensor cores; the target generation | Yes, fast | 70B in Q4-Q8 comfortably | Qwen2.5-Coder-32B / DeepSeek-Coder-V2, full precision |
|
||||
| Ampere (2020) | A100 40/80GB | 40-80GB HBM2e | Minimum viable — FP8 checkpoint (~32GB) fits the 80GB card; no native FP8 tensor cores, so it's upcast/emulated rather than accelerated | Yes, comfortable (native BF16/TF32) | 70B Q4 (~40GB) fits the 80GB card with room; 30-34B comfortable on the 40GB card | Qwen2.5-Coder-32B / Codestral-22B comfortable |
|
||||
| Volta (2017) | V100 16/32GB | 16-32GB HBM2 | No — even the 32GB card has no headroom for the FP8 checkpoint plus activations | FLUX.1-dev FP8 (~18-23GB) fits the 32GB card, tight; SDXL/SD1.5 fine (first-gen FP16 tensor cores) | 32GB card: 30-34B Q4 comfortable, 70B tight/needs multi-GPU. 16GB card: 13-14B comfortable | 32B coder models fit the 32GB card in Q4 |
|
||||
| Pascal (2016) | P100 16GB / P40 24GB | 16-24GB HBM2/GDDR5 | No | SD1.5 fine; SDXL runs but slow — no tensor cores at all, weak/emulated FP16 (worse on the P40 than the P100) | Same VRAM math as Ampere/Volta at matched capacity (P40 24GB ≈ 30B Q4), but noticeably slower tokens/sec | 32B coder Q4 fits the P40 24GB capacity-wise; fine for batch/background, not snappy interactive autocomplete |
|
||||
| Maxwell (2014) | M40 / M60 24GB | 8-24GB GDDR5 | No | Impractical — SD1.5 only, very slow; no real FP16 tensor path | 7B-13B Q4 runs but slow | 7B-class coder models only — a novelty, not a daily driver |
|
||||
|
||||
**CUDA 13 has already dropped Pascal/Volta** (this happened, it's not a future
|
||||
warning anymore) — but that's the *toolkit*, not the driver, and it doesn't
|
||||
block this stack: Docker GPU passthrough only needs the host *driver* to
|
||||
recognize the card, since prebuilt inference images (Ollama, ComfyUI, etc.)
|
||||
already bundle whatever CUDA runtime they need internally. The driver is the
|
||||
part to get right. **NVIDIA has named R580 the last driver branch that adds
|
||||
Volta/Pascal support** (P100/P40/V100 explicitly listed), supported into
|
||||
~June 2028 — pin to R580 explicitly rather than trusting `ubuntu-drivers
|
||||
autoinstall`'s default pick on a fresh/newer Ubuntu install, since a later
|
||||
branch may no longer initialize these cards at all. Also confirm you land on
|
||||
the **proprietary** driver package, not an `-open` one — NVIDIA's open-source
|
||||
kernel modules only support Turing and newer, so Volta/Pascal *require* the
|
||||
closed-source module; `ubuntu-drivers devices` should recommend the right one
|
||||
for the card it detects, but double-check rather than assume on a distro
|
||||
release that defaults newer GPUs to `-open`. None of this is something
|
||||
`require_docker` handles — it installs Docker/Compose only; the NVIDIA
|
||||
driver and `nvidia-container-toolkit` are still on you to install first,
|
||||
and getting the driver branch right is what actually matters here, not the
|
||||
Ubuntu version itself.
|
||||
|
||||
**"Tesla"-branded card power connector — don't assume standard PCIe.**
|
||||
("Tesla" here is NVIDIA's old datacenter-card *brand name*, retired after
|
||||
Volta — not the unrelated, much older Tesla *microarchitecture* that
|
||||
predates Fermi/Kepler/Maxwell/Pascal/Volta. V100/P100/P40/M40 all shipped
|
||||
under the Tesla brand despite being four different architecture
|
||||
generations.) These PCIe cards take an 8-pin **CPU/EPS12V** connector, not
|
||||
the 6+2-pin PCIe
|
||||
connector a normal GPU uses — a standard PCIe cable will not plug in. Get the
|
||||
dongle/adapter (splits a PCIe 8-pin into EPS12V, or use a real EPS cable) and
|
||||
never daisy-chain both 8-pin rails off one PSU cable/splitter — use two
|
||||
separate cable runs. These cards are also passively cooled (built for server
|
||||
chassis airflow, no onboard fan) — a tower case needs a shroud + dedicated
|
||||
fan blowing through the heatsink fins, and there's no display output, which
|
||||
is a non-issue on a headless box like this but worth knowing going in.
|
||||
|
||||
**MoE models are the exception that gives Pascal/Volta real life for coding.**
|
||||
The "coding" column above assumes dense models, where token speed tracks the
|
||||
full parameter count — exactly where Pascal/Volta's missing or first-gen
|
||||
tensor cores hurt most. A mixture-of-experts model breaks that link: VRAM is
|
||||
still set by *total* params (every expert has to be resident — no memory
|
||||
saving from sparsity), but compute per token is set by *active* params only.
|
||||
`qwen3-coder:30b-a3b` in `ollama pull` is the concrete case — 30B total, only
|
||||
~3.3B active per token (128 experts, 8 routed) — so it needs the same ~19GB
|
||||
VRAM (Q4_K_M) as a dense 30B model but computes like a dense ~3B one. That's
|
||||
light enough that Pascal/Volta's weak tensor cores barely matter, making it
|
||||
the best coding model to put on a P40 24GB or a V100 — a dense 32B coder on
|
||||
the same card would be noticeably slower for no quality gain. Mixtral 8x7B
|
||||
(46.7B total / ~13B active, ~24-26GB at Q4) is the same trade at a larger
|
||||
size — fits Volta 32GB or Ampere, with the same active-vs-total gap.
|
||||
|
||||
## Cloud LLM providers (Open WebUI)
|
||||
Open WebUI uses an OpenAI-compatible connection list. The local RAG server is the
|
||||
first entry; any cloud providers added at install follow it. Two semicolon-separated
|
||||
|
||||
+93
-2
@@ -34,6 +34,8 @@ install_ai-stack() {
|
||||
echo "[DRY-RUN] Would copy vendored source $SRC_DIR -> $AS_DIR"
|
||||
echo "[DRY-RUN] Would optionally collect cloud LLM provider keys (Groq/DeepInfra/OpenAI/OpenRouter)"
|
||||
echo "[DRY-RUN] Would run the app installer local-ai-setup.sh (Docker/NVIDIA toolkit, VRAM-aware models, generates compose/.env, starts stack, registers systemd 'local-ai')"
|
||||
echo "[DRY-RUN] Would offer an optional vision-capable model to pull (moondream/llava/qwen2.5vl/llama3.2-vision) via the generated pull-models.sh"
|
||||
echo "[DRY-RUN] Would offer to stop optional services not wanted (Gitea/Portainer/Kiwix/InvokeAI/ComfyUI/Aider) after the full stack starts"
|
||||
echo "[DRY-RUN] Would wire cloud providers into Open WebUI (OPENAI_API_BASE_URLS) preserving the local RAG connection"
|
||||
echo "[DRY-RUN] Would write gpu-mode.sh and optionally enable the GPU switcher (one small GPU shared by Ollama and InvokeAI/ComfyUI)"
|
||||
echo "[DRY-RUN] Would attach Open WebUI to caddy_net and configure Caddy (open-webui:8080, host port 3000)"
|
||||
@@ -67,12 +69,13 @@ install_ai-stack() {
|
||||
log_info " 2) DeepInfra Cheapest host for open models, zero-retention. Key: https://deepinfra.com/dash/api_keys"
|
||||
log_info " 3) OpenAI GPT-5.x, o-series, gpt-image. Key: https://platform.openai.com/api-keys"
|
||||
log_info " 4) OpenRouter One key, 300+ models. Key: https://openrouter.ai/keys"
|
||||
log_info " 0) Skip — stay fully local"
|
||||
echo ""
|
||||
log_info " Example: '1 2' wires Groq + DeepInfra. Leave blank to stay fully local."
|
||||
log_info " Example: '1 2' wires Groq + DeepInfra."
|
||||
echo ""
|
||||
|
||||
local CLOUD_CHOICES=""
|
||||
prompt_text "Cloud providers to add []:" "" CLOUD_CHOICES
|
||||
prompt_text "Cloud providers to add (0 or blank = skip, stay fully local):" "" CLOUD_CHOICES
|
||||
|
||||
# Parallel arrays: display name, OpenAI-compatible base URL, and entered key
|
||||
declare -a CLOUD_NAMES=() CLOUD_URLS=() CLOUD_KEYS=()
|
||||
@@ -80,6 +83,7 @@ install_ai-stack() {
|
||||
for _c in $CLOUD_CHOICES; do
|
||||
_cname="" ; _curl=""
|
||||
case "$_c" in
|
||||
0) continue ;;
|
||||
1) _cname="Groq"; _curl="https://api.groq.com/openai/v1" ;;
|
||||
2) _cname="DeepInfra"; _curl="https://api.deepinfra.com/v1/openai" ;;
|
||||
3) _cname="OpenAI"; _curl="https://api.openai.com/v1" ;;
|
||||
@@ -121,6 +125,93 @@ install_ai-stack() {
|
||||
log_info "Skipped. Run later: cd $AS_DIR && bash local-ai-setup.sh"
|
||||
fi
|
||||
|
||||
# local-ai-setup.sh runs as whoever invoked this wrapper — root, since
|
||||
# setup.sh itself is run via sudo — so everything it just generated
|
||||
# (docker-compose.yml, .env, requirements.txt, server.py, pull-models.sh,
|
||||
# etc.) comes out root-owned. Hand it back to ACTUAL_USER unconditionally,
|
||||
# not just on the cloud-provider path below. Confirmed live: without this,
|
||||
# re-running local-ai-setup.sh directly later (the update path, plain user,
|
||||
# no sudo — exactly what its own "run later" message above tells you to do)
|
||||
# fails with "Permission denied" on any file the first root-run created,
|
||||
# e.g. requirements.txt.
|
||||
ensure_docker_dir_ownership "$AS_DIR"
|
||||
|
||||
# ── Optional services — not everyone wants the whole stack running ────────
|
||||
# local-ai-setup.sh above always generates and starts every service in the
|
||||
# stack unconditionally — Ollama/Open WebUI/ChromaDB/RAG/MCP (the core) plus
|
||||
# Gitea, Portainer, Kiwix, InvokeAI, ComfyUI, and Aider. Several of those
|
||||
# are genuinely optional depending on the box — e.g. Gitea when you already
|
||||
# run git elsewhere, or Portainer when you manage Docker some other way.
|
||||
# Rather than make local-ai-setup.sh's own compose generation conditional
|
||||
# (risky: it's vendored upstream code, and other services reference these
|
||||
# by container name/network in ways that would need auditing one by one),
|
||||
# just stop the ones not wanted after the fact — images are already pulled
|
||||
# either way, and `docker compose up -d <name>` brings any of them back
|
||||
# later with no reinstall needed. User feedback: wanted this choice instead
|
||||
# of always getting the full stack.
|
||||
if [ "$INSTALLER_RAN" = true ]; then
|
||||
echo ""
|
||||
log_info "The full stack is running. Some of these are genuinely optional —"
|
||||
log_info "stop the ones you don't need (start any of them again later with"
|
||||
log_info "'docker compose up -d <service>', no reinstall required):"
|
||||
echo ""
|
||||
echo " 1) Gitea — skip if you already run git elsewhere"
|
||||
echo " 2) Portainer — skip if you manage Docker some other way"
|
||||
echo " 3) Kiwix — offline Wikipedia/docs server"
|
||||
echo " 4) InvokeAI — image generation (SD/SDXL/Flux)"
|
||||
echo " 5) ComfyUI — image generation (node-based)"
|
||||
echo " 6) Aider — AI pair-programming CLI"
|
||||
echo " 7) RAG/MCP stack — ChromaDB + rag-server + mcp-server, for Open WebUI's"
|
||||
echo " RAG tab and MCP tool-calling. Skip if you don't use"
|
||||
echo " those — plain Ollama chat in Open WebUI (and anything"
|
||||
echo " else, like Mealie, talking to Ollama directly) works"
|
||||
echo " fine without this; only that one tab needs it."
|
||||
echo ""
|
||||
local STOP_CHOICES=""
|
||||
prompt_text "Stop which of these? (space-separated numbers, blank to keep everything running):" "" STOP_CHOICES
|
||||
local _s _svc
|
||||
declare -a _TO_STOP=()
|
||||
local _stopping_kiwix=false
|
||||
for _s in $STOP_CHOICES; do
|
||||
case "$_s" in
|
||||
1) _TO_STOP+=("gitea") ;;
|
||||
2) _TO_STOP+=("portainer") ;;
|
||||
3) _TO_STOP+=("kiwix"); _stopping_kiwix=true ;;
|
||||
4) _TO_STOP+=("invokeai") ;;
|
||||
5) _TO_STOP+=("comfyui") ;;
|
||||
6) _TO_STOP+=("aider") ;;
|
||||
# Bundled, not three separate numbers: mcp-server depends_on
|
||||
# rag-server which depends_on chromadb, so stopping only one
|
||||
# of the three leaves the others running against a dead
|
||||
# dependency instead of a clean, fully-stopped chain.
|
||||
7) _TO_STOP+=("mcp-server" "rag-server" "chromadb") ;;
|
||||
*) log_warning "Ignoring unknown choice '$_s'"; continue ;;
|
||||
esac
|
||||
done
|
||||
# mcp-server also depends_on kiwix (not just rag-server) — stopping
|
||||
# kiwix without also stopping mcp-server leaves it running against a
|
||||
# dependency that's down, the same inconsistent state option 7 above
|
||||
# is written to avoid. Cascade automatically rather than trust the
|
||||
# user to notice the same rule applies here too.
|
||||
if [ "$_stopping_kiwix" = true ] && [[ ! " ${_TO_STOP[*]} " == *" mcp-server "* ]]; then
|
||||
log_info "Kiwix is also a dependency of mcp-server — stopping that too."
|
||||
_TO_STOP+=("mcp-server")
|
||||
fi
|
||||
if [ ${#_TO_STOP[@]} -gt 0 ]; then
|
||||
# Dedupe in case option 7 and the kiwix cascade both added mcp-server.
|
||||
local -a _TO_STOP_UNIQUE=()
|
||||
local _seen=" "
|
||||
for _svc in "${_TO_STOP[@]}"; do
|
||||
[[ "$_seen" == *" $_svc "* ]] && continue
|
||||
_TO_STOP_UNIQUE+=("$_svc")
|
||||
_seen+="$_svc "
|
||||
done
|
||||
(cd "$AS_DIR" && docker compose stop "${_TO_STOP_UNIQUE[@]}") \
|
||||
&& log_success "Stopped: ${_TO_STOP_UNIQUE[*]} (images still pulled — bring any back with: docker compose up -d <name>)" \
|
||||
|| log_warning "Couldn't stop one or more services — check: docker compose ps"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Wire cloud providers into the generated compose ───────────────────────
|
||||
if [ ${#CLOUD_NAMES[@]} -gt 0 ] && [ -f "$AS_DIR/docker-compose.yml" ]; then
|
||||
# Prepend the local RAG connection so RAG keeps working, then the clouds.
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
#!/bin/bash
|
||||
# services/anki-progress.sh — Anki study-progress dashboard + ntfy
|
||||
# "started studying" notifications. Reads an anki-sync-server instance's
|
||||
# data directly (read-only) — see services/anki-sync-server.sh, which this
|
||||
# service requires.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
#
|
||||
# Can also be run standalone on any machine:
|
||||
# sudo bash anki-progress.sh
|
||||
# (Docker must already be installed, and an anki-sync-server instance must
|
||||
# already exist on the same box, when run standalone)
|
||||
|
||||
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||
|
||||
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||
|
||||
if [[ -f "$_COMMON" ]]; then
|
||||
# shellcheck source=../lib/common.sh
|
||||
source "$_COMMON"
|
||||
else
|
||||
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||
|
||||
require_docker() {
|
||||
command -v docker &>/dev/null || {
|
||||
log_error "Docker not found. Install it first:"
|
||||
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||
return 1
|
||||
}
|
||||
docker compose version &>/dev/null || {
|
||||
log_error "Docker Compose plugin missing:"
|
||||
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
ensure_docker_dir_ownership() {
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_yn() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_reinstall_mode() {
|
||||
local _var="$1" _r
|
||||
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
|
||||
echo " Already installed."
|
||||
read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r
|
||||
case "${_r,,}" in
|
||||
u|update) eval "$_var='update'" ;;
|
||||
f|fresh) eval "$_var='fresh'" ;;
|
||||
*) eval "$_var='cancel'" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
configure_caddy_for_service() {
|
||||
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||
local _display_port="${_upstream##*:}"
|
||||
|
||||
local _mode="none"
|
||||
[[ -d "$_caddy_dir" ]] && _mode="local"
|
||||
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
|
||||
[[ "$_mode" == "none" ]] && {
|
||||
log_info "Access $_name directly on port $_display_port."
|
||||
return 0
|
||||
}
|
||||
|
||||
echo ""
|
||||
local _do_caddy=""
|
||||
if [[ "$_mode" == "remote" ]]; then
|
||||
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
|
||||
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
|
||||
fi
|
||||
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||||
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||
log_info "Skipping — access at: http://localhost:$_display_port"
|
||||
return 0
|
||||
}
|
||||
|
||||
local _default_domain=""
|
||||
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
|
||||
_default_domain="${_subdomain}.${SITE_DOMAIN}"
|
||||
log_info "Default: $_default_domain"
|
||||
fi
|
||||
local _domain=""
|
||||
read -r -p " Domain [${_default_domain:-required}]: " _domain
|
||||
_domain="${_domain:-$_default_domain}"
|
||||
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||
|
||||
local _block_upstream="$_upstream"
|
||||
if [[ "$_mode" == "remote" ]]; then
|
||||
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
|
||||
fi
|
||||
|
||||
local _site_block
|
||||
_site_block="$(cat << CBLOCK
|
||||
|
||||
# $_name
|
||||
${_domain} {
|
||||
reverse_proxy ${_block_upstream}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/${_domain}.log
|
||||
format json
|
||||
}
|
||||
${_extra}
|
||||
}
|
||||
CBLOCK
|
||||
)"
|
||||
|
||||
if [[ "$_mode" == "local" ]]; then
|
||||
if [[ -f "$_caddyfile" ]]; then
|
||||
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$_caddyfile" "$_bk"
|
||||
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||||
else
|
||||
touch "$_caddyfile"
|
||||
fi
|
||||
|
||||
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||||
log_warning "$_domain already in Caddyfile"
|
||||
local _ow=""
|
||||
read -r -p " Overwrite? [y/N]: " _ow
|
||||
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
||||
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$_site_block" >> "$_caddyfile"
|
||||
log_success "Added $_domain to Caddyfile"
|
||||
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
log_success "$_name accessible at: https://$_domain"
|
||||
else
|
||||
log_warning "Reload failed — check: docker logs caddy"
|
||||
fi
|
||||
else
|
||||
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
||||
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
|
||||
mkdir -p "$_snippet_dir"
|
||||
printf '%s\n' "$_site_block" > "$_snippet_file"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
||||
log_success "Snippet saved: $_snippet_file"
|
||||
fi
|
||||
}
|
||||
write_readme() {
|
||||
local _dir="$1"; shift
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
UNATTENDED="${UNATTENDED:-false}"
|
||||
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||||
|
||||
register_service() { :; }
|
||||
_RUN_STANDALONE=1
|
||||
fi
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
register_service anki-progress utilities "Anki study-progress dashboard + ntfy 'started studying' notifications (reads an anki-sync-server instance's data read-only)" 8099
|
||||
|
||||
install_anki-progress() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Anki Progress Dashboard..."
|
||||
|
||||
# ── Dependency: needs an anki-sync-server instance already installed ────
|
||||
# Meaningless on its own — see CLAUDE.md's "Chaining into another
|
||||
# service" section. Only chains one direction: anki-progress requires
|
||||
# anki-sync-server, never the reverse.
|
||||
local _sync_dirs=()
|
||||
local _d
|
||||
for _d in "$DOCKER_DIR"/anki-sync-server*; do
|
||||
[ -d "$_d" ] && _sync_dirs+=("$(basename "$_d")")
|
||||
done
|
||||
if [ "${#_sync_dirs[@]}" -eq 0 ]; then
|
||||
log_error "No anki-sync-server install found — this dashboard reads its data directly."
|
||||
log_error "Install it first: sudo ./setup.sh anki-sync-server"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local SYNC_INSTANCE="${_sync_dirs[0]}"
|
||||
if [ "${#_sync_dirs[@]}" -gt 1 ] && [ "$UNATTENDED" != true ]; then
|
||||
echo ""
|
||||
echo " Multiple anki-sync-server instances found:"
|
||||
local i
|
||||
for i in "${!_sync_dirs[@]}"; do
|
||||
echo " $((i + 1))) ${_sync_dirs[$i]}"
|
||||
done
|
||||
local _choice=""
|
||||
prompt_text " Which one should this dashboard monitor? [1]:" "1" _choice
|
||||
if [[ "$_choice" =~ ^[0-9]+$ ]] && [ "$_choice" -ge 1 ] && [ "$_choice" -le "${#_sync_dirs[@]}" ]; then
|
||||
SYNC_INSTANCE="${_sync_dirs[$((_choice - 1))]}"
|
||||
fi
|
||||
fi
|
||||
local SYNC_DATA_DIR="$DOCKER_DIR/$SYNC_INSTANCE/data"
|
||||
|
||||
# ── Instance selection (of this dashboard itself) ───────────────────────
|
||||
# A second instance is a real use case (e.g. a second household with its
|
||||
# own anki-sync-server and its own dashboard) — same multi-instance
|
||||
# pattern as every other service here (see CLAUDE.md).
|
||||
local AP_DIR="$DOCKER_DIR/anki-progress"
|
||||
local INSTANCE_SUFFIX="" CONTAINER="anki-progress"
|
||||
local WEB_PORT="8099"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would verify an anki-sync-server instance exists ($SYNC_INSTANCE found)"
|
||||
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||
echo "[DRY-RUN] Would create $AP_DIR(-<name>) with app.py, Dockerfile, docker-compose.yml"
|
||||
echo "[DRY-RUN] Would prompt for ntfy URL/topic and notification timing"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -d "$AP_DIR" ]; then
|
||||
echo ""
|
||||
echo " Anki Progress Dashboard is already installed at $AP_DIR."
|
||||
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||
echo " 2) Add a NEW, separate dashboard instance alongside it"
|
||||
echo ""
|
||||
local _TOP_CHOICE=""
|
||||
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||
local _suffix=""
|
||||
while true; do
|
||||
prompt_text " Short name for the new instance (letters/numbers/hyphens):" "" _suffix
|
||||
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||
if [ -z "$_suffix" ]; then
|
||||
log_warning "Name can't be empty."; continue
|
||||
fi
|
||||
if [ -d "$DOCKER_DIR/anki-progress-$_suffix" ]; then
|
||||
log_warning "anki-progress-$_suffix already exists — pick another name."; continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
AP_DIR="$DOCKER_DIR/anki-progress-$_suffix"
|
||||
CONTAINER="anki-progress-$_suffix"
|
||||
log_info "New instance: $AP_DIR"
|
||||
else
|
||||
if [[ -f "$AP_DIR/docker-compose.yml" ]]; then
|
||||
local MODE=""
|
||||
prompt_reinstall_mode MODE
|
||||
case "$MODE" in
|
||||
update)
|
||||
log_info "Refreshing app code + rebuilding the image — ntfy config and Caddy setup are left as-is."
|
||||
( cd "$AP_DIR" && docker compose up -d --build ) \
|
||||
&& log_success "Anki Progress Dashboard refreshed" \
|
||||
|| log_warning "Refresh failed — check: docker compose -f $AP_DIR/docker-compose.yml logs"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
log_info "Leaving the existing install as-is."
|
||||
return 0
|
||||
;;
|
||||
fresh) ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
# ── ntfy ──────────────────────────────────────────────────────────────
|
||||
# If ntfy is installed locally, reach it directly over caddy_net by
|
||||
# container name — avoids a round trip through the public internet for
|
||||
# a purely internal notification. Otherwise ask for a full URL (a
|
||||
# remote/self-hosted instance elsewhere, or public ntfy.sh).
|
||||
local NTFY_URL="" NTFY_TOPIC=""
|
||||
if [ -d "$DOCKER_DIR/ntfy" ]; then
|
||||
log_info "Local ntfy install detected — reaching it directly over caddy_net."
|
||||
NTFY_URL="http://ntfy:80"
|
||||
else
|
||||
prompt_text " ntfy server URL (e.g. https://ntfy.yourdomain.com, or https://ntfy.sh):" "https://ntfy.sh" NTFY_URL
|
||||
fi
|
||||
prompt_text " ntfy topic to publish 'started studying' notifications to:" "anki-progress" NTFY_TOPIC
|
||||
|
||||
local SESSION_GAP_MINUTES="" NOTIFY_DELAY_MINUTES=""
|
||||
prompt_text " Minutes of inactivity that counts as a new study session starting:" "30" SESSION_GAP_MINUTES
|
||||
prompt_text " Minutes after a session starts to send the notification:" "10" NOTIFY_DELAY_MINUTES
|
||||
|
||||
mkdir -p "$AP_DIR/state"
|
||||
ensure_docker_dir_ownership "$AP_DIR"
|
||||
cd "$AP_DIR" || return 1
|
||||
|
||||
# Mirrors configure_caddy_for_service's own mode resolution — only
|
||||
# "local" joins caddy_net.
|
||||
local _CADDY_MODE="${CADDY_MODE:-none}"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
|
||||
|
||||
local _CADDY_NET_BLOCK=""
|
||||
local _CADDY_NET_SECTION=""
|
||||
if [ "$_CADDY_MODE" = "local" ]; then
|
||||
_CADDY_NET_BLOCK=" networks:
|
||||
- caddy_net
|
||||
"
|
||||
_CADDY_NET_SECTION="
|
||||
networks:
|
||||
caddy_net:
|
||||
external: true
|
||||
name: ${SITE_CADDY_NET:-caddy_net}
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists app.py
|
||||
cat > app.py << 'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
"""Anki study-progress dashboard + ntfy "started studying" notifications.
|
||||
|
||||
Reads every account's collection.anki2 directly (READ-ONLY — never opens for
|
||||
write, so it can't corrupt live data the sync server or a client is using)
|
||||
from the anki-sync-server's data directory, and:
|
||||
|
||||
1. Serves a small web dashboard (reviews today/week, accuracy, streak,
|
||||
last active) per account.
|
||||
2. Runs a background loop that detects when a new study session starts
|
||||
(first review after a gap of SESSION_GAP_MINUTES with no reviews) and
|
||||
sends one ntfy notification NOTIFY_DELAY_MINUTES after that session
|
||||
started, if the session is still going (i.e. more reviews happened
|
||||
after the initial one) — not on every single review.
|
||||
|
||||
All configuration (NTFY_URL, NTFY_TOPIC, SESSION_GAP_MINUTES,
|
||||
NOTIFY_DELAY_MINUTES, ANKI_DATA_DIR, STATE_FILE) comes from environment
|
||||
variables, set in docker-compose.yml / .env by the installer — nothing to
|
||||
hand-edit in this file.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
from flask import Flask, render_template_string
|
||||
|
||||
NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.example.com")
|
||||
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "anki-progress")
|
||||
|
||||
SESSION_GAP_MINUTES = int(os.environ.get("SESSION_GAP_MINUTES", 30))
|
||||
NOTIFY_DELAY_MINUTES = int(os.environ.get("NOTIFY_DELAY_MINUTES", 10))
|
||||
POLL_INTERVAL_SECONDS = 60
|
||||
|
||||
ANKI_DATA_DIR = os.environ.get("ANKI_DATA_DIR", "/anki-data")
|
||||
STATE_FILE = os.environ.get("STATE_FILE", "/app/state/notify_state.json")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def find_collections():
|
||||
"""{username: path-to-collection-file} for every account directory found.
|
||||
Globs for *.anki2 rather than assuming the exact filename, since that's
|
||||
an implementation detail of the sync server we shouldn't hardcode."""
|
||||
result = {}
|
||||
if not os.path.isdir(ANKI_DATA_DIR):
|
||||
return result
|
||||
for entry in sorted(os.listdir(ANKI_DATA_DIR)):
|
||||
user_dir = os.path.join(ANKI_DATA_DIR, entry)
|
||||
if not os.path.isdir(user_dir):
|
||||
continue
|
||||
matches = glob.glob(os.path.join(user_dir, "*.anki2"))
|
||||
if matches:
|
||||
result[entry] = matches[0]
|
||||
return result
|
||||
|
||||
|
||||
def read_revlog_ids_eases(path):
|
||||
"""Returns a list of (epoch_ms, ease) tuples sorted by time, read-only.
|
||||
Opening with mode=ro is what makes this safe to run alongside a live
|
||||
sync server — it never takes a write lock, so it can't corrupt or
|
||||
block the account that's actually in use."""
|
||||
uri = f"file:{path}?mode=ro"
|
||||
con = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
rows = con.execute("SELECT id, ease FROM revlog ORDER BY id ASC").fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
rows = []
|
||||
finally:
|
||||
con.close()
|
||||
return rows
|
||||
|
||||
|
||||
def compute_stats(revlog_rows, now_ms):
|
||||
"""Pure function over a list of (epoch_ms, ease) — kept separate from
|
||||
any file/DB access so it can be unit-tested with synthetic data."""
|
||||
if not revlog_rows:
|
||||
return {
|
||||
"total_reviews": 0, "reviews_today": 0, "reviews_week": 0,
|
||||
"accuracy_pct": None, "streak_days": 0, "last_active": None,
|
||||
}
|
||||
|
||||
day_ms = 24 * 60 * 60 * 1000
|
||||
today_day = now_ms // day_ms
|
||||
today_start = today_day * day_ms
|
||||
week_start = today_start - 6 * day_ms
|
||||
|
||||
reviews_today = sum(1 for ts, _ in revlog_rows if ts >= today_start)
|
||||
reviews_week = sum(1 for ts, _ in revlog_rows if ts >= week_start)
|
||||
total = len(revlog_rows)
|
||||
correct = sum(1 for _, ease in revlog_rows if ease != 1) # ease 1 = "Again" = a miss
|
||||
accuracy_pct = round(100 * correct / total, 1) if total else None
|
||||
|
||||
# Streak: consecutive calendar days with >=1 review, walking backward
|
||||
# from today. Still "alive" through yesterday if today has no reviews
|
||||
# yet (so it doesn't reset to 0 first thing each morning) — but not if
|
||||
# the most recent review is 2+ days old. review_days is unique/sorted
|
||||
# descending, so any day that isn't exactly "expected" means a gap.
|
||||
review_days = sorted({ts // day_ms for ts, _ in revlog_rows}, reverse=True)
|
||||
streak = 0
|
||||
if review_days and review_days[0] in (today_day, today_day - 1):
|
||||
expected = review_days[0]
|
||||
for d in review_days:
|
||||
if d == expected:
|
||||
streak += 1
|
||||
expected -= 1
|
||||
else:
|
||||
break
|
||||
|
||||
last_active = max(ts for ts, _ in revlog_rows)
|
||||
|
||||
return {
|
||||
"total_reviews": total,
|
||||
"reviews_today": reviews_today,
|
||||
"reviews_week": reviews_week,
|
||||
"accuracy_pct": accuracy_pct,
|
||||
"streak_days": streak,
|
||||
"last_active": last_active,
|
||||
}
|
||||
|
||||
|
||||
def detect_current_session_start(revlog_rows, now_ms):
|
||||
"""Walk backwards from the most recent review; the session start is the
|
||||
earliest review such that every gap between consecutive reviews from
|
||||
there to now is < SESSION_GAP_MINUTES. Returns None if the most recent
|
||||
review itself is older than the gap threshold (no session "in progress")."""
|
||||
if not revlog_rows:
|
||||
return None
|
||||
gap_ms = SESSION_GAP_MINUTES * 60 * 1000
|
||||
last_ts = revlog_rows[-1][0]
|
||||
if now_ms - last_ts > gap_ms:
|
||||
return None # most recent review is old news, not an active session
|
||||
|
||||
session_start = last_ts
|
||||
for ts, _ in reversed(revlog_rows[:-1]):
|
||||
if session_start - ts > gap_ms:
|
||||
break
|
||||
session_start = ts
|
||||
return session_start
|
||||
|
||||
|
||||
DASHBOARD_TEMPLATE = """
|
||||
<!doctype html>
|
||||
<title>Anki Progress</title>
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f6f8; margin: 0; padding: 24px; }
|
||||
h1 { color: #333; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.card { background: white; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||
.card h2 { margin: 0 0 10px 0; font-size: 20px; }
|
||||
.stat { display: flex; justify-content: space-between; margin: 4px 0; font-size: 15px; }
|
||||
.stat b { color: #1c4587; }
|
||||
.empty { color: #888; font-style: italic; }
|
||||
</style>
|
||||
<h1>Anki Progress</h1>
|
||||
<div class="grid">
|
||||
{% for user, s in stats.items() %}
|
||||
<div class="card">
|
||||
<h2>{{ user }}</h2>
|
||||
{% if s.total_reviews == 0 %}
|
||||
<div class="empty">No reviews yet</div>
|
||||
{% else %}
|
||||
<div class="stat"><span>Reviews today</span><b>{{ s.reviews_today }}</b></div>
|
||||
<div class="stat"><span>Reviews this week</span><b>{{ s.reviews_week }}</b></div>
|
||||
<div class="stat"><span>Accuracy</span><b>{{ s.accuracy_pct }}%</b></div>
|
||||
<div class="stat"><span>Streak</span><b>{{ s.streak_days }} day{{ 's' if s.streak_days != 1 else '' }}</b></div>
|
||||
<div class="stat"><span>Last active</span><b>{{ s.last_active_str }}</b></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def dashboard():
|
||||
now_ms = int(time.time() * 1000)
|
||||
stats = {}
|
||||
for user, path in find_collections().items():
|
||||
rows = read_revlog_ids_eases(path)
|
||||
s = compute_stats(rows, now_ms)
|
||||
if s["last_active"]:
|
||||
s["last_active_str"] = datetime.fromtimestamp(
|
||||
s["last_active"] / 1000, tz=timezone.utc
|
||||
).astimezone().strftime("%b %-d, %-I:%M %p")
|
||||
else:
|
||||
s["last_active_str"] = "—"
|
||||
stats[user] = s
|
||||
return render_template_string(DASHBOARD_TEMPLATE, stats=stats)
|
||||
|
||||
|
||||
def load_notify_state():
|
||||
if os.path.isfile(STATE_FILE):
|
||||
with open(STATE_FILE) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def save_notify_state(state):
|
||||
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump(state, f)
|
||||
|
||||
|
||||
def send_ntfy(message):
|
||||
try:
|
||||
requests.post(f"{NTFY_URL.rstrip('/')}/{NTFY_TOPIC}",
|
||||
data=message.encode("utf-8"), timeout=10)
|
||||
except requests.RequestException as e:
|
||||
print(f"[ntfy] failed to send: {e}")
|
||||
|
||||
|
||||
def notifier_loop():
|
||||
state = load_notify_state()
|
||||
while True:
|
||||
now_ms = int(time.time() * 1000)
|
||||
for user, path in find_collections().items():
|
||||
rows = read_revlog_ids_eases(path)
|
||||
session_start = detect_current_session_start(rows, now_ms)
|
||||
entry = state.get(user, {})
|
||||
|
||||
if session_start is None:
|
||||
# No active session right now — clear tracking so the next
|
||||
# real session starts fresh.
|
||||
if entry:
|
||||
state[user] = {}
|
||||
continue
|
||||
|
||||
if entry.get("session_start") != session_start:
|
||||
# A new session started (different from whatever we were
|
||||
# tracking) — start the countdown over.
|
||||
state[user] = {"session_start": session_start, "notified": False}
|
||||
entry = state[user]
|
||||
|
||||
elapsed_minutes = (now_ms - session_start) / 60000
|
||||
if not entry.get("notified") and elapsed_minutes >= NOTIFY_DELAY_MINUTES:
|
||||
send_ntfy(f"{user} started studying {NOTIFY_DELAY_MINUTES} minutes ago and is still going.")
|
||||
entry["notified"] = True
|
||||
|
||||
save_notify_state(state)
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=notifier_loop, daemon=True).start()
|
||||
app.run(host="0.0.0.0", port=5000)
|
||||
PYEOF
|
||||
|
||||
backup_if_exists Dockerfile
|
||||
cat > Dockerfile << 'DOCKEREOF'
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
RUN pip install --no-cache-dir flask requests
|
||||
COPY app.py .
|
||||
CMD ["python3", "app.py"]
|
||||
DOCKEREOF
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << COMPOSEEOF
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
$CONTAINER:
|
||||
build: .
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- ANKI_DATA_DIR=/anki-data
|
||||
- STATE_FILE=/app/state/notify_state.json
|
||||
volumes:
|
||||
# Read-only — this container only ever reads collection files (see
|
||||
# app.py's read_revlog_ids_eases, which opens SQLite in mode=ro),
|
||||
# never writes, so it can't corrupt live data the sync server or a
|
||||
# client is using.
|
||||
- $SYNC_DATA_DIR:/anki-data:ro
|
||||
- ./state:/app/state
|
||||
ports:
|
||||
- "${WEB_PORT}:5000"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
COMPOSEEOF
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ENVEOF
|
||||
NTFY_URL=$NTFY_URL
|
||||
NTFY_TOPIC=$NTFY_TOPIC
|
||||
SESSION_GAP_MINUTES=$SESSION_GAP_MINUTES
|
||||
NOTIFY_DELAY_MINUTES=$NOTIFY_DELAY_MINUTES
|
||||
ENVEOF
|
||||
chmod 600 .env
|
||||
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AP_DIR"
|
||||
|
||||
echo ""
|
||||
log_success "Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $AP_DIR (port $WEB_PORT)"
|
||||
log_info "Monitoring: $SYNC_INSTANCE"
|
||||
|
||||
local START=""
|
||||
prompt_yn "Start Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START
|
||||
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
|
||||
docker compose up -d --build \
|
||||
&& log_success "Anki Progress Dashboard started" \
|
||||
|| log_warning "Start failed — check: docker compose logs"
|
||||
fi
|
||||
|
||||
# ── Caddy + Authelia-aware protection ────────────────────────────────────
|
||||
# This dashboard shows every account's personal study activity — same
|
||||
# "sensitive, protect by default" reasoning as
|
||||
# services/security-dashboard.sh: auto-use local Authelia if present, no
|
||||
# prompt needed; otherwise warn clearly and offer a remote instance,
|
||||
# since leaving it open is a real privacy tradeoff, not a neutral default.
|
||||
local EXTRA_BLOCK=""
|
||||
if [ -d "$DOCKER_DIR/authelia" ]; then
|
||||
EXTRA_BLOCK=" import authelia"
|
||||
log_info "Local Authelia detected — protecting with it."
|
||||
else
|
||||
log_warning "No local Authelia found. This dashboard shows every account's"
|
||||
log_warning "personal study activity — recommend protecting it before"
|
||||
log_warning "exposing it publicly."
|
||||
local _use_remote=""
|
||||
prompt_yn " Protect with a remote Authelia instance (e.g. on a homelab)? (y/n):" "y" _use_remote
|
||||
if [[ "$_use_remote" =~ ^[Yy]$ ]]; then
|
||||
local _remote_authelia=""
|
||||
prompt_text " Remote Authelia address (bare host:port on a private network, or a full https:// URL on its own public domain+TLS):" "" _remote_authelia
|
||||
if [ -n "$_remote_authelia" ]; then
|
||||
EXTRA_BLOCK=" forward_auth ${_remote_authelia} {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||
header_up X-Forwarded-Method {method}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-Host {host}
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
configure_caddy_for_service "Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5000" "anki-progress${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" "$EXTRA_BLOCK"
|
||||
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ] \
|
||||
&& _authelia_scope_access "anki-progress" "$CADDY_SERVICE_DOMAIN"
|
||||
|
||||
write_readme "$AP_DIR" << MD
|
||||
# Anki Progress Dashboard${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Read-only study-progress dashboard for the accounts on **$SYNC_INSTANCE**
|
||||
(reviews today/this week, accuracy, streak, last active), plus an ntfy
|
||||
notification sent ${NOTIFY_DELAY_MINUTES} minutes after a study session
|
||||
starts — defined as the first review after ${SESSION_GAP_MINUTES}+ minutes
|
||||
of inactivity, and only sent if the session is still going at that point
|
||||
(not on every single review, and not for a session that's already over).
|
||||
|
||||
Reads collection files directly with SQLite's read-only mode — never opens
|
||||
them for write, so it can't corrupt or interfere with the live sync server
|
||||
or any client actively syncing.
|
||||
|
||||
## Access
|
||||
- URL: $( [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" )
|
||||
|
||||
## Config
|
||||
- \`$AP_DIR/.env\` — ntfy URL/topic, session-gap and notify-delay minutes
|
||||
- Edit and \`docker compose up -d\` to apply changes (no rebuild needed —
|
||||
these are read at container start from environment variables)
|
||||
|
||||
## Manage
|
||||
\`\`\`bash
|
||||
cd $AP_DIR
|
||||
docker compose up -d --build
|
||||
docker compose down
|
||||
docker compose logs -f
|
||||
\`\`\`
|
||||
MD
|
||||
}
|
||||
|
||||
# ── Standalone execution ───────────────────────────────────────────────────
|
||||
if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then
|
||||
install_anki-progress
|
||||
fi
|
||||
@@ -0,0 +1,82 @@
|
||||
## Client setup — pointing Anki at this server instead of AnkiWeb
|
||||
|
||||
Every client below needs the **Sync URL** and one of the **accounts** shown
|
||||
higher up in this README. Do this on every device you want synced — a client
|
||||
still pointed at AnkiWeb won't see collections synced here, and vice versa.
|
||||
|
||||
### Anki Desktop (2.1.66 and newer)
|
||||
1. **Preferences → Network**
|
||||
2. Tick **"Self-hosted sync server"**
|
||||
3. Paste the Sync URL into the field that appears
|
||||
4. **Sync → log in** with one of the accounts above
|
||||
|
||||
### Anki Desktop (older than 2.1.66)
|
||||
There's no GUI field yet — set an environment variable before launching Anki
|
||||
instead, then sync normally:
|
||||
```bash
|
||||
# Linux/macOS
|
||||
export SYNC_ENDPOINT="https://your-sync-url/"
|
||||
anki
|
||||
|
||||
# Windows (Command Prompt)
|
||||
set SYNC_ENDPOINT=https://your-sync-url/
|
||||
anki.exe
|
||||
```
|
||||
Upgrading Anki to 2.1.66+ is the easier long-term fix — do that if you're
|
||||
setting this up for anyone who isn't comfortable with environment variables.
|
||||
|
||||
### AnkiDroid
|
||||
**Settings → Advanced → Custom sync server**, then enter the Sync URL and
|
||||
log in with one of the accounts above (AnkiDroid 2.16+; update the app if
|
||||
this option isn't there).
|
||||
|
||||
### AnkiMobile (iOS)
|
||||
**Settings → Advanced → Custom Sync Server**, same as AnkiDroid — enter the
|
||||
Sync URL and log in.
|
||||
|
||||
### First sync on each device
|
||||
The very first sync from a device that already has a local collection will
|
||||
ask whether to upload local data or download from the server — pick upload
|
||||
from whichever device has your real collection, and download on every other
|
||||
device, or you'll end up with two different collections that never merge.
|
||||
|
||||
## Importing your existing Quizlet sets
|
||||
|
||||
This server only handles syncing already-existing Anki collections — it
|
||||
doesn't import anything itself. Quizlet import happens once, locally, in the
|
||||
Anki desktop app, before your first sync:
|
||||
|
||||
1. **In Quizlet:** open the set → **Export** → choose the plain-text /
|
||||
tab-separated format (Quizlet's export dialog lets you pick the delimiter
|
||||
between term and definition, and between rows — tab and newline are the
|
||||
Anki-friendly defaults) → copy the exported text or download it as a
|
||||
`.txt`/`.csv` file.
|
||||
2. **In Anki Desktop:** **File → Import**, pick the file (or paste the text
|
||||
into a `.txt` file first if you copied it to the clipboard).
|
||||
3. Map the two columns to **Front** and **Back** in the import dialog, pick
|
||||
or create the deck and note type, and import.
|
||||
4. For **math facts or other simple front/back cards**, the Basic note type
|
||||
is enough. For **more complex cards** (extra example fields, images,
|
||||
audio, cloze deletions), switch the note type in the import dialog to a
|
||||
template with more fields, or convert cards afterward — Anki's own
|
||||
built-in note types (Basic, Basic (and reversed card), Cloze) cover most
|
||||
of what Quizlet's own card types can do.
|
||||
5. Sync from this device once the import looks right, so the imported deck
|
||||
becomes the copy every other device downloads.
|
||||
|
||||
### Exporting back out (Anki → Quizlet or anywhere else)
|
||||
**File → Export**, choose "Notes in Plain Text" and pick the deck — this
|
||||
produces the same tab-separated format Quizlet's own import expects, so the
|
||||
round trip works in both directions.
|
||||
|
||||
## Why spaced repetition here actually reschedules failed cards
|
||||
|
||||
Anki's scheduler (FSRS, the default since recent Anki versions) tracks a
|
||||
per-card memory-strength estimate and schedules the next review right before
|
||||
you'd be expected to forget it. Answering "Again" on a card doesn't just
|
||||
requeue it for later the same session — it lowers that card's estimated
|
||||
strength, which shortens every subsequent interval for it until you've
|
||||
proven you know it again, so a card you keep failing gets shown far more
|
||||
often than one you consistently get right. This is scheduling logic inside
|
||||
the Anki client itself; this sync server only stores and syncs the resulting
|
||||
review history, it doesn't change how reviews are scheduled.
|
||||
@@ -0,0 +1,669 @@
|
||||
#!/bin/bash
|
||||
# services/anki-sync-server.sh — Self-hosted Anki flashcard sync server.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
#
|
||||
# Can also be run standalone on any machine:
|
||||
# sudo bash anki-sync-server.sh
|
||||
# (Docker must already be installed when run standalone)
|
||||
|
||||
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||
# Sets up helpers and globals, then defers execution until after the function
|
||||
# definition at the bottom of this file.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||
|
||||
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||
|
||||
if [[ -f "$_COMMON" ]]; then
|
||||
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
||||
# shellcheck source=../lib/common.sh
|
||||
source "$_COMMON"
|
||||
else
|
||||
# One-off copy — inline minimal stubs so the script works without the repo
|
||||
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||
|
||||
require_docker() {
|
||||
command -v docker &>/dev/null || {
|
||||
log_error "Docker not found. Install it first:"
|
||||
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||
return 1
|
||||
}
|
||||
docker compose version &>/dev/null || {
|
||||
log_error "Docker Compose plugin missing:"
|
||||
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
ensure_docker_dir_ownership() {
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_yn() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_reinstall_mode() {
|
||||
local _var="$1" _r
|
||||
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
|
||||
echo " Already installed."
|
||||
read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r
|
||||
case "${_r,,}" in
|
||||
u|update) eval "$_var='update'" ;;
|
||||
f|fresh) eval "$_var='fresh'" ;;
|
||||
*) eval "$_var='cancel'" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local length="${1:-32}"
|
||||
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
|
||||
}
|
||||
|
||||
configure_caddy_for_service() {
|
||||
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||
local _display_port="${_upstream##*:}"
|
||||
|
||||
# Determine mode: local Caddy, remote Caddy, or none
|
||||
local _mode="none"
|
||||
[[ -d "$_caddy_dir" ]] && _mode="local"
|
||||
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
|
||||
[[ "$_mode" == "none" ]] && {
|
||||
log_info "Access $_name directly on port $_display_port."
|
||||
return 0
|
||||
}
|
||||
|
||||
echo ""
|
||||
local _do_caddy=""
|
||||
if [[ "$_mode" == "remote" ]]; then
|
||||
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
|
||||
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
|
||||
fi
|
||||
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||||
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||
log_info "Skipping — access at: http://localhost:$_display_port"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Domain prompt — pre-fill from SITE_DOMAIN when available
|
||||
local _default_domain=""
|
||||
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
|
||||
_default_domain="${_subdomain}.${SITE_DOMAIN}"
|
||||
log_info "Default: $_default_domain"
|
||||
fi
|
||||
local _domain=""
|
||||
read -r -p " Domain [${_default_domain:-required}]: " _domain
|
||||
_domain="${_domain:-$_default_domain}"
|
||||
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||
|
||||
# Build upstream — remote Caddy uses host IP:port, not container name
|
||||
local _block_upstream="$_upstream"
|
||||
if [[ "$_mode" == "remote" ]]; then
|
||||
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
|
||||
fi
|
||||
|
||||
local _site_block
|
||||
_site_block="$(cat << CBLOCK
|
||||
|
||||
# $_name
|
||||
${_domain} {
|
||||
reverse_proxy ${_block_upstream}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/${_domain}.log
|
||||
format json
|
||||
}
|
||||
${_extra}
|
||||
}
|
||||
CBLOCK
|
||||
)"
|
||||
|
||||
if [[ "$_mode" == "local" ]]; then
|
||||
if [[ -f "$_caddyfile" ]]; then
|
||||
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$_caddyfile" "$_bk"
|
||||
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||||
else
|
||||
touch "$_caddyfile"
|
||||
fi
|
||||
|
||||
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||||
log_warning "$_domain already in Caddyfile"
|
||||
local _ow=""
|
||||
read -r -p " Overwrite? [y/N]: " _ow
|
||||
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
||||
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$_site_block" >> "$_caddyfile"
|
||||
log_success "Added $_domain to Caddyfile"
|
||||
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
log_success "$_name accessible at: https://$_domain"
|
||||
else
|
||||
log_warning "Reload failed — check: docker logs caddy"
|
||||
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||
fi
|
||||
else
|
||||
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
||||
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
|
||||
mkdir -p "$_snippet_dir"
|
||||
printf '%s\n' "$_site_block" > "$_snippet_file"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
||||
log_success "Snippet saved: $_snippet_file"
|
||||
log_info "Copy to Caddy machine:"
|
||||
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
|
||||
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
|
||||
fi
|
||||
}
|
||||
write_readme() {
|
||||
local _dir="$1"; shift
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
# ($HOME under sudo is /root, not the real user's home)
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
UNATTENDED="${UNATTENDED:-false}"
|
||||
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||||
|
||||
register_service() { :; } # no-op — no wizard to register into
|
||||
_RUN_STANDALONE=1
|
||||
fi
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
register_service anki-sync-server utilities "Self-hosted Anki flashcard sync server (spaced repetition, syncs across devices without AnkiWeb)" 8080
|
||||
|
||||
# Reads the current ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs out of an
|
||||
# instance's .env into the caller's ANKI_USERS/ANKI_PASSWORDS arrays (bash's
|
||||
# dynamic scoping means a `local` array declared in the caller is visible
|
||||
# here without being passed explicitly — same assumption every other helper
|
||||
# below makes). Numbering is always kept contiguous from 1 by
|
||||
# _anki_rewrite_account_block, so stopping at the first missing index is
|
||||
# safe — there's never a gap to skip over.
|
||||
_anki_load_accounts() {
|
||||
local _dir="$1" _n=1 _u _p
|
||||
ANKI_USERS=() ANKI_PASSWORDS=()
|
||||
while true; do
|
||||
_u="$(grep "^ANKI_SYNC_USER${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)"
|
||||
[ -z "$_u" ] && break
|
||||
_p="$(grep "^ANKI_SYNC_PASSWORD${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)"
|
||||
ANKI_USERS+=("$_u")
|
||||
ANKI_PASSWORDS+=("$_p")
|
||||
_n=$((_n + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# Regenerates the SYNC_USERn=... lines in docker-compose.yml and the
|
||||
# matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs in .env from the
|
||||
# caller's current ANKI_USERS/ANKI_PASSWORDS arrays (always renumbered
|
||||
# contiguously from 1 — see _anki_load_accounts). Used by both the initial
|
||||
# install and every account-management mutation (add/remove/rotate) so the
|
||||
# two never drift apart, same reasoning as CLAUDE.md's shared-helper
|
||||
# guidance for update vs. fresh-install codepaths. Leaves the port, Caddy
|
||||
# block, and every other line in either file untouched — only lines
|
||||
# matching the SYNC_USER/ANKI_SYNC_* patterns are touched.
|
||||
_anki_rewrite_account_block() {
|
||||
local _dir="$1"
|
||||
local _compose="$_dir/docker-compose.yml"
|
||||
local _env="$_dir/.env"
|
||||
|
||||
sed -i '/^ - SYNC_USER[0-9]\+=/d' "$_compose"
|
||||
sed -i '/^ANKI_SYNC_USER[0-9]\+=/d; /^ANKI_SYNC_PASSWORD[0-9]\+=/d' "$_env"
|
||||
|
||||
local _compose_lines="" _env_lines="" i idx
|
||||
for i in "${!ANKI_USERS[@]}"; do
|
||||
idx=$((i + 1))
|
||||
_compose_lines+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}}
|
||||
"
|
||||
_env_lines+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]}
|
||||
ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]}
|
||||
"
|
||||
done
|
||||
|
||||
# Insert right after the fixed SYNC_BASE anchor line — always present,
|
||||
# written by every version of this script's install flow — instead of
|
||||
# appending at the end, so the block stays grouped with SYNC_HOST/
|
||||
# SYNC_PORT/SYNC_BASE rather than drifting after `volumes:`.
|
||||
local _tmp
|
||||
_tmp="$(mktemp)"
|
||||
printf '%s' "$_compose_lines" > "$_tmp"
|
||||
sed -i "\|^ - SYNC_BASE=/data\$|r $_tmp" "$_compose"
|
||||
rm -f "$_tmp"
|
||||
|
||||
printf '%s' "$_env_lines" >> "$_env"
|
||||
}
|
||||
|
||||
# Interactive add/remove/rotate menu for an existing instance's sync
|
||||
# accounts, offered from install_anki-sync-server's "already installed"
|
||||
# menu. Every mutation restarts the container (`docker compose up -d`
|
||||
# re-reads .env for the new/removed/rotated credentials) but never touches
|
||||
# the port, Caddy config, or the image — the things CLAUDE.md's "update vs.
|
||||
# fresh reinstall" convention says a non-destructive path must leave alone.
|
||||
_anki_manage_accounts() {
|
||||
local _dir="$1"
|
||||
local ANKI_USERS=() ANKI_PASSWORDS=()
|
||||
while true; do
|
||||
_anki_load_accounts "$_dir"
|
||||
echo ""
|
||||
echo " Current sync accounts:"
|
||||
local i
|
||||
for i in "${!ANKI_USERS[@]}"; do
|
||||
echo " $((i + 1))) ${ANKI_USERS[$i]}"
|
||||
done
|
||||
[ "${#ANKI_USERS[@]}" -eq 0 ] && echo " (none)"
|
||||
echo ""
|
||||
echo " a) Add an account"
|
||||
echo " r) Remove an account"
|
||||
echo " p) Rotate (reset) an account's password"
|
||||
echo " 0) Done"
|
||||
echo ""
|
||||
local ACTION=""
|
||||
prompt_text " Choice [a/r/p/0]:" "0" ACTION
|
||||
case "$ACTION" in
|
||||
a|A)
|
||||
if [ "${#ANKI_USERS[@]}" -ge 8 ]; then
|
||||
log_warning "That's plenty — stopping at 8 accounts."
|
||||
continue
|
||||
fi
|
||||
local _u=""
|
||||
prompt_text " New username:" "" _u
|
||||
if [ -z "$_u" ]; then
|
||||
log_warning "Name can't be empty."; continue
|
||||
fi
|
||||
ANKI_USERS+=("$_u")
|
||||
ANKI_PASSWORDS+=("$(generate_password 24)")
|
||||
_anki_rewrite_account_block "$_dir"
|
||||
( cd "$_dir" && docker compose up -d ) \
|
||||
&& log_success "Account '$_u' added — password: ${ANKI_PASSWORDS[-1]} (also saved in $_dir/.env)" \
|
||||
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
|
||||
;;
|
||||
r|R)
|
||||
if [ "${#ANKI_USERS[@]}" -eq 0 ]; then
|
||||
log_warning "No accounts to remove."; continue
|
||||
fi
|
||||
local _n=""
|
||||
prompt_text " Remove which number?" "" _n
|
||||
if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then
|
||||
log_warning "Invalid choice."; continue
|
||||
fi
|
||||
local _removed="${ANKI_USERS[$((_n - 1))]}"
|
||||
unset 'ANKI_USERS[_n - 1]' 'ANKI_PASSWORDS[_n - 1]'
|
||||
ANKI_USERS=("${ANKI_USERS[@]}")
|
||||
ANKI_PASSWORDS=("${ANKI_PASSWORDS[@]}")
|
||||
_anki_rewrite_account_block "$_dir"
|
||||
( cd "$_dir" && docker compose up -d ) \
|
||||
&& log_success "Account '$_removed' removed" \
|
||||
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
|
||||
;;
|
||||
p|P)
|
||||
if [ "${#ANKI_USERS[@]}" -eq 0 ]; then
|
||||
log_warning "No accounts yet."; continue
|
||||
fi
|
||||
local _n=""
|
||||
prompt_text " Rotate password for which number?" "" _n
|
||||
if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then
|
||||
log_warning "Invalid choice."; continue
|
||||
fi
|
||||
ANKI_PASSWORDS[$((_n - 1))]="$(generate_password 24)"
|
||||
_anki_rewrite_account_block "$_dir"
|
||||
( cd "$_dir" && docker compose up -d ) \
|
||||
&& log_success "New password for '${ANKI_USERS[$((_n - 1))]}': ${ANKI_PASSWORDS[$((_n - 1))]} (also saved in $_dir/.env)" \
|
||||
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
|
||||
;;
|
||||
0)
|
||||
break
|
||||
;;
|
||||
*)
|
||||
log_warning "Unrecognized choice."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
install_anki-sync-server() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Anki Sync Server..."
|
||||
|
||||
# ── Instance selection ───────────────────────────────────────────────────
|
||||
# First instance keeps the plain "anki-sync-server" name/paths/port exactly
|
||||
# as before (zero behavior change for anyone with a single instance). Only
|
||||
# asking to add a second one introduces suffixed naming — same pattern as
|
||||
# services/ntfy.sh and services/homebox.sh. A second instance is a real
|
||||
# use case here (e.g. a second household wanting fully separate data on
|
||||
# the same box) even though one instance already supports multiple
|
||||
# independent accounts via SYNC_USER1/SYNC_USER2/... — see CLAUDE.md's
|
||||
# "Multi-instance services" section.
|
||||
local ANKI_DIR="$DOCKER_DIR/anki-sync-server"
|
||||
local INSTANCE_SUFFIX="" CONTAINER="anki-sync-server"
|
||||
local WEB_PORT="8080"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||
echo "[DRY-RUN] Would create $ANKI_DIR(-<name>)"
|
||||
echo "[DRY-RUN] Would prompt for one or more sync accounts and generate passwords"
|
||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -d "$ANKI_DIR" ]; then
|
||||
echo ""
|
||||
echo " Anki Sync Server is already installed at $ANKI_DIR."
|
||||
echo " 1) Manage sync accounts (add / remove / rotate a password — doesn't"
|
||||
echo " touch the port, Caddy, or the image)"
|
||||
echo " 2) Manage that install (update image / full reinstall / cancel)"
|
||||
echo " 3) Add a NEW, separate Anki Sync Server instance alongside it (its"
|
||||
echo " own data and port — full isolation)"
|
||||
echo ""
|
||||
local _TOP_CHOICE=""
|
||||
prompt_text " Choice [1/2/3]:" "2" _TOP_CHOICE
|
||||
if [ "$_TOP_CHOICE" = "1" ]; then
|
||||
_anki_manage_accounts "$ANKI_DIR"
|
||||
return 0
|
||||
elif [ "$_TOP_CHOICE" = "3" ]; then
|
||||
local _suffix=""
|
||||
while true; do
|
||||
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix
|
||||
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||
if [ -z "$_suffix" ]; then
|
||||
log_warning "Name can't be empty."; continue
|
||||
fi
|
||||
if [ -d "$DOCKER_DIR/anki-sync-server-$_suffix" ]; then
|
||||
log_warning "anki-sync-server-$_suffix already exists — pick another name."; continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
ANKI_DIR="$DOCKER_DIR/anki-sync-server-$_suffix"
|
||||
CONTAINER="anki-sync-server-$_suffix"
|
||||
log_info "New instance: $ANKI_DIR"
|
||||
else
|
||||
# "Manage that install" on THIS instance — the banner above promises
|
||||
# update/fresh/cancel, so actually offer it instead of falling straight
|
||||
# through into the same unconditional-overwrite flow as a new install.
|
||||
if [[ -f "$ANKI_DIR/docker-compose.yml" ]]; then
|
||||
local MODE=""
|
||||
prompt_reinstall_mode MODE
|
||||
case "$MODE" in
|
||||
update)
|
||||
log_info "Refreshing the Anki Sync Server image only — existing accounts, port, and Caddy setup are left as-is."
|
||||
# The image is a Google distroless "nonroot" build (fixed UID/GID
|
||||
# 65532, no shell — it can't chown anything itself at startup), so
|
||||
# ./data has to already be writable by that exact UID or the
|
||||
# container fails to start. Versions of this installer before this
|
||||
# fix chowned it to ACTUAL_USER instead, which the container can't
|
||||
# write to — re-asserting the correct ownership here repairs any
|
||||
# install made under that bug, non-destructively (it's the
|
||||
# installer's own bug being corrected, not a config choice, so it
|
||||
# belongs in the non-destructive update path).
|
||||
chown -R 65532:65532 "$ANKI_DIR/data" 2>/dev/null
|
||||
( cd "$ANKI_DIR" && docker compose pull && docker compose up -d ) \
|
||||
&& log_success "Anki Sync Server image refreshed" \
|
||||
|| log_warning "Refresh failed — check: docker compose -f $ANKI_DIR/docker-compose.yml logs"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
log_info "Leaving the existing install as-is."
|
||||
return 0
|
||||
;;
|
||||
fresh) ;; # fall through to the full install flow below
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
# ── Sync accounts ─────────────────────────────────────────────────────────
|
||||
# The official sync server has no signup flow of its own — accounts are
|
||||
# fixed credentials baked in as SYNC_USER1, SYNC_USER2, ... at container
|
||||
# start, one per line in .env. Ask for at least one now (each Anki client
|
||||
# — desktop, AnkiDroid, AnkiMobile — logs in with one of these) and offer
|
||||
# to add more for other people sharing this box, since a single instance
|
||||
# already keeps each account's collection completely separate.
|
||||
local ANKI_USERS=() ANKI_PASSWORDS=()
|
||||
local _u=""
|
||||
prompt_text " Username for your Anki sync account:" "$ACTUAL_USER" _u
|
||||
ANKI_USERS+=("$_u")
|
||||
ANKI_PASSWORDS+=("$(generate_password 24)")
|
||||
while true; do
|
||||
local _more=""
|
||||
prompt_yn " Add another Anki sync account (e.g. for a family member)? (y/n):" "n" _more
|
||||
[[ "$_more" =~ ^[Yy]$ ]] || break
|
||||
prompt_text " Username for the additional account:" "" _u
|
||||
if [ -z "$_u" ]; then
|
||||
log_warning "Name can't be empty."; continue
|
||||
fi
|
||||
ANKI_USERS+=("$_u")
|
||||
ANKI_PASSWORDS+=("$(generate_password 24)")
|
||||
if [ "${#ANKI_USERS[@]}" -ge 8 ]; then
|
||||
log_warning "That's plenty — stopping at 8 accounts."
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$ANKI_DIR/data"
|
||||
ensure_docker_dir_ownership "$ANKI_DIR"
|
||||
cd "$ANKI_DIR" || return 1
|
||||
|
||||
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
||||
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
|
||||
# then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a
|
||||
# remote Caddy box can't resolve container names on this host's bridge
|
||||
# network anyway; it reaches this service via the host's published port.
|
||||
local _CADDY_MODE="${CADDY_MODE:-none}"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
|
||||
|
||||
local _CADDY_NET_BLOCK=""
|
||||
local _CADDY_NET_SECTION=""
|
||||
if [ "$_CADDY_MODE" = "local" ]; then
|
||||
_CADDY_NET_BLOCK=" networks:
|
||||
- caddy_net
|
||||
"
|
||||
_CADDY_NET_SECTION="
|
||||
networks:
|
||||
caddy_net:
|
||||
external: true
|
||||
name: ${SITE_CADDY_NET:-caddy_net}
|
||||
"
|
||||
fi
|
||||
|
||||
# Build the SYNC_USERn=... lines for docker-compose.yml (compose-time
|
||||
# interpolation of ${ANKI_SYNC_USERn}/${ANKI_SYNC_PASSWORDn} from .env —
|
||||
# same \${VAR} pattern services/homebox.sh uses for its own .env values)
|
||||
# and the matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn lines for .env.
|
||||
local _COMPOSE_USER_LINES="" _ENV_USER_LINES="" i idx
|
||||
for i in "${!ANKI_USERS[@]}"; do
|
||||
idx=$((i + 1))
|
||||
_COMPOSE_USER_LINES+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}}
|
||||
"
|
||||
_ENV_USER_LINES+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]}
|
||||
ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]}
|
||||
"
|
||||
done
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << ANKI_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
anki-sync-server:
|
||||
image: afrima/anki-sync-server:latest
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SYNC_HOST=0.0.0.0
|
||||
- SYNC_PORT=8080
|
||||
- SYNC_BASE=/data
|
||||
${_COMPOSE_USER_LINES} volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "${WEB_PORT}:8080"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
ANKI_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ANKI_ENV
|
||||
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
# One username/password pair per Anki sync account (SYNC_USER1, SYNC_USER2,
|
||||
# ... in docker-compose.yml). Enter these exact values as the account on
|
||||
# each Anki client (Preferences/Settings → self-hosted sync server). To add,
|
||||
# remove, or reset one of these later, re-run this installer against the
|
||||
# existing install and pick "Manage sync accounts" — don't hand-edit these
|
||||
# lines, the matching docker-compose.yml lines have to change in lockstep.
|
||||
${_ENV_USER_LINES}
|
||||
ANKI_ENV
|
||||
chmod 600 .env
|
||||
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ANKI_DIR"
|
||||
|
||||
# afrima/anki-sync-server is built on gcr.io/distroless/static-debian12:nonroot
|
||||
# — the process always runs as that image's fixed "nonroot" UID/GID (65532),
|
||||
# never as ACTUAL_USER, and distroless has no shell so nothing inside the
|
||||
# container can chown its own data dir at startup. Applied AFTER the
|
||||
# ACTUAL_USER chown above (not before — that call would just clobber it,
|
||||
# since it recurses over the whole $ANKI_DIR including data/) so ./data ends
|
||||
# up owned by 65532 specifically while docker-compose.yml/.env/README.md
|
||||
# (which the sysadmin edits, not the container) stay owned by ACTUAL_USER.
|
||||
# Confirmed live: getting this wrong is exactly what makes the container
|
||||
# fail to come up with a permissions error the moment it tries to create
|
||||
# anything under /data (e.g. a new user's collection).
|
||||
chown -R 65532:65532 "$ANKI_DIR/data"
|
||||
|
||||
echo ""
|
||||
log_success "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $ANKI_DIR (port $WEB_PORT)"
|
||||
echo ""
|
||||
echo " Sync accounts (also saved in $ANKI_DIR/.env):"
|
||||
for i in "${!ANKI_USERS[@]}"; do
|
||||
echo " ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# No Authelia gate here, unlike most other web-facing services in this
|
||||
# repo: this is a raw HTTP sync API that the Anki client itself talks to
|
||||
# (not a browser session), so a forward_auth login portal in front of it
|
||||
# would just break every sync request instead of protecting anything.
|
||||
# SYNC_USER1/SYNC_USER2/... above is this service's own auth boundary —
|
||||
# same reasoning as the has-built-in-auth services in CLAUDE.md, just
|
||||
# with no web UI to additionally gate.
|
||||
configure_caddy_for_service "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "anki${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||
|
||||
local START=""
|
||||
prompt_yn "Start Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START
|
||||
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
|
||||
docker compose up -d \
|
||||
&& log_success "Anki Sync Server started" \
|
||||
|| log_warning "Start failed — check: docker compose logs"
|
||||
fi
|
||||
|
||||
write_readme "$ANKI_DIR" << MD
|
||||
# Anki Sync Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Self-hosted sync server for the [Anki](https://apps.ankiweb.net/) flashcard
|
||||
app — syncs your collection across devices without going through AnkiWeb.
|
||||
Anki's own spaced-repetition scheduler (FSRS) gives failed cards more
|
||||
repetition and correctly-recalled cards longer gaps automatically; nothing
|
||||
here changes that, it's purely the sync backend.
|
||||
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||
This is a separate, fully isolated instance (own data directory, own
|
||||
accounts, own port) — not shared collections with another Anki Sync Server
|
||||
instance.")
|
||||
|
||||
## Access
|
||||
- Sync URL: $( [ -n "${CADDY_SERVICE_CONFIGURED:-}" ] && [ "$CADDY_SERVICE_CONFIGURED" = "true" ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" )
|
||||
- Accounts (username / password):
|
||||
$(for i in "${!ANKI_USERS[@]}"; do echo " - ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"; done)
|
||||
|
||||
Enter the Sync URL and one of the above accounts on each Anki client — see
|
||||
the client setup section below for exactly where.
|
||||
|
||||
## Data
|
||||
- Collections: \`$ANKI_DIR/data\`
|
||||
- Credentials: \`$ANKI_DIR/.env\` (readable by $ACTUAL_USER only)
|
||||
|
||||
## Manage
|
||||
\`\`\`bash
|
||||
cd $ANKI_DIR
|
||||
docker compose up -d
|
||||
docker compose down
|
||||
docker compose logs -f
|
||||
docker compose pull && docker compose up -d
|
||||
\`\`\`
|
||||
|
||||
To add, remove, or reset the password of a sync account later, re-run the
|
||||
installer against this install and pick **"Manage sync accounts"** —
|
||||
don't hand-edit \`.env\`, the matching lines in \`docker-compose.yml\` have
|
||||
to change alongside it:
|
||||
\`\`\`bash
|
||||
sudo ./setup.sh anki-sync-server
|
||||
\`\`\`
|
||||
MD
|
||||
|
||||
log_info "Full client setup + Quizlet import walkthrough written to $ANKI_DIR/README.md"
|
||||
}
|
||||
|
||||
# ── Standalone execution ───────────────────────────────────────────────────
|
||||
if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then
|
||||
install_anki-sync-server
|
||||
fi
|
||||
@@ -174,6 +174,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -240,6 +245,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << ABCOMPOSE
|
||||
name: archivebox
|
||||
|
||||
@@ -262,6 +268,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
ABCOMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ABENV
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
ABENV
|
||||
|
||||
@@ -188,6 +188,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -278,6 +283,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << ARM_COMPOSE
|
||||
name: arm
|
||||
|
||||
@@ -306,6 +312,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
ARM_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ARM_ENV
|
||||
ARM_OUTPUT=$ARM_OUTPUT
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
+467
-98
@@ -255,6 +255,11 @@ CBLOCK
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
@@ -298,6 +303,21 @@ register_service asterisk homelab "Easy Asterisk PBX (intercom/VoIP; auto-tunes
|
||||
# naming. Every sibling service in this repo (pstn-trunk, security-dashboard,
|
||||
# crowdsec) already probes for both directories, so both layouts stay fully
|
||||
# supported without further special-casing.
|
||||
#
|
||||
# Container name itself: new installs use the plain "asterisk" (matching
|
||||
# every other service's own container_name == service name convention —
|
||||
# "easy-" was this repo's install-time container name before, left over from
|
||||
# when the vendor CLI tool's own name (`easy-asterisk`, still installed at
|
||||
# /usr/local/bin/easy-asterisk inside the container — unrelated, never
|
||||
# renamed) got reused for the container too. A box that already has a
|
||||
# running container is read directly from its own docker-compose.yml instead
|
||||
# of assumed from the directory, so an existing "easy-asterisk" install
|
||||
# keeps working with no silent rename — same reasoning as the droplet-layout
|
||||
# preservation above, just one level down (container name, not directory).
|
||||
# Migrating an existing box to the new name is a deliberate, one-time action
|
||||
# (edit docker-compose.yml's container_name + coturn, `docker compose down`
|
||||
# + `up -d`) — once done, every sibling service here re-reads it from that
|
||||
# same file and follows automatically, no further changes needed anywhere.
|
||||
_asterisk_resolve_layout() {
|
||||
if [[ -f "$DOCKER_DIR/asterisk-digital-ocean/docker-compose.yml" ]]; then
|
||||
ASTERISK_DIR="$DOCKER_DIR/asterisk-digital-ocean"
|
||||
@@ -306,12 +326,30 @@ _asterisk_resolve_layout() {
|
||||
ASTERISK_PROJECT="asterisk-do"
|
||||
else
|
||||
ASTERISK_DIR="$DOCKER_DIR/asterisk"
|
||||
ASTERISK_CONTAINER="easy-asterisk"
|
||||
ASTERISK_COTURN="easy-asterisk-coturn"
|
||||
ASTERISK_PROJECT="asterisk"
|
||||
ASTERISK_CONTAINER=""
|
||||
if [[ -f "$ASTERISK_DIR/docker-compose.yml" ]]; then
|
||||
ASTERISK_CONTAINER="$(grep -m1 '^[[:space:]]*container_name:' "$ASTERISK_DIR/docker-compose.yml" | awk '{print $2}')"
|
||||
fi
|
||||
[[ -z "$ASTERISK_CONTAINER" ]] && ASTERISK_CONTAINER="asterisk"
|
||||
ASTERISK_COTURN="${ASTERISK_CONTAINER}-coturn"
|
||||
fi
|
||||
}
|
||||
|
||||
# Live public-IP detection, no prompts — the same DO-metadata -> ifconfig.me
|
||||
# -> `hostname -I` fallback chain _asterisk_detect_digitalocean uses when
|
||||
# actually setting up a droplet, factored out for every OTHER caller that
|
||||
# just needs "what's this box's public IP right now" without the
|
||||
# interactive droplet-mode question attached (the archive-restore IP-patch
|
||||
# below, and _asterisk_run_stack_health_check's IP-mismatch check).
|
||||
_asterisk_current_public_ip() {
|
||||
local ip=""
|
||||
ip="$(curl -fsS --max-time 2 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null || true)"
|
||||
[[ -z "$ip" ]] && ip="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)"
|
||||
[[ -z "$ip" ]] && ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
# ── DigitalOcean droplet detection ─────────────────────────────────────────
|
||||
# Sets IS_DO (true/false), DROPLET_ID and PUBLIC_IP.
|
||||
#
|
||||
@@ -667,9 +705,7 @@ case "$cmd" in
|
||||
PJSIP_CONF="$HERE/config/asterisk/pjsip.conf"
|
||||
if [ -f "$PJSIP_CONF" ]; then
|
||||
OLD_EXT_IP="$(grep -m1 '^external_signaling_address=' "$PJSIP_CONF" | cut -d= -f2)"
|
||||
NEW_EXT_IP="$(curl -fsS --max-time 2 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null || true)"
|
||||
[ -z "$NEW_EXT_IP" ] && NEW_EXT_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)"
|
||||
[ -z "$NEW_EXT_IP" ] && NEW_EXT_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
NEW_EXT_IP="$(_asterisk_current_public_ip)"
|
||||
|
||||
if [ -n "$OLD_EXT_IP" ] && [ -n "$NEW_EXT_IP" ] && [ "$OLD_EXT_IP" != "$NEW_EXT_IP" ]; then
|
||||
echo "This archive's SIP config was for a different box's public IP"
|
||||
@@ -1155,6 +1191,48 @@ _asterisk_patch_voicemail_vendor_files() {
|
||||
log_success "Vendor generator functions patched for voicemail access codes."
|
||||
}
|
||||
|
||||
# ── easy-asterisk CLI: non-interactive Caddy cert sync ──────────────────────
|
||||
# Adds a --sync-caddy-cert flag to the deployed easy-asterisk.sh/
|
||||
# easy-asterisk-v0.10.0.sh copies, mirroring the vendor script's own
|
||||
# --rebuild-dialplan/--write-web-admin-script non-interactive entry points
|
||||
# (see their own comments a few lines up in the vendor file). Needed so
|
||||
# _asterisk_run_stack_health_check() below can trigger
|
||||
# setup_caddy_cert_sync() — the same function "Server Settings -> Force
|
||||
# re-sync Caddy certs" calls in the interactive menu, which finds Caddy's
|
||||
# already-issued cert for DOMAIN_NAME and copies it into
|
||||
# /etc/asterisk/certs so the transport-tls PJSIP transport can actually
|
||||
# bind — from the host via `docker exec`, instead of only being reachable
|
||||
# by a human sitting at the interactive CLI. Patches the deployed copy
|
||||
# only (never vendor/ in git), same convention as
|
||||
# _asterisk_patch_voicemail_vendor_files and friends.
|
||||
_asterisk_patch_cert_sync_cli() {
|
||||
local EA_DIR="$1"
|
||||
local EASY1="$EA_DIR/easy-asterisk.sh"
|
||||
local EASY2
|
||||
EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)"
|
||||
[[ -z "$EASY2" ]] && EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh"
|
||||
local f
|
||||
|
||||
for f in "$EASY1" "$EASY2"; do
|
||||
[[ -f "$f" ]] || continue
|
||||
grep -q -- '--sync-caddy-cert' "$f" && continue
|
||||
if ! grep -q '^# Non-interactive entry point used by the container entrypoint on every$' "$f"; then
|
||||
log_warning "$(basename "$f"): non-interactive-entrypoint anchor not found — vendor template changed upstream."
|
||||
log_warning " Add a --sync-caddy-cert flag branch calling setup_caddy_cert_sync \"auto\" manually (see this installer's comment)."
|
||||
continue
|
||||
fi
|
||||
sed -i '/^# Non-interactive entry point used by the container entrypoint on every$/i\
|
||||
# Non-interactive entry point so services/asterisk.sh'"'"'s host-side stack\
|
||||
# health check can trigger a Caddy cert re-sync without a human at the\
|
||||
# interactive CLI menu (Server Settings -> "Force re-sync Caddy certs").\
|
||||
if [[ "${1:-}" == "--sync-caddy-cert" ]]; then\
|
||||
setup_caddy_cert_sync "auto"\
|
||||
exit 0\
|
||||
fi\
|
||||
' "$f"
|
||||
done
|
||||
}
|
||||
|
||||
# ── asterisk.conf: live_dangerously ─────────────────────────────────────────
|
||||
# pstn-trunk.sh's dialplan leans on AST_CONFIG() for everything permission-
|
||||
# related (pstn-permissions.conf tiers, pstn-trunk-killswitch.conf) — see
|
||||
@@ -1230,6 +1308,111 @@ _asterisk_ensure_live_voicemail_include() {
|
||||
docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true
|
||||
}
|
||||
|
||||
# ── pjsip.conf: [global] keep_alive_interval ─────────────────────────────────
|
||||
# Mitigation for WiFi/LAN SIP clients (e.g. Sipnetic on Android) dropping
|
||||
# their TLS registration every few seconds on some hosts but not others —
|
||||
# confirmed live on an IONOS VPS (never reproduced on a DigitalOcean droplet
|
||||
# running the identical stack, and never over mobile data on either
|
||||
# provider), with OPNsense's own firewall/IDS logs confirmed clean during a
|
||||
# live disconnect and CrowdSec/packet-loss both ruled out first. Leading
|
||||
# theory: an idle-connection timeout somewhere in IONOS's own network
|
||||
# virtualization layer, below anything client-side tools can see. PJSIP's
|
||||
# `keep_alive_interval` sends a lightweight double-CRLF over connection-oriented
|
||||
# transports (TCP/TLS) on a timer, which is the standard fix for exactly this
|
||||
# class of "idle SIP/TLS connection gets silently dropped" symptom.
|
||||
#
|
||||
# **This is a `type=global` option, not a `type=transport` option** — it does
|
||||
# not exist on `[transport-tls]`/`[transport-tcp]` objects at all, on any
|
||||
# Asterisk version. An earlier version of this patch inserted it into
|
||||
# `[transport-tls]` (right after `protocol=tls`), which sorcery always
|
||||
# rejects: "Could not find option suitable for category 'transport-tls'
|
||||
# named 'keep_alive_interval'" — and rejecting the option means the whole
|
||||
# `type=transport` object fails to be created, so `transport-tls` never
|
||||
# binds at all. Confirmed live: this silently took down TLS SIP entirely on
|
||||
# a box that had picked up the patch, reproducing the exact same "network
|
||||
# error on all calls" symptom the rest of this file's health check exists to
|
||||
# catch. The anchor below targets `type=global` inside `[global]` instead.
|
||||
#
|
||||
# This is a *transport-behavior* option, not an endpoint/AOR option —
|
||||
# `qualify_frequency` (already set globally) is an endpoint-level OPTIONS
|
||||
# ping that re-establishes a dropped connection, it doesn't stop the drop
|
||||
# from happening in the first place. `rtp_keepalive` (mobile devices) is
|
||||
# unrelated: RTP media keepalive during an active call, not SIP signaling
|
||||
# connection keepalive while idle.
|
||||
_asterisk_patch_keepalive_vendor_files() {
|
||||
local EA_DIR="$1"
|
||||
local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh"
|
||||
local EASY1="$EA_DIR/easy-asterisk.sh"
|
||||
local EASY2
|
||||
EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)"
|
||||
[[ -z "$EASY2" ]] && EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh"
|
||||
local f
|
||||
|
||||
for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do
|
||||
[[ -f "$f" ]] || continue
|
||||
# Undo the old, incorrect [transport-tls] placement if an earlier
|
||||
# run of this function already patched it there.
|
||||
if grep -q '^protocol=tls$' "$f" && grep -A2 '^protocol=tls$' "$f" | grep -q '^keep_alive_interval='; then
|
||||
sed -i '/^protocol=tls$/{n;/^keep_alive_interval=/d}' "$f"
|
||||
fi
|
||||
if ! grep -q '^type=global$' "$f"; then
|
||||
log_warning "$(basename "$f"): '[global]' anchor not found — vendor template changed upstream."
|
||||
log_warning " Add 'keep_alive_interval=15' manually inside [global] (not [transport-tls]) in this file's pjsip.conf heredoc."
|
||||
continue
|
||||
fi
|
||||
grep -q '^keep_alive_interval=' "$f" || sed -i '/^type=global$/a keep_alive_interval=15' "$f"
|
||||
done
|
||||
|
||||
log_success "Vendor generator functions patched for SIP keepalive (pjsip.conf [global])."
|
||||
}
|
||||
|
||||
# Live-file counterpart to the vendor-template patch above, same reasoning
|
||||
# as _asterisk_ensure_live_voicemail_include (Easy Asterisk's entrypoint
|
||||
# only regenerates pjsip.conf if it's missing, so a box with existing
|
||||
# devices never picks up the vendor patch on a plain restart). Unlike
|
||||
# extensions.conf/voicemail.conf, `pjsip reload` does not pick up changes to
|
||||
# a transport object — PJSIP transports are bound at module load, not
|
||||
# reloadable via sorcery like endpoints/AORs are — so this restarts the
|
||||
# container rather than issuing a reload, same as _asterisk_ensure_live_dangerously.
|
||||
#
|
||||
# `keep_alive_interval` belongs in `[global]` (`type=global`), not
|
||||
# `[transport-tls]` — see the comment on _asterisk_patch_keepalive_vendor_files
|
||||
# for why the old placement made sorcery reject the transport object outright
|
||||
# (killing TLS SIP entirely, not just the keepalive). This also self-heals a
|
||||
# box that already has the bad `[transport-tls]` entry from before that fix.
|
||||
_asterisk_ensure_live_keepalive() {
|
||||
local EA_DIR="$1" CONTAINER_NAME="$2"
|
||||
local CONF_LIVE="$EA_DIR/config/asterisk/pjsip.conf"
|
||||
[[ -f "$CONF_LIVE" ]] || return 0
|
||||
|
||||
local CHANGED=false
|
||||
if sed -n '/^\[transport-tls\]$/,/^\[/{/^keep_alive_interval=/p}' "$CONF_LIVE" | grep -q .; then
|
||||
sed -i '/^\[transport-tls\]$/,/^\[/{/^keep_alive_interval=/d}' "$CONF_LIVE"
|
||||
log_warning "Removed 'keep_alive_interval' from [transport-tls] — that option doesn't exist on a PJSIP"
|
||||
log_warning "transport object and was making the whole TLS transport fail to bind. Moving it to [global]."
|
||||
CHANGED=true
|
||||
fi
|
||||
|
||||
if ! grep -q '^\[global\]$' "$CONF_LIVE"; then
|
||||
log_warning "Couldn't find '[global]' in the live pjsip.conf — add"
|
||||
log_warning "'keep_alive_interval=15' manually inside that section, then: docker restart ${CONTAINER_NAME}"
|
||||
return 0
|
||||
fi
|
||||
if ! grep -q '^keep_alive_interval=' "$CONF_LIVE"; then
|
||||
sed -i '/^\[global\]$/,/^\[/{/^type=global$/a keep_alive_interval=15
|
||||
}' "$CONF_LIVE"
|
||||
log_success "Patched keep_alive_interval=15 into the live pjsip.conf's [global] section."
|
||||
CHANGED=true
|
||||
fi
|
||||
|
||||
[[ "$CHANGED" == true ]] || return 0
|
||||
|
||||
log_info "Restarting Asterisk to apply (transport options aren't picked up by a reload)..."
|
||||
docker restart "$CONTAINER_NAME" &>/dev/null \
|
||||
&& log_success "Restarted." \
|
||||
|| log_warning "Restart failed — check: docker logs $CONTAINER_NAME"
|
||||
}
|
||||
|
||||
_asterisk_remove_presence_timer() {
|
||||
systemctl disable --now asterisk-presence-alert.timer 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/asterisk-presence-alert.timer /etc/systemd/system/asterisk-presence-alert.service
|
||||
@@ -1410,6 +1593,7 @@ _asterisk_write_compose() {
|
||||
"
|
||||
[[ "$USE_EMBEDDED_COTURN" != true ]] && _COTURN_DEPENDS="" && _COTURN_SERVICE=""
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << EOF
|
||||
name: PROJECT_NAME_PLACEHOLDER
|
||||
|
||||
@@ -1485,115 +1669,93 @@ EOF
|
||||
_asterisk_configure_caddy_public() {
|
||||
local DOMAIN_NAME="$1" WEB_ADMIN_PORT_VAL="$2" PUBLIC_IP="$3"
|
||||
|
||||
WEB_ADMIN_PUBLIC_ACCESS_NEEDED=true
|
||||
# Asterisk's own web admin is never exposed publicly by this function —
|
||||
# see services/security-dashboard.sh's _secdash_offer_asterisk_domain
|
||||
# for the actual public-facing use of this domain instead. This only
|
||||
# exists to get DOMAIN_NAME a trusted Caddy-issued TLS cert for SIP TLS,
|
||||
# via a minimal keep-alive page. Cert issuance only needs Caddy to own
|
||||
# the domain's site block and answer the ACME challenge there — it's
|
||||
# unrelated to what the block actually serves.
|
||||
#
|
||||
# An earlier version of this function reverse-proxied Asterisk's own
|
||||
# web admin here, gated (optionally) by Authelia, with WEB_ADMIN_AUTH_DISABLED
|
||||
# flipped to true in .env to hand auth off to it. That coupling was the
|
||||
# root cause of a real live exposure: a box where Authelia protection
|
||||
# was accepted once, but the Authelia import/forward_auth block itself
|
||||
# later went missing from the Caddyfile (e.g. lost on a restore) or a
|
||||
# remote Authelia instance became unreachable/misconfigured, was left
|
||||
# with Asterisk's own login OFF and nothing else gating it — extension/
|
||||
# device data sitting on the public internet with no password at all.
|
||||
# A remote Authelia's forward_auth also proved fragile in practice
|
||||
# (DNS/routing/access-rule mismatches that are hard to diagnose from
|
||||
# this box alone) for something that's only ever meant to keep a
|
||||
# domain's cert alive. A Basic Auth login handled entirely inside Caddy
|
||||
# itself — no external subrequest, no dependency on another box being
|
||||
# correctly configured — is simpler and can't fail this way. Asterisk's
|
||||
# own web admin stays reachable via the CLI only:
|
||||
# docker exec -it <container> easy-asterisk
|
||||
#
|
||||
# Left at the caller's own default (true) here — the web admin's raw
|
||||
# IP:port still needs to be reachable when there's no Caddy in the
|
||||
# picture at all to front this domain instead. Only flipped to false
|
||||
# once we actually confirm Caddy is fronting it (below).
|
||||
|
||||
if [[ -z "$DOMAIN_NAME" ]]; then
|
||||
log_info "No FQDN set — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL} (nothing for Caddy to do)."
|
||||
log_info "No FQDN set — nothing for Caddy to do (SIP TLS stays self-signed)."
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -z "${CADDY_REMOTE_HOST:-}" ]]; then
|
||||
log_info "Caddy not installed — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL}, SIP TLS stays self-signed."
|
||||
log_info "Caddy not installed — SIP TLS stays self-signed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local EXTRA_BLOCK=""
|
||||
if [ -d "$DOCKER_DIR/authelia" ]; then
|
||||
local _use_auth=""
|
||||
prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth
|
||||
if [[ "$_use_auth" =~ ^[Yy]$ ]]; then
|
||||
EXTRA_BLOCK=" import authelia"
|
||||
# Disable built-in auth since Authelia handles it
|
||||
sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env
|
||||
fi
|
||||
else
|
||||
# No local Authelia — offer one running elsewhere (e.g. a homelab).
|
||||
# There's no shared "(authelia)" Caddy snippet to import in that
|
||||
# case (authelia.sh only writes one when installing locally), so
|
||||
# this builds the same forward_auth block inline, targeting the
|
||||
# remote instance directly instead of the local "authelia:9091"
|
||||
# container reference.
|
||||
local _use_remote_auth=""
|
||||
prompt_yn "Protect the web admin with a remote Authelia instance (e.g. on a homelab)? (y/n):" "n" _use_remote_auth
|
||||
if [[ "$_use_remote_auth" =~ ^[Yy]$ ]]; then
|
||||
local _remote_authelia=""
|
||||
prompt_text " Remote Authelia address — a bare host:port over a private network (e.g. a NetBird mesh IP:9091), or a full https:// URL if it's on its own public domain+TLS:" "" _remote_authelia
|
||||
if [[ -n "$_remote_authelia" ]]; then
|
||||
# header_up lines are required here (unlike the local
|
||||
# "authelia:9091" snippet in services/authelia.sh) because
|
||||
# this upstream is reached over a second Caddy hop when
|
||||
# given as a scheme-qualified URL (https://auth.example.com).
|
||||
# Caddy rewrites the outgoing request's Host header to that
|
||||
# upstream host so the remote Caddy can route/SNI-match it —
|
||||
# and without an explicit override, X-Forwarded-Host picks up
|
||||
# that rewritten value instead of the original site's host.
|
||||
# Confirmed live: Authelia was evaluating every request as
|
||||
# if it were for auth.example.com itself (which has
|
||||
# policy: bypass in access_control.rules), so every domain
|
||||
# silently passed through with no 2FA prompt regardless of
|
||||
# its own policy. Pinning these to the original request's
|
||||
# values fixes it regardless of hop count.
|
||||
#
|
||||
# X-Forwarded-Host uses a literal domain, NOT the {host}
|
||||
# placeholder. Confirmed live: {host} still evaluated to
|
||||
# the upstream's own hostname (auth.example.com) rather
|
||||
# than the original site's — Caddy appears to rewrite the
|
||||
# outgoing request's Host to the upstream target before
|
||||
# header_up placeholders are resolved for a scheme-
|
||||
# qualified upstream, so {host} echoes back the already-
|
||||
# rewritten value instead of the original client-facing
|
||||
# host. Since this site block only ever serves one domain
|
||||
# (DOMAIN_NAME), hardcoding it sidesteps the ambiguity
|
||||
# entirely instead of depending on Caddy's internal
|
||||
# header-mutation ordering.
|
||||
EXTRA_BLOCK=" forward_auth ${_remote_authelia} {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||
header_up X-Forwarded-Method {method}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-Host ${DOMAIN_NAME}
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
}"
|
||||
sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env
|
||||
log_info "Using remote Authelia at ${_remote_authelia}."
|
||||
log_info "Verify it's reachable from this box before relying on it — e.g.:"
|
||||
log_info " curl -I ${_remote_authelia}"
|
||||
else
|
||||
log_info "No address entered — skipping Authelia protection."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local WANT_CADDY_PROXY=""
|
||||
prompt_yn "Reverse-proxy the web admin at https://${DOMAIN_NAME}/ via Caddy? (also gets Asterisk a trusted TLS cert for SIP instead of self-signed) (y/n):" "y" WANT_CADDY_PROXY
|
||||
prompt_yn "Get ${DOMAIN_NAME} a trusted TLS cert via Caddy for SIP TLS? (serves a minimal keep-alive page there — not Asterisk's own web admin, which stays reachable only via 'docker exec -it <container> easy-asterisk') (y/n):" "y" WANT_CADDY_PROXY
|
||||
[[ "$WANT_CADDY_PROXY" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
# Caddy is fronting this domain now either way (locally or via a remote
|
||||
# machine) — the keep-alive page doesn't reverse_proxy to anything on
|
||||
# this box in either mode, so the web admin's raw port never needs to
|
||||
# be reachable from the internet for this to work.
|
||||
WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false
|
||||
|
||||
local _CADDY_MODE="local"
|
||||
[[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -n "${CADDY_REMOTE_HOST:-}" ]] && _CADDY_MODE="remote"
|
||||
|
||||
# Asterisk runs with network_mode: host, so whatever proxies to it
|
||||
# needs a way to reach the host, not "localhost" (which resolves
|
||||
# to the proxying container's own netns). A local Caddy container
|
||||
# reaches the host via host.docker.internal (wired up in
|
||||
# services/caddy.sh's compose file); a remote Caddy machine needs
|
||||
# this box's actual public IP instead.
|
||||
local _PROXY_TARGET="host.docker.internal:${WEB_ADMIN_PORT_VAL}"
|
||||
[[ "$_CADDY_MODE" == "remote" ]] && _PROXY_TARGET="${PUBLIC_IP}:${WEB_ADMIN_PORT_VAL}"
|
||||
# Basic Auth handled entirely by Caddy — same generate/hash pattern as
|
||||
# services/security-dashboard.sh's own independent Basic Auth layer.
|
||||
local BASICAUTH_BLOCK=""
|
||||
local _use_basicauth=""
|
||||
prompt_yn " Add a Basic Auth login on this keep-alive page? (y/n):" "y" _use_basicauth
|
||||
if [[ "$_use_basicauth" =~ ^[Yy]$ ]]; then
|
||||
local BA_USER="" BA_PASS="" BA_HASH=""
|
||||
prompt_text " Basic Auth username [admin]:" "admin" BA_USER
|
||||
BA_PASS="$(generate_password 20)"
|
||||
if [[ "$_CADDY_MODE" == "local" ]]; then
|
||||
BA_HASH="$(docker exec caddy caddy hash-password --plaintext "$BA_PASS" 2>/dev/null)"
|
||||
fi
|
||||
if [ -z "$BA_HASH" ]; then
|
||||
log_warning "Could not generate the Basic Auth hash — keep-alive page will be unauthenticated."
|
||||
else
|
||||
BASICAUTH_BLOCK=" basicauth {
|
||||
${BA_USER} ${BA_HASH}
|
||||
}
|
||||
"
|
||||
log_success "Basic Auth username: ${BA_USER}"
|
||||
log_success "Basic Auth password: ${BA_PASS}"
|
||||
log_warning "Save that password now — only the bcrypt hash is written to the Caddyfile, it is not stored anywhere in plaintext."
|
||||
fi
|
||||
fi
|
||||
|
||||
local _SITE_BLOCK
|
||||
_SITE_BLOCK="$(cat << CADDY_BLOCK
|
||||
|
||||
# Asterisk Web Admin
|
||||
# Asterisk domain — keep-alive page only, for the SIP TLS cert. Asterisk's
|
||||
# own web admin is intentionally not served here — use the CLI instead:
|
||||
# docker exec -it <container> easy-asterisk
|
||||
${DOMAIN_NAME} {
|
||||
# Auth (if any) must come before reverse_proxy — forward_auth is the
|
||||
# same directive family as reverse_proxy internally, and Caddy doesn't
|
||||
# reorder repeats of the same directive within a block; it runs them in
|
||||
# the order they're written. With reverse_proxy first, it would handle
|
||||
# and terminate every request immediately, so an auth check written
|
||||
# after it would be dead code that never runs — full bypass regardless
|
||||
# of what the auth server's own rules say.
|
||||
${EXTRA_BLOCK}
|
||||
reverse_proxy ${_PROXY_TARGET}
|
||||
${BASICAUTH_BLOCK} respond "OK" 200
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
@@ -1632,10 +1794,10 @@ CADDY_BLOCK
|
||||
# actually works here. Try it anyway, fall back to a
|
||||
# restart — confirmed necessary on a real deployment.
|
||||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
log_success "Web admin accessible at: https://${DOMAIN_NAME}"
|
||||
log_success "Keep-alive page live at: https://${DOMAIN_NAME}"
|
||||
elif docker restart caddy &>/dev/null; then
|
||||
log_success "Caddy restarted to apply changes (reload API is disabled by default)"
|
||||
log_success "Web admin should be accessible at: https://${DOMAIN_NAME}"
|
||||
log_success "Keep-alive page should be live at: https://${DOMAIN_NAME}"
|
||||
else
|
||||
log_warning "Reload/restart failed — check: docker logs caddy"
|
||||
log_info "Manual fix: docker restart caddy"
|
||||
@@ -1648,7 +1810,8 @@ CADDY_BLOCK
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_SNIPPET_DIR/asterisk.caddy" 2>/dev/null || true
|
||||
log_success "Snippet saved: $_SNIPPET_DIR/asterisk.caddy"
|
||||
log_info "Copy to your Caddy machine: scp $_SNIPPET_DIR/asterisk.caddy caddy-host:~/caddy-snippets/"
|
||||
log_info "Remote Caddy reaches this box over its public IP, so the web admin port stays open below."
|
||||
log_info "This is just a keep-alive page (for the cert) — the remote Caddy machine doesn't need"
|
||||
log_info "to reach anything on this box for it, so no port needs to stay open here for this."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1742,6 +1905,199 @@ _asterisk_remind_non_do_firewall() {
|
||||
echo " UDP ${COTURN_MIN_PORT_VAL}-${COTURN_MAX_PORT_VAL} (TURN relay)"
|
||||
}
|
||||
|
||||
# ── Stack health check (update mode) ────────────────────────────────────────
|
||||
# "update" mode deliberately never re-asks the domain/networking/Caddy
|
||||
# questions a fresh install does, on the assumption whatever's already
|
||||
# configured is meant to stay that way. That assumption silently breaks for
|
||||
# any of: a domain that was set but never got wired into Caddy, a Caddy
|
||||
# block that exists but Asterisk's own TLS cert was never synced to match
|
||||
# it (transport-tls then fails to bind — "Unable to retrieve PJSIP
|
||||
# transport 'transport-tls'" in the logs, breaking every call), a baked-in
|
||||
# external IP left over from before a box move (droplet revert, IP
|
||||
# reassignment), or any of the services that chain off Asterisk (Security
|
||||
# Dashboard, sms-inbound, ntfy) having the exact same "domain set, nothing
|
||||
# actually serving it" gap of their own — none of which "update" would
|
||||
# ever notice or mention on its own. Confirmed live, all of them, across a
|
||||
# single droplet revert.
|
||||
#
|
||||
# Runs every "update", unconditionally — the CHECKING is never opt-in, so a
|
||||
# gap is never missed just because nobody thought to ask. Each FIX is
|
||||
# opt-in and named explicitly as a change when offered, since it's real
|
||||
# config being written (a Caddy block, a synced cert, a rewritten IP) —
|
||||
# never silent, unlike the rest of "update" mode's core promise of
|
||||
# touching nothing. Nothing is written unless a fix is explicitly accepted.
|
||||
_asterisk_run_stack_health_check() {
|
||||
local EA_DIR="$1" CONTAINER="$2"
|
||||
local ISSUES_FOUND=0
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " STACK HEALTH CHECK"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
local _DOMAIN _PORT
|
||||
_DOMAIN="$(grep -E '^DOMAIN_NAME=' "$EA_DIR/.env" 2>/dev/null | cut -d= -f2-)"
|
||||
_PORT="$(grep -E '^WEB_ADMIN_PORT=' "$EA_DIR/.env" 2>/dev/null | cut -d= -f2-)"
|
||||
|
||||
# ── 1. External IP baked into pjsip.conf ────────────────────────────────
|
||||
local _BAKED_IP
|
||||
_BAKED_IP="$(grep -m1 '^external_signaling_address=' "$EA_DIR/config/asterisk/pjsip.conf" 2>/dev/null | cut -d= -f2)"
|
||||
if [[ -n "$_BAKED_IP" ]]; then
|
||||
local _LIVE_IP
|
||||
_LIVE_IP="$(_asterisk_current_public_ip)"
|
||||
if [[ -n "$_LIVE_IP" ]] && [[ "$_BAKED_IP" != "$_LIVE_IP" ]]; then
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
log_warning "✗ Public IP: pjsip.conf has ${_BAKED_IP} baked in — this box is now ${_LIVE_IP}."
|
||||
log_warning " Every call's media negotiation is broken until this is fixed."
|
||||
local _FIX_IP=""
|
||||
prompt_yn " Fix it now? This rewrites external_media_address/external_signaling_address to ${_LIVE_IP} in every config file that has the old IP, then restarts Asterisk — drops any call in progress. (y/n):" "y" _FIX_IP
|
||||
if [[ "$_FIX_IP" =~ ^[Yy]$ ]]; then
|
||||
local _ESC_OLD="${_BAKED_IP//./\\.}"
|
||||
grep -rlF "$_BAKED_IP" "$EA_DIR/config" "$EA_DIR/.env" 2>/dev/null | while read -r _f; do
|
||||
sed -i "s/$_ESC_OLD/$_LIVE_IP/g" "$_f"
|
||||
done
|
||||
(cd "$EA_DIR" && docker compose restart) \
|
||||
&& log_success " Fixed — pjsip.conf now points at ${_LIVE_IP}, Asterisk restarted." \
|
||||
|| log_warning " Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs"
|
||||
fi
|
||||
else
|
||||
log_success "✓ Public IP matches what's baked into pjsip.conf (${_BAKED_IP})."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 2. Asterisk's own web-admin/SIP domain: Caddy block + TLS cert ──────
|
||||
if [[ -z "$_DOMAIN" ]]; then
|
||||
log_info "— No DOMAIN_NAME set (LAN-only / self-signed) — nothing to check here."
|
||||
elif [[ ! -d "$DOCKER_DIR/caddy" ]]; then
|
||||
log_info "— ${_DOMAIN} is set, but no local Caddy is installed here to check."
|
||||
else
|
||||
if grep -q "^${_DOMAIN}" "$DOCKER_DIR/caddy/Caddyfile" 2>/dev/null; then
|
||||
log_success "✓ ${_DOMAIN}: Caddy site block present."
|
||||
else
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
log_warning "✗ ${_DOMAIN}: DOMAIN_NAME is set, but Caddy has no site block for it — nothing is serving it."
|
||||
local _FIX_CADDY=""
|
||||
prompt_yn " Add a Caddy site block for ${_DOMAIN} now? (y/n):" "y" _FIX_CADDY
|
||||
if [[ "$_FIX_CADDY" =~ ^[Yy]$ ]]; then
|
||||
_asterisk_configure_caddy_public "$_DOMAIN" "${_PORT:-8081}" "$(_asterisk_current_public_ip)"
|
||||
fi
|
||||
fi
|
||||
|
||||
local _CERT_OK=false
|
||||
if docker exec "$CONTAINER" sh -c "openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName 2>/dev/null | grep -q \"DNS:${_DOMAIN}\"" 2>/dev/null; then
|
||||
_CERT_OK=true
|
||||
fi
|
||||
if [[ "$_CERT_OK" == true ]]; then
|
||||
log_success "✓ ${_DOMAIN}: TLS certificate matches (transport-tls can bind)."
|
||||
else
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
log_warning "✗ ${_DOMAIN}: no valid TLS certificate for this domain in the container —"
|
||||
log_warning " the transport-tls PJSIP transport will fail to bind, breaking every call"
|
||||
log_warning " (\"Unable to retrieve PJSIP transport 'transport-tls'\" in the logs)."
|
||||
local _FIX_CERT=""
|
||||
prompt_yn " Sync a certificate from Caddy and restart Asterisk now? (y/n):" "y" _FIX_CERT
|
||||
if [[ "$_FIX_CERT" =~ ^[Yy]$ ]]; then
|
||||
if docker exec "$CONTAINER" /usr/local/bin/easy-asterisk --sync-caddy-cert 2>&1 | tail -5; then
|
||||
log_success " Cert sync ran — verify: docker exec $CONTAINER openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName"
|
||||
else
|
||||
log_warning " Cert sync failed — Caddy may not have a certificate for ${_DOMAIN} yet"
|
||||
log_warning " (check: docker logs caddy), or this container predates the"
|
||||
log_warning " --sync-caddy-cert flag — re-run update once more first."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 3. Chained services: Security Dashboard, sms-inbound, ntfy ──────────
|
||||
# Each of these has the exact same "domain set (or fixed), Caddy never
|
||||
# wired" gap Asterisk itself just had — none of them persist enough
|
||||
# state to fix it without re-asking for a domain, so caddy_domain_for_
|
||||
# upstream (lib/common.sh) checks the Caddyfile directly instead, and a
|
||||
# found gap points at that service's own reinstall rather than trying
|
||||
# to script a fix here for config this file doesn't own.
|
||||
#
|
||||
# This box's local Caddyfile is the only thing checkable from here —
|
||||
# any of these three can instead be fronted by a Caddy (and Authelia)
|
||||
# on a completely different box, the same remote-Caddy pattern
|
||||
# sms-inbound.sh and ntfy.sh's own installers already support (see
|
||||
# CADDY_MODE/CADDY_REMOTE_HOST in the site config). "Not found in the
|
||||
# local Caddyfile" only COUNTS as an issue when the site is actually
|
||||
# configured for local Caddy — the same resolution those installers use.
|
||||
# In remote (or no-Caddy) mode it's expected, not broken: reported
|
||||
# informationally, with no fix offered, since guessing wrong here would
|
||||
# add a redundant/conflicting local block for something deliberately
|
||||
# fronted elsewhere.
|
||||
local _SITE_CADDY_MODE="${CADDY_MODE:-none}"
|
||||
[ "$_SITE_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _SITE_CADDY_MODE="local"
|
||||
[ "$_SITE_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _SITE_CADDY_MODE="remote"
|
||||
|
||||
if declare -F caddy_domain_for_upstream >/dev/null 2>&1; then
|
||||
if [[ -f /opt/security-dashboard/app.py ]]; then
|
||||
local _SD_DOMAIN
|
||||
_SD_DOMAIN="$(caddy_domain_for_upstream "host.docker.internal:8092")"
|
||||
if [[ -n "$_SD_DOMAIN" ]]; then
|
||||
log_success "✓ Security Dashboard: Caddy serving it at ${_SD_DOMAIN}."
|
||||
elif [[ "$_SITE_CADDY_MODE" != "local" ]]; then
|
||||
log_info "— Security Dashboard: no site block in this box's local Caddyfile (site is in ${_SITE_CADDY_MODE} Caddy mode — likely fronted by a Caddy/Authelia on a different box; not checked here)."
|
||||
else
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
log_warning "✗ Security Dashboard is installed, but Caddy has no site block for it."
|
||||
local _FIX_SD=""
|
||||
prompt_yn " Configure Caddy for the Security Dashboard now? (y/n):" "y" _FIX_SD
|
||||
if [[ "$_FIX_SD" =~ ^[Yy]$ ]] && declare -F _secdash_configure_caddy >/dev/null 2>&1; then
|
||||
_secdash_configure_caddy 8092
|
||||
elif [[ "$_FIX_SD" =~ ^[Yy]$ ]]; then
|
||||
log_warning " services/security-dashboard.sh isn't loaded in this run — re-run it directly: sudo ./setup.sh security-dashboard"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f /opt/sms-inbound/settings.env ]]; then
|
||||
local SMS_RELAY_DOMAIN="" SMS_RELAY_PORT="" SMS_FORWARD_URL=""
|
||||
# shellcheck disable=SC1091
|
||||
source /opt/sms-inbound/settings.env
|
||||
if [[ -z "$SMS_RELAY_DOMAIN" || "$SMS_FORWARD_URL" == *"<your-domain>"* ]]; then
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
log_warning "✗ sms-inbound is installed, but has no real webhook domain set — SMS delivery can't work."
|
||||
log_warning " Re-run 'sudo ./setup.sh sms-inbound' and choose \"f) Full reinstall\" to be asked for it (needs DNS pointed here first)."
|
||||
elif [[ -n "$(caddy_domain_for_upstream "host.docker.internal:${SMS_RELAY_PORT}")" ]]; then
|
||||
log_success "✓ sms-inbound: Caddy serving the webhook at ${SMS_RELAY_DOMAIN}."
|
||||
elif [[ "$_SITE_CADDY_MODE" != "local" ]]; then
|
||||
log_info "— sms-inbound: no site block in this box's local Caddyfile (site is in ${_SITE_CADDY_MODE} Caddy mode — likely fronted by a Caddy/Authelia on a different box; not checked here)."
|
||||
else
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
log_warning "✗ sms-inbound has a domain set (${SMS_RELAY_DOMAIN}), but Caddy has no site block for it."
|
||||
log_warning " Re-run 'sudo ./setup.sh sms-inbound' and choose \"f) Full reinstall\" to fix it (re-enters the same domain, re-adds the Caddy block)."
|
||||
fi
|
||||
fi
|
||||
|
||||
local _ntfy_dir
|
||||
for _ntfy_dir in "$DOCKER_DIR"/ntfy "$DOCKER_DIR"/ntfy-*; do
|
||||
[[ -d "$_ntfy_dir" ]] || continue
|
||||
local _ntfy_container
|
||||
_ntfy_container="$(basename "$_ntfy_dir")"
|
||||
local _NTFY_DOMAIN
|
||||
_NTFY_DOMAIN="$(caddy_domain_for_upstream "${_ntfy_container}:80")"
|
||||
if [[ -n "$_NTFY_DOMAIN" ]]; then
|
||||
log_success "✓ ntfy (${_ntfy_container}): Caddy serving it at ${_NTFY_DOMAIN}."
|
||||
elif [[ "$_SITE_CADDY_MODE" != "local" ]]; then
|
||||
log_info "— ntfy (${_ntfy_container}): no site block in this box's local Caddyfile (site is in ${_SITE_CADDY_MODE} Caddy mode — likely fronted by a Caddy/Authelia on a different box; not checked here)."
|
||||
else
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
log_warning "✗ ntfy (${_ntfy_container}) is installed, but Caddy has no site block for it."
|
||||
log_warning " Re-run 'sudo ./setup.sh ntfy' and choose \"f) Full reinstall\" to fix it — that's the only mode that re-asks the domain."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [[ "$ISSUES_FOUND" -eq 0 ]]; then
|
||||
log_success "Stack health check: everything checked is fully wired."
|
||||
else
|
||||
log_warning "Stack health check: $ISSUES_FOUND issue(s) found (see above)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Shared: README ─────────────────────────────────────────────────────────
|
||||
# One document with a droplet-only section appended in public-cloud mode, so
|
||||
# the two deployment shapes can't document themselves differently by accident.
|
||||
@@ -2094,6 +2450,10 @@ install_asterisk() {
|
||||
echo "[DRY-RUN] Would offer to also set up the Security Dashboard and a PSTN trunk in this"
|
||||
echo "[DRY-RUN] same run (calling services/security-dashboard.sh / services/pstn-trunk.sh"
|
||||
echo "[DRY-RUN] directly — both stay independently invocable via their own service name too)"
|
||||
echo "[DRY-RUN] Update mode would run a stack health check: baked-in public IP vs. this"
|
||||
echo "[DRY-RUN] box's actual one, Asterisk's own domain (Caddy block + TLS cert), and"
|
||||
echo "[DRY-RUN] whether the Security Dashboard/sms-inbound/ntfy (if installed) actually"
|
||||
echo "[DRY-RUN] have Caddy wired up — reports anything unwired and offers to fix it"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -2141,6 +2501,8 @@ install_asterisk() {
|
||||
_asterisk_write_voicemail_dialplan "$EA_DIR/config/asterisk/voicemail-dialplan.conf"
|
||||
_asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf"
|
||||
_asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER"
|
||||
_asterisk_patch_keepalive_vendor_files "$EA_DIR"
|
||||
_asterisk_patch_cert_sync_cli "$EA_DIR"
|
||||
ensure_docker_dir_ownership "$EA_DIR/config/asterisk"
|
||||
chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf"
|
||||
|
||||
@@ -2148,6 +2510,7 @@ install_asterisk() {
|
||||
if docker compose up -d --build --force-recreate; then
|
||||
log_success "Update complete — vendor files and docker-compose.yml refreshed."
|
||||
_asterisk_ensure_live_dangerously "$EA_DIR" "$CONTAINER"
|
||||
_asterisk_ensure_live_keepalive "$EA_DIR" "$CONTAINER"
|
||||
else
|
||||
log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs"
|
||||
fi
|
||||
@@ -2170,8 +2533,11 @@ install_asterisk() {
|
||||
local _EXISTING_DOMAIN _EXISTING_PORT
|
||||
_EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)"
|
||||
_EXISTING_PORT="$(grep -E '^WEB_ADMIN_PORT=' .env | cut -d= -f2-)"
|
||||
|
||||
log_success "Existing .env and firewall rules were left untouched."
|
||||
_asterisk_run_stack_health_check "$EA_DIR" "$CONTAINER"
|
||||
|
||||
echo ""
|
||||
log_success "Existing .env, firewall rules, and Caddy/Authelia config were left untouched."
|
||||
if [[ -n "$_EXISTING_DOMAIN" ]]; then
|
||||
echo " Web admin: https://${_EXISTING_DOMAIN}/"
|
||||
else
|
||||
@@ -2237,6 +2603,7 @@ install_asterisk() {
|
||||
_asterisk_write_voicemail_dialplan "$EA_DIR/config/asterisk/voicemail-dialplan.conf"
|
||||
_asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf"
|
||||
_asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER"
|
||||
_asterisk_patch_keepalive_vendor_files "$EA_DIR"
|
||||
ensure_docker_dir_ownership "$EA_DIR/config/asterisk"
|
||||
chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf"
|
||||
|
||||
@@ -2366,6 +2733,7 @@ install_asterisk() {
|
||||
# WireGuard/Tailscale) on a subnet this box isn't directly attached to."
|
||||
fi
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ENV
|
||||
# ── Domain ────────────────────────────────────────────────────
|
||||
# ${_domain_comment}
|
||||
@@ -2495,6 +2863,7 @@ ENV
|
||||
if docker compose up -d --build; then
|
||||
log_success "Easy Asterisk started"
|
||||
_asterisk_ensure_live_dangerously "$EA_DIR" "$CONTAINER"
|
||||
_asterisk_ensure_live_keepalive "$EA_DIR" "$CONTAINER"
|
||||
else
|
||||
log_warning "Start failed — check: docker compose logs"
|
||||
fi
|
||||
|
||||
@@ -180,6 +180,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -198,6 +203,59 @@ fi
|
||||
|
||||
register_service audiobookshelf media "Audiobook & podcast server (Audiobookshelf)" 13378
|
||||
|
||||
# Offers to register Audiobookshelf as an Authelia OIDC client and prints
|
||||
# exactly what to paste into its own settings — checked against
|
||||
# audiobookshelf.org's own OIDC docs directly: config lives entirely in
|
||||
# Settings -> Authentication in the app's UI, no env var or config API to
|
||||
# automate the app side with (unlike Mealie/ActualBudget/Immich), so this
|
||||
# only automates the Authelia half. Its own Authelia integration doc
|
||||
# (authelia.com) requires PKCE — the fifth arg to
|
||||
# _authelia_provision_oidc_client below.
|
||||
_audiobookshelf_offer_authelia_oidc() {
|
||||
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
|
||||
[ -d "$DOCKER_DIR/authelia" ] || return 0
|
||||
|
||||
echo ""
|
||||
local USE_SSO=""
|
||||
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Audiobookshelf? (y/n):" "n" USE_SSO
|
||||
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
local APP_DOMAIN
|
||||
APP_DOMAIN="$(_authelia_pick_domain "Domain Audiobookshelf is reachable at (number or domain)")"
|
||||
if [ -z "$APP_DOMAIN" ]; then
|
||||
log_warning "No domain entered — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _2fa="" AUTH_POLICY="two_factor"
|
||||
prompt_yn " Require two-factor for Audiobookshelf logins via Authelia too? (y/n):" "y" _2fa
|
||||
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
|
||||
|
||||
if ! _authelia_provision_oidc_client "Audiobookshelf" "audiobookshelf" "$AUTH_POLICY" "y" "y" "" \
|
||||
"https://${APP_DOMAIN}/auth/openid/callback" "https://${APP_DOMAIN}/auth/openid/mobile-redirect" "audiobookshelf://oauth"; then
|
||||
log_warning "Couldn't register Audiobookshelf as an OIDC client in Authelia — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Audiobookshelf -> Settings -> Authentication -> enable OpenID Connect"
|
||||
echo " Authentication, then fill in (it wants individual endpoints, not a"
|
||||
echo " discovery URL):"
|
||||
echo " Issuer URL: ${OIDC_AUTHELIA_PORTAL_URL}"
|
||||
echo " Authorize URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/authorization"
|
||||
echo " Token URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/token"
|
||||
echo " Userinfo URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/userinfo"
|
||||
echo " JWKS URL: ${OIDC_AUTHELIA_PORTAL_URL}/jwks.json"
|
||||
echo " Client ID: audiobookshelf"
|
||||
echo " Client Secret: $OIDC_CLIENT_SECRET_PLAIN"
|
||||
echo " Signing Algorithm: RS256"
|
||||
echo " Allowed Mobile Redirect URIs: audiobookshelf://oauth"
|
||||
echo ""
|
||||
log_warning "The Client Secret above is shown once — save it now."
|
||||
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "audiobookshelf" "$APP_DOMAIN"
|
||||
}
|
||||
|
||||
install_audiobookshelf() {
|
||||
require_docker || return 1
|
||||
|
||||
@@ -261,6 +319,7 @@ install_audiobookshelf() {
|
||||
( cd "$ABS_DIR" && docker compose pull && docker compose up -d ) \
|
||||
&& log_success "Audiobookshelf image refreshed" \
|
||||
|| log_warning "Refresh failed — check: docker compose -f $ABS_DIR/docker-compose.yml logs"
|
||||
_audiobookshelf_offer_authelia_oidc
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
@@ -325,6 +384,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << ABS_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -346,6 +406,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
ABS_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ABS_ENV
|
||||
AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH
|
||||
PODCASTS_PATH=./podcasts
|
||||
@@ -358,6 +419,8 @@ ABS_ENV
|
||||
|
||||
configure_caddy_for_service "Audiobookshelf${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "audiobooks${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||
|
||||
_audiobookshelf_offer_authelia_oidc
|
||||
|
||||
write_readme "$ABS_DIR" << MD
|
||||
# Audiobookshelf${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
|
||||
+1924
-136
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ install_base() {
|
||||
echo "[DRY-RUN] Would offer to mount SMB data from a NetBird-connected home box (if NetBird is present)"
|
||||
echo "[DRY-RUN] Would offer Caddy reverse proxy install (full repo only)"
|
||||
echo "[DRY-RUN] Would offer CrowdSec intrusion prevention install (full repo only)"
|
||||
echo "[DRY-RUN] Would offer Samba (SMB/CIFS) file sharing install (full repo only)"
|
||||
echo "[DRY-RUN] Would offer to add SSH Host aliases to ~/.ssh/config"
|
||||
return 0
|
||||
fi
|
||||
@@ -80,6 +81,14 @@ install_base() {
|
||||
_base_setup_crowdsec
|
||||
cd "$_BASE_PWD" 2>/dev/null || true
|
||||
|
||||
# ── Samba ────────────────────────────────────────────────────────────────
|
||||
# Same nudge-not-mandatory shape as Caddy/CrowdSec above: fully optional,
|
||||
# independently re-runnable later via `sudo ./setup.sh samba`. Defaults to
|
||||
# n (unlike Caddy/CrowdSec) because it needs real input to be useful — a
|
||||
# share path and at least one user — not just "yes, with sane defaults".
|
||||
_base_setup_samba
|
||||
cd "$_BASE_PWD" 2>/dev/null || true
|
||||
|
||||
# ── SSH Host aliases ─────────────────────────────────────────────────────
|
||||
_base_setup_ssh_aliases
|
||||
|
||||
@@ -329,6 +338,22 @@ _base_setup_crowdsec() {
|
||||
install_crowdsec
|
||||
}
|
||||
|
||||
_base_setup_samba() {
|
||||
if command -v smbd &>/dev/null; then
|
||||
log_info "Samba already installed."
|
||||
return 0
|
||||
fi
|
||||
# Only available when the full repo is sourced (setup.sh loads every
|
||||
# services/*.sh up front) — a standalone copy of base.sh doesn't have
|
||||
# install_samba, so skip silently rather than error.
|
||||
declare -F install_samba &>/dev/null || return 0
|
||||
|
||||
local INSTALL_SAMBA=""
|
||||
prompt_yn "Install Samba (SMB/CIFS) file sharing now — shares, users, passwords? (y/n):" "n" INSTALL_SAMBA
|
||||
[[ "$INSTALL_SAMBA" =~ ^[Yy]$ ]] || return 0
|
||||
install_samba
|
||||
}
|
||||
|
||||
_base_setup_ssh_aliases() {
|
||||
local ADD_ALIAS=""
|
||||
prompt_yn "Add an SSH Host alias now ('ssh myserver' instead of 'ssh user@1.2.3.4')? (y/n):" "n" ADD_ALIAS
|
||||
|
||||
@@ -120,6 +120,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
local _companion; _companion="$(dirname "${BASH_SOURCE[0]}")/beszel.md"
|
||||
[ -f "$_companion" ] && cat "$_companion" >> "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -251,6 +256,112 @@ _beszel_configure_agent() {
|
||||
|| log_warning "Agent failed to start — check: docker compose -f $dir/docker-compose.yml logs beszel-agent"
|
||||
}
|
||||
|
||||
# Registers Beszel as an Authelia OIDC client and prints exactly what to
|
||||
# paste into the hub's own settings. Checked against beszel.dev's own OAuth
|
||||
# docs directly: Beszel is PocketBase-based, and its OAuth2 provider config
|
||||
# is a PocketBase admin-UI setting (Settings -> Auth providers), not
|
||||
# something exposed by any documented API or env var — so, like
|
||||
# Audiobookshelf, this only automates the Authelia half. Beszel's own
|
||||
# Authelia integration doc (authelia.com) requires PKCE.
|
||||
#
|
||||
# Args: DIR (the .env holding DISABLE_PASSWORD_AUTH/USER_CREATION lives there)
|
||||
_beszel_offer_authelia_oidc() {
|
||||
local dir="$1"
|
||||
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
|
||||
[ -d "$DOCKER_DIR/authelia" ] || return 0
|
||||
|
||||
echo ""
|
||||
local USE_SSO=""
|
||||
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Beszel? (y/n):" "n" USE_SSO
|
||||
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
local APP_DOMAIN
|
||||
APP_DOMAIN="$(_authelia_pick_domain "Domain Beszel is reachable at (number or domain)")"
|
||||
if [ -z "$APP_DOMAIN" ]; then
|
||||
log_warning "No domain entered — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _2fa="" AUTH_POLICY="two_factor"
|
||||
prompt_yn " Require two-factor for Beszel logins via Authelia too? (y/n):" "y" _2fa
|
||||
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
|
||||
|
||||
if ! _authelia_provision_oidc_client "Beszel" "beszel" "$AUTH_POLICY" "y" "y" "" \
|
||||
"https://${APP_DOMAIN}/api/oauth2-redirect"; then
|
||||
log_warning "Couldn't register Beszel as an OIDC client in Authelia — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " This lives in PocketBase's own admin panel underneath the hub, not the"
|
||||
echo " hub's own Settings page — checked against beszel.dev's OAuth guide"
|
||||
echo " directly, exact steps:"
|
||||
echo " 1) Go to https://<your-beszel-domain>/_/#/settings and toggle OFF"
|
||||
echo " \"Hide collection create and edit controls\""
|
||||
echo " 2) Go to Collections, edit the \"users\" collection"
|
||||
echo " 3) Options tab -> enable OAuth2 -> Add provider, fill in:"
|
||||
echo " Client ID: beszel"
|
||||
echo " Client Secret: $OIDC_CLIENT_SECRET_PLAIN"
|
||||
echo " Auth URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/authorization"
|
||||
echo " Token URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/token"
|
||||
echo " User Info URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/userinfo"
|
||||
echo " 4) Save, then toggle \"Hide collection create and edit controls\" back ON"
|
||||
echo " at /_/#/settings — leaving it off is its own exposure once you're done"
|
||||
echo ""
|
||||
log_warning "The Client Secret above is shown once — save it now."
|
||||
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "beszel" "$APP_DOMAIN"
|
||||
|
||||
echo ""
|
||||
log_info "Paste those values into Beszel's Settings -> Auth providers -> OpenID"
|
||||
log_info "Connect page now, then log out and click through the Authelia login"
|
||||
log_info "button to confirm it actually works — BEFORE going any further here."
|
||||
echo ""
|
||||
log_warning "The next step can disable Beszel's password login entirely. Confirmed"
|
||||
log_warning "live: saying yes here before actually testing the button leaves NEITHER"
|
||||
log_warning "login path working — the password form is gone, and the OAuth provider"
|
||||
log_warning "was never actually finished on Beszel's side, so its button never"
|
||||
log_warning "appears either. Re-run 'sudo ./setup.sh beszel' (choose update) any time"
|
||||
log_warning "later to come back to this once you've verified the button works."
|
||||
}
|
||||
|
||||
# Split out from _beszel_offer_authelia_oidc so it can also be re-reached on
|
||||
# its own via a later "update" rerun, once the admin has actually gone and
|
||||
# tested the Authelia login button — see that function's own warning for
|
||||
# why this can't be offered in the same breath as printing the paste-in
|
||||
# values. DISABLE_PASSWORD_AUTH/USER_CREATION are real, documented env vars
|
||||
# (beszel.dev's own OAuth guide).
|
||||
_beszel_offer_disable_password_auth() {
|
||||
local dir="$1"
|
||||
[ -f "$dir/.env" ] || return 0
|
||||
grep -qF "client_id: 'beszel'" "$DOCKER_DIR/authelia/config/configuration.yml" 2>/dev/null || return 0
|
||||
grep -q '^DISABLE_PASSWORD_AUTH=true' "$dir/.env" 2>/dev/null && return 0
|
||||
|
||||
echo ""
|
||||
local _tested=""
|
||||
prompt_yn " Have you ALREADY logged into Beszel successfully using the Authelia button (not just pasted the values)? (y/n):" "n" _tested
|
||||
if [[ ! "$_tested" =~ ^[Yy]$ ]]; then
|
||||
log_info "Skipped. Test the Authelia login button first, then re-run 'sudo ./setup.sh beszel' (choose update) to come back to this."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _disable_local=""
|
||||
prompt_yn " Disable Beszel's own password login now, so Authelia is the only way in? (y/n):" "n" _disable_local
|
||||
[[ "$_disable_local" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
local _auto_register=""
|
||||
prompt_yn " Auto-create Beszel accounts for new Authelia logins? (y/n):" "n" _auto_register
|
||||
sed -i '/^DISABLE_PASSWORD_AUTH=/d; /^USER_CREATION=/d' "$dir/.env"
|
||||
{
|
||||
echo "DISABLE_PASSWORD_AUTH=true"
|
||||
[[ "$_auto_register" =~ ^[Yy]$ ]] && echo "USER_CREATION=true"
|
||||
} >> "$dir/.env"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/.env" 2>/dev/null || true
|
||||
( cd "$dir" && docker compose up -d beszel ) \
|
||||
&& log_success "Beszel's own password login is now disabled — Authelia is the only way in." \
|
||||
|| log_warning "Restart failed — check: docker compose -f $dir/docker-compose.yml logs beszel"
|
||||
}
|
||||
|
||||
install_beszel() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Beszel..."
|
||||
@@ -286,6 +397,8 @@ install_beszel() {
|
||||
prompt_yn " The agent was never connected — set it up now? (y/n):" "y" FINISH_AGENT
|
||||
[[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "http://localhost:${_WP} (or its Caddy domain, once configured)"
|
||||
fi
|
||||
_beszel_offer_authelia_oidc "$DIR"
|
||||
_beszel_offer_disable_password_auth "$DIR"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
@@ -350,6 +463,7 @@ networks:
|
||||
# profile recognizes. security_opt: apparmor:unconfined below is
|
||||
# Beszel's own documented fix (beszel.dev/guide/systemd#apparmor-error)
|
||||
# — confirmed live, this exact error on a real box.
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << BESZEL_COMPOSE
|
||||
name: beszel
|
||||
|
||||
@@ -396,6 +510,7 @@ BESZEL_COMPOSE
|
||||
# README). Not threaded through automatically here because Caddy setup
|
||||
# (below) happens after this file is written, same ordering every
|
||||
# other service in this repo uses for its own Caddy prompt.
|
||||
backup_if_exists .env
|
||||
cat > .env << BESZEL_ENV
|
||||
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
@@ -423,6 +538,8 @@ BESZEL_ENV
|
||||
|
||||
_beszel_configure_agent "$DIR" "http://localhost:${WEB_PORT} (or its Caddy domain, once configured)"
|
||||
|
||||
_beszel_offer_authelia_oidc "$DIR"
|
||||
|
||||
write_readme "$DIR" << 'BESZEL_README'
|
||||
# Beszel — lightweight server + Docker monitoring
|
||||
|
||||
@@ -568,6 +685,7 @@ install_beszel-agent() {
|
||||
# and there's no caddy_net to conditionally join since this box never
|
||||
# runs a web UI of its own. See that function's own comment for why the
|
||||
# systemd/dbus/sensor mounts below matter (Services/Temp columns).
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << AGENT_COMPOSE
|
||||
name: beszel-agent
|
||||
|
||||
@@ -593,6 +711,7 @@ services:
|
||||
- /sys/class/thermal:/sys/class/thermal:ro
|
||||
AGENT_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << AGENT_ENV
|
||||
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
||||
HUB_URL=$HUB_URL
|
||||
|
||||
@@ -192,6 +192,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -279,6 +284,7 @@ install_caddy() {
|
||||
|
||||
cd "$CADDY_DIR" || return 1
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << 'CADDY_COMPOSE'
|
||||
name: caddy
|
||||
|
||||
|
||||
@@ -177,6 +177,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -242,6 +247,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << CW_COMPOSE
|
||||
name: calibre-web
|
||||
|
||||
@@ -264,6 +270,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
CW_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << CW_ENV
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
CW_ENV
|
||||
|
||||
@@ -177,6 +177,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -243,6 +248,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << CD_COMPOSE
|
||||
name: changedetection
|
||||
|
||||
@@ -272,6 +278,7 @@ ${_CADDY_NET_BLOCK} depends_on:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
CD_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << CD_ENV
|
||||
# Changedetection.io environment — edit before starting if needed
|
||||
BASE_URL=https://changes.${SITE_DOMAIN}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/bin/bash
|
||||
# services/claude-cli.sh — Claude Code CLI: dual-account setup, model/effort
|
||||
# defaults, and a shared global CLAUDE.md.
|
||||
#
|
||||
# Non-Docker (see CLAUDE.md's "Non-Docker services" section). Installs the
|
||||
# official Claude Code CLI if missing, then wires up:
|
||||
# - two independent account config directories (work/personal), each its
|
||||
# own CLAUDE_CONFIG_DIR behind a shell alias, so `claude-work` and
|
||||
# `claude-personal` are two fully separate logins on one machine
|
||||
# - one shared, imported global CLAUDE.md (durable personal conventions —
|
||||
# modular/reuse code, numbered CLI menus with 0=always-exit, verify web
|
||||
# UI changes with Playwright) that both accounts pull in via `@import`,
|
||||
# so there's exactly one copy to edit, not two that can drift
|
||||
# - settings.json defaults applied to both accounts: model pinned to
|
||||
# claude-sonnet-5, effort level medium, and ENABLE_PROMPT_CACHING_1H=1
|
||||
# (keeps the 1h prompt-cache lifetime even after usage credits kick in,
|
||||
# instead of dropping to 5 minutes — see services/ai-stack.md's hybrid
|
||||
# workflow section for why this pairs with a local-GPU + Claude Code split)
|
||||
#
|
||||
# The Anthropic login itself (browser OAuth) can't be scripted — this only
|
||||
# prepares the directories/aliases/config. Run `claude-work` and
|
||||
# `claude-personal` once each afterward to actually log each one in.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
|
||||
register_service claude-cli extras "Claude Code CLI — dual-account setup (work/personal), model/effort defaults, shared global CLAUDE.md"
|
||||
|
||||
install_claude-cli() {
|
||||
local WORK_DIR="$ACTUAL_HOME/.claude-work"
|
||||
local PERSONAL_DIR="$ACTUAL_HOME/.claude-personal"
|
||||
local SHARED_DIR="$ACTUAL_HOME/.claude-shared"
|
||||
local SHARED_CLAUDE_MD="$SHARED_DIR/CLAUDE.md"
|
||||
local BASHRC="$ACTUAL_HOME/.bashrc"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would install the Claude Code CLI (official installer) if missing"
|
||||
echo "[DRY-RUN] Would create $WORK_DIR and $PERSONAL_DIR config dirs"
|
||||
echo "[DRY-RUN] Would write $SHARED_CLAUDE_MD (shared conventions) and import it from each account's CLAUDE.md"
|
||||
echo "[DRY-RUN] Would write settings.json (model=claude-sonnet-5, effortLevel=medium, ENABLE_PROMPT_CACHING_1H=1) into each account dir"
|
||||
echo "[DRY-RUN] Would add claude-work/claude-personal aliases to $BASHRC (idempotent)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$SHARED_CLAUDE_MD" ]; then
|
||||
local MODE=""
|
||||
prompt_reinstall_mode MODE
|
||||
case "$MODE" in
|
||||
update)
|
||||
log_info "Refreshing shared CLAUDE.md and settings.json only — account dirs/credentials untouched."
|
||||
_claude_cli_write_shared_claude_md "$SHARED_CLAUDE_MD"
|
||||
_claude_cli_write_settings "$WORK_DIR/settings.json"
|
||||
_claude_cli_write_settings "$PERSONAL_DIR/settings.json"
|
||||
ensure_docker_dir_ownership "$SHARED_DIR" "$WORK_DIR" "$PERSONAL_DIR"
|
||||
log_success "claude-cli config refreshed"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
log_info "Leaving the existing claude-cli setup as-is."
|
||||
return 0
|
||||
;;
|
||||
fresh) ;; # fall through to the full setup below
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── Install the CLI itself ──────────────────────────────────────────────
|
||||
if ! command -v claude >/dev/null 2>&1; then
|
||||
log_info "Installing Claude Code CLI..."
|
||||
if curl -fsSL https://claude.ai/install.sh | bash; then
|
||||
log_success "Claude Code CLI installed"
|
||||
else
|
||||
log_error "Claude Code CLI install failed — see https://code.claude.com/docs/en/setup"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_info "Claude Code CLI already installed ($(command -v claude))"
|
||||
fi
|
||||
|
||||
# ── Account config dirs + shared conventions ────────────────────────────
|
||||
mkdir -p "$WORK_DIR" "$PERSONAL_DIR" "$SHARED_DIR"
|
||||
_claude_cli_write_shared_claude_md "$SHARED_CLAUDE_MD"
|
||||
|
||||
local _dir
|
||||
for _dir in "$WORK_DIR" "$PERSONAL_DIR"; do
|
||||
# @import pulls the shared file in at session start (see Claude
|
||||
# Code's memory docs) — one canonical copy, not two that can drift.
|
||||
[ -f "$_dir/CLAUDE.md" ] || printf '@%s\n' "$SHARED_CLAUDE_MD" > "$_dir/CLAUDE.md"
|
||||
_claude_cli_write_settings "$_dir/settings.json"
|
||||
done
|
||||
|
||||
# ── Shell aliases — idempotent, same append-once pattern base.sh uses
|
||||
# for tab completion (grep-before-append, chown after) ──────────────────
|
||||
if [ -f "$BASHRC" ] && ! grep -qF "CLAUDE_CONFIG_DIR=$WORK_DIR" "$BASHRC" 2>/dev/null; then
|
||||
{
|
||||
echo ""
|
||||
echo "# ubuntu-post-install: claude-cli dual-account aliases"
|
||||
echo "alias claude-work='CLAUDE_CONFIG_DIR=$WORK_DIR claude'"
|
||||
echo "alias claude-personal='CLAUDE_CONFIG_DIR=$PERSONAL_DIR claude'"
|
||||
} >> "$BASHRC"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$BASHRC" 2>/dev/null || true
|
||||
log_success "Added claude-work / claude-personal aliases to $BASHRC (new shells, or: source $BASHRC)"
|
||||
fi
|
||||
|
||||
ensure_docker_dir_ownership "$SHARED_DIR" "$WORK_DIR" "$PERSONAL_DIR"
|
||||
|
||||
echo ""
|
||||
log_warning "Login still needs a one-time browser step per account — this only prepared the plumbing:"
|
||||
echo " claude-work # first run: browser OAuth login for your work account"
|
||||
echo " claude-personal # first run: browser OAuth login for your personal account"
|
||||
echo ""
|
||||
echo " Shared conventions : $SHARED_CLAUDE_MD (edit once, both accounts see it)"
|
||||
echo " Work config : $WORK_DIR"
|
||||
echo " Personal config : $PERSONAL_DIR"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Writes/refreshes the three keys this service owns via jq (preserves any
|
||||
# other hand-added settings, e.g. permissions); falls back to a fresh file
|
||||
# if jq is missing (base.sh installs it, but this service can run standalone)
|
||||
# or the existing file isn't valid JSON.
|
||||
_claude_cli_write_settings() {
|
||||
local dest="$1"
|
||||
local patch='{"model":"claude-sonnet-5","effortLevel":"medium","env":{"ENABLE_PROMPT_CACHING_1H":"1"}}'
|
||||
|
||||
if [ -f "$dest" ] && command -v jq >/dev/null 2>&1; then
|
||||
local merged
|
||||
merged="$(jq -s '.[0] * .[1]' "$dest" <(echo "$patch") 2>/dev/null)" \
|
||||
&& [ -n "$merged" ] \
|
||||
&& printf '%s\n' "$merged" > "$dest" \
|
||||
&& return 0
|
||||
log_warning "$dest wasn't valid JSON — leaving it untouched. Merge manually: $patch"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ -f "$dest" ] && return 0
|
||||
echo "$patch" | (command -v jq >/dev/null 2>&1 && jq . || cat) > "$dest"
|
||||
}
|
||||
|
||||
_claude_cli_write_shared_claude_md() {
|
||||
cat > "$1" << 'EOF'
|
||||
# Personal conventions (all projects, both accounts)
|
||||
|
||||
## Code reuse
|
||||
Write shared logic once, in one place. Before adding a new function, check
|
||||
whether an existing one already does it — extend/parameterize rather than
|
||||
duplicate.
|
||||
|
||||
## CLI menus
|
||||
Every interactive menu is numbered. `0` is always "exit" / "back" — never
|
||||
reused for another action, and always present, even on a submenu.
|
||||
|
||||
## Verifying web UI changes
|
||||
After any frontend change, drive it with Playwright before calling it done —
|
||||
navigate the real page, exercise the changed flow, screenshot if the result
|
||||
is visual. Don't declare a UI task complete from reading the code alone.
|
||||
EOF
|
||||
}
|
||||
@@ -174,6 +174,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -212,6 +217,7 @@ install_ddclient() {
|
||||
|
||||
local TZ_VAL; TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << 'DDCLIENT_COMPOSE'
|
||||
name: ddclient
|
||||
|
||||
@@ -229,6 +235,7 @@ services:
|
||||
- ./config:/config
|
||||
DDCLIENT_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << DDCLIENT_ENV
|
||||
TZ=$TZ_VAL
|
||||
DDCLIENT_ENV
|
||||
|
||||
@@ -178,6 +178,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -477,6 +482,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << DRUM_COMPOSE
|
||||
name: drum-rhythm-game
|
||||
|
||||
@@ -492,6 +498,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
DRUM_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << DRUM_ENV
|
||||
CADDY_NET=${SITE_CADDY_NET}
|
||||
DRUM_ENV
|
||||
|
||||
@@ -189,6 +189,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -352,6 +357,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << EMBY_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -377,6 +383,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
EMBY_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << EMBY_ENV
|
||||
MEDIA_PATH=$MEDIA_PATH
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -177,6 +177,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -315,6 +320,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << FB_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -334,6 +340,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
FB_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << FB_ENV
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
FB_ENV
|
||||
|
||||
@@ -199,6 +199,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -339,6 +344,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << FMD_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -357,6 +363,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
FMD_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << FMD_ENV
|
||||
FMD_REGISTRATIONTOKEN=$FMD_TOKEN
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -207,6 +207,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -365,6 +370,7 @@ install_frigate-audio() {
|
||||
|
||||
# ── .env ──────────────────────────────────────────────────────────────────
|
||||
log_info "Writing .env..."
|
||||
backup_if_exists "$DIR/.env"
|
||||
cat > "$DIR/.env" << ENVEOF
|
||||
# Frigate audio stack — generated by setup.sh
|
||||
# DO NOT commit this file — it contains credentials.
|
||||
@@ -432,6 +438,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists "$DIR/docker-compose.yml"
|
||||
cat > "$DIR/docker-compose.yml" << 'COMPOSEEOF'
|
||||
# Frigate NVR + Mosquitto MQTT + frigate-notify
|
||||
# Generated by ubuntu-post-install setup.sh
|
||||
|
||||
@@ -174,6 +174,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -234,6 +239,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << FN_COMPOSE
|
||||
name: frigate-notify
|
||||
|
||||
|
||||
+152
-8
@@ -83,7 +83,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
}
|
||||
|
||||
configure_caddy_for_service() {
|
||||
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" _rp_extra="${5:-}"
|
||||
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||
local _display_port="${_upstream##*:}"
|
||||
@@ -126,12 +126,23 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
|
||||
fi
|
||||
|
||||
local _rp_line="reverse_proxy ${_block_upstream}"
|
||||
if [[ -n "$_rp_extra" ]]; then
|
||||
_rp_line="reverse_proxy ${_block_upstream} {
|
||||
${_rp_extra}
|
||||
}"
|
||||
fi
|
||||
|
||||
local _site_block
|
||||
_site_block="$(cat << CBLOCK
|
||||
|
||||
# $_name
|
||||
${_domain} {
|
||||
reverse_proxy ${_block_upstream}
|
||||
# Auth (if any) must come before reverse_proxy — see lib/common.sh's
|
||||
# configure_caddy_for_service for why (reverse_proxy first would answer
|
||||
# every request itself, making an auth block after it dead code).
|
||||
${_extra}
|
||||
${_rp_line}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
@@ -144,7 +155,6 @@ ${_domain} {
|
||||
output file /var/log/caddy/${_domain}.log
|
||||
format json
|
||||
}
|
||||
${_extra}
|
||||
}
|
||||
CBLOCK
|
||||
)"
|
||||
@@ -192,6 +202,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -526,6 +541,10 @@ install_frigate() {
|
||||
echo " - Prompt to add cameras interactively (RTSP creds go in .env)"
|
||||
echo " or write a starter config.yml if none are added"
|
||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
||||
echo " - If Authelia is installed: offer to protect Frigate with it —"
|
||||
echo " disables Frigate's own login (auth.enabled: False) and pins a"
|
||||
echo " proxy.auth_secret/X-Proxy-Secret handshake so only Caddy can"
|
||||
echo " satisfy Frigate's proxy-auth trust"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -612,6 +631,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << FRIGATE_COMPOSE
|
||||
name: frigate
|
||||
|
||||
@@ -646,6 +666,106 @@ FRIGATE_COMPOSE
|
||||
mkdir -p config
|
||||
mkdir -p "$FRIGATE_MEDIA"
|
||||
|
||||
# Authelia SSO — decided (and, if accepted, wired into Caddy) before
|
||||
# config.yml is written, so the auth block baked into config.yml only
|
||||
# ever reflects a gate that's actually in place (never "native login
|
||||
# disabled, but nothing put in front of it instead"). Frigate has its
|
||||
# own built-in login (username/password) separate from Authelia's —
|
||||
# left alone it would show *after* Authelia's forward_auth already
|
||||
# gated the domain: a redundant second login, and worse, a second
|
||||
# session that can expire independently and force a re-login on its
|
||||
# own schedule regardless of Authelia's "remember me" duration. The
|
||||
# proxy.auth_secret/X-Proxy-Secret handshake (pinned into the Caddy
|
||||
# reverse_proxy block) stops that trust from being spoofed by a
|
||||
# request that reaches Frigate's published host port directly,
|
||||
# bypassing Caddy/Authelia entirely.
|
||||
# Whether to even OFFER this can't just check "$DOCKER_DIR/authelia"
|
||||
# locally — Frigate's own box very often has no local Caddy at all
|
||||
# (configure_caddy_for_service falls back to writing a snippet for
|
||||
# a remote Caddy machine to pick up, confirmed live: this is the
|
||||
# normal shape for a dedicated NVR box), in which case Authelia, if
|
||||
# it exists anywhere, lives on THAT remote Caddy machine instead —
|
||||
# a box this script has no filesystem access to inspect. Default to
|
||||
# "y" only when local Authelia is actually confirmed; otherwise still
|
||||
# offer it (default "n") and sort out local-vs-remote below once the
|
||||
# admin says yes, rather than silently refusing to ask at all.
|
||||
local FRIGATE_USE_AUTHELIA="n" FRIGATE_PROXY_SECRET="" AUTH_CONFIG_BLOCK=""
|
||||
local _frigate_local_authelia="n"
|
||||
[ -d "$DOCKER_DIR/authelia" ] && _frigate_local_authelia="y"
|
||||
echo ""
|
||||
prompt_yn "Protect Frigate with Authelia SSO (disables Frigate's own login)? (y/n):" "$_frigate_local_authelia" FRIGATE_USE_AUTHELIA
|
||||
|
||||
if [[ "$FRIGATE_USE_AUTHELIA" =~ ^[Yy]$ ]]; then
|
||||
FRIGATE_PROXY_SECRET="${ENV_MAP[FRIGATE_PROXY_AUTH_SECRET]:-$(generate_password 32)}"
|
||||
|
||||
# Local Authelia snippet (import authelia) only actually exists
|
||||
# in the Caddyfile it's imported into if Authelia is on THAT
|
||||
# same machine. When it's on this box, that's this box's own
|
||||
# Caddy — safe to assume. When Caddy itself turns out to be
|
||||
# remote (below), "local" instead means "local to wherever
|
||||
# Caddy is", which this script can't see — so ask, rather than
|
||||
# silently emit an import that would fail Caddy's own reload
|
||||
# with "file to import not found" on that other machine.
|
||||
local _frigate_auth_block=" import authelia"
|
||||
if [ "$_frigate_local_authelia" != "y" ]; then
|
||||
echo ""
|
||||
log_info "No local Authelia on this box — Caddy for Frigate may end up on a"
|
||||
log_info "different machine (decided next)."
|
||||
local _authelia_with_caddy=""
|
||||
prompt_yn " Does Authelia run on that SAME machine as Caddy? (y/n):" "y" _authelia_with_caddy
|
||||
if [[ ! "$_authelia_with_caddy" =~ ^[Yy]$ ]]; then
|
||||
# Genuinely cross-machine: Authelia is a third box,
|
||||
# different from both this one and wherever Caddy ends
|
||||
# up. Needs the explicit header-pinned forward_auth form
|
||||
# — see CLAUDE.md's "forward_auth to a remote Authelia"
|
||||
# note for why the bare "authelia:9091" shortcut can't
|
||||
# be used here and X-Forwarded-Host must be pinned
|
||||
# explicitly (a second Caddy hop otherwise silently
|
||||
# evaluates every domain as if it were auth's own
|
||||
# portal domain — confirmed live, a real incident this
|
||||
# exact snippet shape was written to prevent).
|
||||
local _remote_authelia_domain=""
|
||||
prompt_text " Authelia's own portal domain (e.g. authelia.example.com):" "" _remote_authelia_domain
|
||||
if [ -n "$_remote_authelia_domain" ]; then
|
||||
_frigate_auth_block=" forward_auth https://${_remote_authelia_domain} {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||
header_up X-Forwarded-Method {method}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-Host {host}
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
}"
|
||||
else
|
||||
log_warning "No domain entered — falling back to 'import authelia', which will fail"
|
||||
log_warning "Caddy's reload unless Authelia is actually local to that Caddy machine."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
configure_caddy_for_service "Frigate" "frigate:5000" "frigate" \
|
||||
"$_frigate_auth_block" \
|
||||
" header_up X-Proxy-Secret ${FRIGATE_PROXY_SECRET}"
|
||||
if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then
|
||||
AUTH_CONFIG_BLOCK="auth:
|
||||
enabled: False # Authelia already gates the whole domain — its own login would be redundant
|
||||
|
||||
proxy:
|
||||
auth_secret: \"{FRIGATE_PROXY_AUTH_SECRET}\" # must match the X-Proxy-Secret header Caddy sends
|
||||
header_map:
|
||||
user: remote-user
|
||||
role: remote-groups
|
||||
default_role: admin # anyone who passes Authelia gets full access, same as the disabled local login did
|
||||
|
||||
"
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "frigate" "$CADDY_SERVICE_DOMAIN"
|
||||
else
|
||||
log_warning "Caddy wasn't configured for Frigate — leaving Frigate's own login enabled (nothing else is gating access)."
|
||||
FRIGATE_PROXY_SECRET=""
|
||||
fi
|
||||
else
|
||||
configure_caddy_for_service "Frigate" "frigate:5000" "frigate"
|
||||
fi
|
||||
|
||||
# Credentials/IPs go in .env as FRIGATE_* variables; Frigate substitutes
|
||||
# any {FRIGATE_VAR} placeholder in config.yml from its container env at
|
||||
# startup, so RTSP secrets never need to be typed into the YAML directly.
|
||||
@@ -654,12 +774,12 @@ FRIGATE_COMPOSE
|
||||
|
||||
if [ "${#CAM_NAME[@]}" -eq 0 ]; then
|
||||
# No cameras entered — write a starter config the operator edits by hand.
|
||||
cat > config/config.yml << 'FRIGATE_CONFIG'
|
||||
cat > config/config.yml << FRIGATE_CONFIG
|
||||
# Frigate Configuration — Docs: https://docs.frigate.video
|
||||
#
|
||||
# ⚠️ YOU MUST EDIT THIS FILE to add your cameras before starting Frigate.
|
||||
|
||||
mqtt:
|
||||
${AUTH_CONFIG_BLOCK}mqtt:
|
||||
enabled: false # Set to true and configure if you use Home Assistant
|
||||
|
||||
cameras:
|
||||
@@ -696,7 +816,7 @@ FRIGATE_CONFIG
|
||||
# RTSP credentials/IPs come from .env — Frigate substitutes {FRIGATE_VAR}
|
||||
# placeholders below from the container's environment at startup.
|
||||
|
||||
mqtt:
|
||||
${AUTH_CONFIG_BLOCK}mqtt:
|
||||
enabled: false # Set to true and configure if you use Home Assistant
|
||||
|
||||
go2rtc:
|
||||
@@ -721,9 +841,11 @@ snapshots:
|
||||
FRIGATE_CONFIG
|
||||
fi
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << FRIGATE_ENV
|
||||
FRIGATE_MEDIA=$FRIGATE_MEDIA
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
FRIGATE_PROXY_AUTH_SECRET=$FRIGATE_PROXY_SECRET
|
||||
${ENV_CAM_VARS}
|
||||
FRIGATE_ENV
|
||||
chmod 600 .env
|
||||
@@ -732,7 +854,29 @@ FRIGATE_ENV
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true
|
||||
log_success "Frigate configured at $FRIGATE_DIR"
|
||||
|
||||
configure_caddy_for_service "Frigate" "frigate:5000" "frigate"
|
||||
local AUTH_README_SECTION=""
|
||||
if [ -n "$AUTH_CONFIG_BLOCK" ]; then
|
||||
AUTH_README_SECTION="
|
||||
## Authelia SSO
|
||||
Frigate's own login is disabled (\`auth.enabled: False\` in
|
||||
\`config/config.yml\`) — Authelia gates the whole domain instead via Caddy's
|
||||
\`import authelia\` plus a \`proxy.auth_secret\`/\`X-Proxy-Secret\` handshake
|
||||
(the secret lives in \`.env\` as \`FRIGATE_PROXY_AUTH_SECRET\`) so that trust
|
||||
can't be spoofed by a request that reaches Frigate's published port
|
||||
directly, bypassing Caddy.
|
||||
|
||||
Everyone who passes Authelia gets full (admin) access to Frigate —
|
||||
adjust \`config/config.yml\`'s \`proxy.role_map\`/\`default_role\` plus
|
||||
Authelia's own group assignments if you want to give some users
|
||||
view-only access instead.
|
||||
|
||||
To stop Authelia asking for a login again on repeat visits (e.g. from a
|
||||
phone) for as long as possible, increase its \"remember me\" session
|
||||
duration: \`sudo ./setup.sh authelia\` → \"Change 'remember me' session
|
||||
duration\" (this affects every domain that instance protects, not just
|
||||
Frigate).
|
||||
"
|
||||
fi
|
||||
|
||||
write_readme "$FRIGATE_DIR" << MD
|
||||
# Frigate NVR
|
||||
@@ -746,7 +890,7 @@ security cameras. Detects people, cars, animals, and more.
|
||||
- Recordings: \`$FRIGATE_MEDIA\`
|
||||
- Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total)
|
||||
- Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables
|
||||
|
||||
${AUTH_README_SECTION}
|
||||
## Manage
|
||||
\`\`\`bash
|
||||
cd $FRIGATE_DIR
|
||||
|
||||
@@ -92,6 +92,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -199,6 +204,7 @@ install_garage-webui() {
|
||||
ensure_docker_dir_ownership "$DIR"
|
||||
cd "$DIR" || return 1
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << COMPOSE
|
||||
name: garage-webui
|
||||
|
||||
@@ -219,6 +225,7 @@ services:
|
||||
- "${WEB_PORT}:3909"
|
||||
COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ENV
|
||||
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
||||
|
||||
|
||||
@@ -108,6 +108,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -277,6 +282,7 @@ api_bind_addr = "[::]:${ADMIN_PORT}"
|
||||
admin_token = "${ADMIN_TOKEN}"
|
||||
TOML
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << COMPOSE
|
||||
name: garage
|
||||
|
||||
@@ -297,6 +303,7 @@ services:
|
||||
- "${ADMIN_PORT}:${ADMIN_PORT}"
|
||||
COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ENV
|
||||
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
||||
|
||||
|
||||
@@ -187,6 +187,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -405,6 +410,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << GATUS_COMPOSE
|
||||
name: gatus
|
||||
|
||||
@@ -423,6 +429,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
GATUS_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << GATUS_ENV
|
||||
TZ=$TZ_VAL
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
+461
-7
@@ -104,6 +104,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -214,13 +219,13 @@ _gitea_offer_authelia_sso() {
|
||||
prompt_yn " Require two-factor for Gitea logins via Authelia too? (y/n):" "y" _2fa
|
||||
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
|
||||
|
||||
if ! _authelia_provision_oidc_client "Gitea" "gitea" "$AUTH_POLICY" "y" \
|
||||
if ! _authelia_provision_oidc_client "Gitea" "gitea" "$AUTH_POLICY" "y" "n" "" \
|
||||
"https://${GITEA_OIDC_DOMAIN}/user/oauth2/authelia/callback"; then
|
||||
log_warning "Couldn't register Gitea as an OIDC client in Authelia — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _discovery_url="https://auth.${OIDC_AUTHELIA_DOMAIN}/.well-known/openid-configuration"
|
||||
local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
|
||||
log_info "Adding Authelia as an authentication source in Gitea..."
|
||||
if docker exec -u git gitea gitea admin auth add-oauth \
|
||||
--name authelia --provider openidConnect \
|
||||
@@ -240,6 +245,92 @@ _gitea_offer_authelia_sso() {
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$GITEA_OIDC_DOMAIN"
|
||||
}
|
||||
|
||||
# Offers Gitea's OTHER Authelia integration — not the OIDC button above, but
|
||||
# ENABLE_REVERSE_PROXY_AUTHENTICATION: Gitea auto-logs in as whatever user
|
||||
# name arrives in a trusted header, no click and no separate Gitea session
|
||||
# to expire on its own schedule. This is genuinely stronger than the OIDC
|
||||
# button (which still shows a login page, just with an extra option on it)
|
||||
# and matches the pattern services/frigate.sh uses — except Gitea's own
|
||||
# login form stays available as a fallback for anyone NOT arriving from a
|
||||
# trusted source, so there's no "native login disabled with nothing gating
|
||||
# it" failure mode to guard against here the way Frigate's had.
|
||||
#
|
||||
# The security boundary is REVERSE_PROXY_TRUSTED_PROXIES, not a shared
|
||||
# secret: Gitea only honors the identity header from source IPs inside that
|
||||
# range. Gitea's own Docker image shipped this wildcarded (GHSA-f75j-4cw6-
|
||||
# rmx4 — any IP could set X-WEBAUTH-USER and log in as anyone), so this is
|
||||
# always computed from caddy_net's real subnet (same lookup
|
||||
# ufw_allow_from_caddy_net uses) and refuses to enable the feature at all if
|
||||
# that can't be determined — never falls back to a permissive default.
|
||||
#
|
||||
# Requires Gitea to actually be reachable from an address inside that range,
|
||||
# which means joining caddy_net like every other locally-Caddy-fronted
|
||||
# service in this repo (Gitea currently reaches Caddy via its published
|
||||
# host port instead — host.docker.internal upstream — because it predates
|
||||
# this feature). Local Caddy only: a remote Caddy machine's source address
|
||||
# isn't a stable, narrowly-scopeable range the way caddy_net's bridge subnet
|
||||
# is, so this skips remote mode rather than guess at a trust range worth
|
||||
# getting wrong.
|
||||
_gitea_offer_reverse_proxy_auth() {
|
||||
local DIR="$1"
|
||||
|
||||
[ -d "$DOCKER_DIR/authelia" ] || return 0
|
||||
[ -d "$DOCKER_DIR/caddy" ] || return 0
|
||||
|
||||
if grep -q 'ENABLE_REVERSE_PROXY_AUTHENTICATION=true' "$DIR/docker-compose.yml" 2>/dev/null; then
|
||||
log_info "Gitea's zero-click Authelia login (reverse-proxy auth) is already enabled — skipping."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local USE_RP=""
|
||||
prompt_yn " Skip Gitea's own login entirely for anyone arriving via Authelia — fully transparent, no click, no separate Gitea session to re-expire? Rewires Gitea onto Caddy's internal network (Caddy must be on this same machine). (y/n):" "n" USE_RP
|
||||
[[ "$USE_RP" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
local _subnet
|
||||
_subnet="$(docker network inspect "${SITE_CADDY_NET:-caddy_net}" \
|
||||
--format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null)"
|
||||
if [ -z "$_subnet" ]; then
|
||||
log_warning "Couldn't determine ${SITE_CADDY_NET:-caddy_net}'s subnet — refusing to enable"
|
||||
log_warning "reverse-proxy auth without a scoped trust range. An unscoped default lets ANY"
|
||||
log_warning "client impersonate ANY Gitea user via a spoofed header (this was a real Gitea"
|
||||
log_warning "CVE — GHSA-f75j-4cw6-rmx4). Skipping."
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Wiring Gitea onto caddy_net and enabling reverse-proxy authentication..."
|
||||
sed -i "/GITEA__security__INSTALL_LOCK=true/a\\ - GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION=true\\n - GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION=true\\n - GITEA__service__ENABLE_REVERSE_PROXY_EMAIL=true\\n - GITEA__security__REVERSE_PROXY_AUTHENTICATION_USER=Remote-User\\n - GITEA__security__REVERSE_PROXY_AUTHENTICATION_EMAIL=Remote-Email\\n - GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES=${_subnet}" \
|
||||
"$DIR/docker-compose.yml"
|
||||
cat >> "$DIR/docker-compose.yml" << EOF
|
||||
networks:
|
||||
- caddy_net
|
||||
|
||||
networks:
|
||||
caddy_net:
|
||||
external: true
|
||||
name: ${SITE_CADDY_NET:-caddy_net}
|
||||
EOF
|
||||
|
||||
_gitea_fix_ownership "$DIR"
|
||||
(cd "$DIR" && docker compose up -d) \
|
||||
&& log_success "Gitea restarted on caddy_net (trusted range: ${_subnet})." \
|
||||
|| { log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }
|
||||
|
||||
# Re-point Caddy at the container (gitea:3000, now reachable over
|
||||
# caddy_net) instead of the host-published port, with the auth gate in
|
||||
# front. This replaces the plain block set up earlier in this install —
|
||||
# configure_caddy_for_service's own "already exists — overwrite?" prompt
|
||||
# covers that; nothing here bypasses it.
|
||||
configure_caddy_for_service "Gitea" "gitea:3000" "git" " import authelia"
|
||||
if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then
|
||||
log_success "Gitea now signs in transparently via Authelia at https://${CADDY_SERVICE_DOMAIN} — its own login page is still there for anyone reaching it another way."
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$CADDY_SERVICE_DOMAIN"
|
||||
else
|
||||
log_warning "Caddy wasn't reconfigured — env vars are set, but nothing is routing Gitea through Authelia yet."
|
||||
log_warning "Point Gitea's Caddy entry at gitea:3000 (not the old host.docker.internal upstream) with 'import authelia' in front, or just re-run this offer."
|
||||
fi
|
||||
}
|
||||
|
||||
# Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions-
|
||||
# workflow-compatible) with a local runner — mainly useful as a fallback so
|
||||
# .gitea/workflows/*.yml can still run something like a GitHub Actions build
|
||||
@@ -255,7 +346,11 @@ _gitea_offer_authelia_sso() {
|
||||
_gitea_offer_actions_runner() {
|
||||
local DIR="$1"
|
||||
|
||||
grep -q '^ act_runner:$' "$DIR/docker-compose.yml" 2>/dev/null && return 0
|
||||
if grep -q '^ act_runner:$' "$DIR/docker-compose.yml" 2>/dev/null; then
|
||||
log_info "Gitea Actions runner is already set up (act_runner service already in docker-compose.yml) — skipping."
|
||||
log_info "Check its status: docker compose -f $DIR/docker-compose.yml ps act_runner"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local USE_ACTIONS=""
|
||||
@@ -358,6 +453,11 @@ _gitea_remove_sync_timer() {
|
||||
# reconfigure of an existing one. Always asked (matches pstn-trunk.sh's
|
||||
# international-calling step reasoning: a live-editable extra, not a
|
||||
# structural setting tied exclusively to fresh installs).
|
||||
#
|
||||
# Sets _GITEA_SYNC_FLAG as an out-param (not `local` — read it after the
|
||||
# call returns, same convention as CADDY_SERVICE_CONFIGURED) so the caller
|
||||
# can decide whether the real-time webhook offer even makes sense for the
|
||||
# direction just chosen.
|
||||
_gitea_run_sync_direction_step() {
|
||||
local DIR="$1"
|
||||
|
||||
@@ -368,12 +468,13 @@ _gitea_run_sync_direction_step() {
|
||||
echo " 3) Both directions"
|
||||
local _DIR_CHOICE=""
|
||||
prompt_text " Choice [1]:" "1" _DIR_CHOICE
|
||||
local FLAG="" DIR_DESC=""
|
||||
local DIR_DESC=""
|
||||
case "$_DIR_CHOICE" in
|
||||
2) FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
|
||||
3) FLAG=""; DIR_DESC="both directions" ;;
|
||||
*) FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
|
||||
2) _GITEA_SYNC_FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
|
||||
3) _GITEA_SYNC_FLAG=""; DIR_DESC="both directions" ;;
|
||||
*) _GITEA_SYNC_FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
|
||||
esac
|
||||
local FLAG="$_GITEA_SYNC_FLAG"
|
||||
log_info "Sync direction: $DIR_DESC"
|
||||
|
||||
_gitea_remove_sync_timer
|
||||
@@ -424,6 +525,250 @@ _gitea_run_sync_direction_step() {
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
# ── Real-time sync: a GitHub webhook receiver, not just the timer above ────
|
||||
# The timer above polls on a fixed schedule (default 6h) — fine for a slow
|
||||
# backup cadence, but a genuine "GitHub -> Gitea in real time" ask needs
|
||||
# GitHub to tell Gitea the moment something changes instead of Gitea finding
|
||||
# out up to one interval late. GitHub's own webhook (repo Settings ->
|
||||
# Webhooks) is the standard way to do that: it POSTs a JSON payload the
|
||||
# instant someone pushes. This writes a tiny stdlib-only Python HTTP server
|
||||
# to receive it — python3 is already a hard dependency of this directory's
|
||||
# gitea-github-sync.sh itself (used there for JSON parsing), so this adds
|
||||
# no new dependency — running under its own persistent systemd service,
|
||||
# and wires it up to Caddy the same way every other web-facing piece of
|
||||
# this install does.
|
||||
#
|
||||
# Deliberately NOT a Docker container: it just shells out to the existing
|
||||
# gitea-github-sync.sh sitting right next to it in $DIR, the same way the
|
||||
# timer's own systemd service does — no image to build/pull for what's
|
||||
# fundamentally a few lines of stdlib HTTP handling.
|
||||
_gitea_write_webhook_receiver() {
|
||||
local DIR="$1"
|
||||
cat > "$DIR/gitea-github-webhook.py" << 'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
"""Gitea <-> GitHub webhook receiver — triggers an immediate, single-repo
|
||||
mirror sync (gitea-github-sync.sh --repo owner/name --pull-only) the moment
|
||||
GitHub POSTs a push event, instead of waiting for the scheduled timer.
|
||||
|
||||
Written by services/gitea.sh — re-run 'sudo ./setup.sh gitea' (Update mode
|
||||
is fine) to regenerate this file rather than hand-editing it; a hand edit
|
||||
survives until the next Update-mode rerun overwrites it again.
|
||||
|
||||
WEBHOOK_SECRET is read from .env in this same directory at every request,
|
||||
never taken from the environment/systemd unit — /etc/systemd/system/*.service
|
||||
files are world-readable, and .env (chmod 600) is already where every other
|
||||
token in this directory lives.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SYNC_DIR = os.environ.get("GITEA_SYNC_DIR", os.path.dirname(os.path.abspath(__file__)))
|
||||
ENV_PATH = os.path.join(SYNC_DIR, ".env")
|
||||
PORT = int(os.environ.get("WEBHOOK_PORT", "3020"))
|
||||
|
||||
|
||||
def _load_env_value(key):
|
||||
try:
|
||||
with open(ENV_PATH, "r") as f:
|
||||
for line in f:
|
||||
line = line.split("#", 1)[0].strip()
|
||||
if not line.startswith(key + "="):
|
||||
continue
|
||||
return line[len(key) + 1:].strip().strip("'").strip('"')
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
def _reply(self, code, body=b""):
|
||||
self.send_response(code)
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
self._reply(200, b"gitea-github-webhook: listening\n")
|
||||
|
||||
def do_POST(self):
|
||||
secret = _load_env_value("WEBHOOK_SECRET").encode()
|
||||
if not secret:
|
||||
self._reply(503, b"WEBHOOK_SECRET not configured")
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0) or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
|
||||
sig = self.headers.get("X-Hub-Signature-256", "")
|
||||
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
if not sig or not hmac.compare_digest(sig, expected):
|
||||
self._reply(401, b"bad signature")
|
||||
return
|
||||
|
||||
event = self.headers.get("X-GitHub-Event", "")
|
||||
if event == "ping":
|
||||
self._reply(200, b"pong")
|
||||
return
|
||||
if event != "push":
|
||||
self._reply(204)
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(body or b"{}")
|
||||
full_name = payload["repository"]["full_name"]
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
self._reply(400, b"couldn't find repository.full_name in payload")
|
||||
return
|
||||
|
||||
self._reply(202, b"sync queued\n")
|
||||
sync_script = os.path.join(SYNC_DIR, "gitea-github-sync.sh")
|
||||
sync_env = dict(os.environ, SYNC_ENV=ENV_PATH)
|
||||
subprocess.Popen(
|
||||
["bash", sync_script, "--repo", full_name, "--pull-only"],
|
||||
cwd=SYNC_DIR,
|
||||
env=sync_env,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
server.serve_forever()
|
||||
PYEOF
|
||||
chmod +x "$DIR/gitea-github-webhook.py"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/gitea-github-webhook.py"
|
||||
}
|
||||
|
||||
_gitea_write_webhook_service() {
|
||||
local DIR="$1" RUN_USER="$2" RUN_HOME="$3" PORT="$4"
|
||||
local _service="/etc/systemd/system/gitea-github-webhook.service"
|
||||
|
||||
cat > "$_service" << UNIT
|
||||
[Unit]
|
||||
Description=Gitea-GitHub Webhook Receiver (real-time mirror sync trigger)
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${RUN_USER}
|
||||
Environment=HOME=${RUN_HOME}
|
||||
Environment=GITEA_SYNC_DIR=${DIR}
|
||||
Environment=WEBHOOK_PORT=${PORT}
|
||||
ExecStart=/usr/bin/python3 ${DIR}/gitea-github-webhook.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitea-github-webhook.service
|
||||
}
|
||||
|
||||
_gitea_remove_webhook_service() {
|
||||
systemctl disable --now gitea-github-webhook.service 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/gitea-github-webhook.service
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Offers the webhook receiver above as an addition to (not a replacement
|
||||
# for) the timer set up in _gitea_run_sync_direction_step — the timer keeps
|
||||
# covering the Gitea -> GitHub direction (and acts as a safety net for any
|
||||
# push GitHub's webhook delivery ever misses), the webhook just gets the
|
||||
# GitHub -> Gitea direction down from "up to one interval late" to seconds.
|
||||
# Always asked on every install/reconfigure, same "live-editable extra"
|
||||
# pattern as the direction+autosync step itself — see that function's own
|
||||
# comment. Skipped (and any existing webhook torn down) outright when the
|
||||
# chosen direction is push-only, since GitHub has nothing to notify about
|
||||
# in that direction.
|
||||
_gitea_offer_realtime_webhook() {
|
||||
local DIR="$1" SYNC_FLAG="$2"
|
||||
|
||||
if [[ "$SYNC_FLAG" == "--push-only" ]]; then
|
||||
_gitea_remove_webhook_service
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local USE_WEBHOOK=""
|
||||
prompt_yn " Also add a GitHub webhook for near-instant sync (push on GitHub -> synced here in seconds, instead of waiting for the timer above)? (y/n):" "n" USE_WEBHOOK
|
||||
if [[ ! "$USE_WEBHOOK" =~ ^[Yy]$ ]]; then
|
||||
_gitea_remove_webhook_service
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Reuse an existing secret/port across reruns — rotating either one
|
||||
# silently breaks a webhook GitHub already has configured against the
|
||||
# old value, the same reasoning services/asterisk.sh's TURN port-range
|
||||
# persistence follows for a live coturn install.
|
||||
local WEBHOOK_SECRET WEBHOOK_PORT
|
||||
WEBHOOK_SECRET="$(grep '^WEBHOOK_SECRET=' "$DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d "'\"")"
|
||||
WEBHOOK_PORT="$(grep '^WEBHOOK_PORT=' "$DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d "'\"")"
|
||||
[[ -z "$WEBHOOK_SECRET" ]] && WEBHOOK_SECRET="$(generate_password 40)"
|
||||
if [[ -z "$WEBHOOK_PORT" ]]; then
|
||||
WEBHOOK_PORT=3020
|
||||
find_free_port WEBHOOK_PORT "$WEBHOOK_PORT"
|
||||
fi
|
||||
|
||||
if grep -q '^WEBHOOK_SECRET=' "$DIR/.env" 2>/dev/null; then
|
||||
sed -i "s|^WEBHOOK_SECRET=.*|WEBHOOK_SECRET='${WEBHOOK_SECRET}'|" "$DIR/.env"
|
||||
else
|
||||
echo "WEBHOOK_SECRET='${WEBHOOK_SECRET}'" >> "$DIR/.env"
|
||||
fi
|
||||
if grep -q '^WEBHOOK_PORT=' "$DIR/.env" 2>/dev/null; then
|
||||
sed -i "s|^WEBHOOK_PORT=.*|WEBHOOK_PORT='${WEBHOOK_PORT}'|" "$DIR/.env"
|
||||
else
|
||||
echo "WEBHOOK_PORT='${WEBHOOK_PORT}'" >> "$DIR/.env"
|
||||
fi
|
||||
chmod 600 "$DIR/.env"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env"
|
||||
|
||||
_gitea_write_webhook_receiver "$DIR"
|
||||
_gitea_write_webhook_service "$DIR" "$ACTUAL_USER" "$ACTUAL_HOME" "$WEBHOOK_PORT"
|
||||
log_success "Webhook receiver running on port ${WEBHOOK_PORT} (systemctl status gitea-github-webhook)."
|
||||
|
||||
# Bare port -> host.docker.internal:PORT, same convention as every other
|
||||
# host-process (non-container) upstream in this repo — see the
|
||||
# configure_caddy_for_service usage note in CLAUDE.md.
|
||||
configure_caddy_for_service "Gitea GitHub Webhook" "$WEBHOOK_PORT" "gitea-webhook"
|
||||
if [[ "$CADDY_SERVICE_CONFIGURED" == true ]]; then
|
||||
if command -v ufw &>/dev/null; then
|
||||
if [[ "$CADDY_SERVICE_MODE" == "local" ]]; then
|
||||
ufw delete allow "${WEBHOOK_PORT}/tcp" 2>/dev/null || true
|
||||
ufw_allow_from_caddy_net "${WEBHOOK_PORT}"
|
||||
else
|
||||
ufw allow "${WEBHOOK_PORT}/tcp" comment "Gitea GitHub webhook" >/dev/null 2>&1 || true
|
||||
ensure_ufw_enabled
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
log_success "Now add the webhook on GitHub, for every repo you want instant sync from:"
|
||||
log_info " Repo -> Settings -> Webhooks -> Add webhook"
|
||||
log_info " Payload URL: https://${CADDY_SERVICE_DOMAIN}/"
|
||||
log_info " Content type: application/json"
|
||||
log_info " Secret: ${WEBHOOK_SECRET}"
|
||||
log_info " Events: Just the push event"
|
||||
log_info "The timer above still covers every other repo, and this one too, on its"
|
||||
log_info "own schedule — the webhook is an addition, not a replacement for it."
|
||||
else
|
||||
log_warning "Webhook receiver is running (0.0.0.0:${WEBHOOK_PORT}) but nothing is exposing"
|
||||
log_warning "it to the internet, so GitHub can't reach it yet — re-run this installer and"
|
||||
log_warning "configure Caddy for it, or point your own reverse proxy at"
|
||||
log_warning "127.0.0.1:${WEBHOOK_PORT} (or the container-reachable host IP) by hand."
|
||||
log_info " Secret (for whenever you do expose it): ${WEBHOOK_SECRET}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_gitea() {
|
||||
log_info "Setting up self-hosted Gitea..."
|
||||
|
||||
@@ -434,13 +779,19 @@ install_gitea() {
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $DIR with docker-compose.yml (gitea/gitea:latest)"
|
||||
echo "[DRY-RUN] Would scan for free host ports (web + SSH) to avoid collisions"
|
||||
echo "[DRY-RUN] Would open the SSH clone port in UFW (web port too, or scoped to caddy_net"
|
||||
echo "[DRY-RUN] if Caddy ends up fronting it locally)"
|
||||
echo "[DRY-RUN] Would prompt for a Gitea admin username/password, then create that account"
|
||||
echo "[DRY-RUN] and an API token once the container is ready (no manual web wizard)"
|
||||
echo "[DRY-RUN] Would prompt for a GitHub token and copy in gitea-github-sync.sh"
|
||||
echo "[DRY-RUN] Would ask sync direction (GitHub->Gitea / Gitea->GitHub / both) and whether"
|
||||
echo "[DRY-RUN] to install a systemd timer for automatic sync, or print manual instructions"
|
||||
echo "[DRY-RUN] Would offer to run a sync now (dry-run preview or for real), off-schedule"
|
||||
echo "[DRY-RUN] Would offer a GitHub webhook receiver for near-instant GitHub->Gitea sync"
|
||||
echo "[DRY-RUN] (systemd service + Caddy front door), unless direction is push-only"
|
||||
echo "[DRY-RUN] Would offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
|
||||
echo "[DRY-RUN] Would offer zero-click Authelia login (reverse-proxy auth) if Authelia"
|
||||
echo "[DRY-RUN] and local Caddy are both installed — rewires Gitea onto caddy_net"
|
||||
echo "[DRY-RUN] Would offer to enable Gitea Actions (CI) with a local act_runner container"
|
||||
echo "[DRY-RUN] Would write $DIR/README.md"
|
||||
return 0
|
||||
@@ -468,7 +819,9 @@ install_gitea() {
|
||||
&& log_success "Gitea refreshed and restarted." \
|
||||
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
||||
_gitea_run_sync_direction_step "$DIR"
|
||||
_gitea_offer_realtime_webhook "$DIR" "$_GITEA_SYNC_FLAG"
|
||||
_gitea_offer_authelia_sso "$DIR"
|
||||
_gitea_offer_reverse_proxy_auth "$DIR"
|
||||
_gitea_offer_actions_runner "$DIR"
|
||||
log_success "Existing .env (tokens) and web/SSH ports were left untouched."
|
||||
return 0
|
||||
@@ -492,6 +845,7 @@ install_gitea() {
|
||||
[[ "$WEB_PORT" != 3001 ]] && log_info "Port 3001 was taken — Gitea's web UI will use ${WEB_PORT}."
|
||||
[[ "$SSH_PORT" != 2222 ]] && log_info "Port 2222 was taken — Gitea's SSH clone port will use ${SSH_PORT}."
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << EOF
|
||||
name: gitea
|
||||
services:
|
||||
@@ -606,6 +960,7 @@ EOF
|
||||
cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh"
|
||||
chmod +x "$DIR/gitea-github-sync.sh"
|
||||
|
||||
backup_if_exists "$DIR/.env"
|
||||
cat > "$DIR/.env" << ENV
|
||||
# Written by services/gitea.sh — re-run that (update mode) to change any of this.
|
||||
GITEA_URL='http://localhost:${WEB_PORT}'
|
||||
@@ -630,6 +985,7 @@ ENV
|
||||
fi
|
||||
|
||||
_gitea_run_sync_direction_step "$DIR"
|
||||
_gitea_offer_realtime_webhook "$DIR" "$_GITEA_SYNC_FLAG"
|
||||
|
||||
# ── Caddy — no forward_auth gate here. Gitea has its own built-in login,
|
||||
# unlike the no-auth-at-all apps elsewhere in this repo that need Caddy
|
||||
@@ -638,7 +994,27 @@ ENV
|
||||
# replacement requiring Caddy involvement. ─────────────────────────────
|
||||
configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git"
|
||||
|
||||
# ── Firewall ─────────────────────────────────────────────────────────────
|
||||
# SSH clone (SSH_PORT->22) is a different protocol than the web UI — Caddy
|
||||
# can't front it no matter what CADDY_SERVICE_MODE came back as, so it
|
||||
# always needs its own direct rule or `git clone ssh://...` hangs forever
|
||||
# (a dropped SYN with UFW active, not a fast connection-refused).
|
||||
if command -v ufw &>/dev/null; then
|
||||
if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then
|
||||
ufw delete allow "${WEB_PORT}/tcp" 2>/dev/null || true
|
||||
ufw_allow_from_caddy_net "${WEB_PORT}"
|
||||
else
|
||||
ufw allow "${WEB_PORT}/tcp" comment "Gitea web UI" >/dev/null 2>&1 || true
|
||||
fi
|
||||
ufw allow "${SSH_PORT}/tcp" comment "Gitea SSH clone" >/dev/null 2>&1 || true
|
||||
ensure_ufw_enabled
|
||||
log_success "UFW: opened SSH clone port ${SSH_PORT}/tcp"
|
||||
else
|
||||
log_warning "ufw not installed — if you use a firewall, open TCP ${SSH_PORT} for SSH clones."
|
||||
fi
|
||||
|
||||
_gitea_offer_authelia_sso "$DIR"
|
||||
_gitea_offer_reverse_proxy_auth "$DIR"
|
||||
_gitea_offer_actions_runner "$DIR"
|
||||
|
||||
write_readme "$DIR" << MD
|
||||
@@ -672,6 +1048,69 @@ Config (which repos, private/forks handling) lives at
|
||||
\`~/.config/gitea-github-sync/config\` — edit directly, or re-run
|
||||
\`bash gitea-github-sync.sh --init\` to redo it interactively.
|
||||
|
||||
## Real-time sync via GitHub webhook (optional)
|
||||
|
||||
The setup above only covers the GitHub -> Gitea direction; it doesn't apply
|
||||
if you chose Gitea -> GitHub only (GitHub has nothing to notify about in
|
||||
that direction). Adds a small Python HTTP server
|
||||
(\`gitea-github-webhook.py\`, in this directory) run as its own systemd
|
||||
service (\`gitea-github-webhook.service\`) that GitHub POSTs to the instant
|
||||
someone pushes — it verifies the request's HMAC signature against
|
||||
\`WEBHOOK_SECRET\` in \`.env\`, then runs \`gitea-github-sync.sh --repo
|
||||
owner/name --pull-only\` for just that one repo. The scheduled timer above
|
||||
still runs on its own interval regardless — the webhook is an addition
|
||||
that gets the GitHub -> Gitea direction down to seconds, not a replacement
|
||||
for it (and still catches anything a missed webhook delivery would have
|
||||
picked up next interval anyway).
|
||||
|
||||
Not set up yet, or want to change the port/secret? Re-run
|
||||
\`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to "Also add
|
||||
a GitHub webhook...". That only stands up the *receiver* on this box — you
|
||||
still add the actual webhook on GitHub's side afterward, using the payload
|
||||
URL and secret the installer printed (also readable back from \`.env\` as
|
||||
\`WEBHOOK_PORT\` / \`WEBHOOK_SECRET\` if you need them again).
|
||||
|
||||
**Option A — one repo at a time.** Fastest, but only covers repos you do
|
||||
this for individually:
|
||||
repo -> Settings -> Webhooks -> Add webhook
|
||||
- Payload URL: the URL the installer printed
|
||||
- Content type: \`application/json\`
|
||||
- Secret: your \`WEBHOOK_SECRET\`
|
||||
- Events: "Just the push event"
|
||||
|
||||
**Option B — every repo on your account, current AND future, from one
|
||||
setup.** A plain repo webhook (Option A) is always per-repo, no way around
|
||||
that — but a personal GitHub App installed with "All repositories" access
|
||||
covers every repo automatically, including ones you create afterward. No
|
||||
receiver/code change needed for this: an App's webhook uses the exact same
|
||||
HMAC-secret mechanism as a repo webhook, so the same \`WEBHOOK_SECRET\`
|
||||
works for both.
|
||||
|
||||
1. GitHub -> Settings -> Developer settings -> GitHub Apps -> New GitHub App
|
||||
2. Webhook URL: same payload URL as Option A. Webhook secret: your
|
||||
\`WEBHOOK_SECRET\`. (Homepage URL is a separate, purely cosmetic field —
|
||||
point it at anything, e.g. your GitHub profile; GitHub never sends
|
||||
anything there, unlike Webhook URL.)
|
||||
3. Permissions -> Repository permissions -> Contents: Read-only (required
|
||||
to unlock the Push event checkbox)
|
||||
4. Subscribe to events: Push only
|
||||
5. Where can this GitHub App be installed: "Only on this account"
|
||||
6. Create it, then Install App -> choose "All repositories" -> Install
|
||||
|
||||
If you'd already added Option A webhooks on a few repos, they're now
|
||||
redundant (not harmful, just two triggers per push) — remove them once
|
||||
the App is confirmed working.
|
||||
|
||||
**Verify either option** — push to a repo, then watch it arrive:
|
||||
|
||||
\`\`\`bash
|
||||
systemctl status gitea-github-webhook # is it running?
|
||||
journalctl -u gitea-github-webhook -f # watch it receive + trigger syncs
|
||||
\`\`\`
|
||||
|
||||
GitHub also shows delivery attempts and response codes: repo (or App) ->
|
||||
Settings -> Webhooks -> the webhook -> Recent Deliveries.
|
||||
|
||||
## Sign in with Authelia (optional)
|
||||
|
||||
If Authelia is installed, re-run \`sudo ./setup.sh gitea\` (Update mode is
|
||||
@@ -681,6 +1120,21 @@ on Gitea's own login page. Local admin login keeps working exactly as
|
||||
before — this is additive, not a replacement. Managed in Gitea under
|
||||
Site Administration -> Authentication Sources (source name: \`authelia\`).
|
||||
|
||||
## Zero-click Authelia login (optional, stronger)
|
||||
|
||||
A second, separate Authelia integration: instead of an extra button on
|
||||
Gitea's login page, Gitea auto-logs in as whoever Authelia says you are —
|
||||
no click, and no separate Gitea session that can expire on its own and
|
||||
force a re-login later. Re-run \`sudo ./setup.sh gitea\` (Update mode) and
|
||||
answer yes to the "Skip Gitea's own login entirely..." prompt. Requires
|
||||
Authelia and Caddy on this same machine — it moves Gitea onto Caddy's
|
||||
internal Docker network (\`caddy_net\`) and Gitea only trusts the identity
|
||||
header from that network's address range, not from the internet or from
|
||||
its own host-published port. Gitea's own login page keeps working for
|
||||
anyone who reaches it any other way (e.g. directly on its port). New
|
||||
users arriving this way get an ordinary (non-admin) Gitea account created
|
||||
automatically the first time they show up.
|
||||
|
||||
## Gitea Actions (CI) — optional local runner
|
||||
|
||||
Re-run \`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to
|
||||
|
||||
@@ -274,6 +274,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << HOMEASSISTANT_COMPOSE
|
||||
name: homeassistant
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
## "Please select an entity type" with an empty type dropdown
|
||||
|
||||
Some collections hit an upstream Homebox bug
|
||||
([sysadminsmedia/homebox#1593](https://github.com/sysadminsmedia/homebox/issues/1593)):
|
||||
the default `Location`/`Item` entity types never get seeded, so the Create
|
||||
dialog's type dropdown comes up empty and every Location/Item creation fails
|
||||
with "Please select an entity type."
|
||||
|
||||
### Option 1 — let the installer fix it
|
||||
|
||||
Re-run `sudo ./setup.sh homebox`, choose **update**, and say yes when asked
|
||||
"Hit 'Please select an entity type' with an empty type list...?" You'll need
|
||||
a Homebox API token (see step 2 below) — it's used once, right then, and
|
||||
never written to `.env` or disk.
|
||||
|
||||
This only fixes the ONE collection that token's account belongs to. Homebox
|
||||
has no documented way to switch a token between collections, so a
|
||||
multi-collection setup needs this repeated once per collection — not
|
||||
something the installer can do for you in one pass, and not really worth
|
||||
the trouble if you've only hit this on one collection already.
|
||||
|
||||
### Option 2 — fix it by hand in the UI
|
||||
|
||||
1. Log into Homebox (register first if you haven't — the first account
|
||||
becomes the admin).
|
||||
2. Click the **collection selector** (shows your current collection's name,
|
||||
near the top of the page) → **Collection options** (gear icon). This
|
||||
opens Collection Settings.
|
||||
3. Click the **Entity Types** tab (`/collection/entity-types`).
|
||||
4. Click **Create**, add:
|
||||
- Name: `Location`, with **Is Location** toggled **ON**
|
||||
- Name: `Item`, with **Is Location** toggled **OFF**
|
||||
5. Go back to the Create (+) dialog — "Select a type..." now lists both, so
|
||||
you can create Locations and Items normally.
|
||||
|
||||
If you use more than one collection, repeat step 2–4 once per collection —
|
||||
entity types are scoped per collection, not shared across your whole
|
||||
Homebox instance.
|
||||
|
||||
### Option 3 — fix it via the API directly
|
||||
|
||||
Needs an API token from your profile menu (**Create API Token**):
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:<port>/api/v1/entity-types" \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Location","isLocation":true}'
|
||||
|
||||
curl -X POST "http://localhost:<port>/api/v1/entity-types" \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Item","isLocation":false}'
|
||||
```
|
||||
@@ -177,6 +177,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -196,6 +201,197 @@ fi
|
||||
|
||||
register_service homebox utilities "Home inventory and asset management (Homebox)" 7745
|
||||
|
||||
# Offers to wire Homebox's own native OIDC support to Authelia — real
|
||||
# server-side automation via env vars, not paste-in instructions, same
|
||||
# shape as Mealie/ActualBudget. Confirmed against homebox.software's own
|
||||
# OIDC docs and authelia.com's Homebox integration page directly: PKCE is
|
||||
# required, redirect path is /api/v1/users/login/oidc/callback, and the
|
||||
# issuer URL is sensitive to a trailing slash (a real reported bug) — the
|
||||
# portal URL this repo already stores never has one, so left as-is here.
|
||||
#
|
||||
# Args: DIR CONTAINER
|
||||
_homebox_offer_authelia_oidc() {
|
||||
local DIR="$1" CONTAINER="$2"
|
||||
|
||||
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
|
||||
[ -d "$DOCKER_DIR/authelia" ] || return 0
|
||||
|
||||
echo ""
|
||||
local USE_SSO=""
|
||||
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Homebox? (y/n):" "n" USE_SSO
|
||||
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
if grep -q '^HBOX_OIDC_ENABLED=' "$DIR/.env" 2>/dev/null; then
|
||||
echo ""
|
||||
log_info "Authelia SSO is already configured for Homebox (HBOX_OIDC_* already set in $DIR/.env)."
|
||||
local RECONFIGURE=""
|
||||
prompt_yn " Reconfigure it (registers a fresh Authelia client + secret)? (y/n):" "n" RECONFIGURE
|
||||
[[ "$RECONFIGURE" =~ ^[Yy]$ ]] || return 0
|
||||
sed -i '/^HBOX_OIDC_/d; /^HBOX_OPTIONS_TRUST_PROXY=/d' "$DIR/.env"
|
||||
fi
|
||||
|
||||
# Existing installs from before this offer existed won't have env_file
|
||||
# picked up their .env's OIDC additions otherwise — this repo's own
|
||||
# compose template gained it above; a pre-existing compose file needs
|
||||
# the same one-line patch to actually load what's about to be written.
|
||||
if ! grep -q '^\s*env_file: \.env\s*$' "$DIR/docker-compose.yml" 2>/dev/null; then
|
||||
sed -i "/^ hostname: /a\\ env_file: .env" "$DIR/docker-compose.yml"
|
||||
fi
|
||||
|
||||
local APP_DOMAIN
|
||||
APP_DOMAIN="$(_authelia_pick_domain "Domain Homebox is reachable at (number or domain)")"
|
||||
if [ -z "$APP_DOMAIN" ]; then
|
||||
log_warning "No domain entered — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _2fa="" AUTH_POLICY="two_factor"
|
||||
prompt_yn " Require two-factor for Homebox logins via Authelia too? (y/n):" "y" _2fa
|
||||
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
|
||||
|
||||
if ! _authelia_provision_oidc_client "Homebox" "homebox" "$AUTH_POLICY" "y" "y" "groups" \
|
||||
"https://${APP_DOMAIN}/api/v1/users/login/oidc/callback"; then
|
||||
log_warning "Couldn't register Homebox as an OIDC client in Authelia — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat >> "$DIR/.env" << ENV
|
||||
|
||||
# Written by services/homebox.sh's Authelia SSO step — adds "Sign in with
|
||||
# Authelia" alongside local login; local accounts keep working unchanged.
|
||||
HBOX_OIDC_ENABLED=true
|
||||
HBOX_OIDC_ISSUER_URL=${OIDC_AUTHELIA_PORTAL_URL}
|
||||
HBOX_OIDC_CLIENT_ID=homebox
|
||||
HBOX_OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET_PLAIN}
|
||||
HBOX_OIDC_SCOPE=openid profile email groups
|
||||
HBOX_OPTIONS_TRUST_PROXY=true
|
||||
ENV
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" 2>/dev/null || true
|
||||
|
||||
(cd "$DIR" && docker compose up -d) \
|
||||
&& log_success "\"Sign in with Authelia\" added to Homebox — local login still works too." \
|
||||
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
||||
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "homebox" "$APP_DOMAIN"
|
||||
|
||||
echo ""
|
||||
log_info "Test the \"Login with Authelia\" button on Homebox's own login page before"
|
||||
log_info "disabling local login — re-run 'sudo ./setup.sh homebox' (choose update) once"
|
||||
log_info "you've confirmed it works, and you'll be offered that as a separate step."
|
||||
}
|
||||
|
||||
# Split out from _homebox_offer_authelia_oidc so disabling local login is
|
||||
# never offered in the same breath as first setting SSO up — same
|
||||
# reasoning as Mealie/Beszel's equivalent split (confirmed live on Beszel:
|
||||
# saying yes before actually testing the button leaves both login paths
|
||||
# broken at once). Only reached from a later "update" rerun once OIDC is
|
||||
# already configured and the admin declines to reconfigure.
|
||||
_homebox_offer_disable_local_login() {
|
||||
local DIR="$1"
|
||||
grep -q '^HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false' "$DIR/.env" 2>/dev/null && return 0
|
||||
|
||||
echo ""
|
||||
local _tested=""
|
||||
prompt_yn " Have you ALREADY logged into Homebox successfully using the Authelia button (not just enabled it)? (y/n):" "n" _tested
|
||||
if [[ ! "$_tested" =~ ^[Yy]$ ]]; then
|
||||
log_info "Skipped. Test the Authelia login button first, then re-run 'sudo ./setup.sh homebox' (choose update) to come back to this."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _disable_local=""
|
||||
prompt_yn " Also disable Homebox's own local login, so Authelia is the only way in? (y/n):" "n" _disable_local
|
||||
[[ "$_disable_local" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
log_warning "Anyone without an Authelia account (only a local Homebox one) will no longer be able to log in."
|
||||
log_info "Reversible any time: set HBOX_OPTIONS_ALLOW_LOCAL_LOGIN back to true in $DIR/.env and 'docker compose up -d'."
|
||||
local _auto_redirect=""
|
||||
prompt_yn " Skip Homebox's login page entirely and jump straight to Authelia? (y/n):" "y" _auto_redirect
|
||||
|
||||
sed -i '/^HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=/d; /^HBOX_OIDC_AUTO_REDIRECT=/d' "$DIR/.env"
|
||||
{
|
||||
echo "HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false"
|
||||
[[ "$_auto_redirect" =~ ^[Yy]$ ]] && echo "HBOX_OIDC_AUTO_REDIRECT=true"
|
||||
} >> "$DIR/.env"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" 2>/dev/null || true
|
||||
|
||||
(cd "$DIR" && docker compose up -d) \
|
||||
&& log_success "Local login is now disabled — Authelia is the only way into Homebox." \
|
||||
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
||||
}
|
||||
|
||||
# Some Homebox installs hit an upstream bug (sysadminsmedia/homebox#1593): a
|
||||
# collection's default "Location"/"Item" entity types never get seeded, so
|
||||
# the Create dialog's type dropdown comes up empty and every Location/Item
|
||||
# creation fails with "Please select an entity type". There's no
|
||||
# unauthenticated way to detect or fix this — entity types are scoped per
|
||||
# collection (confirmed against Homebox's own swagger doc: GET/POST
|
||||
# /v1/entity-types both require a bearer token) — so this is opt-in and
|
||||
# asks for a token at the moment it runs, same trust model as Immich's
|
||||
# _immich_offer_authelia_oidc(): pasted once, used once, never written to
|
||||
# .env or disk.
|
||||
#
|
||||
# Args: DIR WEB_PORT
|
||||
_homebox_offer_entity_type_fix() {
|
||||
local DIR="$1" WEB_PORT="$2"
|
||||
local LOCAL_URL="http://localhost:${WEB_PORT}"
|
||||
|
||||
echo ""
|
||||
local _hit_bug=""
|
||||
prompt_yn " Hit \"Please select an entity type\" with an empty type list when creating a Location/Item? (y/n):" "n" _hit_bug
|
||||
[[ "$_hit_bug" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
echo ""
|
||||
log_info "That's a known upstream Homebox bug (sysadminsmedia/homebox#1593) — this"
|
||||
log_info "collection's default entity types were never seeded. Fixing it needs an"
|
||||
log_info "API token from an account that's already registered:"
|
||||
echo " 1. Log into Homebox in your browser (register first if you haven't)."
|
||||
echo " 2. Open your profile menu -> Create API Token."
|
||||
echo " 3. Paste it below — used once right now, never saved to disk."
|
||||
echo ""
|
||||
local HB_TOKEN=""
|
||||
prompt_text " Homebox API token:" "" HB_TOKEN
|
||||
if [ -z "$HB_TOKEN" ]; then
|
||||
log_info "Skipped. Re-run 'sudo ./setup.sh homebox' (choose update) once you have a token."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local VERIFY_CODE
|
||||
VERIFY_CODE="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $HB_TOKEN" "$LOCAL_URL/api/v1/users/self" 2>/dev/null)"
|
||||
if [ "$VERIFY_CODE" != "200" ]; then
|
||||
log_warning "Token didn't validate (HTTP $VERIFY_CODE) — skipping. Generate a fresh one and re-run."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local EXISTING_TYPES
|
||||
EXISTING_TYPES="$(curl -s -H "Authorization: Bearer $HB_TOKEN" "$LOCAL_URL/api/v1/entity-types" 2>/dev/null)"
|
||||
if echo "$EXISTING_TYPES" | grep -q '"isLocation":[[:space:]]*true'; then
|
||||
log_success "This collection already has a location-type entity type — nothing to fix."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _created=0 _code
|
||||
_code="$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $HB_TOKEN" \
|
||||
-H "Content-Type: application/json" -d '{"name":"Location","isLocation":true}' \
|
||||
"$LOCAL_URL/api/v1/entity-types" 2>/dev/null)"
|
||||
[[ "$_code" == 20* ]] && _created=$((_created + 1))
|
||||
_code="$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $HB_TOKEN" \
|
||||
-H "Content-Type: application/json" -d '{"name":"Item","isLocation":false}' \
|
||||
"$LOCAL_URL/api/v1/entity-types" 2>/dev/null)"
|
||||
[[ "$_code" == 20* ]] && _created=$((_created + 1))
|
||||
|
||||
if [ "$_created" -eq 2 ]; then
|
||||
log_success "Created the missing 'Location' and 'Item' entity types — the Create dialog's type dropdown should be populated now."
|
||||
else
|
||||
log_warning "Something didn't go through cleanly — check the type dropdown in Homebox and retry if it's still empty."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_info "This only fixes the ONE collection your token's account belongs to. Homebox"
|
||||
log_info "has no documented way to switch a token between collections, so if you use"
|
||||
log_info "more than one collection, log in as a member of each other one, generate a"
|
||||
log_info "token there, and re-run this step for it too."
|
||||
}
|
||||
|
||||
install_homebox() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Homebox..."
|
||||
@@ -256,6 +452,16 @@ install_homebox() {
|
||||
( cd "$HB_DIR" && docker compose pull && docker compose up -d ) \
|
||||
&& log_success "Homebox image refreshed" \
|
||||
|| log_warning "Refresh failed — check: docker compose -f $HB_DIR/docker-compose.yml logs"
|
||||
_homebox_offer_authelia_oidc "$HB_DIR" "$CONTAINER"
|
||||
_homebox_offer_disable_local_login "$HB_DIR"
|
||||
# WEB_PORT isn't persisted anywhere but the compose
|
||||
# file itself — re-derive it here the same way
|
||||
# services/immich.sh does for its own update-path
|
||||
# offer, rather than assuming the pre-scan default.
|
||||
local _EXISTING_PORT
|
||||
_EXISTING_PORT="$(grep -oP '^\s+- "?\K[0-9]+(?=:7745)' "$HB_DIR/docker-compose.yml" 2>/dev/null | head -1)"
|
||||
[ -n "$_EXISTING_PORT" ] && WEB_PORT="$_EXISTING_PORT"
|
||||
_homebox_offer_entity_type_fix "$HB_DIR" "$WEB_PORT"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
@@ -311,6 +517,7 @@ networks:
|
||||
local HB_PEPPER
|
||||
HB_PEPPER="$(generate_password 48)"
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << HB_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -320,6 +527,7 @@ services:
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- HBOX_LOG_LEVEL=info
|
||||
- HBOX_WEB_MAX_UPLOAD_SIZE=10
|
||||
@@ -331,6 +539,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
HB_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << HB_ENV
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -346,6 +555,9 @@ HB_ENV
|
||||
|
||||
configure_caddy_for_service "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:7745" "homebox${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||
|
||||
_homebox_offer_authelia_oidc "$HB_DIR" "$CONTAINER"
|
||||
_homebox_offer_entity_type_fix "$HB_DIR" "$WEB_PORT"
|
||||
|
||||
write_readme "$HB_DIR" << MD
|
||||
# Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
|
||||
@@ -183,6 +183,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -201,6 +206,129 @@ fi
|
||||
|
||||
register_service immich media "Self-hosted photo & video backup — like Google Photos (Immich)" 2283
|
||||
|
||||
# Offers to wire Immich's own native OAuth support to Authelia — real
|
||||
# server-side automation, not just paste-in instructions, unlike
|
||||
# Audiobookshelf/Beszel below (neither exposes a config API; Immich does).
|
||||
# Confirmed against docs.mealie.io's sibling page for Immich
|
||||
# (docs.immich.app/administration/oauth) and, since that page doesn't
|
||||
# document the underlying API, against Immich's own config-file.md and
|
||||
# GitHub source directly for the exact JSON field names under the "oauth"
|
||||
# key — not guessed. GET/PUT /api/system-config exchanges the WHOLE config
|
||||
# object (there's no partial-patch endpoint), so this only ever touches the
|
||||
# "oauth" sub-object and round-trips everything else completely unchanged
|
||||
# — the same GET-modify-PUT shape already proven in this file for the
|
||||
# storage-template step in import-photos.sh (search CURRENT_CONFIG above).
|
||||
#
|
||||
# Unlike Mealie/ActualBudget/Gitea, Immich's admin account isn't created by
|
||||
# this installer — the user creates it themselves on first web visit (see
|
||||
# "First launch" in the generated README) — so there's no API key to call
|
||||
# with at the moment a FRESH install finishes. This is deliberately called
|
||||
# from both the fresh-install path (where it'll usually just tell you to
|
||||
# come back later) and the "update" rerun path (the realistic way most
|
||||
# people actually complete this, once they have an account), same as
|
||||
# _mealie_offer_authelia_oidc's own "works from either" design.
|
||||
#
|
||||
# Args: IMMICH_DIR WEB_PORT
|
||||
_immich_offer_authelia_oidc() {
|
||||
local DIR="$1" WEB_PORT="$2"
|
||||
|
||||
[ -d "$DOCKER_DIR/authelia" ] || return 0
|
||||
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
|
||||
|
||||
echo ""
|
||||
local USE_SSO=""
|
||||
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Immich? (y/n):" "n" USE_SSO
|
||||
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
echo " This writes Immich's OAuth settings for you via its own API — needs an"
|
||||
echo " admin API key: Administration -> Settings -> API Keys -> New API Key"
|
||||
echo " (Admin scope). Leave blank to skip for now — safe to come back to this"
|
||||
echo " later by re-running 'sudo ./setup.sh immich' once you have one."
|
||||
local IMMICH_API_KEY=""
|
||||
prompt_text " Immich admin API key:" "" IMMICH_API_KEY
|
||||
if [ -z "$IMMICH_API_KEY" ]; then
|
||||
log_info "Skipped — no account/API key yet. Come back to this by re-running"
|
||||
log_info "'sudo ./setup.sh immich' (choose \"Manage that install\" -> update)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local IMMICH_LOCAL_URL="http://localhost:${WEB_PORT}"
|
||||
local VERIFY_CODE
|
||||
VERIFY_CODE="$(curl -s -o /dev/null -w '%{http_code}' -H "x-api-key: $IMMICH_API_KEY" "$IMMICH_LOCAL_URL/api/users/me" 2>/dev/null)"
|
||||
if [ "$VERIFY_CODE" != "200" ]; then
|
||||
log_warning "Couldn't verify that API key against Immich (HTTP $VERIFY_CODE) — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local APP_DOMAIN
|
||||
APP_DOMAIN="$(_authelia_pick_domain "Domain Immich is reachable at (number or domain)")"
|
||||
if [ -z "$APP_DOMAIN" ]; then
|
||||
log_warning "No domain entered — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _2fa="" AUTH_POLICY="two_factor"
|
||||
prompt_yn " Require two-factor for Immich logins via Authelia too? (y/n):" "y" _2fa
|
||||
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
|
||||
|
||||
# Same three redirect URIs as the "Immich" preset in authelia.sh's own
|
||||
# generic OIDC menu (web login, account-linking, mobile app callback) —
|
||||
# kept identical on purpose so either path produces the same client.
|
||||
if ! _authelia_provision_oidc_client "Immich" "immich" "$AUTH_POLICY" "y" "n" "" \
|
||||
"https://${APP_DOMAIN}/auth/login" "https://${APP_DOMAIN}/user-settings" "app.immich:///oauth-callback"; then
|
||||
log_warning "Couldn't register Immich as an OIDC client in Authelia — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
local _client_secret="$OIDC_CLIENT_SECRET_PLAIN" _portal_url="$OIDC_AUTHELIA_PORTAL_URL"
|
||||
|
||||
local CURRENT_CONFIG
|
||||
CURRENT_CONFIG="$(curl -s -H "x-api-key: $IMMICH_API_KEY" "$IMMICH_LOCAL_URL/api/system-config" 2>/dev/null)"
|
||||
if [ -z "$CURRENT_CONFIG" ] || ! command -v python3 &>/dev/null; then
|
||||
log_warning "Couldn't read Immich's system config — set OAuth manually instead:"
|
||||
echo " Administration -> Settings -> OAuth Authentication"
|
||||
echo " Issuer URL: ${_portal_url}"
|
||||
echo " Client ID: immich"
|
||||
echo " Client Secret: ${_client_secret}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Secret/issuer are passed via env vars, not interpolated into the
|
||||
# python source as string literals — Authelia's generated secret uses
|
||||
# an rfc3986 charset that isn't guaranteed free of characters (a stray
|
||||
# quote, say) that would otherwise break out of a quoted Python literal.
|
||||
local UPDATED_CONFIG
|
||||
UPDATED_CONFIG="$(echo "$CURRENT_CONFIG" | OIDC_SECRET="$_client_secret" OIDC_ISSUER="$_portal_url" python3 -c "
|
||||
import sys, json, os
|
||||
config = json.load(sys.stdin)
|
||||
config['oauth']['enabled'] = True
|
||||
config['oauth']['issuerUrl'] = os.environ['OIDC_ISSUER']
|
||||
config['oauth']['clientId'] = 'immich'
|
||||
config['oauth']['clientSecret'] = os.environ['OIDC_SECRET']
|
||||
config['oauth']['scope'] = 'openid email profile'
|
||||
config['oauth']['buttonText'] = 'Login with Authelia'
|
||||
json.dump(config, sys.stdout)
|
||||
" 2>/dev/null)"
|
||||
if [ -z "$UPDATED_CONFIG" ]; then
|
||||
log_warning "Couldn't parse Immich's config — set OAuth manually: Administration -> Settings -> OAuth Authentication"
|
||||
echo " Issuer URL: ${_portal_url} Client ID: immich Client Secret: ${_client_secret}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local RESULT
|
||||
RESULT="$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
|
||||
-H "x-api-key: $IMMICH_API_KEY" -H "Content-Type: application/json" \
|
||||
"$IMMICH_LOCAL_URL/api/system-config" -d "$UPDATED_CONFIG" 2>/dev/null)"
|
||||
if [ "$RESULT" = "200" ]; then
|
||||
log_success "\"Sign in with Authelia\" enabled in Immich — local login still works too."
|
||||
else
|
||||
log_warning "Couldn't set Immich's OAuth config (HTTP $RESULT) — set it manually instead:"
|
||||
echo " Administration -> Settings -> OAuth Authentication"
|
||||
echo " Issuer URL: ${_portal_url} Client ID: immich Client Secret: ${_client_secret}"
|
||||
fi
|
||||
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "immich" "$APP_DOMAIN"
|
||||
}
|
||||
|
||||
install_immich() {
|
||||
require_docker || return 1
|
||||
|
||||
@@ -279,6 +407,15 @@ install_immich() {
|
||||
( cd "$IMMICH_DIR" && docker compose pull && docker compose up -d ) \
|
||||
&& log_success "Immich image refreshed" \
|
||||
|| log_warning "Refresh failed — check: docker compose -f $IMMICH_DIR/docker-compose.yml logs"
|
||||
# WEB_PORT isn't persisted anywhere but the compose
|
||||
# file's own port mapping — re-derive it here rather
|
||||
# than assuming the "2283" default this local started
|
||||
# with, which may not match if it was shifted at
|
||||
# install time (collision avoidance / another instance).
|
||||
local _EXISTING_PORT
|
||||
_EXISTING_PORT="$(grep -oP '^\s+- "?\K[0-9]+(?=:2283)' "$IMMICH_DIR/docker-compose.yml" 2>/dev/null | head -1)"
|
||||
[ -n "$_EXISTING_PORT" ] && WEB_PORT="$_EXISTING_PORT"
|
||||
_immich_offer_authelia_oidc "$IMMICH_DIR" "$WEB_PORT"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
@@ -474,6 +611,7 @@ networks:
|
||||
[ -n "$EXTERNAL_LIBRARY" ] && _EXTERNAL_VOLUME_LINE=" - \${EXTERNAL_LIBRARY}:/usr/src/app/external:ro
|
||||
"
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << IMMICH_COMPOSE
|
||||
name: $PROJECT
|
||||
|
||||
@@ -554,6 +692,7 @@ S3_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY
|
||||
fi
|
||||
|
||||
if [ "$IMMICH_STRATEGY" = "2" ]; then
|
||||
backup_if_exists .env
|
||||
cat > .env << IMMICH_ENV
|
||||
# IMMICH CONFIGURATION — External Library Mode
|
||||
#
|
||||
@@ -581,6 +720,7 @@ TZ=$TZ_VAL
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
IMMICH_ENV
|
||||
else
|
||||
backup_if_exists .env
|
||||
cat > .env << IMMICH_ENV
|
||||
# IMMICH CONFIGURATION — Unified Library
|
||||
#
|
||||
@@ -886,6 +1026,14 @@ IMPORT_BODY
|
||||
|
||||
configure_caddy_for_service "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${C_SERVER}:2283" "immich${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||
|
||||
# Almost always a no-op on a truly fresh install — the admin account
|
||||
# (and thus an API key) doesn't exist until the user visits the web UI
|
||||
# for the first time, which hasn't happened yet at this point in the
|
||||
# script. Still offered here for the rare case an instance is being
|
||||
# reconfigured with credentials already in hand; the update rerun path
|
||||
# above is the realistic way most people complete this.
|
||||
_immich_offer_authelia_oidc "$IMMICH_DIR" "$WEB_PORT"
|
||||
|
||||
write_readme "$IMMICH_DIR" << MD
|
||||
# Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
|
||||
@@ -185,6 +185,11 @@ CBLOCK
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -314,6 +319,7 @@ networks:
|
||||
fi
|
||||
|
||||
if [[ "$USE_GPU" =~ ^[Yy]$ ]]; then
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << IOPAINT_GPU
|
||||
name: iopaint
|
||||
|
||||
@@ -346,6 +352,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
IOPAINT_GPU
|
||||
else
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << IOPAINT_CPU
|
||||
name: iopaint
|
||||
|
||||
@@ -373,6 +380,7 @@ IOPAINT_CPU
|
||||
fi
|
||||
|
||||
# ── .env ─────────────────────────────────────────────────────────────────
|
||||
backup_if_exists .env
|
||||
cat > .env << IOPAINT_ENV
|
||||
# IOPaint — change MODEL and restart to switch (no need to edit docker-compose.yml)
|
||||
|
||||
|
||||
@@ -182,6 +182,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -347,6 +352,7 @@ networks:
|
||||
'
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << JELLYFIN_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -368,6 +374,7 @@ $HWACCEL_BLOCK
|
||||
${_DISCOVERY_PORTS}${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
JELLYFIN_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << JELLYFIN_ENV
|
||||
MEDIA_PATH=$MEDIA_PATH
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -177,6 +177,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -314,6 +319,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << JOPLIN_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -340,6 +346,7 @@ ${_CADDY_NET_BLOCK}
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
JOPLIN_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << JOPLIN_ENV
|
||||
# Joplin Server configuration
|
||||
APP_PORT=22300
|
||||
|
||||
@@ -174,6 +174,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -459,6 +464,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists "$JS99ER_DIR/docker-compose.yml"
|
||||
cat > "$JS99ER_DIR/docker-compose.yml" << COMPOSE
|
||||
name: js99er
|
||||
|
||||
|
||||
@@ -188,6 +188,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -410,6 +415,7 @@ install_koha() {
|
||||
fi
|
||||
|
||||
# ── docker-compose.yml ────────────────────────────────────────────────────
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << KOHA_COMPOSE
|
||||
name: koha
|
||||
|
||||
@@ -481,6 +487,7 @@ ${_CADDY_NET_SECTION}
|
||||
KOHA_COMPOSE
|
||||
|
||||
# ── config-main.env ───────────────────────────────────────────────────────
|
||||
backup_if_exists config-main.env
|
||||
cat > config-main.env << KOHA_ENV
|
||||
# Koha ILS configuration — generated at install time
|
||||
MYSQL_SERVER=koha-db
|
||||
|
||||
@@ -388,6 +388,7 @@ for a in data.get('assets', []):
|
||||
mkdir -p "$DIR"
|
||||
ensure_docker_dir_ownership "$DIR"
|
||||
|
||||
backup_if_exists "$DIR/docker-compose.yml"
|
||||
cat > "$DIR/docker-compose.yml" << EOF
|
||||
name: kyber-server
|
||||
services:
|
||||
@@ -411,6 +412,7 @@ EOF
|
||||
# Write .env with restricted permissions
|
||||
# Values are single-quoted so special characters ($, !, &, etc.) are safe.
|
||||
# Exception: single quotes inside a value would still break — avoid them.
|
||||
backup_if_exists "$DIR/.env"
|
||||
cat > "$DIR/.env" << EOF
|
||||
MAXIMA_CREDENTIALS='${EA_EMAIL}:${EA_PASSWORD}'
|
||||
KYBER_TOKEN='${KYBER_TOKEN}'
|
||||
@@ -558,6 +560,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
|
||||
ACTUAL_USER="${SUDO_USER:-$USER}"
|
||||
ACTUAL_HOME=$(eval echo "~$ACTUAL_USER")
|
||||
|
||||
@@ -188,6 +188,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -353,6 +358,7 @@ install_lyrion() {
|
||||
_HTTP_PORT_INTERNAL="9000"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << LYRION_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -375,6 +381,7 @@ ${_NETWORK_BLOCK}${_PORTS_BLOCK} environment:
|
||||
|
||||
LYRION_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << LYRION_ENV
|
||||
MUSIC_PATH=$MUSIC_PATH
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -174,6 +174,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -254,6 +259,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << MM_COMPOSE
|
||||
name: mm-$MM_PORT
|
||||
|
||||
|
||||
@@ -170,6 +170,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -244,6 +249,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << MA_COMPOSE
|
||||
name: mail-archiver
|
||||
|
||||
@@ -279,6 +285,7 @@ ${_CADDY_NET_BLOCK}
|
||||
${_CADDY_NET_SECTION}
|
||||
MA_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << MA_ENV
|
||||
# ── General ───────────────────────────────────────────────────────────────────
|
||||
TZ=$TZ_VAL
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
## Android push notifications inconsistent after a migration (e.g. from PikaPods)
|
||||
|
||||
Symptom: "Enable Push Notifications" is on in System Console, but only some
|
||||
Android users actually get background push notifications — one user gets
|
||||
them reliably, others on the same server don't. Since this is per-device
|
||||
rather than server-wide, work through these in order; the first that
|
||||
reproduces the symptom is almost always the actual cause.
|
||||
|
||||
### 1. Rule out server → push-proxy connectivity first (quick, and if this is
|
||||
broken it explains ALL users failing, not just some)
|
||||
|
||||
The mobile app talks to Google's FCM directly for the device token, but it's
|
||||
*your* Mattermost server that calls out to the push relay (default
|
||||
`https://push.mattermost.com`, System Console → Environment → Push
|
||||
Notification Server) every time it needs to fan out a push. Confirm the new
|
||||
VPS can actually reach it — a fresh box's outbound rules, or a NAT/firewall
|
||||
inherited from the migration, can block this silently:
|
||||
|
||||
```bash
|
||||
docker exec mattermost curl -Is https://push.mattermost.com | head -1
|
||||
```
|
||||
|
||||
Then check the server's own logs for push attempts/failures:
|
||||
|
||||
```bash
|
||||
docker compose logs mattermost | grep -i push
|
||||
```
|
||||
|
||||
If specific users' pushes error out while others succeed, that already rules
|
||||
out a global connectivity/config problem and points at something per-account
|
||||
(section 2) or per-device (section 3).
|
||||
|
||||
### 2. Stale device registration carried over by the migration
|
||||
|
||||
A SQL dump import (`migrate-from-pikapods.sh` or any other DB restore) brings
|
||||
the `Sessions` table with it — including each user's `DeviceId`, the
|
||||
FCM token that was registered against the *old* server. That registration
|
||||
only gets refreshed on a real login, not by the app quietly staying open:
|
||||
a session that survived the move keeps working perfectly for live chat
|
||||
(the websocket connection has nothing to do with push registration) while
|
||||
its background push silently stops working, because the token behind it may
|
||||
now be stale.
|
||||
|
||||
This matches "one user is always fine, everyone else isn't" almost exactly —
|
||||
the working user is typically the one who happened to log out/in (or
|
||||
reinstalled the app) since the migration, refreshing their `DeviceId`, while
|
||||
everyone else's session rode through the import unchanged.
|
||||
|
||||
**Fix:** have affected users fully log out of the Mattermost Android app
|
||||
(not just background it — Menu → Log Out) and log back in. This forces a
|
||||
fresh device-token registration against the current server.
|
||||
|
||||
### 3. Android OEM battery optimization (the most common purely-device-side cause)
|
||||
|
||||
Xiaomi/MIUI, Huawei, Samsung, OnePlus, and Oppo/Vivo all ship aggressive
|
||||
battery managers that kill background apps and their FCM listeners by
|
||||
default — independent of anything about the server. This is the single most
|
||||
common reason some Android phones on the exact same server get pushes and
|
||||
others don't, migration or no migration. Have affected users check, per
|
||||
device:
|
||||
|
||||
- **Settings → Apps → Mattermost → Battery** → set to "Unrestricted" / "No
|
||||
restrictions" / disable "Battery Saver" for the app (menu wording varies
|
||||
by OEM/Android version).
|
||||
- **Notification permission itself** is still granted — Android 13+ requires
|
||||
an explicit runtime permission that can get silently revoked (e.g. after
|
||||
an OS update), separate from the app's own in-app notification settings.
|
||||
- Some OEMs (Xiaomi especially) also gate this behind a separate
|
||||
"Autostart" toggle for the app.
|
||||
|
||||
### 4. Push notification content setting, if section 1 and 2 don't explain it
|
||||
|
||||
System Console → Environment → Push Notification Server → **Push
|
||||
Notification Contents**. If set to anything other than "Send full message
|
||||
contents", the client has to phone the server's own `SiteURL` back for the
|
||||
real content after getting the push shell — so if the *new* domain isn't
|
||||
reliably reachable from a given user's network (split-horizon DNS, a mobile
|
||||
carrier blocking something, a half-finished Caddy/DNS cutover for the new
|
||||
VPS), that user can receive the push notification itself but never see
|
||||
real content, or see it inconsistently. Temporarily switching to "Send full
|
||||
message contents" removes this variable while narrowing down the cause.
|
||||
|
||||
### 5. A changed FQDN specifically — DNS propagation and cert readiness
|
||||
|
||||
If the migration also moved the server to a new domain (not just a new box
|
||||
under the same domain), that alone can produce exactly this
|
||||
some-users-fine/some-users-not pattern for a few days after cutover, on top
|
||||
of section 2 and 4 above:
|
||||
|
||||
- **DNS propagation lags per device.** Different users' resolvers (ISP DNS,
|
||||
carrier DNS on cellular, cached records with old TTLs) pick up the new
|
||||
FQDN's IP at different times. A user on a fast public resolver sees it
|
||||
immediately; someone on carrier DNS with a stale cache might not resolve
|
||||
it correctly for hours. Combined with section 4 (anything but "full
|
||||
message contents" requires a content-fetch call back to `SiteURL`), a
|
||||
device with a stale answer for the new FQDN fails that step while others
|
||||
succeed.
|
||||
- **TLS certificate not fully issued/propagated yet** for the new FQDN
|
||||
(Caddy/Let's Encrypt) causes the same content-fetch failure via cert
|
||||
validation instead of DNS.
|
||||
- **Confirm `MM_SERVICESETTINGS_SITEURL` in `.env` is actually the new FQDN**
|
||||
— a value left over from before the domain change points every client's
|
||||
content-fetch at the wrong place, consistently, not just intermittently.
|
||||
- Users who never explicitly added the new server URL in the mobile app (a
|
||||
kept redirect from the old domain let them keep working without
|
||||
noticing) are still running on their old, stale device registration —
|
||||
this is section 2's mechanism, just caused directly by the FQDN change
|
||||
rather than by the DB import alone.
|
||||
@@ -213,6 +213,11 @@ CBLOCK
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
@@ -518,6 +523,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << EOF
|
||||
name: ${PROJECT}
|
||||
|
||||
@@ -580,6 +586,7 @@ ${_CADDY_NET_BLOCK} healthcheck:
|
||||
${_CADDY_NET_BLOCK}${_COTURN_SERVICE}${_CADDY_NET_SECTION}
|
||||
EOF
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << EOF
|
||||
TZ=$TZ_VAL
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
+91
-3
@@ -180,6 +180,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -219,7 +224,24 @@ _mealie_offer_authelia_oidc() {
|
||||
|
||||
[ -d "$DOCKER_DIR/authelia" ] || return 0
|
||||
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
|
||||
grep -q '^OIDC_AUTH_ENABLED=' "$DIR/.env" 2>/dev/null && return 0
|
||||
|
||||
# Same reasoning as the equivalent check in services/actualbudget.sh: a
|
||||
# silent `return 0` here is indistinguishable from this step not
|
||||
# running at all. Always say something, and offer to redo it.
|
||||
if grep -q '^OIDC_AUTH_ENABLED=' "$DIR/.env" 2>/dev/null; then
|
||||
echo ""
|
||||
log_info "Authelia SSO is already configured for Mealie (OIDC_* already set in $DIR/.env)."
|
||||
local RECONFIGURE=""
|
||||
prompt_yn " Reconfigure it (registers a fresh Authelia client + secret)? (y/n):" "n" RECONFIGURE
|
||||
if [[ ! "$RECONFIGURE" =~ ^[Yy]$ ]]; then
|
||||
_mealie_offer_disable_password_login "$DIR"
|
||||
return 0
|
||||
fi
|
||||
# ALLOW_PASSWORD_LOGIN isn't OIDC_-prefixed but is written by this
|
||||
# same step (see below) — strip it too so reconfiguring doesn't
|
||||
# leave a stale duplicate line if it's set again.
|
||||
sed -i '/^OIDC_/d; /^ALLOW_PASSWORD_LOGIN=/d' "$DIR/.env"
|
||||
fi
|
||||
|
||||
local BASE_URL
|
||||
BASE_URL="$(grep '^BASE_URL=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
|
||||
@@ -237,12 +259,12 @@ _mealie_offer_authelia_oidc() {
|
||||
prompt_yn " Require two-factor for Mealie logins via Authelia too? (y/n):" "y" _2fa
|
||||
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
|
||||
|
||||
if ! _authelia_provision_oidc_client "Mealie" "mealie" "$AUTH_POLICY" "y" "${BASE_URL}/login"; then
|
||||
if ! _authelia_provision_oidc_client "Mealie" "mealie" "$AUTH_POLICY" "y" "n" "" "${BASE_URL}/login"; then
|
||||
log_warning "Couldn't register Mealie as an OIDC client in Authelia — skipping SSO setup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _discovery_url="https://auth.${OIDC_AUTHELIA_DOMAIN}/.well-known/openid-configuration"
|
||||
local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
|
||||
cat >> "$DIR/.env" << ENV
|
||||
|
||||
# Written by services/mealie.sh's Authelia SSO step — adds "Sign in with
|
||||
@@ -273,6 +295,54 @@ ENV
|
||||
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
||||
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "mealie" "${BASE_URL#*://}"
|
||||
|
||||
echo ""
|
||||
log_info "Test the \"Login with Authelia\" button on Mealie's own login page before"
|
||||
log_info "disabling local login — re-run 'sudo ./setup.sh mealie' (choose update,"
|
||||
log_info "then \"Reconfigure? n\") once you've confirmed it works, and you'll be"
|
||||
log_info "offered that as a separate step."
|
||||
}
|
||||
|
||||
# Split out from _mealie_offer_authelia_oidc so disabling local login is
|
||||
# never offered in the same breath as first setting SSO up — confirmed
|
||||
# live (on Beszel, same risk class) that saying yes before actually testing
|
||||
# the Authelia button leaves both login paths broken at once. Only reached
|
||||
# from a later "update" rerun once OIDC is already configured and the admin
|
||||
# declines to reconfigure — i.e. after they've had a real chance to test it.
|
||||
_mealie_offer_disable_password_login() {
|
||||
local DIR="$1"
|
||||
grep -q '^ALLOW_PASSWORD_LOGIN=false' "$DIR/.env" 2>/dev/null && return 0
|
||||
|
||||
echo ""
|
||||
local _tested=""
|
||||
prompt_yn " Have you ALREADY logged into Mealie successfully using the Authelia button (not just enabled it)? (y/n):" "n" _tested
|
||||
if [[ ! "$_tested" =~ ^[Yy]$ ]]; then
|
||||
log_info "Skipped. Test the Authelia login button first, then re-run 'sudo ./setup.sh mealie' (choose update) to come back to this."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _disable_local=""
|
||||
prompt_yn " Also disable Mealie's own username/password login, so Authelia is the only way in? (y/n):" "n" _disable_local
|
||||
[[ "$_disable_local" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
log_warning "Anyone without an Authelia account (only a local Mealie one) will no longer be able to log in."
|
||||
log_info "Reversible any time: set ALLOW_PASSWORD_LOGIN back to true in $DIR/.env and 'docker compose up -d'."
|
||||
local _auto_redirect=""
|
||||
prompt_yn " Skip Mealie's login page entirely and jump straight to Authelia? (y/n):" "y" _auto_redirect
|
||||
|
||||
sed -i '/^ALLOW_PASSWORD_LOGIN=/d; /^OIDC_AUTO_REDIRECT=/d; /^OIDC_REMEMBER_ME=/d' "$DIR/.env"
|
||||
{
|
||||
echo "ALLOW_PASSWORD_LOGIN=false"
|
||||
if [[ "$_auto_redirect" =~ ^[Yy]$ ]]; then
|
||||
echo "OIDC_AUTO_REDIRECT=true"
|
||||
echo "OIDC_REMEMBER_ME=true"
|
||||
fi
|
||||
} >> "$DIR/.env"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" 2>/dev/null || true
|
||||
|
||||
(cd "$DIR" && docker compose up -d) \
|
||||
&& log_success "Local username/password login is now disabled — Authelia is the only way in." \
|
||||
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
||||
}
|
||||
|
||||
install_mealie() {
|
||||
@@ -394,6 +464,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << MEALIE_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -418,6 +489,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
MEALIE_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << MEALIE_ENV
|
||||
# Public URL Mealie is served on — used for email links and OAuth redirects.
|
||||
# Update if you change your domain or switch from HTTP to HTTPS.
|
||||
@@ -431,6 +503,22 @@ MEALIE_ENV
|
||||
|
||||
configure_caddy_for_service "Mealie${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:9000" "recipes${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||
|
||||
# The domain typed at that prompt can differ from the recipes.<domain>
|
||||
# default BASE_URL was already set to above (e.g. the user overrides it
|
||||
# with a different subdomain). Reconcile BASE_URL to match whatever
|
||||
# Caddy actually ended up fronting, since BASE_URL is what gets
|
||||
# registered as the OIDC redirect URI just below — a stale BASE_URL
|
||||
# there means Authelia rejects every login with "redirect_uri does not
|
||||
# match any of the OAuth 2.0 Client's pre-registered redirect_uris" even
|
||||
# though Caddy and DNS both point at the right place. Confirmed live:
|
||||
# this is exactly what happened when the Caddy prompt was answered with
|
||||
# a different subdomain than the auto-generated default.
|
||||
if [ "$CADDY_SERVICE_CONFIGURED" = true ] && [ -n "$CADDY_SERVICE_DOMAIN" ] && [ "$MEALIE_BASE_URL" != "https://${CADDY_SERVICE_DOMAIN}" ]; then
|
||||
MEALIE_BASE_URL="https://${CADDY_SERVICE_DOMAIN}"
|
||||
sed -i "s#^BASE_URL=.*#BASE_URL=${MEALIE_BASE_URL}#" .env
|
||||
log_info "BASE_URL updated to match the domain just configured: $MEALIE_BASE_URL"
|
||||
fi
|
||||
|
||||
declare -F _mealie_offer_authelia_oidc >/dev/null 2>&1 && _mealie_offer_authelia_oidc "$MEALIE_DIR" "$CONTAINER"
|
||||
|
||||
write_readme "$MEALIE_DIR" << MD
|
||||
|
||||
@@ -188,6 +188,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -323,6 +328,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << MC_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -350,6 +356,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
MC_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << MC_ENV
|
||||
MC_HOSTNAME=$MC_HOSTNAME
|
||||
MC_REVERSE_PROXY=false
|
||||
|
||||
@@ -1519,6 +1519,7 @@ PREGENINFOEOF
|
||||
- ./config:/data/config"
|
||||
fi
|
||||
|
||||
backup_if_exists "$MC_DIR/docker-compose.yml"
|
||||
cat > "$MC_DIR/docker-compose.yml" << COMPOSEEOF
|
||||
name: ${MC_NAME}
|
||||
|
||||
@@ -1902,6 +1903,7 @@ EXPOSE 80
|
||||
CLIENTDOCKEREOF
|
||||
|
||||
# Standalone compose for the client-mods page (its own folder).
|
||||
backup_if_exists "$CLIENT_MODS_DIR/docker-compose.yml"
|
||||
cat > "$CLIENT_MODS_DIR/docker-compose.yml" << CMCOMPOSEEOF
|
||||
name: ${CM_NAME}
|
||||
|
||||
|
||||
@@ -177,6 +177,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -242,6 +247,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << N8N_COMPOSE
|
||||
name: n8n
|
||||
|
||||
@@ -266,6 +272,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
N8N_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << N8N_ENV
|
||||
# n8n environment — edit before starting if needed
|
||||
N8N_HOST=n8n
|
||||
|
||||
@@ -70,6 +70,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
@@ -260,6 +265,7 @@ networks:
|
||||
fi
|
||||
|
||||
# ── docker-compose.yml ──────────────────────────────────────────────────
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << NCCOMPOSE
|
||||
name: nextcloud
|
||||
services:
|
||||
@@ -290,6 +296,7 @@ ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
NCCOMPOSE
|
||||
|
||||
# ── .env ────────────────────────────────────────────────────────────────
|
||||
backup_if_exists .env
|
||||
cat > .env << NCENV
|
||||
TZ=$TZ_VAL
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -184,6 +184,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -309,6 +314,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << NTFY_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -329,6 +335,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
NTFY_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << NTFY_ENV
|
||||
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -70,6 +70,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
@@ -280,6 +285,7 @@ networks:
|
||||
fi
|
||||
|
||||
# ── docker-compose.yml ──────────────────────────────────────────────────
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << OOCOMPOSE
|
||||
name: onlyoffice
|
||||
services:
|
||||
@@ -299,6 +305,7 @@ ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
OOCOMPOSE
|
||||
|
||||
# ── .env ────────────────────────────────────────────────────────────────
|
||||
backup_if_exists .env
|
||||
cat > .env << OOENV
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
# JWT authentication — keep JWT_SECRET private
|
||||
|
||||
@@ -210,6 +210,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -325,6 +330,7 @@ networks:
|
||||
# example — NET_ADMIN specifically is only needed if this instance is
|
||||
# ever used as a DHCP server too, which it isn't here, but the other two
|
||||
# (SYS_TIME, SYS_NICE) are part of that same documented baseline.
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << PIHOLE_COMPOSE
|
||||
name: pihole
|
||||
|
||||
@@ -351,6 +357,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
PIHOLE_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << PIHOLE_ENV
|
||||
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
||||
# Admin web UI password (System Console / login screen).
|
||||
|
||||
@@ -177,6 +177,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -242,6 +247,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << PORTAINER_COMPOSE
|
||||
name: portainer
|
||||
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
#!/bin/bash
|
||||
# services/pressbooks.sh — Self-hosted Pressbooks: write/import books, drag-and-drop
|
||||
# image placement, export to PDF/EPUB for professional or personal printing.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
#
|
||||
# Can also be run standalone on any machine:
|
||||
# sudo bash pressbooks.sh
|
||||
# (Docker must already be installed when run standalone)
|
||||
#
|
||||
# Pressbooks is a WordPress Multisite plugin/theme suite, not a normal
|
||||
# WordPress plugin — its own docs are explicit that it "should be used with
|
||||
# a fresh, multisite WordPress installation" and is "not for use on an
|
||||
# existing blog." That means it can never be layered onto an existing
|
||||
# services/wordpress.sh site: it gets its own dedicated WordPress core,
|
||||
# database, and container here, converted to a Multisite network as part of
|
||||
# this installer instead of a plain single-site install.
|
||||
#
|
||||
# Not following the multi-instance pattern documented in CLAUDE.md: that
|
||||
# pattern exists for services that are inherently single-tenant per
|
||||
# install. Pressbooks is the opposite — a single network already hosts any
|
||||
# number of independent books (each its own site in the network, its own
|
||||
# authors, its own theme), which is exactly the multi-tenancy the pattern
|
||||
# gives other services. A second, fully separate Pressbooks *network* would
|
||||
# only matter for something like two unrelated publishing organizations
|
||||
# wanting entirely separate admin/user databases on one box — a much rarer
|
||||
# need than "another book" — so it's left out of scope here.
|
||||
#
|
||||
# Chapters are written and images placed via WordPress's own block editor
|
||||
# (Gutenberg) — dragging an image file into a chapter's content area drops
|
||||
# an Image block at that position, and the block editor's own "Add Media"
|
||||
# dialog also accepts drag-and-drop uploads. This is native WordPress
|
||||
# behavior, not a Pressbooks feature, so it needs no extra plugin here.
|
||||
#
|
||||
# PDF export needs a rendering engine Pressbooks itself doesn't ship:
|
||||
# - PrinceXML, installed on this container — free for non-commercial use
|
||||
# (adds a small logo to page 1 of every PDF), full price for a
|
||||
# commercial/watermark-free license. See install_pressbooks' Dockerfile
|
||||
# generation below.
|
||||
# - DocRaptor, PrinceXML as a paid SaaS API (DOCRAPTOR_API_KEY) — no local
|
||||
# binary to maintain, but not free for real (non-watermarked) documents.
|
||||
# - mPDF, Pressbooks' third documented option, is explicitly unmaintained
|
||||
# upstream — not offered here.
|
||||
# EPUB export needs no extra engine (Pressbooks generates it directly) and
|
||||
# needs EPUBCheck's dependencies below. MOBI export was removed from
|
||||
# Pressbooks entirely after Amazon discontinued KindleGen and stopped
|
||||
# accepting MOBI on KDP (March 2025) — not offered here; see the generated
|
||||
# README for the EPUB→MOBI-via-Calibre workaround for personal Kindle use.
|
||||
|
||||
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||
|
||||
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||
|
||||
if [[ -f "$_COMMON" ]]; then
|
||||
# shellcheck source=../lib/common.sh
|
||||
source "$_COMMON"
|
||||
else
|
||||
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||
|
||||
require_docker() {
|
||||
command -v docker &>/dev/null || {
|
||||
log_error "Docker not found. Install it first:"
|
||||
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||
return 1
|
||||
}
|
||||
docker compose version &>/dev/null || {
|
||||
log_error "Docker Compose plugin missing:"
|
||||
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
ensure_docker_dir_ownership() {
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_yn() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_reinstall_mode() {
|
||||
local _var="$1" _r
|
||||
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
|
||||
echo " Existing install detected. Choose:"
|
||||
echo " u) Update — refresh plugin/theme/image, keep books and settings"
|
||||
echo " f) Full reinstall — re-run every prompt from scratch"
|
||||
echo " c) Cancel — leave everything as-is [default]"
|
||||
read -r -p " Choice [u/f/c, Enter=cancel]: " _r
|
||||
case "${_r,,}" in
|
||||
u) eval "$_var='update'" ;;
|
||||
f) eval "$_var='fresh'" ;;
|
||||
*) eval "$_var='cancel'" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
configure_caddy_for_service() {
|
||||
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||
local _display_port="${_upstream##*:}"
|
||||
|
||||
local _mode="none"
|
||||
[[ -d "$_caddy_dir" ]] && _mode="local"
|
||||
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
|
||||
CADDY_SERVICE_CONFIGURED=false
|
||||
CADDY_SERVICE_MODE=""
|
||||
CADDY_SERVICE_DOMAIN=""
|
||||
[[ "$_mode" == "none" ]] && {
|
||||
log_info "Access $_name directly on port $_display_port."
|
||||
return 0
|
||||
}
|
||||
|
||||
echo ""
|
||||
local _do_caddy=""
|
||||
if [[ "$_mode" == "remote" ]]; then
|
||||
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
|
||||
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
|
||||
fi
|
||||
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||||
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||
log_info "Skipping — access at: http://localhost:$_display_port"
|
||||
return 0
|
||||
}
|
||||
|
||||
local _default_domain=""
|
||||
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
|
||||
_default_domain="${_subdomain}.${SITE_DOMAIN}"
|
||||
log_info "Default: $_default_domain"
|
||||
fi
|
||||
local _domain=""
|
||||
read -r -p " Domain [${_default_domain:-required}]: " _domain
|
||||
_domain="${_domain:-$_default_domain}"
|
||||
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||
|
||||
local _block_upstream="$_upstream"
|
||||
[[ "$_mode" == "remote" ]] && _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
|
||||
|
||||
local _site_block
|
||||
_site_block="$(cat << CBLOCK
|
||||
|
||||
# $_name
|
||||
${_domain} {
|
||||
${_extra}
|
||||
reverse_proxy ${_block_upstream}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/${_domain}.log
|
||||
format json
|
||||
}
|
||||
}
|
||||
CBLOCK
|
||||
)"
|
||||
|
||||
if [[ "$_mode" == "local" ]]; then
|
||||
if [[ -f "$_caddyfile" ]]; then
|
||||
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$_caddyfile" "$_bk"
|
||||
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||||
else
|
||||
touch "$_caddyfile"
|
||||
fi
|
||||
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||||
log_warning "$_domain already in Caddyfile"
|
||||
local _ow=""
|
||||
read -r -p " Overwrite? [y/N]: " _ow
|
||||
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; CADDY_SERVICE_CONFIGURED=true; CADDY_SERVICE_MODE="local"; CADDY_SERVICE_DOMAIN="$_domain"; return 0; }
|
||||
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||||
fi
|
||||
CADDY_SERVICE_CONFIGURED=true
|
||||
CADDY_SERVICE_MODE="local"
|
||||
CADDY_SERVICE_DOMAIN="$_domain"
|
||||
printf '%s\n' "$_site_block" >> "$_caddyfile"
|
||||
log_success "Added $_domain to Caddyfile"
|
||||
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
log_success "$_name accessible at: https://$_domain"
|
||||
else
|
||||
log_warning "Reload failed — check: docker logs caddy"
|
||||
fi
|
||||
else
|
||||
CADDY_SERVICE_CONFIGURED=true
|
||||
CADDY_SERVICE_MODE="remote"
|
||||
CADDY_SERVICE_DOMAIN="$_domain"
|
||||
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
||||
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
|
||||
mkdir -p "$_snippet_dir"
|
||||
printf '%s\n' "$_site_block" > "$_snippet_file"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
||||
log_success "Snippet saved: $_snippet_file"
|
||||
fi
|
||||
}
|
||||
|
||||
write_readme() {
|
||||
local _dir="$1"; shift
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
UNATTENDED="${UNATTENDED:-false}"
|
||||
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||||
|
||||
register_service() { :; }
|
||||
_RUN_STANDALONE=1
|
||||
fi
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
register_service pressbooks utilities "Self-hosted Pressbooks — write/import books with drag-and-drop images, export to PDF/EPUB (Authelia SSO gate)" 8095
|
||||
|
||||
# Fetches the newest release of a pressbooks/<repo> GitHub project as an
|
||||
# installable zip URL, for wp-cli's own "plugin install <url>"/"theme install
|
||||
# <url>" (which download and unpack it itself — nothing here needs to know
|
||||
# how to unzip a WordPress plugin). Prefers an actual release asset (the
|
||||
# packaged, ready-to-install zip these projects publish, vendor/ dependencies
|
||||
# included) and falls back to the tagged source archive GitHub always
|
||||
# generates automatically if no asset is found — that fallback can be
|
||||
# missing composer's vendor/ directory, so it's logged with a warning
|
||||
# rather than silently swapped in.
|
||||
_pressbooks_latest_zip_url() {
|
||||
local _repo="$1" _api _url _tag
|
||||
_api="$(curl -fsSL "https://api.github.com/repos/pressbooks/${_repo}/releases/latest" 2>/dev/null)"
|
||||
_url="$(printf '%s' "$_api" | grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*\.zip"' | head -1 | grep -o 'https://[^"]*')"
|
||||
if [ -z "$_url" ]; then
|
||||
_tag="$(printf '%s' "$_api" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)"
|
||||
if [ -n "$_tag" ]; then
|
||||
_url="https://github.com/pressbooks/${_repo}/archive/refs/tags/${_tag}.zip"
|
||||
log_warning "No packaged release asset found for ${_repo} — falling back to its" >&2
|
||||
log_warning "tagged source archive, which can be missing composer's vendor/ directory." >&2
|
||||
fi
|
||||
fi
|
||||
printf '%s' "$_url"
|
||||
}
|
||||
|
||||
# A release zip's top-level directory sometimes carries a version suffix
|
||||
# (especially the tagged-source-archive fallback above, e.g.
|
||||
# "pressbooks-book-2.5.0/" instead of "pressbooks-book/") — WP-CLI's own
|
||||
# "theme enable"/"plugin activate --network" need the directory to be named
|
||||
# exactly the plugin/theme slug to find it at all. Renames it into place if
|
||||
# a mismatch is found; a no-op if the zip already unpacked to the right name.
|
||||
_pressbooks_normalize_slug() {
|
||||
local _html_dir="$1" _kind="$2" _slug="$3"
|
||||
docker run --rm -v "${_html_dir}:/var/www/html" alpine sh -c "
|
||||
cd /var/www/html/wp-content/${_kind} 2>/dev/null || exit 0
|
||||
[ -d '${_slug}' ] && exit 0
|
||||
d=\$(ls -d ${_slug}-* 2>/dev/null | head -1)
|
||||
[ -n \"\$d\" ] && mv \"\$d\" '${_slug}'
|
||||
exit 0
|
||||
" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Installs/refreshes the Pressbooks plugin and its three companion themes
|
||||
# (McLuhan/pressbooks-book — the default book theme; Aldine — the default
|
||||
# root theme; Publisher — the default theme for the network's own landing
|
||||
# site) network-wide, and activates Publisher on the root site. Shared by
|
||||
# the fresh-install path and the "update" rerun path (CLAUDE.md's
|
||||
# non-destructive-update convention: this only ever touches plugin/theme
|
||||
# code, never wp-config.php, .env, or any book's own content/DB rows).
|
||||
_pressbooks_install_plugins_and_themes() {
|
||||
local _dir="$1" _net="$2" _port="$3"
|
||||
# "wp" is spelled out explicitly rather than relying on the wordpress:cli
|
||||
# entrypoint's own "wp help $1 && set -- wp $@" auto-detection — that
|
||||
# probe itself runs through wp-cli's bootstrap, so anything that breaks
|
||||
# the bootstrap (a bad wp-config.php, a missing bind mount) makes the
|
||||
# probe fail *silently* and falls through to exec-ing the raw
|
||||
# subcommand as if it were a binary ("core: not found") instead of
|
||||
# surfacing the real error.
|
||||
_pb_wpcli() { docker run --rm --network "$_net" -v "${_dir}/html:/var/www/html" --env-file "${_dir}/.env" wordpress:cli wp "$@"; }
|
||||
|
||||
local _plugin_url _book_url _aldine_url _publisher_url
|
||||
_plugin_url="$(_pressbooks_latest_zip_url pressbooks)"
|
||||
_book_url="$(_pressbooks_latest_zip_url pressbooks-book)"
|
||||
_aldine_url="$(_pressbooks_latest_zip_url pressbooks-aldine)"
|
||||
_publisher_url="$(_pressbooks_latest_zip_url pressbooks-publisher)"
|
||||
|
||||
if [ -z "$_plugin_url" ]; then
|
||||
log_error "Couldn't determine a Pressbooks download URL from GitHub (API unreachable or rate-limited)."
|
||||
log_error "Install by hand instead: download a release zip from"
|
||||
log_error " https://github.com/pressbooks/pressbooks/releases"
|
||||
log_error "then, in Network Admin -> Plugins -> Add New -> Upload Plugin, upload it and Network Activate."
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Installing Pressbooks plugin..."
|
||||
_pb_wpcli plugin install "$_plugin_url" --force || log_warning "Pressbooks plugin install reported an error — see docker compose logs."
|
||||
_pressbooks_normalize_slug "${_dir}/html" plugins pressbooks
|
||||
_pb_wpcli plugin activate pressbooks --network || log_warning "Network-activating Pressbooks failed — do it by hand in Network Admin -> Plugins."
|
||||
|
||||
log_info "Installing Pressbooks themes (McLuhan, Aldine, Publisher)..."
|
||||
for _pair in "pressbooks-book:$_book_url" "pressbooks-aldine:$_aldine_url" "pressbooks-publisher:$_publisher_url"; do
|
||||
local _slug="${_pair%%:*}" _url="${_pair#*:}"
|
||||
[ -z "$_url" ] && { log_warning "Couldn't determine a download URL for theme $_slug — skipping."; continue; }
|
||||
_pb_wpcli theme install "$_url" --force || log_warning "Theme $_slug install reported an error."
|
||||
_pressbooks_normalize_slug "${_dir}/html" themes "$_slug"
|
||||
_pb_wpcli theme enable "$_slug" || log_warning "Network-enabling $_slug failed — do it by hand in Network Admin -> Themes."
|
||||
done
|
||||
_pb_wpcli theme activate pressbooks-publisher --url="http://localhost:${_port}" \
|
||||
|| log_warning "Couldn't set Publisher as the network's own landing-site theme — set it by hand in Appearance -> Themes."
|
||||
}
|
||||
|
||||
install_pressbooks() {
|
||||
require_docker || return 1
|
||||
|
||||
echo ""
|
||||
echo "┌─────────────────────────────────────────────────────────────────┐"
|
||||
echo "│ PRESSBOOKS │"
|
||||
echo "│ Self-hosted book platform — write/import chapters with │"
|
||||
echo "│ drag-and-drop images, export to PDF (print) and EPUB (ebook) │"
|
||||
echo "└─────────────────────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
|
||||
local DIR="$DOCKER_DIR/pressbooks"
|
||||
local CONTAINER="pressbooks"
|
||||
local DB_CONTAINER="pressbooks-db"
|
||||
local WP_NET="pressbooks_net"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $DIR — a dedicated WordPress Multisite install (never an"
|
||||
echo "[DRY-RUN] existing site — Pressbooks requires a fresh multisite network)"
|
||||
echo "[DRY-RUN] Would build a custom image on wordpress:php8.3-apache: mod_rewrite +"
|
||||
echo "[DRY-RUN] AllowOverride All (multisite needs working .htaccess rewrites),"
|
||||
echo "[DRY-RUN] Ghostscript/ImageMagick/poppler-utils/libxml2-utils (cover generator +"
|
||||
echo "[DRY-RUN] EPUB validation), and the ImageMagick PDF-coder policy fix Debian ships"
|
||||
echo "[DRY-RUN] disabled by default"
|
||||
echo "[DRY-RUN] Would prompt for a PDF export engine: PrinceXML (installed on this"
|
||||
echo "[DRY-RUN] container, free for non-commercial use) and/or DocRaptor (paid SaaS API key)"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port (8095 default) and dedicated MariaDB"
|
||||
echo "[DRY-RUN] Would run wp-cli non-interactively: core install, convert to Multisite"
|
||||
echo "[DRY-RUN] (subdirectory network), install+network-activate the Pressbooks plugin"
|
||||
echo "[DRY-RUN] and its three themes"
|
||||
echo "[DRY-RUN] Would offer a Caddy reverse proxy gated by Authelia SSO, and to start it"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Existing install? Offer update-in-place ──────────────────────────────
|
||||
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
|
||||
local MODE=""
|
||||
prompt_reinstall_mode MODE
|
||||
case "$MODE" in
|
||||
update)
|
||||
log_info "Refreshing Pressbooks' base image, cover-generator packages, and the"
|
||||
log_info "Pressbooks plugin/themes only — books, domain, and credentials are left"
|
||||
log_info "exactly as they are."
|
||||
( cd "$DIR" && docker compose build --pull && docker compose up -d )
|
||||
local _WP_PORT
|
||||
_WP_PORT="$(grep '^WEB_PORT=' "$DIR/.env" | cut -d= -f2-)"
|
||||
_pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "${_WP_PORT:-8095}"
|
||||
log_success "Pressbooks refreshed"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
log_info "Leaving the existing Pressbooks install as-is."
|
||||
return 0
|
||||
;;
|
||||
fresh) ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── Network title + admin account ────────────────────────────────────────
|
||||
local PB_TITLE="" PB_ADMIN_USER="" PB_ADMIN_EMAIL=""
|
||||
prompt_text "Book network title (shown on the landing site):" "My Book Library" PB_TITLE
|
||||
prompt_text "Admin username:" "admin" PB_ADMIN_USER
|
||||
prompt_text "Admin email:" "" PB_ADMIN_EMAIL
|
||||
local PB_ADMIN_PASS=""
|
||||
[ -f "$DIR/.env" ] && PB_ADMIN_PASS="$(grep '^WP_ADMIN_PASSWORD=' "$DIR/.env" | cut -d= -f2-)"
|
||||
[ -n "$PB_ADMIN_PASS" ] || PB_ADMIN_PASS="$(generate_password 16)"
|
||||
|
||||
# ── PDF export engine ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo " PDF export needs a rendering engine Pressbooks itself doesn't ship."
|
||||
local INSTALL_PRINCE="" PRINCE_LICENSE_PATH="" USE_DOCRAPTOR="" DOCRAPTOR_KEY=""
|
||||
prompt_yn "Install PrinceXML for PDF export? Free for personal use, adds a small logo unless licensed (y/n):" "y" INSTALL_PRINCE
|
||||
if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then
|
||||
prompt_text " Already have a paid PrinceXML license file (removes the logo)? Path, or blank to skip:" "" PRINCE_LICENSE_PATH
|
||||
if [ -n "$PRINCE_LICENSE_PATH" ] && [ ! -f "$PRINCE_LICENSE_PATH" ]; then
|
||||
log_warning " $PRINCE_LICENSE_PATH not found — continuing with the free non-commercial version."
|
||||
PRINCE_LICENSE_PATH=""
|
||||
fi
|
||||
fi
|
||||
prompt_yn "Also configure DocRaptor (SaaS alternative — needs your own API key, paid past a small free quota)? (y/n):" "n" USE_DOCRAPTOR
|
||||
if [[ "$USE_DOCRAPTOR" =~ ^[Yy]$ ]]; then
|
||||
prompt_text " DocRaptor API key (from https://docraptor.com/documentation/api):" "" DOCRAPTOR_KEY
|
||||
fi
|
||||
if [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ]; then
|
||||
log_warning "No PDF engine configured — Pressbooks' PDF export will fail until PrinceXML"
|
||||
log_warning "or DocRaptor is set up (re-run this installer to add one later)."
|
||||
fi
|
||||
|
||||
# ── Free host port ────────────────────────────────────────────────────────
|
||||
local WEB_PORT=8095
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$DIR/html" "$DIR/db" "$DIR/uploads-ini.d"
|
||||
ensure_docker_dir_ownership "$DIR"
|
||||
cd "$DIR" || return 1
|
||||
|
||||
local TZ_VAL="${SITE_TZ:-UTC}"
|
||||
|
||||
# Book covers, full-book PDF/EPUB exports, and large chapter-image
|
||||
# imports all run well past stock PHP limits — sized generously up front
|
||||
# rather than waiting for a first export to hit a wall.
|
||||
cat > uploads-ini.d/uploads.ini << 'PHPINI'
|
||||
file_uploads = On
|
||||
memory_limit = 512M
|
||||
upload_max_filesize = 128M
|
||||
post_max_size = 128M
|
||||
max_execution_time = 600
|
||||
max_input_time = 600
|
||||
PHPINI
|
||||
|
||||
# WordPress core's own is_ssl() only looks at $_SERVER['HTTPS'], never
|
||||
# X-Forwarded-Proto — behind Caddy (which terminates TLS and proxies
|
||||
# plain HTTP to this container) that reads as "never HTTPS," sending
|
||||
# wp-admin into a login/redirect loop the moment Caddy is wired up.
|
||||
#
|
||||
# This lives in a must-use plugin (wp-content/mu-plugins/, autoloaded by
|
||||
# WordPress on every request, no activation needed) rather than in
|
||||
# wp-config.php via WORDPRESS_CONFIG_EXTRA — two real, confirmed-live
|
||||
# problems with the wp-config.php route, in order of discovery:
|
||||
# 1. Compose interpolates $VAR-looking tokens found INSIDE .env file
|
||||
# values too, not just inside docker-compose.yml — a raw $_SERVER
|
||||
# sitting in .env got silently blanked to a bare "_SERVER" before
|
||||
# the container ever saw it.
|
||||
# 2. Routing it through a bind-mounted file and a wp-config.php
|
||||
# `require` line (this repo's first fix for #1) traded that bug for
|
||||
# a worse one: wp-cli's Runner does its own restricted, line-level
|
||||
# parsing of wp-config.php to pull out bootstrap constants without
|
||||
# a full WordPress load, and it can't handle anything past a plain
|
||||
# define(...) statement — an if(){ require ...; } line made *every*
|
||||
# wp-cli command in this script fail with a cryptic
|
||||
# "PHP Parse error ... eval()'d code ... unexpected end of file".
|
||||
# mu-plugins load through WordPress's normal plugin bootstrap, not
|
||||
# wp-cli's special wp-config.php pre-parser, so this sidesteps both
|
||||
# issues entirely — nothing here ever touches wp-config.php or .env.
|
||||
mkdir -p html/wp-content/mu-plugins
|
||||
cat > html/wp-content/mu-plugins/pressbooks-extra-config.php << 'PHPEXTRA'
|
||||
<?php
|
||||
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
|
||||
$_SERVER['HTTPS'] = 'on';
|
||||
}
|
||||
PHPEXTRA
|
||||
[[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo "define('PB_PRINCE_COMMAND', '/usr/local/bin/prince');" >> html/wp-content/mu-plugins/pressbooks-extra-config.php
|
||||
[ -n "$DOCRAPTOR_KEY" ] && echo "define('DOCRAPTOR_API_KEY', '$DOCRAPTOR_KEY');" >> html/wp-content/mu-plugins/pressbooks-extra-config.php
|
||||
|
||||
# Prince license file, if provided, is bind-mounted rather than baked
|
||||
# into the image — keeps a personal/purchased license out of the image
|
||||
# layer, and survives an image rebuild on the "update" path untouched.
|
||||
local PRINCE_LICENSE_VOLUME=""
|
||||
if [ -n "$PRINCE_LICENSE_PATH" ]; then
|
||||
cp "$PRINCE_LICENSE_PATH" "$DIR/prince-license.dat"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/prince-license.dat"
|
||||
chmod 600 "$DIR/prince-license.dat"
|
||||
PRINCE_LICENSE_VOLUME=" - ./prince-license.dat:/usr/local/lib/prince/license/license.dat:ro
|
||||
"
|
||||
fi
|
||||
|
||||
# ── Dockerfile ────────────────────────────────────────────────────────────
|
||||
local _PRINCE_DOCKERFILE_BLOCK=""
|
||||
if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then
|
||||
_PRINCE_DOCKERFILE_BLOCK='
|
||||
# PrinceXML — the PDF rendering engine Pressbooks shells out to for PDF
|
||||
# export. Free for non-commercial use (small logo on page 1 of every PDF; a
|
||||
# purchased license.dat, bind-mounted by docker-compose.yml, removes it).
|
||||
# Uses the "linux-generic" tarball rather than a distro-pinned .deb/.rpm so
|
||||
# this keeps working if wordpress:php8.3-apache'"'"'s underlying Debian release
|
||||
# moves on, and resolves the current major version + exact filename at
|
||||
# build time instead of hardcoding one that will eventually go stale.
|
||||
RUN set -eux; \
|
||||
ARCH="$(uname -m)"; \
|
||||
MAJOR="$(curl -fsSL https://www.princexml.com/download/ | grep -oE "/download/[0-9]+/" | grep -oE "[0-9]+" | sort -n | tail -1)"; \
|
||||
TARBALL_PATH="$(curl -fsSL "https://www.princexml.com/download/${MAJOR}/" | grep -oE "/download/prince-[0-9.]+-linux-generic-${ARCH}\.tar\.gz" | head -1)"; \
|
||||
curl -fsSL "https://www.princexml.com${TARBALL_PATH}" -o /tmp/prince.tar.gz; \
|
||||
mkdir -p /tmp/prince && tar -xzf /tmp/prince.tar.gz -C /tmp/prince --strip-components=1; \
|
||||
printf "\n" | /tmp/prince/install.sh; \
|
||||
rm -rf /tmp/prince /tmp/prince.tar.gz
|
||||
'
|
||||
fi
|
||||
|
||||
backup_if_exists Dockerfile
|
||||
cat > Dockerfile << DOCKERFILE
|
||||
FROM wordpress:php8.3-apache
|
||||
|
||||
# Multisite's subdirectory rewrite rules live in .htaccess — the base
|
||||
# php-apache image ships mod_rewrite disabled and AllowOverride None, so
|
||||
# .htaccess is silently ignored (pretty URLs 404, book pages don't route)
|
||||
# without this.
|
||||
RUN a2enmod rewrite \\
|
||||
&& sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
|
||||
|
||||
# Pressbooks' cover generator shells out to Ghostscript/ImageMagick and
|
||||
# poppler-utils (pdftoppm/pdfinfo) to rasterize book covers; libxml2-utils
|
||||
# (xmllint) backs EPUB/HTMLBook validation. curl/ca-certificates are needed
|
||||
# by the PrinceXML install step below, when enabled.
|
||||
RUN apt-get update \\
|
||||
&& apt-get install -y --no-install-recommends \\
|
||||
ghostscript imagemagick poppler-utils libxml2-utils curl ca-certificates \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Debian's ImageMagick ships a security policy (a CVE-2016-3714 mitigation)
|
||||
# that blocks the PDF/PS/EPS coders by default. Without this, ImageMagick
|
||||
# refuses to rasterize the PDF Ghostscript hands it for a cover thumbnail —
|
||||
# fails with "not authorized \`PDF'" rather than producing an image.
|
||||
RUN for f in /etc/ImageMagick-6/policy.xml /etc/ImageMagick-7/policy.xml; do \\
|
||||
[ -f "\$f" ] && sed -i -E 's/rights="none" pattern="(PDF|PS|EPS)"/rights="read|write" pattern="\\1"/' "\$f"; \\
|
||||
done; true
|
||||
${_PRINCE_DOCKERFILE_BLOCK}
|
||||
DOCKERFILE
|
||||
|
||||
# ── Caddy network wiring ──────────────────────────────────────────────────
|
||||
local _CADDY_MODE="${CADDY_MODE:-none}"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
|
||||
|
||||
local _CADDY_NET_LINE="" _CADDY_NET_SECTION=""
|
||||
if [ "$_CADDY_MODE" = "local" ]; then
|
||||
_CADDY_NET_LINE=" - caddy_net
|
||||
"
|
||||
_CADDY_NET_SECTION="
|
||||
caddy_net:
|
||||
external: true
|
||||
name: ${SITE_CADDY_NET:-caddy_net}
|
||||
"
|
||||
fi
|
||||
|
||||
# ── docker-compose.yml ────────────────────────────────────────────────────
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << PBCOMPOSE
|
||||
name: pressbooks
|
||||
|
||||
services:
|
||||
pressbooks:
|
||||
build: .
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- ./html:/var/www/html
|
||||
- ./uploads-ini.d/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
|
||||
${PRINCE_LICENSE_VOLUME} ports:
|
||||
- "${WEB_PORT}:80"
|
||||
networks:
|
||||
- default
|
||||
${_CADDY_NET_LINE}
|
||||
db:
|
||||
image: mariadb:11
|
||||
container_name: $DB_CONTAINER
|
||||
hostname: $DB_CONTAINER
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./db:/var/lib/mysql
|
||||
networks:
|
||||
- default
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: $WP_NET
|
||||
${_CADDY_NET_SECTION}
|
||||
PBCOMPOSE
|
||||
|
||||
# ── .env ──────────────────────────────────────────────────────────────────
|
||||
local WP_DB_PASS="" WP_DB_ROOT_PASS=""
|
||||
[ -f ".env" ] && WP_DB_PASS="$(grep '^WORDPRESS_DB_PASSWORD=' .env | cut -d= -f2-)"
|
||||
[ -f ".env" ] && WP_DB_ROOT_PASS="$(grep '^MYSQL_ROOT_PASSWORD=' .env | cut -d= -f2-)"
|
||||
[ -n "$WP_DB_PASS" ] || WP_DB_PASS="$(generate_password 24)"
|
||||
[ -n "$WP_DB_ROOT_PASS" ] || WP_DB_ROOT_PASS="$(generate_password 32)"
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << PBENV
|
||||
TZ=$TZ_VAL
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
WEB_PORT=$WEB_PORT
|
||||
|
||||
# Dedicated MariaDB for this network alone.
|
||||
MYSQL_ROOT_PASSWORD=$WP_DB_ROOT_PASS
|
||||
MYSQL_DATABASE=pressbooks
|
||||
MYSQL_USER=pressbooks
|
||||
MYSQL_PASSWORD=$WP_DB_PASS
|
||||
|
||||
WORDPRESS_DB_HOST=$DB_CONTAINER
|
||||
WORDPRESS_DB_NAME=pressbooks
|
||||
WORDPRESS_DB_USER=pressbooks
|
||||
WORDPRESS_DB_PASSWORD=$WP_DB_PASS
|
||||
|
||||
# Only consulted by wp-cli during initial setup below, not read by the
|
||||
# wordpress:apache image itself.
|
||||
WP_SITE_TITLE=$PB_TITLE
|
||||
WP_ADMIN_USER=$PB_ADMIN_USER
|
||||
WP_ADMIN_PASSWORD=$PB_ADMIN_PASS
|
||||
WP_ADMIN_EMAIL=$PB_ADMIN_EMAIL
|
||||
PBENV
|
||||
chmod 600 .env
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
|
||||
|
||||
log_success "Pressbooks configured at $DIR (port $WEB_PORT)"
|
||||
log_info "Building image (first build downloads PrinceXML and cover-generator packages — can take a few minutes)..."
|
||||
|
||||
if ! docker compose build; then
|
||||
log_error "Image build failed — check the output above."
|
||||
return 1
|
||||
fi
|
||||
if ! docker compose up -d; then
|
||||
log_error "Failed to start — check: docker compose logs"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Waiting for WordPress to come up..."
|
||||
local _tries=0
|
||||
until docker exec "$CONTAINER" curl -fs -o /dev/null http://localhost/ 2>/dev/null || [ "$_tries" -ge 30 ]; do
|
||||
sleep 1; _tries=$((_tries + 1))
|
||||
done
|
||||
|
||||
# "wp" spelled out explicitly — see _pb_wpcli's comment above for why.
|
||||
_wpcli() { docker run --rm --network "$WP_NET" -v "$DIR/html:/var/www/html" --env-file "$DIR/.env" wordpress:cli wp "$@"; }
|
||||
|
||||
log_info "Running wp-cli core install..."
|
||||
if ! _wpcli core install \
|
||||
--url="http://localhost:${WEB_PORT}" \
|
||||
--title="$PB_TITLE" \
|
||||
--admin_user="$PB_ADMIN_USER" \
|
||||
--admin_password="$PB_ADMIN_PASS" \
|
||||
--admin_email="$PB_ADMIN_EMAIL" \
|
||||
--skip-email; then
|
||||
log_error "wp-cli core install failed — WordPress may not have been ready yet. Retry manually:"
|
||||
log_error " docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\"
|
||||
log_error " --env-file $DIR/.env wordpress:cli wp core install ..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Multisite refuses to activate with the "Plain" (query-string) permalink
|
||||
# structure — pretty permalinks are a hard prerequisite, not optional.
|
||||
log_info "Setting pretty permalinks and converting to a Multisite network..."
|
||||
_wpcli rewrite structure '/%postname%/' --hard
|
||||
_wpcli core multisite-convert --title="$PB_TITLE"
|
||||
# multisite-convert doesn't rewrite .htaccess itself on Apache — without
|
||||
# this, every site but the root 404s.
|
||||
_wpcli rewrite flush --hard
|
||||
|
||||
_pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "$WEB_PORT"
|
||||
|
||||
# ── Authelia SSO gate ─────────────────────────────────────────────────────
|
||||
# Pressbooks/WordPress has its own login screen, but no native
|
||||
# OIDC/reverse-proxy-auth support the way gitea/mealie do (a third-party
|
||||
# plugin could add one, the same "bigger lift" caveat CLAUDE.md notes for
|
||||
# Jellyfin/Home Assistant) — so this is the same forward_auth gate used
|
||||
# for services with no built-in auth at all: Authelia guards the front
|
||||
# door, WordPress's own login is still a second gate behind it.
|
||||
local EXTRA_BLOCK=""
|
||||
if [ -d "$DOCKER_DIR/authelia" ]; then
|
||||
local USE_AUTHELIA=""
|
||||
prompt_yn "Protect Pressbooks with Authelia SSO? (y/n):" "y" USE_AUTHELIA
|
||||
[[ "$USE_AUTHELIA" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia"
|
||||
fi
|
||||
configure_caddy_for_service "Pressbooks" "${CONTAINER}:80" "books" "$EXTRA_BLOCK"
|
||||
|
||||
# Reconcile the domain WordPress/Multisite think they're on: core install
|
||||
# ran against http://localhost:$WEB_PORT since the final domain isn't
|
||||
# known until the Caddy prompt above. Two passes — the full scheme+host
|
||||
# string first (catches siteurl/home, stored with "http://"), then the
|
||||
# bare host (catches wp_site.domain/wp_blogs.domain, stored without a
|
||||
# scheme) — doing it in the other order would leave siteurl/home on
|
||||
# "http://" instead of "https://" once Caddy is terminating TLS.
|
||||
if [ "$CADDY_SERVICE_CONFIGURED" = true ] && [ -n "$CADDY_SERVICE_DOMAIN" ]; then
|
||||
_wpcli search-replace "http://localhost:${WEB_PORT}" "https://${CADDY_SERVICE_DOMAIN}" --network --all-tables --report-changed-only
|
||||
_wpcli search-replace "localhost:${WEB_PORT}" "$CADDY_SERVICE_DOMAIN" --network --all-tables --report-changed-only
|
||||
log_success "Updated the network's URLs to https://$CADDY_SERVICE_DOMAIN"
|
||||
fi
|
||||
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && [ "$CADDY_SERVICE_CONFIGURED" = true ] \
|
||||
&& _authelia_scope_access "pressbooks" "$CADDY_SERVICE_DOMAIN"
|
||||
|
||||
local PB_ACCESS_URL="http://localhost:${WEB_PORT}"
|
||||
[ "$CADDY_SERVICE_CONFIGURED" = true ] && PB_ACCESS_URL="https://$CADDY_SERVICE_DOMAIN"
|
||||
|
||||
write_readme "$DIR" << MD
|
||||
# Pressbooks
|
||||
|
||||
Self-hosted book platform on a dedicated WordPress Multisite network (its
|
||||
own container/database — never shares an install with \`services/wordpress.sh\`,
|
||||
since Pressbooks requires a fresh multisite network of its own).
|
||||
|
||||
- Network admin: ${PB_ACCESS_URL}/wp-admin/network/
|
||||
- Admin user: \`$PB_ADMIN_USER\`
|
||||
- Admin password: see \`WP_ADMIN_PASSWORD\` in \`.env\`
|
||||
- Book files: \`html/\`
|
||||
- Database files: \`db/\`
|
||||
- PHP limits: \`uploads-ini.d/uploads.ini\` (512M memory, 128M uploads, 600s
|
||||
execution time — a full-book PDF export can take a while)
|
||||
|
||||
## Creating a book
|
||||
My Sites -> Network Admin -> Sites -> Add New creates a new book (its own
|
||||
site in the network). Each book gets its own theme, its own chapters, and
|
||||
its own front/back matter, picked from the Pressbooks admin bar once inside it.
|
||||
|
||||
## Writing and placing images
|
||||
Chapters are written in WordPress's own block editor. Type directly into a
|
||||
chapter; to place an image, either drag an image file straight into the
|
||||
content area to drop it in as an Image block exactly where you dropped it,
|
||||
or use the editor's own Add Media button, which also accepts drag-and-drop
|
||||
in its upload dialog. Cover images are uploaded the same way from a book's
|
||||
own Book Info screen.
|
||||
|
||||
## Exporting
|
||||
Export options live under each book's own Export screen.
|
||||
- **EPUB** — generated directly by Pressbooks, no extra engine needed.
|
||||
- **PDF** — needs the rendering engine chosen at install time:
|
||||
$( [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo " - PrinceXML is installed on this container.$( [ -n "$PRINCE_LICENSE_PATH" ] && echo " A license file is installed — no watermark." || echo " Free non-commercial version — adds a small logo to page 1 of every PDF; re-run this installer with a purchased license.dat to remove it." )" )
|
||||
$( [ -n "$DOCRAPTOR_KEY" ] && echo " - DocRaptor is configured as an alternative/fallback (uses your own API key — real documents count against your DocRaptor plan; DocRaptor's own \`test\` mode produces unlimited watermarked previews for free)." )
|
||||
$( [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ] && echo " - Not configured yet — re-run this installer (Update or Full reinstall) to add PrinceXML and/or DocRaptor." )
|
||||
- **MOBI/Kindle** — Pressbooks removed MOBI export after Amazon discontinued
|
||||
KindleGen and stopped accepting MOBI on KDP (March 2025). For a personal
|
||||
Kindle copy, export EPUB and convert it with Calibre — this repo's own
|
||||
\`calibre-web\` service can do that conversion if you don't already have
|
||||
Calibre elsewhere.
|
||||
|
||||
## Manage
|
||||
\`\`\`bash
|
||||
cd $DIR
|
||||
docker compose up -d # start
|
||||
docker compose down # stop
|
||||
docker compose logs -f # logs
|
||||
docker compose build --pull && docker compose up -d # refresh base image + packages
|
||||
\`\`\`
|
||||
Or re-run \`sudo ./setup.sh pressbooks\` and choose Update, which also
|
||||
refreshes the Pressbooks plugin/themes to their latest release.
|
||||
|
||||
## wp-cli
|
||||
\`\`\`bash
|
||||
docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\
|
||||
--env-file $DIR/.env wordpress:cli wp <command>
|
||||
\`\`\`
|
||||
|
||||
## Backup
|
||||
\`services/backup.sh\` (Kopia) already covers this directory automatically —
|
||||
generic for every \`~/docker/*\` directory with a \`docker-compose.yml\`, so
|
||||
both \`html/\` (every book's content and media) and \`db/\` are captured
|
||||
together on every run with no per-service setup needed.
|
||||
MD
|
||||
|
||||
echo ""
|
||||
echo " Access at: $PB_ACCESS_URL"
|
||||
echo " Network admin: ${PB_ACCESS_URL}/wp-admin/network/"
|
||||
echo " Admin user: $PB_ADMIN_USER"
|
||||
echo " Admin pass: $PB_ADMIN_PASS"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Run immediately when executed directly (deferred until after function definition)
|
||||
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_pressbooks
|
||||
+22
-6
@@ -1436,7 +1436,7 @@ _pstn_check_killswitch_clear() {
|
||||
# _pstn_install_periodic_timer above) ───────────────────────────────────────
|
||||
_pstn_write_usage_alert_script() {
|
||||
local FILE="$1" EA_DIR="$2" ASTERISK_DIR="$3" RATE="$4" MONTH_THRESHOLD="$5" \
|
||||
BURST_THRESHOLD="$6" MAX_MONTHLY_SPEND="$7" NTFY_URL="$8" CONTAINER_NAME="${9:-easy-asterisk}"
|
||||
BURST_THRESHOLD="$6" MAX_MONTHLY_SPEND="$7" NTFY_URL="$8" CONTAINER_NAME="${9:-asterisk}"
|
||||
cat > "$FILE" << 'EOF'
|
||||
#!/bin/bash
|
||||
# Auto-generated by services/pstn-trunk.sh — do not edit directly, re-run
|
||||
@@ -1719,7 +1719,7 @@ _pstn_apply_settings() {
|
||||
local EA_DIR="$1" ASTERISK_DIR="$2"
|
||||
local SERVER="$3" SERVER_IPS="$4" DID="$5"
|
||||
local RING_EXTS="$6" NTFY_URL="$7" RATE="$8" MONTH_THRESHOLD="$9" BURST_THRESHOLD="${10}"
|
||||
local PROVIDER_NAME="${11}" MAX_MONTHLY_SPEND="${12:-0}" CONTAINER_NAME="${13:-easy-asterisk}"
|
||||
local PROVIDER_NAME="${11}" MAX_MONTHLY_SPEND="${12:-0}" CONTAINER_NAME="${13:-asterisk}"
|
||||
|
||||
_pstn_patch_vendor_files "$EA_DIR" || return 1
|
||||
|
||||
@@ -1746,6 +1746,7 @@ _pstn_apply_settings() {
|
||||
# Direct") — unquoted, bash's `source` would treat the second word of
|
||||
# any such value as a command to run ("Direct: command not found"),
|
||||
# confirmed live while testing the multi-IP change.
|
||||
backup_if_exists "$EA_DIR/.pstn-trunk.env"
|
||||
cat > "$EA_DIR/.pstn-trunk.env" << ENV
|
||||
PROVIDER_NAME="${PROVIDER_NAME}"
|
||||
TRUNK_SERVER="${SERVER}"
|
||||
@@ -1879,8 +1880,18 @@ install_pstn-trunk() {
|
||||
local KILLSWITCH_FILE="$ASTERISK_DIR/pstn-trunk-killswitch.conf"
|
||||
local PERSONAL_DIDS_FILE="$ASTERISK_DIR/pstn-personal-dids.conf"
|
||||
local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env"
|
||||
local CONTAINER_NAME="easy-asterisk"
|
||||
[[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do"
|
||||
# Read the box's own container_name instead of assuming — new installs
|
||||
# use plain "asterisk" now, but an existing "easy-asterisk" install
|
||||
# (this repo's container name before that rename) keeps working
|
||||
# unchanged until someone deliberately migrates it. See
|
||||
# services/asterisk.sh's _asterisk_resolve_layout for the reasoning.
|
||||
local CONTAINER_NAME="asterisk"
|
||||
if [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]]; then
|
||||
CONTAINER_NAME="easy-asterisk-do"
|
||||
elif [[ -f "$EA_DIR/docker-compose.yml" ]]; then
|
||||
CONTAINER_NAME="$(grep -m1 '^[[:space:]]*container_name:' "$EA_DIR/docker-compose.yml" | awk '{print $2}')"
|
||||
[[ -z "$CONTAINER_NAME" ]] && CONTAINER_NAME="asterisk"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would require an existing asterisk install (droplet or home/LAN)"
|
||||
@@ -1988,8 +1999,13 @@ install_pstn-trunk() {
|
||||
# never picking up dialplan changes at all. Re-assert the fresh
|
||||
# detection here, discarding the sourced value, so it can't drift
|
||||
# from reality and self-heals the persisted file too.
|
||||
CONTAINER_NAME="easy-asterisk"
|
||||
[[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do"
|
||||
CONTAINER_NAME="asterisk"
|
||||
if [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]]; then
|
||||
CONTAINER_NAME="easy-asterisk-do"
|
||||
elif [[ -f "$EA_DIR/docker-compose.yml" ]]; then
|
||||
CONTAINER_NAME="$(grep -m1 '^[[:space:]]*container_name:' "$EA_DIR/docker-compose.yml" | awk '{print $2}')"
|
||||
[[ -z "$CONTAINER_NAME" ]] && CONTAINER_NAME="asterisk"
|
||||
fi
|
||||
_pstn_check_killswitch_clear "$ASTERISK_DIR"
|
||||
_pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \
|
||||
"$TRUNK_SERVER" "${TRUNK_SERVER_IPS:-}" "$TRUNK_DID" \
|
||||
|
||||
@@ -91,6 +91,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -230,6 +235,7 @@ install_rustdesk() {
|
||||
prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc
|
||||
[ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0"
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << RD_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -251,6 +257,7 @@ services:
|
||||
- ./rustdesk_data:/data
|
||||
RD_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << RD_ENV
|
||||
# ── General ───────────────────────────────────────────────────────────────────
|
||||
TZ=$TZ_VAL
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
#!/bin/bash
|
||||
# services/samba.sh — Samba (SMB/CIFS) file sharing: shares, users, passwords.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
#
|
||||
# Can also be run standalone on any machine:
|
||||
# sudo bash samba.sh
|
||||
#
|
||||
# Samba is a SYSTEM install (apt package + native smbd/nmbd services), NOT a
|
||||
# docker-compose service — same shape as services/crowdsec.sh. There is no
|
||||
# ~/docker/samba compose stack; we only create a docs-only folder there with
|
||||
# a README pointing at the real config under /etc/samba/smb.conf. This is
|
||||
# the SERVER side — for mounting an existing remote Samba share instead, see
|
||||
# services/vpn-data-mount.sh (deliberately the opposite: reads an existing
|
||||
# smb.conf over SSH, never installs Samba, never creates or resets a share
|
||||
# password).
|
||||
|
||||
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||
# Sets up helpers and globals, then defers execution until after the function
|
||||
# definition at the bottom of this file.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||
|
||||
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||
|
||||
if [[ -f "$_COMMON" ]]; then
|
||||
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
||||
# shellcheck source=../lib/common.sh
|
||||
source "$_COMMON"
|
||||
else
|
||||
# One-off copy — inline minimal stubs so the script works without the repo
|
||||
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||
|
||||
ensure_docker_dir_ownership() {
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_yn() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||
}
|
||||
|
||||
ensure_ufw_enabled() {
|
||||
command -v ufw &>/dev/null || return 0
|
||||
ufw status 2>/dev/null | grep -q "Status: active" && return 0
|
||||
local _ssh_port
|
||||
_ssh_port="$(grep -iE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null \
|
||||
| tail -1 | awk '{print $2}')"
|
||||
_ssh_port="${_ssh_port:-22}"
|
||||
ufw allow "${_ssh_port}/tcp" comment 'SSH' >/dev/null 2>&1
|
||||
ufw --force enable >/dev/null 2>&1
|
||||
log_success "UFW enabled (SSH on port ${_ssh_port} allowed first, so this won't lock you out)."
|
||||
}
|
||||
|
||||
write_readme() {
|
||||
local _dir="$1"; shift
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
# ($HOME under sudo is /root, not the real user's home)
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
UNATTENDED="${UNATTENDED:-false}"
|
||||
|
||||
register_service() { :; } # no-op — no wizard to register into
|
||||
_RUN_STANDALONE=1
|
||||
fi
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
register_service samba utilities "Samba file sharing (SMB/CIFS) — shares, users, passwords"
|
||||
|
||||
install_samba() {
|
||||
local SMB_CONF="/etc/samba/smb.conf"
|
||||
local DOCS_DIR="$DOCKER_DIR/samba"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would install samba (smbd/nmbd) if not already present"
|
||||
echo "[DRY-RUN] Would show any shares this installer already manages"
|
||||
echo "[DRY-RUN] Would prompt to add one or more shares (path, guest-or-authenticated, users)"
|
||||
echo "[DRY-RUN] Would create a system Linux account + Samba password for any new user"
|
||||
echo "[DRY-RUN] Would append share stanzas to $SMB_CONF, validate with testparm, restart smbd/nmbd"
|
||||
echo "[DRY-RUN] Would open UFW for SMB (137/138 udp, 139/445 tcp) — scoped to the LAN by default"
|
||||
echo "[DRY-RUN] Would write $DOCS_DIR/README.md (docs only — Samba itself runs natively, not in Docker)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v smbd &>/dev/null; then
|
||||
log_info "Installing Samba..."
|
||||
apt-get update -y
|
||||
apt-get install -y samba || { log_error "Samba install failed"; return 1; }
|
||||
log_success "Samba installed"
|
||||
else
|
||||
log_success "Samba already installed"
|
||||
fi
|
||||
|
||||
backup_if_exists "$SMB_CONF"
|
||||
|
||||
if grep -q '^# ubuntu-post-install:share:' "$SMB_CONF" 2>/dev/null; then
|
||||
echo ""
|
||||
log_info "Shares already managed by this installer:"
|
||||
grep '^# ubuntu-post-install:share:' "$SMB_CONF" | sed 's/^# ubuntu-post-install:share:/ - /'
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local _added_any=false
|
||||
while true; do
|
||||
local ADD_SHARE=""
|
||||
prompt_yn "Add a Samba share now? (y/n):" "y" ADD_SHARE
|
||||
[[ "$ADD_SHARE" =~ ^[Yy]$ ]] || break
|
||||
_samba_add_share "$SMB_CONF" && _added_any=true
|
||||
echo ""
|
||||
done
|
||||
|
||||
if [ "$_added_any" = true ]; then
|
||||
log_info "Validating smb.conf..."
|
||||
if testparm -s "$SMB_CONF" &>/dev/null; then
|
||||
systemctl restart smbd 2>/dev/null
|
||||
systemctl restart nmbd 2>/dev/null # NetBIOS name resolution — some Samba packages split this out
|
||||
log_success "smbd/nmbd restarted with the new configuration"
|
||||
else
|
||||
log_error "testparm reports smb.conf is invalid — NOT restarting smbd/nmbd."
|
||||
log_error "Check manually: sudo testparm -s $SMB_CONF"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_info "No shares added this run."
|
||||
fi
|
||||
|
||||
_samba_configure_firewall
|
||||
|
||||
mkdir -p "$DOCS_DIR"
|
||||
ensure_docker_dir_ownership "$DOCS_DIR"
|
||||
write_readme "$DOCS_DIR" << MD
|
||||
# Samba
|
||||
|
||||
Samba runs natively on this box (not in Docker) — the real config is
|
||||
\`/etc/samba/smb.conf\`, managed by \`systemctl\`. This folder just holds this
|
||||
README; there's no compose stack here.
|
||||
|
||||
## Manage
|
||||
|
||||
\`\`\`bash
|
||||
sudo testparm -s # validate smb.conf before restarting
|
||||
sudo systemctl restart smbd nmbd
|
||||
sudo systemctl status smbd
|
||||
\`\`\`
|
||||
|
||||
## Shares
|
||||
|
||||
Re-run \`sudo ./setup.sh samba\` (or \`sudo bash services/samba.sh\` standalone)
|
||||
to add another share or another user — existing shares/users are left alone.
|
||||
|
||||
Each share this installer wrote is marked in smb.conf with a
|
||||
\`# ubuntu-post-install:share:<name>\` comment right above its \`[<name>]\`
|
||||
stanza, so you can find (or hand-edit / remove) them later.
|
||||
|
||||
## Users
|
||||
|
||||
Samba users need BOTH a Linux account and a separate Samba password
|
||||
(\`smbpasswd\`) — they are not the same credential. This installer creates a
|
||||
system account (\`useradd --system --no-create-home\`, no shell login) for
|
||||
any username that doesn't already exist as a Linux user, adds it to the
|
||||
\`sambashare\` group, and sets its Samba password with \`smbpasswd\`.
|
||||
|
||||
\`\`\`bash
|
||||
sudo smbpasswd <username> # change an existing user's Samba password
|
||||
sudo pdbedit -L # list all Samba users
|
||||
sudo smbpasswd -x <username> # remove a user from Samba (leaves the Linux account alone)
|
||||
\`\`\`
|
||||
|
||||
## Connecting
|
||||
|
||||
- Windows: \`\\\\<server-ip>\\<share-name>\`
|
||||
- macOS Finder: Go -> Connect to Server -> \`smb://<server-ip>/<share-name>\`
|
||||
- Linux: \`smbclient //<server-ip>/<share-name> -U <username>\` or mount with
|
||||
\`mount.cifs\` / \`cifs-utils\` (already installed by \`services/base.sh\`).
|
||||
|
||||
## Firewall
|
||||
|
||||
SMB (137/138 UDP, 139/445 TCP) should almost never be exposed to the public
|
||||
internet — this installer scopes the UFW rule to your LAN subnet by default.
|
||||
Check what's currently allowed with \`sudo ufw status | grep -E '13[7-9]|445'\`.
|
||||
MD
|
||||
|
||||
log_success "Samba configured. Re-run 'sudo ./setup.sh samba' any time to add another share or user."
|
||||
}
|
||||
|
||||
# Appends one [share] stanza to smb.conf. Returns non-zero (and adds nothing)
|
||||
# on a blank/duplicate name so the caller's "did we actually add one" tracking
|
||||
# stays accurate.
|
||||
_samba_add_share() {
|
||||
local _conf="$1"
|
||||
local NAME="" SHARE_PATH="" GUEST=""
|
||||
|
||||
prompt_text " Share name (letters/numbers/hyphens/underscores, e.g. media):" "" NAME
|
||||
NAME="$(echo "$NAME" | tr -cd 'A-Za-z0-9_-')"
|
||||
if [[ -z "$NAME" ]]; then
|
||||
log_warning "Share name required — skipping."
|
||||
return 1
|
||||
fi
|
||||
if grep -q "^\[$NAME\]\$" "$_conf" 2>/dev/null; then
|
||||
log_warning "A share named [$NAME] already exists in smb.conf — skipping."
|
||||
log_warning "Edit $_conf by hand to change it, or pick a different name."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local DEFAULT_PATH="/srv/samba/$NAME"
|
||||
prompt_text " Path to share [$DEFAULT_PATH]:" "$DEFAULT_PATH" SHARE_PATH
|
||||
SHARE_PATH="${SHARE_PATH:-$DEFAULT_PATH}"
|
||||
SHARE_PATH="${SHARE_PATH/#\~/$ACTUAL_HOME}"
|
||||
mkdir -p "$SHARE_PATH"
|
||||
|
||||
prompt_yn " Allow guest (no password) access to '$NAME'? (y/n):" "n" GUEST
|
||||
|
||||
local VALID_USERS=""
|
||||
if [[ ! "$GUEST" =~ ^[Yy]$ ]]; then
|
||||
echo " Enter Samba usernames to grant access to '$NAME' (blank to stop):"
|
||||
while true; do
|
||||
local SUSER=""
|
||||
prompt_text " Username:" "" SUSER
|
||||
[[ -z "$SUSER" ]] && break
|
||||
_samba_ensure_user "$SUSER"
|
||||
VALID_USERS="${VALID_USERS:+$VALID_USERS }$SUSER"
|
||||
done
|
||||
if [[ -z "$VALID_USERS" ]]; then
|
||||
log_warning "No users added and guest access declined — '$NAME' will be inaccessible until you add a user (re-run this installer, or edit smb.conf by hand)."
|
||||
fi
|
||||
fi
|
||||
|
||||
getent group sambashare >/dev/null 2>&1 || groupadd sambashare
|
||||
if [[ "$GUEST" =~ ^[Yy]$ ]]; then
|
||||
chmod 0777 "$SHARE_PATH"
|
||||
else
|
||||
chgrp sambashare "$SHARE_PATH" 2>/dev/null || true
|
||||
chmod 0770 "$SHARE_PATH"
|
||||
fi
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "# ubuntu-post-install:share:$NAME"
|
||||
echo "[$NAME]"
|
||||
echo " path = $SHARE_PATH"
|
||||
echo " browseable = yes"
|
||||
echo " read only = no"
|
||||
if [[ "$GUEST" =~ ^[Yy]$ ]]; then
|
||||
echo " guest ok = yes"
|
||||
else
|
||||
echo " guest ok = no"
|
||||
[[ -n "$VALID_USERS" ]] && echo " valid users = $VALID_USERS"
|
||||
fi
|
||||
} >> "$_conf"
|
||||
|
||||
log_success "Share '$NAME' -> $SHARE_PATH added to smb.conf"
|
||||
}
|
||||
|
||||
# Creates the Linux system account (if missing) and sets a Samba password for
|
||||
# it. Samba users need BOTH — a Linux account and a separate smbpasswd entry
|
||||
# — they are not the same credential, and smbpasswd -a fails outright against
|
||||
# a username with no matching Linux account at all.
|
||||
_samba_ensure_user() {
|
||||
local _user="$1"
|
||||
|
||||
if ! id "$_user" &>/dev/null; then
|
||||
log_info "Linux account '$_user' doesn't exist — creating a system account (no shell login, no home dir)."
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$_user"
|
||||
fi
|
||||
|
||||
getent group sambashare >/dev/null 2>&1 || groupadd sambashare
|
||||
usermod -aG sambashare "$_user"
|
||||
|
||||
if pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qx "$_user"; then
|
||||
log_info "Samba password already set for '$_user' — leaving as-is (change it later with: sudo smbpasswd $_user)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _pass _entered=""
|
||||
_pass="$(generate_password 16)"
|
||||
prompt_text " Samba password for '$_user' [$_pass]:" "$_pass" _entered
|
||||
_pass="${_entered:-$_pass}"
|
||||
|
||||
if printf '%s\n%s\n' "$_pass" "$_pass" | smbpasswd -s -a "$_user" >/dev/null 2>&1 \
|
||||
&& smbpasswd -e "$_user" >/dev/null 2>&1; then
|
||||
log_success "Samba user '$_user' set — password: $_pass (write this down, it isn't stored anywhere else)"
|
||||
else
|
||||
log_warning "Failed to set Samba password for '$_user' — set it manually: sudo smbpasswd $_user"
|
||||
fi
|
||||
}
|
||||
|
||||
# SMB should almost never face the public internet — scope the UFW rule to
|
||||
# the LAN by default (LAN-subnet detection borrowed from the same pattern
|
||||
# services/asterisk.sh uses for its VLAN/local-network prompt).
|
||||
_samba_configure_firewall() {
|
||||
command -v ufw &>/dev/null || {
|
||||
log_warning "ufw not installed — if you use a firewall, open TCP 139/445 and UDP 137/138 for SMB (LAN only, never the internet)."
|
||||
return 0
|
||||
}
|
||||
|
||||
echo ""
|
||||
local DETECTED_NETS DEFAULT_SUBNET=""
|
||||
DETECTED_NETS="$(ip -o -f inet addr show scope global 2>/dev/null \
|
||||
| awk '{print $2, $4}' \
|
||||
| grep -Ev '^(docker|br-|veth|tun|tap|wg)' \
|
||||
| awk '{ split($2,a,"/"); split(a[1],o,"."); print o[1]"."o[2]"."o[3]".0/"a[2] }' \
|
||||
| sort -u)"
|
||||
DEFAULT_SUBNET="$(echo "$DETECTED_NETS" | head -1)"
|
||||
|
||||
local RESTRICT_LAN=""
|
||||
prompt_yn "Restrict Samba access to your local network only (recommended — SMB should never face the internet)? (y/n):" "y" RESTRICT_LAN
|
||||
|
||||
if [[ "$RESTRICT_LAN" =~ ^[Yy]$ ]]; then
|
||||
local SUBNET=""
|
||||
prompt_text " LAN subnet to allow (CIDR)${DEFAULT_SUBNET:+ [$DEFAULT_SUBNET]}:" "$DEFAULT_SUBNET" SUBNET
|
||||
SUBNET="${SUBNET:-$DEFAULT_SUBNET}"
|
||||
if [[ -z "$SUBNET" ]]; then
|
||||
log_warning "No subnet given — skipping UFW rules. Open them manually if needed."
|
||||
return 0
|
||||
fi
|
||||
local p
|
||||
for p in 137 138; do
|
||||
ufw allow from "$SUBNET" to any port "$p" proto udp comment "Samba (LAN)" >/dev/null 2>&1
|
||||
done
|
||||
for p in 139 445; do
|
||||
ufw allow from "$SUBNET" to any port "$p" proto tcp comment "Samba (LAN)" >/dev/null 2>&1
|
||||
done
|
||||
log_success "UFW: Samba opened to $SUBNET only"
|
||||
else
|
||||
log_warning "Opening Samba to ALL sources — not recommended, SMB has a long history of remote exploits."
|
||||
ufw allow 137/udp comment "Samba" >/dev/null 2>&1
|
||||
ufw allow 138/udp comment "Samba" >/dev/null 2>&1
|
||||
ufw allow 139/tcp comment "Samba" >/dev/null 2>&1
|
||||
ufw allow 445/tcp comment "Samba" >/dev/null 2>&1
|
||||
log_success "UFW: Samba opened (unrestricted)"
|
||||
fi
|
||||
ensure_ufw_enabled
|
||||
}
|
||||
|
||||
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_samba
|
||||
+214
-10
@@ -105,7 +105,15 @@ install_security-dashboard() {
|
||||
if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then
|
||||
ASTERISK_EA_CONTAINER="easy-asterisk-do"
|
||||
elif [ -n "$ASTERISK_EA_DIR" ]; then
|
||||
ASTERISK_EA_CONTAINER="easy-asterisk"
|
||||
# Read the box's own container_name instead of assuming — new
|
||||
# installs use plain "asterisk" now, but an existing "easy-asterisk"
|
||||
# install (this repo's container name before that rename) keeps
|
||||
# working unchanged until someone deliberately migrates it. See
|
||||
# services/asterisk.sh's _asterisk_resolve_layout for the reasoning.
|
||||
if [[ -f "$ASTERISK_EA_DIR/docker-compose.yml" ]]; then
|
||||
ASTERISK_EA_CONTAINER="$(grep -m1 '^[[:space:]]*container_name:' "$ASTERISK_EA_DIR/docker-compose.yml" | awk '{print $2}')"
|
||||
fi
|
||||
[[ -z "$ASTERISK_EA_CONTAINER" ]] && ASTERISK_EA_CONTAINER="asterisk"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -171,7 +179,7 @@ install_security-dashboard() {
|
||||
prompt_yn "Reconfigure this dashboard's Caddy protection (Authelia domain, or add/rotate an independent Basic Auth layer)? (y/n):" "n" _reconf
|
||||
if [[ "$_reconf" =~ ^[Yy]$ ]]; then
|
||||
_secdash_remove_caddy_block "$DASHBOARD_PORT"
|
||||
_secdash_configure_caddy "$DASHBOARD_PORT"
|
||||
_secdash_configure_caddy "$DASHBOARD_PORT" "$ASTERISK_EA_DIR" "$ASTERISK_EA_CONTAINER"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -253,7 +261,7 @@ install_security-dashboard() {
|
||||
# below already does this (line ~173); a fresh install needs the same
|
||||
# removal, not just the same write. No-ops if nothing is deployed yet.
|
||||
_secdash_remove_caddy_block "$DASHBOARD_PORT"
|
||||
_secdash_configure_caddy "$DASHBOARD_PORT"
|
||||
_secdash_configure_caddy "$DASHBOARD_PORT" "$ASTERISK_EA_DIR" "$ASTERISK_EA_CONTAINER"
|
||||
_secdash_configure_admin_scoping "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT"
|
||||
|
||||
write_readme "$APP_DIR" << README_MD
|
||||
@@ -678,7 +686,7 @@ SUDOERS
|
||||
# retroactively) using the exact same code path as a fresh install, instead
|
||||
# of hand-patching a live Caddyfile block in place.
|
||||
_secdash_configure_caddy() {
|
||||
local DASHBOARD_PORT="$1"
|
||||
local DASHBOARD_PORT="$1" ASTERISK_EA_DIR="${2:-}" ASTERISK_EA_CONTAINER="${3:-}"
|
||||
|
||||
echo ""
|
||||
if ! command -v docker &>/dev/null || ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then
|
||||
@@ -686,12 +694,17 @@ _secdash_configure_caddy() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
local _default_domain=""
|
||||
if [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
|
||||
_default_domain="security.${SITE_DOMAIN}"
|
||||
fi
|
||||
local SD_DOMAIN=""
|
||||
prompt_text " Domain for the dashboard (e.g. security.yourdomain.com), you'll need to point DNS at this droplet yourself [${_default_domain:-required}]:" "$_default_domain" SD_DOMAIN
|
||||
_secdash_offer_asterisk_domain "$ASTERISK_EA_DIR" "$DASHBOARD_PORT" "$ASTERISK_EA_CONTAINER"
|
||||
SD_DOMAIN="$ASTERISK_TAKEOVER_DOMAIN"
|
||||
|
||||
if [ -z "$SD_DOMAIN" ]; then
|
||||
local _default_domain=""
|
||||
if [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
|
||||
_default_domain="security.${SITE_DOMAIN}"
|
||||
fi
|
||||
prompt_text " Domain for the dashboard (e.g. security.yourdomain.com), you'll need to point DNS at this droplet yourself [${_default_domain:-required}]:" "$_default_domain" SD_DOMAIN
|
||||
fi
|
||||
|
||||
if [ -z "$SD_DOMAIN" ]; then
|
||||
log_warning "No domain entered — dashboard stays on http://localhost:$DASHBOARD_PORT only (not reachable from outside this box)."
|
||||
@@ -801,6 +814,20 @@ CADDYBLOCK
|
||||
log_warning "$SD_DOMAIN already in Caddyfile — leaving the existing entry alone."
|
||||
fi
|
||||
|
||||
# Offer per-user access scoping now that this domain is actually
|
||||
# Authelia-protected (local import or remote forward_auth — EXTRA_BLOCK
|
||||
# is only non-empty when one of those was configured above; skip this
|
||||
# entirely for Basic-Auth-only or no-auth setups, where there's no
|
||||
# Authelia gate to scope). This dashboard was never wired into
|
||||
# _authelia_scope_access before now, on either this domain-takeover path
|
||||
# or the plain separate-subdomain path below it — every protected
|
||||
# domain here defaulted to "any Authelia user", with no way to restrict
|
||||
# it to specific people. Guarded by declare -F: this file can run
|
||||
# standalone with authelia.sh never sourced.
|
||||
if [ -n "$EXTRA_BLOCK" ] && declare -F _authelia_scope_access >/dev/null 2>&1; then
|
||||
_authelia_scope_access "security-dashboard" "$SD_DOMAIN"
|
||||
fi
|
||||
|
||||
# This port never needs to be open to the internet — only Caddy (local,
|
||||
# via host.docker.internal) ever needs to reach it.
|
||||
if command -v ufw &>/dev/null; then
|
||||
@@ -996,6 +1023,100 @@ _secdash_remove_caddy_block() {
|
||||
log_info "Removed the existing dashboard Caddy block (regenerating it fresh)."
|
||||
}
|
||||
|
||||
# Same technique as _secdash_remove_caddy_block above, but keyed on the
|
||||
# block's own opening "<domain> {" line instead of a reverse_proxy marker —
|
||||
# used by _secdash_offer_asterisk_domain to remove ASTERISK'S OLD block for
|
||||
# a domain it's handing over, not this dashboard's own.
|
||||
_secdash_remove_caddy_block_by_domain() {
|
||||
local domain="$1"
|
||||
local caddy_file="$DOCKER_DIR/caddy/Caddyfile"
|
||||
[ -f "$caddy_file" ] || return 0
|
||||
|
||||
local domain_line end_line start_line
|
||||
domain_line="$(grep -nx "${domain} {" "$caddy_file" | head -1 | cut -d: -f1)"
|
||||
[ -z "$domain_line" ] && return 0 # nothing there — fine
|
||||
|
||||
# Pull in a "# <comment>" line directly above it too, if present (every
|
||||
# site block this repo writes has one, e.g. "# Asterisk Web Admin").
|
||||
start_line="$domain_line"
|
||||
if [ "$domain_line" -gt 1 ] && sed -n "$((domain_line - 1))p" "$caddy_file" | grep -qE '^# '; then
|
||||
start_line=$((domain_line - 1))
|
||||
fi
|
||||
|
||||
end_line="$(tail -n "+$domain_line" "$caddy_file" | grep -nx '}' | head -1 | cut -d: -f1)"
|
||||
if [ -z "$end_line" ]; then
|
||||
log_warning "Could not find the end of ${domain}'s existing Caddy block — leaving it as-is."
|
||||
return 1
|
||||
fi
|
||||
end_line=$((domain_line + end_line - 1))
|
||||
|
||||
sed -i "${start_line},${end_line}d" "$caddy_file"
|
||||
log_info "Removed the existing Caddy block for ${domain} (regenerating it fresh)."
|
||||
}
|
||||
|
||||
# ── Optional: take over Asterisk's own public domain instead of a separate
|
||||
# one ─────────────────────────────────────────────────────────────────────
|
||||
# Asterisk's web admin is only Caddy-fronted at its own domain so Caddy can
|
||||
# get it a trusted TLS cert for SIP (see _asterisk_configure_caddy_public in
|
||||
# services/asterisk.sh) — cert issuance only needs Caddy to own that
|
||||
# domain's site block and answer the ACME challenge there; it's unrelated to
|
||||
# what reverse_proxy target the block actually forwards to (Asterisk's own
|
||||
# cert-sync reads the issued cert straight off Caddy's disk storage, not by
|
||||
# hitting the site). So nothing stops this dashboard from taking that domain
|
||||
# over entirely instead of asking for its own — one less DNS entry/cert to
|
||||
# manage, and it closes a real gap along the way: _asterisk_configure_caddy_public
|
||||
# never rewrites an existing site block on a repeat run, it just leaves an
|
||||
# already-present domain line alone. That means a box where
|
||||
# WEB_ADMIN_AUTH_DISABLED got set true (built-in login turned off, from an
|
||||
# earlier "protect with Authelia" answer) but the Authelia import itself
|
||||
# never landed or got lost (e.g. on a restore that didn't carry the Caddyfile
|
||||
# edit) is stuck silently unauthenticated with no reconfigure path ever
|
||||
# revisiting it — confirmed live (2026-08-22): a real box was found exposing
|
||||
# its extensions/device list with no login at all.
|
||||
#
|
||||
# Sets ASTERISK_TAKEOVER_DOMAIN (non-local out-param, same convention as
|
||||
# lib/common.sh's configure_caddy_for_service CADDY_SERVICE_* out-params) to
|
||||
# the domain taken over, or leaves it empty if there's nothing to offer or
|
||||
# the offer was declined — the caller falls back to its normal own-domain
|
||||
# prompt in that case.
|
||||
_secdash_offer_asterisk_domain() {
|
||||
local ea_dir="$1" dashboard_port="$2" ea_container="$3"
|
||||
ASTERISK_TAKEOVER_DOMAIN=""
|
||||
|
||||
[ -n "$ea_dir" ] && [ -f "$ea_dir/.env" ] || return 0
|
||||
local _domain
|
||||
_domain="$(grep -E '^DOMAIN_NAME=' "$ea_dir/.env" | cut -d= -f2-)"
|
||||
[ -n "$_domain" ] || return 0
|
||||
|
||||
local _caddy_file="$DOCKER_DIR/caddy/Caddyfile"
|
||||
[ -f "$_caddy_file" ] && grep -qx "${_domain} {" "$_caddy_file" || return 0
|
||||
|
||||
echo ""
|
||||
log_info "Asterisk already has a public domain: ${_domain} (currently serving its own web"
|
||||
log_info "admin there, kept only so Caddy can get it a trusted TLS cert for SIP)."
|
||||
local _takeover=""
|
||||
prompt_yn " Serve this dashboard there instead, and stop exposing Asterisk's own web admin publicly? (y/n):" "n" _takeover
|
||||
[[ "$_takeover" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
_secdash_remove_caddy_block_by_domain "$_domain"
|
||||
_secdash_remove_caddy_block "$dashboard_port"
|
||||
|
||||
if grep -q '^WEB_ADMIN_AUTH_DISABLED=' "$ea_dir/.env"; then
|
||||
sed -i 's/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=false/' "$ea_dir/.env"
|
||||
# Not restarting Asterisk here — this is a defense-in-depth measure
|
||||
# on a port that's no longer published at all now that the Caddy
|
||||
# block above it is gone, not the live exposure fix (that's already
|
||||
# done by removing the block). Not worth interrupting an active
|
||||
# call for; takes effect on Asterisk's next restart either way.
|
||||
log_info "Re-enabled Asterisk's own web admin login in .env (defense-in-depth; takes effect on"
|
||||
log_info "Asterisk's next restart — not forcing one now in case a call is active)."
|
||||
fi
|
||||
log_warning "Asterisk's native web admin is no longer reachable over HTTPS — use the terminal instead:"
|
||||
log_warning " docker exec -it ${ea_container:-asterisk} easy-asterisk"
|
||||
|
||||
ASTERISK_TAKEOVER_DOMAIN="$_domain"
|
||||
}
|
||||
|
||||
# Full teardown for "Full reinstall" — stops the service and removes
|
||||
# everything a fresh install recreates: systemd unit, sudoers grant, Caddy
|
||||
# site block, the secdash system user, and the app directory. Non-Docker
|
||||
@@ -1956,6 +2077,79 @@ def ea_reload_voicemail():
|
||||
run_sudo(["docker", "exec", ASTERISK_EA_CONTAINER, "asterisk", "-rx", "module reload app_voicemail.so"])
|
||||
|
||||
|
||||
def _ea_endpoint_stanza_bounds(lines, ext):
|
||||
"""Line-index range (start, end-exclusive) of the `[ext]\\ntype=endpoint`
|
||||
PJSIP stanza for one extension, or None if not found. pjsip.conf reuses
|
||||
the same [ext] bracket name for three separate stanzas per device
|
||||
(type=endpoint, type=auth, type=aor — see easy-asterisk-v0.10.0.sh's
|
||||
add_device()), so matching on the bracket alone would land in the wrong
|
||||
one; this only matches the occurrence immediately followed by
|
||||
"type=endpoint", bounded by the next blank line or next [section] the
|
||||
same way lib/common.sh's _remove_caddy_site_block is bounded for Caddy
|
||||
blocks — never an unbounded scan past this one device's own stanza."""
|
||||
target = "[%s]" % ext
|
||||
i, n = 0, len(lines)
|
||||
while i < n:
|
||||
if lines[i].strip() == target and i + 1 < n and lines[i + 1].strip() == "type=endpoint":
|
||||
j = i + 1
|
||||
while j < n and lines[j].strip() != "" and not lines[j].strip().startswith("["):
|
||||
j += 1
|
||||
return i, j
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def _ea_set_endpoint_mailboxes(ext, enabled):
|
||||
"""Adds/updates (enabled) or removes (disabled) the extension's PJSIP
|
||||
`mailboxes=` line, so a phone can actually SUBSCRIBE for MWI (the "new
|
||||
voicemail" notice) on this extension.
|
||||
|
||||
Confirmed live: nothing anywhere in this repo or the vendored
|
||||
easy-asterisk script ever sets this. add_device()'s own device_config
|
||||
template (easy-asterisk-v0.10.0.sh) never writes it, and until this,
|
||||
write_voicemail() below only ever touched voicemail.conf — so recording
|
||||
a voicemail worked fine (voicemail.conf + the dialplan's VoiceMail()
|
||||
call), but no phone ever actually subscribed to be told about it,
|
||||
regardless of whether the voicemail flag was on. `mailboxes=<ext>@default`
|
||||
matches the "default" context name voicemail.conf's [default] section
|
||||
uses (see _asterisk_write_voicemail_conf in services/asterisk.sh) —
|
||||
same context, just referenced from the endpoint side instead of the
|
||||
dialplan side."""
|
||||
path = _ea_pjsip_host_path()
|
||||
if not path or not os.path.isfile(path):
|
||||
return False, "No pjsip.conf found"
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
bounds = _ea_endpoint_stanza_bounds(lines, ext)
|
||||
if not bounds:
|
||||
return False, "No PJSIP endpoint found for extension %s" % ext
|
||||
start, end = bounds
|
||||
|
||||
existing_idx = None
|
||||
for k in range(start, end):
|
||||
if lines[k].lstrip().startswith("mailboxes="):
|
||||
existing_idx = k
|
||||
break
|
||||
|
||||
if enabled:
|
||||
mailbox_line = "mailboxes=%s@default\n" % ext
|
||||
if existing_idx is not None:
|
||||
lines[existing_idx] = mailbox_line
|
||||
else:
|
||||
lines.insert(end, mailbox_line)
|
||||
elif existing_idx is not None:
|
||||
del lines[existing_idx]
|
||||
else:
|
||||
return True, ""
|
||||
|
||||
ok, err = ea_docker_write(EA_PJSIP_CONTAINER_PATH, "".join(lines))
|
||||
if not ok:
|
||||
return False, err
|
||||
ea_reload_pjsip()
|
||||
return True, ""
|
||||
|
||||
|
||||
def write_voicemail(ext, enabled):
|
||||
"""Sets/clears the voicemail flag for one extension, then regenerates
|
||||
voicemail.conf and reloads app_voicemail so the change takes effect
|
||||
@@ -1968,7 +2162,12 @@ def write_voicemail(ext, enabled):
|
||||
pstn-permissions.conf even after disabling — toggling it off and back on
|
||||
later reuses the same PIN instead of silently changing it on the user.
|
||||
Independent of pstn_installed() the same way messaging is: voicemail has
|
||||
no PSTN/trunk dependency."""
|
||||
no PSTN/trunk dependency.
|
||||
|
||||
Also wires up (or tears down) MWI via _ea_set_endpoint_mailboxes() — the
|
||||
extension's PJSIP endpoint needs its own `mailboxes=` line for a phone
|
||||
to ever be told about a new voicemail; voicemail.conf alone is only
|
||||
enough for the recording itself, not the notification."""
|
||||
if not ASTERISK_CONFIG_DIR:
|
||||
return False, "No Asterisk install detected on this box"
|
||||
ext = str(ext).strip()
|
||||
@@ -1990,6 +2189,11 @@ def write_voicemail(ext, enabled):
|
||||
return True, "Saved, but voicemail.conf couldn't be regenerated: %s" % err
|
||||
|
||||
ea_reload_voicemail()
|
||||
|
||||
mok, merr = _ea_set_endpoint_mailboxes(ext, enabled)
|
||||
if not mok:
|
||||
return True, "Saved, but couldn't wire up the phone's voicemail notification (MWI): %s" % merr
|
||||
|
||||
return True, "Saved"
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +60,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -176,6 +181,7 @@ EOF
|
||||
prompt_text " WEB_EXT_API_KEY (e.g. user:12345678:901), blank to skip:" "" FF_KEY
|
||||
prompt_text " WEB_EXT_API_SECRET, blank to skip:" "" FF_SECRET
|
||||
if [ -n "$FF_KEY" ] && [ -n "$FF_SECRET" ]; then
|
||||
backup_if_exists "$SS_DIR/.env"
|
||||
cat > "$SS_DIR/.env" << ENVEOF
|
||||
WEB_EXT_API_KEY="$FF_KEY"
|
||||
WEB_EXT_API_SECRET="$FF_SECRET"
|
||||
|
||||
+62
-15
@@ -53,7 +53,15 @@ _sms_detect_container_name() {
|
||||
if [[ "$_ea_dir" == *asterisk-digital-ocean ]]; then
|
||||
echo "easy-asterisk-do"
|
||||
else
|
||||
echo "easy-asterisk"
|
||||
# Read the box's own container_name instead of assuming — new
|
||||
# installs use plain "asterisk" now, but an existing "easy-asterisk"
|
||||
# install (this repo's container name before that rename) keeps
|
||||
# working unchanged until someone deliberately migrates it. See
|
||||
# services/asterisk.sh's _asterisk_resolve_layout for the reasoning.
|
||||
local _name=""
|
||||
[[ -f "$_ea_dir/docker-compose.yml" ]] && \
|
||||
_name="$(grep -m1 '^[[:space:]]*container_name:' "$_ea_dir/docker-compose.yml" | awk '{print $2}')"
|
||||
echo "${_name:-asterisk}"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -629,17 +637,11 @@ CBLOCK
|
||||
|
||||
_sms_write_readme() {
|
||||
local _url="$1" _relay_domain="$2"
|
||||
write_readme "$SMS_APP_DIR" << MD
|
||||
# Inbound SMS → Sipnetic (via AMI)
|
||||
|
||||
Gets SMS sent to one of your PSTN DIDs delivered into Asterisk as a SIP
|
||||
MESSAGE, landing in Sipnetic the same way internal texting already does —
|
||||
not a push notification, a real message in the softphone.
|
||||
|
||||
## The URL to paste into your DID provider
|
||||
|
||||
In the provider portal, open the DID's SMS settings and paste this into the
|
||||
"Forward to URL" field (on Anveo: Phone Numbers → the DID → SMS tab, tick
|
||||
local _url_section
|
||||
if [ -n "$_url" ]; then
|
||||
_url_section="In the provider portal, open the DID's SMS settings and paste this into the
|
||||
\"Forward to URL\" field (on Anveo: Phone Numbers → the DID → SMS tab, tick
|
||||
the checkbox, paste, press SAVE — RETURN discards):
|
||||
|
||||
\`\`\`
|
||||
@@ -652,7 +654,21 @@ query parameters; with the message last, everything after it can be read back
|
||||
verbatim.
|
||||
|
||||
Treat this URL like a password — anyone holding it can trigger a message
|
||||
delivery into your Asterisk.
|
||||
delivery into your Asterisk."
|
||||
else
|
||||
_url_section="**Not set up yet — no public domain was entered.** Re-run \`sudo ./setup.sh sms-inbound\` and choose \"Full reinstall\" once DNS for the webhook's domain points at this box; nothing here works until then."
|
||||
fi
|
||||
|
||||
write_readme "$SMS_APP_DIR" << MD
|
||||
# Inbound SMS → Sipnetic (via AMI)
|
||||
|
||||
Gets SMS sent to one of your PSTN DIDs delivered into Asterisk as a SIP
|
||||
MESSAGE, landing in Sipnetic the same way internal texting already does —
|
||||
not a push notification, a real message in the softphone.
|
||||
|
||||
## The URL to paste into your DID provider
|
||||
|
||||
${_url_section}
|
||||
|
||||
## How delivery is decided
|
||||
|
||||
@@ -770,8 +786,24 @@ install_sms-inbound() {
|
||||
&& log_success "Relay refreshed and restarted." \
|
||||
|| log_warning "Restart failed — check: journalctl -u sms-inbound -n 50"
|
||||
echo ""
|
||||
log_success "Settings, Caddy and firewall rules were left untouched."
|
||||
echo " Provider URL: ${SMS_FORWARD_URL}"
|
||||
# A missing/placeholder domain here means an earlier run was
|
||||
# left with no real webhook URL (RELAY_DOMAIN entered blank,
|
||||
# or DNS wasn't ready yet) — "update" mode never re-prompts
|
||||
# for the domain (by design, same as every other service's
|
||||
# non-destructive update path), so silently repeating that
|
||||
# broken URL forever, looking like nothing is wrong, is worse
|
||||
# than saying so plainly. Confirmed live: this is exactly
|
||||
# what a DID provider like Anveo rejects — "<your-domain>"
|
||||
# isn't a resolvable hostname.
|
||||
if [[ -z "${SMS_RELAY_DOMAIN:-}" || "${SMS_FORWARD_URL:-}" == *"<your-domain>"* ]]; then
|
||||
log_warning "No real webhook domain was ever set for this install — the stored"
|
||||
log_warning "provider URL is a placeholder, not something a DID provider can use."
|
||||
log_warning "Re-run 'sudo ./setup.sh sms-inbound' and choose \"2) Full reinstall\""
|
||||
log_warning "to be asked for the domain again (needs DNS pointed at this box first)."
|
||||
else
|
||||
log_success "Settings, Caddy and firewall rules were left untouched."
|
||||
echo " Provider URL: ${SMS_FORWARD_URL}"
|
||||
fi
|
||||
echo ""
|
||||
return 0
|
||||
;;
|
||||
@@ -898,7 +930,14 @@ install_sms-inbound() {
|
||||
ensure_ufw_enabled
|
||||
fi
|
||||
|
||||
local FORWARD_URL="https://${RELAY_DOMAIN:-<your-domain>}/sms/${RELAY_TOKEN}?from=\$[from]\$&to=\$[to]\$&message=\$[message]\$"
|
||||
# Empty (not a "<your-domain>" placeholder) when no domain was entered —
|
||||
# a placeholder here used to get persisted to settings.env and silently
|
||||
# re-served as-is on every later "update" run (which never re-prompts
|
||||
# for the domain, by design), looking like a valid webhook URL right up
|
||||
# until a DID provider like Anveo rejected it as an unresolvable host.
|
||||
# Confirmed live.
|
||||
local FORWARD_URL=""
|
||||
[ -n "$RELAY_DOMAIN" ] && FORWARD_URL="https://${RELAY_DOMAIN}/sms/${RELAY_TOKEN}?from=\$[from]\$&to=\$[to]\$&message=\$[message]\$"
|
||||
|
||||
# ── Persist settings ──────────────────────────────────────────────────────
|
||||
# Single-quoted values: this file gets `source`d again on the next
|
||||
@@ -929,6 +968,14 @@ ENV
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [ -z "$FORWARD_URL" ]; then
|
||||
log_warning "Inbound SMS relay is running, but nothing can reach it yet — no domain was entered."
|
||||
log_warning "Point an A record at this box, then re-run 'sudo ./setup.sh sms-inbound' and"
|
||||
log_warning "choose \"2) Full reinstall\" to be asked for the domain again and get a real"
|
||||
log_warning "\"Forward to URL\" to paste into your DID provider."
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
log_success "Inbound SMS → Sipnetic configured."
|
||||
echo ""
|
||||
echo " 1. In your DID provider's portal, open the number's SMS settings and"
|
||||
|
||||
@@ -177,6 +177,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -242,6 +247,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << PDF_COMPOSE
|
||||
name: stirling-pdf
|
||||
|
||||
@@ -261,6 +267,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
PDF_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << PDF_ENV
|
||||
# Stirling PDF configuration
|
||||
|
||||
|
||||
@@ -147,6 +147,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -225,6 +230,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << EOF
|
||||
name: syncthing
|
||||
|
||||
@@ -249,6 +255,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
EOF
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ENV
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
PUID=$PUID
|
||||
|
||||
@@ -180,6 +180,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -474,6 +479,7 @@ networks:
|
||||
- \"${PROTO_MIN}-${PROTO_MAX}:${PROTO_MIN}-${PROTO_MAX}/udp\""
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << TRACCAR_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -560,6 +566,7 @@ SMS_HTTP_PASSWORD=$SMS_HTTP_PASSWORD
|
||||
fi
|
||||
fi
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << TRACCAR_ENV
|
||||
TZ=$TZ_VAL
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -89,6 +89,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -238,6 +243,7 @@ networks:
|
||||
fi
|
||||
|
||||
# Unquoted heredoc; ${...} used for caddy_net vars; all Docker Compose vars escaped with \$
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << UNIFI_COMPOSE
|
||||
name: $PROJECT
|
||||
|
||||
@@ -286,6 +292,7 @@ configs:
|
||||
db.getSiblingDB("\${MONGO_DBNAME}_stat").createUser({user: "\${MONGO_USER}", pwd: "\${MONGO_PASS}", roles: [{role: "\${MONGO_ROLE}", db: "\${MONGO_DBNAME}_stat"}]});
|
||||
UNIFI_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << UNIFI_ENV
|
||||
# ── General ───────────────────────────────────────────────────────────────────
|
||||
TZ=$TZ_VAL
|
||||
|
||||
+57
-3
@@ -177,6 +177,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -206,6 +211,9 @@ install_uptimekuma() {
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $UPTIME_DIR"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
echo "[DRY-RUN] If Authelia is installed: would offer to protect Uptime Kuma with it —"
|
||||
echo "[DRY-RUN] sets DISABLE_AUTH=true (Kuma's own login off) only once Caddy's"
|
||||
echo "[DRY-RUN] 'import authelia' gate is actually confirmed in front of it"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -241,6 +249,40 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
# Authelia SSO — decided (and, if accepted, wired into Caddy) before
|
||||
# docker-compose.yml is written, so DISABLE_AUTH only ever gets set once
|
||||
# Caddy's "import authelia" gate is actually confirmed in front of Kuma.
|
||||
# Unlike Frigate/Gitea, Uptime Kuma with DISABLE_AUTH=true has NO
|
||||
# internal check left at all — it's not IP-scoped (Gitea) or secret-
|
||||
# pinned (Frigate), just fully open to whatever reaches its port, so
|
||||
# this is the one place getting the ordering wrong is worst: a login-
|
||||
# disabled Kuma with nothing gating it is wide open to anyone who can
|
||||
# reach the port, not just spoofable.
|
||||
local UPTIME_USE_AUTHELIA="n" UPTIME_ENV_BLOCK="" _uptime_caddy_done=false
|
||||
if [ -d "$DOCKER_DIR/authelia" ]; then
|
||||
echo ""
|
||||
prompt_yn "Protect Uptime Kuma with Authelia SSO (disables Kuma's own login entirely)? (y/n):" "y" UPTIME_USE_AUTHELIA
|
||||
fi
|
||||
|
||||
if [[ "$UPTIME_USE_AUTHELIA" =~ ^[Yy]$ ]]; then
|
||||
configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" " import authelia"
|
||||
if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then
|
||||
UPTIME_ENV_BLOCK=" - DISABLE_AUTH=true"
|
||||
_uptime_caddy_done=true
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "uptimekuma" "$CADDY_SERVICE_DOMAIN"
|
||||
else
|
||||
log_warning "Caddy wasn't configured — leaving Uptime Kuma's own login enabled (nothing else would be gating access)."
|
||||
fi
|
||||
fi
|
||||
|
||||
local UPTIME_ENV_SECTION=""
|
||||
if [ -n "$UPTIME_ENV_BLOCK" ]; then
|
||||
UPTIME_ENV_SECTION=" environment:
|
||||
${UPTIME_ENV_BLOCK}
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << UPTIME_COMPOSE
|
||||
name: uptime-kuma
|
||||
|
||||
@@ -250,7 +292,7 @@ services:
|
||||
container_name: uptime-kuma
|
||||
hostname: uptime-kuma
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
${UPTIME_ENV_SECTION} volumes:
|
||||
- ./data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
ports:
|
||||
@@ -282,6 +324,15 @@ Docker containers.
|
||||
If Caddy is installed, you can expose this via the prompt during install
|
||||
(see configure_caddy_for_service). Default subdomain: uptime.
|
||||
|
||||
## Authelia SSO (optional)
|
||||
If Authelia is installed, the installer offers to protect Uptime Kuma with
|
||||
it instead of Kuma's own login — this sets \`DISABLE_AUTH=true\` (Kuma's own
|
||||
account/login screen goes away entirely) and puts Caddy's \`import authelia\`
|
||||
gate in front instead, so Authelia is the only thing checking who you are.
|
||||
This only gets set once Caddy confirms it's actually fronting the domain —
|
||||
never with nothing else gating access. Re-run \`sudo ./setup.sh uptimekuma\`
|
||||
to add or change this later.
|
||||
|
||||
## Manage
|
||||
\`\`\`
|
||||
cd $UPTIME_DIR
|
||||
@@ -291,8 +342,11 @@ docker compose logs -f # logs
|
||||
\`\`\`
|
||||
MD
|
||||
|
||||
# Configure Caddy reverse proxy before starting
|
||||
configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime"
|
||||
# Configure Caddy reverse proxy before starting (skip if the Authelia
|
||||
# step above already did it)
|
||||
if [ "$_uptime_caddy_done" != true ]; then
|
||||
configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime"
|
||||
fi
|
||||
|
||||
local START_UPTIME=""
|
||||
prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME
|
||||
|
||||
@@ -194,6 +194,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -225,6 +230,22 @@ register_service vaultwarden utilities "Bitwarden-compatible password manager (V
|
||||
# live. Called right before every `docker compose up` this file does, not
|
||||
# just at install time, so it self-heals regardless of how the box got into
|
||||
# this state.
|
||||
# Vaultwarden requires DOMAIN to include an http(s):// scheme — a bare
|
||||
# hostname crash-loops the container. Called at the same two sites as
|
||||
# _vaultwarden_fix_smtp_halfstate() below, so a box whose .env got a
|
||||
# scheme-less DOMAIN written before this fix existed (or hand-edited since)
|
||||
# self-heals on its next start instead of staying stuck forever.
|
||||
_vaultwarden_fix_domain_scheme() {
|
||||
local env_file="$1"
|
||||
[ -f "$env_file" ] || return 0
|
||||
local domain
|
||||
domain="$(grep '^DOMAIN=' "$env_file" 2>/dev/null | cut -d= -f2-)"
|
||||
if [ -n "$domain" ] && [[ "$domain" != http://* && "$domain" != https://* ]]; then
|
||||
log_warning "DOMAIN in $env_file is missing an http(s):// scheme ('$domain') — Vaultwarden requires one to start. Adding https:// automatically."
|
||||
sed -i "s#^DOMAIN=.*#DOMAIN=https://${domain}#" "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
_vaultwarden_fix_smtp_halfstate() {
|
||||
local env_file="$1"
|
||||
[ -f "$env_file" ] || return 0
|
||||
@@ -298,6 +319,7 @@ install_vaultwarden() {
|
||||
case "$MODE" in
|
||||
update)
|
||||
log_info "Refreshing the Vaultwarden image only — existing config, port, and Caddy setup are left as-is."
|
||||
_vaultwarden_fix_domain_scheme "$VW_DIR/.env"
|
||||
_vaultwarden_fix_smtp_halfstate "$VW_DIR/.env"
|
||||
( cd "$VW_DIR" && docker compose pull && docker compose up -d ) \
|
||||
&& log_success "Vaultwarden image refreshed" \
|
||||
@@ -336,6 +358,15 @@ install_vaultwarden() {
|
||||
local DEFAULT_DOMAIN="https://vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}"
|
||||
prompt_text "Vaultwarden public URL (e.g. https://vault.example.com):" "$DEFAULT_DOMAIN" VW_DOMAIN
|
||||
[ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN"
|
||||
# Vaultwarden requires DOMAIN to include a URL scheme — a bare hostname
|
||||
# (typing "vault.example.com" instead of "https://vault.example.com" at
|
||||
# the prompt above, easy to do despite the example text showing the
|
||||
# scheme) crash-loops the container with no clear startup error.
|
||||
# Confirmed live. Normalize rather than trust free-form input.
|
||||
if [[ "$VW_DOMAIN" != http://* && "$VW_DOMAIN" != https://* ]]; then
|
||||
log_warning "No http(s):// scheme on '$VW_DOMAIN' — Vaultwarden requires one. Prefixing with https://."
|
||||
VW_DOMAIN="https://$VW_DOMAIN"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " SMTP (optional) — for password-reset and invite emails."
|
||||
@@ -394,6 +425,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << VW_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -411,6 +443,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
VW_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << VW_ENV
|
||||
# ── General ───────────────────────────────────────────────────────────────────
|
||||
TZ=$TZ_VAL
|
||||
@@ -487,6 +520,7 @@ MD
|
||||
local START_VW=""
|
||||
prompt_yn "Start Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_VW
|
||||
if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then
|
||||
_vaultwarden_fix_domain_scheme "$VW_DIR/.env"
|
||||
_vaultwarden_fix_smtp_halfstate "$VW_DIR/.env"
|
||||
docker compose up -d \
|
||||
&& log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|
||||
|
||||
@@ -64,6 +64,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -151,6 +156,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << WT_COMPOSE
|
||||
name: watchtower
|
||||
|
||||
@@ -179,6 +185,7 @@ ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
WT_COMPOSE
|
||||
|
||||
# Create .env
|
||||
backup_if_exists .env
|
||||
cat > .env << WT_ENV
|
||||
# Watchtower Configuration
|
||||
# =========================
|
||||
|
||||
@@ -68,6 +68,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -126,6 +131,7 @@ install_watchyourlan() {
|
||||
prompt_text "GUI port [8840]:" "8840" GUI_PORT
|
||||
[ -z "$GUI_PORT" ] && GUI_PORT="8840"
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << 'WYL_COMPOSE'
|
||||
name: watchyourlan
|
||||
|
||||
@@ -141,6 +147,7 @@ services:
|
||||
- ./watchyourlan_data:/data
|
||||
WYL_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << WYL_ENV
|
||||
# ── General ───────────────────────────────────────────────────────────────────
|
||||
TZ=$TZ_VAL
|
||||
|
||||
@@ -199,6 +199,11 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -302,6 +307,7 @@ networks:
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << WGEASY_COMPOSE
|
||||
name: wg-easy
|
||||
|
||||
@@ -332,6 +338,7 @@ services:
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
WGEASY_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << WGEASY_ENV
|
||||
WG_HOST=$WG_HOST
|
||||
# Plain-text password — used only if PASSWORD_HASH could not be generated above
|
||||
|
||||
+58
-3
@@ -169,6 +169,24 @@ CBLOCK
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
@@ -201,11 +219,29 @@ install_wolf-pair() {
|
||||
echo " - Build the wolf-pair image (python:3.12-alpine + docker-cli)"
|
||||
echo " - Run the container with network_mode: host (for localhost:47989 access)"
|
||||
echo " - Mount /var/run/docker.sock:ro (for docker logs wolf)"
|
||||
echo " - Open port $WOLFPAIR_PORT in UFW"
|
||||
echo " - Open port $WOLFPAIR_PORT in UFW (auto-scanned for a free host port —"
|
||||
echo " other services, e.g. wordpress/ntfy/beszel, default to 8090 too)"
|
||||
echo " - Optionally configure a Caddy reverse proxy"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# network_mode: host means there's no HOST:CONTAINER ports: mapping to scan
|
||||
# around a collision on — server.py binds 0.0.0.0 directly on the host, so a
|
||||
# taken 8090 (wordpress/ntfy/beszel all default here too) fails at container
|
||||
# start with "address already in use" and nothing in docker-compose.yml to
|
||||
# point at. Scan once and persist in .env; on a rerun, keep the port already
|
||||
# in use rather than silently moving it out from under an existing Caddy
|
||||
# site block / bookmarked URL.
|
||||
if [ -f "$WOLFPAIR_DIR/.env" ]; then
|
||||
local _existing_port
|
||||
_existing_port="$(grep '^WOLFPAIR_PORT=' "$WOLFPAIR_DIR/.env" 2>/dev/null | cut -d= -f2-)"
|
||||
[ -n "$_existing_port" ] && WOLFPAIR_PORT="$_existing_port"
|
||||
else
|
||||
find_free_port WOLFPAIR_PORT "$WOLFPAIR_PORT"
|
||||
fi
|
||||
[ "$WOLFPAIR_PORT" != "8090" ] && \
|
||||
log_info "Port 8090 already in use — wolf-pair will use $WOLFPAIR_PORT instead."
|
||||
|
||||
mkdir -p "$WOLFPAIR_DIR"
|
||||
ensure_docker_dir_ownership "$WOLFPAIR_DIR"
|
||||
cd "$WOLFPAIR_DIR" || return 1
|
||||
@@ -229,10 +265,11 @@ submitted — otherwise the user resubmits a dead secret and Wolf returns
|
||||
"key not found". We track submitted secrets and fall back to the waiting page
|
||||
until Moonlight initiates a brand-new pairing (which mints a new secret).
|
||||
"""
|
||||
import json, subprocess, re, urllib.request, urllib.error
|
||||
import json, os, subprocess, re, urllib.request, urllib.error
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
WOLF_HTTP = "http://localhost:47989"
|
||||
LISTEN_PORT = int(os.environ.get("WOLFPAIR_PORT", "8090"))
|
||||
|
||||
# Secrets already submitted to Wolf. Wolf erases a secret on first submit, so a
|
||||
# secret in here is dead — show the waiting page instead of re-offering it.
|
||||
@@ -385,7 +422,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
if __name__ == '__main__':
|
||||
HTTPServer.allow_reuse_address = True
|
||||
HTTPServer(('0.0.0.0', 8090), Handler).serve_forever()
|
||||
HTTPServer(('0.0.0.0', LISTEN_PORT), Handler).serve_forever()
|
||||
PYEOF
|
||||
log_success "server.py written"
|
||||
|
||||
@@ -404,6 +441,7 @@ DOCKERFILE
|
||||
# network_mode: host — server.py reaches Wolf at localhost:47989 directly.
|
||||
# Docker socket (ro) — server.py calls `docker logs wolf` to read secrets.
|
||||
log_info "Writing docker-compose.yml..."
|
||||
backup_if_exists "$WOLFPAIR_DIR/docker-compose.yml"
|
||||
cat > "$WOLFPAIR_DIR/docker-compose.yml" << 'COMPOSE'
|
||||
name: wolf-pair
|
||||
|
||||
@@ -414,12 +452,27 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: wolf-pair
|
||||
network_mode: host
|
||||
environment:
|
||||
- WOLFPAIR_PORT=${WOLFPAIR_PORT:-8090}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
restart: unless-stopped
|
||||
COMPOSE
|
||||
log_success "docker-compose.yml written"
|
||||
|
||||
# host networking means server.py binds this port directly — .env feeds it
|
||||
# to the container's WOLFPAIR_PORT (above) via docker compose's own .env
|
||||
# auto-load, same pattern as WOLF_STATE_DIR in services/wolf.sh.
|
||||
backup_if_exists "$WOLFPAIR_DIR/.env"
|
||||
cat > "$WOLFPAIR_DIR/.env" << EOF
|
||||
# Port wolf-pair's pairing UI listens on (host networking — no port mapping
|
||||
# to edit). Auto-scanned at install time to avoid clashing with other
|
||||
# services that also default to 8090 (wordpress, ntfy, beszel).
|
||||
WOLFPAIR_PORT=${WOLFPAIR_PORT}
|
||||
EOF
|
||||
chmod 600 "$WOLFPAIR_DIR/.env"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR/.env"
|
||||
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR"
|
||||
|
||||
# ── 4. Caddy (optional) ───────────────────────────────────────────────────
|
||||
@@ -485,6 +538,8 @@ docker compose logs -f # follow logs
|
||||
- If you set up a Caddy subdomain (e.g. `wolf-pair.yourdomain.com`), that
|
||||
subdomain is for the PIN form only.
|
||||
MD
|
||||
[ "$WOLFPAIR_PORT" != "8090" ] && \
|
||||
sed -i "s/localhost:8090/localhost:${WOLFPAIR_PORT}/g" "$WOLFPAIR_DIR/README.md"
|
||||
|
||||
# ── 7. Build & start ──────────────────────────────────────────────────────
|
||||
echo ""
|
||||
|
||||
+2928
-100
File diff suppressed because it is too large
Load Diff
@@ -207,6 +207,11 @@ CBLOCK
|
||||
cat > "$_dir/README.md"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
@@ -361,6 +366,7 @@ PHPINI
|
||||
"
|
||||
fi
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << WPCOMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
@@ -398,6 +404,7 @@ networks:
|
||||
${_CADDY_NET_SECTION}
|
||||
WPCOMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << WPENV
|
||||
TZ=$TZ_VAL
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
@@ -26,6 +26,31 @@ set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ── Self-install a PATH wrapper, so "cd into the repo every time" stops
|
||||
# being necessary after the first run ────────────────────────────────────
|
||||
# ${BASH_SOURCE[0]}-based HERE above means a plain symlink into
|
||||
# /usr/local/bin wouldn't resolve correctly (bash doesn't follow symlinks
|
||||
# for BASH_SOURCE, so a symlinked invocation would set HERE to the
|
||||
# symlink's own directory, not this repo's) — a thin wrapper that execs
|
||||
# THIS checkout's setup.sh by its real, already-resolved path sidesteps
|
||||
# that entirely. Runs on every invocation (bare, --list/--status, or a
|
||||
# service name) but is idempotent and silent unless something actually
|
||||
# needs writing, so it doesn't add noise to a normal run. Root-only: a
|
||||
# non-root invocation (e.g. --list) can't write to /usr/local/bin anyway,
|
||||
# and skipping silently beats a permission-denied on every read-only
|
||||
# command.
|
||||
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
|
||||
_CLI_WRAPPER="/usr/local/bin/post-install"
|
||||
_WANT_WRAPPER="#!/bin/bash
|
||||
exec \"${HERE}/setup.sh\" \"\$@\""
|
||||
if [ "$(cat "$_CLI_WRAPPER" 2>/dev/null)" != "$_WANT_WRAPPER" ]; then
|
||||
if printf '%s\n' "$_WANT_WRAPPER" > "$_CLI_WRAPPER" 2>/dev/null && chmod +x "$_CLI_WRAPPER" 2>/dev/null; then
|
||||
echo "[INFO] Installed 'post-install' — run it from anywhere from now on (e.g. post-install asterisk)."
|
||||
fi
|
||||
fi
|
||||
unset _CLI_WRAPPER _WANT_WRAPPER
|
||||
fi
|
||||
|
||||
# whiptail requires a valid TERM; when piped through bash (curl | bash) TERM
|
||||
# may be unset, causing raw-mode to fail and arrow keys to leak to the shell.
|
||||
export TERM="${TERM:-xterm-256color}"
|
||||
@@ -96,10 +121,12 @@ is_installed() {
|
||||
base) command -v ncdu >/dev/null 2>&1 ;;
|
||||
glow) command -v glow >/dev/null 2>&1 ;;
|
||||
crowdsec) command -v cscli >/dev/null 2>&1 ;;
|
||||
samba) command -v smbd >/dev/null 2>&1 ;;
|
||||
security-dashboard) [ -f /opt/security-dashboard/app.py ] ;;
|
||||
kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
|
||||
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
|
||||
sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;;
|
||||
claude-cli) [ -f "$ACTUAL_HOME/.claude-shared/CLAUDE.md" ] ;;
|
||||
sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;;
|
||||
sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;;
|
||||
# Either directory counts: boxes set up before the droplet edition was
|
||||
@@ -130,7 +157,7 @@ is_installed() {
|
||||
# is_installed() as 0 or 1.
|
||||
install_count() {
|
||||
case "$1" in
|
||||
base|glow|crowdsec|security-dashboard|kdeconnect|silent-send|sync-cc|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config|ssh-key-import)
|
||||
base|glow|crowdsec|samba|security-dashboard|kdeconnect|silent-send|sync-cc|claude-cli|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config|ssh-key-import)
|
||||
is_installed "$1" && echo 1 || echo 0 ;;
|
||||
wordpress)
|
||||
find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'wordpress-*' -type d 2>/dev/null | wc -l ;;
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tools/anki-deck-math.py — Generate math-fact Anki decks (.apkg) with a
|
||||
vertical/stacked problem layout, Anki's built-in type-the-answer input, and
|
||||
Piper (offline, local neural TTS) audio on both the question and answer
|
||||
side of every card.
|
||||
|
||||
Standalone content-generation tool, unrelated to this repo's services/*.sh
|
||||
installers — run it on any machine with Python (your desktop, laptop, or
|
||||
the same box running services/anki-sync-server.sh), then import the
|
||||
resulting .apkg into Anki (File -> Import) or push it into a sync-server
|
||||
account with AnkiConnect's importPackage action. See services/anki-sync-server.sh
|
||||
and services/anki-progress.sh for the actual self-hosted sync backend and
|
||||
progress dashboard this content is meant to be studied through.
|
||||
|
||||
Decks:
|
||||
multiplication 1-12, all 144 ordered pairs (a x b), shuffled
|
||||
(not sequential — see the note near
|
||||
random.shuffle(pairs) below for why)
|
||||
division inverse of the multiplication deck (144 facts)
|
||||
addsub --lo L --hi H addition + subtraction fact family for [L, H]
|
||||
(subtraction facts derived from the addition
|
||||
facts, e.g. 7+3=10 also gives 10-7=3 and
|
||||
10-3=7 — never negative results)
|
||||
fractions reducing fractions to lowest terms (denominators 2-12)
|
||||
decimals fraction -> decimal conversion (only denominators
|
||||
whose decimal expansion terminates: 2,4,5,8,10,20,25)
|
||||
|
||||
Setup (one time):
|
||||
python3 -m venv ~/anki-deck-venv
|
||||
source ~/anki-deck-venv/bin/activate
|
||||
pip install genanki piper-tts
|
||||
|
||||
# Download at least one voice (one time per voice you want to try —
|
||||
# download_voices saves into the CURRENT directory by default, so cd
|
||||
# somewhere sensible first, e.g. your home directory):
|
||||
python3 -m piper.download_voices en_US-lessac-medium
|
||||
# Other options: en_US-amy-medium (warm/friendly), en_US-ryan-high
|
||||
# (best-quality US male), en_US-libritts_r-medium (multi-speaker),
|
||||
# en_GB-alba-medium / en_GB-cori-high (British accent). Tiers are
|
||||
# low < medium < high — higher sounds more natural but is bigger/slower.
|
||||
|
||||
# Sanity-check the voice before generating a full deck's worth of clips:
|
||||
echo "three times seven" | python3 -m piper -m en_US-lessac-medium.onnx -f /tmp/test.wav
|
||||
# play /tmp/test.wav and confirm it sounds right first.
|
||||
|
||||
Usage (run with the venv activated):
|
||||
python3 anki-deck-math.py --deck multiplication
|
||||
python3 anki-deck-math.py --deck division
|
||||
python3 anki-deck-math.py --deck addsub --lo 3 --hi 7
|
||||
python3 anki-deck-math.py --deck addsub --lo 3 --hi 13
|
||||
python3 anki-deck-math.py --deck addsub --lo 2 --hi 21
|
||||
python3 anki-deck-math.py --deck fractions
|
||||
python3 anki-deck-math.py --deck decimals
|
||||
(add --voice en_US-amy-medium etc. to any of the above to use a voice other
|
||||
than the default en_US-lessac-medium; --model-path to point at a voice
|
||||
file directly if it's not found in any of the usual places checked
|
||||
automatically; --dry-run-tts to test the deck-building logic itself
|
||||
without Piper or any voice model at all, using silent placeholder audio)
|
||||
|
||||
The addsub --hi 21 deck generates ~1600 audio clips and will take noticeably
|
||||
longer than the others — consider `nohup python3 anki-deck-math.py --deck
|
||||
addsub --lo 2 --hi 21 > addsub.log 2>&1 &` if you don't want to wait on it.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import genanki
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--deck", required=True,
|
||||
choices=["multiplication", "division", "addsub", "fractions", "decimals"])
|
||||
parser.add_argument("--lo", type=int, default=None, help="addsub only: low end of range")
|
||||
parser.add_argument("--hi", type=int, default=None, help="addsub only: high end of range")
|
||||
parser.add_argument("--voice", default="en_US-lessac-medium")
|
||||
parser.add_argument("--model-path", default=None)
|
||||
parser.add_argument("--dry-run-tts", action="store_true",
|
||||
help="Skip Piper entirely and write silent placeholder audio instead"
|
||||
" (for testing the deck-building logic without a voice model).")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.deck == "addsub":
|
||||
if args.lo is None or args.hi is None:
|
||||
raise SystemExit("--deck addsub requires --lo and --hi, e.g. --lo 3 --hi 7")
|
||||
if args.lo >= args.hi:
|
||||
raise SystemExit("--lo must be less than --hi")
|
||||
|
||||
SCRATCH = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# ─── Voice resolution (same search order as the multiplication script) ──────
|
||||
_CANDIDATES = [
|
||||
args.model_path,
|
||||
f"{args.voice}.onnx",
|
||||
os.path.join(SCRATCH, f"{args.voice}.onnx"),
|
||||
os.path.expanduser(f"~/{args.voice}.onnx"),
|
||||
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
|
||||
]
|
||||
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
|
||||
|
||||
if VOICE_MODEL is None and not args.dry_run_tts:
|
||||
raise SystemExit(
|
||||
f"Voice model for '{args.voice}' not found. Checked:\n"
|
||||
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
|
||||
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
|
||||
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
|
||||
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
|
||||
)
|
||||
|
||||
|
||||
def piper_tts(text: str, out_path: str) -> None:
|
||||
if args.dry_run_tts:
|
||||
# 44-byte minimal valid WAV header, zero samples — enough for genanki
|
||||
# to accept it as a real media file without needing Piper installed.
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(
|
||||
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
|
||||
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
|
||||
)
|
||||
return
|
||||
subprocess.run(
|
||||
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
|
||||
input=text.encode("utf-8"),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
# ─── Number -> words ─────────────────────────────────────────────────────────
|
||||
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
||||
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
||||
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
|
||||
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
|
||||
"eighty", "ninety"]
|
||||
|
||||
|
||||
def num2words(n):
|
||||
if n < 0:
|
||||
return "negative " + num2words(-n)
|
||||
if n < 20:
|
||||
return ONES[n]
|
||||
if n < 100:
|
||||
t, o = divmod(n, 10)
|
||||
return TENS[t] + ("-" + ONES[o] if o else "")
|
||||
h, rem = divmod(n, 100)
|
||||
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
|
||||
|
||||
|
||||
_NUM_CHECKS = {0: "zero", 9: "nine", 10: "ten", 13: "thirteen", 20: "twenty",
|
||||
21: "twenty-one", 45: "forty-five", 99: "ninety-nine",
|
||||
100: "one hundred", 110: "one hundred ten",
|
||||
121: "one hundred twenty-one", 144: "one hundred forty-four",
|
||||
441: "four hundred forty-one"}
|
||||
for _n, _w in _NUM_CHECKS.items():
|
||||
assert num2words(_n) == _w, f"num2words({_n}) = {num2words(_n)!r}, expected {_w!r}"
|
||||
|
||||
# Ordinal words, singular form, denominators 2-21 (covers every deck below).
|
||||
# Irregular forms (half, third, fifth, eighth, ninth, twelfth) are real
|
||||
# English irregularities, not a suffix rule, so this is a lookup table, not
|
||||
# a formula — a formula would get exactly these wrong.
|
||||
ORDINAL_SINGULAR = {
|
||||
2: "half", 3: "third", 4: "fourth", 5: "fifth", 6: "sixth",
|
||||
7: "seventh", 8: "eighth", 9: "ninth", 10: "tenth", 11: "eleventh",
|
||||
12: "twelfth", 13: "thirteenth", 14: "fourteenth", 15: "fifteenth",
|
||||
16: "sixteenth", 17: "seventeenth", 18: "eighteenth", 19: "nineteenth",
|
||||
20: "twentieth", 21: "twenty-first", 25: "twenty-fifth", 50: "fiftieth",
|
||||
100: "hundredth",
|
||||
}
|
||||
|
||||
|
||||
def ordinal_plural(n):
|
||||
s = ORDINAL_SINGULAR[n]
|
||||
return "halves" if s == "half" else s + "s"
|
||||
|
||||
|
||||
def fraction_words(num, den):
|
||||
"""'three fourths', 'one half', 'seven tenths'."""
|
||||
ord_word = ORDINAL_SINGULAR[den] if num == 1 else ordinal_plural(den)
|
||||
return f"{num2words(num)} {ord_word}"
|
||||
|
||||
|
||||
_FRAC_CHECKS = {
|
||||
(1, 2): "one half", (3, 4): "three fourths", (1, 4): "one fourth",
|
||||
(7, 10): "seven tenths", (1, 3): "one third", (2, 3): "two thirds",
|
||||
(5, 8): "five eighths", (1, 8): "one eighth",
|
||||
}
|
||||
for (_n, _d), _w in _FRAC_CHECKS.items():
|
||||
assert fraction_words(_n, _d) == _w, f"fraction_words({_n},{_d}) = {fraction_words(_n, _d)!r}, expected {_w!r}"
|
||||
|
||||
|
||||
def decimal_words(decimal_str):
|
||||
"""'0.25' -> 'zero point two five' (each digit spoken individually,
|
||||
avoids any ambiguity between e.g. 'point two five' vs 'twenty-five
|
||||
hundredths')."""
|
||||
whole, frac = decimal_str.split(".")
|
||||
digit_words = " ".join(ONES[int(d)] for d in frac)
|
||||
return f"{num2words(int(whole))} point {digit_words}"
|
||||
|
||||
|
||||
assert decimal_words("0.25") == "zero point two five"
|
||||
assert decimal_words("0.5") == "zero point five"
|
||||
assert decimal_words("0.375") == "zero point three seven five"
|
||||
|
||||
|
||||
# ─── Shared genanki model builder ────────────────────────────────────────────
|
||||
# Every deck here renders as two stacked lines with a line under them (same
|
||||
# visual language as the original multiplication deck): TOP over BOTTOM,
|
||||
# with an optional prefix (operator) on the bottom line. Fractions/decimals
|
||||
# reuse the exact same layout as numerator-over-denominator.
|
||||
def build_model(deck_key):
|
||||
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
||||
model_id = 1_600_000_000 + (voice_hash % 90_000_000)
|
||||
return model_id, genanki.Model(
|
||||
model_id,
|
||||
f"Math Fact ({deck_key}, {args.voice})",
|
||||
fields=[{"name": "Top"}, {"name": "Bottom"}, {"name": "Answer"},
|
||||
{"name": "QSound"}, {"name": "ASound"}],
|
||||
templates=[{
|
||||
"name": "Card",
|
||||
"qfmt": """
|
||||
<div class="problem">
|
||||
<div class="line1">{{Top}}</div>
|
||||
<div class="line2">{{Bottom}}</div>
|
||||
<div class="rule"></div>
|
||||
</div>
|
||||
{{QSound}}
|
||||
{{type:Answer}}
|
||||
""",
|
||||
"afmt": """
|
||||
<div class="problem">
|
||||
<div class="line1">{{Top}}</div>
|
||||
<div class="line2">{{Bottom}}</div>
|
||||
<div class="rule"></div>
|
||||
</div>
|
||||
<hr id="answer">
|
||||
{{type:Answer}}
|
||||
{{ASound}}
|
||||
""",
|
||||
}],
|
||||
css="""
|
||||
.card { font-family: Arial, sans-serif; font-size: 28px; text-align: center; }
|
||||
.problem { display: inline-block; text-align: right; margin: 20px auto; }
|
||||
.line1, .line2 { font-size: 48px; padding: 2px 10px; }
|
||||
.rule { border-top: 3px solid black; margin-top: 4px; width: 100%; }
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def build_deck(deck_key, deck_title):
|
||||
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
||||
deck_id = 2_000_000_000 + (voice_hash % 90_000_000)
|
||||
return genanki.Deck(deck_id, deck_title)
|
||||
|
||||
|
||||
def add_note(deck, model, top, bottom, answer, qtext, atext, media_files, tag):
|
||||
qfile = f"q_{tag}.wav"
|
||||
afile = f"a_{tag}.wav"
|
||||
qpath = os.path.join(MEDIA_DIR, qfile)
|
||||
apath = os.path.join(MEDIA_DIR, afile)
|
||||
piper_tts(qtext, qpath)
|
||||
piper_tts(atext, apath)
|
||||
media_files += [qpath, apath]
|
||||
deck.add_note(genanki.Note(
|
||||
model=model,
|
||||
fields=[top, bottom, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
|
||||
))
|
||||
|
||||
|
||||
# ─── Per-deck generators ─────────────────────────────────────────────────────
|
||||
def gen_multiplication():
|
||||
deck_key = "multiplication"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Multiplication Facts (1-12)")
|
||||
media_files = []
|
||||
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
|
||||
random.seed(42)
|
||||
random.shuffle(pairs)
|
||||
for a, b in pairs:
|
||||
ans = a * b
|
||||
add_note(deck, model, str(a), f"× {b}", str(ans),
|
||||
f"{num2words(a)} times {num2words(b)}", num2words(ans),
|
||||
media_files, f"mul_{a}_{b}")
|
||||
return deck, media_files, len(pairs)
|
||||
|
||||
|
||||
def gen_division():
|
||||
deck_key = "division"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Division Facts (inverse of 1-12 times tables)")
|
||||
media_files = []
|
||||
# Same (a, b) pairs as multiplication: product / a = b. This is the
|
||||
# direct inverse of every multiplication card in that deck.
|
||||
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
|
||||
random.seed(43)
|
||||
random.shuffle(pairs)
|
||||
for a, b in pairs:
|
||||
product = a * b
|
||||
add_note(deck, model, str(product), f"÷ {a}", str(b),
|
||||
f"{num2words(product)} divided by {num2words(a)}", num2words(b),
|
||||
media_files, f"div_{a}_{b}")
|
||||
return deck, media_files, len(pairs)
|
||||
|
||||
|
||||
def gen_addsub(lo, hi):
|
||||
deck_key = f"addsub_{lo}_{hi}"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, f"Addition & Subtraction Facts ({lo}-{hi})")
|
||||
media_files = []
|
||||
|
||||
add_pairs = [(a, b) for a in range(lo, hi + 1) for b in range(lo, hi + 1)]
|
||||
random.seed(hash((lo, hi)) & 0xFFFFFFFF)
|
||||
random.shuffle(add_pairs)
|
||||
|
||||
sub_facts = [] # (minuend, subtrahend, answer)
|
||||
seen = set()
|
||||
for a, b in add_pairs:
|
||||
c = a + b
|
||||
for minuend, subtrahend, answer in ((c, a, b), (c, b, a)):
|
||||
key = (minuend, subtrahend)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
sub_facts.append((minuend, subtrahend, answer))
|
||||
random.shuffle(sub_facts)
|
||||
|
||||
count = 0
|
||||
for a, b in add_pairs:
|
||||
ans = a + b
|
||||
add_note(deck, model, str(a), f"+ {b}", str(ans),
|
||||
f"{num2words(a)} plus {num2words(b)}", num2words(ans),
|
||||
media_files, f"add_{lo}_{hi}_{a}_{b}")
|
||||
count += 1
|
||||
for minuend, subtrahend, answer in sub_facts:
|
||||
add_note(deck, model, str(minuend), f"− {subtrahend}", str(answer),
|
||||
f"{num2words(minuend)} minus {num2words(subtrahend)}", num2words(answer),
|
||||
media_files, f"sub_{lo}_{hi}_{minuend}_{subtrahend}")
|
||||
count += 1
|
||||
return deck, media_files, count
|
||||
|
||||
|
||||
def gen_fractions():
|
||||
deck_key = "fractions"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Reducing Fractions to Lowest Terms")
|
||||
media_files = []
|
||||
|
||||
facts = []
|
||||
for den in range(2, 13):
|
||||
for num in range(1, den):
|
||||
g = math.gcd(num, den)
|
||||
if g > 1:
|
||||
facts.append((num, den, num // g, den // g))
|
||||
random.seed(44)
|
||||
random.shuffle(facts)
|
||||
|
||||
for num, den, rnum, rden in facts:
|
||||
answer = f"{rnum}/{rden}"
|
||||
add_note(deck, model, str(num), f"⁄ {den}", answer,
|
||||
fraction_words(num, den), fraction_words(rnum, rden),
|
||||
media_files, f"frac_{num}_{den}")
|
||||
return deck, media_files, len(facts)
|
||||
|
||||
|
||||
def gen_decimals():
|
||||
deck_key = "decimals"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Fraction to Decimal Conversion")
|
||||
media_files = []
|
||||
|
||||
# Only denominators whose only prime factors are 2 and 5 terminate in a
|
||||
# finite decimal (1/3 = 0.333... never terminates) — restricting to
|
||||
# these avoids ever needing to round/repeat.
|
||||
facts = []
|
||||
for den in (2, 4, 5, 8, 10, 20, 25):
|
||||
for num in range(1, den):
|
||||
if math.gcd(num, den) != 1:
|
||||
continue # skip non-lowest-terms fractions (already covered by the fractions deck)
|
||||
value = num / den
|
||||
decimal_str = f"{value:.10f}".rstrip("0")
|
||||
if decimal_str.endswith("."):
|
||||
decimal_str += "0"
|
||||
facts.append((num, den, decimal_str))
|
||||
random.seed(45)
|
||||
random.shuffle(facts)
|
||||
|
||||
for num, den, decimal_str in facts:
|
||||
add_note(deck, model, str(num), f"⁄ {den}", decimal_str,
|
||||
fraction_words(num, den), decimal_words(decimal_str),
|
||||
media_files, f"dec_{num}_{den}")
|
||||
return deck, media_files, len(facts)
|
||||
|
||||
|
||||
# ─── Dispatch ─────────────────────────────────────────────────────────────────
|
||||
if args.deck == "addsub":
|
||||
deck_key = f"addsub_{args.lo}_{args.hi}"
|
||||
else:
|
||||
deck_key = args.deck
|
||||
|
||||
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
|
||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
|
||||
GENERATORS = {
|
||||
"multiplication": lambda: gen_multiplication(),
|
||||
"division": lambda: gen_division(),
|
||||
"addsub": lambda: gen_addsub(args.lo, args.hi),
|
||||
"fractions": lambda: gen_fractions(),
|
||||
"decimals": lambda: gen_decimals(),
|
||||
}
|
||||
|
||||
deck, media_files, count = GENERATORS[args.deck]()
|
||||
if count == 0:
|
||||
raise SystemExit(f"No cards generated for --deck {args.deck} — check the range/args.")
|
||||
|
||||
package = genanki.Package(deck)
|
||||
package.media_files = media_files
|
||||
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
|
||||
package.write_to_file(out_path)
|
||||
|
||||
size_mb = os.path.getsize(out_path) / (1024 * 1024)
|
||||
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")
|
||||
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tools/anki-deck-periodic.py — Generate periodic table Anki decks (.apkg)
|
||||
with Anki's built-in type-the-answer input and Piper (offline, local
|
||||
neural TTS) audio on both sides. See tools/anki-deck-math.py's docstring
|
||||
for one-time setup (venv, genanki + piper-tts, downloading a voice) — same
|
||||
steps apply here, this is a standalone, self-contained script otherwise.
|
||||
|
||||
Decks:
|
||||
prehs symbol<->name, elements 1-36 (H through Kr)
|
||||
hs symbol<->name plus number->symbol, all 118 elements
|
||||
category element category as multiple choice (A/B/C/D shown as
|
||||
plain text options — not a clickable UI, since that needs
|
||||
a desktop-only Anki add-on and would break on
|
||||
AnkiDroid/AnkiMobile), type the letter — only elements
|
||||
with a confirmed category (excludes 8 very recent
|
||||
superheavy elements whose category is still officially
|
||||
unconfirmed)
|
||||
|
||||
Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively
|
||||
maintained public dataset), fetched and spot-checked against known facts
|
||||
before being embedded below — not typed from memory.
|
||||
|
||||
Usage (run with the venv from anki-deck-math.py's docstring activated):
|
||||
python3 anki-deck-periodic.py --deck prehs
|
||||
python3 anki-deck-periodic.py --deck hs
|
||||
python3 anki-deck-periodic.py --deck category
|
||||
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
|
||||
automatically; --dry-run-tts to test the deck-building logic without any
|
||||
voice model at all, using silent placeholder audio)
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import genanki
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"])
|
||||
parser.add_argument("--voice", default="en_US-lessac-medium")
|
||||
parser.add_argument("--model-path", default=None)
|
||||
parser.add_argument("--dry-run-tts", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
SCRATCH = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
_CANDIDATES = [
|
||||
args.model_path,
|
||||
f"{args.voice}.onnx",
|
||||
os.path.join(SCRATCH, f"{args.voice}.onnx"),
|
||||
os.path.expanduser(f"~/{args.voice}.onnx"),
|
||||
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
|
||||
]
|
||||
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
|
||||
|
||||
if VOICE_MODEL is None and not args.dry_run_tts:
|
||||
raise SystemExit(
|
||||
f"Voice model for '{args.voice}' not found. Checked:\n"
|
||||
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
|
||||
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
|
||||
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
|
||||
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
|
||||
)
|
||||
|
||||
|
||||
def piper_tts(text: str, out_path: str) -> None:
|
||||
if args.dry_run_tts:
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(
|
||||
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
|
||||
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
|
||||
)
|
||||
return
|
||||
subprocess.run(
|
||||
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
|
||||
input=text.encode("utf-8"),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
||||
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
||||
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
|
||||
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
|
||||
"eighty", "ninety"]
|
||||
|
||||
|
||||
def num2words(n):
|
||||
if n < 20:
|
||||
return ONES[n]
|
||||
if n < 100:
|
||||
t, o = divmod(n, 10)
|
||||
return TENS[t] + ("-" + ONES[o] if o else "")
|
||||
h, rem = divmod(n, 100)
|
||||
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
|
||||
|
||||
|
||||
assert num2words(1) == "one"
|
||||
assert num2words(26) == "twenty-six"
|
||||
assert num2words(118) == "one hundred eighteen"
|
||||
|
||||
# ─── Element data: (atomic_number, symbol, name, category-or-None) ─────────
|
||||
# category is None for the 8 most recently synthesized superheavy elements
|
||||
# whose chemical category is still officially unconfirmed (excluded from
|
||||
# the category deck below, still included in prehs/hs symbol/name/number).
|
||||
ELEMENTS = [
|
||||
(1, 'H', 'Hydrogen', 'diatomic nonmetal'),
|
||||
(2, 'He', 'Helium', 'noble gas'),
|
||||
(3, 'Li', 'Lithium', 'alkali metal'),
|
||||
(4, 'Be', 'Beryllium', 'alkaline earth metal'),
|
||||
(5, 'B', 'Boron', 'metalloid'),
|
||||
(6, 'C', 'Carbon', 'polyatomic nonmetal'),
|
||||
(7, 'N', 'Nitrogen', 'diatomic nonmetal'),
|
||||
(8, 'O', 'Oxygen', 'diatomic nonmetal'),
|
||||
(9, 'F', 'Fluorine', 'diatomic nonmetal'),
|
||||
(10, 'Ne', 'Neon', 'noble gas'),
|
||||
(11, 'Na', 'Sodium', 'alkali metal'),
|
||||
(12, 'Mg', 'Magnesium', 'alkaline earth metal'),
|
||||
(13, 'Al', 'Aluminium', 'post-transition metal'),
|
||||
(14, 'Si', 'Silicon', 'metalloid'),
|
||||
(15, 'P', 'Phosphorus', 'polyatomic nonmetal'),
|
||||
(16, 'S', 'Sulfur', 'polyatomic nonmetal'),
|
||||
(17, 'Cl', 'Chlorine', 'diatomic nonmetal'),
|
||||
(18, 'Ar', 'Argon', 'noble gas'),
|
||||
(19, 'K', 'Potassium', 'alkali metal'),
|
||||
(20, 'Ca', 'Calcium', 'alkaline earth metal'),
|
||||
(21, 'Sc', 'Scandium', 'transition metal'),
|
||||
(22, 'Ti', 'Titanium', 'transition metal'),
|
||||
(23, 'V', 'Vanadium', 'transition metal'),
|
||||
(24, 'Cr', 'Chromium', 'transition metal'),
|
||||
(25, 'Mn', 'Manganese', 'transition metal'),
|
||||
(26, 'Fe', 'Iron', 'transition metal'),
|
||||
(27, 'Co', 'Cobalt', 'transition metal'),
|
||||
(28, 'Ni', 'Nickel', 'transition metal'),
|
||||
(29, 'Cu', 'Copper', 'transition metal'),
|
||||
(30, 'Zn', 'Zinc', 'transition metal'),
|
||||
(31, 'Ga', 'Gallium', 'post-transition metal'),
|
||||
(32, 'Ge', 'Germanium', 'metalloid'),
|
||||
(33, 'As', 'Arsenic', 'metalloid'),
|
||||
(34, 'Se', 'Selenium', 'polyatomic nonmetal'),
|
||||
(35, 'Br', 'Bromine', 'diatomic nonmetal'),
|
||||
(36, 'Kr', 'Krypton', 'noble gas'),
|
||||
(37, 'Rb', 'Rubidium', 'alkali metal'),
|
||||
(38, 'Sr', 'Strontium', 'alkaline earth metal'),
|
||||
(39, 'Y', 'Yttrium', 'transition metal'),
|
||||
(40, 'Zr', 'Zirconium', 'transition metal'),
|
||||
(41, 'Nb', 'Niobium', 'transition metal'),
|
||||
(42, 'Mo', 'Molybdenum', 'transition metal'),
|
||||
(43, 'Tc', 'Technetium', 'transition metal'),
|
||||
(44, 'Ru', 'Ruthenium', 'transition metal'),
|
||||
(45, 'Rh', 'Rhodium', 'transition metal'),
|
||||
(46, 'Pd', 'Palladium', 'transition metal'),
|
||||
(47, 'Ag', 'Silver', 'transition metal'),
|
||||
(48, 'Cd', 'Cadmium', 'transition metal'),
|
||||
(49, 'In', 'Indium', 'post-transition metal'),
|
||||
(50, 'Sn', 'Tin', 'post-transition metal'),
|
||||
(51, 'Sb', 'Antimony', 'metalloid'),
|
||||
(52, 'Te', 'Tellurium', 'metalloid'),
|
||||
(53, 'I', 'Iodine', 'diatomic nonmetal'),
|
||||
(54, 'Xe', 'Xenon', 'noble gas'),
|
||||
(55, 'Cs', 'Cesium', 'alkali metal'),
|
||||
(56, 'Ba', 'Barium', 'alkaline earth metal'),
|
||||
(57, 'La', 'Lanthanum', 'lanthanide'),
|
||||
(58, 'Ce', 'Cerium', 'lanthanide'),
|
||||
(59, 'Pr', 'Praseodymium', 'lanthanide'),
|
||||
(60, 'Nd', 'Neodymium', 'lanthanide'),
|
||||
(61, 'Pm', 'Promethium', 'lanthanide'),
|
||||
(62, 'Sm', 'Samarium', 'lanthanide'),
|
||||
(63, 'Eu', 'Europium', 'lanthanide'),
|
||||
(64, 'Gd', 'Gadolinium', 'lanthanide'),
|
||||
(65, 'Tb', 'Terbium', 'lanthanide'),
|
||||
(66, 'Dy', 'Dysprosium', 'lanthanide'),
|
||||
(67, 'Ho', 'Holmium', 'lanthanide'),
|
||||
(68, 'Er', 'Erbium', 'lanthanide'),
|
||||
(69, 'Tm', 'Thulium', 'lanthanide'),
|
||||
(70, 'Yb', 'Ytterbium', 'lanthanide'),
|
||||
(71, 'Lu', 'Lutetium', 'lanthanide'),
|
||||
(72, 'Hf', 'Hafnium', 'transition metal'),
|
||||
(73, 'Ta', 'Tantalum', 'transition metal'),
|
||||
(74, 'W', 'Tungsten', 'transition metal'),
|
||||
(75, 'Re', 'Rhenium', 'transition metal'),
|
||||
(76, 'Os', 'Osmium', 'transition metal'),
|
||||
(77, 'Ir', 'Iridium', 'transition metal'),
|
||||
(78, 'Pt', 'Platinum', 'transition metal'),
|
||||
(79, 'Au', 'Gold', 'transition metal'),
|
||||
(80, 'Hg', 'Mercury', 'transition metal'),
|
||||
(81, 'Tl', 'Thallium', 'post-transition metal'),
|
||||
(82, 'Pb', 'Lead', 'post-transition metal'),
|
||||
(83, 'Bi', 'Bismuth', 'post-transition metal'),
|
||||
(84, 'Po', 'Polonium', 'post-transition metal'),
|
||||
(85, 'At', 'Astatine', 'diatomic nonmetal'),
|
||||
(86, 'Rn', 'Radon', 'noble gas'),
|
||||
(87, 'Fr', 'Francium', 'alkali metal'),
|
||||
(88, 'Ra', 'Radium', 'alkaline earth metal'),
|
||||
(89, 'Ac', 'Actinium', 'actinide'),
|
||||
(90, 'Th', 'Thorium', 'actinide'),
|
||||
(91, 'Pa', 'Protactinium', 'actinide'),
|
||||
(92, 'U', 'Uranium', 'actinide'),
|
||||
(93, 'Np', 'Neptunium', 'actinide'),
|
||||
(94, 'Pu', 'Plutonium', 'actinide'),
|
||||
(95, 'Am', 'Americium', 'actinide'),
|
||||
(96, 'Cm', 'Curium', 'actinide'),
|
||||
(97, 'Bk', 'Berkelium', 'actinide'),
|
||||
(98, 'Cf', 'Californium', 'actinide'),
|
||||
(99, 'Es', 'Einsteinium', 'actinide'),
|
||||
(100, 'Fm', 'Fermium', 'actinide'),
|
||||
(101, 'Md', 'Mendelevium', 'actinide'),
|
||||
(102, 'No', 'Nobelium', 'actinide'),
|
||||
(103, 'Lr', 'Lawrencium', 'actinide'),
|
||||
(104, 'Rf', 'Rutherfordium', 'transition metal'),
|
||||
(105, 'Db', 'Dubnium', 'transition metal'),
|
||||
(106, 'Sg', 'Seaborgium', 'transition metal'),
|
||||
(107, 'Bh', 'Bohrium', 'transition metal'),
|
||||
(108, 'Hs', 'Hassium', 'transition metal'),
|
||||
(109, 'Mt', 'Meitnerium', None),
|
||||
(110, 'Ds', 'Darmstadtium', None),
|
||||
(111, 'Rg', 'Roentgenium', None),
|
||||
(112, 'Cn', 'Copernicium', None),
|
||||
(113, 'Nh', 'Nihonium', 'post-transition metal'),
|
||||
(114, 'Fl', 'Flerovium', 'post-transition metal'),
|
||||
(115, 'Mc', 'Moscovium', None),
|
||||
(116, 'Lv', 'Livermorium', None),
|
||||
(117, 'Ts', 'Tennessine', None),
|
||||
(118, 'Og', 'Oganesson', None),
|
||||
]
|
||||
assert len(ELEMENTS) == 118
|
||||
assert [e[0] for e in ELEMENTS] == list(range(1, 119))
|
||||
assert ELEMENTS[0] == (1, 'H', 'Hydrogen', 'diatomic nonmetal')
|
||||
assert ELEMENTS[25] == (26, 'Fe', 'Iron', 'transition metal')
|
||||
assert ELEMENTS[-1] == (118, 'Og', 'Oganesson', None)
|
||||
|
||||
ALL_CATEGORIES = sorted({e[3] for e in ELEMENTS if e[3] is not None})
|
||||
|
||||
|
||||
def build_model(deck_key):
|
||||
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
||||
model_id = 1_700_000_000 + (voice_hash % 90_000_000)
|
||||
return model_id, genanki.Model(
|
||||
model_id,
|
||||
f"Periodic Table ({deck_key}, {args.voice})",
|
||||
fields=[{"name": "Prompt"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
|
||||
templates=[{
|
||||
"name": "Card",
|
||||
"qfmt": """
|
||||
<div class="prompt">{{Prompt}}</div>
|
||||
{{QSound}}
|
||||
{{type:Answer}}
|
||||
""",
|
||||
"afmt": """
|
||||
<div class="prompt">{{Prompt}}</div>
|
||||
<hr id="answer">
|
||||
{{type:Answer}}
|
||||
{{ASound}}
|
||||
""",
|
||||
}],
|
||||
css="""
|
||||
.card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; }
|
||||
.prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; }
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def build_deck(deck_key, deck_title):
|
||||
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
||||
deck_id = 2_100_000_000 + (voice_hash % 90_000_000)
|
||||
return genanki.Deck(deck_id, deck_title)
|
||||
|
||||
|
||||
def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag):
|
||||
qfile = f"q_{tag}.wav"
|
||||
afile = f"a_{tag}.wav"
|
||||
qpath = os.path.join(MEDIA_DIR, qfile)
|
||||
apath = os.path.join(MEDIA_DIR, afile)
|
||||
piper_tts(qtext, qpath)
|
||||
piper_tts(atext, apath)
|
||||
media_files += [qpath, apath]
|
||||
deck.add_note(genanki.Note(
|
||||
model=model,
|
||||
fields=[prompt, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
|
||||
))
|
||||
|
||||
|
||||
def gen_prehs():
|
||||
deck_key = "periodic_prehs"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Periodic Table: Symbols & Names (1-36)")
|
||||
media_files = []
|
||||
subset = [e for e in ELEMENTS if e[0] <= 36]
|
||||
cards = []
|
||||
for number, symbol, name, category in subset:
|
||||
cards.append(("symbol_to_name", number, symbol, name))
|
||||
cards.append(("name_to_symbol", number, symbol, name))
|
||||
random.seed(50)
|
||||
random.shuffle(cards)
|
||||
for kind, number, symbol, name in cards:
|
||||
if kind == "symbol_to_name":
|
||||
add_note(deck, model, symbol, name,
|
||||
f"What element has the symbol {symbol}?", name,
|
||||
media_files, f"prehs_s2n_{number}")
|
||||
else:
|
||||
add_note(deck, model, name, symbol,
|
||||
f"What is the symbol for {name}?", symbol,
|
||||
media_files, f"prehs_n2s_{number}")
|
||||
return deck, media_files, len(cards)
|
||||
|
||||
|
||||
def gen_hs():
|
||||
deck_key = "periodic_hs"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Periodic Table: Symbols, Names & Numbers (1-118)")
|
||||
media_files = []
|
||||
cards = []
|
||||
for number, symbol, name, category in ELEMENTS:
|
||||
cards.append(("symbol_to_name", number, symbol, name))
|
||||
cards.append(("name_to_symbol", number, symbol, name))
|
||||
cards.append(("number_to_symbol", number, symbol, name))
|
||||
random.seed(51)
|
||||
random.shuffle(cards)
|
||||
for kind, number, symbol, name in cards:
|
||||
if kind == "symbol_to_name":
|
||||
add_note(deck, model, symbol, name,
|
||||
f"What element has the symbol {symbol}?", name,
|
||||
media_files, f"hs_s2n_{number}")
|
||||
elif kind == "name_to_symbol":
|
||||
add_note(deck, model, name, symbol,
|
||||
f"What is the symbol for {name}?", symbol,
|
||||
media_files, f"hs_n2s_{number}")
|
||||
else:
|
||||
add_note(deck, model, f"Element #{number}", symbol,
|
||||
f"What is the symbol for element number {num2words(number)}?", symbol,
|
||||
media_files, f"hs_num2s_{number}")
|
||||
return deck, media_files, len(cards)
|
||||
|
||||
|
||||
def gen_category():
|
||||
deck_key = "periodic_category"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Periodic Table: Element Categories (multiple choice)")
|
||||
media_files = []
|
||||
subset = [e for e in ELEMENTS if e[3] is not None]
|
||||
random.seed(52)
|
||||
shuffled = subset[:]
|
||||
random.shuffle(shuffled)
|
||||
|
||||
letters = ["A", "B", "C", "D"]
|
||||
for number, symbol, name, category in shuffled:
|
||||
distractor_pool = [c for c in ALL_CATEGORIES if c != category]
|
||||
distractors = random.sample(distractor_pool, 3)
|
||||
choices = distractors + [category]
|
||||
random.shuffle(choices)
|
||||
correct_letter = letters[choices.index(category)]
|
||||
|
||||
prompt_lines = [f"{name} ({symbol})", ""]
|
||||
for letter, choice in zip(letters, choices):
|
||||
prompt_lines.append(f"{letter}) {choice}")
|
||||
prompt = "\n".join(prompt_lines)
|
||||
|
||||
qtext = f"What category is {name}?"
|
||||
atext = f"{category}"
|
||||
add_note(deck, model, prompt, correct_letter, qtext, atext,
|
||||
media_files, f"cat_{number}")
|
||||
return deck, media_files, len(subset)
|
||||
|
||||
|
||||
if args.deck == "prehs":
|
||||
deck_key = "periodic_prehs"
|
||||
elif args.deck == "hs":
|
||||
deck_key = "periodic_hs"
|
||||
else:
|
||||
deck_key = "periodic_category"
|
||||
|
||||
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
|
||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
|
||||
GENERATORS = {"prehs": gen_prehs, "hs": gen_hs, "category": gen_category}
|
||||
deck, media_files, count = GENERATORS[args.deck]()
|
||||
|
||||
package = genanki.Package(deck)
|
||||
package.media_files = media_files
|
||||
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
|
||||
package.write_to_file(out_path)
|
||||
|
||||
size_mb = os.path.getsize(out_path) / (1024 * 1024)
|
||||
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tools/anki-deck-visual.py — Generate image-based Anki decks (.apkg) for
|
||||
shapes, clocks, and coin-counting, with Anki's built-in type-the-answer
|
||||
input and Piper (offline, local neural TTS) audio. See
|
||||
tools/anki-deck-math.py's docstring for one-time setup (venv, genanki +
|
||||
piper-tts, downloading a voice) — same steps apply here.
|
||||
|
||||
All images are drawn programmatically as SVG (regular-polygon geometry,
|
||||
clock-hand trigonometry, coin layouts) rather than AI-generated — image
|
||||
generation (local or cloud) is a poor fit for content that has to be
|
||||
exactly correct (an exact clock time, an exact side count, an exact coin
|
||||
total), not just plausible-looking. See this script's own point/angle
|
||||
generation functions for how each shape's geometry is computed directly
|
||||
rather than approximated.
|
||||
|
||||
Decks:
|
||||
shapes regular polygons (3-10 sides, image->name and name->sides)
|
||||
plus 5 quadrilateral types (image->name: square, rectangle,
|
||||
rhombus, trapezoid, parallelogram — each one's geometry is
|
||||
genuinely distinct, not just differently labeled)
|
||||
clocks analog clock faces, all 144 hour/5-min combinations,
|
||||
type the time as H:MM (the hour hand moves fractionally
|
||||
with the minutes, e.g. 6:30 sits halfway between 6 and 7 —
|
||||
a static hour hand is the most common "looks right but
|
||||
teaches wrong" bug in generated clock faces)
|
||||
currency US coins (nickel/dime/quarter — no pennies, since they're
|
||||
barely used day to day at this point), 1-4 coins per card,
|
||||
type the total in cents
|
||||
|
||||
Usage (run with the venv from anki-deck-math.py's docstring activated):
|
||||
python3 anki-deck-visual.py --deck shapes
|
||||
python3 anki-deck-visual.py --deck clocks
|
||||
python3 anki-deck-visual.py --deck currency
|
||||
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
|
||||
automatically; --dry-run-tts to test the deck-building logic without any
|
||||
voice model at all, using silent placeholder audio)
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import genanki
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--deck", required=True, choices=["shapes", "clocks", "currency"])
|
||||
parser.add_argument("--voice", default="en_US-lessac-medium")
|
||||
parser.add_argument("--model-path", default=None)
|
||||
parser.add_argument("--dry-run-tts", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
SCRATCH = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
_CANDIDATES = [
|
||||
args.model_path,
|
||||
f"{args.voice}.onnx",
|
||||
os.path.join(SCRATCH, f"{args.voice}.onnx"),
|
||||
os.path.expanduser(f"~/{args.voice}.onnx"),
|
||||
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
|
||||
]
|
||||
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
|
||||
|
||||
if VOICE_MODEL is None and not args.dry_run_tts:
|
||||
raise SystemExit(
|
||||
f"Voice model for '{args.voice}' not found. Checked:\n"
|
||||
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
|
||||
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
|
||||
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
|
||||
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
|
||||
)
|
||||
|
||||
|
||||
def piper_tts(text: str, out_path: str) -> None:
|
||||
if args.dry_run_tts:
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(
|
||||
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
|
||||
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
|
||||
)
|
||||
return
|
||||
subprocess.run(
|
||||
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
|
||||
input=text.encode("utf-8"),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
||||
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
||||
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
|
||||
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
|
||||
"eighty", "ninety"]
|
||||
|
||||
|
||||
def num2words(n):
|
||||
if n < 20:
|
||||
return ONES[n]
|
||||
if n < 100:
|
||||
t, o = divmod(n, 10)
|
||||
return TENS[t] + ("-" + ONES[o] if o else "")
|
||||
h, rem = divmod(n, 100)
|
||||
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
|
||||
|
||||
|
||||
assert num2words(15) == "fifteen"
|
||||
assert num2words(40) == "forty"
|
||||
|
||||
|
||||
def time_words(hour, minute):
|
||||
"""3, 5 -> 'three oh five'; 3, 15 -> 'three fifteen'; 3, 0 -> 'three o'clock'."""
|
||||
if minute == 0:
|
||||
return f"{num2words(hour)} o'clock"
|
||||
if minute < 10:
|
||||
return f"{num2words(hour)} oh {num2words(minute)}"
|
||||
return f"{num2words(hour)} {num2words(minute)}"
|
||||
|
||||
|
||||
assert time_words(3, 0) == "three o'clock"
|
||||
assert time_words(3, 5) == "three oh five"
|
||||
assert time_words(3, 15) == "three fifteen"
|
||||
assert time_words(12, 45) == "twelve forty-five"
|
||||
|
||||
|
||||
def build_model(deck_key):
|
||||
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
||||
model_id = 1_800_000_000 + (voice_hash % 90_000_000)
|
||||
return model_id, genanki.Model(
|
||||
model_id,
|
||||
f"Visual Fact ({deck_key}, {args.voice})",
|
||||
fields=[{"name": "Image"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
|
||||
templates=[{
|
||||
"name": "Card",
|
||||
"qfmt": """
|
||||
<div class="imgwrap">{{Image}}</div>
|
||||
{{QSound}}
|
||||
{{type:Answer}}
|
||||
""",
|
||||
"afmt": """
|
||||
<div class="imgwrap">{{Image}}</div>
|
||||
<hr id="answer">
|
||||
{{type:Answer}}
|
||||
{{ASound}}
|
||||
""",
|
||||
}],
|
||||
css="""
|
||||
.card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; }
|
||||
.imgwrap { margin: 10px auto; }
|
||||
.imgwrap img { max-width: 260px; max-height: 260px; }
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def build_deck(deck_key, deck_title):
|
||||
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
||||
deck_id = 2_200_000_000 + (voice_hash % 90_000_000)
|
||||
return genanki.Deck(deck_id, deck_title)
|
||||
|
||||
|
||||
def add_note(deck, model, image_html, answer, qtext, atext, media_files, tag):
|
||||
qfile = f"q_{tag}.wav"
|
||||
afile = f"a_{tag}.wav"
|
||||
qpath = os.path.join(MEDIA_DIR, qfile)
|
||||
apath = os.path.join(MEDIA_DIR, afile)
|
||||
piper_tts(qtext, qpath)
|
||||
piper_tts(atext, apath)
|
||||
media_files += [qpath, apath]
|
||||
deck.add_note(genanki.Note(
|
||||
model=model,
|
||||
fields=[image_html, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
|
||||
))
|
||||
|
||||
|
||||
def add_text_note(deck, model, text, answer, qtext, atext, media_files, tag):
|
||||
"""For directions that don't need an image (e.g. name -> number of sides)."""
|
||||
add_note(deck, model, f'<div style="font-size:36px;">{text}</div>', answer,
|
||||
qtext, atext, media_files, tag)
|
||||
|
||||
|
||||
# ─── SVG generation ───────────────────────────────────────────────────────────
|
||||
def save_svg(svg_body, filename, viewbox="0 0 200 200"):
|
||||
path = os.path.join(MEDIA_DIR, filename)
|
||||
with open(path, "w") as f:
|
||||
f.write(
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{viewbox}" '
|
||||
f'width="200" height="200">{svg_body}</svg>'
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
|
||||
points = []
|
||||
# Start pointing up (-90deg) so shapes sit "upright" rather than vertex-right.
|
||||
start_angle = -90
|
||||
for i in range(n_sides):
|
||||
angle_deg = start_angle + i * (360 / n_sides)
|
||||
angle_rad = math.radians(angle_deg)
|
||||
x = cx + r * math.cos(angle_rad)
|
||||
y = cy + r * math.sin(angle_rad)
|
||||
points.append((round(x, 1), round(y, 1)))
|
||||
return points
|
||||
|
||||
|
||||
def polygon_svg(points):
|
||||
pts_str = " ".join(f"{x},{y}" for x, y in points)
|
||||
return f'<polygon points="{pts_str}" fill="#6fa8dc" stroke="#1c4587" stroke-width="4"/>'
|
||||
|
||||
|
||||
POLYGON_NAMES = {
|
||||
3: "triangle", 4: "square", 5: "pentagon", 6: "hexagon", 7: "heptagon",
|
||||
8: "octagon", 9: "nonagon", 10: "decagon",
|
||||
}
|
||||
|
||||
QUADRILATERALS = {
|
||||
"square": [(50, 50), (150, 50), (150, 150), (50, 150)],
|
||||
"rectangle": [(30, 60), (170, 60), (170, 140), (30, 140)],
|
||||
"rhombus": [(100, 20), (170, 100), (100, 180), (30, 100)],
|
||||
"trapezoid": [(60, 60), (140, 60), (170, 140), (30, 140)],
|
||||
"parallelogram": [(60, 60), (160, 60), (140, 140), (40, 140)],
|
||||
}
|
||||
|
||||
|
||||
def clock_svg(hour, minute):
|
||||
cx, cy, r = 100, 100, 90
|
||||
minute_angle = minute * 6 - 90
|
||||
hour_angle = (hour % 12) * 30 + minute * 0.5 - 90
|
||||
|
||||
def hand(angle_deg, length, width, color):
|
||||
rad = math.radians(angle_deg)
|
||||
x2 = cx + length * math.cos(rad)
|
||||
y2 = cy + length * math.sin(rad)
|
||||
return f'<line x1="{cx}" y1="{cy}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{color}" stroke-width="{width}" stroke-linecap="round"/>'
|
||||
|
||||
ticks = []
|
||||
numerals = []
|
||||
for h in range(1, 13):
|
||||
angle = math.radians(h * 30 - 90)
|
||||
tx1, ty1 = cx + (r - 10) * math.cos(angle), cy + (r - 10) * math.sin(angle)
|
||||
tx2, ty2 = cx + r * math.cos(angle), cy + r * math.sin(angle)
|
||||
ticks.append(f'<line x1="{tx1:.1f}" y1="{ty1:.1f}" x2="{tx2:.1f}" y2="{ty2:.1f}" stroke="black" stroke-width="2"/>')
|
||||
nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
|
||||
numerals.append(f'<text x="{nx:.1f}" y="{ny:.1f}" font-size="14" text-anchor="middle" dominant-baseline="middle">{h}</text>')
|
||||
|
||||
body = (
|
||||
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="white" stroke="black" stroke-width="3"/>'
|
||||
+ "".join(ticks) + "".join(numerals)
|
||||
+ hand(hour_angle, 45, 6, "black")
|
||||
+ hand(minute_angle, 70, 4, "black")
|
||||
+ f'<circle cx="{cx}" cy="{cy}" r="4" fill="black"/>'
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
COIN_INFO = {5: ("#c0c0c0", "5¢"), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")}
|
||||
COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"}
|
||||
|
||||
|
||||
def coins_svg(coin_values):
|
||||
n = len(coin_values)
|
||||
spacing = 200 // (n + 1)
|
||||
parts = []
|
||||
for i, v in enumerate(coin_values):
|
||||
cx = spacing * (i + 1)
|
||||
color, label = COIN_INFO[v]
|
||||
radius = 30 if v == 25 else (26 if v == 10 else 28)
|
||||
parts.append(
|
||||
f'<circle cx="{cx}" cy="100" r="{radius}" fill="{color}" stroke="#444" stroke-width="2"/>'
|
||||
f'<text x="{cx}" y="105" font-size="14" text-anchor="middle">{label}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def coin_list_words(coin_values):
|
||||
names = [COIN_NAMES[v] for v in coin_values]
|
||||
if len(names) == 1:
|
||||
return f"a {names[0]}"
|
||||
if len(names) == 2:
|
||||
return f"a {names[0]} and a {names[1]}"
|
||||
return ", ".join(f"a {n}" for n in names[:-1]) + f", and a {names[-1]}"
|
||||
|
||||
|
||||
# ─── Per-deck generators ─────────────────────────────────────────────────────
|
||||
def gen_shapes():
|
||||
deck_key = "shapes"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals")
|
||||
media_files = []
|
||||
|
||||
jobs = []
|
||||
for n in range(3, 11):
|
||||
jobs.append(("polygon_image", n))
|
||||
jobs.append(("polygon_sides", n))
|
||||
for qname in QUADRILATERALS:
|
||||
jobs.append(("quad_image", qname))
|
||||
random.seed(60)
|
||||
random.shuffle(jobs)
|
||||
|
||||
for kind, val in jobs:
|
||||
if kind == "polygon_image":
|
||||
n = val
|
||||
name = POLYGON_NAMES[n]
|
||||
svg_path = save_svg(polygon_svg(regular_polygon_points(n)), f"poly_{n}.svg")
|
||||
media_files.append(svg_path)
|
||||
add_note(deck, model, f'<img src="poly_{n}.svg">', name,
|
||||
"What shape is this?", name, media_files, f"shape_img_{n}")
|
||||
elif kind == "polygon_sides":
|
||||
n = val
|
||||
name = POLYGON_NAMES[n]
|
||||
add_text_note(deck, model, name.capitalize(), str(n),
|
||||
f"How many sides does a {name} have?", num2words(n),
|
||||
media_files, f"shape_sides_{n}")
|
||||
else:
|
||||
qname = val
|
||||
svg_path = save_svg(polygon_svg(QUADRILATERALS[qname]), f"quad_{qname}.svg")
|
||||
media_files.append(svg_path)
|
||||
add_note(deck, model, f'<img src="quad_{qname}.svg">', qname,
|
||||
"What shape is this?", qname, media_files, f"shape_quad_{qname}")
|
||||
return deck, media_files, len(jobs)
|
||||
|
||||
|
||||
def gen_clocks():
|
||||
deck_key = "clocks"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Telling Time: Analog Clocks")
|
||||
media_files = []
|
||||
|
||||
times = [(h, m) for h in range(1, 13) for m in range(0, 60, 5)]
|
||||
random.seed(61)
|
||||
random.shuffle(times)
|
||||
|
||||
# The question prompt ("What time is it?") is identical for every card —
|
||||
# generate it once instead of 144 times.
|
||||
shared_qfile = "q_clock_prompt.wav"
|
||||
piper_tts("What time is it?", os.path.join(MEDIA_DIR, shared_qfile))
|
||||
media_files.append(os.path.join(MEDIA_DIR, shared_qfile))
|
||||
|
||||
for hour, minute in times:
|
||||
svg_path = save_svg(clock_svg(hour, minute), f"clock_{hour}_{minute:02d}.svg")
|
||||
media_files.append(svg_path)
|
||||
answer = f"{hour}:{minute:02d}"
|
||||
afile = f"a_clock_{hour}_{minute:02d}.wav"
|
||||
apath = os.path.join(MEDIA_DIR, afile)
|
||||
piper_tts(time_words(hour, minute), apath)
|
||||
media_files.append(apath)
|
||||
deck.add_note(genanki.Note(
|
||||
model=model,
|
||||
fields=[f'<img src="clock_{hour}_{minute:02d}.svg">', answer,
|
||||
f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
|
||||
))
|
||||
return deck, media_files, len(times)
|
||||
|
||||
|
||||
def gen_currency():
|
||||
deck_key = "currency"
|
||||
model_id, model = build_model(deck_key)
|
||||
# Deliberately nickel/dime/quarter only, no pennies — pennies are barely
|
||||
# used day to day at this point, and skipping them keeps every total a
|
||||
# multiple of 5 cents, which is a cleaner first pass at coin counting.
|
||||
deck = build_deck(deck_key, "Counting Coins (nickels, dimes, quarters)")
|
||||
media_files = []
|
||||
denoms = [5, 10, 25]
|
||||
|
||||
combos = set()
|
||||
for count in range(1, 5):
|
||||
def rec(remaining, current):
|
||||
if remaining == 0:
|
||||
combos.add(tuple(sorted(current)))
|
||||
return
|
||||
for d in denoms:
|
||||
if not current or d >= current[-1]:
|
||||
rec(remaining - 1, current + [d])
|
||||
rec(count, [])
|
||||
combos = sorted(combos)
|
||||
random.seed(62)
|
||||
random.shuffle(combos)
|
||||
|
||||
for coin_values in combos:
|
||||
total = sum(coin_values)
|
||||
svg_path = save_svg(coins_svg(list(coin_values)), f"coins_{'_'.join(map(str, coin_values))}.svg")
|
||||
media_files.append(svg_path)
|
||||
qtext = f"How much money is {coin_list_words(list(coin_values))}?"
|
||||
atext = f"{num2words(total)} cents"
|
||||
add_note(deck, model, f'<img src="coins_{"_".join(map(str, coin_values))}.svg">',
|
||||
str(total), qtext, atext, media_files, f"coins_{'_'.join(map(str, coin_values))}")
|
||||
return deck, media_files, len(combos)
|
||||
|
||||
|
||||
if args.deck == "shapes":
|
||||
deck_key = "shapes"
|
||||
elif args.deck == "clocks":
|
||||
deck_key = "clocks"
|
||||
else:
|
||||
deck_key = "currency"
|
||||
|
||||
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
|
||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
|
||||
GENERATORS = {"shapes": gen_shapes, "clocks": gen_clocks, "currency": gen_currency}
|
||||
deck, media_files, count = GENERATORS[args.deck]()
|
||||
|
||||
package = genanki.Package(deck)
|
||||
package.media_files = media_files
|
||||
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
|
||||
package.write_to_file(out_path)
|
||||
|
||||
size_mb = os.path.getsize(out_path) / (1024 * 1024)
|
||||
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")
|
||||
@@ -56,9 +56,13 @@ fi
|
||||
# ── Container + directory detection ─────────────────────────────────────────
|
||||
section "Detecting install"
|
||||
|
||||
CONTAINER="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -m1 -E '^easy-asterisk(-do)?$' || true)"
|
||||
# "asterisk" is this repo's current container name; "easy-asterisk" is what
|
||||
# an install kept from before that rename (never silently renamed under a
|
||||
# running deployment); "easy-asterisk-do"/"asterisk-do" cover a DigitalOcean
|
||||
# droplet install, old or new naming. Whichever is actually running wins.
|
||||
CONTAINER="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -m1 -E '^(easy-)?asterisk(-do)?$' || true)"
|
||||
if [ -z "$CONTAINER" ]; then
|
||||
fail "No running easy-asterisk / easy-asterisk-do container found — is asterisk installed and started?"
|
||||
fail "No running asterisk / easy-asterisk / *-do container found — is asterisk installed and started?"
|
||||
echo ""
|
||||
echo " $PASS passed, $WARN warnings, $FAIL failed. Stopping — nothing else can be checked without a running container."
|
||||
exit 1
|
||||
@@ -196,8 +200,7 @@ if [ -z "$TURN_SERVER" ]; then
|
||||
warn "Add it via: sudo ./setup.sh asterisk (update mode)"
|
||||
else
|
||||
if grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null; then
|
||||
COTURN_CONTAINER="easy-asterisk-coturn"
|
||||
[[ "$CONTAINER" == *-do ]] && COTURN_CONTAINER="easy-asterisk-do-coturn"
|
||||
COTURN_CONTAINER="${CONTAINER}-coturn"
|
||||
ok "Using an embedded, per-Asterisk coturn ($COTURN_CONTAINER); tested separately below."
|
||||
else
|
||||
COTURN_CONTAINER="coturn"
|
||||
|
||||
Vendored
+47
-15
@@ -277,17 +277,34 @@ sync_github_to_gitea() {
|
||||
# old commit indefinitely, with no error at any step. Also no longer
|
||||
# silencing stderr: a real auth/network failure should be visible in the
|
||||
# log, not just "Failed to fetch" with no reason why.
|
||||
local auth_url="${clone_url/https:\/\//https:\/\/$GITHUB_TOKEN@}"
|
||||
if [[ -d "$local_path" ]]; then
|
||||
info "Fetching $full_name from GitHub..."
|
||||
git -C "$local_path" fetch origin '+refs/heads/*:refs/heads/*' --prune --quiet || {
|
||||
err "Failed to fetch $full_name"; return 1; }
|
||||
else
|
||||
info "Cloning $full_name from GitHub..."
|
||||
mkdir -p "$(dirname "$local_path")"
|
||||
local auth_url="${clone_url/https:\/\//https:\/\/$GITHUB_TOKEN@}"
|
||||
git clone --bare --quiet "$auth_url" "$local_path" || {
|
||||
err "Failed to clone $full_name"; return 1; }
|
||||
git init --bare --quiet "$local_path" || { err "Failed to init $full_name"; return 1; }
|
||||
git -C "$local_path" remote add origin "$auth_url"
|
||||
fi
|
||||
# Explicit heads+tags refspec on BOTH the initial clone and every later
|
||||
# fetch, not `git clone --bare` (which pulls every ref the remote
|
||||
# advertises, refs/pull/*/head included) — GitHub exposes PR refs over
|
||||
# the same smart-HTTP endpoint a plain bare clone reads from, and those
|
||||
# live in a namespace Gitea's own PR system reserves for itself. A later
|
||||
# `git push --mirror` (pushes every local ref verbatim) then gets
|
||||
# rejected by Gitea's server-side hook — confirmed live: "hook declined
|
||||
# to update refs/pull/1/head". Scoping fetch AND push to heads/tags only
|
||||
# avoids ever touching that namespace in either direction.
|
||||
git -C "$local_path" fetch origin \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' \
|
||||
--prune --quiet || { err "Failed to fetch $full_name"; return 1; }
|
||||
# Self-heals a repo synced before this fix — a stray refs/pull/* (or any
|
||||
# other non-heads/tags ref) an earlier run's unscoped `clone --bare`
|
||||
# already pulled in would otherwise keep tripping the same Gitea hook on
|
||||
# every sync from here on, with no other way to clear it.
|
||||
git -C "$local_path" for-each-ref --format='%(refname)' \
|
||||
'refs/pull/*' 'refs/merge-requests/*' 'refs/changes/*' \
|
||||
| xargs -r -n1 git -C "$local_path" update-ref -d
|
||||
|
||||
# Ensure repo exists on Gitea
|
||||
local gitea_check
|
||||
@@ -299,12 +316,17 @@ sync_github_to_gitea() {
|
||||
>/dev/null || { err "Failed to create $repo_name on Gitea"; return 1; }
|
||||
fi
|
||||
|
||||
# Push to Gitea
|
||||
# Push to Gitea — same explicit heads+tags scoping as the fetch above,
|
||||
# not --mirror (which would push refs/pull/* etc. verbatim and hit the
|
||||
# same rejected-hook failure this whole fix is for). --prune still makes
|
||||
# Gitea's heads/tags a true mirror of GitHub's (deletes ones GitHub no
|
||||
# longer has), just without ever touching reserved ref namespaces.
|
||||
local gitea_push_url="${GITEA_URL/https:\/\//https:\/\/$GITEA_USER:$GITEA_TOKEN@}"
|
||||
gitea_push_url="${gitea_push_url/http:\/\//http:\/\/$GITEA_USER:$GITEA_TOKEN@}"
|
||||
gitea_push_url="$gitea_push_url/$GITEA_USER/$repo_name.git"
|
||||
|
||||
git -C "$local_path" push --mirror "$gitea_push_url" --quiet || {
|
||||
git -C "$local_path" push --prune --quiet "$gitea_push_url" \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' || {
|
||||
err "Failed to push $full_name to Gitea"; return 1; }
|
||||
ok "GitHub → Gitea: $full_name"
|
||||
_log "PULL $full_name OK"
|
||||
@@ -320,18 +342,26 @@ sync_gitea_to_github() {
|
||||
local gitea_auth_url="${clone_url/https:\/\//https:\/\/$GITEA_USER:$GITEA_TOKEN@}"
|
||||
gitea_auth_url="${gitea_auth_url/http:\/\//http:\/\/$GITEA_USER:$GITEA_TOKEN@}"
|
||||
|
||||
# See the matching comment in sync_github_to_gitea() above — same
|
||||
# explicit-refspec, visible-stderr fix, same reason.
|
||||
# See the matching comment in sync_github_to_gitea() above — same reason
|
||||
# applies in reverse: Gitea also exposes PR refs (refs/pull/*/head) over
|
||||
# its git smart-HTTP endpoint, and GitHub rejects direct pushes to that
|
||||
# same reserved namespace just as Gitea's hook does. Explicit heads+tags
|
||||
# refspec on the initial clone too, not `git clone --bare`.
|
||||
if [[ -d "$local_path" ]]; then
|
||||
info "Fetching $full_name from Gitea..."
|
||||
git -C "$local_path" fetch origin '+refs/heads/*:refs/heads/*' --prune --quiet || {
|
||||
err "Failed to fetch $full_name from Gitea"; return 1; }
|
||||
else
|
||||
info "Cloning $full_name from Gitea..."
|
||||
mkdir -p "$(dirname "$local_path")"
|
||||
git clone --bare --quiet "$gitea_auth_url" "$local_path" || {
|
||||
err "Failed to clone $full_name from Gitea"; return 1; }
|
||||
git init --bare --quiet "$local_path" || { err "Failed to init $full_name"; return 1; }
|
||||
git -C "$local_path" remote add origin "$gitea_auth_url"
|
||||
fi
|
||||
git -C "$local_path" fetch origin \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' \
|
||||
--prune --quiet || { err "Failed to fetch $full_name from Gitea"; return 1; }
|
||||
# Self-heals a repo synced before this fix — see the matching comment above.
|
||||
git -C "$local_path" for-each-ref --format='%(refname)' \
|
||||
'refs/pull/*' 'refs/merge-requests/*' 'refs/changes/*' \
|
||||
| xargs -r -n1 git -C "$local_path" update-ref -d
|
||||
|
||||
# Ensure repo exists on GitHub
|
||||
local gh_check
|
||||
@@ -343,9 +373,11 @@ sync_gitea_to_github() {
|
||||
>/dev/null || { err "Failed to create $repo_name on GitHub"; return 1; }
|
||||
fi
|
||||
|
||||
# Push to GitHub
|
||||
# Push to GitHub — explicit heads+tags scoping, not --mirror. Same
|
||||
# reasoning as the Gitea push above.
|
||||
local github_push_url="https://$GITHUB_TOKEN@github.com/$GITHUB_USER/$repo_name.git"
|
||||
git -C "$local_path" push --mirror "$github_push_url" --quiet || {
|
||||
git -C "$local_path" push --prune --quiet "$github_push_url" \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' || {
|
||||
err "Failed to push $full_name to GitHub"; return 1; }
|
||||
ok "Gitea → GitHub: $full_name"
|
||||
_log "PUSH $full_name OK"
|
||||
|
||||
Vendored
+27
-2
@@ -596,7 +596,9 @@ services:
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD","ollama","list"]
|
||||
interval: 30s; timeout: 10s; retries: 5
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
open-webui:
|
||||
image: ghcr.io/open-webui/open-webui:main
|
||||
@@ -630,7 +632,9 @@ services:
|
||||
- ANONYMIZED_TELEMETRY=FALSE
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL","wget -qO- http://localhost:8000/api/v2/heartbeat || exit 1"]
|
||||
interval: 15s; timeout: 5s; retries: 5
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
rag-server:
|
||||
image: python:3.11-slim
|
||||
@@ -860,6 +864,27 @@ echo "Reasoning model (DeepSeek-R1 14B — optional)..."
|
||||
read -rp "Pull DeepSeek-R1:14b for planning/reasoning? [y/N]: " DR
|
||||
[[ "\${DR,,}" == "y" ]] && docker exec ollama ollama pull deepseek-r1:14b
|
||||
|
||||
echo ""
|
||||
echo "Vision model (optional — image understanding: Mealie's \"import recipe from"
|
||||
echo "photo\", attaching images in Open WebUI chat, etc.). None of the models"
|
||||
echo "above can read an image; pick one of these if you need that:"
|
||||
echo " 1) moondream ~1.7 GB Moondream AI — tiny, built for CPU-only/"
|
||||
echo " weak or old GPU hardware. Recommended"
|
||||
echo " default if you have no GPU or a low-VRAM one."
|
||||
echo " 2) llava:7b ~4.7 GB General-purpose vision, moderate resources."
|
||||
echo " 3) qwen2.5vl:7b ~6 GB Stronger accuracy, needs more RAM/VRAM."
|
||||
echo " 4) llama3.2-vision:11b ~7.9 GB Meta's vision model — heaviest of these four."
|
||||
read -rp "Pull a vision model? [1-4, blank to skip]: " VM
|
||||
case "\$VM" in
|
||||
1) docker exec ollama ollama pull moondream ;;
|
||||
2) docker exec ollama ollama pull llava:7b ;;
|
||||
3) docker exec ollama ollama pull qwen2.5vl:7b ;;
|
||||
4) docker exec ollama ollama pull llama3.2-vision:11b ;;
|
||||
"") : ;;
|
||||
*) echo "Unrecognized choice '\$VM' — skipping. Pull manually later with:"
|
||||
echo " docker exec ollama ollama pull <model>" ;;
|
||||
esac
|
||||
|
||||
echo "" && docker exec ollama ollama list
|
||||
PULLSH
|
||||
chmod +x "$BASE/pull-models.sh"
|
||||
|
||||
Reference in New Issue
Block a user