Add multi-instance support to 13 more services
Retrofits the standard multi-instance pattern (documented in CLAUDE.md) onto actualbudget, filebrowser, fmd, homebox, immich, jellyfin, joplin, lyrion, meshcentral, ntfy, rustdesk, unifi, and vaultwarden. First instance of each keeps its original name/paths/ports unchanged; adding a second instance prompts for a short name and auto-scans for free ports. Service-specific handling beyond the base pattern: - joplin, immich, unifi: dedicated Postgres/Mongo container per instance (not shared), matching the backup-isolation reasoning in CLAUDE.md. - meshcentral, unifi: multiple fixed ports scanned/shifted together so they stay paired per instance. - rustdesk: 6-port block shifted by a fixed offset per instance, since the image hardcodes its internal ports with no per-port env override. - jellyfin: DLNA/discovery UDP ports only published for the first instance to avoid a host-wide fixed-port conflict. - lyrion: first instance keeps network_mode: host (required for Chromecast/Squeezebox broadcast discovery); additional instances fall back to bridge networking with auto-scanned ports, trading away zero-config discovery since a second container can't also bind host networking's fixed ports. - magicmirror.sh already had its own working multi-instance pattern (upfront instance count, numbered subdirs) and was left as-is. Verified via bash -n on every changed file, plus scripted functional runs (fake docker/ss) exercising first + second instance installs for every port-scanning shape used here (single, dual-paired, quad-paired, block-offset) and confirming dedicated per-instance DB naming and the lyrion host->bridge compose output.
This commit is contained in:
+56
-12
@@ -186,16 +186,57 @@ register_service actualbudget utilities "Open-source personal finance & budgetin
|
|||||||
install_actualbudget() {
|
install_actualbudget() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "actualbudget" name/paths/port exactly as
|
||||||
|
# before (zero behavior change for anyone with a single instance). Only
|
||||||
|
# asking to add a second one introduces suffixed naming — same pattern as
|
||||||
|
# services/mattermost.sh and services/wordpress.sh.
|
||||||
local AB_DIR="$DOCKER_DIR/actualbudget"
|
local AB_DIR="$DOCKER_DIR/actualbudget"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="actualbudget"
|
||||||
|
local WEB_PORT="5006"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Actual Budget would:"
|
echo "[DRY-RUN] Actual Budget would:"
|
||||||
echo " - Create $AB_DIR with docker-compose.yml (data/)"
|
echo " - Offer to add a new, separate instance if one already exists"
|
||||||
echo " - Expose port 5006"
|
echo " - Create \$DOCKER_DIR/actualbudget(-<name>) with docker-compose.yml (data/)"
|
||||||
|
echo " - Auto-scan for a free host port if this is an additional instance"
|
||||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
echo " - Offer a Caddy reverse proxy and to start the container"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$AB_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " Actual Budget is already installed at $AB_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate Actual Budget instance alongside it (its own"
|
||||||
|
echo " server, budget file, and port — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/actualbudget-$_suffix" ]; then
|
||||||
|
log_warning "actualbudget-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
AB_DIR="$DOCKER_DIR/actualbudget-$_suffix"
|
||||||
|
CONTAINER="actualbudget-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $AB_DIR (port $WEB_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$AB_DIR/data"
|
mkdir -p "$AB_DIR/data"
|
||||||
ensure_docker_dir_ownership "$AB_DIR"
|
ensure_docker_dir_ownership "$AB_DIR"
|
||||||
cd "$AB_DIR" || return 1
|
cd "$AB_DIR" || return 1
|
||||||
@@ -226,15 +267,15 @@ networks:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << AB_COMPOSE
|
cat > docker-compose.yml << AB_COMPOSE
|
||||||
name: actualbudget
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
actualbudget:
|
actualbudget:
|
||||||
image: actualbudget/actual-server:latest
|
image: actualbudget/actual-server:latest
|
||||||
container_name: actualbudget
|
container_name: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5006:5006"
|
- "${WEB_PORT}:5006"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
env_file:
|
env_file:
|
||||||
@@ -248,17 +289,20 @@ CADDY_NET=$SITE_CADDY_NET
|
|||||||
AB_ENV
|
AB_ENV
|
||||||
|
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR"
|
||||||
log_success "Actual Budget configured at $AB_DIR"
|
log_success "Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $AB_DIR (port $WEB_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "ActualBudget" "actualbudget:5006" "budget"
|
configure_caddy_for_service "ActualBudget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5006" "budget${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$AB_DIR" << MD
|
write_readme "$AB_DIR" << MD
|
||||||
# Actual Budget
|
# Actual Budget${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Open-source personal finance and budgeting tool. Supports bank sync via
|
Open-source personal finance and budgeting tool. Supports bank sync via
|
||||||
SimpleFIN (requires a SimpleFIN account at simplefin.org).
|
SimpleFIN (requires a SimpleFIN account at simplefin.org).
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own budget file,
|
||||||
|
own port) — not shared data with another Actual Budget instance.")
|
||||||
|
|
||||||
- Web UI: http://localhost:5006
|
- Web UI: http://localhost:${WEB_PORT}
|
||||||
- App data: \`data/\`
|
- App data: \`data/\`
|
||||||
|
|
||||||
## Manage
|
## Manage
|
||||||
@@ -276,13 +320,13 @@ docker compose pull && docker compose up -d # update
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_AB=""
|
local START_AB=""
|
||||||
prompt_yn "Start Actual Budget now? (y/n):" "y" START_AB
|
prompt_yn "Start Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_AB
|
||||||
if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then
|
if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then
|
||||||
docker compose up -d && log_success "Actual Budget started" || log_warning "Failed to start — check: docker compose logs"
|
docker compose up -d && log_success "Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Access at: http://localhost:5006"
|
echo " Access at: http://localhost:${WEB_PORT}"
|
||||||
echo " Bank sync: simplefin.org (optional, paid)"
|
echo " Bank sync: simplefin.org (optional, paid)"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-12
@@ -184,14 +184,56 @@ install_filebrowser() {
|
|||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
log_info "Installing FileBrowser Quantum..."
|
log_info "Installing FileBrowser Quantum..."
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "filebrowser" name/paths/port exactly as
|
||||||
|
# before (zero behavior change for anyone with a single instance). Only
|
||||||
|
# asking to add a second one introduces suffixed naming — same pattern as
|
||||||
|
# services/mattermost.sh and services/wordpress.sh.
|
||||||
local FB_DIR="$DOCKER_DIR/filebrowser"
|
local FB_DIR="$DOCKER_DIR/filebrowser"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="filebrowser"
|
||||||
|
local WEB_PORT="8085"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Would create $FB_DIR"
|
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||||
|
echo "[DRY-RUN] Would create $FB_DIR(-<name>)"
|
||||||
echo "[DRY-RUN] Would write docker-compose.yml and data/config.yaml"
|
echo "[DRY-RUN] Would write docker-compose.yml and data/config.yaml"
|
||||||
|
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$FB_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " FileBrowser is already installed at $FB_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate FileBrowser instance alongside it (its own"
|
||||||
|
echo " server, config, and port — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'shared'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/filebrowser-$_suffix" ]; then
|
||||||
|
log_warning "filebrowser-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
FB_DIR="$DOCKER_DIR/filebrowser-$_suffix"
|
||||||
|
CONTAINER="filebrowser-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $FB_DIR (port $WEB_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$FB_DIR/data"
|
mkdir -p "$FB_DIR/data"
|
||||||
ensure_docker_dir_ownership "$FB_DIR"
|
ensure_docker_dir_ownership "$FB_DIR"
|
||||||
cd "$FB_DIR" || return 1
|
cd "$FB_DIR" || return 1
|
||||||
@@ -223,13 +265,13 @@ networks:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << FB_COMPOSE
|
cat > docker-compose.yml << FB_COMPOSE
|
||||||
name: filebrowser
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
filebrowser:
|
filebrowser:
|
||||||
image: gtstef/filebrowser:stable
|
image: gtstef/filebrowser:stable
|
||||||
container_name: filebrowser
|
container_name: $CONTAINER
|
||||||
hostname: filebrowser
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- TZ=${SITE_TZ:-UTC}
|
- TZ=${SITE_TZ:-UTC}
|
||||||
@@ -237,7 +279,7 @@ services:
|
|||||||
- ./data:/home/filebrowser/data
|
- ./data:/home/filebrowser/data
|
||||||
- ${FB_PATH}:/files
|
- ${FB_PATH}:/files
|
||||||
ports:
|
ports:
|
||||||
- "8085:80"
|
- "${WEB_PORT}:80"
|
||||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||||
FB_COMPOSE
|
FB_COMPOSE
|
||||||
|
|
||||||
@@ -282,18 +324,21 @@ FB_CONFIG
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
log_success "FileBrowser Quantum configured at $FB_DIR"
|
log_success "FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $FB_DIR (port $WEB_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "FileBrowser" "filebrowser:80" "files"
|
configure_caddy_for_service "FileBrowser${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "files${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$FB_DIR" << MD
|
write_readme "$FB_DIR" << MD
|
||||||
# FileBrowser Quantum
|
# FileBrowser Quantum${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Web-based file manager with multi-source support, office preview, and
|
Web-based file manager with multi-source support, office preview, and
|
||||||
per-user access control.
|
per-user access control.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own config, own
|
||||||
|
port) — not shared sources with another FileBrowser instance.")
|
||||||
|
|
||||||
## Access
|
## Access
|
||||||
- URL: http://localhost:8085
|
- URL: http://localhost:${WEB_PORT}
|
||||||
- Default login: admin / admin (change immediately!)
|
- Default login: admin / admin (change immediately!)
|
||||||
|
|
||||||
## Adding sources (extra directories)
|
## Adding sources (extra directories)
|
||||||
@@ -320,14 +365,14 @@ docker compose pull && docker compose down && docker compose up -d # update
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_FB=""
|
local START_FB=""
|
||||||
prompt_yn "Start FileBrowser Quantum now? (y/n):" "y" START_FB
|
prompt_yn "Start FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_FB
|
||||||
if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then
|
if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then
|
||||||
docker compose up -d 2>/dev/null \
|
docker compose up -d 2>/dev/null \
|
||||||
&& log_success "FileBrowser Quantum started" \
|
&& log_success "FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|
||||||
|| log_warning "Failed to start — check: docker compose logs"
|
|| log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " Access at: http://localhost:8085"
|
echo " Access at: http://localhost:${WEB_PORT}"
|
||||||
echo " Default login: admin / admin (change immediately!)"
|
echo " Default login: admin / admin (change immediately!)"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-14
@@ -196,17 +196,58 @@ register_service fmd utilities "Android device tracking — alternative to Googl
|
|||||||
install_fmd() {
|
install_fmd() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "fmd" name/paths/port exactly as before
|
||||||
|
# (zero behavior change for anyone with a single instance). Only asking to
|
||||||
|
# add a second one introduces suffixed naming — same pattern as
|
||||||
|
# services/mattermost.sh and services/wordpress.sh.
|
||||||
local FMD_DIR="$DOCKER_DIR/fmd"
|
local FMD_DIR="$DOCKER_DIR/fmd"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="fmd"
|
||||||
|
local WEB_PORT="8084"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] FindMyDevice would:"
|
echo "[DRY-RUN] FindMyDevice would:"
|
||||||
echo " - Create $FMD_DIR with docker-compose.yml + .env (data/)"
|
echo " - Offer to add a new, separate instance if one already exists"
|
||||||
|
echo " - Create \$DOCKER_DIR/fmd(-<name>) with docker-compose.yml + .env (data/)"
|
||||||
echo " - Generate a random admin password"
|
echo " - Generate a random admin password"
|
||||||
echo " - Expose port 8084"
|
echo " - Auto-scan for a free host port if this is an additional instance"
|
||||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
echo " - Offer a Caddy reverse proxy and to start the container"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$FMD_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " FindMyDevice is already installed at $FMD_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate FindMyDevice instance alongside it (its own"
|
||||||
|
echo " server, password, and port — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/fmd-$_suffix" ]; then
|
||||||
|
log_warning "fmd-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
FMD_DIR="$DOCKER_DIR/fmd-$_suffix"
|
||||||
|
CONTAINER="fmd-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $FMD_DIR (port $WEB_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$FMD_DIR"
|
mkdir -p "$FMD_DIR"
|
||||||
ensure_docker_dir_ownership "$FMD_DIR"
|
ensure_docker_dir_ownership "$FMD_DIR"
|
||||||
cd "$FMD_DIR" || return 1
|
cd "$FMD_DIR" || return 1
|
||||||
@@ -238,20 +279,20 @@ networks:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << FMD_COMPOSE
|
cat > docker-compose.yml << FMD_COMPOSE
|
||||||
name: fmd
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
fmd:
|
fmd:
|
||||||
image: nulide/findmydevice
|
image: nulide/findmydevice
|
||||||
container_name: fmd
|
container_name: $CONTAINER
|
||||||
hostname: fmd
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD}
|
- FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/fmd/data
|
- ./data:/fmd/data
|
||||||
ports:
|
ports:
|
||||||
- "8084:8080"
|
- "${WEB_PORT}:8080"
|
||||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||||
FMD_COMPOSE
|
FMD_COMPOSE
|
||||||
|
|
||||||
@@ -262,17 +303,20 @@ FMD_ENV
|
|||||||
|
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR"
|
||||||
log_success "FindMyDevice configured at $FMD_DIR"
|
log_success "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $FMD_DIR (port $WEB_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "FindMyDevice" "fmd:8080" "fmd"
|
configure_caddy_for_service "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "fmd${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$FMD_DIR" << MD
|
write_readme "$FMD_DIR" << MD
|
||||||
# FindMyDevice (FMD)
|
# FindMyDevice (FMD)${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Self-hosted Android device tracking — locate, lock, or wipe your device
|
Self-hosted Android device tracking — locate, lock, or wipe your device
|
||||||
from the web UI. Alternative to Google's Find My Device.
|
from the web UI. Alternative to Google's Find My Device.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own password, own
|
||||||
|
port) — not shared devices with another FindMyDevice instance.")
|
||||||
|
|
||||||
- Web UI: http://localhost:8084
|
- Web UI: http://localhost:${WEB_PORT}
|
||||||
- Admin password: stored in \`.env\` (\`FMD_ADMIN_PASSWORD\`)
|
- Admin password: stored in \`.env\` (\`FMD_ADMIN_PASSWORD\`)
|
||||||
- App data: \`data/\`
|
- App data: \`data/\`
|
||||||
|
|
||||||
@@ -287,19 +331,19 @@ docker compose pull && docker compose up -d # update
|
|||||||
|
|
||||||
## Mobile app
|
## Mobile app
|
||||||
Install **FindMyDevice** from **F-Droid** (not the Play Store version):
|
Install **FindMyDevice** from **F-Droid** (not the Play Store version):
|
||||||
1. Open the app → Settings → Server URL → \`http://YOUR-SERVER-IP:8084\`
|
1. Open the app → Settings → Server URL → \`http://YOUR-SERVER-IP:${WEB_PORT}\`
|
||||||
2. Enter your admin password from \`.env\`
|
2. Enter your admin password from \`.env\`
|
||||||
3. Grant location and accessibility permissions
|
3. Grant location and accessibility permissions
|
||||||
MD
|
MD
|
||||||
|
|
||||||
local START_FMD=""
|
local START_FMD=""
|
||||||
prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD
|
prompt_yn "Start FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_FMD
|
||||||
if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then
|
if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then
|
||||||
docker compose up -d && log_success "FindMyDevice started" || log_warning "Failed to start — check: docker compose logs"
|
docker compose up -d && log_success "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Access at: http://localhost:8084"
|
echo " Access at: http://localhost:${WEB_PORT}"
|
||||||
echo " Password: $FMD_PASS (saved in .env)"
|
echo " Password: $FMD_PASS (saved in .env)"
|
||||||
echo " Mobile app: FindMyDevice on F-Droid"
|
echo " Mobile app: FindMyDevice on F-Droid"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
+63
-17
@@ -185,14 +185,56 @@ install_homebox() {
|
|||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
log_info "Installing Homebox..."
|
log_info "Installing Homebox..."
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "homebox" name/paths/port exactly as
|
||||||
|
# before (zero behavior change for anyone with a single instance). Only
|
||||||
|
# asking to add a second one introduces suffixed naming — same pattern as
|
||||||
|
# services/mattermost.sh and services/wordpress.sh.
|
||||||
local HB_DIR="$DOCKER_DIR/homebox"
|
local HB_DIR="$DOCKER_DIR/homebox"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="homebox"
|
||||||
|
local WEB_PORT="7745"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Would create $HB_DIR"
|
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||||
|
echo "[DRY-RUN] Would create $HB_DIR(-<name>)"
|
||||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
||||||
|
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$HB_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " Homebox is already installed at $HB_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate Homebox instance alongside it (its own"
|
||||||
|
echo " server, inventory, and port — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'garage'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/homebox-$_suffix" ]; then
|
||||||
|
log_warning "homebox-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
HB_DIR="$DOCKER_DIR/homebox-$_suffix"
|
||||||
|
CONTAINER="homebox-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $HB_DIR (port $WEB_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$HB_DIR/data"
|
mkdir -p "$HB_DIR/data"
|
||||||
ensure_docker_dir_ownership "$HB_DIR"
|
ensure_docker_dir_ownership "$HB_DIR"
|
||||||
cd "$HB_DIR" || return 1
|
cd "$HB_DIR" || return 1
|
||||||
@@ -221,13 +263,13 @@ networks:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << HB_COMPOSE
|
cat > docker-compose.yml << HB_COMPOSE
|
||||||
name: homebox
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
homebox:
|
homebox:
|
||||||
image: ghcr.io/sysadminsmedia/homebox:latest
|
image: ghcr.io/sysadminsmedia/homebox:latest
|
||||||
container_name: homebox
|
container_name: $CONTAINER
|
||||||
hostname: homebox
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- HBOX_LOG_LEVEL=info
|
- HBOX_LOG_LEVEL=info
|
||||||
@@ -235,7 +277,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
ports:
|
ports:
|
||||||
- "7745:7745"
|
- "${WEB_PORT}:7745"
|
||||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||||
HB_COMPOSE
|
HB_COMPOSE
|
||||||
|
|
||||||
@@ -246,18 +288,21 @@ HB_ENV
|
|||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HB_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HB_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
log_success "Homebox configured at $HB_DIR"
|
log_success "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $HB_DIR (port $WEB_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "Homebox" "homebox:7745" "homebox"
|
configure_caddy_for_service "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:7745" "homebox${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$HB_DIR" << 'MD'
|
write_readme "$HB_DIR" << MD
|
||||||
# Homebox
|
# Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Home inventory and asset management. Track items, locations, labels,
|
Home inventory and asset management. Track items, locations, labels,
|
||||||
warranties, and attachments across your household.
|
warranties, and attachments across your household.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own inventory, own
|
||||||
|
port) — not shared items with another Homebox instance.")
|
||||||
|
|
||||||
## Access
|
## Access
|
||||||
- URL: http://localhost:7745
|
- URL: http://localhost:${WEB_PORT}
|
||||||
- Register your account on first visit — the first user becomes the admin.
|
- Register your account on first visit — the first user becomes the admin.
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
@@ -265,27 +310,28 @@ warranties, and attachments across your household.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
Key environment variables (edit docker-compose.yml to change):
|
Key environment variables (edit docker-compose.yml to change):
|
||||||
- `HBOX_LOG_LEVEL` — log verbosity (info, debug, warn, error)
|
- \`HBOX_LOG_LEVEL\` — log verbosity (info, debug, warn, error)
|
||||||
- `HBOX_WEB_MAX_UPLOAD_SIZE` — max attachment upload size in MB (default: 10)
|
- \`HBOX_WEB_MAX_UPLOAD_SIZE\` — max attachment upload size in MB (default: 10)
|
||||||
|
|
||||||
## Manage
|
## Manage
|
||||||
```bash
|
\`\`\`bash
|
||||||
|
cd $HB_DIR
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose down
|
docker compose down
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
docker compose pull && docker compose down && docker compose up -d
|
docker compose pull && docker compose down && docker compose up -d
|
||||||
```
|
\`\`\`
|
||||||
MD
|
MD
|
||||||
|
|
||||||
local START_HB=""
|
local START_HB=""
|
||||||
prompt_yn "Start Homebox now? (y/n):" "y" START_HB
|
prompt_yn "Start Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_HB
|
||||||
if [ "$START_HB" = "y" ] || [ "$START_HB" = "Y" ]; then
|
if [ "$START_HB" = "y" ] || [ "$START_HB" = "Y" ]; then
|
||||||
docker compose up -d 2>/dev/null \
|
docker compose up -d 2>/dev/null \
|
||||||
&& log_success "Homebox started" \
|
&& log_success "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|
||||||
|| log_warning "Failed to start — check: docker compose logs"
|
|| log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " Access at: http://localhost:7745"
|
echo " Access at: http://localhost:${WEB_PORT}"
|
||||||
echo " Register your account on first visit."
|
echo " Register your account on first visit."
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-19
@@ -189,23 +189,75 @@ register_service immich media "Self-hosted photo & video backup — like Google
|
|||||||
install_immich() {
|
install_immich() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "immich" name/paths/port and
|
||||||
|
# immich_server/immich_machine_learning/immich_redis/immich_postgres
|
||||||
|
# container names exactly as before (zero behavior change for anyone with
|
||||||
|
# a single instance). Only asking to add a second one introduces suffixed
|
||||||
|
# naming — same pattern as services/mattermost.sh and services/wordpress.sh.
|
||||||
|
# Each instance gets its own dedicated Postgres (already the case — one
|
||||||
|
# per compose project) and its own model-cache volume (scoped by the
|
||||||
|
# per-instance Compose project name), so instances are fully isolated for
|
||||||
|
# backup/restore too, per CLAUDE.md's "Multi-instance services" section.
|
||||||
local IMMICH_DIR="$DOCKER_DIR/immich"
|
local IMMICH_DIR="$DOCKER_DIR/immich"
|
||||||
|
local INSTANCE_SUFFIX="" PROJECT="immich"
|
||||||
|
local C_SERVER="immich_server" C_ML="immich_machine_learning" C_REDIS="immich_redis" C_DB="immich_postgres"
|
||||||
|
local WEB_PORT="2283"
|
||||||
local DEFAULT_PHOTOS="$ACTUAL_HOME/photos"
|
local DEFAULT_PHOTOS="$ACTUAL_HOME/photos"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Immich would:"
|
echo "[DRY-RUN] Immich would:"
|
||||||
echo " - Create $IMMICH_DIR with docker-compose.yml + .env"
|
echo " - Offer to add a new, separate instance if one already exists"
|
||||||
echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres"
|
echo " - Create \$DOCKER_DIR/immich(-<name>) with docker-compose.yml + .env"
|
||||||
|
echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres (dedicated per instance)"
|
||||||
echo " - Strategy 1 (unified): all photos in one folder, import-photos.sh helper"
|
echo " - Strategy 1 (unified): all photos in one folder, import-photos.sh helper"
|
||||||
echo " - Strategy 2 (external): existing photos indexed read-only, new uploads separate"
|
echo " - Strategy 2 (external): existing photos indexed read-only, new uploads separate"
|
||||||
echo " - Optionally store thumbnails/encoded-video/new-uploads in S3-compatible"
|
echo " - Optionally store thumbnails/encoded-video/new-uploads in S3-compatible"
|
||||||
echo " object storage instead of local disk (native IMMICH_STORAGE_ENGINE=s3 —"
|
echo " object storage instead of local disk (native IMMICH_STORAGE_ENGINE=s3 —"
|
||||||
echo " NOT a FUSE mount, those are unreliable for Immich's access pattern)"
|
echo " NOT a FUSE mount, those are unreliable for Immich's access pattern)"
|
||||||
echo " - Expose port 2283"
|
echo " - Expose port 2283, auto-scanned for additional instances"
|
||||||
echo " - Offer a Caddy reverse proxy and to start the stack"
|
echo " - Offer a Caddy reverse proxy and to start the stack"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$IMMICH_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " Immich is already installed at $IMMICH_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate Immich instance alongside it (its own"
|
||||||
|
echo " server, database, and port — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/immich-$_suffix" ]; then
|
||||||
|
log_warning "immich-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
IMMICH_DIR="$DOCKER_DIR/immich-$_suffix"
|
||||||
|
PROJECT="immich-$_suffix"
|
||||||
|
C_SERVER="immich_server_$_suffix"
|
||||||
|
C_ML="immich_ml_$_suffix"
|
||||||
|
C_REDIS="immich_redis_$_suffix"
|
||||||
|
C_DB="immich_postgres_$_suffix"
|
||||||
|
DEFAULT_PHOTOS="$ACTUAL_HOME/photos-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $IMMICH_DIR (port $WEB_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Photo library setup ─────────────────────────────────────────────────
|
# ── Photo library setup ─────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo " PHOTO LIBRARY SETUP"
|
echo " PHOTO LIBRARY SETUP"
|
||||||
@@ -384,18 +436,18 @@ networks:
|
|||||||
"
|
"
|
||||||
|
|
||||||
cat > docker-compose.yml << IMMICH_COMPOSE
|
cat > docker-compose.yml << IMMICH_COMPOSE
|
||||||
name: immich
|
name: $PROJECT
|
||||||
|
|
||||||
services:
|
services:
|
||||||
immich-server:
|
immich-server:
|
||||||
container_name: immich_server
|
container_name: $C_SERVER
|
||||||
image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release}
|
image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release}
|
||||||
volumes:
|
volumes:
|
||||||
${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localtime:ro
|
${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localtime:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
- 2283:2283
|
- ${WEB_PORT}:2283
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- database
|
- database
|
||||||
@@ -404,7 +456,7 @@ ${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localt
|
|||||||
disable: false
|
disable: false
|
||||||
${_CADDY_NET_BLOCK}
|
${_CADDY_NET_BLOCK}
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
container_name: immich_machine_learning
|
container_name: $C_ML
|
||||||
image: ghcr.io/immich-app/immich-machine-learning:\${IMMICH_VERSION:-release}
|
image: ghcr.io/immich-app/immich-machine-learning:\${IMMICH_VERSION:-release}
|
||||||
volumes:
|
volumes:
|
||||||
- model-cache:/cache
|
- model-cache:/cache
|
||||||
@@ -415,14 +467,14 @@ ${_CADDY_NET_BLOCK}
|
|||||||
disable: false
|
disable: false
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: $C_REDIS
|
||||||
image: docker.io/valkey/valkey:9-bookworm
|
image: docker.io/valkey/valkey:9-bookworm
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: valkey-cli ping || exit 1
|
test: valkey-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: $C_DB
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: \${DB_PASSWORD}
|
POSTGRES_PASSWORD: \${DB_PASSWORD}
|
||||||
@@ -541,7 +593,7 @@ IMMICH_ENV
|
|||||||
IMPORT_HEAD
|
IMPORT_HEAD
|
||||||
|
|
||||||
cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_VARS
|
cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_VARS
|
||||||
IMMICH_URL="http://localhost:2283"
|
IMMICH_URL="http://localhost:${WEB_PORT}"
|
||||||
SOURCE_DIR="$EXISTING_PHOTOS_SOURCE"
|
SOURCE_DIR="$EXISTING_PHOTOS_SOURCE"
|
||||||
IMMICH_DIR="$IMMICH_DIR"
|
IMMICH_DIR="$IMMICH_DIR"
|
||||||
IMPORT_VARS
|
IMPORT_VARS
|
||||||
@@ -791,17 +843,20 @@ IMPORT_BODY
|
|||||||
log_success "Import helper written: $IMMICH_DIR/import-photos.sh"
|
log_success "Import helper written: $IMMICH_DIR/import-photos.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "Immich configured at $IMMICH_DIR"
|
log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $IMMICH_DIR (port $WEB_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "Immich" "immich-server:2283" "immich"
|
configure_caddy_for_service "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${C_SERVER}:2283" "immich${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$IMMICH_DIR" << MD
|
write_readme "$IMMICH_DIR" << MD
|
||||||
# Immich
|
# Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Self-hosted photo and video backup — like Google Photos but private.
|
Self-hosted photo and video backup — like Google Photos but private.
|
||||||
Mobile apps (iOS/Android) auto-upload in the background.
|
Mobile apps (iOS/Android) auto-upload in the background.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own dedicated
|
||||||
|
database, own port) — not shared photos with another Immich instance.")
|
||||||
|
|
||||||
- Web UI: http://localhost:2283
|
- Web UI: http://localhost:${WEB_PORT}
|
||||||
- Photo storage: $( [ "$USE_S3" = true ] && echo "S3 bucket \`$S3_BUCKET\` (thumbnails, encoded video, new uploads)" || echo "\`$UPLOAD_LOCATION\`" )
|
- Photo storage: $( [ "$USE_S3" = true ] && echo "S3 bucket \`$S3_BUCKET\` (thumbnails, encoded video, new uploads)" || echo "\`$UPLOAD_LOCATION\`" )
|
||||||
- App data (postgres, model cache): inside this folder
|
- App data (postgres, model cache): inside this folder
|
||||||
- Edit paths/credentials in \`.env\`, then \`docker compose up -d\` to apply.
|
- Edit paths/credentials in \`.env\`, then \`docker compose up -d\` to apply.
|
||||||
@@ -836,8 +891,8 @@ docker compose pull && docker compose up -d # update
|
|||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## First launch
|
## First launch
|
||||||
1. Open http://localhost:2283 and create your admin account.
|
1. Open http://localhost:${WEB_PORT} and create your admin account.
|
||||||
2. Install the Immich mobile app and point it at \`http://<server-ip>:2283\`.
|
2. Install the Immich mobile app and point it at \`http://<server-ip>:${WEB_PORT}\`.
|
||||||
3. (External library mode) Go to Admin → External Libraries → Create Library,
|
3. (External library mode) Go to Admin → External Libraries → Create Library,
|
||||||
set import path to \`/usr/src/app/external\`, and click Scan.
|
set import path to \`/usr/src/app/external\`, and click Scan.
|
||||||
|
|
||||||
@@ -855,13 +910,13 @@ docker compose pull && docker compose up -d # update
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_IMMICH=""
|
local START_IMMICH=""
|
||||||
prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH
|
prompt_yn "Start Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_IMMICH
|
||||||
if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then
|
if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then
|
||||||
docker compose up -d && log_success "Immich started" || log_warning "Failed to start — check: docker compose logs"
|
docker compose up -d && log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Access at: http://localhost:2283"
|
echo " Access at: http://localhost:${WEB_PORT}"
|
||||||
echo " First launch: create your admin account in the web UI."
|
echo " First launch: create your admin account in the web UI."
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-16
@@ -188,19 +188,67 @@ register_service jellyfin media "Free media server — movies, TV, music (Jellyf
|
|||||||
install_jellyfin() {
|
install_jellyfin() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "jellyfin" name/paths/ports exactly as
|
||||||
|
# before (zero behavior change for anyone with a single instance). Only
|
||||||
|
# asking to add a second one introduces suffixed naming — same pattern as
|
||||||
|
# services/mattermost.sh and services/wordpress.sh (and services/emby.sh,
|
||||||
|
# the same idea for a sibling media server).
|
||||||
local JELLYFIN_DIR="$DOCKER_DIR/jellyfin"
|
local JELLYFIN_DIR="$DOCKER_DIR/jellyfin"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="jellyfin"
|
||||||
|
local WEB_PORT="8096"
|
||||||
local DEFAULT_MEDIA="$ACTUAL_HOME/media"
|
local DEFAULT_MEDIA="$ACTUAL_HOME/media"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Jellyfin would:"
|
echo "[DRY-RUN] Jellyfin would:"
|
||||||
echo " - Create $JELLYFIN_DIR with docker-compose.yml + .env (config/ cache/)"
|
echo " - Offer to add a new, separate instance if one already exists"
|
||||||
|
echo " - Create \$DOCKER_DIR/jellyfin(-<name>) with docker-compose.yml + .env (config/ cache/)"
|
||||||
echo " - Mount a media folder (default $DEFAULT_MEDIA) read-only at /media"
|
echo " - Mount a media folder (default $DEFAULT_MEDIA) read-only at /media"
|
||||||
echo " - Auto-enable VAAPI hw transcoding if /dev/dri/renderD128 exists"
|
echo " - Auto-enable VAAPI hw transcoding if /dev/dri/renderD128 exists"
|
||||||
echo " - Expose port 8096 (+ DLNA 1900/udp, discovery 7359/udp)"
|
echo " - Expose port 8096, auto-scanned for additional instances"
|
||||||
|
echo " - First instance only: DLNA 1900/udp + discovery 7359/udp (host-wide;"
|
||||||
|
echo " additional instances skip these to avoid a fixed-port conflict)"
|
||||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
echo " - Offer a Caddy reverse proxy and to start the container"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$JELLYFIN_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " Jellyfin is already installed at $JELLYFIN_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate Jellyfin instance alongside it (its own"
|
||||||
|
echo " server, library, and port — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/jellyfin-$_suffix" ]; then
|
||||||
|
log_warning "jellyfin-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
JELLYFIN_DIR="$DOCKER_DIR/jellyfin-$_suffix"
|
||||||
|
CONTAINER="jellyfin-$_suffix"
|
||||||
|
DEFAULT_MEDIA="$ACTUAL_HOME/media-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $JELLYFIN_DIR (port $WEB_PORT)"
|
||||||
|
log_warning "DLNA/discovery (1900/udp, 7359/udp) are fixed, host-wide ports already"
|
||||||
|
log_warning "claimed by the first instance — this instance skips them (web UI and"
|
||||||
|
log_warning "app-based streaming are unaffected; DLNA auto-discovery is not)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
local MEDIA_PATH=""
|
local MEDIA_PATH=""
|
||||||
prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH
|
prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH
|
||||||
MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}"
|
MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}"
|
||||||
@@ -248,14 +296,25 @@ networks:
|
|||||||
"
|
"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# DLNA (1900/udp) and discovery (7359/udp) are fixed, host-wide UDP ports —
|
||||||
|
# only the first instance publishes them to avoid a bind conflict with an
|
||||||
|
# existing instance. Additional instances still work for the web UI and
|
||||||
|
# every app-based client; only DLNA/network auto-discovery is instance-1-only.
|
||||||
|
local _DISCOVERY_PORTS=""
|
||||||
|
if [ -z "$INSTANCE_SUFFIX" ]; then
|
||||||
|
_DISCOVERY_PORTS=' - "1900:1900/udp"
|
||||||
|
- "7359:7359/udp"
|
||||||
|
'
|
||||||
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << JELLYFIN_COMPOSE
|
cat > docker-compose.yml << JELLYFIN_COMPOSE
|
||||||
name: jellyfin
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
jellyfin:
|
jellyfin:
|
||||||
image: jellyfin/jellyfin:latest
|
image: jellyfin/jellyfin:latest
|
||||||
container_name: jellyfin
|
container_name: $CONTAINER
|
||||||
hostname: jellyfin
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- TZ=$TZ_VAL
|
- TZ=$TZ_VAL
|
||||||
@@ -265,10 +324,8 @@ $HWACCEL_BLOCK
|
|||||||
- ./cache:/cache
|
- ./cache:/cache
|
||||||
- \${MEDIA_PATH}:/media:ro
|
- \${MEDIA_PATH}:/media:ro
|
||||||
ports:
|
ports:
|
||||||
- "8096:8096"
|
- "${WEB_PORT}:8096"
|
||||||
- "1900:1900/udp"
|
${_DISCOVERY_PORTS}${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||||
- "7359:7359/udp"
|
|
||||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
|
||||||
JELLYFIN_COMPOSE
|
JELLYFIN_COMPOSE
|
||||||
|
|
||||||
cat > .env << JELLYFIN_ENV
|
cat > .env << JELLYFIN_ENV
|
||||||
@@ -278,16 +335,22 @@ JELLYFIN_ENV
|
|||||||
|
|
||||||
mkdir -p config cache
|
mkdir -p config cache
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR"
|
||||||
log_success "Jellyfin configured at $JELLYFIN_DIR"
|
log_success "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $JELLYFIN_DIR (port $WEB_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "Jellyfin" "jellyfin:8096" "jellyfin"
|
configure_caddy_for_service "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8096" "jellyfin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$JELLYFIN_DIR" << MD
|
write_readme "$JELLYFIN_DIR" << MD
|
||||||
# Jellyfin
|
# Jellyfin${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Free media server (movies, TV, music) — a no-paywall alternative to Emby.
|
Free media server (movies, TV, music) — a no-paywall alternative to Emby.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own library, own
|
||||||
|
port) — not a shared library with another Jellyfin instance. DLNA and
|
||||||
|
network auto-discovery are only published for the first instance (fixed,
|
||||||
|
host-wide ports); this instance still works for the web UI and every
|
||||||
|
app-based client.")
|
||||||
|
|
||||||
- Web UI: http://localhost:8096
|
- Web UI: http://localhost:${WEB_PORT}
|
||||||
- Media folder (read-only): \`$MEDIA_PATH\` → mounted at /media
|
- Media folder (read-only): \`$MEDIA_PATH\` → mounted at /media
|
||||||
- App data: \`config/\` and \`cache/\` in this folder
|
- App data: \`config/\` and \`cache/\` in this folder
|
||||||
- Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`.
|
- Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`.
|
||||||
@@ -309,13 +372,13 @@ docker compose pull && docker compose up -d # update
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_JF=""
|
local START_JF=""
|
||||||
prompt_yn "Start Jellyfin now? (y/n):" "y" START_JF
|
prompt_yn "Start Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_JF
|
||||||
if [ "$START_JF" = "y" ] || [ "$START_JF" = "Y" ]; then
|
if [ "$START_JF" = "y" ] || [ "$START_JF" = "Y" ]; then
|
||||||
docker compose up -d && log_success "Jellyfin started" || log_warning "Failed to start — check: docker compose logs"
|
docker compose up -d && log_success "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Access at: http://localhost:8096"
|
echo " Access at: http://localhost:${WEB_PORT}"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+66
-16
@@ -185,14 +185,61 @@ install_joplin() {
|
|||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
log_info "Installing Joplin Server..."
|
log_info "Installing Joplin Server..."
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "joplin"/"joplin-db" names/paths/port
|
||||||
|
# exactly as before (zero behavior change for anyone with a single
|
||||||
|
# instance). Only asking to add a second one introduces suffixed naming —
|
||||||
|
# same pattern as services/mattermost.sh and services/wordpress.sh.
|
||||||
|
# Each instance gets its own dedicated Postgres container (not shared) —
|
||||||
|
# see CLAUDE.md's "Multi-instance services" section for why: Kopia's
|
||||||
|
# generic backup stops the container to snapshot it, so a shared DB would
|
||||||
|
# back up/restore every instance's notes as one unit instead of per-instance.
|
||||||
local JOPLIN_DIR="$DOCKER_DIR/joplin"
|
local JOPLIN_DIR="$DOCKER_DIR/joplin"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="joplin" DB_CONTAINER="joplin-db"
|
||||||
|
local WEB_PORT="22300"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Would create $JOPLIN_DIR"
|
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
echo "[DRY-RUN] Would create \$DOCKER_DIR/joplin(-<name>)"
|
||||||
|
echo "[DRY-RUN] Would write docker-compose.yml and .env (dedicated Postgres per instance)"
|
||||||
|
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$JOPLIN_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " Joplin Server is already installed at $JOPLIN_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate Joplin Server instance alongside it (its own"
|
||||||
|
echo " server, database, and port — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'work'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/joplin-$_suffix" ]; then
|
||||||
|
log_warning "joplin-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
JOPLIN_DIR="$DOCKER_DIR/joplin-$_suffix"
|
||||||
|
CONTAINER="joplin-$_suffix"
|
||||||
|
DB_CONTAINER="joplin-db-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $JOPLIN_DIR (port $WEB_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$JOPLIN_DIR"
|
mkdir -p "$JOPLIN_DIR"
|
||||||
ensure_docker_dir_ownership "$JOPLIN_DIR"
|
ensure_docker_dir_ownership "$JOPLIN_DIR"
|
||||||
cd "$JOPLIN_DIR" || return 1
|
cd "$JOPLIN_DIR" || return 1
|
||||||
@@ -203,7 +250,7 @@ install_joplin() {
|
|||||||
local DB_PASS=""
|
local DB_PASS=""
|
||||||
[ -f ".env" ] && DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)"
|
[ -f ".env" ] && DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)"
|
||||||
[ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)"
|
[ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)"
|
||||||
local BASE_URL="https://joplin.${SITE_DOMAIN}"
|
local BASE_URL="https://joplin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}"
|
||||||
|
|
||||||
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
||||||
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
|
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
|
||||||
@@ -229,24 +276,24 @@ networks:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << JOPLIN_COMPOSE
|
cat > docker-compose.yml << JOPLIN_COMPOSE
|
||||||
name: joplin
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
joplin:
|
joplin:
|
||||||
image: joplin/server:latest
|
image: joplin/server:latest
|
||||||
container_name: joplin
|
container_name: $CONTAINER
|
||||||
hostname: joplin
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- joplin-db
|
- joplin-db
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "22300:22300"
|
- "${WEB_PORT}:22300"
|
||||||
${_CADDY_NET_BLOCK}
|
${_CADDY_NET_BLOCK}
|
||||||
joplin-db:
|
joplin-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: joplin-db
|
container_name: $DB_CONTAINER
|
||||||
hostname: joplin-db
|
hostname: $DB_CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
@@ -278,19 +325,22 @@ JOPLIN_ENV
|
|||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JOPLIN_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JOPLIN_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
log_success "Joplin Server configured at $JOPLIN_DIR"
|
log_success "Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $JOPLIN_DIR (port $WEB_PORT)"
|
||||||
log_info "APP_BASE_URL set to: $BASE_URL"
|
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."
|
log_warning "APP_BASE_URL in .env must match the public URL used by Joplin clients."
|
||||||
|
|
||||||
configure_caddy_for_service "Joplin" "joplin:22300" "joplin"
|
configure_caddy_for_service "Joplin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:22300" "joplin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$JOPLIN_DIR" << MD
|
write_readme "$JOPLIN_DIR" << MD
|
||||||
# Joplin Server
|
# Joplin Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Self-hosted sync server for the Joplin note-taking app.
|
Self-hosted sync server for the Joplin note-taking app.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own dedicated
|
||||||
|
database, own port) — not shared notes with another Joplin instance.")
|
||||||
|
|
||||||
## Access
|
## Access
|
||||||
- URL: http://localhost:22300
|
- URL: http://localhost:${WEB_PORT}
|
||||||
- Default admin: admin@localhost / admin (change immediately after first login!)
|
- Default admin: admin@localhost / admin (change immediately after first login!)
|
||||||
|
|
||||||
## Important
|
## Important
|
||||||
@@ -319,14 +369,14 @@ docker compose pull && docker compose down && docker compose up -d # update
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_JOPLIN=""
|
local START_JOPLIN=""
|
||||||
prompt_yn "Start Joplin Server now? (y/n):" "y" START_JOPLIN
|
prompt_yn "Start Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_JOPLIN
|
||||||
if [ "$START_JOPLIN" = "y" ] || [ "$START_JOPLIN" = "Y" ]; then
|
if [ "$START_JOPLIN" = "y" ] || [ "$START_JOPLIN" = "Y" ]; then
|
||||||
docker compose up -d 2>/dev/null \
|
docker compose up -d 2>/dev/null \
|
||||||
&& log_success "Joplin Server started" \
|
&& log_success "Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|
||||||
|| log_warning "Failed to start — check: docker compose logs"
|
|| log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " Access at: http://localhost:22300"
|
echo " Access at: http://localhost:${WEB_PORT}"
|
||||||
echo " Default login: admin@localhost / admin (change immediately!)"
|
echo " Default login: admin@localhost / admin (change immediately!)"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-20
@@ -196,19 +196,85 @@ register_service lyrion media "Music streaming server — Squeezebox, Chromecast
|
|||||||
install_lyrion() {
|
install_lyrion() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "lyrion" name/paths/ports and
|
||||||
|
# network_mode: host exactly as before (zero behavior change for anyone
|
||||||
|
# with a single instance) — host networking is what makes Chromecast and
|
||||||
|
# Squeezebox UDP broadcast/multicast discovery work without manual setup.
|
||||||
|
#
|
||||||
|
# A second instance CANNOT also use network_mode: host — both would bind
|
||||||
|
# the same fixed host ports (9000/9090/3483) and collide outright, and
|
||||||
|
# Docker only allows one container on host networking to own a given port.
|
||||||
|
# So additional instances switch to bridge networking with auto-scanned,
|
||||||
|
# per-instance ports instead. The tradeoff: bridge mode means this
|
||||||
|
# instance loses the zero-config broadcast/multicast auto-discovery that
|
||||||
|
# host networking provides — Chromecasts and Squeezebox hardware won't
|
||||||
|
# find it automatically. Players still work, just not auto-discovered:
|
||||||
|
# point the Squeezer app / Squeezebox firmware at this server's address
|
||||||
|
# and port manually instead of relying on discovery.
|
||||||
local LYRION_DIR="$DOCKER_DIR/lyrion"
|
local LYRION_DIR="$DOCKER_DIR/lyrion"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="lyrion"
|
||||||
|
local USE_HOST_NETWORK=true
|
||||||
|
local WEB_PORT="9000" CLI_PORT="9090" PLAYER_PORT="3483"
|
||||||
local DEFAULT_MUSIC="$ACTUAL_HOME/music"
|
local DEFAULT_MUSIC="$ACTUAL_HOME/music"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Lyrion Music Server would:"
|
echo "[DRY-RUN] Lyrion Music Server would:"
|
||||||
echo " - Create $LYRION_DIR with docker-compose.yml + .env (config/ playlists/)"
|
echo " - Offer to add a new, separate instance if one already exists"
|
||||||
|
echo " - Create \$DOCKER_DIR/lyrion(-<name>) with docker-compose.yml + .env (config/ playlists/)"
|
||||||
echo " - Mount a music folder (default $DEFAULT_MUSIC) read-only at /music"
|
echo " - Mount a music folder (default $DEFAULT_MUSIC) read-only at /music"
|
||||||
echo " - Run with network_mode: host (required for Chromecast/Squeezebox UDP discovery)"
|
echo " - First instance: network_mode: host (Chromecast/Squeezebox UDP discovery)"
|
||||||
echo " - Expose port 9000 (web), 9090 (CLI), 3483 (players)"
|
echo " ports 9000 (web), 9090 (CLI), 3483 (players)"
|
||||||
|
echo " - Additional instances: bridge networking with auto-scanned ports —"
|
||||||
|
echo " loses zero-config discovery; players need the server address entered manually"
|
||||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
echo " - Offer a Caddy reverse proxy and to start the container"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$LYRION_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " Lyrion is already installed at $LYRION_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate Lyrion instance alongside it (its own"
|
||||||
|
echo " server, library, and ports — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/lyrion-$_suffix" ]; then
|
||||||
|
log_warning "lyrion-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
LYRION_DIR="$DOCKER_DIR/lyrion-$_suffix"
|
||||||
|
CONTAINER="lyrion-$_suffix"
|
||||||
|
DEFAULT_MUSIC="$ACTUAL_HOME/music-$_suffix"
|
||||||
|
USE_HOST_NETWORK=false
|
||||||
|
|
||||||
|
log_warning "Additional Lyrion instances use bridge networking (not host) so they"
|
||||||
|
log_warning "don't collide with the first instance's fixed ports. This instance"
|
||||||
|
log_warning "loses zero-config Chromecast/Squeezebox discovery — enter its address"
|
||||||
|
log_warning "manually in the Squeezer app / Squeezebox firmware instead."
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -tlnH "sport = :${CLI_PORT}" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -tlnH "sport = :${PLAYER_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
CLI_PORT=$((CLI_PORT + 1))
|
||||||
|
PLAYER_PORT=$((PLAYER_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $LYRION_DIR (web $WEB_PORT, CLI $CLI_PORT, player $PLAYER_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
local MUSIC_PATH=""
|
local MUSIC_PATH=""
|
||||||
prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH
|
prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH
|
||||||
MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}"
|
MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}"
|
||||||
@@ -221,17 +287,28 @@ install_lyrion() {
|
|||||||
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER")
|
UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER")
|
||||||
|
|
||||||
|
local _NETWORK_BLOCK=" network_mode: host
|
||||||
|
"
|
||||||
|
local _PORTS_BLOCK=""
|
||||||
|
if [ "$USE_HOST_NETWORK" != true ]; then
|
||||||
|
_NETWORK_BLOCK=""
|
||||||
|
_PORTS_BLOCK=" ports:
|
||||||
|
- \"${WEB_PORT}:9000\"
|
||||||
|
- \"${CLI_PORT}:9090\"
|
||||||
|
- \"${PLAYER_PORT}:3483\"
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << LYRION_COMPOSE
|
cat > docker-compose.yml << LYRION_COMPOSE
|
||||||
name: lyrion
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
lyrion:
|
lyrion:
|
||||||
image: lmscommunity/lyrionmusicserver:stable
|
image: lmscommunity/lyrionmusicserver:stable
|
||||||
container_name: lyrion
|
container_name: $CONTAINER
|
||||||
hostname: lyrion
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
${_NETWORK_BLOCK}${_PORTS_BLOCK} environment:
|
||||||
environment:
|
|
||||||
- HTTP_PORT=9000
|
- HTTP_PORT=9000
|
||||||
- PUID=$UID_VAL
|
- PUID=$UID_VAL
|
||||||
- PGID=$GID_VAL
|
- PGID=$GID_VAL
|
||||||
@@ -251,19 +328,26 @@ LYRION_ENV
|
|||||||
|
|
||||||
mkdir -p config playlists
|
mkdir -p config playlists
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LYRION_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LYRION_DIR"
|
||||||
log_success "Lyrion Music Server configured at $LYRION_DIR"
|
log_success "Lyrion Music Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $LYRION_DIR (port $WEB_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "Lyrion" "9000" "lyrion"
|
configure_caddy_for_service "Lyrion${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${WEB_PORT}" "lyrion${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$LYRION_DIR" << MD
|
write_readme "$LYRION_DIR" << MD
|
||||||
# Lyrion Music Server
|
# Lyrion Music Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Stream music to Squeezebox devices, the Squeezer Android/iOS app, and Chromecast.
|
Stream music to Squeezebox devices, the Squeezer Android/iOS app, and Chromecast.
|
||||||
Formerly known as Logitech Media Server (LMS).
|
Formerly known as Logitech Media Server (LMS).
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own library, own
|
||||||
|
ports) — not a shared library with another Lyrion instance. It runs on
|
||||||
|
**bridge networking**, not host networking, so it does NOT get zero-config
|
||||||
|
Chromecast/Squeezebox discovery — enter this server's address and port
|
||||||
|
manually in the Squeezer app or Squeezebox firmware instead of relying on
|
||||||
|
auto-discovery.")
|
||||||
|
|
||||||
- Web UI: http://localhost:9000
|
- Web UI: http://localhost:${WEB_PORT}
|
||||||
- Player port: 3483 (Squeezeboxes / apps)
|
- Player port: ${PLAYER_PORT} (Squeezeboxes / apps)
|
||||||
- CLI port: 9090
|
- CLI port: ${CLI_PORT}
|
||||||
- Music folder (read-only): \`$MUSIC_PATH\` → mounted at /music
|
- Music folder (read-only): \`$MUSIC_PATH\` → mounted at /music
|
||||||
- App data: \`config/\` and \`playlists/\`
|
- App data: \`config/\` and \`playlists/\`
|
||||||
|
|
||||||
@@ -277,21 +361,26 @@ docker compose pull && docker compose up -d # update
|
|||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices
|
$( [ "$USE_HOST_NETWORK" = true ] && echo "- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices
|
||||||
works without manual port mapping.
|
works without manual port mapping." || echo "- Uses bridge networking (auto-scanned ports) since the first instance already
|
||||||
|
owns the fixed host-networking ports. Discovery is manual for this instance." )
|
||||||
- Change the music path in \`.env\` (\`MUSIC_PATH=\`), then \`docker compose up -d\`.
|
- Change the music path in \`.env\` (\`MUSIC_PATH=\`), then \`docker compose up -d\`.
|
||||||
- Add music libraries in the web UI under Settings → Music Library.
|
- Add music libraries in the web UI under Settings → Music Library.
|
||||||
MD
|
MD
|
||||||
|
|
||||||
local START_LMS=""
|
local START_LMS=""
|
||||||
prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS
|
prompt_yn "Start Lyrion Music Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_LMS
|
||||||
if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then
|
if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then
|
||||||
docker compose up -d && log_success "Lyrion started" || log_warning "Failed to start — check: docker compose logs"
|
docker compose up -d && log_success "Lyrion${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Access at: http://localhost:9000"
|
echo " Access at: http://localhost:${WEB_PORT}"
|
||||||
echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery"
|
if [ "$USE_HOST_NETWORK" = true ]; then
|
||||||
|
echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery"
|
||||||
|
else
|
||||||
|
echo " Note: uses bridge networking — no auto-discovery, configure players manually"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+64
-17
@@ -196,17 +196,61 @@ register_service meshcentral utilities "Self-hosted remote device management ser
|
|||||||
install_meshcentral() {
|
install_meshcentral() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "meshcentral" name/paths/ports exactly as
|
||||||
|
# before (zero behavior change for anyone with a single instance). Only
|
||||||
|
# asking to add a second one introduces suffixed naming — same pattern as
|
||||||
|
# services/mattermost.sh and services/wordpress.sh. Two ports (web + agent)
|
||||||
|
# move together so they stay easy to reason about instance-to-instance.
|
||||||
local MC_DIR="$DOCKER_DIR/meshcentral"
|
local MC_DIR="$DOCKER_DIR/meshcentral"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="meshcentral"
|
||||||
|
local WEB_PORT="4430" AGENT_PORT="4433"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] MeshCentral would:"
|
echo "[DRY-RUN] MeshCentral would:"
|
||||||
echo " - Create $MC_DIR with docker-compose.yml + .env (data/ files/ backups/)"
|
echo " - Offer to add a new, separate instance if one already exists"
|
||||||
|
echo " - Create \$DOCKER_DIR/meshcentral(-<name>) with docker-compose.yml + .env (data/ files/ backups/)"
|
||||||
echo " - Prompt for hostname (domain or IP for agent connections)"
|
echo " - Prompt for hostname (domain or IP for agent connections)"
|
||||||
echo " - Expose port 4430 (HTTPS web) and 4433 (agent)"
|
echo " - Expose port 4430 (HTTPS web) and 4433 (agent), auto-scanned for additional instances"
|
||||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
echo " - Offer a Caddy reverse proxy and to start the container"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$MC_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " MeshCentral is already installed at $MC_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate MeshCentral instance alongside it (its own"
|
||||||
|
echo " server, devices, and ports — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'clients'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/meshcentral-$_suffix" ]; then
|
||||||
|
log_warning "meshcentral-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
MC_DIR="$DOCKER_DIR/meshcentral-$_suffix"
|
||||||
|
CONTAINER="meshcentral-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -tlnH "sport = :${AGENT_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
AGENT_PORT=$((AGENT_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
local MC_HOSTNAME=""
|
local MC_HOSTNAME=""
|
||||||
prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME
|
prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME
|
||||||
MC_HOSTNAME="${MC_HOSTNAME:-localhost}"
|
MC_HOSTNAME="${MC_HOSTNAME:-localhost}"
|
||||||
@@ -239,13 +283,13 @@ networks:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << MC_COMPOSE
|
cat > docker-compose.yml << MC_COMPOSE
|
||||||
name: meshcentral
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
meshcentral:
|
meshcentral:
|
||||||
image: ghcr.io/ylianst/meshcentral:latest
|
image: ghcr.io/ylianst/meshcentral:latest
|
||||||
container_name: meshcentral
|
container_name: $CONTAINER
|
||||||
hostname: meshcentral
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
@@ -260,8 +304,8 @@ services:
|
|||||||
- ./files:/opt/meshcentral/meshcentral-files
|
- ./files:/opt/meshcentral/meshcentral-files
|
||||||
- ./backups:/opt/meshcentral/meshcentral-backups
|
- ./backups:/opt/meshcentral/meshcentral-backups
|
||||||
ports:
|
ports:
|
||||||
- "4430:443"
|
- "${WEB_PORT}:443"
|
||||||
- "4433:4433"
|
- "${AGENT_PORT}:4433"
|
||||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||||
MC_COMPOSE
|
MC_COMPOSE
|
||||||
|
|
||||||
@@ -274,18 +318,21 @@ MC_ENV
|
|||||||
|
|
||||||
mkdir -p data files backups
|
mkdir -p data files backups
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR"
|
||||||
log_success "MeshCentral configured at $MC_DIR"
|
log_success "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "MeshCentral" "meshcentral:443" "mesh"
|
configure_caddy_for_service "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:443" "mesh${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$MC_DIR" << MD
|
write_readme "$MC_DIR" << MD
|
||||||
# MeshCentral
|
# MeshCentral${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Self-hosted remote device management — remotely access, manage, and monitor
|
Self-hosted remote device management — remotely access, manage, and monitor
|
||||||
all your computers from a single web interface. Install agents on each device.
|
all your computers from a single web interface. Install agents on each device.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own devices, own
|
||||||
|
ports) — not shared devices with another MeshCentral instance.")
|
||||||
|
|
||||||
- Web UI: https://localhost:4430 (self-signed cert on first launch)
|
- Web UI: https://localhost:${WEB_PORT} (self-signed cert on first launch)
|
||||||
- Agent listener: port 4433 (devices connect here — forward this port if remote)
|
- Agent listener: port ${AGENT_PORT} (devices connect here — forward this port if remote)
|
||||||
- Hostname: \`$MC_HOSTNAME\` (update \`MC_HOSTNAME\` in .env if it changes)
|
- Hostname: \`$MC_HOSTNAME\` (update \`MC_HOSTNAME\` in .env if it changes)
|
||||||
- App data: \`data/\`, \`files/\`, \`backups/\`
|
- App data: \`data/\`, \`files/\`, \`backups/\`
|
||||||
|
|
||||||
@@ -299,14 +346,14 @@ docker compose pull && docker compose up -d # update
|
|||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## First launch
|
## First launch
|
||||||
1. Open https://localhost:4430 (accept the self-signed cert warning)
|
1. Open https://localhost:${WEB_PORT} (accept the self-signed cert warning)
|
||||||
2. Create your admin account
|
2. Create your admin account
|
||||||
3. Go to "My Devices" → "+ Add Device" → download the agent for each OS
|
3. Go to "My Devices" → "+ Add Device" → download the agent for each OS
|
||||||
4. Install the agent on every computer you want to manage
|
4. Install the agent on every computer you want to manage
|
||||||
|
|
||||||
## Remote access
|
## Remote access
|
||||||
For devices outside your LAN to connect:
|
For devices outside your LAN to connect:
|
||||||
- Forward **TCP port 4433** on your router to this server
|
- Forward **TCP port ${AGENT_PORT}** on your router to this server
|
||||||
- Set \`MC_HOSTNAME\` in \`.env\` to your public domain/IP, then restart
|
- Set \`MC_HOSTNAME\` in \`.env\` to your public domain/IP, then restart
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
@@ -314,13 +361,13 @@ https://meshcentral.com/docs/
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_MC=""
|
local START_MC=""
|
||||||
prompt_yn "Start MeshCentral now? (y/n):" "y" START_MC
|
prompt_yn "Start MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_MC
|
||||||
if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then
|
if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then
|
||||||
docker compose up -d && log_success "MeshCentral started" || log_warning "Failed to start — check: docker compose logs"
|
docker compose up -d && log_success "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Access at: https://localhost:4430 (accept self-signed cert)"
|
echo " Access at: https://localhost:${WEB_PORT} (accept self-signed cert)"
|
||||||
echo " First visit: create your admin account"
|
echo " First visit: create your admin account"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-17
@@ -192,13 +192,57 @@ register_service ntfy utilities "Self-hosted push notifications (ntfy)" 8090
|
|||||||
install_ntfy() {
|
install_ntfy() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
log_info "Installing ntfy..."
|
log_info "Installing ntfy..."
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "ntfy" name/paths/port exactly as before
|
||||||
|
# (zero behavior change for anyone with a single instance — including
|
||||||
|
# crowdsec.sh/pstn-trunk.sh, which curl the default port directly). Only
|
||||||
|
# asking to add a second one introduces suffixed naming — same pattern as
|
||||||
|
# services/mattermost.sh and services/wordpress.sh.
|
||||||
local NTFY_DIR="$DOCKER_DIR/ntfy"
|
local NTFY_DIR="$DOCKER_DIR/ntfy"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="ntfy"
|
||||||
|
local WEB_PORT="8090"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Would create $NTFY_DIR"
|
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||||
|
echo "[DRY-RUN] Would create $NTFY_DIR(-<name>)"
|
||||||
|
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$NTFY_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " ntfy is already installed at $NTFY_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate ntfy instance alongside it (its own"
|
||||||
|
echo " server, topics, and port — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'alerts'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/ntfy-$_suffix" ]; then
|
||||||
|
log_warning "ntfy-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
NTFY_DIR="$DOCKER_DIR/ntfy-$_suffix"
|
||||||
|
CONTAINER="ntfy-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $NTFY_DIR (port $WEB_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$NTFY_DIR"
|
mkdir -p "$NTFY_DIR"
|
||||||
ensure_docker_dir_ownership "$NTFY_DIR"
|
ensure_docker_dir_ownership "$NTFY_DIR"
|
||||||
cd "$NTFY_DIR" || return 1
|
cd "$NTFY_DIR" || return 1
|
||||||
@@ -227,13 +271,13 @@ networks:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << NTFY_COMPOSE
|
cat > docker-compose.yml << NTFY_COMPOSE
|
||||||
name: ntfy
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/ntfy:latest
|
image: binwiederhier/ntfy:latest
|
||||||
container_name: ntfy
|
container_name: $CONTAINER
|
||||||
hostname: ntfy
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: serve
|
command: serve
|
||||||
environment:
|
environment:
|
||||||
@@ -242,7 +286,7 @@ services:
|
|||||||
- ./cache:/var/cache/ntfy
|
- ./cache:/var/cache/ntfy
|
||||||
- ./config:/etc/ntfy
|
- ./config:/etc/ntfy
|
||||||
ports:
|
ports:
|
||||||
- "8090:80"
|
- "${WEB_PORT}:80"
|
||||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||||
NTFY_COMPOSE
|
NTFY_COMPOSE
|
||||||
|
|
||||||
@@ -258,7 +302,7 @@ NTFY_ENV
|
|||||||
if [ ! -f config/server.yml ]; then
|
if [ ! -f config/server.yml ]; then
|
||||||
local NTFY_BASE_URL=""
|
local NTFY_BASE_URL=""
|
||||||
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
|
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
|
||||||
NTFY_BASE_URL="https://ntfy.${SITE_DOMAIN}"
|
NTFY_BASE_URL="https://ntfy${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}"
|
||||||
fi
|
fi
|
||||||
cat > config/server.yml << NTFY_CFG
|
cat > config/server.yml << NTFY_CFG
|
||||||
# ntfy server configuration — https://docs.ntfy.sh/config/
|
# ntfy server configuration — https://docs.ntfy.sh/config/
|
||||||
@@ -291,22 +335,25 @@ NTFY_CFG
|
|||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
log_success "ntfy configured at $NTFY_DIR"
|
log_success "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $NTFY_DIR (port $WEB_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "ntfy" "ntfy:80" "ntfy"
|
configure_caddy_for_service "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "ntfy${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$NTFY_DIR" << MD
|
write_readme "$NTFY_DIR" << MD
|
||||||
# ntfy
|
# ntfy${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Self-hosted push notification server. Send notifications from scripts to your
|
Self-hosted push notification server. Send notifications from scripts to your
|
||||||
phone or browser.
|
phone or browser.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own topics, own
|
||||||
|
port) — not shared topics with another ntfy instance.")
|
||||||
|
|
||||||
## Access
|
## Access
|
||||||
- URL: http://localhost:8090
|
- URL: http://localhost:${WEB_PORT}
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
- Send a notification: \`curl -d "Hello!" localhost:8090/mytopic\`
|
- Send a notification: \`curl -d "Hello!" localhost:${WEB_PORT}/mytopic\`
|
||||||
- Subscribe on phone: ntfy app -> Add subscription -> localhost:8090/mytopic
|
- Subscribe on phone: ntfy app -> Add subscription -> localhost:${WEB_PORT}/mytopic
|
||||||
|
|
||||||
## Access model
|
## Access model
|
||||||
\`auth-default-access: read-write\` — anyone who knows a topic name can read
|
\`auth-default-access: read-write\` — anyone who knows a topic name can read
|
||||||
@@ -335,15 +382,15 @@ docker compose logs -f # logs
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_NTFY=""
|
local START_NTFY=""
|
||||||
prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY
|
prompt_yn "Start ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_NTFY
|
||||||
if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then
|
if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then
|
||||||
docker compose up -d 2>/dev/null && log_success "ntfy started" || log_warning "Failed to start"
|
docker compose up -d 2>/dev/null && log_success "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " Access at: http://localhost:8090"
|
echo " Access at: http://localhost:${WEB_PORT}"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic"
|
echo " Send notification: curl -d \"Hello!\" localhost:${WEB_PORT}/mytopic"
|
||||||
echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic"
|
echo " Subscribe on phone: ntfy app → Add subscription → localhost:${WEB_PORT}/mytopic"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+84
-26
@@ -100,21 +100,76 @@ register_service rustdesk utilities "Self-hosted remote desktop relay (RustDesk)
|
|||||||
install_rustdesk() {
|
install_rustdesk() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
log_info "Installing RustDesk server..."
|
log_info "Installing RustDesk server..."
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "rustdesk" name/paths/ports exactly as
|
||||||
|
# before (zero behavior change for anyone with a single instance). Only
|
||||||
|
# asking to add a second one introduces suffixed naming — same pattern as
|
||||||
|
# services/mattermost.sh and services/wordpress.sh. The 6 ports
|
||||||
|
# (21115-21119 TCP + 21116 UDP) are fixed *inside* the container — the
|
||||||
|
# image doesn't expose an env var to change them — so an additional
|
||||||
|
# instance shifts the whole host-side block by a fixed offset instead of
|
||||||
|
# scanning port-by-port, the same approach services/traccar.sh uses for
|
||||||
|
# its device-protocol range.
|
||||||
local RD_DIR="$DOCKER_DIR/rustdesk"
|
local RD_DIR="$DOCKER_DIR/rustdesk"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="rustdesk"
|
||||||
|
local PORT_OFFSET=0
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Would create $RD_DIR (rustdesk_data/)"
|
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||||
|
echo "[DRY-RUN] Would create \$DOCKER_DIR/rustdesk(-<name>) (rustdesk_data/)"
|
||||||
echo "[DRY-RUN] Would deploy rustdesk/rustdesk-server-s6:latest"
|
echo "[DRY-RUN] Would deploy rustdesk/rustdesk-server-s6:latest"
|
||||||
echo "[DRY-RUN] Ports: 21115-21119 TCP, 21116 UDP"
|
echo "[DRY-RUN] Ports: 21115-21119 TCP, 21116 UDP — whole block shifted for additional instances"
|
||||||
echo "[DRY-RUN] Would prompt for server FQDN/IP (RELAY env var)"
|
echo "[DRY-RUN] Would prompt for server FQDN/IP (RELAY env var)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$RD_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " RustDesk is already installed at $RD_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate RustDesk relay instance alongside it (its own"
|
||||||
|
echo " server and ports — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'work'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/rustdesk-$_suffix" ]; then
|
||||||
|
log_warning "rustdesk-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
RD_DIR="$DOCKER_DIR/rustdesk-$_suffix"
|
||||||
|
CONTAINER="rustdesk-$_suffix"
|
||||||
|
|
||||||
|
PORT_OFFSET=10
|
||||||
|
while ss -tlnH "sport = :$((21115 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -tlnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -ulnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -tlnH "sport = :$((21117 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -tlnH "sport = :$((21118 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -tlnH "sport = :$((21119 + PORT_OFFSET))" 2>/dev/null | grep -q .; do
|
||||||
|
PORT_OFFSET=$((PORT_OFFSET + 10))
|
||||||
|
done
|
||||||
|
log_info "New instance: $RD_DIR (ports $((21115 + PORT_OFFSET))-$((21119 + PORT_OFFSET)))"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$RD_DIR/rustdesk_data"
|
mkdir -p "$RD_DIR/rustdesk_data"
|
||||||
ensure_docker_dir_ownership "$RD_DIR"
|
ensure_docker_dir_ownership "$RD_DIR"
|
||||||
cd "$RD_DIR" || return 1
|
cd "$RD_DIR" || return 1
|
||||||
|
|
||||||
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
|
local P_NAT=$((21115 + PORT_OFFSET)) P_ID=$((21116 + PORT_OFFSET)) \
|
||||||
|
P_RELAY=$((21117 + PORT_OFFSET)) P_WS=$((21118 + PORT_OFFSET)) P_WSS=$((21119 + PORT_OFFSET))
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " RustDesk needs to know its own public hostname or IP."
|
echo " RustDesk needs to know its own public hostname or IP."
|
||||||
@@ -134,23 +189,23 @@ install_rustdesk() {
|
|||||||
prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc
|
prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc
|
||||||
[ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0"
|
[ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0"
|
||||||
|
|
||||||
cat > docker-compose.yml << 'RD_COMPOSE'
|
cat > docker-compose.yml << RD_COMPOSE
|
||||||
name: rustdesk
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
rustdesk:
|
rustdesk:
|
||||||
image: rustdesk/rustdesk-server-s6:latest
|
image: rustdesk/rustdesk-server-s6:latest
|
||||||
container_name: rustdesk
|
container_name: $CONTAINER
|
||||||
hostname: rustdesk
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "21115:21115"
|
- "${P_NAT}:21115"
|
||||||
- "21116:21116"
|
- "${P_ID}:21116"
|
||||||
- "21116:21116/udp"
|
- "${P_ID}:21116/udp"
|
||||||
- "21117:21117"
|
- "${P_RELAY}:21117"
|
||||||
- "21118:21118"
|
- "${P_WS}:21118"
|
||||||
- "21119:21119"
|
- "${P_WSS}:21119"
|
||||||
volumes:
|
volumes:
|
||||||
- ./rustdesk_data:/data
|
- ./rustdesk_data:/data
|
||||||
RD_COMPOSE
|
RD_COMPOSE
|
||||||
@@ -161,8 +216,8 @@ TZ=$TZ_VAL
|
|||||||
|
|
||||||
# ── RustDesk server ───────────────────────────────────────────────────────────
|
# ── RustDesk server ───────────────────────────────────────────────────────────
|
||||||
# RELAY: public FQDN or IP that clients use to reach the relay daemon (HBBR).
|
# RELAY: public FQDN or IP that clients use to reach the relay daemon (HBBR).
|
||||||
# Include the port if it's non-standard: hostname:21117
|
# Include the port if it's non-standard: hostname:$P_RELAY
|
||||||
RELAY=$RELAY_HOST:21117
|
RELAY=$RELAY_HOST:$P_RELAY
|
||||||
|
|
||||||
# ENCRYPTED_ONLY: 1 = only clients with the matching public key can connect.
|
# ENCRYPTED_ONLY: 1 = only clients with the matching public key can connect.
|
||||||
# After first startup, copy the key from ./rustdesk_data/id_ed25519.pub to
|
# After first startup, copy the key from ./rustdesk_data/id_ed25519.pub to
|
||||||
@@ -177,13 +232,16 @@ RD_ENV
|
|||||||
|
|
||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$RD_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$RD_DIR"
|
||||||
log_success "RustDesk configured at $RD_DIR"
|
log_success "RustDesk${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $RD_DIR (ports $P_NAT-$P_WSS)"
|
||||||
|
|
||||||
write_readme "$RD_DIR" << MD
|
write_readme "$RD_DIR" << MD
|
||||||
# RustDesk — self-hosted remote desktop relay
|
# RustDesk${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} — self-hosted remote desktop relay
|
||||||
|
|
||||||
Open-source TeamViewer alternative. This is the server-side relay/rendezvous
|
Open-source TeamViewer alternative. This is the server-side relay/rendezvous
|
||||||
daemon. Clients use the RustDesk desktop/mobile app to connect.
|
daemon. Clients use the RustDesk desktop/mobile app to connect.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own key, own port
|
||||||
|
block) — not shared clients with another RustDesk instance.")
|
||||||
|
|
||||||
## After starting: get the public key
|
## After starting: get the public key
|
||||||
|
|
||||||
@@ -193,8 +251,8 @@ cat $RD_DIR/rustdesk_data/id_ed25519.pub
|
|||||||
|
|
||||||
Paste this key into each client:
|
Paste this key into each client:
|
||||||
**Settings → Network → ID/Relay Server**
|
**Settings → Network → ID/Relay Server**
|
||||||
- ID Server: $RELAY_HOST
|
- ID Server: $RELAY_HOST:$P_ID
|
||||||
- Relay Server: $RELAY_HOST
|
- Relay Server: $RELAY_HOST:$P_RELAY
|
||||||
- Key: <paste id_ed25519.pub contents>
|
- Key: <paste id_ed25519.pub contents>
|
||||||
|
|
||||||
## Firewall / router rules required
|
## Firewall / router rules required
|
||||||
@@ -202,11 +260,11 @@ Paste this key into each client:
|
|||||||
Open these ports to this server's IP:
|
Open these ports to this server's IP:
|
||||||
| Port | Protocol | Purpose |
|
| Port | Protocol | Purpose |
|
||||||
|------|----------|---------|
|
|------|----------|---------|
|
||||||
| 21115 | TCP | NAT type test |
|
| $P_NAT | TCP | NAT type test |
|
||||||
| 21116 | TCP+UDP | ID register / hole-punching |
|
| $P_ID | TCP+UDP | ID register / hole-punching |
|
||||||
| 21117 | TCP | Relay traffic |
|
| $P_RELAY | TCP | Relay traffic |
|
||||||
| 21118 | TCP | WebSocket |
|
| $P_WS | TCP | WebSocket |
|
||||||
| 21119 | TCP | WebSocket HTTPS |
|
| $P_WSS | TCP | WebSocket HTTPS |
|
||||||
|
|
||||||
## Cross-VLAN setup
|
## Cross-VLAN setup
|
||||||
Use the server's FQDN (not LAN IP) in RELAY so clients on any VLAN
|
Use the server's FQDN (not LAN IP) in RELAY so clients on any VLAN
|
||||||
@@ -224,10 +282,10 @@ docker compose pull && docker compose up -d # update
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_RD=""
|
local START_RD=""
|
||||||
prompt_yn "Start RustDesk server now? (y/n):" "y" START_RD
|
prompt_yn "Start RustDesk server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_RD
|
||||||
if [ "$START_RD" = "y" ] || [ "$START_RD" = "Y" ]; then
|
if [ "$START_RD" = "y" ] || [ "$START_RD" = "Y" ]; then
|
||||||
docker compose up -d \
|
docker compose up -d \
|
||||||
&& log_success "RustDesk started" \
|
&& log_success "RustDesk${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|
||||||
|| log_warning "Failed to start — check: docker compose logs"
|
|| log_warning "Failed to start — check: docker compose logs"
|
||||||
echo ""
|
echo ""
|
||||||
echo " After startup, get the public key:"
|
echo " After startup, get the public key:"
|
||||||
@@ -237,7 +295,7 @@ MD
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Relay host: $RELAY_HOST"
|
echo " Relay host: $RELAY_HOST"
|
||||||
echo " Ports 21115-21119 must be open in your firewall/router."
|
echo " Ports $P_NAT-$P_WSS must be open in your firewall/router."
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+90
-27
@@ -98,16 +98,72 @@ register_service unifi utilities "Ubiquiti network controller (UniFi)" 8443
|
|||||||
install_unifi() {
|
install_unifi() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
log_info "Installing UniFi Network Application..."
|
log_info "Installing UniFi Network Application..."
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "unifi-db"/"unifi-app" names/paths/ports
|
||||||
|
# exactly as before (zero behavior change for anyone with a single
|
||||||
|
# instance). Only asking to add a second one introduces suffixed naming —
|
||||||
|
# same pattern as services/mattermost.sh and services/wordpress.sh.
|
||||||
|
# Each instance gets its own dedicated MongoDB container (not shared) —
|
||||||
|
# see CLAUDE.md's "Multi-instance services" section for why: Kopia's
|
||||||
|
# generic backup stops the container to snapshot it, so a shared DB would
|
||||||
|
# back up/restore every instance's site data as one unit instead of
|
||||||
|
# per-instance. All 4 published ports move together per instance.
|
||||||
local UNIFI_DIR="$DOCKER_DIR/unifi"
|
local UNIFI_DIR="$DOCKER_DIR/unifi"
|
||||||
|
local INSTANCE_SUFFIX="" PROJECT="unifi" DB_CONTAINER="unifi-db" APP_CONTAINER="unifi-app"
|
||||||
|
local WEB_PORT="8443" INFORM_PORT="8080" STUN_PORT="3478" DISCOVERY_PORT="10001"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Would create $UNIFI_DIR (mongo_db_data/, unifi_data/)"
|
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||||
|
echo "[DRY-RUN] Would create \$DOCKER_DIR/unifi(-<name>) (mongo_db_data/, unifi_data/)"
|
||||||
echo "[DRY-RUN] Would deploy mongo:4 + linuxserver/unifi-network-application:latest"
|
echo "[DRY-RUN] Would deploy mongo:4 + linuxserver/unifi-network-application:latest"
|
||||||
echo "[DRY-RUN] Ports: 8443 (HTTPS web UI), 8080 (device inform), 3478/udp (STUN), 10001/udp (discovery)"
|
echo "[DRY-RUN] Ports: 8443 (HTTPS web UI), 8080 (device inform), 3478/udp (STUN), 10001/udp (discovery)"
|
||||||
|
echo "[DRY-RUN] — all 4 auto-scanned/shifted together for additional instances"
|
||||||
echo "[DRY-RUN] Would generate MongoDB credentials"
|
echo "[DRY-RUN] Would generate MongoDB credentials"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$UNIFI_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " UniFi is already installed at $UNIFI_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate UniFi controller instance alongside it (its own"
|
||||||
|
echo " database, sites, and ports — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'guest-site'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/unifi-$_suffix" ]; then
|
||||||
|
log_warning "unifi-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
UNIFI_DIR="$DOCKER_DIR/unifi-$_suffix"
|
||||||
|
PROJECT="unifi-$_suffix"
|
||||||
|
DB_CONTAINER="unifi-db-$_suffix"
|
||||||
|
APP_CONTAINER="unifi-app-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -tlnH "sport = :${INFORM_PORT}" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -ulnH "sport = :${STUN_PORT}" 2>/dev/null | grep -q . \
|
||||||
|
|| ss -ulnH "sport = :${DISCOVERY_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
INFORM_PORT=$((INFORM_PORT + 1))
|
||||||
|
STUN_PORT=$((STUN_PORT + 1))
|
||||||
|
DISCOVERY_PORT=$((DISCOVERY_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $UNIFI_DIR (web $WEB_PORT, inform $INFORM_PORT, STUN $STUN_PORT, discovery $DISCOVERY_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$UNIFI_DIR"
|
mkdir -p "$UNIFI_DIR"
|
||||||
ensure_docker_dir_ownership "$UNIFI_DIR"
|
ensure_docker_dir_ownership "$UNIFI_DIR"
|
||||||
cd "$UNIFI_DIR" || return 1
|
cd "$UNIFI_DIR" || return 1
|
||||||
@@ -143,13 +199,13 @@ networks:
|
|||||||
|
|
||||||
# Unquoted heredoc; ${...} used for caddy_net vars; all Docker Compose vars escaped with \$
|
# Unquoted heredoc; ${...} used for caddy_net vars; all Docker Compose vars escaped with \$
|
||||||
cat > docker-compose.yml << UNIFI_COMPOSE
|
cat > docker-compose.yml << UNIFI_COMPOSE
|
||||||
name: unifi
|
name: $PROJECT
|
||||||
|
|
||||||
services:
|
services:
|
||||||
unifi-db:
|
unifi-db:
|
||||||
image: mongo:4
|
image: mongo:4
|
||||||
container_name: unifi-db
|
container_name: $DB_CONTAINER
|
||||||
hostname: unifi-db
|
hostname: $DB_CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
@@ -162,8 +218,8 @@ services:
|
|||||||
|
|
||||||
unifi-app:
|
unifi-app:
|
||||||
image: lscr.io/linuxserver/unifi-network-application:latest
|
image: lscr.io/linuxserver/unifi-network-application:latest
|
||||||
container_name: unifi-app
|
container_name: $APP_CONTAINER
|
||||||
hostname: unifi-app
|
hostname: $APP_CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -171,10 +227,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./unifi_data:/config
|
- ./unifi_data:/config
|
||||||
ports:
|
ports:
|
||||||
- "8443:8443"
|
- "${WEB_PORT}:8443"
|
||||||
- "8080:8080"
|
- "${INFORM_PORT}:8080"
|
||||||
- "3478:3478/udp"
|
- "${STUN_PORT}:3478/udp"
|
||||||
- "10001:10001/udp"
|
- "${DISCOVERY_PORT}:10001/udp"
|
||||||
# Optional — uncomment as needed:
|
# Optional — uncomment as needed:
|
||||||
# - "1900:1900/udp" # L2 discovery (may conflict with UPnP)
|
# - "1900:1900/udp" # L2 discovery (may conflict with UPnP)
|
||||||
# - "8843:8843" # guest portal HTTPS
|
# - "8843:8843" # guest portal HTTPS
|
||||||
@@ -216,7 +272,7 @@ UNIFI_ENV
|
|||||||
mkdir -p mongo_db_data unifi_data
|
mkdir -p mongo_db_data unifi_data
|
||||||
ensure_docker_dir_ownership "$UNIFI_DIR"
|
ensure_docker_dir_ownership "$UNIFI_DIR"
|
||||||
|
|
||||||
log_success "UniFi configured at $UNIFI_DIR"
|
log_success "UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $UNIFI_DIR (web port $WEB_PORT)"
|
||||||
|
|
||||||
# ── Optional Caddy reverse proxy (HTTPS backend requires tls_insecure_skip_verify) ──
|
# ── Optional Caddy reverse proxy (HTTPS backend requires tls_insecure_skip_verify) ──
|
||||||
local _caddy_mode="none"
|
local _caddy_mode="none"
|
||||||
@@ -232,15 +288,18 @@ UNIFI_ENV
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
local CADDY_UNIFI=""
|
local CADDY_UNIFI=""
|
||||||
prompt_yn "Configure Caddy reverse proxy for UniFi? (y/n):" "n" CADDY_UNIFI
|
prompt_yn "Configure Caddy reverse proxy for UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}? (y/n):" "n" CADDY_UNIFI
|
||||||
if [ "$CADDY_UNIFI" = "y" ] || [ "$CADDY_UNIFI" = "Y" ]; then
|
if [ "$CADDY_UNIFI" = "y" ] || [ "$CADDY_UNIFI" = "Y" ]; then
|
||||||
local UNIFI_DOMAIN=""
|
local UNIFI_DOMAIN=""
|
||||||
local _def_domain="unifi.${SITE_DOMAIN:-example.com}"
|
local _def_domain="unifi${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}"
|
||||||
prompt_text "UniFi domain [${_def_domain}]:" "$_def_domain" UNIFI_DOMAIN
|
prompt_text "UniFi domain [${_def_domain}]:" "$_def_domain" UNIFI_DOMAIN
|
||||||
if [ -n "$UNIFI_DOMAIN" ]; then
|
if [ -n "$UNIFI_DOMAIN" ]; then
|
||||||
# UniFi uses HTTPS internally — upstream must use https:// + skip verify
|
# UniFi uses HTTPS internally — upstream must use https:// + skip verify.
|
||||||
local _upstream="https://unifi-app:8443"
|
# Local mode reaches the container over Docker networking (internal port
|
||||||
[ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:8443"
|
# never changes); remote mode reaches this host's published port, which
|
||||||
|
# is WEB_PORT (auto-scanned for additional instances).
|
||||||
|
local _upstream="https://${APP_CONTAINER}:8443"
|
||||||
|
[ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:${WEB_PORT}"
|
||||||
|
|
||||||
local _site_block
|
local _site_block
|
||||||
_site_block="$(cat << CBLOCK
|
_site_block="$(cat << CBLOCK
|
||||||
@@ -277,7 +336,7 @@ CBLOCK
|
|||||||
|| log_warning "Caddy reload failed — check: docker logs caddy"
|
|| log_warning "Caddy reload failed — check: docker logs caddy"
|
||||||
else
|
else
|
||||||
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
||||||
local _snippet_file="$_snippet_dir/unifi.caddy"
|
local _snippet_file="$_snippet_dir/unifi${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.caddy"
|
||||||
mkdir -p "$_snippet_dir"
|
mkdir -p "$_snippet_dir"
|
||||||
printf '%s\n' "$_site_block" > "$_snippet_file"
|
printf '%s\n' "$_site_block" > "$_snippet_file"
|
||||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
||||||
@@ -290,25 +349,29 @@ CBLOCK
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
write_readme "$UNIFI_DIR" << MD
|
write_readme "$UNIFI_DIR" << MD
|
||||||
# UniFi Network Application
|
# UniFi Network Application${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Ubiquiti network controller. Manages UniFi APs, switches, and gateways.
|
Ubiquiti network controller. Manages UniFi APs, switches, and gateways.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own dedicated database, own
|
||||||
|
sites/devices, own ports) — not shared adoption with another UniFi instance.
|
||||||
|
Devices can only be adopted by one controller at a time.")
|
||||||
|
|
||||||
## Access
|
## Access
|
||||||
- Web UI: **https://localhost:8443** (HTTPS, self-signed cert — accept the warning)
|
- Web UI: **https://localhost:${WEB_PORT}** (HTTPS, self-signed cert — accept the warning)
|
||||||
- First run: complete the setup wizard and adopt your devices.
|
- First run: complete the setup wizard and adopt your devices.
|
||||||
|
|
||||||
## Device adoption
|
## Device adoption
|
||||||
Make sure devices can reach **http://<server-ip>:8080/inform** as the inform URL.
|
Make sure devices can reach **http://<server-ip>:${INFORM_PORT}/inform** as the inform URL.
|
||||||
In the controller: Settings → System → Application Configuration → Override inform host.
|
In the controller: Settings → System → Application Configuration → Override inform host.
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
| Port | Protocol | Purpose |
|
| Port | Protocol | Purpose |
|
||||||
|------|----------|---------|
|
|------|----------|---------|
|
||||||
| 8443 | TCP | HTTPS web UI |
|
| ${WEB_PORT} | TCP | HTTPS web UI |
|
||||||
| 8080 | TCP | Device inform / HTTP redirect |
|
| ${INFORM_PORT} | TCP | Device inform / HTTP redirect |
|
||||||
| 3478 | UDP | STUN |
|
| ${STUN_PORT} | UDP | STUN |
|
||||||
| 10001 | UDP | AP discovery |
|
| ${DISCOVERY_PORT} | UDP | AP discovery |
|
||||||
|
|
||||||
## Manage
|
## Manage
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
@@ -327,15 +390,15 @@ docker compose pull && docker compose up -d # update (wait for DB first)
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_UNIFI=""
|
local START_UNIFI=""
|
||||||
prompt_yn "Start UniFi now? (y/n):" "y" START_UNIFI
|
prompt_yn "Start UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_UNIFI
|
||||||
if [ "$START_UNIFI" = "y" ] || [ "$START_UNIFI" = "Y" ]; then
|
if [ "$START_UNIFI" = "y" ] || [ "$START_UNIFI" = "Y" ]; then
|
||||||
docker compose up -d \
|
docker compose up -d \
|
||||||
&& log_success "UniFi started (first startup takes ~60 s while DB initializes)" \
|
&& log_success "UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started (first startup takes ~60 s while DB initializes)" \
|
||||||
|| log_warning "Failed to start — check: docker compose logs"
|
|| log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Web UI: https://localhost:8443 (accept the self-signed cert warning)"
|
echo " Web UI: https://localhost:${WEB_PORT} (accept the self-signed cert warning)"
|
||||||
echo " MongoDB credentials saved to: $UNIFI_DIR/.env"
|
echo " MongoDB credentials saved to: $UNIFI_DIR/.env"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-11
@@ -202,16 +202,61 @@ register_service vaultwarden utilities "Bitwarden-compatible password manager (V
|
|||||||
install_vaultwarden() {
|
install_vaultwarden() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
log_info "Installing Vaultwarden..."
|
log_info "Installing Vaultwarden..."
|
||||||
|
|
||||||
|
# ── Instance selection ───────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "vaultwarden" name/paths/port exactly as
|
||||||
|
# before (zero behavior change for anyone with a single instance). Only
|
||||||
|
# asking to add a second one introduces suffixed naming — same pattern as
|
||||||
|
# services/mattermost.sh and services/wordpress.sh. A real use case:
|
||||||
|
# separate personal and family/household vaults with independent admin
|
||||||
|
# tokens, domains, and SMTP.
|
||||||
local VW_DIR="$DOCKER_DIR/vaultwarden"
|
local VW_DIR="$DOCKER_DIR/vaultwarden"
|
||||||
|
local INSTANCE_SUFFIX="" CONTAINER="vaultwarden"
|
||||||
|
local WEB_PORT="8888"
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Would create $VW_DIR (vaultwarden_data/)"
|
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||||
|
echo "[DRY-RUN] Would create $VW_DIR(-<name>) (vaultwarden_data/)"
|
||||||
echo "[DRY-RUN] Would deploy vaultwarden/server:latest"
|
echo "[DRY-RUN] Would deploy vaultwarden/server:latest"
|
||||||
echo "[DRY-RUN] Would generate admin token and prompt for domain"
|
echo "[DRY-RUN] Would generate admin token and prompt for domain"
|
||||||
|
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
|
||||||
echo "[DRY-RUN] Signups disabled by default (enable via admin panel)"
|
echo "[DRY-RUN] Signups disabled by default (enable via admin panel)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$VW_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " Vaultwarden is already installed at $VW_DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate Vaultwarden instance alongside it (its own"
|
||||||
|
echo " server, vault, and port — full isolation)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/vaultwarden-$_suffix" ]; then
|
||||||
|
log_warning "vaultwarden-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
VW_DIR="$DOCKER_DIR/vaultwarden-$_suffix"
|
||||||
|
CONTAINER="vaultwarden-$_suffix"
|
||||||
|
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $VW_DIR (port $WEB_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$VW_DIR/vaultwarden_data"
|
mkdir -p "$VW_DIR/vaultwarden_data"
|
||||||
ensure_docker_dir_ownership "$VW_DIR"
|
ensure_docker_dir_ownership "$VW_DIR"
|
||||||
cd "$VW_DIR" || return 1
|
cd "$VW_DIR" || return 1
|
||||||
@@ -225,7 +270,7 @@ install_vaultwarden() {
|
|||||||
echo " can connect and password-reset emails link correctly."
|
echo " can connect and password-reset emails link correctly."
|
||||||
echo ""
|
echo ""
|
||||||
local VW_DOMAIN=""
|
local VW_DOMAIN=""
|
||||||
local DEFAULT_DOMAIN="https://vault.${SITE_DOMAIN:-example.com}"
|
local DEFAULT_DOMAIN="https://vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}"
|
||||||
prompt_text "Vaultwarden public URL (e.g. https://vault.example.com):" "$DEFAULT_DOMAIN" VW_DOMAIN
|
prompt_text "Vaultwarden public URL (e.g. https://vault.example.com):" "$DEFAULT_DOMAIN" VW_DOMAIN
|
||||||
[ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN"
|
[ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN"
|
||||||
|
|
||||||
@@ -266,19 +311,19 @@ networks:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << VW_COMPOSE
|
cat > docker-compose.yml << VW_COMPOSE
|
||||||
name: vaultwarden
|
name: $CONTAINER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
vaultwarden:
|
vaultwarden:
|
||||||
image: vaultwarden/server:latest
|
image: vaultwarden/server:latest
|
||||||
container_name: vaultwarden
|
container_name: $CONTAINER
|
||||||
hostname: vaultwarden
|
hostname: $CONTAINER
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./vaultwarden_data:/data
|
- ./vaultwarden_data:/data
|
||||||
ports:
|
ports:
|
||||||
- "8888:80"
|
- "${WEB_PORT}:80"
|
||||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||||
VW_COMPOSE
|
VW_COMPOSE
|
||||||
|
|
||||||
@@ -311,15 +356,18 @@ VW_ENV
|
|||||||
|
|
||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$VW_DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$VW_DIR"
|
||||||
log_success "Vaultwarden configured at $VW_DIR"
|
log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $VW_DIR (port $WEB_PORT)"
|
||||||
|
|
||||||
configure_caddy_for_service "Vaultwarden" "vaultwarden:80" "vault"
|
configure_caddy_for_service "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
|
||||||
write_readme "$VW_DIR" << MD
|
write_readme "$VW_DIR" << MD
|
||||||
# Vaultwarden — Bitwarden-compatible password manager
|
# Vaultwarden${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} — Bitwarden-compatible password manager
|
||||||
|
|
||||||
Lightweight, self-hosted Bitwarden server. Works with all official
|
Lightweight, self-hosted Bitwarden server. Works with all official
|
||||||
Bitwarden clients: browser extension, desktop app, and mobile app.
|
Bitwarden clients: browser extension, desktop app, and mobile app.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
This is a separate, fully isolated instance (own server, own vault, own
|
||||||
|
port) — not shared credentials with another Vaultwarden instance.")
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
1. Point your Bitwarden client to: $VW_DOMAIN
|
1. Point your Bitwarden client to: $VW_DOMAIN
|
||||||
@@ -353,10 +401,10 @@ docker compose pull && docker compose up -d # update
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
local START_VW=""
|
local START_VW=""
|
||||||
prompt_yn "Start Vaultwarden now? (y/n):" "y" START_VW
|
prompt_yn "Start Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_VW
|
||||||
if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then
|
if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then
|
||||||
docker compose up -d \
|
docker compose up -d \
|
||||||
&& log_success "Vaultwarden started" \
|
&& log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|
||||||
|| log_warning "Failed to start — check: docker compose logs"
|
|| log_warning "Failed to start — check: docker compose logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user