Compare commits
122
Commits
165f3d3ecd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
697ee95461 |
@@ -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'
|
||||||
|
|||||||
@@ -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`, `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-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` |
|
||||||
@@ -240,6 +240,7 @@ utilities
|
|||||||
onlyoffice
|
onlyoffice
|
||||||
paintplus
|
paintplus
|
||||||
portainer
|
portainer
|
||||||
|
pressbooks
|
||||||
rustdesk
|
rustdesk
|
||||||
stirling-pdf
|
stirling-pdf
|
||||||
syncthing
|
syncthing
|
||||||
|
|||||||
@@ -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
|
||||||
+97
-23
@@ -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}
|
||||||
@@ -1356,6 +1362,29 @@ _authelia_gen_temp_password() {
|
|||||||
| fold -w1 | shuf | tr -d '\n'
|
| fold -w1 | shuf | tr -d '\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Lets the admin type a specific password instead of always getting an
|
||||||
|
# auto-generated one — same masked-input, "[Enter = auto-generate]"
|
||||||
|
# convention services/backup.sh/borg-backup.sh/koha.sh already use for their
|
||||||
|
# own passwords, rather than inventing a separate typed-vs-generated menu
|
||||||
|
# choice here. Sets two out-params (not `local` — read them after the call
|
||||||
|
# returns, same convention as OIDC_CLIENT_SECRET_PLAIN elsewhere in this
|
||||||
|
# file): AUTHELIA_CHOSEN_PASSWORD (the plaintext, never written to disk —
|
||||||
|
# only its argon2 hash is) and AUTHELIA_PASSWORD_AUTO_GENERATED (so callers
|
||||||
|
# can word their own "here's the password" message correctly either way).
|
||||||
|
_authelia_prompt_password() {
|
||||||
|
AUTHELIA_CHOSEN_PASSWORD=""
|
||||||
|
AUTHELIA_PASSWORD_AUTO_GENERATED=false
|
||||||
|
local _pw=""
|
||||||
|
if [ "$UNATTENDED" != true ]; then
|
||||||
|
read -rsp " Password [Enter = auto-generate]: " _pw; echo
|
||||||
|
fi
|
||||||
|
if [ -z "$_pw" ]; then
|
||||||
|
_pw="$(_authelia_gen_temp_password)"
|
||||||
|
AUTHELIA_PASSWORD_AUTO_GENERATED=true
|
||||||
|
fi
|
||||||
|
AUTHELIA_CHOSEN_PASSWORD="$_pw"
|
||||||
|
}
|
||||||
|
|
||||||
# Adds a new user to an EXISTING Authelia instance's users.yml — the scripted
|
# Adds a new user to an EXISTING Authelia instance's users.yml — the scripted
|
||||||
# version of the manual "generate a hash, paste a users.yml block, restart"
|
# version of the manual "generate a hash, paste a users.yml block, restart"
|
||||||
# steps this file's own generated README already documents. Non-destructive:
|
# steps this file's own generated README already documents. Non-destructive:
|
||||||
@@ -1376,8 +1405,9 @@ add_authelia_user() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Add a new user to this Authelia instance."
|
echo " Add a new user to this Authelia instance."
|
||||||
echo " They log in with their username (not email). A temporary password"
|
echo " They log in with their username (not email). You'll set a password"
|
||||||
echo " is generated below — hand it to them directly. \"Forgot Password\""
|
echo " next — type your own or leave it blank to auto-generate one — shown"
|
||||||
|
echo " once here either way, never stored in plaintext. \"Forgot Password\""
|
||||||
echo " and Authelia's own Settings → Change Password both require working"
|
echo " and Authelia's own Settings → Change Password both require working"
|
||||||
echo " SMTP (both email a one-time code), so until that's fixed, use this"
|
echo " SMTP (both email a one-time code), so until that's fixed, use this"
|
||||||
echo " menu's \"Edit an existing user\" → \"Reset password\" for future resets."
|
echo " menu's \"Edit an existing user\" → \"Reset password\" for future resets."
|
||||||
@@ -1399,9 +1429,9 @@ add_authelia_user() {
|
|||||||
local NEW_ADMIN_YN=""
|
local NEW_ADMIN_YN=""
|
||||||
prompt_yn " Grant admin group membership too? (y/n):" "n" NEW_ADMIN_YN
|
prompt_yn " Grant admin group membership too? (y/n):" "n" NEW_ADMIN_YN
|
||||||
|
|
||||||
log_info "Generating temporary password + hash..."
|
_authelia_prompt_password
|
||||||
local TEMP_PASS NEW_HASH
|
local TEMP_PASS="$AUTHELIA_CHOSEN_PASSWORD" NEW_HASH
|
||||||
TEMP_PASS="$(_authelia_gen_temp_password)"
|
log_info "Generating password hash..."
|
||||||
NEW_HASH=$(docker run --rm authelia/authelia:4.39.20 \
|
NEW_HASH=$(docker run --rm authelia/authelia:4.39.20 \
|
||||||
authelia crypto hash generate argon2 --password "$TEMP_PASS" 2>/dev/null \
|
authelia crypto hash generate argon2 --password "$TEMP_PASS" 2>/dev/null \
|
||||||
| grep -oP '(?<=Digest: ).*')
|
| grep -oP '(?<=Digest: ).*')
|
||||||
@@ -1439,8 +1469,12 @@ ${GROUPS_BLOCK}"
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " New user: ${NEW_USERNAME}"
|
echo " New user: ${NEW_USERNAME}"
|
||||||
echo " Temp password: ${TEMP_PASS}"
|
if [ "$AUTHELIA_PASSWORD_AUTO_GENERATED" = true ]; then
|
||||||
|
echo " Temp password: ${TEMP_PASS}"
|
||||||
|
else
|
||||||
|
echo " Password: ${TEMP_PASS} (the one you just typed)"
|
||||||
|
fi
|
||||||
echo " Give this to them directly (it's shown once, nothing stores it in"
|
echo " Give this to them directly (it's shown once, nothing stores it in"
|
||||||
echo " plaintext). They can log in with it as-is and keep using it, or"
|
echo " plaintext). They can log in with it as-is and keep using it, or"
|
||||||
echo " change it themselves from Authelia's Settings page — but that page"
|
echo " change it themselves from Authelia's Settings page — but that page"
|
||||||
@@ -2355,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
|
||||||
@@ -2365,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
|
||||||
@@ -2397,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
|
||||||
@@ -2697,7 +2766,7 @@ _authelia_manage_one_user() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Editing user: $TARGET (admin: $IS_ADMIN, 2FA-exempt: $IS_EXEMPT)"
|
echo " Editing user: $TARGET (admin: $IS_ADMIN, 2FA-exempt: $IS_EXEMPT)"
|
||||||
echo " 1) Edit email / display name"
|
echo " 1) Edit email / display name"
|
||||||
echo " 2) Reset password"
|
echo " 2) Set/reset password (type your own, or auto-generate)"
|
||||||
echo " 3) Reset 2FA device (they register a new one on next login)"
|
echo " 3) Reset 2FA device (they register a new one on next login)"
|
||||||
if [ "$IS_EXEMPT" = "yes" ]; then
|
if [ "$IS_EXEMPT" = "yes" ]; then
|
||||||
echo " 4) Restore the 2FA requirement for this user"
|
echo " 4) Restore the 2FA requirement for this user"
|
||||||
@@ -2729,9 +2798,9 @@ _authelia_manage_one_user() {
|
|||||||
log_success "Updated $TARGET's email/display name."
|
log_success "Updated $TARGET's email/display name."
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
log_info "Generating a new temporary password + hash..."
|
_authelia_prompt_password
|
||||||
local NEW_TEMP_PASS NEW_HASH
|
local NEW_TEMP_PASS="$AUTHELIA_CHOSEN_PASSWORD" NEW_HASH
|
||||||
NEW_TEMP_PASS="$(_authelia_gen_temp_password)"
|
log_info "Generating password hash..."
|
||||||
NEW_HASH=$(docker run --rm authelia/authelia:4.39.20 \
|
NEW_HASH=$(docker run --rm authelia/authelia:4.39.20 \
|
||||||
authelia crypto hash generate argon2 --password "$NEW_TEMP_PASS" 2>/dev/null \
|
authelia crypto hash generate argon2 --password "$NEW_TEMP_PASS" 2>/dev/null \
|
||||||
| grep -oP '(?<=Digest: ).*')
|
| grep -oP '(?<=Digest: ).*')
|
||||||
@@ -2740,8 +2809,13 @@ _authelia_manage_one_user() {
|
|||||||
else
|
else
|
||||||
_authelia_set_user_field "$USERS_FILE" "$START" "$END" "password" " password: \"${NEW_HASH}\""
|
_authelia_set_user_field "$USERS_FILE" "$START" "$END" "password" " password: \"${NEW_HASH}\""
|
||||||
chown 1000:1000 "$USERS_FILE" 2>/dev/null || true
|
chown 1000:1000 "$USERS_FILE" 2>/dev/null || true
|
||||||
log_success "Password reset for $TARGET."
|
if [ "$AUTHELIA_PASSWORD_AUTO_GENERATED" = true ]; then
|
||||||
echo " New password: ${NEW_TEMP_PASS}"
|
log_success "Password reset for $TARGET (auto-generated)."
|
||||||
|
echo " New password: ${NEW_TEMP_PASS}"
|
||||||
|
else
|
||||||
|
log_success "Password set for $TARGET."
|
||||||
|
echo " Password: ${NEW_TEMP_PASS} (the one you just typed)"
|
||||||
|
fi
|
||||||
echo " Give this to them directly — shown once, not stored in plaintext anywhere."
|
echo " Give this to them directly — shown once, not stored in plaintext anywhere."
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -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 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 Caddy reverse proxy install (full repo only)"
|
||||||
echo "[DRY-RUN] Would offer CrowdSec intrusion prevention 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"
|
echo "[DRY-RUN] Would offer to add SSH Host aliases to ~/.ssh/config"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -80,6 +81,14 @@ install_base() {
|
|||||||
_base_setup_crowdsec
|
_base_setup_crowdsec
|
||||||
cd "$_BASE_PWD" 2>/dev/null || true
|
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 ─────────────────────────────────────────────────────
|
# ── SSH Host aliases ─────────────────────────────────────────────────────
|
||||||
_base_setup_ssh_aliases
|
_base_setup_ssh_aliases
|
||||||
|
|
||||||
@@ -329,6 +338,22 @@ _base_setup_crowdsec() {
|
|||||||
install_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() {
|
_base_setup_ssh_aliases() {
|
||||||
local ADD_ALIAS=""
|
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
|
prompt_yn "Add an SSH Host alias now ('ssh myserver' instead of 'ssh user@1.2.3.4')? (y/n):" "n" ADD_ALIAS
|
||||||
|
|||||||
+342
-4
@@ -453,6 +453,11 @@ _gitea_remove_sync_timer() {
|
|||||||
# reconfigure of an existing one. Always asked (matches pstn-trunk.sh's
|
# reconfigure of an existing one. Always asked (matches pstn-trunk.sh's
|
||||||
# international-calling step reasoning: a live-editable extra, not a
|
# international-calling step reasoning: a live-editable extra, not a
|
||||||
# structural setting tied exclusively to fresh installs).
|
# 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() {
|
_gitea_run_sync_direction_step() {
|
||||||
local DIR="$1"
|
local DIR="$1"
|
||||||
|
|
||||||
@@ -463,12 +468,13 @@ _gitea_run_sync_direction_step() {
|
|||||||
echo " 3) Both directions"
|
echo " 3) Both directions"
|
||||||
local _DIR_CHOICE=""
|
local _DIR_CHOICE=""
|
||||||
prompt_text " Choice [1]:" "1" _DIR_CHOICE
|
prompt_text " Choice [1]:" "1" _DIR_CHOICE
|
||||||
local FLAG="" DIR_DESC=""
|
local DIR_DESC=""
|
||||||
case "$_DIR_CHOICE" in
|
case "$_DIR_CHOICE" in
|
||||||
2) FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
|
2) _GITEA_SYNC_FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
|
||||||
3) FLAG=""; DIR_DESC="both directions" ;;
|
3) _GITEA_SYNC_FLAG=""; DIR_DESC="both directions" ;;
|
||||||
*) FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
|
*) _GITEA_SYNC_FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
|
||||||
esac
|
esac
|
||||||
|
local FLAG="$_GITEA_SYNC_FLAG"
|
||||||
log_info "Sync direction: $DIR_DESC"
|
log_info "Sync direction: $DIR_DESC"
|
||||||
|
|
||||||
_gitea_remove_sync_timer
|
_gitea_remove_sync_timer
|
||||||
@@ -519,6 +525,250 @@ _gitea_run_sync_direction_step() {
|
|||||||
esac
|
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() {
|
install_gitea() {
|
||||||
log_info "Setting up self-hosted Gitea..."
|
log_info "Setting up self-hosted Gitea..."
|
||||||
|
|
||||||
@@ -529,12 +779,16 @@ install_gitea() {
|
|||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Would create $DIR with docker-compose.yml (gitea/gitea:latest)"
|
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 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] 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] 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 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] 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] 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 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 \"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] 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] and local Caddy are both installed — rewires Gitea onto caddy_net"
|
||||||
@@ -565,6 +819,7 @@ install_gitea() {
|
|||||||
&& log_success "Gitea refreshed and restarted." \
|
&& log_success "Gitea refreshed and restarted." \
|
||||||
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
||||||
_gitea_run_sync_direction_step "$DIR"
|
_gitea_run_sync_direction_step "$DIR"
|
||||||
|
_gitea_offer_realtime_webhook "$DIR" "$_GITEA_SYNC_FLAG"
|
||||||
_gitea_offer_authelia_sso "$DIR"
|
_gitea_offer_authelia_sso "$DIR"
|
||||||
_gitea_offer_reverse_proxy_auth "$DIR"
|
_gitea_offer_reverse_proxy_auth "$DIR"
|
||||||
_gitea_offer_actions_runner "$DIR"
|
_gitea_offer_actions_runner "$DIR"
|
||||||
@@ -730,6 +985,7 @@ ENV
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
_gitea_run_sync_direction_step "$DIR"
|
_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,
|
# ── 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
|
# unlike the no-auth-at-all apps elsewhere in this repo that need Caddy
|
||||||
@@ -738,6 +994,25 @@ ENV
|
|||||||
# replacement requiring Caddy involvement. ─────────────────────────────
|
# replacement requiring Caddy involvement. ─────────────────────────────
|
||||||
configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git"
|
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_authelia_sso "$DIR"
|
||||||
_gitea_offer_reverse_proxy_auth "$DIR"
|
_gitea_offer_reverse_proxy_auth "$DIR"
|
||||||
_gitea_offer_actions_runner "$DIR"
|
_gitea_offer_actions_runner "$DIR"
|
||||||
@@ -773,6 +1048,69 @@ Config (which repos, private/forks handling) lives at
|
|||||||
\`~/.config/gitea-github-sync/config\` — edit directly, or re-run
|
\`~/.config/gitea-github-sync/config\` — edit directly, or re-run
|
||||||
\`bash gitea-github-sync.sh --init\` to redo it interactively.
|
\`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)
|
## Sign in with Authelia (optional)
|
||||||
|
|
||||||
If Authelia is installed, re-run \`sudo ./setup.sh gitea\` (Update mode is
|
If Authelia is installed, re-run \`sudo ./setup.sh gitea\` (Update mode is
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+52
-3
@@ -174,6 +174,19 @@ CBLOCK
|
|||||||
[ -f "$_file" ] || return 0
|
[ -f "$_file" ] || return 0
|
||||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
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
|
fi
|
||||||
|
|
||||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||||
@@ -206,11 +219,29 @@ install_wolf-pair() {
|
|||||||
echo " - Build the wolf-pair image (python:3.12-alpine + docker-cli)"
|
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 " - Run the container with network_mode: host (for localhost:47989 access)"
|
||||||
echo " - Mount /var/run/docker.sock:ro (for docker logs wolf)"
|
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"
|
echo " - Optionally configure a Caddy reverse proxy"
|
||||||
return 0
|
return 0
|
||||||
fi
|
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"
|
mkdir -p "$WOLFPAIR_DIR"
|
||||||
ensure_docker_dir_ownership "$WOLFPAIR_DIR"
|
ensure_docker_dir_ownership "$WOLFPAIR_DIR"
|
||||||
cd "$WOLFPAIR_DIR" || return 1
|
cd "$WOLFPAIR_DIR" || return 1
|
||||||
@@ -234,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
|
"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).
|
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
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
WOLF_HTTP = "http://localhost:47989"
|
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
|
# 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.
|
# secret in here is dead — show the waiting page instead of re-offering it.
|
||||||
@@ -390,7 +422,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
HTTPServer.allow_reuse_address = True
|
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
|
PYEOF
|
||||||
log_success "server.py written"
|
log_success "server.py written"
|
||||||
|
|
||||||
@@ -420,12 +452,27 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: wolf-pair
|
container_name: wolf-pair
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- WOLFPAIR_PORT=${WOLFPAIR_PORT:-8090}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
COMPOSE
|
COMPOSE
|
||||||
log_success "docker-compose.yml written"
|
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"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR"
|
||||||
|
|
||||||
# ── 4. Caddy (optional) ───────────────────────────────────────────────────
|
# ── 4. Caddy (optional) ───────────────────────────────────────────────────
|
||||||
@@ -491,6 +538,8 @@ docker compose logs -f # follow logs
|
|||||||
- If you set up a Caddy subdomain (e.g. `wolf-pair.yourdomain.com`), that
|
- If you set up a Caddy subdomain (e.g. `wolf-pair.yourdomain.com`), that
|
||||||
subdomain is for the PIN form only.
|
subdomain is for the PIN form only.
|
||||||
MD
|
MD
|
||||||
|
[ "$WOLFPAIR_PORT" != "8090" ] && \
|
||||||
|
sed -i "s/localhost:8090/localhost:${WOLFPAIR_PORT}/g" "$WOLFPAIR_DIR/README.md"
|
||||||
|
|
||||||
# ── 7. Build & start ──────────────────────────────────────────────────────
|
# ── 7. Build & start ──────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
+2921
-100
File diff suppressed because it is too large
Load Diff
@@ -121,6 +121,7 @@ is_installed() {
|
|||||||
base) command -v ncdu >/dev/null 2>&1 ;;
|
base) command -v ncdu >/dev/null 2>&1 ;;
|
||||||
glow) command -v glow >/dev/null 2>&1 ;;
|
glow) command -v glow >/dev/null 2>&1 ;;
|
||||||
crowdsec) command -v cscli >/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 ] ;;
|
security-dashboard) [ -f /opt/security-dashboard/app.py ] ;;
|
||||||
kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
|
kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
|
||||||
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
|
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
|
||||||
@@ -156,7 +157,7 @@ is_installed() {
|
|||||||
# is_installed() as 0 or 1.
|
# is_installed() as 0 or 1.
|
||||||
install_count() {
|
install_count() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
base|glow|crowdsec|security-dashboard|kdeconnect|silent-send|sync-cc|claude-cli|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 ;;
|
is_installed "$1" && echo 1 || echo 0 ;;
|
||||||
wordpress)
|
wordpress)
|
||||||
find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'wordpress-*' -type d 2>/dev/null | wc -l ;;
|
find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'wordpress-*' -type d 2>/dev/null | wc -l ;;
|
||||||
|
|||||||
Vendored
+47
-15
@@ -277,17 +277,34 @@ sync_github_to_gitea() {
|
|||||||
# old commit indefinitely, with no error at any step. Also no longer
|
# old commit indefinitely, with no error at any step. Also no longer
|
||||||
# silencing stderr: a real auth/network failure should be visible in the
|
# silencing stderr: a real auth/network failure should be visible in the
|
||||||
# log, not just "Failed to fetch" with no reason why.
|
# log, not just "Failed to fetch" with no reason why.
|
||||||
|
local auth_url="${clone_url/https:\/\//https:\/\/$GITHUB_TOKEN@}"
|
||||||
if [[ -d "$local_path" ]]; then
|
if [[ -d "$local_path" ]]; then
|
||||||
info "Fetching $full_name from GitHub..."
|
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
|
else
|
||||||
info "Cloning $full_name from GitHub..."
|
info "Cloning $full_name from GitHub..."
|
||||||
mkdir -p "$(dirname "$local_path")"
|
mkdir -p "$(dirname "$local_path")"
|
||||||
local auth_url="${clone_url/https:\/\//https:\/\/$GITHUB_TOKEN@}"
|
git init --bare --quiet "$local_path" || { err "Failed to init $full_name"; return 1; }
|
||||||
git clone --bare --quiet "$auth_url" "$local_path" || {
|
git -C "$local_path" remote add origin "$auth_url"
|
||||||
err "Failed to clone $full_name"; return 1; }
|
|
||||||
fi
|
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
|
# Ensure repo exists on Gitea
|
||||||
local gitea_check
|
local gitea_check
|
||||||
@@ -299,12 +316,17 @@ sync_github_to_gitea() {
|
|||||||
>/dev/null || { err "Failed to create $repo_name on Gitea"; return 1; }
|
>/dev/null || { err "Failed to create $repo_name on Gitea"; return 1; }
|
||||||
fi
|
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@}"
|
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/http:\/\//http:\/\/$GITEA_USER:$GITEA_TOKEN@}"
|
||||||
gitea_push_url="$gitea_push_url/$GITEA_USER/$repo_name.git"
|
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; }
|
err "Failed to push $full_name to Gitea"; return 1; }
|
||||||
ok "GitHub → Gitea: $full_name"
|
ok "GitHub → Gitea: $full_name"
|
||||||
_log "PULL $full_name OK"
|
_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@}"
|
local gitea_auth_url="${clone_url/https:\/\//https:\/\/$GITEA_USER:$GITEA_TOKEN@}"
|
||||||
gitea_auth_url="${gitea_auth_url/http:\/\//http:\/\/$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
|
# See the matching comment in sync_github_to_gitea() above — same reason
|
||||||
# explicit-refspec, visible-stderr fix, 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
|
if [[ -d "$local_path" ]]; then
|
||||||
info "Fetching $full_name from Gitea..."
|
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
|
else
|
||||||
info "Cloning $full_name from Gitea..."
|
info "Cloning $full_name from Gitea..."
|
||||||
mkdir -p "$(dirname "$local_path")"
|
mkdir -p "$(dirname "$local_path")"
|
||||||
git clone --bare --quiet "$gitea_auth_url" "$local_path" || {
|
git init --bare --quiet "$local_path" || { err "Failed to init $full_name"; return 1; }
|
||||||
err "Failed to clone $full_name from Gitea"; return 1; }
|
git -C "$local_path" remote add origin "$gitea_auth_url"
|
||||||
fi
|
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
|
# Ensure repo exists on GitHub
|
||||||
local gh_check
|
local gh_check
|
||||||
@@ -343,9 +373,11 @@ sync_gitea_to_github() {
|
|||||||
>/dev/null || { err "Failed to create $repo_name on GitHub"; return 1; }
|
>/dev/null || { err "Failed to create $repo_name on GitHub"; return 1; }
|
||||||
fi
|
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"
|
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; }
|
err "Failed to push $full_name to GitHub"; return 1; }
|
||||||
ok "Gitea → GitHub: $full_name"
|
ok "Gitea → GitHub: $full_name"
|
||||||
_log "PUSH $full_name OK"
|
_log "PUSH $full_name OK"
|
||||||
|
|||||||
Reference in New Issue
Block a user