24 Commits
Author SHA1 Message Date
Outis a339d5fbb0 Merge pull request #446 from outis1one/claude/pensive-hopper-4c9e7i
Add anki-sync-server service — self-hosted Anki flashcard sync
2026-09-09 11:02:35 -04:00
Claude c2cf5bfe69 Add sync-account management menu to anki-sync-server
Re-running the installer against an existing install now offers "Manage
sync accounts" (add / remove / rotate a password) as a first-class menu
option, instead of requiring a hand-edit of .env kept in lockstep with
docker-compose.yml.

_anki_rewrite_account_block() regenerates the SYNC_USERn lines in both
files from the current account list, always renumbered contiguously from
1, and is shared by the initial install and every management mutation so
they can't drift apart. Only SYNC_USER/ANKI_SYNC_* lines are touched —
port, Caddy wiring, and everything else in either file is left alone.

Verified against a stubbed docker/ss sandbox: add, remove (mid-list, with
renumbering), and password rotation all produce the expected .env/
docker-compose.yml diffs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 14:58:32 +00:00
Claude 92f8503d48 Add anki-sync-server service — self-hosted Anki flashcard sync
Docker-based sync backend for the Anki app (afrima/anki-sync-server, the
official Rust sync server). Supports multiple independent accounts per
instance, the repo's multi-instance pattern, port collision avoidance,
Caddy wiring, and update/fresh/cancel reinstall detection.

No Authelia gate — this is a raw sync API the Anki client talks to, not
a browser session, so a forward_auth portal in front of it would just
break every sync request; SYNC_USER1/SYNC_USER2/... is its own auth
boundary.

Companion services/anki-sync-server.md covers client setup (Desktop,
AnkiDroid, AnkiMobile) and the Quizlet import/export walkthrough.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 14:07:31 +00:00
Outis d95bd7fd3c Merge pull request #445 from outis1one/claude/gitea-webhook-base-url-9ofg1n
Fix inactivity sync being skipped when remember_me is unchanged
2026-09-09 09:45:38 -04:00
Claude 3cd9a1ece3 Fix _authelia_set_remember_me skipping inactivity sync on a no-op remember_me
The "already equal" early-exit compared only remember_me against the typed
value, so re-entering an unchanged remember_me (the exact case for anyone
who'd set it before the earlier fix existed) skipped the inactivity write
entirely, leaving inactivity stuck at its old mismatched value. Now only
skips when both keys already match the typed duration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148pWopbt3tEKZWHYuHTb3c
2026-09-09 13:20:32 +00:00
Outis 5c13054cdd Merge pull request #444 from outis1one/claude/gitea-webhook-base-url-9ofg1n
Fix Authelia remember_me not actually keeping sessions alive
2026-09-09 09:16:51 -04:00
Outis 7d5674aad8 Merge pull request #443 from outis1one/claude/wolf-controller-setup-vl05t5
wolf: fix stale command list in the post-install summary, surface the…
2026-09-09 09:16:19 -04:00
Claude 2d82b2b278 Fix Authelia remember_me not actually keeping sessions alive
inactivity (idle timeout) was independent of remember_me and stayed at a
much shorter default (2h), so a long remember_me got silently overridden
by ordinary daily gaps between visits. install_authelia()'s template now
defaults inactivity to match remember_me, and the "Change remember me
duration" menu option now writes both keys together instead of just one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148pWopbt3tEKZWHYuHTb3c
2026-09-08 22:43:20 +00:00
Claude 08a2617b06 wolf: fix stale command list in the post-install summary, surface the Steam Input workflow
The final echo summary still advertised a removed `apps` command and left out
everything added since (cores, backup, controllers, steam-add-nonsteam-game,
steam-setup-frontends, cemu-clone-controller, cemu-sync-controllers,
install-completion, etc.). Also add a short pointer to the Steam-as-4-controller-hub
workflow (documented in depth further down in README.md) right in the install
summary, since it's currently only discoverable by reading the generated README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V99t5754SyXdnMTpVe2ba5
2026-09-07 22:29:55 +00:00
Outis 39387b5e0f Merge pull request #442 from outis1one/claude/wolf-cemu-four-controllers-373xn8
Claude/wolf cemu four controllers 373xn8
2026-09-05 15:00:15 -04:00
Claude e67ac50c61 wolf: document the ES-DE/RetroArch-in-Steam workflow in the generated README
The steam-setup-frontends command and the ES-DE/RetroArch AppImage
download step had no matching section in the ~/docker/wolf/README.md
content this file generates, unlike every other manage.sh command. Adds
one, alongside the existing Cemu-in-Steam section: why you'd want it,
how the mounts/cores are already shared, and the two honest caveats
(returning to Steam from ES-DE only works via this path, and whether
controller mappings sync across separately-paired Wolf clients is
expected but unconfirmed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-05 18:58:04 +00:00
Claude 3ebbeba672 wolf: add steam-setup-frontends to wait for Steam sign-in then auto-wire ES-DE/RetroArch
Steam Guard's QR-code sign-in can't be scripted (needs a phone approving
a prompt), so this polls for it instead: starts Wolf if needed, waits
for Steam's userdata/ to appear (or proceeds immediately if already
signed in), then re-invokes the existing steam-add-nonsteam-game command
for whichever of ES-DE.AppImage/RetroArch.AppImage was downloaded during
install. Points to ./manage.sh cores all for the shared cores/shaders/
overlays directory rather than duplicating that download logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:30:24 +00:00
Claude 07394769ca wolf: add optional ES-DE/RetroArch AppImage downloads for Steam Input
Both projects ship official standalone Linux AppImages separate from
the esde/retroarch Wolf catalog containers this repo already runs.
Adding either one to Steam as a non-Steam game (steam-add-nonsteam-game,
now reaching the same roms/saves/bios/retro-home/retroarch mounts as
the esde/retroarch containers) lets Steam Input assign a 4th controller
its own identity by device path, past Wolf's 3-concrete-pad-type ceiling.

ES-DE is hosted on GitLab rather than GitHub, so this adds a GitLab
Releases API counterpart to the existing GitHub-based download helper.
RetroArch's own buildbot doesn't publish through either API, so this
uses hizzlekizzle/RetroArch-AppImage, the community nightly-build
project the AppImage catalogs themselves point to (flagged as
third-party, same treatment this file already gives the Dolphin
community build). Both downloads are opt-in (default no) and symlink
to a fixed filename so steam-add-nonsteam-game's case-sensitive
substring match finds them regardless of the vendor's own asset name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:04:00 +00:00
Claude ae939c4085 wolf: mount ROMs/saves/BIOS/retro-home/retroarch into the Steam container
Lets a manually-downloaded ES-DE or RetroArch AppImage, added via
./manage.sh steam-add-nonsteam-game, see the same library, cores, and
ES-DE settings/custom systems (TI-99, Wii U) the esde/retroarch
containers already have, instead of starting from an empty config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 14:15:33 +00:00
Outis 28996eff57 Merge pull request #441 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix cemu-sync-controllers silently finding zero joysticks
2026-09-03 22:48:40 -04:00
Claude 93efe0d607 wolf: fix cemu-sync-controllers silently finding zero joysticks
docker exec needs an explicit -i flag to forward stdin into the
container process; without it, the heredoc piped into `python3 -`
never reached the containerized script, which ran empty and printed
nothing. cemu-sync-controllers then misread that empty output as
"SDL reported zero joysticks" — a false negative with a working set of
4 controllers already confirmed live in ES-DE, not an actual SDL or
GUID problem. Root-caused against the user's own live output: the
exact same probe script, run directly (not via the manage.sh command),
found real device nodes (js0-js4) present in the same container at the
same time cemu-sync-controllers reported zero.

Audited every other docker exec call in this file for the same
stdin-via-heredoc pattern; this was the only one missing -i.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 02:48:08 +00:00
Outis 79861c72f3 Merge pull request #440 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: add ./manage.sh cemu-sync-controllers — full auto-clone via SDL
2026-09-03 22:40:33 -04:00
Claude 2422ce1385 wolf: add ./manage.sh cemu-sync-controllers — full auto-clone via SDL
cemu-clone-controller still needed one manual Cemu GUI step per new
device (click +, select it, map one button, Save) just to learn its
real uuid. This eliminates that too: instead of getting the uuid from
Cemu, it asks SDL directly (SDL_JoystickGetDeviceGUID/GetGUIDString via
ctypes against libSDL2 — the exact library Cemu itself links against)
for the live GUID of every controller connected to the active session,
then clones the proven-working mapping onto each one automatically.

Confirmed live end-to-end on the user's real box: querying SDL this
way inside a running ES-DE session reproduced the exact uuids Cemu had
already written by hand for two different controllers (Nintendo Switch
Pro and Xbox One S) — byte-for-byte identical to their real
controllerProfiles/controllerN.xml. This is what makes trusting SDL as
the uuid source safe, after an earlier attempt to reverse-engineer the
GUID's CRC16 portion by hand (tried 7 different CRC16 variants) failed
to match either device.

Also improved cemu-clone-controller's sibling: the synced profile's
display_name now comes from SDL's own live device name (when the probe
reports one) rather than always inheriting the mapping template's name,
which was a real but purely cosmetic issue caught while re-testing.

Verified end-to-end in an isolated harness against the real captured
controller0.xml/controller1.xml: idempotent skip on already-synced
slots (no needless backups, existing files left byte-identical), a new
slot correctly cloned with the live name and same 24-pair mapping set,
and valid XML output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 02:25:22 +00:00
Outis 6fc6c3b84d Merge pull request #439 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 20:52:47 -04:00
Claude e6522eadec wolf: add ./manage.sh cemu-clone-controller
Cemu's own Wolf-facing controller slots (up to 4) still each need a
separate manual bind through Cemu's Input Settings even after
./manage.sh controllers makes them distinct SDL devices — that dialog's
Save button is documented elsewhere in this README as getting cut off
the viewport, making binding all 4 slots by hand painful.

Validated the underlying assumption against two real, separately
hand-mapped profiles pulled live from a working install (Nintendo
Switch Pro on controller0.xml, Xbox One S on controller1.xml): both
contain the exact same 24 <mapping>/<button> pairs, just in a different
order — real proof Cemu's mapping format is device-agnostic and
order-independent, not just the README's prior unverified claim.

New command clones a proven-working <mappings> block onto a new
device's controllerN.xml, needing only that device's own real <uuid>
(which still has to come from Cemu itself — a hand-computed SDL GUID
risks not matching what SDL actually reports for the live device, so
this never guesses one). Getting that uuid only needs a single-button
minimal bind in Cemu's UI, not full mapping, since only the uuid gets
kept from it.

Verified end-to-end against the real captured controller0.xml/
controller1.xml content: cloning controller0's mappings onto
controller1's real uuid reproduces the exact same 24-pair set Cemu
itself wrote, and the output validates as well-formed XML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 00:45:22 +00:00
Claude 2dcfaafc87 wolf: add Cemu TV-audio-stuck diagnostic block to the generated README
Copyable follow-up for the existing Cubeb/PulseAudio hang note: checks
whether a full Wolf restart has actually happened, whether PulseAudio
inside the Desktop container has any sinks at all, and pulls Cemu's own
log.txt plus its current <Audio> settings.xml block — narrows "stuck on
Disabled" down to a container-level audio problem vs. Cemu's own
device-switch path before guessing at a fix.

Verified the heredoc escaping by rendering the write_readme block through
a real bash heredoc and confirming $WOLF_DIR interpolates while every
other $ stays literal in the output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 19:23:20 +00:00
Outis 33e4f64d69 Merge pull request #438 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 14:58:11 -04:00
Claude cbfc28c2dd wolf: fix ES-DE ROMDirectory staying blank despite the self-healing patch
Root-caused against the user's own live output: the settings file had
<string name="ROMDirectory" value="" /> — the key was present, just
empty. The self-healing check only tested whether the string
"ROMDirectory" appeared anywhere in the file, so a key that exists with
a blank value (ES-DE can write this itself if its own first-run "select
ROM directory" step goes unanswered in a headless Moonlight session,
saving an empty path back over GOW's template) looked "already
populated" and got skipped — leaving ROM discovery broken even after
the fix had run.

Now ROMDirectory is independently forced to /ROMs whenever its value is
blank, on top of (not instead of) the existing RunInBackground patch —
no longer gated on the key's mere presence. Verified against the exact
reported bug (ROMDirectory present but blank) plus the existing missing-
file, already-correct, and idempotent-rerun scenarios in isolated /tmp
harnesses before touching the real script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:57:29 +00:00
Claude a212be09c3 wolf: add ./manage.sh steam-add-nonsteam-game, mount emulators/ into Steam
Adding an emulator (Cemu, etc.) to Steam as a non-Steam game previously
needed Steam's own Big Picture file-browser flow through Moonlight. This
writes the shortcut directly into Steam's binary shortcuts.vdf instead,
matching this repo's existing no-manual-wizard pattern (ge-proton,
install-ea-app). Motivated by wanting to test whether Steam Input's
per-device controller tracking (distinct device paths, not SDL GUIDs)
can hand Cemu 4 explicitly-assigned controllers where ES-DE/Cemu's own
SDL-based handling can't tell identical controllers apart.

- New generic binary VDF (KeyValues) parser/serializer: round-trips any
  existing shortcuts.vdf entries byte-for-byte and only inserts/replaces
  the one entry matching the given Exe path, so it's safe against a file
  that already has real, hand-configured shortcuts. Verified in isolated
  /tmp harnesses: fresh file, idempotent re-add, a second distinct entry,
  and preserving a synthetic pre-existing GUI-set entry (icon,
  LaunchOptions, tags) untouched.
- Steam's own CATALOG entry had no mount for emulators/ at all (unlike
  esde/retroarch) — added emulators:/home/retro/Applications so an
  AppImage is actually reachable from inside the Steam container. Like
  the ES-DE settings mount, this only takes effect on a freshly created
  WolfSteam container (Wolf reuses existing ones) — noted in the README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:52:31 +00:00
6 changed files with 1728 additions and 31 deletions
+20 -1
View File
@@ -623,6 +623,25 @@ in `services/authelia.sh`) — prompts for a new duration (`12h`, `7d`,
Sessions persist through reboots regardless of duration (Redis stores Sessions persist through reboots regardless of duration (Redis stores
session state in a volume). 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 **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` 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 key doesn't error, Authelia just silently ignores it — confirmed against
@@ -635,7 +654,7 @@ touch this by hand instead of the menu option, the current schema is:
session: session:
secret: 'your-existing-secret' secret: 'your-existing-secret'
expiration: 1h expiration: 1h
inactivity: 5m inactivity: 1y
remember_me: 1y remember_me: 1y
cookies: cookies:
- domain: 'example.com' - domain: 'example.com'
+1 -1
View File
@@ -186,7 +186,7 @@ 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 | | `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) | | `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`, `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) | | `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `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` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
+82
View File
@@ -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.
+646
View File
@@ -0,0 +1,646 @@
#!/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."
( 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"
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
+51 -10
View File
@@ -242,7 +242,8 @@ install_authelia() {
echo " 7) Reconfigure from scratch (regenerates secrets/users — breaks" echo " 7) Reconfigure from scratch (regenerates secrets/users — breaks"
echo " existing sessions for every domain already on this instance)" echo " existing sessions for every domain already on this instance)"
echo " 8) Show who has universal vs. service-scoped access" echo " 8) Show who has universal vs. service-scoped access"
echo " 9) Change \"Remember me\" session duration (stay logged in longer)" echo " 9) Change \"Remember me\" session duration (stay logged in longer — also"
echo " raises the inactivity timeout to match, so it can't cut it short)"
echo " 10) Protect an existing site with this instance (pick a local Caddy site," echo " 10) Protect an existing site with this instance (pick a local Caddy site,"
echo " or type one on a different box — gates it with a login, same as any" echo " or type one on a different box — gates it with a login, same as any"
echo " other service already protected this way)" echo " other service already protected this way)"
@@ -501,7 +502,12 @@ access_control:
session: session:
name: authelia_session name: authelia_session
expiration: 12h expiration: 12h
inactivity: 2h # Matches remember_me below, not a shorter default — an idle timeout
# shorter than remember_me silently cuts a "remembered" session short
# regardless of its own duration. See _authelia_set_remember_me()'s
# comment for the live case this caused. Change both together (that
# function does exactly this) rather than one at a time.
inactivity: 7d
remember_me: 7d remember_me: 7d
cookies: cookies:
- domain: ${AUTHELIA_DOMAIN} - domain: ${AUTHELIA_DOMAIN}
@@ -2383,6 +2389,19 @@ _authelia_report_access_scope() {
# earlier version of this very file's own README section) uses the old # earlier version of this very file's own README section) uses the old
# name, which Authelia would just silently ignore rather than error on. # name, which Authelia would just silently ignore rather than error on.
# #
# Also writes the SAME value into `inactivity` — a separate session field
# (default 2h, set alongside remember_me in install_authelia()'s own
# template) that ends a session after that much idle time regardless of
# remember_me, since it isn't disabled or extended by the "Remember me"
# checkbox. Confirmed live: a user who'd set remember_me to 1y still got
# logged out after ordinary daily gaps (overnight, a workday) because
# inactivity was still sitting at its 2h default — remember_me alone does
# NOT deliver "won't be asked to log in again for the duration I set"
# without this. Tying the two together is what actually delivers that.
# `expiration` (the session cap when "Remember me" is NOT checked) is left
# alone — a shorter default there for an un-remembered session is correct,
# separate behavior, not the same gap.
#
# This only controls AUTHELIA's own session — it does not touch how long # This only controls AUTHELIA's own session — it does not touch how long
# a native-OIDC app's (Gitea/Mealie/ActualBudget) own session/token lasts # a native-OIDC app's (Gitea/Mealie/ActualBudget) own session/token lasts
# after logging in via Authelia. A long remember_me makes re-authenticating # after logging in via Authelia. A long remember_me makes re-authenticating
@@ -2393,27 +2412,48 @@ _authelia_set_remember_me() {
local config_file="$DOCKER_DIR/authelia/config/configuration.yml" local config_file="$DOCKER_DIR/authelia/config/configuration.yml"
[ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; } [ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; }
local current local current current_inactivity
current="$(grep -E '^ remember_me:' "$config_file" | awk '{print $2}' | tr -d "'\"")" current="$(grep -E '^ remember_me:' "$config_file" | awk '{print $2}' | tr -d "'\"")"
current_inactivity="$(grep -E '^ inactivity:' "$config_file" | awk '{print $2}' | tr -d "'\"")"
echo "" echo ""
echo " Current \"remember me\" duration: ${current:-not set}" echo " Current \"remember me\" duration: ${current:-not set} (inactivity timeout: ${current_inactivity:-not set})"
echo " How long a session lasts when someone checks \"Remember me\" at login —" echo " How long a session lasts when someone checks \"Remember me\" at login —"
echo " applies to every domain this Authelia instance protects." echo " applies to every domain this Authelia instance protects. Also sets"
echo " \"inactivity\" (idle timeout) to the same value, so a gap between visits"
echo " shorter than this can't log you out early — otherwise inactivity's own"
echo " separate, much shorter default cuts a long remember_me short."
echo " Examples: 12h, 7d, 1M (month), 1y. Set to -1 to disable Remember Me entirely." echo " Examples: 12h, 7d, 1M (month), 1y. Set to -1 to disable Remember Me entirely."
local new_duration="" local new_duration=""
prompt_text " New duration [${current:-7d}]:" "${current:-7d}" new_duration prompt_text " New duration [${current:-7d}]:" "${current:-7d}" new_duration
if [ -z "$new_duration" ] || [ "$new_duration" = "$current" ]; then if [ -z "$new_duration" ]; then
log_info "No change made." log_info "No change made."
return 0 return 0
fi fi
# Only truly a no-op if BOTH keys already match — remember_me alone
# matching isn't enough to skip, or an install still carrying the old
# mismatched inactivity default (from before this function synced the
# two) could never actually get inactivity fixed by re-entering the
# same remember_me value. Confirmed live: this is exactly what
# happened on a box that had already set remember_me: 1y before this
# sync existed — re-running with "1y" again hit this early return and
# left inactivity untouched.
if [ "$new_duration" = "$current" ] && [ "$new_duration" = "$current_inactivity" ]; then
log_info "No change made — remember_me and inactivity already both ${new_duration}."
return 0
fi
if grep -qE '^ remember_me:' "$config_file"; then if grep -qE '^ remember_me:' "$config_file"; then
sed -i "s/^ remember_me:.*/ remember_me: '${new_duration}'/" "$config_file" sed -i "s/^ remember_me:.*/ remember_me: '${new_duration}'/" "$config_file"
else else
sed -i "/^session:\$/a\\ remember_me: '${new_duration}'" "$config_file" sed -i "/^session:\$/a\\ remember_me: '${new_duration}'" "$config_file"
fi fi
if grep -qE '^ inactivity:' "$config_file"; then
sed -i "s/^ inactivity:.*/ inactivity: '${new_duration}'/" "$config_file"
else
sed -i "/^ remember_me:/a\\ inactivity: '${new_duration}'" "$config_file"
fi
chown 1000:1000 "$config_file" 2>/dev/null || true chown 1000:1000 "$config_file" 2>/dev/null || true
log_success "\"Remember me\" duration set to ${new_duration}." log_success "\"Remember me\" duration and inactivity timeout both set to ${new_duration}."
local restart_auth="" local restart_auth=""
prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth
@@ -2425,9 +2465,10 @@ _authelia_set_remember_me() {
echo "" echo ""
log_info "Takes effect for NEW logins where \"Remember me\" is checked at Authelia's" log_info "Takes effect for NEW logins where \"Remember me\" is checked at Authelia's"
log_info "login page — existing sessions keep whatever expiration they already had." log_info "login page — existing sessions keep whatever expiration/inactivity they"
log_info "The checkbox itself is already on the login form by default; this only" log_info "already had. The checkbox itself is already on the login form by default;"
log_info "changes how long checking it actually keeps you signed in." log_info "this only changes how long checking it actually keeps you signed in, and"
log_info "stops the separate inactivity timeout from cutting that short."
} }
# Export/import accounts (+ optionally 2FA/session state) — for migrating to # Export/import accounts (+ optionally 2FA/session state) — for migrating to
+928 -19
View File
File diff suppressed because it is too large Load Diff