Merge branch 'main' into claude/zealous-feynman-odDrh

This commit is contained in:
Outis
2026-06-08 22:06:02 -04:00
committed by GitHub
24 changed files with 2970 additions and 48 deletions
+4 -4
View File
@@ -67,11 +67,11 @@ Update them any time with `sudo ./setup.sh configure`.
|-------|---------|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo) |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` |
| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
| `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `utilities` | `actualbudget`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `joplin`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` |
| `extras` | `silent-send`, `sync-cc` |
| `gaming` | `drum-rhythm-game`, `js99er`, `minecraft`, `wolf`, `wolf-pair` |
| `extras` | `kdeconnect`, `silent-send`, `sync-cc` |
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency); `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
Run `./setup.sh --list` to see descriptions.
+285
View File
@@ -0,0 +1,285 @@
#!/bin/bash
# services/archivebox.sh — Self-hosted web archiving (ArchiveBox).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash archivebox.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
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
}
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}'"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
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"
}
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}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service archivebox utilities "Self-hosted web archiving — save pages like Wayback Machine (ArchiveBox)" 8000
install_archivebox() {
require_docker || return 1
local AB_DIR="$DOCKER_DIR/archivebox"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] archivebox would:"
echo " - Create $AB_DIR with docker-compose.yml"
echo " - Initialize ArchiveBox data directory"
echo " - Expose port 8000 (web UI)"
return 0
fi
mkdir -p "$AB_DIR"
ensure_docker_dir_ownership "$AB_DIR"
cd "$AB_DIR" || return 1
cat > docker-compose.yml << 'ABCOMPOSE'
name: archivebox
services:
archivebox:
image: archivebox/archivebox:latest
container_name: archivebox
restart: unless-stopped
user: "1000:1000"
environment:
- ALLOWED_HOSTS=*
- MEDIA_MAX_SIZE=750m
- PUBLIC_INDEX=True
- PUBLIC_SNAPSHOTS=True
- PUBLIC_ADD_VIEW=False
volumes:
- ./data:/data
ports:
- "8000:8000"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
ABCOMPOSE
cat > .env << ABENV
CADDY_NET=$SITE_CADDY_NET
ABENV
mkdir -p data
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR"
log_info "Initializing ArchiveBox data directory..."
docker compose run --rm archivebox init --setup \
&& log_success "ArchiveBox initialized" \
|| log_warning "Init failed — will retry on first start"
configure_caddy_for_service "ArchiveBox" "archivebox:8000" "archive"
write_readme "$AB_DIR" << 'MD'
# ArchiveBox
Self-hosted web archiving — saves full snapshots of web pages (HTML, screenshots,
PDFs, WARC) like a personal Wayback Machine.
- Web UI: http://localhost:8000
## Manage
```bash
cd ~/docker/archivebox
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose up -d # update
```
## Add URLs to archive
```bash
# Via web UI — visit http://localhost:8000 and use the Add page
# Via CLI:
echo "https://example.com" | docker compose run --rm archivebox add
docker compose run --rm archivebox add --depth=1 https://example.com
```
## Create admin user
```bash
docker compose run --rm archivebox manage createsuperuser
```
MD
local START_AB=""
prompt_yn "Start ArchiveBox now? (y/n):" "y" START_AB
if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then
docker compose up -d \
&& log_success "ArchiveBox started — http://localhost:8000" \
|| log_warning "Start failed — check: docker compose logs"
fi
echo ""
echo " Web UI: http://localhost:8000"
echo " Add URLs via the web UI or: echo 'URL' | docker compose run --rm archivebox add"
echo ""
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_archivebox
+2 -2
View File
@@ -2,7 +2,7 @@
# services/base.sh — essential CLI packages installed on every box.
# Part of the modular post-install system (sourced by setup.sh).
register_service base base "Essential CLI packages (net-tools, git, htop, glow, …)"
register_service base base "Essential CLI packages (net-tools, git, htop, btop, glow, …)"
install_base() {
log_info "Installing essential packages..."
@@ -10,7 +10,7 @@ install_base() {
# Core utilities present on every install.
run_cmd apt-get install -y \
net-tools ncdu git curl wget htop tree zip unzip \
net-tools ncdu git curl wget htop btop tree zip unzip \
ca-certificates gnupg jq rsync || log_warning "Some essential packages failed to install"
# glow — terminal markdown reader (charmbracelet). Not in Ubuntu repos,
+287
View File
@@ -0,0 +1,287 @@
#!/bin/bash
# services/calibre-web.sh — Ebook library web UI with metadata editing (Calibre-Web).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash calibre-web.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
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
}
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}'"
}
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"
}
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}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service calibre-web media "Ebook library web UI with metadata editing (Calibre-Web)" 8083
install_calibre_web() {
require_docker || return 1
log_info "Installing Calibre-Web..."
local CW_DIR="$DOCKER_DIR/calibre-web"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $CW_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
return 0
fi
mkdir -p "$CW_DIR/config" "$CW_DIR/books"
ensure_docker_dir_ownership "$CW_DIR"
cd "$CW_DIR" || return 1
cat > docker-compose.yml << CW_COMPOSE
name: calibre-web
services:
calibre-web:
image: lscr.io/linuxserver/calibre-web:latest
container_name: calibre-web
hostname: calibre-web
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=${SITE_TZ:-UTC}
- DOCKER_MODS=linuxserver/mods:universal-calibre
volumes:
- ./config:/config
- ./books:/books
ports:
- "8083:8083"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
CW_COMPOSE
cat > .env << CW_ENV
CADDY_NET=$SITE_CADDY_NET
CW_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CW_DIR"
echo ""
log_success "Calibre-Web configured at $CW_DIR"
configure_caddy_for_service "Calibre-Web" "calibre-web:8083" "books"
write_readme "$CW_DIR" << 'MD'
# Calibre-Web
Web-based ebook library with metadata editing, reading, and format conversion
powered by Calibre.
## First-run setup
1. Open the UI (http://localhost:8083) and log in with admin / admin123
2. When prompted for the database location, enter: `/books`
(point this at your existing Calibre library or an empty directory)
3. Change the default password immediately under Admin → Edit User
## Ebook conversion
The `DOCKER_MODS=linuxserver/mods:universal-calibre` environment variable
installs the full Calibre binary inside the container, enabling on-the-fly
ebook conversion (e.g. EPUB → MOBI/AZW3).
## Books directory
Place your Calibre library (or individual books) in:
~/docker/calibre-web/books/
If you already have a Calibre library elsewhere, mount that path instead by
editing the `./books:/books` volume line in docker-compose.yml.
## Manage
```bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose down && docker compose up -d
```
MD
local START_CW=""
prompt_yn "Start Calibre-Web now? (y/n):" "y" START_CW
if [ "$START_CW" = "y" ] || [ "$START_CW" = "Y" ]; then
docker compose up -d 2>/dev/null \
&& log_success "Calibre-Web started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:8083"
echo " Default login: admin / admin123 (change immediately!)"
echo " Point the database to /books on first run."
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_calibre_web
+288
View File
@@ -0,0 +1,288 @@
#!/bin/bash
# services/changedetection.sh — Web page change detection and notification (Changedetection.io).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash changedetection.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
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
}
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}'"
}
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"
}
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}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service changedetection utilities "Web page change detection and notification (Changedetection.io)" 5000
install_changedetection() {
require_docker || return 1
log_info "Installing Changedetection.io..."
local CD_DIR="$DOCKER_DIR/changedetection"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $CD_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
return 0
fi
mkdir -p "$CD_DIR/data"
ensure_docker_dir_ownership "$CD_DIR"
cd "$CD_DIR" || return 1
cat > docker-compose.yml << 'CD_COMPOSE'
name: changedetection
services:
changedetection:
image: ghcr.io/dgtlmoon/changedetection.io:latest
container_name: changedetection
hostname: changedetection
restart: unless-stopped
ports:
- "5000:5000"
environment:
- BASE_URL=${BASE_URL}
- PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000
volumes:
- ./data:/datastore
networks:
- caddy_net
depends_on:
- playwright-chrome
playwright-chrome:
image: browserless/chrome:latest
container_name: playwright-chrome
hostname: playwright-chrome
restart: unless-stopped
environment:
- DEFAULT_LAUNCH_ARGS=--no-sandbox --disable-dev-shm-usage
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
CD_COMPOSE
cat > .env << CD_ENV
# Changedetection.io environment — edit before starting if needed
BASE_URL=https://changes.${SITE_DOMAIN}
CADDY_NET=${SITE_CADDY_NET}
CD_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CD_DIR"
chmod 600 "$CD_DIR/.env"
echo ""
log_success "Changedetection.io configured at $CD_DIR"
configure_caddy_for_service "Changedetection" "changedetection:5000" "changes"
write_readme "$CD_DIR" << 'MD'
# Changedetection.io
Monitor web pages for changes and receive notifications via email, Slack,
Discord, ntfy, and many other channels. Includes a Playwright/Chrome sidecar
for JavaScript-heavy pages.
## Access
- URL: http://localhost:5000
- Optional password can be set in Settings → General within the UI.
## Manage
```bash
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose down && docker compose up -d # update
```
## Environment
Edit `.env` to change `BASE_URL` (used for notification links),
then restart: `docker compose down && docker compose up -d`
MD
local START_CD=""
prompt_yn "Start Changedetection.io now? (y/n):" "y" START_CD
if [ "$START_CD" = "y" ] || [ "$START_CD" = "Y" ]; then
docker compose up -d 2>/dev/null \
&& log_success "Changedetection.io started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:5000"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_changedetection
+298
View File
@@ -0,0 +1,298 @@
#!/bin/bash
# services/drum-rhythm-game.sh — Browser-based drum rhythm game (outis1one/drum-rhythm-game).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash drum-rhythm-game.sh
# (Docker must already be installed when run standalone)
#
# Serves a single self-contained index.html via nginx. No login — protect
# with Authelia via Caddy if you want access control.
# Source: https://github.com/outis1one/drum-rhythm-game
# ── 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
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
}
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}'"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
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"
}
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}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service drum-rhythm-game gaming "Browser-based drum rhythm game (outis1one/drum-rhythm-game)" 8096
install_drum-rhythm-game() {
require_docker || return 1
local DRUM_DIR="$DOCKER_DIR/drum-rhythm-game"
local REPO_URL="https://github.com/outis1one/drum-rhythm-game.git"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] drum-rhythm-game would:"
echo " - Clone $REPO_URL to $DRUM_DIR/html"
echo " - Serve index.html via nginx on port 8096"
echo " - Offer Authelia SSO protection via Caddy (no built-in auth)"
return 0
fi
mkdir -p "$DRUM_DIR"
ensure_docker_dir_ownership "$DRUM_DIR"
cd "$DRUM_DIR" || return 1
# Clone or update the game source
if [ -d "$DRUM_DIR/html/.git" ]; then
log_info "Updating drum-rhythm-game source..."
git -C "$DRUM_DIR/html" pull --ff-only 2>/dev/null \
&& log_success "Updated to latest" \
|| log_warning "Could not pull latest — using existing version"
else
log_info "Cloning drum-rhythm-game..."
git clone --depth 1 "$REPO_URL" "$DRUM_DIR/html" \
|| { log_error "Clone failed — check network and git access"; return 1; }
fi
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DRUM_DIR/html"
cat > docker-compose.yml << 'DRUM_COMPOSE'
name: drum-rhythm-game
services:
drum-rhythm-game:
image: nginx:alpine
container_name: drum-rhythm-game
hostname: drum-rhythm-game
restart: unless-stopped
volumes:
- ./html:/usr/share/nginx/html:ro
ports:
- "8096:80"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
DRUM_COMPOSE
cat > .env << DRUM_ENV
CADDY_NET=${SITE_CADDY_NET}
DRUM_ENV
ensure_docker_dir_ownership "$DRUM_DIR"
log_success "drum-rhythm-game configured at $DRUM_DIR"
# No built-in auth — offer Authelia SSO protection
local DRUM_EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
prompt_yn "Protect drum-rhythm-game with Authelia SSO? (y/n):" "y" _use_auth
[[ "$_use_auth" =~ ^[Yy]$ ]] && DRUM_EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "Drum Rhythm Game" "drum-rhythm-game:80" "drums" "$DRUM_EXTRA_BLOCK"
write_readme "$DRUM_DIR" << 'MD'
# Drum Rhythm Game
Browser-based drum rhythm game — 124 synthesized orchestra pieces across
18 genres, 120 drum patterns. Supports keyboard and USB drum controllers.
No server required; all audio synthesized in-browser via Web Audio API.
Source: https://github.com/outis1one/drum-rhythm-game
## Access
- URL: http://localhost:8096
## Manage
```bash
cd ~/docker/drum-rhythm-game
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
```
## Update game
```bash
cd ~/docker/drum-rhythm-game
git -C html pull
docker compose restart
```
MD
local START_DRUM=""
prompt_yn "Start drum-rhythm-game now? (y/n):" "y" START_DRUM
if [ "$START_DRUM" = "y" ] || [ "$START_DRUM" = "Y" ]; then
docker compose up -d \
&& log_success "Drum Rhythm Game started — http://localhost:8096" \
|| log_warning "Start failed — check: docker compose logs"
fi
echo ""
echo " URL: http://localhost:8096"
echo " Controls: keyboard or USB drum controller"
echo " Update: git -C $DRUM_DIR/html pull && docker compose -f $DRUM_DIR/docker-compose.yml restart"
echo ""
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_drum-rhythm-game
+19
View File
@@ -278,6 +278,25 @@ HA_CONFIG
configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home"
fi
write_readme "$HOMEASSISTANT_DIR" << MD
# Home Assistant
Home automation hub. Built-in auth — no Authelia needed.
## Access
- URL: http://localhost:8123
- First run: create your admin account through the onboarding wizard
## Manage
\`\`\`bash
cd $HOMEASSISTANT_DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose up -d # update
\`\`\`
MD
local START_HA=""
prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA
if [ "$START_HA" = "y" ] || [ "$START_HA" = "Y" ]; then
+277
View File
@@ -0,0 +1,277 @@
#!/bin/bash
# services/homebox.sh — Home inventory and asset management (Homebox).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash homebox.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
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
}
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}'"
}
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"
}
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}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service homebox utilities "Home inventory and asset management (Homebox)" 7745
install_homebox() {
require_docker || return 1
log_info "Installing Homebox..."
local HB_DIR="$DOCKER_DIR/homebox"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $HB_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
return 0
fi
mkdir -p "$HB_DIR/data"
ensure_docker_dir_ownership "$HB_DIR"
cd "$HB_DIR" || return 1
cat > docker-compose.yml << HB_COMPOSE
name: homebox
services:
homebox:
image: ghcr.io/sysadminsmedia/homebox:latest
container_name: homebox
hostname: homebox
restart: unless-stopped
environment:
- HBOX_LOG_LEVEL=info
- HBOX_WEB_MAX_UPLOAD_SIZE=10
volumes:
- ./data:/data
ports:
- "7745:7745"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
HB_COMPOSE
cat > .env << HB_ENV
CADDY_NET=$SITE_CADDY_NET
HB_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HB_DIR"
echo ""
log_success "Homebox configured at $HB_DIR"
configure_caddy_for_service "Homebox" "homebox:7745" "homebox"
write_readme "$HB_DIR" << 'MD'
# Homebox
Home inventory and asset management. Track items, locations, labels,
warranties, and attachments across your household.
## Access
- URL: http://localhost:7745
- Register your account on first visit — the first user becomes the admin.
## Data
- All inventory data and attachments are stored in: ./data/
## Configuration
Key environment variables (edit docker-compose.yml to change):
- `HBOX_LOG_LEVEL` — log verbosity (info, debug, warn, error)
- `HBOX_WEB_MAX_UPLOAD_SIZE` — max attachment upload size in MB (default: 10)
## Manage
```bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose down && docker compose up -d
```
MD
local START_HB=""
prompt_yn "Start Homebox now? (y/n):" "y" START_HB
if [ "$START_HB" = "y" ] || [ "$START_HB" = "Y" ]; then
docker compose up -d 2>/dev/null \
&& log_success "Homebox started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:7745"
echo " Register your account on first visit."
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_homebox
+316
View File
@@ -0,0 +1,316 @@
#!/bin/bash
# services/joplin.sh — Self-hosted Joplin sync server for notes.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash joplin.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
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
}
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}'"
}
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"
}
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}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service joplin utilities "Self-hosted Joplin sync server for notes" 22300
install_joplin() {
require_docker || return 1
log_info "Installing Joplin Server..."
local JOPLIN_DIR="$DOCKER_DIR/joplin"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $JOPLIN_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
return 0
fi
mkdir -p "$JOPLIN_DIR"
ensure_docker_dir_ownership "$JOPLIN_DIR"
cd "$JOPLIN_DIR" || return 1
local DB_PASS
DB_PASS="$(generate_password 32)"
local BASE_URL="https://joplin.${SITE_DOMAIN}"
cat > docker-compose.yml << 'JOPLIN_COMPOSE'
name: joplin
services:
joplin:
image: joplin/server:latest
container_name: joplin
hostname: joplin
restart: unless-stopped
depends_on:
- joplin-db
env_file: .env
ports:
- "22300:22300"
networks:
- caddy_net
joplin-db:
image: postgres:15-alpine
container_name: joplin-db
hostname: joplin-db
restart: unless-stopped
env_file: .env
volumes:
- ./db-data:/var/lib/postgresql/data
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
JOPLIN_COMPOSE
cat > .env << JOPLIN_ENV
# Joplin Server configuration
APP_PORT=22300
# Must match the public URL — update if your domain changes
APP_BASE_URL=${BASE_URL}
# Database connection (Joplin Server)
DB_CLIENT=pg
POSTGRES_HOST=joplin-db
POSTGRES_DATABASE=joplin
POSTGRES_USER=joplin
POSTGRES_PASSWORD=${DB_PASS}
# PostgreSQL sidecar
POSTGRES_DB=joplin
# Caddy network
CADDY_NET=${SITE_CADDY_NET}
JOPLIN_ENV
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JOPLIN_DIR"
echo ""
log_success "Joplin Server configured at $JOPLIN_DIR"
log_info "APP_BASE_URL set to: $BASE_URL"
log_warning "APP_BASE_URL in .env must match the public URL used by Joplin clients."
configure_caddy_for_service "Joplin" "joplin:22300" "joplin"
write_readme "$JOPLIN_DIR" << MD
# Joplin Server
Self-hosted sync server for the Joplin note-taking app.
## Access
- URL: http://localhost:22300
- Default admin: admin@localhost / admin (change immediately after first login!)
## Important
\`APP_BASE_URL\` in \`.env\` must exactly match the public URL your Joplin
clients connect to (e.g. https://joplin.example.com). If this URL changes,
update .env and restart the stack.
## Client setup
In the Joplin desktop or mobile app:
Tools → Options → Synchronisation → Synchronisation target: Joplin Server
Enter your server URL, email, and password.
## Manage
\`\`\`bash
cd $JOPLIN_DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose down && docker compose up -d # update
\`\`\`
## Files
- docker-compose.yml — stack definition
- .env — secrets and config (chmod 600)
- db-data/ — PostgreSQL data volume
MD
local START_JOPLIN=""
prompt_yn "Start Joplin Server now? (y/n):" "y" START_JOPLIN
if [ "$START_JOPLIN" = "y" ] || [ "$START_JOPLIN" = "Y" ]; then
docker compose up -d 2>/dev/null \
&& log_success "Joplin Server started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:22300"
echo " Default login: admin@localhost / admin (change immediately!)"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_joplin
+25 -2
View File
@@ -455,7 +455,13 @@ COMPOSE
log_success "js99er configured at $JS99ER_DIR"
# ── 5. Reverse proxy (no-ops if Caddy isn't installed locally) ───────────
configure_caddy_for_service "js99er" "js99er:80" "js99er"
local JS99ER_EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
prompt_yn "Protect js99er with Authelia SSO? (y/n):" "y" _use_auth
[[ "$_use_auth" =~ ^[Yy]$ ]] && JS99ER_EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "js99er" "js99er:80" "js99er" "$JS99ER_EXTRA_BLOCK"
# ── 6. Build & start ─────────────────────────────────────────────────────
local START_JS99ER=""
@@ -477,7 +483,24 @@ COMPOSE
fi
fi
# ── 7. Access summary ────────────────────────────────────────────────────
write_readme "$JS99ER_DIR" << MD
# js99er — TI-99/4A Emulator
Browser-based TI-99/4A emulator. No built-in auth — protect with Authelia if exposing externally.
## Access
- URL: http://localhost:${JS99ER_PORT}
- Online (no install): https://js99er.net
## Manage
\`\`\`bash
cd $JS99ER_DIR
docker compose up -d --build # start (or rebuild)
docker compose down # stop
docker compose logs -f # logs
\`\`\`
MD
echo ""
echo " Access at: http://localhost:${JS99ER_PORT}"
echo " If you set a domain above, it is also reachable via that domain (HTTPS)."
+153
View File
@@ -0,0 +1,153 @@
#!/bin/bash
# services/kdeconnect.sh — Phone/desktop integration via KDE Connect.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash kdeconnect.sh
#
# KDE Connect is an APT package — NOT a Docker service.
# It enables Android/iPhone ↔ Linux integration:
# • Shared clipboard • File transfer
# • Phone notifications on desktop • Remote input (trackpad/keyboard)
# • SMS from desktop • Battery status
#
# NOTE: setup.sh is_installed() needs a case entry for this service:
# kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
# ── 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; }
# 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}'"
}
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 kdeconnect extras "Phone/desktop integration — notifications, clipboard, file transfer (KDE Connect)"
install_kdeconnect() {
log_info "Installing KDE Connect phone/desktop integration..."
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ KDE CONNECT — Phone / Desktop Integration │"
echo "│ Shared clipboard, notifications, file transfer, remote input │"
echo "│ Works with Android (Play Store / F-Droid) and iPhone (App Store)│"
echo "└─────────────────────────────────────────────────────────────────┘"
echo ""
# ── Already installed? ────────────────────────────────────────────────────
if command -v kdeconnect &>/dev/null; then
log_info "KDE Connect is already installed — skipping."
return 0
fi
# ── DRY-RUN: describe the plan and bail before touching anything real ─────
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would install: kdeconnect"
if dpkg -l gnome-shell 2>/dev/null | grep -q ^ii; then
echo "[DRY-RUN] Would install: indicator-kdeconnect (GNOME/Ubuntu detected)"
fi
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
echo "[DRY-RUN] Would open UFW ports 1714:1764/tcp and 1714:1764/udp"
fi
return 0
fi
# ── 1. Install kdeconnect ─────────────────────────────────────────────────
log_info "Installing kdeconnect package..."
if apt-get install -y kdeconnect; then
log_success "kdeconnect installed"
else
log_error "Failed to install kdeconnect — check apt output above"
return 1
fi
# ── 2. GNOME indicator (Ubuntu/GNOME only) ────────────────────────────────
if dpkg -l gnome-shell 2>/dev/null | grep -q ^ii; then
log_info "GNOME detected — installing indicator-kdeconnect for system tray support..."
if apt-get install -y indicator-kdeconnect; then
log_success "indicator-kdeconnect installed"
else
log_warning "Could not install indicator-kdeconnect — continuing without it"
fi
fi
# ── 3. Open UFW firewall ports (KDE Connect port range) ───────────────────
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
log_info "Opening UFW ports 1714:1764/tcp and 1714:1764/udp (KDE Connect)..."
ufw allow 1714:1764/tcp
ufw allow 1714:1764/udp
log_success "UFW ports 1714-1764 opened"
else
log_info "UFW not active — skipping firewall rules"
echo " If you enable UFW later, run:"
echo " sudo ufw allow 1714:1764/tcp"
echo " sudo ufw allow 1714:1764/udp"
fi
# ── 4. Usage instructions ─────────────────────────────────────────────────
echo ""
echo " ┌─ Next steps ────────────────────────────────────────────────────┐"
echo " │ 1. Install KDE Connect on your phone: │"
echo " │ Android: Play Store or F-Droid → search 'KDE Connect' │"
echo " │ iPhone: App Store → search 'KDE Connect' │"
echo " │ │"
echo " │ 2. Ensure your phone and computer are on the same WiFi network. │"
echo " │ │"
echo " │ 3. Open KDE Connect on your phone — your computer should │"
echo " │ appear automatically. Tap it and accept the pairing request │"
echo " │ on both devices. │"
echo " │ │"
echo " │ Linux Mint Cinnamon: a system tray applet is available — │"
echo " │ right-click the desktop → Applets → search 'KDE Connect' │"
echo " │ and add it to your panel. │"
echo " └──────────────────────────────────────────────────────────────────┘"
echo ""
log_success "KDE Connect installation complete"
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_kdeconnect
+9 -1
View File
@@ -317,7 +317,15 @@ MM_COMPOSE
log_success "MagicMirror instance $i configured at $MM_DIR (port $MM_PORT)"
# Offer Caddy only for first instance
[ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror"
if [ "$i" -eq 1 ]; then
local MM_EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
prompt_yn "Protect MagicMirror with Authelia SSO? (y/n):" "y" _use_auth
[[ "$_use_auth" =~ ^[Yy]$ ]] && MM_EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror" "$MM_EXTRA_BLOCK"
fi
local START_MM=""
prompt_yn "Start instance $i now? (y/n):" "y" START_MM
+15 -1
View File
@@ -205,6 +205,13 @@ install_mealie() {
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER")
# BASE_URL must match the public URL Mealie is served on (used for email links,
# OAuth redirects, and the web app manifest). Default to SITE_DOMAIN if set.
local MEALIE_BASE_URL="http://localhost:9925"
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
MEALIE_BASE_URL="https://recipes.${SITE_DOMAIN}"
fi
cat > docker-compose.yml << MEALIE_COMPOSE
name: mealie
@@ -214,6 +221,7 @@ services:
container_name: mealie
hostname: mealie
restart: unless-stopped
env_file: .env
environment:
- PUID=$UID_VAL
- PGID=$GID_VAL
@@ -221,7 +229,6 @@ services:
- ALLOW_SIGNUP=true
- MAX_WORKERS=1
- WEB_CONCURRENCY=1
- BASE_URL=http://localhost:9925
volumes:
- ./data:/app/data
ports:
@@ -235,6 +242,13 @@ networks:
name: \${CADDY_NET:-caddy_net}
MEALIE_COMPOSE
cat > .env << MEALIE_ENV
# Public URL Mealie is served on — used for email links and OAuth redirects.
# Update if you change your domain or switch from HTTP to HTTPS.
BASE_URL=$MEALIE_BASE_URL
CADDY_NET=$SITE_CADDY_NET
MEALIE_ENV
mkdir -p data
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR"
log_success "Mealie configured at $MEALIE_DIR"
+19
View File
@@ -2490,6 +2490,25 @@ NETEOF
echo " Run: sudo ./setup.sh backup"
echo ""
write_readme "$MC_DIR" << MD
# Minecraft — ${MC_NAME}
## Manage
\`\`\`bash
cd $MC_DIR
docker compose up -d --build # start (builds image on first run)
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose up -d --build # update
\`\`\`
## Backups
World data lives in \`./data\` — covered by Kopia/Borg if installed.
\`\`\`bash
sudo ./setup.sh backup
\`\`\`
MD
# ── Optional: start server and run pre-gen now ──────────────────────────────
local START_MC=""
prompt_yn "Start the Minecraft server now? (first build takes a few minutes) (y/n) [y]:" "y" START_MC
+283
View File
@@ -0,0 +1,283 @@
#!/bin/bash
# services/n8n.sh — Workflow automation — connect all your self-hosted services (n8n).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash n8n.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
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
}
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}'"
}
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"
}
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}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service n8n utilities "Workflow automation — connect all your self-hosted services (n8n)" 5678
install_n8n() {
require_docker || return 1
log_info "Installing n8n..."
local N8N_DIR="$DOCKER_DIR/n8n"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $N8N_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
return 0
fi
mkdir -p "$N8N_DIR/data"
ensure_docker_dir_ownership "$N8N_DIR"
cd "$N8N_DIR" || return 1
cat > docker-compose.yml << 'N8N_COMPOSE'
name: n8n
services:
n8n:
image: n8nio/n8n:latest
container_name: n8n
hostname: n8n
restart: unless-stopped
ports:
- "5678:5678"
env_file:
- .env
environment:
- N8N_PORT=5678
- N8N_PROTOCOL=https
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC}
- N8N_HOST=${N8N_HOST:-n8n}
- WEBHOOK_URL=${WEBHOOK_URL}
volumes:
- ./data:/home/node/.n8n
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
N8N_COMPOSE
cat > .env << N8N_ENV
# n8n environment — edit before starting if needed
N8N_HOST=n8n
WEBHOOK_URL=https://n8n.${SITE_DOMAIN}
GENERIC_TIMEZONE=${SITE_TZ}
CADDY_NET=${SITE_CADDY_NET}
N8N_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$N8N_DIR"
chmod 600 "$N8N_DIR/.env"
echo ""
log_success "n8n configured at $N8N_DIR"
configure_caddy_for_service "n8n" "n8n:5678" "n8n"
write_readme "$N8N_DIR" << 'MD'
# n8n
Workflow automation platform — connect all your self-hosted services with
a visual editor. Create webhooks, scheduled jobs, and multi-step automations.
## Access
- URL: http://localhost:5678
- On first run, n8n prompts you to create an owner account.
## Manage
```bash
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose down && docker compose up -d # update
```
## Environment
Edit `.env` to change `WEBHOOK_URL` or `N8N_HOST` after deployment,
then restart: `docker compose down && docker compose up -d`
MD
local START_N8N=""
prompt_yn "Start n8n now? (y/n):" "y" START_N8N
if [ "$START_N8N" = "y" ] || [ "$START_N8N" = "Y" ]; then
docker compose up -d 2>/dev/null \
&& log_success "n8n started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:5678"
echo " Create your owner account on first visit."
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_n8n
+22
View File
@@ -235,6 +235,28 @@ CADDY_NET=$SITE_CADDY_NET
NTFY_ENV
mkdir -p cache config
# Write server.yml — ntfy needs base-url for correct push notification links
# Only on fresh install; never clobber existing config
if [ ! -f config/server.yml ]; then
local NTFY_BASE_URL=""
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
NTFY_BASE_URL="https://ntfy.${SITE_DOMAIN}"
fi
cat > config/server.yml << NTFY_CFG
# ntfy server configuration — https://docs.ntfy.sh/config/
base-url: "${NTFY_BASE_URL:-https://ntfy.example.com}" # UPDATE to your actual domain
cache-file: /var/cache/ntfy/cache.db
cache-duration: 12h
auth-file: /var/cache/ntfy/auth.db
auth-default-access: deny-all
behind-proxy: true
NTFY_CFG
[[ -n "$NTFY_BASE_URL" ]] \
&& log_info "base-url set to $NTFY_BASE_URL — update if domain changes" \
|| log_warning "base-url set to placeholder — edit config/server.yml after install"
fi
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR"
echo ""
+305
View File
@@ -0,0 +1,305 @@
#!/bin/bash
# services/stirling-pdf.sh — PDF toolkit — merge, split, compress, OCR (Stirling PDF).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash stirling-pdf.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
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
}
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}'"
}
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"
}
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}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service stirling-pdf utilities "PDF toolkit — merge, split, compress, OCR (Stirling PDF)" 8070
install_stirling_pdf() {
require_docker || return 1
log_info "Installing Stirling PDF..."
local PDF_DIR="$DOCKER_DIR/stirling-pdf"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $PDF_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
return 0
fi
mkdir -p "$PDF_DIR"
ensure_docker_dir_ownership "$PDF_DIR"
cd "$PDF_DIR" || return 1
cat > docker-compose.yml << 'PDF_COMPOSE'
name: stirling-pdf
services:
stirling-pdf:
image: frooodle/s-pdf:latest
container_name: stirling-pdf
hostname: stirling-pdf
restart: unless-stopped
env_file: .env
ports:
- "8070:8080"
volumes:
- ./training-data:/usr/share/tessdata
- ./extraConfigs:/configs
- ./logs:/logs
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
PDF_COMPOSE
cat > .env << PDF_ENV
# Stirling PDF configuration
# Set to true to enable login/user management (requires restart)
DOCKER_ENABLE_SECURITY=false
# Set to true to install LibreOffice for advanced HTML/book conversion ops
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false
# Caddy network
CADDY_NET=${SITE_CADDY_NET}
PDF_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PDF_DIR"
echo ""
log_success "Stirling PDF configured at $PDF_DIR"
log_info "Security/login is disabled by default (DOCKER_ENABLE_SECURITY=false)."
log_info "To enable built-in auth, set DOCKER_ENABLE_SECURITY=true in .env and restart."
# No built-in auth by default — offer Authelia SSO protection
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
prompt_yn "Protect Stirling PDF with Authelia SSO? (y/n):" "y" _use_auth
[[ "$_use_auth" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "Stirling PDF" "stirling-pdf:8080" "pdf" "$EXTRA_BLOCK"
write_readme "$PDF_DIR" << MD
# Stirling PDF
Feature-rich PDF toolkit: merge, split, compress, rotate, OCR, convert, and more.
## Access
- URL: http://localhost:8070
- No login required by default (security disabled)
## Enabling built-in auth
Set \`DOCKER_ENABLE_SECURITY=true\` in \`.env\`, then restart:
\`\`\`bash
cd $PDF_DIR
docker compose down && docker compose up -d
\`\`\`
## OCR / Tesseract
Additional Tesseract language packs can be placed in \`./training-data/\`.
See: https://github.com/Frooodle/Stirling-PDF#ocr
## Manage
\`\`\`bash
cd $PDF_DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose down && docker compose up -d # update
\`\`\`
## Files
- docker-compose.yml — stack definition
- .env — configuration flags
- training-data/ — Tesseract OCR language data
- extraConfigs/ — optional extra config files
- logs/ — application logs
MD
local START_PDF=""
prompt_yn "Start Stirling PDF now? (y/n):" "y" START_PDF
if [ "$START_PDF" = "y" ] || [ "$START_PDF" = "Y" ]; then
docker compose up -d 2>/dev/null \
&& log_success "Stirling PDF started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:8070"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_stirling_pdf
+258
View File
@@ -0,0 +1,258 @@
#!/bin/bash
# services/syncthing.sh — Continuous file sync between devices (Syncthing).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash syncthing.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# 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
}
# 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}'"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
if [[ ! -d "$_caddy_dir" ]]; then
log_info "Access $_name directly on port ${_upstream##*:}."
return 0
fi
echo ""
local _do_caddy=""
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:${_upstream##*:}"
return 0
}
local _domain=""
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Back up before touching
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
# Remove existing block for this domain if present
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
cat >> "$_caddyfile" << CBLOCK
# $_name
$_domain {
reverse_proxy $_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
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
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
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 syncthing utilities "Continuous file sync between devices (Syncthing)" 8384
install_syncthing() {
require_docker || return 1
log_info "Installing Syncthing..."
local DIR="$DOCKER_DIR/syncthing"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env"
echo "[DRY-RUN] Would expose web UI on 8384, sync protocol on 22000 (tcp+udp), discovery on 21027/udp"
return 0
fi
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
local PUID PGID
PUID="$(id -u "$ACTUAL_USER")"
PGID="$(id -g "$ACTUAL_USER")"
cat > docker-compose.yml << EOF
name: syncthing
services:
syncthing:
image: syncthing/syncthing:latest
container_name: syncthing
hostname: syncthing
restart: unless-stopped
environment:
- PUID=\${PUID:-$PUID}
- PGID=\${PGID:-$PGID}
- TZ=\${SITE_TZ:-UTC}
ports:
- "8384:8384"
- "22000:22000/tcp"
- "22000:22000/udp"
- "21027:21027/udp"
volumes:
- ./config:/var/syncthing/config
- ./data:/var/syncthing
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
EOF
cat > .env << ENV
CADDY_NET=$SITE_CADDY_NET
PUID=$PUID
PGID=$PGID
SITE_TZ=$SITE_TZ
ENV
chmod 600 .env
chown "$ACTUAL_USER:$ACTUAL_USER" .env
configure_caddy_for_service "Syncthing" "syncthing:8384" "sync"
write_readme "$DIR" << MD
# Syncthing
Continuous, decentralised file synchronisation between devices.
## Access
- Web UI: http://localhost:8384
- First run: go to **Settings → GUI** and set a username and password.
## Firewall ports (for LAN sync)
Open these on the host firewall so other Syncthing devices can reach this node:
\`\`\`
sudo ufw allow 22000/tcp comment "Syncthing sync protocol"
sudo ufw allow 22000/udp comment "Syncthing sync protocol (QUIC)"
sudo ufw allow 21027/udp comment "Syncthing local discovery"
\`\`\`
## Manage
\`\`\`
cd $DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose up -d # update
\`\`\`
MD
local START=""
prompt_yn "Start Syncthing now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d \
&& log_success "Syncthing started" \
|| log_warning "Start failed — check: docker compose logs"
fi
echo " Access at: http://localhost:8384"
echo " First run: set a username and password in Settings → GUI"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_syncthing
+41 -15
View File
@@ -202,25 +202,36 @@ UNIFI_ENV
log_success "UniFi configured at $UNIFI_DIR"
# ── Optional Caddy reverse proxy (HTTPS backend requires special config) ──
if [ -d "$DOCKER_DIR/caddy" ]; then
# ── Optional Caddy reverse proxy (HTTPS backend requires tls_insecure_skip_verify) ──
local _caddy_mode="none"
[ -d "$DOCKER_DIR/caddy" ] && _caddy_mode="local"
[ -n "${CADDY_REMOTE_HOST:-}" ] && [ "$_caddy_mode" != "local" ] && _caddy_mode="remote"
if [ "$_caddy_mode" != "none" ]; then
echo ""
echo " UniFi web UI is HTTPS-only (self-signed cert internally)."
echo " Caddy can proxy it, but requires tls_insecure_skip_verify."
echo " Caddy proxies it using tls_insecure_skip_verify."
if [ "$_caddy_mode" = "remote" ]; then
echo " Remote Caddy (${CADDY_REMOTE_HOST}) — a snippet file will be saved."
fi
echo ""
local CADDY_UNIFI=""
prompt_yn "Configure Caddy reverse proxy for UniFi? (y/n):" "n" CADDY_UNIFI
if [ "$CADDY_UNIFI" = "y" ] || [ "$CADDY_UNIFI" = "Y" ]; then
local UNIFI_DOMAIN=""
prompt_text "UniFi domain (e.g. unifi.example.com):" "unifi.${SITE_DOMAIN:-example.com}" UNIFI_DOMAIN
local _def_domain="unifi.${SITE_DOMAIN:-example.com}"
prompt_text "UniFi domain [${_def_domain}]:" "$_def_domain" UNIFI_DOMAIN
if [ -n "$UNIFI_DOMAIN" ]; then
local CADDYFILE="$DOCKER_DIR/caddy/Caddyfile"
cp "$CADDYFILE" "$CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true
cat >> "$CADDYFILE" << CADDY_BLOCK
# UniFi uses HTTPS internally — upstream must use https:// + skip verify
local _upstream="https://unifi-app:8443"
[ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:8443"
local _site_block
_site_block="$(cat << CBLOCK
# UniFi Network Application
$UNIFI_DOMAIN {
reverse_proxy https://unifi-app:8443 {
${UNIFI_DOMAIN} {
reverse_proxy ${_upstream} {
transport http {
tls_insecure_skip_verify
}
@@ -234,15 +245,30 @@ $UNIFI_DOMAIN {
}
log {
output file /var/log/caddy/$UNIFI_DOMAIN.log
output file /var/log/caddy/${UNIFI_DOMAIN}.log
format json
}
}
CADDY_BLOCK
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null \
&& log_success "Caddy configured for $UNIFI_DOMAIN" \
|| log_warning "Caddy reload failed — check: docker logs caddy"
CBLOCK
)"
if [ "$_caddy_mode" = "local" ]; then
local CADDYFILE="$DOCKER_DIR/caddy/Caddyfile"
cp "$CADDYFILE" "$CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true
printf '%s\n' "$_site_block" >> "$CADDYFILE"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null \
&& log_success "Caddy configured for $UNIFI_DOMAIN" \
|| log_warning "Caddy reload failed — check: docker logs caddy"
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/unifi.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/"
fi
fi
fi
fi
+1 -5
View File
@@ -254,10 +254,8 @@ services:
env_file: .env
volumes:
- ./vaultwarden_data:/data
expose:
- "80"
ports:
- "3012:3012" # WebSocket (legacy — not needed for Vaultwarden v1.29+)
- "8888:80"
networks:
- caddy_net
@@ -284,8 +282,6 @@ ADMIN_TOKEN=$ADMIN_TOKEN
SIGNUPS_ALLOWED=false
SIGNUPS_VERIFY=false
# WebSocket notifications (v1.29+: built into port 80, no separate port needed)
WEBSOCKET_ENABLED=true
# ── SMTP (optional — for password-reset and invite emails) ────────────────────
SMTP_HOST=$SMTP_HOST
+21 -5
View File
@@ -215,13 +215,26 @@ install_wg-easy() {
cd "$WGEASY_DIR" || return 1
# Auto-detect public IP as default for WG_HOST
local PUBLIC_IP WG_HOST WG_PASSWORD
local PUBLIC_IP WG_HOST WG_PASSWORD WG_PASSWORD_HASH
PUBLIC_IP=$(curl -s --connect-timeout 5 ifconfig.me 2>/dev/null || echo "your-public-ip")
WG_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16)
prompt_text "Public IP or hostname for VPN [$PUBLIC_IP]:" "$PUBLIC_IP" WG_HOST
cat > docker-compose.yml << 'WGEASY_COMPOSE'
# wg-easy v14+ requires PASSWORD_HASH (bcrypt). Generate via docker.
log_info "Generating bcrypt password hash (requires Docker)..."
WG_PASSWORD_HASH=$(docker run --rm ghcr.io/wg-easy/wg-easy:latest wgpw "$WG_PASSWORD" 2>/dev/null \
| grep -oP '\$2[ab]\$[^\s]+' | head -1)
if [[ -z "$WG_PASSWORD_HASH" ]]; then
log_warning "Could not generate bcrypt hash — falling back to plaintext PASSWORD env var."
log_warning "If wg-easy fails to start, run: docker run --rm ghcr.io/wg-easy/wg-easy wgpw 'yourpassword'"
log_warning "Then set PASSWORD_HASH in docker-compose.yml and remove PASSWORD."
fi
# Escape $ in hash for docker-compose env (bcrypt hashes contain $$)
local WG_HASH_ESCAPED="${WG_PASSWORD_HASH//\$/\$\$}"
cat > docker-compose.yml << WGEASY_COMPOSE
name: wg-easy
services:
@@ -237,8 +250,8 @@ services:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
environment:
- WG_HOST=${WG_HOST}
- PASSWORD=${WG_PASSWORD}
- WG_HOST=\${WG_HOST}
- PASSWORD_HASH=${WG_HASH_ESCAPED:-\${WG_PASSWORD}}
- WG_DEFAULT_DNS=1.1.1.1
volumes:
- ./config:/etc/wireguard
@@ -251,11 +264,12 @@ services:
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
name: \${CADDY_NET:-caddy_net}
WGEASY_COMPOSE
cat > .env << WGEASY_ENV
WG_HOST=$WG_HOST
# Plain-text password — used only if PASSWORD_HASH could not be generated above
WG_PASSWORD=$WG_PASSWORD
CADDY_NET=$SITE_CADDY_NET
WGEASY_ENV
@@ -304,6 +318,8 @@ MD
echo ""
echo " Web UI: http://localhost:51821"
echo " Password: $WG_PASSWORD (saved in .env)"
[[ -n "$WG_PASSWORD_HASH" ]] && echo " Auth: bcrypt hash configured (v14+ compatible)" \
|| echo " Auth: WARNING — bcrypt hash generation failed; see README"
echo " Router: forward UDP 51820 → this server for external VPN access"
echo ""
}
+7 -1
View File
@@ -433,7 +433,13 @@ COMPOSE
fi
# ── 5. Caddy (optional) ───────────────────────────────────────────────────
configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair"
local WOLFPAIR_EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
prompt_yn "Protect wolf-pair with Authelia SSO? (y/n):" "y" _use_auth
[[ "$_use_auth" =~ ^[Yy]$ ]] && WOLFPAIR_EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair" "$WOLFPAIR_EXTRA_BLOCK"
# ── 6. README ─────────────────────────────────────────────────────────────
write_readme "$WOLFPAIR_DIR" << 'MD'
+34 -12
View File
@@ -1046,7 +1046,7 @@ PYEOF
echo " Set up automatic backups with the backup module:"
echo " sudo ./setup.sh backup"
echo ""
# ── Caddy reverse proxy (Wolf web UI — no built-in auth, offer Authelia) ────
# Wolf's web UI (pair/manage) has no built-in auth — protect with Authelia if available
local WOLF_EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
@@ -1060,32 +1060,54 @@ PYEOF
Cloud gaming via Moonlight. Stream any Moonlight-compatible game or app from
this server to any device on your network.
# Wolf — Cloud Gaming (Games-on-Whales)
Stream games to any Moonlight client over your LAN or Tailscale VPN.
## Pair a new client
1. Open Moonlight on the client device
2. Add host: this server's IP
3. Run the pin command on the server:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh pin
\`\`\`
## Manage
\`\`\`bash
cd $WOLF_DIR
./manage.sh start # start Wolf
./manage.sh stop # stop Wolf
./manage.sh logs # stream logs
./manage.sh pin # show pairing PIN
./manage.sh update # pull latest and restart
./manage.sh start # start Wolf
./manage.sh stop # stop
./manage.sh restart # restart
./manage.sh logs # live logs
./manage.sh status # container status
./manage.sh update # pull latest image and restart
./manage.sh add-apps # add game launchers
\`\`\`
## Pairing
1. Open Moonlight on your device, select this server
2. Run \`./manage.sh pin\` to get the pairing PIN
3. Enter the PIN in Moonlight
## Ports (open on firewall / router)
| Port(s) | Protocol | Use |
|---------|----------|-----|
| 4798447990 | TCP | Moonlight control |
| 48010 | TCP | RTSP |
| 4799848000 | UDP | RTP video/audio/control |
## Game storage: \`$GAME_STORAGE_DIR\`
- \`roms/\` → /ROMs (EmulationStation)
- \`steam/\` → Steam data
- \`saves/\` → RetroArch saves
## Backup
\`\`\`bash
sudo ./setup.sh backup # covers /etc/wolf saves and ES-DE settings
\`\`\`
MD
local START_WOLF=""
prompt_yn "Start Wolf now? (y/n):" "y" START_WOLF
if [ "$START_WOLF" = "y" ] || [ "$START_WOLF" = "Y" ]; then
docker compose up -d && log_success "Wolf started" || log_warning "Failed to start — check: docker compose logs"
if [[ "$START_WOLF" =~ ^[Yy]$ ]]; then
docker compose up -d \
&& log_success "Wolf started — pair Moonlight to this server's IP" \
|| log_warning "Start failed — check: docker compose logs"
fi
log_success "Done. Pair Moonlight and play."
+1
View File
@@ -79,6 +79,7 @@ is_installed() {
base) command -v ncdu >/dev/null 2>&1 ;;
glow) command -v glow >/dev/null 2>&1 ;;
crowdsec) command -v cscli >/dev/null 2>&1 ;;
kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;;
sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;;