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 ships no shell so nothing inside the container can chown its own data dir at startup. The installer's final chown gave the whole instance directory to ACTUAL_USER, including ./data, which the container then can't write to — it fails outright the moment it tries to create anything under /data (e.g. a new user's collection), not just at sync time. Confirmed live. Fix: re-chown ./data to 65532:65532 specifically, applied *after* the existing ACTUAL_USER chown (not before — that call recurses over the whole instance dir and would just clobber it). docker-compose.yml/.env/ README.md stay owned by ACTUAL_USER as before. The same fix is applied in the "update" path so an already-broken existing install self-heals on the next non-destructive update, without touching its port, Caddy config, or accounts. Verified live: fresh install now leaves data/ owned by 65532:65532 while the rest of the instance dir stays ACTUAL_USER; simulating a pre-fix broken install (data/ owned by root) and running "update" correctly repairs it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
670 lines
30 KiB
Bash
670 lines
30 KiB
Bash
#!/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
|